From 4f946aa7aa12c374443bca1b8cbd3698d77020f2 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 15 Jun 2026 19:31:31 +0000 Subject: [PATCH 1/2] feat: introduce oliphaunt Squashed from origin/f0rr0/oliphaunt-release-ready at c626e1c. Included commits: - feat: introduce oliphaunt - feat: extend windows extension producers - feat: add windows postgis extension producer - fix: repair windows extension ci - fix: repair windows postgis sqlite build - fix: pass windows postgis cmake args - fix: decouple native extension artifact packaging - fix: materialize sources with bun --- .config/nextest.toml | 22 + .gitattributes | 4 +- .github/ISSUE_TEMPLATE/bug_report.yml | 24 +- .github/ISSUE_TEMPLATE/feature_request.yml | 17 + .github/actions/collect-ci-summary/action.yml | 16 + .github/actions/setup-android/action.yml | 106 + .github/actions/setup-apple/action.yml | 12 + .github/actions/setup-bun/action.yml | 20 + .github/actions/setup-deno/action.yml | 123 + .github/actions/setup-maestro/action.yml | 15 + .github/actions/setup-moon/action.yml | 53 + .github/actions/setup-msvc/action.yml | 33 + .github/actions/setup-node-pnpm/action.yml | 43 + .github/actions/setup-rust-tools/action.yml | 13 +- .github/actions/setup-rust/action.yml | 31 + .github/actions/setup-wasmer-llvm/action.yml | 8 +- .github/dependabot.yml | 20 - .github/moon.yml | 41 + .github/pull_request_template.md | 14 +- .github/scripts/check-release-changelog.sh | 119 - .github/scripts/check-release-intent.sh | 98 +- .github/scripts/download-aot-artifacts.sh | 6 - .github/scripts/download-build-artifacts.sh | 125 + .../download-wasix-runtime-build-artifacts.sh | 14 + .github/scripts/plan-affected.py | 17 + .github/scripts/prepare-linux-apt.sh | 25 + .github/scripts/require-workflow-success.sh | 60 +- .github/scripts/run-moon-targets.sh | 20 + .github/scripts/run-planned-moon-job.sh | 55 + .github/scripts/setup-native-build-tools.sh | 106 + .github/workflows/assets.yml | 225 - .github/workflows/ci.yml | 1589 +- .github/workflows/conventional-commits.yml | 58 - .github/workflows/mobile-e2e.yml | 313 + .github/workflows/release.yml | 567 +- .github/zizmor.yml | 2 +- .gitignore | 22 +- .lychee.toml | 15 + .markdownlint-cli2.jsonc | 19 + .moon/toolchains.yml | 18 + .moon/workspace.yml | 44 + .prototools | 5 + .release-please-manifest.json | 51 + .typos.toml | 19 + CONTRIBUTING.md | 40 +- Cargo.lock | 505 +- Cargo.toml | 135 +- LICENSE | 2 +- Package.swift | 33 + README.md | 286 +- THIRD_PARTY_NOTICES.md | 21 +- assets/generated/asset-inputs.sha256 | 1 - assets/generated/pgxs-build.tsv | 8 - assets/sources.toml | 88 - assets/wasix-build/.gitignore | 2 - assets/wasix-build/configure_wasix_dl.sh | 99 - .../wasix-build/docker_contrib_extensions.sh | 98 - assets/wasix-build/docker_initdb.sh | 103 - assets/wasix-build/docker_pgdump.sh | 86 - assets/wasix-build/docker_pglite.sh | 112 - assets/wasix-build/docker_runtime_support.sh | 80 - .../patches/postgres-pglite-wasix-dl.patch | 1187 -- assets/wasix-build/pg_config_wasix.sh | 61 - assets/wasix-build/prepare_patched_source.sh | 32 - assets/wasix-build/profile_flags.sh | 111 - .../wasix_shim/pglite_wasix_bridge_abi_test.c | 330 - benchmarks/README.md | 32 + benchmarks/mobile/README.md | 4 + benchmarks/moon.yml | 27 + benchmarks/native/README.md | 8 + benchmarks/native/baselines/README.md | 5 + benchmarks/native/sql/benchmark1.sql | 3 + benchmarks/native/sql/benchmark10.sql | 1 + benchmarks/native/sql/benchmark11.sql | 1 + benchmarks/native/sql/benchmark12.sql | 1 + benchmarks/native/sql/benchmark13.sql | 2 + benchmarks/native/sql/benchmark14.sql | 2 + benchmarks/native/sql/benchmark15.sql | 2 + benchmarks/native/sql/benchmark16.sql | 2 + benchmarks/native/sql/benchmark2.sql | 1 + benchmarks/native/sql/benchmark3.sql | 1 + benchmarks/native/sql/benchmark4.sql | 1 + benchmarks/native/sql/benchmark5.sql | 1 + benchmarks/native/sql/benchmark6.sql | 2 + benchmarks/native/sql/benchmark7.sql | 1 + benchmarks/native/sql/benchmark8.sql | 1 + benchmarks/native/sql/benchmark9.sql | 1 + benchmarks/reports/README.md | 4 + benchmarks/wasix/README.md | 4 + biome.json | 66 + clippy.toml | 2 +- coverage/baseline.toml | 299 + crates/aot/aarch64-apple-darwin/Cargo.toml | 13 - crates/aot/aarch64-apple-darwin/README.md | 4 - .../aot/aarch64-unknown-linux-gnu/Cargo.toml | 13 - .../aot/aarch64-unknown-linux-gnu/README.md | 4 - crates/aot/x86_64-pc-windows-msvc/Cargo.toml | 13 - crates/aot/x86_64-pc-windows-msvc/README.md | 4 - .../aot/x86_64-unknown-linux-gnu/Cargo.toml | 13 - crates/aot/x86_64-unknown-linux-gnu/README.md | 4 - crates/assets/Cargo.toml | 25 - crates/assets/README.md | 6 - docs/ASSETS.md | 167 - docs/DEVELOPMENT.md | 196 - docs/DONE.md | 928 -- docs/EXTENSIONS.md | 164 - docs/PERFORMANCE.md | 109 - docs/README.md | 10 + docs/RELEASE.md | 166 - docs/RUNTIME.md | 109 - docs/TAURI.md | 80 - docs/TESTING.md | 136 - docs/TODO.md | 517 - .../final-product-source-architecture.md | 248 + docs/architecture/ios.md | 410 + docs/architecture/native-liboliphaunt.md | 519 + .../{pglite-oxide.png => oliphaunt.png} | Bin docs/internal/DONE.md | 1838 +++ docs/internal/IMPLEMENTATION_CHECKLIST.md | 1193 ++ docs/internal/OLIPHAUNT_PATCH_STACK.md | 135 + docs/internal/OLIPHAUNT_TRACK_REVIEW.md | 753 + .../PERFORMANCE.md} | 122 +- docs/internal/PG18_WASIX_PERF_STATUS.md | 1010 ++ docs/internal/PG18_WASIX_POSTGRES.md | 666 + docs/internal/PHYSICAL_ARCHIVE_FORMAT.md | 92 + docs/internal/TODO.md | 285 + docs/internal/WASIX_PATCH_STACK.md | 189 + docs/maintainers/assets.md | 195 + docs/maintainers/compiler-caching.md | 169 + docs/maintainers/development.md | 375 + .../maintainers/extension-packaging-policy.md | 388 + docs/maintainers/mobile-stability-model.md | 167 + docs/maintainers/native-runtime-contract.md | 191 + docs/maintainers/performance-evidence.md | 642 + docs/maintainers/release-setup.md | 446 + docs/maintainers/release.md | 162 + docs/maintainers/repo-structure.md | 278 + docs/maintainers/rust-sdk-policy.md | 59 + docs/maintainers/sdk-api-surface.md | 809 + docs/maintainers/sdk-parity-policy.md | 201 + docs/maintainers/sdk-products-policy.md | 119 + docs/maintainers/testing.md | 313 + docs/maintainers/tooling.md | 200 + .../wasm-usage-legacy.md} | 104 +- examples/moon.yml | 35 + examples/tauri-sqlx-vanilla/package-lock.json | 1361 -- examples/tools/check-examples.sh | 65 + .../tools/check-lockfiles.sh | 18 +- moon.yml | 399 + package.json | 10 + pnpm-lock.yaml | 13724 ++++++++++++++++ pnpm-workspace.yaml | 30 + prek.toml | 19 +- release-please-config.json | 411 + release-plz.toml | 91 - renovate.json | 68 + rust-toolchain.toml | 2 +- scripts/check-asset-input-fingerprint.sh | 41 - scripts/ci-scope.sh | 123 - scripts/perf/node-bench/package-lock.json | 33 - scripts/perf/node-bench/package.json | 9 - .../perf/node-bench/start_nodefs_socket.mjs | 115 - scripts/perf/run_bench_matrix.sh | 135 - scripts/validate.sh | 451 - src/bindings/wasix-rust/CHANGELOG.md | 8 + .../wasix-rust/THIRD_PARTY_NOTICES.md | 17 + .../crates/oliphaunt-wasix/CHANGELOG.md | 17 + .../crates/oliphaunt-wasix/Cargo.toml | 101 + .../crates/oliphaunt-wasix/README.md | 143 + .../crates/oliphaunt-wasix/release.toml | 9 + .../src/bin/oliphaunt_wasix_dump.rs} | 12 +- .../src/bin/oliphaunt_wasix_proxy.rs} | 16 +- .../crates/oliphaunt-wasix/src/lib.rs | 34 + .../oliphaunt-wasix/src/oliphaunt}/aot.rs | 95 +- .../oliphaunt-wasix/src/oliphaunt}/assets.rs | 70 +- .../oliphaunt-wasix/src/oliphaunt}/backend.rs | 177 +- .../oliphaunt-wasix/src/oliphaunt}/base.rs | 780 +- .../src/oliphaunt/base/template_clone.rs | 326 + .../oliphaunt-wasix/src/oliphaunt}/builder.rs | 47 +- .../oliphaunt-wasix/src/oliphaunt}/client.rs | 270 +- .../oliphaunt-wasix/src/oliphaunt}/config.rs | 4 +- .../src/oliphaunt}/data_dir.rs | 2 +- .../oliphaunt-wasix/src/oliphaunt/engine.rs | 25 + .../oliphaunt-wasix/src/oliphaunt}/errors.rs | 16 +- .../src/oliphaunt}/extensions.rs | 255 +- .../src/oliphaunt}/generated_extensions.rs | 184 +- .../src/oliphaunt}/interface.rs | 0 .../oliphaunt-wasix/src/oliphaunt}/mod.rs | 22 +- .../oliphaunt-wasix/src/oliphaunt}/parse.rs | 69 +- .../oliphaunt-wasix/src/oliphaunt}/pg_dump.rs | 73 +- .../src/oliphaunt}/postgres_mod.rs | 1497 +- .../src/oliphaunt/postgres_mod/stdio.rs | 422 + .../src/oliphaunt/postgres_mod/wasix_fs.rs | 664 + .../oliphaunt-wasix/src/oliphaunt}/proxy.rs | 26 +- .../oliphaunt-wasix/src/oliphaunt}/server.rs | 74 +- .../src/oliphaunt}/sync_host_fs.rs | 0 .../src/oliphaunt}/templating.rs | 8 +- .../oliphaunt-wasix/src/oliphaunt}/timing.rs | 0 .../src/oliphaunt}/transport.rs | 4 +- .../oliphaunt-wasix/src/oliphaunt}/types.rs | 2 +- .../oliphaunt-wasix/src/oliphaunt}/wire.rs | 2 +- .../src}/protocol/buffer_reader.rs | 0 .../src}/protocol/buffer_writer.rs | 0 .../oliphaunt-wasix/src}/protocol/messages.rs | 0 .../oliphaunt-wasix/src}/protocol/mod.rs | 2 + .../oliphaunt-wasix/src}/protocol/parser.rs | 0 .../src}/protocol/serializer.rs | 0 .../src/protocol/shared_fixture_tests.rs | 107 + .../src}/protocol/string_utils.rs | 0 .../oliphaunt-wasix/src}/protocol/tests.rs | 0 .../oliphaunt-wasix/src}/protocol/types.rs | 14 + .../crates/oliphaunt-wasix/tests/cli_smoke.rs | 96 + .../oliphaunt-wasix/tests}/client_compat.rs | 58 +- .../tests}/extensions_smoke.rs | 224 +- .../tests}/performance_smoke.rs | 86 +- .../tests}/postgres_regression.rs | 67 +- .../oliphaunt-wasix/tests}/proxy_smoke.rs | 7 +- .../oliphaunt-wasix/tests}/runtime_smoke.rs | 187 +- .../oliphaunt-wasix/tests}/support/mod.rs | 0 .../examples}/build_pgdata_template.rs | 4 +- .../examples}/tauri-sqlx-vanilla/.gitignore | 0 .../.vscode/extensions.json | 0 .../examples}/tauri-sqlx-vanilla/README.md | 12 +- .../examples}/tauri-sqlx-vanilla/index.html | 4 +- .../examples}/tauri-sqlx-vanilla/package.json | 0 .../tauri-sqlx-vanilla/src-tauri/.gitignore | 0 .../tauri-sqlx-vanilla/src-tauri/Cargo.lock | 114 +- .../tauri-sqlx-vanilla/src-tauri/Cargo.toml | 6 +- .../tauri-sqlx-vanilla/src-tauri/build.rs | 0 .../src-tauri/capabilities/default.json | 0 .../src-tauri/icons/128x128.png | Bin .../src-tauri/icons/128x128@2x.png | Bin .../src-tauri/icons/32x32.png | Bin .../src-tauri/icons/Square107x107Logo.png | Bin .../src-tauri/icons/Square142x142Logo.png | Bin .../src-tauri/icons/Square150x150Logo.png | Bin .../src-tauri/icons/Square284x284Logo.png | Bin .../src-tauri/icons/Square30x30Logo.png | Bin .../src-tauri/icons/Square310x310Logo.png | Bin .../src-tauri/icons/Square44x44Logo.png | Bin .../src-tauri/icons/Square71x71Logo.png | Bin .../src-tauri/icons/Square89x89Logo.png | Bin .../src-tauri/icons/StoreLogo.png | Bin .../src-tauri/icons/icon.icns | Bin .../src-tauri/icons/icon.ico | Bin .../src-tauri/icons/icon.png | Bin .../tauri-sqlx-vanilla/src-tauri/src/bench.rs | 24 +- .../src-tauri/src/bin/profile_queries.rs | 4 +- .../tauri-sqlx-vanilla/src-tauri/src/lib.rs | 2 +- .../tauri-sqlx-vanilla/src-tauri/src/main.rs | 0 .../src-tauri/tauri.conf.json | 10 +- .../tauri-sqlx-vanilla/src/assets/tauri.svg | 0 .../src/assets/typescript.svg | 0 .../tauri-sqlx-vanilla/src/assets/vite.svg | 0 .../examples}/tauri-sqlx-vanilla/src/main.ts | 0 .../tauri-sqlx-vanilla/src/styles.css | 0 .../tauri-sqlx-vanilla/tsconfig.json | 0 .../tauri-sqlx-vanilla/vite.config.ts | 0 src/bindings/wasix-rust/moon.yml | 202 + .../wasix-rust/tools/check-examples.sh | 71 + .../wasix-rust/tools/check-package.sh | 47 + src/bindings/wasix-rust/tools/check-unit.sh | 19 + src/docs/.gitignore | 26 + src/docs/README.md | 41 + src/docs/content/learn/embedded-postgres.mdx | 74 + src/docs/content/learn/index.mdx | 40 + src/docs/content/learn/mobile-stability.mdx | 72 + src/docs/content/learn/native-runtime.mdx | 210 + src/docs/content/learn/sqlite-upgrade.mdx | 69 + src/docs/content/learn/tauri.mdx | 111 + src/docs/content/reference/capabilities.mdx | 74 + src/docs/content/reference/extensions.mdx | 79 + src/docs/content/reference/index.mdx | 51 + src/docs/content/reference/performance.mdx | 93 + src/docs/content/reference/releases.mdx | 46 + src/docs/content/reference/sdk-products.mdx | 55 + src/docs/content/sdk/c-abi/api-reference.md | 26 + src/docs/content/sdk/c-abi/guide.mdx | 116 + src/docs/content/sdk/c-abi/index.mdx | 88 + src/docs/content/sdk/index.mdx | 82 + src/docs/content/sdk/kotlin/api-reference.md | 26 + src/docs/content/sdk/kotlin/guide.mdx | 161 + src/docs/content/sdk/kotlin/index.mdx | 88 + .../content/sdk/react-native/api-reference.md | 27 + .../content/sdk/react-native/architecture.mdx | 137 + src/docs/content/sdk/react-native/guide.mdx | 200 + src/docs/content/sdk/react-native/index.mdx | 98 + src/docs/content/sdk/rust/api-reference.md | 25 + src/docs/content/sdk/rust/guide.mdx | 163 + src/docs/content/sdk/rust/index.mdx | 76 + src/docs/content/sdk/swift/api-reference.md | 24 + src/docs/content/sdk/swift/guide.mdx | 157 + src/docs/content/sdk/swift/index.mdx | 76 + .../content/sdk/typescript/api-reference.md | 25 + src/docs/content/sdk/typescript/guide.mdx | 142 + src/docs/content/sdk/typescript/index.mdx | 71 + src/docs/content/sdk/wasm/api-reference.md | 25 + .../docs/content/sdk/wasm/dump-restore.mdx | 64 +- src/docs/content/sdk/wasm/guide.mdx | 139 + src/docs/content/sdk/wasm/index.mdx | 73 + src/docs/content/sdk/wasm/runtime.mdx | 118 + src/docs/content/start/index.mdx | 49 + src/docs/docs-manifest.toml | 323 + src/docs/moon.yml | 162 + src/docs/next.config.mjs | 16 + src/docs/package.json | 47 + src/docs/postcss.config.mjs | 7 + src/docs/proxy.ts | 29 + src/docs/reference/doxygen/Doxyfile | 15 + src/docs/source.config.ts | 23 + src/docs/src/app/(home)/layout.tsx | 6 + src/docs/src/app/(home)/page.tsx | 373 + src/docs/src/app/api/search/route.ts | 9 + src/docs/src/app/docs/[[...slug]]/page.tsx | 63 + src/docs/src/app/docs/layout.tsx | 7 + src/docs/src/app/global.css | 211 + src/docs/src/app/layout.tsx | 44 + src/docs/src/app/llms-full.txt/route.ts | 10 + .../app/llms.mdx/docs/[[...slug]]/route.ts | 23 + src/docs/src/app/llms.txt/route.ts | 8 + src/docs/src/app/og/docs/[...slug]/route.tsx | 28 + src/docs/src/components/mdx.tsx | 84 + src/docs/src/components/oliphaunt.tsx | 1751 ++ src/docs/src/lib/cn.ts | 1 + src/docs/src/lib/docs-data.ts | 225 + src/docs/src/lib/layout.shared.tsx | 19 + src/docs/src/lib/shared.ts | 10 + src/docs/src/lib/source.ts | 37 + src/docs/static/img/favicon.svg | 4 + src/docs/tools/check-docs-product.mjs | 1347 ++ src/docs/tools/check-fumadocs-source.mjs | 40 + src/docs/tools/generate-api-reference.mjs | 462 + src/docs/tools/generate-content.mjs | 1215 ++ src/docs/tools/publish-next-export.mjs | 30 + src/docs/tools/run-docs-task.mjs | 137 + src/docs/tools/smoke-built-site.mjs | 109 + src/docs/tsconfig.json | 46 + src/extensions/artifacts/native/moon.yml | 83 + .../native/tools/check-release-artifacts.sh | 31 + .../tools/extension-artifact-packager.mjs | 982 ++ .../native/tools/package-release-assets.sh | 588 + src/extensions/artifacts/packages/moon.yml | 62 + .../tools/package-mobile-release-assets.sh | 65 + src/extensions/artifacts/wasix/moon.yml | 58 + .../wasix/tools/package-release-assets.sh | 154 + .../catalog}/extensions.promoted.toml | 16 +- .../extensions/catalog}/extensions.smoke.toml | 97 +- src/extensions/contrib/amcheck/CHANGELOG.md | 5 + src/extensions/contrib/amcheck/VERSION | 1 + src/extensions/contrib/amcheck/moon.yml | 55 + src/extensions/contrib/amcheck/release.toml | 7 + .../contrib/amcheck/targets/artifacts.toml | 57 + .../contrib/auto_explain/CHANGELOG.md | 5 + src/extensions/contrib/auto_explain/VERSION | 1 + src/extensions/contrib/auto_explain/moon.yml | 55 + .../contrib/auto_explain/release.toml | 7 + .../auto_explain/targets/artifacts.toml | 57 + src/extensions/contrib/bloom/CHANGELOG.md | 5 + src/extensions/contrib/bloom/VERSION | 1 + src/extensions/contrib/bloom/moon.yml | 55 + src/extensions/contrib/bloom/release.toml | 7 + .../contrib/bloom/targets/artifacts.toml | 57 + src/extensions/contrib/btree_gin/CHANGELOG.md | 5 + src/extensions/contrib/btree_gin/VERSION | 1 + src/extensions/contrib/btree_gin/moon.yml | 55 + src/extensions/contrib/btree_gin/release.toml | 7 + .../contrib/btree_gin/targets/artifacts.toml | 57 + .../contrib/btree_gist/CHANGELOG.md | 5 + src/extensions/contrib/btree_gist/VERSION | 1 + src/extensions/contrib/btree_gist/moon.yml | 55 + .../contrib/btree_gist/release.toml | 7 + .../contrib/btree_gist/targets/artifacts.toml | 57 + src/extensions/contrib/citext/CHANGELOG.md | 5 + src/extensions/contrib/citext/VERSION | 1 + src/extensions/contrib/citext/moon.yml | 55 + src/extensions/contrib/citext/release.toml | 7 + .../contrib/citext/targets/artifacts.toml | 57 + src/extensions/contrib/cube/CHANGELOG.md | 5 + src/extensions/contrib/cube/VERSION | 1 + src/extensions/contrib/cube/moon.yml | 55 + src/extensions/contrib/cube/release.toml | 7 + .../contrib/cube/targets/artifacts.toml | 57 + src/extensions/contrib/dict_int/CHANGELOG.md | 5 + src/extensions/contrib/dict_int/VERSION | 1 + src/extensions/contrib/dict_int/moon.yml | 55 + src/extensions/contrib/dict_int/release.toml | 7 + .../contrib/dict_int/targets/artifacts.toml | 57 + src/extensions/contrib/dict_xsyn/CHANGELOG.md | 5 + src/extensions/contrib/dict_xsyn/VERSION | 1 + src/extensions/contrib/dict_xsyn/moon.yml | 55 + src/extensions/contrib/dict_xsyn/release.toml | 7 + .../contrib/dict_xsyn/targets/artifacts.toml | 57 + .../contrib/earthdistance/CHANGELOG.md | 5 + src/extensions/contrib/earthdistance/VERSION | 1 + src/extensions/contrib/earthdistance/moon.yml | 55 + .../contrib/earthdistance/release.toml | 7 + .../earthdistance/targets/artifacts.toml | 57 + src/extensions/contrib/file_fdw/CHANGELOG.md | 5 + src/extensions/contrib/file_fdw/VERSION | 1 + src/extensions/contrib/file_fdw/moon.yml | 55 + src/extensions/contrib/file_fdw/release.toml | 7 + .../contrib/file_fdw/targets/artifacts.toml | 57 + .../contrib/fuzzystrmatch/CHANGELOG.md | 5 + src/extensions/contrib/fuzzystrmatch/VERSION | 1 + src/extensions/contrib/fuzzystrmatch/moon.yml | 55 + .../contrib/fuzzystrmatch/release.toml | 7 + .../fuzzystrmatch/targets/artifacts.toml | 57 + src/extensions/contrib/hstore/CHANGELOG.md | 5 + src/extensions/contrib/hstore/VERSION | 1 + src/extensions/contrib/hstore/moon.yml | 55 + src/extensions/contrib/hstore/release.toml | 7 + .../contrib/hstore/targets/artifacts.toml | 57 + src/extensions/contrib/intarray/CHANGELOG.md | 5 + src/extensions/contrib/intarray/VERSION | 1 + src/extensions/contrib/intarray/moon.yml | 55 + src/extensions/contrib/intarray/release.toml | 7 + .../contrib/intarray/targets/artifacts.toml | 57 + src/extensions/contrib/isn/CHANGELOG.md | 5 + src/extensions/contrib/isn/VERSION | 1 + src/extensions/contrib/isn/moon.yml | 55 + src/extensions/contrib/isn/release.toml | 7 + .../contrib/isn/targets/artifacts.toml | 57 + src/extensions/contrib/lo/CHANGELOG.md | 5 + src/extensions/contrib/lo/VERSION | 1 + src/extensions/contrib/lo/moon.yml | 55 + src/extensions/contrib/lo/release.toml | 7 + .../contrib/lo/targets/artifacts.toml | 57 + src/extensions/contrib/ltree/CHANGELOG.md | 5 + src/extensions/contrib/ltree/VERSION | 1 + src/extensions/contrib/ltree/moon.yml | 55 + src/extensions/contrib/ltree/release.toml | 7 + .../contrib/ltree/targets/artifacts.toml | 57 + src/extensions/contrib/moon.yml | 31 + .../contrib/pageinspect/CHANGELOG.md | 5 + src/extensions/contrib/pageinspect/VERSION | 1 + src/extensions/contrib/pageinspect/moon.yml | 55 + .../contrib/pageinspect/release.toml | 7 + .../pageinspect/targets/artifacts.toml | 57 + .../contrib/pg_buffercache/CHANGELOG.md | 5 + src/extensions/contrib/pg_buffercache/VERSION | 1 + .../contrib/pg_buffercache/moon.yml | 55 + .../contrib/pg_buffercache/release.toml | 7 + .../pg_buffercache/targets/artifacts.toml | 57 + .../contrib/pg_freespacemap/CHANGELOG.md | 5 + .../contrib/pg_freespacemap/VERSION | 1 + .../contrib/pg_freespacemap/moon.yml | 55 + .../contrib/pg_freespacemap/release.toml | 7 + .../pg_freespacemap/targets/artifacts.toml | 57 + .../contrib/pg_surgery/CHANGELOG.md | 5 + src/extensions/contrib/pg_surgery/VERSION | 1 + src/extensions/contrib/pg_surgery/moon.yml | 55 + .../contrib/pg_surgery/release.toml | 7 + .../contrib/pg_surgery/targets/artifacts.toml | 57 + src/extensions/contrib/pg_trgm/CHANGELOG.md | 5 + src/extensions/contrib/pg_trgm/VERSION | 1 + src/extensions/contrib/pg_trgm/moon.yml | 55 + src/extensions/contrib/pg_trgm/release.toml | 7 + .../contrib/pg_trgm/targets/artifacts.toml | 57 + .../contrib/pg_visibility/CHANGELOG.md | 5 + src/extensions/contrib/pg_visibility/VERSION | 1 + src/extensions/contrib/pg_visibility/moon.yml | 55 + .../contrib/pg_visibility/release.toml | 7 + .../pg_visibility/targets/artifacts.toml | 57 + .../contrib/pg_walinspect/CHANGELOG.md | 5 + src/extensions/contrib/pg_walinspect/VERSION | 1 + src/extensions/contrib/pg_walinspect/moon.yml | 55 + .../contrib/pg_walinspect/release.toml | 7 + .../pg_walinspect/targets/artifacts.toml | 57 + src/extensions/contrib/pgcrypto/CHANGELOG.md | 5 + src/extensions/contrib/pgcrypto/VERSION | 1 + src/extensions/contrib/pgcrypto/moon.yml | 55 + src/extensions/contrib/pgcrypto/release.toml | 7 + .../contrib/pgcrypto/targets/artifacts.toml | 57 + src/extensions/contrib/postgres18.toml | 205 + src/extensions/contrib/seg/CHANGELOG.md | 5 + src/extensions/contrib/seg/VERSION | 1 + src/extensions/contrib/seg/moon.yml | 55 + src/extensions/contrib/seg/release.toml | 7 + .../contrib/seg/targets/artifacts.toml | 57 + src/extensions/contrib/tablefunc/CHANGELOG.md | 5 + src/extensions/contrib/tablefunc/VERSION | 1 + src/extensions/contrib/tablefunc/moon.yml | 55 + src/extensions/contrib/tablefunc/release.toml | 7 + .../contrib/tablefunc/targets/artifacts.toml | 57 + src/extensions/contrib/tcn/CHANGELOG.md | 5 + src/extensions/contrib/tcn/VERSION | 1 + src/extensions/contrib/tcn/moon.yml | 55 + src/extensions/contrib/tcn/release.toml | 7 + .../contrib/tcn/targets/artifacts.toml | 57 + .../contrib/tsm_system_rows/CHANGELOG.md | 5 + .../contrib/tsm_system_rows/VERSION | 1 + .../contrib/tsm_system_rows/moon.yml | 55 + .../contrib/tsm_system_rows/release.toml | 7 + .../tsm_system_rows/targets/artifacts.toml | 57 + .../contrib/tsm_system_time/CHANGELOG.md | 5 + .../contrib/tsm_system_time/VERSION | 1 + .../contrib/tsm_system_time/moon.yml | 55 + .../contrib/tsm_system_time/release.toml | 7 + .../tsm_system_time/targets/artifacts.toml | 57 + src/extensions/contrib/unaccent/CHANGELOG.md | 5 + src/extensions/contrib/unaccent/VERSION | 1 + src/extensions/contrib/unaccent/moon.yml | 55 + src/extensions/contrib/unaccent/release.toml | 7 + .../contrib/unaccent/targets/artifacts.toml | 57 + src/extensions/contrib/uuid_ossp/CHANGELOG.md | 5 + src/extensions/contrib/uuid_ossp/VERSION | 1 + src/extensions/contrib/uuid_ossp/moon.yml | 55 + src/extensions/contrib/uuid_ossp/release.toml | 7 + .../contrib/uuid_ossp/targets/artifacts.toml | 57 + src/extensions/evidence/matrix.toml | 437 + ...2026-06-07-transitional-catalog-smoke.json | 604 + .../evidence/schemas/matrix.schema.json | 41 + .../evidence/schemas/run.schema.json | 55 + src/extensions/external/README.md | 8 + src/extensions/external/age/moon.yml | 23 + src/extensions/external/age/source.toml | 4 + .../external/pg_hashids/CHANGELOG.md | 5 + src/extensions/external/pg_hashids/VERSION | 1 + src/extensions/external/pg_hashids/moon.yml | 49 + .../external/pg_hashids/release.toml | 7 + .../external/pg_hashids/source.toml | 4 + .../pg_hashids/targets/artifacts.toml | 57 + src/extensions/external/pg_ivm/CHANGELOG.md | 5 + src/extensions/external/pg_ivm/VERSION | 1 + src/extensions/external/pg_ivm/moon.yml | 49 + src/extensions/external/pg_ivm/release.toml | 7 + src/extensions/external/pg_ivm/source.toml | 4 + .../external/pg_ivm/targets/artifacts.toml | 57 + .../targets/native-static-registry.toml | 11 + .../external/pg_textsearch/CHANGELOG.md | 5 + src/extensions/external/pg_textsearch/VERSION | 1 + .../external/pg_textsearch/moon.yml | 49 + .../external/pg_textsearch/recipe.toml | 43 + .../external/pg_textsearch/release.toml | 7 + .../external/pg_textsearch/source.toml | 4 + .../pg_textsearch/targets/artifacts.toml | 57 + .../targets/native-static-registry.toml | 6 + .../external/pg_textsearch/tests/smoke.sql | 5 + .../pg_textsearch/tests/upstream.toml | 6 + .../external/pg_uuidv7/CHANGELOG.md | 5 + src/extensions/external/pg_uuidv7/VERSION | 1 + src/extensions/external/pg_uuidv7/moon.yml | 49 + .../external/pg_uuidv7/release.toml | 7 + src/extensions/external/pg_uuidv7/source.toml | 4 + .../external/pg_uuidv7/targets/artifacts.toml | 57 + src/extensions/external/pgtap/CHANGELOG.md | 5 + src/extensions/external/pgtap/VERSION | 1 + src/extensions/external/pgtap/moon.yml | 49 + src/extensions/external/pgtap/recipe.toml | 48 + src/extensions/external/pgtap/release.toml | 7 + src/extensions/external/pgtap/source.toml | 4 + .../external/pgtap/targets/artifacts.toml | 57 + .../pgtap/targets/native-static-registry.toml | 13 + .../external/pgtap/targets/native.toml | 7 + .../external/pgtap/targets/wasix.toml | 7 + src/extensions/external/pgtap/tests/smoke.sql | 7 + .../external/pgtap/tests/upstream.toml | 6 + src/extensions/external/postgis/CHANGELOG.md | 5 + src/extensions/external/postgis/VERSION | 1 + src/extensions/external/postgis/blockers.toml | 11 + .../postgis/dependencies/geos/source.toml | 4 + .../postgis/dependencies/json-c/source.toml | 4 + .../postgis/dependencies/libiconv/source.toml | 7 + .../postgis/dependencies/libxml2/source.toml | 4 + .../postgis/dependencies/proj/source.toml | 4 + .../postgis/dependencies/sqlite/source.toml | 4 + src/extensions/external/postgis/deps.toml | 49 + src/extensions/external/postgis/moon.yml | 49 + .../external/postgis/patches/README.md | 3 + src/extensions/external/postgis/recipe.toml | 72 + src/extensions/external/postgis/release.toml | 7 + src/extensions/external/postgis/source.toml | 4 + .../external/postgis/targets/artifacts.toml | 57 + .../targets/native-static-registry.toml | 40 + .../external/postgis/targets/native.toml | 35 + .../external/postgis/targets/wasix.toml | 62 + .../external/postgis/tests/regression.sql | 3 + .../external/postgis/tests/smoke.sql | 61 + .../external/postgis/tests/upstream.toml | 12 + .../external/postgis/tools/build_wasix.sh | 279 + src/extensions/external/vector/CHANGELOG.md | 5 + src/extensions/external/vector/VERSION | 1 + src/extensions/external/vector/moon.yml | 49 + src/extensions/external/vector/release.toml | 7 + src/extensions/external/vector/source.toml | 4 + .../external/vector/targets/artifacts.toml | 57 + .../extensions}/generated/contrib-build.tsv | 2 + .../generated/docs/extension-evidence.json | 1509 ++ src/extensions/generated/docs/extensions.json | 1358 ++ .../generated/extensions.build-plan.json | 495 +- .../generated/extensions.catalog.json | 595 +- .../generated/mobile/static-extensions.tsv | 40 + .../generated/mobile/static-registry.json | 342 + src/extensions/generated/pgxs-build.tsv | 7 + src/extensions/generated/sdk/js.json | 1249 ++ src/extensions/generated/sdk/kotlin.json | 1249 ++ .../generated/sdk/react-native.json | 1249 ++ src/extensions/generated/sdk/rust.json | 1249 ++ src/extensions/generated/sdk/swift.json | 1249 ++ .../generated/wasix/extensions.json | 771 + src/extensions/model/moon.yml | 31 + src/extensions/moon.yml | 164 + src/extensions/schemas/recipe.schema.json | 41 + .../schemas/support-table.schema.json | 64 + src/extensions/tools/check-extension-model.py | 2095 +++ src/extensions/tools/check-extension-tree.py | 145 + src/lib.rs | 30 - src/postgres/versions/18/moon.yml | 28 + src/postgres/versions/18/source.toml | 4 + src/runtimes/broker/CHANGELOG.md | 10 + src/runtimes/broker/Cargo.toml | 18 + src/runtimes/broker/README.md | 9 + src/runtimes/broker/moon.yml | 118 + src/runtimes/broker/release.toml | 6 + src/runtimes/broker/src/main.rs | 599 + src/runtimes/broker/targets/checksums.toml | 7 + .../broker/targets/linux-arm64-gnu.toml | 10 + .../broker/targets/linux-x64-gnu.toml | 10 + src/runtimes/broker/targets/macos-arm64.toml | 10 + .../broker/targets/windows-x64-msvc.toml | 10 + src/runtimes/broker/tools/check-package.sh | 46 + src/runtimes/liboliphaunt/native/CHANGELOG.md | 11 + src/runtimes/liboliphaunt/native/README.md | 219 + .../native/THIRD_PARTY_NOTICES.md | 16 + src/runtimes/liboliphaunt/native/VERSION | 1 + .../build-external-pgrx-extensions-macos.sh | 592 + .../bin/build-ios-extension-xcframeworks.sh | 556 + .../native/bin/build-ios-xcframework.sh | 343 + .../native/bin/build-macos-happy-path.sh | 5 + .../liboliphaunt/native/bin/build-output.bash | 42 + .../bin/build-postgres18-android-arm64.sh | 985 ++ .../bin/build-postgres18-android-x86_64.sh | 6 + .../native/bin/build-postgres18-ios-device.sh | 901 + .../bin/build-postgres18-ios-simulator.sh | 901 + .../native/bin/build-postgres18-linux.sh | 1448 ++ .../native/bin/build-postgres18-macos.sh | 1698 ++ .../native/bin/build-postgres18-windows.ps1 | 2581 +++ .../native/bin/check-c-abi-conformance.sh | 9 + .../bin/check-external-extension-pins.sh | 142 + .../bin/check-postgres18-ios-simulator.sh | 222 + .../liboliphaunt/native/bin/common.sh | 10 + src/runtimes/liboliphaunt/native/bin/icu.sh | 246 + .../native/bin/mobile-postgis-extensions.sh | 659 + .../native/bin/mobile-static-extensions.sh | 290 + .../bin/run-native-postgres-regression-sql.sh | 74 + .../native/bin/smoke-host-happy-path.sh | 13 + .../native/bin/smoke-macos-happy-path.sh | 5 + .../liboliphaunt/native/include/oliphaunt.h | 172 + src/runtimes/liboliphaunt/native/moon.yml | 222 + ...001-liboliphaunt-add-backend-host-io.patch | 93 + ...liboliphaunt-add-embedded-entrypoint.patch | 152 + ...urn-from-embedded-frontend-terminate.patch | 60 + ...boliphaunt-run-embedded-exit-cleanup.patch | 73 + .../0005-liboliphaunt-restore-host-cwd.patch | 50 + ...liphaunt-add-static-extension-loader.patch | 223 + ...sable-shell-commands-on-apple-mobile.patch | 187 + ...-liboliphaunt-clean-embedded-symbols.patch | 144 + ...iboliphaunt-guard-embedded-proc-exit.patch | 172 + ...-liboliphaunt-use-host-runtime-paths.patch | 95 + ...t-add-android-embedded-shared-memory.patch | 483 + ...e-event-triggers-in-embedded-backend.patch | 84 + ...iboliphaunt-register-static-icu-data.patch | 132 + ...unt-use-portable-embedded-socketpair.patch | 136 + ...boliphaunt-add-embedded-meson-option.patch | 46 + .../native/portable-uuid/include/uuid/uuid.h | 10 + .../native/portable-uuid/portable_uuid.c | 77 + .../postgres18/external-extensions.toml | 36 + .../native/postgres18/source.toml | 24 + src/runtimes/liboliphaunt/native/release.toml | 14 + .../smoke/liboliphaunt_abi_conformance.c | 193 + .../native/smoke/liboliphaunt_smoke.c | 1878 +++ .../native/src/liboliphaunt_archive.c | 720 + .../native/src/liboliphaunt_archive_tar.c | 1243 ++ .../native/src/liboliphaunt_bootstrap.c | 356 + .../src/liboliphaunt_builtin_extensions.c | 47 + .../liboliphaunt/native/src/liboliphaunt_fs.c | 533 + .../native/src/liboliphaunt_internal.h | 174 + .../native/src/liboliphaunt_native.c | 564 + .../native/src/liboliphaunt_platform.h | 371 + .../native/src/liboliphaunt_process.c | 86 + .../native/src/liboliphaunt_protocol.c | 766 + .../native/src/liboliphaunt_runtime.c | 131 + .../src/liboliphaunt_static_extensions.c | 317 + .../native/src/liboliphaunt_trace.c | 92 + .../native/targets/android-arm64-v8a.toml | 10 + .../native/targets/android-x86_64.toml | 10 + .../native/targets/apple-spm-xcframework.toml | 9 + .../native/targets/checksums.toml | 7 + .../native/targets/ios-xcframework.toml | 10 + .../native/targets/linux-arm64-gnu.toml | 10 + .../native/targets/linux-x64-gnu.toml | 10 + .../native/targets/macos-arm64.toml | 10 + .../native/targets/macos-x64.toml | 12 + .../native/targets/package-size.toml | 7 + .../native/targets/runtime-resources.toml | 7 + .../native/targets/windows-x64-msvc.toml | 11 + .../native/tools/build-ci-target.sh | 91 + .../native/tools/build-release-runtime.mjs | 35 + .../native/tools/check-patch-stack.mjs | 425 + .../liboliphaunt/native/tools/check-track.sh | 359 + .../native/tools/run-host-c-smoke.mjs | 469 + .../runtimes/liboliphaunt/wasix/CHANGELOG.md | 42 +- src/runtimes/liboliphaunt/wasix/VERSION | 1 + .../wasix/assets/build/.gitignore | 2 + .../wasix/assets/build}/analyze_pgl_stubs.sh | 30 +- .../wasix/assets/build/build_wasix_geos.sh | 61 + .../wasix/assets/build/build_wasix_icu.sh | 176 + .../wasix/assets/build/build_wasix_jsonc.sh | 59 + .../assets/build/build_wasix_libiconv.sh | 80 + .../wasix/assets/build/build_wasix_libxml2.sh | 67 + .../wasix/assets/build/build_wasix_openssl.sh | 77 + .../wasix/assets/build/build_wasix_proj.sh | 76 + .../wasix/assets/build/build_wasix_sqlite.sh | 82 + .../wasix/assets/build/configure_wasix_dl.sh | 97 + .../wasix/assets/build}/docker/Dockerfile | 26 +- .../assets/build/docker_contrib_extensions.sh | 152 + .../wasix/assets/build/docker_initdb.sh | 118 + .../wasix/assets/build/docker_oliphaunt.sh | 141 + .../wasix/assets/build/docker_pgdump.sh | 99 + .../assets/build}/docker_pgxs_extensions.sh | 71 +- .../assets/build/docker_runtime_support.sh | 89 + .../wasix/assets/build}/docker_wasix_env.sh | 4 +- .../wasix/assets/build/pg_config_wasix.sh | 121 + .../experiment-patch-disposition.toml | 78 + ...haunt-wasix-add-wasix-dl-build-spine.patch | 134 + ...aunt-wasix-add-backend-host-io-hooks.patch | 99 + ...t-wasix-export-startup-packet-parser.patch | 58 + ...unt-wasix-add-host-lifecycle-exports.patch | 226 + ...six-add-loop-pumped-protocol-exports.patch | 1653 ++ ...unt-wasix-report-copy-protocol-state.patch | 138 + ...x-add-wasix-pgxs-side-module-support.patch | 114 + ...x-reset-copy-state-on-error-recovery.patch | 38 + ...-route-process-identity-through-port.patch | 53 + ...-wasix-route-sysv-shmem-through-port.patch | 137 + ...phaunt-wasix-prefer-posix-semaphores.patch | 29 + ...iphaunt-wasix-capture-startup-errors.patch | 86 + ...fail-active-portals-on-host-recovery.patch | 60 + ...-speed-up-hash-bytes-unaligned-loads.patch | 183 + ...op-xid-current-transaction-fast-path.patch | 43 + ...six-add-btree-int4-compare-fast-path.patch | 78 + ...x-keep-btree-delete-scratch-on-stack.patch | 111 + ...d-pg-dump-executequery-lto-collision.patch | 161 + ...x-schedule-ready-after-host-recovery.patch | 42 + ...-exception-stack-after-host-recovery.patch | 43 + ...1-oliphaunt-wasix-declare-wasix-fork.patch | 48 + ...t-wasix-use-wasm-ld-for-backend-core.patch | 43 + ...ownership-check-under-embedded-wasix.patch | 29 + ...add-like-literal-substring-fast-path.patch | 96 + ...d-simple-query-backend-timing-probes.patch | 229 + ...ecutor-storage-backend-timing-probes.patch | 99 + ...d-btree-insert-backend-timing-probes.patch | 98 + ...d-btree-search-compare-timing-probes.patch | 140 + ...unt-wasix-stub-pg-dump-parallel-fork.patch | 37 + ...dd-first-int4-leaf-compare-fast-path.patch | 114 + ...dd-heap-update-backend-timing-probes.patch | 101 + ...-avoid-xlog-size-checkpoint-requests.patch | 46 + ...e-lightweight-embedded-runtime-paths.patch | 63 + ...-set-embedded-postmaster-environment.patch | 31 + ...six-avoid-xlogwrite-prevseg-division.patch | 74 + ...unt-wasix-skip-activity-id-reporting.patch | 125 + ...irectory-fsync-eisdir-as-unsupported.patch | 29 + ...haunt-wasix-register-static-icu-data.patch | 131 + .../assets/build/postgres/patches/series | 38 + .../wasix/assets/build/postgres/source.toml | 41 + .../assets/build/prepare_postgres_source.sh | 119 + .../wasix/assets/build/profile_flags.sh | 114 + .../wasix/assets/build/source_lane.sh | 79 + .../wasix/assets/build/wasix_icu_link.sh | 34 + .../build/wasix_shim/oliphaunt_wasix_bridge.c | 470 +- .../oliphaunt_wasix_bridge_abi_test.c | 353 + .../wasix_shim/oliphaunt_wasix_initdb_shim.c | 104 +- .../oliphaunt_wasix_initdb_shim_abi_test.c | 64 +- .../build/wasix_shim/oliphaunt_wasix_shim.c | 0 .../wasix/assets/build/wasix_third_party.sh | 332 + .../assets/generated/asset-inputs.sha256 | 1 + .../wasix/assets}/generated/wasix-dl.exports | 424 +- .../aot/aarch64-apple-darwin/Cargo.toml | 13 + .../crates/aot/aarch64-apple-darwin/README.md | 4 + .../crates/aot/aarch64-apple-darwin}/build.rs | 43 +- .../aot/aarch64-apple-darwin/src/lib.rs | 0 .../aot/aarch64-unknown-linux-gnu/Cargo.toml | 13 + .../aot/aarch64-unknown-linux-gnu/README.md | 4 + .../aot/aarch64-unknown-linux-gnu}/build.rs | 43 +- .../aot/aarch64-unknown-linux-gnu/src/lib.rs | 0 .../aot/x86_64-pc-windows-msvc/Cargo.toml | 13 + .../aot/x86_64-pc-windows-msvc/README.md | 4 + .../aot/x86_64-pc-windows-msvc}/build.rs | 43 +- .../aot/x86_64-pc-windows-msvc/src/lib.rs | 0 .../aot/x86_64-unknown-linux-gnu/Cargo.toml | 13 + .../aot/x86_64-unknown-linux-gnu/README.md | 4 + .../aot/x86_64-unknown-linux-gnu/build.rs | 43 +- .../aot/x86_64-unknown-linux-gnu/src/lib.rs | 0 .../wasix/crates/assets/Cargo.toml | 25 + .../wasix/crates/assets/README.md | 6 + .../wasix/crates}/assets/build.rs | 36 +- .../wasix/crates}/assets/src/lib.rs | 59 + src/runtimes/liboliphaunt/wasix/moon.yml | 192 + src/runtimes/liboliphaunt/wasix/release.toml | 19 + .../liboliphaunt/wasix/targets/checksums.toml | 7 + .../wasix/targets/linux-arm64-gnu.toml | 10 + .../wasix/targets/linux-x64-gnu.toml | 10 + .../wasix/targets/macos-arm64.toml | 10 + .../wasix/targets/wasix-runtime.toml | 7 + .../wasix/targets/windows-x64-msvc.toml | 10 + .../wasix/tools/build-aot-target.sh | 25 + .../wasix/tools/build-runtime-portable.sh | 39 + .../tools/check-asset-input-fingerprint.sh | 44 + .../wasix/tools/check-patch-stack.mjs | 473 + .../liboliphaunt/wasix/tools/runtime-smoke.sh | 52 + src/runtimes/node-direct/CHANGELOG.md | 5 + src/runtimes/node-direct/README.md | 15 + src/runtimes/node-direct/moon.yml | 92 + .../native/node-addon/oliphaunt_node.cc | 602 + src/runtimes/node-direct/package.json | 34 + .../packages/darwin-arm64/README.md | 3 + .../packages/darwin-arm64/package.json | 31 + .../packages/linux-arm64-gnu/README.md | 3 + .../packages/linux-arm64-gnu/package.json | 34 + .../packages/linux-x64-gnu/README.md | 3 + .../packages/linux-x64-gnu/package.json | 34 + .../packages/win32-x64-msvc/README.md | 3 + .../packages/win32-x64-msvc/package.json | 31 + src/runtimes/node-direct/release.toml | 11 + .../node-direct/targets/checksums.toml | 7 + .../node-direct/targets/linux-arm64-gnu.toml | 11 + .../node-direct/targets/linux-x64-gnu.toml | 11 + .../node-direct/targets/macos-arm64.toml | 11 + .../node-direct/targets/windows-x64-msvc.toml | 11 + .../node-direct/tools/build-node-addon.sh | 285 + .../node-direct/tools/check-package.sh | 109 + src/sdks/js/ARCHITECTURE.md | 418 + src/sdks/js/CHANGELOG.md | 5 + src/sdks/js/README.md | 134 + src/sdks/js/jsr.json | 24 + src/sdks/js/moon.yml | 238 + src/sdks/js/package.json | 93 + src/sdks/js/release.toml | 23 + .../js/src/__tests__/asset-resolver.test.ts | 485 + .../js/src/__tests__/broker-frames.test.ts | 104 + src/sdks/js/src/__tests__/client.test.ts | 478 + src/sdks/js/src/__tests__/config.test.ts | 189 + .../js/src/__tests__/native-bindings.test.ts | 243 + src/sdks/js/src/__tests__/native-smoke.ts | 159 + .../js/src/__tests__/physical-archive.test.ts | 132 + .../src/__tests__/protocol-fixtures.test.ts | 204 + src/sdks/js/src/__tests__/query.test.ts | 232 + .../js/src/__tests__/runtime-modes.test.ts | 277 + src/sdks/js/src/__tests__/server-wire.test.ts | 57 + src/sdks/js/src/client.ts | 584 + src/sdks/js/src/config.ts | 373 + src/sdks/js/src/generated/extensions.ts | 1132 ++ src/sdks/js/src/index.ts | 66 + src/sdks/js/src/native/assets-deno.ts | 328 + src/sdks/js/src/native/assets-node.ts | 227 + src/sdks/js/src/native/bun.ts | 201 + src/sdks/js/src/native/common.ts | 245 + src/sdks/js/src/native/default.ts | 27 + src/sdks/js/src/native/deno.ts | 231 + src/sdks/js/src/native/ffi-layout.ts | 118 + src/sdks/js/src/native/node-addon.ts | 469 + src/sdks/js/src/native/node.ts | 79 + src/sdks/js/src/native/runtime-ambient.d.ts | 23 + src/sdks/js/src/native/tar.ts | 158 + src/sdks/js/src/native/types.ts | 45 + src/sdks/js/src/native/zip.ts | 139 + src/sdks/js/src/protocol.ts | 20 + src/sdks/js/src/query.ts | 656 + src/sdks/js/src/runtime/broker-frames.ts | 198 + src/sdks/js/src/runtime/broker.ts | 1008 ++ src/sdks/js/src/runtime/byte-stream.ts | 40 + src/sdks/js/src/runtime/direct.ts | 104 + src/sdks/js/src/runtime/node-adapter.ts | 221 + src/sdks/js/src/runtime/pgwire.ts | 209 + src/sdks/js/src/runtime/physical-archive.ts | 315 + src/sdks/js/src/runtime/server.ts | 465 + src/sdks/js/src/runtime/types.ts | 28 + src/sdks/js/src/types.ts | 123 + src/sdks/js/tools/check-sdk.sh | 355 + src/sdks/js/tsconfig.build.json | 7 + src/sdks/js/tsconfig.json | 19 + src/sdks/js/typedoc.json | 14 + src/sdks/kotlin/.gitignore | 7 + src/sdks/kotlin/CHANGELOG.md | 12 + src/sdks/kotlin/README.md | 299 + src/sdks/kotlin/VERSION | 1 + src/sdks/kotlin/build.gradle.kts | 9 + src/sdks/kotlin/gradle.properties | 7 + src/sdks/kotlin/gradle/libs.versions.toml | 25 + .../kotlin/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + src/sdks/kotlin/gradlew | 248 + src/sdks/kotlin/gradlew.bat | 82 + src/sdks/kotlin/moon.yml | 177 + .../build.gradle.kts | 63 + .../android/OliphauntAndroidExtension.java | 26 + .../android/OliphauntAndroidPlugin.java | 234 + .../ResolveOliphauntAndroidAssetsTask.java | 1019 ++ .../oliphaunt/android/liboliphaunt.version | 1 + src/sdks/kotlin/oliphaunt/build.gradle.kts | 1867 +++ .../src/androidMain/cpp/CMakeLists.txt | 124 + .../src/androidMain/cpp/include/oliphaunt.h | 172 + .../cpp/oliphaunt_android_bridge.cpp | 646 + .../oliphaunt/AndroidNativeDirectEngine.kt | 363 + .../kotlin/dev/oliphaunt/DefaultEngine.kt | 31 + .../kotlin/dev/oliphaunt/OliphauntAndroid.kt | 43 + .../oliphaunt/OliphauntAndroidNativeBridge.kt | 50 + .../OliphauntAndroidRuntimeAssets.kt | 717 + .../OliphauntAndroidDefaultEngineTest.kt | 206 + .../OliphauntAndroidRuntimeAssetsTest.kt | 606 + .../kotlin/dev/oliphaunt/Oliphaunt.kt | 672 + .../commonMain/kotlin/dev/oliphaunt/Query.kt | 569 + .../dev/oliphaunt/OliphauntDatabaseTest.kt | 1413 ++ .../oliphaunt/src/generated/extensions.json | 1249 ++ .../kotlin/dev/oliphaunt/DefaultEngine.kt | 3 + .../oliphaunt/SharedProtocolFixtureTest.kt | 222 + .../src/nativeInterop/cinterop/oliphaunt.def | 3 + .../cinterop/oliphaunt_kotlin_bridge.c | 301 + .../cinterop/oliphaunt_kotlin_bridge.h | 31 + .../kotlin/dev/oliphaunt/DefaultEngine.kt | 9 + .../dev/oliphaunt/NativeDirectEngine.kt | 458 + .../dev/oliphaunt/NativeDirectEngineTest.kt | 298 + src/sdks/kotlin/release.toml | 18 + src/sdks/kotlin/settings.gradle.kts | 33 + src/sdks/kotlin/tools/check-sdk.sh | 718 + src/sdks/react-native/.gitignore | 7 + src/sdks/react-native/CHANGELOG.md | 12 + .../react-native/OliphauntReactNative.podspec | 43 + src/sdks/react-native/README.md | 488 + src/sdks/react-native/android/build.gradle | 866 + .../react-native/android/gradle.properties | 4 + src/sdks/react-native/android/settings.gradle | 51 + .../android/src/main/AndroidManifest.xml | 1 + .../android/src/main/cpp/CMakeLists.txt | 120 + .../src/main/cpp/OliphauntJsiBindings.cpp | 778 + .../android/src/main/cpp/include/oliphaunt.h | 172 + .../reactnative/OliphauntJsiCallback.kt | 9 + .../OliphauntJsiPromiseCallback.kt | 26 + .../reactnative/OliphauntJsiStreamCallback.kt | 26 + .../oliphaunt/reactnative/OliphauntModule.kt | 648 + .../oliphaunt/reactnative/OliphauntPackage.kt | 33 + .../OliphauntAndroidBoundaryTest.kt | 115 + src/sdks/react-native/app.plugin.js | 234 + .../react-native/examples/expo/.gitignore | 43 + .../examples/expo/.vscode/extensions.json | 1 + .../examples/expo/.vscode/settings.json | 7 + src/sdks/react-native/examples/expo/LICENSE | 21 + src/sdks/react-native/examples/expo/README.md | 226 + src/sdks/react-native/examples/expo/app.json | 54 + .../assets/expo.icon/Assets/expo-symbol 2.svg | 3 + .../expo/assets/expo.icon/Assets/grid.png | Bin 0 -> 53681 bytes .../examples/expo/assets/expo.icon/icon.json | 40 + .../assets/images/android-icon-background.png | Bin 0 -> 17549 bytes .../assets/images/android-icon-foreground.png | Bin 0 -> 78796 bytes .../assets/images/android-icon-monochrome.png | Bin 0 -> 4140 bytes .../expo/assets/images/expo-badge-white.png | Bin 0 -> 4129 bytes .../expo/assets/images/expo-badge.png | Bin 0 -> 4137 bytes .../examples/expo/assets/images/expo-logo.png | Bin 0 -> 3317 bytes .../examples/expo/assets/images/favicon.png | Bin 0 -> 1129 bytes .../examples/expo/assets/images/icon.png | Bin 0 -> 799005 bytes .../examples/expo/assets/images/logo-glow.png | Bin 0 -> 331624 bytes .../expo/assets/images/react-logo.png | Bin 0 -> 6341 bytes .../expo/assets/images/react-logo@2x.png | Bin 0 -> 14225 bytes .../expo/assets/images/react-logo@3x.png | Bin 0 -> 21252 bytes .../expo/assets/images/splash-icon.png | Bin 0 -> 3317 bytes .../expo/assets/images/tabIcons/explore.png | Bin 0 -> 215 bytes .../assets/images/tabIcons/explore@2x.png | Bin 0 -> 347 bytes .../assets/images/tabIcons/explore@3x.png | Bin 0 -> 468 bytes .../expo/assets/images/tabIcons/home.png | Bin 0 -> 253 bytes .../expo/assets/images/tabIcons/home@2x.png | Bin 0 -> 343 bytes .../expo/assets/images/tabIcons/home@3x.png | Bin 0 -> 479 bytes .../expo/assets/images/tutorial-web.png | Bin 0 -> 58959 bytes src/sdks/react-native/examples/expo/eas.json | 21 + .../examples/expo/eslint.config.js | 10 + src/sdks/react-native/examples/expo/index.js | 6 + .../expo/maestro/installed-smoke.yaml | 15 + .../examples/expo/metro.config.js | 26 + .../react-native/examples/expo/package.json | 72 + .../examples/expo/scripts/reset-project.js | 114 + .../examples/expo/src/SmokeDashboard.tsx | 1265 ++ .../src/components/animated-icon.module.css | 6 + .../expo/src/components/animated-icon.tsx | 132 + .../expo/src/components/animated-icon.web.tsx | 108 + .../examples/expo/src/components/app-tabs.tsx | 32 + .../expo/src/components/app-tabs.web.tsx | 115 + .../expo/src/components/external-link.tsx | 25 + .../examples/expo/src/components/hint-row.tsx | 35 + .../expo/src/components/themed-text.tsx | 73 + .../expo/src/components/themed-view.tsx | 16 + .../expo/src/components/ui/collapsible.tsx | 65 + .../expo/src/components/web-badge.tsx | 43 + .../examples/expo/src/constants/theme.ts | 65 + .../examples/expo/src/declarations.d.ts | 2 + .../expo/src/expo-router-template/_layout.tsx | 17 + .../expo/src/expo-router-template/explore.tsx | 180 + .../react-native/examples/expo/src/global.css | 9 + .../expo/src/hooks/use-color-scheme.ts | 1 + .../expo/src/hooks/use-color-scheme.web.ts | 22 + .../examples/expo/src/hooks/use-theme.ts | 14 + .../examples/expo/src/postgres-workload.ts | 995 ++ .../examples/expo/src/sqlite-benchmark.ts | 439 + .../react-native/examples/expo/tsconfig.json | 18 + src/sdks/react-native/ios/Oliphaunt.mm | 918 ++ src/sdks/react-native/ios/OliphauntAdapter.h | 39 + .../react-native/ios/OliphauntAdapter.swift | 843 + .../react-native/ios/OliphauntReactNative.h | 14 + .../ios/podspecs/COliphaunt.podspec | 35 + .../ios/podspecs/Oliphaunt.podspec | 33 + src/sdks/react-native/moon.yml | 671 + src/sdks/react-native/package.json | 94 + src/sdks/react-native/react-native.config.js | 10 + src/sdks/react-native/release.toml | 19 + .../react-native/src/__tests__/client.test.ts | 1768 ++ .../src/__tests__/config-plugin.test.ts | 68 + .../src/__tests__/protocol-fixtures.test.ts | 225 + src/sdks/react-native/src/benchmark.ts | 597 + src/sdks/react-native/src/client.ts | 778 + .../src/generated/extensions.json | 1249 ++ .../react-native/src/generated/extensions.ts | 1132 ++ src/sdks/react-native/src/index.ts | 76 + src/sdks/react-native/src/jsiTransport.ts | 123 + src/sdks/react-native/src/protocol.ts | 20 + src/sdks/react-native/src/query.ts | 656 + src/sdks/react-native/src/smoke.ts | 141 + .../react-native/src/specs/NativeOliphaunt.ts | 96 + .../tools/android-smoke-artifacts.sh | 84 + src/sdks/react-native/tools/check-sdk.sh | 945 ++ src/sdks/react-native/tools/codegen-check.cjs | 13 + .../react-native/tools/expo-android-runner.sh | 749 + .../react-native/tools/expo-ios-runner.sh | 1010 ++ .../tools/expo-runner-android-device.sh | 377 + .../react-native/tools/expo-runner-common.sh | 135 + .../tools/expo-runner-ios-device.sh | 223 + .../tools/expo-runner-ios-installed-app.sh | 752 + .../react-native/tools/expo-runner-metro.sh | 54 + .../tools/expo-runner-reporting.sh | 170 + .../tools/expo-runner-runtime-resources.sh | 181 + .../tools/expo-runner-workspace.sh | 326 + src/sdks/react-native/tools/mobile-build.sh | 35 + src/sdks/react-native/tools/mobile-drill.sh | 50 + src/sdks/react-native/tools/mobile-e2e.sh | 59 + .../tools/mobile-extension-runtime.sh | 707 + .../react-native/tsconfig.build.commonjs.json | 15 + src/sdks/react-native/tsconfig.build.json | 3 + .../react-native/tsconfig.build.module.json | 14 + .../react-native/tsconfig.build.types.json | 10 + src/sdks/react-native/tsconfig.json | 18 + src/sdks/react-native/typedoc.json | 14 + src/sdks/rust/.gitignore | 2 + src/sdks/rust/ARCHITECTURE.md | 147 + src/sdks/rust/CHANGELOG.md | 12 + src/sdks/rust/Cargo.toml | 60 + src/sdks/rust/README.md | 371 + src/sdks/rust/moon.yml | 231 + src/sdks/rust/release.toml | 6 + src/sdks/rust/src/backup.rs | 2386 +++ src/sdks/rust/src/bin/extension_artifact.rs | 516 + src/sdks/rust/src/bin/extension_index.rs | 245 + src/sdks/rust/src/bin/package_resources.rs | 1274 ++ src/sdks/rust/src/broker.rs | 1409 ++ src/sdks/rust/src/builder.rs | 270 + src/sdks/rust/src/config.rs | 435 + src/sdks/rust/src/database.rs | 391 + src/sdks/rust/src/engine.rs | 265 + src/sdks/rust/src/error.rs | 195 + src/sdks/rust/src/executor.rs | 504 + src/sdks/rust/src/extension.rs | 459 + src/sdks/rust/src/generated/extensions.rs | 947 ++ src/sdks/rust/src/ipc.rs | 277 + src/sdks/rust/src/lib.rs | 82 + src/sdks/rust/src/liboliphaunt/ffi.rs | 253 + src/sdks/rust/src/liboliphaunt/mod.rs | 861 + src/sdks/rust/src/liboliphaunt/root.rs | 445 + .../rust/src/liboliphaunt/root/extensions.rs | 263 + src/sdks/rust/src/liboliphaunt/root/files.rs | 219 + .../rust/src/liboliphaunt/root/fingerprint.rs | 117 + .../rust/src/liboliphaunt/root/manifest.rs | 302 + .../rust/src/liboliphaunt/root/runtime.rs | 161 + .../liboliphaunt/root/runtime/cache_key.rs | 425 + .../src/liboliphaunt/root/runtime/install.rs | 382 + .../src/liboliphaunt/root/runtime/locate.rs | 92 + .../rust/src/liboliphaunt/root/template.rs | 412 + src/sdks/rust/src/lifecycle.rs | 78 + src/sdks/rust/src/performance.rs | 100 + src/sdks/rust/src/pgwire.rs | 443 + src/sdks/rust/src/protocol.rs | 107 + src/sdks/rust/src/query.rs | 1004 ++ src/sdks/rust/src/reply.rs | 57 + src/sdks/rust/src/runtime_resources.rs | 3234 ++++ .../runtime_resources/extension_artifact.rs | 1094 ++ .../src/runtime_resources/extension_index.rs | 1081 ++ .../rust/src/runtime_resources/manifest.rs | 519 + .../rust/src/runtime_resources/package.rs | 699 + .../src/runtime_resources/static_registry.rs | 779 + src/sdks/rust/src/server.rs | 781 + src/sdks/rust/src/storage.rs | 150 + src/sdks/rust/tests/native_extensions.rs | 1093 ++ src/sdks/rust/tests/native_root_locking.rs | 595 + src/sdks/rust/tests/native_sql_regression.rs | 1026 ++ src/sdks/rust/tests/protocol_parser_fuzz.rs | 219 + .../rust/tests/protocol_query_fixtures.rs | 248 + src/sdks/rust/tests/sdk_config_modes.rs | 735 + src/sdks/rust/tests/sdk_extensions.rs | 651 + src/sdks/rust/tests/sdk_native_smoke.rs | 2045 +++ src/sdks/rust/tests/sdk_shape.rs | 1464 ++ src/sdks/rust/tools/check-sdk.sh | 286 + src/sdks/swift/.gitignore | 1 + src/sdks/swift/.swift-format | 39 + src/sdks/swift/.swiftlint.yml | 43 + src/sdks/swift/CHANGELOG.md | 12 + src/sdks/swift/LIBOLIPHAUNT_VERSION | 1 + src/sdks/swift/Package.resolved | 24 + src/sdks/swift/Package.swift | 31 + src/sdks/swift/README.md | 237 + src/sdks/swift/Sources/COliphaunt/bridge.c | 345 + src/sdks/swift/Sources/COliphaunt/empty.c | 1 + .../Sources/COliphaunt/include/COliphaunt.h | 32 + .../COliphaunt/include/module.modulemap | 4 + .../Sources/COliphaunt/include/oliphaunt.h | 172 + .../swift/Sources/Oliphaunt/Oliphaunt.swift | 855 + .../Oliphaunt/OliphauntNativeDirect.swift | 549 + .../Sources/Oliphaunt/OliphauntQuery.swift | 566 + .../Oliphaunt/OliphauntRuntimeResources.swift | 1181 ++ .../Tests/OliphauntTests/OliphauntTests.swift | 2461 +++ .../OliphauntTests/ProtocolFixtureTests.swift | 185 + src/sdks/swift/VERSION | 1 + src/sdks/swift/moon.yml | 199 + src/sdks/swift/release.toml | 15 + src/sdks/swift/tools/check-sdk.sh | 239 + src/shared/contracts/moon.yml | 27 + src/shared/contracts/test-matrix.toml | 250 + .../contracts/tools/check-test-matrix.py | 408 + .../extension-runtime-contract/contract.toml | 15 + .../extension-runtime-contract/moon.yml | 27 + .../tools/check-contract.py | 48 + .../backup/physical-archive-manifest.json | 11 + .../fixtures/consumer-shape/products.json | 1089 ++ .../fixtures/lifecycle/session-lifecycle.json | 26 + src/shared/fixtures/manifest.toml | 2 + src/shared/fixtures/moon.yml | 45 + .../protocol/query-response-cases.json | 134 + .../react-native-jsi/binary-transport.json | 33 + .../runtime-resources/manifest.properties | 10 + .../runtime-resources/package-size.tsv | 7 + .../template-pgdata-manifest.properties | 10 + .../sdk-capabilities/mode-support.json | 60 + src/shared/js-core/README.md | 7 + src/shared/js-core/moon.yml | 32 + src/shared/js-core/src/protocol.ts | 20 + src/shared/js-core/src/query.ts | 656 + src/shared/js-core/tools/check-js-core.mjs | 27 + src/sources/moon.yml | 67 + src/sources/third-party/native/README.md | 3 + src/sources/third-party/native/moon.yml | 28 + src/sources/third-party/shared/icu.toml | 4 + src/sources/third-party/shared/moon.yml | 28 + src/sources/third-party/shared/openssl.toml | 4 + src/sources/third-party/wasix/README.md | 3 + src/sources/third-party/wasix/moon.yml | 28 + .../toolchains/android-emulator-runner.toml | 12 + src/sources/toolchains/maestro.toml | 14 + src/sources/toolchains/moon.yml | 28 + src/sources/toolchains/wasix.toml | 16 + tests/cli_smoke.rs | 82 - tools/coverage/check-product | 3 + tools/coverage/coverage.py | 805 + tools/coverage/moon.yml | 27 + tools/coverage/run-product | 3 + tools/coverage/summarize | 3 + {scripts => tools/dev}/bootstrap-tools.sh | 118 +- tools/dev/bun.sh | 102 + tools/dev/deno.sh | 104 + tools/dev/doctor.sh | 150 + tools/dev/install-actionlint.sh | 81 + {scripts => tools/dev}/install-hooks.sh | 0 tools/dev/moon.yml | 47 + tools/dev/setup-android-sdk.sh | 277 + tools/dev/setup-maestro.sh | 61 + tools/dev/start-android-emulator-ci.sh | 103 + tools/graph/affected.py | 64 + tools/graph/cache-witness.py | 105 + tools/graph/ci_plan.py | 598 + tools/graph/graph.py | 604 + tools/graph/moon.yml | 96 + tools/graph/synthetic/affected.toml | 394 + tools/graph/synthetic/coverage.toml | 133 + tools/graph/synthetic/release.toml | 378 + tools/perf/bench-react-native-expo-android.sh | 7 + tools/perf/bench-react-native-expo-ios.sh | 7 + tools/perf/check-native-perf-harness.sh | 1244 ++ tools/perf/check-native-perf-report.sh | 20 + .../perf/matrix}/build_bench_matrix.mjs | 44 +- .../matrix/native_oliphaunt_provenance.mjs | 935 ++ tools/perf/matrix/run_bench_matrix.sh | 12 + .../matrix/run_mobile_footprint_matrix.sh | 999 ++ .../matrix/run_native_oliphaunt_matrix.sh | 778 + .../matrix/run_native_speed_diagnostics.sh | 162 + .../summarize_native_oliphaunt_matrix.mjs | 1337 ++ .../summarize_native_speed_diagnostics.mjs | 214 + tools/perf/moon.yml | 66 + tools/perf/runner/Cargo.toml | 28 + tools/perf/runner/src/benchmarks.rs | 807 + tools/perf/runner/src/diagnostics.rs | 802 + tools/perf/runner/src/legacy_wasix.rs | 412 + tools/perf/runner/src/main.rs | 1670 ++ tools/perf/runner/src/native_liboliphaunt.rs | 1236 ++ tools/perf/runner/src/native_postgres.rs | 1014 ++ tools/perf/runner/src/prepared_updates.rs | 1019 ++ tools/perf/runner/src/process_rss.rs | 189 + tools/perf/runner/src/report.rs | 347 + tools/perf/runner/src/shared.rs | 208 + tools/perf/runner/src/sqlite.rs | 248 + tools/policy/check-coverage.sh | 146 + tools/policy/check-crate-package.sh | 41 + {scripts => tools/policy}/check-crate-size.sh | 0 .../policy}/check-dependency-invariants.sh | 51 +- tools/policy/check-docs.sh | 178 + tools/policy/check-feature-powerset.sh | 10 + .../policy/check-final-source-architecture.py | 598 + .../check-mobile-extension-artifacts.sh | 434 + tools/policy/check-moon-product-graph.mjs | 1114 ++ tools/policy/check-native-boundaries.sh | 333 + tools/policy/check-policy-tools.sh | 25 + tools/policy/check-prek.sh | 26 + tools/policy/check-react-native-boundary.sh | 22 + tools/policy/check-release-policy.py | 1070 ++ tools/policy/check-repo-structure.sh | 596 + tools/policy/check-repo.sh | 31 + tools/policy/check-rust-lint.sh | 16 + tools/policy/check-rust-test-topology.sh | 59 + tools/policy/check-sdk-doc-examples.mjs | 176 + .../check-sdk-mobile-extension-surface.sh | 619 + tools/policy/check-sdk-parity.sh | 1166 ++ tools/policy/check-semver.sh | 10 + tools/policy/check-source-inputs.mjs | 226 + tools/policy/check-source-inputs.sh | 156 + tools/policy/check-supply-chain.sh | 10 + tools/policy/check-test-strategy.mjs | 603 + tools/policy/check-tooling-stack.sh | 619 + tools/policy/check-wasm-artifacts.sh | 14 + tools/policy/check-workflows.sh | 32 + tools/policy/fetch-sources.mjs | 541 + tools/policy/format.sh | 42 + tools/policy/generate-sdk-api-surface.mjs | 480 + tools/policy/moon.mjs | 30 + tools/policy/moon.yml | 122 + tools/policy/sdk-check-lib.sh | 96 + tools/policy/sdk-manifest.toml | 75 + tools/release/archive_dir.py | 114 + tools/release/artifact_target_matrix.py | 451 + tools/release/artifact_targets.py | 183 + tools/release/build-extension-ci-artifacts.py | 477 + tools/release/build-sdk-ci-artifacts.sh | 230 + tools/release/check_artifact_targets.py | 1279 ++ tools/release/check_broker_release_assets.py | 171 + tools/release/check_consumer_shape.py | 1100 ++ tools/release/check_cratesio_publication.py | 172 + tools/release/check_github_release_assets.py | 167 + .../check_liboliphaunt_release_assets.py | 529 + .../check_node_direct_release_assets.py | 163 + tools/release/check_publish_environment.py | 119 + tools/release/check_registry_publication.py | 620 + tools/release/check_release_metadata.py | 788 + tools/release/check_release_please_config.py | 159 + tools/release/check_release_versions.py | 371 + tools/release/check_staged_artifacts.py | 1012 ++ tools/release/check_wasm_crate_payloads.py | 72 + tools/release/extension_artifact_targets.py | 150 + tools/release/liboliphaunt-extension-guard.sh | 41 + tools/release/moon.yml | 150 + tools/release/package-broker-assets.sh | 90 + .../package-liboliphaunt-aggregate-assets.sh | 52 + tools/release/package-liboliphaunt-assets.sh | 194 + .../package-liboliphaunt-linux-assets.sh | 69 + .../package-liboliphaunt-macos-assets.sh | 76 + .../package-liboliphaunt-mobile-assets.sh | 131 + .../package-liboliphaunt-windows-assets.ps1 | 149 + tools/release/product_metadata.py | 462 + tools/release/publish_swiftpm_source_tag.py | 242 + tools/release/release.py | 1477 ++ tools/release/release_plan.py | 534 + .../release/render_swiftpm_release_package.py | 217 + .../release}/sync-example-lockfiles.py | 18 +- tools/release/upload_github_release_assets.py | 92 + .../verify_github_release_attestations.py | 110 + tools/release/verify_product_tag.py | 81 + tools/release/write_checksum_manifest.py | 52 + tools/runtime/preflight.sh | 510 + tools/runtime/with-native-runtime-lock.py | 161 + tools/test/create-broker-release-fixture.py | 57 + .../create-liboliphaunt-release-fixture.py | 183 + tools/test/moon.yml | 27 + tools/test/release_fixture_utils.py | 50 + tools/test/run-js-tests.mjs | 103 + {xtask => tools/xtask}/Cargo.toml | 26 +- tools/xtask/moon.yml | 59 + tools/xtask/src/aot_serializer.rs | 213 + tools/xtask/src/asset_checks.rs | 1711 ++ tools/xtask/src/asset_io.rs | 435 + tools/xtask/src/asset_manifest.rs | 580 + tools/xtask/src/asset_pipeline.rs | 3081 ++++ .../xtask}/src/extension_catalog.rs | 1511 +- tools/xtask/src/fs_utils.rs | 322 + tools/xtask/src/main.rs | 536 + tools/xtask/src/postgres_guard.rs | 1713 ++ tools/xtask/src/release_workspace.rs | 214 + tools/xtask/src/source_spine.rs | 720 + tools/xtask/src/template_runner.rs | 384 + xtask/src/main.rs | 10272 ------------ 1308 files changed, 247920 insertions(+), 23466 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 .github/actions/collect-ci-summary/action.yml create mode 100644 .github/actions/setup-android/action.yml create mode 100644 .github/actions/setup-apple/action.yml create mode 100644 .github/actions/setup-bun/action.yml create mode 100644 .github/actions/setup-deno/action.yml create mode 100644 .github/actions/setup-maestro/action.yml create mode 100644 .github/actions/setup-moon/action.yml create mode 100644 .github/actions/setup-msvc/action.yml create mode 100644 .github/actions/setup-node-pnpm/action.yml create mode 100644 .github/actions/setup-rust/action.yml delete mode 100644 .github/dependabot.yml create mode 100644 .github/moon.yml delete mode 100755 .github/scripts/check-release-changelog.sh delete mode 100755 .github/scripts/download-aot-artifacts.sh create mode 100755 .github/scripts/download-build-artifacts.sh create mode 100755 .github/scripts/download-wasix-runtime-build-artifacts.sh create mode 100644 .github/scripts/plan-affected.py create mode 100755 .github/scripts/prepare-linux-apt.sh create mode 100755 .github/scripts/run-moon-targets.sh create mode 100755 .github/scripts/run-planned-moon-job.sh create mode 100755 .github/scripts/setup-native-build-tools.sh delete mode 100644 .github/workflows/assets.yml delete mode 100644 .github/workflows/conventional-commits.yml create mode 100644 .github/workflows/mobile-e2e.yml create mode 100644 .lychee.toml create mode 100644 .markdownlint-cli2.jsonc create mode 100644 .moon/toolchains.yml create mode 100644 .moon/workspace.yml create mode 100644 .prototools create mode 100644 .release-please-manifest.json create mode 100644 .typos.toml create mode 100644 Package.swift delete mode 100644 assets/generated/asset-inputs.sha256 delete mode 100644 assets/generated/pgxs-build.tsv delete mode 100644 assets/sources.toml delete mode 100644 assets/wasix-build/.gitignore delete mode 100755 assets/wasix-build/configure_wasix_dl.sh delete mode 100755 assets/wasix-build/docker_contrib_extensions.sh delete mode 100755 assets/wasix-build/docker_initdb.sh delete mode 100755 assets/wasix-build/docker_pgdump.sh delete mode 100755 assets/wasix-build/docker_pglite.sh delete mode 100755 assets/wasix-build/docker_runtime_support.sh delete mode 100644 assets/wasix-build/patches/postgres-pglite-wasix-dl.patch delete mode 100755 assets/wasix-build/pg_config_wasix.sh delete mode 100755 assets/wasix-build/prepare_patched_source.sh delete mode 100644 assets/wasix-build/profile_flags.sh delete mode 100644 assets/wasix-build/wasix_shim/pglite_wasix_bridge_abi_test.c create mode 100644 benchmarks/README.md create mode 100644 benchmarks/mobile/README.md create mode 100644 benchmarks/moon.yml create mode 100644 benchmarks/native/README.md create mode 100644 benchmarks/native/baselines/README.md create mode 100644 benchmarks/native/sql/benchmark1.sql create mode 100644 benchmarks/native/sql/benchmark10.sql create mode 100644 benchmarks/native/sql/benchmark11.sql create mode 100644 benchmarks/native/sql/benchmark12.sql create mode 100644 benchmarks/native/sql/benchmark13.sql create mode 100644 benchmarks/native/sql/benchmark14.sql create mode 100644 benchmarks/native/sql/benchmark15.sql create mode 100644 benchmarks/native/sql/benchmark16.sql create mode 100644 benchmarks/native/sql/benchmark2.sql create mode 100644 benchmarks/native/sql/benchmark3.sql create mode 100644 benchmarks/native/sql/benchmark4.sql create mode 100644 benchmarks/native/sql/benchmark5.sql create mode 100644 benchmarks/native/sql/benchmark6.sql create mode 100644 benchmarks/native/sql/benchmark7.sql create mode 100644 benchmarks/native/sql/benchmark8.sql create mode 100644 benchmarks/native/sql/benchmark9.sql create mode 100644 benchmarks/reports/README.md create mode 100644 benchmarks/wasix/README.md create mode 100644 biome.json create mode 100644 coverage/baseline.toml delete mode 100644 crates/aot/aarch64-apple-darwin/Cargo.toml delete mode 100644 crates/aot/aarch64-apple-darwin/README.md delete mode 100644 crates/aot/aarch64-unknown-linux-gnu/Cargo.toml delete mode 100644 crates/aot/aarch64-unknown-linux-gnu/README.md delete mode 100644 crates/aot/x86_64-pc-windows-msvc/Cargo.toml delete mode 100644 crates/aot/x86_64-pc-windows-msvc/README.md delete mode 100644 crates/aot/x86_64-unknown-linux-gnu/Cargo.toml delete mode 100644 crates/aot/x86_64-unknown-linux-gnu/README.md delete mode 100644 crates/assets/Cargo.toml delete mode 100644 crates/assets/README.md delete mode 100644 docs/ASSETS.md delete mode 100644 docs/DEVELOPMENT.md delete mode 100644 docs/DONE.md delete mode 100644 docs/EXTENSIONS.md delete mode 100644 docs/PERFORMANCE.md create mode 100644 docs/README.md delete mode 100644 docs/RELEASE.md delete mode 100644 docs/RUNTIME.md delete mode 100644 docs/TAURI.md delete mode 100644 docs/TESTING.md delete mode 100644 docs/TODO.md create mode 100644 docs/architecture/final-product-source-architecture.md create mode 100644 docs/architecture/ios.md create mode 100644 docs/architecture/native-liboliphaunt.md rename docs/assets/{pglite-oxide.png => oliphaunt.png} (100%) create mode 100644 docs/internal/DONE.md create mode 100644 docs/internal/IMPLEMENTATION_CHECKLIST.md create mode 100644 docs/internal/OLIPHAUNT_PATCH_STACK.md create mode 100644 docs/internal/OLIPHAUNT_TRACK_REVIEW.md rename docs/{PERFORMANCE_INTERNAL.md => internal/PERFORMANCE.md} (69%) create mode 100644 docs/internal/PG18_WASIX_PERF_STATUS.md create mode 100644 docs/internal/PG18_WASIX_POSTGRES.md create mode 100644 docs/internal/PHYSICAL_ARCHIVE_FORMAT.md create mode 100644 docs/internal/TODO.md create mode 100644 docs/internal/WASIX_PATCH_STACK.md create mode 100644 docs/maintainers/assets.md create mode 100644 docs/maintainers/compiler-caching.md create mode 100644 docs/maintainers/development.md create mode 100644 docs/maintainers/extension-packaging-policy.md create mode 100644 docs/maintainers/mobile-stability-model.md create mode 100644 docs/maintainers/native-runtime-contract.md create mode 100644 docs/maintainers/performance-evidence.md create mode 100644 docs/maintainers/release-setup.md create mode 100644 docs/maintainers/release.md create mode 100644 docs/maintainers/repo-structure.md create mode 100644 docs/maintainers/rust-sdk-policy.md create mode 100644 docs/maintainers/sdk-api-surface.md create mode 100644 docs/maintainers/sdk-parity-policy.md create mode 100644 docs/maintainers/sdk-products-policy.md create mode 100644 docs/maintainers/testing.md create mode 100644 docs/maintainers/tooling.md rename docs/{USAGE.md => maintainers/wasm-usage-legacy.md} (78%) create mode 100644 examples/moon.yml delete mode 100644 examples/tauri-sqlx-vanilla/package-lock.json create mode 100755 examples/tools/check-examples.sh rename scripts/check-example-lockfiles.sh => examples/tools/check-lockfiles.sh (55%) create mode 100644 moon.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 release-please-config.json delete mode 100644 release-plz.toml create mode 100644 renovate.json delete mode 100755 scripts/check-asset-input-fingerprint.sh delete mode 100755 scripts/ci-scope.sh delete mode 100644 scripts/perf/node-bench/package-lock.json delete mode 100644 scripts/perf/node-bench/package.json delete mode 100644 scripts/perf/node-bench/start_nodefs_socket.mjs delete mode 100755 scripts/perf/run_bench_matrix.sh delete mode 100755 scripts/validate.sh create mode 100644 src/bindings/wasix-rust/CHANGELOG.md create mode 100644 src/bindings/wasix-rust/THIRD_PARTY_NOTICES.md create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/CHANGELOG.md create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml rename src/{bin/pglite_dump.rs => bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs} (77%) rename src/{bin/pglite_proxy.rs => bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_proxy.rs} (89%) create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/aot.rs (87%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/assets.rs (60%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/backend.rs (66%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/base.rs (80%) create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/builder.rs (86%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/client.rs (84%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/config.rs (97%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/data_dir.rs (99%) create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/engine.rs rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/errors.rs (80%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/extensions.rs (68%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/generated_extensions.rs (75%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/interface.rs (100%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/mod.rs (63%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/parse.rs (65%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/pg_dump.rs (93%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/postgres_mod.rs (65%) create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/proxy.rs (98%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/server.rs (89%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/sync_host_fs.rs (100%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/templating.rs (94%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/timing.rs (100%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/transport.rs (83%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/types.rs (99%) rename src/{pglite => bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt}/wire.rs (99%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/buffer_reader.rs (100%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/buffer_writer.rs (100%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/messages.rs (100%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/mod.rs (94%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/parser.rs (100%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/serializer.rs (100%) create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/string_utils.rs (100%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/tests.rs (100%) rename src/{ => bindings/wasix-rust/crates/oliphaunt-wasix/src}/protocol/types.rs (63%) create mode 100644 src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/cli_smoke.rs rename {tests => src/bindings/wasix-rust/crates/oliphaunt-wasix/tests}/client_compat.rs (96%) rename {tests => src/bindings/wasix-rust/crates/oliphaunt-wasix/tests}/extensions_smoke.rs (76%) rename {tests => src/bindings/wasix-rust/crates/oliphaunt-wasix/tests}/performance_smoke.rs (73%) rename {tests => src/bindings/wasix-rust/crates/oliphaunt-wasix/tests}/postgres_regression.rs (91%) rename {tests => src/bindings/wasix-rust/crates/oliphaunt-wasix/tests}/proxy_smoke.rs (96%) rename {tests => src/bindings/wasix-rust/crates/oliphaunt-wasix/tests}/runtime_smoke.rs (83%) rename {tests => src/bindings/wasix-rust/crates/oliphaunt-wasix/tests}/support/mod.rs (100%) rename {examples => src/bindings/wasix-rust/examples}/build_pgdata_template.rs (71%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/.gitignore (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/.vscode/extensions.json (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/README.md (70%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/index.html (96%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/package.json (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/.gitignore (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/Cargo.lock (99%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/Cargo.toml (77%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/build.rs (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/capabilities/default.json (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/128x128.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/128x128@2x.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/32x32.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square107x107Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square142x142Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square150x150Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square284x284Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square30x30Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square310x310Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square44x44Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square71x71Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/Square89x89Logo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/StoreLogo.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/icon.icns (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/icon.ico (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/icons/icon.png (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/src/bench.rs (94%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/src/bin/profile_queries.rs (86%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/src/lib.rs (95%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/src/main.rs (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src-tauri/tauri.conf.json (71%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src/assets/tauri.svg (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src/assets/typescript.svg (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src/assets/vite.svg (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src/main.ts (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/src/styles.css (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/tsconfig.json (100%) rename {examples => src/bindings/wasix-rust/examples}/tauri-sqlx-vanilla/vite.config.ts (100%) create mode 100644 src/bindings/wasix-rust/moon.yml create mode 100755 src/bindings/wasix-rust/tools/check-examples.sh create mode 100755 src/bindings/wasix-rust/tools/check-package.sh create mode 100755 src/bindings/wasix-rust/tools/check-unit.sh create mode 100644 src/docs/.gitignore create mode 100644 src/docs/README.md create mode 100644 src/docs/content/learn/embedded-postgres.mdx create mode 100644 src/docs/content/learn/index.mdx create mode 100644 src/docs/content/learn/mobile-stability.mdx create mode 100644 src/docs/content/learn/native-runtime.mdx create mode 100644 src/docs/content/learn/sqlite-upgrade.mdx create mode 100644 src/docs/content/learn/tauri.mdx create mode 100644 src/docs/content/reference/capabilities.mdx create mode 100644 src/docs/content/reference/extensions.mdx create mode 100644 src/docs/content/reference/index.mdx create mode 100644 src/docs/content/reference/performance.mdx create mode 100644 src/docs/content/reference/releases.mdx create mode 100644 src/docs/content/reference/sdk-products.mdx create mode 100644 src/docs/content/sdk/c-abi/api-reference.md create mode 100644 src/docs/content/sdk/c-abi/guide.mdx create mode 100644 src/docs/content/sdk/c-abi/index.mdx create mode 100644 src/docs/content/sdk/index.mdx create mode 100644 src/docs/content/sdk/kotlin/api-reference.md create mode 100644 src/docs/content/sdk/kotlin/guide.mdx create mode 100644 src/docs/content/sdk/kotlin/index.mdx create mode 100644 src/docs/content/sdk/react-native/api-reference.md create mode 100644 src/docs/content/sdk/react-native/architecture.mdx create mode 100644 src/docs/content/sdk/react-native/guide.mdx create mode 100644 src/docs/content/sdk/react-native/index.mdx create mode 100644 src/docs/content/sdk/rust/api-reference.md create mode 100644 src/docs/content/sdk/rust/guide.mdx create mode 100644 src/docs/content/sdk/rust/index.mdx create mode 100644 src/docs/content/sdk/swift/api-reference.md create mode 100644 src/docs/content/sdk/swift/guide.mdx create mode 100644 src/docs/content/sdk/swift/index.mdx create mode 100644 src/docs/content/sdk/typescript/api-reference.md create mode 100644 src/docs/content/sdk/typescript/guide.mdx create mode 100644 src/docs/content/sdk/typescript/index.mdx create mode 100644 src/docs/content/sdk/wasm/api-reference.md rename docs/PG_DUMP.md => src/docs/content/sdk/wasm/dump-restore.mdx (60%) create mode 100644 src/docs/content/sdk/wasm/guide.mdx create mode 100644 src/docs/content/sdk/wasm/index.mdx create mode 100644 src/docs/content/sdk/wasm/runtime.mdx create mode 100644 src/docs/content/start/index.mdx create mode 100644 src/docs/docs-manifest.toml create mode 100644 src/docs/moon.yml create mode 100644 src/docs/next.config.mjs create mode 100644 src/docs/package.json create mode 100644 src/docs/postcss.config.mjs create mode 100644 src/docs/proxy.ts create mode 100644 src/docs/reference/doxygen/Doxyfile create mode 100644 src/docs/source.config.ts create mode 100644 src/docs/src/app/(home)/layout.tsx create mode 100644 src/docs/src/app/(home)/page.tsx create mode 100644 src/docs/src/app/api/search/route.ts create mode 100644 src/docs/src/app/docs/[[...slug]]/page.tsx create mode 100644 src/docs/src/app/docs/layout.tsx create mode 100644 src/docs/src/app/global.css create mode 100644 src/docs/src/app/layout.tsx create mode 100644 src/docs/src/app/llms-full.txt/route.ts create mode 100644 src/docs/src/app/llms.mdx/docs/[[...slug]]/route.ts create mode 100644 src/docs/src/app/llms.txt/route.ts create mode 100644 src/docs/src/app/og/docs/[...slug]/route.tsx create mode 100644 src/docs/src/components/mdx.tsx create mode 100644 src/docs/src/components/oliphaunt.tsx create mode 100644 src/docs/src/lib/cn.ts create mode 100644 src/docs/src/lib/docs-data.ts create mode 100644 src/docs/src/lib/layout.shared.tsx create mode 100644 src/docs/src/lib/shared.ts create mode 100644 src/docs/src/lib/source.ts create mode 100644 src/docs/static/img/favicon.svg create mode 100644 src/docs/tools/check-docs-product.mjs create mode 100644 src/docs/tools/check-fumadocs-source.mjs create mode 100644 src/docs/tools/generate-api-reference.mjs create mode 100644 src/docs/tools/generate-content.mjs create mode 100644 src/docs/tools/publish-next-export.mjs create mode 100644 src/docs/tools/run-docs-task.mjs create mode 100644 src/docs/tools/smoke-built-site.mjs create mode 100644 src/docs/tsconfig.json create mode 100644 src/extensions/artifacts/native/moon.yml create mode 100755 src/extensions/artifacts/native/tools/check-release-artifacts.sh create mode 100755 src/extensions/artifacts/native/tools/extension-artifact-packager.mjs create mode 100755 src/extensions/artifacts/native/tools/package-release-assets.sh create mode 100644 src/extensions/artifacts/packages/moon.yml create mode 100755 src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh create mode 100644 src/extensions/artifacts/wasix/moon.yml create mode 100755 src/extensions/artifacts/wasix/tools/package-release-assets.sh rename {assets => src/extensions/catalog}/extensions.promoted.toml (81%) rename {assets => src/extensions/catalog}/extensions.smoke.toml (73%) create mode 100644 src/extensions/contrib/amcheck/CHANGELOG.md create mode 100644 src/extensions/contrib/amcheck/VERSION create mode 100644 src/extensions/contrib/amcheck/moon.yml create mode 100644 src/extensions/contrib/amcheck/release.toml create mode 100644 src/extensions/contrib/amcheck/targets/artifacts.toml create mode 100644 src/extensions/contrib/auto_explain/CHANGELOG.md create mode 100644 src/extensions/contrib/auto_explain/VERSION create mode 100644 src/extensions/contrib/auto_explain/moon.yml create mode 100644 src/extensions/contrib/auto_explain/release.toml create mode 100644 src/extensions/contrib/auto_explain/targets/artifacts.toml create mode 100644 src/extensions/contrib/bloom/CHANGELOG.md create mode 100644 src/extensions/contrib/bloom/VERSION create mode 100644 src/extensions/contrib/bloom/moon.yml create mode 100644 src/extensions/contrib/bloom/release.toml create mode 100644 src/extensions/contrib/bloom/targets/artifacts.toml create mode 100644 src/extensions/contrib/btree_gin/CHANGELOG.md create mode 100644 src/extensions/contrib/btree_gin/VERSION create mode 100644 src/extensions/contrib/btree_gin/moon.yml create mode 100644 src/extensions/contrib/btree_gin/release.toml create mode 100644 src/extensions/contrib/btree_gin/targets/artifacts.toml create mode 100644 src/extensions/contrib/btree_gist/CHANGELOG.md create mode 100644 src/extensions/contrib/btree_gist/VERSION create mode 100644 src/extensions/contrib/btree_gist/moon.yml create mode 100644 src/extensions/contrib/btree_gist/release.toml create mode 100644 src/extensions/contrib/btree_gist/targets/artifacts.toml create mode 100644 src/extensions/contrib/citext/CHANGELOG.md create mode 100644 src/extensions/contrib/citext/VERSION create mode 100644 src/extensions/contrib/citext/moon.yml create mode 100644 src/extensions/contrib/citext/release.toml create mode 100644 src/extensions/contrib/citext/targets/artifacts.toml create mode 100644 src/extensions/contrib/cube/CHANGELOG.md create mode 100644 src/extensions/contrib/cube/VERSION create mode 100644 src/extensions/contrib/cube/moon.yml create mode 100644 src/extensions/contrib/cube/release.toml create mode 100644 src/extensions/contrib/cube/targets/artifacts.toml create mode 100644 src/extensions/contrib/dict_int/CHANGELOG.md create mode 100644 src/extensions/contrib/dict_int/VERSION create mode 100644 src/extensions/contrib/dict_int/moon.yml create mode 100644 src/extensions/contrib/dict_int/release.toml create mode 100644 src/extensions/contrib/dict_int/targets/artifacts.toml create mode 100644 src/extensions/contrib/dict_xsyn/CHANGELOG.md create mode 100644 src/extensions/contrib/dict_xsyn/VERSION create mode 100644 src/extensions/contrib/dict_xsyn/moon.yml create mode 100644 src/extensions/contrib/dict_xsyn/release.toml create mode 100644 src/extensions/contrib/dict_xsyn/targets/artifacts.toml create mode 100644 src/extensions/contrib/earthdistance/CHANGELOG.md create mode 100644 src/extensions/contrib/earthdistance/VERSION create mode 100644 src/extensions/contrib/earthdistance/moon.yml create mode 100644 src/extensions/contrib/earthdistance/release.toml create mode 100644 src/extensions/contrib/earthdistance/targets/artifacts.toml create mode 100644 src/extensions/contrib/file_fdw/CHANGELOG.md create mode 100644 src/extensions/contrib/file_fdw/VERSION create mode 100644 src/extensions/contrib/file_fdw/moon.yml create mode 100644 src/extensions/contrib/file_fdw/release.toml create mode 100644 src/extensions/contrib/file_fdw/targets/artifacts.toml create mode 100644 src/extensions/contrib/fuzzystrmatch/CHANGELOG.md create mode 100644 src/extensions/contrib/fuzzystrmatch/VERSION create mode 100644 src/extensions/contrib/fuzzystrmatch/moon.yml create mode 100644 src/extensions/contrib/fuzzystrmatch/release.toml create mode 100644 src/extensions/contrib/fuzzystrmatch/targets/artifacts.toml create mode 100644 src/extensions/contrib/hstore/CHANGELOG.md create mode 100644 src/extensions/contrib/hstore/VERSION create mode 100644 src/extensions/contrib/hstore/moon.yml create mode 100644 src/extensions/contrib/hstore/release.toml create mode 100644 src/extensions/contrib/hstore/targets/artifacts.toml create mode 100644 src/extensions/contrib/intarray/CHANGELOG.md create mode 100644 src/extensions/contrib/intarray/VERSION create mode 100644 src/extensions/contrib/intarray/moon.yml create mode 100644 src/extensions/contrib/intarray/release.toml create mode 100644 src/extensions/contrib/intarray/targets/artifacts.toml create mode 100644 src/extensions/contrib/isn/CHANGELOG.md create mode 100644 src/extensions/contrib/isn/VERSION create mode 100644 src/extensions/contrib/isn/moon.yml create mode 100644 src/extensions/contrib/isn/release.toml create mode 100644 src/extensions/contrib/isn/targets/artifacts.toml create mode 100644 src/extensions/contrib/lo/CHANGELOG.md create mode 100644 src/extensions/contrib/lo/VERSION create mode 100644 src/extensions/contrib/lo/moon.yml create mode 100644 src/extensions/contrib/lo/release.toml create mode 100644 src/extensions/contrib/lo/targets/artifacts.toml create mode 100644 src/extensions/contrib/ltree/CHANGELOG.md create mode 100644 src/extensions/contrib/ltree/VERSION create mode 100644 src/extensions/contrib/ltree/moon.yml create mode 100644 src/extensions/contrib/ltree/release.toml create mode 100644 src/extensions/contrib/ltree/targets/artifacts.toml create mode 100644 src/extensions/contrib/moon.yml create mode 100644 src/extensions/contrib/pageinspect/CHANGELOG.md create mode 100644 src/extensions/contrib/pageinspect/VERSION create mode 100644 src/extensions/contrib/pageinspect/moon.yml create mode 100644 src/extensions/contrib/pageinspect/release.toml create mode 100644 src/extensions/contrib/pageinspect/targets/artifacts.toml create mode 100644 src/extensions/contrib/pg_buffercache/CHANGELOG.md create mode 100644 src/extensions/contrib/pg_buffercache/VERSION create mode 100644 src/extensions/contrib/pg_buffercache/moon.yml create mode 100644 src/extensions/contrib/pg_buffercache/release.toml create mode 100644 src/extensions/contrib/pg_buffercache/targets/artifacts.toml create mode 100644 src/extensions/contrib/pg_freespacemap/CHANGELOG.md create mode 100644 src/extensions/contrib/pg_freespacemap/VERSION create mode 100644 src/extensions/contrib/pg_freespacemap/moon.yml create mode 100644 src/extensions/contrib/pg_freespacemap/release.toml create mode 100644 src/extensions/contrib/pg_freespacemap/targets/artifacts.toml create mode 100644 src/extensions/contrib/pg_surgery/CHANGELOG.md create mode 100644 src/extensions/contrib/pg_surgery/VERSION create mode 100644 src/extensions/contrib/pg_surgery/moon.yml create mode 100644 src/extensions/contrib/pg_surgery/release.toml create mode 100644 src/extensions/contrib/pg_surgery/targets/artifacts.toml create mode 100644 src/extensions/contrib/pg_trgm/CHANGELOG.md create mode 100644 src/extensions/contrib/pg_trgm/VERSION create mode 100644 src/extensions/contrib/pg_trgm/moon.yml create mode 100644 src/extensions/contrib/pg_trgm/release.toml create mode 100644 src/extensions/contrib/pg_trgm/targets/artifacts.toml create mode 100644 src/extensions/contrib/pg_visibility/CHANGELOG.md create mode 100644 src/extensions/contrib/pg_visibility/VERSION create mode 100644 src/extensions/contrib/pg_visibility/moon.yml create mode 100644 src/extensions/contrib/pg_visibility/release.toml create mode 100644 src/extensions/contrib/pg_visibility/targets/artifacts.toml create mode 100644 src/extensions/contrib/pg_walinspect/CHANGELOG.md create mode 100644 src/extensions/contrib/pg_walinspect/VERSION create mode 100644 src/extensions/contrib/pg_walinspect/moon.yml create mode 100644 src/extensions/contrib/pg_walinspect/release.toml create mode 100644 src/extensions/contrib/pg_walinspect/targets/artifacts.toml create mode 100644 src/extensions/contrib/pgcrypto/CHANGELOG.md create mode 100644 src/extensions/contrib/pgcrypto/VERSION create mode 100644 src/extensions/contrib/pgcrypto/moon.yml create mode 100644 src/extensions/contrib/pgcrypto/release.toml create mode 100644 src/extensions/contrib/pgcrypto/targets/artifacts.toml create mode 100644 src/extensions/contrib/postgres18.toml create mode 100644 src/extensions/contrib/seg/CHANGELOG.md create mode 100644 src/extensions/contrib/seg/VERSION create mode 100644 src/extensions/contrib/seg/moon.yml create mode 100644 src/extensions/contrib/seg/release.toml create mode 100644 src/extensions/contrib/seg/targets/artifacts.toml create mode 100644 src/extensions/contrib/tablefunc/CHANGELOG.md create mode 100644 src/extensions/contrib/tablefunc/VERSION create mode 100644 src/extensions/contrib/tablefunc/moon.yml create mode 100644 src/extensions/contrib/tablefunc/release.toml create mode 100644 src/extensions/contrib/tablefunc/targets/artifacts.toml create mode 100644 src/extensions/contrib/tcn/CHANGELOG.md create mode 100644 src/extensions/contrib/tcn/VERSION create mode 100644 src/extensions/contrib/tcn/moon.yml create mode 100644 src/extensions/contrib/tcn/release.toml create mode 100644 src/extensions/contrib/tcn/targets/artifacts.toml create mode 100644 src/extensions/contrib/tsm_system_rows/CHANGELOG.md create mode 100644 src/extensions/contrib/tsm_system_rows/VERSION create mode 100644 src/extensions/contrib/tsm_system_rows/moon.yml create mode 100644 src/extensions/contrib/tsm_system_rows/release.toml create mode 100644 src/extensions/contrib/tsm_system_rows/targets/artifacts.toml create mode 100644 src/extensions/contrib/tsm_system_time/CHANGELOG.md create mode 100644 src/extensions/contrib/tsm_system_time/VERSION create mode 100644 src/extensions/contrib/tsm_system_time/moon.yml create mode 100644 src/extensions/contrib/tsm_system_time/release.toml create mode 100644 src/extensions/contrib/tsm_system_time/targets/artifacts.toml create mode 100644 src/extensions/contrib/unaccent/CHANGELOG.md create mode 100644 src/extensions/contrib/unaccent/VERSION create mode 100644 src/extensions/contrib/unaccent/moon.yml create mode 100644 src/extensions/contrib/unaccent/release.toml create mode 100644 src/extensions/contrib/unaccent/targets/artifacts.toml create mode 100644 src/extensions/contrib/uuid_ossp/CHANGELOG.md create mode 100644 src/extensions/contrib/uuid_ossp/VERSION create mode 100644 src/extensions/contrib/uuid_ossp/moon.yml create mode 100644 src/extensions/contrib/uuid_ossp/release.toml create mode 100644 src/extensions/contrib/uuid_ossp/targets/artifacts.toml create mode 100644 src/extensions/evidence/matrix.toml create mode 100644 src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json create mode 100644 src/extensions/evidence/schemas/matrix.schema.json create mode 100644 src/extensions/evidence/schemas/run.schema.json create mode 100644 src/extensions/external/README.md create mode 100644 src/extensions/external/age/moon.yml create mode 100644 src/extensions/external/age/source.toml create mode 100644 src/extensions/external/pg_hashids/CHANGELOG.md create mode 100644 src/extensions/external/pg_hashids/VERSION create mode 100644 src/extensions/external/pg_hashids/moon.yml create mode 100644 src/extensions/external/pg_hashids/release.toml create mode 100644 src/extensions/external/pg_hashids/source.toml create mode 100644 src/extensions/external/pg_hashids/targets/artifacts.toml create mode 100644 src/extensions/external/pg_ivm/CHANGELOG.md create mode 100644 src/extensions/external/pg_ivm/VERSION create mode 100644 src/extensions/external/pg_ivm/moon.yml create mode 100644 src/extensions/external/pg_ivm/release.toml create mode 100644 src/extensions/external/pg_ivm/source.toml create mode 100644 src/extensions/external/pg_ivm/targets/artifacts.toml create mode 100644 src/extensions/external/pg_ivm/targets/native-static-registry.toml create mode 100644 src/extensions/external/pg_textsearch/CHANGELOG.md create mode 100644 src/extensions/external/pg_textsearch/VERSION create mode 100644 src/extensions/external/pg_textsearch/moon.yml create mode 100644 src/extensions/external/pg_textsearch/recipe.toml create mode 100644 src/extensions/external/pg_textsearch/release.toml create mode 100644 src/extensions/external/pg_textsearch/source.toml create mode 100644 src/extensions/external/pg_textsearch/targets/artifacts.toml create mode 100644 src/extensions/external/pg_textsearch/targets/native-static-registry.toml create mode 100644 src/extensions/external/pg_textsearch/tests/smoke.sql create mode 100644 src/extensions/external/pg_textsearch/tests/upstream.toml create mode 100644 src/extensions/external/pg_uuidv7/CHANGELOG.md create mode 100644 src/extensions/external/pg_uuidv7/VERSION create mode 100644 src/extensions/external/pg_uuidv7/moon.yml create mode 100644 src/extensions/external/pg_uuidv7/release.toml create mode 100644 src/extensions/external/pg_uuidv7/source.toml create mode 100644 src/extensions/external/pg_uuidv7/targets/artifacts.toml create mode 100644 src/extensions/external/pgtap/CHANGELOG.md create mode 100644 src/extensions/external/pgtap/VERSION create mode 100644 src/extensions/external/pgtap/moon.yml create mode 100644 src/extensions/external/pgtap/recipe.toml create mode 100644 src/extensions/external/pgtap/release.toml create mode 100644 src/extensions/external/pgtap/source.toml create mode 100644 src/extensions/external/pgtap/targets/artifacts.toml create mode 100644 src/extensions/external/pgtap/targets/native-static-registry.toml create mode 100644 src/extensions/external/pgtap/targets/native.toml create mode 100644 src/extensions/external/pgtap/targets/wasix.toml create mode 100644 src/extensions/external/pgtap/tests/smoke.sql create mode 100644 src/extensions/external/pgtap/tests/upstream.toml create mode 100644 src/extensions/external/postgis/CHANGELOG.md create mode 100644 src/extensions/external/postgis/VERSION create mode 100644 src/extensions/external/postgis/blockers.toml create mode 100644 src/extensions/external/postgis/dependencies/geos/source.toml create mode 100644 src/extensions/external/postgis/dependencies/json-c/source.toml create mode 100644 src/extensions/external/postgis/dependencies/libiconv/source.toml create mode 100644 src/extensions/external/postgis/dependencies/libxml2/source.toml create mode 100644 src/extensions/external/postgis/dependencies/proj/source.toml create mode 100644 src/extensions/external/postgis/dependencies/sqlite/source.toml create mode 100644 src/extensions/external/postgis/deps.toml create mode 100644 src/extensions/external/postgis/moon.yml create mode 100644 src/extensions/external/postgis/patches/README.md create mode 100644 src/extensions/external/postgis/recipe.toml create mode 100644 src/extensions/external/postgis/release.toml create mode 100644 src/extensions/external/postgis/source.toml create mode 100644 src/extensions/external/postgis/targets/artifacts.toml create mode 100644 src/extensions/external/postgis/targets/native-static-registry.toml create mode 100644 src/extensions/external/postgis/targets/native.toml create mode 100644 src/extensions/external/postgis/targets/wasix.toml create mode 100644 src/extensions/external/postgis/tests/regression.sql create mode 100644 src/extensions/external/postgis/tests/smoke.sql create mode 100644 src/extensions/external/postgis/tests/upstream.toml create mode 100755 src/extensions/external/postgis/tools/build_wasix.sh create mode 100644 src/extensions/external/vector/CHANGELOG.md create mode 100644 src/extensions/external/vector/VERSION create mode 100644 src/extensions/external/vector/moon.yml create mode 100644 src/extensions/external/vector/release.toml create mode 100644 src/extensions/external/vector/source.toml create mode 100644 src/extensions/external/vector/targets/artifacts.toml rename {assets => src/extensions}/generated/contrib-build.tsv (93%) create mode 100644 src/extensions/generated/docs/extension-evidence.json create mode 100644 src/extensions/generated/docs/extensions.json rename {assets => src/extensions}/generated/extensions.build-plan.json (67%) rename {assets => src/extensions}/generated/extensions.catalog.json (72%) create mode 100644 src/extensions/generated/mobile/static-extensions.tsv create mode 100644 src/extensions/generated/mobile/static-registry.json create mode 100644 src/extensions/generated/pgxs-build.tsv create mode 100644 src/extensions/generated/sdk/js.json create mode 100644 src/extensions/generated/sdk/kotlin.json create mode 100644 src/extensions/generated/sdk/react-native.json create mode 100644 src/extensions/generated/sdk/rust.json create mode 100644 src/extensions/generated/sdk/swift.json create mode 100644 src/extensions/generated/wasix/extensions.json create mode 100644 src/extensions/model/moon.yml create mode 100644 src/extensions/moon.yml create mode 100644 src/extensions/schemas/recipe.schema.json create mode 100644 src/extensions/schemas/support-table.schema.json create mode 100755 src/extensions/tools/check-extension-model.py create mode 100644 src/extensions/tools/check-extension-tree.py delete mode 100644 src/lib.rs create mode 100644 src/postgres/versions/18/moon.yml create mode 100644 src/postgres/versions/18/source.toml create mode 100644 src/runtimes/broker/CHANGELOG.md create mode 100644 src/runtimes/broker/Cargo.toml create mode 100644 src/runtimes/broker/README.md create mode 100644 src/runtimes/broker/moon.yml create mode 100644 src/runtimes/broker/release.toml create mode 100644 src/runtimes/broker/src/main.rs create mode 100644 src/runtimes/broker/targets/checksums.toml create mode 100644 src/runtimes/broker/targets/linux-arm64-gnu.toml create mode 100644 src/runtimes/broker/targets/linux-x64-gnu.toml create mode 100644 src/runtimes/broker/targets/macos-arm64.toml create mode 100644 src/runtimes/broker/targets/windows-x64-msvc.toml create mode 100755 src/runtimes/broker/tools/check-package.sh create mode 100644 src/runtimes/liboliphaunt/native/CHANGELOG.md create mode 100644 src/runtimes/liboliphaunt/native/README.md create mode 100644 src/runtimes/liboliphaunt/native/THIRD_PARTY_NOTICES.md create mode 100644 src/runtimes/liboliphaunt/native/VERSION create mode 100755 src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh create mode 100644 src/runtimes/liboliphaunt/native/bin/build-output.bash create mode 100755 src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh create mode 100644 src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 create mode 100755 src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/common.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/icu.sh create mode 100644 src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh create mode 100644 src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/smoke-host-happy-path.sh create mode 100755 src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh create mode 100644 src/runtimes/liboliphaunt/native/include/oliphaunt.h create mode 100644 src/runtimes/liboliphaunt/native/moon.yml create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0001-liboliphaunt-add-backend-host-io.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0002-liboliphaunt-add-embedded-entrypoint.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0003-liboliphaunt-return-from-embedded-frontend-terminate.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0004-liboliphaunt-run-embedded-exit-cleanup.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0005-liboliphaunt-restore-host-cwd.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0006-liboliphaunt-add-static-extension-loader.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0008-liboliphaunt-clean-embedded-symbols.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0009-liboliphaunt-guard-embedded-proc-exit.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0010-liboliphaunt-use-host-runtime-paths.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0011-liboliphaunt-add-android-embedded-shared-memory.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0013-liboliphaunt-register-static-icu-data.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0014-liboliphaunt-use-portable-embedded-socketpair.patch create mode 100644 src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0015-liboliphaunt-add-embedded-meson-option.patch create mode 100644 src/runtimes/liboliphaunt/native/portable-uuid/include/uuid/uuid.h create mode 100644 src/runtimes/liboliphaunt/native/portable-uuid/portable_uuid.c create mode 100644 src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml create mode 100644 src/runtimes/liboliphaunt/native/postgres18/source.toml create mode 100644 src/runtimes/liboliphaunt/native/release.toml create mode 100644 src/runtimes/liboliphaunt/native/smoke/liboliphaunt_abi_conformance.c create mode 100644 src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_platform.h create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c create mode 100644 src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c create mode 100644 src/runtimes/liboliphaunt/native/targets/android-arm64-v8a.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/android-x86_64.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/apple-spm-xcframework.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/checksums.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/ios-xcframework.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/linux-arm64-gnu.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/linux-x64-gnu.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/macos-arm64.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/macos-x64.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/package-size.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/runtime-resources.toml create mode 100644 src/runtimes/liboliphaunt/native/targets/windows-x64-msvc.toml create mode 100755 src/runtimes/liboliphaunt/native/tools/build-ci-target.sh create mode 100755 src/runtimes/liboliphaunt/native/tools/build-release-runtime.mjs create mode 100755 src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs create mode 100755 src/runtimes/liboliphaunt/native/tools/check-track.sh create mode 100755 src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs rename CHANGELOG.md => src/runtimes/liboliphaunt/wasix/CHANGELOG.md (76%) create mode 100644 src/runtimes/liboliphaunt/wasix/VERSION create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/.gitignore rename {assets/wasix-build => src/runtimes/liboliphaunt/wasix/assets/build}/analyze_pgl_stubs.sh (74%) create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_geos.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_jsonc.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libxml2.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_openssl.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_proj.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_sqlite.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh rename {assets/wasix-build => src/runtimes/liboliphaunt/wasix/assets/build}/docker/Dockerfile (60%) create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh rename {assets/wasix-build => src/runtimes/liboliphaunt/wasix/assets/build}/docker_pgxs_extensions.sh (51%) create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh rename {assets/wasix-build => src/runtimes/liboliphaunt/wasix/assets/build}/docker_wasix_env.sh (58%) create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0003-oliphaunt-wasix-export-startup-packet-parser.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0004-oliphaunt-wasix-add-host-lifecycle-exports.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0006-oliphaunt-wasix-report-copy-protocol-state.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0009-oliphaunt-wasix-route-process-identity-through-port.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0011-oliphaunt-wasix-prefer-posix-semaphores.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0012-oliphaunt-wasix-capture-startup-errors.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0021-oliphaunt-wasix-declare-wasix-fork.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0036-oliphaunt-wasix-skip-activity-id-reporting.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0038-oliphaunt-wasix-register-static-icu-data.patch create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh rename assets/wasix-build/wasix_shim/pglite_wasix_bridge.c => src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c (53%) create mode 100644 src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c rename assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim.c => src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c (84%) rename assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim_abi_test.c => src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim_abi_test.c (66%) rename assets/wasix-build/wasix_shim/pglite_wasix_shim.c => src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_shim.c (100%) create mode 100755 src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh create mode 100644 src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 rename {assets => src/runtimes/liboliphaunt/wasix/assets}/generated/wasix-dl.exports (80%) create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md rename {crates/aot/x86_64-pc-windows-msvc => src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin}/build.rs (80%) rename {crates => src/runtimes/liboliphaunt/wasix/crates}/aot/aarch64-apple-darwin/src/lib.rs (100%) create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md rename {crates/aot/aarch64-apple-darwin => src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu}/build.rs (80%) rename {crates => src/runtimes/liboliphaunt/wasix/crates}/aot/aarch64-unknown-linux-gnu/src/lib.rs (100%) create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md rename {crates/aot/aarch64-unknown-linux-gnu => src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc}/build.rs (80%) rename {crates => src/runtimes/liboliphaunt/wasix/crates}/aot/x86_64-pc-windows-msvc/src/lib.rs (100%) create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml create mode 100644 src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md rename {crates => src/runtimes/liboliphaunt/wasix/crates}/aot/x86_64-unknown-linux-gnu/build.rs (80%) rename {crates => src/runtimes/liboliphaunt/wasix/crates}/aot/x86_64-unknown-linux-gnu/src/lib.rs (100%) create mode 100644 src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml create mode 100644 src/runtimes/liboliphaunt/wasix/crates/assets/README.md rename {crates => src/runtimes/liboliphaunt/wasix/crates}/assets/build.rs (84%) rename {crates => src/runtimes/liboliphaunt/wasix/crates}/assets/src/lib.rs (75%) create mode 100644 src/runtimes/liboliphaunt/wasix/moon.yml create mode 100644 src/runtimes/liboliphaunt/wasix/release.toml create mode 100644 src/runtimes/liboliphaunt/wasix/targets/checksums.toml create mode 100644 src/runtimes/liboliphaunt/wasix/targets/linux-arm64-gnu.toml create mode 100644 src/runtimes/liboliphaunt/wasix/targets/linux-x64-gnu.toml create mode 100644 src/runtimes/liboliphaunt/wasix/targets/macos-arm64.toml create mode 100644 src/runtimes/liboliphaunt/wasix/targets/wasix-runtime.toml create mode 100644 src/runtimes/liboliphaunt/wasix/targets/windows-x64-msvc.toml create mode 100755 src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh create mode 100755 src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh create mode 100755 src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh create mode 100755 src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs create mode 100755 src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh create mode 100644 src/runtimes/node-direct/CHANGELOG.md create mode 100644 src/runtimes/node-direct/README.md create mode 100644 src/runtimes/node-direct/moon.yml create mode 100644 src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc create mode 100644 src/runtimes/node-direct/package.json create mode 100644 src/runtimes/node-direct/packages/darwin-arm64/README.md create mode 100644 src/runtimes/node-direct/packages/darwin-arm64/package.json create mode 100644 src/runtimes/node-direct/packages/linux-arm64-gnu/README.md create mode 100644 src/runtimes/node-direct/packages/linux-arm64-gnu/package.json create mode 100644 src/runtimes/node-direct/packages/linux-x64-gnu/README.md create mode 100644 src/runtimes/node-direct/packages/linux-x64-gnu/package.json create mode 100644 src/runtimes/node-direct/packages/win32-x64-msvc/README.md create mode 100644 src/runtimes/node-direct/packages/win32-x64-msvc/package.json create mode 100644 src/runtimes/node-direct/release.toml create mode 100644 src/runtimes/node-direct/targets/checksums.toml create mode 100644 src/runtimes/node-direct/targets/linux-arm64-gnu.toml create mode 100644 src/runtimes/node-direct/targets/linux-x64-gnu.toml create mode 100644 src/runtimes/node-direct/targets/macos-arm64.toml create mode 100644 src/runtimes/node-direct/targets/windows-x64-msvc.toml create mode 100755 src/runtimes/node-direct/tools/build-node-addon.sh create mode 100755 src/runtimes/node-direct/tools/check-package.sh create mode 100644 src/sdks/js/ARCHITECTURE.md create mode 100644 src/sdks/js/CHANGELOG.md create mode 100644 src/sdks/js/README.md create mode 100644 src/sdks/js/jsr.json create mode 100644 src/sdks/js/moon.yml create mode 100644 src/sdks/js/package.json create mode 100644 src/sdks/js/release.toml create mode 100644 src/sdks/js/src/__tests__/asset-resolver.test.ts create mode 100644 src/sdks/js/src/__tests__/broker-frames.test.ts create mode 100644 src/sdks/js/src/__tests__/client.test.ts create mode 100644 src/sdks/js/src/__tests__/config.test.ts create mode 100644 src/sdks/js/src/__tests__/native-bindings.test.ts create mode 100644 src/sdks/js/src/__tests__/native-smoke.ts create mode 100644 src/sdks/js/src/__tests__/physical-archive.test.ts create mode 100644 src/sdks/js/src/__tests__/protocol-fixtures.test.ts create mode 100644 src/sdks/js/src/__tests__/query.test.ts create mode 100644 src/sdks/js/src/__tests__/runtime-modes.test.ts create mode 100644 src/sdks/js/src/__tests__/server-wire.test.ts create mode 100644 src/sdks/js/src/client.ts create mode 100644 src/sdks/js/src/config.ts create mode 100644 src/sdks/js/src/generated/extensions.ts create mode 100644 src/sdks/js/src/index.ts create mode 100644 src/sdks/js/src/native/assets-deno.ts create mode 100644 src/sdks/js/src/native/assets-node.ts create mode 100644 src/sdks/js/src/native/bun.ts create mode 100644 src/sdks/js/src/native/common.ts create mode 100644 src/sdks/js/src/native/default.ts create mode 100644 src/sdks/js/src/native/deno.ts create mode 100644 src/sdks/js/src/native/ffi-layout.ts create mode 100644 src/sdks/js/src/native/node-addon.ts create mode 100644 src/sdks/js/src/native/node.ts create mode 100644 src/sdks/js/src/native/runtime-ambient.d.ts create mode 100644 src/sdks/js/src/native/tar.ts create mode 100644 src/sdks/js/src/native/types.ts create mode 100644 src/sdks/js/src/native/zip.ts create mode 100644 src/sdks/js/src/protocol.ts create mode 100644 src/sdks/js/src/query.ts create mode 100644 src/sdks/js/src/runtime/broker-frames.ts create mode 100644 src/sdks/js/src/runtime/broker.ts create mode 100644 src/sdks/js/src/runtime/byte-stream.ts create mode 100644 src/sdks/js/src/runtime/direct.ts create mode 100644 src/sdks/js/src/runtime/node-adapter.ts create mode 100644 src/sdks/js/src/runtime/pgwire.ts create mode 100644 src/sdks/js/src/runtime/physical-archive.ts create mode 100644 src/sdks/js/src/runtime/server.ts create mode 100644 src/sdks/js/src/runtime/types.ts create mode 100644 src/sdks/js/src/types.ts create mode 100755 src/sdks/js/tools/check-sdk.sh create mode 100644 src/sdks/js/tsconfig.build.json create mode 100644 src/sdks/js/tsconfig.json create mode 100644 src/sdks/js/typedoc.json create mode 100644 src/sdks/kotlin/.gitignore create mode 100644 src/sdks/kotlin/CHANGELOG.md create mode 100644 src/sdks/kotlin/README.md create mode 100644 src/sdks/kotlin/VERSION create mode 100644 src/sdks/kotlin/build.gradle.kts create mode 100644 src/sdks/kotlin/gradle.properties create mode 100644 src/sdks/kotlin/gradle/libs.versions.toml create mode 100644 src/sdks/kotlin/gradle/wrapper/gradle-wrapper.jar create mode 100644 src/sdks/kotlin/gradle/wrapper/gradle-wrapper.properties create mode 100755 src/sdks/kotlin/gradlew create mode 100644 src/sdks/kotlin/gradlew.bat create mode 100644 src/sdks/kotlin/moon.yml create mode 100644 src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts create mode 100644 src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidExtension.java create mode 100644 src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidPlugin.java create mode 100644 src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java create mode 100644 src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/liboliphaunt.version create mode 100644 src/sdks/kotlin/oliphaunt/build.gradle.kts create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include/oliphaunt.h create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/cpp/oliphaunt_android_bridge.cpp create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/DefaultEngine.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidNativeBridge.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidDefaultEngineTest.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Query.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/generated/extensions.json create mode 100644 src/sdks/kotlin/oliphaunt/src/jvmMain/kotlin/dev/oliphaunt/DefaultEngine.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt.def create mode 100644 src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.c create mode 100644 src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.h create mode 100644 src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/DefaultEngine.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt create mode 100644 src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt create mode 100644 src/sdks/kotlin/release.toml create mode 100644 src/sdks/kotlin/settings.gradle.kts create mode 100755 src/sdks/kotlin/tools/check-sdk.sh create mode 100644 src/sdks/react-native/.gitignore create mode 100644 src/sdks/react-native/CHANGELOG.md create mode 100644 src/sdks/react-native/OliphauntReactNative.podspec create mode 100644 src/sdks/react-native/README.md create mode 100644 src/sdks/react-native/android/build.gradle create mode 100644 src/sdks/react-native/android/gradle.properties create mode 100644 src/sdks/react-native/android/settings.gradle create mode 100644 src/sdks/react-native/android/src/main/AndroidManifest.xml create mode 100644 src/sdks/react-native/android/src/main/cpp/CMakeLists.txt create mode 100644 src/sdks/react-native/android/src/main/cpp/OliphauntJsiBindings.cpp create mode 100644 src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h create mode 100644 src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiCallback.kt create mode 100644 src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiPromiseCallback.kt create mode 100644 src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiStreamCallback.kt create mode 100644 src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt create mode 100644 src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntPackage.kt create mode 100644 src/sdks/react-native/android/src/test/java/dev/oliphaunt/reactnative/OliphauntAndroidBoundaryTest.kt create mode 100644 src/sdks/react-native/app.plugin.js create mode 100644 src/sdks/react-native/examples/expo/.gitignore create mode 100644 src/sdks/react-native/examples/expo/.vscode/extensions.json create mode 100644 src/sdks/react-native/examples/expo/.vscode/settings.json create mode 100644 src/sdks/react-native/examples/expo/LICENSE create mode 100644 src/sdks/react-native/examples/expo/README.md create mode 100644 src/sdks/react-native/examples/expo/app.json create mode 100644 src/sdks/react-native/examples/expo/assets/expo.icon/Assets/expo-symbol 2.svg create mode 100644 src/sdks/react-native/examples/expo/assets/expo.icon/Assets/grid.png create mode 100644 src/sdks/react-native/examples/expo/assets/expo.icon/icon.json create mode 100644 src/sdks/react-native/examples/expo/assets/images/android-icon-background.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/android-icon-foreground.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/android-icon-monochrome.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/expo-badge-white.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/expo-badge.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/expo-logo.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/favicon.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/icon.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/logo-glow.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/react-logo.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/react-logo@2x.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/react-logo@3x.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/splash-icon.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/tabIcons/explore.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/tabIcons/explore@2x.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/tabIcons/explore@3x.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/tabIcons/home.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/tabIcons/home@2x.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/tabIcons/home@3x.png create mode 100644 src/sdks/react-native/examples/expo/assets/images/tutorial-web.png create mode 100644 src/sdks/react-native/examples/expo/eas.json create mode 100644 src/sdks/react-native/examples/expo/eslint.config.js create mode 100644 src/sdks/react-native/examples/expo/index.js create mode 100644 src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml create mode 100644 src/sdks/react-native/examples/expo/metro.config.js create mode 100644 src/sdks/react-native/examples/expo/package.json create mode 100644 src/sdks/react-native/examples/expo/scripts/reset-project.js create mode 100644 src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/animated-icon.module.css create mode 100644 src/sdks/react-native/examples/expo/src/components/animated-icon.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/animated-icon.web.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/app-tabs.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/app-tabs.web.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/external-link.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/hint-row.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/themed-text.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/themed-view.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/ui/collapsible.tsx create mode 100644 src/sdks/react-native/examples/expo/src/components/web-badge.tsx create mode 100644 src/sdks/react-native/examples/expo/src/constants/theme.ts create mode 100644 src/sdks/react-native/examples/expo/src/declarations.d.ts create mode 100644 src/sdks/react-native/examples/expo/src/expo-router-template/_layout.tsx create mode 100644 src/sdks/react-native/examples/expo/src/expo-router-template/explore.tsx create mode 100644 src/sdks/react-native/examples/expo/src/global.css create mode 100644 src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.ts create mode 100644 src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.web.ts create mode 100644 src/sdks/react-native/examples/expo/src/hooks/use-theme.ts create mode 100644 src/sdks/react-native/examples/expo/src/postgres-workload.ts create mode 100644 src/sdks/react-native/examples/expo/src/sqlite-benchmark.ts create mode 100644 src/sdks/react-native/examples/expo/tsconfig.json create mode 100644 src/sdks/react-native/ios/Oliphaunt.mm create mode 100644 src/sdks/react-native/ios/OliphauntAdapter.h create mode 100644 src/sdks/react-native/ios/OliphauntAdapter.swift create mode 100644 src/sdks/react-native/ios/OliphauntReactNative.h create mode 100644 src/sdks/react-native/ios/podspecs/COliphaunt.podspec create mode 100644 src/sdks/react-native/ios/podspecs/Oliphaunt.podspec create mode 100644 src/sdks/react-native/moon.yml create mode 100644 src/sdks/react-native/package.json create mode 100644 src/sdks/react-native/react-native.config.js create mode 100644 src/sdks/react-native/release.toml create mode 100644 src/sdks/react-native/src/__tests__/client.test.ts create mode 100644 src/sdks/react-native/src/__tests__/config-plugin.test.ts create mode 100644 src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts create mode 100644 src/sdks/react-native/src/benchmark.ts create mode 100644 src/sdks/react-native/src/client.ts create mode 100644 src/sdks/react-native/src/generated/extensions.json create mode 100644 src/sdks/react-native/src/generated/extensions.ts create mode 100644 src/sdks/react-native/src/index.ts create mode 100644 src/sdks/react-native/src/jsiTransport.ts create mode 100644 src/sdks/react-native/src/protocol.ts create mode 100644 src/sdks/react-native/src/query.ts create mode 100644 src/sdks/react-native/src/smoke.ts create mode 100644 src/sdks/react-native/src/specs/NativeOliphaunt.ts create mode 100644 src/sdks/react-native/tools/android-smoke-artifacts.sh create mode 100755 src/sdks/react-native/tools/check-sdk.sh create mode 100644 src/sdks/react-native/tools/codegen-check.cjs create mode 100755 src/sdks/react-native/tools/expo-android-runner.sh create mode 100755 src/sdks/react-native/tools/expo-ios-runner.sh create mode 100644 src/sdks/react-native/tools/expo-runner-android-device.sh create mode 100644 src/sdks/react-native/tools/expo-runner-common.sh create mode 100755 src/sdks/react-native/tools/expo-runner-ios-device.sh create mode 100644 src/sdks/react-native/tools/expo-runner-ios-installed-app.sh create mode 100644 src/sdks/react-native/tools/expo-runner-metro.sh create mode 100644 src/sdks/react-native/tools/expo-runner-reporting.sh create mode 100644 src/sdks/react-native/tools/expo-runner-runtime-resources.sh create mode 100644 src/sdks/react-native/tools/expo-runner-workspace.sh create mode 100755 src/sdks/react-native/tools/mobile-build.sh create mode 100755 src/sdks/react-native/tools/mobile-drill.sh create mode 100755 src/sdks/react-native/tools/mobile-e2e.sh create mode 100644 src/sdks/react-native/tools/mobile-extension-runtime.sh create mode 100644 src/sdks/react-native/tsconfig.build.commonjs.json create mode 100644 src/sdks/react-native/tsconfig.build.json create mode 100644 src/sdks/react-native/tsconfig.build.module.json create mode 100644 src/sdks/react-native/tsconfig.build.types.json create mode 100644 src/sdks/react-native/tsconfig.json create mode 100644 src/sdks/react-native/typedoc.json create mode 100644 src/sdks/rust/.gitignore create mode 100644 src/sdks/rust/ARCHITECTURE.md create mode 100644 src/sdks/rust/CHANGELOG.md create mode 100644 src/sdks/rust/Cargo.toml create mode 100644 src/sdks/rust/README.md create mode 100644 src/sdks/rust/moon.yml create mode 100644 src/sdks/rust/release.toml create mode 100644 src/sdks/rust/src/backup.rs create mode 100644 src/sdks/rust/src/bin/extension_artifact.rs create mode 100644 src/sdks/rust/src/bin/extension_index.rs create mode 100644 src/sdks/rust/src/bin/package_resources.rs create mode 100644 src/sdks/rust/src/broker.rs create mode 100644 src/sdks/rust/src/builder.rs create mode 100644 src/sdks/rust/src/config.rs create mode 100644 src/sdks/rust/src/database.rs create mode 100644 src/sdks/rust/src/engine.rs create mode 100644 src/sdks/rust/src/error.rs create mode 100644 src/sdks/rust/src/executor.rs create mode 100644 src/sdks/rust/src/extension.rs create mode 100644 src/sdks/rust/src/generated/extensions.rs create mode 100644 src/sdks/rust/src/ipc.rs create mode 100644 src/sdks/rust/src/lib.rs create mode 100644 src/sdks/rust/src/liboliphaunt/ffi.rs create mode 100644 src/sdks/rust/src/liboliphaunt/mod.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/extensions.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/files.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/fingerprint.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/manifest.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/runtime.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/runtime/install.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs create mode 100644 src/sdks/rust/src/liboliphaunt/root/template.rs create mode 100644 src/sdks/rust/src/lifecycle.rs create mode 100644 src/sdks/rust/src/performance.rs create mode 100644 src/sdks/rust/src/pgwire.rs create mode 100644 src/sdks/rust/src/protocol.rs create mode 100644 src/sdks/rust/src/query.rs create mode 100644 src/sdks/rust/src/reply.rs create mode 100644 src/sdks/rust/src/runtime_resources.rs create mode 100644 src/sdks/rust/src/runtime_resources/extension_artifact.rs create mode 100644 src/sdks/rust/src/runtime_resources/extension_index.rs create mode 100644 src/sdks/rust/src/runtime_resources/manifest.rs create mode 100644 src/sdks/rust/src/runtime_resources/package.rs create mode 100644 src/sdks/rust/src/runtime_resources/static_registry.rs create mode 100644 src/sdks/rust/src/server.rs create mode 100644 src/sdks/rust/src/storage.rs create mode 100644 src/sdks/rust/tests/native_extensions.rs create mode 100644 src/sdks/rust/tests/native_root_locking.rs create mode 100644 src/sdks/rust/tests/native_sql_regression.rs create mode 100644 src/sdks/rust/tests/protocol_parser_fuzz.rs create mode 100644 src/sdks/rust/tests/protocol_query_fixtures.rs create mode 100644 src/sdks/rust/tests/sdk_config_modes.rs create mode 100644 src/sdks/rust/tests/sdk_extensions.rs create mode 100644 src/sdks/rust/tests/sdk_native_smoke.rs create mode 100644 src/sdks/rust/tests/sdk_shape.rs create mode 100755 src/sdks/rust/tools/check-sdk.sh create mode 100644 src/sdks/swift/.gitignore create mode 100644 src/sdks/swift/.swift-format create mode 100644 src/sdks/swift/.swiftlint.yml create mode 100644 src/sdks/swift/CHANGELOG.md create mode 100644 src/sdks/swift/LIBOLIPHAUNT_VERSION create mode 100644 src/sdks/swift/Package.resolved create mode 100644 src/sdks/swift/Package.swift create mode 100644 src/sdks/swift/README.md create mode 100644 src/sdks/swift/Sources/COliphaunt/bridge.c create mode 100644 src/sdks/swift/Sources/COliphaunt/empty.c create mode 100644 src/sdks/swift/Sources/COliphaunt/include/COliphaunt.h create mode 100644 src/sdks/swift/Sources/COliphaunt/include/module.modulemap create mode 100644 src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h create mode 100644 src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift create mode 100644 src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift create mode 100644 src/sdks/swift/Sources/Oliphaunt/OliphauntQuery.swift create mode 100644 src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift create mode 100644 src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift create mode 100644 src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift create mode 100644 src/sdks/swift/VERSION create mode 100644 src/sdks/swift/moon.yml create mode 100644 src/sdks/swift/release.toml create mode 100755 src/sdks/swift/tools/check-sdk.sh create mode 100644 src/shared/contracts/moon.yml create mode 100644 src/shared/contracts/test-matrix.toml create mode 100644 src/shared/contracts/tools/check-test-matrix.py create mode 100644 src/shared/extension-runtime-contract/contract.toml create mode 100644 src/shared/extension-runtime-contract/moon.yml create mode 100644 src/shared/extension-runtime-contract/tools/check-contract.py create mode 100644 src/shared/fixtures/backup/physical-archive-manifest.json create mode 100644 src/shared/fixtures/consumer-shape/products.json create mode 100644 src/shared/fixtures/lifecycle/session-lifecycle.json create mode 100644 src/shared/fixtures/manifest.toml create mode 100644 src/shared/fixtures/moon.yml create mode 100644 src/shared/fixtures/protocol/query-response-cases.json create mode 100644 src/shared/fixtures/react-native-jsi/binary-transport.json create mode 100644 src/shared/fixtures/runtime-resources/manifest.properties create mode 100644 src/shared/fixtures/runtime-resources/package-size.tsv create mode 100644 src/shared/fixtures/runtime-resources/template-pgdata-manifest.properties create mode 100644 src/shared/fixtures/sdk-capabilities/mode-support.json create mode 100644 src/shared/js-core/README.md create mode 100644 src/shared/js-core/moon.yml create mode 100644 src/shared/js-core/src/protocol.ts create mode 100644 src/shared/js-core/src/query.ts create mode 100644 src/shared/js-core/tools/check-js-core.mjs create mode 100644 src/sources/moon.yml create mode 100644 src/sources/third-party/native/README.md create mode 100644 src/sources/third-party/native/moon.yml create mode 100644 src/sources/third-party/shared/icu.toml create mode 100644 src/sources/third-party/shared/moon.yml create mode 100644 src/sources/third-party/shared/openssl.toml create mode 100644 src/sources/third-party/wasix/README.md create mode 100644 src/sources/third-party/wasix/moon.yml create mode 100644 src/sources/toolchains/android-emulator-runner.toml create mode 100644 src/sources/toolchains/maestro.toml create mode 100644 src/sources/toolchains/moon.yml create mode 100644 src/sources/toolchains/wasix.toml delete mode 100644 tests/cli_smoke.rs create mode 100755 tools/coverage/check-product create mode 100755 tools/coverage/coverage.py create mode 100644 tools/coverage/moon.yml create mode 100755 tools/coverage/run-product create mode 100755 tools/coverage/summarize rename {scripts => tools/dev}/bootstrap-tools.sh (52%) create mode 100755 tools/dev/bun.sh create mode 100755 tools/dev/deno.sh create mode 100755 tools/dev/doctor.sh create mode 100755 tools/dev/install-actionlint.sh rename {scripts => tools/dev}/install-hooks.sh (100%) create mode 100644 tools/dev/moon.yml create mode 100755 tools/dev/setup-android-sdk.sh create mode 100755 tools/dev/setup-maestro.sh create mode 100755 tools/dev/start-android-emulator-ci.sh create mode 100755 tools/graph/affected.py create mode 100755 tools/graph/cache-witness.py create mode 100644 tools/graph/ci_plan.py create mode 100755 tools/graph/graph.py create mode 100644 tools/graph/moon.yml create mode 100644 tools/graph/synthetic/affected.toml create mode 100644 tools/graph/synthetic/coverage.toml create mode 100644 tools/graph/synthetic/release.toml create mode 100755 tools/perf/bench-react-native-expo-android.sh create mode 100755 tools/perf/bench-react-native-expo-ios.sh create mode 100755 tools/perf/check-native-perf-harness.sh create mode 100755 tools/perf/check-native-perf-report.sh rename {scripts/perf => tools/perf/matrix}/build_bench_matrix.mjs (77%) create mode 100644 tools/perf/matrix/native_oliphaunt_provenance.mjs create mode 100755 tools/perf/matrix/run_bench_matrix.sh create mode 100755 tools/perf/matrix/run_mobile_footprint_matrix.sh create mode 100755 tools/perf/matrix/run_native_oliphaunt_matrix.sh create mode 100755 tools/perf/matrix/run_native_speed_diagnostics.sh create mode 100644 tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs create mode 100644 tools/perf/matrix/summarize_native_speed_diagnostics.mjs create mode 100644 tools/perf/moon.yml create mode 100644 tools/perf/runner/Cargo.toml create mode 100644 tools/perf/runner/src/benchmarks.rs create mode 100644 tools/perf/runner/src/diagnostics.rs create mode 100644 tools/perf/runner/src/legacy_wasix.rs create mode 100644 tools/perf/runner/src/main.rs create mode 100644 tools/perf/runner/src/native_liboliphaunt.rs create mode 100644 tools/perf/runner/src/native_postgres.rs create mode 100644 tools/perf/runner/src/prepared_updates.rs create mode 100644 tools/perf/runner/src/process_rss.rs create mode 100644 tools/perf/runner/src/report.rs create mode 100644 tools/perf/runner/src/shared.rs create mode 100644 tools/perf/runner/src/sqlite.rs create mode 100755 tools/policy/check-coverage.sh create mode 100755 tools/policy/check-crate-package.sh rename {scripts => tools/policy}/check-crate-size.sh (100%) rename {scripts => tools/policy}/check-dependency-invariants.sh (59%) create mode 100755 tools/policy/check-docs.sh create mode 100755 tools/policy/check-feature-powerset.sh create mode 100755 tools/policy/check-final-source-architecture.py create mode 100755 tools/policy/check-mobile-extension-artifacts.sh create mode 100755 tools/policy/check-moon-product-graph.mjs create mode 100755 tools/policy/check-native-boundaries.sh create mode 100755 tools/policy/check-policy-tools.sh create mode 100755 tools/policy/check-prek.sh create mode 100755 tools/policy/check-react-native-boundary.sh create mode 100644 tools/policy/check-release-policy.py create mode 100755 tools/policy/check-repo-structure.sh create mode 100755 tools/policy/check-repo.sh create mode 100755 tools/policy/check-rust-lint.sh create mode 100755 tools/policy/check-rust-test-topology.sh create mode 100755 tools/policy/check-sdk-doc-examples.mjs create mode 100755 tools/policy/check-sdk-mobile-extension-surface.sh create mode 100755 tools/policy/check-sdk-parity.sh create mode 100755 tools/policy/check-semver.sh create mode 100755 tools/policy/check-source-inputs.mjs create mode 100755 tools/policy/check-source-inputs.sh create mode 100755 tools/policy/check-supply-chain.sh create mode 100755 tools/policy/check-test-strategy.mjs create mode 100755 tools/policy/check-tooling-stack.sh create mode 100755 tools/policy/check-wasm-artifacts.sh create mode 100755 tools/policy/check-workflows.sh create mode 100755 tools/policy/fetch-sources.mjs create mode 100755 tools/policy/format.sh create mode 100755 tools/policy/generate-sdk-api-surface.mjs create mode 100644 tools/policy/moon.mjs create mode 100644 tools/policy/moon.yml create mode 100755 tools/policy/sdk-check-lib.sh create mode 100644 tools/policy/sdk-manifest.toml create mode 100755 tools/release/archive_dir.py create mode 100755 tools/release/artifact_target_matrix.py create mode 100644 tools/release/artifact_targets.py create mode 100755 tools/release/build-extension-ci-artifacts.py create mode 100755 tools/release/build-sdk-ci-artifacts.sh create mode 100644 tools/release/check_artifact_targets.py create mode 100755 tools/release/check_broker_release_assets.py create mode 100755 tools/release/check_consumer_shape.py create mode 100755 tools/release/check_cratesio_publication.py create mode 100755 tools/release/check_github_release_assets.py create mode 100755 tools/release/check_liboliphaunt_release_assets.py create mode 100644 tools/release/check_node_direct_release_assets.py create mode 100755 tools/release/check_publish_environment.py create mode 100755 tools/release/check_registry_publication.py create mode 100755 tools/release/check_release_metadata.py create mode 100755 tools/release/check_release_please_config.py create mode 100755 tools/release/check_release_versions.py create mode 100755 tools/release/check_staged_artifacts.py create mode 100755 tools/release/check_wasm_crate_payloads.py create mode 100644 tools/release/extension_artifact_targets.py create mode 100644 tools/release/liboliphaunt-extension-guard.sh create mode 100644 tools/release/moon.yml create mode 100755 tools/release/package-broker-assets.sh create mode 100755 tools/release/package-liboliphaunt-aggregate-assets.sh create mode 100755 tools/release/package-liboliphaunt-assets.sh create mode 100755 tools/release/package-liboliphaunt-linux-assets.sh create mode 100755 tools/release/package-liboliphaunt-macos-assets.sh create mode 100755 tools/release/package-liboliphaunt-mobile-assets.sh create mode 100644 tools/release/package-liboliphaunt-windows-assets.ps1 create mode 100644 tools/release/product_metadata.py create mode 100755 tools/release/publish_swiftpm_source_tag.py create mode 100755 tools/release/release.py create mode 100644 tools/release/release_plan.py create mode 100755 tools/release/render_swiftpm_release_package.py rename {scripts => tools/release}/sync-example-lockfiles.py (86%) create mode 100755 tools/release/upload_github_release_assets.py create mode 100755 tools/release/verify_github_release_attestations.py create mode 100755 tools/release/verify_product_tag.py create mode 100755 tools/release/write_checksum_manifest.py create mode 100755 tools/runtime/preflight.sh create mode 100755 tools/runtime/with-native-runtime-lock.py create mode 100644 tools/test/create-broker-release-fixture.py create mode 100644 tools/test/create-liboliphaunt-release-fixture.py create mode 100644 tools/test/moon.yml create mode 100644 tools/test/release_fixture_utils.py create mode 100755 tools/test/run-js-tests.mjs rename {xtask => tools/xtask}/Cargo.toml (52%) create mode 100644 tools/xtask/moon.yml create mode 100644 tools/xtask/src/aot_serializer.rs create mode 100644 tools/xtask/src/asset_checks.rs create mode 100644 tools/xtask/src/asset_io.rs create mode 100644 tools/xtask/src/asset_manifest.rs create mode 100644 tools/xtask/src/asset_pipeline.rs rename {xtask => tools/xtask}/src/extension_catalog.rs (56%) create mode 100644 tools/xtask/src/fs_utils.rs create mode 100644 tools/xtask/src/main.rs create mode 100644 tools/xtask/src/postgres_guard.rs create mode 100644 tools/xtask/src/release_workspace.rs create mode 100644 tools/xtask/src/source_spine.rs create mode 100644 tools/xtask/src/template_runner.rs delete mode 100644 xtask/src/main.rs diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..e7fadacc --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,22 @@ +[profile.default] +retries = 0 +fail-fast = false + +[profile.ci] +retries = 0 +fail-fast = false +status-level = "fail" +final-status-level = "slow" + +[profile.ci.junit] +path = "junit.xml" + +[profile.slow] +retries = 0 +fail-fast = false +slow-timeout = { period = "120s", terminate-after = 2 } + +[profile.release] +retries = 0 +fail-fast = false +slow-timeout = { period = "300s", terminate-after = 2 } diff --git a/.gitattributes b/.gitattributes index 43b8b9ae..45fc38d3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ -spikes/wasix-postgres-build/patches/*.patch whitespace=-blank-at-eol,-space-before-tab +*.patch text eol=lf whitespace=-blank-at-eol,-space-before-tab +*.diff text eol=lf whitespace=-blank-at-eol,-space-before-tab +src/**/patches/series text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 42c43678..cc505cf8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,8 +1,25 @@ name: Bug report -description: Report incorrect behavior in pglite-oxide. +description: Report incorrect Oliphaunt behavior in a product, SDK, runtime, or tool. title: "bug: " labels: ["bug"] body: + - type: dropdown + id: product + attributes: + label: Product area + description: Which product or surface is affected? + options: + - liboliphaunt + - Rust SDK + - Swift SDK + - Kotlin SDK + - React Native SDK + - TypeScript SDK + - WASM/WASIX runtime + - Repo tooling / CI / release + - Documentation + validations: + required: true - type: textarea id: summary attributes: @@ -14,14 +31,13 @@ body: id: repro attributes: label: Reproduction - description: Minimal Rust code or commands that reproduce the issue. - render: rust + description: Minimal code, commands, SQL, or app steps that reproduce the issue. validations: required: true - type: input id: versions attributes: label: Versions - description: pglite-oxide, Rust, OS, and architecture. + description: Oliphaunt package versions, platform versions, OS, architecture, and relevant toolchain versions. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 22162a28..e98f2e36 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -3,6 +3,23 @@ description: Suggest a focused API, runtime, or packaging improvement. title: "feat: " labels: ["enhancement"] body: + - type: dropdown + id: product + attributes: + label: Product area + description: Which product or surface would this improve? + options: + - liboliphaunt + - Rust SDK + - Swift SDK + - Kotlin SDK + - React Native SDK + - TypeScript SDK + - WASM/WASIX runtime + - Repo tooling / CI / release + - Documentation + validations: + required: true - type: textarea id: use_case attributes: diff --git a/.github/actions/collect-ci-summary/action.yml b/.github/actions/collect-ci-summary/action.yml new file mode 100644 index 00000000..55b5bc36 --- /dev/null +++ b/.github/actions/collect-ci-summary/action.yml @@ -0,0 +1,16 @@ +name: Collect CI summary +description: Append a small Moon/release summary to the GitHub step summary. + +runs: + using: composite + steps: + - name: Write CI summary + shell: bash + run: | + { + echo "## Oliphaunt CI" + echo + echo "- Moon projects: \`moon query projects\`" + echo "- Moon tasks: \`moon query tasks\`" + echo "- Release plan: \`tools/release/release.py plan --from-product-tags --head-ref HEAD\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/setup-android/action.yml b/.github/actions/setup-android/action.yml new file mode 100644 index 00000000..b2d61d6e --- /dev/null +++ b/.github/actions/setup-android/action.yml @@ -0,0 +1,106 @@ +name: Set up Android +description: Set up Java and expose Android SDK paths for Gradle/Expo jobs. + +inputs: + ndk-version: + description: Android NDK side-by-side version required by native SDK builds. + required: false + default: "27.0.12077973" + cmake-version: + description: Android CMake version required by native SDK builds. + required: false + default: "3.22.1" + compile-sdk: + description: Android platform API level used by SDK checks. + required: false + default: "36" + native-ccache: + description: Whether to install and cache ccache for native Android C/C++ builds. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Set up Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 + with: + distribution: temurin + java-version: "17" + cache: gradle + cache-dependency-path: | + src/sdks/kotlin/**/*.gradle* + src/sdks/kotlin/**/gradle-wrapper.properties + src/sdks/kotlin/**/libs.versions.toml + src/sdks/react-native/examples/expo/package.json + src/sdks/react-native/package.json + pnpm-lock.yaml + + - name: Restore native Android ccache + if: ${{ inputs.native-ccache == 'true' }} + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: ~/.cache/oliphaunt-ccache/android + key: android-ccache-${{ runner.os }}-${{ runner.arch }}-${{ inputs.ndk-version }}-${{ hashFiles('src/postgres/versions/18/**', 'src/sources/third-party/shared/**', 'src/sources/third-party/native/**', 'src/shared/extension-runtime-contract/**', 'src/runtimes/liboliphaunt/native/**') }} + restore-keys: | + android-ccache-${{ runner.os }}-${{ runner.arch }}-${{ inputs.ndk-version }}- + + - name: Set up native Android ccache + if: ${{ inputs.native-ccache == 'true' }} + shell: bash + run: | # zizmor: ignore[github-env] ccache environment values are fixed runner-owned paths and constants. + if ! command -v ccache >/dev/null 2>&1; then + case "$RUNNER_OS" in + Linux) + .github/scripts/prepare-linux-apt.sh + sudo apt-get update + sudo apt-get install -y ccache + ;; + macOS) + brew install ccache + ;; + *) + echo "ccache is not provisioned for RUNNER_OS=$RUNNER_OS" >&2 + exit 1 + ;; + esac + fi + echo "CCACHE_DIR=$HOME/.cache/oliphaunt-ccache/android" >> "$GITHUB_ENV" + echo "CCACHE_BASEDIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV" + echo "CCACHE_NOHASHDIR=true" >> "$GITHUB_ENV" + echo "CCACHE_COMPILERCHECK=content" >> "$GITHUB_ENV" + mkdir -p "$HOME/.cache/oliphaunt-ccache/android" + ccache --set-config=max_size=2G + ccache --zero-stats + + - name: Configure Android SDK + shell: bash + env: + NDK_VERSION: ${{ inputs.ndk-version }} + CMAKE_VERSION: ${{ inputs.cmake-version }} + COMPILE_SDK: ${{ inputs.compile-sdk }} + run: | # zizmor: ignore[github-env] Android SDK paths are runner-owned or HOME-scoped and validated before export. + if [[ -z "${ANDROID_HOME:-}" ]]; then + if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then + export ANDROID_HOME="${ANDROID_SDK_ROOT}" + elif [[ -d "$HOME/Library/Android/sdk" ]]; then + export ANDROID_HOME="$HOME/Library/Android/sdk" + elif [[ -d "$HOME/Android/Sdk" ]]; then + export ANDROID_HOME="$HOME/Android/Sdk" + else + export ANDROID_HOME="$HOME/android-sdk" + fi + fi + tools/dev/setup-android-sdk.sh \ + --sdk-root "$ANDROID_HOME" \ + --ndk-version "$NDK_VERSION" \ + --cmake-version "$CMAKE_VERSION" \ + --compile-sdk "$COMPILE_SDK" + + echo "ANDROID_HOME=${ANDROID_HOME}" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=${ANDROID_HOME}" >> "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION}" >> "$GITHUB_ENV" + echo "${ANDROID_HOME}/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + echo "${ANDROID_HOME}/platform-tools" >> "$GITHUB_PATH" + echo "ANDROID_HOME=${ANDROID_HOME}" + echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION}" diff --git a/.github/actions/setup-apple/action.yml b/.github/actions/setup-apple/action.yml new file mode 100644 index 00000000..3fc91b42 --- /dev/null +++ b/.github/actions/setup-apple/action.yml @@ -0,0 +1,12 @@ +name: Set up Apple +description: Report Xcode and Ruby tooling for Apple SDK jobs. + +runs: + using: composite + steps: + - name: Show Apple toolchain + shell: bash + run: | + xcodebuild -version + ruby --version + gem --version diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml new file mode 100644 index 00000000..10c88629 --- /dev/null +++ b/.github/actions/setup-bun/action.yml @@ -0,0 +1,20 @@ +name: Set up Bun +description: Install the pinned Bun toolchain for TypeScript npm consumer checks. + +inputs: + bun-version: + description: Bun version. + required: false + default: "1.3.14" + +runs: + using: composite + steps: + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: ${{ inputs.bun-version }} + + - name: Print Bun version + shell: bash + run: bun --version diff --git a/.github/actions/setup-deno/action.yml b/.github/actions/setup-deno/action.yml new file mode 100644 index 00000000..8bd3e97c --- /dev/null +++ b/.github/actions/setup-deno/action.yml @@ -0,0 +1,123 @@ +name: Set up Deno +description: Install the pinned Deno toolchain for TypeScript/JSR release checks. + +inputs: + deno-version: + description: Deno version. + required: false + default: "v2.8.1" + +runs: + using: composite + steps: + - name: Resolve Deno binary cache path + id: deno-cache + shell: bash + env: + DENO_VERSION_INPUT: ${{ inputs.deno-version }} + run: | + set -euo pipefail + version="$DENO_VERSION_INPUT" + version="${version#v}" + cache_dir="${RUNNER_TEMP}/oliphaunt-deno-${version}-${RUNNER_OS}-${RUNNER_ARCH}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "cache-dir=${cache_dir}" >> "$GITHUB_OUTPUT" + echo "binary=${cache_dir}/deno" >> "$GITHUB_OUTPUT" + + - name: Restore Deno binary cache + id: deno-binary-cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: ${{ steps.deno-cache.outputs.cache-dir }} + key: deno-binary-${{ runner.os }}-${{ runner.arch }}-${{ inputs.deno-version }} + + - name: Use cached Deno binary + id: cached-deno + shell: bash + env: + DENO_BINARY: ${{ steps.deno-cache.outputs.binary }} + DENO_CACHE_DIR: ${{ steps.deno-cache.outputs.cache-dir }} + run: | # zizmor: ignore[github-env] path is derived from RUNNER_TEMP plus pinned Deno version and is validated before export. + set -euo pipefail + binary="$DENO_BINARY" + if [ -x "$binary" ]; then + echo "$DENO_CACHE_DIR" >> "$GITHUB_PATH" + echo "hit=true" >> "$GITHUB_OUTPUT" + "$binary" --version + else + echo "hit=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Deno + id: setup-deno + if: steps.cached-deno.outputs.hit != 'true' + continue-on-error: true + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 + with: + deno-version: ${{ inputs.deno-version }} + cache: true + + - name: Cache Deno binary from setup action + if: steps.cached-deno.outputs.hit != 'true' && steps.setup-deno.outcome == 'success' + shell: bash + env: + DENO_BINARY: ${{ steps.deno-cache.outputs.binary }} + DENO_CACHE_DIR: ${{ steps.deno-cache.outputs.cache-dir }} + run: | + set -euo pipefail + mkdir -p "$DENO_CACHE_DIR" + cp "$(command -v deno)" "$DENO_BINARY" + chmod +x "$DENO_BINARY" + + - name: Install Deno with release download retries + if: steps.cached-deno.outputs.hit != 'true' && steps.setup-deno.outcome != 'success' + shell: bash + env: + DENO_BINARY: ${{ steps.deno-cache.outputs.binary }} + DENO_CACHE_DIR: ${{ steps.deno-cache.outputs.cache-dir }} + DENO_VERSION_RESOLVED: ${{ steps.deno-cache.outputs.version }} + run: | # zizmor: ignore[github-env] path is derived from RUNNER_TEMP plus pinned Deno version and is validated before export. + set -euo pipefail + version="$DENO_VERSION_RESOLVED" + case "${RUNNER_OS}-${RUNNER_ARCH}" in + Linux-X64) + artifact="deno-x86_64-unknown-linux-gnu.zip" + ;; + Linux-ARM64) + artifact="deno-aarch64-unknown-linux-gnu.zip" + ;; + macOS-X64) + artifact="deno-x86_64-apple-darwin.zip" + ;; + macOS-ARM64) + artifact="deno-aarch64-apple-darwin.zip" + ;; + *) + echo "unsupported Deno runner platform: ${RUNNER_OS}-${RUNNER_ARCH}" >&2 + exit 1 + ;; + esac + url="https://github.com/denoland/deno/releases/download/v${version}/${artifact}" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fL \ + --retry 8 \ + --retry-all-errors \ + --retry-delay 5 \ + --connect-timeout 20 \ + --output "$tmp/deno.zip" \ + "$url" + python3 - "$tmp/deno.zip" "$DENO_CACHE_DIR" <<'PY' + import sys + import zipfile + + archive, output = sys.argv[1], sys.argv[2] + with zipfile.ZipFile(archive) as zip_file: + zip_file.extractall(output) + PY + chmod +x "$DENO_BINARY" + echo "$DENO_CACHE_DIR" >> "$GITHUB_PATH" + + - name: Print Deno version + shell: bash + run: deno --version diff --git a/.github/actions/setup-maestro/action.yml b/.github/actions/setup-maestro/action.yml new file mode 100644 index 00000000..01676f59 --- /dev/null +++ b/.github/actions/setup-maestro/action.yml @@ -0,0 +1,15 @@ +name: Set up Maestro +description: Install the pinned open-source Maestro CLI for local emulator/simulator E2E. + +runs: + using: composite + steps: + - name: Set up Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 + with: + distribution: temurin + java-version: "17" + + - name: Install Maestro CLI + shell: bash + run: tools/dev/setup-maestro.sh diff --git a/.github/actions/setup-moon/action.yml b/.github/actions/setup-moon/action.yml new file mode 100644 index 00000000..f24acb3f --- /dev/null +++ b/.github/actions/setup-moon/action.yml @@ -0,0 +1,53 @@ +name: Set up Moon +description: Install the pinned proto/Moon toolchain and optionally hydrate JavaScript workspace dependencies. + +inputs: + install-workspace: + description: Install pnpm workspace dependencies for JavaScript-family tasks. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Set up proto and Moon + uses: moonrepo/setup-toolchain@261c62cb5b0f580c7be7c8cd0f023a2e96756095 + with: + auto-install: true + + - name: Restore Moon plugin cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: ~/.moon/plugins + key: moon-plugins-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.moon/toolchains.yml', '.moon/workspace.yml', '.prototools') }} + restore-keys: | + moon-plugins-${{ runner.os }}-${{ runner.arch }}- + + - name: Verify toolchain + shell: bash + run: | + moon --version + node --version + pnpm --version + + - name: Install workspace dependencies + if: ${{ inputs.install-workspace == 'true' }} + shell: bash + run: pnpm install --frozen-lockfile + + - name: Hydrate Moon plugins + shell: bash + run: | + for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do + if moon query projects >/dev/null; then + exit 0 + fi + if [ "$attempt" -lt 6 ]; then + sleep_seconds=$((attempt * 10)) + else + sleep_seconds=60 + fi + echo "Moon plugin hydration failed on attempt $attempt; retrying in ${sleep_seconds}s" >&2 + sleep "$sleep_seconds" + done + moon query projects >/dev/null diff --git a/.github/actions/setup-msvc/action.yml b/.github/actions/setup-msvc/action.yml new file mode 100644 index 00000000..424b28fa --- /dev/null +++ b/.github/actions/setup-msvc/action.yml @@ -0,0 +1,33 @@ +name: Set up MSVC +description: Configure the MSVC developer environment and force Rust to use the MSVC linker under Git Bash. + +runs: + using: composite + steps: + - name: Configure MSVC developer command prompt + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 + with: + arch: x64 + + - name: Configure Rust MSVC linker + shell: pwsh + run: | # zizmor: ignore[github-env] MSVC linker path is derived from runner-owned VCToolsInstallDir and rejects Git link.exe before export. + $ErrorActionPreference = "Stop" + $link = $null + if ($env:VCToolsInstallDir) { + $candidate = Join-Path $env:VCToolsInstallDir "bin\HostX64\x64\link.exe" + if (Test-Path $candidate) { + $link = $candidate + } + } + if (-not $link) { + $link = (Get-Command link.exe -CommandType Application | Select-Object -First 1).Source + } + if (-not $link) { + throw "MSVC link.exe was not found after msvc-dev-cmd setup" + } + if ($link -match "\\Git\\usr\\bin\\link\.exe$") { + throw "Git link.exe shadowed MSVC link.exe: $link" + } + "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER=$link" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "Configured Rust MSVC linker: $link" diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml new file mode 100644 index 00000000..37cb85f8 --- /dev/null +++ b/.github/actions/setup-node-pnpm/action.yml @@ -0,0 +1,43 @@ +name: Set up Node and pnpm +description: Install the pinned Node.js and pnpm toolchain for Oliphaunt. + +inputs: + node-version: + description: Node.js version. + required: false + default: "22.22.3" + pnpm-version: + description: pnpm version. + required: false + default: "11.5.0" + +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + with: + node-version: ${{ inputs.node-version }} + + - name: Enable pnpm + shell: bash + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} + run: | + corepack enable + corepack prepare "pnpm@${PNPM_VERSION}" --activate + node --version + pnpm --version + + - name: Resolve pnpm store + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm store + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-pnpm-${{ inputs.pnpm-version }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-pnpm-${{ inputs.pnpm-version }}- diff --git a/.github/actions/setup-rust-tools/action.yml b/.github/actions/setup-rust-tools/action.yml index 51c709c3..9285e892 100644 --- a/.github/actions/setup-rust-tools/action.yml +++ b/.github/actions/setup-rust-tools/action.yml @@ -5,7 +5,7 @@ inputs: toolchain: description: Rust toolchain version. required: false - default: "1.92" + default: "1.93" components: description: Comma-separated Rust components. required: false @@ -43,6 +43,17 @@ runs: workspaces: ${{ inputs.cache-workspaces }} save-if: ${{ inputs.cache-save-if }} + - name: Install ripgrep + shell: bash + run: | + set -euo pipefail + if command -v rg >/dev/null 2>&1; then + rg --version + exit 0 + fi + cargo install ripgrep --version 15.1.0 --locked + rg --version + - name: Install Cargo tools if: ${{ inputs.tools != '' }} uses: taiki-e/install-action@1f2425cdb59f8fffb99ee16a5968edf6f57a2b93 diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 00000000..5c6b616f --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,31 @@ +name: Set up Rust +description: Install the pinned Rust toolchain and optional Rust tools. + +inputs: + components: + description: Comma-separated Rust components. + required: false + default: "" + tools: + description: Comma-separated Rust tools. + required: false + default: "" + cache-save-if: + description: Expression string passed to Swatinem/rust-cache save-if. + required: false + default: "false" + cache: + description: Whether to enable the Cargo cache. + required: false + default: "true" + +runs: + using: composite + steps: + - name: Set up Rust tooling + uses: ./.github/actions/setup-rust-tools + with: + components: ${{ inputs.components }} + cache: ${{ inputs.cache }} + cache-save-if: ${{ inputs.cache-save-if }} + tools: ${{ inputs.tools }} diff --git a/.github/actions/setup-wasmer-llvm/action.yml b/.github/actions/setup-wasmer-llvm/action.yml index 8d5fb2d0..9796e9fa 100644 --- a/.github/actions/setup-wasmer-llvm/action.yml +++ b/.github/actions/setup-wasmer-llvm/action.yml @@ -67,7 +67,13 @@ runs: archive="$runner_temp/llvm-${LLVM_VERSION}.tar.xz" rm -rf "$install_dir" mkdir -p "$install_dir" - curl -L --fail --retry 3 --output "$archive" "$LLVM_URL" + curl -L --fail \ + --retry 8 \ + --retry-all-errors \ + --retry-delay 5 \ + --connect-timeout 20 \ + --output "$archive" \ + "$LLVM_URL" tar -xJf "$archive" -C "$install_dir" fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 162d77a4..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: 2 -updates: - - package-ecosystem: cargo - directory: / - schedule: - interval: weekly - groups: - cargo-patch: - update-types: [patch] - cargo-minor: - update-types: [minor] - cargo-major: - update-types: [major] - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - groups: - actions: - patterns: ["*"] diff --git a/.github/moon.yml b/.github/moon.yml new file mode 100644 index 00000000..ac5505b7 --- /dev/null +++ b/.github/moon.yml @@ -0,0 +1,41 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "ci-workflows" +language: "yaml" +layer: "configuration" +stack: "infrastructure" +tags: ["ci", "github-actions", "workflows"] + +project: + title: "GitHub Actions" + description: "GitHub workflow, action, and repository automation checks." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash tools/policy/check-workflows.sh" + inputs: + - "/.github/actions/**/*" + - "/.github/workflows/**/*" + - "/.github/zizmor.yml" + - "/tools/policy/check-workflows.sh" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "bash tools/policy/check-workflows.sh" + inputs: + - "/.github/actions/**/*" + - "/.github/workflows/**/*" + - "/.github/zizmor.yml" + - "/tools/policy/check-workflows.sh" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 82e297ff..1c5842d8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,13 +4,13 @@ - [ ] Package/API/runtime change: PR title uses `feat:`, `fix:`, `perf:`, `refactor:`, `revert:`, or a breaking `!`. - [ ] Docs/CI/repository-only change: no release intended. -- [ ] Asset/source-spine change: source pins/fingerprints are current and the Assets workflow will generate/test release artifacts. +- [ ] Source/input/runtime asset change: source pins, generated metadata, and release metadata affectedness are current. ## Verification -- [ ] `scripts/validate.sh repo` -- [ ] `scripts/validate.sh artifacts` -- [ ] `scripts/validate.sh lint` -- [ ] `scripts/validate.sh test` -- [ ] `scripts/validate.sh package` when published package contents changed -- [ ] `cargo deny check` +- [ ] `pnpm doctor` +- [ ] `pnpm fmt:check` +- [ ] `pnpm check` +- [ ] `pnpm test` +- [ ] `pnpm release-check` +- [ ] Product-specific smoke/package/perf checks when product behavior or artifacts changed. diff --git a/.github/scripts/check-release-changelog.sh b/.github/scripts/check-release-changelog.sh deleted file mode 100755 index 33b9a50b..00000000 --- a/.github/scripts/check-release-changelog.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "$root" - -package_version="$( - awk ' - /^\[package\][[:space:]]*$/ { - in_package = 1 - next - } - /^\[/ && in_package { - exit - } - in_package && $0 ~ /^[[:space:]]*version[[:space:]]*=/ { - line = $0 - sub(/^[^=]*=[[:space:]]*"/, "", line) - sub(/".*$/, "", line) - print line - exit - } - ' Cargo.toml -)" - -if [[ -z "${package_version}" ]]; then - echo "could not read package version from Cargo.toml" >&2 - exit 1 -fi - -is_version_heading_awk=' - function is_version_heading(line) { - return index(line, "## [" version "] - ") == 1 || - (index(line, "## [" version "](") == 1 && - line ~ /\) - [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) - } -' - -top_release_heading="$( - awk ' - /^## \[Unreleased\]/ { - seen_unreleased = 1 - next - } - seen_unreleased && /^## \[/ { - print - exit - } - ' CHANGELOG.md -)" - -if [[ -z "${top_release_heading}" ]]; then - echo "CHANGELOG.md does not contain a release section after [Unreleased]" >&2 - exit 1 -fi - -if ! awk -v version="${package_version}" -v heading="${top_release_heading}" "${is_version_heading_awk}"' - BEGIN { - exit is_version_heading(heading) ? 0 : 1 - } -'; then - cat >&2 <&2 <&2 </dev/null)"; then + return 0 + fi + printf '%s\n' "${manifest}" | + python3 -c ' +import json +import sys + +try: + data = json.load(sys.stdin) +except json.JSONDecodeError: + sys.exit(0) +for path, version in sorted(data.items()): + print(f"{path}={version}") +' +} + +base_release_manifest_versions="$(release_manifest_versions_from_ref "${base_ref}")" +head_release_manifest_versions="$(release_manifest_versions_from_ref "${head_ref}")" -if [[ -z "${base_versions}" || -z "${head_versions}" ]]; then - echo "could not read package versions from Cargo.toml files" >&2 +if [[ -z "${base_versions}" || -z "${head_versions}" || -z "${head_release_manifest_versions}" ]]; then + echo "could not read package versions or release-please manifest versions" >&2 exit 1 fi @@ -73,17 +93,28 @@ changed_existing_versions="$( <(printf '%s\n' "${head_versions}" | sed 's/=/\t/' | sort -t $'\t' -k1,1) | awk -F '\t' '$2 != $3 { print $1 "=" $2 " -> " $3 }' )" +if [[ -n "${base_release_manifest_versions}" ]]; then + changed_existing_release_manifest_versions="$( + join -t $'\t' \ + <(printf '%s\n' "${base_release_manifest_versions}" | sed 's/=/\t/' | sort -t $'\t' -k1,1) \ + <(printf '%s\n' "${head_release_manifest_versions}" | sed 's/=/\t/' | sort -t $'\t' -k1,1) | + awk -F '\t' '$2 != $3 { print $1 "=" $2 " -> " $3 }' + )" +else + changed_existing_release_manifest_versions="" +fi -if [[ -n "${changed_existing_versions}" && "${is_release_pr}" != true ]]; then +if [[ -n "${changed_existing_versions}${changed_existing_release_manifest_versions}" && "${is_release_pr}" != true ]]; then cat >&2 <&2 <&2 +printf '%s\n' "${release_products}" | sed 's/^/ /' >&2 exit 1 diff --git a/.github/scripts/download-aot-artifacts.sh b/.github/scripts/download-aot-artifacts.sh deleted file mode 100755 index a1d1e23f..00000000 --- a/.github/scripts/download-aot-artifacts.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" - -cargo run -p xtask -- assets download --latest-compatible --all-targets diff --git a/.github/scripts/download-build-artifacts.sh b/.github/scripts/download-build-artifacts.sh new file mode 100755 index 00000000..84365aa5 --- /dev/null +++ b/.github/scripts/download-build-artifacts.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +workflow="${1:?usage: download-build-artifacts.sh [--run-id ] [--job ] --artifact [--artifact ...]}" +sha="${2:?usage: download-build-artifacts.sh [--run-id ] [--job ] --artifact [--artifact ...]}" +destination="${3:?usage: download-build-artifacts.sh [--run-id ] [--job ] --artifact [--artifact ...]}" +shift 3 + +artifacts=() +required_job="" +selected_run_id="" +while [[ $# -gt 0 ]]; do + case "$1" in + --run-id) + selected_run_id="${2:?--run-id requires a run id}" + shift 2 + ;; + --job) + required_job="${2:?--job requires a name}" + shift 2 + ;; + --artifact) + artifacts+=("${2:?--artifact requires a name}") + shift 2 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [[ "${#artifacts[@]}" -eq 0 ]]; then + echo "at least one --artifact is required" >&2 + exit 2 +fi + +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${GH_REPO:?GH_REPO is required}" + +artifact_present() { + local run_id="$1" + local artifact="$2" + gh api "repos/$GH_REPO/actions/runs/$run_id/artifacts" \ + --paginate \ + --jq '.artifacts[].name' | + grep -Fxq "$artifact" +} + +required_job_success() { + local run_id="$1" + if [[ -z "$required_job" ]]; then + return 0 + fi + + local conclusion + conclusion="$( + GH_RUN_JSON="$(gh run view "$run_id" --json jobs)" REQUIRED_JOB="$required_job" python3 -c 'import json, os +required = os.environ["REQUIRED_JOB"] +data = json.loads(os.environ["GH_RUN_JSON"]) +print(next((job.get("conclusion") or "" for job in data.get("jobs", []) if isinstance(job, dict) and job.get("name") == required), ""))' + )" || return 1 + [[ "$conclusion" == "success" ]] +} + +run_id="$selected_run_id" +if [[ -n "$run_id" ]]; then + if ! required_job_success "$run_id"; then + echo "$workflow run $run_id does not satisfy required job ${required_job:-}" >&2 + exit 1 + fi + for artifact in "${artifacts[@]}"; do + if ! artifact_present "$run_id" "$artifact"; then + echo "$workflow run $run_id is missing required artifact $artifact" >&2 + exit 1 + fi + done +else + while IFS= read -r candidate; do + [[ -n "$candidate" ]] || continue + if ! required_job_success "$candidate"; then + continue + fi + missing=0 + for artifact in "${artifacts[@]}"; do + if ! artifact_present "$candidate" "$artifact"; then + missing=1 + break + fi + done + if [[ "$missing" -eq 0 ]]; then + run_id="$candidate" + break + fi + done < <( + if [[ -n "$required_job" ]]; then + gh run list \ + --workflow "$workflow" \ + --commit "$sha" \ + --limit 20 \ + --json databaseId,status,conclusion,event,createdAt \ + --jq '.[].databaseId' + else + gh run list \ + --workflow "$workflow" \ + --commit "$sha" \ + --limit 20 \ + --json databaseId,status,conclusion,event,createdAt \ + --jq '.[] | select(.status == "completed" and .conclusion == "success") | .databaseId' + fi + ) +fi + +if [[ -z "$run_id" ]]; then + echo "no $workflow workflow run found for $sha with required job/artifacts: ${required_job:-} / ${artifacts[*]}" >&2 + exit 1 +fi + +mkdir -p "$destination" +for artifact in "${artifacts[@]}"; do + echo "Downloading $workflow artifact $artifact from run $run_id" + gh run download "$run_id" \ + --name "$artifact" \ + --dir "$destination" +done diff --git a/.github/scripts/download-wasix-runtime-build-artifacts.sh b/.github/scripts/download-wasix-runtime-build-artifacts.sh new file mode 100755 index 00000000..26fc4259 --- /dev/null +++ b/.github/scripts/download-wasix-runtime-build-artifacts.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" +: "${GITHUB_SHA:?GITHUB_SHA is required}" + +# Installs the portable and AOT WASIX runtime outputs from the selected same-SHA +# Builds workflow whose artifact builder gate passed. This is a release artifact +# handoff, not a release-time runtime rebuild. +if [[ -n "${BUILDS_RUN_ID:-}" ]]; then + cargo run -p xtask -- assets download --run-id "$BUILDS_RUN_ID" --required-job artifact-builders --all-targets +else + cargo run -p xtask -- assets download --sha "$GITHUB_SHA" --required-job artifact-builders --all-targets +fi diff --git a/.github/scripts/plan-affected.py b/.github/scripts/plan-affected.py new file mode 100644 index 00000000..6e821948 --- /dev/null +++ b/.github/scripts/plan-affected.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""GitHub Actions wrapper for the shared Moon affected CI planner.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools" / "graph")) + +import ci_plan # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(ci_plan.emit_github_outputs()) diff --git a/.github/scripts/prepare-linux-apt.sh b/.github/scripts/prepare-linux-apt.sh new file mode 100755 index 00000000..ab1e04e4 --- /dev/null +++ b/.github/scripts/prepare-linux-apt.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(uname -s)" != "Linux" ]; then + exit 0 +fi + +disabled_any=0 +for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + [ -f "$file" ] || continue + if ! grep -q "packages.microsoft.com" "$file"; then + continue + fi + + disabled_any=1 + if [ "$file" = "/etc/apt/sources.list" ]; then + sudo sed -i.bak '/packages\.microsoft\.com/s/^/# disabled by oliphaunt CI: /' "$file" + else + sudo mv "$file" "$file.disabled" + fi +done + +if [ "$disabled_any" = "1" ]; then + echo "Disabled preinstalled packages.microsoft.com apt sources before apt-get update" +fi diff --git a/.github/scripts/require-workflow-success.sh b/.github/scripts/require-workflow-success.sh index be359444..7f621efd 100644 --- a/.github/scripts/require-workflow-success.sh +++ b/.github/scripts/require-workflow-success.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -workflow="${1:?usage: require-workflow-success.sh [timeout-seconds] [--artifact ...]}" -sha="${2:?usage: require-workflow-success.sh [timeout-seconds] [--artifact ...]}" +workflow="${1:?usage: require-workflow-success.sh [timeout-seconds] [--job ] [--artifact ...]}" +sha="${2:?usage: require-workflow-success.sh [timeout-seconds] [--job ] [--artifact ...]}" timeout="${3:-7200}" if [[ $# -ge 3 ]]; then shift 3 @@ -11,8 +11,18 @@ else fi required_artifacts=() +required_job="" +expected_run_id="" while [[ $# -gt 0 ]]; do case "$1" in + --run-id) + expected_run_id="${2:?--run-id requires a run id}" + shift 2 + ;; + --job) + required_job="${2:?--job requires a name}" + shift 2 + ;; --artifact) required_artifacts+=("${2:?--artifact requires a name}") shift 2 @@ -27,6 +37,14 @@ done : "${GH_TOKEN:?GH_TOKEN is required}" : "${GH_REPO:?GH_REPO is required}" +emit_run_id() { + local run_id="$1" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + fi + echo "selected $workflow run $run_id" +} + required_artifacts_present() { run_id="$1" if [[ "${#required_artifacts[@]}" -eq 0 ]]; then @@ -43,6 +61,30 @@ required_artifacts_present() { done } +required_job_success() { + run_id="$1" + if [[ -z "$required_job" ]]; then + return 0 + fi + + conclusion="$( + GH_RUN_JSON="$(gh run view "$run_id" --json jobs)" REQUIRED_JOB="$required_job" python3 -c 'import json, os +required = os.environ["REQUIRED_JOB"] +data = json.loads(os.environ["GH_RUN_JSON"]) +print(next((job.get("conclusion") or "" for job in data.get("jobs", []) if isinstance(job, dict) and job.get("name") == required), ""))' + )" || return 1 + [[ "$conclusion" == "success" ]] +} + +if [[ -n "$expected_run_id" ]]; then + if required_job_success "$expected_run_id" && required_artifacts_present "$expected_run_id"; then + emit_run_id "$expected_run_id" + exit 0 + fi + echo "$workflow run $expected_run_id does not satisfy the required job/artifact gate" >&2 + exit 1 +fi + deadline=$((SECONDS + timeout)) while true; do runs="$(gh run list \ @@ -53,15 +95,21 @@ while true; do --jq '.[] | [.databaseId, .status, (.conclusion // ""), .url, .event] | @tsv')" if [ -n "$runs" ]; then echo "$runs" - for run_id in $(echo "$runs" | awk -F '\t' '$2 == "completed" && $3 == "success" { print $1 }'); do - if required_artifacts_present "$run_id"; then + if [[ -n "$required_job" ]]; then + candidate_run_ids="$(echo "$runs" | awk -F '\t' '{ print $1 }')" + else + candidate_run_ids="$(echo "$runs" | awk -F '\t' '$2 == "completed" && $3 == "success" { print $1 }')" + fi + for run_id in $candidate_run_ids; do + if required_job_success "$run_id" && required_artifacts_present "$run_id"; then + emit_run_id "$run_id" exit 0 fi - echo "$workflow run $run_id is successful but is missing one or more required artifacts" + echo "$workflow run $run_id does not satisfy the required job/artifact gate" done if echo "$runs" | awk -F '\t' '$2 != "completed" { active=1 } END { exit active ? 0 : 1 }'; then echo "$workflow is still running for $sha" - elif echo "$runs" | awk -F '\t' '$2 == "completed" && $3 != "success" && $5 != "workflow_dispatch" { failed=1 } END { exit failed ? 0 : 1 }'; then + elif [[ -z "$required_job" ]] && echo "$runs" | awk -F '\t' '$2 == "completed" && $3 != "success" && $5 != "workflow_dispatch" { failed=1 } END { exit failed ? 0 : 1 }'; then echo "$workflow failed for $sha" >&2 exit 1 else diff --git a/.github/scripts/run-moon-targets.sh b/.github/scripts/run-moon-targets.sh new file mode 100755 index 00000000..c06669b7 --- /dev/null +++ b/.github/scripts/run-moon-targets.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +unset MOON_BASE +unset MOON_HEAD + +moon_bin="${MOON_BIN:-}" +if [[ -z "$moon_bin" ]]; then + for candidate in "$HOME/.proto/shims/moon" "$HOME/.proto/bin/moon"; do + if [[ -x "$candidate" ]]; then + moon_bin="$candidate" + break + fi + done +fi +if [[ -z "$moon_bin" ]]; then + moon_bin="moon" +fi + +exec "$moon_bin" run "$@" diff --git a/.github/scripts/run-planned-moon-job.sh b/.github/scripts/run-planned-moon-job.sh new file mode 100755 index 00000000..f35f8866 --- /dev/null +++ b/.github/scripts/run-planned-moon-job.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +job="${1:-}" +if [[ -z "$job" ]]; then + echo "usage: .github/scripts/run-planned-moon-job.sh " >&2 + exit 2 +fi + +job_targets_json="${OLIPHAUNT_CI_JOB_TARGETS_JSON:-}" +if [[ -z "$job_targets_json" && -f target/graph/ci-plan.json ]]; then + job_targets_json="$(python3 -c 'import json; print(json.dumps(json.load(open("target/graph/ci-plan.json")).get("job_targets", {})))')" +fi +if [[ -z "$job_targets_json" ]]; then + echo "missing OLIPHAUNT_CI_JOB_TARGETS_JSON or target/graph/ci-plan.json" >&2 + exit 2 +fi + +targets_file="$(mktemp)" +trap 'rm -f "$targets_file"' EXIT + +OLIPHAUNT_CI_JOB_TARGETS_JSON="$job_targets_json" python3 - "$job" >"$targets_file" <<'PY' +import json +import os +import sys + +job = sys.argv[1] +try: + mapping = json.loads(os.environ["OLIPHAUNT_CI_JOB_TARGETS_JSON"]) +except json.JSONDecodeError as error: + raise SystemExit(f"invalid CI job target JSON: {error}") +targets = mapping.get(job, []) +if not isinstance(targets, list) or not all(isinstance(target, str) for target in targets): + raise SystemExit(f"CI job {job!r} has invalid target list") +for target in targets: + print(target) +PY + +targets=() +while IFS= read -r target; do + target="${target%$'\r'}" + targets+=("$target") +done <"$targets_file" + +if [[ "${#targets[@]}" -eq 0 ]]; then + echo "CI job '$job' has no planned Moon targets" >&2 + exit 2 +fi + +moon_args=() +if [[ -n "${OLIPHAUNT_MOON_UPSTREAM:-}" ]]; then + moon_args+=(--upstream "$OLIPHAUNT_MOON_UPSTREAM") +fi + +exec .github/scripts/run-moon-targets.sh "${moon_args[@]}" "${targets[@]}" diff --git a/.github/scripts/setup-native-build-tools.sh b/.github/scripts/setup-native-build-tools.sh new file mode 100755 index 00000000..838bbc86 --- /dev/null +++ b/.github/scripts/setup-native-build-tools.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +ccache_max_size="${1:-2G}" +enable_ccache=1 + +install_macos_tools() { + local missing_packages=() + + require_brew_tool() { + local command_name="$1" + local package_name="$2" + if ! command -v "$command_name" >/dev/null 2>&1; then + missing_packages+=("$package_name") + fi + } + + require_brew_tool ccache ccache + require_brew_tool autoconf autoconf + require_brew_tool aclocal automake + require_brew_tool glibtoolize libtool + + if ((${#missing_packages[@]} > 0)); then + HOMEBREW_NO_AUTO_UPDATE=1 brew install "${missing_packages[@]}" + fi +} + +install_linux_tools() { + .github/scripts/prepare-linux-apt.sh + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + ccache \ + cmake \ + curl \ + git \ + make \ + perl \ + pkg-config \ + ripgrep \ + rsync \ + sqlite3 \ + xz-utils +} + +install_windows_tools() { + python -m pip install --user meson==1.10.0 ninja==1.13.0 + if [ ! -x /c/Strawberry/perl/bin/perl.exe ]; then + choco install -y strawberryperl --no-progress --limit-output + fi + choco install -y winflexbison3 --no-progress --limit-output + local winflex_dir="" + local candidate + for candidate in \ + /c/ProgramData/chocolatey/lib/winflexbison3/tools \ + /c/tools/winflexbison3; do + if [ -x "$candidate/win_flex.exe" ] && [ -x "$candidate/win_bison.exe" ]; then + winflex_dir="$candidate" + break + fi + done + if [ -z "$winflex_dir" ]; then + candidate="$(cmd.exe /C "where win_flex.exe" 2>/dev/null | tr -d '\r' | head -n 1 || true)" + if [ -n "$candidate" ] && command -v cygpath >/dev/null 2>&1; then + candidate="$(cygpath -u "$candidate")" + fi + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + winflex_dir="$(dirname "$candidate")" + fi + fi + if [ -z "$winflex_dir" ]; then + echo "setup-native-build-tools.sh: missing win_flex.exe from winflexbison3" >&2 + exit 1 + fi + export PATH="$winflex_dir:$PATH" + if [ -n "${GITHUB_PATH:-}" ]; then + if command -v cygpath >/dev/null 2>&1; then + cygpath -w "$winflex_dir" >>"$GITHUB_PATH" + else + printf '%s\n' "$winflex_dir" >>"$GITHUB_PATH" + fi + fi + enable_ccache=0 +} + +case "$(uname -s)" in + Darwin) + install_macos_tools + ;; + Linux) + install_linux_tools + ;; + MINGW* | MSYS* | CYGWIN*) + install_windows_tools + ;; + *) + ;; +esac + +if [ "$enable_ccache" = "1" ] && command -v ccache >/dev/null 2>&1; then + ccache --max-size="$ccache_max_size" + if [ "${OLIPHAUNT_CCACHE_ZERO_STATS:-0}" = "1" ]; then + ccache --zero-stats + fi +fi diff --git a/.github/workflows/assets.yml b/.github/workflows/assets.yml deleted file mode 100644 index 14a65ae8..00000000 --- a/.github/workflows/assets.yml +++ /dev/null @@ -1,225 +0,0 @@ -name: Assets -run-name: Assets / ${{ github.event_name == 'workflow_dispatch' && inputs.target || (github.event_name == 'pull_request' && format('PR {0}', github.event.pull_request.number) || github.ref_name) }} - -on: - pull_request: - paths: - - ".github/workflows/assets.yml" - - ".github/actions/setup-wasmer-llvm/**" - - "Cargo.lock" - - "Cargo.toml" - - "assets/**" - - "crates/aot/**" - - "crates/assets/**" - - "xtask/**" - push: - branches: [main] - paths: - - ".github/workflows/assets.yml" - - ".github/actions/setup-wasmer-llvm/**" - - "Cargo.lock" - - "Cargo.toml" - - "assets/**" - - "crates/aot/**" - - "crates/assets/**" - - "xtask/**" - workflow_dispatch: - inputs: - target: - description: Native AOT target to build - required: true - default: all - type: choice - options: - - all - - aarch64-apple-darwin - - x86_64-unknown-linux-gnu - - aarch64-unknown-linux-gnu - - x86_64-pc-windows-msvc - schedule: - - cron: "17 3 * * 1" - -permissions: - contents: read - -concurrency: - group: assets-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.target || 'all' }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - ASSET_PROFILE: release-o3 - RUST_CACHE_SAVE_IF: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - WASMER_LLVM_VERSION: "22.1" - WASMER_LLVM_LINUX_X64_URL: https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-amd64.tar.xz - -defaults: - run: - shell: bash - -jobs: - portable-wasix: - name: Build portable WASIX assets - runs-on: ubuntu-latest - timeout-minutes: 360 - permissions: - contents: read - actions: write - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - fetch-depth: 0 - persist-credentials: false - - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools - with: - cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - - name: Verify source-controlled asset inputs - run: cargo run -p xtask -- assets verify-committed - - - name: Fetch pinned asset sources - run: cargo run -p xtask -- assets fetch - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - - - name: Build WASIX builder image and save cache - if: ${{ github.ref == 'refs/heads/main' }} - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f - with: - context: assets/wasix-build/docker - file: assets/wasix-build/docker/Dockerfile - tags: pglite-oxide-wasix-build:ci - load: true - cache-from: type=gha,scope=wasix-builder - cache-to: type=gha,mode=max,scope=wasix-builder - - - name: Build WASIX builder image - if: ${{ github.ref != 'refs/heads/main' }} - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f - with: - context: assets/wasix-build/docker - file: assets/wasix-build/docker/Dockerfile - tags: pglite-oxide-wasix-build:ci - load: true - cache-from: type=gha,scope=wasix-builder - - - name: Install Wasmer LLVM 22.1 for WASIX template generation - uses: ./.github/actions/setup-wasmer-llvm - with: - url: ${{ env.WASMER_LLVM_LINUX_X64_URL }} - version: ${{ env.WASMER_LLVM_VERSION }} - cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - - name: Build portable WASIX modules and package runtime assets - env: - IMAGE: pglite-oxide-wasix-build:ci - run: | - cargo run -p xtask --features template-runner -- assets release-build \ - --profile "$ASSET_PROFILE" \ - --target-triple x86_64-unknown-linux-gnu \ - --skip-aot \ - --skip-package-size - - - name: Validate generated portable assets - run: cargo run -p xtask -- assets check --strict-generated - - - name: Upload portable WASIX build outputs - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: pglite-oxide-portable-wasix - path: | - assets/wasix-build/build/** - target/pglite-oxide/assets/** - assets/generated/** - if-no-files-found: error - - native-targets: - name: Select native AOT targets - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - matrix: ${{ steps.targets.outputs.matrix }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - persist-credentials: false - - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools - with: - cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - - name: Build target matrix - id: targets - env: - REQUESTED_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.target || 'all' }} - run: cargo run --quiet -p xtask -- assets ci-matrix --target "$REQUESTED_TARGET" --github-output >> "$GITHUB_OUTPUT" - - native-aot: - name: Native AOT / ${{ matrix.target }} - needs: - - portable-wasix - - native-targets - runs-on: ${{ matrix.os }} - timeout-minutes: 180 - permissions: - contents: read - actions: write - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.native-targets.outputs.matrix) }} - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - fetch-depth: 0 - persist-credentials: false - - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools - with: - cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - - name: Download portable WASIX build outputs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c - with: - name: pglite-oxide-portable-wasix - path: . - - - name: Install Wasmer LLVM 22.1 for AOT generation - uses: ./.github/actions/setup-wasmer-llvm - with: - url: ${{ matrix.llvm_url }} - version: ${{ env.WASMER_LLVM_VERSION }} - cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - - name: Generate and package target AOT artifacts - env: - AOT_TARGET: ${{ matrix.target }} - run: | - cargo run -p xtask -- assets aot --target-triple "$AOT_TARGET" - cargo run -p xtask -- assets package-aot --target-triple "$AOT_TARGET" - cargo run -p xtask -- assets check-aot --target-triple "$AOT_TARGET" - - - name: Check target AOT crate - env: - AOT_PACKAGE: ${{ matrix.package }} - run: cargo check -p "$AOT_PACKAGE" --locked - - - name: Run asset smoke tests - run: cargo run -p xtask -- assets smoke - - - name: Upload target artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: ${{ matrix.artifact }} - path: | - target/pglite-oxide/aot/${{ matrix.target }}/** - if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72091ffa..c7f2f112 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,51 @@ -name: CI -run-name: CI / ${{ github.event_name == 'pull_request' && format('PR {0}', github.event.pull_request.number) || github.ref_name }} +name: Builds +run-name: Builds / ${{ github.event_name == 'pull_request' && format('PR {0}', github.event.pull_request.number) || github.ref_name }} on: pull_request: + merge_group: + types: [checks_requested] push: branches: [main] + workflow_dispatch: + inputs: + wasm_target: + description: WASM AOT target to build when WASM runtime inputs are affected + required: true + default: all + type: choice + options: + - all + - macos-arm64 + - linux-x64-gnu + - linux-arm64-gnu + - windows-x64-msvc + native_target: + description: Native runtime target to run for focused manual debugging + required: true + default: all + type: choice + options: + - all + - macos-arm64 + - linux-x64-gnu + - linux-arm64-gnu + - windows-x64-msvc + - android-arm64-v8a + - android-x86_64 + - ios-xcframework + mobile_target: + description: Mobile lane to run for focused manual debugging + required: true + default: all + type: choice + options: + - all + - android + - ios + - both + schedule: + - cron: "41 4 * * 1" permissions: contents: read @@ -16,43 +57,88 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 - PREK_VERSION: 0.3.10 - CARGO_HACK_VERSION: 0.6.44 + NODE_VERSION: 22.22.3 + PNPM_VERSION: 11.5.0 + BUN_VERSION: 1.3.14 + DENO_VERSION: v2.8.1 + ACTIONLINT_VERSION: 1.7.12 + ASSET_PROFILE: release + WASMER_LLVM_VERSION: "22.1" + WASMER_LLVM_LINUX_X64_URL: https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-amd64.tar.xz RUST_CACHE_SAVE_IF: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + MOON_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + MOON_HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + +defaults: + run: + shell: bash jobs: - scope: - name: Determine changed surfaces + affected: + name: build-plan runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 outputs: - repo: ${{ steps.scope.outputs.repo }} - rust: ${{ steps.scope.outputs.rust }} - examples: ${{ steps.scope.outputs.examples }} - package: ${{ steps.scope.outputs.package }} - assets: ${{ steps.scope.outputs.assets }} - ci: ${{ steps.scope.outputs.ci }} - docs: ${{ steps.scope.outputs.docs }} - docs_only: ${{ steps.scope.outputs.docs_only }} + builder_jobs: ${{ steps.plan.outputs.builder_jobs }} + jobs: ${{ steps.plan.outputs.jobs }} + job_targets: ${{ steps.plan.outputs.job_targets }} + broker_runtime_matrix: ${{ steps.plan.outputs.broker_runtime_matrix }} + extension_artifacts_native_matrix: ${{ steps.plan.outputs.extension_artifacts_native_matrix }} + extension_artifacts_wasix_matrix: ${{ steps.plan.outputs.extension_artifacts_wasix_matrix }} + extension_package_products: ${{ steps.plan.outputs.extension_package_products }} + extension_package_products_csv: ${{ steps.plan.outputs.extension_package_products_csv }} + liboliphaunt_wasix_aot_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_wasix_aot_runtime_matrix }} + liboliphaunt_native_android_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_native_android_runtime_matrix }} + liboliphaunt_native_desktop_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_native_desktop_runtime_matrix }} + liboliphaunt_native_ios_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_native_ios_runtime_matrix }} + mobile_extension_package_native_targets: ${{ steps.plan.outputs.mobile_extension_package_native_targets }} + mobile_extension_package_native_targets_csv: ${{ steps.plan.outputs.mobile_extension_package_native_targets_csv }} + react_native_android_mobile_app_matrix: ${{ steps.plan.outputs.react_native_android_mobile_app_matrix }} + node_direct_runtime_matrix: ${{ steps.plan.outputs.node_direct_runtime_matrix }} + projects: ${{ steps.plan.outputs.projects }} + tasks: ${{ steps.plan.outputs.tasks }} + reason: ${{ steps.plan.outputs.reason }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - name: Classify changed paths - id: scope + - name: Set up Moon + uses: ./.github/actions/setup-moon + with: + install-workspace: "false" + + - name: Plan artifact builder jobs + id: plan env: - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: scripts/ci-scope.sh "$BASE_SHA" "$HEAD_SHA" + GITHUB_EVENT_NAME: ${{ github.event_name }} + MOON_BASE: ${{ github.event.pull_request.base.sha }} + MOON_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + WASM_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.wasm_target || 'all' }} + NATIVE_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.native_target || 'all' }} + MOBILE_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.mobile_target || 'all' }} + run: python3 .github/scripts/plan-affected.py - repo-hygiene: - name: Repository hygiene - needs: scope - runs-on: ubuntu-latest - timeout-minutes: 15 + - name: Upload build plan + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: artifact-build-plan + path: target/graph/ci-plan.json + if-no-files-found: error + + extension-artifacts-native: + name: build-extension-native (${{ matrix.target }}) + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'extension-artifacts-native') }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.extension_artifacts_native_matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -60,54 +146,133 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Bun + uses: ./.github/actions/setup-bun + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Set up Apple + if: ${{ runner.os == 'macOS' }} + uses: ./.github/actions/setup-apple + + - name: Set up Android + if: ${{ startsWith(matrix.target, 'android-') }} + uses: ./.github/actions/setup-android + with: + native-ccache: "true" + + - name: Set up MSVC + if: ${{ runner.os == 'Windows' }} + uses: ./.github/actions/setup-msvc + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: - components: rustfmt cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - tools: prek@${{ env.PREK_VERSION }} - - name: Validate repository hygiene - run: scripts/validate.sh repo + - name: Restore native compiler cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: | + ~/.ccache + ${{ matrix.build-root }} + key: liboliphaunt-native-extension-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('src/postgres/versions/18/**', 'src/sources/third-party/shared/**', 'src/sources/third-party/native/**', 'src/shared/extension-runtime-contract/**', 'src/extensions/**', 'src/runtimes/liboliphaunt/native/**', 'tools/xtask/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + liboliphaunt-native-extension-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}- + liboliphaunt-native-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}- - - name: Verify asset inputs - if: ${{ github.event_name == 'push' || needs.scope.outputs.assets == 'true' || needs.scope.outputs.package == 'true' || needs.scope.outputs.ci == 'true' }} - run: scripts/validate.sh artifacts + - name: Configure native compiler cache + run: .github/scripts/setup-native-build-tools.sh 2G - workflow-lint: - name: Workflow lint - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.ci == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - actions: read - contents: read + - name: Fetch pinned native runtime sources + run: bun tools/policy/fetch-sources.mjs native-runtime + + - name: Build native exact-extension artifacts + env: + OLIPHAUNT_EXTENSION_PRODUCTS: ${{ matrix.extensions_csv }} + OLIPHAUNT_EXTENSION_TARGET: ${{ matrix.target }} + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-native + + - name: Upload native exact-extension artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-extension-artifacts-${{ matrix.target }} + path: target/extensions/native/release-assets + if-no-files-found: error + + - name: Upload native exact-extension build logs + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-extension-logs-${{ matrix.target }} + path: | + target/liboliphaunt-mobile-extension-ci/**/*.log + target/liboliphaunt-mobile-extension-release/**/*.log + target/liboliphaunt-pg18-extension-release/**/*.log + target/liboliphaunt-pg18-*-extension-release/**/*.log + /tmp/liboliphaunt-ci-*-extensions.log + /tmp/liboliphaunt-release-*-extensions.log + /tmp/liboliphaunt-ci-extension-assets-fetch.log + /tmp/liboliphaunt-release-extension-assets-fetch.log + if-no-files-found: ignore + + extension-artifacts-wasix: + name: build-extension-wasix (${{ matrix.target }}) + needs: + - affected + - liboliphaunt-wasix-runtime + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'extension-artifacts-wasix') }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.extension_artifacts_wasix_matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + fetch-depth: 0 persist-credentials: false - - name: Lint GitHub Actions workflows - uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Download portable WASIX runtime outputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: liboliphaunt-wasix-runtime-portable + path: . + + - name: Build WASIX exact-extension artifacts + env: + OLIPHAUNT_EXTENSION_PRODUCTS: ${{ matrix.extensions_csv }} + OLIPHAUNT_EXTENSION_TARGET: ${{ matrix.target }} + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-wasix - - name: Audit GitHub Actions workflows - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e + - name: Upload WASIX exact-extension artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - advanced-security: false - config: .github/zizmor.yml - inputs: .github/workflows .github/actions - min-severity: medium - persona: auditor - version: 1.24.1 + name: liboliphaunt-wasix-extension-artifacts-${{ matrix.target }} + path: target/extensions/wasix/release-assets + if-no-files-found: error - rust-lint: - name: Rust lint - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.rust == 'true' }} + extension-packages: + name: build-extension-packages + needs: + - affected + - extension-artifacts-native + - extension-artifacts-wasix + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'extension-packages') }} runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -115,21 +280,48 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: - components: clippy cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - name: Validate lint gates - run: scripts/validate.sh lint + - name: Download native exact-extension artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: liboliphaunt-native-extension-artifacts-* + path: target/extensions/native/release-assets + merge-multiple: true - rust-tests: - name: Rust tests - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.rust == 'true' }} + - name: Download WASIX exact-extension artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: liboliphaunt-wasix-extension-artifacts-* + path: target/extensions/wasix/release-assets + merge-multiple: true + + - name: Build exact-extension product packages + env: + OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS: ${{ needs.affected.outputs.extension_package_products_csv }} + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-packages + + - name: Upload exact-extension package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-extension-package-artifacts + path: target/extension-artifacts + if-no-files-found: error + + mobile-extension-packages: + name: build-mobile-extension-packages + needs: + - affected + - extension-artifacts-native + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'mobile-extension-packages') }} runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -137,54 +329,142 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - name: Validate test gates - run: scripts/validate.sh test + - name: Download native exact-extension artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: liboliphaunt-native-extension-artifacts-* + path: target/extensions/native/release-assets + merge-multiple: true - runtime-targets: - name: Select runtime AOT targets - needs: scope - if: ${{ needs.scope.outputs.rust == 'true' && needs.scope.outputs.assets != 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - matrix: ${{ steps.targets.outputs.matrix }} + - name: Build mobile exact-extension package artifacts + env: + OLIPHAUNT_EXTENSION_PACKAGE_NATIVE_TARGETS: ${{ needs.affected.outputs.mobile_extension_package_native_targets_csv }} + OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS: ${{ needs.affected.outputs.extension_package_products_csv }} + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh mobile-extension-packages + + - name: Upload mobile exact-extension package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-mobile-extension-package-artifacts + path: target/extension-artifacts + if-no-files-found: error + + liboliphaunt-native-desktop: + name: build-native-runtime-desktop (${{ matrix.target }}) + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-native-desktop') }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_native_desktop_runtime_matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Bun + uses: ./.github/actions/setup-bun + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - name: Build target matrix - id: targets - run: cargo run --quiet -p xtask -- assets ci-matrix --github-output >> "$GITHUB_OUTPUT" + - name: Set up MSVC + if: ${{ runner.os == 'Windows' }} + uses: ./.github/actions/setup-msvc + + - name: Restore native compiler cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: | + ~/.ccache + ${{ matrix.build-root }} + key: liboliphaunt-native-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('src/postgres/versions/18/**', 'src/sources/third-party/shared/**', 'src/sources/third-party/native/**', 'src/shared/extension-runtime-contract/**', 'src/runtimes/liboliphaunt/native/**', 'tools/xtask/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + liboliphaunt-native-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}- + + - name: Configure native compiler cache + run: .github/scripts/setup-native-build-tools.sh 2G - runtime-aot-tests: - name: Runtime AOT smoke / ${{ matrix.target }} + - name: Fetch pinned native runtime sources + run: bun tools/policy/fetch-sources.mjs native-runtime + + - name: Build liboliphaunt native runtime + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh liboliphaunt-native-desktop + + - name: Package liboliphaunt macOS release asset + if: ${{ startsWith(matrix.target, 'macos-') }} + env: + OLIPHAUNT_RELEASE_FETCH_ASSETS: "0" + run: tools/release/package-liboliphaunt-macos-assets.sh + + - name: Package liboliphaunt Linux release asset + if: ${{ startsWith(matrix.target, 'linux-') }} + env: + OLIPHAUNT_RELEASE_FETCH_ASSETS: "0" + run: tools/release/package-liboliphaunt-linux-assets.sh + + - name: Package liboliphaunt Windows release asset + if: ${{ startsWith(matrix.target, 'windows-') }} + shell: pwsh + env: + OLIPHAUNT_RELEASE_FETCH_ASSETS: "0" + run: ./tools/release/package-liboliphaunt-windows-assets.ps1 + + - name: Upload liboliphaunt release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-release-assets-${{ matrix.target }} + path: target/liboliphaunt/release-assets + if-no-files-found: error + + - name: Show native compiler cache stats + if: ${{ always() }} + env: + NATIVE_TARGET: ${{ matrix.target }} + run: | + if [[ "$NATIVE_TARGET" != windows-* ]] && command -v ccache >/dev/null 2>&1; then + ccache --show-stats + fi + + - name: Upload liboliphaunt build logs + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-logs-${{ matrix.target }} + path: | + ${{ matrix.build-root }}/*.log + target/liboliphaunt/**/*.log + if-no-files-found: ignore + + liboliphaunt-native-android: + name: build-native-runtime-android (${{ matrix.target }}) needs: - - scope - - runtime-targets - if: ${{ needs.scope.outputs.rust == 'true' && needs.scope.outputs.assets != 'true' }} - runs-on: ${{ matrix.os }} - timeout-minutes: 180 - permissions: - contents: read - actions: read - defaults: - run: - shell: bash + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-native-android') }} strategy: fail-fast: false - matrix: ${{ fromJson(needs.runtime-targets.outputs.matrix) }} + matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_native_android_runtime_matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -192,65 +472,250 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Bun + uses: ./.github/actions/setup-bun + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - name: Download compatible runtime artifacts + - name: Set up Android + uses: ./.github/actions/setup-android + with: + native-ccache: "true" + + - name: Restore native compiler cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: | + ~/.ccache + ${{ matrix.build-root }} + key: liboliphaunt-native-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('src/postgres/versions/18/**', 'src/sources/third-party/shared/**', 'src/sources/third-party/native/**', 'src/shared/extension-runtime-contract/**', 'src/runtimes/liboliphaunt/native/**', 'tools/xtask/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + liboliphaunt-native-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}- + + - name: Configure native compiler cache + run: .github/scripts/setup-native-build-tools.sh 2G + + - name: Build liboliphaunt Android target env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AOT_TARGET: ${{ matrix.target }} - run: | - cargo run -p xtask -- assets download \ - --latest-compatible \ - --target-triple "$AOT_TARGET" + OLIPHAUNT_CI_TARGET: ${{ matrix.target }} + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh liboliphaunt-native-android - - name: Check target AOT crate + - name: Package liboliphaunt Android release asset env: - AOT_PACKAGE: ${{ matrix.package }} - run: cargo check -p "$AOT_PACKAGE" --locked + OLIPHAUNT_CI_TARGET: ${{ matrix.target }} + run: tools/release/package-liboliphaunt-mobile-assets.sh "$OLIPHAUNT_CI_TARGET" - - name: Run runtime tests against target AOT - run: scripts/validate.sh runtime-smoke + - name: Upload liboliphaunt release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-release-assets-${{ matrix.target }} + path: target/liboliphaunt/release-assets + if-no-files-found: error - asset-status: - name: Wait for same-SHA Assets - needs: scope - if: ${{ needs.scope.outputs.assets == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 360 - permissions: - actions: read - contents: read + - name: Upload liboliphaunt Android target artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-target-${{ matrix.target }} + path: ${{ matrix.ci-artifact-root }} + if-no-files-found: error + + - name: Show native compiler cache stats + if: ${{ always() }} + run: | + if command -v ccache >/dev/null 2>&1; then + ccache --show-stats + fi + + - name: Upload liboliphaunt Android build logs + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-logs-${{ matrix.target }} + path: | + ${{ matrix.build-root }}/*.log + target/liboliphaunt/**/*.log + if-no-files-found: ignore + + liboliphaunt-native-ios: + name: build-native-runtime-ios (${{ matrix.target }}) + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-native-ios') }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_native_ios_runtime_matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Bun + uses: ./.github/actions/setup-bun + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - name: Wait for successful same-SHA Assets workflow + - name: Set up Apple + uses: ./.github/actions/setup-apple + + - name: Restore native compiler cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 + with: + path: | + ~/.ccache + ${{ matrix.build-root }} + key: liboliphaunt-native-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('src/postgres/versions/18/**', 'src/sources/third-party/shared/**', 'src/sources/third-party/native/**', 'src/shared/extension-runtime-contract/**', 'src/runtimes/liboliphaunt/native/**', 'tools/xtask/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + liboliphaunt-native-ccache-${{ matrix.target }}-${{ runner.os }}-${{ runner.arch }}- + + - name: Configure native compiler cache + run: .github/scripts/setup-native-build-tools.sh 2G + + - name: Build liboliphaunt iOS target env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - ASSET_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + OLIPHAUNT_CI_TARGET: ${{ matrix.target }} + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh liboliphaunt-native-ios + + - name: Package liboliphaunt iOS release asset + env: + OLIPHAUNT_CI_TARGET: ${{ matrix.target }} + run: tools/release/package-liboliphaunt-mobile-assets.sh "$OLIPHAUNT_CI_TARGET" + + - name: Upload liboliphaunt release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-release-assets-${{ matrix.target }} + path: target/liboliphaunt/release-assets + if-no-files-found: error + + - name: Upload liboliphaunt iOS target artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-target-${{ matrix.target }} + path: ${{ matrix.ci-artifact-root }} + if-no-files-found: error + + - name: Show native compiler cache stats + if: ${{ always() }} run: | - args=() - while IFS= read -r artifact; do - args+=(--artifact "$artifact") - done < <(cargo run --quiet -p xtask -- assets ci-artifacts) - bash .github/scripts/require-workflow-success.sh Assets "$ASSET_SHA" 21000 "${args[@]}" - - examples: - name: Examples - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.examples == 'true' }} + if command -v ccache >/dev/null 2>&1; then + ccache --show-stats + fi + + - name: Upload liboliphaunt iOS build logs + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-logs-${{ matrix.target }} + path: | + ${{ matrix.build-root }}/*.log + target/liboliphaunt/**/*.log + if-no-files-found: ignore + + liboliphaunt-native-release-assets: + name: build-native-runtime-release-assets + needs: + - affected + - liboliphaunt-native-android + - liboliphaunt-native-desktop + - liboliphaunt-native-ios + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-native-release-assets') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Download liboliphaunt target release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: liboliphaunt-native-release-assets-* + path: target/liboliphaunt/release-assets + merge-multiple: true + + - name: Package aggregate liboliphaunt release assets + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh liboliphaunt-native-release-assets + + - name: Upload aggregate liboliphaunt release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-native-release-assets + path: target/liboliphaunt/release-assets + if-no-files-found: error + + rust-sdk-package: + name: build-rust-sdk + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'rust-sdk-package') }} runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + components: clippy,llvm-tools-preview + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + tools: cargo-nextest@0.9.137,cargo-llvm-cov@0.8.7 + + - name: Build Rust SDK package artifacts + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh rust-sdk-package + + - name: Upload Rust SDK package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-rust-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-rust + if-no-files-found: error + + broker-runtime: + name: build-broker-runtime (${{ matrix.target }}) + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'broker-runtime') }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.broker_runtime_matrix) }} + runs-on: ${{ matrix.runner }} timeout-minutes: 45 steps: - name: Checkout repository @@ -259,41 +724,436 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Set up MSVC + if: ${{ runner.os == 'Windows' }} + uses: ./.github/actions/setup-msvc + + - name: Build broker runtime release asset + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh broker-runtime + + - name: Upload broker release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-broker-release-assets-${{ matrix.target }} + path: target/oliphaunt-broker/release-assets + if-no-files-found: error + + node-direct: + name: build-node-direct (${{ matrix.target }}) + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'node-direct') }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.node_direct_runtime_matrix) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Set up MSVC + if: ${{ runner.os == 'Windows' }} + uses: ./.github/actions/setup-msvc + + - name: Build Node direct release asset + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh node-direct + + - name: Upload Node direct release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-node-direct-release-assets-${{ matrix.target }} + path: target/oliphaunt-node-direct/release-assets + if-no-files-found: error + + - name: Upload Node direct optional npm package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-node-direct-npm-package-${{ matrix.target }} + path: target/oliphaunt-node-direct/npm-packages/*.tgz + if-no-files-found: error + + swift-sdk-package: + name: build-swift-sdk + needs: + - affected + - liboliphaunt-native-ios + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'swift-sdk-package') }} + runs-on: macos-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Apple + uses: ./.github/actions/setup-apple + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Download Apple liboliphaunt release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: liboliphaunt-native-release-assets-ios-xcframework + path: target/liboliphaunt/release-assets + + - name: Build Swift SDK package artifacts + env: + OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR: ${{ github.workspace }}/target/liboliphaunt/release-assets + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh swift-sdk-package + + - name: Upload Swift SDK package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-swift-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-swift + if-no-files-found: error + + kotlin-sdk-package: + name: build-kotlin-sdk + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'kotlin-sdk-package') }} + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Android + uses: ./.github/actions/setup-android + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Build Kotlin SDK package artifacts + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh kotlin-sdk-package + + - name: Upload Kotlin SDK package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-kotlin-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-kotlin + if-no-files-found: error + + react-native-sdk-package: + name: build-react-native-sdk + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'react-native-sdk-package') }} + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + with: + install-workspace: "true" + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Build React Native SDK package artifacts + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh react-native-sdk-package + + - name: Upload React Native SDK package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-react-native-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-react-native + if-no-files-found: error + + js-sdk-package: + name: build-js-sdk + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'js-sdk-package') }} + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + with: + install-workspace: "true" + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Set up Bun + uses: ./.github/actions/setup-bun + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Set up Deno + uses: ./.github/actions/setup-deno + with: + deno-version: ${{ env.DENO_VERSION }} + + - name: Build TypeScript SDK package artifacts + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh js-sdk-package + + - name: Upload JS SDK package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-js-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-js + if-no-files-found: error + + wasix-rust-package: + name: build-wasix-rust-binding + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'wasix-rust-package') }} + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + with: + install-workspace: "true" + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + components: llvm-tools-preview + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + tools: cargo-nextest@0.9.137,cargo-llvm-cov@0.8.7 + - name: Install Tauri Linux dependencies run: | + .github/scripts/prepare-linux-apt.sh sudo apt-get update - sudo apt-get install -y \ + sudo apt-get install -y --no-install-recommends \ libayatana-appindicator3-dev \ + libglib2.0-dev \ + libgtk-3-dev \ + librsvg2-dev \ libssl-dev \ libwebkit2gtk-4.1-dev \ - librsvg2-dev \ - patchelf \ + libxdo-dev \ pkg-config - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Build WASIX Rust binding package artifacts + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh wasix-rust-package + + - name: Upload WASIX Rust binding package artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: oliphaunt-wasix-rust-package-artifacts + path: target/sdk-artifacts/oliphaunt-wasix-rust + if-no-files-found: error + + liboliphaunt-wasix-runtime: + name: build-liboliphaunt-wasix-runtime + needs: + - affected + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-runtime') }} + runs-on: ubuntu-latest + timeout-minutes: 360 + permissions: + contents: read + actions: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Bun + uses: ./.github/actions/setup-bun + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - cache-workspaces: | - . -> target - examples/tauri-sqlx-vanilla/src-tauri -> target - - name: Install Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + - name: Verify source-controlled asset inputs + run: cargo run -p xtask -- assets verify-committed + + - name: Fetch pinned asset sources + run: bun tools/policy/fetch-sources.mjs wasix-runtime + + - name: Restore WASIX compilation cache + id: wasix-build-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 with: - node-version: 22 - cache: npm - cache-dependency-path: examples/tauri-sqlx-vanilla/package-lock.json + path: | + target/liboliphaunt-pg18/source + target/oliphaunt-wasix/wasix-build/source-cache + target/oliphaunt-wasix/wasix-build/work/docker-oliphaunt + target/oliphaunt-wasix/wasix-build/work/icu-native + target/oliphaunt-wasix/wasix-build/work/icu-wasix + target/oliphaunt-wasix/wasix-build/work/icu-wasix-build + target/oliphaunt-wasix/wasix-build/work/json-c-wasix + target/oliphaunt-wasix/wasix-build/work/json-c-wasix-build + target/oliphaunt-wasix/wasix-build/work/libiconv-wasix + target/oliphaunt-wasix/wasix-build/work/libiconv-wasix-build + target/oliphaunt-wasix/wasix-build/work/libxml2-wasix + target/oliphaunt-wasix/wasix-build/work/libxml2-wasix-build + target/oliphaunt-wasix/wasix-build/work/openssl-wasix + target/oliphaunt-wasix/wasix-build/work/openssl-wasix-build + target/oliphaunt-wasix/wasix-build/work/proj-wasix + target/oliphaunt-wasix/wasix-build/work/proj-wasix-build + target/oliphaunt-wasix/wasix-build/work/sqlite-wasix + target/oliphaunt-wasix/wasix-build/work/sqlite-wasix-build + target/oliphaunt-wasix/wasix-build/work/geos-wasix + target/oliphaunt-wasix/wasix-build/work/geos-wasix-build + target/oliphaunt-wasix/wasix-build/build + key: wasix-build-${{ runner.os }}-${{ env.ASSET_PROFILE }}-${{ env.WASMER_LLVM_VERSION }}-${{ hashFiles('src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256', 'src/postgres/versions/18/**', 'src/sources/third-party/**', 'src/sources/toolchains/**', 'src/shared/extension-runtime-contract/**', 'src/runtimes/liboliphaunt/wasix/assets/build/**', 'src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml', 'src/runtimes/liboliphaunt/wasix/crates/assets/**', 'tools/xtask/**', '.github/actions/setup-wasmer-llvm/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + wasix-build-${{ runner.os }}-${{ env.ASSET_PROFILE }}- - - name: Validate examples - run: scripts/validate.sh examples + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - package: - name: Package checks - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.package == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 45 + - name: Build WASIX builder image and save cache + if: ${{ github.ref == 'refs/heads/main' }} + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f + with: + context: src/runtimes/liboliphaunt/wasix/assets/build/docker + file: src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile + tags: oliphaunt-wasix-wasix-build:ci + load: true + cache-from: type=gha,scope=wasix-builder + cache-to: type=gha,mode=max,scope=wasix-builder + + - name: Build WASIX builder image + if: ${{ github.ref != 'refs/heads/main' }} + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f + with: + context: src/runtimes/liboliphaunt/wasix/assets/build/docker + file: src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile + tags: oliphaunt-wasix-wasix-build:ci + load: true + cache-from: type=gha,scope=wasix-builder + + - name: Install Wasmer LLVM 22.1 for WASIX template generation + uses: ./.github/actions/setup-wasmer-llvm + with: + url: ${{ env.WASMER_LLVM_LINUX_X64_URL }} + version: ${{ env.WASMER_LLVM_VERSION }} + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Build and validate portable WASIX runtime + env: + IMAGE: oliphaunt-wasix-wasix-build:ci + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh liboliphaunt-wasix-runtime + + - name: Save WASIX compilation cache + if: ${{ github.ref == 'refs/heads/main' && steps.wasix-build-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: | + target/liboliphaunt-pg18/source + target/oliphaunt-wasix/wasix-build/source-cache + target/oliphaunt-wasix/wasix-build/work/docker-oliphaunt + target/oliphaunt-wasix/wasix-build/work/icu-native + target/oliphaunt-wasix/wasix-build/work/icu-wasix + target/oliphaunt-wasix/wasix-build/work/icu-wasix-build + target/oliphaunt-wasix/wasix-build/work/json-c-wasix + target/oliphaunt-wasix/wasix-build/work/json-c-wasix-build + target/oliphaunt-wasix/wasix-build/work/libiconv-wasix + target/oliphaunt-wasix/wasix-build/work/libiconv-wasix-build + target/oliphaunt-wasix/wasix-build/work/libxml2-wasix + target/oliphaunt-wasix/wasix-build/work/libxml2-wasix-build + target/oliphaunt-wasix/wasix-build/work/openssl-wasix + target/oliphaunt-wasix/wasix-build/work/openssl-wasix-build + target/oliphaunt-wasix/wasix-build/work/proj-wasix + target/oliphaunt-wasix/wasix-build/work/proj-wasix-build + target/oliphaunt-wasix/wasix-build/work/sqlite-wasix + target/oliphaunt-wasix/wasix-build/work/sqlite-wasix-build + target/oliphaunt-wasix/wasix-build/work/geos-wasix + target/oliphaunt-wasix/wasix-build/work/geos-wasix-build + target/oliphaunt-wasix/wasix-build/build + key: wasix-build-${{ runner.os }}-${{ env.ASSET_PROFILE }}-${{ env.WASMER_LLVM_VERSION }}-${{ hashFiles('src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256', 'src/postgres/versions/18/**', 'src/sources/third-party/**', 'src/sources/toolchains/**', 'src/shared/extension-runtime-contract/**', 'src/runtimes/liboliphaunt/wasix/assets/build/**', 'src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml', 'src/runtimes/liboliphaunt/wasix/crates/assets/**', 'tools/xtask/**', '.github/actions/setup-wasmer-llvm/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml') }} + + - name: Upload portable WASIX build outputs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-wasix-runtime-portable + path: | + target/oliphaunt-wasix/wasix-build/build/** + target/oliphaunt-wasix/assets/** + src/extensions/generated/** + src/runtimes/liboliphaunt/wasix/assets/generated/** + if-no-files-found: error + + liboliphaunt-wasix-aot: + name: build-liboliphaunt-wasix-aot (${{ matrix.target_id }}) + needs: + - affected + - liboliphaunt-wasix-runtime + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-aot') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 180 + permissions: + contents: read + actions: write + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_wasix_aot_runtime_matrix || '{"include":[]}') }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -301,92 +1161,401 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - name: Validate package checks - run: scripts/validate.sh package + - name: Set up MSVC + if: ${{ runner.os == 'Windows' }} + uses: ./.github/actions/setup-msvc - feature-powerset: - name: Feature powerset - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.rust == 'true' }} + - name: Download portable WASIX build outputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: liboliphaunt-wasix-runtime-portable + path: . + + - name: Install Wasmer LLVM 22.1 for AOT generation + uses: ./.github/actions/setup-wasmer-llvm + with: + url: ${{ matrix.llvm_url }} + version: ${{ env.WASMER_LLVM_VERSION }} + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Build, validate, and smoke target AOT artifacts + env: + AOT_TARGET: ${{ matrix.target }} + AOT_PACKAGE: ${{ matrix.package }} + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh liboliphaunt-wasix-aot + + - name: Stage target AOT artifact envelope + env: + AOT_TARGET: ${{ matrix.target }} + run: | + python3 - <<'PY' + import os + import shutil + from pathlib import Path + + target = os.environ["AOT_TARGET"] + source = Path("target/oliphaunt-wasix/aot") / target + if not source.is_dir(): + raise SystemExit(f"missing AOT output directory: {source}") + upload = Path("target/oliphaunt-wasix/aot-upload") + shutil.rmtree(upload, ignore_errors=True) + (upload / "files").mkdir(parents=True, exist_ok=True) + shutil.copytree(source, upload / "files", dirs_exist_ok=True) + (upload / "target-triple.txt").write_text(f"{target}\n", encoding="utf-8") + PY + + - name: Upload target artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-wasix-runtime-aot-${{ matrix.target_id }} + path: | + target/oliphaunt-wasix/aot-upload/** + if-no-files-found: error + + liboliphaunt-wasix-release-assets: + name: build-liboliphaunt-wasix-release-assets + needs: + - affected + - liboliphaunt-wasix-runtime + - liboliphaunt-wasix-aot + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-release-assets') && (github.event_name != 'workflow_dispatch' || inputs.wasm_target == 'all') }} runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - tools: cargo-hack@${{ env.CARGO_HACK_VERSION }} - - name: Check feature combinations - run: scripts/validate.sh feature-powerset + - name: Download portable WASIX runtime outputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: liboliphaunt-wasix-runtime-portable + path: . + + - name: Download WASIX AOT runtime outputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + pattern: liboliphaunt-wasix-runtime-aot-* + path: target/oliphaunt-wasix/aot-downloads + + - name: Restore WASIX AOT target layout + run: | + shopt -s nullglob + for artifact_dir in target/oliphaunt-wasix/aot-downloads/liboliphaunt-wasix-runtime-aot-*; do + marker="$artifact_dir/target-triple.txt" + raw_target_dir="$artifact_dir/files" + if [ ! -f "$marker" ] || [ ! -d "$raw_target_dir" ]; then + echo "Invalid WASIX AOT artifact envelope in $artifact_dir" >&2 + exit 1 + fi + target="$(tr -d '\r\n' < "$marker")" + if [ -z "$target" ]; then + echo "Empty WASIX AOT target marker in $marker" >&2 + exit 1 + fi + destination="target/oliphaunt-wasix/aot/$target" + mkdir -p "$destination" + rsync -a "$raw_target_dir/" "$destination/" + done + + - name: Package WASIX release assets + run: OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh liboliphaunt-wasix-release-assets - semver: - name: Public API compatibility - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.package == 'true' }} + - name: Upload WASIX release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: liboliphaunt-wasix-release-assets + path: target/oliphaunt-wasix/release-assets + if-no-files-found: error + + mobile-build-android: + name: mobile-build-android (${{ matrix.target }}) + needs: + - affected + - mobile-extension-packages + - liboliphaunt-native-android + - kotlin-sdk-package + - react-native-sdk-package + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'mobile-build-android') }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.affected.outputs.react_native_android_mobile_app_matrix) }} runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 180 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + with: + install-workspace: "true" + + - name: Set up Android + uses: ./.github/actions/setup-android + with: + native-ccache: "true" + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} - - name: Check semver compatibility - uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 + - name: Download Android liboliphaunt target + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: liboliphaunt-native-target-${{ matrix.target }} + path: . - supply-chain: - name: Supply chain - needs: scope - if: ${{ github.event_name == 'push' || needs.scope.outputs.rust == 'true' || needs.scope.outputs.ci == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 10 + - name: Download Kotlin SDK package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: oliphaunt-kotlin-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-kotlin + + - name: Download React Native SDK package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: oliphaunt-react-native-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-react-native + + - name: Download exact-extension package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: oliphaunt-mobile-extension-package-artifacts + path: target/extension-artifacts + + - name: Build Android mobile app + env: + OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS: "0" + OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS: "1" + OLIPHAUNT_EXPO_SDK_ARTIFACT_ROOT: ${{ github.workspace }}/target/sdk-artifacts + OLIPHAUNT_EXPO_ANDROID_ABI: ${{ matrix.abi }} + OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release + OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS: "1" + OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT: ${{ github.workspace }}/target/extension-artifacts + OLIPHAUNT_EXPO_ANDROID_OLIPHAUNT_SO: ${{ github.workspace }}/${{ matrix.build-root }}/out/liboliphaunt.so + OLIPHAUNT_EXPO_ANDROID_RUNTIME_DIR: ${{ github.workspace }}/target/liboliphaunt-pg18-linux-x64-gnu/install + OLIPHAUNT_EXPO_ANDROID_INITDB: ${{ github.workspace }}/target/liboliphaunt-pg18-linux-x64-gnu/install/bin/initdb + run: | + OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh mobile-build-android + + - name: Validate Android mobile app artifacts + run: python3 tools/release/check_staged_artifacts.py --require-mobile android --require-mobile-prebuilt-extensions + + - name: Upload Android mobile build logs + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: react-native-mobile-android-build-logs-${{ matrix.target }} + path: | + target/mobile/react-native/android-build/logs + target/oliphaunt-expo-android-*/logs + if-no-files-found: ignore + + - name: Upload Android mobile app + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: react-native-mobile-android-app-${{ matrix.target }} + path: target/mobile-build/react-native/android + if-no-files-found: error + + mobile-build-ios: + name: mobile-build-ios + needs: + - affected + - mobile-extension-packages + - liboliphaunt-native-ios + - react-native-sdk-package + - swift-sdk-package + if: ${{ contains(fromJson(needs.affected.outputs.jobs), 'mobile-build-ios') }} + runs-on: macos-26 + timeout-minutes: 180 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + fetch-depth: 0 persist-credentials: false - - name: Check dependency policy - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb + + - name: Set up Moon + uses: ./.github/actions/setup-moon + with: + install-workspace: "true" + + - name: Set up Apple + uses: ./.github/actions/setup-apple + + - name: Set up Rust + uses: ./.github/actions/setup-rust + with: + cache-save-if: ${{ env.RUST_CACHE_SAVE_IF }} + + - name: Download iOS liboliphaunt target + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: liboliphaunt-native-target-ios-xcframework + path: . + + - name: Download Swift SDK package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: oliphaunt-swift-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-swift + + - name: Download React Native SDK package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: oliphaunt-react-native-sdk-package-artifacts + path: target/sdk-artifacts/oliphaunt-react-native + + - name: Download exact-extension package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: oliphaunt-mobile-extension-package-artifacts + path: target/extension-artifacts + + - name: Build iOS mobile app + env: + OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS: "0" + OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS: "1" + OLIPHAUNT_EXPO_SDK_ARTIFACT_ROOT: ${{ github.workspace }}/target/sdk-artifacts + OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release + OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator + OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS: "1" + OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT: ${{ github.workspace }}/target/extension-artifacts + OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK: ${{ github.workspace }}/target/liboliphaunt-ios-xcframework/out/liboliphaunt.xcframework + OLIPHAUNT_EXPO_IOS_RUNTIME_DIR: ${{ github.workspace }}/target/liboliphaunt-pg18/install + OLIPHAUNT_EXPO_IOS_INITDB: ${{ github.workspace }}/target/liboliphaunt-pg18/install/bin/initdb + run: | + OLIPHAUNT_CI_JOB_TARGETS_JSON='${{ needs.affected.outputs.job_targets }}' OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh mobile-build-ios + + - name: Validate iOS mobile app artifacts + run: python3 tools/release/check_staged_artifacts.py --require-mobile ios --require-mobile-prebuilt-extensions + + - name: Upload iOS mobile build logs + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: react-native-mobile-ios-build-logs + path: | + target/mobile/react-native/ios-build/logs + target/mobile/react-native/ios-build/xcodebuild.log + target/oliphaunt-expo-ios-*/logs + target/oliphaunt-expo-ios-*/xcodebuild.log + if-no-files-found: ignore + + - name: Upload iOS mobile app + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: react-native-mobile-ios-app + path: target/mobile-build/react-native/ios + if-no-files-found: error + + builders: + name: artifact-builders + if: ${{ always() }} + needs: + - affected + - extension-artifacts-native + - extension-artifacts-wasix + - extension-packages + - mobile-extension-packages + - liboliphaunt-native-android + - liboliphaunt-native-desktop + - liboliphaunt-native-ios + - liboliphaunt-native-release-assets + - rust-sdk-package + - broker-runtime + - node-direct + - swift-sdk-package + - kotlin-sdk-package + - react-native-sdk-package + - js-sdk-package + - wasix-rust-package + - liboliphaunt-wasix-runtime + - liboliphaunt-wasix-aot + - liboliphaunt-wasix-release-assets + - mobile-build-android + - mobile-build-ios + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check selected artifact builder jobs + env: + NEEDS_JSON: ${{ toJson(needs) }} + BUILDER_JOBS: ${{ needs.affected.outputs.builder_jobs }} + run: | + python3 - <<'PY' + import json + import os + import sys + + needs = json.loads(os.environ["NEEDS_JSON"]) + try: + builder_jobs = set(json.loads(os.environ.get("BUILDER_JOBS") or "[]")) + except json.JSONDecodeError as error: + print(f"invalid builder job JSON: {error}", file=sys.stderr) + raise SystemExit(2) + failures = [] + for job in sorted(builder_jobs): + result = needs.get(job, {}).get("result") + if result != "success": + failures.append(f"{job}={result or 'missing'}") + if failures: + print("selected artifact builder job failures: " + ", ".join(failures), file=sys.stderr) + raise SystemExit(1) + print("selected artifact builder jobs passed: " + (", ".join(sorted(builder_jobs)) or "(none)")) + PY required: - name: Required checks - if: always() + name: required + if: ${{ always() }} needs: - - scope - - repo-hygiene - - workflow-lint - - rust-lint - - rust-tests - - runtime-targets - - runtime-aot-tests - - asset-status - - examples - - package - - feature-powerset - - semver - - supply-chain + - affected + - builders runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Fail if any required job failed - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 + - name: Check required jobs + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + python3 - <<'PY' + import json + import sys + import os - - name: All required jobs passed - run: echo "All required CI jobs passed or were intentionally skipped." + needs = json.loads(os.environ["NEEDS_JSON"]) + failures = [] + for job in ("affected", "builders"): + result = needs.get(job, {}).get("result") + if result == "success": + continue + failures.append(f"{job}={result or 'missing'}") + if failures: + print("required job failures: " + ", ".join(failures), file=sys.stderr) + raise SystemExit(1) + print("required artifact builder gate passed") + PY diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml deleted file mode 100644 index d5d53f52..00000000 --- a/.github/workflows/conventional-commits.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Conventional Commits -run-name: Commit policy / ${{ github.event_name == 'pull_request' && format('PR {0}', github.event.pull_request.number) || github.ref_name }} - -on: - pull_request: - types: [opened, edited, synchronize, reopened, ready_for_review] - push: - branches: [main] - -permissions: - contents: read - pull-requests: read - -concurrency: - group: conventional-commits-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - PREK_VERSION: 0.3.10 - -jobs: - conventional: - name: Validate commit and PR title - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools - with: - tools: prek@${{ env.PREK_VERSION }} - - - name: Check PR title - if: github.event_name == 'pull_request' - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: | - printf '%s\n' "${PR_TITLE}" > "${RUNNER_TEMP}/pr-title.txt" - scripts/validate.sh commit-msg "${RUNNER_TEMP}/pr-title.txt" - - name: Check release intent for package changes - if: github.event_name == 'pull_request' - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_TITLE: ${{ github.event.pull_request.title }} - run: ./.github/scripts/check-release-intent.sh "$PR_TITLE" "$BASE_SHA" "$HEAD_SHA" "$HEAD_BRANCH" - - name: Check HEAD commit subject - run: | - git log -1 --pretty=%s > "${RUNNER_TEMP}/head-commit.txt" - scripts/validate.sh commit-msg "${RUNNER_TEMP}/head-commit.txt" diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml new file mode 100644 index 00000000..c9100228 --- /dev/null +++ b/.github/workflows/mobile-e2e.yml @@ -0,0 +1,313 @@ +name: Mobile E2E +run-name: Mobile E2E / ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }} + +on: + workflow_run: # zizmor: ignore[dangerous-triggers] read-only artifact consumer; permissions are actions:read/contents:read and no untrusted code runs with write credentials. + workflows: ["Builds"] + types: [completed] + workflow_dispatch: + inputs: + sha: + description: Commit SHA whose Builds artifacts should be tested. Defaults to the current ref SHA. + required: false + type: string + platform: + description: Mobile platform to test. + required: true + default: all + type: choice + options: + - all + - android + - ios + +permissions: + actions: read + contents: read + +concurrency: + group: mobile-e2e-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || inputs.sha || github.sha }}-${{ inputs.platform || 'all' }} + cancel-in-progress: false + +env: + NODE_VERSION: 22.22.3 + PNPM_VERSION: 11.5.0 + +jobs: + resolve: + name: resolve-build-artifacts + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + android: ${{ steps.plan.outputs.android }} + ios: ${{ steps.plan.outputs.ios }} + run_id: ${{ steps.plan.outputs.run_id }} + sha: ${{ steps.plan.outputs.sha }} + steps: + - name: Resolve mobile app artifacts + id: plan + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || '' }} + WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha || '' }} + INPUT_SHA: ${{ inputs.sha || '' }} + INPUT_PLATFORM: ${{ inputs.platform || 'all' }} + DEFAULT_SHA: ${{ github.sha }} + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + import subprocess + import sys + + repo = os.environ["GH_REPO"] + requested_platform = os.environ.get("INPUT_PLATFORM") or "all" + sha = os.environ.get("WORKFLOW_RUN_SHA") or os.environ.get("INPUT_SHA") or os.environ["DEFAULT_SHA"] + event_run_id = os.environ.get("WORKFLOW_RUN_ID") + event_name = os.environ.get("EVENT_NAME") + requested = { + "android": requested_platform in {"all", "android"}, + "ios": requested_platform in {"all", "ios"}, + } + + def gh_json(args): + output = subprocess.check_output(["gh", *args], text=True) + return json.loads(output) if output else None + + def artifact_names(run_id): + output = subprocess.check_output( + [ + "gh", + "api", + f"repos/{repo}/actions/runs/{run_id}/artifacts", + "--paginate", + "--jq", + ".artifacts[].name", + ], + text=True, + ) + return {line.strip() for line in output.splitlines() if line.strip()} + + def artifact_builders_succeeded(run_id): + data = gh_json(["run", "view", str(run_id), "--json", "jobs"]) + for job in data.get("jobs", []): + if job.get("name") == "artifact-builders": + return job.get("conclusion") == "success" + return False + + def complete_for_request(run_id): + names = artifact_names(run_id) + return { + "android": requested["android"] and "react-native-mobile-android-app-android-x86_64" in names, + "ios": requested["ios"] and "react-native-mobile-ios-app" in names, + } + + candidate_ids = [] + if event_name == "workflow_run" and event_run_id: + candidate_ids.append(event_run_id) + else: + runs = gh_json( + [ + "run", + "list", + "--workflow", + "ci.yml", + "--commit", + sha, + "--limit", + "20", + "--json", + "databaseId,status,conclusion", + ] + ) + candidate_ids.extend( + str(run["databaseId"]) + for run in runs + if run.get("status") == "completed" and run.get("conclusion") == "success" + ) + + selected_run = "" + selected = {"android": False, "ios": False} + for run_id in candidate_ids: + if not artifact_builders_succeeded(run_id): + continue + available = complete_for_request(run_id) + if event_name == "workflow_run": + matched = any(available.values()) + else: + matched = all( + available[platform] + for platform, wanted in requested.items() + if wanted + ) + if matched: + selected_run = str(run_id) + selected = available + break + + if not selected_run: + if event_name == "workflow_run": + print(f"::notice::Builds run for {sha} did not publish requested mobile app artifacts; skipping Mobile E2E.") + selected_run = event_run_id or "" + else: + print(f"No successful Builds run for {sha} contains requested mobile app artifacts.", file=sys.stderr) + raise SystemExit(1) + + missing = [platform for platform, wanted in requested.items() if wanted and not selected[platform]] + if event_name != "workflow_run" and missing: + print(f"Requested Mobile E2E platform artifacts are missing for {sha}: {', '.join(missing)}", file=sys.stderr) + raise SystemExit(1) + + print(f"sha={sha}") + print(f"run_id={selected_run}") + print(f"android={str(selected['android']).lower()}") + print(f"ios={str(selected['ios']).lower()}") + PY + + android: + name: android-installed-app + needs: + - resolve + if: ${{ needs.resolve.outputs.android == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 1 + persist-credentials: false + + - name: Set up Node and pnpm + uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + + - name: Set up Android + uses: ./.github/actions/setup-android + + - name: Set up Maestro + uses: ./.github/actions/setup-maestro + + - name: Download Android app artifact + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p target/mobile-build/react-native/android + gh run download '${{ needs.resolve.outputs.run_id }}' \ + --name react-native-mobile-android-app-android-x86_64 \ + --dir target/mobile-build/react-native/android + find target/mobile-build/react-native/android -maxdepth 2 -type f -print + + - name: Start Android emulator + run: tools/dev/start-android-emulator-ci.sh + + - name: Run Android installed-app E2E + env: + OLIPHAUNT_EXPO_ANDROID_BUILD_ARTIFACT_DIR: ${{ github.workspace }}/target/mobile-build/react-native/android + OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release + OLIPHAUNT_EXPO_ANDROID_LIFECYCLE_SMOKE: "0" + OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER: maestro + run: bash src/sdks/react-native/tools/mobile-e2e.sh android + + - name: Upload Android E2E reports + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: react-native-mobile-android-e2e-reports + path: | + target/mobile/react-native/android-e2e/reports + target/mobile/react-native/android-e2e/logs + ${{ runner.temp }}/oliphaunt-android-emulator.log + if-no-files-found: ignore + + ios: + name: ios-installed-app + needs: + - resolve + if: ${{ needs.resolve.outputs.ios == 'true' }} + runs-on: macos-26 + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 1 + persist-credentials: false + + - name: Set up Node and pnpm + uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + + - name: Set up Apple + uses: ./.github/actions/setup-apple + + - name: Set up Maestro + uses: ./.github/actions/setup-maestro + + - name: Download iOS app artifact + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p target/mobile-build/react-native/ios + gh run download '${{ needs.resolve.outputs.run_id }}' \ + --name react-native-mobile-ios-app \ + --dir target/mobile-build/react-native/ios + find target/mobile-build/react-native/ios -maxdepth 2 -print + + - name: Run iOS installed-app E2E + env: + OLIPHAUNT_EXPO_IOS_BUILD_ARTIFACT_DIR: ${{ github.workspace }}/target/mobile-build/react-native/ios + OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release + OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator + OLIPHAUNT_EXPO_IOS_LIFECYCLE_SMOKE: "0" + OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER: maestro + run: bash src/sdks/react-native/tools/mobile-e2e.sh ios + + - name: Upload iOS E2E reports + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: react-native-mobile-ios-e2e-reports + path: | + target/mobile/react-native/ios-e2e/reports + target/mobile/react-native/ios-e2e/logs + target/mobile/react-native/ios-e2e/*.log + if-no-files-found: ignore + + required: + name: required + if: ${{ always() }} + needs: + - resolve + - android + - ios + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check Mobile E2E jobs + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: | + python3 - <<'PY' + import json + import sys + + needs = json.loads(__import__("os").environ["NEEDS_JSON"]) + failures = [] + for job, info in needs.items(): + result = info.get("result") + if result in {"success", "skipped"}: + continue + failures.append(f"{job}={result or 'missing'}") + if failures: + print("Mobile E2E failures: " + ", ".join(failures), file=sys.stderr) + raise SystemExit(1) + print("Mobile E2E gate passed") + PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4432219..5520de28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,16 +24,40 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + CANONICAL_RELEASE_REPOSITORY: f0rr0/oliphaunt + NODE_VERSION: 22.22.3 + PNPM_VERSION: 11.5.0 + NPM_VERSION: 11.5.1 + DENO_VERSION: v2.8.1 + BUN_VERSION: 1.3.14 + +defaults: + run: + shell: bash jobs: + release-identity: + name: Validate release identity + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require canonical release repository + run: | + if [[ "${GITHUB_REPOSITORY}" != "${CANONICAL_RELEASE_REPOSITORY}" ]]; then + echo "Release workflow is pinned to ${CANONICAL_RELEASE_REPOSITORY}; got ${GITHUB_REPOSITORY}" >&2 + exit 1 + fi + prepare-release-pr: name: Prepare release PR + needs: release-identity runs-on: ubuntu-latest timeout-minutes: 20 - if: ${{ github.repository == 'f0rr0/oliphaunt' && inputs.operation == 'prepare-release-pr' }} + if: ${{ inputs.operation == 'prepare-release-pr' }} environment: release-pr permissions: contents: write + issues: write pull-requests: write steps: - name: Require main @@ -49,58 +73,55 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools - with: - cache: "false" + - name: Set up Moon + uses: ./.github/actions/setup-moon - - name: Create or update release PR - id: release_plz_pr - uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 - with: - command: release-pr - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + - name: Validate release metadata + run: | + tools/release/release.py check - - name: Refresh release PR example lockfiles - if: ${{ steps.release_plz_pr.outputs.pr != '' && steps.release_plz_pr.outputs.pr != 'null' }} + - name: Require release PR token env: - GH_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} - PR: ${{ steps.release_plz_pr.outputs.pr }} + RELEASE_PR_TOKEN: ${{ secrets.RELEASE_PR_TOKEN }} run: | - set -euo pipefail - - pr_number="$(jq -r '.number // empty' <<< "${PR}")" - if [[ -z "${pr_number}" ]]; then - echo "release-plz did not return a release PR; skipping example lockfile refresh" - exit 0 + if [[ -z "${RELEASE_PR_TOKEN}" ]]; then + echo "RELEASE_PR_TOKEN is required so generated release PRs trigger normal PR CI." >&2 + echo "Configure a GitHub App or maintainer bot token in the release-pr environment." >&2 + exit 1 fi - gh auth setup-git - gh pr checkout "${pr_number}" - scripts/sync-example-lockfiles.py + - name: Create or update release-please PR + id: release_please + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 + with: + token: ${{ secrets.RELEASE_PR_TOKEN }} + target-branch: main + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true - if git diff --quiet -- examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock; then - echo "example lockfiles already current" - exit 0 + - name: Report release-please PR result + run: | + if [[ "${{ steps.release_please.outputs.prs_created }}" == "true" ]]; then + echo "release-please created or updated a release PR." + else + echo "release-please found no releasable changes." fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock - git commit -m "chore(release): refresh example lockfiles" - git push - publish: name: Publish release - runs-on: ubuntu-latest - timeout-minutes: 120 - if: ${{ github.repository == 'f0rr0/oliphaunt' && inputs.operation != 'prepare-release-pr' }} - environment: ${{ inputs.operation == 'publish' && 'crates-io' || 'release-dry-run' }} + needs: + - release-identity + runs-on: macos-latest + timeout-minutes: 240 + if: ${{ inputs.operation != 'prepare-release-pr' }} + environment: ${{ inputs.operation == 'publish' && 'release-publish' || 'release-dry-run' }} permissions: actions: read + attestations: write contents: write id-token: write + pull-requests: read steps: - name: Require main run: | @@ -115,81 +136,445 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Rust tooling - uses: ./.github/actions/setup-rust-tools + - name: Set up Moon + uses: ./.github/actions/setup-moon + + - name: Set up Rust + uses: ./.github/actions/setup-rust with: cache-save-if: "true" - - name: Require successful same-SHA CI workflow + - name: Configure macOS release toolchains + run: | + if [[ -n "${JAVA_HOME_17_X64:-}" ]]; then + echo "JAVA_HOME=${JAVA_HOME_17_X64}" >> "$GITHUB_ENV" + echo "${JAVA_HOME_17_X64}/bin" >> "$GITHUB_PATH" + fi + if [[ -z "${ANDROID_HOME:-}" && -d "$HOME/Library/Android/sdk" ]]; then + echo "ANDROID_HOME=$HOME/Library/Android/sdk" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$HOME/Library/Android/sdk" >> "$GITHUB_ENV" + fi + + - name: Validate release metadata + run: | + tools/release/release.py check + + - name: Enable pnpm for registry release checks + run: | + corepack enable + corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate + npm install --global "npm@${{ env.NPM_VERSION }}" + node --version + npm_version="$(npm --version)" + echo "npm ${npm_version}" + node - "$npm_version" <<'NODE' + const min = [11, 5, 1]; + const version = process.argv[2] ?? ''; + const current = version.split('.').map((part) => Number.parseInt(part, 10)); + const valid = + current.length >= min.length && + current.every(Number.isFinite) && + (current[0] > min[0] || + (current[0] === min[0] && + (current[1] > min[1] || + (current[1] === min[1] && current[2] >= min[2])))); + if (!version || !valid) { + console.error(`npm ${version || ''} is too old for trusted publishing; need >= ${min.join('.')}`); + process.exit(1); + } + NODE + pnpm --version + + - name: Create release-please GitHub releases + id: release_please + if: ${{ inputs.operation == 'publish' }} + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 + with: + token: ${{ secrets.GITHUB_TOKEN }} + target-branch: main + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-pull-request: true + + - name: Plan product releases + id: release_plan + run: | + tools/release/release.py plan --from-product-tags --include-current-tags --head-ref HEAD --format github-output >> "$GITHUB_OUTPUT" + + - name: No package release planned + if: ${{ steps.release_plan.outputs.has_release_changes != 'true' }} + run: echo "No release-affecting product changes were found since the last product tag." + + - name: Check publish environment + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_GPG_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: tools/release/check_publish_environment.py --products-json "${PRODUCTS_JSON}" + + - name: Require same-SHA Builds artifact builder gate + id: builds_artifact_gate + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - run: bash .github/scripts/require-workflow-success.sh CI "$GITHUB_SHA" 7200 + run: bash .github/scripts/require-workflow-success.sh Builds "$GITHUB_SHA" 7200 --job artifact-builders - - name: Validate release changelog - run: .github/scripts/check-release-changelog.sh + - name: Require exact-extension package build artifacts + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.has_extension_products == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} + run: | + bash .github/scripts/require-workflow-success.sh \ + Builds \ + "$GITHUB_SHA" \ + 7200 \ + --run-id "${BUILDS_RUN_ID}" \ + --job artifact-builders \ + --artifact oliphaunt-extension-package-artifacts + + - name: Validate product changelogs + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} + env: + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + run: tools/release/release.py check --products-json "${PRODUCTS_JSON}" - - name: Download release asset and AOT artifacts + - name: Validate product versions and registry state + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: .github/scripts/download-aot-artifacts.sh + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + run: tools/release/release.py check-registries --products-json "${PRODUCTS_JSON}" --head-ref HEAD --require-identities - - name: Validate staged release packages and dry-runs - run: scripts/validate.sh release + - name: Check existing WASIX runtime release tag + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} + id: wasix_runtime_existing_tag + run: tools/release/release.py publish --product liboliphaunt-wasix --step existing-tag --head-ref HEAD --format github-output >> "$GITHUB_OUTPUT" - - name: Confirm release dry-run coverage - if: ${{ inputs.operation == 'publish-dry-run' }} - run: | - echo "scripts/validate.sh release staged the generated release workspace," - echo "dry-ran every internal asset/AOT crate, enforced package sizes," - echo "and attempted the root crate dry-run." - echo "The real publish step uses the same staged Cargo.toml so" - echo "generated payloads are included in the published crates." - echo "Skipping release-plz dry_run because same-release internal crates" - echo "are not present in crates.io until the real publish step." + - name: Check existing WASIX Rust binding release tag + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_oliphaunt_wasix_rust == 'true' }} + id: wasix_rust_existing_tag + run: tools/release/release.py publish --product oliphaunt-wasix-rust --step existing-tag --head-ref HEAD --format github-output >> "$GITHUB_OUTPUT" - - name: Publish with release-plz - if: ${{ inputs.operation == 'publish' }} - id: release_plz_publish - uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 - with: - command: release - manifest_path: target/pglite-oxide/release/workspace/Cargo.toml + - name: Check existing Rust SDK release tag + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_oliphaunt_rust == 'true' }} + id: rust_existing_tag + run: tools/release/release.py publish --product oliphaunt-rust --step existing-tag --head-ref HEAD --format github-output >> "$GITHUB_OUTPUT" + + - name: Download WASIX runtime build artifacts + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} + run: .github/scripts/download-wasix-runtime-build-artifacts.sh - - name: Require release output - if: ${{ inputs.operation == 'publish' && steps.release_plz_publish.outputs.releases_created != 'true' }} + - name: Download WASIX release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} run: | - echo "release-plz completed without creating a release." >&2 - echo "Check that Cargo.toml contains an unpublished version and that release-plz was run without dry_run." >&2 - exit 1 + .github/scripts/download-build-artifacts.sh \ + Builds \ + "$GITHUB_SHA" \ + target/oliphaunt-wasix/release-assets \ + --run-id "$BUILDS_RUN_ID" \ + --job artifact-builders \ + --artifact liboliphaunt-wasix-release-assets - - name: Resolve release tag - if: ${{ inputs.operation == 'publish' }} - id: release_tag + - name: Download exact-extension package artifacts + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.has_extension_products == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} run: | - version="$(cargo metadata --no-deps --format-version 1 \ - --manifest-path target/pglite-oxide/release/workspace/Cargo.toml \ - | jq -r '.packages[] | select(.name == "pglite-oxide") | .version')" - if [[ -z "${version}" || "${version}" == "null" ]]; then - echo "could not resolve pglite-oxide package version" >&2 - exit 1 + .github/scripts/download-build-artifacts.sh \ + Builds \ + "$GITHUB_SHA" \ + target/extension-artifacts \ + --run-id "$BUILDS_RUN_ID" \ + --job artifact-builders \ + --artifact oliphaunt-extension-package-artifacts + + - name: Download SDK package artifacts + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PRODUCT_OLIPHAUNT_RUST: ${{ steps.release_plan.outputs.product_oliphaunt_rust }} + PRODUCT_OLIPHAUNT_SWIFT: ${{ steps.release_plan.outputs.product_oliphaunt_swift }} + PRODUCT_OLIPHAUNT_KOTLIN: ${{ steps.release_plan.outputs.product_oliphaunt_kotlin }} + PRODUCT_OLIPHAUNT_REACT_NATIVE: ${{ steps.release_plan.outputs.product_oliphaunt_react_native }} + PRODUCT_OLIPHAUNT_JS: ${{ steps.release_plan.outputs.product_oliphaunt_js }} + PRODUCT_OLIPHAUNT_WASIX_RUST: ${{ steps.release_plan.outputs.product_oliphaunt_wasix_rust }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} + run: | + download_sdk_artifact() { + local product="$1" + local artifact="$2" + .github/scripts/download-build-artifacts.sh \ + Builds \ + "$GITHUB_SHA" \ + "target/sdk-artifacts/$product" \ + --run-id "$BUILDS_RUN_ID" \ + --job artifact-builders \ + --artifact "$artifact" + } + [ "$PRODUCT_OLIPHAUNT_RUST" != "true" ] || download_sdk_artifact oliphaunt-rust oliphaunt-rust-sdk-package-artifacts + [ "$PRODUCT_OLIPHAUNT_SWIFT" != "true" ] || download_sdk_artifact oliphaunt-swift oliphaunt-swift-sdk-package-artifacts + [ "$PRODUCT_OLIPHAUNT_KOTLIN" != "true" ] || download_sdk_artifact oliphaunt-kotlin oliphaunt-kotlin-sdk-package-artifacts + [ "$PRODUCT_OLIPHAUNT_REACT_NATIVE" != "true" ] || download_sdk_artifact oliphaunt-react-native oliphaunt-react-native-sdk-package-artifacts + [ "$PRODUCT_OLIPHAUNT_JS" != "true" ] || download_sdk_artifact oliphaunt-js oliphaunt-js-sdk-package-artifacts + [ "$PRODUCT_OLIPHAUNT_WASIX_RUST" != "true" ] || download_sdk_artifact oliphaunt-wasix-rust oliphaunt-wasix-rust-package-artifacts + + - name: Download liboliphaunt release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} + run: | + .github/scripts/download-build-artifacts.sh \ + Builds \ + "$GITHUB_SHA" \ + target/liboliphaunt/release-assets \ + --run-id "$BUILDS_RUN_ID" \ + --job artifact-builders \ + --artifact liboliphaunt-native-release-assets + + - name: Set up Deno for TypeScript JSR consumer checks + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_oliphaunt_js == 'true' }} + uses: ./.github/actions/setup-deno + with: + deno-version: ${{ env.DENO_VERSION }} + + - name: Set up Bun for TypeScript npm consumer checks + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_oliphaunt_js == 'true' }} + uses: ./.github/actions/setup-bun + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Download native helper release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && (steps.release_plan.outputs.product_oliphaunt_broker == 'true' || steps.release_plan.outputs.product_oliphaunt_node_direct == 'true') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PRODUCT_OLIPHAUNT_BROKER: ${{ steps.release_plan.outputs.product_oliphaunt_broker }} + PRODUCT_OLIPHAUNT_NODE_DIRECT: ${{ steps.release_plan.outputs.product_oliphaunt_node_direct }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} + run: | + download_helper_artifacts() { + local prefix="$1" + local destination="$2" + .github/scripts/download-build-artifacts.sh \ + Builds \ + "$GITHUB_SHA" \ + "$destination" \ + --run-id "$BUILDS_RUN_ID" \ + --job artifact-builders \ + --artifact "${prefix}-macos-arm64" \ + --artifact "${prefix}-linux-x64-gnu" \ + --artifact "${prefix}-linux-arm64-gnu" \ + --artifact "${prefix}-windows-x64-msvc" + } + if [ "$PRODUCT_OLIPHAUNT_BROKER" = "true" ]; then + download_helper_artifacts \ + oliphaunt-broker-release-assets \ + target/oliphaunt-broker/release-assets + fi + if [ "$PRODUCT_OLIPHAUNT_NODE_DIRECT" = "true" ]; then + download_helper_artifacts \ + oliphaunt-node-direct-release-assets \ + target/oliphaunt-node-direct/release-assets fi - echo "tag=${version}" >> "$GITHUB_OUTPUT" - - name: Package public release assets - if: ${{ inputs.operation == 'publish' }} - run: cargo run -p xtask -- release package-assets + - name: Download Node direct optional npm packages + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }} + run: | + .github/scripts/download-build-artifacts.sh \ + Builds \ + "$GITHUB_SHA" \ + target/oliphaunt-node-direct/npm-packages \ + --run-id "$BUILDS_RUN_ID" \ + --job artifact-builders \ + --artifact oliphaunt-node-direct-npm-package-macos-arm64 \ + --artifact oliphaunt-node-direct-npm-package-linux-x64-gnu \ + --artifact oliphaunt-node-direct-npm-package-linux-arm64-gnu \ + --artifact oliphaunt-node-direct-npm-package-windows-x64-msvc - - name: Upload public release assets - if: ${{ inputs.operation == 'publish' }} + - name: Validate selected release product dry-runs + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' }} + env: + OLIPHAUNT_BROKER_RELEASE_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-broker/release-assets + OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-node-direct/release-assets + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + run: tools/release/release.py publish-dry-run --products-json "${PRODUCTS_JSON}" --head-ref HEAD + + - name: Publish liboliphaunt GitHub release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ steps.release_tag.outputs.tag }} + run: tools/release/release.py publish --product liboliphaunt-native --step github-release-assets --head-ref HEAD + + - name: Publish selected extension GitHub release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRODUCTS_JSON: ${{ steps.release_plan.outputs.extension_products_json }} + run: tools/release/release.py publish --step github-release-assets --products-json "${PRODUCTS_JSON}" --head-ref HEAD + + - name: Attest selected extension release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.has_extension_products == 'true' }} + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 + with: + subject-path: | + target/extension-artifacts/*/release-assets/*.tar.gz + target/extension-artifacts/*/release-assets/*.tar.zst + target/extension-artifacts/*/release-assets/*.zip + target/extension-artifacts/*/release-assets/*.json + target/extension-artifacts/*/release-assets/*.properties + target/extension-artifacts/*/release-assets/*.sha256 + + - name: Attest liboliphaunt release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_native == 'true' }} + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 + with: + subject-path: | + target/liboliphaunt/release-assets/*.tar.gz + target/liboliphaunt/release-assets/*.tar.zst + target/liboliphaunt/release-assets/*.zip + target/liboliphaunt/release-assets/*.tsv + target/liboliphaunt/release-assets/*.sha256 + + - name: Publish Swift SDK GitHub release and SwiftPM tags + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_swift == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product oliphaunt-swift --step github-release --head-ref HEAD + + - name: Publish Kotlin SDK to Maven Central + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_kotlin == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_GPG_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: tools/release/release.py publish --product oliphaunt-kotlin --step maven-central --head-ref HEAD + + - name: Publish React Native package to npm + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_react_native == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product oliphaunt-react-native --step npm --head-ref HEAD + + - name: Publish WASIX runtime crates to crates.io + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product liboliphaunt-wasix --step crates-io --head-ref HEAD + + - name: Publish WASIX Rust binding to crates.io + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_wasix_rust == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product oliphaunt-wasix-rust --step crates-io --head-ref HEAD + + - name: Publish Rust SDK to crates.io + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_rust == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product oliphaunt-rust --step crates-io --head-ref HEAD + + - name: Publish broker GitHub release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OLIPHAUNT_BROKER_RELEASE_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-broker/release-assets + run: tools/release/release.py publish --product oliphaunt-broker --step github-release-assets --head-ref HEAD + + - name: Attest broker release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_broker == 'true' }} + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 + with: + subject-path: | + target/oliphaunt-broker/release-assets/*.tar.gz + target/oliphaunt-broker/release-assets/*.zip + target/oliphaunt-broker/release-assets/*.sha256 + + - name: Publish Node direct GitHub release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS: ${{ github.workspace }}/target/oliphaunt-node-direct/release-assets + run: tools/release/release.py publish --product oliphaunt-node-direct --step github-release-assets --head-ref HEAD + + - name: Attest Node direct release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 + with: + subject-path: | + target/oliphaunt-node-direct/release-assets/*.tar.gz + target/oliphaunt-node-direct/release-assets/*.zip + target/oliphaunt-node-direct/release-assets/*.sha256 + + - name: Publish Node direct optional packages to npm + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_node_direct == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product oliphaunt-node-direct --step npm --head-ref HEAD + + - name: Publish TypeScript packages to npm and JSR + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_oliphaunt_js == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product oliphaunt-js --step npm-jsr --head-ref HEAD + + - name: Upload WASIX GitHub release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: tools/release/release.py publish --product liboliphaunt-wasix --step github-release-assets --head-ref HEAD + + - name: Attest WASIX release assets + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' && steps.release_plan.outputs.product_liboliphaunt_wasix == 'true' }} + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 + with: + subject-path: | + target/oliphaunt-wasix/release-assets/*.tar.zst + target/oliphaunt-wasix/release-assets/*.sha256 + + - name: Verify published release + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} run: | - gh release upload "$RELEASE_TAG" \ - target/pglite-oxide/release-assets/*.tar.zst \ - target/pglite-oxide/release-assets/*.sha256 \ - --clobber \ - --repo "$GITHUB_REPOSITORY" + gh auth setup-git + git fetch --force --tags origin + tools/release/release.py verify-release --products-json "${PRODUCTS_JSON}" --head-ref HEAD + + - name: Run consumer shape gates + if: ${{ steps.release_plan.outputs.has_release_changes == 'true' && inputs.operation == 'publish' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRODUCTS_JSON: ${{ steps.release_plan.outputs.products_json }} + run: tools/release/release.py consumer-shape --require-ready --products-json "${PRODUCTS_JSON}" diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 14d9f6e3..e48df653 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,2 +1,2 @@ -# Shared by `scripts/validate.sh workflows` and the workflow-lint CI job. +# Shared by `moon run ci-workflows:check` and the workflow-lint CI job. rules: {} diff --git a/.gitignore b/.gitignore index eb5f90ff..6f2d3cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,20 @@ /target/ +/.moon/cache/ node_modules/ -/scripts/perf/node-bench/node_modules/ /tmp/ -/assets/checkouts/ -/assets/wasix-build/build/ -/assets/wasix-build/work/ -/target/pglite-oxide/ -/crates/assets/assets/ -/crates/aot/*/artifacts/ +/target/oliphaunt-sources/checkouts/ +/target/oliphaunt-wasix/ +/src/target/ +/src/docs/.docusaurus/ +/src/docs/.next/ +/src/docs/.source/ +/src/docs/build/ +/src/docs/out/ +/src/runtimes/liboliphaunt/wasix/crates/assets/assets/ +/src/runtimes/liboliphaunt/wasix/crates/aot/*/artifacts/ +/src/sdks/react-native/ios/vendor/oliphaunt-swift/ +/src/sdks/react-native/ios/vendor/liboliphaunt.xcframework/ **/.DS_Store __pycache__/ # Build artifacts from cargo package -pglite_oxide-*.crates.tar.gz +oliphaunt_wasix-*.crates.tar.gz diff --git a/.lychee.toml b/.lychee.toml new file mode 100644 index 00000000..fafbf350 --- /dev/null +++ b/.lychee.toml @@ -0,0 +1,15 @@ +accept = [200, 206, 429] +exclude = [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", +] +exclude_path = [ + "target", + "node_modules", + "src/runtimes/liboliphaunt/wasix/assets/build", +] +max_retries = 2 +timeout = 20 +user_agent = "oliphaunt-link-check" diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 00000000..d169c907 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,19 @@ +{ + "globs": [ + "README.md", + "docs/**/*.md", + "src/**/README.md", + "src/sdks/react-native/examples/expo/README.md", + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md" + ], + "ignores": ["target/**", "**/node_modules/**", "src/runtimes/liboliphaunt/wasix/assets/build/**"], + "config": { + "default": true, + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD033": false, + "MD041": false + } +} diff --git a/.moon/toolchains.yml b/.moon/toolchains.yml new file mode 100644 index 00000000..f8ac0eda --- /dev/null +++ b/.moon/toolchains.yml @@ -0,0 +1,18 @@ +$schema: "https://moonrepo.dev/schemas/toolchains.json" + +proto: + version: "0.57.4" + +javascript: + packageManager: "pnpm" + installDependencies: false + syncProjectWorkspaceDependencies: false + +node: + versionFromPrototools: true + +pnpm: + versionFromPrototools: true + +rust: + versionFromPrototools: false diff --git a/.moon/workspace.yml b/.moon/workspace.yml new file mode 100644 index 00000000..d697fd16 --- /dev/null +++ b/.moon/workspace.yml @@ -0,0 +1,44 @@ +$schema: "https://moonrepo.dev/schemas/workspace.json" + +projects: + globFormat: "source-path" + globs: + - "moon.yml" + - "benchmarks/moon.yml" + - "examples/moon.yml" + - "src/*/moon.yml" + - "src/bindings/*/moon.yml" + - "src/extensions/artifacts/*/moon.yml" + - "src/extensions/contrib/moon.yml" + - "src/extensions/contrib/*/moon.yml" + - "src/extensions/external/*/moon.yml" + - "src/extensions/model/moon.yml" + - "src/postgres/versions/*/moon.yml" + - "src/runtimes/*/moon.yml" + - "src/runtimes/liboliphaunt/*/moon.yml" + - "src/sdks/*/moon.yml" + - "src/sources/*/moon.yml" + - "src/sources/third-party/*/moon.yml" + - "src/shared/*/moon.yml" + - "tools/*/moon.yml" + sources: + ci-workflows: ".github" + +defaultProject: "oliphaunt-rust" + +pipeline: + installDependencies: false + cacheLifetime: "7 days" + logRunningCommand: true + +vcs: + provider: "github" + defaultBranch: "main" + +telemetry: false + +experiments: + asyncAffectedTracking: true + asyncGraphBuilding: true + casOutputsCache: true + nativeFileHashing: true diff --git a/.prototools b/.prototools new file mode 100644 index 00000000..e04ca5b7 --- /dev/null +++ b/.prototools @@ -0,0 +1,5 @@ +moon = "2.3.2" +node = "22.22.3" +pnpm = "11.5.0" +bun = "1.3.14" +deno = "2.8.1" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..1f4c7c96 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,51 @@ +{ + "src/runtimes/liboliphaunt/native": "0.1.0", + "src/sdks/rust": "0.1.0", + "src/runtimes/broker": "0.1.0", + "src/runtimes/node-direct": "0.1.0", + "src/sdks/swift": "0.6.0", + "src/sdks/kotlin": "0.1.0", + "src/sdks/react-native": "0.1.0", + "src/sdks/js": "0.1.0", + "src/extensions/contrib/amcheck": "0.1.0", + "src/extensions/contrib/auto_explain": "0.1.0", + "src/extensions/contrib/bloom": "0.1.0", + "src/extensions/contrib/btree_gin": "0.1.0", + "src/extensions/contrib/btree_gist": "0.1.0", + "src/extensions/contrib/citext": "0.1.0", + "src/extensions/contrib/cube": "0.1.0", + "src/extensions/contrib/dict_int": "0.1.0", + "src/extensions/contrib/dict_xsyn": "0.1.0", + "src/extensions/contrib/earthdistance": "0.1.0", + "src/extensions/contrib/file_fdw": "0.1.0", + "src/extensions/contrib/fuzzystrmatch": "0.1.0", + "src/extensions/contrib/hstore": "0.1.0", + "src/extensions/contrib/intarray": "0.1.0", + "src/extensions/contrib/isn": "0.1.0", + "src/extensions/contrib/lo": "0.1.0", + "src/extensions/contrib/ltree": "0.1.0", + "src/extensions/contrib/pageinspect": "0.1.0", + "src/extensions/contrib/pg_buffercache": "0.1.0", + "src/extensions/contrib/pg_freespacemap": "0.1.0", + "src/extensions/contrib/pg_surgery": "0.1.0", + "src/extensions/contrib/pg_trgm": "0.1.0", + "src/extensions/contrib/pg_visibility": "0.1.0", + "src/extensions/contrib/pg_walinspect": "0.1.0", + "src/extensions/contrib/pgcrypto": "0.1.0", + "src/extensions/contrib/seg": "0.1.0", + "src/extensions/contrib/tablefunc": "0.1.0", + "src/extensions/contrib/tcn": "0.1.0", + "src/extensions/contrib/tsm_system_rows": "0.1.0", + "src/extensions/contrib/tsm_system_time": "0.1.0", + "src/extensions/contrib/unaccent": "0.1.0", + "src/extensions/contrib/uuid_ossp": "0.1.0", + "src/extensions/external/pg_hashids": "0.1.0", + "src/extensions/external/pg_ivm": "0.1.0", + "src/extensions/external/pg_textsearch": "0.1.0", + "src/extensions/external/pg_uuidv7": "0.1.0", + "src/extensions/external/pgtap": "0.1.0", + "src/extensions/external/postgis": "0.1.0", + "src/extensions/external/vector": "0.1.0", + "src/runtimes/liboliphaunt/wasix": "0.6.0", + "src/bindings/wasix-rust/crates/oliphaunt-wasix": "0.6.0" +} diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 00000000..af430982 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,19 @@ +[files] +extend-exclude = [ + "target/**", + "**/node_modules/**", + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/**", + "src/runtimes/liboliphaunt/native/patches/**", + "src/runtimes/liboliphaunt/native/postgres18/**", +] + +[default] +extend-ignore-re = [ + "PNPM", + "WASIX", +] + +[default.extend-words] +oliphaunt = "oliphaunt" +wasix = "wasix" +ser = "ser" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51693f81..8f156975 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,9 +5,12 @@ Run the same gates as CI before opening a PR: ```sh -scripts/validate.sh ci -scripts/validate.sh release -cargo deny check +pnpm doctor +pnpm fmt:check +pnpm check +pnpm test +pnpm release-check +tools/release/release.py publish-dry-run ``` The runtime smoke starts embedded Postgres and is intentionally slower than unit tests. @@ -15,29 +18,32 @@ The runtime smoke starts embedded Postgres and is intentionally slower than unit Install local hooks with: ```sh -scripts/install-hooks.sh +tools/dev/install-hooks.sh ``` Hooks stay deliberately smaller than CI: pre-commit handles file hygiene and -formatting, while pre-push runs whitespace diff checking, clippy, and -`scripts/validate.sh test`. That test gate compiles runtime tests when host AOT -artifacts are unavailable and runs them when artifacts have been materialized. -CI repeats those hook checks and remains the source of truth for generated AOT -runtime matrices, packaging, Tauri, frontend, feature combinations, public API -compatibility, and supply-chain checks. +formatting, while commit-msg validates Conventional Commit messages. Run +`pnpm check`, `pnpm test`, and `pnpm release-check` before release-sensitive +PRs. CI remains the source of truth for generated AOT runtime matrices, +packaging, Tauri, frontend, feature combinations, public API compatibility, and +supply-chain checks. In GitHub branch protection, require the aggregate `Required checks` status and -the Conventional Commit status before merging. Local hooks are convenience -checks and can be skipped; CI is authoritative. +the `release-intent` job before merging. Local hooks are convenience checks and +can be skipped; CI is authoritative. ## Assets -Bundled runtime assets must stay aligned with `docs/ASSETS.md`. If the WASI runtime -changes, update the asset metadata in `Cargo.toml` and run the full local checks. +Bundled runtime assets must stay aligned with product-local runtime metadata +under `src/runtimes/` and extension metadata under `src/extensions/`. If a +runtime or extension artifact target changes, update the owning product +metadata and run the affected Moon checks. ## Releases Releases are manual and must be dispatched from `main` through the GitHub -Actions `Release` workflow. release-plz owns version bumps, changelog updates, -tags, GitHub releases, and crates.io publishing. See `docs/RELEASE.md` for the -release-intent, Trusted Publishing, and manual workflow details. +Actions `Release` workflow. release-please manifest mode owns version bumps, +changelog updates, release PRs, and tags. Product-local release metadata owns +publish targets and artifact shape; Moon dependency scopes provide release +coupling. See `docs/maintainers/release.md` for release intent, trusted +publishing, and manual workflow details. diff --git a/Cargo.lock b/Cargo.lock index c858cd8d..4476696a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -153,6 +162,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "2.0.1" @@ -477,6 +492,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -645,6 +666,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.20.11" @@ -778,6 +826,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -787,6 +845,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -859,7 +928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -923,6 +992,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -1029,12 +1122,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -1094,6 +1205,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1745,6 +1866,17 @@ dependencies = [ "redox_syscall 0.8.1", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libtest-mimic" version = "0.8.2" @@ -1821,9 +1953,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lz4_flex" @@ -2091,6 +2223,113 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "oliphaunt" +version = "0.1.0" +dependencies = [ + "crossbeam-channel", + "ed25519-dalek", + "flate2", + "fs2", + "futures-util", + "getrandom 0.3.4", + "libloading", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "tar", + "tokio", + "tokio-postgres", + "toml 0.9.12+spec-1.1.0", + "ureq", + "zip", + "zstd", +] + +[[package]] +name = "oliphaunt-broker" +version = "0.1.0" +dependencies = [ + "oliphaunt", +] + +[[package]] +name = "oliphaunt-perf" +version = "0.0.0" +dependencies = [ + "anyhow", + "directories", + "futures-util", + "oliphaunt", + "oliphaunt-wasix", + "rusqlite", + "serde", + "serde_json", + "sqlx", + "tar", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "oliphaunt-wasix" +version = "0.6.0" +dependencies = [ + "anyhow", + "async-trait", + "directories", + "dunce", + "filetime", + "flate2", + "hex", + "oliphaunt-wasix-aot-aarch64-apple-darwin", + "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-wasix-assets", + "regex", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "tar", + "tempfile", + "tokio", + "tokio-postgres", + "tracing", + "wasmer", + "wasmer-config", + "wasmer-types", + "wasmer-wasix", + "webc", + "zstd", +] + +[[package]] +name = "oliphaunt-wasix-aot-aarch64-apple-darwin" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-aot-x86_64-pc-windows-msvc" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-assets" +version = "0.6.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2168,64 +2407,6 @@ dependencies = [ "serde", ] -[[package]] -name = "pglite-oxide" -version = "0.5.1" -dependencies = [ - "anyhow", - "async-trait", - "directories", - "dunce", - "filetime", - "flate2", - "hex", - "pglite-oxide-aot-aarch64-apple-darwin", - "pglite-oxide-aot-aarch64-unknown-linux-gnu", - "pglite-oxide-aot-x86_64-pc-windows-msvc", - "pglite-oxide-aot-x86_64-unknown-linux-gnu", - "pglite-oxide-assets", - "regex", - "serde", - "serde_json", - "sha2 0.10.9", - "sqlx", - "tar", - "tempfile", - "tokio", - "tokio-postgres", - "tracing", - "wasmer", - "wasmer-config", - "wasmer-types", - "wasmer-wasix", - "webc", - "zstd", -] - -[[package]] -name = "pglite-oxide-aot-aarch64-apple-darwin" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-aot-aarch64-unknown-linux-gnu" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-aot-x86_64-pc-windows-msvc" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-aot-x86_64-unknown-linux-gnu" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-assets" -version = "0.5.1" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "phf" version = "0.13.1" @@ -2301,6 +2482,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2322,7 +2513,7 @@ dependencies = [ "base64", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "hmac 0.13.0", "md-5 0.11.0", "memchr", @@ -2338,7 +2529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" dependencies = [ "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "postgres-protocol", ] @@ -2690,6 +2881,20 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.8.16" @@ -2720,6 +2925,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.12.1", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -2754,6 +2973,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2987,6 +3241,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3050,6 +3310,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.8.6" @@ -3414,7 +3684,7 @@ dependencies = [ "async-trait", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "futures-channel", "futures-util", "log", @@ -3626,12 +3896,33 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unty" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -3673,6 +3964,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -4380,6 +4677,24 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.2.1" @@ -4418,6 +4733,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4427,6 +4758,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -4721,16 +5058,11 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", - "directories", - "futures-util", - "pglite-oxide", "serde", "serde_json", "sha2 0.10.9", - "sqlx", "tar", "tokio", - "tokio-postgres", "toml 0.9.12+spec-1.1.0", "walkdir", "wasmer", @@ -4811,6 +5143,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" @@ -4844,12 +5182,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 8a3428db..37da6eed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,123 +1,22 @@ -[package] -name = "pglite-oxide" -version = "0.5.1" -edition = "2024" -rust-version = "1.93" -description = "Embedded Postgres for Rust tests and local apps. No Docker, works with SQLx and any Postgres client." -readme = "README.md" -repository = "https://github.com/f0rr0/pglite-oxide" -homepage = "https://github.com/f0rr0/pglite-oxide" -documentation = "https://docs.rs/pglite-oxide" -keywords = ["postgres", "pglite", "wasm", "database", "embedded"] -categories = ["database-implementations", "wasm", "development-tools::testing"] -license = "MIT AND Apache-2.0 AND PostgreSQL" -exclude = [ - ".github/**", - "Cargo.toml.orig", - "assets/checkouts/**", - "assets/wasix-build/build/**", - "assets/wasix-build/work/**", - "crates/**", - "examples/tauri-sqlx-vanilla/**", - "release-plz.toml", - "xtask/**", -] - [workspace] members = [ - ".", - "crates/assets", - "crates/aot/aarch64-apple-darwin", - "crates/aot/x86_64-unknown-linux-gnu", - "crates/aot/aarch64-unknown-linux-gnu", - "crates/aot/x86_64-pc-windows-msvc", - "xtask", + "src/bindings/wasix-rust/crates/oliphaunt-wasix", + "src/sdks/rust", + "src/runtimes/broker", + "src/runtimes/liboliphaunt/wasix/crates/assets", + "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin", + "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu", + "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu", + "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc", + "tools/perf/runner", + "tools/xtask", ] -exclude = ["examples/tauri-sqlx-vanilla/src-tauri"] +exclude = ["src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri"] resolver = "3" -[features] -default = ["bundled", "extensions"] -bundled = [ - "dep:pglite-oxide-assets", - "dep:pglite-oxide-aot-aarch64-apple-darwin", - "dep:pglite-oxide-aot-x86_64-unknown-linux-gnu", - "dep:pglite-oxide-aot-aarch64-unknown-linux-gnu", - "dep:pglite-oxide-aot-x86_64-pc-windows-msvc", -] -extensions = ["bundled"] - -[package.metadata.pglite-oxide.assets] -postgres-version = "17.5" -postgres-pglite-branch = "REL_17_5-pglite" -pglite-build-repo = "electric-sql/pglite-build" -pglite-build-branch = "portable" -pglite-build-commit = "c195113dbaf09488f8d5eeb2db91dacd123b74d0" -pglite-npm-version-checked = "0.4.5" -runtime-archive-sha256 = "8c2271c53d4f2786f7406ec0211e679e1a13352fc14ae87215ae3569b547fc1f" -pglite-wasix-sha256 = "4ce77a543675b25a5b1fd93c62bd175576bf1fee0266b9fd96fac193bf13b811" -pgdata-template-archive-sha256 = "a0a91f4fbd0428787ce78b351ee84f0c33f9ce8578448701b0f6080f7d8b052e" -pg-dump-wasix-sha256 = "59482f1193c35147c1b50e6a5fd9bc2dfed4b15e0624102c4da93a266d8303ed" -initdb-wasix-sha256 = "" - -[dependencies] -anyhow = "1" -async-trait = "0.1" -tar = "0.4" -zstd = { version = "0.13", default-features = false } -directories = "6" -tracing = "0.1" -flate2 = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -regex = "1" -tempfile = "3" -hex = "0.4" -sha2 = "0.10" -dunce = "1" -filetime = "0.2" -pglite-oxide-assets = { version = "=0.5.1", path = "crates/assets", optional = true } -tokio = { version = "1", features = ["io-util", "rt-multi-thread"] } -wasmer = { version = "=7.2.0-alpha.3", default-features = false, features = [ - "sys", - "headless", - "compiler", - "wasmer-artifact-load", -] } -wasmer-config = "=0.702.0-alpha.3" -wasmer-types = "=7.2.0-alpha.3" -wasmer-wasix = { version = "=0.702.0-alpha.3", default-features = false, features = [ - "sys-minimal", - "sys-poll", - "host-vnet", - "time", -] } -webc = "=12.0.0" - -[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies] -pglite-oxide-aot-aarch64-apple-darwin = { version = "=0.5.1", path = "crates/aot/aarch64-apple-darwin", optional = true } - -[target.'cfg(all(target_os = "linux", target_arch = "x86_64"))'.dependencies] -pglite-oxide-aot-x86_64-unknown-linux-gnu = { version = "=0.5.1", path = "crates/aot/x86_64-unknown-linux-gnu", optional = true } - -[target.'cfg(all(target_os = "linux", target_arch = "aarch64"))'.dependencies] -pglite-oxide-aot-aarch64-unknown-linux-gnu = { version = "=0.5.1", path = "crates/aot/aarch64-unknown-linux-gnu", optional = true } - -[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] -pglite-oxide-aot-x86_64-pc-windows-msvc = { version = "=0.5.1", path = "crates/aot/x86_64-pc-windows-msvc", optional = true } - -[dev-dependencies] -sqlx = { version = "0.8", default-features = false, features = [ - "postgres", - "runtime-tokio", -] } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -tokio-postgres = "0.7" - -[[bin]] -name = "pglite-dump" -path = "src/bin/pglite_dump.rs" - -[[bin]] -name = "pglite-proxy" -path = "src/bin/pglite_proxy.rs" +[workspace.package] +edition = "2024" +rust-version = "1.93" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +license = "MIT AND Apache-2.0 AND PostgreSQL" diff --git a/LICENSE b/LICENSE index 4780985e..ac7484ea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 pglite-oxide Contributors +Copyright (c) 2024 oliphaunt-wasix Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..95cc0807 --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +// SwiftPM is the public Apple SDK entrypoint. Release automation tags this +// root package and pairs it with checksum-covered liboliphaunt-native-v assets. +let package = Package( + name: "Oliphaunt", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library(name: "Oliphaunt", targets: ["Oliphaunt"]) + ], + targets: [ + .target( + name: "COliphaunt", + path: "src/sdks/swift/Sources/COliphaunt", + publicHeadersPath: "include" + ), + .target( + name: "Oliphaunt", + dependencies: ["COliphaunt"], + path: "src/sdks/swift/Sources/Oliphaunt" + ), + .testTarget( + name: "OliphauntTests", + dependencies: ["Oliphaunt"], + path: "src/sdks/swift/Tests/OliphauntTests" + ) + ] +) diff --git a/README.md b/README.md index 62daec96..ed9c78a3 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,156 @@ -

- pglite-oxide logo -

- -

pglite-oxide

- -

- Embedded Postgres for Rust tests and local apps.
- Real PostgreSQL. Instant testing. Packaged runtime. Direct Rust API or a local Postgres URL. -

- -

- Usage - · - Performance - · - Extensions - · - Dump & Upgrade - · - Testing - · - Tauri -

- -

- CI - crates.io - docs.rs - MSRV - License -

- -`pglite-oxide` brings PGlite/Postgres to Rust with a small API. Open a database -directly with `Pglite`, or hand `PgliteServer` to SQLx and any standard -Postgres client. The packaged runtime is PostgreSQL 17.5. No local Postgres -install, no Docker, no runtime build toolchain. - -## Add Postgres In One Minute ⚡ - -Already using SQLx or another Postgres client? Add the crate and point your -client at an embedded database URL: +# oliphaunt + +Native-first embedded PostgreSQL for application developers who want PostgreSQL +semantics without running a separate database service. + +The long-term product is a small family of native SDKs over the same engine: + +- direct embedded mode for the lowest-latency in-process Tauri and Rust desktop + apps; +- broker mode for robust desktop apps that need crash isolation today. The + durable multi-root daemon is the longer-term broker shape and is not + advertised as available until it exists; +- server mode for real PostgreSQL client compatibility with `psql`, `pg_dump`, + ORMs, and connection pools. + +This repository now has two product lanes: + +- `liboliphaunt`: the C ABI boundary over embedded PostgreSQL 18. +- `oliphaunt`: the Rust SDK built on that native boundary. + +The existing `oliphaunt-wasix` WASIX release lane is preserved in +`src/bindings/wasix-rust/crates/oliphaunt-wasix` while native parity is built out. It remains separate from +the native SDK so we can keep the legacy release path stable without shaping the +new architecture around it. Native Rust APIs are not routed through +`oliphaunt-wasix`; they live in `oliphaunt`. + +SDK ownership is explicit. Rust is the SDK for Tauri and Rust desktop apps, +Swift is the SDK for iOS and macOS apps, Kotlin is the SDK for Android apps, +and React Native is the TypeScript/TurboModule SDK over the Swift and Kotlin +SDKs. TypeScript is the SDK for Node.js, Bun, Deno, and Tauri JavaScript apps. +SDK features should have parity where the platform can support them honestly; +platform support is summarized in the +[`Capability Matrix`](src/docs/content/reference/capabilities.md), +with the maintainer contract in +[`SDK Parity`](docs/maintainers/sdk-parity-policy.md). + +## Layout + +- `src/runtimes/liboliphaunt/native/`: C ABI, PostgreSQL 18 source pin, patch stack, native build and + smoke harnesses. +- `src/sdks/rust/`: native Rust SDK surface. +- `src/bindings/wasix-rust/crates/oliphaunt-wasix/`: existing WASIX-based Rust package. +- `src/runtimes/liboliphaunt/wasix/crates/assets/` and `src/runtimes/liboliphaunt/wasix/crates/aot/`: packaged WASIX release assets. +- `src/sdks/swift/`, `src/sdks/kotlin/`, `src/sdks/react-native/`, + and `src/sdks/js/`: platform and runtime SDKs. +- `tools/policy/sdk-manifest.toml`: SDK ownership registry used by parity checks. +- `tools/`: repo automation, including `xtask` and validation scripts. +- `benchmarks/`: benchmark plans and future cross-engine harnesses. +- `src/docs/`: public Fumadocs/Next docs product, generated matrices, + tested snippets, API-reference stubs, and LLM docs. +- Public SDK docs live under `src/docs/content/sdk/`; product roots + keep only package README/CHANGELOG files and source-adjacent API comments. +- `docs/`: architecture, release, development, maintainer, and internal source + material. +- `docs/internal/`: maintainer-only progress notes and generated patch-stack + audits. + +See `docs/maintainers/repo-structure.md` for the repository policy and the evidence behind +the layout. + +## Current Native Status + +The native track is usable as an active development lane, not yet a default +release replacement: + +- macOS arm64 native `liboliphaunt` builds against PostgreSQL 18.4; +- the C smoke opens, executes raw protocol queries, recovers after SQL errors, + streams a large protocol response, closes, and reopens the same PGDATA from a + new process; +- the Rust SDK for Tauri and Rust desktop apps exposes `NativeDirect`, + `NativeBroker`, and `NativeServer`; +- broker mode uses Unix-domain sockets on Unix platforms, with explicit TCP + fallback for portability and debugging, and enforces the selected bootstrap + policy inside the helper; +- direct and broker expose same-version physical backup/restore, while server + mode also exposes logical SQL backup through packaged `pg_dump`; +- the gated native extension matrix creates or loads release-ready PostgreSQL 18 + extensions by exact SQL name, then verifies restart and physical restore + through broker/direct-C-ABI and server paths; +- Rust, Swift, Kotlin, React Native, and TypeScript SDK lanes track the same product + concepts where platform constraints allow it, with platform status summarized + in `src/docs/content/reference/capabilities.md`; +- the benchmark matrix measures native direct, broker, server, native + PostgreSQL controls, and SQLite comparison data without entering the legacy + WASIX release lane. + +Maintainers track release-claim evidence and open blocker audits in +[docs/internal/OLIPHAUNT_TRACK_REVIEW.md](docs/internal/OLIPHAUNT_TRACK_REVIEW.md). + +## Common Commands ```sh -cargo add pglite-oxide +moon query projects +moon query tasks +moon run repo:check +moon run :check +moon run :test +moon run :package +moon run :coverage +moon run liboliphaunt-native:test +moon run oliphaunt-react-native:smoke-mobile +moon run oliphaunt-js:check ``` -```rust,no_run -use pglite_oxide::PgliteServer; -use sqlx::{Connection, Row}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let server = PgliteServer::temporary_tcp()?; - // For a persistent TCP server: - // let server = PgliteServer::builder().path("./.pglite").start()?; - let mut conn = sqlx::PgConnection::connect(&server.database_url()).await?; - - let row = sqlx::query("SELECT $1::int4 + 1 AS answer") - .bind(41_i32) - .fetch_one(&mut conn) - .await?; - assert_eq!(row.try_get::("answer")?, 42); - - conn.close().await?; - server.shutdown()?; - Ok(()) -} +Moon is the contributor command surface. `.prototools` pins Moon, Node, pnpm, +Bun, and Deno. Use pnpm to install JavaScript workspace dependencies when +working on JavaScript-family projects; do not use it as a repo-wide task +router. Bun is required for the +TypeScript SDK check because Bun installs `@oliphaunt/ts` from npm; Deno is +used by strict JSR consumer-release gates. + +React Native installed-app validation uses the Expo development-client example +as the default harness because the package always exercises custom Swift/Kotlin +native code. `moon run oliphaunt-react-native:smoke-android`, +`moon run oliphaunt-react-native:smoke-ios`, and +`moon run oliphaunt-react-native:smoke-mobile` run the installed app lanes. +`moon run oliphaunt-react-native:check` is the package-only TypeScript, +Codegen, and native-source lane. `moon run +oliphaunt-js:check` validates the desktop JavaScript SDK, including npm and JSR +package shape. + +For liboliphaunt work, use the product Moon tasks above. Product inner loops +should use `moon run :check` and `moon run :test`; CI lanes +use `moon ci` through `.github/scripts/run-moon-ci.sh`. + +## Native Performance Matrix + +After building `liboliphaunt`, run: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh ``` -That's it. Real PostgreSQL, no service setup. - -## Why pglite-oxide ✨ - -Postgres should be as easy to add to a Rust project as SQLite. - -- ⚡ **No service tax**: no Docker, no local Postgres, no testcontainers. -- 🔌 **Use your real stack**: SQLx, `tokio-postgres`, CLIs, and other clients - connect through a normal local URL. -- 🌉 **Proxy included**: expose an embedded database to non-Rust tools with - `pglite-proxy`. -- 🧪 **Clean tests**: temporary databases are isolated, fast, and removed on - drop. -- 💾 **Persistent apps**: keep local app data across restarts when you want it. -- 🧩 **Extensions included**: `pgvector`, `pg_trgm`, `hstore`, `citext`, and - more. -- 📦 **Portable dumps**: use bundled `pg_dump` for logical backups and upgrade - paths. -- 🚀 **Near-native feel**: close to native Postgres, fully embedded. - -## Near-Native Performance 🚀 - -Current local snapshot on `Apple M1 Pro`, `16 GB RAM`, and `macOS 26.4.1`. -Full numbers and reproduction steps live in the -[performance guide](https://github.com/f0rr0/pglite-oxide/blob/main/docs/PERFORMANCE.md). Lower is better. - -| Operation | native pg + SQLx | pglite-oxide + SQLx | vanilla PGlite + SQLx | -|---|---:|---:|---:| -| 25,000 INSERTs in one transaction | 132.36 ms | 149.54 ms | 257.02 ms | -| 25,000 INSERTs in one statement | 46.14 ms | 59.39 ms | 117.19 ms | -| 25,000 INSERTs into an indexed table | 188.72 ms | 253.38 ms | 352.64 ms | -| 5,000 indexed SELECTs | 81.39 ms | 125.31 ms | 203.05 ms | -| 25,000 indexed UPDATEs | 351.05 ms | 578.96 ms | 720.63 ms | - -`pglite-oxide` stays close to native Postgres while running entirely embedded -and consistently performs better than vanilla PGlite. - -## Extensions 🧩 - -Bundled extensions are supported, including `pgvector`, `pg_trgm`, `hstore`, -`citext`, `ltree`, and more. See the -[extensions guide](https://github.com/f0rr0/pglite-oxide/blob/main/docs/EXTENSIONS.md) -for the full catalog and usage details. - -```rust,no_run -use pglite_oxide::{extensions, PgliteServer}; -use sqlx::Connection; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let server = PgliteServer::builder() - .path("./.pglite") - .extension(extensions::VECTOR) - .start()?; - let mut conn = sqlx::PgConnection::connect(&server.database_url()).await?; - - sqlx::query("CREATE TABLE IF NOT EXISTS items (embedding vector(3))") - .execute(&mut conn) - .await?; - sqlx::query("INSERT INTO items VALUES ('[1,2,3]')") - .execute(&mut conn) - .await?; - - conn.close().await?; - server.shutdown()?; - Ok(()) -} +For fast local plumbing checks: + +```sh +cargo build -p oliphaunt-perf +target/debug/oliphaunt-perf native-liboliphaunt --engine direct --suite rtt --iterations 10 +target/debug/oliphaunt-perf native-liboliphaunt --engine broker --suite rtt --iterations 10 +target/debug/oliphaunt-perf native-liboliphaunt --engine server --suite rtt --iterations 10 ``` -## Docs +The native matrix opts in to `perf-runner support explicitly, so ordinary +asset/release automation does not compile legacy WASIX or benchmark-only code. +Use `--quick` for a one-repeat plumbing run and `--plan-only` to inspect the +native-only command plan without checking artifacts or building anything. +Focused diagnostic runs can select one engine or suite without changing the +release default: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh \ + --quick --engines broker --suites streaming +``` -- [Usage guide](https://github.com/f0rr0/pglite-oxide/blob/main/docs/USAGE.md) -- [Extensions](https://github.com/f0rr0/pglite-oxide/blob/main/docs/EXTENSIONS.md) -- [Performance guide](https://github.com/f0rr0/pglite-oxide/blob/main/docs/PERFORMANCE.md) -- [Dump and upgrade guide](https://github.com/f0rr0/pglite-oxide/blob/main/docs/PG_DUMP.md) -- [Testing guide](https://github.com/f0rr0/pglite-oxide/blob/main/docs/TESTING.md) -- [Tauri usage](https://github.com/f0rr0/pglite-oxide/blob/main/docs/TAURI.md) -- [Runtime guide](https://github.com/f0rr0/pglite-oxide/blob/main/docs/RUNTIME.md) +Selector runs are for local evidence and debugging. Release evidence uses the +default all-engine/all-suite matrix. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 575bddf6..4ed084cb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,13 +1,18 @@ # Third-Party Notices -`pglite-oxide` Rust code is licensed under the MIT license in `LICENSE`. +Oliphaunt source code in this repository is licensed under the MIT license in +`LICENSE`. -The bundled PGlite/PostgreSQL runtime assets derive from Electric SQL PGlite and -PostgreSQL: +This file is the repository-level notice index. Product-specific runtime and +packaging notices live next to the product that ships the relevant artifacts: -- PGlite: https://github.com/electric-sql/pglite -- PGlite PostgreSQL fork: https://github.com/electric-sql/postgres-pglite -- PostgreSQL license: https://www.postgresql.org/about/licence/ +- `src/runtimes/liboliphaunt/native/THIRD_PARTY_NOTICES.md` +- `src/bindings/wasix-rust/THIRD_PARTY_NOTICES.md` + +Shared PostgreSQL source pins, third-party source pins, and extension metadata +are maintained in `src/postgres/versions/18/`, `src/sources/third-party/`, and +`src/extensions/`. Generated release artifacts must include the notices for +every product they ship. -Those bundled assets are covered by their upstream Apache-2.0 and PostgreSQL -license terms. +- PostgreSQL license: https://www.postgresql.org/about/licence/ +- ICU / Unicode License v3: https://github.com/unicode-org/icu/blob/main/LICENSE diff --git a/assets/generated/asset-inputs.sha256 b/assets/generated/asset-inputs.sha256 deleted file mode 100644 index b5d018cd..00000000 --- a/assets/generated/asset-inputs.sha256 +++ /dev/null @@ -1 +0,0 @@ -3b74fc87331f910141c8a76224451a6c1b1b8e6936f233b04b0b65a0d6d991d7 diff --git a/assets/generated/pgxs-build.tsv b/assets/generated/pgxs-build.tsv deleted file mode 100644 index 274a9c57..00000000 --- a/assets/generated/pgxs-build.tsv +++ /dev/null @@ -1,8 +0,0 @@ -# id sql_name source_dir module_file archive stable make_args -age age assets/checkouts/age age.so extensions/age.tar.zst true SIZEOF_DATUM=4 -pg_hashids pg_hashids assets/checkouts/pg_hashids pg_hashids.so extensions/pg_hashids.tar.zst true - -pg_ivm pg_ivm assets/checkouts/pg_ivm pg_ivm.so extensions/pg_ivm.tar.zst true - -pg_textsearch pg_textsearch assets/checkouts/pg_textsearch pg_textsearch.so extensions/pg_textsearch.tar.zst true - -pg_uuidv7 pg_uuidv7 assets/checkouts/pg_uuidv7 pg_uuidv7.so extensions/pg_uuidv7.tar.zst true - -pgtap pgtap assets/checkouts/pgtap - extensions/pgtap.tar.zst true - -vector vector assets/checkouts/pgvector vector.so extensions/vector.tar.zst true - diff --git a/assets/sources.toml b/assets/sources.toml deleted file mode 100644 index eabb60fd..00000000 --- a/assets/sources.toml +++ /dev/null @@ -1,88 +0,0 @@ -[toolchain] -wasmer = "7.2.0-alpha.3" -wasmer-wasix = "0.702.0-alpha.3" -wasixcc = "2026-03-02.1" -llvm = "22.1" -docker_image = "ghcr.io/f0rr0/pglite-oxide-wasix-build" -docker_image_digest = "sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b" - -[build] -postgres_prefix = "/" -postgres_pkglibdir = "/lib/postgresql" -postgres_sharedir = "/share/postgresql" -main_flags = ["-fwasm-exceptions"] -extension_flags = ["-fwasm-exceptions", "-fPIC", "-Wl,-shared"] -archive_format = "tar.zst" -deterministic_archives = true - -[[sources]] -name = "pglite" -url = "https://github.com/electric-sql/pglite.git" -branch = "main" -commit = "1337be6e33b7c294f8987c918b1e64d2421365ee" - -[[sources]] -name = "pglite-build" -url = "https://github.com/electric-sql/pglite-build" -branch = "portable" -commit = "c195113dbaf09488f8d5eeb2db91dacd123b74d0" - -[[sources]] -name = "postgres-pglite" -url = "https://github.com/electric-sql/postgres-pglite" -branch = "REL_17_5-pglite" -commit = "01792c31a62b7045eb22e93d7dad022bb64b1184" - -[[sources]] -name = "pgvector" -url = "https://github.com/pgvector/pgvector.git" -branch = "master" -commit = "d238409becebb8172fe696ffa776badfad4b631c" - -[[sources]] -name = "pgtap" -url = "https://github.com/theory/pgtap.git" -branch = "postgres-pglite-submodule" -commit = "b89585a64ffef012ff0f219de9197c669aa8485b" - -[[sources]] -name = "pg_ivm" -url = "https://github.com/sraoss/pg_ivm.git" -branch = "postgres-pglite-submodule" -commit = "b66487f7a6f8deee3998e858d773e19923e4bd4b" - -[[sources]] -name = "pg_uuidv7" -url = "https://github.com/fboulnois/pg_uuidv7/" -branch = "postgres-pglite-submodule" -commit = "c707aae2411181be4802f5fa565b44d9c0bcbc29" - -[[sources]] -name = "pg_hashids" -url = "https://github.com/iCyberon/pg_hashids" -branch = "postgres-pglite-submodule" -commit = "8c404dd86408f3a987a3ff6825ac7e42bd618b98" - -[[sources]] -name = "age" -url = "https://github.com/apache/age.git" -branch = "PG17" -commit = "e1467f12e0b1d15dd35d3ab93f057a7112d425b8" - -[[sources]] -name = "pg_textsearch" -url = "https://github.com/timescale/pg_textsearch.git" -branch = "postgres-pglite-submodule" -commit = "5c5147bf2610d786f1bd139951b9fb7fe4ac68fb" - -[[sources]] -name = "postgis" -url = "https://github.com/postgis/postgis.git" -branch = "postgres-pglite-submodule" -commit = "08d9b9f749fa3531591055db2a736bfb6df47006" - -[[sources]] -name = "pglite-bindings" -url = "https://github.com/electric-sql/pglite-bindings" -branch = "main" -commit = "0d31326a41f7251548103f73e601699b0ebae2fa" diff --git a/assets/wasix-build/.gitignore b/assets/wasix-build/.gitignore deleted file mode 100644 index 5998c97c..00000000 --- a/assets/wasix-build/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -work/ diff --git a/assets/wasix-build/configure_wasix_dl.sh b/assets/wasix-build/configure_wasix_dl.sh deleted file mode 100755 index dc182668..00000000 --- a/assets/wasix-build/configure_wasix_dl.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" -DEFAULT_PGSRC="$ROOT/work/postgres-pglite-wasix-src" -if [ ! -d "$DEFAULT_PGSRC" ]; then - DEFAULT_PGSRC="$REPO_ROOT/assets/checkouts/postgres-pglite" -fi -PGSRC="${PGSRC:-$DEFAULT_PGSRC}" -BUILD="${BUILD_DIR:-$ROOT/work/configure-smoke}" - -WASIX_HOME="${WASIX_HOME:-/tmp/wasixcc-home/.wasixcc}" -export HOME="${WASIX_HOME%/.wasixcc}" -export PATH="$WASIX_HOME/bin:$PATH" - -mkdir -p "$BUILD" - -. "$ROOT/profile_flags.sh" -pglite_oxide_apply_wasix_profile configure - -COMMON_CPPFLAGS="-I$PGSRC/src/include/port/wasix-dl" -if [ "${PGLITE_OXIDE_WASIX_BACKEND_TIMING:-0}" = "1" ]; then - COMMON_CPPFLAGS="$COMMON_CPPFLAGS -DPGLITE_WASIX_BACKEND_TIMING" -fi -COMMON_CFLAGS="$PGLITE_OXIDE_PROFILE_CFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -Wno-unused-command-line-argument" -COMMON_LDFLAGS="$PGLITE_OXIDE_PROFILE_LDFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes" -MAIN_LDFLAGS="-sMODULE_KIND=dynamic-main -sSTACK_SIZE=8MB -sINITIAL_MEMORY=128MB" -SIDE_MODULE_LDFLAGS="-Wl,-shared" - -if [ "${PGLITE_MODE:-0}" = "1" ]; then - mkdir -p "$ROOT/build/wasix-pglite" - PGLITE_SHIM="$ROOT/build/wasix-pglite/pglite_wasix_bridge.o" - - wasixcc $COMMON_CFLAGS $COMMON_CPPFLAGS \ - -include stdbool.h \ - -include stdlib.h \ - -I"$PGSRC/src/include/port/wasix-dl" \ - -c "$ROOT/wasix_shim/pglite_wasix_bridge.c" \ - -o "$PGLITE_SHIM" - - PGLITE_CFLAGS="\ - -D__PGLITE__\ - -DPGLITE_WASIX_DL\ - -Dsystem=pgl_system -Dpopen=pgl_popen -Dpclose=pgl_pclose\ - -Dgeteuid=pgl_geteuid -Dgetuid=pgl_getuid -Dgetpwuid=pgl_getpwuid\ - -Dexit=pgl_exit\ - -Dmunmap=pgl_munmap\ - -Dfcntl=pgl_fcntl\ - -Datexit=pgl_atexit\ - -Dsetsockopt=pgl_setsockopt -Dgetsockopt=pgl_getsockopt -Dgetsockname=pgl_getsockname\ - -Drecv=pgl_recv -Dsend=pgl_send -Dconnect=pgl_connect\ - -Dpoll=pgl_poll\ - -Dshmget=pgl_shmget -Dshmat=pgl_shmat -Dshmdt=pgl_shmdt -Dshmctl=pgl_shmctl\ - -Dlongjmp=pgl_longjmp -Dsiglongjmp=pgl_siglongjmp\ - -Wno-declaration-after-statement\ - -Wno-macro-redefined\ - -Wno-unused-function\ - -Wno-missing-prototypes\ - -Wno-incompatible-pointer-types" - LDFLAGS_EXTRA=" $PGLITE_SHIM" -else - mkdir -p "$ROOT/build/wasix-shim" - GENERIC_SHIM="$ROOT/build/wasix-shim/pglite_wasix_shim.o" - - wasixcc $COMMON_CFLAGS $COMMON_CPPFLAGS \ - -I"$PGSRC/src/include/port/wasix-dl" \ - -c "$ROOT/wasix_shim/pglite_wasix_shim.c" \ - -o "$GENERIC_SHIM" - - PGLITE_CFLAGS="" - LDFLAGS_EXTRA=" $GENERIC_SHIM" -fi - -cd "$BUILD" - -CC=wasixcc \ -AR=wasixar \ -RANLIB=wasixranlib \ -NM=wasixnm \ -CPPFLAGS="$COMMON_CPPFLAGS" \ -CFLAGS="$COMMON_CFLAGS$PGLITE_CFLAGS" \ -LDFLAGS="$COMMON_LDFLAGS" \ -LDFLAGS_EX="$MAIN_LDFLAGS$LDFLAGS_EXTRA" \ -LDFLAGS_SL="$SIDE_MODULE_LDFLAGS" \ -"$PGSRC/configure" \ - --prefix=/ \ - --libdir=/lib \ - --datadir=/share/postgresql \ - --bindir=/bin \ - --host=wasm32-wasix \ - --with-template=wasix-dl \ - --without-readline \ - --without-icu \ - --without-zlib \ - --without-llvm \ - --disable-largefile \ - --without-pam \ - --with-openssl=no diff --git a/assets/wasix-build/docker_contrib_extensions.sh b/assets/wasix-build/docker_contrib_extensions.sh deleted file mode 100755 index b7802bd0..00000000 --- a/assets/wasix-build/docker_contrib_extensions.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" - -IMAGE="${IMAGE:-pglite-oxide-wasix-build:local}" -JOBS="${JOBS:-4}" -CONTAINER_ROOT="${CONTAINER_ROOT:-/work/assets/wasix-build}" -CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_ROOT/work/docker-pglite}" -CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_ROOT/work/postgres-pglite-wasix-src}" -CONTAINER_PLAN="${CONTAINER_PLAN:-/work/assets/generated/contrib-build.tsv}" -DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" -if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then - DOCKER=/usr/local/bin/docker -fi -if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then - DOCKER=/opt/homebrew/bin/docker -fi -if [ -z "$DOCKER" ]; then - echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 - exit 127 -fi -export PATH="$(dirname "$DOCKER"):$PATH" -DOCKER_USER_ARGS=() -if [ "${PGLITE_OXIDE_DOCKER_AS_ROOT:-0}" != "1" ]; then - DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) -fi - -"$ROOT/prepare_patched_source.sh" - -if [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then - "$DOCKER" build \ - -t "$IMAGE" \ - -f "$ROOT/docker/Dockerfile" \ - "$ROOT/docker" -else - echo "reusing Docker image $IMAGE" -fi - -"$DOCKER" run --rm \ - "${DOCKER_USER_ARGS[@]}" \ - --cpus="$JOBS" \ - -e CONTAINER_ROOT="$CONTAINER_ROOT" \ - -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ - -e PGSRC="$CONTAINER_PGSRC" \ - -e PLAN="$CONTAINER_PLAN" \ - -e JOBS="$JOBS" \ - -e PGLITE_OXIDE_BUILD_PROFILE="${PGLITE_OXIDE_BUILD_PROFILE:-release-o3}" \ - -e PGLITE_OXIDE_WASIX_COPT="${PGLITE_OXIDE_WASIX_COPT:-}" \ - -e PGLITE_OXIDE_WASIX_LOPT="${PGLITE_OXIDE_WASIX_LOPT:-}" \ - -e PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT="${PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT:-no}" \ - -e PGLITE_OXIDE_WASIX_BUILD_WASM_OPT="${PGLITE_OXIDE_WASIX_BUILD_WASM_OPT:-yes}" \ - -e PGLITE_OXIDE_WASM_OPT_FLAGS="${PGLITE_OXIDE_WASM_OPT_FLAGS-}" \ - -e PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT="${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT-}" \ - -e PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED="${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ - -e PGLITE_OXIDE_WASIX_COMPILER_FLAGS="${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_LINKER_FLAGS="${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_BACKEND_TIMING="${PGLITE_OXIDE_WASIX_BACKEND_TIMING:-0}" \ - -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ - -v "$REPO_ROOT:/work" \ - -w /work \ - "$IMAGE" \ - bash -lc ' - set -euo pipefail - . ./assets/wasix-build/docker_wasix_env.sh - . ./assets/wasix-build/profile_flags.sh - pglite_oxide_apply_wasix_profile build - - test -f "$BUILD_DIR/config.status" - test -f "$BUILD_DIR/src/backend/pglite" - cmp -s "$PGSRC/.pglite-oxide-source-head" "$BUILD_DIR/.pglite-oxide-source-head" - cmp -s "$PGSRC/.pglite-oxide-patch-sha256" "$BUILD_DIR/.pglite-oxide-patch-sha256" - sha256sum -c "$BUILD_DIR/.pglite-oxide-bridge-sha256" >/dev/null - test "$(pglite_oxide_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.pglite-oxide-build-profile")" - - if [ ! -f "$PLAN" ]; then - echo "generated contrib build plan missing: $PLAN" >&2 - exit 1 - fi - - while IFS=$'\''\t'\'' read -r id sql_name contrib_dir module_file archive stable; do - case "$id" in ""|"#"*) continue ;; esac - test -n "$sql_name" - test -n "$contrib_dir" - echo "building contrib extension $id from contrib/$contrib_dir" - test -d "$BUILD_DIR/contrib/$contrib_dir" - make -s -j"$JOBS" -C "$BUILD_DIR/contrib/$contrib_dir" all - if [ "$module_file" = "-" ]; then - continue - fi - if [ ! -f "$BUILD_DIR/contrib/$contrib_dir/$module_file" ]; then - echo "expected WASIX side module missing: $BUILD_DIR/contrib/$contrib_dir/$module_file" >&2 - find "$BUILD_DIR/contrib/$contrib_dir" -maxdepth 1 -type f -name "*.so" -print >&2 - exit 1 - fi - done < "$PLAN" - ' diff --git a/assets/wasix-build/docker_initdb.sh b/assets/wasix-build/docker_initdb.sh deleted file mode 100755 index 27339c0e..00000000 --- a/assets/wasix-build/docker_initdb.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" - -IMAGE="${IMAGE:-pglite-oxide-wasix-build:local}" -JOBS="${JOBS:-4}" -CONTAINER_ROOT="${CONTAINER_ROOT:-/work/assets/wasix-build}" -CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_ROOT/work/docker-pglite}" -CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_ROOT/work/postgres-pglite-wasix-src}" -DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" -if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then - DOCKER=/usr/local/bin/docker -fi -if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then - DOCKER=/opt/homebrew/bin/docker -fi -if [ -z "$DOCKER" ]; then - echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 - exit 127 -fi -export PATH="$(dirname "$DOCKER"):$PATH" -DOCKER_USER_ARGS=() -if [ "${PGLITE_OXIDE_DOCKER_AS_ROOT:-0}" != "1" ]; then - DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) -fi - -"$ROOT/prepare_patched_source.sh" - -if [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then - "$DOCKER" build \ - -t "$IMAGE" \ - -f "$ROOT/docker/Dockerfile" \ - "$ROOT/docker" -else - echo "reusing Docker image $IMAGE" -fi - -"$DOCKER" run --rm \ - "${DOCKER_USER_ARGS[@]}" \ - --cpus="$JOBS" \ - -e CONTAINER_ROOT="$CONTAINER_ROOT" \ - -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ - -e PGSRC="$CONTAINER_PGSRC" \ - -e JOBS="$JOBS" \ - -e PGLITE_OXIDE_BUILD_PROFILE="${PGLITE_OXIDE_BUILD_PROFILE:-release-o3}" \ - -e PGLITE_OXIDE_WASIX_COPT="${PGLITE_OXIDE_WASIX_COPT:-}" \ - -e PGLITE_OXIDE_WASIX_LOPT="${PGLITE_OXIDE_WASIX_LOPT:-}" \ - -e PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT="${PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT:-no}" \ - -e PGLITE_OXIDE_WASIX_BUILD_WASM_OPT="${PGLITE_OXIDE_WASIX_BUILD_WASM_OPT:-yes}" \ - -e PGLITE_OXIDE_WASM_OPT_FLAGS="${PGLITE_OXIDE_WASM_OPT_FLAGS-}" \ - -e PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT="${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT-}" \ - -e PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED="${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ - -e PGLITE_OXIDE_WASIX_COMPILER_FLAGS="${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_LINKER_FLAGS="${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" \ - -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ - -v "$REPO_ROOT:/work" \ - -w /work \ - "$IMAGE" \ - bash -lc ' - set -euo pipefail - . ./assets/wasix-build/docker_wasix_env.sh - . ./assets/wasix-build/profile_flags.sh - pglite_oxide_apply_wasix_profile build - export AR=wasixar - export RANLIB=wasixranlib - export NM=wasixnm - export LLVM_NM=wasixnm - - test -f "$BUILD_DIR/config.status" - cmp -s "$PGSRC/.pglite-oxide-source-head" "$BUILD_DIR/.pglite-oxide-source-head" - cmp -s "$PGSRC/.pglite-oxide-patch-sha256" "$BUILD_DIR/.pglite-oxide-patch-sha256" - sha256sum -c "$BUILD_DIR/.pglite-oxide-bridge-sha256" >/dev/null - test "$(pglite_oxide_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.pglite-oxide-build-profile")" - - COMMON_CPPFLAGS="-I$PGSRC/src/include/port/wasix-dl" - COMMON_CFLAGS="$PGLITE_OXIDE_PROFILE_CFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -Wno-unused-command-line-argument" - COMMON_LDFLAGS="$PGLITE_OXIDE_PROFILE_LDFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes" - MAIN_LDFLAGS="-sMODULE_KIND=dynamic-main -sSTACK_SIZE=8MB -sINITIAL_MEMORY=128MB -Wl,--wrap=system -Wl,--wrap=popen -Wl,--wrap=pclose" - - INITDB_BUILD_DIR="$CONTAINER_ROOT/build/wasix-initdb" - mkdir -p "$INITDB_BUILD_DIR" - GENERIC_SHIM="$INITDB_BUILD_DIR/pglite_wasix_shim.o" - INITDB_SHIM="$INITDB_BUILD_DIR/pglite_wasix_initdb_shim.o" - wasixcc $COMMON_CFLAGS $COMMON_CPPFLAGS \ - -I"$BUILD_DIR/src/include" \ - -I"$PGSRC/src/include/port/wasix-dl" \ - -c "$CONTAINER_ROOT/wasix_shim/pglite_wasix_shim.c" \ - -o "$GENERIC_SHIM" - wasixcc $COMMON_CFLAGS $COMMON_CPPFLAGS \ - -I"$BUILD_DIR/src/include" \ - -I"$PGSRC/src/include/port/wasix-dl" \ - -c "$CONTAINER_ROOT/wasix_shim/pglite_wasix_initdb_shim.c" \ - -o "$INITDB_SHIM" - - make -s -C "$BUILD_DIR/src/bin/initdb" clean - make -s -j"$JOBS" -C "$BUILD_DIR/src/bin/initdb" initdb \ - CFLAGS="$COMMON_CFLAGS -Dsystem=pgl_initdb_system -Dpopen=pgl_initdb_popen -Dpclose=pgl_initdb_pclose -Dgeteuid=pgl_geteuid -Dgetuid=pgl_getuid -Dgetpwuid=pgl_getpwuid -Wno-unused-function -Wno-missing-prototypes" \ - LDFLAGS="$COMMON_LDFLAGS -L$BUILD_DIR/src/common -L$BUILD_DIR/src/port" \ - LDFLAGS_EX="$MAIN_LDFLAGS $GENERIC_SHIM $INITDB_SHIM $BUILD_DIR/src/fe_utils/libpgfeutils.a $BUILD_DIR/src/interfaces/libpq/libpq.a $BUILD_DIR/src/common/libpgcommon.a $BUILD_DIR/src/port/libpgport.a" - test -f "$BUILD_DIR/src/bin/initdb/initdb" - ' diff --git a/assets/wasix-build/docker_pgdump.sh b/assets/wasix-build/docker_pgdump.sh deleted file mode 100755 index 257aa271..00000000 --- a/assets/wasix-build/docker_pgdump.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" - -IMAGE="${IMAGE:-pglite-oxide-wasix-build:local}" -JOBS="${JOBS:-4}" -CONTAINER_ROOT="${CONTAINER_ROOT:-/work/assets/wasix-build}" -CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_ROOT/work/docker-pglite}" -CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_ROOT/work/postgres-pglite-wasix-src}" -DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" -if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then - DOCKER=/usr/local/bin/docker -fi -if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then - DOCKER=/opt/homebrew/bin/docker -fi -if [ -z "$DOCKER" ]; then - echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 - exit 127 -fi -export PATH="$(dirname "$DOCKER"):$PATH" -DOCKER_USER_ARGS=() -if [ "${PGLITE_OXIDE_DOCKER_AS_ROOT:-0}" != "1" ]; then - DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) -fi - -"$ROOT/prepare_patched_source.sh" - -if [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then - "$DOCKER" build \ - -t "$IMAGE" \ - -f "$ROOT/docker/Dockerfile" \ - "$ROOT/docker" -else - echo "reusing Docker image $IMAGE" -fi - -"$DOCKER" run --rm \ - "${DOCKER_USER_ARGS[@]}" \ - --cpus="$JOBS" \ - -e CONTAINER_ROOT="$CONTAINER_ROOT" \ - -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ - -e PGSRC="$CONTAINER_PGSRC" \ - -e JOBS="$JOBS" \ - -e PGLITE_OXIDE_BUILD_PROFILE="${PGLITE_OXIDE_BUILD_PROFILE:-release-o3}" \ - -e PGLITE_OXIDE_WASIX_COPT="${PGLITE_OXIDE_WASIX_COPT:-}" \ - -e PGLITE_OXIDE_WASIX_LOPT="${PGLITE_OXIDE_WASIX_LOPT:-}" \ - -e PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT="${PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT:-no}" \ - -e PGLITE_OXIDE_WASIX_BUILD_WASM_OPT="${PGLITE_OXIDE_WASIX_BUILD_WASM_OPT:-yes}" \ - -e PGLITE_OXIDE_WASM_OPT_FLAGS="${PGLITE_OXIDE_WASM_OPT_FLAGS-}" \ - -e PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT="${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT-}" \ - -e PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED="${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ - -e PGLITE_OXIDE_WASIX_COMPILER_FLAGS="${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_LINKER_FLAGS="${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_BACKEND_TIMING="${PGLITE_OXIDE_WASIX_BACKEND_TIMING:-0}" \ - -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ - -v "$REPO_ROOT:/work" \ - -w /work \ - "$IMAGE" \ - bash -lc ' - set -euo pipefail - . ./assets/wasix-build/docker_wasix_env.sh - . ./assets/wasix-build/profile_flags.sh - pglite_oxide_apply_wasix_profile build - export AR=wasixar - export RANLIB=wasixranlib - export NM=wasixnm - export LLVM_NM=wasixnm - - test -f "$BUILD_DIR/config.status" - cmp -s "$PGSRC/.pglite-oxide-source-head" "$BUILD_DIR/.pglite-oxide-source-head" - cmp -s "$PGSRC/.pglite-oxide-patch-sha256" "$BUILD_DIR/.pglite-oxide-patch-sha256" - sha256sum -c "$BUILD_DIR/.pglite-oxide-bridge-sha256" >/dev/null - test "$(pglite_oxide_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.pglite-oxide-build-profile")" - make -s -C "$BUILD_DIR/src/bin/pg_dump" clean - make -s -C "$BUILD_DIR/src/bin/pg_dump" pg_dump \ - libpq="$BUILD_DIR/src/interfaces/libpq/libpq.a" \ - LIBS="$BUILD_DIR/src/common/libpgcommon.a $BUILD_DIR/src/port/libpgport.a -lm" - test -f "$BUILD_DIR/src/bin/pg_dump/pg_dump" - if wasixnm -u "$BUILD_DIR/src/bin/pg_dump/pg_dump" | grep -E " PQ[A-Za-z0-9_]+$"; then - echo "pg_dump still imports libpq symbols; expected standalone WASIX pg_dump" >&2 - exit 1 - fi - ' diff --git a/assets/wasix-build/docker_pglite.sh b/assets/wasix-build/docker_pglite.sh deleted file mode 100755 index b06c546c..00000000 --- a/assets/wasix-build/docker_pglite.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" - -IMAGE="${IMAGE:-pglite-oxide-wasix-build:local}" -JOBS="${JOBS:-4}" -CONTAINER_ROOT="${CONTAINER_ROOT:-/work/assets/wasix-build}" -CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_ROOT/work/docker-pglite}" -CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_ROOT/work/postgres-pglite-wasix-src}" -DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" -if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then - DOCKER=/usr/local/bin/docker -fi -if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then - DOCKER=/opt/homebrew/bin/docker -fi -if [ -z "$DOCKER" ]; then - echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 - exit 127 -fi -export PATH="$(dirname "$DOCKER"):$PATH" -DOCKER_USER_ARGS=() -if [ "${PGLITE_OXIDE_DOCKER_AS_ROOT:-0}" != "1" ]; then - DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) -fi - -"$ROOT/prepare_patched_source.sh" - -if [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then - "$DOCKER" build \ - -t "$IMAGE" \ - -f "$ROOT/docker/Dockerfile" \ - "$ROOT/docker" -else - echo "reusing Docker image $IMAGE" -fi - -"$DOCKER" run --rm \ - "${DOCKER_USER_ARGS[@]}" \ - --cpus="$JOBS" \ - -e CONTAINER_ROOT="$CONTAINER_ROOT" \ - -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ - -e PGSRC="$CONTAINER_PGSRC" \ - -e FORCE_RECONFIGURE="${FORCE_RECONFIGURE:-0}" \ - -e JOBS="$JOBS" \ - -e PGLITE_MODE=1 \ - -e PGLITE_OXIDE_BUILD_PROFILE="${PGLITE_OXIDE_BUILD_PROFILE:-release-o3}" \ - -e PGLITE_OXIDE_WASIX_COPT="${PGLITE_OXIDE_WASIX_COPT:-}" \ - -e PGLITE_OXIDE_WASIX_LOPT="${PGLITE_OXIDE_WASIX_LOPT:-}" \ - -e PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT="${PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT:-no}" \ - -e PGLITE_OXIDE_WASIX_BUILD_WASM_OPT="${PGLITE_OXIDE_WASIX_BUILD_WASM_OPT:-yes}" \ - -e PGLITE_OXIDE_WASM_OPT_FLAGS="${PGLITE_OXIDE_WASM_OPT_FLAGS-}" \ - -e PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT="${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT-}" \ - -e PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED="${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ - -e PGLITE_OXIDE_WASIX_COMPILER_FLAGS="${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_LINKER_FLAGS="${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_BACKEND_TIMING="${PGLITE_OXIDE_WASIX_BACKEND_TIMING:-0}" \ - -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ - -v "$REPO_ROOT:/work" \ - -w /work \ - "$IMAGE" \ - bash -lc ' - set -euo pipefail - . ./assets/wasix-build/docker_wasix_env.sh - . ./assets/wasix-build/profile_flags.sh - pglite_oxide_apply_wasix_profile configure - profile_signature="$(pglite_oxide_wasix_profile_signature)" - - needs_configure=0 - if [ "${FORCE_RECONFIGURE:-0}" = "1" ] || [ ! -f "$BUILD_DIR/config.status" ]; then - needs_configure=1 - elif ! cmp -s "$PGSRC/.pglite-oxide-source-head" "$BUILD_DIR/.pglite-oxide-source-head"; then - needs_configure=1 - elif ! cmp -s "$PGSRC/.pglite-oxide-patch-sha256" "$BUILD_DIR/.pglite-oxide-patch-sha256"; then - needs_configure=1 - elif [ ! -f "$BUILD_DIR/.pglite-oxide-bridge-sha256" ]; then - needs_configure=1 - elif ! sha256sum -c "$BUILD_DIR/.pglite-oxide-bridge-sha256" >/dev/null 2>&1; then - needs_configure=1 - elif [ ! -f "$BUILD_DIR/.pglite-oxide-build-profile" ]; then - needs_configure=1 - elif [ "$profile_signature" != "$(cat "$BUILD_DIR/.pglite-oxide-build-profile")" ]; then - needs_configure=1 - fi - - if [ "$needs_configure" = "1" ]; then - rm -rf "$BUILD_DIR" - ./assets/wasix-build/configure_wasix_dl.sh - cp "$PGSRC/.pglite-oxide-source-head" "$BUILD_DIR/.pglite-oxide-source-head" - cp "$PGSRC/.pglite-oxide-patch-sha256" "$BUILD_DIR/.pglite-oxide-patch-sha256" - sha256sum ./assets/wasix-build/wasix_shim/pglite_wasix_bridge.c \ - > "$BUILD_DIR/.pglite-oxide-bridge-sha256" - printf "%s\n" "$profile_signature" > "$BUILD_DIR/.pglite-oxide-build-profile" - else - echo "reusing configured PGlite build at $BUILD_DIR" - fi - pglite_oxide_apply_wasix_profile build - rm -rf "$BUILD_DIR/src/timezone/compiled" - mkdir -p "$BUILD_DIR/src/timezone/compiled" - /usr/sbin/zic \ - -d "$BUILD_DIR/src/timezone/compiled" \ - "$PGSRC/src/timezone/data/tzdata.zi" - test -f "$BUILD_DIR/src/timezone/compiled/UTC" - test -f "$BUILD_DIR/src/timezone/compiled/GMT" - test -f "$BUILD_DIR/src/timezone/compiled/Etc/UTC" - test -f "$BUILD_DIR/src/timezone/compiled/America/New_York" - make -s -C "$BUILD_DIR/src/backend" generated-headers - make -s -C "$BUILD_DIR/src/backend" submake-libpgport - make -s -j"$JOBS" -C "$BUILD_DIR/src/backend" pglite - ' diff --git a/assets/wasix-build/docker_runtime_support.sh b/assets/wasix-build/docker_runtime_support.sh deleted file mode 100755 index fd33571f..00000000 --- a/assets/wasix-build/docker_runtime_support.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" - -IMAGE="${IMAGE:-pglite-oxide-wasix-build:local}" -JOBS="${JOBS:-4}" -CONTAINER_ROOT="${CONTAINER_ROOT:-/work/assets/wasix-build}" -CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_ROOT/work/docker-pglite}" -CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_ROOT/work/postgres-pglite-wasix-src}" -DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" -if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then - DOCKER=/usr/local/bin/docker -fi -if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then - DOCKER=/opt/homebrew/bin/docker -fi -if [ -z "$DOCKER" ]; then - echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 - exit 127 -fi -export PATH="$(dirname "$DOCKER"):$PATH" -DOCKER_USER_ARGS=() -if [ "${PGLITE_OXIDE_DOCKER_AS_ROOT:-0}" != "1" ]; then - DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) -fi - -"$ROOT/prepare_patched_source.sh" - -if [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then - "$DOCKER" build \ - -t "$IMAGE" \ - -f "$ROOT/docker/Dockerfile" \ - "$ROOT/docker" -else - echo "reusing Docker image $IMAGE" -fi - -"$DOCKER" run --rm \ - "${DOCKER_USER_ARGS[@]}" \ - --cpus="$JOBS" \ - -e CONTAINER_ROOT="$CONTAINER_ROOT" \ - -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ - -e PGSRC="$CONTAINER_PGSRC" \ - -e JOBS="$JOBS" \ - -e PGLITE_OXIDE_BUILD_PROFILE="${PGLITE_OXIDE_BUILD_PROFILE:-release-o3}" \ - -e PGLITE_OXIDE_WASIX_COPT="${PGLITE_OXIDE_WASIX_COPT:-}" \ - -e PGLITE_OXIDE_WASIX_LOPT="${PGLITE_OXIDE_WASIX_LOPT:-}" \ - -e PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT="${PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT:-no}" \ - -e PGLITE_OXIDE_WASIX_BUILD_WASM_OPT="${PGLITE_OXIDE_WASIX_BUILD_WASM_OPT:-yes}" \ - -e PGLITE_OXIDE_WASM_OPT_FLAGS="${PGLITE_OXIDE_WASM_OPT_FLAGS-}" \ - -e PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT="${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT-}" \ - -e PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED="${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ - -e PGLITE_OXIDE_WASIX_COMPILER_FLAGS="${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_LINKER_FLAGS="${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_BACKEND_TIMING="${PGLITE_OXIDE_WASIX_BACKEND_TIMING:-0}" \ - -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ - -v "$REPO_ROOT:/work" \ - -w /work \ - "$IMAGE" \ - bash -lc ' - set -euo pipefail - . ./assets/wasix-build/docker_wasix_env.sh - . ./assets/wasix-build/profile_flags.sh - pglite_oxide_apply_wasix_profile build - - test -f "$BUILD_DIR/config.status" - test -f "$BUILD_DIR/src/backend/pglite" - cmp -s "$PGSRC/.pglite-oxide-source-head" "$BUILD_DIR/.pglite-oxide-source-head" - cmp -s "$PGSRC/.pglite-oxide-patch-sha256" "$BUILD_DIR/.pglite-oxide-patch-sha256" - sha256sum -c "$BUILD_DIR/.pglite-oxide-bridge-sha256" >/dev/null - test "$(pglite_oxide_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.pglite-oxide-build-profile")" - - make -s -j"$JOBS" -C "$BUILD_DIR/src/pl/plpgsql/src" all - make -s -j"$JOBS" -C "$BUILD_DIR/src/backend/snowball" all - test -f "$BUILD_DIR/src/pl/plpgsql/src/plpgsql.so" - test -f "$BUILD_DIR/src/backend/snowball/dict_snowball.so" - test -f "$BUILD_DIR/src/backend/snowball/snowball_create.sql" - ' diff --git a/assets/wasix-build/patches/postgres-pglite-wasix-dl.patch b/assets/wasix-build/patches/postgres-pglite-wasix-dl.patch deleted file mode 100644 index 7765655f..00000000 --- a/assets/wasix-build/patches/postgres-pglite-wasix-dl.patch +++ /dev/null @@ -1,1187 +0,0 @@ -diff --git a/src/Makefile.shlib b/src/Makefile.shlib -index 723fe2b7fa..1f99174a1f 100644 ---- a/src/Makefile.shlib -+++ b/src/Makefile.shlib -@@ -239,6 +239,17 @@ ifeq ($(PORTNAME), emscripten) - # endif - endif - -+ifeq ($(PORTNAME), wasix-dl) -+ LINK.shared = $(COMPILER) -shared -Wno-unused-function -+ ifdef soname -+ # wasm side modules use unversioned shared libraries -+ shlib = $(shlib_bare) -+ soname = $(shlib_bare) -+ endif -+ BUILD.exports = ( $(AWK) '/^[^\#]/ {printf "%s\n",$$1}' $< ) | sort -u >$@ -+ exports_file = $(SHLIB_EXPORTS:%.txt=%.list) -+endif -+ - ## - ## BUILD - ## -diff --git a/src/backend/Makefile b/src/backend/Makefile -index a215e39386..dbafda3a7e 100644 ---- a/src/backend/Makefile -+++ b/src/backend/Makefile -@@ -60,7 +60,7 @@ override LDFLAGS := $(LDFLAGS) $(LDFLAGS_EX) $(LDFLAGS_EX_BE) - - all: submake-libpgport submake-catalog-headers submake-utils-headers postgres $(POSTGRES_IMP) - --ifneq ($(PORTNAME), emscripten) -+ifeq (,$(filter emscripten wasix-dl,$(PORTNAME))) - ifneq ($(PORTNAME), cygwin) - ifneq ($(PORTNAME), win32) - -@@ -109,6 +109,30 @@ pglite-libc: - - endif - -+ifeq ($(PORTNAME), wasix-dl) -+AR ?= llvm-ar -+WASM_LD ?= $(shell $(CC) -print-prog-name=wasm-ld) -+LIBPGCORE ?= $(top_builddir)/libpgcore.a -+LIBPG = $(top_builddir)/libpostgres.a -+PGCORE = $(top_builddir)/src/common/libpgcommon_srv.a $(top_builddir)/src/port/libpgport_srv.a $(LIBPG) -+PGMAIN = main/main.o tcop/postgres.o -+PGBACKEND = $(filter-out $(PGMAIN) $(top_builddir)/src/common/libpgcommon_srv.a $(top_builddir)/src/port/libpgport_srv.a,$(call expand_subsys,$(OBJS))) -+ -+postgres: $(OBJS) -+ $(AR) rcs $(top_builddir)/libpgmain.a $(PGMAIN) -+ $(AR) rcs $(LIBPG) $(PGBACKEND) -+ $(WASM_LD) --relocatable -o $(top_builddir)/libpgcore.o --whole-archive $(PGCORE) --no-whole-archive -+ $(AR) rcs $(LIBPGCORE) $(top_builddir)/libpgcore.o -+ COPTS="$(LOPTS)" $(CC) $(MAIN_MODULE) $(CFLAGS) $(LDFLAGS) -nostartfiles -o $@ $(LIBPGCORE) $(top_builddir)/libpgmain.a $(LIBS) -+ -+pglite: $(OBJS) -+ $(AR) rcs $(top_builddir)/libpgmain.a $(PGMAIN) -+ $(AR) rcs $(LIBPG) $(PGBACKEND) -+ $(WASM_LD) --relocatable -o $(top_builddir)/libpgcore.o --whole-archive $(PGCORE) --no-whole-archive -+ $(AR) rcs $(LIBPGCORE) $(top_builddir)/libpgcore.o -+ COPTS="$(LOPTS)" $(CC) $(MAIN_MODULE) $(CFLAGS) $(LDFLAGS) -nostartfiles -o $@ $(LIBPGCORE) $(top_builddir)/libpgmain.a $(LIBS) -+endif -+ - ifeq ($(PORTNAME), cygwin) - - postgres: $(OBJS) -diff --git a/src/backend/commands/copyfromparse.c b/src/backend/commands/copyfromparse.c -index 97a4c387a3..5b5a19b7f2 100644 ---- a/src/backend/commands/copyfromparse.c -+++ b/src/backend/commands/copyfromparse.c -@@ -174,6 +174,9 @@ ReceiveCopyBegin(CopyFromState cstate) - int16 format = (cstate->opts.binary ? 1 : 0); - int i; - -+#ifdef PGLITE_WASIX_DL -+ pgl_protocol_report_copy_response(PGL_WASIX_PROTOCOL_COPY_IN); -+#endif - pq_beginmessage(&buf, PqMsg_CopyInResponse); - pq_sendbyte(&buf, format); /* overall format */ - pq_sendint16(&buf, natts); -diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c -index 84dc465cba..63526776be 100644 ---- a/src/backend/commands/copyto.c -+++ b/src/backend/commands/copyto.c -@@ -137,6 +137,9 @@ SendCopyBegin(CopyToState cstate) - int16 format = (cstate->opts.binary ? 1 : 0); - int i; - -+#ifdef PGLITE_WASIX_DL -+ pgl_protocol_report_copy_response(PGL_WASIX_PROTOCOL_COPY_OUT); -+#endif - pq_beginmessage(&buf, PqMsg_CopyOutResponse); - pq_sendbyte(&buf, format); /* overall format */ - pq_sendint16(&buf, natts); -diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c -index e0257cc49b..5137013369 100644 ---- a/src/backend/replication/walsender.c -+++ b/src/backend/replication/walsender.c -@@ -701,6 +701,9 @@ UploadManifest(void) - ib = CreateIncrementalBackupInfo(mcxt); - - /* Send a CopyInResponse message */ -+#ifdef PGLITE_WASIX_DL -+ pgl_protocol_report_copy_response(PGL_WASIX_PROTOCOL_COPY_IN); -+#endif - pq_beginmessage(&buf, PqMsg_CopyInResponse); - pq_sendbyte(&buf, 0); - pq_sendint16(&buf, 0); -@@ -954,6 +957,9 @@ StartReplication(StartReplicationCmd *cmd) - WalSndSetState(WALSNDSTATE_CATCHUP); - - /* Send a CopyBothResponse message, and start streaming */ -+#ifdef PGLITE_WASIX_DL -+ pgl_protocol_report_copy_response(PGL_WASIX_PROTOCOL_COPY_BOTH); -+#endif - pq_beginmessage(&buf, PqMsg_CopyBothResponse); - pq_sendbyte(&buf, 0); - pq_sendint16(&buf, 0); -@@ -1496,6 +1502,9 @@ StartLogicalReplication(StartReplicationCmd *cmd) - WalSndSetState(WALSNDSTATE_CATCHUP); - - /* Send a CopyBothResponse message, and start streaming */ -+#ifdef PGLITE_WASIX_DL -+ pgl_protocol_report_copy_response(PGL_WASIX_PROTOCOL_COPY_BOTH); -+#endif - pq_beginmessage(&buf, PqMsg_CopyBothResponse); - pq_sendbyte(&buf, 0); - pq_sendint16(&buf, 0); -diff --git a/src/common/file_utils.c b/src/common/file_utils.c -index 35458d1844..a04ee56efa 100644 ---- a/src/common/file_utils.c -+++ b/src/common/file_utils.c -@@ -416,1 +416,1 @@ fsync_fname(const char *fname, bool isdir) -- if (returncode != 0 && !(isdir && (errno == EBADF || errno == EINVAL))) -+ if (returncode != 0 && !(isdir && (errno == EBADF || errno == EINVAL || errno == EISDIR))) -diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c -index dd3307cb76..eb14fa5c13 100644 ---- a/src/backend/tcop/backend_startup.c -+++ b/src/backend/tcop/backend_startup.c -@@ -47,7 +47,7 @@ bool Trace_connection_negotiation = false; - static void BackendInitialize(ClientSocket *client_sock, CAC_state cac); - static int ProcessSSLStartup(Port *port); - --#if defined(__EMSCRIPTEN__) -+#if defined(__EMSCRIPTEN__) || defined(PGLITE_WASIX_DL) - int ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done); - #else - static int ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done); -@@ -459,7 +459,9 @@ reject: - * should make no assumption here about the order in which the client may make - * requests. - */ --#if defined(__EMSCRIPTEN__) -+#if defined(PGLITE_WASIX_DL) -+__attribute__((export_name("ProcessStartupPacket"))) int -+#elif defined(__EMSCRIPTEN__) - int EMSCRIPTEN_KEEPALIVE - #else - static int -diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c -index 1b41bf4de9..2394275215 100644 ---- a/src/backend/tcop/postgres.c -+++ b/src/backend/tcop/postgres.c -@@ -81,6 +81,15 @@ - #include "utils/timestamp.h" - #include "utils/varlena.h" - -+#ifdef PGLITE_WASIX_DL -+#include "port/wasix-dl.h" -+#define PGLITE_HOST_EXPORT(name) __attribute__((export_name(name))) -+extern void pglite_wasix_process_startup_options(struct Port *port); -+extern volatile int pglite_wasix_startup_error_capture_active; -+#else -+#define PGLITE_HOST_EXPORT(name) -+#endif -+ - /* ---------------- - * global variables - * ---------------- -@@ -243,7 +251,7 @@ void initDummyPort() { - MemoryContextSwitchTo(oldcontext); - } - --void pgl_startPGlite() { -+PGLITE_HOST_EXPORT("pgl_startPGlite") void pgl_startPGlite() { - initDummyPort(); - whereToSendOutput = DestRemote; - // initdb execs postgres in single mode, which sets this to true -@@ -267,15 +275,18 @@ void pgl_startPGlite() { - - } - --void pgl_pq_flush() { -+PGLITE_HOST_EXPORT("pgl_pq_flush") void pgl_pq_flush() { - pq_flush(); - } - --struct Port* pgl_getMyProcPort() { -+PGLITE_HOST_EXPORT("pgl_getMyProcPort") struct Port* pgl_getMyProcPort() { - return MyProcPort; - } - --void pgl_sendConnData() { -+PGLITE_HOST_EXPORT("pgl_sendConnData") void pgl_sendConnData() { -+#ifdef PGLITE_WASIX_DL -+ pglite_wasix_process_startup_options(MyProcPort); -+#endif - ClientAuthInProgress = false; - - { -@@ -297,6 +308,28 @@ void pgl_sendConnData() { - ReadyForQuery(DestRemote); - } - -+#ifdef PGLITE_WASIX_DL -+static CommandDest pglite_wasix_startup_error_saved_dest = DestDebug; -+ -+static void -+pglite_wasix_begin_startup_error_capture(void) -+{ -+ if (MyProcPort == NULL) -+ initDummyPort(); -+ pglite_wasix_startup_error_saved_dest = whereToSendOutput; -+ pglite_wasix_startup_error_capture_active = 1; -+ whereToSendOutput = DestRemote; -+} -+ -+static void -+pglite_wasix_end_startup_error_capture(void) -+{ -+ pglite_wasix_startup_error_capture_active = 0; -+ if (whereToSendOutput == DestRemote) -+ whereToSendOutput = pglite_wasix_startup_error_saved_dest; -+} -+#endif -+ - #endif // ifdef __PGLITE__ - - /* ---------------------------------------------------------------- -@@ -1125,6 +1156,8 @@ exec_simple_query(const char *query_string) - bool use_implicit_block; - char msec_str[32]; - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_SIMPLE_QUERY); -+ - /* - * Report query to various monitoring facilities. - */ -@@ -1148,7 +1181,9 @@ exec_simple_query(const char *query_string) - * one of those, else bad things will happen in xact.c. (Note that this - * will normally change current memory context.) - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_START_XACT); - start_xact_command(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_START_XACT); - - /* - * Zap any pre-existing unnamed statement. (While not strictly necessary, -@@ -1156,7 +1191,9 @@ exec_simple_query(const char *query_string) - * statement and portal; this ensures we recover any storage used by prior - * unnamed operations.) - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_DROP_UNNAMED); - drop_unnamed_stmt(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_DROP_UNNAMED); - - /* - * Switch to appropriate context for constructing parsetrees. -@@ -1167,7 +1204,9 @@ exec_simple_query(const char *query_string) - * Do basic parsing of the query or queries (this should be safe even if - * we are in aborted transaction state!) - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_PARSE); - parsetree_list = pg_parse_query(query_string); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_PARSE); - - /* Log immediately if dictated by log_statement */ - if (check_log_statement(parsetree_list)) -@@ -1244,7 +1283,9 @@ exec_simple_query(const char *query_string) - errdetail_abort())); - - /* Make sure we are in a transaction command */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_START_XACT); - start_xact_command(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_START_XACT); - - /* - * If using an implicit transaction block, and we're not already in a -@@ -1264,7 +1305,9 @@ exec_simple_query(const char *query_string) - */ - if (analyze_requires_snapshot(parsetree)) - { -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_SNAPSHOT); - PushActiveSnapshot(GetTransactionSnapshot()); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_SNAPSHOT); - snapshot_set = true; - } - -@@ -1291,11 +1334,15 @@ exec_simple_query(const char *query_string) - else - oldcontext = MemoryContextSwitchTo(MessageContext); - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_ANALYZE_REWRITE); - querytree_list = pg_analyze_and_rewrite_fixedparams(parsetree, query_string, - NULL, 0, NULL); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_ANALYZE_REWRITE); - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_PLAN); - plantree_list = pg_plan_queries(querytree_list, query_string, - CURSOR_OPT_PARALLEL_OK, NULL); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_PLAN); - - /* - * Done with the snapshot used for parsing/planning. -@@ -1336,7 +1383,9 @@ exec_simple_query(const char *query_string) - /* - * Start the portal. No parameters here. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_PORTAL_START); - PortalStart(portal, NULL, 0, InvalidSnapshot); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_PORTAL_START); - - /* - * Select the appropriate output format: text unless we are doing a -@@ -1363,9 +1412,11 @@ exec_simple_query(const char *query_string) - /* - * Now we can create the destination receiver object. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_DEST_RECEIVER); - receiver = CreateDestReceiver(dest); - if (dest == DestRemote) - SetRemoteDestReceiverParams(receiver, portal); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_DEST_RECEIVER); - - /* - * Switch back to transaction context for execution. -@@ -1375,6 +1426,7 @@ exec_simple_query(const char *query_string) - /* - * Run the portal to completion, and then drop it (and the receiver). - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_PORTAL_RUN); - (void) PortalRun(portal, - FETCH_ALL, - true, /* always top level */ -@@ -1382,6 +1434,7 @@ exec_simple_query(const char *query_string) - receiver, - receiver, - &qc); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_PORTAL_RUN); - - receiver->rDestroy(receiver); - -@@ -1400,7 +1453,9 @@ exec_simple_query(const char *query_string) - */ - if (use_implicit_block) - EndImplicitTransactionBlock(); -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_FINISH_XACT); - finish_xact_command(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_FINISH_XACT); - } - else if (IsA(parsetree->stmt, TransactionStmt)) - { -@@ -1408,7 +1463,9 @@ exec_simple_query(const char *query_string) - * If this was a transaction control statement, commit it. We will - * start a new xact command for the next command. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_FINISH_XACT); - finish_xact_command(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_FINISH_XACT); - } - else - { -@@ -1423,7 +1480,9 @@ exec_simple_query(const char *query_string) - * We need a CommandCounterIncrement after every query, except - * those that start or end a transaction block. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_COMMAND_COUNTER); - CommandCounterIncrement(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_COMMAND_COUNTER); - - /* - * Disable statement timeout between queries of a multi-query -@@ -1439,7 +1498,9 @@ exec_simple_query(const char *query_string) - * command the client sent, regardless of rewriting. (But a command - * aborted by error will not send an EndCommand report at all.) - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_END_COMMAND); - EndCommand(&qc, dest, false); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_END_COMMAND); - - /* Now we may drop the per-parsetree context, if one was created. */ - if (per_parsetree_context) -@@ -1451,7 +1512,9 @@ exec_simple_query(const char *query_string) - * something if the parsetree list was empty; otherwise the last loop - * iteration already did it.) - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_EXEC_FINISH_XACT); - finish_xact_command(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_FINISH_XACT); - - /* - * If there were no parsetrees, return EmptyQueryResponse message. -@@ -1484,6 +1547,7 @@ exec_simple_query(const char *query_string) - TRACE_POSTGRESQL_QUERY_DONE(query_string); - - debug_query_string = NULL; -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_EXEC_SIMPLE_QUERY); - } - - /* -@@ -4231,20 +4295,28 @@ PostgresSingleUserMain(int argc, char *argv[], - { - const char *dbname = NULL; - -+ PGL_BACKEND_TIMING_RESET(); -+ - Assert(!IsUnderPostmaster); - - /* Initialize startup process environment. */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_STANDALONE_PROCESS); - InitStandaloneProcess(argv[0]); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_STANDALONE_PROCESS); - - /* - * Set default values for command-line options. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_GUC_INIT); - InitializeGUCOptions(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_GUC_INIT); - - /* - * Parse command-line options. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_SWITCH_PARSE); - process_postgres_switches(argc, argv, PGC_POSTMASTER, &dbname); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_SWITCH_PARSE); - - /* Must have gotten a database name, or have a default (the username) */ - if (dbname == NULL) -@@ -4258,13 +4330,16 @@ PostgresSingleUserMain(int argc, char *argv[], - } - - /* Acquire configuration parameters */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_CONFIG_FILES); - if (!SelectConfigFiles(userDoption, progname)) - proc_exit(1); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_CONFIG_FILES); - - /* - * Validate we have been given a reasonable-looking DataDir and change - * into it. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_DATA_DIR_LOCK); - checkDataDir(); - ChangeToDataDir(); - -@@ -4272,17 +4347,25 @@ PostgresSingleUserMain(int argc, char *argv[], - * Create lockfile for data directory. - */ - CreateDataDirLockFile(false); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_DATA_DIR_LOCK); - - /* read control file (error checking and contains config ) */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_CONTROL_FILE); - LocalProcessControlFile(false); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_CONTROL_FILE); - - /* - * process any libraries that should be preloaded at postmaster start - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_PRELOAD_LIBS); - process_shared_preload_libraries(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_PRELOAD_LIBS); - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_SHARED_MEMORY); - /* Initialize MaxBackends */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_INIT_MAX_BACKENDS); - InitializeMaxBackends(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_INIT_MAX_BACKENDS); - - /* - * Give preloaded libraries a chance to request additional shared memory. -@@ -4302,7 +4385,9 @@ PostgresSingleUserMain(int argc, char *argv[], - */ - InitializeWalConsistencyChecking(); - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_CREATE_SHARED_MEMORY); - CreateSharedMemoryAndSemaphores(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_CREATE_SHARED_MEMORY); - - /* - * Remember stand-alone backend startup time,roughly at the same point -@@ -4314,7 +4399,10 @@ PostgresSingleUserMain(int argc, char *argv[], - * Create a per-backend PGPROC struct in shared memory. We must do this - * before we can use LWLocks. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_INIT_PROCESS); - InitProcess(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_INIT_PROCESS); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_SHARED_MEMORY); - - /* - * Now that sufficient infrastructure has been initialized, PostgresMain() -@@ -4323,7 +4411,7 @@ PostgresSingleUserMain(int argc, char *argv[], - PostgresMain(dbname, username); - } - --void PostgresSendReadyForQueryIfNecessary() { -+PGLITE_HOST_EXPORT("PostgresSendReadyForQueryIfNecessary") void PostgresSendReadyForQueryIfNecessary() { - /* - * (1) If we've reached idle state, tell the frontend we're ready for - * a new query. -@@ -4431,7 +4519,7 @@ void PostgresSendReadyForQueryIfNecessary() { - } - } - --void PostgresMainLoopOnce() { -+PGLITE_HOST_EXPORT("PostgresMainLoopOnce") void PostgresMainLoopOnce() { - - int firstchar; - StringInfoData input_message; -@@ -4805 +4893 @@ void PostgresMainLoopOnce() { --void PostgresMainLongJmp() { -+PGLITE_HOST_EXPORT("PostgresMainLongJmp") void PostgresMainLongJmp() { -@@ -4894,6 +4982,9 @@ void PostgresMainLongJmp() { - if (doing_extended_query_message) - ignore_till_sync = true; - -+ if (!ignore_till_sync) -+ send_ready_for_query = true; -+ - /* We don't have a transaction command open anymore */ - xact_started = false; - -@@ -4994,7 +5085,9 @@ PostgresMain(const char *dbname, const char *username) - } - - /* Early initialization */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_BASE_INIT); - BaseInit(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_BASE_INIT); - - /* We need to allow SIGINT, etc during the initial transaction */ - sigprocmask(SIG_SETMASK, &UnBlockSig, NULL); -@@ -5008,10 +5101,18 @@ PostgresMain(const char *dbname, const char *username) - * - * Honor session_preload_libraries if not dealing with a WAL sender. - */ -+#ifdef PGLITE_WASIX_DL -+ pglite_wasix_begin_startup_error_capture(); -+#endif -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_INIT_POSTGRES); - InitPostgres(dbname, InvalidOid, /* database to connect to */ - username, InvalidOid, /* role to connect as */ - (!am_walsender) ? INIT_PG_LOAD_SESSION_LIBS : 0, - NULL); /* no out_dbname */ -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_INIT_POSTGRES); -+#ifdef PGLITE_WASIX_DL -+ pglite_wasix_end_startup_error_capture(); -+#endif - - /* - * If the PostmasterContext is still around, recycle the space; we don't -@@ -5025,6 +5126,7 @@ PostgresMain(const char *dbname, const char *username) - - SetProcessingMode(NormalProcessing); - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_POST_INIT); - /* - * Now all GUC states are fully set up. Report them to client if - * appropriate. -@@ -5061,7 +5163,9 @@ PostgresMain(const char *dbname, const char *username) - /* Welcome banner for standalone case */ - if (whereToSendOutput == DestDebug) - printf("\nPostgreSQL stand-alone backend %s\n", PG_VERSION); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_POST_INIT); - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_MESSAGE_CONTEXTS); - /* - * Create the memory context we will use in the main loop. - * -@@ -5084,6 +5188,7 @@ PostgresMain(const char *dbname, const char *username) - MemoryContextSwitchTo(row_description_context); - initStringInfo(&row_description_buf); - MemoryContextSwitchTo(TopMemoryContext); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_MESSAGE_CONTEXTS); - - /* Fire any defined login event triggers, if appropriate */ - EventTriggerOnLogin(); -diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c -index 288c55a90d..0a4ba03fb2 100644 ---- a/src/backend/utils/init/postinit.c -+++ b/src/backend/utils/init/postinit.c -@@ -65,6 +65,9 @@ - #include "utils/snapmgr.h" - #include "utils/syscache.h" - #include "utils/timeout.h" -+#ifdef PGLITE_WASIX_DL -+#include "port/wasix-dl.h" -+#endif - - static HeapTuple GetDatabaseTuple(const char *dbname); - static HeapTuple GetDatabaseTupleByOid(Oid dboid); -@@ -759,6 +762,7 @@ InitPostgres(const char *in_dbname, Oid dboid, - * - * Once I have done this, I am visible to other backends! - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_INIT_PROC_PHASE2); - InitProcessPhase2(); - - /* -@@ -786,6 +790,7 @@ InitPostgres(const char *in_dbname, Oid dboid, - RegisterTimeout(IDLE_STATS_UPDATE_TIMEOUT, - IdleStatsUpdateTimeoutHandler); - } -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_INIT_PROC_PHASE2); - - /* - * If this is either a bootstrap process or a standalone backend, start up -@@ -802,7 +807,9 @@ InitPostgres(const char *in_dbname, Oid dboid, - */ - CreateAuxProcessResourceOwner(); - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_STARTUP_XLOG); - StartupXLOG(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_STARTUP_XLOG); - /* Release (and warn about) any buffer pins leaked in StartupXLOG */ - ReleaseAuxProcessResources(true); - /* Reset CurrentResourceOwner to nothing for the moment */ -@@ -822,6 +829,7 @@ InitPostgres(const char *in_dbname, Oid dboid, - * We must do this before starting a transaction because transaction abort - * would try to touch these hashtables. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_RELCACHE_CATCACHE_INIT); - RelationCacheInitialize(); - InitCatalogCache(); - InitPlanCache(); -@@ -837,6 +845,7 @@ InitPostgres(const char *in_dbname, Oid dboid, - * at least entries for pg_database and catalogs used for authentication. - */ - RelationCacheInitializePhase2(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_RELCACHE_CATCACHE_INIT); - - /* - * Set up process-exit callback to do pre-shutdown cleanup. This is the -@@ -872,6 +881,7 @@ InitPostgres(const char *in_dbname, Oid dboid, - */ - if (!bootstrap) - { -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_TRANSACTION_SNAPSHOT); - /* statement_timestamp must be set for timeouts to work correctly */ - SetCurrentStatementStartTimestamp(); - StartTransactionCommand(); -@@ -885,6 +895,7 @@ InitPostgres(const char *in_dbname, Oid dboid, - XactIsoLevel = XACT_READ_COMMITTED; - - (void) GetTransactionSnapshot(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_TRANSACTION_SNAPSHOT); - } - - /* -@@ -895,6 +906,7 @@ InitPostgres(const char *in_dbname, Oid dboid, - * process, we use a fixed ID, otherwise we figure it out from the - * authenticated user name. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_SESSION_USER); - if (bootstrap || AmAutoVacuumWorkerProcess() || AmLogicalSlotSyncWorkerProcess()) - { - InitializeSessionUserIdStandalone(); -@@ -902,12 +914,12 @@ InitPostgres(const char *in_dbname, Oid dboid, - } - else if (!IsUnderPostmaster) - { --#if defined(__EMSCRIPTEN__) -+#if defined(__EMSCRIPTEN__) || defined(PGLITE_WASIX_DL) - if (!strcmp( username , WASM_USERNAME )) { - #endif - InitializeSessionUserIdStandalone(); - am_superuser = true; --#if defined(__EMSCRIPTEN__) -+#if defined(__EMSCRIPTEN__) || defined(PGLITE_WASIX_DL) - } else { - //puts("# 894: switching session id"); - InitializeSessionUserId(username, InvalidOid, false); -@@ -947,6 +959,7 @@ if (!strcmp( username , WASM_USERNAME )) { - hba_authname(MyClientConnectionInfo.auth_method)); - am_superuser = superuser(); - } -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_SESSION_USER); - - /* - * Binary upgrades only allowed super-user connections -@@ -1043,7 +1056,9 @@ if (!strcmp( username , WASM_USERNAME )) { - HeapTuple tuple; - Form_pg_database dbform; - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_DATABASE_LOOKUP); - tuple = GetDatabaseTuple(in_dbname); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_DATABASE_LOOKUP); - if (!HeapTupleIsValid(tuple)) - ereport(FATAL, - (errcode(ERRCODE_UNDEFINED_DATABASE), -@@ -1089,7 +1104,11 @@ if (!strcmp( username , WASM_USERNAME )) { - * CREATE DATABASE. - */ - if (!bootstrap) -+ { -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_DATABASE_LOCK_RECHECK); - LockSharedObject(DatabaseRelationId, dboid, 0, RowExclusiveLock); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_DATABASE_LOCK_RECHECK); -+ } - - /* - * Recheck pg_database to make sure the target database hasn't gone away. -@@ -1101,6 +1120,7 @@ if (!strcmp( username , WASM_USERNAME )) { - HeapTuple tuple; - Form_pg_database datform; - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_DATABASE_LOCK_RECHECK); - tuple = GetDatabaseTupleByOid(dboid); - if (HeapTupleIsValid(tuple)) - datform = (Form_pg_database) GETSTRUCT(tuple); -@@ -1134,6 +1154,7 @@ if (!strcmp( username , WASM_USERNAME )) { - /* pass the database name back to the caller */ - if (out_dbname) - strcpy(out_dbname, dbname); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_DATABASE_LOCK_RECHECK); - } - - /* -@@ -1175,6 +1196,7 @@ if (!strcmp( username , WASM_USERNAME )) { - * Now we should be able to access the database directory safely. Verify - * it's there and looks reasonable. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_DATABASE_PATH); - fullpath = GetDatabasePath(MyDatabaseId, MyDatabaseTableSpace); - - if (!bootstrap) -@@ -1200,6 +1222,7 @@ if (!strcmp( username , WASM_USERNAME )) { - - SetDatabasePath(fullpath); - pfree(fullpath); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_DATABASE_PATH); - - /* - * It's now possible to do real access to the system catalogs. -@@ -1207,10 +1230,16 @@ if (!strcmp( username , WASM_USERNAME )) { - * Load relcache entries for the system catalogs. This must create at - * least the minimum set of "nailed-in" cache entries. - */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_RELCACHE_PHASE3); -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_RELATION_CACHE_PHASE3); - RelationCacheInitializePhase3(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_RELATION_CACHE_PHASE3); - - /* set up ACL framework (so CheckMyDatabase can check permissions) */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_INITIALIZE_ACL); - initialize_acl(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_INITIALIZE_ACL); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_RELCACHE_PHASE3); - - /* - * Re-read the pg_database row for our database, check permissions and set -@@ -1219,8 +1248,12 @@ if (!strcmp( username , WASM_USERNAME )) { - * user is a superuser, so the above stuff has to happen first.) - */ - if (!bootstrap) -+ { -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_CHECK_MY_DATABASE); - CheckMyDatabase(dbname, am_superuser, - (flags & INIT_PG_OVERRIDE_ALLOW_CONNS) != 0); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_CHECK_MY_DATABASE); -+ } - - /* - * Now process any command-line switches and any additional GUC variable -@@ -1228,10 +1261,16 @@ if (!strcmp( username , WASM_USERNAME )) { - * because we didn't know if client is a superuser. - */ - if (MyProcPort != NULL) -+ { -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_STARTUP_OPTIONS); - process_startup_options(MyProcPort, am_superuser); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_STARTUP_OPTIONS); -+ } - - /* Process pg_db_role_setting options */ -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_PROCESS_SETTINGS); - process_settings(MyDatabaseId, GetSessionUserId()); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_PROCESS_SETTINGS); - - /* Apply PostAuthDelay as soon as we've read all options */ - if (PostAuthDelay > 0) -@@ -1242,6 +1281,7 @@ if (!strcmp( username , WASM_USERNAME )) { - * selected the active user and gotten the right GUC settings. - */ - -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_SESSION_INITIALIZATION); - /* set default namespace search path */ - InitializeSearchPath(); - -@@ -1250,6 +1290,7 @@ if (!strcmp( username , WASM_USERNAME )) { - - /* Initialize this backend's session state. */ - InitializeSession(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_SESSION_INITIALIZATION); - - /* - * If this is an interactive session, load any libraries that should be -@@ -1259,7 +1300,11 @@ if (!strcmp( username , WASM_USERNAME )) { - * access needs to be done. - */ - if ((flags & INIT_PG_LOAD_SESSION_LIBS) != 0) -+ { -+ PGL_BACKEND_TIMING_START(PGL_BACKEND_TIMING_SESSION_PRELOAD_LIBS); - process_session_preload_libraries(); -+ PGL_BACKEND_TIMING_END(PGL_BACKEND_TIMING_SESSION_PRELOAD_LIBS); -+ } - - /* report this backend in the PgBackendStatus array */ - if (!bootstrap) -@@ -1333,6 +1378,15 @@ process_startup_options(Port *port, bool am_superuser) - } - } - -+#ifdef PGLITE_WASIX_DL -+void -+pglite_wasix_process_startup_options(Port *port) -+{ -+ if (port != NULL) -+ process_startup_options(port, true); -+} -+#endif -+ - /* - * Load GUC settings from pg_db_role_setting. - * -diff --git a/src/backend/utils/mmgr/portalmem.c b/src/backend/utils/mmgr/portalmem.c -index 93137820ac..4fd3eed96e 100644 ---- a/src/backend/utils/mmgr/portalmem.c -+++ b/src/backend/utils/mmgr/portalmem.c -@@ -28,6 +28,10 @@ - #include "utils/snapmgr.h" - #include "utils/timestamp.h" - -+#ifdef PGLITE_WASIX_DL -+extern int is_pglite_active; -+#endif -+ - /* - * Estimate of the maximum number of open portals a user would have, - * used in initially sizing the PortalHashTable in EnablePortalManager(). -@@ -795,6 +799,10 @@ AtAbort_Portals(void) - */ - if (portal->status == PORTAL_ACTIVE && shmem_exit_inprogress) - MarkPortalFailed(portal); -+#ifdef PGLITE_WASIX_DL -+ else if (portal->status == PORTAL_ACTIVE && is_pglite_active) -+ MarkPortalFailed(portal); -+#endif - - /* - * Do nothing else to cursors held over from a previous transaction. -diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h -new file mode 100644 -index 0000000000..acbaf6cd8c ---- /dev/null -+++ b/src/include/port/wasix-dl.h -@@ -0,0 +1,133 @@ -+#pragma once -+ -+#ifndef I_WASIX_DL -+#define I_WASIX_DL -+ -+#undef HAVE_PTHREAD -+ -+#ifdef HAVE_GETRLIMIT -+#undef HAVE_GETRLIMIT -+#endif -+ -+#ifdef HAVE_SETSID -+#undef HAVE_SETSID -+#endif -+ -+#define PLATFORM_DEFAULT_SYNC_METHOD SYNC_METHOD_FDATASYNC -+ -+#ifndef EMSCRIPTEN_KEEPALIVE -+#define EMSCRIPTEN_KEEPALIVE __attribute__((used)) -+#endif -+ -+#ifndef __declspec -+#define __declspec(x) __attribute__((used)) -+#endif -+ -+#define em_callback_func void -+#define emscripten_set_main_loop(...) -+#define emscripten_force_exit(...) -+#define EM_JS(...) -+ -+#if defined(PGLITE_WASIX_DL) && defined(PGLITE_WASIX_BACKEND_TIMING) -+#define PGL_BACKEND_TIMING_MAIN_PRE 1 -+#define PGL_BACKEND_TIMING_RESTART_SINGLE_USER_MAIN 2 -+#define PGL_BACKEND_TIMING_ASYNC_SINGLE_USER_MAIN 3 -+#define PGL_BACKEND_TIMING_STANDALONE_PROCESS 4 -+#define PGL_BACKEND_TIMING_GUC_INIT 5 -+#define PGL_BACKEND_TIMING_SWITCH_PARSE 6 -+#define PGL_BACKEND_TIMING_CONFIG_FILES 7 -+#define PGL_BACKEND_TIMING_DATA_DIR_LOCK 8 -+#define PGL_BACKEND_TIMING_CONTROL_FILE 9 -+#define PGL_BACKEND_TIMING_PRELOAD_LIBS 10 -+#define PGL_BACKEND_TIMING_SHARED_MEMORY 11 -+#define PGL_BACKEND_TIMING_BASE_INIT 12 -+#define PGL_BACKEND_TIMING_INIT_POSTGRES 13 -+#define PGL_BACKEND_TIMING_POST_INIT 14 -+#define PGL_BACKEND_TIMING_MESSAGE_CONTEXTS 15 -+#define PGL_BACKEND_TIMING_POSTMASTER_ENVIRONMENT 16 -+#define PGL_BACKEND_TIMING_INIT_PROC_PHASE2 17 -+#define PGL_BACKEND_TIMING_STARTUP_XLOG 18 -+#define PGL_BACKEND_TIMING_RELCACHE_CATCACHE_INIT 19 -+#define PGL_BACKEND_TIMING_TRANSACTION_SNAPSHOT 20 -+#define PGL_BACKEND_TIMING_SESSION_USER 21 -+#define PGL_BACKEND_TIMING_DATABASE_LOOKUP 22 -+#define PGL_BACKEND_TIMING_DATABASE_LOCK_RECHECK 23 -+#define PGL_BACKEND_TIMING_DATABASE_PATH 24 -+#define PGL_BACKEND_TIMING_RELCACHE_PHASE3 25 -+#define PGL_BACKEND_TIMING_CHECK_MY_DATABASE 26 -+#define PGL_BACKEND_TIMING_STARTUP_OPTIONS 27 -+#define PGL_BACKEND_TIMING_PROCESS_SETTINGS 28 -+#define PGL_BACKEND_TIMING_SESSION_INITIALIZATION 29 -+#define PGL_BACKEND_TIMING_SESSION_PRELOAD_LIBS 30 -+#define PGL_BACKEND_TIMING_INIT_MAX_BACKENDS 31 -+#define PGL_BACKEND_TIMING_CREATE_SHARED_MEMORY 32 -+#define PGL_BACKEND_TIMING_INIT_PROCESS 33 -+#define PGL_BACKEND_TIMING_RELATION_CACHE_PHASE3 34 -+#define PGL_BACKEND_TIMING_INITIALIZE_ACL 35 -+#define PGL_BACKEND_TIMING_EXEC_SIMPLE_QUERY 36 -+#define PGL_BACKEND_TIMING_EXEC_START_XACT 37 -+#define PGL_BACKEND_TIMING_EXEC_DROP_UNNAMED 38 -+#define PGL_BACKEND_TIMING_EXEC_PARSE 39 -+#define PGL_BACKEND_TIMING_EXEC_SNAPSHOT 40 -+#define PGL_BACKEND_TIMING_EXEC_ANALYZE_REWRITE 41 -+#define PGL_BACKEND_TIMING_EXEC_PLAN 42 -+#define PGL_BACKEND_TIMING_EXEC_PORTAL_START 43 -+#define PGL_BACKEND_TIMING_EXEC_DEST_RECEIVER 44 -+#define PGL_BACKEND_TIMING_EXEC_PORTAL_RUN 45 -+#define PGL_BACKEND_TIMING_EXEC_FINISH_XACT 46 -+#define PGL_BACKEND_TIMING_EXEC_COMMAND_COUNTER 47 -+#define PGL_BACKEND_TIMING_EXEC_END_COMMAND 48 -+ -+extern void pgl_backend_timing_reset(void); -+extern void pgl_backend_timing_start(int id); -+extern void pgl_backend_timing_end(int id); -+ -+#define PGL_BACKEND_TIMING_RESET() pgl_backend_timing_reset() -+#define PGL_BACKEND_TIMING_START(id) pgl_backend_timing_start(id) -+#define PGL_BACKEND_TIMING_END(id) pgl_backend_timing_end(id) -+#else -+#define PGL_BACKEND_TIMING_RESET() -+#define PGL_BACKEND_TIMING_START(id) -+#define PGL_BACKEND_TIMING_END(id) -+#endif -+ -+#define PGL_WASIX_PROTOCOL_COPY_IN 1 -+#define PGL_WASIX_PROTOCOL_COPY_OUT 2 -+#define PGL_WASIX_PROTOCOL_COPY_BOTH 3 -+extern volatile int pglite_wasix_startup_error_capture_active; -+extern void pgl_protocol_report_copy_response(int state); -+ -+#ifdef __PGLITE__ -+#include -+#define fe_utils_quote_all_identifiers quote_all_identifiers -+#define sdk_sock_flush() ((void) 0) -+#define fork() (errno = ENOSYS, -1) -+#ifndef PDEBUG -+#define PDEBUG(...) ((void) 0) -+#endif -+#ifndef WASM_USERNAME -+#define WASM_USERNAME "postgres" -+#endif -+#ifndef WASM_PREFIX -+#define WASM_PREFIX "" -+#endif -+#ifndef WASM_PGOPTS -+#define WASM_PGOPTS \ -+ "-c", "log_checkpoints=false", \ -+ "-c", "search_path=pg_catalog", \ -+ "-c", "exit_on_error=true", \ -+ "-c", "ignore_invalid_pages=on", \ -+ "-c", "temp_buffers=8MB", \ -+ "-c", "work_mem=4MB", \ -+ "-c", "fsync=on", \ -+ "-c", "synchronous_commit=on", \ -+ "-c", "wal_buffers=4MB", \ -+ "-c", "min_wal_size=80MB", \ -+ "-c", "shared_buffers=128MB" -+#endif -+extern int pgl_system(const char *command); -+#define system_wasi(command) pgl_system(command) -+#define proc_exit(arg) pg_proc_exit(arg) -+#endif -+ -+#endif /* I_WASIX_DL */ -diff --git a/src/include/port/wasix-dl/sys/ipc.h b/src/include/port/wasix-dl/sys/ipc.h -new file mode 100644 -index 0000000000..0872fdb47f ---- /dev/null -+++ b/src/include/port/wasix-dl/sys/ipc.h -@@ -0,0 +1,37 @@ -+#pragma once -+ -+#include -+ -+#ifndef IPC_PRIVATE -+#define IPC_PRIVATE ((key_t) 0) -+#endif -+#ifndef IPC_CREAT -+#define IPC_CREAT 01000 -+#endif -+#ifndef IPC_EXCL -+#define IPC_EXCL 02000 -+#endif -+#ifndef IPC_NOWAIT -+#define IPC_NOWAIT 04000 -+#endif -+ -+#ifndef IPC_RMID -+#define IPC_RMID 0 -+#endif -+#ifndef IPC_SET -+#define IPC_SET 1 -+#endif -+#ifndef IPC_STAT -+#define IPC_STAT 2 -+#endif -+ -+struct ipc_perm -+{ -+ key_t __key; -+ uid_t uid; -+ gid_t gid; -+ uid_t cuid; -+ gid_t cgid; -+ mode_t mode; -+ unsigned short __seq; -+}; -diff --git a/src/include/port/wasix-dl/sys/shm.h b/src/include/port/wasix-dl/sys/shm.h -new file mode 100644 -index 0000000000..91f59d9e39 ---- /dev/null -+++ b/src/include/port/wasix-dl/sys/shm.h -@@ -0,0 +1,30 @@ -+#pragma once -+ -+#include -+#include -+#include -+ -+#ifndef SHM_RDONLY -+#define SHM_RDONLY 010000 -+#endif -+#ifndef SHM_RND -+#define SHM_RND 020000 -+#endif -+#ifndef SHMLBA -+#define SHMLBA 4096 -+#endif -+ -+struct shmid_ds -+{ -+ struct ipc_perm shm_perm; -+ size_t shm_segsz; -+ time_t shm_atime; -+ time_t shm_dtime; -+ time_t shm_ctime; -+ unsigned long shm_nattch; -+}; -+ -+int shmget(key_t key, size_t size, int shmflg); -+void *shmat(int shmid, const void *shmaddr, int shmflg); -+int shmdt(const void *shmaddr); -+int shmctl(int shmid, int cmd, struct shmid_ds *buf); -diff --git a/src/makefiles/Makefile.wasix-dl b/src/makefiles/Makefile.wasix-dl -new file mode 100644 -index 0000000000..c0e3bac8c8 ---- /dev/null -+++ b/src/makefiles/Makefile.wasix-dl -@@ -0,0 +1,14 @@ -+# Use unversioned shared objects for WebAssembly side modules. -+rpath = -+AROPT = crs -+ -+# Rule for building a shared library from a single .o file. -+%.so: %.o -+ $(CC) $(CFLAGS) $< $(LDFLAGS) $(LDFLAGS_SL) -shared -o $@ -+ -+# WASIX side modules install import lists under the same layout contract that -+# PGlite's dynamic loader consumes, without reusing Emscripten-named variables. -+wasm_dl_include_dir := $(pkgincludedir)/wasix-dl -+wasm_dl_base_dir := $(wasm_dl_include_dir)/base -+wasm_dl_imports_dir := $(wasm_dl_base_dir)/imports -+wasm_dl_extension_dir := $(wasm_dl_include_dir)/extension -diff --git a/src/makefiles/pgxs.mk b/src/makefiles/pgxs.mk -index 79705050cf..6780d837e0 100644 ---- a/src/makefiles/pgxs.mk -+++ b/src/makefiles/pgxs.mk -@@ -249,11 +249,11 @@ ifdef MODULES - ifeq ($(with_llvm), yes) - $(foreach mod, $(MODULES), $(call install_llvm_module,$(mod),$(mod).bc)) - endif # with_llvm --ifeq ($(PORTNAME), emscripten) -+ifneq (,$(filter emscripten wasix-dl,$(PORTNAME))) - find . -name "*.o" -exec $(LLVM_NM) --undefined-only {} \; | awk '{print $$2}' | sed '/^$$/d' | sort -u > '$(MODULES).undef.txt' - find . -type f \( -name "*.o" -o -name "*.so" \) -exec $(LLVM_NM) --defined-only {} \; | awk '$$2 ~ /^[TDB]$$/ {print $$3}' | sed '/^$$/d' | sort -u > '$(MODULES).defs.txt' -- comm -23 '$(MODULES).undef.txt' '$(MODULES).defs.txt' > '$(emscripten_extension_imports_dir)/$(MODULES).imports' --endif # PORTNAME=emscripten -+ comm -23 '$(MODULES).undef.txt' '$(MODULES).defs.txt' > '$(if $(wasm_dl_extension_imports_dir),$(wasm_dl_extension_imports_dir),$(emscripten_extension_imports_dir))/$(MODULES).imports' -+endif # PORTNAME=emscripten wasix-dl - endif # MODULES - ifdef DOCS - ifdef docdir -@@ -276,11 +276,11 @@ ifdef MODULE_big - ifeq ($(with_llvm), yes) - $(call install_llvm_module,$(MODULE_big),$(OBJS)) - endif # with_llvm --ifeq ($(PORTNAME), emscripten) -+ifneq (,$(filter emscripten wasix-dl,$(PORTNAME))) - find . -name "*.o" -exec $(LLVM_NM) --undefined-only {} \; | awk '{print $$2}' | sed '/^$$/d' | sort -u > '$(MODULE_big).undef.txt' - find . -type f \( -name "*.o" -o -name "*.so" \) -exec $(LLVM_NM) --defined-only {} \; | awk '$$2 ~ /^[TDB]$$/ {print $$3}' | sed '/^$$/d' | sort -u > '$(MODULE_big).defs.txt' -- comm -23 '$(MODULE_big).undef.txt' '$(MODULE_big).defs.txt' > '$(emscripten_extension_imports_dir)/$(MODULE_big).imports' --endif # PORTNAME=emscripten -+ comm -23 '$(MODULE_big).undef.txt' '$(MODULE_big).defs.txt' > '$(if $(wasm_dl_extension_imports_dir),$(wasm_dl_extension_imports_dir),$(emscripten_extension_imports_dir))/$(MODULE_big).imports' -+endif # PORTNAME=emscripten wasix-dl - - install: install-lib - endif # MODULE_big -@@ -307,8 +307,8 @@ endif # DOCS - ifneq (,$(PROGRAM)$(SCRIPTS)$(SCRIPTS_built)) - $(MKDIR_P) '$(DESTDIR)$(bindir)' - endif --ifeq ($(PORTNAME), emscripten) -- $(MKDIR_P) '$(DESTDIR)$(emscripten_extension_imports_dir)' -+ifneq (,$(filter emscripten wasix-dl,$(PORTNAME))) -+ $(MKDIR_P) '$(DESTDIR)$(if $(wasm_dl_extension_imports_dir),$(wasm_dl_extension_imports_dir),$(emscripten_extension_imports_dir))' - endif - - ifdef MODULE_big -@@ -331,8 +331,8 @@ ifdef MODULES - ifeq ($(with_llvm), yes) - $(foreach mod, $(MODULES), $(call uninstall_llvm_module,$(mod))) - endif # with_llvm --ifeq ($(PORTNAME), emscripten) -- rm -f '$(DESTDIR)$(emscripten_extension_imports_dir)/$(MODULES).imports' -+ifneq (,$(filter emscripten wasix-dl,$(PORTNAME))) -+ rm -f '$(DESTDIR)$(if $(wasm_dl_extension_imports_dir),$(wasm_dl_extension_imports_dir),$(emscripten_extension_imports_dir))/$(MODULES).imports' - endif - endif # MODULES - ifdef DOCS -@@ -356,8 +356,8 @@ ifeq ($(with_llvm), yes) - $(call uninstall_llvm_module,$(MODULE_big)) - endif # with_llvm - --ifeq ($(PORTNAME), emscripten) -- rm -f '$(DESTDIR)$(emscripten_extension_imports_dir)/$(MODULE_big).imports' -+ifneq (,$(filter emscripten wasix-dl,$(PORTNAME))) -+ rm -f '$(DESTDIR)$(if $(wasm_dl_extension_imports_dir),$(wasm_dl_extension_imports_dir),$(emscripten_extension_imports_dir))/$(MODULE_big).imports' - endif - - uninstall: uninstall-lib -diff --git a/src/template/wasix-dl b/src/template/wasix-dl -new file mode 100644 -index 0000000000..f747e11735 ---- /dev/null -+++ b/src/template/wasix-dl -@@ -0,0 +1,13 @@ -+# src/template/wasix-dl -+ -+# Prefer unnamed POSIX semaphores if available, unless user overrides choice. -+if test x"$PREFERRED_SEMAPHORES" = x"" ; then -+ PREFERRED_SEMAPHORES=UNNAMED_POSIX -+fi -+ -+# Keep the same GNU feature surface as the Emscripten/WASI templates and -+# identify the dynamic-linking WASIX personality inside shared extension code. -+CPPFLAGS="$CPPFLAGS -D_GNU_SOURCE -DPGLITE_WASIX_DL" -+ -+# Side modules must be position-independent WebAssembly objects. -+CFLAGS_SL="-fPIC" diff --git a/assets/wasix-build/pg_config_wasix.sh b/assets/wasix-build/pg_config_wasix.sh deleted file mode 100755 index 61d0141e..00000000 --- a/assets/wasix-build/pg_config_wasix.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BUILD_DIR="${BUILD_DIR:-/work/assets/wasix-build/work/docker-pglite}" -PGSRC="${PGSRC:-/work/assets/checkouts/postgres-pglite}" -PREFIX="${PGLITE_WASIX_PREFIX:-$BUILD_DIR/install}" - -case "${1:-}" in - --pgxs) - echo "$BUILD_DIR/src/makefiles/pgxs.mk" - ;; - --bindir) - echo "$PREFIX/bin" - ;; - --sharedir) - echo "$PREFIX/share" - ;; - --sysconfdir) - echo "$PREFIX/etc" - ;; - --libdir) - echo "$PREFIX/lib" - ;; - --pkglibdir) - echo "$PREFIX/lib/postgresql" - ;; - --includedir | --pkgincludedir) - echo "$PREFIX/include" - ;; - --mandir) - echo "$PREFIX/share/man" - ;; - --docdir) - echo "$PREFIX/share/doc" - ;; - --localedir) - echo "$PREFIX/share/locale" - ;; - --version) - echo "PostgreSQL 17.5-wasix-pglite" - ;; - --configure) - echo "--host=wasm32-wasix --with-template=wasix-dl" - ;; - --cc) - echo "wasixcc" - ;; - --cppflags) - echo "-I$BUILD_DIR/src/include -I$PGSRC/src/include -I$PGSRC/src/include/port/wasix-dl" - ;; - --cflags) - echo "" - ;; - --ldflags | --libs) - echo "" - ;; - *) - echo "unsupported pg_config_wasix.sh option: ${1:-}" >&2 - exit 2 - ;; -esac diff --git a/assets/wasix-build/prepare_patched_source.sh b/assets/wasix-build/prepare_patched_source.sh deleted file mode 100755 index dd69867a..00000000 --- a/assets/wasix-build/prepare_patched_source.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" -UPSTREAM_PGSRC="${UPSTREAM_PGSRC:-$REPO_ROOT/assets/checkouts/postgres-pglite}" -PATCHED_PGSRC="${PATCHED_PGSRC:-$ROOT/work/postgres-pglite-wasix-src}" -PATCH_PATH="${PATCH_PATH:-$ROOT/patches/postgres-pglite-wasix-dl.patch}" -POSTGRES_PGLITE_COMMIT="${POSTGRES_PGLITE_COMMIT:-$(git -C "$UPSTREAM_PGSRC" rev-parse HEAD)}" - -PATCH_SHA="$(shasum -a 256 "$PATCH_PATH" | awk '{print $1}')" -HEAD_FILE="$PATCHED_PGSRC/.pglite-oxide-source-head" -PATCH_FILE="$PATCHED_PGSRC/.pglite-oxide-patch-sha256" - -if [ -e "$PATCHED_PGSRC/.git" ] \ - && [ -f "$HEAD_FILE" ] \ - && [ -f "$PATCH_FILE" ] \ - && [ "$(cat "$HEAD_FILE")" = "$POSTGRES_PGLITE_COMMIT" ] \ - && [ "$(cat "$PATCH_FILE")" = "$PATCH_SHA" ]; then - echo "reusing patched postgres-pglite source at $PATCHED_PGSRC" - exit 0 -fi - -git -C "$UPSTREAM_PGSRC" worktree remove --force "$PATCHED_PGSRC" >/dev/null 2>&1 || true -rm -rf "$PATCHED_PGSRC" -git -C "$UPSTREAM_PGSRC" worktree prune -git -C "$UPSTREAM_PGSRC" worktree add --detach "$PATCHED_PGSRC" "$POSTGRES_PGLITE_COMMIT" -git -C "$PATCHED_PGSRC" apply --unidiff-zero --whitespace=nowarn "$PATCH_PATH" - -printf '%s' "$POSTGRES_PGLITE_COMMIT" > "$HEAD_FILE" -printf '%s' "$PATCH_SHA" > "$PATCH_FILE" -echo "prepared patched postgres-pglite source at $PATCHED_PGSRC" diff --git a/assets/wasix-build/profile_flags.sh b/assets/wasix-build/profile_flags.sh deleted file mode 100644 index ed052309..00000000 --- a/assets/wasix-build/profile_flags.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash - -pglite_oxide_wasix_profile="${PGLITE_OXIDE_BUILD_PROFILE:-release-o3}" - -case "$pglite_oxide_wasix_profile" in - debug) - PGLITE_OXIDE_PROFILE_CFLAGS="${PGLITE_OXIDE_WASIX_COPT:--O0 -g3}" - PGLITE_OXIDE_PROFILE_LDFLAGS="${PGLITE_OXIDE_WASIX_LOPT:-}" - ;; - release) - PGLITE_OXIDE_PROFILE_CFLAGS="${PGLITE_OXIDE_WASIX_COPT:--O2 -g0}" - PGLITE_OXIDE_PROFILE_LDFLAGS="${PGLITE_OXIDE_WASIX_LOPT:-}" - ;; - release-o3) - PGLITE_OXIDE_PROFILE_CFLAGS="${PGLITE_OXIDE_WASIX_COPT:--O3 -g0 -flto=thin}" - PGLITE_OXIDE_PROFILE_LDFLAGS="${PGLITE_OXIDE_WASIX_LOPT:--flto=thin}" - ;; - release-os) - PGLITE_OXIDE_PROFILE_CFLAGS="${PGLITE_OXIDE_WASIX_COPT:--Os -g0}" - PGLITE_OXIDE_PROFILE_LDFLAGS="${PGLITE_OXIDE_WASIX_LOPT:-}" - ;; - release-oz) - PGLITE_OXIDE_PROFILE_CFLAGS="${PGLITE_OXIDE_WASIX_COPT:--Oz -g0}" - PGLITE_OXIDE_PROFILE_LDFLAGS="${PGLITE_OXIDE_WASIX_LOPT:-}" - ;; - *) - echo "unknown PGLITE_OXIDE_BUILD_PROFILE=$pglite_oxide_wasix_profile" >&2 - exit 2 - ;; -esac - -PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT="${PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT:-no}" -PGLITE_OXIDE_WASIX_BUILD_WASM_OPT="${PGLITE_OXIDE_WASIX_BUILD_WASM_OPT:-yes}" -PGLITE_OXIDE_WASIX_BACKEND_TIMING="${PGLITE_OXIDE_WASIX_BACKEND_TIMING:-0}" -if [ -z "${PGLITE_OXIDE_WASM_OPT_FLAGS:-}" ]; then - case "$pglite_oxide_wasix_profile" in - release*) - PGLITE_OXIDE_WASM_OPT_FLAGS="--converge:--strip-debug:--strip-producers" - ;; - *) - PGLITE_OXIDE_WASM_OPT_FLAGS="" - ;; - esac -elif [ "$PGLITE_OXIDE_WASM_OPT_FLAGS" = "none" ]; then - PGLITE_OXIDE_WASM_OPT_FLAGS="" -fi - -pglite_oxide_reject_asyncify_flag() { - local name="$1" - local value="${!name:-}" - - if [ -z "$value" ] || [ -n "${PGLITE_OXIDE_ALLOW_ASYNCIFY_EXPERIMENT:-}" ]; then - return - fi - - case "$value" in - *ASYNCIFY*|*asyncify*) - echo "$name contains Asyncify flags; production WASIX artifacts require WebAssembly exceptions. Set PGLITE_OXIDE_ALLOW_ASYNCIFY_EXPERIMENT=1 only for isolated experiments." >&2 - exit 2 - ;; - esac -} - -for pglite_oxide_flag_var in \ - PGLITE_OXIDE_PROFILE_CFLAGS \ - PGLITE_OXIDE_PROFILE_LDFLAGS \ - PGLITE_OXIDE_WASM_OPT_FLAGS \ - PGLITE_OXIDE_WASIX_COMPILER_FLAGS \ - PGLITE_OXIDE_WASIX_LINKER_FLAGS -do - pglite_oxide_reject_asyncify_flag "$pglite_oxide_flag_var" -done - -pglite_oxide_apply_wasix_profile() { - local phase="${1:-build}" - - export PGLITE_OXIDE_PROFILE_CFLAGS - export PGLITE_OXIDE_PROFILE_LDFLAGS - export WASIXCC_COMPILER_FLAGS="${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" - export WASIXCC_LINKER_FLAGS="${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" - export WASIXCC_WASM_OPT_FLAGS="$PGLITE_OXIDE_WASM_OPT_FLAGS" - if [ -n "${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT:-}" ]; then - export WASIXCC_WASM_OPT_SUPPRESS_DEFAULT="$PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT" - fi - if [ -n "${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED:-}" ]; then - export WASIXCC_WASM_OPT_PRESERVE_UNOPTIMIZED="$PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED" - fi - - if [ "$phase" = "configure" ]; then - export WASIXCC_RUN_WASM_OPT="$PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT" - else - export WASIXCC_RUN_WASM_OPT="$PGLITE_OXIDE_WASIX_BUILD_WASM_OPT" - fi -} - -pglite_oxide_wasix_profile_signature() { - printf 'profile=%s\n' "$pglite_oxide_wasix_profile" - printf 'cflags=%s\n' "$PGLITE_OXIDE_PROFILE_CFLAGS" - printf 'ldflags=%s\n' "$PGLITE_OXIDE_PROFILE_LDFLAGS" - printf 'configure_wasm_opt=%s\n' "$PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT" - printf 'build_wasm_opt=%s\n' "$PGLITE_OXIDE_WASIX_BUILD_WASM_OPT" - printf 'wasm_opt_flags=%s\n' "$PGLITE_OXIDE_WASM_OPT_FLAGS" - printf 'wasm_opt_suppress_default=%s\n' "${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT:-}" - printf 'wasm_opt_preserve_unoptimized=%s\n' "${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED:-}" - printf 'compiler_flags=%s\n' "${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" - printf 'linker_flags=%s\n' "${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" - printf 'backend_timing=%s\n' "$PGLITE_OXIDE_WASIX_BACKEND_TIMING" - if [ -f ./assets/wasix-build/configure_wasix_dl.sh ]; then - printf 'configure_wasix_dl_sha256=%s\n' "$(sha256sum ./assets/wasix-build/configure_wasix_dl.sh | awk '{print $1}')" - fi -} diff --git a/assets/wasix-build/wasix_shim/pglite_wasix_bridge_abi_test.c b/assets/wasix-build/wasix_shim/pglite_wasix_bridge_abi_test.c deleted file mode 100644 index 742e4bc1..00000000 --- a/assets/wasix-build/wasix_shim/pglite_wasix_bridge_abi_test.c +++ /dev/null @@ -1,330 +0,0 @@ -#ifndef _GNU_SOURCE -#define _GNU_SOURCE -#endif -#ifndef _DARWIN_C_SOURCE -#define _DARWIN_C_SOURCE -#endif -#ifndef _POSIX_C_SOURCE -#define _POSIX_C_SOURCE 200809L -#endif - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#define CHECK(condition) \ - do \ - { \ - if (!(condition)) \ - { \ - fprintf(stderr, "bridge ABI check failed at %s:%d: %s\n", __FILE__, __LINE__, \ - #condition); \ - return 1; \ - } \ - } while (0) - -FILE *pgl_popen(const char *command, const char *mode); -int pgl_system(const char *command); -int pgl_set_force_host_error_recovery(int new_value); -int pgl_setPGliteActive(int new_value); -int pgl_atexit(void (*function)(void)); -void pgl_run_atexit_funcs(void); -uid_t pgl_geteuid(void); -uid_t pgl_getuid(void); -struct passwd *pgl_getpwuid(uid_t uid); -int pgl_wasix_input_reset(void); -int pgl_wasix_input_write(const void *buffer, size_t length); -size_t pgl_wasix_input_available(void); -int pgl_wasix_output_reset(void); -size_t pgl_wasix_output_len(void); -size_t pgl_wasix_output_read(void *buffer, size_t max_length); -int pgl_fcntl(int fd, int cmd, ...); -int pgl_setsockopt(int fd, int level, int optname, const void *optval, socklen_t optlen); -int pgl_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen); -int pgl_getsockname(int fd, struct sockaddr *addr, socklen_t *len); -int pgl_set_protocol_stdio(int enabled); -int pgl_set_protocol_transport(int mode); -int pgl_protocol_stream_active(void); -void pgl_protocol_report_copy_response(int state); -int pgl_protocol_copy_state(void); -ssize_t pgl_recv(int fd, void *buf, size_t n, int flags); -ssize_t pgl_send(int fd, const void *buf, size_t n, int flags); -int pgl_connect(int socket, const struct sockaddr *address, socklen_t address_len); -int pgl_poll(struct pollfd fds[], nfds_t nfds, int timeout); -int pgl_munmap(void *addr, size_t length); -int pgl_shmget(key_t key, size_t size, int shmflg); -void *pgl_shmat(int shmid, const void *shmaddr, int shmflg); -int pgl_shmdt(const void *shmaddr); -int pgl_shmctl(int shmid, int cmd, struct shmid_ds *buf); - -int -pg_char_to_encoding_private(const char *name) -{ - return strcmp(name, "UTF8") == 0 ? 6 : -1; -} - -const char * -pg_encoding_to_char_private(int encoding) -{ - return encoding == 6 ? "UTF8" : ""; -} - -static int atexit_counter; - -static void -increment_atexit_counter(void) -{ - atexit_counter++; -} - -static int -check_locale_pipe(void) -{ - char temp_template[] = "/tmp/pglite-bridge-abi-XXXXXX"; - char *dir = mkdtemp(temp_template); - CHECK(dir != NULL); - CHECK(setenv("PGSYSCONFDIR", dir, 1) == 0); - CHECK(setenv("PGCLIENTENCODING", "UTF8", 1) == 0); - - errno = 0; - CHECK(pgl_popen("uname -a", "r") == NULL); - CHECK(errno == ENOSYS); - errno = 0; - CHECK(pgl_popen("locale -a", "w") == NULL); - CHECK(errno == ENOSYS); - - FILE *file = pgl_popen("locale -a", "r"); - CHECK(file != NULL); - char contents[128] = {0}; - size_t read_len = fread(contents, 1, sizeof(contents) - 1, file); - CHECK(fclose(file) == 0); - CHECK(read_len > 0); - CHECK(strstr(contents, "C\n") != NULL); - CHECK(strstr(contents, "C.UTF8\n") != NULL); - CHECK(strstr(contents, "POSIX\n") != NULL); - CHECK(unsetenv("PGSYSCONFDIR") == 0); - errno = 0; - CHECK(pgl_popen("locale -a", "r") == NULL); - CHECK(errno == ENOENT); - return 0; -} - -static int -check_identity_and_fail_closed_calls(void) -{ - CHECK(pgl_geteuid() == 123); - CHECK(pgl_getuid() == 123); - struct passwd *pw = pgl_getpwuid(123); - CHECK(pw != NULL); - CHECK(strcmp(pw->pw_name, "postgres") == 0); - CHECK(pw->pw_uid == 123); - errno = 0; - CHECK(pgl_getpwuid(999) == NULL); - CHECK(errno == ENOENT); - - errno = 0; - CHECK(pgl_system("echo unsafe") == -1); - CHECK(errno == ENOSYS); - - CHECK(pgl_set_force_host_error_recovery(1) == 0); - CHECK(pgl_set_force_host_error_recovery(0) == 1); - CHECK(pgl_setPGliteActive(1) == 0); - CHECK(pgl_setPGliteActive(0) == 1); - CHECK(pgl_atexit(increment_atexit_counter) == 0); - CHECK(pgl_atexit(increment_atexit_counter) == 0); - pgl_run_atexit_funcs(); - CHECK(atexit_counter == 2); - pgl_run_atexit_funcs(); - CHECK(atexit_counter == 2); - - errno = 0; - CHECK(pgl_connect(1, NULL, 0) == -1); - CHECK(errno == ENOSYS); - errno = 0; - CHECK(pgl_connect(-1, NULL, 0) == -1); - CHECK(errno == EBADF); - return 0; -} - -static int -check_protocol_socket(void) -{ - char buf[8] = {0}; - const char input[] = "abc"; - const char output[] = "xyz"; - - CHECK(pgl_wasix_input_reset() == 0); - CHECK(pgl_wasix_output_reset() == 0); - CHECK(pgl_recv(1, buf, sizeof(buf), 0) == 0); - CHECK(pgl_wasix_input_write(input, sizeof(input) - 1) == (int) (sizeof(input) - 1)); - CHECK(pgl_wasix_input_available() == sizeof(input) - 1); - CHECK(pgl_recv(1, buf, 2, 0) == 2); - CHECK(memcmp(buf, "ab", 2) == 0); - CHECK(pgl_wasix_input_available() == 1); - - CHECK(pgl_send(1, output, sizeof(output) - 1, 0) == (ssize_t) (sizeof(output) - 1)); - CHECK(pgl_wasix_output_len() == sizeof(output) - 1); - memset(buf, 0, sizeof(buf)); - CHECK(pgl_wasix_output_read(buf, sizeof(buf)) == sizeof(output) - 1); - CHECK(memcmp(buf, output, sizeof(output) - 1) == 0); - - CHECK(pgl_set_protocol_stdio(0) == 0); - CHECK(pgl_protocol_stream_active() == 0); - CHECK(pgl_set_protocol_stdio(1) == 0); - CHECK(pgl_protocol_stream_active() == 1); - CHECK(pgl_set_protocol_stdio(0) == 1); - CHECK(pgl_protocol_stream_active() == 0); - CHECK(pgl_set_protocol_transport(2) == 0); - CHECK(pgl_protocol_stream_active() == 0); - CHECK(pgl_protocol_copy_state() == 0); - pgl_protocol_report_copy_response(1); - CHECK(pgl_protocol_copy_state() == 1); - CHECK(pgl_send(1, output, sizeof(output) - 1, 0) == (ssize_t) (sizeof(output) - 1)); - CHECK(pgl_protocol_stream_active() == 1); - CHECK(pgl_set_protocol_transport(0) == 2); - CHECK(pgl_protocol_stream_active() == 0); - CHECK(pgl_protocol_copy_state() == 0); - CHECK(pgl_set_protocol_transport(2) == 0); - pgl_protocol_report_copy_response(0); - CHECK(pgl_protocol_copy_state() == 0); - CHECK(pgl_set_protocol_transport(0) == 2); - errno = 0; - CHECK(pgl_set_protocol_transport(99) == -1); - CHECK(errno == EINVAL); - -#ifdef ENOTSOCK - errno = 0; - CHECK(pgl_recv(2, buf, sizeof(buf), 0) == -1); - CHECK(errno == ENOTSOCK); - errno = 0; - CHECK(pgl_send(2, output, sizeof(output) - 1, 0) == -1); - CHECK(errno == ENOTSOCK); -#endif - - CHECK(pgl_fcntl(1, F_GETFL) == 0); - CHECK(pgl_fcntl(1, F_SETFL, O_NONBLOCK) == 0); -#ifdef O_APPEND - errno = 0; - CHECK(pgl_fcntl(1, F_SETFL, O_APPEND) == -1); - CHECK(errno == EINVAL); -#endif - - int opt = 1; - CHECK(pgl_setsockopt(1, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt)) == 0); -#ifdef TCP_NODELAY - CHECK(pgl_setsockopt(1, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)) == 0); -#endif - errno = 0; - CHECK(pgl_setsockopt(1, SOL_SOCKET, 0x7ffffffe, &opt, sizeof(opt)) == -1); - CHECK(errno == ENOPROTOOPT); - - opt = 0; - socklen_t optlen = sizeof(opt); - CHECK(pgl_getsockopt(1, SOL_SOCKET, SO_TYPE, &opt, &optlen) == 0); - CHECK(opt == SOCK_STREAM); - CHECK(optlen == (socklen_t) sizeof(opt)); - errno = 0; - optlen = sizeof(opt); - CHECK(pgl_getsockopt(1, SOL_SOCKET, 0x7ffffffd, &opt, &optlen) == -1); - CHECK(errno == ENOPROTOOPT); - - struct sockaddr_storage addr; - socklen_t addrlen = sizeof(addr); - CHECK(pgl_getsockname(1, (struct sockaddr *) &addr, &addrlen) == 0); - CHECK(addr.ss_family == AF_UNIX); - - CHECK(pgl_wasix_input_reset() == 0); - struct pollfd fds[1] = {{.fd = 1, .events = POLLIN, .revents = 0}}; - CHECK(pgl_poll(fds, 1, 0) == 0); - CHECK(fds[0].revents == 0); - CHECK(pgl_wasix_input_write("q", 1) == 1); - CHECK(pgl_poll(fds, 1, 0) == 1); - CHECK((fds[0].revents & POLLIN) != 0); - - struct pollfd ignored[1] = {{.fd = -1, .events = POLLIN, .revents = 0}}; - CHECK(pgl_poll(ignored, 1, 0) == 0); - struct pollfd mixed[2] = { - {.fd = 1, .events = POLLOUT, .revents = 0}, - {.fd = 99, .events = POLLIN, .revents = 0}, - }; - CHECK(pgl_poll(mixed, 2, 0) == 2); - CHECK((mixed[0].revents & POLLOUT) != 0); -#ifdef POLLNVAL - CHECK((mixed[1].revents & POLLNVAL) != 0); -#endif - return 0; -} - -static int -check_memory_and_shared_memory(void) -{ - errno = 0; - CHECK(pgl_munmap(NULL, 0) == -1); - CHECK(errno == EINVAL); - -#if defined(MAP_ANON) - int anon_flag = MAP_ANON; -#elif defined(MAP_ANONYMOUS) - int anon_flag = MAP_ANONYMOUS; -#else - int anon_flag = 0; -#endif - if (anon_flag != 0) - { - void *mapping = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | anon_flag, -1, 0); - CHECK(mapping != MAP_FAILED); - CHECK(pgl_munmap(mapping, 4096) == 0); - } - - key_t key = 4242; - int shmid = pgl_shmget(key, 64, IPC_CREAT | IPC_EXCL); - CHECK(shmid > 0); - errno = 0; - CHECK(pgl_shmget(key, 64, IPC_CREAT | IPC_EXCL) == -1); - CHECK(errno == EEXIST); - errno = 0; - CHECK(pgl_shmget(key + 1, 64, 0) == -1); - CHECK(errno == ENOENT); - - void *addr = pgl_shmat(shmid, NULL, 0); - CHECK(addr != (void *) -1); - memset(addr, 0x7b, 64); - - struct shmid_ds statbuf; - CHECK(pgl_shmctl(shmid, IPC_STAT, &statbuf) == 0); - CHECK(statbuf.shm_segsz == 64); - CHECK(statbuf.shm_nattch == 1); - CHECK(pgl_shmdt(addr) == 0); - CHECK(pgl_shmctl(shmid, IPC_RMID, NULL) == 0); - errno = 0; - CHECK(pgl_shmat(shmid, NULL, 0) == (void *) -1); - CHECK(errno == EINVAL); - return 0; -} - -int -main(void) -{ - CHECK(check_locale_pipe() == 0); - CHECK(check_identity_and_fail_closed_calls() == 0); - CHECK(check_protocol_socket() == 0); - CHECK(check_memory_and_shared_memory() == 0); - return 0; -} diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..d1522f11 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,32 @@ +# Benchmarks + +Benchmark definitions, workload specs, baselines, and intentionally promoted +reports belong here. Executable benchmark harnesses stay under `tools/perf`. + +The long-term benchmark matrix should compare: + +- native PostgreSQL control; +- `liboliphaunt` direct mode; +- `oliphaunt` direct, broker, and server modes; +- SQLite baselines for comparable embedded workloads. + +The native `oliphaunt` matrix in +`tools/perf/matrix/run_native_oliphaunt_matrix.sh` now includes direct, broker, +server, native PostgreSQL, SQLite, streaming, direct/broker/server +prepared-update, native PostgreSQL prepared-update, resource, and artifact-size +rows. RTT report rows include p50/p90/p95/p99 tail latency. Prepared-update +report rows include fresh-process p50/p90/p95, native-PostgreSQL p90 ratios, +and command-level CPU/RSS/footprint. + +Current layout: + +- `native/sql/`: fixed SQL workloads used by native direct, broker, server, + native PostgreSQL, and SQLite comparison suites. +- `native/baselines/`: committed native baselines when promoted as release + evidence. +- `wasix/`: WASIX benchmark specs and baselines. +- `mobile/`: mobile benchmark specs and baselines. +- `reports/`: published reports promoted as release evidence. + +Tooling may live in `tools/` when it is an executable harness, but benchmark +plans, datasets, baselines, and published reports live here. diff --git a/benchmarks/mobile/README.md b/benchmarks/mobile/README.md new file mode 100644 index 00000000..f5f9fb39 --- /dev/null +++ b/benchmarks/mobile/README.md @@ -0,0 +1,4 @@ +# Mobile Benchmarks + +Mobile benchmark specs and baselines live here. Emulator, simulator, and device +orchestration stays under `tools/perf` and product-owned mobile tooling. diff --git a/benchmarks/moon.yml b/benchmarks/moon.yml new file mode 100644 index 00000000..0608d9f1 --- /dev/null +++ b/benchmarks/moon.yml @@ -0,0 +1,27 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "benchmarks" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["benchmarks", "performance", "inputs"] + +project: + title: "Benchmark Inputs" + description: "Benchmark SQL fixtures, baselines, and report shape documentation." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/perf" + paths: + "**/*": ["@oliphaunt/perf"] + +tasks: + check: + tags: ["quality", "static"] + command: "true" + inputs: + - "/benchmarks/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/benchmarks/native/README.md b/benchmarks/native/README.md new file mode 100644 index 00000000..3c9e0ce9 --- /dev/null +++ b/benchmarks/native/README.md @@ -0,0 +1,8 @@ +# Native Benchmarks + +Native benchmark specs live here. Runner code stays under `tools/perf`. + +- `sql/`: fixed SQL workload files used by native direct, broker, server, + native PostgreSQL, and SQLite comparison suites. +- `baselines/`: committed comparison baselines when a release intentionally + records them. diff --git a/benchmarks/native/baselines/README.md b/benchmarks/native/baselines/README.md new file mode 100644 index 00000000..ceb4f024 --- /dev/null +++ b/benchmarks/native/baselines/README.md @@ -0,0 +1,5 @@ +# Native Baselines + +Committed native benchmark baselines live here when release evidence is +intentionally promoted. Local and CI-generated reports stay under `target/perf` +unless they are promoted as release artifacts. diff --git a/benchmarks/native/sql/benchmark1.sql b/benchmarks/native/sql/benchmark1.sql new file mode 100644 index 00000000..3bb2bda7 --- /dev/null +++ b/benchmarks/native/sql/benchmark1.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS bench_fixture; +CREATE TABLE bench_fixture(id integer PRIMARY KEY, value text); +INSERT INTO bench_fixture SELECT i, 'value-' || i::text FROM generate_series(1, 1000) AS i; diff --git a/benchmarks/native/sql/benchmark10.sql b/benchmarks/native/sql/benchmark10.sql new file mode 100644 index 00000000..9acf6948 --- /dev/null +++ b/benchmarks/native/sql/benchmark10.sql @@ -0,0 +1 @@ +UPDATE bench_fixture SET value = reverse(value) WHERE id BETWEEN 501 AND 1000; diff --git a/benchmarks/native/sql/benchmark11.sql b/benchmarks/native/sql/benchmark11.sql new file mode 100644 index 00000000..4b94b307 --- /dev/null +++ b/benchmarks/native/sql/benchmark11.sql @@ -0,0 +1 @@ +SELECT avg(id), percentile_cont(0.9) WITHIN GROUP (ORDER BY id) FROM bench_fixture; diff --git a/benchmarks/native/sql/benchmark12.sql b/benchmarks/native/sql/benchmark12.sql new file mode 100644 index 00000000..30988939 --- /dev/null +++ b/benchmarks/native/sql/benchmark12.sql @@ -0,0 +1 @@ +DELETE FROM bench_fixture WHERE id % 7 = 0; diff --git a/benchmarks/native/sql/benchmark13.sql b/benchmarks/native/sql/benchmark13.sql new file mode 100644 index 00000000..5a204381 --- /dev/null +++ b/benchmarks/native/sql/benchmark13.sql @@ -0,0 +1,2 @@ +CREATE TABLE IF NOT EXISTS bench_fixture_copy AS SELECT * FROM bench_fixture WHERE false; +INSERT INTO bench_fixture_copy SELECT * FROM bench_fixture; diff --git a/benchmarks/native/sql/benchmark14.sql b/benchmarks/native/sql/benchmark14.sql new file mode 100644 index 00000000..1e73c5e8 --- /dev/null +++ b/benchmarks/native/sql/benchmark14.sql @@ -0,0 +1,2 @@ +TRUNCATE bench_fixture_copy; +INSERT INTO bench_fixture_copy SELECT * FROM bench_fixture WHERE id % 3 = 0; diff --git a/benchmarks/native/sql/benchmark15.sql b/benchmarks/native/sql/benchmark15.sql new file mode 100644 index 00000000..5c19cdcc --- /dev/null +++ b/benchmarks/native/sql/benchmark15.sql @@ -0,0 +1,2 @@ +DELETE FROM bench_fixture WHERE id > 1500; +INSERT INTO bench_fixture SELECT i, 'refill-' || i::text FROM generate_series(2001, 2500) AS i; diff --git a/benchmarks/native/sql/benchmark16.sql b/benchmarks/native/sql/benchmark16.sql new file mode 100644 index 00000000..b72422ba --- /dev/null +++ b/benchmarks/native/sql/benchmark16.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS bench_fixture_copy; +DROP TABLE IF EXISTS bench_fixture; diff --git a/benchmarks/native/sql/benchmark2.sql b/benchmarks/native/sql/benchmark2.sql new file mode 100644 index 00000000..23de70d3 --- /dev/null +++ b/benchmarks/native/sql/benchmark2.sql @@ -0,0 +1 @@ +INSERT INTO bench_fixture SELECT i, 'extra-' || i::text FROM generate_series(1001, 2000) AS i; diff --git a/benchmarks/native/sql/benchmark3.sql b/benchmarks/native/sql/benchmark3.sql new file mode 100644 index 00000000..81d48abc --- /dev/null +++ b/benchmarks/native/sql/benchmark3.sql @@ -0,0 +1 @@ +SELECT count(*), min(id), max(id) FROM bench_fixture; diff --git a/benchmarks/native/sql/benchmark4.sql b/benchmarks/native/sql/benchmark4.sql new file mode 100644 index 00000000..88959e86 --- /dev/null +++ b/benchmarks/native/sql/benchmark4.sql @@ -0,0 +1 @@ +SELECT * FROM bench_fixture WHERE id BETWEEN 250 AND 260 ORDER BY id; diff --git a/benchmarks/native/sql/benchmark5.sql b/benchmarks/native/sql/benchmark5.sql new file mode 100644 index 00000000..ba83532b --- /dev/null +++ b/benchmarks/native/sql/benchmark5.sql @@ -0,0 +1 @@ +UPDATE bench_fixture SET value = value || '-updated' WHERE id % 10 = 0; diff --git a/benchmarks/native/sql/benchmark6.sql b/benchmarks/native/sql/benchmark6.sql new file mode 100644 index 00000000..a4552db0 --- /dev/null +++ b/benchmarks/native/sql/benchmark6.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS bench_fixture_value_idx ON bench_fixture(value); +SELECT count(*) FROM bench_fixture WHERE value LIKE 'value-9%'; diff --git a/benchmarks/native/sql/benchmark7.sql b/benchmarks/native/sql/benchmark7.sql new file mode 100644 index 00000000..e8bb9bfc --- /dev/null +++ b/benchmarks/native/sql/benchmark7.sql @@ -0,0 +1 @@ +SELECT id, value FROM bench_fixture ORDER BY value LIMIT 100; diff --git a/benchmarks/native/sql/benchmark8.sql b/benchmarks/native/sql/benchmark8.sql new file mode 100644 index 00000000..b9308f94 --- /dev/null +++ b/benchmarks/native/sql/benchmark8.sql @@ -0,0 +1 @@ +SELECT value, count(*) FROM bench_fixture GROUP BY value ORDER BY count(*) DESC, value LIMIT 50; diff --git a/benchmarks/native/sql/benchmark9.sql b/benchmarks/native/sql/benchmark9.sql new file mode 100644 index 00000000..6bd1f526 --- /dev/null +++ b/benchmarks/native/sql/benchmark9.sql @@ -0,0 +1 @@ +UPDATE bench_fixture SET value = reverse(value) WHERE id BETWEEN 1 AND 500; diff --git a/benchmarks/reports/README.md b/benchmarks/reports/README.md new file mode 100644 index 00000000..01ead683 --- /dev/null +++ b/benchmarks/reports/README.md @@ -0,0 +1,4 @@ +# Benchmark Reports + +Published benchmark reports live here only when they are intentionally promoted +as release evidence. Measured local and CI output stays under `target/perf`. diff --git a/benchmarks/wasix/README.md b/benchmarks/wasix/README.md new file mode 100644 index 00000000..58d4973d --- /dev/null +++ b/benchmarks/wasix/README.md @@ -0,0 +1,4 @@ +# WASIX Benchmarks + +WASIX benchmark specs and baselines live here. Rust runner implementation and +runtime orchestration stay under `tools/perf` and the WASIX product source tree. diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..6da8c004 --- /dev/null +++ b/biome.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "files": { + "includes": [ + "package.json", + "biome.json", + "renovate.json", + ".markdownlint-cli2.jsonc", + "src/docs/package.json", + "src/docs/tsconfig.json", + "src/docs/app/**/*.ts", + "src/docs/app/**/*.tsx", + "src/docs/app/**/*.css", + "src/docs/lib/**/*.ts", + "src/docs/mdx-components.tsx", + "src/docs/next.config.mjs", + "src/docs/source.config.ts", + "src/docs/tools/**/*.mjs", + "src/sdks/react-native/package.json", + "src/sdks/react-native/typedoc.json", + "src/sdks/react-native/react-native.config.js", + "src/sdks/react-native/src/**/*.ts", + "src/sdks/react-native/src/**/*.tsx", + "src/sdks/js/package.json", + "src/sdks/js/typedoc.json", + "src/sdks/js/jsr.json", + "src/sdks/js/src/**/*.ts", + "tools/perf/matrix/**/*.json", + "tools/perf/matrix/**/*.ts", + "tools/test/**/*.mjs", + "!**/node_modules", + "!**/lib", + "!**/dist", + "!**/.expo", + "!**/ios", + "!**/android" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "json": { + "formatter": { + "trailingCommas": "none" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + } + } + } +} diff --git a/clippy.toml b/clippy.toml index 93e1038d..3dfc3206 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,2 +1,2 @@ -msrv = "1.92.0" +msrv = "1.93.0" avoid-breaking-exported-api = true diff --git a/coverage/baseline.toml b/coverage/baseline.toml new file mode 100644 index 00000000..e6cd8bb4 --- /dev/null +++ b/coverage/baseline.toml @@ -0,0 +1,299 @@ +# Coverage baselines are measured product evidence, not policy placeholders. +# `line_threshold` is the aggregate gate. `per_file_line_threshold` prevents +# aggregate coverage from hiding weak files. `measured_line_coverage` records +# the last committed local/CI measurement for audit only. + +[policy] +fail_on_unmeasured_product = true +minimum_new_sdk_line_coverage = 80.0 +release_ratchet_percentage_points = 2.0 +target_sdk_line_coverage = 85.0 + +[products.oliphaunt-rust] +tool = "cargo-llvm-cov" +line_threshold = 80.0 +measured_line_coverage = 82.56 +summary = "target/coverage/oliphaunt-rust/summary.json" +reports = ["target/coverage/oliphaunt-rust/lcov.info"] +source_globs = ["src/sdks/rust/src/*.rs"] +exclude_globs = [ + "src/sdks/rust/tests/**", + "src/runtimes/liboliphaunt/native/**", + "src/postgres/versions/18/**", + "target/**", +] +per_file_line_threshold = 50.0 +branch_coverage = "advisory" +function_coverage = "advisory" + +[[products.oliphaunt-rust.waivers]] +path = "src/sdks/rust/src/broker.rs" +reason = "broker process orchestration is runtime/integration evidence, not deterministic unit coverage yet" +evidence = "oliphaunt-rust smoke/regression lanes and TypeScript broker helper tests" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-rust.waivers]] +path = "src/sdks/rust/src/ipc.rs" +reason = "local IPC framing is exercised through broker integration tests and shared protocol fixtures" +evidence = "oliphaunt-rust broker tests plus src/shared/fixtures/protocol/query-response-cases.json consumers" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-rust.waivers]] +path = "src/sdks/rust/src/lib.rs" +reason = "crate root is a re-export surface with no durable executable behavior to line-cover" +evidence = "cargo doctests and SDK package-shape checks" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-rust.waivers]] +path = "src/sdks/rust/src/pgwire.rs" +reason = "external wire compatibility is regression evidence rather than SDK wrapper unit coverage" +evidence = "server/client compatibility regression lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-rust.waivers]] +path = "src/sdks/rust/src/server.rs" +reason = "server process lifecycle requires runtime artifacts and belongs in smoke/regression lanes" +evidence = "oliphaunt-rust smoke/regression server tests" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[products.oliphaunt-swift] +tool = "swift test --enable-code-coverage" +line_threshold = 80.0 +measured_line_coverage = 86.83 +summary = "target/coverage/oliphaunt-swift/summary.json" +reports = ["target/coverage/oliphaunt-swift/swift-coverage.json"] +source_globs = [ + "src/sdks/swift/Sources/Oliphaunt/*.swift", + "src/sdks/swift/Sources/Oliphaunt/**/*.swift", +] +exclude_globs = [ + "src/sdks/swift/Sources/COliphaunt/**", + "src/sdks/swift/Tests/**", + "target/**", +] +per_file_line_threshold = 50.0 +branch_coverage = "advisory" +function_coverage = "advisory" + +[[products.oliphaunt-swift.waivers]] +path = "src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift" +reason = "native direct FFI shell is validated by runtime smoke/XCTest paths rather than pure Swift line coverage" +evidence = "Swift smoke, iOS/macOS packaging, and liboliphaunt C ABI tests" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[products.oliphaunt-kotlin] +tool = "kover" +line_threshold = 80.0 +measured_line_coverage = 83.15 +summary = "target/coverage/oliphaunt-kotlin/summary.json" +reports = ["target/coverage/oliphaunt-kotlin/kover.xml"] +source_globs = ["src/sdks/kotlin/oliphaunt/src/**/*.kt"] +exclude_globs = [ + "src/sdks/kotlin/oliphaunt/src/*Test/**", + "src/sdks/kotlin/oliphaunt/src/**/*Test.kt", + "src/sdks/kotlin/**/build/**", + "src/sdks/kotlin/**/.cxx/**", + "src/sdks/kotlin/**/generated/**", + "target/**", +] +per_file_line_threshold = 50.0 +branch_coverage = "advisory" +function_coverage = "advisory" + +[[products.oliphaunt-kotlin.waivers]] +path = "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt" +reason = "Android JNI-backed direct engine is runtime/device evidence, not JVM/Kover unit coverage" +evidence = "Android unit/resource tests, native smoke, and mobile E2E lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-kotlin.waivers]] +path = "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt" +reason = "Android app-facing helper binds packaged resources and is covered by Android resource/package tests" +evidence = "OliphauntAndroidRuntimeAssetsTest and package-shape checks" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-kotlin.waivers]] +path = "src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidNativeBridge.kt" +reason = "JNI bridge shell is validated by native runtime smoke and bridge compile checks" +evidence = "Android CMake compile, JNI smoke, and liboliphaunt C ABI tests" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-kotlin.waivers]] +path = "src/sdks/kotlin/oliphaunt/src/jvmMain/kotlin/dev/oliphaunt/DefaultEngine.kt" +reason = "JVM target intentionally reports runtime unavailable until the JVM/native artifact resolver is implemented" +evidence = "Kotlin package-shape tests and SDK parity mode-support fixtures" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-kotlin.waivers]] +path = "src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/DefaultEngine.kt" +reason = "Kotlin/Native default engine selection is validated with native target smoke rather than Kover JVM reports" +evidence = "Kotlin native smoke/regression lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-kotlin.waivers]] +path = "src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt" +reason = "Kotlin/Native C interop and owner-dispatcher behavior requires native runtime evidence outside Kover" +evidence = "Kotlin native smoke/regression lanes and liboliphaunt C ABI tests" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[products.oliphaunt-js] +tool = "vitest-v8" +line_threshold = 80.0 +measured_line_coverage = 82.43 +summary = "target/coverage/oliphaunt-js/summary.json" +reports = [ + "target/coverage/oliphaunt-js/coverage-summary.json", + "target/coverage/oliphaunt-js/lcov.info", +] +source_globs = [ + "src/sdks/js/src/*.ts", + "src/sdks/js/src/**/*.ts", +] +exclude_globs = [ + "src/sdks/js/src/__tests__/**", + "src/sdks/js/src/**/*.d.ts", + "src/sdks/js/src/**/types.ts", + "src/sdks/js/lib/**", + "src/sdks/js/node_modules/**", +] +per_file_line_threshold = 50.0 +branch_coverage = "advisory" +function_coverage = "advisory" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/native/assets-deno.ts" +reason = "Deno native asset resolution is covered by Deno package/runtime checks, not Node Vitest line coverage" +evidence = "TypeScript Deno package-shape and smoke lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/native/bun.ts" +reason = "Bun FFI binding is covered by Bun package/runtime checks, not Node Vitest line coverage" +evidence = "TypeScript Bun package-shape and smoke lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/native/default.ts" +reason = "runtime selector depends on host runtime and is covered through Node/Bun/Deno package checks" +evidence = "TypeScript package and native smoke lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/native/deno.ts" +reason = "Deno FFI binding is covered by Deno runtime checks, not Node Vitest line coverage" +evidence = "TypeScript Deno package-shape and smoke lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/native/node-addon.ts" +reason = "Node native addon loader is validated by package-shape, artifact-resolution, and native smoke lanes" +evidence = "native-bindings tests, package checks, and TypeScript native smoke" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/native/tar.ts" +reason = "tar extraction helpers are covered through asset resolver tests and release artifact checks" +evidence = "asset-resolver.test.ts and release artifact validation" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/runtime/broker.ts" +reason = "broker helper lifecycle requires Rust helper/runtime artifacts and belongs in smoke/regression evidence" +evidence = "runtime-modes tests, broker frame tests, and TypeScript native broker smoke" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/runtime/node-adapter.ts" +reason = "Node process adapter is partially exercised but runtime process behavior belongs in smoke lanes" +evidence = "runtime-modes tests and broker/server smoke lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/runtime/pgwire.ts" +reason = "external PostgreSQL wire compatibility is regression evidence rather than unit line coverage" +evidence = "server-wire tests and external client regression lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[[products.oliphaunt-js.waivers]] +path = "src/sdks/js/src/runtime/server.ts" +reason = "server process lifecycle requires runtime artifacts and belongs in smoke/regression evidence" +evidence = "runtime-modes tests and TypeScript native server smoke" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[products.oliphaunt-react-native] +tool = "vitest-v8" +line_threshold = 80.0 +measured_line_coverage = 80.97 +summary = "target/coverage/oliphaunt-react-native/summary.json" +reports = [ + "target/coverage/oliphaunt-react-native/coverage-summary.json", + "target/coverage/oliphaunt-react-native/lcov.info", +] +source_globs = [ + "src/sdks/react-native/src/*.ts", + "src/sdks/react-native/src/**/*.ts", + "src/sdks/react-native/app.plugin.js", +] +exclude_globs = [ + "src/sdks/react-native/src/__tests__/**", + "src/sdks/react-native/lib/**", + "src/sdks/react-native/node_modules/**", +] +per_file_line_threshold = 50.0 +branch_coverage = "advisory" +function_coverage = "advisory" + +[[products.oliphaunt-react-native.waivers]] +path = "src/sdks/react-native/src/specs/NativeOliphaunt.ts" +reason = "React Native TurboModule Codegen spec is validated by Codegen/package checks, not runtime JS line coverage" +evidence = "codegen:check, package-shape checks, and RN native adapter compile lanes" +owner = "@oliphaunt/core" +expires = "before-0.2.0" + +[products.oliphaunt-wasix-rust] +tool = "cargo-llvm-cov" +line_threshold = 80.0 +measured_line_coverage = 88.94 +summary = "target/coverage/oliphaunt-wasix-rust/summary.json" +reports = ["target/coverage/oliphaunt-wasix-rust/lcov.info"] +source_globs = ["src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/*.rs"] +exclude_globs = [ + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs", + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/tests.rs", + "src/runtimes/liboliphaunt/wasix/assets/generated/**", + "src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/**", + "src/runtimes/liboliphaunt/wasix/crates/aot/**", + "target/**", +] +per_file_line_threshold = 50.0 +branch_coverage = "advisory" +function_coverage = "advisory" + +[[products.oliphaunt-wasix-rust.waivers]] +path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/mod.rs" +reason = "protocol module root is an export shim; behavior lives in the measured protocol modules" +evidence = "oliphaunt-wasix protocol nextest and doctest coverage" +owner = "@oliphaunt/core" +expires = "before-0.2.0" diff --git a/crates/aot/aarch64-apple-darwin/Cargo.toml b/crates/aot/aarch64-apple-darwin/Cargo.toml deleted file mode 100644 index f0a0dc31..00000000 --- a/crates/aot/aarch64-apple-darwin/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "pglite-oxide-aot-aarch64-apple-darwin" -version = "0.5.1" -edition = "2024" -rust-version = "1.92" -description = "Internal Wasmer AOT artifacts for pglite-oxide on aarch64-apple-darwin" -repository = "https://github.com/f0rr0/pglite-oxide" -license = "MIT AND Apache-2.0 AND PostgreSQL" -publish = true -include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] - -[lib] -path = "src/lib.rs" diff --git a/crates/aot/aarch64-apple-darwin/README.md b/crates/aot/aarch64-apple-darwin/README.md deleted file mode 100644 index 9c39ab0d..00000000 --- a/crates/aot/aarch64-apple-darwin/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# pglite-oxide-aot-aarch64-apple-darwin - -Internal target-specific Wasmer AOT artifact crate for `pglite-oxide`. -Do not depend on this crate directly. diff --git a/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml b/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml deleted file mode 100644 index 0be4496d..00000000 --- a/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "pglite-oxide-aot-aarch64-unknown-linux-gnu" -version = "0.5.1" -edition = "2024" -rust-version = "1.92" -description = "Internal Wasmer AOT artifacts for pglite-oxide on aarch64-unknown-linux-gnu" -repository = "https://github.com/f0rr0/pglite-oxide" -license = "MIT AND Apache-2.0 AND PostgreSQL" -publish = true -include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] - -[lib] -path = "src/lib.rs" diff --git a/crates/aot/aarch64-unknown-linux-gnu/README.md b/crates/aot/aarch64-unknown-linux-gnu/README.md deleted file mode 100644 index 034067d4..00000000 --- a/crates/aot/aarch64-unknown-linux-gnu/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# pglite-oxide-aot-aarch64-unknown-linux-gnu - -Internal target-specific Wasmer AOT artifact crate for `pglite-oxide`. -Do not depend on this crate directly. diff --git a/crates/aot/x86_64-pc-windows-msvc/Cargo.toml b/crates/aot/x86_64-pc-windows-msvc/Cargo.toml deleted file mode 100644 index 348b1f7b..00000000 --- a/crates/aot/x86_64-pc-windows-msvc/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "pglite-oxide-aot-x86_64-pc-windows-msvc" -version = "0.5.1" -edition = "2024" -rust-version = "1.92" -description = "Internal Wasmer AOT artifacts for pglite-oxide on x86_64-pc-windows-msvc" -repository = "https://github.com/f0rr0/pglite-oxide" -license = "MIT AND Apache-2.0 AND PostgreSQL" -publish = true -include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] - -[lib] -path = "src/lib.rs" diff --git a/crates/aot/x86_64-pc-windows-msvc/README.md b/crates/aot/x86_64-pc-windows-msvc/README.md deleted file mode 100644 index e0d849f5..00000000 --- a/crates/aot/x86_64-pc-windows-msvc/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# pglite-oxide-aot-x86_64-pc-windows-msvc - -Internal target-specific Wasmer AOT artifact crate for `pglite-oxide`. -Do not depend on this crate directly. diff --git a/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml b/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml deleted file mode 100644 index c1a454f8..00000000 --- a/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "pglite-oxide-aot-x86_64-unknown-linux-gnu" -version = "0.5.1" -edition = "2024" -rust-version = "1.92" -description = "Internal Wasmer AOT artifacts for pglite-oxide on x86_64-unknown-linux-gnu" -repository = "https://github.com/f0rr0/pglite-oxide" -license = "MIT AND Apache-2.0 AND PostgreSQL" -publish = true -include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] - -[lib] -path = "src/lib.rs" diff --git a/crates/aot/x86_64-unknown-linux-gnu/README.md b/crates/aot/x86_64-unknown-linux-gnu/README.md deleted file mode 100644 index bf4ba9a8..00000000 --- a/crates/aot/x86_64-unknown-linux-gnu/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# pglite-oxide-aot-x86_64-unknown-linux-gnu - -Internal target-specific Wasmer AOT artifact crate for `pglite-oxide`. -Do not depend on this crate directly. diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml deleted file mode 100644 index c0ff5bed..00000000 --- a/crates/assets/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "pglite-oxide-assets" -version = "0.5.1" -edition = "2024" -rust-version = "1.92" -description = "Internal PGlite runtime and extension assets for pglite-oxide" -repository = "https://github.com/f0rr0/pglite-oxide" -homepage = "https://github.com/f0rr0/pglite-oxide" -documentation = "https://docs.rs/pglite-oxide-assets" -license = "MIT AND Apache-2.0 AND PostgreSQL" -publish = true -include = [ - "Cargo.toml", - "build.rs", - "README.md", - "src/**", - "payload/**", -] - -[lib] -path = "src/lib.rs" - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/crates/assets/README.md b/crates/assets/README.md deleted file mode 100644 index 9fe9be2c..00000000 --- a/crates/assets/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# pglite-oxide-assets - -Internal runtime assets for `pglite-oxide`. - -Do not depend on this crate directly. It is published so Cargo can resolve the -default `pglite-oxide` feature set from crates.io. diff --git a/docs/ASSETS.md b/docs/ASSETS.md deleted file mode 100644 index 2f4f60e5..00000000 --- a/docs/ASSETS.md +++ /dev/null @@ -1,167 +0,0 @@ -# Maintainer Asset Notes - -This page is maintainer documentation for packaged runtime assets, generated -payloads, and release provenance. It is not end-user product documentation. -Application users should start with `README.md`, `docs/USAGE.md`, and -`docs/RUNTIME.md`. - -`pglite-oxide` ships the database runtime as package-managed assets. Most users -do not need to download Postgres, run Docker, install LLVM, or configure a -runtime path. - -## What Ships - -With default features, the crate includes: - -- the portable PGlite/Postgres WASIX runtime tree; -- a prepopulated PGDATA template for faster temporary databases; -- bundled extension archives for supported SQL extensions; -- the packaged `initdb` module used by asset CI and explicit fresh-initdb paths; -- the packaged `pg_dump` module used by the public dump API and CLI; -- a target-specific Wasmer AOT pack when the current host target is supported. - -The internal asset crates exist only because crates.io packages dependencies as -separate crates. Application code should depend on `pglite-oxide`, not on -`pglite-oxide-assets` or `pglite-oxide-aot-*` directly. - -## Feature Flags - -Default install: - -```toml -pglite-oxide = "0.4" -``` - -Default features include the packaged runtime/AOT assets and bundled extension -APIs: - -```toml -pglite-oxide = { version = "0.4", default-features = false, features = ["bundled"] } -``` - -The `bundled` feature keeps the package-managed PGlite/Postgres runtime and the -current platform's AOT crate, but leaves the public extension API disabled. -This is the "embedded Postgres without extension helpers" mode. - -Size-sensitive builds can opt out of packaged assets entirely: - -```toml -pglite-oxide = { version = "0.4", default-features = false } -``` - -When bundled assets are disabled, normal database opens do not have packaged -runtime/AOT assets available. This mode is intended for specialized maintainer -and custom-runtime workflows. - -## Cache Behavior - -Runtime files are expanded into a cache and then composed with a small writable -per-root skeleton by default. Temporary and template-backed databases use a -cached PGDATA template as a lower filesystem and materialize files into the -database root only when PostgreSQL opens them for mutation. - -The runtime tree keeps both `/bin/pglite` and `/bin/postgres`. They are the same -backend module; the `postgres` path exists so upstream `initdb` can discover and -spawn the backend through PostgreSQL's normal `find_other_exec()` path. - -The cache is content-addressed by the asset manifest and artifact hashes. If an -asset hash does not match the manifest, startup fails instead of using a mixed -or corrupted runtime. - -## Extension Assets - -Extensions are demand-driven. An extension archive is installed into the -database root only when the builder requests it or `enable_extension` is called: - -```rust,no_run -use pglite_oxide::{extensions, Pglite}; - -let mut db = Pglite::builder() - .temporary() - .extension(extensions::VECTOR) - .open()?; - -db.enable_extension(extensions::PG_TRGM)?; -# Ok::<_, Box>(()) -``` - -Archive extraction rejects parent traversal, absolute paths, symlinks, -hardlinks, device nodes, and unsupported entry types. - -## Provenance - -Asset provenance is recorded in `assets/sources.toml`, the committed asset -input fingerprint, and the generated asset/AOT manifests produced by the Assets -workflow. Generated manifests record source pins, runtime hashes, `initdb` -hashes, PGDATA template hashes, extension archive hashes, target information, -and Wasmer engine identity. - -The public repository tracks source-controlled inputs and crate skeletons. It -does not track upstream source checkouts, generated PGDATA templates, portable -WASIX blobs, or native AOT binaries. -Maintainer source trees are fetched on demand into ignored -`assets/checkouts/**` directories: - -```sh -cargo run -p xtask -- assets fetch -``` - -Normal development and source-free validation do not clone upstream repositories -or run Docker. The source-free gate is: - -```sh -cargo run -p xtask -- assets verify-committed -``` - -It verifies source pins, source/build input fingerprints, extension -metadata/constants when generated manifests are installed, AOT crate templates, -and the absence of committed PGDATA template, portable WASIX, or native AOT -blobs. - -Release assets are built with the `release-o3` profile by default: WASIX C code -uses `-O3 -g0 -flto=thin`, links with `-flto=thin`, and Binaryen runs the -wasixcc default optimization plus `--converge`, `--strip-debug`, and -`--strip-producers`. - -Generated runtime hashes in package metadata are refreshed in the release -staging workspace. They are not a committed source-of-truth value in normal -development; `assets/sources.toml` and `assets/generated/asset-inputs.sha256` -are the small committed provenance files. - -The `Assets` workflow mirrors the release topology: one Linux/Docker job builds -portable WASIX modules from `assets/wasix-build` into -`target/pglite-oxide/assets`, then native matrix jobs generate and package -target-specific Wasmer AOT crates into `target/pglite-oxide/aot/`. -Artifacts are uploaded with checksums, manifests, and the committed asset-input -fingerprint. - -Manual `Assets` runs use the same producer path. Maintainers may select one -native target for focused validation, but the workflow still rebuilds portable -WASIX assets, generates AOT artifacts, runs the runtime gate, stages the release -workspace, package-checks the target crate, and uploads the canonical release -artifact shape. - -Native AOT generation intentionally installs Wasmer's LLVM 22.1.x custom build -only inside the Assets workflow or a maintainer's explicit local artifact -build. Normal contributors and end users never need LLVM; they use committed -Rust sources plus downloaded or released AOT payloads. - -The normal CI runtime matrix downloads the latest compatible Assets workflow -bundle, verifies that the downloaded fingerprint matches the current source -inputs, installs the payloads into ignored generated paths, and runs runtime -tests. Any change to source pins, WASIX patches, extension catalogs, build -scripts, or AOT crate templates is treated as asset-producing and must pass the -full `Assets` workflow. Release validation downloads the exact-SHA portable and -AOT bundles, stages them into a clean release workspace, validates package -contents, and only then publishes. - -Published releases also attach public `.tar.zst` mirrors of the validated -portable WASIX and target AOT bundles. `xtask assets download --release ` -installs those release assets directly and does not require the GitHub CLI. - -After an intentional asset-source change and regenerated artifacts, refresh the -committed input fingerprint: - -```sh -cargo run -p xtask -- assets input-fingerprint --write -``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md deleted file mode 100644 index e935e49e..00000000 --- a/docs/DEVELOPMENT.md +++ /dev/null @@ -1,196 +0,0 @@ -# Maintainer Development Guide - -This page is maintainer documentation for repository validation, generated -artifacts, and local release workflows. It is not end-user product -documentation. - -Run the local gates before opening a PR: - -```sh -scripts/bootstrap-tools.sh -scripts/validate.sh dev -scripts/validate.sh workflows -scripts/validate.sh supply-chain -``` - -The validation entrypoint is split by maintainer workflow: - -- `scripts/validate.sh repo`: file hygiene and formatting; -- `scripts/validate.sh artifacts`: source-controlled asset input verification - plus AOT crate template checks; -- `scripts/validate.sh lint`: dependency invariants and clippy; -- `scripts/validate.sh test`: source-only no-default-features checks, - doctests, and test compilation without requiring generated runtime assets; -- `scripts/validate.sh workflows`: local `actionlint` and `zizmor` checks using - the same zizmor config and severity/persona as CI; -- `scripts/validate.sh runtime`: hard-requires portable assets plus host AOT, - installs them into ignored paths, and runs the real runtime tests; -- `scripts/validate.sh runtime-smoke`: the runtime smoke subset; -- `scripts/validate.sh examples`: Tauri/Rust/frontend example checks; -- `scripts/validate.sh package`: package all published crates and enforce - crates.io size limits; -- `scripts/validate.sh feature-powerset`: cargo-hack feature combination checks; -- `scripts/validate.sh semver`: cargo-semver-checks public API compatibility; -- `scripts/validate.sh supply-chain`: cargo-deny dependency policy checks; -- `scripts/validate.sh ci`: full local CI parity lane; -- `scripts/validate.sh dev-ci`: fast contributor lane for repo, lint, source - tests, and examples; -- `scripts/validate.sh release`: release-workspace package checks plus publish - dry-runs for internal crates after CI-generated AOT artifacts have been - downloaded. - -The hook split is intentionally small: - -- pre-commit: file hygiene and formatting -- pre-push: whitespace diff check, `scripts/validate.sh lint`, and - `scripts/validate.sh test` -- CI/release: path-aware combinations of the same validation modes, workflow - linting, feature powerset, public API compatibility, crate packaging, - native AOT runtime tests, release-plz dry-run/publish, and supply-chain - policy - -Install local hooks and pinned CLI tools when needed. The bootstrap installs -`cargo-binstall` first and uses binary installs for Rust tools before falling -back to source builds. - -```sh -scripts/bootstrap-tools.sh -scripts/install-hooks.sh -``` - -`tests/runtime_smoke.rs` starts the real WASM backend and is intentionally -slower than the protocol unit tests. - -## Maintenance Utilities - -The repository includes maintenance commands: - -- `pglite-dump` is the logical dump CLI entry point. -- `pglite-proxy` exposes a local PostgreSQL socket backed by the embedded - runtime. -- `xtask assets template` generates the architecture-independent PGDATA - template from the split WASIX `initdb` module. Portable WASIX, PGDATA - templates, and native AOT payloads remain generated-only. - -Asset and source checks: - -```sh -cargo run -p xtask -- assets verify-committed -cargo run -p xtask -- assets fetch -cargo run -p xtask -- assets check --strict-local -cargo run -p xtask -- assets check --strict-generated -cargo run -p xtask --features template-runner -- assets template -cargo run -p xtask -- assets source-spine --check-patch-applies -cargo run -p xtask -- assets audit-upstream --strict -cargo run -p xtask -- assets input-fingerprint --write -cargo run -p xtask -- package-size --enforce -``` - -## Local Runtime Development - -Local development has three supported modes. - -Fast contributor mode does not require Docker, upstream source checkouts, or -generated native AOT payloads. Use it for ordinary Rust, docs, tests, examples, -and workflow edits: - -```sh -scripts/validate.sh dev-ci -cargo check --workspace --all-targets -cargo test --workspace --no-default-features -``` - -For the shortest source-only path, use: - -```sh -scripts/validate.sh dev -``` - -Host-platform artifact mode is for runtime work on the current machine. It -builds or packages only the current host target, leaves all generated payloads -in ignored paths, and then runs the real runtime tests: - -```sh -host="$(rustc -vV | awk '/^host:/{print $2}')" -cargo run -p xtask -- assets fetch -cargo run -p xtask --features aot-serializer -- assets build-host -scripts/validate.sh runtime -``` - -Local AOT generation requires the Wasmer LLVM 22.1.x build for the -maintainer-only serializer. That build includes the LLVM target set Wasmer's -LLVM backend expects, including LoongArch and WebAssembly. Set -`LLVM_SYS_221_PREFIX` to an extracted -`wasmerio/llvm-custom-builds` 22.x archive, or use downloaded-artifact mode to -avoid local LLVM setup. - -When the portable WASIX assets are already current and only the host AOT crate -needs to be refreshed, skip the source/Docker build and generate host AOT from -the existing generated portable assets: - -```sh -host="$(rustc -vV | awk '/^host:/{print $2}')" -cargo run -p xtask -- assets aot --target-triple "$host" -cargo run -p xtask -- assets package-aot --target-triple "$host" -scripts/validate.sh runtime -``` - -Downloaded-artifact mode is the intended way to test a CI-produced runtime -locally without rebuilding Postgres/WASIX. Download the successful Assets -workflow artifacts for the exact commit and install the host target payloads -into the same ignored generated locations used by the local build path: - -```sh -host="$(rustc -vV | awk '/^host:/{print $2}')" -cargo run -p xtask -- assets download --sha --target-triple "$host" -scripts/validate.sh runtime -``` - -For Rust-only work where the asset inputs have not changed, the same command -can install the latest compatible `main` bundle after verifying the -asset-input fingerprint: - -```sh -host="$(rustc -vV | awk '/^host:/{print $2}')" -cargo run -p xtask -- assets download --latest-compatible --target-triple "$host" -scripts/validate.sh runtime -``` - -Released artifact bundles can be installed without the GitHub CLI because they -are public GitHub release assets: - -```sh -host="$(rustc -vV | awk '/^host:/{print $2}')" -cargo run -p xtask -- assets download --release --target-triple "$host" -scripts/validate.sh runtime -``` - -Release validation can download every supported target from the exact Assets -workflow SHA: - -```sh -cargo run -p xtask -- assets download --sha --all-targets -scripts/validate.sh release -``` - -Developers should not be expected to build every target locally. Local runtime -work validates the host target; the Assets workflow is the authority for the -full macOS, Linux, and Windows AOT matrix. - -Contributors do not need upstream source checkouts for normal Rust, docs, -examples, or package validation. Maintainers fetch sources only when rebuilding -the portable WASIX runtime, extensions, `initdb`, `pg_dump`, or the generated -PGDATA template. Portable WASIX artifacts, generated PGDATA templates, and -native AOT artifacts are generated under `target/pglite-oxide/**` locally or by -CI; they are not committed to git. - -Rust-only PRs download the latest compatible Assets workflow bundle, verify its -asset-input fingerprint, install it into ignored generated paths, and run the -runtime test suite on every supported host target. Asset-producing PRs run the -heavier `Assets` workflow instead: that workflow rebuilds portable WASIX from -pinned sources, generates native AOT for every target, runs smoke tests, and -uploads release artifacts. - -Release process details are tracked in [RELEASE.md](RELEASE.md). -Completed implementation work is summarized in [DONE.md](DONE.md), and the -implementation backlog is tracked in [TODO.md](TODO.md). diff --git a/docs/DONE.md b/docs/DONE.md deleted file mode 100644 index 31ee0b09..00000000 --- a/docs/DONE.md +++ /dev/null @@ -1,928 +0,0 @@ -# Done (Maintainers) - -This is the single status document for implementation work already completed. -It is maintainer-facing and intentionally separate from the end-user docs. - -## Runtime Direction - -The repository now has one production direction: WASIX dynamic linking plus -headless Wasmer loading of CI-produced LLVM AOT artifacts. - -Removed or excluded from the production path: - -- Wasmtime/static-WASI runtime path; -- Emscripten/JavaScript glue runtime path; -- user-side Docker, LLVM, Cranelift, or local Postgres compilation; -- duplicated runtime layouts and host-side timezone/path rewrite shims; -- historical spike workspaces from the tracked repository. - -Production build inputs now live under `assets/`. - -## Workspace And Asset Crates - -Implemented: - -- root `pglite-oxide` crate remains the public crate; -- `pglite-oxide-assets` is the published runtime asset crate skeleton; -- source-only target AOT crate templates exist under `crates/aot/*`; -- `xtask` owns source checks, build orchestration, packaging, manifest checks, - package sizing, upstream audits, and source-spine validation; -- upstream checkouts are no longer tracked; maintainers fetch pinned sources on - demand into ignored `assets/checkouts`; -- source pins live in `assets/sources.toml`; -- root packages exclude upstream checkouts from published crates. -- `xtask assets verify-committed` validates source-controlled asset inputs, - source pins, package metadata, AOT crate templates, and generated extension - coherence when generated manifests are installed, without local upstream - checkouts; - -Generated release asset set: - -- portable PGlite WASIX runtime archive; -- `pg_dump.wasix.wasm`; -- deterministic `.tar.zst` archives for the 37 requested extension build - candidates. All 37 packaged extensions are stable public constants after - direct, server, restart, and lifecycle materialization gates; -- prepopulated PGDATA template archive; -- native Wasmer LLVM AOT artifacts. - -These artifacts are generated locally under `target/pglite-oxide/**` or by the -Assets workflow and are consumed by release staging without being committed to -git. - -## Source And Build Spine - -Implemented: - -- active source baseline switched to `electric-sql/postgres-pglite` - `REL_17_5-pglite` at `01792c31a62b7045eb22e93d7dad022bb64b1184`, matching - the audited `@electric-sql/pglite` 0.4.5 source/artifact pair; -- `pglite-build` `portable` is pinned as build-script provenance; -- maintained WASIX build files live under `assets/wasix-build`; -- `xtask assets build --execute` can produce the main runtime, support modules, - requested contrib/PGXS extension side modules, SQL-only extension payloads, - and `pg_dump` for the local target; -- `xtask assets package` emits deterministic archives, generated manifests, and - crate assets; -- `xtask assets aot` regenerates local Wasmer LLVM AOT artifacts; -- `xtask assets check --strict-generated` validates generated metadata; -- `xtask assets source-spine --check-patch-applies` validates the maintained - source patch and C ABI harness; -- `xtask assets audit-upstream --strict` records upstream fix decisions; -- required upstream fixes from `REL_17_5-pglite` are now the active source - spine rather than comparison material. The WASIX patch keeps dynamic-main and - side-module support, C startup timers, and explicit exports for the stable - branch lifecycle while reusing upstream `pgl_startPGlite`, - `pgl_setPGliteActive`, `ProcessStartupPacket`, `PostgresMainLoopOnce`, and - `PostgresMainLongJmp`; -- source-spine review conclusion: upstream PGlite's libc/host adaptations are - purposeful for wasm hosts, not arbitrary shortcuts. `pglitec.c` supplies - stable `postgres` identity, explicit process-active state, manual top-level - longjmp recovery, socket callbacks, shared-memory emulation, and explicit - atexit replay because browser/Emscripten cannot provide normal Postgres - child processes, sockets, Unix users, SysV shared memory, or a native process - lifecycle. The WASIX bridge keeps the same architectural contracts where - Wasmer still needs host assistance, but uses Rust-owned input/output buffers - instead of Emscripten callback pointers because the Rust host does not have - Emscripten's JS table callback mechanism; -- WASIX-specific deviation from upstream PGlite: top-level Postgres longjmp - detection uses `jmp_buf` pointer identity instead of upstream's buffer-content - `memcmp`. The memcmp test is acceptable in the Emscripten artifact it was - written for, but under Wasmer/WASIX it misclassified nested PostgreSQL - `PG_TRY` handlers and skipped normal portal cleanup. Pointer identity keeps - the host escape hatch scoped to the single exported top-level recovery buffer; -- WASIX-specific PostgreSQL fix: active portal abort cleanup is owned in - `AtAbort_Portals` for `PGLITE_WASIX_DL`, not in Rust. This keeps simple-query - and COPY error recovery at the PostgreSQL portal lifecycle boundary and avoids - fabricating cleanup behavior in the wire proxy; -- stable branch behavior note: startup `ParameterStatus` messages may be emitted - on raw protocol paths before `ReadyForQuery`. Tests now allow those legal - PostgreSQL messages instead of assuming the older minimal message sequence; -- new roots are now created through the packaged PGDATA template path. The old - embedded-backend `pgl_initdb` path was removed; explicit fresh-initdb paths - now use the bundled split WASIX `initdb` command and remain outside the - default fast path; -- the old builder-branch `pglite-wasm/*` runtime wrapper is no longer the - production patch target. It remains historical/reference material only; -- `xtask package-size --enforce` passes locally for the root, asset, and macOS - arm64 AOT crates. - -Parity verified against upstream PGlite stable source and TypeScript host: - -- startup/initdb: upstream TypeScript creates a cluster with `initdb.wasm`, - dumps PGDATA, loads that tarball into the main runtime, calls - `_pgl_setPGliteActive(1)`, runs `callMain([...startParams, -D, PGDATA, - PGDATABASE])`, expects exit `99`, then calls `_pgl_startPGlite()`. - pglite-oxide matches the main-runtime lifecycle from `_pgl_setPGliteActive` - onward and deliberately consumes a packaged PGDATA template instead of - exposing split runtime `initdb` yet. That is an explicit product gap, not a - hidden fallback; -- startup packet: upstream calls `_pgl_getMyProcPort()`, - `_ProcessStartupPacket(...)`, `_pgl_sendConnData()`, and `_pgl_pq_flush()`. - pglite-oxide uses the same C exports. Server connections now open the - embedded backend against the startup packet database, apply client startup - options on the C side, and apply non-`postgres` users through PostgreSQL - `SET ROLE` semantics, matching PGlite's single-process identity model; -- query loop: upstream feeds the whole frontend message buffer, repeatedly calls - `_PostgresMainLoopOnce()` while frontend bytes or libpq buffered data remain, - catches status `100`, calls `_PostgresMainLongJmp()`, then always calls - `_PostgresSendReadyForQueryIfNecessary()` and `_pgl_pq_flush()`. The Rust host - now follows that control flow with Rust-owned input/output buffers instead of - Emscripten callback pointers; -- close: upstream clears active state, sends protocol terminate, and replays - `_pgl_run_atexit_funcs()`. The Rust host clears active state and replays - atexit on shutdown, while tests cover clean restart, root locking, and stale - runtime-state cleanup; -- host ABI: upstream `pglitec.c` emulates sockets, identity, shared memory, - `system`/`popen`, timers, longjmp, and atexit because browser/Emscripten does - not provide normal process or OS services. pglite-oxide keeps the same - categories only where WASIX still needs host assistance, and the ABI harness - tests stable identity, fail-closed `system`, protocol fd bridging, shared - memory, atexit replay, mmap, and libpq encoding aliases; -- justified deviations: WASIX longjmp detection uses pointer identity instead of - upstream's `jmp_buf` content `memcmp`; simple-query/COPY portal abort cleanup - is owned in PostgreSQL `AtAbort_Portals`; startup `ParameterStatus` messages - are accepted as legal protocol output; split WASIX `initdb` is now the owned - template-generation path instead of resurrecting the old builder wrapper. - -## Runtime Behavior - -Implemented: - -- runtime loads verified headless Wasmer AOT artifacts; -- AOT artifacts record source module hash, Wasmer version, and engine identity; -- runtime verifies asset and archive hashes before use; -- unsupported targets return a clear missing-AOT-artifact error instead of - compiling locally; -- `Pglite::preload()` and `Pglite::preload_extensions(...)` exist; -- `Pglite::preload()` now warms the persistent runtime cache, headless Wasmer - engine, main AOT module, shared WASIX runtime, and runtime side modules; -- `Pglite::preload_extensions(...)` warms requested extension artifacts and - side-module cache entries generically; -- direct, persistent, app-id, proxy, server, and temporary roots now share the - `RootPlan`/`prepare_root` root-preparation pipeline; -- direct API, server API, proxy CLI, raw protocol API, and direct `pg_dump` now - share `BackendSession` for WASIX instance creation, backend start, startup - packet handling, protocol transport, shutdown, restart, and atexit replay; -- roots can install immutable runtime files from a persistent runtime cache and - install the embedded PGDATA template without running initdb on the default - startup path; -- mutable PGDATA template files are copied or archive-installed, never - hardlinked; immutable runtime files hardlink from cache when possible; -- persistent roots use lock files to prevent concurrent direct/server opens; -- runtime and extension archive extraction rejects unsafe paths, symlinks, - hardlinks, device nodes, and unsupported archive entry types; -- runtime uses canonical Postgres paths: - `/bin`, `/lib/postgresql`, `/share/postgresql/extension`, and - `/share/postgresql/timezonesets`. - -## Public API Surface - -Implemented: - -- `PgliteBuilder::extension`; -- `PgliteBuilder::extensions`; -- `PgliteBuilder::username`; -- `PgliteBuilder::database`; -- `PgliteBuilder::debug_level`; -- `PgliteBuilder::relaxed_durability`; -- `PgliteBuilder::startup_arg`; -- `PgliteBuilder::startup_args`; -- `PgliteBuilder::load_data_dir_archive`; -- `Pglite::enable_extension`; -- `Pglite::preload`; -- `Pglite::preload_extensions`; -- `Pglite::dump_data_dir`; -- `Pglite::dump_data_dir_with_format`; -- `Pglite::try_clone`; -- physical PGDATA archives now apply Wasmer overlay whiteouts, so files deleted - from the lower template are not resurrected by dump/load/clone; -- physical PGDATA archives are written from a materialized effective PGDATA view - instead of directly mixing lower-template and upper-overlay entries in the tar - writer; -- physical PGDATA archive/clone now checkpoints, quiesces the backend, - materializes the archive, and restarts the same backend session; docs state - this is a same-runtime/same-version physical import/export path, not a - cross-version backup protocol; -- `Pglite::exec_protocol_raw`; -- `Pglite::exec_protocol_raw_stream`; -- `Pglite::dump_sql`; -- `Pglite::dump_bytes`; -- `PgliteServerBuilder::extension`; -- `PgliteServerBuilder::extensions`; -- `PgliteServerBuilder::username`; -- `PgliteServerBuilder::database`; -- `PgliteServerBuilder::debug_level`; -- `PgliteServerBuilder::relaxed_durability`; -- `PgliteServerBuilder::startup_arg`; -- `PgliteServerBuilder::startup_args`; -- `PgliteServer::database_url`; -- `PgliteServer::dump_sql`; -- `PgliteServer::dump_bytes`; -- `PgDumpOptions`; -- 37 public extension constants plus `extensions::ALL`, covering the smoke-gated - packaged PGlite/Postgres catalog: `amcheck`, `auto_explain`, `bloom`, - `age`, `btree_gin`, `btree_gist`, `citext`, `cube`, `dict_int`, `dict_xsyn`, - `earthdistance`, `file_fdw`, `fuzzystrmatch`, `hstore`, `intarray`, `isn`, - `lo`, `ltree`, `pageinspect`, `pg_buffercache`, `pg_freespacemap`, - `pg_hashids`, `pg_ivm`, `pg_surgery`, `pg_textsearch`, `pg_trgm`, - `pg_uuidv7`, `pg_visibility`, `pg_walinspect`, SQL-only `pgtap`, `seg`, - `tablefunc`, `tcn`, `tsm_system_rows`, `tsm_system_time`, `unaccent`, and - `vector`. - -`pglite-dump` no longer exposes the old archive-unpack behavior. It is now a -real logical dump CLI backed by the packaged WASIX `pg_dump` module. - -`relaxed_durability` is a startup-profile flag rather than a hidden mutation of -`PostgresConfig`; explicit user `postgres_config` values win and -`relaxed_durability(true).relaxed_durability(false)` returns to the normal -profile. - -## Protocol And Server Correctness - -Implemented coverage: - -- direct Rust API open/init/query; -- persistence, close/reopen, stale runtime-state cleanup, interrupted PGDATA - cleanup, and root-lock conflicts; -- SQLx and `tokio-postgres` local-server connections; -- SSLRequest no-SSL response; -- CancelRequest safe close; -- backend-open failures no longer map every non-`template1` startup failure to - SQLSTATE `3D000`. PostgreSQL/C now owns startup identity and database errors: - the WASIX backend captures `InitPostgres` startup `ErrorResponse` bytes, the - proxy forwards them directly, and runtime/filesystem failures before - PostgreSQL can speak protocol remain synthesized `XX000`; -- Parse, Bind, and Execute error recovery; -- SQLSTATE preservation for syntax, missing relation, invalid typed parameter, - wrong parameter count, and extension-originated errors; -- extended-query `ReadyForQuery` synchronization; -- successful pipelined extended queries; -- mixed success/error/success pipelined queries; -- explicit prepared-statement reuse; -- transaction error recovery through rollback; -- client disconnect during an extended-query exchange; -- partial TCP reads and pipelined simple queries; -- server-mode `COPY FROM STDIN` now streams through the backend-owned protocol - pump instead of Rust SQL-text detection or proxy-fabricated COPY state. Normal - SQLx/tokio-postgres traffic uses the buffered raw-protocol path; when - PostgreSQL emits a real `CopyInResponse`, `CopyOutResponse`, or - `CopyBothResponse`, the WASIX bridge flushes buffered backend output to the - attached socket continuation and lets PostgreSQL continue on the socket. Raw - wire coverage includes simple COPY, extended-protocol COPY, CSV `WITH (...)` - COPY, binary COPY, `CopyData`, `CopyDone`, `CopyFail`, Unix-socket COPY - parity, and post-COPY connection reuse. -- continuation bytes are borrowed in the proxy read loop and materialized only - after the C bridge reports active streaming COPY; -- direct raw protocol streaming is routed through the shared `BackendSession` - framed sender instead of a separate client-only transport path; -- Rust-owned guest bridge allocations are scoped through `pg_free`/`free`, and - debug builds now have a direct raw-protocol stress test proving repeated - bridge round trips keep allocation/free counters balanced; -- direct LISTEN/UNLISTEN quotes channel identifiers and dispatches notifications - by the exact backend channel name, including case-sensitive and quoted names. -- a larger PostgreSQL regression subset now ports the relevant PGlite test - surface for datatypes, DDL, transactions/savepoints, planner/index behavior, - and direct `/dev/blob` CSV COPY. The datatype coverage also found and fixed a - direct-client multidimensional array parser bug, with unit coverage for - nested arrays, quoted values, and unquoted NULL handling. - -## Independent P0 Architecture Review - -The P0 review was re-run against the current Rust host, WASIX bridge, source -patch, and regression tests. No current P0 architecture blockers remain in the -reviewed surface. The completed P0 items were moved out of the backlog; future -major protocol, backup, runtime, or source-spine changes should get a new -review entry here instead of leaving completed checklists in `TODO.md`. - -Verified ownership boundaries: - -- Rust owns hosting, root preparation, caches, process lifecycle, direct/server - API shape, and typed fallbacks for host/runtime failures before PostgreSQL can - speak wire protocol; -- PostgreSQL/C owns SQLSTATEs, startup identity/database errors, query protocol - state, COPY state, portal cleanup, and longjmp recovery boundaries; -- the WASIX bridge owns only the host ABI that Wasmer/WASIX cannot provide as a - normal OS process boundary: protocol fd transport, locale/identity shims, - single-process shared memory, fail-closed process calls, and explicit - allocation/free ownership. - -Review conclusions: - -- guest-memory ownership is scoped through `GuestAllocator`, `pg_free`/`free`, - and debug allocation/free counters; -- detached protocol stdio fails closed rather than silently accepting bytes; -- COPY state is reported by PostgreSQL through - `pgl_protocol_report_copy_response`; the proxy no longer parses SQL text, - fabricates COPY state, scans whole backend buffers, or eagerly copies - continuation bytes for ordinary traffic; -- direct raw protocol streaming and direct `pg_dump` use the shared - `BackendSession` transport instead of a separate clone/server path; -- startup role/database failures are PostgreSQL-owned: WASIX backend open - captures `InitPostgres` `ErrorResponse` bytes, the proxy forwards those bytes, - and Rust no longer probes `pg_database` or string-guesses `3D000`; -- direct API, server API, proxy CLI, raw protocol, physical archive/clone, and - direct `pg_dump` share `RootPlan`/`prepare_root` and `BackendSession` - lifecycle paths; -- side-module cache seeding is keyed by artifact name, source module hash, - Wasmer version, Wasmer-WASIX version, and engine identity; -- AOT startup keeps full SHA verification behind - `PGLITE_OXIDE_AOT_VERIFY=full` while default loading uses metadata receipts - and mmap/native deserialization; -- PGDATA physical archive/clone materializes the effective overlay view with - whiteouts, quiesces/restarts the backend, and is documented as - same-runtime/same-version physical transfer rather than a WAL-aware backup; -- public API parity additions were reviewed: `fresh_temporary()` stayed out, - raw protocol streaming is real, physical clone/export has honest semantics, - startup args remain advanced, and listener channel names are identifier - quoted. - -Residual work from this review is intentionally not P0 architecture debt: -target-matrix CI, broader extension generation, additional PostgreSQL -regression subsets, release performance gates, and future split-WASIX `initdb` -support remain tracked in `TODO.md`. - -## Extensions And `pg_dump` - -Implemented coverage: - -- `vector` direct API load, `CREATE EXTENSION`, insert, distance query, and - pgvector type cases; -- `vector` through `PgliteServer` and SQLx; -- SQLx recovery after vector-originated errors; -- demand-driven extension install and idempotent `enable_extension`; -- installed extension side modules are seeded into the headless Wasmer cache on - reopen; -- `pg_trgm` direct API and SQLx server smoke coverage; -- `hstore` direct API, persistence/reopen, and SQLx server smoke coverage; -- PGlite extension tests were ported into a generic promotion gate for direct - API, server API, restart, and lifecycle materialization. The gate now covers - every packaged candidate. AGE now uses its upstream 32-bit `SIZEOF_DATUM=4` - SQL generation path, passes direct/server/restart/lifecycle gates, and is - exposed as `extensions::AGE`; -- extension discovery now merges PGlite docs/REPL exports, PGlite package - exports, PostgreSQL contrib metadata, `postgres-pglite` `other_extensions` - pins, PGlite tests, and the packaged asset manifest into - `assets/generated/extensions.catalog.json`; -- `xtask assets fetch` now clones/fetches every pinned source from - `assets/sources.toml` into ignored `assets/checkouts/**` directories, - including the external extension sources for pgtap, pg_ivm, pg_uuidv7, - pg_hashids, AGE, PostGIS, and pg_textsearch; -- extension build intent now lives in `assets/extensions.promoted.toml` instead - of being inferred from already-packaged artifacts. The generated catalog - separates requested, packaged, stable, and publicly promoted state; -- extension smoke evidence now lives in `assets/extensions.smoke.toml`; - generated public constants require requested + packaged + stable + direct, - server, and restart smoke status recorded as passed; -- `xtask extensions build-plan --write` generates - `assets/generated/extensions.build-plan.json`, - `assets/generated/contrib-build.tsv`, and - `assets/generated/pgxs-build.tsv`; `xtask assets check --strict-generated` - fails if those generated files drift; -- the WASIX extension build spine now uses generic contrib and PGXS build - scripts driven by the generated build plans, replacing the previous - `pg_trgm`-only and `pgvector`-only Docker scripts; -- the generated catalog now requires every discovered SQL extension to be - either requested for build or explicitly blocked with a concrete reason. The - current catalog discovers 40 SQL extensions, requests/packages 37, and blocks - only `pgcrypto`, PostGIS, and `uuid-ossp` on missing pinned native dependency - stacks; -- native side-module names are generated from control-file `module_pathname` - and PGXS Makefile metadata instead of assuming `.so`. This covers - cases such as `intarray` using `_int.so` and SQL-only extensions such as - `pgtap`; -- both generated build plans now support native and SQL-only extensions. The - local WASIX build produced all requested contrib and PGXS extension payloads, - generated local macOS arm64 AOT artifacts for all requested native modules, - and packaged all requested extension archives into `pglite-oxide-assets`; -- contrib packaging now carries extension-owned tsearch rule files into - `share/postgresql/tsearch_data`, matching PGlite behavior for `dict_xsyn` and - `unaccent`; -- generated extension constants are emitted only for extensions that are - requested, packaged, stable, and direct/server/restart smoke-passed; generated - asset includes carry all packaged candidates so private promotion tests can - exercise candidates before they become public API; -- manifest metadata records extension source kind, control files, - dependencies, lifecycle, imports, required core exports, unresolved imports, - installed files, load order, and smoke status; -- the `wasix-dl` export list is generated from the runtime exports plus - runtime-support/extension side-module imports, rather than being a - hand-maintained export allowlist; -- extension archive hash mismatch rejection; -- public WASIX `pg_dump` runner loads through the AOT manifest, connects to - `PgliteServer`, dumps plain SQL, restores into fresh `Pglite`, and verifies - schema/data; -- direct `Pglite::dump_sql` no longer uses a temporary physical clone, public - `PgliteServer`, or OS loopback TCP; it runs the standalone WASIX `pg_dump` - against an in-process Wasmer virtual TCP connection whose host side is routed - through the same direct raw-protocol backend; -- direct `Pglite::dump_sql` rejects database/user options that would imply a - different backend than the already-open direct session; callers needing that - use the server `pg_dump` path; -- the direct `pg_dump` transport keeps `pg_dump`/libpq stock and owns the only - required semantic adapter in Rust: a first-write-readiness normalization for - Wasmer's in-memory `TcpSocketHalf` so libpq's connect-time and first-write - polls remain level-triggered; -- public `pg_dump` coverage includes indexes, views, sequences, - `--schema-only`, `--quote-all-identifiers`, source-server reuse after dump, - and vector extension dump/restore; -- `PgDumpOptions` rejects passthrough flags that conflict with the typed - output/connection contract instead of letting callers override the internal - output file, format, host, port, username, database, or job count. - -## WASIX C Boundary Ownership - -The remaining C-side differences are owned as WASIX portability and host ABI, -not hidden generic stubs: - -- `pg_proto.c` manually coordinates `ReadyForQuery` for the current - call/return protocol loop and is covered by SQLx, `tokio-postgres`, and raw - wire-protocol tests; -- `pg_main.c` drives initdb boot/single-user phases inside one embedded process, - with named helpers for boot, stdin restoration, and single-user replay; -- `pgl_os.h` emulates only the expected initdb boot/single `popen()` commands - under `PGLITE_WASIX_DL` and fails closed otherwise; -- `pgl_stubs.h` is gated to `PGLITE_WASIX_DL`, and future removals are driven by - link-symbol analysis; -- `pglite_wasix_bridge.c` owns locale command emulation, stable `postgres` - uid/passwd identity, protocol socket buffers, fail-closed `system()`, selected - fd/socket delegation to WASIX libc, and single-process SysV shared memory. - -The source-spine guard checks for removed spike smells: debug-only `#pragma` -markers, diagnostic `popen`, broad socket fake-success behavior, layout -mirroring, timezone rewrites, and generic stub logging. - -## Validation Already Run - -The following local gates passed before this consolidation: - -```sh -cargo fmt --check -cargo check -p pglite-oxide --all-targets -cargo check -p pglite-oxide --no-default-features --all-targets -cargo run -p xtask -- assets check --strict-generated -cargo run -p xtask -- assets source-spine --check-patch-applies -cargo run -p xtask -- assets audit-upstream --strict -cargo run -p xtask -- package-size --enforce -cargo test --test client_compat -cargo test --test runtime_smoke -cargo test --test extensions_smoke -``` - -The public `pg_dump` round-trip tests and asset/AOT hash-mismatch tests also -passed locally. - -## Cold-Start Performance Work - -Implemented: - -- internal phase timing via `capture_phase_timings`; -- `cargo run -p xtask -- perf cold` emits structured JSON with explicit - `cacheStateBefore`, `processStateBefore`, `rootState`, `queryState`, and - `workload` fields, so first-install bootstrap, process warmup, new-root first - query, and client/server first query are no longer conflated; -- `cargo run -p xtask -- perf cold --reset-cache` removes the pglite-oxide cache - before measuring, making runtime extraction, AOT materialization, PGDATA - template install, and extension-template creation visible in the first - operation that pays each cost; -- process-wide headless Wasmer engine cache; -- process-wide AOT `Module` cache keyed by artifact hash; -- AOT manifests now include raw artifact SHA256/size metadata; the default - startup path uses an atomic cache receipt and file metadata instead of scanning - the raw AOT file; -- bundled runtime, extension, PGDATA-template, and AOT content hashes are kept - off the default startup path and are only scanned with - `PGLITE_OXIDE_AOT_VERIFY=full`; -- process-wide shared Tokio runtime, WASIX runtime, and `SharedCache`; -- side-module seeding is reused by artifact name, module hash, Wasmer version, - Wasmer-WASIX version, and engine identity; -- phase timing now propagates into the server listener thread, so - `PgliteServer` cold runs report root preparation, listener bind/spawn, - proxy backend open, client connect, first query, and shutdown phases instead - of a single opaque total; -- server accept loops now use blocking `accept()` plus an explicit wake - connection during shutdown, removing the previous nonblocking accept plus - 10ms sleep polling jitter; -- fresh proxy backend initialization no longer runs the post-client - `ROLLBACK`/`DISCARD ALL` cleanup path. Fresh startup applies default GUCs - directly; full reset remains in place after client disconnects; -- persistent runtime asset cache under the platform cache directory; -- runtime-cache repair removes mutable scratch state and restores required - support files before the cache is used as a shared overlay source; -- per-root runtime scratch directories are reset during root preparation; -- `password` is copied as per-root mutable support data instead of hardlinked - from the shared runtime cache; -- PGDATA template manifests are parsed without archive hashing on the default - path; -- the parsed generated asset manifest is cached process-wide, avoiding repeated - 1.4 MB JSON parses during AOT, extension, and PGDATA template checks; -- an eager PGDATA template overlay is implemented as the mainline template - path: the cached initialized template is mounted as lower `/base`, the - per-instance upper starts almost empty, and individual template files are - copied into the upper only before mutating opens; -- the eager PGDATA overlay is passed as a runner-level WASIX mount. Nested - mounts placed inside the supplied `WasiFsRoot` were not sufficient because - `WasiRunner::prepare_webc_env` rebuilds the final mount tree from the root - `/` filesystem plus runner-owned mounts; -- direct `Pglite::open` no longer performs a separate session-setup round trip - and no longer folds session defaults into array discovery SQL. The Rust WASIX - host now calls the real C `ProcessStartupPacket` export from - `backend_startup.c`; C `pgl_sendConnData()` applies the direct-session - defaults before connection data is sent, so `BeginReportingGUCOptions` - observes `TimeZone=UTC` and `search_path=public`; -- `PgliteBuilder::postgres_config`, `PgliteServerBuilder::postgres_config`, - and `pglite-proxy --postgres-config name=value` now pass user startup GUCs - through PostgreSQL's normal `-c name=value` argv handling. User settings are - appended after the default profile, so they override defaults without - special-casing individual GUCs such as `synchronous_commit`; -- server-mode client startup `options=-c ...` is now applied on the C side after - `ProcessStartupPacket` parses the packet and before `pgl_sendConnData()` - emits `AuthenticationOk` and `ParameterStatus`, preserving PostgreSQL's - startup-option timing for supported single-backend clients; -- extension-enabled PGDATA template caches include the startup-GUC entries in - their manifest and cache key, so a template created under one backend config - is not reused for another config; -- direct scalar open/query paths no longer scan `pg_type` for array metadata. - Built-in PostgreSQL array OIDs are registered statically in the Rust direct - client, and runtime-created enum/domain/composite arrays are discovered - lazily from parameter/result OIDs or through explicit - `refresh_array_types()` calls; -- the old `pgl_stubs.h` `ProcessStartupPacket` placeholder has been removed - from the maintained WASIX patch. Startup packet parsing now lives in - PostgreSQL's `backend_startup.c`, and the host no longer calls a separate - Rust-side default-GUC helper; -- focused tests cover process AOT cache reuse, extension preload reuse, - cross-instance state isolation, mutable PGDATA clone safety, eager PGDATA - lower-file visibility, direct runtime smoke, vector direct/server smoke, and - proxy smoke. - -Previous local debug `xtask perf cold` run after explicit preload: - -- explicit preload: about 605ms; -- temporary first query: about 553ms; -- warm temporary first query: about 547ms; -- representative extension-backed first query after extension preload: about - 646ms; -- server plus first `tokio-postgres` query: about 543ms. - -In that run bundled archive/module SHA scans were absent from the default path. -The remaining visible costs were main Wasmer deserialization at about 447ms and -temporary filesystem setup at about 321ms, mostly runtime clone plus PGDATA -template clone. - -Latest local debug `cargo run -p xtask -- perf cold` run after the shared -root-preparation work: - -- explicit preload: about 640ms; -- temporary first query: about 404ms; -- warm temporary first query: about 386ms; -- representative extension-backed first query after extension preload: about - 504ms; -- server plus first `tokio-postgres` query: about 371ms. - -That run removed the full immutable runtime clone from temporary opens. The -same prepared runtime-layout machinery now feeds direct, persistent, app-id, -proxy, and server roots as well. Per-root runtime setup was about 30ms and -`wasix.mountfs_overlay_construct` was under 1ms at that point. The dominant -remaining setup cost was PGDATA template clone/install at about 187-190ms, -followed by -backend start around 44-48ms and Wasmer instance creation around 30-36ms. - -Latest local debug `cargo run -p xtask -- perf cold` run after the eager PGDATA -overlay and parsed-manifest cache: - -- explicit preload: about 601ms; -- temporary first query: about 191ms; -- warm temporary first query: about 144ms; -- representative extension-backed first query after extension preload: about - 257ms; -- server plus first `tokio-postgres` query: about 123ms. - -In that run `pgdata.overlay_prepare` was about 0.4-0.5ms, down from the -previous 187-190ms template clone/install cost. The visible per-open costs are now -Wasmer instance creation around 30-37ms and PostgreSQL backend start around -49-52ms. Main-module AOT deserialization remains the dominant explicit preload -cost at about 506ms on this local debug profile. - -Historical local debug run after removing the separate direct session-setup -round trip, before lazy/generated array metadata: - -- explicit preload: about 535ms; -- temporary first query: about 230ms; -- warm temporary first query: about 133ms; -- representative extension-backed first query after extension preload: about - 254ms; -- server plus first `tokio-postgres` query: about 118ms. - -The warm direct `pglite.open` phase dropped to about 112ms. At that point the -remaining direct-open client-side cost was the array catalog scan, about 30ms -for the warm catalog query and less than 1ms for Rust-side parser/serializer -registration. Scalar paths no longer pay that scan after lazy/generated array -metadata. - -Latest local release work: - -- asset release builds now default to `release-o3`, which compiles WASIX C - modules with `-O3 -g0 -flto=thin` and links with `-flto=thin`; -- release profiles run wasixcc's default Binaryen optimization plus - `--converge`, `--strip-debug`, and `--strip-producers`; -- the current exact PGlite speed-suite run favors `release-o3 + converge/strip` - plus ThinLTO for SQL workload parity. The package-size gate still passes - locally with the macOS arm64 AOT crate at about 7.2MiB compressed and the - asset crate at about 5.6MiB compressed. Earlier startup-only runs favored - `release-os` over `release-oz`, and adding a project `-msimd128` flag was - redundant because the WASIX EH+PIC sysroot already invokes clang with SIMD, - relaxed SIMD, and extended const enabled; -- Wasmer LLVM AOT codegen experiments selected the mainline serializer profile: - nonvolatile memory operations plus a readonly funcref table. Nonvolatile - memory operations improved the exact PGlite server SQLx speed suite by about - 9% geomean and won all 18 cases, but Wasmer marks that optimization as not - fully WebAssembly-spec compliant. Adding readonly funcref on top was about - 1.4% faster geomean than nonvolatile-only and improved indexed updates, but - regressed CREATE INDEX and DROP TABLE cases. The risk is now explicit release - profile surface and must be covered by the correctness matrix. The macOS - arm64 packaged AOT artifacts were regenerated with this profile; -- exact PGlite speed-suite comparison now has its own harness and diagnostic - path. The latest ThinLTO `release-o3` direct run on macOS arm64 measured test - 9 at about 569ms, test 10 at about 724ms, test 11 at about 98ms, and test 14 - at about 77ms. Against the locally audited npm NodeFS reference, the direct - suite is about 1.22x faster geomean, with 16/18 wins but not a 10x-class - result under identical SQL/Postgres semantics; -- selected speed-case diagnostics show that host filesystem work is not the - remaining dominant cost on the heavy SQL cases. Test 10, for example, was - about 748ms total with about 21ms in traced filesystem work and about 743ms - inside PostgreSQL/AOT dispatch. This points the next investigation at - symbolized AOT/Postgres executor profiling, not more Rust result parsing or - root-layout tuning; -- prepared indexed-update benchmarking now compares SQLx sequential prepared - updates, tokio-postgres sequential prepared updates over TCP and Unix - sockets, tokio-postgres pipelined prepared updates over TCP and Unix sockets, - and native Postgres equivalents using the exact PGlite Test 9/10 values. - Deferring extended-protocol `Sync` flush only within bytes already read from - one socket read reduced PgliteServer TCP pipelined prepared updates from about - `612.835ms -> 399.921ms` for numeric indexed updates and - `640.691ms -> 416.837ms` for text indexed updates. Unix-socket PgliteServer - was faster again at about 374/397ms, so transport still matters for - sequential prepared execution and modestly for pipelined execution. The exact - simple-query server speed suite stayed in the same range after the change: - Test 9 about 583ms and Test 10 about 740ms locally. A larger 256KiB proxy - read buffer was tested and rejected because it regressed the same pipelined - prepared workload to about 545/562ms; -- the native Postgres benchmark helper now attempts graceful termination before - falling back to `Child::kill()`, because SIGKILL can leak SysV shared-memory - IDs on macOS. `perf prepared-updates --skip-native` exists for Pglite-only - runs when local native Postgres IPC state is unhealthy; -- `perf prepared-updates --gate` now emits protocol counters and fails if - ordinary prepared traffic activates the backend-owned streaming continuation - or if pipelined prepared traffic stops batching. The timing thresholds are - intentionally a local regression smoke gate until stable CI runner baselines - exist; -- phase timing guards are hot-path no-ops when no recorder is active, so - diagnostic spans do not call `Instant::now()` in normal runtime traffic; -- PostgreSQL spinlocks are enabled in the WASIX build. The earlier - `--disable-spinlocks` fallback is gone, and the source-spine guard rejects it - if it returns. This is a correctness/architecture baseline because wasixcc - exposes the required atomic operations; local single-backend speed numbers are - mixed enough that it should not be treated as a standalone benchmark win; -- the shared runtime overlay and eager PGDATA overlay are now mainline runtime - behavior, with the old full-local runtime and full-template clone paths kept - only as internal build/staging machinery where still required; -- local release `cargo run -p xtask -- perf cold` with no env overrides showed - warmed preload around 18ms, temporary first query around 100ms, warm temporary - first query around 83ms, representative extension-backed first query around - 148ms after extension preload, and server first query around 77ms; -- that run predated lazy/generated array metadata and showed direct open - dominated by backend startup around 33-40ms plus the old array catalog scan - around 24-33ms. Scalar paths no longer pay that catalog scan; new release - numbers should replace this historical baseline; -- after adding deeper preload instrumentation, local release runs showed - explicit preload between about 15ms and 56ms depending on OS cache warmth. - The first uncached visible run spent about 37ms in main AOT mmap - deserialization and about 10ms in runtime cache setup; repeated warmed runs - spent about 10ms in main AOT deserialization for both mmap and file modes; -- Wasmer AOT loading now uses the native mmapped-file deserializer as the only - production path; the old file deserializer runtime switch was removed; -- after promoting the mainline AOT and filesystem paths, local release - `cargo run --release -p xtask -- perf cold` showed primary visible latencies - around 36ms for preload, 55ms for a new temporary direct first query, 45ms - for a second new temporary direct first query, 47ms for server SQLx first - query, and 57ms for server SQLx vector first query; -- the same mainline artifact profile measured exact PGlite server speed-suite - Test 9 at about 587ms, Test 10 at about 730ms, Test 11 at about 91ms, Test 14 - at about 71ms, and 18-test geomean around 76ms locally. Prepared-update - server probes measured TCP pipelined prepared updates around 395/414ms and - Unix pipelined prepared updates around 366/392ms for the numeric/text indexed - workloads; -- after static built-in arrays and lazy runtime array discovery, local release - `cargo run --release -p xtask -- perf cold` showed explicit preload about - 52ms, temporary first query about 88ms, warm temporary first query about 79ms, - representative extension-backed first query about 131ms after extension - preload, and server first query about 75ms. Scalar direct paths did not emit - the `pglite.array_type_catalog_query` phase; -- after server-thread timing and accept-loop cleanup, local release - `cargo run --release -p xtask -- perf cold` showed explicit preload about - 19-22ms, temporary first query about 86-89ms, warm temporary first query about - 77ms, representative extension-backed first query about 132-140ms after - extension preload, tokio-postgres server first query about 68ms, and SQLx - server first query about 68-70ms. The server path now shows `server.start` - around 52-54ms, - `proxy.backend_open` around 44-46ms, `postgres.backend_start` around 35-37ms, - tokio-postgres connect around 0.6ms/query around 5.5ms, and SQLx connect - around 2.1ms/query around 6.0ms; -- `xtask perf cold` includes the extension-enabled SQLx server path, now named - `process_warm_new_temp_server_sqlx_vector_first_query`, which starts - `PgliteServer` with a requested bundled extension and measures a first - extension-backed SQLx query for a new temporary server root. This keeps - server-mode extension install/load, `CREATE EXTENSION`, client connect, and - first extension query visible as one product-shaped path. The first local - release run measured about 175ms total, dominated by `proxy.extension_enable` - around 107ms; SQLx connect and the - first vector query were both sub-millisecond on that run; -- cold perf reporting now breaks out preload runtime cache setup, AOT install, - mmap/file deserialization, WASIX runtime construction, instance creation, - startup-packet/default-GUC work, client protocol round trips, extension side - module seeding, and public `pg_dump` runner phases; -- instrumented WASIX runtime artifacts can export C-side backend startup timers - via `pgl_backend_timing_elapsed_us`, and the Rust host records them as - `postgres.backend.c.*` phases when the export is present. Production WASIX - artifacts keep `PGLITE_OXIDE_WASIX_BACKEND_TIMING=0`, so the C timing macros - compile away and the export is absent. Local release instrumented runs show - backend startup split mainly between `postgres.backend.c.shared_memory` around - 11-12ms and `postgres.backend.c.init_postgres` around 19-21ms, inside - `postgres.backend.c.async_single_user_main` around 33-36ms; -- C-side timers now reach inside `InitPostgres`: `StartupXLOG`, - relcache/catcache initialization, transaction snapshot, session-user setup, - database lookup/recheck/path validation, `CheckMyDatabase`, startup option - processing, session initialization, and session preload libraries are reported - as individual `postgres.backend.c.*` phases; -- the C timing ABI has additional instrumented-only IDs for - `InitializeMaxBackends`, `CreateSharedMemoryAndSemaphores`, `InitProcess`, - `RelationCacheInitializePhase3`, and `initialize_acl`, so the two remaining - startup hotspots can be subdivided without adding production clock reads; -- a generic extension-set PGDATA template cache now builds templates through - normal `CREATE EXTENSION`, runs `CHECKPOINT`, then closes the embedded backend - through the runtime `pgl_shutdown` export before caching the template. The - cache is keyed by the base runtime/template manifest plus sorted extension - archive identities and is mounted as the lower PGDATA template for direct and - server temporary roots; -- direct and server extension paths skip redundant `CREATE EXTENSION` when the - requested extension set is already present in the cached template, while still - installing/preloading side-module assets into each instance root; -- extension-template cache keys were bumped to version 2 after adding clean - backend shutdown, so older templates that left `pg_control` in a - recovery-heavy state are ignored; -- current local release timings with the clean generic extension template cache - show extension-template lookup/overlay under 1ms, extension archive install - around 5ms, and extension-enabled `StartupXLOG` around 3-4ms instead of the - previous roughly 350ms recovery path. In the steady cached run, the direct - vector first-query path for a new temporary root was about 82-93ms and the - SQLx vector first-query path for a new temporary server root was about - 74-78ms; -- pure MountFS runtime composition now keeps core runtime assets in the shared - cached lower runtime and materializes only mutable state plus requested - extension assets in the per-root upper layer. Runtime and extension smoke - tests assert that core binaries/catalog files are not copied into the upper - root and unrelated extensions are not installed. Local release comparison - showed per-root runtime setup dropping from roughly 7ms to about 0.6-0.9ms, - the SQLx first-query path for a new temporary server root around 55ms, and - the SQLx vector first-query path for a new temporary server root around 66ms - after cache cleanup; -- cold perf operations now report `primaryLatencyPhase` and - `primaryLatencyMicros` so user-visible latency is separated from teardown. - The deeper local release run showed direct first-query totals were previously - inflated by a Rust-side host directory sync during query finish; -- direct `Pglite` no longer calls host directory `sync_all` after every - non-transaction query. PostgreSQL's WAL/fsync path owns durability, and the - server path already avoided this extra host sync. In the local release run, - direct visible latency dropped from about 68ms to about 53ms for the first - new temporary root and to about 45ms for the second new temporary root; -- direct and server protocol timing now splits startup packet handling, - protocol input/output, guest `PostgresMainLoopOnce`, direct parse/describe, - direct execute, and direct result finish. The remaining first-query protocol - cost is mostly PostgreSQL main-loop work for the parse/describe or prepared - extended-query batch, not Rust parsing or buffer copies; -- `cargo run -p xtask -- perf warm` now measures true warm behavior separately - from first-open work: repeated direct scalar queries, direct transaction - batches, direct extension-backed queries, SQLx repeated queries over one - connection, SQLx repeated connect-query-close cycles, SQLx extension-backed - repeated queries, and tokio-postgres repeated queries. It reports total and - per-iteration average phases while keeping open/shutdown phases as context; -- `cargo run --release -p xtask -- perf bench` now provides a product-style - benchmark harness similar to PGlite's published benchmark families. It runs - trimmed-average CRUD round-trip benchmarks and a generated SQLite - speedtest-style suite through both the direct Rust API and `PgliteServer` - with a long-lived SQLx connection. The speed suite is generated locally - instead of vendoring PGlite's multi-megabyte generated SQL files, and supports - `--suite`, `--mode`, `--iterations`, and `--scale` for local and CI runs; -- May 1, 2026 local release parity/timing run after pinning - `REL_17_5-pglite@01792c31` recorded raw JSON under `target/perf/`: - `cold-release-latest.json`, `warm-release-latest.json`, and - `bench-release-latest.json`; -- that cold release run used existing caches and production artifacts, so C-side - backend timers were absent by design. Primary visible latencies were: - preload 28.8ms, first direct temporary query 41.1ms, second direct temporary - query 30.0ms, vector preload 8.4ms, first direct vector query 36.8ms, - first tokio-postgres server query 31.4ms, first SQLx server query 31.9ms, - first SQLx server vector query 36.9ms, and first SQLx vector query on an - existing persistent root 25.8ms; -- dominant cold phases in that run were production runtime/AOT preload - (`aot.deserialize.mmap` 16.3ms), Wasmer instance creation for new roots - (about 5.3-10.2ms), backend start for template roots (about 18-24ms), and - first protocol dispatch/query work (about 4.4-6.1ms). Per-root runtime setup - stayed below the 1ms reporting threshold for scalar temporary roots; -- warm release run with 100 query iterations and 20 connect iterations showed: - direct scalar repeated query average 0.024ms, direct transaction batch average - 0.022ms, direct vector repeated query average 0.025ms, SQLx single-connection - query average 0.054ms, SQLx vector single-connection query average 0.058ms, - tokio-postgres single-connection query average 0.175ms, and SQLx - connect-query-close average 18.565ms; -- product-style benchmark run with `--suite all --mode all --iterations 100 - --scale 1` showed RTT trimmed averages from about 0.031-0.101ms for direct - CRUD cases and about 0.055-0.130ms for SQLx server CRUD cases. The generated - speed suite remained dominated by indexed updates: direct 25k indexed update - 4.390s, direct 25k text indexed update 8.024s, SQLx server 25k indexed update - 4.350s, and SQLx server 25k text indexed update 8.057s; -- follow-up parity work found that the WASIX host was starting single-user - Postgres with `shared_buffers=400kB`, while `@electric-sql/pglite@0.4.5` - reports `shared_buffers=128MB`. The fix moved the intended buffer GUCs into - the Rust startup arguments (`shared_buffers=128MB`, `wal_buffers=4MB`, - `min_wal_size=80MB`). The exact PGlite speed-source rerun now records local - all-suite direct timings around 570ms for Test 9, 732ms for Test 10, 106ms for - Test 11, and 86ms for Test 14; SQLx server timings were about 593ms, 726ms, - 102ms, and 83ms for the same tests. - `perf diagnose-buffer-cache` verifies zero Postgres shared read blocks for the - table-copy hotspots after setup, matching PGlite's effective buffer behavior; -- `xtask assets check` now guards production WASIX inputs for mandatory - WebAssembly exception and dynamic-linking flags and rejects Asyncify markers - in production configure scripts; -- production profile scripts reject Asyncify flag injection by default; the - explicit `PGLITE_OXIDE_ALLOW_ASYNCIFY_EXPERIMENT=1` override is reserved for - local snapshot/journaling experiments; -- final package sizes stayed under crates.io's 10 MB compressed limit: - `pglite-oxide` about 7.15 MB, `pglite-oxide-assets` about 4.87 MB, and - `pglite-oxide-aot-aarch64-apple-darwin` about 5.62 MB; -- `cargo test --release --workspace --all-targets`, - `cargo check --workspace --no-default-features --all-targets`, - `cargo run -p xtask -- assets check --strict-generated`, and - `cargo run -p xtask -- package-size --limit 10000000` passed against the - regenerated artifacts. - -## CI/CD And Release Workflow - -- validation now uses a DRY `scripts/validate.sh` entrypoint with explicit - modes for repository hygiene, linting, tests, examples, package checks, and - release dry-runs; -- CI classifies changed paths through `scripts/ci-scope.sh` so docs-only, - CI-only, test-only, package-affecting, and asset-affecting PRs can run the - right checks without forcing every maintainer change through release work; -- release intent checks now focus on published package surfaces - (`Cargo.toml`, `build.rs`, `src/**`, and `crates/**`) instead of forcing - docs, tests, examples, xtask-only, or source-build-script maintenance to use - release-producing PR titles; -- the manual Release workflow keeps the three maintainer operations: - `prepare-release-pr`, `publish-dry-run`, and `publish`, with job-scoped - permissions and Trusted Publishing through `id-token: write`; -- release-plz remains the release owner with one root changelog, one version - group, exact internal dependency versions, internal asset/AOT changes folded - into the root release notes, and bare SemVer tags for the user-facing root - release; -- the Assets workflow now uses production build inputs under - `assets/wasix-build`, the `release-o3` profile, one Linux/Docker portable - WASIX build job, and native AOT matrix jobs for macOS, Linux, and Windows; -- the portable WASIX build in the Assets workflow is now the artifact producer: - it builds generated runtime assets under `target/pglite-oxide/assets`, uploads - them with provenance, and feeds native AOT matrix jobs; -- normal CI now has a Rust-only native AOT runtime matrix that downloads the - latest compatible Assets workflow bundle, verifies the asset-input - fingerprint, installs generated artifacts into ignored paths, and runs the - runtime test suite on macOS arm/x64, Linux arm/x64, and Windows x64; -- asset and AOT crates are source-only in git; release jobs download generated - portable and AOT workflow artifacts for the exact SHA, stage them into crate - skeletons, package-check that generated workspace, and publish with - release-plz dirty-publish support; -- dependency invariant checks now block Wasmtime/static-WASI regressions and - backend compiler crates such as LLVM/Cranelift/Singlepass from entering the - normal user dependency tree; -- the public dependency graph now uses Cargo target-specific dependencies for - AOT packs, so a normal `pglite-oxide` install resolves the target-independent - `pglite-oxide-assets` crate plus only the current platform's - `pglite-oxide-aot-*` crate; -- source-only `scripts/validate.sh test` no longer pretends runtime coverage - happened when AOT artifacts are absent. `scripts/validate.sh runtime` is now - the hard runtime gate and requires portable assets plus the host AOT pack; -- `.github/scripts/download-aot-artifacts.sh` is a thin wrapper over - `xtask assets download`; exact-SHA, latest-compatible, host-target, and - all-target artifact downloads share one implementation; -- AOT serialization is now owned by a maintainer-only `xtask` feature. The - normal runtime tree keeps headless Wasmer loading, while - `xtask --features aot-serializer` is the only path that enables Wasmer LLVM; -- the Assets workflow now probes the LLVM AOT serializer before full AOT - generation, validates generated portable assets before AOT work, smokes the - target runtime before packaging/upload, and fails on empty/missing AOT - manifests instead of uploading placeholder crates; -- `wasmer-wasix` is now explicitly feature-minimized for the runtime path - (`sys-minimal`, `sys-poll`, `host-vnet`, and `time`). The root dependency gate - rejects Wasmtime, backend compiler crates, Cranelift/Singlepass, LLVM, and - broad HTTP/TLS stacks such as `reqwest`, `hyper`, and `rustls`; -- normal CI cache writes are limited to `main` while PRs still restore existing - Rust caches. Release and AOT-heavy jobs opt into cache writes explicitly. diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md deleted file mode 100644 index d36cd272..00000000 --- a/docs/EXTENSIONS.md +++ /dev/null @@ -1,164 +0,0 @@ -# Extensions - -Bundled SQL extensions are enabled explicitly. The runtime installs only the -extension assets each database asks for. - -The public extension API is available through the default feature set. If you -disable default features, enable `extensions`; it currently implies `bundled` -because extension constants are backed by packaged, smoke-tested extension -payloads. - -## Enable Extensions At Open Time - -The builder path is the easiest option and resolves bundled extension -dependencies before the database opens. - -```rust,no_run -use pglite_oxide::{extensions, Pglite}; - -fn main() -> Result<(), Box> { - let mut db = Pglite::builder() - .temporary() - .extension(extensions::VECTOR) - .extension(extensions::PG_TRGM) - .open()?; - db.close()?; - Ok(()) -} -``` - -You can also add multiple extensions at once: - -```rust,no_run -use pglite_oxide::{extensions, Pglite}; - -fn main() -> Result<(), Box> { - let mut db = Pglite::builder() - .temporary() - .extensions([extensions::HSTORE, extensions::LTREE, extensions::UNACCENT]) - .open()?; - db.close()?; - Ok(()) -} -``` - -## Enable Extensions After Open - -Use `enable_extension(...)` when you want to install an extension into an -already-open direct database: - -```rust,no_run -use pglite_oxide::{extensions, Pglite}; - -fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; - db.enable_extension(extensions::VECTOR)?; - db.close()?; - Ok(()) -} -``` - -For dependency-heavy extensions, prefer the builder path. Builder requests are -resolved as a set before open, while `enable_extension(...)` installs the -extension you name into the current root. - -## Preload Extension Artifacts - -Use `preload_extensions(...)` when an extension-backed first query sits on a hot -path: - -```rust,no_run -use pglite_oxide::{extensions, Pglite}; - -fn main() -> Result<(), Box> { - Pglite::preload_extensions([extensions::VECTOR, extensions::PG_TRGM])?; - Ok(()) -} -``` - -## Server And CLI Usage - -Extensions work in server mode too: - -```rust,no_run -use pglite_oxide::{extensions, PgliteServer}; - -fn main() -> Result<(), Box> { - let server = PgliteServer::builder() - .temporary() - .extension(extensions::VECTOR) - .start()?; - server.shutdown()?; - Ok(()) -} -``` - -The proxy CLI accepts SQL extension names: - -```sh -pglite-proxy --temporary --extension vector --extension pg_trgm --print-uri -``` - -## Available Bundled Extensions - -Current public constants: - -- `extensions::AGE` -- `extensions::AMCHECK` -- `extensions::AUTO_EXPLAIN` -- `extensions::BLOOM` -- `extensions::BTREE_GIN` -- `extensions::BTREE_GIST` -- `extensions::CITEXT` -- `extensions::CUBE` -- `extensions::DICT_INT` -- `extensions::DICT_XSYN` -- `extensions::EARTHDISTANCE` -- `extensions::FILE_FDW` -- `extensions::FUZZYSTRMATCH` -- `extensions::HSTORE` -- `extensions::INTARRAY` -- `extensions::ISN` -- `extensions::LO` -- `extensions::LTREE` -- `extensions::PAGEINSPECT` -- `extensions::PG_BUFFERCACHE` -- `extensions::PG_FREESPACEMAP` -- `extensions::PG_HASHIDS` -- `extensions::PG_IVM` -- `extensions::PG_SURGERY` -- `extensions::PG_TEXTSEARCH` -- `extensions::PG_TRGM` -- `extensions::PG_UUIDV7` -- `extensions::PG_VISIBILITY` -- `extensions::PG_WALINSPECT` -- `extensions::PGTAP` -- `extensions::SEG` -- `extensions::TABLEFUNC` -- `extensions::TCN` -- `extensions::TSM_SYSTEM_ROWS` -- `extensions::TSM_SYSTEM_TIME` -- `extensions::UNACCENT` -- `extensions::VECTOR` - -`extensions::ALL` is the slice of all currently public bundled extensions. -`extensions::by_sql_name(...)` resolves a bundled extension constant from its SQL -name, for example `"vector"` or `"pg_trgm"`. - -## Not Currently Available - -The generated extension catalog currently tracks additional candidates that are -not part of the bundled public surface: - -- `pgcrypto` -- `uuid-ossp` -- `postgis` - -They are not in `extensions::ALL` and do not have public constants in the -current asset set. - -## Safety And Install Behavior - -Bundled extension archives are installed into the database root before their SQL -setup runs. Archive extraction is path-safe and validated against the packaged -asset manifest. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md deleted file mode 100644 index 84cacd38..00000000 --- a/docs/PERFORMANCE.md +++ /dev/null @@ -1,109 +0,0 @@ -# Performance - -`pglite-oxide` is built to stay close to native Postgres while keeping the -database embedded in the Rust process. - -This page tracks the repo benchmark matrix. The main comparison uses SQLx on -each wire-protocol path: - -- native Postgres with SQLx; -- `pglite-oxide + SQLx`; -- vanilla `@electric-sql/pglite` persisted with NodeFS and reached through - `@electric-sql/pglite-socket`, then measured with SQLx. - -## Snapshot - -Snapshot run: `20260507T113000Z` - -Environment: - -- OS: `macOS 26.4.1 (Darwin 25.4.0 arm64)` -- CPU: `Apple M1 Pro` -- RAM: `16 GB` -- Logical cores: `10` -- Node: `v24.13.0` -- Node packages: `@electric-sql/pglite@0.4.5`, - `@electric-sql/pglite-socket@0.1.5` -- Native Postgres: `18.3 (Homebrew)` -- Oxide Wasmer: `7.2.0-alpha.2` -- Oxide Wasmer WASIX: `0.702.0-alpha.2` -- RTT iterations: `100` -- Speed source: exact upstream SQL from - `assets/checkouts/pglite/packages/benchmark/src` - -Every mode was run serially. - -## Representative Operations - -Lower is better. - -| Operation | native pg + SQLx | pglite-oxide + SQLx | vanilla PGlite + SQLx | -|---|---:|---:|---:| -| 25,000 INSERTs in one transaction | 132.36 ms | 149.54 ms | 257.02 ms | -| 25,000 INSERTs in one statement | 46.14 ms | 59.39 ms | 117.19 ms | -| 25,000 INSERTs into an indexed table | 188.72 ms | 253.38 ms | 352.64 ms | -| 5,000 indexed SELECTs | 81.39 ms | 125.31 ms | 203.05 ms | -| 25,000 indexed UPDATEs | 351.05 ms | 578.96 ms | 720.63 ms | - -## Full Operation Table - -| ID | Test | native pg + SQLx | pglite-oxide + SQLx | vanilla PGlite + SQLx | -|---|---|---:|---:|---:| -| 1 | Test 1: 1000 INSERTs | 9.13 ms | 19.76 ms | 15.66 ms | -| 2 | Test 2: 25000 INSERTs in a transaction | 132.36 ms | 149.54 ms | 257.02 ms | -| 2.1 | Test 2.1: 25000 INSERTs in single statement | 46.14 ms | 59.39 ms | 117.19 ms | -| 3 | Test 3: 25000 INSERTs into an indexed table | 188.72 ms | 253.38 ms | 352.64 ms | -| 3.1 | Test 3.1: 25000 INSERTs into an indexed table in single statement | 66.41 ms | 95.12 ms | 93.88 ms | -| 4 | Test 4: 100 SELECTs without an index | 107.63 ms | 162.89 ms | 242.03 ms | -| 5 | Test 5: 100 SELECTs on a string comparison | 305.38 ms | 338.01 ms | 434.63 ms | -| 6 | Test 6: Creating indexes | 9.94 ms | 13.08 ms | 17.12 ms | -| 7 | Test 7: 5000 SELECTs with an index | 81.39 ms | 125.31 ms | 203.05 ms | -| 8 | Test 8: 1000 UPDATEs without an index | 47.91 ms | 74.42 ms | 103.66 ms | -| 9 | Test 9: 25000 UPDATEs with an index | 351.05 ms | 578.96 ms | 720.63 ms | -| 10 | Test 10: 25000 text UPDATEs with an index | 471.74 ms | 712.38 ms | 858.95 ms | -| 11 | Test 11: INSERTs from a SELECT | 65.64 ms | 97.43 ms | 112.87 ms | -| 12 | Test 12: DELETE without an index | 7.54 ms | 9.74 ms | 11.69 ms | -| 13 | Test 13: DELETE with an index | 9.31 ms | 26.58 ms | 27.7 ms | -| 14 | Test 14: A big INSERT after a big DELETE | 53 ms | 71.6 ms | 87.72 ms | -| 15 | Test 15: A big DELETE followed by 12000 small INSERTs | 58.98 ms | 74.49 ms | 112.18 ms | -| 16 | Test 16: DROP TABLE | 3.43 ms | 10.17 ms | 6.74 ms | - -## Reproduce - -Run the serial matrix: - -```sh -scripts/perf/run_bench_matrix.sh -``` - -That command runs: - -1. `pglite-oxide + SQLx` RTT + speed benchmarks -2. native Postgres + SQLx RTT + speed benchmarks -3. vanilla PGlite + SQLx RTT + speed benchmarks -4. a markdown comparison report - -Outputs land under `target/perf/`: - -- `bench-oxide-.json` -- `bench-native-postgres-sqlx-.json` -- `bench-pglite-nodefs-sqlx-.json` -- `bench-pglite-nodefs-sqlx-ready-.json` -- `bench-comparison-.md` - -Override the native Postgres binaries when needed: - -```sh -PGLITE_OXIDE_NATIVE_POSTGRES=/path/to/postgres \ -PGLITE_OXIDE_NATIVE_INITDB=/path/to/initdb \ -scripts/perf/run_bench_matrix.sh -``` - -## Reading The Matrix - -- `pglite-oxide + SQLx` is the product-style path for apps that connect through - standard Postgres clients. -- `vanilla PGlite + SQLx` keeps upstream PGlite on NodeFS, but uses the same Rust - SQLx client path as the other wire-protocol rows. -- These are machine-local numbers. Re-run the matrix before quoting them in a - release note or public comparison. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..ad32ffb0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,10 @@ +# Repository Docs + +This directory is for maintainer, architecture, and internal documentation. +Public product documentation lives in `src/docs`; SDK pages are +centralized under `src/docs/content/sdk`. Product roots keep only +package README/CHANGELOG files and source-adjacent API comments. + +- `architecture/`: design rationale and product-boundary decisions. +- `maintainers/`: repository, tooling, release, testing, and benchmark process. +- `internal/`: progress notes, audits, and implementation-history references. diff --git a/docs/RELEASE.md b/docs/RELEASE.md deleted file mode 100644 index 66d3b15d..00000000 --- a/docs/RELEASE.md +++ /dev/null @@ -1,166 +0,0 @@ -# Release Process (Maintainers) - -This page is maintainer documentation for versioning, release CI, and crates.io -publishing. It is not part of the end-user documentation path. - -Release automation is workspace-aware. `release-plz` owns version bumps for the -root crate, `pglite-oxide-assets`, and every `pglite-oxide-aot-*` crate. -Feature PRs should not edit package versions directly. - -The root crate is the only user-facing release and changelog. Asset and AOT -crate changes are included in the root `CHANGELOG.md`, while those internal -crates do not create separate GitHub releases or tags. The public Git tag stays -the bare SemVer version, for example `0.4.0`, because the internal crates are -implementation details. - -Before publishing, CI packages every published crate and enforces crates.io's -10 MB compressed `.crate` limit. - -Releases use source-controlled inputs plus CI-generated portable WASIX and AOT -artifacts. The git repo intentionally does not commit portable runtime blobs or -native AOT binaries. The release workflow first verifies source pins, asset -input fingerprints, extension metadata, and crate templates; then it requires a -successful `Assets` workflow for the same SHA, downloads the generated portable -and AOT artifacts, stages them into a clean release workspace, package-checks -that workspace, and only then runs release-plz. - -The release source of truth is the exact Assets workflow output for the release -SHA. Portable WASIX artifacts are built from pinned sources, native AOT -artifacts are generated from that portable asset set, and release validation -checks package contents, target triples, Wasmer versions, runtime hashes, and -crate sizes before publishing. The release workflow runs source/lint/example -checks before artifact download, then reruns the Rust test gate after artifact -installation so the release host executes against materialized native artifacts -instead of compile-only tests. - -Normal CI and release CI split responsibilities deliberately. Rust-only changes -download the latest compatible Assets workflow bundle, verify its asset-input -fingerprint, and run native AOT runtime tests on the supported host matrix. -Asset-producing changes run the `Assets` workflow, which rebuilds portable -WASIX from pinned sources before generating and smoking the target AOT packs. -The release workflow refuses to publish unless the generated portable and AOT -artifacts for the exact release SHA are downloaded, staged, and package-checked. - -`pglite-oxide` publishes source crates to crates.io with release-plz. The CLI -binaries in this repository are maintenance helpers, so the release path -deliberately avoids binary artifact tooling such as cargo-dist until there is a -user-facing binary to distribute. - -## One-time setup - -- Ensure the crate owner has crates.io publish rights for `pglite-oxide`, - `pglite-oxide-assets`, and every `pglite-oxide-aot-*` crate. -- Configure crates.io Trusted Publishing for every published crate. Use - repository `f0rr0/pglite-oxide`, workflow `.github/workflows/release.yml`, - and environment `crates-io`. -- Do not configure `CARGO_REGISTRY_TOKEN`; the release workflow relies on the - GitHub OIDC token granted by `id-token: write`. -- Repository Actions settings must allow GitHub Actions to create pull requests. -- The `Release` workflow uses job-scoped permissions. The release-PR job needs - `contents: write` and `pull-requests: write`; the publish job needs - `contents: write` and `id-token: write`. -- If release PRs should run normal PR CI automatically, configure a - `RELEASE_PLZ_TOKEN` secret backed by a GitHub App or maintainer bot token. - Without it, release-plz falls back to `GITHUB_TOKEN`; GitHub does not trigger - normal PR workflows from PRs opened by that token. -- Do not set `package.publish = ["crates-io"]`; crates.io is Cargo's default - registry, and release-plz treats `package.publish` entries as named alternate - registries. - -## Release intent - -release-plz uses Conventional Commits as the release changeset. PRs that touch -release-affecting package files must use one of these PR title types: - -- `feat:` for user-facing additions -- `fix:` for behavior fixes -- `perf:` for performance improvements -- `refactor:` for behavior-preserving package changes that still need a release -- `revert:` for reverted release-affecting changes -- any type with `!` for breaking changes - -Docs, CI, issue-template, tests, examples, xtask-only maintenance, source -checkout scripts, and other repository-only changes may use non-release types -such as `docs:`, `ci:`, `chore:`, `style:`, or `test:`. The CI release intent -check treats these paths as release-affecting: `Cargo.toml`, `build.rs`, -`src/**`, and `crates/**`. - -Package version bumps are release-plz owned. Feature and fix PRs may change -package code, dependencies, and generated assets, but they must not change -workspace package versions. The version bump and matching `CHANGELOG.md` -section must come from a `release-plz-*` PR titled `chore(release): ...`. - -## Maintainer paths - -- Docs-only and repository-only PRs run the lightweight repository hygiene and - workflow checks. They do not need a release title or changelog entry. -- Test-only PRs run Rust checks but do not need a release title unless they also - change published package code. -- Runtime, API, generated asset, and AOT crate changes are release-affecting. - Use a release-producing PR title such as `fix:`, `feat:`, `perf:`, or - `refactor:`. -- Source-spine and asset-build script changes are not automatically - release-affecting until they change generated package contents under - `src/**` or `crates/**`, but CI treats them as asset-producing changes and - requires committed artifact verification plus the `Assets` workflow when they - affect release artifacts. - -## Releasing from main - -1. Merge release-worthy work to `main`. -2. Open GitHub Actions, run `Release` from `main`, and choose - `prepare-release-pr`. -3. Review and merge the release-plz PR. It updates `Cargo.toml`, `Cargo.lock`, - and `CHANGELOG.md`. -4. Wait for the `Assets` workflow on `main` to pass for the release commit. -5. Run `Release` from `main` with `publish-dry-run`. -6. If the dry run passes, run `Release` again with `publish`. - -For portable asset-source changes, regenerate and verify the generated artifact -set before merging: - -```sh -cargo run -p xtask -- assets fetch -cargo run -p xtask --features aot-serializer -- assets build-host -cargo run -p xtask -- assets verify-committed -``` - -Portable WASIX and native AOT artifacts are not committed. They are produced by -the `Assets` workflow matrix and downloaded by -`.github/scripts/download-aot-artifacts.sh` during dry-run and publish jobs. The -architecture-independent PGDATA template is also generated by that workflow -from the split WASIX `initdb` module and is not checked in. -`xtask release stage` materializes those generated payloads into crate skeletons -inside `target/pglite-oxide/release/workspace`; packaging and publish dry-runs -run from that staged workspace. The real `release-plz` publish step is also -pointed at the staged workspace manifest so the published asset and AOT crates -contain those generated payloads. - -After `release-plz` creates the root GitHub release, the publish job also -packages the same generated portable WASIX and target AOT payloads with -`xtask release package-assets` and uploads them as public GitHub release assets. -Those public `.tar.zst` bundles are mirrors of the validated generated inputs; -the crates.io packages remain the distribution path used by normal consumers. - -The manual publish job uses `release_always = true` because the workflow is not -triggered on every merge; it only runs when a maintainer explicitly selects a -publish operation. The job fails if release-plz reports that it created no -release, so a green publish run means a crate/GitHub release was actually -produced. The dry-run operation stops after staged package validation because -same-release internal crates are not present in crates.io until the real -release-plz publish step. - -The publish job also validates release-note readiness before running expensive -package checks. The current root package version must be the first release -section in `CHANGELOG.md`, that section must contain release-note body content, -and the `[Unreleased]` compare link must start at that version. If this check -fails, run `prepare-release-pr` and merge the generated release-plz PR before -publishing. - -release-plz publishes unpublished package versions to crates.io, creates the -bare SemVer tag such as `0.4.0`, and creates the GitHub release from the -generated changelog. The root crate depends on internal crates with exact -versions. Plain Cargo and release-plz dry-runs cannot fully dry-run the root -crate before those exact internal versions exist in the registry, so validation -dry-runs every internal crate, enforces package sizes, attempts the root checks, -and leaves final workspace publish ordering to the real release-plz publish. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md deleted file mode 100644 index fa1b499f..00000000 --- a/docs/RUNTIME.md +++ /dev/null @@ -1,109 +0,0 @@ -# Runtime Guide - -`pglite-oxide` embeds a PostgreSQL-compatible runtime in the current Rust -process. The direct API talks to that backend directly, and `PgliteServer` -exposes the same backend through a local Postgres connection string. - -## Choose A Mode - -Use `Pglite` when your Rust code owns the database calls: - -- direct function and method calls; -- no socket listener; -- best fit for tests, commands, jobs, and Tauri state. - -Use `PgliteServer` when a library expects a PostgreSQL URI: - -- SQLx, Diesel, SeaORM, `tokio-postgres`, or cross-language clients; -- local TCP or Unix socket listener; -- compatibility layer for existing Postgres clients. - -Both modes still use one embedded backend. - -## Persistence Modes - -Direct and server builders expose the same root choices: - -- `path(...)` for a persistent database under an explicit directory; -- `app(...)` or `app_id(...)` for a persistent database under app data; -- `temporary()` for a fast cached temporary database; -- `fresh_temporary()` for an explicit fresh-cluster path. - -Choose `temporary()` for most tests. Choose `fresh_temporary()` only when you -need a brand-new cluster and are willing to pay its slower startup path. - -## Operational Limits - -The current runtime model is single-backend: - -- one `Pglite` instance owns one embedded backend; -- one `PgliteServer` exposes one embedded backend; -- downstream client pools should use one connection; -- server mode is for local compatibility, not a multi-user Postgres replacement. - -Generated server URLs include `sslmode=disable`. `CancelRequest` and normal -startup packets are supported, but there is still one backend behind the server. - -## Root Locking And Lifecycle - -Persistent roots are locked while open. A second direct or server open against -the same root fails instead of sharing one data directory unsafely. - -Close database clients before calling `PgliteServer::shutdown()`. The current -server thread waits for active client work to finish before exiting. - -If you need a same-version physical clone, use `dump_data_dir()` / -`load_data_dir_archive(...)` or `try_clone()`. For portable exports and -upgrades, use logical dumps through `pg_dump`. - -## Startup And Preload - -The crate exposes two preload hooks: - -```rust,no_run -use pglite_oxide::{extensions, Pglite}; - -fn main() -> Result<(), Box> { - Pglite::preload()?; - Pglite::preload_extensions([extensions::VECTOR])?; - Ok(()) -} -``` - -Call them before a visible startup path when you want to warm the packaged -runtime and bundled extension artifacts. - -Startup configuration belongs on the builders: - -- `postgres_config(...)` for PostgreSQL GUCs; -- `username(...)` and `database(...)` for the session target; -- `relaxed_durability(true)` for cacheable local workloads; -- `startup_arg(...)` only for advanced cases. - -## Supported Targets - -Default builds include packaged runtime assets and host artifacts for: - -- macOS arm64; -- Linux x64; -- Linux arm64; -- Windows x64. - -Unsupported host targets fail with a missing-artifact error instead of trying -to compile PostgreSQL locally. - -Browser, worker, and mobile topics from upstream PGlite docs do not apply to -this crate. `pglite-oxide` is a Rust crate for local embedded and desktop/server -workloads. - -## What Server Mode Is For - -Reach for `PgliteServer` when you need client-library compatibility: - -- SQLx migrations and query APIs; -- ORMs that expect a PostgreSQL URI; -- test fixtures for Python, Go, or Node clients; -- local tools that already speak the Postgres wire protocol. - -Reach for `Pglite` when you control the Rust call site. It avoids the extra -socket layer and keeps the API surface smaller. diff --git a/docs/TAURI.md b/docs/TAURI.md deleted file mode 100644 index 97e6f19f..00000000 --- a/docs/TAURI.md +++ /dev/null @@ -1,80 +0,0 @@ -# Tauri Usage - -Use `pglite-oxide` from Rust state, not from the webview. The crate's main value -in Tauri is a sidecar-free local Postgres runtime that commands, background -tasks, and Rust libraries can share. - -See the -[Tauri SQLx example](https://github.com/f0rr0/pglite-oxide/blob/main/examples/tauri-sqlx-vanilla/README.md) -for a Tauri v2 app that keeps the database in Rust state and exposes a small -SQLx-backed profile command to the frontend. - -## Direct Rust State - -Use `Pglite` when your Tauri commands own the database calls: - -```rust,no_run -use pglite_oxide::Pglite; -use serde_json::json; -use std::sync::Mutex; -use tauri::State; - -struct Db(Mutex); - -#[tauri::command] -fn add_item(db: State<'_, Db>, value: String) -> Result<(), String> { - let mut db = db.0.lock().map_err(|err| err.to_string())?; - db.query( - "INSERT INTO items(value) VALUES ($1)", - &[json!(value)], - None, - ) - .map_err(|err| err.to_string())?; - Ok(()) -} -``` - -Open the database under your app data directory during setup: - -```rust,no_run -use pglite_oxide::Pglite; - -fn main() -> Result<(), Box> { - let mut db = Pglite::builder() - .app("com", "example", "desktop-app") - .open()?; - db.close()?; - Ok(()) -} -``` - -## Existing Postgres Clients - -Use `PgliteServer` when another Rust library expects a PostgreSQL URL: - -```rust,no_run -use pglite_oxide::PgliteServer; - -fn main() -> Result<(), Box> { - let server = PgliteServer::builder() - .path("./.pglite") - .start()?; - - let database_url = server.database_url(); - println!("{database_url}"); - - server.shutdown()?; - Ok(()) -} -``` - -This is the right fit for SQLx or other client libraries that already speak the -Postgres wire protocol. - -## Operational Guidance - -- Keep database access serialized around one backend. -- Configure SQLx and other pools with one connection. -- Prefer `Pglite` over `PgliteServer` when you do not need a PostgreSQL URI. -- Use `temporary()` or `temporary_tcp()` for tests. -- Use `fresh_temporary()` only when you need fresh-cluster semantics. diff --git a/docs/TESTING.md b/docs/TESTING.md deleted file mode 100644 index a6364dbb..00000000 --- a/docs/TESTING.md +++ /dev/null @@ -1,136 +0,0 @@ -# Testing With pglite-oxide - -`pglite-oxide` is intended for tests that need real Postgres semantics without -Docker. - -## Direct Rust Tests - -Use `Pglite::temporary()` when the code under test can call the direct Rust API: - -```rust,no_run -use pglite_oxide::Pglite; - -#[test] -fn stores_rows() -> Result<(), Box> { - let mut db = Pglite::temporary()?; - - db.exec("CREATE TABLE items (id int primary key, name text)", None)?; - db.exec("INSERT INTO items VALUES (1, 'alpha')", None)?; - - let rows = db.query("SELECT name FROM items WHERE id = 1", &[], None)?; - assert_eq!(rows.rows[0].get("name").unwrap(), "alpha"); - - db.close()?; - Ok(()) -} -``` - -Use `fresh_temporary()` only when the test must validate fresh-cluster -initialization behavior: - -```rust,no_run -use pglite_oxide::Pglite; - -#[test] -fn fresh_cluster_path() -> Result<(), Box> { - let mut db = Pglite::builder().fresh_temporary().open()?; - db.close()?; - Ok(()) -} -``` - -## Server Tests - -Use `PgliteServer` when the application already talks to Postgres through a -client library: - -```rust,no_run -use pglite_oxide::PgliteServer; -use sqlx::{Connection, Row}; - -#[tokio::test] -async fn sqlx_query() -> Result<(), Box> { - let server = PgliteServer::temporary_tcp()?; - let mut conn = sqlx::PgConnection::connect(&server.database_url()).await?; - - let row = sqlx::query("SELECT $1::int4 + 1 AS n") - .bind(41_i32) - .fetch_one(&mut conn) - .await?; - assert_eq!(row.try_get::("n")?, 42); - - conn.close().await?; - server.shutdown()?; - Ok(()) -} -``` - -Keep client pools at one connection. - -## Extension Tests - -Enable bundled extensions through the builder: - -```rust,no_run -use pglite_oxide::{extensions, Pglite}; - -#[test] -fn vector_query() -> Result<(), Box> { - let mut db = Pglite::builder() - .temporary() - .extension(extensions::VECTOR) - .open()?; - - db.exec("CREATE TABLE items (embedding vector(3))", None)?; - db.exec("INSERT INTO items VALUES ('[1,2,3]')", None)?; - db.exec("SELECT embedding <-> '[1,2,4]' FROM items", None)?; - - db.close()?; - Ok(()) -} -``` - -When an extension has bundled dependencies, prefer the builder path over -post-open `enable_extension(...)`. - -## Snapshot And Fixture Setup - -Use physical data-dir archives or `try_clone()` when a test suite needs a -pre-populated same-version fixture: - -```rust,no_run -use pglite_oxide::Pglite; - -#[test] -fn clone_fixture() -> Result<(), Box> { - let mut seed = Pglite::temporary()?; - seed.exec("CREATE TABLE items(value TEXT)", None)?; - seed.exec("INSERT INTO items VALUES ('alpha')", None)?; - - let mut clone = seed.try_clone()?; - clone.exec("SELECT * FROM items", None)?; - - clone.close()?; - seed.close()?; - Ok(()) -} -``` - -Use logical dumps, not physical archives, when you need a portable export. - -## Cross-Language Tests - -Use `pglite-proxy` when the test process lives outside Rust: - -```sh -pglite-proxy --temporary --tcp 127.0.0.1:0 --print-uri -``` - -Pass the printed URI to Python `psycopg`, Go `pgx`, Node `pg`, or another -standard Postgres client. - -## COPY And Raw Protocol Tests - -Direct `Pglite` supports `/dev/blob` for `COPY TO` and `COPY FROM`. Server mode -supports ordinary client-driven `COPY FROM STDIN` and other standard wire -protocol flows through the local Postgres endpoint. diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index e00d9c16..00000000 --- a/docs/TODO.md +++ /dev/null @@ -1,517 +0,0 @@ -# To Do (Maintainers) - -This is the single implementation backlog for `pglite-oxide`. It is -maintainer-facing and intentionally separate from the user-facing docs. - -This file should contain only unfinished architecture, implementation, release, -and research work. - -## Product Target - -`pglite-oxide` should provide embedded Postgres for Rust tests and local apps: - -- no Docker, local LLVM, Cranelift, or Postgres build step for users; -- direct Rust API for embedded use; -- local server mode for SQLx, `tokio-postgres`, Diesel, SeaORM, Python, Go, - Node, and any other Postgres client; -- bundled pgvector and common SQL extensions; -- `pg_dump` support driven by the same packaged runtime. - -The production runtime target is PGlite/Postgres built as WASIX dynamic-linking -modules, precompiled with Wasmer LLVM AOT in CI, then loaded through headless -Wasmer in applications. - -## Release Blockers - -These are the top-level blockers before calling the WASIX/Wasmer path -production ready: - -1. Generate and validate asset/AOT packs across the supported target matrix: - macOS arm64/x64, Linux x64/arm64, and Windows x64. -2. Enforce cold-start and warm-path release gates in CI after collecting - release-mode baselines on GitHub-hosted runners. -3. Finish the remaining extension dependency stacks: pinned WASIX - OpenSSL/libcrypto for `pgcrypto`, pinned WASIX OSSP UUID/libuuid for - `uuid-ossp`, and the pinned PostGIS geospatial stack. -4. Harden public `pg_dump` API/CLI across target platforms and add release-mode - performance gates. -5. Harden CI, release-plz, package-size gates, Trusted Publishing, and - dependency invariants for all internal asset and AOT crates. -6. Validate the split WASIX `initdb` artifact end to end in the Assets workflow: - generated template determinism, fresh direct/server temporary roots, - interrupted-initdb cleanup, and package-size impact. - -## Bucket 1: Performance And Runtime Architecture - -Performance work is product work, not optional research. If a work item affects -both cold and warm behavior, track it under cold. - -### Current State - -- Default runtime uses headless Wasmer and packaged AOT artifacts. -- Runtime assets use pure mount composition; immutable runtime files stay in the - shared cache and per-root upper layers contain mutable state and requested - extension assets. -- PGDATA uses the eager template overlay; local PGDATA setup is under 1ms. -- Direct scalar paths no longer scan `pg_type` for arrays on open/query. -- Direct query paths no longer force Rust-side directory `sync_all`. -- Direct API, server API, proxy CLI, raw protocol, and `pg_dump` share - `BackendSession`. -- Current local release runs show visible first-query paths mostly in the - tens-of-ms range, dominated by PostgreSQL backend startup, Wasmer instance - creation, and first protocol round trips. The latest public benchmark - snapshot lives in [PERFORMANCE.md](PERFORMANCE.md). Maintainer tuning details - live in [PERFORMANCE_INTERNAL.md](PERFORMANCE_INTERNAL.md); historical - rollout notes stay in [DONE.md](DONE.md). - -### Release Gates - -- temporary database first `SELECT 1` under 500ms on GitHub Ubuntu; -- persistent database first `SELECT 1` under 500ms on GitHub Ubuntu; -- `PgliteServer` start plus first SQLx query under 500ms on GitHub Ubuntu; -- temporary database with requested extensions plus first extension-backed - query under 500ms on GitHub Ubuntu; -- public `pg_dump` startup plus first dump row under 500ms on GitHub Ubuntu; -- warm temporary direct first query under 100ms on GitHub Ubuntu; -- warm temporary server plus first SQLx query under 100ms on GitHub Ubuntu; -- warm temporary server plus first extension-backed SQLx query under 125ms on - GitHub Ubuntu; -- no more than 15% regression after stable baselines. - -### Cold Path Work - -- Turn the local `xtask perf cold`, `perf warm`, speed-suite, and prepared - update baselines into CI checks on representative runners. -- Keep the source/runtime invariants in the performance matrix: - `postgres-pglite` `REL_17_5-pglite` at - `01792c31a62b7045eb22e93d7dad022bb64b1184`, Wasmer/WebAssembly exceptions, - WASIX dynamic linking, spinlock-enabled WASIX build, and PGlite buffer - profile (`shared_buffers=128MB`, `wal_buffers=4MB`, `min_wal_size=80MB`). -- Reduce explicit `Pglite::preload()` latency further by profiling runtime - cache setup, mmap/native deserialization, and Wasmer native artifact loading. -- Keep Wasmer native mmap deserialization as the only production AOT loading - path. Do not reintroduce full content hashing on default startup. -- Cross-platform validate the default pure mount composition and eager PGDATA - overlay across direct, persistent, app-id, temporary, proxy, and server roots. -- Validate lazy/generated direct-client array metadata across built-in arrays, - enum arrays, domain arrays, composite arrays, explicit - `refresh_array_types`, transactions, row-mode results, and caller-supplied - parser/serializer overrides. -- Deepen C-side timers inside remaining backend-open costs: - shared-memory initialization, relcache/catcache work, database lookup, - `CheckMyDatabase`, and session initialization. Keep these timers gated out of - production artifacts unless explicitly enabled. -- Keep an explicit regression guard so extension-template opens do not fall - back into slow `StartupXLOG` recovery. -- Extend server perf visibility to proxy CLI runs, TCP, Unix sockets, - tokio-postgres, SQLx, scalar roots, and extension-enabled roots. -- Remove or mount-compose the remaining per-root requested-extension asset - materialization cost. This must stay generic by requested extension set, not - special-cased to vector. -- Add an init-profile dimension to extension-set PGDATA template cache keys - once init options become configurable. -- Evaluate catalog/syscache warmup during template creation: `SELECT 1`, - representative prepared/extended query, extension type I/O/query smoke, - SQLx/tokio-postgres startup flow, and representative extension setup. -- Run temporary durability experiments (`fsync=off`, `synchronous_commit=off`, - reduced WAL work) for temporary roots only. Persistent databases stay - conservative unless separately proven safe. -- Ensure side-module AOT artifact identity is tied to main runtime identity. -- Add Linux perf/perfmap support for symbolized AOT profiling so remaining time - can be attributed to executor, btree, heap, WAL, memory, or Wasmer-generated - code. -- Validate ThinLTO build time, package-size budget, and performance on every - supported CI target. -- Validate the current Wasmer LLVM codegen profile across the full target and - correctness matrix: nonvolatile memory operations plus readonly funcref table. -- Benchmark pgvector insert/query/distance workloads with the default WASIX - toolchain feature baseline, including SIMD/relaxed-SIMD behavior. -- Inspect tail calls, extended const expressions, and wide arithmetic use so - feature usage is consistent across target artifacts. - -### Warm/Steady-State Work - -- Reduce PostgreSQL backend startup without changing semantics, especially - `shared_memory`, `InitPostgres`, `relcache_phase3`, database/session setup, - and PGlite-specific startup work. -- Evaluate safe relcache/catcache/syscache warmup only if it is normal - Postgres-compatible state and cannot cache broken process-global state. -- Keep warm opens free of AOT decompression, full hashing, and asset extraction. -- Test whether any supported Wasmer engine/runtime reuse can reduce instance - creation without leaking Store, WASI env, fd, mount, protocol, or database - state. -- Use `perf warm` for long-lived `PgliteServer` baselines: repeated - connections, repeated SQLx/tokio-postgres prepared queries, transaction - batches, extension-backed queries, reconnect after client disconnect, and - idle-to-next-query latency. -- Keep `perf prepared-updates --skip-native --gate` in the local regression - path while CI baselines settle. -- Extend extended-protocol batching only through protocol-correct reductions in - host/backend crossings and buffer copies. Do not add sleep-based coalescing. -- Add direct warm benchmarks for prepared query reuse and first unknown runtime - array type discovery. -- Add warm public `pg_dump` benchmarks that measure startup separately from - dump volume. -- Evaluate context switching, experimental async APIs, CPU idle/backoff, and - opt-in backend pools only if correctness and state reset semantics are - stronger than user expectations. -- Defer threaded/multi-backend execution until the single-backend path is stable - and atomics/shared memory do not break dynamic linking or Postgres - process-global assumptions. - -### Runtime Experiments - -Experiments are real work. Each must report timing, correctness, state -isolation, artifact size impact, and implementation risk. - -- WASIX journaling, Wasmer `StoreSnapshot`, or InstaBoot-style restore: - re-enter only with a small upstream repro or a fixed journal layer. The last - local spike passed `SELECT 1` from an instance-created restore but did not - skip Postgres startup; backend-ready/protocol-ready snapshots were too slow - and failed fd seek replay. -- Cranelift: evaluate direct `SELECT 1`, SQL error recovery, representative - extension create/query, server SQLx smoke, compile speed, and cross-platform - exception/dynamic-linking behavior. -- Singlepass: evaluate only after the same longjmp/error and extension suite - passes. -- Asyncify: keep out of production unless a specific snapshot or journaling path - proves a need on an experiment branch. -- Alternative engines such as V8 or JavaScriptCore: evaluate only for mobile or - special embedded targets, checking WASIX, dynamic linking, filesystem, - exceptions, and headless/AOT implications. -- Native CPU tuning: evaluate only if artifacts remain portable or target packs - are split intentionally. - -## Bucket 2: CI, CD, Release, And Workspace Hygiene - -### CI/CD Target Model - -- Validate the source-controlled-inputs model on GitHub: normal CI must keep - using only source templates plus downloaded compatible Assets workflow - bundles, and asset-producing changes must remain the only PR path that - fetches upstream sources or runs Docker. -- Harden `xtask release publish`: the local command should stage and validate - exactly what the Release workflow publishes, then either invoke release-plz in - a Trusted Publishing environment or fail before any partial publish is - possible. -- Keep `xtask release stage` and `scripts/validate.sh release` as the only - packaging path for generated portable/AOT crate contents. Any future release - check must run against the staged workspace, not ad hoc copied artifacts. -- Split packaged runtime payloads from extension payloads after the `bundled` - feature model lands. Today `bundled` gives users an embedded-runtime install - mode without the public extension API, but the single `pglite-oxide-assets` - crate still carries extension archives and the target AOT pack can carry - extension AOT artifacts. A future crate split should make `bundled` - runtime-only at download/package-size level and keep extension archives plus - extension AOT artifacts behind `extensions`. -- Keep the local development split into three modes: fast assetless contributor - checks, host-platform artifact-backed runtime work, and downloaded CI - artifact testing. Developers validate their host platform locally; CI remains - responsible for the full target matrix. -- Ensure new asset-producing inputs update the committed asset-input - fingerprint and are covered by source-free `assets verify-committed`, - generated-asset validation, package-size checks, and runtime smoke tests. -- Release CI must publish only artifacts generated and tested for the exact - release SHA, with package checks performed against the same staged crate - contents that are published. - -### Target Matrix - -First-class targets: - -- `aarch64-apple-darwin`; -- `x86_64-unknown-linux-gnu`; -- `aarch64-unknown-linux-gnu`; -- `x86_64-pc-windows-msvc`. - -Experimental targets: - -- Linux musl; -- Android; -- iOS through V8/JSC/interpreter paths if feasible; -- RISC-V after Wasmer target support matures. - -### Asset And AOT CI - -- Validate the first full `Assets` workflow run after the portable-WASIX plus - native-AOT CI split lands. -- Keep dynamic-link closure checks, manifest validation, package-size checks, - and smoke tests coupled to asset release orchestration. -- Package strategy order: - 1. raw AOT artifact if the crate stays under crates.io's compressed limit; - 2. `.zst` compressed artifact with one-time expansion into the persistent - cache; - 3. deterministic split AOT/asset packs if compression is still too large. - -### Workspace Hygiene - -- Keep root `pglite-oxide` as the public crate. -- Keep asset/AOT crates internal implementation details with exact internal - dependency versions. -- Keep the source-free asset workflow honest as new asset-producing inputs are - added: every new input must be covered by `assets verify-committed`, the - input fingerprint, or an explicit asset CI gate. -- Keep one active source root: configured `postgres-pglite` - `REL_17_5-pglite` pinned to the audited commit. -- Keep user-facing docs free of implementation backlog/status notes. -- Keep [DONE.md](DONE.md) as the only completed-work/status document and this - file as the only implementation backlog. - -### Normal CI - -- Validate the first path-aware CI run for docs-only, CI-only, test-only, and - package-affecting PRs. -- Validate the first Rust-only native-AOT runtime matrix run across macOS - arm/x64, Linux arm/x64, and Windows x64. -- Keep doctests, no-default-features checks, feature powerset, dependency - invariants, package checks, supply-chain - checks, and example checks routed through the DRY validation script. -- keep the minimal `wasmer` and `wasmer-wasix` feature sets while retaining - filesystem mounts, WASIX env/args, networking required by `pg_dump`, and - dynamic linking; -- macOS multi-module LLVM exception gate: main module plus at least two side - modules, SQL error recovery after each load, and normal parallel test - scheduling on macOS arm64 and x64; -- keep actionlint, cargo-deny, and GitHub Actions security audit green. - -### Release And Publishing - -- Configure Trusted Publishing for every published crate. -- Complete first-publish/bootstrap verification for internal asset/AOT crates on - crates.io. -- Validate that release-plz publishes internal asset/AOT crates before the root - crate when exact internal dependency versions require that order. -- Keep release PRs using a GitHub App or bot token if maintainers want normal PR - CI to run automatically on release-plz branches. - -### Reproducibility - -- Record Wasmer crate version, Wasmer CLI/tool version, wasixcc/toolchain - version, WASIX libc/EH-PIC sysroot identity, LLVM version, postgres-pglite - commit, pglite-build commit, extension repository commits, Docker image - digest, and build profile in manifests. -- Use Wasmer reproducible-build controls such as `WASMER_REPRODUCIBLE_BUILD=1` - where applicable. -- Add deterministic two-build comparisons for identical source pins. -- Make asset and AOT crate hashes stable enough for audit and cache - invalidation. - -## Bucket 3: Source, Build Spine, And Asset Provenance - -The active source baseline is `electric-sql/postgres-pglite` -`REL_17_5-pglite` at `01792c31a62b7045eb22e93d7dad022bb64b1184`, matching the -`@electric-sql/pglite` 0.4.5 source/artifact pair. The historical -`REL_17_5_WASM-pglite-builder` branch remains reference material for extension -and `pg_dump` packaging ideas, not the production source spine. -`electric-sql/pglite-build` `portable` remains pinned as build-script -provenance, not as a second runtime source root. - -### Source-Spine Work - -- Keep stable branch lifecycle/protocol exports: - `_start`/single-user startup, `pgl_setPGliteActive`, `pgl_startPGlite`, - `ProcessStartupPacket`, `PostgresMainLoopOnce`, and `PostgresMainLongJmp`. -- Critique and document each PGlite adaptation before copying it. Keep only host - ABI adaptations still necessary under Wasmer/WASIX. -- Keep `pglite-build` and the builder branch as reference inputs for extension - symbol discovery and packaging, without reintroducing the old `pglite-wasm/*` - wrapper as production runtime code. -- Keep the generated `wasix-dl` export list wired to side-module import - discovery and extend negative tests as more extension packs are added. -- Replace catalog-driven extension packaging with install-delta packaging before - promoting extensions that scatter files outside the standard `.so`, - `.control`, and extension SQL layout. Reuse upstream `pack_extension.py` - concepts without using non-deterministic archive writing. -- Keep manifest fields for extension imports and core exports current so - dynamic-link failures are diagnosed before startup. -- Add negative fixtures proving wrong-core side modules and unresolved imports - fail during validation, before runtime startup. -- Verify `vector`, at least one contrib extension, one PGXS extension, and - `pg_dump` are always built from the same configured tree before release. - -### Upstream Audit - -- Keep `xtask assets audit-upstream --strict` as the source of truth for newer - upstream `postgres-pglite` fixes, marking each item as included, replaced by - WASIX architecture, optional, or pending. -- Keep the WASIX longjmp bridge intentionally narrower than upstream - Emscripten's `jmp_buf` content comparison: pointer identity against exported - top-level `postgresmain_sigjmp_buf`. -- Keep active-portal abort cleanup in PostgreSQL-owned code for - `PGLITE_WASIX_DL`; do not reintroduce Rust-side synthetic `Sync` or portal - cleanup without a failing upstream regression test. -- Keep startup identity/database handling owned by PostgreSQL startup code; Rust - should synthesize only runtime/host failures that occur before PostgreSQL can - emit wire output. -- Audit, cherry-pick, or explicitly reject remaining upstream/runtime items: - background-worker disable semantics, artifact cache fixes, data-directory - locking deltas, upstream `postgresConfig` parity beyond the Rust startup-GUC - API, and `pgoutput` symbol exports. -- Decide whether proxy/frontend startup should eventually stop fabricating - startup responses in Rust and converge further toward upstream - `interactive_one`/`ProcessStartupPacket` lifecycle for every client - connection. -- Keep future config changes flowing through the Rust startup-GUC API, a pinned - upstream `postgresConfig` surface, or a documented initdb-time config model. - -### Canonical Assets - -- Keep timezone data generated by `zic` inside the pinned build image from - PostgreSQL `tzdata.zi`, never from a maintainer host. -- Generate PGDATA with the desired timezone instead of patching extracted config - text. -- Keep runtime prefix files packaged from the pinned configured tree, including - timezone files, extension SQL/control files, and installed support libraries. -- Keep asset manifests tied to source commits, Docker image digest, Wasmer - version, engine identity, source module hashes, import/export sets, archive - hashes, and package sizes. - -## Bucket 4: Runtime Correctness And Protocol - -- Continue expanding PostgreSQL regression coverage beyond the current PGlite - parity subset into less common planner, catalog, lock, utility-command, and - wait/socket behavior. -- Add broader raw wire-protocol and fuzz coverage around extended query - sequencing. -- Keep export guards requiring `PostgresMainLongJmp`, - `PostgresSendReadyForQueryIfNecessary`, `pgl_pq_flush`, and WASIX - input/output symbols. -- If a future Wasmer version resumes the C `sigsetjmp` boundary directly, keep - the explicit recovery export as a tested no-op fallback until tests prove it - can be removed. -- Keep the guard that treats missing `ParseComplete` as an error on successful - Parse paths. -- Keep the production patch free of `pglite-wasm/*`; future frontend/initdb - stubs must be justified by link-symbol analysis against the stable branch. -- Add a C/link audit for the split-initdb child-process shim and keep it - fail-closed to locale discovery plus upstream initdb's `postgres` boot/check - commands. -- Keep interrupted-PGDATA and root-locking tests as the owned coverage for - failed opens. Do not add a fake child-process kill model unless the runtime - grows a real child-process boundary. -- Harden backend-side COPY error coverage beyond the current suite. -- Investigate returning from COPY streaming continuation to buffered mode after - COPY if it can be proven correct for SQLx, tokio-postgres, raw TCP, Unix - sockets, `CopyFail`, and post-COPY reuse. -- Keep direct raw protocol streaming and direct `pg_dump` on the shared - `BackendSession` path; do not reintroduce clone/server indirection. -- Reject asset mixing through negative tests: wrong runtime, wrong side module, - wrong AOT identity, wrong extension archive, and stale manifest. - -## Bucket 5: Extensions - -Extension catalog generation discovers 40 SQL extensions from PGlite docs/REPL -exports, PostgreSQL contrib, `postgres-pglite/pglite/other_extensions`, pinned -external repositories, and the packaged asset manifest. The current build plan -requests and packages 37 extensions. All 37 packaged extensions have passed -direct, server, restart, and lifecycle materialization gates and are public -constants. `pgcrypto`, `uuid-ossp`, and PostGIS remain explicitly blocked until -their native dependency stacks are pinned and smoke-tested for WASIX. - -### Remaining Promotion Order - -1. Add pinned WASIX OpenSSL/libcrypto sysroot and promote `pgcrypto`. -2. Add pinned WASIX OSSP UUID/libuuid sysroot and promote `uuid-ossp`. -3. Add pinned WASIX geospatial dependency stack and install-delta packaging for - PostGIS. - -### Extension Rules And Hardening - -- Generate public constants only after direct, server, restart, and lifecycle - smoke gates pass for the current asset set. -- Keep PGlite `live` out of SQL extension constants until there is a - Rust-native live-query API. -- Keep `extensions::ALL` limited to extensions passing for the current asset - set. -- Keep every discovered SQL extension either build-requested or blocked with a - concrete reason. -- Verify that manifest metadata remains sufficient for preload/config/shared - memory/restart/dependency/load-order needs; add fields only where the current - dependency, load-order, and lifecycle metadata are insufficient. -- Prove preload-required extensions such as `pg_stat_statements` apply - `shared_preload_libraries` before backend startup before exposing them. -- Make extension dependency errors fail at manifest/build-plan generation time - where possible. -- Add extension load-order and missing-native-dependency failure tests. -- Add preload/startup-config extension tests before exposing extensions that - require postmaster-time configuration. -- Add lifecycle negative tests for missing side modules, wrong core runtime, - missing SQL/control files, repeated enable, reopen after install, and missing - requested archives. -- Keep generated native-module metadata authoritative: SQL extension names and - native side-module names can differ, and some extensions are SQL-only. -- Replace remaining PGXS build assumptions with extension-specific build - metadata where external modules require extra flags, generated headers, - install hooks, generated SQL, or multiple side modules. -- Add automation that updates `assets/extensions.smoke.toml` from reviewed smoke - suite output instead of requiring maintainers to edit it by hand. - -## Bucket 6: `pg_dump` - -- Keep public dump/restore tests for direct `Pglite`, `PgliteServer`, vector, - indexes, views, sequences, `--schema-only`, and quoted identifiers. -- Keep direct `Pglite::dump_sql` no-clone, no-public-server, and no-OS-loopback: - stock WASIX `pg_dump`/libpq should route through Wasmer virtual networking and - host-side `exec_protocol_raw`. -- Do not add a pglite-oxide-specific `pg_dump` callback ABI unless stock libpq - over virtual networking fails a concrete correctness or performance gate. -- Keep rejecting passthrough flags that conflict with the typed API's managed - output file, output format, host, port, username, database, and job count. -- Add release-mode performance and cross-platform CI for `PgDumpOptions`, - `Pglite::dump_sql`, `Pglite::dump_bytes`, `PgliteServer::dump_sql`, - `PgliteServer::dump_bytes`, and the real `pglite-dump` CLI. - -## Bucket 7: Examples, Docs, And Ecosystem Tests - -- Add examples and CI for SQLx, `tokio-postgres`, rstest, Diesel, SeaORM, - Tauri, pgvector local RAG, Python/psycopg, Go/pgx, and Node `pg`. -- Add Python, Go, and Node proxy examples that verify SQLSTATE preservation and - recovery behavior through ordinary client libraries. -- Keep README first screen focused on embedded Postgres, tests, local apps, - pgvector/common extensions, no Docker, and any Postgres client through local - server mode. -- Keep user-facing docs free of internal status notes; implementation notes stay - in this file or [DONE.md](DONE.md). - -Required release test categories: - -- direct `SELECT 1`, persistence, restart, temporary template cache, and root - locks; -- SQLx and `tokio-postgres` server connections; -- SSLRequest, CancelRequest, Parse/Bind/Execute error recovery, and pipelined - extended queries; -- vector create/insert/query/distance through direct API and server mode; -- generated extension smoke suite; -- unsafe archive rejection and canonical path validation; -- manifest SHA validation and AOT source-module identity verification; -- unsupported target errors; -- macOS multi-module exception recovery; -- public dump/restore; -- Python, Go, and Node proxy tests; -- package size checks and publish dry-runs. - -## Experiment And Decision Policy - -Every runtime-affecting experiment must end in one repo-visible state: - -- `promoted`: implementation is on the production path; -- `blocked`: evidence and blocker are documented; -- `rejected`: reason and alternative are documented. - -Do not leave runtime-affecting experiments as loose notes. - -## Reference Material To Recheck - -- Wasmer 7 announcement and runtime feature docs; -- Wasmer 7.2 alpha release notes; -- Wasmer WASIX dynamic-linking docs; -- Wasmer WordPress/WebAssembly case study; -- Wasmer InstaBoot documentation; -- Wasmer macOS multi-module LLVM exception issue; -- Wasmer embedded/iOS tracking issue; -- Wasmer Rust API docs; -- PGlite extension docs and extension-development docs; -- `postgres-pglite` `REL_17_5-pglite` and historical - `REL_17_5_WASM-pglite-builder` reference branch; -- `pglite-build` `portable`; -- PGlite data-directory locking, startup config, and `pgoutput` upstream PRs. diff --git a/docs/architecture/final-product-source-architecture.md b/docs/architecture/final-product-source-architecture.md new file mode 100644 index 00000000..02ddcf58 --- /dev/null +++ b/docs/architecture/final-product-source-architecture.md @@ -0,0 +1,248 @@ +# Oliphaunt Source Architecture + +This document describes the active repository model. It is not a migration log. + +## Authority Boundaries + +Oliphaunt uses one source graph and one release identity system: + +- Moon owns projects, task execution, affectedness, dependency scopes, and task + caching. +- release-please manifest mode owns product versions, changelogs, release PRs, + and product-scoped tags. +- Product-local `release.toml` files own package metadata that release-please + does not model: owner, kind, publish targets, registry coordinates, release + artifacts, and compatibility-version files. +- Product-local `targets/*.toml` files own platform artifact metadata. +- `tools/release/release.py` owns protected publishing, checksums, + attestations, registry checks, and artifact verification. + +There is no separate release graph, release-input graph, CI jobs graph, or +consumer lockfile. If a relationship affects source, task execution, or release +coupling, it must be visible in Moon or release-please/product-local metadata. + +## Source Shape + +Source products and shared domains live under `src/`: + +```text +src/postgres/versions/18/ PostgreSQL 18 source pin and validation +src/sources/ shared source and toolchain pins +src/extensions/ exact SQL extension catalog, recipes, evidence +src/runtimes/liboliphaunt/native native C ABI runtime +src/runtimes/liboliphaunt/wasix WASIX runtime and AOT assets +src/runtimes/broker Rust broker helper runtime +src/runtimes/node-direct Node direct native runtime +src/sdks/rust Rust SDK +src/sdks/swift Swift SDK +src/sdks/kotlin Kotlin/Android SDK +src/sdks/react-native React Native SDK +src/sdks/js TypeScript SDK +src/bindings/wasix-rust Rust binding for the WASIX runtime +src/shared/contracts cross-language protocol and API contracts +src/shared/extension-runtime-contract extension/runtime ABI contract +src/shared/fixtures shared semantic test fixtures +src/docs public docs site +``` + +Generated local state lives outside source roots or in ignored product build +directories. Root `target/`, `.moon/cache/`, `node_modules/`, Gradle build +state, Swift `.build/`, Xcode DerivedData, Expo state, and docs build output are +generated state and must not be tracked. + +## Moon Graph + +Moon is the only task and affectedness graph. Project dependencies represent +real source relationships and use dependency scopes: + +- `production` and `peer` are release-affecting compatibility edges. +- `build` is a test, generation, fixture, or package-shape edge. It affects CI + and local affected tasks, but it does not force downstream product releases. + +Examples: + +- `liboliphaunt-native -> oliphaunt-rust`, `oliphaunt-swift`, + `oliphaunt-kotlin`, `oliphaunt-node-direct`, and `oliphaunt-broker` are + production edges. +- `oliphaunt-swift -> oliphaunt-react-native` and + `oliphaunt-kotlin -> oliphaunt-react-native` are production edges. +- `oliphaunt-rust -> oliphaunt-js` is a production edge because the TypeScript + SDK uses the Rust broker helper. +- `extensions -> SDKs` is a build edge. SDK tests and generated metadata react + to extension catalog changes, but exact extension source releases do not + automatically release SDK packages. +- `shared-fixtures -> SDKs` is a build edge. Fixtures affect tests and coverage, + not package releases. + +Use Moon queries for graph inspection: + +```sh +moon query projects +moon query tasks +moon query affected --upstream none --downstream deep +moon project-graph +moon action-graph oliphaunt-rust:test +``` + +Do not add a second graph format to answer questions Moon already answers. + +## CI Model + +CI has stable GitHub job names because branch protection needs stable checks. +Moon decides which tasks are affected and how tasks depend on each other. + +The flow is: + +1. The affected planner calls Moon queries and maps `ci-*` task tags to stable + GitHub job names and exact Moon task targets. +2. Product jobs call `.github/scripts/run-planned-moon-job.sh `, which + reads the planned target map and delegates to `.github/scripts/run-moon-targets.sh`. +3. GitHub matrix is used only for real runner or target fan-out: OS, CPU, ABI, + simulator, device, native runtime target, broker target, Node direct target, + and WASIX AOT target. +4. The `artifact-builders` aggregate answers the release-deliverable question first: + every selected runtime, helper runtime, SDK package, extension artifact, + extension package, and mobile app builder must finish successfully. + React Native package changes select both Android and iOS mobile app builders, + because the RN release surface is the JS package plus native Swift/Kotlin + integration built from staged runtime and extension artifacts. +5. The `required` aggregate is intentionally thin. It gates `affected` and + `artifact-builders` only, so static checks, docs, coverage, regressions, E2E, and + release-readiness cannot replace the release artifact proof. +6. The `Builds` workflow selects artifact-producing builder jobs only. It does + not become a catch-all quality/regression run on pull requests or full + non-PR runs. Separate quality workflows can use Moon later, but they are not + part of the release-deliverable artifact builder gate. +7. Builder jobs invoke only their planned builder Moon targets. GitHub `needs:` + expresses artifact ordering; builder invocations pass `--upstream none` + through `.github/scripts/run-planned-moon-job.sh` so upstream `check`, `test`, + docs, coverage, or regression tasks cannot run accidentally inside the + release artifact lane. +8. Expensive runtime, mobile, benchmark, publish, registry, and provenance jobs + are selected by affectedness, but they execute live when current runner state + matters. + +Mobile build jobs do not own ABI lists. They request target surfaces such as +`react-native-android` and `react-native-ios`; the selected native runtime +target IDs come from `src/runtimes/liboliphaunt/native/targets/*.toml`. Mobile +E2E belongs in a separate installed-app workflow that consumes the app artifacts +from `Builds`; it must not rebuild runtimes, SDKs, or extension packages. + +Moon task options must be semantic: + +- cache deterministic checks, tests, package-shape checks, generated freshness, + docs builds, and measured unit coverage with declared inputs and outputs. +- use `runInCI: skip` for expensive dependency tasks that should remain valid + in CI action graphs but should not run as broad affected work. +- use `runInCI: false` only for local/manual tasks that CI must never invoke. +- keep runtime/device/provenance tasks uncached in CI with `MOON_CACHE=off`. + +## Release Model + +Release decisions come from release-please components and Moon dependency +scopes: + +1. release-please identifies product components, versions, changelogs, and tag + prefixes. +2. Product-local `release.toml` adds publish and artifact metadata. +3. `tools/release/release.py plan` maps changed paths to owning Moon projects. +4. The release closure follows only Moon `production` and `peer` dependencies. +5. CI affectedness still follows all Moon dependencies, including `build`. + +This keeps release behavior explicit without duplicating source globs. A +PostgreSQL 18 source change releases native and WASIX runtimes plus downstream +products that have production/peer compatibility edges. A shared fixture change +runs affected tests but releases no package. An exact extension source change +releases that exact extension artifact product, not every SDK. + +Release planning adapts Moon project sources and dependency scopes for +product-tag diffs; it must not introduce hand-authored source glob or dependency +metadata. CI execution must run the exact task targets emitted by the affected +planner instead of recomputing affectedness inside each product job. + +Release publishing consumes artifacts from the same-SHA `Builds` run whose +`artifact-builders` job succeeded. It does not require unrelated static/docs/regression +jobs in that workflow to be green before downloading runtime, SDK, helper, +extension, or mobile build artifacts. + +## Extensions + +Extensions are exact SQL extension artifacts, not packs. + +- Public selection is by SQL extension name, for example `vector` or `postgis`. +- Public PostgreSQL contrib extensions own exact-extension product folders under + `src/extensions/contrib//` with target metadata, changelog, version, and + `release.toml`; the shared PostgreSQL 18 contrib catalog stays in + `src/extensions/contrib/postgres18.toml`. +- External extensions own folders under `src/extensions/external//` with + source pin, recipe, target metadata, tests, changelog, version, and + `release.toml`. +- Complex external extensions keep dependency source pins under their own + extension folder, for example PostGIS dependency pins under + `src/extensions/external/postgis/dependencies/`. +- `src/shared/extension-runtime-contract/` defines the runtime contract shared + by native and WASIX extension artifacts. +- `src/extensions/artifacts/native/` and `src/extensions/artifacts/wasix/` + validate publishable exact-extension artifact shape. +- Native runtime targets may opt out of exact-extension artifact publication + with product-local target metadata when no real extension producer exists for + that target. They must not appear in exact-extension matrices until the + producer exists. + +SDK packages must not ship all extensions. App developers install or configure +only the SQL extension artifacts they use; generated registries and package +checks must prove unselected extension files do not enter app artifacts. + +## Tool Entrypoints + +Use Moon directly for repository tasks: + +```sh +moon run :check +moon run :test +moon run :coverage +moon run :package +moon run :smoke --cache off +moon query affected --upstream none --downstream deep +``` + +`moon run :package` is the local package-shape lane. It must not build +platform runtimes, exact-extension matrices, mobile apps, or publishable SDK +artifact envelopes. Publishable artifacts are produced by explicit +`package-artifacts`, runtime, extension, and mobile builder tasks selected by +the `Builds` workflow. + +Use pnpm only for JavaScript dependency installation and package-manager +commands. Use Cargo, SwiftPM/Xcode, Gradle, npm/JSR, and Expo through +product-local Moon tasks or product-owned scripts. Do not add root alias layers +over Moon. + +## Mobile E2E Decision + +Decision (2026-06-08): installed-app mobile E2E uses the pinned open-source +Maestro CLI on GitHub-hosted emulator/simulator jobs. This is not an open +research loop. Do not keep re-checking Maestro, Detox, Appium, EAS-only flows, +Firebase Test Lab, BrowserStack, Sauce, AWS Device Farm, or paid hosted-device +services during routine implementation. + +Reopen the decision only with a written implementation proposal that names a +concrete installed-app E2E requirement that the pinned open-source Maestro CLI +cannot satisfy. When mobile E2E breaks, debug the chosen implementation first: +app artifact shape, simulator/emulator setup, Maestro flow files, logs, and CI +runner assumptions. The default proof path must stay free and reproducible from +a public checkout; paid hosted-device services are not part of the default +model. + +## Removed Surfaces + +These surfaces are retired and must not reappear: + +- `release-plz.toml` and release-plz release PR/changelog/tag ownership. +- `tools/release/release-graph.toml`. +- `tools/release/release-inputs.toml`. +- `tools/graph/jobs.toml`. +- custom affected task runners that bypass Moon. +- broad registry reinstall gates as routine CI policy. +- extension packs, aliases, or grouped selectors. +- root product aliases such as `crates/`, `sdks/`, root `assets/`, and + root-level runtime build trees. diff --git a/docs/architecture/ios.md b/docs/architecture/ios.md new file mode 100644 index 00000000..4a2d32f7 --- /dev/null +++ b/docs/architecture/ios.md @@ -0,0 +1,410 @@ +# iOS Architecture Investigation + +This document evaluates the strongest iOS architecture for `liboliphaunt` without +assuming the current direct-mode shape is the final answer. + +## Confidence Reframe + +The iOS product should not depend on a speculative broker to feel reliable. +The reliable, shippable baseline is: + +- crash-consistent storage through PostgreSQL WAL; +- a resident in-process engine with one root and one physical session; +- explicit lifecycle APIs for backgrounding, cancellation, checkpoint, backup, + and logical detach; +- test evidence that app relaunch after process death reopens the same root + cleanly. + +That is not crash isolation. It is crash consistency. If native PostgreSQL +crashes in direct mode, the host app process dies. The next app launch should +recover the database by WAL replay and reopen the same root. That is the honest +guarantee we can make on every supported iOS version. + +Crash isolation is a different product capability. On iOS it requires a proven +separate process. Today the only credible Apple-supported path is +`ExtensionFoundation.AppExtensionProcess` on iOS 26+. That should be built as a +separate, availability-gated `NativeExtensionBroker` mode. If it fails the +feasibility gate, the product answer is not to fake isolation in direct mode; +the answer is that iOS supports fast embedded direct mode, and crash-contained +embedded PostgreSQL is unavailable on that OS/configuration. + +## Hard Constraints + +### iOS Does Not Have A General Helper Daemon Model + +The macOS-style "app plus bundled XPC service" model is not generally available +to iOS apps. The iPhoneOS SDK marks `NSXPCConnection.init(serviceName:)` and +Mach-service creation unavailable on iOS, and the Foundation `Process`/`NSTask` +API is not present in the iPhoneOS Foundation headers. The App Store rules also +require apps to stay self-contained and not download, install, or execute code +that changes app functionality after review. + +Implication: a robust iOS database process cannot be designed as a normal +spawned helper, launch agent, or app-owned daemon. + +### ExtensionFoundation Is Real, But It Is Not A Normal Daemon + +Starting in the current iOS SDK, `ExtensionFoundation.AppExtensionProcess` is +available on iOS 26+. It creates or attaches to a separate app-extension +process, exposes `makeXPCConnection()` and `makeXPCSession()`, and reports +unexpected extension termination through an interruption handler. + +This is the first credible Apple-supported path to iOS process isolation for +`liboliphaunt`. It still has limits: + +- it requires iOS 26+; +- it runs an app extension, not an arbitrary service; +- the host must keep a strong `AppExtensionProcess` reference; +- the system can suspend an extension if no XPC connection is established; +- an app extension is not a long-running background worker; +- app and extension storage must be explicit, usually through an App Group; +- one extension identity appears to map to one running process, so it should + not be treated as an unlimited worker pool. + +Implication: an iOS broker is possible enough to deserve a serious spike, but +it must be modeled as a constrained extension-process broker, not as the same +broker implementation we can ship on macOS or Android. + +### Background Execution Is Finite + +UIKit gives apps a short background window and a finite extension mechanism via +`beginBackgroundTask`. BackgroundTasks can relaunch the app for scheduled work, +but not as an interactive database server. App extensions cannot call +`UIApplication` and must use extension-safe background mechanisms. + +Implication: iOS cannot promise an always-on local PostgreSQL service. The SDK +should make foreground database calls fast, finish or cancel foreground work +cleanly when the app backgrounds, and use scheduled background work only for +maintenance such as checkpoint, vacuum policy, sync, or backups. + +### PostgreSQL Wants A Process Boundary For Crash Recovery + +Normal PostgreSQL robustness comes from a supervisor process, child backend +processes, shared-memory reinitialization after abnormal child death, and WAL +replay after immediate shutdown or crash. Our embedded direct path starts a +standalone PostgreSQL backend inside the app process and routes FE/BE protocol +I/O through host callbacks. + +The direct path can recover from PostgreSQL `ERROR`, protocol errors, +cancellation, and many controlled `proc_exit` paths. It cannot recover from a +native crash, abort, memory corruption, or process-wide PostgreSQL global state +poisoning without taking down the app process. Full in-process close/reopen +across arbitrary roots is not a sound product promise until PostgreSQL can be +proven re-entrant under our patches. + +Implication: direct mode can be SQLite-like in latency and embedding, but not in +crash containment. Only a separate iOS extension process can make PostgreSQL +death survivable for the host app. + +## Brittle Assumptions To Remove + +The architecture should explicitly reject these assumptions: + +- `close()` means full PostgreSQL shutdown. In mobile direct mode it does not; + it is logical detach from a resident process-wide runtime. +- `reopenable` is a single capability. Same-root logical reopen, root switching, + and crash restart are separate properties. +- iOS broker means desktop broker. iOS process isolation is extension-process + isolation with lifecycle and OS-version limits. +- iOS server mode can be emulated with a loopback listener. That gives a + connection-shaped API without PostgreSQL's real process semantics. +- background execution can keep an interactive database alive indefinitely. It + cannot. +- WASIX makes iOS direct mode safe. A same-process Wasm runtime may improve + portability or memory sandboxing, but it does not provide host-app survival + after database runtime failure. + +Removing these assumptions makes the architecture less magical and more +defensible. + +## Candidate Architectures + +| Candidate | Strengths | Failure Modes | Verdict | +| --- | --- | --- | --- | +| In-process `NativeDirect` | Lowest latency, App Store viable on all supported iOS versions, simplest Swift/RN DX, static extensions work | App crashes if native PostgreSQL crashes; one resident root/session; logical close only | Ship as universal fast path, with honest capabilities | +| In-process multi-session | Looks like server semantics without IPC | PostgreSQL globals, shared memory, signals, session state, temp objects, GUCs, transactions; no crash isolation | Reject unless upstream PostgreSQL grows a real embeddable multi-session runtime | +| In-process loopback server | Familiar connection string shape | No process isolation; true multi-client sessions still require PostgreSQL's process model; background behavior is misleading | Do not ship on iOS | +| Spawned helper/XPC service | Would solve crash isolation and restart | Not generally available to iOS apps | Reject for App Store iOS | +| ExtensionFoundation broker | Separate process, XPC, interruption handling, App Store-shaped packaging path on iOS 26+ | New OS floor, extension lifecycle limits, likely one process per extension identity, App Group storage, unknown memory/throughput ceilings | Best robust iOS direction; spike and gate before promising | +| System extension / Network Extension / File Provider abuse | Separate process in some cases | Wrong extension point, entitlement/review risk, user-visible policy mismatch | Reject | +| BackgroundTasks broker | System-supported background launch | Scheduled, finite, non-interactive; not request/response | Use only for maintenance | +| WASIX/Wasm engine | Sandboxed memory model and legacy portability | Same host process, likely lower perf, no native extension story, does not create iOS process isolation | Keep as legacy/compatibility, not best iOS default | +| Remote/cloud broker | Strong isolation | Not embedded/offline, not SQLite competitor | Out of scope for the embedded product | + +## Recommended iOS Product Shape + +### 1. Universal Fast Path: `NativeDirect` + +`NativeDirect` should remain the default iOS runtime for broad OS support and +low latency. It should be marketed as an embedded single-session PostgreSQL +runtime, not as a local server. + +The API should present this as an app-scope resident database, not a disposable +object that happens to keep native state behind the scenes. + +Contract: + +- one resident PostgreSQL backend per app process; +- one physical session; +- one database root per process lifetime; +- many Swift/RN callers may enqueue work, but execution is serialized; +- transaction APIs pin the physical session and reject unpinned work; +- `close()` is logical detach, not full PostgreSQL shutdown; +- same-root logical reopen is supported; +- root switching requires a fresh process; +- native crashes terminate the host app; +- WAL recovery happens after the app relaunches and opens the same root. + +DX requirements: + +- provide an app-scope `OliphauntContainer` or `OliphauntResidentDatabase` manager; +- open once per app process and reuse stable handles; +- make `close()`/`detach()` release the logical SDK handle only; +- provide `destroyRoot(...)` only when the root is not resident in the current + process; +- provide `fullShutdown` as unsupported in iOS direct mode rather than a best + effort; +- serialize all work through a fair owner queue; +- expose query cancellation and statement timeouts; +- make transaction helpers pin the physical session and reject unpinned work; +- expose `prepareForBackground(deadline:)` and `resumeFromBackground()`; +- default Swift/RN integrations should register lifecycle hooks when the app + framework is present, while still allowing manual control. + +This turns the current "close is special" behavior into the public model rather +than a surprising implementation detail. + +### 2. Robust Path: `NativeExtensionBroker` On iOS 26+ + +The best process-isolated iOS architecture is a bundle-only custom app extension +owned by the app and launched through `ExtensionFoundation.AppExtensionProcess`. +The extension owns `liboliphaunt`, the PostgreSQL runtime, and the selected static +extensions. The host app talks to it over XPC/XPCSession. + +Target contract: + +- host app defines a bundle-only extension point for `liboliphaunt`; +- broker extension links the same `liboliphaunt` C ABI and selected extension + objects; +- PGDATA and runtime resources live in an App Group container; +- XPC messages carry control requests and raw protocol chunks; +- the broker serializes one physical PostgreSQL session per opened root; +- host observes `onInterruption`/XPC invalidation and marks in-flight requests + as failed with unknown transaction outcome; +- reconnect starts a fresh extension process and reopens the same root after + WAL recovery; +- no automatic replay of writes unless the user opts into an idempotent request + envelope; +- capability reporting says `processIsolated=true`, + `crashRestartable=true`, and `independentSessions=false`. + +Important limit: this should not advertise `multiRoot=true` until proven. If +one extension identity maps to one running process, and `liboliphaunt` embeds one +process-wide PostgreSQL runtime, then iOS broker v1 is still a single-root +process-isolated runtime. A future multi-root design would need either a +defensible worker-slot model with multiple bundled extension identities, or a +much deeper PostgreSQL re-entrancy breakthrough. + +This mode should fail closed: + +- unavailable below iOS 26; +- unavailable without the broker extension target; +- unavailable without the required App Group; +- unavailable when extension lifecycle or XPC throughput evidence is missing; +- unavailable for multi-root unless worker multiplicity is proven. + +It should be named and documented as iOS process-isolated mode, not as generic +desktop broker parity. + +### 3. Server Mode: Explicitly Unavailable On iOS + +`NativeServer` should not be faked on iOS. A same-process loopback listener does +not provide independent sessions or crash isolation, and a real PostgreSQL +postmaster-style process tree is not an iOS app model. If a future +ExtensionFoundation broker can safely host a compatibility socket for one client, +that should be named as compatibility, not as true server mode. + +## Lifecycle Policy + +The SDK should provide explicit lifecycle APIs instead of hoping app authors +guess correctly: + +- `prepareForBackground(deadline:)`: stop accepting new work, cancel or allow + bounded active work, optionally `CHECKPOINT` if idle, then return before the + system deadline. +- `resumeFromBackground()`: verify the session still responds; if broker mode + was interrupted, reconnect and reopen. +- `cancel()`: interrupt the current PostgreSQL statement and surface a normal + PostgreSQL cancellation error when possible. +- `checkpoint()`: explicit durability boundary for apps about to background. +- crash drill helpers for tests: kill broker extension, kill host process, and + reopen root to prove WAL recovery. + +For direct mode, background handling improves data durability and UX but cannot +make native PostgreSQL crash-isolated. For broker mode, background handling must +also account for the extension being suspended or killed independently. + +The mobile SDK should treat lifecycle as part of the database API: + +- every long-running call accepts cancellation; +- every queued call has a bounded wait/cancellation path; +- background transition blocks new work before attempting checkpoint/cancel; +- foreground transition verifies the live session with a cheap query or protocol + sync before accepting normal traffic; +- memory warning handling can recommend `DISCARD ALL`, checkpoint, or app-level + query cancellation, but must not pretend to free PostgreSQL's process-wide + runtime in direct mode. + +## Extension Policy + +iOS extension support should stay static and opt-in: + +- selected extensions are compiled into the app and, for broker mode, into + the broker extension target; +- SQL/control/share assets are packaged as resources and copied/materialized by + the SDK; +- `CREATE EXTENSION` succeeds only when the selected extension is present and its + static registry is ready; +- dynamic extension loading is not the portable iOS path; +- downloading executable extension code after review is not allowed. + +This is the right tradeoff for iOS package size, App Store review, and +predictable crash/debug symbols. + +## Capability Vocabulary Needed + +The current broad `reopenable` bit is too coarse. iOS needs these distinct +capability fields across Swift, Kotlin, Rust, and React Native: + +- `sameRootLogicalReopen` +- `rootSwitchable` +- `crashRestartable` +- `processIsolated` +- `independentSessions` +- `maxClientSessions` +- `multiRoot` +- `backgroundContinuable` +- `requiresAppGroup` +- `minimumOS` + +This avoids selling direct mode as more recoverable than it is, and avoids +selling an iOS extension broker as a full desktop broker before worker +multiplicity is proven. + +Example direct-mode capabilities: + +```json +{ + "engine": "nativeDirect", + "processIsolated": false, + "sameRootLogicalReopen": true, + "rootSwitchable": false, + "crashRestartable": false, + "independentSessions": false, + "maxClientSessions": 1, + "backgroundContinuable": false +} +``` + +Example extension-broker capabilities after the feasibility gate passes: + +```json +{ + "engine": "nativeExtensionBroker", + "processIsolated": true, + "sameRootLogicalReopen": true, + "rootSwitchable": false, + "crashRestartable": true, + "independentSessions": false, + "maxClientSessions": 1, + "backgroundContinuable": false, + "requiresAppGroup": true, + "minimumOS": "iOS 26" +} +``` + +The broker should not report `rootSwitchable=true` or `multiRoot=true` until a +real multi-worker model is proven. + +## Direct-Mode Confidence Gate + +Direct mode is shippable on iOS only when these tests pass on simulator and real +devices: + +1. Open the same persistent root repeatedly through the app-scope manager and + prove all logical handles share the resident runtime. +2. Reject opening a different root in the same process with a precise error. +3. Run concurrent Swift tasks/RN promises and prove fair serialization, + transaction pinning, cancellation, and close/detach behavior. +4. Enter background during idle, during a read, during a write transaction, and + during a long-running query; prove the lifecycle policy either finishes + within deadline or cancels cleanly. +5. Kill the app process after committed writes, after uncommitted writes, and + during WAL activity; relaunch and prove PostgreSQL recovery returns a + consistent database. +6. Inject PostgreSQL `ERROR`, malformed protocol, cancellation, and controlled + `proc_exit`; prove the host surfaces errors without corrupting the session + when PostgreSQL allows recovery. +7. Inject an actual native crash in the backend thread; document that direct + mode crashes the host app, then prove app relaunch recovers the root. +8. Verify selected static extensions, backup/restore, memory warning + handling, and package-size reporting. + +This gate does not claim direct mode is crash-isolated. It proves the direct +mode guarantee: fast, single-session, crash-consistent embedded PostgreSQL. + +## Feasibility Gate For The iOS Broker + +Do not productize `NativeExtensionBroker` until these pass on real devices: + +1. Build an iOS 26+ app with a bundle-only custom ExtensionFoundation extension + point and a broker extension target. +2. Start the extension, establish XPCSession or NSXPCConnection, and roundtrip a + 1 MB binary payload without JS/UI blocking. +3. Link `liboliphaunt` into the extension, open a packaged-template PGDATA root in + an App Group container, run `SELECT 1`, close, and reopen. +4. Kill the extension process during idle and during a transaction; host app + must stay alive, report unknown transaction state, reconnect, and pass WAL + recovery. +5. Background and foreground during an active query; the SDK must either finish + within the deadline or cancel cleanly. +6. Package selected static extensions in the extension and verify + `CREATE EXTENSION vector` and `CREATE EXTENSION graph` when selected. +7. Measure XPC raw protocol RTT, streaming throughput, memory, app IPA size, and + extension memory ceiling against direct mode. +8. Verify TestFlight/App Store review viability with the extension declared as a + private bundle-only app extension and no downloaded executable code. + +## Decision + +The strongest iOS architecture is a two-tier product: + +- `NativeDirect` is the universal, fastest, SQLite-competitive embedded mode. + It must be honest about single-root, single-session, logical close, and lack + of crash isolation. +- `NativeExtensionBroker` is the best robust iOS mode for iOS 26+ if the + feasibility gate passes. It gives the host app a recoverable process boundary, + but should start as single-root and single-session rather than pretending to + be a desktop broker. + +WASIX does not solve the iOS stability problem unless it runs out of process, +and it is unlikely to be the performance-default path for competing with SQLite. +It remains useful as compatibility and cross-platform fallback, not as the ideal +iOS architecture. + +## References + +- Apple ExtensionFoundation `AppExtensionProcess`: + https://developer.apple.com/documentation/ExtensionFoundation/AppExtensionProcess +- Apple custom app-extension support: + https://developer.apple.com/documentation/extensionfoundation/adding-support-for-app-extensions-to-your-app +- Apple app extensions overview: + https://developer.apple.com/documentation/technologyoverviews/app-extensions +- Apple background execution: + https://developer.apple.com/documentation/uikit/extending-your-app-s-background-execution-time +- Apple App Store Review Guidelines: + https://developer.apple.com/app-store/review/guidelines +- PostgreSQL server shutdown and WAL recovery: + https://www.postgresql.org/docs/current/server-shutdown.html +- PostgreSQL `CREATE EXTENSION`: + https://www.postgresql.org/docs/current/sql-createextension.html diff --git a/docs/architecture/native-liboliphaunt.md b/docs/architecture/native-liboliphaunt.md new file mode 100644 index 00000000..151b8400 --- /dev/null +++ b/docs/architecture/native-liboliphaunt.md @@ -0,0 +1,519 @@ +# Native liboliphaunt PG18 Path + +The native product is split into two repo boundaries: + +- `oliphaunt/` owns the C ABI, PostgreSQL 18 source pin, patch stack, build + scripts, C smoke harness, and runtime header. +- `src/sdks/rust/` owns the Rust SDK shape over that C ABI. + +The existing `oliphaunt-wasix` crate and WASIX release lane remain in place while +the native path is built out separately. The WASIX crate does not select or load +native `liboliphaunt`; Rust native behavior is owned by `oliphaunt`. + +## Source And Patch Stack + +The C lane is pinned in: + +```text +src/runtimes/liboliphaunt/native/postgres18/source.toml +``` + +It currently targets PostgreSQL `18.4` and applies the patch stack in +`src/runtimes/liboliphaunt/native/patches/postgresql-18.4`. + +External PG18 extension candidates are pinned separately in: + +```text +src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml +``` + +That manifest is an internal research input, not a public SDK extension catalog. The +native validation wrapper runs +`src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh` without network access, +which verifies the manifest shape and any local checkout that exists. Use the +script's `--online` mode only when deliberately refreshing those pins against +upstream. + +The entrypoint does not use `postgres --single`. It adds a dedicated embedded +backend entrypoint, routes libpq backend reads/writes through host-owned I/O +callbacks, and runs PostgreSQL's normal exit callbacks without calling +`exit(3)`. + +## Build + +Build the macOS happy-path dylib with: + +```sh +src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +``` + +The script emits: + +```text +target/liboliphaunt-pg18/out/liboliphaunt.dylib +target/liboliphaunt-pg18/install/bin/initdb +target/liboliphaunt-pg18/install/bin/postgres +``` + +`ccache` is used automatically when available. Set `OLIPHAUNT_CCACHE=off` to +disable it, or set `OLIPHAUNT_CCACHE=/path/to/ccache` to force a specific +binary. + +The dylib build is also stamped. The script hashes the edited `liboliphaunt` +headers/sources and fingerprints the PostgreSQL embedded object/archive inputs; +if the stamp still matches and the dylib exports the required C ABI symbols, it +prints `reusing native liboliphaunt dylib` and skips the C object compile plus +dylib relink. Harnesses call `src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +--check-oliphaunt-current` in no-build mode so stale C ABI sources fail fast +instead of producing false-green native evidence. Set +`OLIPHAUNT_FORCE_RELINK=1` to force that relink. + +Extension builds are opt-in and intentionally cacheable. The default direct +build is core-only for fast C ABI iteration. When +`OLIPHAUNT_BUILD_EXTENSIONS=1` is set, the build script fingerprints the +extension source trees, compiler selection, PostgreSQL patch/build inputs, and +`liboliphaunt` C ABI sources. If the fingerprint and required normal/embedded +artifacts are still valid, the script reuses the extension artifacts instead of +running the expensive clean/rebuild loop again. Set +`OLIPHAUNT_FORCE_EXTENSION_REBUILD=1` to force a full extension rebuild. +Harnesses can call `src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +--check-extension-artifacts-current` to prove the same fingerprint and artifact +readiness without downloading, extracting, configuring, compiling, or relinking. + +External pgrx candidates have their own opt-in harness: + +```sh +src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh --fetch +src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh +``` + +It reads the same source-pin manifest, uses the manifest-pinned `cargo-pgrx` +major/minor, builds the crate subdirectory recorded by the manifest as a normal +PostgreSQL package with the native PG18 `pg_config`, then rebuilds with linker +flags that bind the module to `@rpath/liboliphaunt.dylib` for direct/broker +embedded loading. It writes per-extension input stamps and exposes +`src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh --check-current`, which +is the no-build gate used by `src/runtimes/liboliphaunt/native/tools/check-track.sh +external-pgrx`. The build lane is explicit and disk-guarded because candidate +extensions can have very different compile and artifact-size profiles. A repo-local +`target/liboliphaunt-tools/bin/cargo-pgrx` is discovered automatically. The +external-pgrx fingerprint tracks build-affecting inputs rather than the whole +harness file, so comments and DX-only script edits do not stale heavy extension +artifacts. `--refresh-current-stamps` validates existing normal and embedded +payloads, then rewrites the input stamps without repackaging. + +## C ABI Contract + +The canonical header is: + +```text +src/runtimes/liboliphaunt/native/include/oliphaunt.h +``` + +The current ABI is intentionally small: + +- `oliphaunt_init` +- `oliphaunt_exec_protocol` +- `oliphaunt_exec_simple_query` +- `oliphaunt_exec_protocol_stream` +- `oliphaunt_cancel` +- `oliphaunt_close` +- `oliphaunt_register_static_extensions` +- `oliphaunt_last_error` +- `oliphaunt_version` +- `oliphaunt_capabilities` +- `oliphaunt_free_response` + +`oliphaunt_exec_protocol` accepts frontend PostgreSQL protocol frames after +`oliphaunt_init` has initialized the embedded backend session. Responses are owned +by the native library until `oliphaunt_free_response`. + +Direct mode sets the process `PGDATA` environment variable to the active +`config.pgdata` for the backend lifetime so PostgreSQL extensions that consult +standard process state resolve files inside the selected root. `oliphaunt_close` +restores the caller's previous value, or unsets `PGDATA` if it was unset before +open. Broker and server modes provide stronger process isolation for apps that +cannot tolerate a process-wide environment mutation. + +`oliphaunt_register_static_extensions` is the direct/mobile extension module +loader boundary. A process that links extension code statically calls it before +`oliphaunt_init` with module stems, PostgreSQL magic functions, optional `_PG_init` +callbacks, and exported C symbols. The PostgreSQL `dfmgr` patch then resolves +those entries through the normal `CREATE EXTENSION`/`LOAD` path instead of +calling `dlopen`/`dlsym`. The registry is process-wide, rejects malformed or +duplicate entries, and freezes at first backend startup. `oliphaunt_capabilities` +advertises this as `OLIPHAUNT_CAP_STATIC_EXTENSIONS`. + +The Rust runtime resourcesr now emits the portable platform handoff for this: +`oliphaunt/static-registry/manifest.properties` and, for mobile-ready packages, +`oliphaunt/static-registry/oliphaunt_static_registry.c`. That generated source +exports `liboliphaunt_selected_static_extensions(size_t *count)`. Swift, Kotlin, +and React Native native bridges discover that optional symbol through the +process image and register its rows through the loaded `liboliphaunt` +`oliphaunt_register_static_extensions` symbol before `oliphaunt_init`. This keeps the +generated registry source independent of whether a platform links liboliphaunt +statically or loads it dynamically. + +`oliphaunt_exec_simple_query` executes one SQL buffer through PostgreSQL's +simple-query protocol without requiring SDKs to allocate a frontend frame first. +This is a convenience and performance ABI for the common direct `execute(sql)` +path; raw protocol remains the cross-language compatibility boundary. + +`oliphaunt_exec_protocol_stream` executes the same request shape but delivers +backend bytes to a callback as chunks. The C runtime scans backend frames +incrementally and completes the call when it observes `ReadyForQuery`. Streamed +backend chunks use a bounded in-process queue with producer backpressure so a +slow callback cannot turn a large result or `COPY` stream into unbounded RSS +growth. The default queue budget is 4 MiB and can be overridden for diagnostics +with `OLIPHAUNT_STREAM_QUEUE_MAX_BYTES`. + +Protocol execution does not impose a default query timeout. Startup readiness +uses `OLIPHAUNT_STARTUP_TIMEOUT_MS` with the legacy `OLIPHAUNT_TIMEOUT_MS` +fallback, but long-running SQL must run until PostgreSQL completes or the owner +explicitly calls `oliphaunt_cancel`. Ordinary close waits for active SQL and then +detaches/closes the owning SDK handle. + +`oliphaunt_cancel` requests cancellation of the active embedded backend query +out-of-band. It maps to PostgreSQL's normal interrupt path by setting +`InterruptPending` and `QueryCancelPending`, waking `MyLatch` when available, +and waking the host I/O condition variables. It is advertised through +`OLIPHAUNT_CAP_QUERY_CANCEL`; the Rust SDK maps broker and server cancellation onto +their own transport-native mechanisms. + +Bootstrap no longer shells through `system(3)`: the C runtime forks and execs +`initdb` directly when a PGDATA root has no `PG_VERSION`. The Rust SDK now uses +the production bootstrap path first: `BootstrapStrategy::PackagedTemplate` +hydrates new roots from a cached base PGDATA template before entering +`oliphaunt_init`, so direct mode does not pay `initdb` on every fresh open. +`initdb` remains the explicit tooling fallback. + +Native v1 is one active embedded PostgreSQL backend per process. The product +path keeps this honest with a process-wide guard; robust multi-root app behavior +belongs in broker mode rather than fake direct-mode multiplexing. Every live +root is protected by explicit root ownership, so direct, broker-helper, server, +backup, and restore fail fast instead of racing on the same PGDATA directory. +Plain C ABI callers get a default stable sibling filesystem lease from +`oliphaunt_init` for ``, plus +`/.oliphaunt.lock` as the visible root marker. C +`oliphaunt_restore` takes the same stable lease before staging or publishing a +restored root, so restore cannot replace a root currently owned by the direct +C ABI. Stable lease filenames live beside the root directory and use the shared +`.oliphaunt-root-.lock` algorithm used by the Rust SDK, so C, Rust, +Swift, Kotlin, and React Native platform adapters contend on the same root +identity. SDKs that own a broader root coordinator set +`OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK`; the Rust SDK does this because its +coordinator uses a same-process canonical root registry plus stable filesystem +leases across direct, broker, server, backup, and restore paths. The stable +leases are keyed by the canonical root path and remain held while restore +replacement moves the old root aside and publishes the validated new root. +Restore/import uses stable path reservation before publish, which means a +missing or intentionally empty target stays empty until the validated archive is +ready to publish. + +## Rust SDK + +Point the Rust SDK at the build outputs: + +```sh +export LIBOLIPHAUNT_PATH="$PWD/target/liboliphaunt-pg18/out/liboliphaunt.dylib" +export OLIPHAUNT_INSTALL_DIR="$PWD/target/liboliphaunt-pg18/install" +``` + +```rust,no_run +use oliphaunt::Oliphaunt; + +# async fn demo() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .open() + .await?; + +let result = db.query("SELECT 1::text AS value").await?; +assert_eq!(result.get_text(0, "value")?, Some("1")); + +let parameterized = db + .query_params( + "SELECT ($1::int4 + $2::int4)::text AS sum", + [1_i32, 41_i32], + ) + .await?; +assert_eq!(parameterized.get_text(0, "sum")?, Some("42")); + +db.execute("CREATE TABLE items(id bigint PRIMARY KEY)").await?; + +let tx = db.transaction().await?; +tx.query_params("INSERT INTO items VALUES ($1)", [1_i64]).await?; +tx.commit().await?; + +db.close().await?; +# Ok(()) +# } +``` + +Without `LIBOLIPHAUNT_PATH`, the native runtime returns a clear startup +error. The old `oliphaunt-wasix` crate is still the WASIX-oriented release lane. +`NativeBroker` and `NativeServer` are selected from the same builder through +`.native_broker()` and `.native_server()`. +Broker mode starts the helper with the same storage bootstrap policy selected on +the builder. In particular, `.existing_only()` is enforced by the helper and +will not silently create a new root. + +The `OliphauntRuntime` implementation is intentionally split by responsibility: + +- `src/sdks/rust/src/runtimes/liboliphaunt/native/mod.rs`: runtime/session behavior. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/ffi.rs`: C ABI structs, symbols, and + library resolution. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/root.rs`: process/file root locks and + PGDATA path preparation. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/root/runtime.rs`: runtime cache + orchestration. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/root/runtime/locate.rs`: native install + and embedded-module discovery. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/root/runtime/install.rs`: selected + runtime asset installation. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/root/runtime/cache_key.rs`: runtime + cache manifest, key, and validation logic. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/root/template.rs`: packaged-template + PGDATA bootstrap and root hydration. +- `src/sdks/rust/src/runtimes/liboliphaunt/native/root/extensions.rs`: selected extension + SQL/data/module materialization policy. + +Runtime materialization is profile-aware. Direct/broker use liboliphaunt-linked +extension modules because the embedded backend resolves modules inside the +`liboliphaunt` process. Server mode uses standalone PostgreSQL extension modules +from the install tree. The filtered `share/postgresql` tree is still manifest +gated, so a symlink back to the full install tree is not used because it would +make unselected extensions visible. + +The materialized runtime and base PGDATA template are content-keyed under +`$TMPDIR/oliphaunt-runtime-cache` by default. Override with +`OLIPHAUNT_RUNTIME_CACHE_DIR` when benchmarking, testing cache invalidation, +or packaging a controlled runtime cache location. Template hydration defaults to +physical byte-copy because current native matrix evidence shows better p90 +stability than APFS clone-on-write on the benchmark host. Set +`OLIPHAUNT_PGDATA_COPY_MODE=prefer-clone` for diagnostics that need to +compare clone-on-write hydration explicitly. + +`NativeBroker` uses the same direct C ABI inside a helper process. On Unix +platforms the Rust SDK connects to that helper over a per-session Unix-domain +socket in `/tmp`; TCP loopback remains available by setting +`OLIPHAUNT_BROKER_TRANSPORT=tcp`. Every broker session uses a generated +per-process authentication token passed to the helper through its environment +and verified as the first IPC frame, so an unrelated local client cannot drive a +fresh helper merely by finding the socket or TCP port. Broker sessions retain +their launch plan and relaunch the helper against the same root when the helper +has exited between operations. In-flight requests are not automatically replayed +after a crash because their commit state may be unknown; the caller sees that +error, and subsequent operations can recover through normal PostgreSQL WAL +recovery. + +Direct/server physical backup uses PostgreSQL's low-level online backup API: +`pg_backup_start`, archive `pgdata`, then `pg_backup_stop(wait_for_archive => +false)` to obtain and write `backup_label` and `tablespace_map` into the +archive. `pg_wal` is collected after backup stop so the archive has the WAL +needed for same-version recovery. Broker forwards the same physical backup from +its helper process. The physical archive format is a concrete single-root +archive: restore accepts only regular files and directories under `pgdata`, and +backup fails if PGDATA contains anything else. Symlinks, hardlinks, FIFOs, +sockets, device nodes, sparse/special tar records, external tablespaces, and +linked WAL directories are rejected rather than silently producing a +non-portable archive. Server mode also supports `BackupRequest::sql()` through +packaged `pg_dump`. + +Same-version physical restore is a first-class Rust SDK operation: +`Oliphaunt::restore(RestoreRequest::physical_archive(path, artifact))`. The SDK +does not ask callers to unpack tar archives manually. It stages restore output +next to the target root, rejects archive path traversal, duplicate canonical +paths, malformed tar framing, unsupported tar header formats, invalid tar +numeric and fixed-width string fields, unexpected link metadata, directory +entries with payload bytes, and unsupported archive entry types. Restore writes +only to the validated canonical archive path for each entry, validates the +archive tree shape before writing staging files, validates `PG_VERSION`, +`global/pg_control`, and `backup_label`, then publishes the root atomically. +The C ABI performs the same pre-extraction archive validation, so Swift, +Kotlin, and React Native platform adapters inherit the lower-level restore +contract rather than relying on Rust-only checks. +Existing roots are protected +by default and can be replaced only with `replace_existing()`, which first takes +the root lock. Existing symlink targets are rejected because restore publishes +with directory renames at the target path; callers should pass the real database +root path explicitly. + +## Smoke Tests + +Use the C smoke as the fastest C ABI harness: + +```sh +src/runtimes/liboliphaunt/native/bin/smoke-host-happy-path.sh +``` + +It compiles the host C harness, opens a database, sends raw protocol bytes for +`SELECT 1 AS value`, verifies ABI version/capability reporting, invalid init and +invalid exec argument errors, malformed frontend frame rejection and recovery, +SQL-error recovery with a second successful query, large owned-response growth, +streaming callback delivery, stream-callback failure recovery, active-query +cancellation and recovery, idempotent response cleanup, static extension +registration and PostgreSQL symbol resolution through `CREATE FUNCTION ... AS +'module', 'symbol'`, C ABI backup/restore, restore rejection for malformed tar +metadata and unsupported archive entries, backup rejection for symlinked PGDATA +entries, `PGDATA` pointing at the live root while the direct backend is active, +and restoration of the caller's `PGDATA` environment after direct backend +shutdown. It then closes cleanly and asserts that same-process direct reopen is +rejected with the documented process-lifetime error. The shell harness launches +the binary again against the same PGDATA root to verify persistence/reopen +across process boundaries. + +Use the Rust SDK shape test for the separate package: + +```sh +LIBOLIPHAUNT_PATH="$PWD/target/liboliphaunt-pg18/out/liboliphaunt.dylib" \ +OLIPHAUNT_INSTALL_DIR="$PWD/target/liboliphaunt-pg18/install" \ +cargo test -p oliphaunt --test sdk_shape -- --nocapture +``` + +The env-gated Rust SQL regression test exercises the same native runtime modes +for broader SQL behavior. It includes client-driven `COPY FROM STDIN` through +raw protocol frames, validates `CopyInResponse`, `CommandComplete`, and +`ReadyForQuery`, verifies the inserted payloads, and then runs a normal query to +prove post-COPY session reuse. It also drives invalid COPY input and an explicit +frontend `CopyFail`, verifies `ErrorResponse` plus `ReadyForQuery`, proves no +rows were committed, and runs follow-up queries to catch stuck COPY state. It +also includes `COPY TO STDOUT` streaming through `exec_protocol_raw_stream`, +validates `CopyOutResponse`, `CopyData`, `CommandComplete`, and `ReadyForQuery`, +verifies the expected streamed line and payload counts, and again proves the +session can execute normal queries after COPY: + +```sh +OLIPHAUNT_TRACK_BUILD=never src/runtimes/liboliphaunt/native/tools/check-track.sh quick +``` + +## Current Deliberate Gaps + +- One active direct backend per process; use broker/server for process-isolated + lifecycles and multi-root app designs. +- `NativeDirect`, `NativeBroker`, and `NativeServer` expose out-of-band query + cancellation. Direct maps to `oliphaunt_cancel`, broker uses a separate + authenticated cancel IPC endpoint, and server sends PostgreSQL's native + CancelRequest packet with the startup `BackendKeyData`. +- Rust `Oliphaunt::close()` rejects queued non-close work with `EngineStopped` + once close begins, waits for the active SDK-owned operation to finish, and + then closes or logically detaches the runtime. Interruption is explicit + through `Oliphaunt::cancel()`. +- Direct native protocol streaming is implemented in the C ABI. Broker mode + forwards native chunks over IPC. Server mode forwards complete PostgreSQL wire + frames as it reads them from the local server connection. Direct streaming + applies C-level producer backpressure with a bounded chunk queue. +- Extensions are materialized for selected PG18-supported extensions, and + `NATIVE_EXTENSION_MANIFEST` records SQL/control assets, native module + requirements, data files, smoke SQL strategy, coverage evidence, mobile + static-link status, and first-party/external packaging policy for every + supported row. `Extension::RELEASE_READY_PG18_SUPPORTED` is the public exact + extension catalog; custom static manifests are restricted to release-ready + first-party extensions. The external pgrx lane remains internal/deferred and + must not be surfaced as shippable SDK extensions until licensing, static + mobile linkage, and lifecycle evidence are complete. Required preload hooks + are derived from selected extensions, so extensions that need preload can add + `shared_preload_libraries` to direct, broker, and server startup from manifest + data instead of app code. Resource + packages now record package-level mobile + static-registry readiness and the runtime-resource generator can fail iOS/Android release builds + with `--require-mobile-static-registry` when selected module-backed extensions + are still pending. The low-level C ABI registry now exists through + `oliphaunt_register_static_extensions`; the Rust runtime-resource generator now generates the + platform registry source for complete mobile packages, and platform packages + still have to link the selected extension objects with the expected renamed + magic/init symbols before marking a package complete. Platform package builds + that actually link static extension + registry rows can declare exact module stems with `--mobile-static-module`; + unknown or unselected stems are rejected. Kotlin and React Native Android + split-resource packaging deliberately cannot declare those stems complete, + because that path cannot generate or verify the static-registry source; use + the Rust runtime resources for mobile release extension artifacts. Generated + packages record the exact selected extensions, dependency-expanded runtime + manifest, static-registry state, and per-extension size evidence, so + app-bundled resources are auditable without local path leakage. Signed + dynamic desktop extension artifacts and device-tested per-platform extension + object builds are not complete yet. The gated + native extension matrix iterates the manifest and covers install/load, + restart, physical backup, and physical restore for every currently packaged + extension across broker/direct-C-ABI and server paths. +- Broker mode starts one helper process per active root and uses a shared Rust + supervisor to enforce `.broker_max_roots(n)` and duplicate-root admission. + Sessions report `multi_root=true` when the configured broker root budget is + greater than one. The helper still takes the same filesystem root lock, so + independent broker runtimes cannot accidentally own the same root. Durable + reconnect, crash-restart policy, and upgrade orchestration remain broker + release gates. +- Server mode starts a local PostgreSQL process and exposes a connection string; + SDK-owned protocol traffic uses a short Unix-domain socket on Unix by default + with buffered frame reads, while the public connection string remains + PostgreSQL-compatible TCP. The runtime cache includes `pg_dump` and `psql`, + while broader ORM/pool parity tests are still release gates. +- The latest complete source-current native matrix is + `target/perf/native-liboliphaunt-20260524T090412Z/report.md`, with verified + provenance at + `target/perf/native-liboliphaunt-20260524T090412Z/provenance.json` for that + recorded source/artifact set. The checkout has since gained backup ABI and + tar-writer changes, so refresh the full matrix before making a current-source + release claim. The + PostgreSQL control is `postgres (PostgreSQL) 18.4`, with matched `safe` + durability and `throughput` runtime footprint across native liboliphaunt, + native PostgreSQL, and SQLite controls. Template bootstrap keeps the + open-time gate fixed: direct open p90 is `440.28 ms` versus native PostgreSQL + tokio at `576.4 ms`. The safe-profile direct path passes repeated RTT, open, + and RSS gates, but still misses speed-suite p90 (`2.668 s` versus `2.419 s`), + speed tail throughput (`0.907x` native PostgreSQL), and physical + backup/restore (`0.558 s` versus `0.344 s`) against the new native PostgreSQL + physical-archive control with equal `56.17 MB` p50 payloads. + The benchmark harness uses 10 fresh-process RTT repeats, 20 fresh-process + speed repeats, 10 prepared repeats, and 10 backup/restore repeats before + classifying a run as release evidence. Direct, broker, server, native + PostgreSQL tokio, and SQLite are `stable` on that host run. Individual direct + speed cases `1`, `2`, `2.1`, `3`, `3.1`, `4`, `5`, `10`, and `13` remain + above the 5% per-case tolerance in the complete matrix; isolated + fresh-process diagnostics reproduce `1`, `2.1`, `3`, `4`, `10`, and `13`. +- A current-source focused backup diagnostic lives at + `target/perf/native-liboliphaunt-20260524Tbackup-final-direct/report.md`. + It is partial evidence only, but it verifies the new `oliphaunt_backup_ex` + path that appends SDK metadata during the C archive write. Direct p90 improved + to `0.534 s`; native PostgreSQL physical p90 in the same run is `0.324 s`. + `OLIPHAUNT_TRACE_BACKUP=1` attributes the remaining direct cost mainly to + `pg_backup_start` and PGDATA archiving. + The matrix includes SQLite embedded comparison rows, + artifact-size rows, large-result streaming, `COPY TO STDOUT` streaming, and + prepared-update rows for sequential and pipelined direct/broker/server/native + PostgreSQL paths. Native matrix runs build `oliphaunt-perf` explicitly, and + the native boundary guard keeps Wasmer runtime crates behind opt-in feature + gates. Remaining gaps are the measured speed + and backup misses, SQLite open/RSS competitiveness, dedicated extended-query + and typed-helper benchmark lanes, and repeating the full matrix whenever + benchmark harness or runtime inputs change. +- Swift now has a native-direct C ABI runtime path through + `OliphauntNativeDirectEngine`, with env-backed tests for open, raw protocol + execution, cancellation, and close. Kotlin/Native now has the same direct + C ABI path through `NativeDirectEngine`, including env-backed tests for + missing-library diagnostics, raw protocol execution, process-bound reopen, + cancellation, and close. The Kotlin public handle now treats `close()` as a + lifecycle primitive: it marks the handle closed, waits for serialized + execution to drain, then detaches native direct handles. Swift, Kotlin, + and React Native now expose simple and parameterized typed result helpers + layered over raw protocol execution, matching the Rust SDK concept without + adding SQL semantics to the C ABI. React Native exposes the same `cancel()` + lifecycle method in its Codegen-safe TurboModule surface. React Native iOS now + delegates its TurboModule calls to `Oliphaunt` through an + Objective-C-visible Swift adapter instead of carrying a duplicate C ABI + runtime. React Native Android delegates its TurboModule calls to the + Kotlin SDK instead of carrying a separate Kotlin/JNI/CMake runtime. The React + Native SDK verifier now runs Android Codegen, Kotlin compilation, Gradle + `assembleDebug`, Kotlin SDK JNI syntax, Swift adapter compilation against + `Oliphaunt`, and a synthetic Android runtime/template asset packaging check + when `ANDROID_HOME` is set. Android runtime materialization and template + PGDATA hydration are owned by the Kotlin SDK; Apple materialization and + template hydration are owned by `Oliphaunt`. Packaged Android + `liboliphaunt.so`, extension loading with real artifacts, full New + Architecture app builds, and iOS/Android device smoke tests are not complete + yet. + +The full maintainer track critique and release blocker list lives in +`docs/internal/OLIPHAUNT_TRACK_REVIEW.md`. diff --git a/docs/assets/pglite-oxide.png b/docs/assets/oliphaunt.png similarity index 100% rename from docs/assets/pglite-oxide.png rename to docs/assets/oliphaunt.png diff --git a/docs/internal/DONE.md b/docs/internal/DONE.md new file mode 100644 index 00000000..4941260d --- /dev/null +++ b/docs/internal/DONE.md @@ -0,0 +1,1838 @@ +# Done (Maintainers) + +This is the single status document for implementation work already completed. +It is maintainer-facing and intentionally separate from the end-user docs. + +## Native Perf Evidence + +Implemented: + +- native benchmark provenance now distinguishes release evidence, partial + reports, and diagnostic runs; +- React Native benchmark reports now include app-reported process memory through + `Oliphaunt.processMemory()`. iOS records Mach task resident/physical-footprint + bytes, Android records `Debug.MemoryInfo` PSS/dirty/heap fields, and the + mobile footprint matrix summarizes those fields instead of trusting missing + host-side process output as zero; +- release-grade perf validation requires the full native matrix: direct, + broker, server, native PostgreSQL controls, SQLite control, RTT, speed, + streaming, and prepared-update suites; +- release-grade perf validation now verifies the raw benchmark JSON and + resource files exist for base runs and configured repeats, including p50, + p90, p95, and p99 latency fields for benchmark reports; +- no-build harness checks synthesize a tiny release fixture so provenance and + raw-output validation stay in the fast maintainer loop without launching the + native benchmark suite. +- native perf reports include throughput, backup/restore, p90/p99 CPU/RSS and + child-RSS evidence, plus a `Native Direct Regression Diagnostics` section with + focused rerun and `perf diagnose-speed-cases` commands when NativeDirect + misses a native PostgreSQL gate; +- native perf reports now keep backup payload bytes visible and include a + same-semantics native PostgreSQL physical-archive control. The current matrix + compares liboliphaunt physical backup/restore against a `pg_backup_start` / + `pg_backup_stop` filtered-PGDATA tar control with equal `56.17 MB` p50 + payloads. The logical `pg_dump`/`pg_restore -Fc` row remains comparison data, + not the direct parity gate. +- direct physical backups now prefer `oliphaunt_backup_ex`, which appends SDK + root/archive metadata while the C archive is still being written instead of + validating and copying the full archive in Rust afterward. The C tar writer + also uses direct `read(2)` file reads, per-entry buffer reservation, and + opt-in `OLIPHAUNT_TRACE_BACKUP=1` phase diagnostics. +- `tools/perf/matrix/run_native_speed_diagnostics.sh` runs repeated + fresh-process native-direct and native-PostgreSQL speed-case diagnostics and + writes versioned `oliphaunt.native-speed-diagnostics.v1` summaries. The first + current-source follow-up run for `20260524T090412Z` reproduced speed misses + for cases `1`, `2.1`, `3`, `4`, `10`, and `13`; cases `2`, `3.1`, and `5` + did not reproduce above the + 5% tolerance in isolated diagnostics. +- the mobile footprint matrix emits Android/iOS Expo dev-client benchmark and + process-death recovery cases for the requested shared-buffer, WAL-buffer, + WAL-size, and Safe/Balanced durability sweep; its no-build guard verifies + honest case counts, skips invalid 8MB/16MB WAL-minimum cases for the current + 16MB WAL-segment PG18 build, and summarizes p50/p90/p95/p99, package size, + Android PSS/RSS, and iOS resident memory. +- the iOS device artifact lane now builds a current XCFramework with device and + simulator slices that fail freshness checks if PostgreSQL imports + mobile-forbidden shared-memory or semaphore APIs; the physical iPhone + installed-app path reached successful build/install, crash-recovery verify, + a full smoke run with automatic physical-device background/foreground + lifecycle exercise through Safari, and quick plus full-candidate installed-app + footprint matrices with Safe/Balanced durability, same-device SQLite + baselines, process-death WAL recovery, and app-reported Mach task + resident/physical-footprint memory. A follow-up physical iPhone quick tuning + slice varied `shared_buffers=8/16/32/64/128MB` and + `min_wal_size=8/16/32MB` under Balanced durability with 15/15 passing cases, + proving effective GUC capture and showing physical footprint is mostly flat + across small WAL minima while 128MB shared buffers add a modest footprint + step. The harness also has a reuse-installed-app retry mode for locked-device + launch failures, and a post-change physical iPhone reuse-installed smoke + passed background/foreground lifecycle SQL after adding a bounded Expo launch + URL read in the example app. +- the Android Expo installed-app lane was recovered on the local API 34 emulator + by cold-starting the AVD with `-gpu swiftshader_indirect` and + `-no-snapshot-load`; the balancedMobile Safe 32MB/shared-buffers, 4MB-WAL + quick slice passed benchmark and process-death recovery, including same-device + SQLite comparison and Android PSS/RSS capture. Later Android emulator retry + evidence also proved app-reported `Debug.MemoryInfo` capture in one + balancedMobile quick case, but the same local AVD killed a follow-up app + process before attach/startup, so that failure is recorded as harness/device + instability rather than database performance evidence. +- SDK parity checks now guard the mobile direct-mode lifecycle contract: + one resident backend per process, one physical session, serialized requests, + same-root logical reopen only, no crash isolation, and + `prepareForBackground`/`resumeFromBackground` documentation. +- `target/perf/native-liboliphaunt-20260524T090412Z/report.md` is a complete + PostgreSQL 18.4 native release matrix with direct, broker, server, native + PostgreSQL, and SQLite rows for RTT, speed, streaming, prepared updates, and + backup/restore. Strict + `tools/perf/check-native-perf-report.sh` provenance + verification passed against that recorded source/artifact set; later backup + ABI/tar-writer changes require a refreshed full matrix before current-source + release claims. The report shows + NativeDirect passing RTT, open, and RSS gates while still missing speed-suite + p90, speed tail throughput, physical backup/restore p90, and physical backup + throughput, so those misses remain tracked work instead of parity claims. +- `tools/xtask` now keeps `wasmer-types` behind the AOT serializer feature. + Native no-default-feature builds no longer compile that legacy runtime crate, + and `tools/policy/check-native-boundaries.sh` guards the feature boundary. + +## Runtime Direction + +The repository now has one production direction: WASIX dynamic linking plus +headless Wasmer loading of CI-produced LLVM AOT artifacts. + +Removed or excluded from the production path: + +- Wasmtime/static-WASI runtime path; +- Emscripten/JavaScript glue runtime path; +- user-side Docker, LLVM, Cranelift, or local Postgres compilation; +- duplicated runtime layouts and host-side timezone/path rewrite shims; +- historical spike workspaces from the tracked repository. + +Production build inputs now live under `assets/`. + +## Workspace And Asset Crates + +Implemented: + +- root `oliphaunt-wasix` crate remains the public crate; +- `oliphaunt-wasix-assets` is the published runtime asset crate skeleton; +- source-only target AOT crate templates exist under `src/runtimes/liboliphaunt/wasix/crates/aot/*`; +- `xtask` owns source checks, build orchestration, packaging, manifest checks, + package sizing, upstream audits, and source-spine validation; +- upstream checkouts are no longer tracked; maintainers fetch pinned sources on + demand into ignored `target/oliphaunt-sources/checkouts`; +- source pins live in `src/sources/third-party/**`; +- root packages exclude upstream checkouts from published crates. +- `xtask assets verify-committed` validates source-controlled asset inputs, + source pins, package metadata, AOT crate templates, and generated extension + coherence when generated manifests are installed, without local upstream + checkouts; + +Generated release asset set: + +- portable Oliphaunt WASIX runtime archive; +- `pg_dump.wasix.wasm`; +- deterministic `.tar.zst` archives for the 37 requested extension build + candidates. All 37 promoted exact extensions are stable public constants after + direct, server, restart, and lifecycle materialization gates; +- prepopulated PGDATA template archive; +- native Wasmer LLVM AOT artifacts. + +These artifacts are generated locally under `target/oliphaunt-wasix/**` or by the +Builds workflow WASIX runtime jobs and are consumed by release staging without +being committed to git. + +## Builder-First Release Staging + +Implemented: + +- the Builds workflow now owns release-shaped runtime, SDK, and exact-extension + package artifacts; the Release workflow requires same-SHA Builds artifacts + and consumes them instead of rebuilding native assets during publish; +- exact-extension package staging now renames native and WASIX assets into the + independent extension product/version namespace and writes a release manifest + beside the archive assets; +- exact-extension artifact targets are now product-local metadata under each + external extension, and the Builds workflow derives the native extension + matrix from those targets across macOS, Linux, Windows, iOS, and Android; +- React Native mobile build jobs consume native exact-extension artifact + outputs and stage package-shaped manifests locally instead of waiting for the + aggregate native+WASIX extension package release assembly; +- GitHub release asset verification reads staged exact-extension package + manifests for extension products, and attestation verification includes + extension, broker, Node direct, liboliphaunt, and WASIX asset families; +- the Rust SDK no longer declares a false `github-release-assets` publish target + because it has no GitHub release asset payload beyond the registry package. + +## Source And Build Spine + +Implemented: + +- active source baseline switched to `postgres/postgres` + `PG17 legacy lane` at `01792c31a62b7045eb22e93d7dad022bb64b1184`, matching + the audited `@electric/wasm` 0.4.5 source/artifact pair; +- `oliphaunt-build` `portable` is pinned as build-script provenance; +- maintained WASIX build files live under `src/runtimes/liboliphaunt/wasix/assets/build`; +- `xtask assets build --execute` can produce the main runtime, support modules, + requested contrib/PGXS extension side modules, SQL-only extension payloads, + and `pg_dump` for the local target; +- `xtask assets package` emits deterministic archives, generated manifests, and + crate assets; +- `xtask assets aot` regenerates local Wasmer LLVM AOT artifacts; +- `xtask assets check --strict-generated` validates generated metadata; +- `xtask assets source-spine --check-patch-applies` validates the maintained + source patch and C ABI harness; +- `xtask assets audit-upstream --strict` records upstream fix decisions; +- required upstream fixes from `PG17 legacy lane` are now the active source + spine rather than comparison material. The WASIX patch keeps dynamic-main and + side-module support, C startup timers, and explicit exports for the stable + branch lifecycle while reusing upstream `oliphaunt_wasix_start`, + `pgl_setOliphauntActive`, `ProcessStartupPacket`, `PostgresMainLoopOnce`, and + `PostgresMainLongJmp`; +- source-spine review conclusion: upstream Oliphaunt's libc/host adaptations are + purposeful for wasm hosts, not arbitrary shortcuts. `oliphauntc.c` supplies + stable `postgres` identity, explicit process-active state, manual top-level + longjmp recovery, socket callbacks, shared-memory emulation, and explicit + atexit replay because browser/Emscripten cannot provide normal Postgres + child processes, sockets, Unix users, SysV shared memory, or a native process + lifecycle. The WASIX bridge keeps the same architectural contracts where + Wasmer still needs host assistance, but uses Rust-owned input/output buffers + instead of Emscripten callback pointers because the Rust host does not have + Emscripten's JS table callback mechanism; +- WASIX-specific deviation from upstream Oliphaunt: top-level Postgres longjmp + detection uses `jmp_buf` pointer identity instead of upstream's buffer-content + `memcmp`. The memcmp test is acceptable in the Emscripten artifact it was + written for, but under Wasmer/WASIX it misclassified nested PostgreSQL + `PG_TRY` handlers and skipped normal portal cleanup. Pointer identity keeps + the host escape hatch scoped to the single exported top-level recovery buffer; +- WASIX-specific PostgreSQL fix: active portal abort cleanup is owned in + `AtAbort_Portals` for `OLIPHAUNT_WASIX_DL`, not in Rust. This keeps simple-query + and COPY error recovery at the PostgreSQL portal lifecycle boundary and avoids + fabricating cleanup behavior in the wire proxy; +- stable branch behavior note: startup `ParameterStatus` messages may be emitted + on raw protocol paths before `ReadyForQuery`. Tests now allow those legal + PostgreSQL messages instead of assuming the older minimal message sequence; +- new roots are now created through the packaged PGDATA template path. The old + embedded-backend `pgl_initdb` path was removed; explicit fresh-initdb paths + now use the bundled split WASIX `initdb` command and remain outside the + default fast path; +- the old builder-branch `oliphaunt-wasix/*` runtime wrapper is no longer the + production patch target. It remains historical/reference material only; +- `xtask package-size --enforce` passes locally for the root, asset, and macOS + arm64 AOT crates. + +Parity verified against upstream Oliphaunt stable source and TypeScript host: + +- startup/initdb: upstream TypeScript creates a cluster with `initdb.wasm`, + dumps PGDATA, loads that tarball into the main runtime, calls + `_pgl_setOliphauntActive(1)`, runs `callMain([...startParams, -D, PGDATA, + PGDATABASE])`, expects exit `99`, then calls `_oliphaunt_wasix_start()`. + oliphaunt-wasix matches the main-runtime lifecycle from `_pgl_setOliphauntActive` + onward and deliberately consumes a packaged PGDATA template instead of + exposing split runtime `initdb` yet. That is an explicit product gap, not a + hidden fallback; +- startup packet: upstream calls `_pgl_getMyProcPort()`, + `_ProcessStartupPacket(...)`, `_pgl_sendConnData()`, and `_pgl_pq_flush()`. + oliphaunt-wasix uses the same C exports. Server connections now open the + embedded backend against the startup packet database, apply client startup + options on the C side, and apply non-`postgres` users through PostgreSQL + `SET ROLE` semantics, matching Oliphaunt's single-process identity model; +- query loop: upstream feeds the whole frontend message buffer, repeatedly calls + `_PostgresMainLoopOnce()` while frontend bytes or libpq buffered data remain, + catches status `100`, calls `_PostgresMainLongJmp()`, then always calls + `_PostgresSendReadyForQueryIfNecessary()` and `_pgl_pq_flush()`. The Rust host + now follows that control flow with Rust-owned input/output buffers instead of + Emscripten callback pointers; +- close: upstream clears active state, sends protocol terminate, and replays + `_pgl_run_atexit_funcs()`. The Rust host clears active state and replays + atexit on shutdown, while tests cover clean restart, root locking, and stale + runtime-state cleanup; +- host ABI: upstream `oliphauntc.c` emulates sockets, identity, shared memory, + `system`/`popen`, timers, longjmp, and atexit because browser/Emscripten does + not provide normal process or OS services. oliphaunt-wasix keeps the same + categories only where WASIX still needs host assistance, and the ABI harness + tests stable identity, fail-closed `system`, protocol fd bridging, shared + memory, atexit replay, mmap, and libpq encoding aliases; +- justified deviations: WASIX longjmp detection uses pointer identity instead of + upstream's `jmp_buf` content `memcmp`; simple-query/COPY portal abort cleanup + is owned in PostgreSQL `AtAbort_Portals`; startup `ParameterStatus` messages + are accepted as legal protocol output; split WASIX `initdb` is now the owned + template-generation path instead of resurrecting the old builder wrapper. + +## Runtime Behavior + +Implemented: + +- runtime loads verified headless Wasmer AOT artifacts; +- AOT artifacts record source module hash, Wasmer version, and engine identity; +- runtime verifies asset and archive hashes before use; +- unsupported targets return a clear missing-AOT-artifact error instead of + compiling locally; +- `Oliphaunt::preload()` and `Oliphaunt::preload_extensions(...)` exist; +- `Oliphaunt::preload()` now warms the persistent runtime cache, headless Wasmer + engine, main AOT module, shared WASIX runtime, and runtime side modules; +- `Oliphaunt::preload_extensions(...)` warms requested extension artifacts and + side-module cache entries generically; +- direct, persistent, app-id, proxy, server, and temporary roots now share the + `RootPlan`/`prepare_root` root-preparation pipeline; +- direct API, server API, proxy CLI, raw protocol API, and direct `pg_dump` now + share `BackendSession` for WASIX instance creation, backend start, startup + packet handling, protocol transport, shutdown, restart, and atexit replay; +- roots can install immutable runtime files from a persistent runtime cache and + install the embedded PGDATA template without running initdb on the default + startup path; +- mutable PGDATA template files are copied or archive-installed, never + hardlinked; immutable runtime files hardlink from cache when possible; +- persistent roots use lock files to prevent concurrent direct/server opens; +- runtime and extension archive extraction rejects unsafe paths, symlinks, + hardlinks, device nodes, and unsupported archive entry types; +- runtime uses canonical Postgres paths: + `/bin`, `/lib/postgresql`, `/share/postgresql/extension`, and + `/share/postgresql/timezonesets`. + +## Public API Surface + +Implemented: + +- `OliphauntBuilder::extension`; +- `OliphauntBuilder::extensions`; +- `OliphauntBuilder::username`; +- `OliphauntBuilder::database`; +- `OliphauntBuilder::debug_level`; +- `OliphauntBuilder::relaxed_durability`; +- `OliphauntBuilder::startup_arg`; +- `OliphauntBuilder::startup_args`; +- `OliphauntBuilder::load_data_dir_archive`; +- `Oliphaunt::enable_extension`; +- `Oliphaunt::preload`; +- `Oliphaunt::preload_extensions`; +- `Oliphaunt::dump_data_dir`; +- `Oliphaunt::dump_data_dir_with_format`; +- `Oliphaunt::try_clone`; +- physical PGDATA archives now apply Wasmer overlay whiteouts, so files deleted + from the lower template are not resurrected by dump/load/clone; +- physical PGDATA archives are written from a materialized effective PGDATA view + instead of directly mixing lower-template and upper-overlay entries in the tar + writer; +- physical PGDATA archive/clone now checkpoints, quiesces the backend, + materializes the archive, and restarts the same backend session; docs state + this is a same-runtime/same-version physical import/export path, not a + cross-version backup protocol; +- `Oliphaunt::exec_protocol_raw`; +- `Oliphaunt::exec_protocol_raw_stream`; +- `Oliphaunt::dump_sql`; +- `Oliphaunt::dump_bytes`; +- `OliphauntServerBuilder::extension`; +- `OliphauntServerBuilder::extensions`; +- `OliphauntServerBuilder::username`; +- `OliphauntServerBuilder::database`; +- `OliphauntServerBuilder::debug_level`; +- `OliphauntServerBuilder::relaxed_durability`; +- `OliphauntServerBuilder::startup_arg`; +- `OliphauntServerBuilder::startup_args`; +- `OliphauntServer::database_url`; +- `OliphauntServer::dump_sql`; +- `OliphauntServer::dump_bytes`; +- `PgDumpOptions`; +- 37 public extension constants plus `extensions::ALL`, covering the smoke-gated + packaged Oliphaunt/Postgres catalog: `amcheck`, `auto_explain`, `bloom`, + `age`, `btree_gin`, `btree_gist`, `citext`, `cube`, `dict_int`, `dict_xsyn`, + `earthdistance`, `file_fdw`, `fuzzystrmatch`, `hstore`, `intarray`, `isn`, + `lo`, `ltree`, `pageinspect`, `pg_buffercache`, `pg_freespacemap`, + `pg_hashids`, `pg_ivm`, `pg_surgery`, `pg_textsearch`, `pg_trgm`, + `pg_uuidv7`, `pg_visibility`, `pg_walinspect`, SQL-only `pgtap`, `seg`, + `tablefunc`, `tcn`, `tsm_system_rows`, `tsm_system_time`, `unaccent`, and + `vector`. + +`oliphaunt-wasix-dump` no longer exposes the old archive-unpack behavior. It is now a +real logical dump CLI backed by the packaged WASIX `pg_dump` module. + +`relaxed_durability` is a startup-profile flag rather than a hidden mutation of +`PostgresConfig`; explicit user `postgres_config` values win and +`relaxed_durability(true).relaxed_durability(false)` returns to the normal +profile. + +## Protocol And Server Correctness + +Implemented coverage: + +- direct Rust API open/init/query; +- persistence, close/reopen, stale runtime-state cleanup, interrupted PGDATA + cleanup, and root-lock conflicts; +- SQLx and `tokio-postgres` local-server connections; +- SSLRequest no-SSL response; +- CancelRequest safe close; +- backend-open failures no longer map every non-`template1` startup failure to + SQLSTATE `3D000`. PostgreSQL/C now owns startup identity and database errors: + the WASIX backend captures `InitPostgres` startup `ErrorResponse` bytes, the + proxy forwards them directly, and runtime/filesystem failures before + PostgreSQL can speak protocol remain synthesized `XX000`; +- Parse, Bind, and Execute error recovery; +- SQLSTATE preservation for syntax, missing relation, invalid typed parameter, + wrong parameter count, and extension-originated errors; +- extended-query `ReadyForQuery` synchronization; +- successful pipelined extended queries; +- mixed success/error/success pipelined queries; +- explicit prepared-statement reuse; +- transaction error recovery through rollback; +- client disconnect during an extended-query exchange; +- partial TCP reads and pipelined simple queries; +- server-mode `COPY FROM STDIN` now streams through the backend-owned protocol + pump instead of Rust SQL-text detection or proxy-fabricated COPY state. Normal + SQLx/tokio-postgres traffic uses the buffered raw-protocol path; when + PostgreSQL emits a real `CopyInResponse`, `CopyOutResponse`, or + `CopyBothResponse`, the WASIX bridge flushes buffered backend output to the + attached socket continuation and lets PostgreSQL continue on the socket. Raw + wire coverage includes simple COPY, extended-protocol COPY, CSV `WITH (...)` + COPY, binary COPY, `CopyData`, `CopyDone`, `CopyFail`, Unix-socket COPY + parity, and post-COPY connection reuse. +- continuation bytes are borrowed in the proxy read loop and materialized only + after the C bridge reports active streaming COPY; +- direct raw protocol streaming is routed through the shared `BackendSession` + framed sender instead of a separate client-only transport path; +- Rust-owned guest bridge allocations are scoped through `pg_free`/`free`, and + debug builds now have a direct raw-protocol stress test proving repeated + bridge round trips keep allocation/free counters balanced; +- direct LISTEN/UNLISTEN quotes channel identifiers and dispatches notifications + by the exact backend channel name, including case-sensitive and quoted names. +- a larger PostgreSQL regression subset now ports the relevant Oliphaunt test + surface for datatypes, DDL, transactions/savepoints, planner/index behavior, + and direct `/dev/blob` CSV COPY. The datatype coverage also found and fixed a + direct-client multidimensional array parser bug, with unit coverage for + nested arrays, quoted values, and unquoted NULL handling. + +## Independent P0 Architecture Review + +The P0 review was re-run against the current Rust host, WASIX bridge, source +patch, and regression tests. No current P0 architecture blockers remain in the +reviewed surface. The completed P0 items were moved out of the backlog; future +major protocol, backup, runtime, or source-spine changes should get a new +review entry here instead of leaving completed checklists in `TODO.md`. + +Verified ownership boundaries: + +- Rust owns hosting, root preparation, caches, process lifecycle, direct/server + API shape, and typed fallbacks for host/runtime failures before PostgreSQL can + speak wire protocol; +- PostgreSQL/C owns SQLSTATEs, startup identity/database errors, query protocol + state, COPY state, portal cleanup, and longjmp recovery boundaries; +- the WASIX bridge owns only the host ABI that Wasmer/WASIX cannot provide as a + normal OS process boundary: protocol fd transport, locale/identity shims, + single-process shared memory, fail-closed process calls, and explicit + allocation/free ownership. + +Review conclusions: + +- guest-memory ownership is scoped through `GuestAllocator`, `pg_free`/`free`, + and debug allocation/free counters; +- detached protocol stdio fails closed rather than silently accepting bytes; +- COPY state is reported by PostgreSQL through + `pgl_protocol_report_copy_response`; the proxy no longer parses SQL text, + fabricates COPY state, scans whole backend buffers, or eagerly copies + continuation bytes for ordinary traffic; +- direct raw protocol streaming and direct `pg_dump` use the shared + `BackendSession` transport instead of a separate clone/server path; +- startup role/database failures are PostgreSQL-owned: WASIX backend open + captures `InitPostgres` `ErrorResponse` bytes, the proxy forwards those bytes, + and Rust no longer probes `pg_database` or string-guesses `3D000`; +- direct API, server API, proxy CLI, raw protocol, physical archive/clone, and + direct `pg_dump` share `RootPlan`/`prepare_root` and `BackendSession` + lifecycle paths; +- side-module cache seeding is keyed by artifact name, source module hash, + Wasmer version, Wasmer-WASIX version, and engine identity; +- AOT startup keeps full SHA verification behind + `OLIPHAUNT_WASM_AOT_VERIFY=full` while default loading uses metadata receipts + and mmap/native deserialization; +- PGDATA physical archive/clone materializes the effective overlay view with + whiteouts, quiesces/restarts the backend, and is documented as + same-runtime/same-version physical transfer rather than a WAL-aware backup; +- public API parity additions were reviewed at that point: raw protocol + streaming is real, physical clone/export has honest semantics, startup args + remain advanced, and listener channel names are identifier quoted. + +Residual work from this review is intentionally not P0 architecture debt: +target-matrix CI, broader extension generation, additional PostgreSQL +regression subsets, release performance gates, and future split-WASIX `initdb` +support remain tracked in `TODO.md`. + +## Extensions And `pg_dump` + +Implemented coverage: + +- `vector` direct API load, `CREATE EXTENSION`, insert, distance query, and + pgvector type cases; +- `vector` through `OliphauntServer` and SQLx; +- SQLx recovery after vector-originated errors; +- demand-driven extension install and idempotent `enable_extension`; +- installed extension side modules are seeded into the headless Wasmer cache on + reopen; +- `pg_trgm` direct API and SQLx server smoke coverage; +- `hstore` direct API, persistence/reopen, and SQLx server smoke coverage; +- Oliphaunt extension tests were ported into a generic promotion gate for direct + API, server API, restart, and lifecycle materialization. The gate now covers + every packaged candidate. AGE now uses its upstream 32-bit `SIZEOF_DATUM=4` + SQL generation path, passes direct/server/restart/lifecycle gates, and is + exposed as `extensions::AGE`; +- extension discovery now merges Oliphaunt docs/REPL exports, Oliphaunt package + exports, PostgreSQL contrib metadata, `postgres-oliphaunt` `other_extensions` + pins, Oliphaunt tests, and the packaged asset manifest into + `src/extensions/generated/extensions.catalog.json`; +- `xtask assets fetch` now clones/fetches every pinned source from + `src/sources/third-party/**` into ignored `target/oliphaunt-sources/checkouts/**` directories, + including the external extension sources for pgtap, pg_ivm, pg_uuidv7, + pg_hashids, AGE, PostGIS, and pg_textsearch; +- extension build intent now lives in `src/extensions/catalog/extensions.promoted.toml` instead + of being inferred from already-packaged artifacts. The generated catalog + separates requested, packaged, stable, and publicly promoted state; +- extension smoke evidence now lives in `src/extensions/catalog/extensions.smoke.toml`; + generated public constants require requested + packaged + stable + direct, + server, and restart smoke status recorded as passed; +- `xtask extensions build-plan --write` generates + `src/extensions/generated/extensions.build-plan.json`, + `src/extensions/generated/contrib-build.tsv`, and + `src/extensions/generated/pgxs-build.tsv`; `xtask assets check --strict-generated` + fails if those generated files drift; +- the WASIX extension build spine now uses generic contrib and PGXS build + scripts driven by the generated build plans, replacing the previous + `pg_trgm`-only and `pgvector`-only Docker scripts; +- the generated catalog now requires every discovered SQL extension to be + either requested for build or explicitly blocked with a concrete reason. The + current catalog discovers 40 SQL extensions, requests/packages 37, and blocks + only `pgcrypto`, PostGIS, and `uuid-ossp` on missing pinned native dependency + stacks; +- native side-module names are generated from control-file `module_pathname` + and PGXS Makefile metadata instead of assuming `.so`. This covers + cases such as `intarray` using `_int.so` and SQL-only extensions such as + `pgtap`; +- both generated build plans now support native and SQL-only extensions. The + local WASIX build produced all requested contrib and PGXS extension payloads, + generated local macOS arm64 AOT artifacts for all requested native modules, + and packaged all requested extension archives into `oliphaunt-wasix-assets`; +- contrib packaging now carries extension-owned tsearch rule files into + `share/postgresql/tsearch_data`, matching Oliphaunt behavior for `dict_xsyn` and + `unaccent`; +- generated extension constants are emitted only for extensions that are + requested, packaged, stable, and direct/server/restart smoke-passed; generated + asset includes carry all packaged candidates so private promotion tests can + exercise candidates before they become public API; +- manifest metadata records extension source kind, control files, + dependencies, lifecycle, imports, required core exports, unresolved imports, + installed files, load order, and smoke status; +- the `wasix-dl` export list is generated from the runtime exports plus + runtime-support/extension side-module imports, rather than being a + hand-maintained export allowlist; +- extension archive hash mismatch rejection; +- public WASIX `pg_dump` runner loads through the AOT manifest, connects to + `OliphauntServer`, dumps plain SQL, restores into fresh `Oliphaunt`, and verifies + schema/data; +- direct `Oliphaunt::dump_sql` no longer uses a temporary physical clone, public + `OliphauntServer`, or OS loopback TCP; it runs the standalone WASIX `pg_dump` + against an in-process Wasmer virtual TCP connection whose host side is routed + through the same direct raw-protocol backend; +- direct `Oliphaunt::dump_sql` rejects database/user options that would imply a + different backend than the already-open direct session; callers needing that + use the server `pg_dump` path; +- the direct `pg_dump` transport keeps `pg_dump`/libpq stock and owns the only + required semantic adapter in Rust: a first-write-readiness normalization for + Wasmer's in-memory `TcpSocketHalf` so libpq's connect-time and first-write + polls remain level-triggered; +- public `pg_dump` coverage includes indexes, views, sequences, + `--schema-only`, `--quote-all-identifiers`, source-server reuse after dump, + and vector extension dump/restore; +- `PgDumpOptions` rejects passthrough flags that conflict with the typed + output/connection contract instead of letting callers override the internal + output file, format, host, port, username, database, or job count. + +## WASIX C Boundary Ownership + +The remaining C-side differences are owned as WASIX portability and host ABI, +not hidden generic stubs: + +- `pg_proto.c` manually coordinates `ReadyForQuery` for the current + call/return protocol loop and is covered by SQLx, `tokio-postgres`, and raw + wire-protocol tests; +- `pg_main.c` drives initdb boot/single-user phases inside one embedded process, + with named helpers for boot, stdin restoration, and single-user replay; +- `pgl_os.h` emulates only the expected initdb boot/single `popen()` commands + under `OLIPHAUNT_WASIX_DL` and fails closed otherwise; +- `pgl_stubs.h` is gated to `OLIPHAUNT_WASIX_DL`, and future removals are driven by + link-symbol analysis; +- `oliphaunt_wasix_bridge.c` owns locale command emulation, stable `postgres` + uid/passwd identity, protocol socket buffers, fail-closed `system()`, selected + fd/socket delegation to WASIX libc, and single-process SysV shared memory. + +The source-spine guard checks for removed spike smells: debug-only `#pragma` +markers, diagnostic `popen`, broad socket fake-success behavior, layout +mirroring, timezone rewrites, and generic stub logging. + +## Validation Already Run + +The following local gates passed before this consolidation: + +```sh +cargo fmt --check +cargo check -p oliphaunt-wasix --all-targets +cargo check -p oliphaunt-wasix --no-default-features --all-targets +cargo run -p xtask -- assets check --strict-generated +cargo run -p xtask -- assets source-spine --check-patch-applies +cargo run -p xtask -- assets audit-upstream --strict +cargo run -p xtask -- package-size --enforce +cargo test --test client_compat +cargo test --test runtime_smoke +cargo test --test extensions_smoke +``` + +The public `pg_dump` round-trip tests and asset/AOT hash-mismatch tests also +passed locally. + +## Cold-Start Performance Work + +Implemented: + +- internal phase timing via `capture_phase_timings`; +- `cargo run -p oliphaunt-perf -- cold` emits structured JSON with explicit + `cacheStateBefore`, `processStateBefore`, `rootState`, `queryState`, and + `workload` fields, so first-install bootstrap, process warmup, new-root first + query, and client/server first query are no longer conflated; +- `cargo run -p oliphaunt-perf -- cold --reset-cache` removes the oliphaunt-wasix cache + before measuring, making runtime extraction, AOT materialization, PGDATA + template install, and extension-template creation visible in the first + operation that pays each cost; +- process-wide headless Wasmer engine cache; +- process-wide AOT `Module` cache keyed by artifact hash; +- AOT manifests now include raw artifact SHA256/size metadata; the default + startup path uses an atomic cache receipt and file metadata instead of scanning + the raw AOT file; +- bundled runtime, extension, PGDATA-template, and AOT content hashes are kept + off the default startup path and are only scanned with + `OLIPHAUNT_WASM_AOT_VERIFY=full`; +- process-wide shared Tokio runtime, WASIX runtime, and `SharedCache`; +- side-module seeding is reused by artifact name, module hash, Wasmer version, + Wasmer-WASIX version, and engine identity; +- phase timing now propagates into the server listener thread, so + `OliphauntServer` cold runs report root preparation, listener bind/spawn, + proxy backend open, client connect, first query, and shutdown phases instead + of a single opaque total; +- server accept loops now use blocking `accept()` plus an explicit wake + connection during shutdown, removing the previous nonblocking accept plus + 10ms sleep polling jitter; +- fresh proxy backend initialization no longer runs the post-client + `ROLLBACK`/`DISCARD ALL` cleanup path. Fresh startup applies default GUCs + directly; full reset remains in place after client disconnects; +- persistent runtime asset cache under the platform cache directory; +- runtime-cache repair removes mutable scratch state and restores required + support files before the cache is used as a shared overlay source; +- per-root runtime scratch directories are reset during root preparation; +- `password` is copied as per-root mutable support data instead of hardlinked + from the shared runtime cache; +- PGDATA template manifests are parsed without archive hashing on the default + path; +- the parsed generated asset manifest is cached process-wide, avoiding repeated + 1.4 MB JSON parses during AOT, extension, and PGDATA template checks; +- an eager PGDATA template overlay is implemented as the mainline template + path: the cached initialized template is mounted as lower `/base`, the + per-instance upper starts almost empty, and individual template files are + copied into the upper only before mutating opens; +- the eager PGDATA overlay is passed as a runner-level WASIX mount. Nested + mounts placed inside the supplied `WasiFsRoot` were not sufficient because + `WasiRunner::prepare_webc_env` rebuilds the final mount tree from the root + `/` filesystem plus runner-owned mounts; +- direct `Oliphaunt::open` no longer performs a separate session-setup round trip + and no longer folds session defaults into array discovery SQL. The Rust WASIX + host now calls the real C `ProcessStartupPacket` export from + `backend_startup.c`; C `pgl_sendConnData()` applies the direct-session + defaults before connection data is sent, so `BeginReportingGUCOptions` + observes `TimeZone=UTC` and `search_path=public`; +- `OliphauntBuilder::postgres_config`, `OliphauntServerBuilder::postgres_config`, + and `oliphaunt-wasix-proxy --postgres-config name=value` now pass user startup GUCs + through PostgreSQL's normal `-c name=value` argv handling. User settings are + appended after the default profile, so they override defaults without + special-casing individual GUCs such as `synchronous_commit`; +- server-mode client startup `options=-c ...` is now applied on the C side after + `ProcessStartupPacket` parses the packet and before `pgl_sendConnData()` + emits `AuthenticationOk` and `ParameterStatus`, preserving PostgreSQL's + startup-option timing for supported single-backend clients; +- extension-enabled PGDATA template caches include the startup-GUC entries in + their manifest and cache key, so a template created under one backend config + is not reused for another config; +- direct scalar open/query paths no longer scan `pg_type` for array metadata. + Built-in PostgreSQL array OIDs are registered statically in the Rust direct + client, and runtime-created enum/domain/composite arrays are discovered + lazily from parameter/result OIDs or through explicit + `refresh_array_types()` calls; +- the old `pgl_stubs.h` `ProcessStartupPacket` placeholder has been removed + from the maintained WASIX patch. Startup packet parsing now lives in + PostgreSQL's `backend_startup.c`, and the host no longer calls a separate + Rust-side default-GUC helper; +- focused tests cover process AOT cache reuse, extension preload reuse, + cross-instance state isolation, mutable PGDATA clone safety, eager PGDATA + lower-file visibility, direct runtime smoke, vector direct/server smoke, and + proxy smoke. + +Previous local debug `oliphaunt-perf cold` run after explicit preload: + +- explicit preload: about 605ms; +- temporary first query: about 553ms; +- warm temporary first query: about 547ms; +- representative extension-backed first query after extension preload: about + 646ms; +- server plus first `tokio-postgres` query: about 543ms. + +In that run bundled archive/module SHA scans were absent from the default path. +The remaining visible costs were main Wasmer deserialization at about 447ms and +temporary filesystem setup at about 321ms, mostly runtime clone plus PGDATA +template clone. + +Latest local debug `cargo run -p oliphaunt-perf -- cold` run after the shared +root-preparation work: + +- explicit preload: about 640ms; +- temporary first query: about 404ms; +- warm temporary first query: about 386ms; +- representative extension-backed first query after extension preload: about + 504ms; +- server plus first `tokio-postgres` query: about 371ms. + +That run removed the full immutable runtime clone from temporary opens. The +same prepared runtime-layout machinery now feeds direct, persistent, app-id, +proxy, and server roots as well. Per-root runtime setup was about 30ms and +`wasix.mountfs_overlay_construct` was under 1ms at that point. The dominant +remaining setup cost was PGDATA template clone/install at about 187-190ms, +followed by +backend start around 44-48ms and Wasmer instance creation around 30-36ms. + +Latest local debug `cargo run -p oliphaunt-perf -- cold` run after the eager PGDATA +overlay and parsed-manifest cache: + +- explicit preload: about 601ms; +- temporary first query: about 191ms; +- warm temporary first query: about 144ms; +- representative extension-backed first query after extension preload: about + 257ms; +- server plus first `tokio-postgres` query: about 123ms. + +In that run `pgdata.overlay_prepare` was about 0.4-0.5ms, down from the +previous 187-190ms template clone/install cost. The visible per-open costs are now +Wasmer instance creation around 30-37ms and PostgreSQL backend start around +49-52ms. Main-module AOT deserialization remains the dominant explicit preload +cost at about 506ms on this local debug profile. + +Historical local debug run after removing the separate direct session-setup +round trip, before lazy/generated array metadata: + +- explicit preload: about 535ms; +- temporary first query: about 230ms; +- warm temporary first query: about 133ms; +- representative extension-backed first query after extension preload: about + 254ms; +- server plus first `tokio-postgres` query: about 118ms. + +The warm direct `oliphaunt.open` phase dropped to about 112ms. At that point the +remaining direct-open client-side cost was the array catalog scan, about 30ms +for the warm catalog query and less than 1ms for Rust-side parser/serializer +registration. Scalar paths no longer pay that scan after lazy/generated array +metadata. + +Latest local release work: + +- asset release builds now default to `release-o3`, which compiles WASIX C + modules with `-O3 -g0 -flto=thin` and links with `-flto=thin`; +- release profiles run wasixcc's default Binaryen optimization plus + `--converge`, `--strip-debug`, and `--strip-producers`; +- the current exact Oliphaunt speed-suite run favors `release-o3 + converge/strip` + plus ThinLTO for SQL workload parity. The package-size gate still passes + locally with the macOS arm64 AOT crate at about 7.2MiB compressed and the + asset crate at about 5.6MiB compressed. Earlier startup-only runs favored + `release-os` over `release-oz`, and adding a project `-msimd128` flag was + redundant because the WASIX EH+PIC sysroot already invokes clang with SIMD, + relaxed SIMD, and extended const enabled; +- Wasmer LLVM AOT codegen experiments selected the mainline serializer profile: + nonvolatile memory operations plus a readonly funcref table. Nonvolatile + memory operations improved the exact Oliphaunt server SQLx speed suite by about + 9% geomean and won all 18 cases, but Wasmer marks that optimization as not + fully WebAssembly-spec compliant. Adding readonly funcref on top was about + 1.4% faster geomean than nonvolatile-only and improved indexed updates, but + regressed CREATE INDEX and DROP TABLE cases. The risk is now explicit release + profile surface and must be covered by the correctness matrix. The macOS + arm64 packaged AOT artifacts were regenerated with this profile; +- exact Oliphaunt speed-suite comparison now has its own harness and diagnostic + path. The latest ThinLTO `release-o3` direct run on macOS arm64 measured test + 9 at about 569ms, test 10 at about 724ms, test 11 at about 98ms, and test 14 + at about 77ms. Against the locally audited npm NodeFS reference, the direct + suite is about 1.22x faster geomean, with 16/18 wins but not a 10x-class + result under identical SQL/Postgres semantics; +- selected speed-case diagnostics show that host filesystem work is not the + remaining dominant cost on the heavy SQL cases. Test 10, for example, was + about 748ms total with about 21ms in traced filesystem work and about 743ms + inside PostgreSQL/AOT dispatch. This points the next investigation at + symbolized AOT/Postgres executor profiling, not more Rust result parsing or + root-layout tuning; +- prepared indexed-update benchmarking now compares SQLx sequential prepared + updates, tokio-postgres sequential prepared updates over TCP and Unix + sockets, tokio-postgres pipelined prepared updates over TCP and Unix sockets, + and native Postgres equivalents using the exact Oliphaunt Test 9/10 values. + Deferring extended-protocol `Sync` flush only within bytes already read from + one socket read reduced OliphauntServer TCP pipelined prepared updates from about + `612.835ms -> 399.921ms` for numeric indexed updates and + `640.691ms -> 416.837ms` for text indexed updates. Unix-socket OliphauntServer + was faster again at about 374/397ms, so transport still matters for + sequential prepared execution and modestly for pipelined execution. The exact + simple-query server speed suite stayed in the same range after the change: + Test 9 about 583ms and Test 10 about 740ms locally. A larger 256KiB proxy + read buffer was tested and rejected because it regressed the same pipelined + prepared workload to about 545/562ms; +- the native Postgres benchmark helper now attempts graceful termination before + falling back to `Child::kill()`, because SIGKILL can leak SysV shared-memory + IDs on macOS. `perf prepared-updates --skip-native` exists for Oliphaunt-only + runs when local native Postgres IPC state is unhealthy; +- `perf prepared-updates --gate` now emits protocol counters and fails if + ordinary prepared traffic activates the backend-owned streaming continuation + or if pipelined prepared traffic stops batching. The timing thresholds are + intentionally a local regression smoke gate until stable CI runner baselines + exist; +- phase timing guards are hot-path no-ops when no recorder is active, so + diagnostic spans do not call `Instant::now()` in normal runtime traffic; +- PostgreSQL spinlocks are enabled in the WASIX build. The earlier + `--disable-spinlocks` fallback is gone, and the source-spine guard rejects it + if it returns. This is a correctness/architecture baseline because wasixcc + exposes the required atomic operations; local single-backend speed numbers are + mixed enough that it should not be treated as a standalone benchmark win; +- the shared runtime overlay and eager PGDATA overlay are now mainline runtime + behavior, with the old full-local runtime and full-template clone paths kept + only as internal build/staging machinery where still required; +- local release `cargo run -p oliphaunt-perf -- cold` with no env overrides showed + warmed preload around 18ms, temporary first query around 100ms, warm temporary + first query around 83ms, representative extension-backed first query around + 148ms after extension preload, and server first query around 77ms; +- that run predated lazy/generated array metadata and showed direct open + dominated by backend startup around 33-40ms plus the old array catalog scan + around 24-33ms. Scalar paths no longer pay that catalog scan; new release + numbers should replace this historical baseline; +- after adding deeper preload instrumentation, local release runs showed + explicit preload between about 15ms and 56ms depending on OS cache warmth. + The first uncached visible run spent about 37ms in main AOT mmap + deserialization and about 10ms in runtime cache setup; repeated warmed runs + spent about 10ms in main AOT deserialization for both mmap and file modes; +- Wasmer AOT loading now uses the native mmapped-file deserializer as the only + production path; the old file deserializer runtime switch was removed; +- after promoting the mainline AOT and filesystem paths, local release + `cargo run --release -p oliphaunt-perf -- cold` showed primary visible latencies + around 36ms for preload, 55ms for a new temporary direct first query, 45ms + for a second new temporary direct first query, 47ms for server SQLx first + query, and 57ms for server SQLx vector first query; +- the same mainline artifact profile measured exact Oliphaunt server speed-suite + Test 9 at about 587ms, Test 10 at about 730ms, Test 11 at about 91ms, Test 14 + at about 71ms, and 18-test geomean around 76ms locally. Prepared-update + server probes measured TCP pipelined prepared updates around 395/414ms and + Unix pipelined prepared updates around 366/392ms for the numeric/text indexed + workloads; +- after static built-in arrays and lazy runtime array discovery, local release + `cargo run --release -p oliphaunt-perf -- cold` showed explicit preload about + 52ms, temporary first query about 88ms, warm temporary first query about 79ms, + representative extension-backed first query about 131ms after extension + preload, and server first query about 75ms. Scalar direct paths did not emit + the `oliphaunt.array_type_catalog_query` phase; +- after server-thread timing and accept-loop cleanup, local release + `cargo run --release -p oliphaunt-perf -- cold` showed explicit preload about + 19-22ms, temporary first query about 86-89ms, warm temporary first query about + 77ms, representative extension-backed first query about 132-140ms after + extension preload, tokio-postgres server first query about 68ms, and SQLx + server first query about 68-70ms. The server path now shows `server.start` + around 52-54ms, + `proxy.backend_open` around 44-46ms, `postgres.backend_start` around 35-37ms, + tokio-postgres connect around 0.6ms/query around 5.5ms, and SQLx connect + around 2.1ms/query around 6.0ms; +- `oliphaunt-perf cold` includes the extension-enabled SQLx server path, now named + `process_warm_new_temp_server_sqlx_vector_first_query`, which starts + `OliphauntServer` with a requested bundled extension and measures a first + extension-backed SQLx query for a new temporary server root. This keeps + server-mode extension install/load, `CREATE EXTENSION`, client connect, and + first extension query visible as one product-shaped path. The first local + release run measured about 175ms total, dominated by `proxy.extension_enable` + around 107ms; SQLx connect and the + first vector query were both sub-millisecond on that run; +- cold perf reporting now breaks out preload runtime cache setup, AOT install, + mmap/file deserialization, WASIX runtime construction, instance creation, + startup-packet/default-GUC work, client protocol round trips, extension side + module seeding, and public `pg_dump` runner phases; +- instrumented WASIX runtime artifacts can export C-side backend startup timers + via `pgl_backend_timing_elapsed_us`, and the Rust host records them as + `postgres.backend.c.*` phases when the export is present. Production WASIX + artifacts keep `OLIPHAUNT_WASM_WASIX_BACKEND_TIMING=0`, so the C timing macros + compile away and the export is absent. Local release instrumented runs show + backend startup split mainly between `postgres.backend.c.shared_memory` around + 11-12ms and `postgres.backend.c.init_postgres` around 19-21ms, inside + `postgres.backend.c.async_single_user_main` around 33-36ms; +- C-side timers now reach inside `InitPostgres`: `StartupXLOG`, + relcache/catcache initialization, transaction snapshot, session-user setup, + database lookup/recheck/path validation, `CheckMyDatabase`, startup option + processing, session initialization, and session preload libraries are reported + as individual `postgres.backend.c.*` phases; +- the C timing ABI has additional instrumented-only IDs for + `InitializeMaxBackends`, `CreateSharedMemoryAndSemaphores`, `InitProcess`, + `RelationCacheInitializePhase3`, and `initialize_acl`, so the two remaining + startup hotspots can be subdivided without adding production clock reads; +- a generic extension-set PGDATA template cache now builds templates through + normal `CREATE EXTENSION`, runs `CHECKPOINT`, then closes the embedded backend + through the runtime `pgl_shutdown` export before caching the template. The + cache is keyed by the base runtime/template manifest plus sorted extension + archive identities and is mounted as the lower PGDATA template for direct and + server temporary roots; +- direct and server extension paths skip redundant `CREATE EXTENSION` when the + requested extension set is already present in the cached template, while still + installing/preloading side-module assets into each instance root; +- extension-template cache keys were bumped to version 2 after adding clean + backend shutdown, so older templates that left `pg_control` in a + recovery-heavy state are ignored; +- current local release timings with the clean generic extension template cache + show extension-template lookup/overlay under 1ms, extension archive install + around 5ms, and extension-enabled `StartupXLOG` around 3-4ms instead of the + previous roughly 350ms recovery path. In the steady cached run, the direct + vector first-query path for a new temporary root was about 82-93ms and the + SQLx vector first-query path for a new temporary server root was about + 74-78ms; +- pure MountFS runtime composition now keeps core runtime assets in the shared + cached lower runtime and materializes only mutable state plus requested + extension assets in the per-root upper layer. Runtime and extension smoke + tests assert that core binaries/catalog files are not copied into the upper + root and unrelated extensions are not installed. Local release comparison + showed per-root runtime setup dropping from roughly 7ms to about 0.6-0.9ms, + the SQLx first-query path for a new temporary server root around 55ms, and + the SQLx vector first-query path for a new temporary server root around 66ms + after cache cleanup; +- cold perf operations now report `primaryLatencyPhase` and + `primaryLatencyMicros` so user-visible latency is separated from teardown. + The deeper local release run showed direct first-query totals were previously + inflated by a Rust-side host directory sync during query finish; +- direct `Oliphaunt` no longer calls host directory `sync_all` after every + non-transaction query. PostgreSQL's WAL/fsync path owns durability, and the + server path already avoided this extra host sync. In the local release run, + direct visible latency dropped from about 68ms to about 53ms for the first + new temporary root and to about 45ms for the second new temporary root; +- direct and server protocol timing now splits startup packet handling, + protocol input/output, guest `PostgresMainLoopOnce`, direct parse/describe, + direct execute, and direct result finish. The remaining first-query protocol + cost is mostly PostgreSQL main-loop work for the parse/describe or prepared + extended-query batch, not Rust parsing or buffer copies; +- `cargo run -p oliphaunt-perf -- warm` now measures true warm behavior separately + from first-open work: repeated direct scalar queries, direct transaction + batches, direct extension-backed queries, SQLx repeated queries over one + connection, SQLx repeated connect-query-close cycles, SQLx extension-backed + repeated queries, and tokio-postgres repeated queries. It reports total and + per-iteration average phases while keeping open/shutdown phases as context; +- `cargo run --release -p oliphaunt-perf -- bench` now provides a product-style + benchmark harness similar to Oliphaunt's published benchmark families. It runs + trimmed-average CRUD round-trip benchmarks and a generated SQLite + speedtest-style suite through both the direct Rust API and `OliphauntServer` + with a long-lived SQLx connection. The speed suite is generated locally + instead of vendoring Oliphaunt's multi-megabyte generated SQL files, and supports + `--suite`, `--mode`, `--iterations`, and `--scale` for local and CI runs; +- May 1, 2026 local release parity/timing run after pinning + `PG17 legacy lane@01792c31` recorded raw JSON under `target/perf/`: + `cold-release-latest.json`, `warm-release-latest.json`, and + `bench-release-latest.json`; +- that cold release run used existing caches and production artifacts, so C-side + backend timers were absent by design. Primary visible latencies were: + preload 28.8ms, first direct temporary query 41.1ms, second direct temporary + query 30.0ms, vector preload 8.4ms, first direct vector query 36.8ms, + first tokio-postgres server query 31.4ms, first SQLx server query 31.9ms, + first SQLx server vector query 36.9ms, and first SQLx vector query on an + existing persistent root 25.8ms; +- dominant cold phases in that run were production runtime/AOT preload + (`aot.deserialize.mmap` 16.3ms), Wasmer instance creation for new roots + (about 5.3-10.2ms), backend start for template roots (about 18-24ms), and + first protocol dispatch/query work (about 4.4-6.1ms). Per-root runtime setup + stayed below the 1ms reporting threshold for scalar temporary roots; +- warm release run with 100 query iterations and 20 connect iterations showed: + direct scalar repeated query average 0.024ms, direct transaction batch average + 0.022ms, direct vector repeated query average 0.025ms, SQLx single-connection + query average 0.054ms, SQLx vector single-connection query average 0.058ms, + tokio-postgres single-connection query average 0.175ms, and SQLx + connect-query-close average 18.565ms; +- product-style benchmark run with `--suite all --mode all --iterations 100 + --scale 1` showed RTT trimmed averages from about 0.031-0.101ms for direct + CRUD cases and about 0.055-0.130ms for SQLx server CRUD cases. The generated + speed suite remained dominated by indexed updates: direct 25k indexed update + 4.390s, direct 25k text indexed update 8.024s, SQLx server 25k indexed update + 4.350s, and SQLx server 25k text indexed update 8.057s; +- follow-up parity work found that the WASIX host was starting single-user + Postgres with `shared_buffers=400kB`, while `@electric/wasm@0.4.5` + reports `shared_buffers=128MB`. The fix moved the intended buffer GUCs into + the Rust startup arguments (`shared_buffers=128MB`, `wal_buffers=4MB`, + `min_wal_size=80MB`). The exact Oliphaunt speed-source rerun now records local + all-suite direct timings around 570ms for Test 9, 732ms for Test 10, 106ms for + Test 11, and 86ms for Test 14; SQLx server timings were about 593ms, 726ms, + 102ms, and 83ms for the same tests. + `perf diagnose-buffer-cache` verifies zero Postgres shared read blocks for the + table-copy hotspots after setup, matching Oliphaunt's effective buffer behavior; +- `xtask assets check` now guards production WASIX inputs for mandatory + WebAssembly exception and dynamic-linking flags and rejects Asyncify markers + in production configure scripts; +- production profile scripts reject Asyncify flag injection by default; the + explicit `OLIPHAUNT_WASM_ALLOW_ASYNCIFY_EXPERIMENT=1` override is reserved for + local snapshot/journaling experiments; +- final package sizes stayed under crates.io's 10 MB compressed limit: + `oliphaunt-wasix` about 7.15 MB, `oliphaunt-wasix-assets` about 4.87 MB, and + `oliphaunt-wasix-aot-aarch64-apple-darwin` about 5.62 MB; +- `cargo test --release --workspace --all-targets`, + `cargo check --workspace --no-default-features --all-targets`, + `cargo run -p xtask -- assets check --strict-generated`, and + `cargo run -p xtask -- package-size --limit 10000000` passed against the + regenerated artifacts. + +## CI/CD And Release Workflow + +- CI now uses Moon as the source and task graph. The affected planner calls + Moon queries and maps tagged tasks to stable GitHub `Checks` jobs; product + jobs run `.github/scripts/run-moon-targets.sh`, which delegates execution to + `moon run`; +- GitHub matrix is reserved for real target fan-out: native runtime targets, + broker targets, Node direct targets, mobile build/E2E targets, and WASIX AOT + targets. There is no separate CI jobs graph; +- release-please manifest mode owns version bumps, changelog updates, release + PRs, and product tags. Product-local `release.toml` files own publish targets + and artifact metadata, while Moon production/peer scopes provide release + coupling; +- the manual Release workflow keeps protected `prepare-release-pr`, + `publish-dry-run`, and `publish` operations with job-scoped permissions, + trusted publishing, checksums, attestations, and product-native publish + commands; +- native runtime, broker, Node direct, mobile, and WASIX AOT artifacts are + selected by Moon affectedness and built through target metadata. Release jobs + download those CI artifacts into `target/release-assets/`, validate package + shape, and publish through the owning product release step; +- dependency invariant checks now block Wasmtime/static-WASI regressions and + backend compiler crates such as LLVM/Cranelift/Singlepass from entering the + normal user dependency tree; +- the public dependency graph now uses Cargo target-specific dependencies for + AOT packs, so a normal `oliphaunt-wasix` install resolves the target-independent + `oliphaunt-wasix-assets` crate plus only the current platform's + `oliphaunt-wasix-aot-*` crate; +- source-only `tools/policy/check-rust-test-topology.sh` no longer runs broad + Cargo product validation from the root policy lane. `pnpm moon run + liboliphaunt-wasix:smoke` is now the hard runtime gate and requires portable + assets plus the host AOT pack; +- `.github/scripts/download-wasix-runtime-build-artifacts.sh` is a thin wrapper + over `xtask assets download`; exact-SHA, latest-compatible, host-target, and + all-target WASIX runtime artifact downloads share one implementation; +- AOT serialization is now owned by a maintainer-only `xtask` feature. The + normal runtime tree keeps headless Wasmer loading, while + `xtask --features aot-serializer` is the only path that enables Wasmer LLVM; +- the WASIX runtime CI jobs now probe the LLVM AOT serializer before full AOT + generation, validates generated portable assets before AOT work, smokes the + target runtime before packaging/upload, and fails on empty/missing AOT + manifests instead of uploading placeholder crates; +- `wasmer-wasix` is now explicitly feature-minimized for the runtime path + (`sys-minimal`, `sys-poll`, `host-vnet`, and `time`). The root dependency gate + rejects Wasmtime, backend compiler crates, Cranelift/Singlepass, LLVM, and + broad HTTP/TLS stacks such as `reqwest`, `hyper`, and `rustls`; +- normal CI cache writes are limited to `main` while PRs still restore existing + Rust caches. Release and AOT-heavy jobs opt into cache writes explicitly. + +## Backlog Grooming Verification + +The implementation backlog was reconciled against the repository state and the +following completed work was removed from `TODO.md`: + +- current runtime-state notes for headless Wasmer AOT loading, pure MountFS + runtime composition, eager PGDATA overlays, direct scalar no-`pg_type` array + startup scans, no direct-query host `sync_all`, and shared + `BackendSession`/root-preparation paths. These are already covered by the + runtime, performance, and architecture sections above; +- CI/CD scaffolding that is present in the repo: Moon affected planning, + tag-driven exact Moon target execution, native/WASIX target matrices, source-only AOT + crate templates, staged release workspaces, exact-SHA artifact downloads, + package-size gates, and Release workflow trusted-publishing permissions + through `id-token: write`; +- public `pg_dump` functionality that is already implemented and tested: + `PgDumpOptions`, direct and server `dump_sql`/`dump_bytes`, the `oliphaunt-wasix-dump` + CLI, typed rejection of managed passthrough flags, no-clone/no-public-server + direct dumps, stock libpq over virtual networking, indexes/views/sequences, + `--schema-only`, `--quote-all-identifiers`, source-server reuse after dump, + and vector dump/restore coverage; +- split WASIX `initdb` runtime support that is present locally: direct and + server `fresh_temporary()`/`template_cache(false)` paths, split-initdb module + execution, interrupted-PGDATA cleanup, initdb shim ABI/source-spine checks, + and PGDATA template manifest checks. The remaining backlog tracks only full + WASIX runtime CI proof, deterministic two-build comparison, and package-size + impact; +- extension catalog/promotion infrastructure already in place: generated + catalog/build plans, `src/extensions/catalog/extensions.promoted.toml`, + `src/extensions/catalog/extensions.smoke.toml`, public constants for the 37 smoke-passed + exact extensions, candidate/private smoke gates, generated native-module + metadata, load-order metadata, and generated `wasix-dl` export lists; +- protocol and runtime guards already covered by tests or xtask checks: + SSLRequest, CancelRequest, Parse/Bind/Execute recovery, SQLSTATE preservation, + pipelined simple and extended query recovery, COPY streaming over TCP and Unix + sockets, bridge allocation accounting, startup role/database ownership, + export guards, longjmp boundary checks, broad-stub rejection, unsafe archive + rejection, unsupported-target errors, and package-size checks; +- performance tooling already available locally: `oliphaunt-perf cold`, + `oliphaunt-perf warm`, `oliphaunt-perf bench`, + `oliphaunt-perf prepared-updates --skip-native --gate`, primary-latency phase + reporting, production buffer-profile validation, warm server benchmarks, and + product-style SQLx/native/Oliphaunt comparison outputs. The remaining backlog + tracks turning these into stable GitHub-runner release gates. + +## Native SDK API Surface Inventory + +The native SDK parity track now has a no-build public surface inventory: + +- `tools/policy/generate-sdk-api-surface.mjs --write` regenerates + `src/docs/content/reference/sdk-api-surface.md` from the current Rust, Swift, Kotlin, and React + Native SDK sources; +- `tools/policy/check-sdk-parity.sh` runs the generator in `--check` mode so + accidental public symbol drift is visible in the fast parity gate; +- `docs/maintainers/sdk-parity-policy.md` links the inventory next to `docs/products/sdk-manifest.toml`, so + ownership, supported platform shape, and public API review evidence stay + together. + +## SDK Parity Edge-Case Tests + +Rust, Swift, Kotlin, and React Native now cover the edge cases that define the +current public SDK contract: + +- escaped transaction handles are rejected after rollback or commit; +- transaction-owned streaming uses the pinned session boundary; +- closing during an active transaction closes the session and rejects pinned + work instead of committing; +- PostgreSQL query cancellation remains a structured SQLSTATE `57014` + ErrorResponse on typed query paths; +- `connectionString` is present only on server-capable sessions that advertise + independent PostgreSQL client connections; +- startup `username` and `database` identity is first-class across Rust, Swift, + Kotlin, and React Native. Rust now feeds the configured identity through + direct, broker, and server startup paths, while mobile SDKs reject empty or + NUL-containing values before crossing native/TurboModule boundaries; +- backend query parsers now use strict UTF-8 semantics across Rust, Swift, + Kotlin, and React Native. Row-description C-strings and text accessors reject + malformed backend bytes instead of silently replacement-decoding them; +- simple-query protocol builders now reject NUL-containing SQL across Rust, + Swift, Kotlin, and React Native before constructing a frontend C-string frame; +- extended-query protocol builders now reject NUL-containing SQL and parameter + lists above the PostgreSQL protocol `Int16` limit across Rust, Swift, Kotlin, + and React Native before constructing frontend frames; +- typed query parsers now reject unexpected backend message tags across Rust, + Swift, Kotlin, and React Native instead of silently ignoring them, while + preserving known simple/extended-query control tags, validating async backend + control-message framing, and validating the `ReadyForQuery` + transaction-status byte; +- extension IDs are validated and normalized before public SDK open calls + cross into the engine or TurboModule boundary. + +## Native Mobile Exact-Extension Smoke Coverage + +The SDK parity track now has explicit mobile packaging smoke evidence for +selected and unselected extension assets: + +- Swift `OliphauntRuntimeResources` tests materialize a vector-selected resource + package and assert `vector.control` / `vector--1.0.sql` are present while + `hstore.control` stays absent; +- the Kotlin Android SDK check builds a synthetic runtime resources and verifies + generated Android assets preserve the selected vector extension control file + without leaking unselected hstore assets; +- the React Native Android SDK check performs the same assertion against the + produced AAR, proving React Native inherits the Kotlin packaging boundary + rather than carrying a private resource runtime. + +## Native Extension Asset Shape Guards + +The native extension release lane now rejects incomplete selected extension +asset sets before an app reaches `CREATE EXTENSION`: + +- create-extension assets must include both the control file and at least one + SQL install file when materializing runtime resources; +- loadable-module-only extensions such as `auto_explain` are still allowed to + omit create-extension SQL/control files; +- cached native runtimes are invalidated when a selected extension has + SQL/control assets but lacks the matching native module; +- cached native runtimes are also invalidated when a selected native-module + extension has a control/module pair but no SQL install file; +- the gated native extension matrix already covers install/load, reopen, + physical backup, restore, and restored reopen for direct, broker, and server + modes when extension artifacts are available. + +## Native Extension Dependency And Recovery Fixtures + +The extension release lane now has explicit evidence for the remaining negative +fixtures: + +- runtime materialization resolves extension dependencies before copying assets, + so selecting `earthdistance` requires `cube` SQL/control/module assets and + fails fast when the transitive dependency artifact set is incomplete; +- the gated native extension matrix reruns `CREATE EXTENSION ` after a + successful install for every create-extension row, asserts PostgreSQL returns + an `ErrorResponse` plus `ReadyForQuery`, and immediately proves the same + session can run a follow-up query; +- loadable-module-only extensions such as `auto_explain` remain outside the + repeated `CREATE EXTENSION` fixture because their product contract is `LOAD`. + +## Native Extension Preload Startup Proof + +Preload-required extensions now have fast source-level regression proof across +all native modes: + +- direct mode builds `shared_preload_libraries=pg_search` into the C ABI + `oliphaunt_init` startup argument vector before the embedded backend starts, and + deduplicates repeated selections; +- broker mode forwards the resolved extension list, including `pg_search`, to + the helper process before the helper opens its direct-mode backend; +- server mode builds the same preload setting into the `postgres -c ...` + startup arguments before spawning the local PostgreSQL-compatible server; +- extensions that do not require preload hooks, such as `graph`, do not add a + `shared_preload_libraries` setting. + +## Native Extension Size Report + +Rust SDK resource outputs now include selected-extension size evidence as part +of the portable Swift/Kotlin/React Native handoff: + +- `package_native_resources(...)` writes `oliphaunt/package-size.tsv` and + exposes `NativeRuntimeResources::size_report`; +- the report records runtime, template PGDATA, static-registry, package, + de-duplicated selected-extension, and per-extension asset bytes; +- per-extension rows count the concrete resolved extension assets for each + selected extension, including required dependencies, while the selected + extension row is de-duplicated for total app-size impact; +- the `oliphaunt-resources` CLI prints `packageSizeReport=...`, + `selectedExtensionBytes=...`, and `extensionBytes=:` so CI + can diff package-size impact without custom filesystem walkers. +- Swift consumes the same TSV through + `OliphauntRuntimeResources.packageSizeReport()` and tests both valid and + malformed report parsing; +- Kotlin Android consumes it through `OliphauntAndroid.packageSizeReport(context)` + with Android unit coverage for the parser; +- Kotlin and React Native Android packaging checks now preserve + `assets/oliphaunt/package-size.tsv`, so mobile artifacts carry the Rust + runtime-resource generator's extension byte evidence instead of forcing SDK-specific + resource walkers. + +## Native PostgreSQL Patch-Stack Review Gate + +The native PostgreSQL 18 patch stack now has deterministic source-only release +evidence: + +- `src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --write` generates + `docs/internal/OLIPHAUNT_PATCH_STACK.md` from `src/runtimes/liboliphaunt/native/postgres18/source.toml` + and the maintained patch directory; +- `src/runtimes/liboliphaunt/native/tools/check-track.sh` runs the same script in `--check` + mode before native Rust or SDK checks, so stale patch review evidence fails + fast without rebuilding PostgreSQL; +- `source.toml` now lists every maintained patch, including the static extension + loader patch, and the static loader patch has the same deterministic + `Subject: [PATCH] liboliphaunt-native: ...` and `diff --git` metadata as the rest of + the series; +- the generated review lists patch order, subject lines, changed upstream files, + and patch-introduced `oliphaunt_*` symbols, while rejecting SDK/runtime/product + terms that belong above PostgreSQL. +- the patch-stack review now assigns each audit requirement to its owning patch + or patches and verifies the required evidence inside those patches, so host + I/O, lifecycle, cleanup, cwd restore, runtime-path, static-extension, shell + exclusion, Android shared-memory, and event-trigger changes stay independently + reviewable; +- the checker now rejects unexpected upstream PostgreSQL touchpoints unless + they are added to the expected touchpoint table with a rationale, preventing + quiet patch-stack growth. + +## Public C ABI Conformance Gate + +`liboliphaunt` now has a consumer-style C ABI conformance check that compiles and +links against only `src/runtimes/liboliphaunt/native/include/oliphaunt.h`: + +- `oliphaunt/smoke/liboliphaunt_abi_conformance.c` verifies ABI/version constants, + capability bits, public struct field types, exported function prototypes, and + safe global/no-handle calls without including PostgreSQL server headers; +- `src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh` builds the conformance program + with strict C11 warnings and links it to the current `liboliphaunt.dylib`; +- `src/runtimes/liboliphaunt/native/tools/check-track.sh quick` now runs that conformance check + before the heavier native happy-path smoke, so C ABI drift fails in the fast + native lane before Rust, Swift, Kotlin, or React Native bindings trust the + runtime. + +## Direct Streaming Cancellation Regression + +The Rust SDK now covers cancellation while a direct-mode streaming response is +active: + +- `streaming_cancel_uses_out_of_band_cancel_and_releases_owner` proves the + owner executor can cancel a large active stream without queueing cancellation + behind the stream callback, then accepts follow-up raw protocol work on the + same handle; +- the native `OliphauntRuntime` smoke path now runs a streaming + `pg_sleep`/large-response query, cancels it through the opened handle, checks + for PostgreSQL's query-canceled response, and verifies the backend can execute + another query afterward. + +## Broker And Server Cancellation Reuse Regression + +Native runtime cancellation coverage now proves the process-isolated modes keep +their real PostgreSQL semantics after interruption: + +- broker mode records `pg_backend_pid()` before repeated `Oliphaunt::cancel()` + calls, runs cancellation and recovery through the helper process, then asserts + the same helper/backend identity is still serving the handle afterward; +- server mode's external Tokio PostgreSQL client smoke starts independent + clients, issues a PostgreSQL `CancelRequest` through the client cancel token + after startup, asserts SQLSTATE `QUERY_CANCELED`, and then reuses that client + for another query. + +## Direct And Broker Transaction Failure Regressions + +The Rust SDK now exercises serial-session transaction failure semantics across +both `NativeDirect` and `NativeBroker` modes: + +- `with_transaction_commits_rolls_back_and_rejects_unpinned_interleaving` now + runs for direct and broker mode configurations, proving body failure rolls + back and releases the single physical session in both modes; +- `transaction_commit_and_rollback_failures_release_serial_session` uses a + scripted runtime to force `COMMIT` and `ROLLBACK` failures, proving failed + commit attempts trigger best-effort rollback and failed rollback attempts + still release the session pin before subsequent work; +- dropped transactions and close-while-transaction-active coverage now run for + both direct and broker mode configurations instead of only the default direct + mode. + +## Native Server Lifecycle Regressions + +Server mode now has explicit lifecycle evidence for the connection string and +external clients: + +- server smoke asserts the opened handle's `connection_string()` matches the + advertised capability value and remains stable after protocol, streaming, + cancellation, and backup work; +- the Tokio PostgreSQL external-client smoke asserts the connection string stays + stable after independent clients use the server; +- SQLx pool coverage now closes the owned server while the pool is alive and + verifies subsequent pool work is rejected; +- active external client coverage starts a long-running PostgreSQL query through + `tokio-postgres`, closes the owned server, verifies the active query is + interrupted, and verifies the external client cannot run more SQL afterward. + +## Native Root Manifest Gate + +Live native roots now carry a root-owned `manifest.properties` compatibility +record: + +- root preparation adopts existing PostgreSQL 18 roots by writing the manifest + under the native root lock; +- direct, broker-helper, and server paths reject roots whose manifest or + `pgdata/PG_VERSION` targets a PostgreSQL major other than 18 before exposing + the engine; +- initdb-style uninitialized roots are marked as pending and refreshed after + direct/server initialization observes `PG_VERSION`; +- unit coverage validates adoption, uninitialized-to-initialized refresh, and + incompatible PGDATA rejection, while broker smoke corrupts the manifest and + verifies `existing_only()` fails during helper startup. + +## Physical Archive Compatibility Metadata + +Native physical backups now carry compatibility metadata instead of relying on +implicit PGDATA shape alone: + +- Rust direct, broker, and server backup paths annotate physical archives with + the root `manifest.properties` plus `.oliphaunt/backup-manifest.properties`; +- the backup manifest records PostgreSQL major/version number, PGDATA version, + server encoding, locale, data-checksum state, active + `shared_preload_libraries`, required preload libraries, selected extensions, + and installed PostgreSQL extensions; +- restore validates root and backup manifests before publishing a target root, + while still accepting legacy archives that only contain `pgdata/**`; +- legacy restores adopt a current root manifest during staging, so restored + roots satisfy the same open-time root gate as newly bootstrapped roots; +- C archive restore now accepts the same root-level metadata paths, keeping the + archive shape compatible with the C ABI boundary. + +## React Native Package-Size Report Parity + +React Native now exposes the same package-size evidence as the platform SDKs +instead of merely preserving the TSV inside app artifacts: + +- the TypeScript SDK exposes `Oliphaunt.packageSizeReport(...)` with a typed + `PackageSizeReport`/`ExtensionSizeReport` shape; +- RN Android delegates report lookup to `OliphauntAndroid.packageSizeReport(...)`, + including a Kotlin SDK resource-root overload for local unpacked package + smoke tests; +- RN iOS delegates report lookup to `OliphauntRuntimeResources.packageSizeReport()` + through the existing Swift adapter; +- SDK parity checks, RN unit tests, Android boundary tests, and generated API + surface docs now guard the public report API and platform delegation. + +## Native Extended-Protocol Recovery Regression + +The native SQL regression now exercises raw extended protocol error recovery +across every mode that the env-gated test can open: + +- a failing `Parse` request must return an `ErrorResponse` followed by + `ReadyForQuery`, and the same session must accept a normal query afterward; +- a valid named prepared statement is kept alive across a failing `Bind` with an + invalid integer parameter; +- the same prepared statement is then rebound and executed successfully, + proving direct, broker, and server paths recover without losing the physical + session or desynchronizing the PostgreSQL frontend/backend protocol. + +## Native Privilege, Utility, And Lock Regression + +The native SQL regression now also covers more PostgreSQL behavior that has to +work identically through direct, broker, and server modes: + +- role creation, `SET ROLE`, schema/table grants, and structured `42501` + privilege errors for disallowed writes; +- SQL-language functions over composite table rows; +- post-error session reuse after a privilege failure; +- standalone `VACUUM` and `ANALYZE` utility commands; +- transactional table locks plus session advisory lock/unlock behavior. + +## Native Broker Root Metadata Recovery + +Broker-mode root metadata now has explicit PGDATA-version recovery coverage: + +- a broker-opened persistent root records `pgdataVersion=18` in + `manifest.properties`; +- corrupting that manifest to claim `pgdataVersion=17` is rejected before the + helper reopens the root; +- restoring the valid manifest lets the same broker path reopen the root and + read previously committed data, proving failed metadata validation does not + leave the root unusable. + +## Native Performance Evidence Classification + +The native benchmark matrix now classifies benchmark plans before running +expensive work: + +- default direct/broker/server and rtt/speed/streaming/prepared runs are marked + `releaseEvidence=1` only when they meet the release minimums: 100 RTT samples, + 10 RTT repeats, 25,000 prepared-update rows, 10 prepared repeats, and 20 speed + repeats; +- quick all-mode runs are marked as diagnostic but not partial, so maintainers + can use them for plumbing checks without mistaking them for release evidence; +- focused runs are marked `partialReport=1` and `diagnosticRun=1`; +- the no-build perf harness verifies these classifications through + `--plan-only`, so the evidence contract is checked without rebuilding or + running the full matrix. + +## Native Performance Report Release Gate + +The native perf report validator now rejects weak evidence by default: + +- `tools/perf/check-native-perf-report.sh` passes + `--require-release-evidence` to the provenance verifier; +- release verification requires `releaseEvidence=true`, `partialReport=false`, + `diagnosticRun=false`, all native engines, all benchmark suites, SQLite and + prepared-update controls, and counts at or above the recorded release + minimums; +- focused diagnostic reports can still verify source/artifact provenance with + `OLIPHAUNT_PERF_ALLOW_DIAGNOSTIC=1`, but that path is explicitly not + release evidence; +- the no-build perf harness creates temporary release and diagnostic provenance + fixtures and proves the strict verifier accepts only the release fixture. + +## Kotlin Native-Direct Owner Thread Tightening + +Kotlin direct runtimes now route every handle-bound native call through the +dedicated owner thread: + +- Kotlin/Native capabilities are serialized through the same owner dispatcher + and execution mutex as raw protocol, streaming, backup, and close; +- Android JNI opens the backend on the session's single-thread dispatcher + instead of opening on the caller coroutine and only moving later work; +- Android capability reads also run on that dispatcher, leaving only + out-of-band cancellation outside the owner queue; +- Android native-direct session backup now rejects unsupported formats before + crossing JNI, matching Kotlin/Native's defensive session boundary. + +## React Native Exact-Extension Bridge Validation + +React Native native adapters now preserve extension validation instead of +silently dropping malformed bridge values: + +- iOS rejects non-array or non-string `extensions` before opening through + `Oliphaunt`; +- Android rejects non-array or non-string `extensions` before opening + through `OliphauntAndroid`; +- the SDK parity guard rejects the previous lossy `compactMap`/nullable + `getString` patterns. + +## React Native Startup Identity Bridge Validation + +React Native native adapters now validate startup identity before platform SDK +open calls: + +- iOS preserves empty `username`/`database` values instead of converting them to + `nil` and falling back to `postgres`; +- iOS and Android reject blank or NUL-containing startup identity at the native + adapter boundary before opening through Swift or Kotlin; +- the SDK parity guard checks that the native adapters keep those bridge + validations in place. + +## React Native Scalar Config Bridge Validation + +React Native native adapters now reject malformed scalar config values instead +of treating them as omitted: + +- iOS no longer uses a lossy optional string cast for open/runtime resource + fields; +- iOS rejects blank `resourceRoot`/`iosResourceRoot` before falling back to + bundled resources; +- Android rejects non-string scalar values with an explicit bridge error before + opening through `OliphauntAndroid`; +- the SDK parity guard rejects the previous lossy Swift cast pattern and checks + scalar validation in both native adapters. + +## React Native Native Override Path Validation + +React Native now rejects malformed native override paths before they can suppress +default native resolution: + +- TypeScript rejects blank or NUL-containing `libraryPath`, `runtimeDirectory`, + and open-time `resourceRoot` before crossing the TurboModule boundary; +- restore validates `libraryPath` before forwarding the C ABI override; +- iOS and Android adapters repeat the same blank/NUL checks at the native bridge + boundary so direct native calls cannot bypass the JS guard. + +## SDK Root Path NUL Validation + +Rust, Swift, Kotlin, and React Native now reject NUL-containing database roots +before filesystem work, platform engine calls, TurboModule calls, or C ABI +conversion: + +- Rust validates persistent open roots before native runtime selection and + restore target roots before physical archive unpack; +- Swift validates file URL roots for `OliphauntDatabase` and the native direct + engine before open/restore work reaches the C bridge; +- Kotlin validates common open/restore roots and repeats the guard in native and + Android direct engines; +- React Native validates open/restore roots in TypeScript and Android native + adapter code, while the iOS adapter routes path values through the same + NUL-aware helper used for native override paths. + +## Rust SDK Direct Session And Extension Manifest Guardrails + +The Rust SDK now rejects two malformed native-product inputs before they reach +runtime, filesystem, or extension packaging work: + +- `NativeDirect` and `NativeBroker` accept exactly one logical client session; + requesting zero sessions now fails at `OpenConfig::validate`, and requesting + more than one still returns the explicit unsupported-session error; +- Rust SDK-owned `initdb`, broker helper, and server executable paths reject + empty or NUL-containing values before process startup; +- static and signed-dynamic extension manifest paths reject embedded NUL + bytes during config validation, and direct manifest loading rejects the same + malformed paths before attempting any filesystem read. + +## React Native JSI Transport Selection + +React Native now has a real TypeScript-side fast-path selector for raw protocol +bytes: + +- the public `execProtocolRaw` API probes the versioned + `globalThis.__oliphauntReactNativeJsi` host transport and sends `Uint8Array` + requests without base64 when it is installed; +- capability normalization reports `rawProtocolTransport = "jsi-array-buffer"` + for opened handles and supported-mode discovery when that host transport is + available; +- the Codegen TurboModule no longer exposes base64 binary methods; protocol, + backup, and restore bytes require the JSI transport, and tests reject missing + or non-binary host transports before native sessions are used; +- the iOS adapter exposes an `NSData` raw-protocol handoff into `Oliphaunt`, + and the Android adapter exposes a `ByteArray` handoff into the Kotlin SDK + session, so the native JSI installer can avoid base64 without creating a + private React Native database runtime. + +## React Native Native JSI Installers + +React Native New Architecture builds now install the high-throughput +`jsi-array-buffer` transport on both native platforms: + +- iOS implements `RCTTurboModuleWithJSIBindings`, installs + `globalThis.__oliphauntReactNativeJsi`, accepts ArrayBuffer/typed-array + requests, and resolves promises with ArrayBuffer responses from the Swift + `Oliphaunt` adapter; +- Android implements `TurboModuleWithJSIBindings`, builds a small + ReactAndroid Prefab C++/JNI library, accepts ArrayBuffer/typed-array + requests, and resolves promises through the Kotlin SDK + `OliphauntDatabase.execProtocolRaw` byte-array hook; +- the React Native package check generates the real iOS Codegen header and + syntax-checks the `RCT_NEW_ARCH_ENABLED` Objective-C++ path, builds the + Android C++ JSI installer through the selected debug ABI matrix, and still + rejects any duplicate React Native Android native database runtime. + +## React Native Binary Transport Guard + +React Native now treats Codegen as lifecycle/control glue only and enforces the +JSI binary path in validation: + +- `src/sdks/react-native/tools/check-sdk.sh` fails if `execProtocolRaw`, + streaming, backup, or restore byte methods are added to the TurboModule + Codegen spec; +- the same check rejects base64, `atob`/`btoa`, or Node `Buffer` binary + conversion in React Native runtime source; +- PostgreSQL protocol, backup, and restore traffic therefore remain on the + versioned JSI `ArrayBuffer` transport while `open`, `close`, `cancel`, + `capabilities`, mode discovery, and package-size reporting keep the official + TurboModule Codegen surface. + +## SDK Package Artifact Checks + +Every native SDK lane now has a fast package-surface check before broader +release publishing: + +- Rust records and verifies `cargo package -p oliphaunt --list`, requiring + the public SDK sources/tests and rejecting product-external generated trees; +- Swift archives a sanitized SwiftPM source package from a scratch copy, + verifies `Package.swift`, podspecs, C bridge, Swift sources, and tests are + present, and rejects generated `.build`/`.swiftpm` content; +- Kotlin assembles the Multiplatform metadata/source jars, JVM jar, Android + release AAR, and macOS/native source jar, then verifies common/platform API + files, metadata linkdata, JVM unavailable-runtime classes, selected Android + JNI adapter ABIs, and absence of bundled PostgreSQL runtime binaries by + default; +- React Native already inspects `npm pack --dry-run --json`, so the SDK parity + gate now requires all four package artifact checks to remain in place. + +## SDK README Example Coverage + +The SDK parity gate now mechanically links public README code examples to +compiled or tested SDK coverage: + +- Rust, Swift, Kotlin, and React Native README code blocks carry + `liboliphaunt-doc-example:` markers; +- `tools/policy/check-sdk-doc-examples.mjs` rejects unmarked Rust/Swift/Kotlin/ + TypeScript README examples, duplicate IDs, stale coverage markers, and + examples without SDK test/source coverage; +- the current coverage set includes Rust backup/restore and typed-query + examples, Swift open/raw/streaming/typed/parameterized examples, Kotlin + Android-open/streaming/typed/parameterized examples, and React Native + open/query plus parameterized query examples. + +## Android Fast ABI Validation + +Android SDK validation now supports a shared Gradle ABI filter for the Kotlin +SDK, React Native adapter, and delegated Kotlin runtime: + +- `src/sdks/react-native/tools/check-sdk.sh` defaults Android Gradle/CMake work + to one ABI selected from the host CPU for fast local iteration; +- `src/sdks/kotlin/tools/check-sdk.sh` uses the same default for Kotlin Android + validation; +- `OLIPHAUNT_REACT_NATIVE_ANDROID_ABI_FILTERS=all` or + `OLIPHAUNT_KOTLIN_ANDROID_ABI_FILTERS=all` restores full ABI coverage; +- comma-separated subsets are forwarded as + `-PoliphauntAndroidAbiFilters=...`, and both + `src/sdks/react-native/android` and `src/sdks/kotlin/oliphaunt` validate the + same supported ABI set. + +## React Native JSI Argument Hardening + +The React Native New Architecture transport now validates numeric JSI arguments +before crossing into Swift or Kotlin: + +- iOS and Android reject non-finite, fractional, negative, or unsafe database + handles before native handle casts; +- typed-array `byteOffset` and `byteLength` values are checked as + non-negative integers before copying protocol, backup, or restore bytes; +- the React Native SDK check guards both native JSI installers for these + validations, and the Android boundary test asserts the same invariant for the + Kotlin-backed adapter. + +## React Native Installed-App Smoke Entrypoint + +React Native now ships a reusable app/device smoke runner instead of leaving +each app to invent its own native-boundary test: + +- `runInstalledOliphauntReactNativeSmoke(...)` uses the installed package singleton, + so it proves the app's TurboModule/JSI installation path rather than a mock + client; +- the smoke opens a delegated Swift/Kotlin SDK session, checks the expected + engine and `jsi-array-buffer` transport, runs `SELECT 1`, runs a parameterized + query, optionally requires packaged resource-size evidence, records JS timer + progress, and always closes the database; +- the pure `runOliphauntReactNativeSmoke(client, ...)` form is covered by the + React Native TypeScript tests, while the remaining release gap is wiring that + installed-app runner into iOS simulator/device and Android emulator/device CI + jobs with real packaged `liboliphaunt` runtime resources. + +## Native Direct Reopen Capability + +The SDKs now publish reopenability as an explicit capability instead of letting +apps infer it from process isolation: + +- Rust `EngineCapabilities`, Swift `OliphauntCapabilities`, Kotlin + `EngineCapabilities`, and React Native `EngineCapabilities` all expose + `reopenable`; +- `NativeDirect` reports `false` because the embedded PostgreSQL backend is a + process-lifetime direct session, while `NativeBroker` and `NativeServer` + report `true`; +- a local C-core experiment removed the process-spent guard and crashed on the + second same-process direct open in PostgreSQL relation/storage startup, which + shows this is not an fd-table-only reset problem. Broker/server remain the + robust close/reopen paths. + +## Expo Android Installed-App Smoke Harness + +React Native now has a repeatable real-app Android validation path instead of +only package-level checks: + +- `src/sdks/react-native/examples/expo` is an Expo SDK 56 development-build + app pinned to React Native 0.85 and the local packed + `@oliphaunt/react-native` SDK, and its app smoke now calls the installed + package runner directly before attaching the example's CRUD/perf workload via + the same live NativeDirect session; +- `src/sdks/react-native/tools/expo-android-runner.sh` packs the RN SDK when + sources changed, installs the tarball into the example, packages Android + `liboliphaunt.so` plus runtime/template PGDATA assets, builds the dev-client + APK, launches it through the Expo development-client deep link, waits for + `OLIPHAUNT_EXPO_SMOKE_PASS`, and prints APK/package size plus Android + PSS/RSS; +- the smoke generates the ignored Expo `android/` project on demand, so a clean + checkout does not need committed native project output before app-level + validation can run; +- `pnpm --dir src/sdks/react-native/examples/expo run smoke:android` exposes the same + installed-app gate as a named validation lane, and SDK parity checks require + the harness, docs, example command, and machine-readable pass signal to stay + present; +- the installed app has validated the New Architecture JSI `ArrayBuffer` + transport, delegated Kotlin runtime path, `SELECT 1`, parameterized query, + 100-row transaction insert, select p90, package-size reporting, and JS timer + liveness on an Android emulator. + +## Expo iOS And MCP Smoke Harness + +React Native now has the matching iOS app-level validation scaffold and local +Expo MCP tool path: + +- `src/sdks/react-native/tools/expo-ios-runner.sh` packages the RN SDK with an + iOS `oliphaunt/` resource bundle, accepts only an iOS + XCFramework/framework or an iOS/iOS-simulator dylib, and rejects macOS + `liboliphaunt.dylib` artifacts before CocoaPods/Xcode work starts; +- the iOS harness patches only the ignored generated Expo `ios/Podfile` to use + local `COliphaunt` and `Oliphaunt` pods, runs `pod install`, builds the + dev-client app, and can launch a booted simulator to wait for + `OLIPHAUNT_EXPO_SMOKE_PASS`; +- `OLIPHAUNT_EXPO_IOS_BUILD_ONLY=1` keeps the harness useful on machines where + CoreSimulator is unavailable, while still validating generated-spec, + CocoaPods, Swift SDK, resource bundle, and Xcode integration; +- the React Native iOS Swift adapter now discovers bundled `liboliphaunt` from the + packaged resource root or app frameworks when `libraryPath` is not supplied, + so app developers do not need host-environment library overrides for normal + packaged builds; +- `src/sdks/react-native/examples/expo` installs `expo-mcp` and exposes + `npm run mcp:start`, which runs + `EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client` for Codex/MCP-driven + local logs, DevTools, screenshots, and automation. + +## Apple Mobile Template-Only Bootstrap Guard + +The C layer now enforces the mobile bootstrap model before the full iOS +PostgreSQL artifact lane exists: + +- `src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c` compiles out the `fork`/`exec` initdb + path on Apple mobile platforms and returns an actionable error when PGDATA has + no `PG_VERSION`; +- macOS keeps the direct `initdb` tooling fallback, so desktop smoke and local + native iteration continue to work from an empty PGDATA root; +- `src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh` now performs a fast iOS simulator + syntax check over the liboliphaunt C shim files, catching forbidden mobile C + APIs without rebuilding PostgreSQL for iOS. + +## React Native Chunked JSI Streaming + +React Native no longer advertises streaming based on the raw owned-response +transport alone: + +- the TypeScript transport reports `protocolStream=true` only when the installed + versioned JSI transport exposes `execProtocolStream`; +- iOS installs an `execProtocolStream` host function that delegates to + `OliphauntCore.execProtocolStream` and calls the JS chunk callback for each + native chunk; +- Android installs the same host function through JNI, delegates to + `OliphauntDatabase.execProtocolStream`, and keeps stream completion separate from + chunk emission; +- package tests cover native chunk use, malformed chunks, and JS chunk callback + failures, while Android boundary tests keep the Kotlin and C++ JSI stream + hooks present. + +## Native Regression Coverage Expansion + +The native regression backlog now has concrete coverage instead of a product +placeholder: + +- `src/sdks/rust/tests/native_sql_regression.rs` runs a broader curated + PostgreSQL suite across `NativeDirect`, `NativeBroker`, and `NativeServer` + when native artifacts are available, covering domains, enums, generated + columns, deferrable uniqueness, foreign keys, JSONB, range types, recursive + CTEs, window functions, lateral joins, partial expression indexes, `MERGE`, + privileges, utility commands, table locks, advisory locks, COPY success, + COPY input errors, COPY fail recovery, streaming `COPY TO STDOUT`, extended + protocol parse/bind error recovery, and post-error session reuse; +- `src/sdks/rust/tests/protocol_parser_fuzz.rs` adds deterministic + fuzz-style corpora for backend query response parsing, mutated valid backend + frames, and frontend simple-query request construction, proving parser paths + return structured errors instead of panicking on malformed bytes; +- `src/sdks/rust/tests/sdk_shape.rs` now has optional native-server + compatibility smokes for `tokio-postgres` prepared/pipelined clients and + `pg_dump`, alongside the existing SQLx pool and `psql` checks. These tests + skip cleanly when the native artifact or matching PostgreSQL tools are absent. + +## Runtime Footprint Profiles And Startup GUC Overrides + +Rust, Swift, Kotlin, and React Native now expose the same startup-tuning shape: + +- Rust adds `RuntimeFootprintProfile`, `PostgresStartupGuc`, builder + `runtime_footprint(...)`, and builder `startup_guc(...)`/`startup_gucs(...)`; +- direct and broker pass profile/durability/explicit GUCs through the existing + C ABI startup-arg vector, while broker forwards them to helper restarts and + server mode preserves its `max_client_sessions` contract as `max_connections`; +- Swift, Kotlin, and React Native expose matching profile and startup-GUC + configuration, validate names/values before native open, and default mobile + SDK opens to `balanced` durability with the `balancedMobile` footprint; +- docs now describe the throughput, balanced-mobile, and small-mobile profiles + plus the override precedence for benchmark matrices. + +The native perf harness now accepts the same tuning shape: + +- `oliphaunt-perf native-liboliphaunt` and `oliphaunt-perf native-postgres` accept + `--runtime-footprint` and repeatable `--startup-guc name=value`; +- native benchmark JSON includes `nativeTuning` with profile, explicit + overrides, SDK startup assignments, and native-PostgreSQL control assignments; +- the release matrix script records profile/GUCs in its plan, provenance, and + markdown summary, and forwards the tuning to direct, broker, server, prepared, + streaming, and native-PostgreSQL control runs; +- Expo Android/iOS smoke and benchmark harnesses forward durability, runtime + footprint, and startup GUCs through Metro env and dev-client links; +- `tools/perf/matrix/run_mobile_footprint_matrix.sh` enumerates the requested + Android/iOS shared-buffer, WAL-buffer, WAL-minimum, and Safe/Balanced device + sweep. It skips `min_wal_size=8MB/16MB` by default because the current PG18 + artifact uses 16MB WAL segments and PostgreSQL rejects those GUC-only minima. + +## Explicit Lifecycle Capability Vocabulary + +The SDK contract no longer relies on a single ambiguous `reopenable` boolean: + +- Rust `EngineCapabilities` now exposes `same_root_logical_reopen`, + `root_switchable`, and `crash_restartable`; +- Swift, Kotlin, and React Native expose matching camelCase fields in their + capability structs/dictionaries; +- native direct reports same-root resident logical reopen only, with + `rootSwitchable=false` and `crashRestartable=false`; +- broker reports process isolation, root-switchability, and helper + crash-restartability; server reports root-switchability but no in-place + crash restart for the current SDK-owned server handle; +- SDK tests and docs assert these semantics so mobile callers do not infer crash + isolation from direct-mode logical close/reopen. + +## Exact Extension Packaging Recipes + +Public extension and SDK docs now describe exact-extension packaging without a +pack/group concept: + +- `src/docs/content/reference/extensions.md` documents Rust runtime-resource generation, + prebuilt third-party artifacts, mobile static registry generation, package + size reports, and exact selected extension manifests; +- the Swift README documents `COliphaunt`/`Oliphaunt` CocoaPods resource + packaging, selected iOS extension XCFramework placement, and link-time + failure for missing selected modules; +- the Kotlin README documents Android runtime resources, `jniLibs`, selected + extension archives, and `liboliphaunt_extensions.so` generation; +- the React Native README documents that RN delegates extension packaging to + Swift/Kotlin, does not ship native runtime or extension assets implicitly, + and uses the same exact SQL extension names as the platform SDKs. diff --git a/docs/internal/IMPLEMENTATION_CHECKLIST.md b/docs/internal/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 00000000..b2e7b0fb --- /dev/null +++ b/docs/internal/IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,1193 @@ +# Final Source Architecture Implementation Checklist + +This is the active implementation checklist for making the repository match +`docs/architecture/final-product-source-architecture.md` exhaustively. Keep it +evidence-based: an item is only complete when the listed repo source, verifier, +or CI/build output proves the contract. + +## Status Legend + +- `[x]` implemented and locally verified in this branch. +- `[~]` implemented or partially implemented, but needs broader evidence. +- `[ ]` missing, contradictory, or not yet proven. + +## Source Shape + +- [x] Product source roots live under `src/`: + - `src/postgres/versions/18/` + - `src/sources/` + - `src/extensions/` + - `src/runtimes/liboliphaunt/native/` + - `src/runtimes/liboliphaunt/wasix/` + - `src/runtimes/broker/` + - `src/runtimes/node-direct/` + - `src/sdks/rust/` + - `src/sdks/swift/` + - `src/sdks/kotlin/` + - `src/sdks/react-native/` + - `src/sdks/js/` + - `src/bindings/wasix-rust/` + - `src/shared/contracts/` + - `src/shared/extension-runtime-contract/` + - `src/shared/fixtures/` + - `src/docs/` +- [x] Generated local state is ignored and untracked. Evidence: + `bash tools/policy/check-repo-structure.sh` passes, and tracked-file scans + find no root `assets/`, root `crates/`, root `sdks/`, root runtime build + trees, or generated local state under product source roots. +- [x] Retired root aliases and old product roots are rejected. Evidence: + `bash tools/policy/check-repo-structure.sh` passes, `find . -maxdepth 2` + finds no root `assets`, `crates`, or `sdks` directories, and `git ls-files` + finds no tracked files under those retired roots. + +## Moon Graph + +- [x] Moon is the only task and affectedness graph. Evidence: + `tools/graph/graph.py check` passes and reports Moon projects/release + products. +- [x] Stable CI job names are derived from Moon task `ci-*` tags. Evidence: + `tools/graph/ci_plan.py` and `tools/policy/check-moon-product-graph.mjs`. +- [x] Runtime target fan-out is metadata-driven, not hardcoded in mobile jobs. + Evidence: focused mobile planner output narrows native runtime and native + extension matrices by surface, and `tools/policy/check-release-policy.py` + asserts Android mobile builds request only `android-arm64-v8a` and + `android-x86_64` extension artifacts while iOS mobile builds request only + `ios-xcframework`. +- [x] Moon dependency scopes encode release-affecting versus build-only edges. + Evidence: `tools/release/release.py plan --changed-file ... --format json` + probes prove extension catalog changes run affected CI without releases, + exact extension target changes release only that extension product, + native runtime patches release native plus production downstream products, and + WASIX patches release only WASIX runtime plus the WASIX Rust binding. +- [x] React Native depends on Swift/Kotlin at the product graph level. Mobile + installed-app builder jobs consume target-scoped exact-extension package + artifacts through CI artifact handoff, not a Moon product dependency. + Evidence: focused Android/iOS planner output selects + `mobile-extension-packages` plus only the Android or iOS native extension + targets, and `oliphaunt-react-native` no longer has `extension-packages` in + Moon project dependencies. + +## CI Builder Model + +- [x] Builds workflow owns release-shaped runtime artifacts: + - `liboliphaunt-native` matrix + - `liboliphaunt-native-release-assets` + - `liboliphaunt-wasix-runtime` + - `liboliphaunt-wasix-aot` + - `liboliphaunt-wasix-release-assets` + Evidence: target jobs emit per-platform release assets, then + `liboliphaunt-native-release-assets` runs the Moon-modeled + `liboliphaunt-native:release-assets` aggregate task against downloaded target + artifacts instead of inline workflow shell. +- [x] Builds workflow owns release-shaped helper artifacts: + - `broker-runtime` + - `node-direct` + Evidence: the CI planner maps these jobs to + `oliphaunt-broker:release-assets` and + `oliphaunt-node-direct:release-assets`, not package-shape or + release-check tasks. +- [x] Builds workflow owns SDK package artifacts: + - Rust SDK + - Swift SDK + - Kotlin SDK + - React Native SDK + - TypeScript SDK + - WASIX Rust binding +- [x] Builds workflow owns exact-extension artifacts: + - `extension-artifacts-native` matrix over published native targets. Each + target row carries the selected exact-extension product set in + `extensions_csv`, so a full builder plan emits 7 native rows instead of + rebuilding PostgreSQL once per product-target pair. Linux, macOS, Android, + iOS, and Windows rows currently carry all 39 exact-extension products. + - `extension-artifacts-wasix` matrix over published WASIX targets. A full + builder plan emits 1 `wasix-portable` row covering 39 exact-extension + products. + - `extension-packages` for full native+WASIX release packages + - `mobile-extension-packages` for Android/iOS installed-app builds that need + only the selected mobile native extension targets + Evidence: mobile package assembly now fails closed unless + `OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS` and + `OLIPHAUNT_EXTENSION_PACKAGE_NATIVE_TARGETS` are explicit; only the + release-wide `extension-packages` path may stage all exact-extension + products. +- [x] Builds workflow has a builder-only aggregate. Evidence: + `tools/graph/ci_plan.py` emits `builder_jobs`, and the `artifact-builders` GitHub job + fails if any selected runtime, helper runtime, SDK package, exact-extension + artifact/package, or mobile app builder fails. Local planner probe confirms a + full run selects runtime, WASIX, helper, SDK, extension, and mobile app + builders, with no docs, coverage, regression, release-readiness, or mobile + E2E jobs selected. An `extension-artifacts-native:build-target`-only plan selects + only the native extension artifact builder and does not select + `liboliphaunt-native`. WASIX AOT target fan-out is emitted by the affected + planner as matrix data, not by a separate CI planner job. +- [x] Builds workflow disables Moon output cache for every artifact-producing + builder invocation. Evidence: `tools/policy/check-release-policy.py` rejects + any selected `ci-*` builder job whose `run-planned-moon-job.sh` line omits + `MOON_CACHE=off`; compiler/package-manager caches remain available below + Moon for ccache, Cargo, Gradle, pnpm, and Docker layers. +- [x] Builds workflow disables upstream Moon expansion for every + artifact-producing builder invocation. Evidence: + `tools/policy/check-release-policy.py` rejects any selected `ci-*` builder + job whose `run-planned-moon-job.sh` line omits + `OLIPHAUNT_MOON_UPSTREAM=none`, so `Builds` jobs cannot silently pull in + `check`, `test`, docs, coverage, regression, or release-readiness work while + producing runtime, SDK, extension, or mobile app artifacts. +- [x] Native exact-extension artifact builders are independent target builders + from the same PostgreSQL/liboliphaunt source and ABI inputs. They are now + addressed by target, receive the exact selected product set as + `OLIPHAUNT_EXTENSION_PRODUCTS`, do not run the `liboliphaunt-native` runtime + artifact producer through Moon upstream expansion, and upload target indexes + containing one row per produced exact-extension artifact. Native + exact-extension builders restore the same target-scoped compiler/build cache + family as base native runtime builders, with extension inputs in the cache + key, so repeated exact-extension jobs do not intentionally recompile + unchanged PostgreSQL/liboliphaunt inputs. The Moon task now depends on + `source-inputs:source-fetch-native-runtime`, not the weaker + extension-only source fetch, because native extension builds need shared and + native third-party source pins such as ICU. Mobile and Swift jobs still + select `liboliphaunt-native` explicitly because they consume its packaged + runtime artifacts. +- [x] Public artifact matrix labels use friendly target ids consistently. + Evidence: WASIX AOT CI matrix emits `target_id` values such as + `macos-arm64`, `linux-x64-gnu`, `linux-arm64-gnu`, and + `windows-x64-msvc`, matching native/helper target ids. +- [x] WASIX AOT builder target metadata is product-local. Evidence: + `src/runtimes/liboliphaunt/wasix/targets/*` declares runner, triple, asset, + and `llvm_url`; `tools/release/artifact_target_matrix.py` reads those fields + instead of carrying target-specific URL maps in the CI planner. +- [x] WASIX AOT build artifacts use an explicit GitHub artifact envelope. + Evidence: AOT builders stage `target-triple.txt` plus a `files/` payload + before upload; release aggregation restores the target layout from that + marker instead of assuming GitHub artifact downloads preserve + `target/oliphaunt-wasix/aot/` parent paths. +- [x] Mobile build jobs consume prebuilt native runtime and target-scoped native + extension package artifacts. They do not source-build liboliphaunt or stage + extension packages locally, and focused Android/iOS mobile plans build only + the platform app artifact path. They do not build WASIX extension artifacts + and do not start emulator/simulator E2E jobs in the `Builds` workflow. +- [x] Mobile-focused extension artifact builders are target-scoped. Evidence: + direct `tools/graph/ci_plan.py` probes show Android mobile builds select + native extension artifacts for `android-arm64-v8a` and `android-x86_64` + only, iOS mobile builds select `ios-xcframework` only, and standalone + extension-package builds still select every published native + exact-extension target. Focused Android/iOS builder plans now emit zero + WASIX exact-extension matrix rows because the WASIX extension builder is not + selected. +- [x] Exact-extension product selection is scoped by intent. Evidence: + focused mobile builder plans emit `OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS` for + `oliphaunt-extension-vector` only; full builder plans emit the explicit full + exact-extension product list so `extension-packages` builds every + exact-extension product across native and WASIX publishable targets. Policy + also checks single-product matrix narrowing for both external + (`oliphaunt-extension-vector`) and PostgreSQL contrib + (`oliphaunt-extension-amcheck`) extension products, and checks the real + affectedness shape where a single exact-extension product change also + selects aggregate native/WASIX/package tasks. +- [x] Mobile build jobs require staged SDK package artifacts in CI. Evidence: + CI sets `OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS=1`; Android consumes the staged + React Native tarball plus Kotlin Maven repository artifacts; iOS consumes the + staged React Native tarball and creates a local Git source from the staged + Swift source archive for CocoaPods. +- [x] Mobile build jobs inspect the produced app artifact for selected-extension + correctness. Evidence: CI runs + `tools/release/check_staged_artifacts.py --require-mobile android + --require-mobile-prebuilt-extensions` and the corresponding iOS command after + app build, so the app package must contain only selected extension files and + must have matching prebuilt exact-extension package inputs. +- [x] iOS selected-extension artifact inspection is link-aware instead of + metadata-only. Evidence: + `src/sdks/react-native/tools/mobile-extension-runtime.sh` now rejects missing + or unselected `liboliphaunt_extension_.xcframework` inputs after + unpacking exact-extension artifacts; `src/sdks/react-native/tools/expo-ios-runner.sh` + stages generated registry C under compile-only + `ios/generated/static-registry/`; and + `tools/release/check_staged_artifacts.py --require-mobile ios + --require-mobile-prebuilt-extensions` now requires Xcode link evidence for + selected extension frameworks while rejecting build-only registry source or + extension-framework inputs inside the final `.app` resource bundle. +- [x] Android selected-extension artifact inspection is link-aware at the + Android SDK/RN package build boundary instead of metadata-only. Evidence: + the Kotlin Android SDK accepts `oliphauntAndroidLinkEvidenceFile`, passes it + into its CMake static-extension target, and writes + `oliphaunt-android-static-extension-link-v1` rows for ABI, liboliphaunt, each + selected static extension archive, and dependency archives. React Native + Android passes the same property through its builder and + `src/sdks/react-native/tools/check-sdk.sh build-android-bridge` asserts that + vector's `liboliphaunt_extension_vector.a` was linked for the selected ABI. + The staged mobile artifact checker now requires this Android link evidence + whenever `--require-mobile android --require-mobile-prebuilt-extensions` is + used, validates the linked `liboliphaunt.so`, verifies selected extension + archive paths against the static-registry manifest target id + (`android-arm64-v8a`/`android-x86_64`), and rejects missing or unselected + dependency archive rows. +- [x] Swift SDK package artifacts render the public SwiftPM release manifest + from the real Apple liboliphaunt SwiftPM target artifact in CI, not a local + fixture and not the all-platform aggregate release asset job. Evidence: + `swift-sdk-package` depends on `liboliphaunt-native-ios`, downloads + `liboliphaunt-native-release-assets-ios-xcframework`, sets + `OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR`, and + `src/sdks/swift/tools/check-sdk.sh package-shape` fails closed unless that + directory contains a real + `liboliphaunt--apple-spm-xcframework.zip` with macOS, iOS device, + and iOS simulator slices. +- [x] Downloaded-artifact consumer jobs run their explicit Moon target with + `OLIPHAUNT_MOON_UPSTREAM=none`, so they do not re-run producer tasks after + GitHub artifact handoff. SDK package jobs also run their package target with + upstream traversal disabled so they cannot silently rebuild helper/runtime + package tasks inside an SDK builder. +- [x] WASIX exact-extension packaging consumes portable runtime outputs instead + of rerunning source-generation checks. Evidence: strict generated asset + validation remains in `liboliphaunt-wasix:runtime-portable`; the WASIX + extension artifact packager now only validates and stages archives from the + downloaded/generated portable runtime asset root. +- [x] Release dry-run SDK validation consumes staged builder artifacts in + all release modes instead of rebuilding SDKs from source. Evidence: + `release.py` validates staged Cargo crates, Kotlin Maven repository + artifacts, Swift release manifests/source archives, and npm/JSR tarballs; + Kotlin, React Native, TypeScript, WASIX Rust, and Rust dry-runs return after + staged validation rather than invoking `check-sdk.sh`, Gradle local publish, + `cargo package`, or `cargo publish --dry-run`. +- [x] Kotlin SDK builder artifacts use the consumer-facing Maven repository as + the package boundary. Evidence: `tools/release/build-sdk-ci-artifacts.sh` + stages `target/sdk-artifacts/oliphaunt-kotlin/maven` only, React Native + Android derives the Kotlin dependency from that staged Maven repo, and + `tools/release/check_staged_artifacts.py` now requires the Maven repository + instead of loose top-level AAR/JAR files. +- [x] Builds workflow no longer defines mobile E2E, docs, coverage, + regression, release-intent, release-readiness, or repository policy jobs. + Evidence: `.github/workflows/ci.yml` now contains only `affected`, runtime + artifact builders, helper runtime builders, exact-extension artifact/package + builders, SDK package builders, mobile app builders, `artifact-builders`, and + `required`; `tools/policy/check-release-policy.py` rejects any non-builder + Moon job that reappears in this workflow. +- [x] Required job aggregate is builder-first. Evidence: + `required` gates only `affected` and `artifact-builders`; `artifact-builders` verifies every + selected runtime, helper runtime, SDK package, extension artifact, extension + package, and mobile app builder job from `builder_jobs`. Static checks, + docs, coverage, regressions, and E2E are intentionally outside this builder + workflow and cannot replace the release artifact gate. +- [x] Full non-PR Builds runs are deliverable builders by default. Evidence: + `tools/graph/ci_plan.py::plan_for_full_run()` starts from `BUILDER_JOBS` + plus the WASIX AOT target planner dependency, and + `tools/policy/check-release-policy.py` rejects full-run plans that select + non-builder side lanes such as `repo`, `release-intent`, docs, regressions, + E2E, coverage, or release-readiness. +- [x] Mobile app builders consume staged SDK, runtime, and exact-extension + artifacts with source-build fallbacks disabled in CI. Evidence: + `tools/policy/check-release-policy.py` requires Android/iOS mobile build jobs + to depend on the relevant liboliphaunt target builder, SDK package builders, + and `mobile-extension-packages`; download the staged artifacts; set + `OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS=0`, + `OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS=1`, and + `OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS=1`; and run strict + `check_staged_artifacts.py --require-mobile-*-prebuilt-extensions` + validation after app build. Android and iOS mobile builders now force + release-mode app artifacts (`OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE=release`, + `OLIPHAUNT_EXPO_IOS_CONFIGURATION=Release`, and + `OLIPHAUNT_EXPO_IOS_SDK=iphonesimulator`) so installed-app E2E consumes the + same artifact class produced by `Builds`. +- [x] Mobile installed-app E2E is separated from the builder workflow and + consumes built app artifacts from `Builds`. Evidence: + `.github/workflows/mobile-e2e.yml` triggers from successful `Builds` runs or + explicit `workflow_dispatch`, requires the `artifact-builders` job to have succeeded, + downloads `react-native-mobile-android-app-android-x86_64` and + `react-native-mobile-ios-app`, runs the pinned Maestro path through + `src/sdks/react-native/tools/mobile-e2e.sh`, starts Android with the existing + `tools/dev/start-android-emulator-ci.sh`, and does not invoke + `run-planned-moon-job.sh`, `mobile-build:*`, or native/source-build fallback + paths. `tools/policy/check-release-policy.py` enforces these invariants. +- [x] React Native mobile task semantics match the Moon CI model. Evidence: + `oliphaunt-react-native:e2e`, `mobile-drill-android`, and `mobile-drill-ios` + are `runInCI=false` because routine CI must never invoke aggregate/manual + device drills. Platform installed-app E2E lanes + `mobile-e2e-android` and `mobile-e2e-ios` are `runInCI=skip` so broad + `moon ci` does not start emulator/simulator jobs, while the tasks remain + graph-valid for explicit installed-app CI workflows. Both + `tools/policy/check-test-strategy.mjs` and + `tools/policy/check-moon-product-graph.mjs` enforce this distinction. + +## Release Model + +- [x] release-please manifest mode owns product components, versions, + changelogs, and tags. Evidence: `release-please-config.json` and + `.release-please-manifest.json`. +- [x] Product-local `release.toml` files own registry/package metadata. + Evidence: `tools/release/product_metadata.py` validates Moon release products + against release-please components. +- [x] There is no active `release-graph.toml`, `release-inputs.toml`, or + `tools/graph/jobs.toml` release brain. +- [x] `tools/release/release.py plan` uses Moon project ownership and dependency + scopes for release closure. Evidence: direct release-plan probes for + extension catalog, PostGIS target metadata, native runtime patch, and WASIX + runtime patch paths. +- [x] Release workflow consumes Builds artifacts instead of rebuilding native, + helper, SDK, WASIX, or extension artifacts during publish. +- [x] Swift SDK release dry-run/publish consumes the Swift SDK builder output + directly: `Oliphaunt-source.zip` plus `Package.swift.release` under + `target/sdk-artifacts/oliphaunt-swift`. Aggregate liboliphaunt release + assets are only downloaded when `liboliphaunt-native` itself is selected for + release. Evidence: `release.py` validates and copies the staged SwiftPM + manifest, and `.github/workflows/release.yml` no longer ties Swift releases + to the aggregate native asset download. +- [x] Staged npm SDK artifacts are real release inputs. Evidence: + `release.py` validates `@oliphaunt/ts` and `@oliphaunt/react-native` + tarballs for exact filename, package name, version, no `workspace:` + dependency specs, and built `package/lib` output before dry-run/publish. +- [x] WASIX runtime release download searches successful same-SHA Builds runs + until it finds the complete portable/AOT artifact set, so focused reruns do + not shadow earlier complete runs. +- [x] WASIX runtime release download filters same-SHA Builds runs by the + `builders` job before installing portable/AOT runtime outputs. Evidence: + `.github/scripts/download-wasix-runtime-build-artifacts.sh` invokes + `xtask assets download --required-job artifact-builders`, `xtask` verifies the + required job conclusion before trying a run, and + `tools/release/check_artifact_targets.py` enforces the handoff. +- [x] Broker release asset publishing verifies the `oliphaunt-broker` release + tag before uploading artifacts. +- [x] Exact-extension GitHub release verification includes every uploaded + package file: JSON manifest, `.properties` manifest, checksum manifest, and + payload assets. +- [~] Release provenance and attestations cover runtime/helper/extension/WASIX + asset families. Local policy checks pass; full GitHub release dry-run and + verification still need CI evidence. +- [x] Release dry-run/publish rejects incomplete staged exact-extension package + artifacts. Evidence: `tools/release/release.py` validates staged extension + package manifest identity, JSON asset checksums, checksum-manifest entries, + and declared native/WASIX target coverage; `release.py` now has no native, + WASIX, or exact-extension local builder fallback, so missing staged extension + package artifacts fail immediately. + +## Extensions + +- [x] Public selection model is exact SQL extension name only. No packs, + aliases, or grouped selectors. +- [x] Every public extension in the generated SDK catalog is modeled as an + exact-extension release product. Evidence: + `src/extensions/generated/sdk/rust.json` drives + `tools/policy/check-release-policy.py`, which requires the release product + set to match the public catalog. The current graph has 39 exact-extension + products: PostgreSQL contrib products under `src/extensions/contrib//` + and external products under `src/extensions/external//`. +- [x] Exact-extension products have product-local `release.toml`, `VERSION`, + `CHANGELOG.md`, and target metadata. PostgreSQL contrib exact-extension + products depend on `extension-contrib-postgres18` and + `extension-runtime-contract`; external exact-extension products depend on + `extension-runtime-contract` and their product-local source/recipe metadata. +- [x] Exact-extension target metadata must declare every native runtime target + that advertises exact-extension artifact support, with unpublished opt-outs + required when no real producer exists. Published WASIX target coverage remains + exact. Evidence: + `tools/release/check_artifact_targets.py`, + `src/runtimes/liboliphaunt/native/targets/*.toml`, per-extension + `targets/artifacts.toml` rows, and full builder planner output that includes + `windows-x64-msvc` in the native exact-extension artifact matrix with all 39 + exact-extension products selected. +- [x] Native and WASIX extension artifact builders emit target-addressed + release assets consumed by package assembly. +- [x] Exact-extension package assembly is single-path. Release builds the + complete native+WASIX package set; mobile app builders consume target-scoped + package artifacts through the same manifest/checksum/staging code and only + require Android/iOS native targets. +- [x] Exact-extension package assembly reads target-addressed artifact indexes + only from declared published targets, not recursive scratch globs. Evidence: + a stale `target/extensions/native/release-assets/test-mobile` directory no + longer creates duplicate vector package rows. +- [x] Exact-extension package assembly has no broad native-index fallback. + Evidence: `tools/release/build-extension-ci-artifacts.py` now requires + product-scoped target indexes from + `target/extensions/native/release-assets///...` and fails + when required target artifacts are missing. +- [x] Mobile exact-extension package assembly filters to the requested mobile + native targets instead of carrying every downloaded desktop/native artifact + into mobile build handoff artifacts. Evidence: + `python3 tools/release/build-extension-ci-artifacts.py + oliphaunt-extension-vector --output-root + target/extension-artifacts-mobile-validate --require-native-target + android-x86_64 --require-native-target ios-xcframework` stages only + `android-x86_64` and `ios-xcframework` vector assets. +- [x] Exact-extension release packages emit JSON manifest, ecosystem-friendly + `.properties` manifest, and checksum manifest. Evidence: + `tools/release/build-extension-ci-artifacts.py oliphaunt-extension-vector + --output-root target/extension-artifacts-test` staged + `oliphaunt-extension-vector-0.1.0-manifest.properties` and + `oliphaunt-extension-vector-0.1.0-release-assets.sha256`. +- [x] SDK package checks prove wrapper packages do not ship runtime or + extension payloads. Evidence: + `tools/release/check_staged_artifacts.py --inspect-present` validates staged + Swift, Kotlin, React Native, and TypeScript package artifacts, rejects + runtime/share/static-registry payload leaks, and caught then removed a stale + Kotlin debug AAR that embedded smoke runtime/vector assets. SDK staging now + runs `check_staged_artifacts.py --require-sdk-product "$product"` for every + SDK product and stages only the Kotlin release AAR. +- [x] Mobile app artifact checks prove unselected extension files do not enter + app artifacts. Evidence: + `tools/release/check_staged_artifacts.py --require-mobile ios + --require-mobile-prebuilt-extensions` validates the fresh iOS `.app` built + from staged React Native, Swift, liboliphaunt, and exact-extension artifacts; + the checker binds the build report to the inspected app path, byte size, + selected extensions, CocoaPods extension link file lists, and Xcode linked + products. Strict Android prebuilt mode remains pending on Linux-produced + `android-arm64-v8a` vector extension package evidence because the current + local macOS host cannot build that Android target. +- [~] Local staged artifact inspection covers wrapper packages and + exact-extension package shape. Strict iOS installed-app artifact inspection + is now green after rebuilding through the current staged handoff. Remaining + work: produce the matching Android exact-extension package on Linux/CI or + devbox, rebuild/validate the Android mobile artifact against that package, + and then run full `--inspect-present` without stale local Android state. +- [~] Each advertised extension needs current target smoke evidence across + desktop native, mobile static registry targets, and WASIX. Builder targeting + now covers every published native target plus WASIX: full builder planning + emits 7 native target rows, with Windows scoped to contrib products until + external PGXS/PostGIS Windows producers exist, and 1 `wasix-portable` row + carrying the WASIX exact-extension product set. Current smoke evidence is + still transitional/catalog-level and needs real target smoke results from CI + before this item can be marked complete. + +## SDK Contracts + +- [x] Rust, Swift, Kotlin, React Native, TypeScript, and WASIX Rust binding are + peer products with product-local Moon tasks and package artifacts. Evidence: + each product is tagged `release-product`, declares release-please component + metadata, has a product-local `package-artifacts` task, writes to + `target/sdk-artifacts//**/*`, and maps to a `ci-*-sdk-package` + builder tag. `tools/policy/check-moon-product-graph.mjs` enforces the + commands, outputs, cache policy, and CI tag mappings for all six peer SDKs. +- [x] Kotlin SDK package artifacts include an Android-consumable Maven + repository layout for both `oliphaunt-android` and the + `dev.oliphaunt.android` Gradle plugin. Evidence: + `tools/release/build-sdk-ci-artifacts.sh oliphaunt-kotlin` passes and stages + both Maven artifacts under `target/sdk-artifacts/oliphaunt-kotlin/maven`. +- [x] React Native package artifacts exclude native runtime/resource payloads. + Evidence: `src/sdks/react-native/package.json` excludes + `android/src/main/assets/**`, `android/src/main/jniLibs/**`, and + `ios/resources/**`, and staged package validation passes for + `oliphaunt-react-native`. +- [~] React Native is TypeScript/TurboModule glue over Swift and Kotlin, not a + private database runtime. Static checks exist, and the installed-app E2E + workflow now consumes `Builds` app artifacts without rebuilding source. iOS + selected-extension packaging requires exact XCFramework unpacking, + compile-only static-registry source staging, Xcode link evidence, explicit + resource-bundle discovery, and static registry linker retention in the final + app binary. Local iOS installed-app E2E is green for the `vector` selection, + but this remains partial until GitHub Mobile E2E can run against same-SHA + `Builds` artifacts. +- [~] Kotlin Android and Swift iOS/macOS consume liboliphaunt and exact selected + extension artifacts through ecosystem-native package surfaces. Kotlin Android + now resolves exact extension releases independently from `liboliphaunt-native` + and verifies per-extension checksums; Swift base release now explicitly stays + extension-free, no longer advertises nonexistent `OliphauntExtension*` + SwiftPM products, renders its release manifest from the CI-built Apple + liboliphaunt XCFramework artifact, and exposes + `OliphauntExtensionArtifactResolver.resolveNativeArtifacts(...)` to select the + exact target artifact names and dependency closure for Swift app integrations. + Swift SDK package artifact creation now passes against a deterministic + release-shaped Apple XCFramework fixture, proving the public SwiftPM + manifest/package boundary locally. React Native iOS now has strict link-aware + selected-extension package inspection plus local release-mode installed-app + proof using the real native Apple XCFramework artifact and exact-extension + package handoff. Remaining work: same-SHA GitHub Mobile E2E evidence after + the workflow is available on the default branch. +- [x] TypeScript package artifacts stay SDK-scoped. Evidence: + `tools/release/build-sdk-ci-artifacts.sh oliphaunt-js` stages the npm tarball + and JSR source only; the affected planner now selects only `js-sdk-package` + for `oliphaunt-js:package-artifacts`. Broker and Node-direct helper artifacts + are built and downloaded only when the helper products themselves are being + released. +- [x] Node direct optional npm packages are built in the Builds workflow and + published from staged tarballs. Evidence: + `src/runtimes/node-direct/tools/build-node-addon.sh` emits both + `target/oliphaunt-node-direct/release-assets/*` and + `target/oliphaunt-node-direct/npm-packages/*.tgz`; the release workflow + downloads `oliphaunt-node-direct-npm-package-*`; `release.py` validates and + publishes those tarballs directly. +- [x] WASIX Rust binding package artifacts stay binding-scoped and do not force + a runtime rebuild. Evidence: + `MOON_BIN=$HOME/.proto/shims/moon + .github/scripts/run-moon-targets.sh oliphaunt-wasix-rust:package-artifacts` + passes. The builder packages the root `oliphaunt-wasix` crate through a + narrowed WASIX workspace package set so Cargo sees the same-release internal + asset/AOT crates, stages only `oliphaunt-wasix-0.5.1.crate` plus package-file + metadata under `target/sdk-artifacts/oliphaunt-wasix-rust`, and + `python3 tools/release/check_staged_artifacts.py --require-sdk-product + oliphaunt-wasix-rust` validates that the SDK artifact does not carry runtime + payloads. + +## Tool Entrypoints And Policy + +- [x] Repository tasks are Moon-first. Root package-manager aliases are not the + public orchestration surface. +- [x] pnpm remains JS dependency/package-manager tooling, not the global graph. +- [x] Cargo, SwiftPM/Xcode, Gradle, npm/JSR, and Expo are invoked through + product-local Moon tasks or product-owned scripts. +- [x] Policy checks reject stale release graphs, root product aliases, broad + generated-state inputs, and mobile source-build fallbacks. +- [x] Policy checks reject retired release-tool references on active product, + workflow, and release surfaces. Evidence: + `tools/policy/check-final-source-architecture.py --self-test` scans tracked + `src`, `.github`, and `tools/release` files for retired `release-plz` and + `git-cliff` references while allowing the architecture/tooling docs to name + retired surfaces as policy. +- [~] Policy scripts should remain minimal and guard real architecture + invariants only. Continue pruning brittle substring checks as better + structural checks become available. + +## Verification Commands + +Run before claiming this architecture complete: + +- [x] `bash -n tools/release/build-sdk-ci-artifacts.sh + src/sdks/swift/tools/check-sdk.sh` +- [x] `python3 -m py_compile tools/release/release.py + tools/release/build-extension-ci-artifacts.py tools/graph/ci_plan.py + tools/release/check_artifact_targets.py + tools/release/check_release_metadata.py` +- [x] `python3 tools/graph/graph.py check` +- [x] `node tools/policy/check-moon-product-graph.mjs` +- [x] `python3 tools/release/check_artifact_targets.py` +- [x] `python3 tools/policy/check-release-policy.py` +- [x] `moon run ci-workflows:check graph-tools:check` +- [x] `MOON_BIN=$HOME/.proto/shims/moon + .github/scripts/run-moon-targets.sh ci-workflows:check` +- [x] `bash src/sdks/react-native/tools/check-sdk.sh build-android-bridge` +- [x] `moon run policy-tools:check release-tools:check graph-tools:check` +- [x] `MOON_BIN=$HOME/.proto/shims/moon + .github/scripts/run-moon-targets.sh extensions:check` +- [x] `MOON_BIN=$HOME/.proto/shims/moon + .github/scripts/run-moon-targets.sh extension-model:check + extension-artifacts-native:check extension-artifacts-wasix:check` +- [x] `moon query projects` +- [x] `moon query tasks` +- [x] `moon run :check` completed locally with 84 tasks, 72 cached, after + builder-first CI policy, conditional matrix emission, and JS staged-artifact + release checks were aligned. +- [x] `moon run :test` completed locally with 28 tasks, including native + host smoke, Rust nextest, Swift tests, Kotlin Gradle tests, React Native and + TypeScript Vitest suites, docs tests, broker tests, and WASIX Rust tests. +- [x] `moon run :package` completed locally with 14 tasks, 5 cached. This now + verifies local package shape only; publishable SDK artifact envelopes use + explicit `package-artifacts` builder tasks, and runtime/extension/mobile + artifacts stay in target-scoped builder jobs. +- [x] `python3 tools/graph/ci_plan.py` for a full run now selects only + `affected` plus 21 artifact-producing builder jobs. WASIX AOT target fan-out + is emitted by the affected plan as + `liboliphaunt_wasix_aot_runtime_matrix`; there is no separate AOT planner job + in the Builds workflow. +- [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=all + WASM_TARGET=linux-x64-gnu MOBILE_TARGET=all + python3 .github/scripts/plan-affected.py` now selects only + `affected`, `liboliphaunt-wasix-runtime`, and `liboliphaunt-wasix-aot`; + it does not select `liboliphaunt-wasix-release-assets`, + `wasix-rust-package`, SDK packages, extension packages, or mobile builders. + The emitted AOT matrix contains the single friendly target id + `linux-x64-gnu`. +- [x] `tools/release/release.py plan` +- [x] `tools/release/release.py check` +- [x] `tools/release/release.py consumer-shape --format json --require-ready + --products-json '["oliphaunt-swift"]'` +- [x] `tools/release/release.py publish-dry-run --products-json + '["oliphaunt-extension-vector"]' --head-ref HEAD` fails closed when the + staged exact-extension package is incomplete or missing. +- [x] `python3 tools/release/artifact_target_matrix.py + liboliphaunt-wasix-aot-runtime` emits friendly `target_id` values for every + WASIX AOT builder target from product-local target metadata. +- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-js` +- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-kotlin` +- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-react-native` +- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-rust` +- [x] `tools/release/build-sdk-ci-artifacts.sh oliphaunt-wasix-rust` +- [x] `MOON_BIN=$HOME/.proto/shims/moon + .github/scripts/run-moon-targets.sh oliphaunt-rust:package-artifacts` +- [x] `MOON_BIN=$HOME/.proto/shims/moon + .github/scripts/run-moon-targets.sh oliphaunt-wasix-rust:package-artifacts` +- [x] `OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR=$PWD/target/test-fixtures/liboliphaunt-swift-release + tools/release/build-sdk-ci-artifacts.sh oliphaunt-swift` passes against a + deterministic release-shaped liboliphaunt fixture whose Apple SwiftPM + XCFramework zip has macOS, iOS device, and iOS simulator slices. This proves + the Swift SDK package artifact path renders a checksum-pinned public + `Package.swift.release`, stages `Oliphaunt-source.zip`, and passes + `python3 tools/release/check_staged_artifacts.py --require-sdk-product + oliphaunt-swift`. The CI `liboliphaunt-native-ios` builder still owns proof + that the real native Apple XCFramework asset is produced. +- [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=all + WASM_TARGET=all MOBILE_TARGET=ios python3 .github/scripts/plan-affected.py` +- [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=all + WASM_TARGET=all MOBILE_TARGET=android python3 .github/scripts/plan-affected.py` +- [x] `tools/graph/ci_plan.py` direct probe for + `{"extension-artifacts-native:build-target"}` selects + `extension-artifacts-native` without `liboliphaunt-native`, proving extension + artifact-only work does not create a native-runtime waterfall. +- [x] `tools/graph/ci_plan.py` direct probes for + `oliphaunt-react-native:mobile-build-android` and + `oliphaunt-react-native:mobile-build-ios` select only Android or iOS native + extension artifacts respectively. +- [x] `tools/graph/ci_plan.py` direct probe for + `oliphaunt-react-native:package-artifacts` selects + `react-native-sdk-package`, `mobile-build-android`, `mobile-build-ios`, + `kotlin-sdk-package`, `swift-sdk-package`, Android/iOS native runtime + builders, and `mobile-extension-packages`; native target selection is exactly + `android-arm64-v8a`, `android-x86_64`, and `ios-xcframework`. +- [x] `tools/graph/ci_plan.py` direct probe for a single + `oliphaunt-extension-postgis` change with aggregate artifact/package tasks + selects only `oliphaunt-extension-postgis`, emits 6 native rows, and emits 1 + WASIX row. +- [x] `python3 tools/release/check_staged_artifacts.py + --require-sdk-product oliphaunt-rust` +- [x] `python3 tools/release/check_staged_artifacts.py + --require-sdk-product oliphaunt-kotlin` +- [x] `python3 tools/release/check_staged_artifacts.py + --require-sdk-product oliphaunt-swift` +- [x] `python3 tools/release/check_staged_artifacts.py + --require-sdk-product oliphaunt-react-native` +- [x] `python3 tools/release/check_staged_artifacts.py + --require-sdk-product oliphaunt-js` +- [x] `python3 tools/release/check_staged_artifacts.py + --require-sdk-product oliphaunt-wasix-rust` +- [x] `python3 tools/release/check_staged_artifacts.py --require-mobile ios + --require-mobile-prebuilt-extensions` passes after rebuilding + `pnpm --dir src/sdks/react-native/examples/expo run mobile-build:ios` with + staged SDK, native runtime, and exact-extension artifacts. The fresh app + keeps generated static-registry C under compile-only + `ios/generated/static-registry`, bundles runtime resources under + `OliphauntReactNativeResources.bundle`, and proves selected + `liboliphaunt_extension_vector` linkage through CocoaPods and Xcode build + products. +- [x] Local iOS release-mode installed-app E2E passes with exact-extension + `vector` using the staged native Apple XCFramework, generated exact-extension + package, and clean simulator install: + `OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS=0 + OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS=1 + OLIPHAUNT_EXPO_IOS_EXTENSIONS=vector + OLIPHAUNT_EXPO_IOS_REPACK_RN=1 + bash src/sdks/react-native/tools/mobile-build.sh ios`, followed by + `bash src/sdks/react-native/tools/mobile-e2e.sh ios` against + `target/mobile-build/react-native/ios-local-vector-fix-v4`. The app launches + from `OliphauntReactNativeResources.bundle`, the bundled template PGDATA uses + `dynamic_shared_memory_type = mmap`, UTC time zones, and `C` locale settings, + the final app binary contains + `_liboliphaunt_selected_static_extensions` plus vector registry symbols, and + Maestro sees `liboliphaunt-smoke-status-passed`. +- [x] `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=ios-xcframework + WASM_TARGET=all MOBILE_TARGET=all python3 .github/scripts/plan-affected.py` +- [x] Focused mobile builder plans are target-consistent: + `GITHUB_EVENT_NAME=workflow_dispatch NATIVE_TARGET=android-arm64-v8a + WASM_TARGET=all MOBILE_TARGET=android python3 .github/scripts/plan-affected.py` + emits one Android exact-extension row, one Android app row, and + `mobile_extension_package_native_targets=["android-arm64-v8a"]`; the matching + iOS probe emits only `ios-xcframework`. Incompatible focused inputs such as + `MOBILE_TARGET=android NATIVE_TARGET=ios-xcframework` now fail closed in the + planner. +- [x] Android SDK provisioning is shared and reproducible. Evidence: + `.github/actions/setup-android` calls `tools/dev/setup-android-sdk.sh`; the + script bootstraps Android command-line tools when `sdkmanager` is absent, + installs the pinned platform-tools/platform/build-tools/CMake/NDK packages + through `sdkmanager`, and passes idempotently on the local Android SDK with + NDK `27.0.12077973`, CMake `3.22.1`, and compile SDK `36`. +- [x] `bash src/sdks/kotlin/tools/check-sdk.sh check-static` +- [x] `bash src/runtimes/node-direct/tools/build-node-addon.sh` +- [x] `python3 tools/release/build-extension-ci-artifacts.py + oliphaunt-extension-vector --output-root target/extension-artifacts-validate + --require-native-target android-x86_64 --require-native-target + ios-xcframework` +- [x] `./gradlew :oliphaunt-android-gradle-plugin:compileJava :oliphaunt:tasks --no-daemon` +- [x] `swift test --package-path src/sdks/swift --scratch-path + target/swift-test-extension-resolver-2` +- [x] `tools/release/release.py publish-dry-run` passes in public no-product + policy/metadata mode. Product-scoped dry-runs still require staged builder + artifacts from the same-SHA `Builds` workflow and remain covered by the + release workflow evidence items below. +- [x] Local builder-architecture checks passed after the builder-first CI + cleanup: `git diff --check`, `actionlint .github/workflows/ci.yml + .github/workflows/mobile-e2e.yml .github/workflows/release.yml`, + `node tools/policy/check-moon-product-graph.mjs`, + `bash tools/policy/check-tooling-stack.sh`, + `python3 tools/release/check_artifact_targets.py`, and + `python3 tools/policy/check-release-policy.py`. +- [x] Local PR 38 CI hardening checks passed after fixing the observed builder + failures: `cargo run -p xtask -- assets verify-committed`, `bash -n` for the + touched native scripts, `sh -n src/runtimes/node-direct/tools/build-node-addon.sh`, + `actionlint .github/workflows/ci.yml .github/workflows/mobile-e2e.yml + .github/workflows/release.yml`, `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `node tools/policy/check-moon-product-graph.mjs`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `bash tools/policy/check-sdk-parity.sh`, and + `bash tools/policy/check-repo-structure.sh`. +- [x] Local PR 38 Windows builder follow-up checks passed after making native + extension source fetch skip PostgreSQL preparation and making the Windows base + runtime prune exact-extension artifacts before packaging: + `bash -n src/extensions/artifacts/native/tools/package-release-assets.sh`, + `git diff --check`, `cargo run -p oliphaunt --bin oliphaunt-resources + --locked -- --list-extensions`, `cargo run -p xtask -- assets + verify-committed`, `actionlint .github/workflows/ci.yml + .github/workflows/mobile-e2e.yml .github/workflows/release.yml`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `node tools/policy/check-moon-product-graph.mjs`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `bash tools/policy/check-sdk-parity.sh`, `bash tools/policy/check-repo-structure.sh`, + and `bash src/runtimes/node-direct/tools/check-package.sh check-static`. + PowerShell parsing/execution still needs the GitHub Windows runner because + `pwsh` is not installed in this macOS worktree. +- [x] GitHub Builds run `27380605889` on `ff25ab64` proved the next CI-only + blockers after the Windows pruning patch: Windows desktop source fetch still + prepared the WASIX PostgreSQL tree, Windows exact-extension Meson setup needed + WinFlexBison provisioning, and WASIX runtime artifact tasks were skipped by + `runInCI: skip`. The follow-up patch makes every native-runtime source fetch + that only needs pinned checkouts pass `--skip-postgres-prepare`, lets Windows + native extension builders run `.github/scripts/setup-native-build-tools.sh`, + marks WASIX artifact-producing Moon tasks `runInCI: true`, and extends the + executable ownership policy to `src/extensions/artifacts/packages/tools/*`. + Local checks after this patch passed: `bash -n` for touched shell scripts, + `actionlint .github/workflows/ci.yml .github/workflows/mobile-e2e.yml + .github/workflows/release.yml`, `node tools/policy/check-moon-product-graph.mjs`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `bash tools/policy/check-repo-structure.sh`, + `node tools/policy/check-test-strategy.mjs`, + `cargo run -p xtask -- assets verify-committed`, + `bash tools/policy/check-tooling-stack.sh`, and `git diff --check`. +- [x] GitHub Builds run `27381488172` on `9181f71a` proved the next CI-only + blockers: Apple native extension jobs failed on macOS Bash 3.2 empty-array + expansion in `build-postgres18-macos.sh`, the iOS XCFramework validator + incorrectly applied mobile-forbidden shm/semaphore checks to the macOS + framework slice, and the Windows native extension row selected external + PGXS/PostGIS products even though Windows has no producer for those sources. + The Android native extension jobs also reached the combined mobile static + link and failed on duplicate `difference` / `pg_finfo_difference` symbols + exported by contrib `fuzzystrmatch` and PostGIS `postgis_legacy.o`. + The follow-up patch guards Apple empty-array expansion, limits the mobile API + import check to iOS/iOS simulator slices, marks external PGXS/PostGIS Windows + targets unpublished with explicit reasons, and relaxes policy to require + explicit native target opt-outs rather than false published coverage. It also + namespaces PostGIS legacy mobile static symbols, records those mappings as + exact-extension `staticSymbolAliases`, and teaches generated mobile static + registry source to keep SQL-visible symbol names while pointing at aliased + link-time identifiers. Local checks after this patch passed: `bash -n` for + touched shell scripts, `cargo check --manifest-path src/sdks/rust/Cargo.toml + --locked`, focused Rust static-registry alias test, `git diff --check`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, `python3 -m py_compile` for + touched Python release/graph modules, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `python3 tools/release/artifact_target_matrix.py extension-artifacts-native`, + and `tools/release/release.py consumer-shape --format json --require-ready + --products-json '["oliphaunt-extension-vector"]'`. +- [x] GitHub Builds run `27383810080` on `d7ad6eca` proved the next CI-only + blockers: the WASIX runtime committed asset-input fingerprint was stale, + Android x86_64 and Linux arm64 native-runtime source fetches failed through + `ftpmirror.gnu.org` libiconv 502s, Apple extension runtime builds passed the + embedded `-bundle_loader` through `PG_LDFLAGS` so PostgreSQL's Darwin default + `BE_DLLLIBS=-bundle_loader ../../src/backend/postgres` won and failed when + the backend executable link was intentionally tolerated, and the Windows + exact-extension row still selected `pgcrypto` even though the current Windows + runtime disables SSL/OpenSSL and does not package that dependency. The + follow-up refreshes `asset-inputs.sha256`, switches libiconv inputs to the + canonical GNU URL, routes Darwin PGXS embedded bundle-loader wiring through + `BE_DLLLIBS`, keeps Bash strict-mode arrays guarded by explicit counts, and + marks Windows `pgcrypto` unpublished with an OpenSSL runtime dependency + reason. The native exact-extension matrix now keeps Android/iOS/Linux/macOS + at 39 products and Windows at 31 before the next Windows `uuid-ossp` follow-up. + Local evidence after this patch passed: + focused macOS `OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES=amcheck` + `build-postgres18-macos.sh`, `bash -n` for touched shell scripts, + `cargo run -p xtask -- assets verify-committed`, + `bash tools/policy/check-source-inputs.sh`, + `node tools/policy/check-source-inputs.mjs`, + `python3 src/extensions/tools/check-extension-model.py --check`, + `python3 tools/release/check_artifact_targets.py`, + `python3 tools/policy/check-release-policy.py`, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `node tools/policy/check-moon-product-graph.mjs`, and `git diff --check`. +- [x] Builds workflow green on PR for affected builder jobs, including + Android/iOS release-mode mobile build jobs when selected. +- [x] GitHub Builds run `27384916687` on `eab81d45` proved the Windows + `pgcrypto` opt-out but exposed one more Windows native exact-extension + metadata gap: the row still selected `uuid-ossp`; the Windows MSVC producer + reached `oliphaunt-extension-artifact` for `uuid-ossp` and failed because the + staged runtime lacked the required control and SQL install files. The same run + exposed a Darwin external PGXS `pg_textsearch` link gap: its `SHLIB_LINK=-lm` + override dropped the embedded `BE_DLLLIBS=-bundle_loader ...` path after the + tolerated backend executable link failure, so iOS/macOS builds saw unresolved + PostgreSQL backend symbols. The follow-up marks the `uuid-ossp` Windows target + unpublished with an explicit UUID dependency/control-file reason, reduces the + Windows native exact-extension row to 30 products until the MSVC producer has + portable UUID packaging support, and folds Darwin `pg_textsearch`/`pgvector` + `-lm` linkage into `BE_DLLLIBS` when the embedded bundle-loader path is + active. Local focused macOS + `OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES=pg_textsearch` + `build-postgres18-macos.sh` passed, and `otool -L` on the packaged module + shows `@rpath/liboliphaunt.dylib`. +- [x] GitHub Builds run `27386002923` on `3397cd67` proved the Windows + `uuid-ossp` opt-out and Darwin PGXS link fix: Windows exact-extension + `windows-x64-msvc` and macOS exact-extension `macos-arm64` both passed. The + same run exposed two mobile exact-extension blockers: Android + `android-arm64-v8a` and `android-x86_64` failed because PostGIS + `postgis_legacy.o` exports token-pasted + `pg_finfo_oliphaunt_static_postgis_3_difference`, while the static alias + metadata expected `oliphaunt_static_postgis_3_pg_finfo_difference`; iOS + `ios-xcframework` failed because mobile static contrib compiles such as + `dict_xsyn` did not inherit PostgreSQL ICU include flags and could not find + `unicode/ucol.h`. The follow-up maps `pg_finfo_difference` to the + token-pasted PostGIS symbol in both mobile object staging and release + artifacts, and feeds `icu_cflags` into iOS/Android mobile static extension + compiles. Local evidence after this patch passed: focused Android arm64 + `OLIPHAUNT_MOBILE_STATIC_EXTENSIONS=postgis` produced + `target/mobile-smoke-android-arm64-postgis/out/liboliphaunt.so` with + `symbol-aliases.list` mapping `pg_finfo_difference` to + `pg_finfo_oliphaunt_static_postgis_3_difference`, `llvm-nm` proved the + matching `postgis_legacy.o` symbols, and focused iOS simulator/device + `OLIPHAUNT_MOBILE_STATIC_EXTENSIONS=dict_xsyn` builds produced + `out/liboliphaunt.dylib` while compiling `dict_xsyn.o` with the matching + `icu-ios-*/include` path. +- [x] GitHub Builds run `27388349669` on `274f86a6` proved the Android + PostGIS alias and mobile static ICU fixes: native exact-extension + `android-arm64-v8a`, `android-x86_64`, Linux, macOS, and Windows rows all + passed, as did WASIX runtime and WASIX exact-extension packaging. The run + exposed the next iOS-only packaging blocker: `ios-xcframework` got past the + `dict_xsyn` ICU compile and device static archive production, then failed in + `build-ios-extension-xcframeworks.sh` because Bash 3.2 plus `set -u` treats + empty selected dependency arrays as unbound during XCFramework manifest and + dependency packaging. The follow-up guards the empty-array expansions for + selected dependencies/extensions/stems and adds a mobile policy assertion for + the strict-mode guard. Local evidence after this patch passed: `bash -n` for + the touched shell scripts, `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `git diff --check`, and a focused `dict_xsyn` iOS XCFramework packaging + smoke that produced simulator/device archives under + `target/ios-xcframework-dict-xsyn-smoke/out` with `dependencies=` in the + manifest. +- [x] GitHub Builds run `27390718093` on `964cc35` proved the iOS + XCFramework manifest/dependency guard got past the previous + `selected_dependencies[@]` failure, but exposed the same Bash 3.2 + strict-mode class in native extension release packaging: + `package-release-assets.sh` failed at `mobile_dependency_args[@]` while + packaging iOS products with no mobile dependency archives. The follow-up + guards empty `mobile_dependency_args` and `extra_args` expansions in both + iOS and Android package paths, and pins those guards in the mobile extension + surface policy. Local evidence after this patch passed under Bash 3.2: + unguarded empty-array harness reproduces `mobile_dependency_args[@]: unbound + variable`, the guarded harness passes, and the exact CI-shaped + `extension-artifacts-native:build-target` command for `ios-xcframework` + completed all 39 selected products with `/bin/bash` 3.2 selected by PATH. +- [x] GitHub Builds run `27392985628` on `e831c4a9` proved the native + exact-extension matrix and mobile extension package assembly are now green, + including `ios-xcframework`, both Android exact-extension targets, and + `build-mobile-extension-packages`. The run exposed the next mobile app + builder regression: `mobile-build-ios`, `mobile-build-android + (android-arm64-v8a)`, and `mobile-build-android (android-x86_64)` failed + before emitting runner logs, with the aggregate `artifact-builders` and + `required` jobs failing only because those builders failed. Local + reproduction with downloaded CI iOS native/SDK/mobile-extension inputs + showed the silent exit came from + `static_registry_source="$(mobile_static_registry_source_for_library ...)"` + when the prebuilt liboliphaunt artifact does not have an adjacent + `liboliphaunt_mobile_static_registry.c`; under `set -e` the helper's final + missing-file test returned 1 and aborted the script without a diagnostic. + The follow-up makes Android and iOS static-registry lookup return success + with an empty result so exact-extension packages can provide the generated + static registry source, and also hardens React Native runner empty-array + expansions under Bash 3.2 strict mode. Local evidence after this patch + passed: Bash syntax checks for the touched runner/policy scripts, + `bash tools/policy/check-sdk-mobile-extension-surface.sh`, + `node tools/policy/check-moon-product-graph.mjs`, + `python3 tools/release/check_artifact_targets.py`, `git diff --check`, + CI-artifact package lookup for the selected `vector` iOS XCFramework zip, + and the iOS mobile-build entrypoint progressed to + `Preparing iOS runtime resources from exact-extension package artifacts: + vector`; the remaining local stop is expected because the downloaded CI + `initdb` dylib install names point at the GitHub runner workspace path. +- [x] GitHub Builds run `27400178224` on `e494777f` proved the previous silent + React Native mobile runner abort is fixed and that the upstream builder + chain stayed green: native runtime Android/iOS rows, Android/iOS native + exact-extension rows, and `build-mobile-extension-packages` all passed. The + remaining leaf failures were the three mobile app builders, all with the + same CI-only terminal error: + `oliphaunt-resources: run native PGDATA template initdb: Permission denied`. + The failure occurs after GitHub artifact handoff, before Xcode or Gradle app + packaging, and is consistent with downloaded native runtime artifacts losing + executable bits on PostgreSQL tools such as `bin/initdb`. +- [x] GitHub Builds run `27403805978` on `aac266ca` selected the mobile + artifact/app lanes again after the runtime tool permission fix and kept the + native runtime, native exact-extension, SDK, WASIX runtime, and portable + WASIX extension builders green. The next direct failure is + `build-liboliphaunt-wasix-aot (windows-x64-msvc)` failing before compilation + in `.github/actions/setup-wasmer-llvm` while downloading + `llvm-windows-amd64.tar.xz` with `curl: (52) Empty reply from server`; the + remaining AOT/package/mobile rows were still running or waiting. The + follow-up hardens the Wasmer LLVM installer with the same retry-all-errors + and connection-timeout policy used by other repo downloaders. +- [~] GitHub Builds run `27406731304` on `682840b2` is the current verification + run for the Wasmer LLVM download hardening. Early evidence is clean: + `build-plan` passed, early fan-out jobs are succeeding, and no completed + failures were reported while native runtime, native extension, and WASIX + runtime rows continued running. The Windows AOT row reached and passed + `Install Wasmer LLVM 22.1 for AOT generation`, proving the retry-hardened + setup step clears the previous `curl: (52) Empty reply from server` blocker. + AOT/package aggregation advanced further, but `mobile-build-ios` then failed + in `src/sdks/react-native/tools/expo-ios-runner.sh` with + `mapfile: command not found` on macOS Bash 3.2 after successfully preparing + iOS runtime resources from exact-extension package artifacts. The follow-up + replaces that `mapfile` use with a Bash 3-compatible read loop and adds a + policy guard against reintroducing Bash 4-only `mapfile`/`readarray` usage in + the iOS mobile runner. This run must still prove AOT artifact completion, + mobile app builders, and required aggregate before the CI evidence can be + marked complete. +- [x] GitHub Builds run `27410008857` on `443bf1b8` completed successfully + with all 44 PR checks green. This proves the Wasmer LLVM setup retry + hardening, all native/WASIX runtime and exact-extension builders, AOT + artifact fan-out, artifact/package aggregation, `mobile-build-ios`, both + Android mobile build rows, `artifact-builders`, and `required` on the same + SHA. The iOS mobile app build advanced past the previous macOS Bash 3.2 + `mapfile` failure and published the release-mode simulator app artifact. +- [x] Local installed-app iOS validation after the green `Builds` run exposed + runtime issues that artifact inspection alone did not catch, then proved the + local fixes. The failure chain was: React Native resources existed under + `OliphauntReactNativeResources.bundle` but Swift/native-direct bundle + discovery did not find them; the local `pnpm pack` path excluded + `ios/resources/**` and did not inject staged mobile assets after install; Rust + exact-extension packaging generated a host-locale PGDATA template with + `dynamic_shared_memory_type = posix`; the iOS app did not force-link the pure + C selected-extension registry object, so `CREATE EXTENSION vector` could not + resolve `vector`; and simulator data-container reuse could preserve stale bad + PGDATA. The follow-up adds explicit RN/iOS resource-bundle discovery, shared + staged asset injection for artifact and local pack installs, mobile-safe Rust + template initdb/config normalization with a template cache bump, clean + simulator reinstall by default, and `-u + _liboliphaunt_selected_static_extensions` when generated static-registry + sources are present. Local release-mode iOS app build and installed-app E2E + now pass for selected `vector`. +- [x] GitHub Builds run `27420575821` on `ed24c6e` picked up the installed-app + fixes but exposed a new Android SDK provisioning flake before any native + Android source build: `build-native-runtime-android (android-x86_64)` failed + during `.github/actions/setup-android` while `sdkmanager` downloaded NDK + `27.0.12077973`, ending with `Error on ZipFile unknown archive` at 21% + download/unzip progress. The follow-up hardens the shared + `tools/dev/setup-android-sdk.sh` package install with bounded sdkmanager + retries and cleanup of partial selected platform/build-tools/CMake/NDK + directories before retrying, and pins that invariant in + `tools/policy/check-tooling-stack.sh`. Local evidence after this patch passed: + `bash -n tools/dev/setup-android-sdk.sh tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-tooling-stack.sh`, and `git diff --check`. A + replacement `Builds` run is required because `27420575821` cannot be the green + builder evidence. +- [x] GitHub Builds run `27420928633` on `e3b9667` completed successfully with + all 44 job conclusions green. This proves the Android SDK retry hardening + cleared the previous corrupt NDK download/setup failure, and that the full + builder graph still passes afterward: native Android/iOS/desktop runtime + artifacts, native/WASIX exact-extension artifacts, WASIX portable and AOT + runtime artifacts, exact-extension package aggregation, mobile + exact-extension packages, iOS and Android release-mode mobile app builders, + `artifact-builders`, and `required`. +- [x] GitHub Builds run `27425676382` on `b9320719` completed successfully with + all 44 job conclusions green after recording the `27420928633` evidence. The + latest PR-head builder evidence remained fully green for native runtime, + WASIX runtime/AOT, exact-extension, SDK, mobile app, `artifact-builders`, and + `required` jobs before the WASIX release version bump below. +- [x] Local release version freshness no longer blocks the selected product + closure. `tools/release/check_release_versions.py --products-json + "$(cat target/release-dry-run-local/products.json)" --head-ref HEAD` first + failed because `liboliphaunt-wasix` and `oliphaunt-wasix-rust` still used + `0.5.1` while legacy tag `0.5.1` points at the old release commit. The + follow-up bumps both products to `0.6.0`, updates the WASIX runtime asset/AOT + crates, pins `oliphaunt-wasix` runtime crate dependencies to `=0.6.0`, refreshes + root and Tauri example lockfiles, and updates the optional perf-runner + dependency. Local checks passed after the bump: `tools/release/release.py + check`, `tools/release/sync-example-lockfiles.py --check`, `cargo metadata + --locked --format-version 1 --no-deps`, `tools/release/release.py + check-registries --products-json "$(cat + target/release-dry-run-local/products.json)" --head-ref HEAD`, and + `git diff --check`. +- [x] The WASIX Rust publishing surface now uses the WASIX product name instead + of the generic WASM name. The public Cargo package is `oliphaunt-wasix`, the + Rust crate/import identifier is `oliphaunt_wasix`, the internal payload crates + publish as `oliphaunt-wasix-assets` and `oliphaunt-wasix-aot-*`, and CI/release + artifact paths use `target/oliphaunt-wasix`. Local evidence: hidden-file-aware + scan for the retired WASM package/import spellings returns no source matches, + `cargo metadata --locked --format-version 1 --no-deps` resolves the renamed + packages, `tools/release/release.py check` passes, and + `tools/release/release.py check-registries --products-json "$(cat + target/release-dry-run-local/products.json)" --head-ref HEAD` reports + `crates:oliphaunt-wasix@0.6.0` plus the renamed internal WASIX crates. +- [x] GitHub Builds run `27434296236` on `cf0ef3f2` proved the WASIX rename + commit still had a stale committed WASIX asset-input fingerprint. The + `build-liboliphaunt-wasix-runtime` job failed during + `cargo run -p xtask -- assets verify-committed` with computed fingerprint + `aed54dc5dbe84544a6627a5fe30d8a7670ea670558e0bc184d57061f8848911e` while + `src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256` + still held `183cff37e33e3349577c6061a85e9ee96a2e30ee5dfeddc93b0eb7789a1f926a`. + The follow-up refreshes the committed fingerprint with + `cargo run -p xtask -- assets input-fingerprint --write`. A replacement + same-SHA `Builds` run is required because `27434296236` cannot be green + builder evidence. +- [x] GitHub Builds run `27448574605` on `927457d3` proved the native + exact-extension source-fetch gap after decoupling artifact packaging from Rust: + all native extension rows failed before build work because CI runs + `extension-artifacts-native` with `OLIPHAUNT_MOON_UPSTREAM=none`, so the + `source-inputs:source-fetch-native-runtime` dependency did not materialize ICU, + OpenSSL, and extension checkouts. The follow-up makes source checkout + materialization a Bun-native Git/curl/tar script, removes Cargo/`xtask` inputs + from source-fetch Moon tasks, wires explicit Bun source fetches into native + extension/native runtime/WASIX runtime CI jobs, and keeps standalone native + release helpers on the same source-fetch path. A replacement run on `3ffaaae` + proved the missing-checkout failure was gone and exposed a Windows Git Bash + `tar` absolute-drive path issue during libiconv archive extraction; the fetcher + now passes workspace-relative forward-slash paths to `tar`. The next + replacement run on `cb43c96` proved that fix and got Windows PostGIS to SQL + generation, where the producer still treated `uninstall_rtpostgis.sql` as a + source template; the Windows path now mirrors the PostGIS Makefile by + preprocessing `rtpostgis.sql` and generating `uninstall_rtpostgis.sql` through + `utils/create_uninstall.pl`. The `1e88feb` retry got past that point and failed + later because `extensions/postgis_extension_helper.sql` was not generated; the + Windows path now preprocesses `postgis_extension_helper.sql.in` before composing + extension upgrade SQL. The `d3cb9bd` replacement run `27451612217` got past + the missing helper but failed because the handwritten SQL preprocessor treated + a commented usage example inside `libpgcommon/sql/AddToSearchPath.sql.inc` as a + live recursive include; the preprocessor now ignores directives while inside + block comments and reports explicit include cycles. The `00f268f` replacement + run `27468133225` proved that fix and all non-Windows native extension rows + passed, but Windows then failed compiling the bundled PostGIS FlatGeobuf C++ + shim because `liblwgeom.h` includes `proj.h`; the FlatGeobuf compile now uses + the pinned PROJ include directory and native command log tails are written to + stderr so follow-up CI failures show the compiler diagnostic directly. The + `5031bb9` replacement run `27471755925` got past that include gap and failed + later in `postgis-flatgeobuf-geometrywriter.log` because `geometrywriter.h` + includes `lwgeom_log.h` before `liblwgeom.h`, so MSVC saw PostGIS' + GCC-style `__attribute__((format(...)))` declarations before PostGIS' + compatibility define was visible. The FlatGeobuf compile now force-includes a + build-local MSVC compatibility header for that bundled C++ shim. The + `8702131` replacement run `27473015280` proved that compatibility header and + advanced to `postgis-flatgeobuf-geometryreader.log`, where MSVC then rejected + GCC's C++ compound-literal extension in `POINT4D` assignments; the Windows + producer now patches those two checked-out FlatGeobuf assignments to explicit + `POINT4D.x/y/z/m` field writes before compiling the shim. The `2cb5864` + replacement run `27473624660` proved the FlatGeobuf static library build + completed and then failed at Meson setup because the handwritten Windows + module listed optional `doc/postgis_comments.sql` without generating it; the + Windows producer now materializes that optional comments SQL before writing the + Meson module. The `2e42999` replacement run `27474227200` proved that Meson + setup and all non-Windows native extension rows plus Windows WASIX AOT passed, + then failed compiling `pg_textsearch` because MSVC saw SQL-callable type + functions declared without `PGDLLEXPORT` in `types/vector.h` and + `types/query.h` before their `PG_FUNCTION_INFO_V1` definitions; the Windows + source patch now gives those declarations the same exported linkage as the + generated function-info declarations. The `d30dcd7f` replacement run + `27476719363` proved that `pg_textsearch` compiles and again left only the + Windows native extension row failing; PostGIS proper then reached Meson + compilation and failed because its module did not get the FlatGeobuf + MSVC compatibility shim, so `lwgeom_log.h`/`lwgeom_pg.h` exposed GCC + `__attribute__` annotations, and because PostGIS has many SQL-callable + forward declarations without `PGDLLEXPORT`. The Windows PostGIS producer now + force-includes a small MSVC compatibility header for the Meson module and + normalizes checked-out PostGIS `Datum ... (PG_FUNCTION_ARGS);` declarations + to exported linkage before build. The `27fae19b` replacement run + `27488767029` proved the compatibility header is present in PostGIS compile + commands and cleared the `__attribute__` failure, while showing that the first + PowerShell traversal used `Get-ChildItem -Include` in a way that silently did + not patch declarations on CI; the producer now uses an explicit extension + filter, a lower no-op count guard, and targeted assertions for the PostGIS + declarations that MSVC reported with mismatched exported linkage. The + `cc2e21d` replacement run `27489945425` showed the targeted assertion still + missed CI's declaration form, so the normalizer now also handles optional + leading whitespace and existing `extern Datum` declarations. `d7ab3ce` run + `27490693556` showed Windows still missed the declaration after checkout, so + the normalizer also tolerates CRLF line endings and optional space before + `PG_FUNCTION_ARGS`. `f00624a` run `27491174973` got past the source-patch + assertions and reached PostGIS MSVC compilation, then failed on + `FALLTHROUGH` expanding to `[[fallthrough]]` in C mode; the forced Windows + compatibility header now maps `FALLTHROUGH` to `((void)0)` under MSVC. + `e263edb` run `27491847193` reached later PostGIS compilation and failed on + the `POSTGIS_DEPRECATE` macro's generated legacy declarations; the Windows + patch now exports that macro's generated declarations as well. `b94e3f7` run + `27492582908` then reached deeper `liblwgeom` compilation and failed to + resolve `ryu/ryu.h`; the Windows PostGIS include roots now include + `deps/`. `68ab0c3` run `27493306145` reached `liblwgeom/topo` compilation + and showed that the single Meson target's include order let the server-side + `postgis/lwgeom_geos.h` shadow `liblwgeom/lwgeom_geos.h`, which pulled + `fmgr.h` into topology sources without PostgreSQL's core prelude. The + Windows PostGIS include roots now put `liblwgeom` before `postgis`; source + files under `postgis/` still resolve their source-local headers first, while + `liblwgeom/topo` fallback includes resolve to liblwgeom headers. `0b124b9` + run `27494545976` then compiled all `postgis-3` objects and failed at final + link because PROJ 9.8's `proj.h` emitted `dllimport` references while the + producer links pinned static PROJ, and because MSVC lacks POSIX + `strcasecmp`/`strncasecmp`. `74f403f` run `27495281356` proved the string + aliases but still linked against `__imp_proj_*`, showing that `PROJ_STATIC` + is not the PROJ 9.8 export-control macro. The Windows PostGIS and FlatGeobuf + compatibility headers now define `PROJ_DLL` empty before `proj.h` is + included, and the PostGIS compatibility header maps those case-insensitive + string calls to MSVC's `_stricmp`/`_strnicmp`. Local evidence passed: + `bun tools/policy/fetch-sources.mjs native-runtime --force`, + `bun tools/policy/fetch-sources.mjs wasix-runtime --force`, + `node tools/policy/check-moon-product-graph.mjs`, + `bash tools/policy/check-tooling-stack.sh`, + `bash tools/policy/check-repo-structure.sh`, Bash syntax checks for touched + shell scripts, direct execution of the embedded PostGIS SQL preprocessor + against `postgis_extension_helper.sql.in`, a focused PostGIS Windows source + patch anchor check, PowerShell tokenization for the touched Windows packager, + and `git diff --check`. +- [ ] Mobile E2E workflow green on PR/main for selected Android/iOS app + artifacts from the same successful `Builds` SHA. Current blocker: GitHub + cannot dispatch `.github/workflows/mobile-e2e.yml` from this PR because the + new workflow file is not registered on the default branch yet; direct + `gh workflow run .github/workflows/mobile-e2e.yml --ref + f0rr0/oliphaunt-release-ready -f + sha=b93207193561ba4a68ba61b14e42b9ad53157e2f -f platform=all` returns + `HTTP 404: workflow ... not found on the default branch`. +- [ ] Release workflow dry-run green for selected products. Current local + blocker after the WASIX `0.6.0` bump is registry identity bootstrap, not + version freshness: `tools/release/release.py check-registries --products-json + "$(cat target/release-dry-run-local/products.json)" --head-ref HEAD + --require-identities` fails because first-public-release package identities + are still missing for crates.io, Maven Central, npm, and JSR packages, + including `crates:oliphaunt-wasix` and the internal `oliphaunt-wasix-*` + crates. The + release setup guide documents this as expected pre-bootstrap state; hosted + `publish-dry-run` also enforces this preflight. A future release dry-run will + also need a same-SHA green `Builds` run for the latest WASIX release/rename + commit. +- [x] Consumer-shape validation for the full selected product closure is green. + The checker now treats `oliphaunt-node-direct` as a consumer-facing helper + product: the private root source package stays unpublishable, optional + platform npm packages publish with provenance and OS/CPU/libc constraints, + release metadata declares exactly those optional packages, and the TypeScript + SDK can keep selecting Node direct by exact optional platform packages. + Evidence: `tools/release/release.py consumer-shape --require-ready --product + oliphaunt-node-direct` and `tools/release/release.py consumer-shape + --require-ready --products-json "$(cat + target/release-dry-run-local/products.json)"` pass. +- [~] Windows native exact-extension coverage has a producer path for all nine + previous Windows gaps. The Windows build script now generates Meson + producers inside the patched PostgreSQL source tree for `pg_hashids`, + `pg_ivm`, `pg_textsearch`, `pg_uuidv7`, `vector`, `pgcrypto`, and + `uuid-ossp`, builds pinned static OpenSSL for `pgcrypto`, links the + first-party portable UUID source into `uuid-ossp`, and stages pgTAP's + generated SQL/control files without a native module. It also builds a Windows + PostGIS producer that compiles pinned static GEOS, PROJ, SQLite, json-c, and + libxml2 dependencies, links `postgis-3`, and stages PostGIS SQL/data plus + `proj/proj.db`. Target metadata now publishes those nine rows on + `windows-x64-msvc`, so the native exact-extension matrix reports 39 Windows + products. Local evidence after this patch passed: + `python3 src/extensions/tools/check-extension-model.py --write-evidence`, + `python3 src/extensions/tools/check-extension-model.py --check`, + `python3 tools/release/release.py check`, + `python3 tools/release/artifact_target_matrix.py extension-artifacts-native`, + and `git diff --check`. Remaining work: get GitHub Windows runner proof for + the expanded MSVC producers. +- [x] GitHub required aggregate green. + +## Immediate Next Work + +1. Get hosted Mobile E2E evidence once `.github/workflows/mobile-e2e.yml` is + available on the default branch or another approved same-SHA dispatch path + exists. Preserve the architecture invariant: installed-app E2E must consume + same-SHA `Builds` app artifacts and must not rebuild runtimes, SDKs, or + extension packages. +2. Run a release dry-run after release tags/artifacts are available for the + selected product closure and after first-public-release registry identities + are bootstrapped. The strict identity gate is currently expected to fail for + new crates.io, Maven Central, npm, and JSR package coordinates. +3. Get a same-SHA green `Builds` run for the latest WASIX release/rename + commit; previous green builder evidence predates that commit and cannot + satisfy the release workflow's same-SHA artifact gate. +4. Get Windows `extension-artifacts-native` CI evidence for the new 39-product + Windows row, including the PostGIS dependency and module producer. diff --git a/docs/internal/OLIPHAUNT_PATCH_STACK.md b/docs/internal/OLIPHAUNT_PATCH_STACK.md new file mode 100644 index 00000000..32a1cdb4 --- /dev/null +++ b/docs/internal/OLIPHAUNT_PATCH_STACK.md @@ -0,0 +1,135 @@ + +# liboliphaunt PostgreSQL 18 Patch Stack Review + +This source-only review artifact keeps the native PostgreSQL patch stack deterministic and reviewable without rebuilding PostgreSQL. + +Regenerate with: + +```sh +src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --write +``` + +## Source Pin + +- PostgreSQL: `18.4` +- URL: `https://ftp.postgresql.org/pub/source/v18.4/postgresql-18.4.tar.bz2` +- SHA-256: `81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094` +- Patch directory: `../patches/postgresql-18.4` + +## Patch Series + +| Order | Patch | Author | Subject | +| --- | --- | --- | --- | +| 1 | `0001-liboliphaunt-add-backend-host-io.patch` | liboliphaunt | liboliphaunt: add backend host I/O callbacks | +| 2 | `0002-liboliphaunt-add-embedded-entrypoint.patch` | liboliphaunt | liboliphaunt: add embedded backend entrypoint | +| 3 | `0003-liboliphaunt-return-from-embedded-frontend-terminate.patch` | liboliphaunt | liboliphaunt: return from embedded frontend terminate | +| 4 | `0004-liboliphaunt-run-embedded-exit-cleanup.patch` | liboliphaunt | liboliphaunt: run embedded exit cleanup without exiting | +| 5 | `0005-liboliphaunt-restore-host-cwd.patch` | liboliphaunt | liboliphaunt: restore host cwd after embedded shutdown | +| 6 | `0006-liboliphaunt-add-static-extension-loader.patch` | liboliphaunt | liboliphaunt: add static extension loader | +| 7 | `0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch` | liboliphaunt | liboliphaunt: disable shell commands on Apple mobile targets | +| 8 | `0008-liboliphaunt-clean-embedded-symbols.patch` | liboliphaunt | liboliphaunt: clean embedded symbols | +| 9 | `0009-liboliphaunt-guard-embedded-proc-exit.patch` | liboliphaunt | liboliphaunt: guard embedded proc_exit failures | +| 10 | `0010-liboliphaunt-use-host-runtime-paths.patch` | liboliphaunt | liboliphaunt: use host-provided embedded runtime paths | +| 11 | `0011-liboliphaunt-add-android-embedded-shared-memory.patch` | liboliphaunt | liboliphaunt: add embedded mobile shared memory and semaphores | +| 12 | `0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch` | liboliphaunt | liboliphaunt: enable event triggers in embedded backend sessions | +| 13 | `0013-liboliphaunt-register-static-icu-data.patch` | liboliphaunt | liboliphaunt: register static ICU data | +| 14 | `0014-liboliphaunt-use-portable-embedded-socketpair.patch` | liboliphaunt | liboliphaunt: use portable embedded socketpair | +| 15 | `0015-liboliphaunt-add-embedded-meson-option.patch` | liboliphaunt | liboliphaunt: add embedded meson option | + +## Changed Upstream Files + +- `meson.build` (`0015-liboliphaunt-add-embedded-meson-option.patch`) +- `meson_options.txt` (`0015-liboliphaunt-add-embedded-meson-option.patch`) +- `src/backend/access/transam/xlogarchive.c` (`0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch`) +- `src/backend/archive/shell_archive.c` (`0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch`) +- `src/backend/commands/collationcmds.c` (`0013-liboliphaunt-register-static-icu-data.patch`) +- `src/backend/commands/event_trigger.c` (`0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch`) +- `src/backend/libpq/be-secure.c` (`0001-liboliphaunt-add-backend-host-io.patch`) +- `src/backend/libpq/pqcomm.c` (`0001-liboliphaunt-add-backend-host-io.patch`) +- `src/backend/port/Makefile` (`0011-liboliphaunt-add-android-embedded-shared-memory.patch`) +- `src/backend/port/meson.build` (`0011-liboliphaunt-add-android-embedded-shared-memory.patch`) +- `src/backend/port/oliphaunt_embedded_sema.c` (`0011-liboliphaunt-add-android-embedded-shared-memory.patch`) +- `src/backend/port/oliphaunt_embedded_shmem.c` (`0011-liboliphaunt-add-android-embedded-shared-memory.patch`) +- `src/backend/storage/ipc/ipc.c` (`0004-liboliphaunt-run-embedded-exit-cleanup.patch`, `0009-liboliphaunt-guard-embedded-proc-exit.patch`) +- `src/backend/tcop/postgres.c` (`0002-liboliphaunt-add-embedded-entrypoint.patch`, `0003-liboliphaunt-return-from-embedded-frontend-terminate.patch`, `0004-liboliphaunt-run-embedded-exit-cleanup.patch`, `0005-liboliphaunt-restore-host-cwd.patch`, `0009-liboliphaunt-guard-embedded-proc-exit.patch`, `0010-liboliphaunt-use-host-runtime-paths.patch`, `0014-liboliphaunt-use-portable-embedded-socketpair.patch`) +- `src/backend/utils/adt/pg_locale.c` (`0013-liboliphaunt-register-static-icu-data.patch`) +- `src/backend/utils/adt/pg_locale_icu.c` (`0013-liboliphaunt-register-static-icu-data.patch`) +- `src/backend/utils/fmgr/dfmgr.c` (`0006-liboliphaunt-add-static-extension-loader.patch`, `0008-liboliphaunt-clean-embedded-symbols.patch`) +- `src/include/libpq/libpq-be.h` (`0001-liboliphaunt-add-backend-host-io.patch`) +- `src/include/port.h` (`0011-liboliphaunt-add-android-embedded-shared-memory.patch`) +- `src/include/storage/dsm_impl.h` (`0011-liboliphaunt-add-android-embedded-shared-memory.patch`) +- `src/include/storage/ipc.h` (`0004-liboliphaunt-run-embedded-exit-cleanup.patch`, `0009-liboliphaunt-guard-embedded-proc-exit.patch`) +- `src/include/tcop/tcopprot.h` (`0003-liboliphaunt-return-from-embedded-frontend-terminate.patch`, `0008-liboliphaunt-clean-embedded-symbols.patch`) +- `src/include/utils/pg_locale.h` (`0013-liboliphaunt-register-static-icu-data.patch`) +- `src/port/chklocale.c` (`0011-liboliphaunt-add-android-embedded-shared-memory.patch`) + +## Expected Upstream Touchpoints + +| File | Rationale | +| --- | --- | +| `meson.build` | Meson-hosted embedded builds enable OLIPHAUNT_EMBEDDED through an explicit opt-in build option. | +| `meson_options.txt` | Meson-hosted embedded builds declare the opt-in embedded backend option without changing default PostgreSQL builds. | +| `src/backend/access/transam/xlogarchive.c` | Apple mobile embedded builds compile out optional archive shell commands. | +| `src/backend/archive/shell_archive.c` | Apple mobile embedded builds compile out optional archive shell commands. | +| `src/backend/commands/collationcmds.c` | Static ICU consumers register linked common data before collation commands call ICU locale APIs. | +| `src/backend/commands/event_trigger.c` | Embedded FE/BE protocol sessions can run event triggers without changing standalone recovery behavior. | +| `src/backend/libpq/be-secure.c` | Backend secure read/write path delegates to a host I/O vtable only when OLIPHAUNT_EMBEDDED is set. | +| `src/backend/libpq/pqcomm.c` | Standalone embedded sessions avoid waiting on a non-existent postmaster death latch. | +| `src/backend/port/Makefile` | Embedded mobile builds swap unavailable SysV shared memory and semaphores for process-local implementations. | +| `src/backend/port/meson.build` | Android embedded builds swap unavailable SysV shared memory and semaphores for process-local implementations. | +| `src/backend/port/oliphaunt_embedded_sema.c` | Embedded mobile semaphore implementation for one backend in one process. | +| `src/backend/port/oliphaunt_embedded_shmem.c` | Embedded mobile shared memory implementation for one backend in one process. | +| `src/backend/storage/ipc/ipc.c` | Embedded backend cleanup and proc_exit unwinding stay at PostgreSQL lifecycle boundaries. | +| `src/backend/tcop/postgres.c` | Embedded backend entrypoint, protocol lifecycle, cwd restoration, and host runtime paths. | +| `src/backend/utils/adt/pg_locale.c` | Static ICU consumers register linked common data before PostgreSQL validates or canonicalizes ICU locales. | +| `src/backend/utils/adt/pg_locale_icu.c` | Static ICU consumers register linked common data before PostgreSQL opens ICU collators or converters. | +| `src/backend/utils/fmgr/dfmgr.c` | Static extension lookup reuses PostgreSQL dynamic function manager semantics. | +| `src/include/libpq/libpq-be.h` | Host I/O vtable is attached to PostgreSQL Port state under OLIPHAUNT_EMBEDDED. | +| `src/include/port.h` | Embedded mobile builds avoid POSIX shared memory declarations in the portable path. | +| `src/include/storage/dsm_impl.h` | Embedded mobile builds keep DSM on mmap instead of POSIX or SysV shared memory. | +| `src/include/storage/ipc.h` | Embedded cleanup and proc_exit guard declarations. | +| `src/include/tcop/tcopprot.h` | Embedded entrypoint and returning PostgresMain declarations. | +| `src/include/utils/pg_locale.h` | Declares the generic static ICU data registration helper for PostgreSQL ICU call sites. | +| `src/port/chklocale.c` | Android embedded builds avoid unsupported locale-environment mutation. | + +## PostgreSQL Patch Symbols + +- `oliphaunt_embedded_main` (`0002-liboliphaunt-add-embedded-entrypoint.patch`, `0008-liboliphaunt-clean-embedded-symbols.patch`) +- `oliphaunt_embedded_proc_exit` (`0004-liboliphaunt-run-embedded-exit-cleanup.patch`, `0005-liboliphaunt-restore-host-cwd.patch`, `0009-liboliphaunt-guard-embedded-proc-exit.patch`) +- `oliphaunt_embedded_proc_exit_handler` (`0009-liboliphaunt-guard-embedded-proc-exit.patch`) +- `oliphaunt_embedded_set_proc_exit_handler` (`0009-liboliphaunt-guard-embedded-proc-exit.patch`) +- `oliphaunt_static_extension_init` (`0006-liboliphaunt-add-static-extension-loader.patch`) +- `oliphaunt_static_extension_lookup` (`0006-liboliphaunt-add-static-extension-loader.patch`) +- `oliphaunt_static_extension_magic` (`0006-liboliphaunt-add-static-extension-loader.patch`) +- `oliphaunt_static_extension_symbol` (`0006-liboliphaunt-add-static-extension-loader.patch`) + +## Audit Checklist + +| Requirement | Owning Patch | Required Evidence | Review Posture | +| --- | --- | --- | --- | +| Host-owned protocol I/O vtable | `0001-liboliphaunt-add-backend-host-io.patch` | `OliphauntEmbeddedIO`, `secure_raw_read`, `secure_raw_write` | Generic libpq backend hook; normal socket I/O remains untouched. | +| Standalone backend waitset guard | `0001-liboliphaunt-add-backend-host-io.patch` | `WL_POSTMASTER_DEATH`, `if (IsUnderPostmaster)` | Embedded standalone sessions avoid a postmaster-death wait handle that cannot exist. | +| Explicit embedded backend entrypoint | `0002-liboliphaunt-add-embedded-entrypoint.patch` | `oliphaunt_embedded_main`, `pq_init(&client_sock)`, `PostgresMain(dbname, username)` | Uses PostgreSQL backend initialization and FE/BE protocol instead of single-user query transport. | +| Frontend Terminate returns to host owner | `0003-liboliphaunt-return-from-embedded-frontend-terminate.patch` | `frontend sends Terminate`, `return;`, `proc_exit(0)` | Only OLIPHAUNT_EMBEDDED changes backend termination into a returning thread lifecycle. | +| PostgreSQL exit callbacks still run | `0004-liboliphaunt-run-embedded-exit-cleanup.patch` | `oliphaunt_embedded_proc_exit`, `proc_exit_prepare(code)` | Keeps upstream cleanup ordering for shmem, locks, callbacks, and backend-local state. | +| Startup FATAL does not exit the host process | `0009-liboliphaunt-guard-embedded-proc-exit.patch` | `oliphaunt_embedded_set_proc_exit_handler`, `siglongjmp`, `proc_exit_handler` | Embedded startup failures unwind to liboliphaunt after PostgreSQL cleanup callbacks run. | +| Embedded proc_exit guard is cleared before returning to host | `0009-liboliphaunt-guard-embedded-proc-exit.patch` | `embedded_cleanup:`, `oliphaunt_embedded_set_proc_exit_handler(NULL, NULL)`, `chdir(original_cwd)` | Normal and FATAL startup paths share one cleanup label so thread-local exit guards and host cwd are restored before returning. | +| Host working directory is restored | `0005-liboliphaunt-restore-host-cwd.patch` | `original_cwd`, `getcwd(original_cwd`, `chdir(original_cwd)` | Contains PostgreSQL standalone ChangeToDataDir side effects inside the backend lifetime. | +| Static extension registry uses PostgreSQL dfmgr path | `0006-liboliphaunt-add-static-extension-loader.patch` | `oliphaunt_static_extension_lookup`, `lookup_library_symbol`, `oliphaunt_static_extension_symbol` | CREATE EXTENSION/LOAD semantics stay in PostgreSQL; hosts only provide module symbols. | +| Static extension ABI magic is validated | `0006-liboliphaunt-add-static-extension-loader.patch`, `0008-liboliphaunt-clean-embedded-symbols.patch` | `oliphaunt_static_extension_magic`, `Pg_magic_struct`, `memcmp(&magic_data_ptr->abi_fields` | Static modules still pass PostgreSQL ABI checks before symbols are used. | +| Runtime paths come from host-packaged resources | `0010-liboliphaunt-use-host-runtime-paths.patch` | `oliphaunt_embedded_set_runtime_paths`, `my_exec_path`, `PGSYSCONFDIR` | Avoids executable-bit assumptions for mobile resources while preserving PostgreSQL path derivation. | +| Apple mobile builds do not call system(3) | `0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch` | `OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS`, `TARGET_OS_IPHONE`, `archive_command cannot be executed` | Mobile direct mode fails optional shell archive/restore hooks explicitly instead of compiling unavailable APIs. | +| Embedded mobile shared memory and semaphores are process-local | `0011-liboliphaunt-add-android-embedded-shared-memory.patch` | `oliphaunt_embedded_shmem.c`, `oliphaunt_embedded_sema.c`, `OLIPHAUNT_EMBEDDED_MOBILE_SHMEM` | Android and Apple mobile builds avoid unavailable SysV shared memory and semaphores while direct mode remains one backend per process. | +| Event triggers run in embedded protocol sessions | `0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch` | `EventTriggersHaveRunnableBackend`, `OLIPHAUNT_EMBEDDED`, `event_triggers` | Keeps upstream single-user escape hatch outside OLIPHAUNT_EMBEDDED but treats embedded protocol sessions as runnable backends. | +| Static ICU data is registered before PostgreSQL calls ICU APIs | `0013-liboliphaunt-register-static-icu-data.patch` | `pg_register_static_icu_data`, `udata_setCommonData`, `init_icu_converter` | Static ICU consumers can initialize PostgreSQL without loose ICU data files while dynamic ICU builds remain unchanged. | +| Meson builds expose an explicit embedded backend option | `0015-liboliphaunt-add-embedded-meson-option.patch` | `oliphaunt_embedded`, `add_project_arguments`, `-DOLIPHAUNT_EMBEDDED` | Windows and other Meson-hosted embedded builds enable the backend entrypoint through PostgreSQL build configuration while default server builds remain unchanged. | + +## Guardrails + +- `source.toml` patch series exactly matches the patch directory. +- Every patch has a deterministic `From: liboliphaunt ` header. +- Every patch has a deterministic `Subject: [PATCH] liboliphaunt: ...` header. +- Added PostgreSQL lines are checked for trailing whitespace, space-before-tab indentation, and SDK/runtime/product-specific terms that belong above PostgreSQL. +- Changed upstream files must exactly match the expected touchpoint table above; new upstream touchpoints need an explicit rationale before landing. +- Required audit checks prove their evidence in the named owning patch or patches, keeping host I/O, embedded lifecycle, cleanup, cwd restore, runtime paths, static extensions, mobile shell exclusion, embedded mobile shared memory, and event triggers reviewable independently. +- Changed upstream files and patch-introduced `oliphaunt_*` symbols are listed here for release review. diff --git a/docs/internal/OLIPHAUNT_TRACK_REVIEW.md b/docs/internal/OLIPHAUNT_TRACK_REVIEW.md new file mode 100644 index 00000000..0f1e967c --- /dev/null +++ b/docs/internal/OLIPHAUNT_TRACK_REVIEW.md @@ -0,0 +1,753 @@ +# liboliphaunt Track Review + +Date: 2026-05-16 + +## Executive Summary + +The native track is directionally correct: `liboliphaunt` is the C boundary over +PostgreSQL 18, `oliphaunt` is the canonical Rust SDK, and the existing +WASIX `oliphaunt-wasix` lane is no longer shaping native architecture decisions. +That separation is the right foundation if the goal is an embedded PostgreSQL +product that can credibly compete with SQLite for application developers. + +The current implementation is still not complete enough to market as that +product. The biggest remaining gaps are not API naming or package polish. They +are: + +- lifecycle: direct mode is one active embedded PostgreSQL backend per process; +- streaming: native direct has a chunked C ABI, broker forwards chunks over IPC, + and server forwards PostgreSQL wire messages per frame; large results and + `COPY TO STDOUT` now have native matrix rows plus a native SQL regression + proving post-COPY session reuse across direct, broker, and server. The + source-current full matrix has been refreshed after the latest harness + changes; +- coverage: we have smoke and selected SQL regression coverage, not a + PostgreSQL-grade regression matrix; +- benchmarking: direct, broker, server, native PostgreSQL, and SQLite embedded + are now measured from one reproducible harness with p50/p90/p95, CPU, RSS, + footprint, artifact-size rows, and source/artifact provenance. The latest + full source-current matrix passes provenance verification; the remaining + discipline is to refresh that run after every benchmark harness or runtime + input change and reject stale reports; +- mobile packaging: Swift and Kotlin/Native now have concrete native-direct + C ABI runtime paths; React Native has the typed New Architecture package + shape, and Android now has a content-keyed runtime/template asset lane, but + final platform artifacts and device distribution are not wired end to end; +- extensions: extensions are opt-in in the Rust model, and the packaged + PG18 extension matrix now passes install/load, restart, physical backup, and + physical restore checks across broker/direct-C-ABI and server paths; pgGraph + and ParadeDB external smokes now also cover core functional queries across + direct, broker, and server; the C ABI static registry exists and is smoke + tested, while generated platform registry sources/device packaging and signed + manifests remain release blockers. + +This track pass addressed concrete gaps: + +- C `initdb` bootstrap no longer uses `system(3)` or shell quoting. It now + forks and execs `initdb` directly, preserves stderr for diagnosis, suppresses + stdout, reports exec/status/signal failures, and avoids command-injection + classes of bugs. +- Direct native streaming now exists at the C ABI through + `oliphaunt_exec_protocol_stream`. It drains backend writes into a bounded chunk + queue with producer backpressure, scans protocol frames incrementally for + `ReadyForQuery`, and invokes the sink from the caller thread instead of the + backend thread. +- Broker streaming now uses dedicated IPC stream request/chunk/end frames + instead of materializing the whole response in the client process. +- Broker IPC now uses Unix-domain sockets by default on Unix platforms, keeping + the helper-process path off localhost TCP while preserving an explicit TCP + fallback for portability and debugging. +- Broker IPC now requires a per-session authentication frame before any protocol + or control request. The SDK generates the token and passes it to the helper + through the child environment rather than argv. +- Broker startup now propagates the parent bootstrap policy to the helper, + including `ExistingOnly` and the explicit `initdb` tooling fallback, so the + helper cannot silently hydrate a root the caller intended to treat as + pre-existing. Broker shutdown also waits briefly for graceful helper exit + before falling back to kill. +- Broker capabilities now stay honest: direct mode remains one process-global + backend, while broker mode supervises one isolated helper per active root. + A shared broker runtime enforces `.broker_max_roots(n)`, rejects duplicate + active roots, and reports multi-root capability only when the configured root + budget is greater than one. +- Broker sessions now retain their helper launch plan and relaunch a fresh + helper against the same root when the previous helper exits between + operations. The SDK deliberately does not replay an in-flight request after a + crash because the request's commit state may be unknown; a caller observes + that failure and later operations can recover through PostgreSQL WAL recovery. +- The native root/runtime module is split by responsibility: process plus + filesystem root locking and PGDATA path preparation stay in + `oliphaunt/root.rs`; runtime-cache discovery + and materialization are split across `oliphaunt/root/runtime.rs`, + `oliphaunt/root/runtime/locate.rs`, `oliphaunt/root/runtime/install.rs`, and + `oliphaunt/root/runtime/cache_key.rs`; deterministic filesystem copying lives + in `oliphaunt/root/files.rs`; runtime/template cache fingerprinting lives in + `oliphaunt/root/fingerprint.rs`; and packaged-template PGDATA cache + construction lives in `oliphaunt/root/template.rs`. +- Selected extension asset materialization now lives in + `oliphaunt/root/extensions.rs`, including SQL/control file selection, data + files, module-file sets, and the filters that hide unselected extension + assets from materialized runtime trees. +- Server mode now streams PostgreSQL wire messages frame-by-frame and returns + SQL `ErrorResponse` frames as raw protocol bytes instead of converting normal + SQL errors into Rust execution failures. +- Server mode now separates short connection-attempt timeouts from longer query + I/O timeouts, so normal work such as `CREATE EXTENSION` is not constrained by + the retry loop's 250 ms connection probe. +- Server mode now retries auto-assigned localhost ports only when PostgreSQL + reports a bind conflict during startup. This removes parallel-test and local + process races without hiding fixed-port failures or unrelated server startup + errors. +- Server mode now uses a short SDK-owned Unix-domain socket on Unix for + internal protocol traffic while keeping the public TCP connection string for + external clients. The raw-wire client also reads backend frames through a + reusable buffered reader instead of issuing header/body reads and allocating + body/frame buffers per message. +- The Rust owner executor now uses `crossbeam-channel` for the per-session + command queue instead of `std::sync::mpsc`, preserving the serialized owner + thread while reducing direct-mode RTT handoff overhead. +- `liboliphaunt` now exposes `oliphaunt_exec_simple_query` and + `OLIPHAUNT_CAP_SIMPLE_QUERY` so SDK simple-query calls do not need to build and + revalidate frontend protocol frames outside the engine. The Rust direct + engine loads this symbol opportunistically, while broker mode forwards a + first-class simple-query IPC frame to the helper and lets the helper call the + same engine hook. +- The C ABI no longer imposes a hard-coded default execution timeout while + waiting for `ReadyForQuery`. Startup readiness still has a bounded wait, but + normal owned-response and streaming protocol calls run until PostgreSQL + completes, exits, closes, or is explicitly canceled. The C smoke harness sets + the legacy wait-timeout env after startup and proves a `pg_sleep` query still + succeeds, preventing accidental reintroduction of a synthetic query cap. +- The native performance harness now measures `NativeDirect`, `NativeBroker`, + and `NativeServer` as distinct SDK modes instead of reporting broker as + unavailable or using native PostgreSQL control numbers as a substitute. +- Native benchmark matrix runs now write `provenance.json` with SHA-256s for + benchmark harness sources, `liboliphaunt`/PostgreSQL patch inputs, Rust SDK + sources, `xtask`, and native artifacts. Product-local perf checks verify a + retained run directory against the current checkout so stale reports are not + accidentally treated as current release evidence. +- The native benchmark matrix now records prepared-update rows for + `NativeDirect`, `NativeBroker`, and `NativeServer`. Each row uses PostgreSQL's + extended protocol with one named prepared statement and covers both + sequential Bind/Execute/Sync traffic and a pipelined Bind/Execute batch inside + one transaction. +- The gated native extension matrix now creates or loads every currently + release-ready exact extension through broker/direct-C-ABI and server paths, reopens the + root, takes a physical backup, restores it into a new root, and verifies the + extension remains visible after restore. The manifest also distinguishes + SQL-only extensions such as `pgtap` from extensions that require a native + module. +- The native PostgreSQL build harness now fingerprints extension source trees, + compiler selection, PostgreSQL patch/build inputs, and `liboliphaunt` C ABI + sources before rebuilding extension artifacts. Gated extension validation can + reuse existing normal and embedded module artifacts unless those inputs + change, with `OLIPHAUNT_FORCE_EXTENSION_REBUILD=1` as the explicit escape + hatch. +- Repeated native C validation no longer relinks `liboliphaunt.dylib` when the + edited C ABI sources and PostgreSQL embedded object/archive inputs are + unchanged. The build harness writes a separate dylib input stamp, verifies + required exported C ABI symbols before reuse, and exposes + `OLIPHAUNT_FORCE_RELINK=1` for deliberate relink diagnostics. +- The Rust SDK now exposes `NATIVE_EXTENSION_MANIFEST` as the product manifest + for supported PG18 extensions. It records each extension's SQL/control asset + class, native module requirement, dependencies, runtime data files, smoke SQL + strategy, gated direct-C-ABI/broker/server coverage, and mobile static-link + status. The gated native extension matrix iterates this manifest directly. +- The C smoke harness now covers ABI version/capability reporting, invalid init + and invalid exec/stream arguments, malformed frontend frame rejection and + recovery, normal protocol success, SQL error recovery, large owned responses, + stream callback delivery, stream callback failure recovery, response cleanup, + active-query cancellation and recovery, direct C ABI backup/restore, + malicious archive-entry rejection, symlinked PGDATA backup rejection, caller + `PGDATA` environment restoration after close, explicit same-process direct + reopen rejection, and process-bound reopen through a second harness process. +- `liboliphaunt` now exposes out-of-band direct query cancellation through + `oliphaunt_cancel` and `OLIPHAUNT_CAP_QUERY_CANCEL`. The Rust SDK surfaces this as + `Oliphaunt::cancel()` backed by an `EngineCancel` handle that bypasses the owner + queue, so a long-running direct query can be interrupted without waiting + behind itself. +- Broker and server now preserve that same out-of-band cancellation contract at + their natural transport layer. Broker mode creates a separate authenticated + cancel IPC endpoint so cancellation never competes with the busy query stream. + Server mode captures PostgreSQL startup `BackendKeyData` and sends the native + CancelRequest packet over a fresh connection to cancel the SDK-owned backend. +- `Oliphaunt::close()` now treats close as a lifecycle boundary rather than a + cancellation primitive. Once close begins, the executor rejects queued + non-close work with `EngineStopped`, waits for active work to finish, then + lets the owner thread close or detach the runtime. Query interruption remains + explicit through `Oliphaunt::cancel()`. +- The native runtime now has a profile-aware content-keyed runtime cache. + Direct/broker use liboliphaunt-linked extension modules; server mode uses + standalone PostgreSQL modules. This fixes the previous server-mode crash caused + by loading embedded modules into a standalone server while preserving + opt-in extension isolation. +- Direct liboliphaunt and managed server startup now provide `PGDATA` in the + backend environment. PostgreSQL itself receives `-D`, but some existing + extension code, including pgGraph persistence, reads `PGDATA` directly; the + native runtime now supplies that root and restores the caller's environment + after the embedded backend exits. +- `liboliphaunt` now exposes `oliphaunt_register_static_extensions` and + `OLIPHAUNT_CAP_STATIC_EXTENSIONS`. The PostgreSQL `dfmgr` patch resolves + registered in-binary extension modules through the normal dynamic-loader path, + validates PostgreSQL magic, calls the registered init hook once, and resolves + exported SQL-callable C symbols without requiring a module file. The C smoke + harness registers a fixture extension before `oliphaunt_init` and proves + `CREATE FUNCTION ... AS 'module', 'symbol' LANGUAGE C` executes through that + registry. +- The Rust runtime resourcesr now emits a static-registry package alongside + runtime/template resources. Mobile-ready packages contain + `static-registry/oliphaunt_static_registry.c`, generated from selected + extension SQL assets, plus a manifest that records module stems, symbol + prefixes, and SQL-callable symbols. The generated source exports + `liboliphaunt_selected_static_extensions`; Swift and Kotlin native bridges look + up that optional process symbol and register the rows through the loaded + `oliphaunt_register_static_extensions` symbol before `oliphaunt_init`. +- Extension configuration now fails during `OpenConfig` validation for + duplicate extension names, empty or non-portable IDs, unsupported extension + source/loading combinations, and source/loading mismatches. + This keeps extension packaging mistakes out of runtime materialization. +- `BootstrapStrategy::PackagedTemplate` now materializes a content-keyed base + PGDATA template and hydrates new roots before entering the engine. Template + hydration now defaults to physical byte-copy because paired local evidence + showed better p90 stability than APFS copy-on-write cloning; clone mode + remains available as an explicit diagnostic setting. +- The speed-case diagnostic harness now supports native liboliphaunt direct for a + single case per process and native PostgreSQL controls for matched case-level + diagnosis. The native PostgreSQL control connects to `template1`, matching + liboliphaunt's current session target, so per-case comparisons no longer mix + different database targets. Diagnostic output now records the process model + and key PostgreSQL GUCs, and ad hoc native PostgreSQL diagnostics default to + the repo's pinned `target/liboliphaunt-pg18/install/bin` tools when present + instead of accidentally using a different `postgres` on `PATH`. +- The native benchmark matrix has a source-current PostgreSQL 18.4 full local + run at `target/perf/native-liboliphaunt-20260524T090412Z/report.md` with + matched `safe` durability for native liboliphaunt, native PostgreSQL, and + SQLite controls plus strict verified `provenance.json` for that recorded + source/artifact set. Later backup ABI and tar-writer changes require a new + full matrix before using the report as current-checkout release evidence. + Native direct passes + repeated RTT, open, and RSS gates against the native PostgreSQL control in + that run; RTT gate p90 is `107 us` versus `112 us` for native PostgreSQL + tokio, and open p90 is `440.28 ms` versus `576.4 ms`. Native direct still + misses speed-suite p90 (`2.668 s` versus `2.419 s`), speed tail throughput + (`0.907x` native PostgreSQL), and physical backup/restore (`0.558 s` versus + `0.344 s`) against the native PostgreSQL physical-archive control. The matrix uses 10 + fresh-process RTT repeats, 20 fresh-process speed repeats, 10 prepared + repeats, and 10 backup/restore repeats before classifying evidence as + release-grade. Direct, broker, server, native PostgreSQL tokio, and SQLite are + `stable` on that host run. Per-case speed misses in the complete matrix are + `1`, `2`, `2.1`, `3`, `3.1`, `4`, `5`, `10`, and `13`; isolated repeated + diagnostics reproduce `1`, `2.1`, `3`, `4`, `10`, and `13`. A focused + current-source backup diagnostic at + `target/perf/native-liboliphaunt-20260524Tbackup-final-direct/report.md` + verifies `oliphaunt_backup_ex` and the current C tar writer, improving direct + physical backup/restore p90 to `0.534 s` while still missing native + PostgreSQL physical p90 at `0.324 s`. Against SQLite, direct wins total + speed-suite p90 but still loses open p90 and RSS by large margins. The streaming section + includes large row results and `COPY TO STDOUT`, and prepared-update rows + cover sequential and pipelined extended-protocol traffic across direct, + broker, server, and native PostgreSQL controls. +- The first source-current full-matrix attempt exposed a server-mode raw + protocol bug where a large pipelined frontend batch could fill the PostgreSQL + server's output socket while the SDK was still writing the request. + `PostgresWireClient` now switches large raw requests to duplex read/write, + and the full rerun passes with server pipelined prepared p90 at `0.239 s` + numeric and `0.265 s` text versus native PostgreSQL tokio at `0.288 s` and + `0.291 s`. +- Direct/server physical backup now uses PostgreSQL's low-level online backup + API (`pg_backup_start` and `pg_backup_stop`) and writes the generated + `backup_label`/`tablespace_map` into the physical archive. The implementation + collects `pg_wal` after backup stop, and the native server smoke restores the + archive into a new root and reads user data from it. Broker mode forwards the + same direct physical backup through the helper process. Physical archives are + explicitly single-root concrete archives: backup fails on non-regular PGDATA + entries, and restore accepts only regular files and directories under + `pgdata`. Restore also rejects malformed framing, missing terminators, + trailing non-zero data, unsupported tar header formats, invalid tar numeric + and fixed-width string fields, duplicate canonical paths, unexpected link + metadata, and directory entries with payload bytes. Restore extracts through + the validated canonical archive path rather than delegating destination + interpretation to the tar reader, and validates archive tree shape before + writing staging files in both the Rust SDK and C ABI restore paths. Symlinks, + hardlinks, FIFOs, sockets, device nodes, sparse/special tar records, external + tablespaces, and linked WAL directories are rejected. Server mode also + exposes logical SQL backup through packaged `pg_dump`. +- Restore/import is now a first-class Rust SDK operation through + `Oliphaunt::restore(RestoreRequest::physical_archive(...))`. It stages restore + output, rejects archive traversal and unsupported archive entry types, + validates required recovery files, rejects symlink targets, protects + existing roots by default, and supports explicit locked replacement. Swift, + Kotlin, and React Native API shapes mirror the same root-level restore model. +- The native benchmark matrix now includes a file-backed SQLite control via + rusqlite plus artifact-size rows for `liboliphaunt`, embedded modules, and the + PostgreSQL install tree. +- Native liboliphaunt benchmark runs now sample child-process RSS for broker and + server modes from the xtask process tree. RTT, speed, and streaming reports no + longer have to infer helper/server memory solely from `/usr/bin/time` on the + parent benchmark process. +- The xtask RSS/process-tree sampler has been extracted to + `tools/xtask/src/process_rss.rs` with focused unit coverage for descendant + aggregation and cycle/double-count protection. This keeps benchmark resource + accounting separate from command orchestration. +- The Swift SDK now includes `OliphauntNativeDirectEngine`, backed by a small + C bridge that dynamically loads `liboliphaunt` or resolves already-linked C ABI + symbols. Env-backed Swift tests open a temporary native-direct root, execute + raw protocol bytes, cancel an active `pg_sleep`, and close through the C ABI. +- The Kotlin SDK now includes a Kotlin/Native `NativeDirectEngine`, backed by a + small static cinterop bridge that dynamically loads `liboliphaunt`, keeps the + public API suspend-first, defaults `OliphauntDatabase.open` to native direct on + Kotlin/Native, runs blocking protocol work off the caller coroutine, exposes + cancellation outside the serialized execution queue, makes `close()` wait for + the execution lane before detaching, and recursively cleans temporary roots + with symlink-safe POSIX tree removal. +- The React Native TurboModule surface now exposes `cancel(handle)` and the + TypeScript `OliphauntDatabase.close()` path delegates wait-and-detach close to + the platform SDK, matching the Rust/Swift/Kotlin lifecycle contract at the + public API layer. +- React Native iOS now delegates its TurboModule implementation to `Oliphaunt` + through an Objective-C-visible Swift adapter. The Objective-C++ file keeps + only React Native handle/promise plumbing and New Architecture registration; + Swift owns open, protocol execution, backup, restore, cancellation, close, + resource materialization, template hydration, and extension checks. +- React Native Android now delegates its TurboModule implementation through the + Kotlin SDK `OliphauntAndroid` facade and stores the returned `OliphauntDatabase` + handle. The package Gradle build includes the local `:oliphaunt` + project in this repo, falls back to the published Kotlin SDK coordinate for + packaged app builds, generates the official Codegen TurboModule base class, + compiles Kotlin, verifies the Kotlin SDK JNI bridge, and exercises synthetic + runtime/template asset packaging in the verifier. Runtime materialization, + template hydration, JNI loading, and extension manifest checks are owned + by the Kotlin SDK instead of duplicated in the RN package. Device validation + remains blocked on packaged Android `liboliphaunt.so` and real runtime/template + artifacts. +- Server compatibility coverage now includes real `psql`, `tokio-postgres` + independent-client, `sqlx` pool, packaged `pg_dump` through SQL backup, and + persistent close/reopen smoke. Broker coverage now includes `ExistingOnly` + rejection for empty roots plus persistent close/reopen smoke. +- The Rust SDK now exposes `Oliphaunt::query(sql)` over the native simple-query + path. The parser keeps the C ABI raw, but gives Rust callers field metadata, + rows, command tags, null handling, and PostgreSQL `ErrorResponse` propagation + without asking applications to decode backend protocol frames for ordinary + one-result-set queries. Multi-result-set and COPY traffic stay on the raw or + streaming protocol APIs. +- Swift, Kotlin, and React Native now expose the same typed result concept for + simple SQL and PostgreSQL extended-protocol parameters. Their parsers and + frontend message builders live in the SDK language layer, not in the C ABI, + and cover field metadata, rows, command tags, nulls, and SQL errors while + keeping multi-result-set and COPY traffic on raw protocol APIs. +- The same query layer now exposes `query_params(sql, params)` using + PostgreSQL's extended protocol. Parameters are encoded into `Parse`/`Bind`/ + `Describe`/`Execute`/`Sync` frames in Rust and then sent through the same raw + protocol engine path, so the low-level C ABI remains stable while the Rust DX + gets a safe non-interpolating query API. +- Dropping an unfinished `Transaction` now queues a best-effort `ROLLBACK` on + the owner executor before releasing the physical-session pin. This prevents a + Rust lifetime mistake from leaving the single direct/broker backend session + inside an open SQL transaction and blocking unrelated follow-up work. +- Native SQL regression coverage now lives in `oliphaunt` itself instead + of depending on the legacy WASIX crate's regression tests. The new + `native_sql_regression` test runs the same compact PostgreSQL behavior suite + through direct, broker, and server modes: parameterized inserts, numeric/bool/ + JSONB/bytea/array values, trigger side effects, views, constraint errors, + savepoint recovery, committed transactions, index-plan checks, and SQL error + recovery. + +## Product Architecture Judgment + +The ultimate product should remain Rust-first for now: + +- `liboliphaunt` owns the embeddable PostgreSQL C ABI and upstream patch stack. +- `oliphaunt` owns Rust configuration, root management, extensions, + async execution semantics, broker/server selection, tests, and benchmarks. +- Swift, Kotlin, and React Native should follow the Rust SDK semantics instead + of defining parallel product behavior. + +This is the right split because the hardest correctness decisions are +PostgreSQL lifecycle, storage roots, extension loading, backup/restore, +concurrency, and performance. Rust is the best place in this repo to encode +those decisions once and make other SDKs thinner. + +The three-mode model is also correct: + +- `NativeDirect` is the lowest-latency embedded path. It must stay honest: one + physical backend session, no fake pools, no fake independent connections. +- `NativeBroker` is the robust app mode. It should become the default desktop + recommendation when developers need multiple roots, crash isolation, upgrade + orchestration, or long-running app behavior. +- `NativeServer` is the compatibility mode. It must be a real PostgreSQL server + process for `psql`, `pg_dump`, ORMs, pools, and independent sessions. + +The mode split is how this competes with SQLite without pretending PostgreSQL +has SQLite's process model. SQLite wins by having a small, direct, single-file +engine. Native Oliphaunt can compete only if it is honest about where PostgreSQL is +stronger: SQL compatibility, extensions, types, query planner, ecosystem, and +server compatibility. Direct mode should win on embedded latency where possible; +broker/server modes should win on robustness and compatibility where direct mode +cannot. + +## C ABI And PostgreSQL Patch Stack + +The current C ABI is deliberately small: + +- `oliphaunt_init` +- `oliphaunt_exec_protocol` +- `oliphaunt_exec_simple_query` +- `oliphaunt_exec_protocol_stream` +- `oliphaunt_cancel` +- `oliphaunt_close` +- `oliphaunt_last_error` +- `oliphaunt_version` +- `oliphaunt_capabilities` +- `oliphaunt_free_response` + +That is a good first boundary. It keeps query semantics on PostgreSQL's native +wire protocol and avoids inventing a second SQL API at the C layer. + +The PostgreSQL 18 patch stack is mostly defensible: + +- host I/O hooks are added below backend libpq communication; +- a dedicated embedded backend entrypoint is used instead of single-user mode; +- embedded shutdown runs PostgreSQL cleanup without exiting the host process; +- current-working-directory restoration is explicit. + +The patches should remain generic and upstreamable. They must not learn about +Rust, React Native, iOS, Kotlin, extension manifests, or product policy. +The upstream shape should be "PostgreSQL can run one backend session with +host-provided read/write callbacks and explicit lifecycle." + +Open patch-stack risks: + +- simultaneous direct instances are rejected; repeated direct lifetimes in one + process remain a release-gated lifecycle area because PostgreSQL process + globals are not designed as a normal library lifecycle; +- embedded lifecycle still touches `postgres.c` enough that every PostgreSQL + minor bump needs careful review; +- no upstream-style test target exists inside the patched PostgreSQL tree for + the embedded entrypoint; +- cancellation now has mode-specific implementations for direct, broker, and + server, and close now waits for active SDK-owned work before shutdown/detach. + Rust SDK smoke now covers repeated cancellation/recovery for direct, broker, + and server plus PostgreSQL CancelRequest behavior from an external + `tokio-postgres` server-mode client; +- COPY now has direct/broker/server/native-PostgreSQL rows in the + source-current native matrix and env-gated Rust regression coverage for both + client-driven `COPY FROM STDIN` and streamed `COPY TO STDOUT`. The regression + sends frontend CopyData/CopyDone frames, validates the `CopyInResponse`, + inserted payloads, and post-COPY reuse, then drives invalid COPY input and + frontend `CopyFail` to verify `ErrorResponse`, `ReadyForQuery`, zero committed + rows, and post-error session reuse. It then streams `COPY TO STDOUT`, + validates the backend protocol frames and payload size, and proves the + session can execute a normal query afterward. The remaining release + discipline is to refresh those measurements whenever the harness, runtime + inputs, or protocol paths change. + +## Rust SDK Review + +Strengths: + +- `oliphaunt-wasix` and `oliphaunt` are separate packages. +- `EngineMode` separates direct, broker, and server instead of mixing WASIX and + native selection. +- direct mode is cloneable at the Rust handle level but serialized through an + owner executor. +- transaction/session pinning prevents unpinned work from interleaving with a + physical-session-sensitive transaction. +- extension selection is explicit by exact PostgreSQL extension name. +- root locking treats live storage as a directory and now combines a + same-process canonical root registry with the `.oliphaunt.lock` filesystem + marker plus a stable sibling filesystem lease keyed by the canonical root + path. The stable lease is used across direct, broker-helper, server, backup, + and restore paths, so root replacement stays locked while the old directory is + moved aside and the restored directory is published. Restore/import reserves + missing and empty target paths with the stable lease without creating an + in-root marker before publish. +- plain C ABI callers now also get default root ownership at `oliphaunt_init` + through a stable sibling filesystem lease keyed to ``, with + `/.oliphaunt.lock` kept as the visible root marker. + C `oliphaunt_restore` takes the same stable lease before staging or publishing, + so it cannot replace a live direct-C-ABI root. The stable filename now uses + the same SHA-256 prefix algorithm as the Rust SDK rather than a C-only hash, + so cross-SDK missing-target restore reservations contend on one root + identity. SDKs may opt out only with `OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK`; the + Rust SDK sets that flag because it already owns the broader + direct/broker/server/backup/restore coordinator. + +Gaps: + +- `NativeDirect` now uses the chunked `liboliphaunt` streaming ABI. `NativeBroker` + forwards those chunks over broker IPC. `NativeServer` streams complete + PostgreSQL wire frames as they are read from the local server connection. +- `NativeBroker` is a supervised worker-per-root architecture using + authenticated Unix-domain socket IPC on Unix. That is the correct shape while + the direct C ABI is process-global: one worker can crash without taking down + the application or other broker roots, and the SDK can bound root fan-out. + Helper relaunch after an observed crash is now covered; durable request + replay, richer crash policy, and upgrade orchestration remain + broker-supervisor release gates. +- `NativeServer` is a real server process with release-gate smoke for `psql`, + packaged `pg_dump`, `tokio-postgres`, `tokio-postgres` external cancellation, + `sqlx` pools, restart, and concurrent sessions. It still needs broader + ORM-specific coverage beyond SQLx. +- direct and broker expose same-version physical backup; server additionally + exposes logical SQL backup through `pg_dump`. Physical restore/import is now + a first-class Rust SDK API with safe staging and locked replacement, and the + server restore smoke proves the archive can recover user data. +- root/runtime/template materialization is no longer carrying the low-level + filesystem copy, fingerprinting, selected-extension asset policy, + runtime-cache orchestration, runtime asset installation, runtime cache + key/validation, or packaged-template bootstrap policy in the same file. + Focused cache-key tests now prove selected extension SQL/module changes + invalidate runtime caches, unselected extension assets remain invisible, and + selected extensions are required during cache validation. + +## Extension Architecture + +The correct extension model is manifest-gated and opt-in: + +- no default "everything" bundle; +- `CREATE EXTENSION` succeeds only when the selected exact extension provides + SQL/control files and the linked or packaged module; +- mobile uses static registries and resource bundles; +- desktop may additionally support signed dynamic extension artifacts; +- Rust defines the exact-extension resource contract first, other SDKs mirror it. + +The current Rust surface supports explicit extension selection, and the root +materializer copies selected extension assets. Native smoke coverage verifies +that an unselected extension fails and the selected `vector` extension works +through direct, broker, and server. The external pgrx lane also builds pgGraph +`graph` and ParadeDB `pg_search` as opt-in extension candidates, and an +env-gated external matrix now +proves install/load, extension-specific behavior, restart, physical backup, and +physical restore for both external modules across direct, broker, and server. +The pgGraph smoke builds and traverses a tiny graph, then verifies root-scoped +persisted mmap auto-load, exact search, and shortest path after reopen/restore. +The ParadeDB smoke creates a real BM25 index and exercises `@@@`, +`paradedb.all`, `pdb.score`, and tokenizer stopword behavior after +reopen/restore. +`NATIVE_EXTENSION_MANIFEST` now records, per extension: + +- PostgreSQL 18 support status; +- SQL/control asset class; +- shared module requirement; +- transitive data files; +- smoke SQL strategy; +- restart and backup/restore coverage status; +- direct-C-ABI/broker/server coverage status; +- mobile static-link status. + +The remaining extension release blocker is now real platform extension-object +builds and device evidence, not the low-level loader or generated registry +source. Runtime resources record `mobileStaticRegistryState`, +`mobileStaticRegistryPending`, `nativeModuleStems`, and +`mobileStaticRegistrySource`, and the runtime-resource generator has a +`--require-mobile-static-registry` release gate. Kotlin and React Native +Android split-resource builds now stay pending for module-backed extensions +instead of accepting a static-module declaration without generated registry +source; mobile-complete Android/iOS selected-extension artifacts must consume Rust +runtime-resource generator output that includes `static-registry/oliphaunt_static_registry.c`. +pgGraph `graph` and ParadeDB `pg_search` are represented as internal external +PG18 candidates, not release-ready first-party selections, so they cannot +silently enter mobile or desktop bundles. Generated runtime resources now +record exact selected extensions, dependency-expanded runtime manifests, +static-registry state, and per-extension size evidence. Module-backed +extensions still need generated, linked, and device-tested platform extension +objects with the expected static symbol names before iOS/Android release, and +signed dynamic desktop extension artifacts still need a real signature and +loader policy before they can be accepted. + +Extensions without official PostgreSQL 18 support should stay out of the first +native release lane. + +## Swift, Kotlin, And React Native DX + +The SDK direction is sound: + +- Swift now exposes actor/async APIs and a native-direct C ABI engine. It still + needs platform-native XCFramework/resource packaging for release. +- Kotlin now exposes suspend APIs, a Kotlin/Native native-direct C ABI engine, + and an Android JNI-backed native-direct engine behind the same common API. + Android hides JNI/threading and runtime materialization behind that SDK shape + rather than introducing a second product boundary. +- React Native should use New Architecture TurboModules and a typed TypeScript + API. The current TypeScript/TurboModule shape now includes `cancel()` and + wait-and-detach close. iOS delegates to `Oliphaunt`; Android delegates to the + Kotlin SDK. Both still need full app/device smoke coverage with packaged + `liboliphaunt` and real runtime artifacts. + +The React Native package now keeps Codegen for typed lifecycle/control calls +and requires a versioned New Architecture JSI direct-buffer transport for +protocol, backup, and restore bytes. That is the right performance stance for +this product: apps fail early if the JSI installer is missing instead of +silently accepting a serialized binary fallback. The remaining React Native gap is +full app/device smoke coverage with packaged `liboliphaunt` and real runtime +artifacts. + +The mobile SDKs are not production-complete until they package and load the +real runtime resources and pass device/simulator tests that open, query, cancel, +close, restart, and load selected extensions. + +## Testing Strategy + +PostgreSQL's own testing model should be the north star. The official +PostgreSQL 18 docs describe regression tests as a comprehensive SQL test suite +covering standard SQL and PostgreSQL extensions. PostgreSQL also documents TAP +tests for executable/client behavior and temporary test servers. + +liboliphaunt should adopt that shape: + +1. C ABI tests: + - covered by the C smoke: init/shutdown, first bootstrap, process-bound + reopen, protocol success, protocol error recovery, invalid init and + invalid exec/stream arguments, malformed frontend frame rejection and + recovery, large owned responses, stream callback success/failure, close + after error, active-query cancellation and recovery, capabilities, and + version. + +2. SQL regression: + - run a curated PostgreSQL regression subset through direct mode; + - run the same SQL through broker and server; + - compare against a native PostgreSQL control where output is stable; + - classify expected differences explicitly. + +3. Client/server compatibility: + - `psql`; + - `pg_dump` and restore; + - `sqlx`; + - `tokio-postgres`; + - connection pools; + - concurrent sessions in server mode. + +4. Concurrency: + - many async Rust tasks sharing one direct handle; + - fair queueing; + - transaction pinning; + - cancellation and close during active SDK-owned work; + - queued work rejection once close begins; + - broker crash/reconnect. + + Broker crash/reconnect now has an env-gated native smoke that kills the + helper, waits for PostgreSQL crash recovery on relaunch, and reads data + through the same Rust handle. + +5. Extensions: + - absent extension fails; + - selected extension succeeds; + - unselected extension fails; + - each PG18-supported extension has direct/broker/server/restart/dump smoke. + +6. Mobile and RN: + - Swift XCTest on macOS and iOS simulator/device; + - Kotlin/JNI Android instrumentation; + - React Native iOS/Android sample with Codegen and nonblocking JS thread; + - large payload test using the future direct-buffer transport. + +## Benchmark Strategy + +The product gate should be native PostgreSQL parity. The native path calls the +same database engine and should not add material latency, CPU, or memory +overhead beyond its chosen embedding mode. + +Required benchmark dimensions: + +- simple-query RTT p50/p90/p95/p99; +- extended protocol prepare/bind/execute; +- typed query overhead; +- transaction throughput; +- batched insert/update/delete; +- indexed update workloads; +- COPY in/out; +- large result streaming; +- cold open and warm open; +- close/reopen across process; +- backup/restore; +- RSS, peak footprint, and CPU seconds; +- artifact size and resource bundle size; +- native PostgreSQL control; +- SQLite control; +- native direct/broker/server modes. + +The matrix script now measures the three native SDK modes separately: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh +``` + +For fast local checks, run: + +```sh +target/debug/oliphaunt-perf native-liboliphaunt --engine direct --suite rtt --iterations 10 +target/debug/oliphaunt-perf native-liboliphaunt --engine broker --suite rtt --iterations 10 +target/debug/oliphaunt-perf native-liboliphaunt --engine server --suite rtt --iterations 10 +``` + +Release claims should come only from serial matrix runs on an idle machine, with +the run directory and exact binary versions retained. The native matrix writes +`provenance.json`; verify retained evidence with: + +```sh +OLIPHAUNT_PERF_RUN_DIR="$PWD/target/perf/native-liboliphaunt-" \ +tools/perf/check-native-perf-report.sh +``` + +## Code Organization Review + +Good boundaries: + +- `oliphaunt/` owns the native C and PostgreSQL patch stack. +- `src/sdks/rust/` owns the Rust SDK. +- `src/bindings/wasix-rust/crates/oliphaunt-wasix/` remains WASIX-focused. +- `src/sdks/swift`, `src/sdks/kotlin`, and `src/sdks/react-native` own language/platform packages. +- `tools/` owns repo automation. + +Files to split next: + +- `src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c`: backup/restore archive handling has been + split into `src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c`; bootstrap, runtime-tool + discovery, process-global instance guarding, protocol tracing, filesystem + helpers, ustar archive read/write, raw protocol execution, streaming + backpressure, readiness scanning, embedded backend read/write callbacks, and + backend argv/default-GUC construction now live in dedicated C translation + units behind `src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h`. The remaining native + lifecycle file is small enough to keep focused on backend ownership and + public non-query ABI orchestration. +- `src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c`: physical archive lifecycle is now + separated from tar mechanics. If the archive format grows beyond same-version + physical tar, introduce a format dispatcher instead of adding branches to + the tar module. +- `tools/xtask/src/main.rs`: extension cataloging, process RSS sampling, and + perf command orchestration now live in dedicated modules. The remaining split + is asset/release orchestration; benchmark result/report models can move again + if `perf.rs` keeps growing. + +## Release Blockers + +Native direct should not be the default until these are true: + +- no native direct benchmark gate regresses beyond the accepted tolerance + against native PostgreSQL control; +- direct/broker/server each have real performance rows, including + prepared-update rows measured beside the native PostgreSQL prepared-update + control; +- selected extensions pass direct/broker/server tests; +- large response streaming is native, benchmarked, and covered for direct mode; +- direct, broker, and server streaming are benchmarked against native + PostgreSQL controls; +- lifecycle policy is documented and enforced; +- restore/import coverage includes direct, broker, and server backup/restore + smokes with selected extensions; +- mobile packaging can open/query/restart with selected extensions; +- React Native has a direct-buffer path for large results. The TurboModule + Codegen surface is lifecycle/control-only; protocol, backup, and restore + bytes require the versioned JSI `ArrayBuffer` transport, and validation + rejects base64 or Codegen binary regressions. + +## References + +- PostgreSQL 18 regression testing documentation: + https://www.postgresql.org/docs/current/regress.html +- PostgreSQL TAP testing documentation: + https://www.postgresql.org/docs/current/regress-tap.html +- React Native Turbo Native Modules: + https://reactnative.dev/docs/turbo-native-modules-introduction +- React Native Codegen type appendix: + https://reactnative.dev/docs/appendix diff --git a/docs/PERFORMANCE_INTERNAL.md b/docs/internal/PERFORMANCE.md similarity index 69% rename from docs/PERFORMANCE_INTERNAL.md rename to docs/internal/PERFORMANCE.md index 705b8742..eaec33ec 100644 --- a/docs/PERFORMANCE_INTERNAL.md +++ b/docs/internal/PERFORMANCE.md @@ -2,9 +2,9 @@ This page is maintainer documentation for performance tuning, measurement harnesses, and release profiling. Public benchmark results now live in -`docs/PERFORMANCE.md`. +[`../PERFORMANCE.md`](../PERFORMANCE.md). -`pglite-oxide` is optimized for test setup and local-app startup. The runtime +`oliphaunt-wasix` is optimized for test setup and local-app startup. The runtime avoids user-side compilation: supported targets load packaged Wasmer AOT artifacts and reuse cached runtime files. @@ -12,22 +12,22 @@ artifacts and reuse cached runtime files. For test suites: -- use `Pglite::temporary()` or `PgliteServer::temporary_tcp()`; +- use `Oliphaunt::temporary()` or `OliphauntServer::temporary_tcp()`; - reuse the process when possible so the template and module caches stay warm; - keep Postgres client pools at one connection; -- call `Pglite::preload()` once before a visible UI path or a large test group; -- call `Pglite::preload_extensions([...])` when extension setup is on the hot +- call `Oliphaunt::preload()` once before a visible UI path or a large test group; +- call `Oliphaunt::preload_extensions([...])` when extension setup is on the hot path. Example: ```rust,no_run -use pglite_oxide::{extensions, Pglite}; +use oliphaunt_wasix::{extensions, Oliphaunt}; fn main() -> Result<(), Box> { - Pglite::preload_extensions([extensions::VECTOR])?; + Oliphaunt::preload_extensions([extensions::VECTOR])?; - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; @@ -76,31 +76,49 @@ per-root asset population. PGDATA setup is under 1ms. The remaining direct first-query costs are mostly PostgreSQL backend startup, Wasmer instance creation, and the protocol roundtrip for the query itself. -Direct `Pglite::open` no longer runs a separate session-setup query. Direct +Direct `Oliphaunt::open` no longer runs a separate session-setup query. Direct session defaults are applied during startup before connection data is sent, not through SQL. The regenerated WASIX runtime owns this as a required `pgl_apply_default_gucs` bridge helper. -Direct `Pglite` no longer forces a host directory `sync_all` after every +Direct `Oliphaunt` no longer forces a host directory `sync_all` after every non-transaction query. PostgreSQL's own WAL/fsync behavior owns durability; the extra Rust-side directory sync was expensive and weaker than file-level database fsyncs. This matches the server path, which did not pay that cost. -Direct `Pglite` also no longer scans `pg_type` on scalar open/query paths. +Direct `Oliphaunt` also no longer scans `pg_type` on scalar open/query paths. Built-in PostgreSQL array OIDs are registered statically in the Rust direct client. Runtime-created enum/domain/composite arrays are discovered lazily when they appear in direct API parameters or result metadata, or explicitly through -`Pglite::refresh_array_types()`. +`Oliphaunt::refresh_array_types()`. -The WASIX startup arguments explicitly preserve PGlite's effective buffer +The WASIX startup arguments explicitly preserve Oliphaunt's effective buffer profile: `shared_buffers=128MB`, `wal_buffers=4MB`, and `min_wal_size=80MB`. -This matters for PGlite benchmark parity. Without those GUCs, single-user +This matters for Oliphaunt benchmark parity. Without those GUCs, single-user startup fell back to a tiny `shared_buffers=400kB`, causing table-copy and indexed-update workloads to reread relation pages from the host filesystem. +Native `oliphaunt` exposes the same throughput profile explicitly through +`RuntimeFootprintProfile::Throughput`, then adds mobile profiles for benchmark +matrices that need lower resident memory: `BalancedMobile` reduces hidden +server slot counts, sets `shared_buffers=32MB`, shrinks WAL targets to the +smallest valid default for the current 16MB WAL-segment build +(`min_wal_size=32MB`), caps mobile WAL growth at `max_wal_size=64MB`, and +forces PG18 sync I/O; `SmallMobile` reduces shared buffers to `8MB` and further +shrinks work memory. The mobile matrix also sweeps explicit `max_wal_size` +values (`32MB`, `64MB`, and default) so WAL footprint wins are attributable to +bounded checkpoint behavior rather than hidden defaults. Explicit startup GUC +overrides are appended after the profile and durability settings so benchmark +reports can attribute wins or regressions to concrete PostgreSQL knobs. +Experiments below `min_wal_size=32MB` require a template cluster initialized +with a smaller WAL segment size, such as `initdb --wal-segsize=4`; this is a +PGDATA/template property, not a startup GUC. The Expo mobile footprint harness +passes the requested segment size through to template generation and records the +effective read-only `wal_segment_size` setting next to the intended GUCs. + Detailed C-side backend startup timers are an instrumented-build diagnostic, not production runtime surface. Build WASIX assets with -`PGLITE_OXIDE_WASIX_BACKEND_TIMING=1` when investigating `shared_memory`, +`OLIPHAUNT_WASM_WASIX_BACKEND_TIMING=1` when investigating `shared_memory`, `InitPostgres`, or relcache work. Production WASIX artifacts leave that flag off, so timing macros compile away and the `pgl_backend_timing_elapsed_us` export is absent. @@ -113,22 +131,20 @@ filesystem itself. ## Release Asset Profile -The default asset release profile is `release-o3`: WASIX C modules are compiled -with `-O3 -g0 -flto=thin`, linked with `-flto=thin`, then Binaryen runs with the -wasixcc default optimization level plus `--converge`, `--strip-debug`, and -`--strip-producers`. This is the current SQL-workload profile: local PGlite -benchmark parity runs showed broad speed wins over non-LTO O3 and `release-os`, -and package-size checks still stayed comfortably under crates.io limits. +The default asset release profile is `release`: WASIX C modules are compiled +with `-O2 -g0`, then Binaryen runs with the wasixcc default optimization level +plus `--converge`, `--strip-debug`, and `--strip-producers`. This is the current +PG18 SQL-workload profile: local parity runs kept the O2 lane strict green, +while `release-o3`/ThinLTO was mixed and did not justify becoming the default. Available profile knobs: -- `PGLITE_OXIDE_BUILD_PROFILE=release-o3` is the default release asset profile; +- `OLIPHAUNT_WASM_BUILD_PROFILE=release` is the default release asset profile; - `release`, `release-o3`, `release-os`, and `release-oz` remain available for - comparison builds. `release-o3` is the performance profile and includes - ThinLTO by default; -- set `PGLITE_OXIDE_WASM_OPT_FLAGS=none` to disable the release-profile + comparison builds. `release-o3` includes ThinLTO by default; +- set `OLIPHAUNT_WASM_WASM_OPT_FLAGS=none` to disable the release-profile Binaryen converge/strip extras for local build iteration; -- set `PGLITE_OXIDE_WASM_OPT_FLAGS=''` to override the +- set `OLIPHAUNT_WASM_WASM_OPT_FLAGS=''` to override the release-profile Binaryen extras. The WASIX toolchain already enables the relevant Wasm feature baseline for this @@ -137,7 +153,7 @@ extra `-msimd128` did not change the generated AOT artifact sizes in the local release experiment, so it is not carried as a project-specific flag. Wasmer LLVM AOT is generated with the selected mainline codegen profile: -nonvolatile memory operations and a readonly funcref table. Local exact PGlite +nonvolatile memory operations and a readonly funcref table. Local exact Oliphaunt speed-suite measurements showed nonvolatile memory operations improving the server SQLx suite by about 9% geomean. Adding the readonly funcref table on top was about 1.4% faster geomean than nonvolatile-only and improved the indexed @@ -153,7 +169,7 @@ modules, so there is no supported non-EH fallback and no opt-out flag. Asyncify is not part of production builds; it may only be used in an isolated snapshot/journaling experiment if a specific restore design proves it needs that control-flow model. The build scripts reject Asyncify flags by default; -`PGLITE_OXIDE_ALLOW_ASYNCIFY_EXPERIMENT=1` is reserved for local experiment +`OLIPHAUNT_WASM_ALLOW_ASYNCIFY_EXPERIMENT=1` is reserved for local experiment branches only. WASIX dynamic linking is also mandatory. The main module is built as a @@ -176,7 +192,7 @@ and file metadata, then lets Wasmer deserialize the cached native artifact. If deserialization fails, the cache entry is deleted, rebuilt once from the bundled artifact, and retried. -Set `PGLITE_OXIDE_AOT_VERIFY=full` to force full SHA-256 verification of cached +Set `OLIPHAUNT_WASM_AOT_VERIFY=full` to force full SHA-256 verification of cached AOT files, bundled runtime archives, bundled extension archives, PGDATA template archives, and runtime/template module matches. This is useful for debugging cache corruption or CI integrity checks, but it adds cold-start latency and is @@ -203,7 +219,7 @@ cargo test --test performance_smoke -- --nocapture To measure the current cold-start path: ```sh -cargo run -p xtask -- perf cold +cargo run -p oliphaunt-perf -- cold ``` This runs operations sequentially in one process. Each operation reports @@ -217,13 +233,13 @@ operation and excludes cleanup/teardown where appropriate. To include first-install cache bootstrap costs in the first measured preload: ```sh -cargo run -p xtask -- perf cold --reset-cache +cargo run -p oliphaunt-perf -- cold --reset-cache ``` To measure true warm behavior after startup, use the warm harness: ```sh -cargo run -p xtask -- perf warm +cargo run -p oliphaunt-perf -- warm ``` It keeps databases/servers alive and measures repeated direct queries, @@ -231,56 +247,56 @@ transactions, SQLx/tokio-postgres queries, repeated SQLx connections, and extension-backed queries separately from open and shutdown phases. Use `--iterations N` and `--connections N` for shorter local probes. -To run product-style SQL benchmarks similar to PGlite's published benchmark +To run product-style SQL benchmarks similar to Oliphaunt's published benchmark families: ```sh -cargo run --release -p xtask -- perf bench +cargo run --release -p oliphaunt-perf -- bench ``` This emits JSON with two benchmark suites: -- `rtt`: PGlite-style CRUD round-trip microbenchmarks. Each query runs many +- `rtt`: Oliphaunt-style CRUD round-trip microbenchmarks. Each query runs many times, the lowest and highest 10% are discarded when enough samples exist, and the trimmed average is reported. - `speed`: a generated SQLite speedtest-style SQL suite with large insert, select, update, index, delete, and drop workloads. -The RTT suite can run through the direct Rust API, through `PgliteServer` with a -single long-lived SQLx connection, and through `PgliteServer` with a raw +The RTT suite can run through the direct Rust API, through `OliphauntServer` with a +single long-lived SQLx connection, and through `OliphauntServer` with a raw `tokio-postgres` simple-query-protocol connection. The raw `tokio-postgres` mode is there to separate proxy/wire overhead from SQLx client overhead: ```sh -cargo run --release -p xtask -- perf bench --suite rtt --mode server-sqlx -cargo run --release -p xtask -- perf bench --suite rtt --mode server-tokio-postgres-simple -cargo run --release -p xtask -- perf bench --suite speed --mode direct --scale 0.05 -cargo run --release -p xtask -- perf bench --suite speed --speed-source pglite +cargo run --release -p oliphaunt-perf -- bench --suite rtt --mode server-sqlx +cargo run --release -p oliphaunt-perf -- bench --suite rtt --mode server-tokio-postgres-simple +cargo run --release -p oliphaunt-perf -- bench --suite speed --mode direct --scale 0.05 +cargo run --release -p oliphaunt-perf -- bench --suite speed --speed-source oliphaunt ``` -The speed suite is generated locally instead of vendoring PGlite's generated +The speed suite is generated locally instead of vendoring Oliphaunt's generated multi-megabyte SQL files. Use `--scale` for quick local probes and `--scale 1` -for the full default shape. Use `--speed-source pglite` when you need exact +for the full default shape. Use `--speed-source oliphaunt` when you need exact parity with the SQL files checked out under -`assets/checkouts/pglite/packages/benchmark/src`; this mode requires +`target/oliphaunt-sources/checkouts/oliphaunt/packages/benchmark/src`; this mode requires `--scale 1`. To compare simple-query indexed updates against parameterized prepared updates and client pipelining: ```sh -cargo run --release -p xtask -- perf prepared-updates -cargo run --release -p xtask -- perf prepared-updates --skip-native -cargo run --release -p xtask -- perf prepared-updates --skip-native --gate +cargo run --release -p oliphaunt-perf -- prepared-updates +cargo run --release -p oliphaunt-perf -- prepared-updates --skip-native +cargo run --release -p oliphaunt-perf -- prepared-updates --skip-native --gate ``` -This parses the exact update values from PGlite benchmark Tests 9 and 10, uses +This parses the exact update values from Oliphaunt benchmark Tests 9 and 10, uses the same indexed-table setup, and measures SQLx sequential prepared execution, tokio-postgres sequential prepared execution, tokio-postgres pipelined prepared execution over TCP and Unix sockets, and the same tokio-postgres modes against native Postgres. Use `--skip-native` when local native Postgres IPC state is not -healthy or when only PgliteServer modes are needed. This is a server/protocol -benchmark; it does not replace the exact PGlite simple-query suite. +healthy or when only OliphauntServer modes are needed. This is a server/protocol +benchmark; it does not replace the exact Oliphaunt simple-query suite. `--gate` is a local regression smoke gate, not a final CI performance oracle. It checks the transport shape that caused the COPY/prepared-update regression: @@ -294,12 +310,12 @@ pump activation, or backend execution. For focused investigation of indexed update hotspots, run: ```sh -cargo run --release -p xtask -- perf diagnose-indexed-update -cargo run --release -p xtask -- perf diagnose-buffer-cache +cargo run --release -p oliphaunt-perf -- diagnose-indexed-update +cargo run --release -p oliphaunt-perf -- diagnose-buffer-cache ``` This opens fresh temporary databases, runs setup outside the measured section, -then compares exact PGlite Test 9/10 SQL against controlled variants: lookup +then compares exact Oliphaunt Test 9/10 SQL against controlled variants: lookup index only, unlogged table, text update after numeric update, vacuumed variants, and one set-based update. The buffer-cache diagnostic runs `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` for the remaining table-copy hotspots diff --git a/docs/internal/PG18_WASIX_PERF_STATUS.md b/docs/internal/PG18_WASIX_PERF_STATUS.md new file mode 100644 index 00000000..d08ca146 --- /dev/null +++ b/docs/internal/PG18_WASIX_PERF_STATUS.md @@ -0,0 +1,1010 @@ +# PG18 WASIX Performance Status + +Date: 2026-05-29 + +## 2026-05-29 Recheck + +The active PG18 WASIX runtime is now the 37-patch Oliphaunt-style +implementation stack. The apparent `1.26x`-`1.28x` regression came from +running the perf harness through a dev `cargo run` host. The comparable +product path is `cargo run --release -p xtask ...`; with that release host, the +regenerated 37-patch PG18 artifact is faster than same-host PG17.5 and the +documented PG17.5 release table on every speed-test head. + +Upstream `postgres/postgres` was checked against real PostgreSQL +base tags by fetching `REL_17_5` and `REL_18_3` from `postgres/postgres` into +the local Oliphaunt checkouts. `PG17 legacy lane` changes 54 files versus +PostgreSQL `REL_17_5`; `PG18 legacy lane` changes 55 files versus PostgreSQL +`REL_18_3`. The core runtime deltas are the same in both branches: +`build-oliphaunt.sh`, `oliphaunt/src/oliphauntc/oliphauntc.c`, `xlog.c`, +`checkpointer.c`, `postgres.c`, `postinit.c`, `guc.c`, plus storage/init +support files. The upstream PG18 branch does not contain hidden btree, hash, +LIKE, or executor speed patches beyond the Oliphaunt single-backend lifecycle +shape. + +Diagnostic timing/count patches `0035`-`0039` are not release implementation +patches and are not in the active series. `0039` was explicitly harmful +because it added live `XLogWrite()` counters even when backend timing was not +compiled in. A later release-candidate `0035` experiment that tried to coalesce +single-user `XLogWrite()` buffer writes was also rejected: the broadened +ring-contiguous version made the Test 11 diagnostic worse (`INSERT 2` about +`204 ms`, `COMMIT` about `38 ms`) than the 34-patch lane (`INSERT 2` about +`93 ms`, `COMMIT` about `14 ms`). + +Another rejected `0035` experiment tried to avoid the `xlblocks` readiness scan in +single-user `XLogWrite()` while preserving write grouping and LSN advancement. +It built, but regressed the focused Test 11 buffer/cache diagnostic +(`INSERT 1` `45.412 ms`, `INSERT 2` `144.774 ms`, `COMMIT` `21.959 ms`), so it +was removed and the active artifacts were restored to the 34-patch fingerprint +below. + +The accepted `0035` patch, +`0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch`, keeps the +PostgreSQL `XLogWrite()` grouping and LSN advancement semantics but replaces the +per-WAL-page `XLByteInPrevSeg()` dynamic division in the hot scan loop with +cached open-segment byte bounds. This did not fix the isolated Test 11 COMMIT +diagnostic (`15.603 ms`, versus `13.993 ms` for the 34-patch baseline and +`0.711 ms` for same-host PG17.5), but it materially improved the full speed +suite. + +A release-o3/ThinLTO rebuild of the same 35-patch source was tested at +`target/perf/pg18-wasix-core-release-o3-35patch-prevseg-bounds-release-host-speed-server-sqlx.json`. +It improved Test 15 (`73.637 ms`, `0.989x` documented PG17.5) but regressed +overall (`1.104x` geomean versus the O2 35-patch median) and missed documented +PG17.5 on Tests 1, 3, 6, 10, 11, and 16. Keep the active perf artifact on the +O2 release profile. + +Current active artifact: + +- PostgreSQL version: `18.4` +- active patch count: `36` +- source fingerprint: + `18.4:81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094:3cfc56f67ba63996e8efd7414464c269a3e65f3d4abbd3c65c7a4cf8e8b0d7c4` +- latest accepted perf patch: + `0036-oliphaunt-wasix-skip-activity-id-reporting.patch` +- verification: + `assets release-build --profile release + --skip-build --skip-package-size` passes source-spine, source-isolation, + canonical-layout, manifest-pin, and AOT packaging checks. +- current release-host result files: + `target/perf/pg18-wasix-core-release-o2-36patch-skip-activity-id-release-host-speed-server-sqlx.json`, + `target/perf/pg18-wasix-core-release-o2-36patch-skip-activity-id-release-host-speed-server-sqlx-rerun.json`, + `target/perf/pg18-wasix-core-release-o2-36patch-skip-activity-id-release-host-speed-server-sqlx-rerun2.json` +- release-host reruns can use the stable PG18 generated asset and AOT paths: + `OLIPHAUNT_WASM_GENERATED_ASSETS_DIR=.../target/oliphaunt-wasix/assets`, + and `OLIPHAUNT_WASM_GENERATED_AOT_DIR=.../target/oliphaunt-wasix/aot`. + Asset, PGDATA-template, and AOT manifests carry source-fingerprint metadata, + so a stale artifact fails with a fingerprint mismatch before measurement. + +Current 37-patch O2 release-host three-run median against same-host PG17.5 +`0.5.0` and the documented PG17.5 release-lane table in +`src/docs/content/reference/performance.md`: + +| Test | PG18 37-patch O2 median ms | PG17.5 same-host median ms | PG18 / same-host | Documented PG17.5 ms | PG18 / documented | +| --- | ---: | ---: | ---: | ---: | ---: | +| 1 | 19.565 | 19.893 | 0.984 | 19.76 | 0.990 | +| 2 | 144.833 | 165.821 | 0.873 | 149.54 | 0.969 | +| 2.1 | 50.490 | 61.670 | 0.819 | 59.39 | 0.850 | +| 3 | 209.441 | 253.007 | 0.828 | 253.38 | 0.827 | +| 3.1 | 79.769 | 107.124 | 0.745 | 95.12 | 0.839 | +| 4 | 142.026 | 190.276 | 0.746 | 162.89 | 0.872 | +| 5 | 236.305 | 388.982 | 0.607 | 338.01 | 0.699 | +| 6 | 11.756 | 14.996 | 0.784 | 13.08 | 0.899 | +| 7 | 120.748 | 142.110 | 0.850 | 125.31 | 0.964 | +| 8 | 68.267 | 85.746 | 0.796 | 74.42 | 0.917 | +| 9 | 520.213 | 610.845 | 0.852 | 578.96 | 0.899 | +| 10 | 583.991 | 775.341 | 0.753 | 712.38 | 0.820 | +| 11 | 76.259 | 97.814 | 0.780 | 97.43 | 0.783 | +| 12 | 6.814 | 10.813 | 0.630 | 9.74 | 0.700 | +| 13 | 11.898 | 27.974 | 0.425 | 26.58 | 0.448 | +| 14 | 69.192 | 78.119 | 0.886 | 71.60 | 0.966 | +| 15 | 74.343 | 90.388 | 0.822 | 74.49 | 0.998 | +| 16 | 6.777 | 9.970 | 0.680 | 10.17 | 0.666 | + +Geomean: `0.759x` same-host PG17.5 and `0.826x` documented PG17.5. This is +per-head parity against both the same-host PG17.5 release-lane rerun and the +documented PG17.5 release table. The previous 35-patch O2 median was already +green against same-host PG17.5 but missed the documented table on Test 15 +(`75.954 ms` versus `74.49 ms`). The accepted 36th patch removes unused +pg_stat_activity query/plan ID reporting from the embedded WASIX runtime, +bringing Test 15 to `74.343 ms` while keeping every other head under both +baselines. + +The current Test 15 diagnostic is at +`target/perf/pg18-wasix-core-release-o2-36patch-skip-activity-id-diagnose-speed-15.json`. +It records `74.108 ms` total for the selected benchmark buffer, with +`73.530 ms` inside `postgres.protocol.dispatch_buffer`, `253 us` in +`postgres.protocol.input_write`, `30 us` in output read, and `0 us` in +`client.finish.sync_to_fs`. The same diagnostic on the old 31-patch stack was +`104.165 ms` with `103.748 ms` in dispatch, and the 35-patch release candidate +was `76.756 ms` with `76.271 ms` in dispatch. + +The current branch also cannot directly rerun the released PG17.5 AOT: the Rust +loader now expects the newer PG18 protocol-buffer export +`oliphaunt_wasix_input_reset`, while the released PG17.5 module does not export +`_oliphaunt_wasix_input_reset`. Therefore the preserved `0.5.0` same-host JSON +files remain the PG17.5 comparison baseline unless a compatibility runner is +restored. + +Implementation comparison status: + +- The PG18 37-patch lane has the important Oliphaunt-style PG18.3 lifecycle hints + from upstream `PG18 legacy lane` commit + `cf82a9936be24e6b4203855b34d77a49c83ba2bd`: postmaster-environment flags, + XLog checkpoint-request guard, local in-process checkpoint behavior, GUC + report allocation skip, and the POSIX semaphore reset fast path. The + LIKE/hash/btree/top-XID fast paths are not upstream Oliphaunt deltas; they were + ported from the concurrent WASIX experiment and are already in this PG18 + lane. +- `XLogFlush()` is not materially different between PG17.5 Oliphaunt and PG18.3 + Oliphaunt. The earlier isolated COMMIT diagnostic gap pointed at PG18 + `XLogWrite()`/WAL-buffer scan behavior or the current Oliphaunt WASIX runtime + state around it, but that sub-step no longer blocks speed-suite parity. +- Strict speed-suite parity is now met on the current three-run median. Future + work should focus on validating the activity-ID reporting tradeoff against + any pg_stat_activity/query-id tests we decide to support in the embedded + embedded product, plus broader smoke coverage for extensions and pg_dump. + +## Current Status + +Superseded by the `2026-05-29 Recheck` section above. This section is retained +as historical context for the earlier 31-patch and pre-release-host +measurements; do not use its fingerprint or result files as the active PG18 lane +status. + +The PG18 WASIX runtime builds and runs the product-style +`OliphauntServer` + SQLx speed suite as a core-only perf probe. It is not yet a +replacement candidate for the released PG17.5 WASIX lane. + +Current repeated PG18 probe: + +- PostgreSQL version: `18.4` +- source fingerprint: + `18.4:81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094:56d87b5a7e76fca055e49574e8c09df1e363905a2d60d0e81853a679824c192a` +- build profile: `release` (`-O2 -g0`) +- scope: core-only, extensions and `pg_dump` skipped through + `OLIPHAUNT_WASM_SKIP_EXTENSIONS_FOR_PERF=1` +- result files: + `target/perf/pg18-wasix-core-release-o2-31patch-speed-server-sqlx.json`, + `target/perf/pg18-wasix-core-release-o2-31patch-speed-server-sqlx-rerun.json`, + and + `target/perf/pg18-wasix-core-release-o2-31patch-speed-server-sqlx-rerun2.json` + +The current fair comparison is the product-style `OliphauntServer` + SQLx speed +suite against the documented PG17.5 release-lane table in `src/docs/content/reference/performance.md`. +Across three local PG18 runs, the median geomean is `1.063x` PG17.5, not the +older `1.4-1.5x` estimate from stale single-run files. The first run after AOT +crate rebuild was a cold outlier (`1.410x` geomean); the next two runs were +`0.983x` and `1.046x`. + +Median PG18 server-SQLx misses vs documented PG17.5 release lane: + +- Test 15, big DELETE plus 12k small INSERTs: `95.92 ms` vs `74.49 ms` + (`1.29x`). +- Test 1, 1000 INSERTs: `24.40 ms` vs `19.76 ms` (`1.23x`). +- Test 14, big INSERT after big DELETE: `87.57 ms` vs `71.60 ms` (`1.22x`). +- Test 11, INSERTs from SELECT: `119.07 ms` vs `97.43 ms` (`1.22x`). +- Test 10, 25k text indexed UPDATEs: `862.53 ms` vs `712.38 ms` (`1.21x`). +- Test 7, 5000 indexed SELECTs: `146.06 ms` vs `125.31 ms` (`1.17x`). + +The current lane is therefore near parity, but not equal across all heads. The +remaining repeatable gap is simple-query per-statement overhead, especially +Test 15's `BEGIN; DELETE FROM t1;` followed by 12k one-row `INSERT` statements. +Focused Test 15 diagnostic +`target/perf/pg18-wasix-core-release-o2-31patch-speed-diagnose-15.json` +recorded `104.17 ms` (`1.40x` PG17.5), all inside backend protocol dispatch. + +A same-host PG17.5 release-lane rerun was attempted from the public `0.5.0` +release artifacts. Those artifacts cannot be run through the current PG18 +branch loader because the released PG17.5 module uses the old `oliphaunt` archive +names and does not export the newer `oliphaunt_wasix_input_reset` +protocol-buffer entrypoint. Running the `0.5.0` code in a detached worktree +with the public `0.5.0` assets does work, and gives a same-host baseline: + +- PG17.5 same-host server-SQLx files: + `target/perf/oliphaunt17-0.5.0-samehost-speed-server-sqlx.json` and + `target/perf/oliphaunt17-0.5.0-samehost-speed-server-sqlx-rerun.json`. +- PG17.5 same-host median is `1.088x` the documented release table geomean. +- PG18 median is `0.977x` the same-host PG17.5 median geomean. + +The same-host comparison means PG18 is at overall parity with the released +PG17.5 lane on this machine. The remaining work is per-head parity, not broad +throughput parity. PG18 still trails same-host PG17.5 most on: + +- Test 1, 1000 INSERTs: `24.40 ms` vs PG17.5 `19.89 ms` (`1.23x`). +- Test 11, INSERTs from SELECT: `119.07 ms` vs PG17.5 `97.81 ms` (`1.22x`). +- Test 14, big INSERT after big DELETE: `87.57 ms` vs PG17.5 `78.12 ms` + (`1.12x`). +- Test 10, 25k text indexed UPDATEs: `862.53 ms` vs PG17.5 `775.34 ms` + (`1.11x`). + +## Upstream PG18.3 Oliphaunt Branch Hints + +Checked upstream `postgres/postgres` branch `PG18 legacy lane` at +commit `cf82a9936be24e6b4203855b34d77a49c83ba2bd` on 2026-05-29. + +Superseded by the `2026-05-29 Recheck` implementation-comparison bullets above. +The active 37-patch PG18 lane now carries the relevant upstream Oliphaunt +lifecycle/runtime choices under Oliphaunt-owned markers. The historical detail +below records what was found before those patches were incorporated. + +The branch has six relevant PG18.3 choices: + +- `src/backend/tcop/postgres.c`: `oliphaunt_wasix_start()` sets + `IsPostmasterEnvironment = true` and `IsUnderPostmaster = true`. The PG18 + Oliphaunt lane now ports those flags in `oliphaunt_wasix_start()` as patch + `0034`. +- `src/backend/access/transam/xlog.c`: upstream Oliphaunt wraps the + `XLogCheckpointNeeded(openLogSegNo) -> RequestCheckpoint(CHECKPOINT_CAUSE_XLOG)` + path in `#ifndef __OLIPHAUNT__`, preventing automatic XLog-size checkpoint + requests in the Oliphaunt build. +- `src/backend/postmaster/checkpointer.c`: upstream Oliphaunt wraps the + `if (!IsPostmasterEnvironment)` guard in `#ifndef __OLIPHAUNT__`, so + `RequestCheckpoint()` runs the local `CreateCheckPoint(... | + CHECKPOINT_IMMEDIATE)` body even after Oliphaunt marks itself as a postmaster + environment. +- `src/backend/port/posix_sema.c`: upstream Oliphaunt changes + `PGSemaphoreReset()` to a single `sem_trywait()` under `__OLIPHAUNT__`. +- `src/backend/utils/misc/guc.c`: upstream Oliphaunt skips the + `guc_strdup(record->last_reported)` copy in `ReportGUCOption()`. +- `build-oliphaunt.sh`: upstream PG18.3 still uses `--disable-spinlocks`. + +The old PG17.5 release lane already guarded for the upstream Oliphaunt +checkpointer shape in its source-spine checks (`stable-checkpointer-disable`, +`stable-external-checkpointer`, and `stable-postmaster-environment`). The PG18 +embedded WASIX runtime intentionally renamed the markers to `OLIPHAUNT_WASM_*` and +currently bans direct `__OLIPHAUNT__` inheritance, so these should be ported as +Oliphaunt-specific patches if we adopt them. + +This upstream comparison lines up with the focused Test 11 evidence. In the +same-host focused buffer/cache diagnostic, PG18 and PG17.5 had near-identical +`INSERT INTO ... SELECT` execution times, but PG18 spent `13.993 ms` in +`COMMIT` while PG17.5 spent only `0.711 ms`. The most plausible upstream hint +for that miss is not btree/executor work; it is the XLog/checkpoint behavior +around transaction end. + +Later WAL instrumentation narrows this further. Timing patch `0038` showed the +PG18 COMMIT miss is not raw `pg_pwrite`, WALWriteLock, pgstat accounting, fsync, +or walsender wakeup. Almost all time is in `XLogWrite()`'s per-page loop: + +- `COMMIT`: `13.912 ms` +- `commit_xlog_flush`: `13.824 ms` +- `xlog_flush_xlog_write`: `13.821 ms` +- `xlog_write_loop`: `13.819 ms` +- `xlog_write_loop_scan`: `12.884 ms` +- `xlog_write_pwrite`: `0.819 ms` + +Counter patch `0039` shows why: on the Test 11 COMMIT, PG18 writes one full +`wal_buffers` ring worth of WAL: + +- `xlog_write_loop_count`: `512` +- `xlog_write_page_count`: `512` +- `xlog_write_group_count`: `2` +- `xlog_write_pwrite_count`: `2` +- `xlog_write_pwrite_bytes`: `4,194,304` +- `xlog_write_request_bytes`: `4,188,592` + +That means PG18 is doing 512 page-readiness checks and two actual writes at +COMMIT. + +A PG17.5 `0.5.0` same-host WAL-state rerun with the same diagnostic shape shows +that PG17.5 reaches COMMIT with essentially the same WAL state, but completes +the tail write much faster: + +| Statement | PG17.5 elapsed | PG17.5 insert/flush gap | PG18 elapsed | PG18 insert/flush gap | +| --- | ---: | ---: | ---: | ---: | +| `BEGIN` | `0.065 ms` | `0` | `0.022 ms` | `0` | +| first `INSERT INTO ... SELECT` | `102.184 ms` | `2,757,824` | `29.965 ms` | `2,757,824` | +| second `INSERT INTO ... SELECT` | `197.732 ms` | `9,277,984` | `94.253 ms` | `9,278,000` | +| `COMMIT` | `1.093 ms` | `0` | `13.890 ms` | `0` | + +The PG17.5 elapsed values in this WAL-state rerun were produced from a local +diagnostic build of the `0.5.0` xtask, so they should not replace the documented +release-lane timing table. The WAL LSN state is still decisive: PG18 is not slow +because it uniquely defers more WAL to COMMIT. It is slow because the same +already-open WAL tail write path costs much more in the current PG18 WASIX +artifact. + +A direct single-user `XLogWrite()` fast-path experiment was tested and rejected. +The first version skipped the normal segment initialization path and failed +during buffer-cache setup case 10 with `WASI exited with code: ExitCode::127`. +Changing the fast path to use `XLogFileInit()` fixed the crash, but made Test 11 +COMMIT slower: `23.197 ms` vs the existing PG18 `13.993 ms` and PG17.5 +`0.711 ms`. The experiment was removed from the active patch series. The +saved failed-result file is +`target/perf/pg18-wasix-core-release-o2-40patch-xlog-fastpath-init-buffer-cache.json`. + +An even narrower replacement experiment, +`0040-oliphaunt-wasix-fast-path-open-segment-xlog-write.patch`, was also tested +and rejected. It only ran when the WAL segment was already open and the write +stayed inside that segment. The focused diagnostic result is saved at +`target/perf/pg18-wasix-core-release-o2-40patch-open-segment-xlog-fastpath-buffer-cache.json`. +It reduced Test 11 `COMMIT` only from `13.890 ms` to `11.890 ms`, while +regressing the two `INSERT INTO ... SELECT` statements from `29.965/94.253 ms` +to `119.319/209.435 ms`. It also produced a WAL state after `BEGIN` where +write/flush LSN was ahead of insert LSN by `2216` bytes, so the approach is not +correct enough to keep in the active patch series. The active lane is back to +39 patches. + +Patch status: + +- `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch` now ports the + upstream XLog/checkpointer behavior under `OLIPHAUNT_WASM_SINGLE_USER`. + It intentionally does not set `IsPostmasterEnvironment` or + `IsUnderPostmaster` in `oliphaunt_wasix_start()`, so the startup-environment + change remains isolated for a later A/B test. +- `0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch` now + ports upstream PG18.3 Oliphaunt's smaller `PGSemaphoreReset()` and + `ReportGUCOption()` shortcuts under `OLIPHAUNT_WASM_SINGLE_USER`. +- `0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch` now matches + the released PG17.5 lane and upstream PG18.3 Oliphaunt by setting + `IsPostmasterEnvironment = true` and `IsUnderPostmaster = true` in + `oliphaunt_wasix_start()`. This is paired with `0032` so the lane does not + inherit the multi-process XLog/checkpointer path. +- The sibling PG18/native worktree's perf notes called out the release-lane + WASIX buffer profile as critical for Oliphaunt benchmark parity: + `shared_buffers=128MB`, `wal_buffers=4MB`, and `min_wal_size=80MB`. The PG18 + single-user Rust startup path already preserves those same defaults in + `DEFAULT_STARTUP_GUCS`, matching the PG17.5 release lane, so the current + remaining misses are not explained by falling back to PostgreSQL's tiny + standalone `shared_buffers` default. The source-spine guard now checks this + explicitly. + +Recommended next measurement order: + +1. Rerun the full server-SQLx median table from the restored 39-patch artifact + if we need a fresh all-head view after the 0040 rejection. The generated + runtime and AOT manifests are back on source fingerprint + `18.4:81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094:56d87b5a7e76fca055e49574e8c09df1e363905a2d60d0e81853a679824c192a`. +2. Avoid direct XLogWrite fast paths that advance write/flush from rounded page + boundaries without preserving PostgreSQL's insert-LSN invariants. The next + WAL attempt should either reduce per-page metadata cost without changing LSN + advancement, or move unavoidable work earlier only if the full per-head + median table improves. +3. Keep `PGSemaphoreReset()` and `ReportGUCOption()` as secondary evidence + until measured. They are credible micro-optimizations, but the current + evidence points first at COMMIT/checkpoint behavior and many-small-statement + overhead. + +The previous `target/perf/pg18-wasix-core-release-o2-31patch-speed-diagnose-9-10.json` +file is stale for current status. Rerunning the same focused diagnostic in +`target/perf/pg18-wasix-core-release-o2-31patch-speed-diagnose-9-10-rerun.json` +gave: + +- Test 9, 25k indexed UPDATEs: `645.27 ms` vs documented PG17.5 + release-lane `578.96 ms` (`1.11x` slower). +- Test 10, 25k text indexed UPDATEs: `949.05 ms` vs documented PG17.5 + release-lane `712.38 ms` (`1.33x` slower). + +Additional controlled indexed-update diagnostic: +`target/perf/pg18-wasix-core-release-o2-31patch-diagnose-indexed-update.json` + +This synthetic run uses the PG18 AOT/runtime asset lane and fresh temporary +databases for each case. It is useful for isolating logged/indexed update +mechanics, but it is not the fair release-lane speed benchmark shape because it +does not run all earlier benchmark cases before measuring Test 9/10: + +- exact numeric indexed update: `1128.52 ms`, `1.95x` the documented PG17.5 + Test 9 number (`578.96 ms`). +- exact text indexed update: `1500.82 ms`, `2.11x` the documented PG17.5 Test + 10 number (`712.38 ms`). +- lookup-index-only numeric update: `964.99 ms`, `1.67x` PG17.5 Test 9. +- lookup-index-only text update: `995.51 ms`, `1.40x` PG17.5 Test 10. +- unlogged numeric update: `1196.71 ms`, not better than logged. +- unlogged text update: `1160.32 ms`, better than the exact logged text case + but still `1.63x` PG17.5 Test 10. + +This rules out WAL/fsync as the dominant explanation for the synthetic cold +indexed-update miss. Removing the updated-column index helps, but only +partially; the remaining cost is still in repeated statement execution plus +tuple/index maintenance. + +Upstream PG18.3 Oliphaunt keeps using `--disable-spinlocks`, but this is no longer +a leading explanation for the PG18 Test 11 gap. The current PG18 WASIX +`pg_config.h` has no `HAVE_SPINLOCKS` define and links `src/backend/port/tas.s` +to `tas/dummy.s`; the lane is already on PostgreSQL's no-native-spinlock TAS +path. The `OLIPHAUNT_WASM_PG18_DISABLE_SPINLOCKS=1` knob remains wired, but the +configure script reports the option as unrecognized on this PG18 branch and the +observed build state is already spinlock-disabled. + +For historical context, the last full PG18 speed-table probe before the later +diagnostic patches was: +`target/perf/pg18-wasix-core-release-o2-24patch-speed-server-sqlx.json`. Against +the documented PG17.5 release-lane table in `src/docs/content/reference/performance.md`, that run is +`1.480x` geomean, or about `48.0%` slower. Against native Postgres + SQLx from +the same table, it is `2.270x` geomean, or about `127.0%` slower. + +In that older 24-patch run, the only wins over the documented PG17.5 release +lane were: + +- Test 5, string comparison SELECTs: `271.85 ms` vs `338.01 ms` +- Test 12, DELETE without index: `8.73 ms` vs `9.74 ms` +- Test 13, DELETE with index: `15.24 ms` vs `26.58 ms` + +The release-blocking misses are concentrated in ordinary insert/index/update +workloads: + +- Test 2, 25k INSERTs in a transaction: `317.42 ms` vs `149.54 ms` +- Test 3, 25k INSERTs into an indexed table: `463.47 ms` vs `253.38 ms` +- Test 7, 5000 indexed SELECTs: `316.48 ms` vs `125.31 ms` +- Test 9, 25k indexed UPDATEs: `1322.57 ms` vs `578.96 ms` +- Test 10, 25k text indexed UPDATEs: `1520.13 ms` vs `712.38 ms` +- Test 15, big DELETE plus 12k small INSERTs: `186.20 ms` vs `74.49 ms` + +## Backend Timing Probe + +A timing-enabled core-only artifact was built with +`OLIPHAUNT_WASM_WASIX_BACKEND_TIMING=1` and used only for diagnostics. Do not +treat that artifact as a release artifact. + +Diagnostic file: +`target/perf/pg18-wasix-core-release-o2-backend-timing-diagnose-speed-9-10.json` + +The probe measured fresh-database speed cases 9 and 10 through +`perf diagnose-speed-cases --engine wasix --ids 9,10`. It recorded backend C +timings around `exec_simple_query`, `PortalRun`, and `finish_xact_command`: + +- Test 9, indexed integer UPDATEs: `1422.63 ms` measured. `exec_simple_query` + was `1414.48 ms`; `PortalRun` was `593.69 ms`; `finish_xact_command` was only + `0.58 ms`. +- Test 10, indexed text UPDATEs: `1611.18 ms` measured. `exec_simple_query` + was `1599.00 ms`; `PortalRun` was `731.02 ms`; `finish_xact_command` was only + `0.41 ms`. + +This rules out transaction finish, fsync, or client/protocol transport as the +primary explanation for these two misses. The time is almost entirely inside +backend simple-query execution, but only about `42-45%` of elapsed time is +currently attributed to `PortalRun`. The remaining `54-58%` is still inside +`exec_simple_query` and needs finer probes around parse/rewrite/plan, command +counter, command completion, receiver teardown, and per-statement loop costs. + +Expanded diagnostic file: +`target/perf/pg18-wasix-core-release-o2-backend-timing-expanded-diagnose-speed-9-10.json` + +The expanded probe adds timing around simple-query parse, snapshot, +analyze/rewrite, plan, portal start, destination receiver setup, command +counter, and command completion. Its absolute elapsed times are not directly +comparable to release numbers because it adds many timing calls inside the 25k +statement loop, but the distribution is useful: + +- Test 9: `exec_plan` was `684.44 ms` (`35.0%`), `PortalRun` was `548.21 ms` + (`28.0%`), and `pg_analyze_and_rewrite_fixedparams` was `118.44 ms` + (`6.1%`). `finish_xact_command` was `0.62 ms`. +- Test 10: `exec_plan` was `719.02 ms` (`32.9%`), `PortalRun` was `707.39 ms` + (`32.4%`), and `pg_analyze_and_rewrite_fixedparams` was `135.94 ms` + (`6.2%`). `finish_xact_command` was `0.41 ms`. + +The indexed-update regression is therefore not just btree execution. Repeated +simple-query planning is as large as, or larger than, execution for these +cases. That makes the next high-signal experiment a protocol/query-path +comparison: run equivalent prepared extended-protocol updates against PG18 +WASIX and the documented PG17.5 release lane before adding more btree-specific +shortcuts. + +## Prepared Update Comparison + +Prepared-update diagnostics were run against the timing-enabled PG18 artifact +with source fingerprint +`18.4:81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094:62572d0cf8eb828bf90085986da48f17bccfd06cca8444224f431e326b4f8983`. +The timing patch only instruments simple-query execution; the direct raw +extended-protocol measurement below does not run through those simple-query +probes during the measured update batch. + +SQLx prepared update file: +`target/perf/pg18-wasix-core-release-o2-prepared-updates-sqlx.json` + +SQLx prepared updates are not a useful rescue path in the current server shape: + +- numeric indexed update: `3790.06 ms` +- text indexed update: `3828.13 ms` +- protocol shape: `50000` Bind, `50000` Execute, and `50002` Sync messages + across the two cases, with `50010` protocol batches and `50084` socket + flushes. + +This is slower than simple-query speed tests 9 and 10 because SQLx issues a +Sync boundary per update in this harness. Avoiding planning does not help if +the protocol path adds 25k round-trip/flush boundaries. + +Direct raw pipelined prepared update file: +`target/perf/pg18-wasix-core-release-o2-prepared-updates-direct-raw-pipelined.json` + +The raw frontend/backend protocol path prepares one statement, sends all 25k +Bind/Execute/ClosePortal messages in one batch, then sends one Sync: + +- numeric indexed update: `1029.91 ms`, down from the PG18 simple-query + `1322.57 ms` (`22.1%` faster), but still `1.78x` the documented PG17.5 + release-lane simple-query time of `578.96 ms`. +- text indexed update: `1026.58 ms`, down from the PG18 simple-query + `1520.13 ms` (`32.5%` faster), but still `1.44x` the documented PG17.5 + release-lane simple-query time of `712.38 ms`. + +Conclusion: repeated simple-query planning and protocol shape explain a large +piece of the PG18 indexed-update regression, especially for text updates, but +they do not explain enough to make PG18 competitive with the released PG17.5 +lane. Even with planning mostly removed and the protocol batched, PG18 still +needs executor/storage/btree work before it can replace the release lane. + +The tokio-postgres prepared server path currently fails before measurement with +`invalid message length: expected buffer to be empty`. That should be tracked +as an extended-protocol compatibility bug, but it is not the primary perf +blocker because the direct raw protocol path can already run the prepared batch. + +## Storage/Executor Timing Probe + +Patch `0026` adds diagnostic-only timing around coarse executor/storage hot +paths: + +- `heapam_tuple_update` +- `_bt_doinsert` +- `XLogInsertRecord` + +Storage diagnostic file: +`target/perf/pg18-wasix-core-release-o2-backend-timing-storage-diagnose-speed-9-10.json` + +This diagnostic was run against the 26-patch timing-enabled artifact with +source fingerprint +`18.4:81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094:4a71c8a39ce745890a54f69c9f55b069122729a6f08fe15159de07e2925a4b94`. +The absolute elapsed numbers are inflated by additional timing calls inside the +25k statement loop, but the distribution is useful: + +- Test 9: `exec_plan` was `689.95 ms`; `PortalRun` was `1106.64 ms`; + `_bt_doinsert` was `316.71 ms`; `heapam_tuple_update` was `254.55 ms`; + `XLogInsertRecord` was `184.12 ms`; `finish_xact_command` was `0.54 ms`. +- Test 10: `exec_plan` was `725.38 ms`; `PortalRun` was `1276.53 ms`; + `_bt_doinsert` was `446.81 ms`; `heapam_tuple_update` was `266.85 ms`; + `XLogInsertRecord` was `202.59 ms`; `finish_xact_command` was `0.42 ms`. + +The storage timings are nested/coarse and should not be summed as independent +costs. They do show that after separating planning/protocol overhead, btree +insertion is the largest named execution component for indexed updates, +especially for text updates. Heap update and WAL insertion are material but +smaller. The next probe should split `_bt_doinsert` into scan-key build, +unique check/search, insert-location search, page insertion/split, and WAL +record construction. + +## Btree Insert Timing Probe + +Patch `0027` splits the coarse `_bt_doinsert` probe into btree subphases: + +- scan-key construction +- root-to-leaf insert search +- uniqueness check +- insert-location search +- page insertion +- page split + +Btree diagnostic file: +`target/perf/pg18-wasix-core-release-o2-backend-timing-btree-diagnose-speed-9-10.json` + +This diagnostic was run against the 27-patch timing-enabled artifact. As with +the other timing builds, absolute elapsed time is inflated by instrumentation in +the 25k statement loop. The subphase distribution is still useful: + +- Test 9: `_bt_doinsert` was `862.00 ms`; scan-key build was `71.30 ms`; + insert search was `87.28 ms`; insert-location search was `96.72 ms`; + page insertion was `260.63 ms`; page split was only `14.12 ms`. +- Test 10: `_bt_doinsert` was `987.99 ms`; scan-key build was `71.21 ms`; + insert search was `88.75 ms`; insert-location search was `223.50 ms`; + page insertion was `259.59 ms`; page split was only `13.91 ms`. + +No uniqueness-check timing was reported in cases 9 or 10 because these benchmark +indexes are not unique. Page splits are also not the culprit. The text update +case pays much more in `_bt_findinsertloc` than the numeric update case, while +both cases spend a similar amount in page insertion. A substantial residual +remains inside `_bt_doinsert` after the named subphases, so the next btree probe +should look at comparator/TID/posting-list/dedup support paths used between the +current timers rather than at split handling. + +Patch `0028` adds that next diagnostic layer. It times leaf-page insert binary +search and `_bt_compare` separately as `btree_binsrch_insert` and +`btree_compare`. `_bt_compare` is intentionally a diagnostic-only timer because +it can run many times per statement and will perturb absolute elapsed time; the +useful signal is whether comparison/search accounts for the residual left by +the `0027` split. The probe uses only `oliphaunt_wasix_*` timing symbols and +remains compiled out of normal release artifacts. + +The 29-patch timing artifact was rebuilt and packaged with +`OLIPHAUNT_WASM_SKIP_EXTENSIONS_FOR_PERF=1` for the focused diagnostic. That +perf package includes the PG18 runtime, plpgsql, dict_snowball, initdb, and AOT +artifacts, but intentionally skips external PGXS extensions. + +Btree compare diagnostic file: +`target/perf/pg18-wasix-core-release-o2-backend-timing-btree-compare-diagnose-speed-9-10.json` + +This run is more invasive than the `0027` split because it times `_bt_compare` +inside hot binary-search loops and nested timers double-count. Absolute elapsed +time should therefore not be compared to release numbers. The distribution is +still useful: + +- Test 9: elapsed was `7310.06 ms`; `_bt_doinsert` was `3578.45 ms`; + `_bt_compare` was `1867.51 ms`; leaf insert binary search was `1333.26 ms`; + root-to-leaf insert search was `1307.54 ms`; insert-location search was + `1507.74 ms`; page insertion was `293.59 ms`; page split was `16.12 ms`. +- Test 10: elapsed was `8093.33 ms`; `_bt_doinsert` was `3999.24 ms`; + `_bt_compare` was `2023.19 ms`; leaf insert binary search was `1392.06 ms`; + root-to-leaf insert search was `1468.11 ms`; insert-location search was + `1725.99 ms`; page insertion was `311.86 ms`; page split was `18.77 ms`. + +Conclusion: comparison/search work is now the leading btree signal for indexed +integer lookup and reinsertion. The next candidate should not target page +splits. It should either revive a corrected first-column int4 tuple-data +compare shortcut for the simple integer benchmark shape or split heap update +and WAL behavior before changing more release-default behavior. + +Patch `0030` is the first release-default candidate from that conclusion. It +adds a corrected first-column int4 leaf-tuple fast path. Unlike the earlier +full-concurrent experiment shortcut, this version keeps the old `oliphaunt` naming +out of the source, requires the built-in integer btree opfamily, requires a +normal non-null non-posting non-pivot leaf tuple, and treats equality as a +successful key comparison so the attribute loop is skipped while PostgreSQL's +existing heap-TID/truncated-key tie-break code still runs. It should primarily +affect Test 9. Test 10 still needs heap update, non-HOT update, and WAL +investigation because it updates an unindexed text column while looking up rows +through the integer index on `a`. + +Focused non-timing diagnostic file after `0030`: +`target/perf/pg18-wasix-core-release-o2-30patch-speed-diagnose-9-10.json` + +This was run against a rebuilt non-timing release-profile artifact with external +PGXS extensions skipped for the perf package: + +- Test 9: `1256.64 ms`. +- Test 10: `1476.79 ms`. + +Compared with the earlier PG18 simple-query speed run (`1322.57 ms` and +`1520.13 ms`), `0030` is a small directional win for the int4 indexed-update +case and a marginal win for the text case. It is not enough to catch the +documented PG17.5 release lane (`578.96 ms` and `712.38 ms`). Keep the patch +as a candidate, but the next material optimization needs to address heap +update/WAL behavior and/or remove more repeated planning/protocol overhead. + +Patch `0031` corrects the next diagnostic direction: Test 10 updates the +unindexed text column `c` while using the integer index on `a` for lookup, so it +is not primarily a text btree comparator workload. The patch adds +diagnostic-only heap update timers for modified-column detection, toast work, +new-page buffer selection, heap tuple insertion, and heap update WAL logging. + +Heap diagnostic file: +`target/perf/pg18-wasix-core-release-o2-31patch-backend-timing-heap-diagnose-speed-9-10.json` + +This timing artifact is intentionally not a release-performance comparison. It +adds hot-loop timing calls around btree comparison and heap update internals and +inflates the absolute elapsed time to `6657.05 ms` for Test 9 and `6944.91 ms` +for Test 10. The useful signal is distribution: + +- Test 9: `_bt_doinsert` was `3161.08 ms`; `_bt_compare` was `1642.43 ms`; + `exec_plan` was `694.00 ms`; `heapam_tuple_update` was `459.31 ms`; + `XLogInsertRecord` was `176.97 ms`; heap subprobes were small + (`heap_get_buffer_for_tuple` `44.50 ms`, `heap_determine_columns` + `40.03 ms`, `heap_put_tuple` `37.47 ms`). +- Test 10: `_bt_doinsert` was `3338.45 ms`; `_bt_compare` was `1683.47 ms`; + `exec_plan` was `716.89 ms`; `heapam_tuple_update` was `469.71 ms`; + `XLogInsertRecord` was `193.83 ms`; heap subprobes were again small + (`heap_get_buffer_for_tuple` `45.60 ms`, `heap_put_tuple` `37.76 ms`, + `heap_determine_columns` `37.15 ms`). + +Conclusion: the Test 10 miss is not primarily TOAST, heap-page selection, heap +tuple copy, or heap-update WAL logging. The timing artifact still points at +non-HOT indexed update behavior, btree reinsertion/search/compare work, and +repeated simple-query planning as the high-value areas. The heap subprobes do +not justify a heap-specific shortcut yet. + +The full-concurrent experiment's btree bottom-up-delete runtime toggle was also +tested as a possible diagnostic import. It is not carried in the patch stack. +Even with upstream behavior selected by default, the local embedded port +regressed the no-timing 9/10 run to `2284.27 ms` and `2795.44 ms`; the +`index-unchanged-off` override was no better overall (`2327.91 ms` and +`2765.07 ms`). That makes the hook itself too perturbing for this lane, and +disabling bottom-up deletion remains rejected as a default behavior change. + +Patch `0029` also fixes a buildability issue found while refreshing the timing +package: standalone PG18 WASIX `pg_dump` references `fork()` through PostgreSQL's +parallel dump support, but the single-user sysroot does not provide that symbol. +The patch gives the packaged tool a local `ENOSYS` fork stub under +`OLIPHAUNT_WASM_SINGLE_USER`, so `pg_dump` links without a fork runtime import +and any accidental parallel worker use fails through the existing error path. + +## Release Hygiene + +The rebuilt PG18 runtime binary does not contain the old `pgl_*`/`Oliphaunt` +runtime symbol strings when inspected from the packaged `oliphaunt/bin/oliphaunt` +module. The PG18 patch stack and build scripts also keep the new +`oliphaunt_wasix_*` naming. + +Patch `0030` work also tightened generated metadata: PG18 asset manifests now +filter released-lane source pins whose names, URLs, or branch labels refer to +the old Oliphaunt provenance and replace the PostgreSQL source pin with the PG18 +tarball plus the PG18 patch-series fingerprint. The regenerated PG18 perf +manifest now reports PostgreSQL `18.4`, and +zero old `pgl`/`oliphaunt` provenance references in its source pins. + +## Patch Disposition From This Run + +The full-concurrent experiment's LIKE literal substring fast path remains in +the embedded WASIX runtime as patch `0024`. It appears directionally useful for Test +5, but it does not address the dominant misses. + +The full-concurrent experiment's first-column int4 tuple-data btree shortcut was +tested and removed from the default patch stack. With that shortcut present, +the core-only O2 run was still not competitive (`1.385x` geomean vs PG17.5), +and the indexed update cases remained more than `2x` slower. The patch remains +recorded as deferred in +`src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml`. + +## Full Concurrent WASIX Direction + +The concurrent PG18 WASIX experiment is still valuable runtime research, but it +is not a near-term performance replacement path. Its upstream Wasmer and +WASIX-libc patches are mostly correctness/runtime-enablement work: + +- fixed shared file-backed memory remapping and `msync` +- memory-copy exclusion for forked stores +- resource limits for stack reporting +- socket, epoll, waitpid, futex, signal, and `sigsetjmp` behavior fixes +- fork declarations and full EXEC_BACKEND runtime unblockers + +Those are necessary for proper multi-process PostgreSQL semantics under WASIX, +but they add lifecycle and memory-management work that the product-style +single-backend lane intentionally avoids. They should be tracked upstream and +mined for narrow fixes, not used as the default product architecture until the +runtime can show competitive steady-state SQL numbers. + +The actual local concurrent experiment branch is +`/Users/sid/dev/oliphaunt-oxide-wasix-pg18-experiment` on +`f0rr0/wasix-pg18-experiment`, with a detached copy at +`/Users/sid/.codex/worktrees/2eae/oliphaunt-oxide`. Its query-hot PostgreSQL patch +stack has now been fully triaged against this embedded WASIX runtime: + +- `0006-like-literal-substring-fast-path.patch`: already ported as `0024` with + tighter LIKE/collation guards. +- `0007-top-xid-current-transaction-fast-path.patch`: already ported as `0015`. +- `0008-btree-int4-compare-fast-path.patch`: already ported as `0016` with + tighter opfamily/type/collation guards. +- `0009-btree-delete-stack-state.patch`: already ported as `0017` under + `__wasi__ && OLIPHAUNT_WASM_SINGLE_USER`. +- `0010-btree-bottomup-delete-runtime-toggle.patch`: remains rejected for the + default lane. A local embedded port of the diagnostic hook made the default + release-profile Test 9/10 run slower before any override was enabled, and + disabling bottom-up deletion changes PostgreSQL index maintenance behavior. +- `0011-btree-first-int4-compare-fast-path.patch`: already ported as `0030` + with tighter leaf/non-null/non-posting/non-pivot/built-in-int4 guards. + +That leaves the remaining same-host misses after `0032`-`0034` as measurement +work rather than an obvious unported concurrent-experiment patch. The next +artifact rebuild needs to answer whether the newly ported checkpoint, +postmaster-environment, semaphore-reset, and GUC-reporting changes close Test +11 and improve the smaller Test 1/10/14/15 misses. + +## Recommendation + +Do not promote PG18 WASIX as the `0.6.0` replacement yet solely on this local +evidence. The lane is close enough to parity that the old "2x slower indexed +updates" framing should be retired, but release confidence still needs a +repeat-based run under the same conditions as the PG17.5 release-lane table. + +Continue the PG18 WASIX runtime, not the full concurrent WASIX lane, as the +main product direction. The next perf work should focus on closing the +remaining median misses rather than adding broad speculative PostgreSQL +shortcuts: + +- use repeated server-SQLx runs and compare medians/p90s, because the first run + after AOT crate rebuild can be a cold outlier; +- focus next on Test 15 and other many-small-simple-statement cases, where PG18 + still shows per-statement overhead; +- keep the spinlock-disabled build knob as the highest-signal upstream Oliphaunt + PG18.3 experiment once Docker is available; +- treat old indexed-update files above `2x` as stale unless reproduced with the + current AOT/runtime lane; +- keep `release`/O2 as the measured baseline until `release-o3`/thin-LTO or + wasm-opt proves a repeatable win for this workload; +- leave the full concurrent WASIX mmap/fork patches as runtime research unless + a narrow part directly improves the single-backend lane. + +## 2026-05-29 34-Patch Rebuild + +Docker was brought up locally and the PG18 WASIX runtime was rebuilt with the +34-patch stack in release/O2 profile: + +```sh +OLIPHAUNT_WASM_SKIP_EXTENSIONS_FOR_PERF=1 \ +OLIPHAUNT_WASM_BUILD_PROFILE=release \ +FORCE_RECONFIGURE=1 \ +cargo run -p xtask -- assets release-build \ + \ + --profile release \ + --skip-package-size +``` + +The backend/runtime build completed. Packaging then required the maintainer-only +template runner, so the packaging/AOT phase was rerun with: + +```sh +OLIPHAUNT_WASM_SKIP_EXTENSIONS_FOR_PERF=1 \ +OLIPHAUNT_WASM_BUILD_PROFILE=release \ +cargo run -p xtask --features template-runner -- \ + assets release-build \ + \ + --profile release \ + --skip-build \ + --skip-package-size +``` + +The generated runtime and AOT manifests now report fingerprint +`18.4:81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094:941081dbd639e3aa39f631673060cb4b9f506bcb6fb81da821959bb64eab4552`. + +Focused diagnostics after the rebuild: + +- Test 1: `20.720 ms` +- Test 11: `114.064 ms` + +That improves the old focused PG18 31-patch Test 1/11 numbers (`24.40 ms` and +`119.07 ms`), but still does not fully match same-host PG17.5 for Test 11. + +The full server-SQLx speed benchmark was run twice against the rebuilt PG18 AOT +artifact: + +- `target/perf/pg18-wasix-core-release-o2-34patch-speed-server-sqlx.json` +- `target/perf/pg18-wasix-core-release-o2-34patch-speed-server-sqlx-rerun.json` + +Using the median of those two PG18 runs against the median of the two same-host +PG17.5 `0.5.0` runs gives `1.002x` geomean. Against the documented release-lane +table it is still `1.090x` geomean. The parity is therefore geomean-only; it is +not per-head parity. + +| Test | PG18 median ms | PG17.5 same-host median ms | PG18 / PG17.5 | PG18 / documented | +| --- | ---: | ---: | ---: | ---: | +| 1 | 21.40 | 19.89 | 1.076 | 1.083 | +| 2 | 170.08 | 165.82 | 1.026 | 1.137 | +| 2.1 | 62.47 | 61.67 | 1.013 | 1.052 | +| 3 | 289.88 | 253.01 | 1.146 | 1.144 | +| 3.1 | 90.27 | 107.12 | 0.843 | 0.949 | +| 4 | 191.12 | 190.28 | 1.004 | 1.173 | +| 5 | 361.47 | 388.98 | 0.929 | 1.069 | +| 6 | 17.42 | 15.00 | 1.162 | 1.332 | +| 7 | 164.94 | 142.11 | 1.161 | 1.316 | +| 8 | 94.25 | 85.75 | 1.099 | 1.266 | +| 9 | 665.56 | 610.85 | 1.090 | 1.150 | +| 10 | 822.84 | 775.34 | 1.061 | 1.155 | +| 11 | 112.66 | 97.81 | 1.152 | 1.156 | +| 12 | 9.90 | 10.81 | 0.916 | 1.016 | +| 13 | 15.38 | 27.97 | 0.550 | 0.579 | +| 14 | 80.28 | 78.12 | 1.028 | 1.121 | +| 15 | 103.93 | 90.39 | 1.150 | 1.395 | +| 16 | 8.73 | 9.97 | 0.876 | 0.858 | + +The Test 11 buffer/cache diagnostic still shows the same COMMIT gap as the +31-patch lane: + +| Statement | PG18 34-patch ms | PG17.5 same-host ms | +| --- | ---: | ---: | +| `BEGIN` | 0.019 | 0.023 | +| `INSERT INTO t1 SELECT b,a,c FROM t2` | 29.808 | 29.384 | +| `INSERT INTO t2 SELECT b,a,c FROM t1` | 93.056 | 90.342 | +| `COMMIT` | 13.993 | 0.711 | + +This confirms that patches `0032` and `0034` are applied and built, but they do +not explain or remove the remaining Test 11 COMMIT cost. Source inspection also +confirms the rebuilt PG18 source contains the same upstream PG18.3 Oliphaunt-style +`IsPostmasterEnvironment = true`, `IsUnderPostmaster = true`, +`XLogCheckpointNeeded` guard, and `RequestCheckpoint` guard. The remaining +COMMIT gap is therefore either a different PG18 WAL/checkpoint/pgstat/SLRU path, +or an interaction with the current Oliphaunt WASIX runtime/startup state, not a +simple failure to port those upstream Oliphaunt branch hints. + +The status after this rebuild is: + +- PG18 WASIX is now geomean-parity with same-host PG17.5 within local + two-run noise. +- PG18 WASIX is still not per-test parity and is still behind the + documented release-lane table. +- The broad "PG18 is catastrophically slower" claim is no longer accurate for + the current 34-patch release/O2 artifact. +- The remaining high-signal misses are Test 3, 6, 7, 8, 11, and 15, with Test + 11 still carrying a clear COMMIT-specific gap. + +## 2026-05-29 COMMIT Timing Split + +The Test 11 COMMIT gap was split with diagnostic-only timing patches: + +- `0035-oliphaunt-wasix-add-commit-backend-timing-probes.patch` +- `0036-oliphaunt-wasix-add-commit-record-backend-timing-probes.patch` +- `0037-oliphaunt-wasix-add-xlog-flush-write-timing-probes.patch` +- `0038-oliphaunt-wasix-split-xlog-write-tail-timing.patch` + +Both patches are compiled out unless `OLIPHAUNT_WASIX_BACKEND_TIMING=1`. + +The first timing build showed that the cost is inside +`RecordTransactionCommit()`: + +| Phase | Time | +| --- | ---: | +| `COMMIT` wall time | `13.870 ms` | +| `exec_finish_xact` | `13.805 ms` | +| `commit_record` | `13.780 ms` | +| `commit_resource_locks` | `0.010 ms` | +| `commit_local_cleanup` | `0.004 ms` | +| all other commit cleanup probes | `0.001 ms` each | + +The second timing build split `RecordTransactionCommit()`: + +| Phase | Time | +| --- | ---: | +| `COMMIT` wall time | `20.725 ms` | +| `commit_record` | `20.642 ms` | +| `commit_xlog_flush` | `20.634 ms` | +| `commit_xlog_record` | `0.002 ms` | +| `commit_clog_commit_tree` | `0.002 ms` | +| `commit_sync_rep_wait` | `0.001 ms` | + +The timing build has extra probe overhead and a different code shape, so its +absolute COMMIT time should not be compared directly to release/O2. The useful +fact is the attribution: essentially all of the PG18 Test 11 COMMIT gap is +`XLogFlush(XactLastRecEnd)`, even with: + +| Setting | Value | +| --- | --- | +| `fsync` | `off` | +| `synchronous_commit` | `on` | +| `shared_buffers` | `128MB` | +| `wal_buffers` | `4MB` | + +This rules out the PG18 transaction cleanup additions as the main cause: +`AtEOXact_Aio`, relcache/typecache cleanup, invalidation, procarray end, +resource-owner cleanup, notification, memory cleanup, CLOG commit, WAL record +construction, and sync-rep wait are all microsecond-level in the diagnostic. + +The PG17.5 Oliphaunt-style source and the current PG18 source are structurally +similar in `RecordTransactionCommit()`: both call `XLogFlush(XactLastRecEnd)` +when `synchronous_commit > off`. Upstream `PG18 legacy lane` does not carry an +additional Oliphaunt-specific `XLogFlush` fast path; it only has the checkpoint +guard that is already ported as `OLIPHAUNT_WASM_SINGLE_USER`. + +The third timing build ruled out the obvious lower-level suspects: + +| Phase | Time | +| --- | ---: | +| `COMMIT` wall time | `13.750 ms` | +| `commit_xlog_flush` | `13.653 ms` | +| `xlog_flush_xlog_write` | `13.650 ms` | +| `xlog_write_pwrite` | `0.760 ms` | +| `xlog_flush_wal_write_lock` | `0.001 ms` | + +A `max_wal_senders=0` startup-GUC diagnostic still showed the same shape: +`COMMIT` was `15.535 ms` and `commit_xlog_flush` was `15.437 ms`. So the +remaining gap is not walsender wakeup, WALWriteLock waiting, fsync, or raw host +write time. + +The fourth timing build split `XLogWrite()` itself: + +| Phase | Time | +| --- | ---: | +| `COMMIT` wall time | `13.912 ms` | +| `commit_xlog_flush` | `13.824 ms` | +| `xlog_flush_xlog_write` | `13.821 ms` | +| `xlog_write_loop` | `13.819 ms` | +| `xlog_write_loop_scan` | `12.884 ms` | +| `xlog_write_pwrite` | `0.819 ms` | +| `xlog_write_pgstat_io` | `0.001 ms` | +| `xlog_write_fsync` | `0.001 ms` | +| `xlog_write_walsnd_request` | `0.001 ms` | + +This changes the diagnosis: the PG18 COMMIT miss is dominated by the +per-WAL-page scan/grouping loop in `XLogWrite()`, not by PG18's new +`pgstat_count_io_op_time()` accounting, the filesystem call, fsync, or +walsender signaling. Relation sizes match the PG17.5 diagnostic exactly, so the +next question is whether PG18 is arriving at COMMIT with more unwritten WAL or +whether the same WAL-buffer walk is materially slower in the PG18/WASIX codegen +path. + +Next high-signal checks: + +- add a count/byte diagnostic for `XLogWrite()` loop iterations, pages grouped, + and bytes passed to `pg_pwrite()` for PG18, then compare against a PG17.5 + timing build if feasible; +- inspect `AdvanceXLInsertBuffer()`/WAL-buffer-full write behavior to see whether + PG18 defers more WAL to COMMIT than PG17.5 under the same `wal_buffers=4MB`; +- evaluate a WASIX-single-user fast path that writes WAL from contiguous buffer + ranges without the full per-page scan when the target range is known ready; +- separately test `relaxed_durability(true)` / `synchronous_commit=off`, because + that should bypass the synchronous `XLogFlush()` commit path and establish the + upper bound for Test 11 parity. diff --git a/docs/internal/PG18_WASIX_POSTGRES.md b/docs/internal/PG18_WASIX_POSTGRES.md new file mode 100644 index 00000000..790c41b0 --- /dev/null +++ b/docs/internal/PG18_WASIX_POSTGRES.md @@ -0,0 +1,666 @@ +# PG18 WASIX PostgreSQL Runtime + +This runtime is the fresh PostgreSQL 18 WASIX build that keeps the released +Oliphaunt WASM product shape: one embedded backend behind the direct Rust API +and the local server wrapper. It is not the concurrent full PostgreSQL WASIX +experiment and should not take postmaster or multi-backend assumptions as a +performance constraint. + +## Target Shape + +- PostgreSQL 18.4 source, pinned independently from the released PG17.5 lane. +- Oliphaunt-style single backend execution, engineered from first principles. +- Existing released-lane capabilities preserved before replacement: + protocol execution, template packaging, initdb, pg_dump, bundled extensions, + runtime support modules, and the server wrapper. +- The released artifact pipeline packages standalone WASIX `initdb` and + `pg_dump` tools. `pg_dumpall.c` is patched only because it shares the + renamed pg_dump helper; pg_dumpall and psql are not separate packaged WASIX + tools in this lane unless future work adds them explicitly. +- Patch series kept small, ordered, and reviewable. Each patch should explain + which PostgreSQL invariant it changes and why the embedded WASIX runtime + still preserves the useful part of that invariant. +- Performance goal is to beat the released PG17.5 WASIX lane on the existing + benches before this becomes a replacement candidate. + +## Research Assessment + +The concurrent full-PostgreSQL WASIX experiment is still valuable for finding +Wasmer, WASIX libc, fork, socket, shared-memory, and toolchain blockers, but it +is not the right replacement path for the released embedded product yet. The +experiment evidence points to persistent process/fork/shmem/socket/RSS costs +before query execution, while the released product wins by keeping one backend, +one host lifecycle, direct FE/BE pumping, prebuilt PGDATA, and AOT reuse. + +The practical direction is therefore: + +- Keep full concurrent PostgreSQL under WASIX as upstream/runtime research and + a correctness oracle for patches that should eventually make WASIX more + POSIX-like. +- Build the replacement product as PG18 WASIX `wasix-dl`, preserving the + released Oliphaunt-style execution model and only taking runtime patches that + improve that model. +- Treat the PG18 experiment's PostgreSQL hot-path patches as candidates after + parity is buildable. The strongest candidates are WASIX-gated + `hash_bytes()` load folding, top-level `TransactionIdIsCurrentTransactionId` + short-circuiting, and narrow btree int4 comparator fast paths. They attack + hot guest CPU paths without changing the host lifecycle. +- Keep diagnostic toggles, such as bottom-up btree delete disabling, out of the + default product path unless benchmarks prove a production-safe default. +- Defer broader planner/executor shortcuts, locale-sensitive LIKE shortcuts, + and stack-allocation rewrites until they have focused regression coverage, + because they can silently change SQL semantics or memory pressure. +- Record every full-PG experiment patch in + `src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml` + before porting or rejecting it. The source-spine guard checks that manifest + so experiment patches cannot be copied into this runtime without a WASIX + rationale. + +The immediate conclusion is that a "proper" concurrent PostgreSQL under WASIX +is unlikely to match native PostgreSQL or released Oliphaunt-style WASM performance +soon. A fresh PG18 WASIX runtime can plausibly beat the released PG17.5 lane +because it keeps the low-overhead lifecycle while inheriting newer PostgreSQL, +newer WASIX/Wasmer fixes, tighter host ABI boundaries, and targeted hot-path +patches from the experiment. + +## PG17.5 Release-Lane Implementation Comparison + +The released PG17.5 WASIX lane is represented by the monolithic patch: + +```sh +src/runtimes/liboliphaunt/wasix/assets/build/patches/postgres-oliphaunt-wasix-dl.patch +``` + +The PG18 lane represents the same product shape as a 37-patch PostgreSQL 18.4 +series under: + +```sh +src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches +``` + +The top-level execution model is equivalent. Both lanes start PostgreSQL +through `PostgresSingleUserMain()`, attach a host-backed frontend/backend +`Port`, let the Rust host call PostgreSQL's startup-packet parser, emit normal +startup protocol messages, and then drive PostgreSQL one frontend message at a +time through an exported `PostgresMainLoopOnce()`. + +The ABI is intentionally not equivalent. PG17.5 still exposes the +Oliphaunt-shaped names and compile macro, such as `OLIPHAUNT_WASIX_DL`, +`oliphaunt_wasix_start`, `pgl_pq_flush`, `pgl_getMyProcPort`, and +`pgl_sendConnData`. PG18 replaces those with Oliphaunt-owned symbols: +`OLIPHAUNT_WASM_SINGLE_USER`, `oliphaunt_wasix_start`, +`oliphaunt_wasix_pq_flush`, `oliphaunt_wasix_get_proc_port`, and +`oliphaunt_wasix_send_conn_data`. The Rust host now resolves the +`oliphaunt_wasix_*` exports directly, so PG18 should not leak old `pgl*` +symbols as part of the public lane contract. + +The build spine is similar but split for reviewability. Both lanes add a +`wasix-dl` PostgreSQL template, position-independent side-module builds, a +backend `libpgcore` object, WASIX dynamic-linker makefile support, and PGXS +side-module extension installation. PG18 keeps the same broad shape while +using `OLIPHAUNT_WASM_SINGLE_USER`, `DLSUFFIX=".so"`, and explicit pthread and +unnamed POSIX semaphore settings in the template. PG18 also carries a later +patch that forces backend-core linking through `wasm-ld --relocatable`. + +The main implementation delta is protocol I/O. PG17.5 routes backend protocol +bytes through the old broad WASIX/Oliphaunt shim path. PG18 adds an explicit +`OliphauntWasmHostIO` callback table to `Port`, and the backend libpq +`secure_raw_read()` and `secure_raw_write()` functions dispatch through those +callbacks only when the embedded WASIX port installs them. This is a +cleaner boundary, but it is also one of the few places where the PG18 port is +not mechanically identical to PG17.5 and should remain on the copy/flush audit +list. + +The error-recovery and protocol-state work is at least as complete in PG18 as +in PG17.5. PG18 carries the host-callable top-level recovery export, COPY +state reset on error, active portal failure during abort, post-error +ReadyForQuery scheduling for simple-protocol recovery, and re-arming of +`PG_exception_stack` after host-forced recovery. These are the pieces that let +one backend survive query failures in the embedded host. + +The PG17.5 monolithic patch has one source hunk not currently present in the +PG18 patch stack: `src/common/file_utils.c` treats `EISDIR` like `EBADF` and +`EINVAL` when fsyncing a directory. That is a portability/tooling delta, not a +steady-state query hot path, and it does not explain the PG18 indexed-update +regression. It should still be either ported as a narrow PG18 patch if a WASIX +tool path needs it, or documented as unnecessary after initdb/pg_dump coverage. + +PG18 carries several PostgreSQL hot-path patches that PG17.5 did not carry: +WASIX-gated `hash_bytes()` load folding, a top-XID visibility shortcut, guarded +btree int4 comparator shortcuts, btree delete scratch-buffer stack placement, +a deterministic `%literal%` LIKE fast path, upstream-style checkpoint/runtime +fast paths, an `XLogWrite()` hot-loop segment-bounds check, and an embedded-only +activity-ID reporting guard. Diagnostic timing probes remain available around +simple-query execution, executor/storage, btree insertion, btree comparison, +and heap update subphases. With a release-built host and the 37-patch O2 +artifact, PG18 is faster than same-host PG17.5 and the documented PG17.5 +release table on every speed-test head; see +`docs/internal/PG18_WASIX_PERF_STATUS.md`. + +## Upstream PG18 legacy lane Hints + +The upstream Oliphaunt branch +`https://github.com/postgres/postgres/tree/PG18 legacy lane` was +compared against PostgreSQL `REL_18_3` on 2026-05-29. It is an Emscripten +single-artifact port, not a WASIX dynamic-main lane, so its symbol names and +build scripts are reference material only. It should not be copied into PG18 +WASIX with `pgl*` or `oliphaunt` ABI names. + +For the comparison, the local Oliphaunt checkouts were given the real upstream +PostgreSQL base tags by fetching `REL_17_5` and `REL_18_3` from +`postgres/postgres`. `PG17 legacy lane` changes 54 files versus PostgreSQL +`REL_17_5`; `PG18 legacy lane` changes 55 files versus PostgreSQL `REL_18_3`. +The core runtime patch surface is the same in both Oliphaunt branches: +`build-oliphaunt.sh`, `oliphaunt/src/oliphauntc/oliphauntc.c`, +`src/backend/access/transam/xlog.c`, `src/backend/postmaster/checkpointer.c`, +`src/backend/tcop/postgres.c`, `src/backend/utils/init/postinit.c`, +`src/backend/utils/misc/guc.c`, and a small set of storage/init support files. +There are no hidden upstream PG18 Oliphaunt btree, hash, LIKE, executor, or planner +speed patches. + +Useful findings from that branch: + +- The top-level single-user architecture matches our direction: dummy + frontend/backend port, exported startup packet parser, startup protocol + emission, loop-pumped `PostgresMainLoopOnce()`, and host-managed top-level + longjmp recovery. +- The branch still relies on broad C preprocessor remaps for `recv`, `send`, + `system`, `popen`, `pclose`, identity calls, SysV shared memory, `fcntl`, + `munmap`, and longjmp. Our PG18 lane deliberately replaces the protocol + side of that with `OliphauntWasmHostIO` callbacks and uses + `oliphaunt_wasix_*` names for the remaining WASIX bridge. +- It sets both `IsPostmasterEnvironment` and `IsUnderPostmaster` when the host + starts the embedded backend, then patches checkpoint paths so Oliphaunt does not + try to signal a missing checkpointer from WAL segment pressure. Our active + PG18 lane now ports that shape under Oliphaunt-owned markers: patch `0034` + sets those environment flags in `oliphaunt_wasix_start()`, and patch `0032` + keeps XLog-size checkpoint requests disabled while preserving local + in-process `RequestCheckpoint()` behavior for embedded WASIX. +- It patches `RequestCheckpoint()` to run in-process under Oliphaunt even when the + backend was made to look postmaster-owned. The active PG18 lane now does the + same under `OLIPHAUNT_WASM_SINGLE_USER`. +- It shortens `PGSemaphoreReset()` to one `sem_trywait()` under Oliphaunt. The + active PG18 lane ports this as part of the current 37-patch stack. It is a + lifecycle/runtime cleanup optimization, not an explanation for the earlier + isolated Test 11 COMMIT gap. +- It changes `pg_flush_data()` to call `fsync()` under oliphaunt. That is a + portability choice for Emscripten MEMFS, not an obvious WASIX performance + improvement. It is likely too blunt for the PG18 release lane without a + tool-specific failure. +- It skips `guc_strdup()` for `record->last_reported` in `ReportGUCOption()`. + The active PG18 lane now ports this. It can reduce long-lived GUC-report + allocations, but it affects startup or changed GUC reporting, not the speed + suite's steady-state query work. +- It adds private encoding symbol shims in libpq and pg_dump to avoid static + link/LTO collisions. Our WASIX bridge and initdb shim already expose + `pg_char_to_encoding_private` and `pg_encoding_to_char_private`; the PG18 + lane also carries a narrower pg_dump LTO helper rename. If future pg_dump or + extension builds fail with encoding symbol collisions, this branch is useful + prior art. +- It collects extension undefined symbols into import lists and then builds the + main oliphaunt export list from those imports plus a manually included export + file. Our PGXS side-module lane already has analogous import-list + generation, but the upstream branch is a good checklist for extension + packaging completeness. +- It packages a minimal ICU data tree. Our release lane should keep validating + ICU/collation behavior through the existing asset pipeline instead of + inheriting Oliphaunt's Emscripten preload layout. + +None of the PG18 legacy lane source deltas explained the earlier PG18 WASIX +per-head misses. The upstream branch's relevant lifecycle/runtime deltas are +already present in the active PG18 lane, and the branch does not carry the +btree/hash/top-XID/LIKE hot-path patches already present here. The final Test +15 gap was closed by an Oliphaunt-specific embedded activity-ID reporting guard, +not by wholesale copying more upstream Oliphaunt code. + +## Patch Stack Plan + +1. Build spine: teach PostgreSQL 18 about the `wasix-dl` dynamic-main build + target without adding behavioral changes. Done in patch 0001. This patch + keeps PostgreSQL spinlocks on the normal WASIX toolchain atomics path; the + old disabled-spinlock fallback is not part of this lane. +2. Host I/O: add explicit embedded backend I/O hooks instead of relying on + broad syscall remapping. Done in patch 0002. +3. Startup parsing: expose PostgreSQL's own startup packet parser to the + WASIX host under `OLIPHAUNT_WASM_SINGLE_USER`. Done in patch 0003. +4. Host lifecycle exports: attach a host-backed FE/BE Port after standalone + initialization and emit the startup protocol messages expected by the + released Rust host. Done in patch 0004. +5. Loop-pumped protocol: split the `PostgresMain()` loop so the host can drive + one frontend message at a time without starting a second backend lifecycle. + Done in patch 0005. +6. COPY protocol handoff: report PostgreSQL CopyIn/CopyOut/CopyBoth response + transitions to the WASIX host so the released proxy can switch from buffered + pumping to streaming at PostgreSQL-owned protocol boundaries. Done in patch + 0006. +7. PGXS side-module parity: add the `wasix-dl` platform makefile expected by + configure and emit WASIX extension import lists from PGXS installs without + inheriting Emscripten-named variables or absolute non-`DESTDIR` paths. Done + in patch 0007. +8. Error recovery protocol state: reset PostgreSQL-owned COPY handoff state + when top-level error recovery aborts the active subprotocol. Done in patch + 0008. +9. Error recovery: keep PostgreSQL's top-level cleanup path host-callable so + one backend can recover from query failures. Initial recovery export done in + patch 0005; the WASIX bridge and Rust host already route forced + process-exit ERROR recovery through `PostgresMainLongJmp()`. +10. WASIX process identity: route PostgreSQL's process identity lookups through + the `wasix-dl` port header instead of PG18 configure-script-wide identity + remaps. Done in patch 0009. +11. Shared memory and semaphores: route SysV shared-memory calls through the + `wasix-dl` port header and local WASIX headers instead of PG18 + configure-script-wide shared-memory remaps. Done for SysV shared memory in + patch 0010. Prefer unnamed POSIX semaphores explicitly in patch 0011. +12. Startup error capture: route `InitPostgres()` failures through the + host-backed protocol Port before the exported main-loop recovery buffer is + active, so database/role/startup-option errors remain PostgreSQL-owned. + Done in patch 0012. +13. Host-recovery portal cleanup: mark active portals failed during + `AtAbort_Portals()` while the embedded backend is active, preserving + PostgreSQL-owned cleanup when a WASIX host routes nested ERROR unwinds + through the top-level recovery export. Done in patch 0013. +14. Hash hot path: use WASIX-only `memcpy`-based 32-bit loads in the + unaligned little-endian `hash_bytes()` and `hash_bytes_extended()` paths. + This keeps unaligned C access defined while giving LLVM a single-load + lowering opportunity in hash-table-heavy query paths. Done in patch 0014; + promotion still requires benchmark evidence. +15. Transaction visibility hot path: short-circuit + `TransactionIdIsCurrentTransactionId()` for the common top-level case after + the top XID comparison fails and no parallel, parent, or child XID source + can still match. Done in patch 0015; promotion still requires benchmark + evidence. +16. Btree int4 compare hot path: avoid the fmgr trampoline for WASIX + embedded-runtime comparisons that prove they are the built-in integer btree + family with int4 input on both sides and InvalidOid collation, while still + using `index_getattr()` and falling back to upstream comparison for every + other case. Done in patch 0016; the more aggressive direct tuple-data + shortcut from the full-PG experiment remains intentionally unported pending + runtime evidence and tighter layout proof. +17. Btree delete scratch buffers: keep `MaxTIDsPerBTreePage` simple-deletion + and bottom-up-deletion scratch arrays on the stack in the embedded WASIX + lane. The arrays are page-size bounded and PostgreSQL already uses similar + page-local stack buffers elsewhere; upstream heap allocation remains for + all other builds. Done in patch 0017. +18. pg_dump LTO hygiene: rename pg_dump's generic `executeQuery()` helper to + `executeDumpQuery()` so standalone WASIX pg_dump builds do not expose an + unnecessarily collision-prone external symbol under thin LTO. Done in patch + 0018. +19. Host-forced recovery ReadyForQuery scheduling: preserve PostgreSQL's + post-ERROR `send_ready_for_query` behavior when the WASIX host invokes the + recovery export directly, while still withholding ReadyForQuery during + extended-protocol skip-till-Sync recovery. Done in patch 0019. +20. Host-forced recovery exception boundary: re-arm `PG_exception_stack` to + `postgresmain_sigjmp_buf` when the WASIX host invokes the recovery export + directly, matching PostgreSQL's normal post-`sigsetjmp` recovery flow. Done + in patch 0020. +21. Extension/tool parity: rebuild contrib, pg_dump, initdb, and packaged + extensions against the PG18 lane. +22. Performance work: carry Wasmer, WASIX libc, LTO/codegen, file-system, and + memory-growth improvements into this lane only after parity is testable. + +## Experiment Patch Disposition + +The full-concurrent PG18 WASIX experiment remains useful prior art, but its +patches are not the default source of truth for this lane. The reviewed +disposition manifest is: + +```sh +src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml +``` + +The manifest records each experiment patch by filename, whether it was ported, +replaced, deferred, or rejected, and why that decision fits a single-backend +WASIX product. The currently carried performance/tool patches are the hash +load fast path, top-XID visibility fast path, guarded btree int4 comparator, +btree delete scratch-buffer stack placement, LIKE substring shortcut, first +int4 leaf compare shortcut, and pg_dump LTO symbol hygiene. The +bottom-up-delete runtime toggle and full EXEC_BACKEND/fork runtime patches +remain outside the default lane. + +When the full-PG experiment checkout exists locally, the source-spine guard +also compares the disposition manifest against the actual experiment patch +directory, so new experiment patches cannot appear without an explicit +embedded-runtime decision. + +## Current Slice + +The initial source-prep entrypoint is: + +```sh +src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh +``` + +It downloads or reuses the PostgreSQL 18.4 tarball, verifies the upstream +checksum, extracts into `target/oliphaunt-wasix/wasix-build`, +and applies the patch series in +`src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series`. +The source-spine guard also requires that file to match the duplicate +`[patches].series` list in `postgres/source.toml`, so build metadata +and the applied patch order cannot drift silently. It also rejects orphan +`.patch` files that are not listed in the series; source fingerprints and +applied patch contents must describe the same stack. The guard also checks +that the stack remains reviewable: currently exactly 31 sequentially numbered +`oliphaunt-wasix` patches, each with a matching subject/filename slug, an +Oliphaunt maintainer header, and a short rationale before the diff. When a +prepared PG18 source tree exists, xtask recomputes the source fingerprint from +the PostgreSQL tarball metadata, patch series file, and patch file hashes, then +compares both the source-tree marker and work-root marker against that value. +The same prepared-source verifier is used by PG18 source prep and build-output +discovery, so template/package/AOT commands do not accept a stale prepared +source tree. If source prep uses an overridden PG18 work root, xtask verifies +the marker in that actual work root rather than the default target directory. +The same guard scans the PG18 patch stack, PG18 build scripts, Rust host loader, +prepared source tree, and generated PG18 manifests/AOT metadata for legacy +Oliphaunt ABI tokens such as `__OLIPHAUNT__`, `OLIPHAUNT_*`, `PGL_*`, and `pgl_*`. PG17 +and upstream Oliphaunt remain valid references, but the PG18 release lane must use +only `oliphaunt_wasix_*` and `OLIPHAUNT_WASM_*` names in runtime/build +surfaces. + +The existing PG17.5 released build remains untouched. + +## Build Entry Points + +The PG18 WASIX backend has dedicated configure/backend entrypoints: + +```sh +src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh +src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh +``` + +The PG18 configure script uses the new source-prep path and a smaller compile +profile than the released lane: it does not define `__OLIPHAUNT__` or +`OLIPHAUNT_WASIX_DL`, and it relies on the explicit host I/O hooks instead of +remapping `recv()`/`send()`. It still uses the existing WASIX bridge for +runtime process, identity, shared-memory, and longjmp compatibility while those +areas are being replaced by smaller PostgreSQL patches. +The PG18 backend Docker entrypoint uses the same `source_lane.sh` helper as the +companion build scripts for its build directory and prepared source path, so the +backend and extension/tool stages do not drift onto different build roots. + +The companion build scripts now default to the stable PostgreSQL source and +build tree: + +```sh + src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh + src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh + src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh + src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh + src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh +``` + +The standalone WASIX `initdb` build keeps explicit frontend-tool remaps for +process identity lookups and links the dedicated `oliphaunt_wasix_initdb_shim`. +The `pg_config_wasix.sh` helper is also source-marker aware. PG18 runs must +receive an explicit prepared `PGSRC`, so standalone PGXS invocations fail closed +instead of silently returning unrelated include paths. `PGSRC` must carry the +prepared-source PostgreSQL version and source-fingerprint markers, so an +arbitrary upstream checkout cannot masquerade as the patched source stack. The +helper exposes `--includedir-server` as the selected build tree's `src/include`, +which keeps direct extension Makefile calls on the same server-header surface +as the Docker PGXS wrapper. +That shim now covers the PG18 port surface used by `wasix-dl`, including +`getegid`, `getgid`, and `getpwuid_r`, so tool builds do not accidentally depend +on whichever subset WASIX libc happens to provide. + +`cargo run -p xtask -- assets build` prints the +PG18 build spine, and adding `--execute` runs the PG18 backend script followed +by the same released-lane companion scripts against the PG18 build tree. + +`assets fetch` and +`assets release-build --fetch` now skip only the +released `postgres-oliphaunt` backend checkout. They still fetch the shared +non-backend source pins, then run +`src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh` to +materialize the pinned PostgreSQL 18.4 tarball plus the PG18 patch stack. +`assets source-spine` uses the same PG18 +source-spine guard instead of the released patch checkout guard; adding +`--check-patch-applies` materializes the pinned PG18 source tree and validates +the applied runtime, tool, contrib, and hot-path patch markers. The PG18 +source-spine command defaults to source-only validation; `--strict-local` also +requires the shared non-backend source checkouts to be present, clean, and pinned. + +Once those outputs exist, `assets template` and +`assets package` discover the PG18 build tree, +derive manifest PostgreSQL versions from the prepared PG18 source markers, and +write explicit PG18 source-fingerprint and PG18 source pins into +generated asset manifests. PG18 packaged asset discovery rejects manifests +whose fingerprint does not match the current PostgreSQL tarball plus patch +series hash. Contrib extension control files are staged from the active +PostgreSQL source tree, not from the generated PG17 catalog metadata. The +default path remains the released PG17.5 lane unless the source selection is +explicitly selected. + +PG18 build outputs must also carry the same source fingerprint and PostgreSQL +version markers as the prepared source. The backend Docker entrypoint stamps +those markers after configure, companion build stages fail closed if either +marker drifts, and xtask checks the markers again before packaging, template, or +build-output manifest generation can consume an existing build tree. + +PGDATA template manifests produced by `assets template --source fingerprint ...` also +carry camelCase `sourceLane` metadata, and the asset manifest's +`pgdata-template` entry records the same lane. PG18 templates also carry the +same source fingerprint as the runtime assets. Older released templates without +those fields still parse as released-lane templates. + +The source-spine guard also checks the prepared PG18 tree against the runtime +assets and promoted contrib build plan. The required `plpgsql`, +`dict_snowball`, and timezone source inputs must be present; every promoted +contrib extension must have its PG18 `contrib/` source directory and +Makefile; and CREATE EXTENSION entries must have the matching PG18 control file +plus at least one packaged extension SQL file. +When optional upstream discovery checkouts are absent, xtask falls back to the +committed generated extension build plan so this parity check can still run in +source-only worktrees. + +The same guard validates the applied PG18 source tree, not just the patch files: +the prepared source must contain the host I/O Port hooks, startup packet export, +single-backend lifecycle exports, loop-pumped protocol exports, COPY handoff +reports, host-forced recovery fixes, portal abort cleanup, and the carried +WASIX-gated hot-path patches. + +The source-controlled WASIX export list treats hybrid protocol switching as part +of the host/runtime ABI. `pgl_set_protocol_transport` and +`pgl_protocol_stream_active` are exported alongside the older buffered protocol +helpers so COPY streaming cannot silently disappear from a PG18 build. +The source-only guards also compare the Rust host's loaded runtime symbols with +the WASIX runtime export validator and the PG18 PostgreSQL-side +`OLIPHAUNT_WASM_HOST_EXPORT` declarations, so host ABI drift fails before a +build artifact is packaged. + +The same selector is also accepted by `assets release-build`, `assets aot`, +`assets package-aot`, `assets check-aot`, and `assets export-list`, but PG18 +PG18 WASIX is now the default stable runtime. Build-output manifests remain +stamped under the PG18 build tree at +`target/oliphaunt-wasix/wasix-build/build/outputs.json`, with +the old stable manifest path accepted only as a compatibility fallback during +local discovery. + +Portable assets now write to the stable generated directory +`target/oliphaunt-wasix/assets`. AOT intermediates remain under +`target/oliphaunt-wasix/wasix-build/build/aot`, while packaged +AOT outputs write to the stable generated directory +`target/oliphaunt-wasix/aot`. Packaged AOT manifests carry explicit +`source fingerprint`, source-fingerprint, and `postgres-version` metadata, and +`assets check-aot` verifies those fields before checking module hashes. + +The Rust asset parser preserves the same source-fingerprint metadata that xtask +writes into PG18 asset manifests. Embedded PGDATA template manifests must match +the top-level asset manifest fingerprint, and bundled AOT manifests must match +the same fingerprint and PostgreSQL version before their module hashes are +accepted. The `oliphaunt-wasix-assets` build script probes +`target/oliphaunt-wasix/assets` plus the publishable payload unless +`OLIPHAUNT_WASM_GENERATED_ASSETS_DIR` explicitly overrides the asset directory. +Any selected PG18 manifest must carry a non-empty source-fingerprint plus a +PostgreSQL 18 runtime version before embedding. + +Runtime reuse has the same fail-closed stance. A full-local runtime root is +only reused when its saved runtime source key matches the currently embedded +runtime archive key; otherwise the runtime archive is reinstalled before use. +Existing PGDATA roots are also checked against the current runtime PostgreSQL +major version before they are accepted, including overlay PGDATA manifests. A +PG17 root must fail with an explicit migration/separate-root error under the +PG18 lane instead of being paired with PG18 binaries. + +Crate package-size enforcement is deliberately released-lane only for now. The +PG18 lane writes experimental generated assets under ignored target paths; it is +not staged into the publishable `oliphaunt-wasix-assets/payload` and AOT crate +`artifacts` directories. Therefore `assets release-build --source fingerprint +stable` must use `--skip-package-size` until PG18 gets a dedicated +release-staging path; otherwise xtask fails instead of silently measuring the +released PG17 crate payload. + +Perf reports now carry WASIX runtime asset provenance when the measured engine +is the bundled WASIX runtime. The JSON field is `wasixRuntimeAssets` and +records the asset source selection, PostgreSQL version, optional PG18 source +fingerprint, and PGDATA-template lane/fingerprint/version. Native PostgreSQL, +SQLite, native liboliphaunt, and Node Oliphaunt controls omit the field. This +keeps future PG18-versus-released-lane benchmark reports self-identifying even +when both lanes can be built from the same xtask binary. + +`assets check` and `assets verify-committed` now include a source-fingerprint isolation +guard. It proves the released and PG18 build manifests, portable asset +directories, and AOT directories are distinct; it also checks that public +download, local install, and release bundle paths still target the released +PG17.5 lane, and that package-size enforcement does not silently fall back to +the released crate lane for a PG18 release-build. The same guard checks that +PG18 fetch/release-build preflight skips the released backend source pin and +uses the tarball source-prep script instead. PG18-owned source-prep, configure, +and backend Docker scripts are also source-guarded against released checkout +paths, the released `postgres-oliphaunt` source name, the generated PG17 patched +source root, and the old `__OLIPHAUNT__`/`OLIPHAUNT_WASIX_DL` export macro style. +Generated asset manifest +validation also checks the manifest `source fingerprint` field when a lane is selected, +so PG18 packaged assets cannot pass as released-lane assets by version inference +alone. Build-output manifests carry the same PG18 source fingerprint and are +ignored by export-list generation if the fingerprint no longer matches the +current source stack; PG18 build-output manifest module paths must also stay +under the PG18 build root instead of the released build root. + +Promoted extension packaging is also fail-closed. PostgreSQL contrib and PGXS +style extensions are lane-scoped through the selected build directory. The PG18 +source-spine guard verifies that promoted PGXS source directories stay under +shared `target/oliphaunt-sources/checkouts/*` pins instead of the released backend checkout, that +each source directory is represented in `src/sources/third-party/**`, and, when the +checkout is present, that packaging-visible Makefile/control/SQL inputs exist. +PG18 asset manifests derive extension `control-files` from the packaged +extension archive contents, so contrib metadata cannot leak released +`postgres-oliphaunt/contrib` source paths into a PG18 manifest. +PostGIS remains `build = false` until there is a dedicated WASIX geospatial +dependency stack and lane-scoped PostGIS builder. If PostGIS is promoted before +that work lands, xtask must fail early instead of pointing at the released +`postgres-oliphaunt/oliphaunt/other_extensions` artifact tree. + +## Verification Status + +This slice has moved beyond source-only verification. The PG18 source spine, +backend build path, non-timing runtime artifact, AOT/package metadata, and +focused speed probes for the indexed-update misses have all been exercised. +The current perf package is still core-only because external PGXS extensions +were skipped with `OLIPHAUNT_WASM_SKIP_EXTENSIONS_FOR_PERF=1`; full extension +release packaging remains a promotion blocker. + +Verified locally: + +- PostgreSQL 18.4 source preparation reuses or downloads the pinned tarball, + checks the upstream checksum, and applies patches 0001 through 0031 cleanly. + Cached prepared sources are rebuilt if patch backup/reject files are present, + and a prepared tree containing `.orig` or `.rej` artifacts is rejected. The + prepared source and work-root fingerprint markers must also match the current + tarball metadata and patch stack hash. +- The PG18 patch stack is source-guarded for review hygiene: sequential patch + numbering, Oliphaunt subjects matching filenames, maintainer headers, rationale + text, and no TODO/FIXME placeholders. +- The prepared PG18 source contains the standalone `initdb` and `pg_dump` source + files, the released-lane runtime-support inputs for `plpgsql`, `dict_snowball`, + and timezones, plus every promoted contrib source/control input required by + the generated extension build plan. CREATE EXTENSION contrib entries also + have root extension SQL files available for packaging. Unsupported promoted + builders such as PostGIS are rejected until they have a dedicated PG18 WASIX + build path. Promoted PGXS extensions are checked for lane-neutral pinned + source directories and, when local checkouts exist, Makefile/control/SQL + packaging inputs. The PGXS `pg_config_wasix.sh` helper is source-marker aware + and rejects PG18 use without an explicit prepared-source path and source + fingerprint markers. Extension build metadata and extension manifest metadata + both fall back to the generated build plan when upstream Oliphaunt discovery + inputs are absent, so PG18 packaging does not need live released-lane discovery + files just to recover lifecycle/dependency metadata. PG18 extension manifest + control-file metadata is derived from packaged archive contents rather than + released-lane source paths. +- The applied PG18 source contains the expected embedded runtime ABI hooks and + the currently carried performance/tool patches, including hash load folding, + top-XID lookup short-circuiting, btree int4 comparison and delete scratch + paths, and pg_dump helper renaming. +- The source-controlled WASIX export list includes the Rust host's required + lifecycle, protocol, buffered I/O, and hybrid streaming symbols. +- The Rust WASIX host loader, runtime export validator, and PG18 PostgreSQL + host-export patch surface are checked together so required runtime symbols do + not silently drift between layers. +- `assets check` validates the PG18 WASIX source-spine guard and the Rust + startup ABI boundary in + source-only mode. It also runs `bash -n` over the WASIX build shell scripts, + including the PG18 source-prep and backend entrypoint scripts. +- `assets verify-committed` additionally validates the source-fingerprint isolation + guard and the source-controlled WASIX export list. +- Generated asset manifests now carry explicit `source fingerprint` metadata and a + source fingerprint that must match the current PG18 tarball plus patch stack. +- Existing PG18 build trees are accepted only when their stamped source + fingerprint and PostgreSQL version markers match the prepared source. +- Packaged AOT manifests now carry explicit `source fingerprint`, `postgres-version`, + and source-fingerprint metadata. +- PGDATA template manifests and asset-manifest `pgdata-template` entries now + carry lane metadata as well, plus PG18 source fingerprints for experimental + PG18 templates. +- Runtime asset parsing preserves PG18 source fingerprints, and embedded PGDATA + template/AOT manifests are checked against the bundled asset manifest before + use. +- WASIX perf reports include bundled runtime asset provenance, so benchmark JSON + identifies the measured source selection, PostgreSQL version, and PG18 source + fingerprint before the numbers are compared. +- Unit coverage checks that PG18 extension manifests use packaged control files + and reject released-lane path leaks; the legacy PG17 source selection is no longer + selectable. +- The asset crate build script is source-marker aware: selected manifests are + checked against the requested lane before embedding. +- Public release-asset bundling now validates PG18/stable portable and AOT + manifests before writing public release archives. The download/install path + performs the same checks on downloaded portable and AOT manifests before + copying them into canonical generated asset directories. +- `assets build` emits the PG18 lane build + commands without executing them; plain `assets build` selects the same PG18 + stable lane. +- `assets fetch` and release-build `--fetch` + prepare the pinned PG18 tarball source instead of fetching the released + `postgres-oliphaunt` backend checkout. +- `assets source-spine` validates the PG18 + source-spine guard; its `--check-patch-applies` path goes through the PG18 + tarball source-prep script, and `--strict-local` additionally checks shared + non-backend source checkout pins. +- The PG18 source-spine guard rejects released PG17/Oliphaunt checkout markers in + the PG18 patch stack and PG18-owned build scripts. +- `assets source-spine --check-patch-applies` + passes against the prepared PostgreSQL 18.4 source tree in source-only mode. +- `assets source-spine` passes after the + 37-patch stack, including the PG18 source-spine guard and source fingerprint + isolation guard. +- `assets release-build --skip-build --skip-aot + --skip-package-size` regenerates the PG18 asset manifest, and the regenerated + source pins contain the PostgreSQL 18.4 tarball plus PG18 patch-series + fingerprint rather than released-lane backend provenance. +- `assets release-build` carries source-fingerprint + validation through its package-size step; without `--skip-package-size` it now + fails explicitly because PG18 is not staged into publishable crates yet. +- `cargo check -p xtask` passes for the current edited + xtask surface. Earlier `cargo check -p xtask --all-features` coverage passed + for the feature-gated packaging/AOT/perf code paths. +- Full server-SQLx evidence after the accepted `0036` O2 artifact is in + `target/perf/pg18-wasix-core-release-o2-36patch-skip-activity-id-release-host-speed-server-sqlx.json` + plus its two reruns. The three-run median is `0.759x` same-host PG17.5 + geomean and `0.826x` documented PG17.5 geomean; PG18 is faster than both + baselines on every speed-test head. +- Shell syntax, the C bridge/initdb shim ABI harnesses, and the PG18 `wasix-dl` + port header syntax pass local static checks. + +Remaining promotion blockers are full extension/tool release packaging parity +and runtime protocol compatibility for the tokio-postgres prepared server path. +Strict speed-suite parity against both same-host PG17.5 and the documented +PG17.5 release table is now green on the current three-run median. diff --git a/docs/internal/PHYSICAL_ARCHIVE_FORMAT.md b/docs/internal/PHYSICAL_ARCHIVE_FORMAT.md new file mode 100644 index 00000000..17b57d48 --- /dev/null +++ b/docs/internal/PHYSICAL_ARCHIVE_FORMAT.md @@ -0,0 +1,92 @@ +# Physical Archive Format + +This is the maintainer contract for `BackupFormat::PhysicalArchive` in the +native Rust SDK and `liboliphaunt` integration. It is intentionally narrower +than `pg_basebackup` and is versioned as `oliphaunt-physical-archive-v1`. + +## Scope + +Physical archives are same-major PostgreSQL 18 restore artifacts for Oliphaunt +native roots. They are used by direct and broker mode backups and are the only +restore artifact accepted by the native SDK today. SQL backups and the future +`OliphauntArchive` format are separate contracts. + +## Container + +The container is a tar archive. Restore validation accepts GNU or ustar headers +only, verifies header checksums and numeric/string fields, requires a complete +tar terminator, and rejects trailing bytes after the terminator. + +Archive entries may be regular files or directories only. Symlinks, hard links, +FIFOs, device nodes, and all other entry types are rejected. Regular file +entries must not carry link metadata, directory entries must not carry payload +bytes, and duplicate canonical paths are rejected. + +## Paths + +Allowed canonical archive paths are: + +- `pgdata/` and descendants; +- `manifest.properties`; +- `.oliphaunt/backup-manifest.properties`. + +Path canonicalization removes `.` components and rejects absolute paths, parent +directory traversal, Windows prefixes, and entries that would place a file below +an already-seen file or replace an already-seen subtree. + +## Metadata + +`manifest.properties` is the native root manifest. It must validate through the +same parser used for opened native roots. + +`.oliphaunt/backup-manifest.properties` identifies the archive and compatibility +metadata. Required keys are: + +- `archiveLayout=oliphaunt-physical-archive-v1`; +- `product=oliphaunt`; +- `postgresMajor=18`; +- `pgdataVersion`; +- `postgresVersionNum`; +- `serverEncoding`; +- `lcCollate`; +- `lcCtype`; +- `dataChecksums`; +- `sharedPreloadLibraries`; +- `requiredPreloadLibraries`; +- `selectedExtensions`; +- `installedExtensions`. + +`postgresVersionNum` must be a PostgreSQL 18 version number, and `pgdataVersion` +must agree with `pgdata/PG_VERSION` when that file is present. + +## Backup Creation + +Backup creation starts PostgreSQL backup mode, archives `PGDATA`, stops backup +mode, then appends required WAL plus generated `backup_label` and +`tablespace_map` files. The initial `PGDATA` pass skips runtime-local state: +`postmaster.pid`, `postmaster.opts`, `pg_internal.init`, `pgsql_tmp*`, transient +content directories, and `pg_wal` contents before `pg_backup_stop`. + +Metadata may be appended to an existing physical archive. If metadata entries +already exist, they are replaced rather than duplicated. + +## Restore + +Restore unpacks into a staging directory first, validates required PostgreSQL +files, validates archive/root metadata, applies regular-file and directory +permissions, then publishes the staged root according to the selected restore +target policy. Existing targets must be empty unless replacement was explicitly +requested, and restore target paths must not be empty or contain NUL bytes. + +The required restored files are: + +- `pgdata/PG_VERSION`; +- `pgdata/global/pg_control`; +- `pgdata/backup_label`. + +## Verification + +The Rust unit tests under `backup::tests` are the executable contract for this +format. They cover valid annotation/restore behavior and malicious or malformed +tar cases such as traversal, links, duplicate paths, invalid checksums, invalid +numeric fields, truncated terminators, trailing data, and tree-shape conflicts. diff --git a/docs/internal/TODO.md b/docs/internal/TODO.md new file mode 100644 index 00000000..5dfeb627 --- /dev/null +++ b/docs/internal/TODO.md @@ -0,0 +1,285 @@ +# Native Product Backlog (Maintainers) + +This is the unfinished implementation backlog for the native `liboliphaunt` and +`oliphaunt` product track. Completed work belongs in [DONE.md](DONE.md). + +The current product objective is native PostgreSQL through `liboliphaunt`, not the +legacy runtime lane. Keep this file focused on work that makes the native direct, +broker, server, and SDK surfaces more correct, faster, easier to ship, or easier +to validate. + +Backlog priorities: + +- `P0`: blocks calling the native product production-ready. +- `P1`: hardening needed for a durable, low-maintenance product. +- `P2`: future capabilities that should not shape the current release contract + until they have measured evidence. + +When finishing an item, move the durable summary to [DONE.md](DONE.md) and leave +only genuine follow-up work here. + +## Product Target + +`liboliphaunt` is the C engine boundary over patched PostgreSQL 18. +`oliphaunt` is the canonical Rust SDK and the shape followed by Swift, +Kotlin, and React Native: + +- `NativeDirect` for lowest-latency embedded use; +- `NativeBroker` for robust desktop apps, multi-root ownership, crash isolation, + recovery, and upgrade orchestration; +- `NativeServer` for true PostgreSQL client compatibility, independent + connections, pools, `psql`, `pg_dump`, SQLx, and other external clients; +- explicit opt-in extensions with static registry support for mobile and a + manifest model that can later carry signed desktop dynamic extension + artifacts; +- benchmarks and release gates against native PostgreSQL and SQLite, using p90 + and p99 latency, throughput, CPU, memory, RSS, child-process RSS, open time, + backup/restore time, and artifact size. + +The native product must not fake semantics for convenience. Direct mode has one +physical backend session. Broker mode provides process isolation and multi-root +supervision. Server mode is the only mode that advertises independent +concurrent PostgreSQL sessions. + +## P0 Native Release Backlog + +### P0-01: Keep The PostgreSQL Patch Stack Minimal And Defensible + +Outcome: `liboliphaunt` patches stay generic, reviewable, and upstreamable. + +Remaining work: + +- Re-audit every PostgreSQL 18 patch after each backend change: + - host I/O vtables; + - embedded entrypoint and lifecycle; + - frontend terminate return path; + - cleanup and current-working-directory restoration; + - static extension loader. +- Keep patch comments and exported symbols generic. They must not mention a + language SDK, product packaging detail, or temporary experiment. + +Acceptance: + +- `src/runtimes/liboliphaunt/native/tools/check-track.sh quick` proves C smoke plus Rust SDK + smoke without rebuilding current artifacts. +- Patch-stack review output is deterministic and checked into release evidence. +- No patch grows product-specific branching that belongs above PostgreSQL. + +### P0-02: Finish Rust SDK Runtime Semantics + +Outcome: the Rust SDK is complete and honest across direct, broker, and server. + +Remaining work: + +- Keep `Oliphaunt` clone semantics explicit: clones share one executor in direct + and broker sessions; server mode exposes a connection string for independent + clients instead of pretending direct mode can pool. + +Acceptance: + +- Rust direct, broker, and server tests cover close, cancel, checkpoint, + transaction pinning, backup, restore, reopen, and external client recovery. +- No direct-mode API advertises independent concurrent sessions. + +### P0-03: Complete SDK Parity For The Public Contract + +Outcome: Rust, Swift, Kotlin, and React Native expose the same product concepts +where the platform can support them. + +Remaining work: + +- Keep Rust classified as an SDK and the canonical product shape. +- Keep React Native as TypeScript and TurboModule glue over Swift and Kotlin; + it must not grow a private database runtime. +- Build Android `NativeBroker` as a remote-process bound service with binder + death/reconnect and WAL-recovery tests. This is the first mobile process + isolation path. +- Run and document an iOS ExtensionFoundation/AppExtensionProcess broker + feasibility track before promising iOS process isolation. Keep iOS direct as + non-isolated unless that track passes real-device lifecycle and App Store + constraints. +- Expand physical iOS benchmark-matrix evidence for the current `liboliphaunt` + XCFramework before choosing mobile defaults. Device and simulator slices now + build and reject forbidden mobile IPC imports, and the physical iPhone + app build/install, crash-recovery verify, full smoke/lifecycle lanes, and + process-memory-capable quick plus full-candidate Safe/Balanced footprint + matrices pass. A Balanced quick tuning slice across + `shared_buffers=8/16/32/64/128MB` and `min_wal_size=8/16/32MB` also passes. + The next iOS evidence steps are Safe coverage for the chosen candidate axes, + `wal_buffers` variation, runtime-footprint profile variation, and then the + full preset physical-device matrix for the selected mobile default. +- Keep `pnpm moon run oliphaunt-swift:smoke` green as the fast + no-artifact gate for PostgreSQL 18 embedded patch portability while the full + iOS simulator/device artifacts are being built. +- Keep the Android Expo installed-app smoke lane reproducible from a checkout + without a committed generated `android/` directory. The local + `Pixel_9_API_34_Google_API` AVD now has benchmark and crash-recovery evidence + when cold-started with software GPU/no snapshot, but physical Android device + evidence and process-memory-capable full candidate/tuning slices are still + needed before release claims. A later local AVD retry killed the app process + before attach/startup and produced no Metro bundle request; treat that as a + harness/device reliability gap, not PostgreSQL tuning evidence. + +Acceptance: + +- `src/runtimes/liboliphaunt/native/tools/check-track.sh sdks` passes on a current native + runtime. +- `pnpm --dir src/sdks/react-native/examples/expo run smoke:android` passes on an Android + emulator/device with current native Android artifacts. +- `pnpm moon run oliphaunt-swift:smoke` passes on macOS with Xcode and + stays warning-clean for the PostgreSQL embedded patch objects. +- `pnpm moon run liboliphaunt-native:build-ios-xcframework` produces current + iOS simulator/device `liboliphaunt.dylib` slices with the public C ABI + symbols. +- `pnpm --dir src/sdks/react-native/examples/expo run smoke:ios` passes on an iOS + simulator/device with current native iOS artifacts. +- Every row in `docs/maintainers/sdk-parity-policy.md` has SDK-specific tests or a documented + product reason for non-parity. + +### P0-04: Finish Extension Release Evidence + +Outcome: extensions are opt-in, size-conscious, and backed by lifecycle +evidence across direct, broker, server, and mobile static registry packaging. + +Remaining work: + +- Keep exact PostgreSQL extension names as the only public selection primitive; + do not introduce first-party selection aliases. +- Prove the native exact-extension artifact matrix is green for every published + native runtime target: `macos-arm64`, `linux-x64-gnu`, `linux-arm64-gnu`, + `windows-x64-msvc`, `ios-xcframework`, `android-arm64-v8a`, and + `android-x86_64`. The package graph must not relabel one target's artifacts + as cross-platform evidence; each builder must emit product-versioned + extension release assets, target metadata, and smoke evidence for the target + it built. +- Keep pgGraph and ParadeDB `pg_search` as explicit external extension + candidates with pinned source, license, build fingerprint, preload metadata, + and smoke evidence. + +Acceptance: + +- `src/runtimes/liboliphaunt/native/tools/check-track.sh extensions` passes with first-party + extension artifacts. +- `extension-packages:assemble-release` receives native extension artifacts for every + published native runtime target and WASIX extension artifacts for every + published WASIX target. +- `extension-packages:assemble-mobile` receives only the Android/iOS native + extension artifacts needed by focused mobile installed-app builders and does + not force WASIX or desktop extension builders into mobile E2E runs. +- External extensions pass the opt-in external pgrx lane before they are + advertised as shippable. +- Mobile package checks reject module-backed extensions unless a complete static + registry is present. + +### P0-05: Make Benchmarks Release-Grade + +Outcome: native release claims are backed by reproducible benchmark reports that +can be compared against native PostgreSQL and SQLite. + +Remaining work: + +- Keep the native matrix native-only and no-build by default when artifacts are + current. +- Keep the verified source-current full report fresh after benchmark harness, + Rust SDK, or liboliphaunt runtime input changes. Latest complete verified + baseline before the backup ABI/tar-writer updates: + `target/perf/native-liboliphaunt-20260524T090412Z/report.md`. A new full + matrix is required before current-checkout release claims. +- Investigate measured NativeDirect misses from the current report: + speed-suite p90 (`1.103x` native PostgreSQL), speed tail throughput (`0.907x` + native PostgreSQL), physical backup/restore total p90 (`1.622x` native + PostgreSQL physical), physical backup throughput (`0.617x` native PostgreSQL + physical), and speed cases `1`, `2.1`, `3`, `4`, `10`, and `13`, which + reproduced in + `target/perf/native-speed-diagnostics-20260524T090412Z-speed-misses/summary.md`. + Cases `2`, `3.1`, and `5` missed in the full matrix but did not reproduce + above tolerance in isolated fresh-process diagnostics. +- Keep investigating the current-source focused backup miss from + `target/perf/native-liboliphaunt-20260524Tbackup-final-direct/report.md`: + direct physical backup/restore p90 is `0.534 s` versus native PostgreSQL + physical at `0.324 s`. `OLIPHAUNT_TRACE_BACKUP=1` shows the remaining direct + cost is concentrated in `pg_backup_start` and PGDATA archiving after metadata + append/copy overhead was removed from the hot path. +- Expand the release matrix beyond the current verified coverage where still + missing: extended-query RTT as its own lane, typed query helper overhead, + transaction throughput, dedicated bulk load variants, and cold/warm open + repeat rows. +- Keep benchmark quality rules and NativeDirect regression diagnostics green as + the suite changes. + +Acceptance: + +- `tools/perf/check-native-perf-harness.sh` passes. +- A complete provenance-verified report exists for direct, broker, server, + native PostgreSQL, and SQLite. +- `src/docs/content/reference/performance.md` is updated only from verified output. + +## P1 Product Hardening Backlog + +### P1-01: Improve Repository And Release Organization + +Outcome: `liboliphaunt`, `oliphaunt`, Swift, Kotlin, and React Native remain +separate products with clean ownership. + +Remaining work: + +- Keep native C sources under `src/runtimes/liboliphaunt/native/`. +- Keep the Rust SDK under `src/sdks/rust/`. +- Keep platform SDKs under `src/sdks/swift`, `src/sdks/kotlin`, and `src/sdks/react-native`. +- Keep legacy package code from becoming a dependency of native product checks. + +### P1-02: Harden Storage And Backup + +Outcome: directory storage feels ergonomic without hiding PostgreSQL realities. + +Remaining work: + +- Add multi-version upgrade policy fixtures once a second root schema or + PostgreSQL major exists. +- Add restore upgrade choreography once archive metadata has more than one + supported PostgreSQL major or archive layout. +- Add import/export documentation for desktop and mobile apps. + +### P1-03: Reduce Open-Time And Steady-State Overhead + +Outcome: native direct is not slower than the native PostgreSQL control for SDK +traffic after accounting for process model differences. + +Remaining work: + +- Profile direct-mode copies across C ABI, Rust protocol buffers, Swift `Data`, + Kotlin `ByteArray`, JNI byte arrays, and React Native JSI ArrayBuffer + transport. +- Add zero-copy or single-copy transport paths where the platform API supports + them. +- Investigate warm template cache behavior and file-copy strategy per platform. +- Keep any startup GUC tuning explicit, documented, and safe for persistent + roots. + +### P1-04: Strengthen Developer Experience + +Outcome: app developers can adopt native Oliphaunt with predictable packaging and +idiomatic APIs. + +Remaining work: + +- Add guided quickstarts for Rust/Tauri, Swift iOS, Swift macOS, Android, and + React Native. +- Add troubleshooting docs for root locks, missing runtime resources, missing + static registry entries, preload-required extensions, and benchmark misses. +- Add example apps that exercise open, query, transaction, extension, backup, + restore, and close. + +## P2 Future Capabilities + +These are not release blockers until they have evidence and a crisp product +boundary. + +- Signed dynamic desktop extensions. +- Out-of-process broker pools for multiple active backend workers per root. +- Mobile broker/server adapters when platform constraints and app-store rules + are fully understood. +- Live-query APIs designed as native Rust/Swift/Kotlin/TypeScript surfaces + rather than a compatibility shim. +- Cross-language generated bindings after the Rust, Swift, Kotlin, and React + Native SDK shapes are stable. diff --git a/docs/internal/WASIX_PATCH_STACK.md b/docs/internal/WASIX_PATCH_STACK.md new file mode 100644 index 00000000..5a8ea4c5 --- /dev/null +++ b/docs/internal/WASIX_PATCH_STACK.md @@ -0,0 +1,189 @@ + +# oliphaunt-wasix PostgreSQL 18 WASIX Patch Stack Review + +This source-only review artifact keeps the WASIX PostgreSQL patch stack deterministic and reviewable without rebuilding PostgreSQL. + +Regenerate with: + +```sh +src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write +``` + +## Source Pin + +- PostgreSQL: `18.4` +- URL: `https://ftp.postgresql.org/pub/source/v18.4/postgresql-18.4.tar.bz2` +- SHA-256: `81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094` +- Patch directory: `src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches` +- Experiment disposition policy: `do-not-port-experiment-patches-without-a-recorded-wasix-runtime-rationale` + +## Patch Series + +| Order | Patch | Author | Subject | +| --- | --- | --- | --- | +| 1 | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add wasix-dl build spine | +| 2 | `0002-oliphaunt-wasix-add-backend-host-io-hooks.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add backend host I/O hooks | +| 3 | `0003-oliphaunt-wasix-export-startup-packet-parser.patch` | Oliphaunt Maintainers | oliphaunt-wasix: export startup packet parser | +| 4 | `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add host lifecycle exports | +| 5 | `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add loop-pumped protocol exports | +| 6 | `0006-oliphaunt-wasix-report-copy-protocol-state.patch` | Oliphaunt Maintainers | oliphaunt-wasix: report COPY protocol state | +| 7 | `0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add wasix PGXS side-module support | +| 8 | `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch` | Oliphaunt Maintainers | oliphaunt-wasix: reset copy state on error recovery | +| 9 | `0009-oliphaunt-wasix-route-process-identity-through-port.patch` | Oliphaunt Maintainers | oliphaunt-wasix: route process identity through port | +| 10 | `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch` | Oliphaunt Maintainers | oliphaunt-wasix: route sysv shmem through port | +| 11 | `0011-oliphaunt-wasix-prefer-posix-semaphores.patch` | Oliphaunt Maintainers | oliphaunt-wasix: prefer POSIX semaphores | +| 12 | `0012-oliphaunt-wasix-capture-startup-errors.patch` | Oliphaunt Maintainers | oliphaunt-wasix: capture startup errors | +| 13 | `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch` | Oliphaunt Maintainers | oliphaunt-wasix: fail active portals on host recovery | +| 14 | `0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch` | Oliphaunt Maintainers | oliphaunt-wasix: speed up hash_bytes unaligned loads | +| 15 | `0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add top xid current transaction fast path | +| 16 | `0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add btree int4 compare fast path | +| 17 | `0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch` | Oliphaunt Maintainers | oliphaunt-wasix: keep btree delete scratch on stack | +| 18 | `0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch` | Oliphaunt Maintainers | oliphaunt-wasix: avoid pg_dump executeQuery LTO collision | +| 19 | `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch` | Oliphaunt Maintainers | oliphaunt-wasix: schedule ready after host recovery | +| 20 | `0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch` | Oliphaunt Maintainers | oliphaunt-wasix: rearm exception stack after host recovery | +| 21 | `0021-oliphaunt-wasix-declare-wasix-fork.patch` | Oliphaunt Maintainers | oliphaunt-wasix: stub fork_process in embedded WASIX runtime | +| 22 | `0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch` | Oliphaunt Maintainers | oliphaunt-wasix: use wasm-ld for backend core | +| 23 | `0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch` | Oliphaunt Maintainers | oliphaunt-wasix: skip data-dir ownership check under embedded WASIX | +| 24 | `0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add like literal substring fast path | +| 25 | `0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add simple-query backend timing probes | +| 26 | `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add executor storage backend timing probes | +| 27 | `0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add btree insert backend timing probes | +| 28 | `0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add btree search compare timing probes | +| 29 | `0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch` | Oliphaunt Maintainers | oliphaunt-wasix: stub pg_dump parallel fork | +| 30 | `0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add first int4 leaf compare fast path | +| 31 | `0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch` | Oliphaunt Maintainers | oliphaunt-wasix: add heap update backend timing probes | +| 32 | `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch` | Oliphaunt Maintainers | oliphaunt-wasix: avoid XLog-size checkpoint requests | +| 33 | `0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch` | Oliphaunt Maintainers | oliphaunt-wasix: use lightweight embedded runtime paths | +| 34 | `0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch` | Oliphaunt Maintainers | oliphaunt-wasix: set embedded postmaster environment | +| 35 | `0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch` | Oliphaunt Maintainers | oliphaunt-wasix: avoid xlogwrite prevseg division | +| 36 | `0036-oliphaunt-wasix-skip-activity-id-reporting.patch` | Oliphaunt Maintainers | oliphaunt-wasix: skip activity id reporting | +| 37 | `0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch` | Oliphaunt Maintainers | oliphaunt-wasix: treat directory fsync EISDIR as unsupported | +| 38 | `0038-oliphaunt-wasix-register-static-icu-data.patch` | Oliphaunt Maintainers | oliphaunt-wasix: register static ICU data | + +## Changed Upstream Files + +| File | Owning Patch(es) | Rationale | +| --- | --- | --- | +| `src/backend/access/heap/heapam_handler.c` | `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch` | Keeps embedded heap update timing observable. | +| `src/backend/access/heap/heapam.c` | `0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch` | Adds embedded timing probes and heap fast-path scope. | +| `src/backend/access/nbtree/nbtdedup.c` | `0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch` | Keeps btree delete scratch storage on stack under embedded WASIX. | +| `src/backend/access/nbtree/nbtinsert.c` | `0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch`, `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch`, `0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch` | Adds embedded btree insert timing and int4 fast-path scope. | +| `src/backend/access/nbtree/nbtsearch.c` | `0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch`, `0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch`, `0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch` | Adds embedded btree search timing and guarded int4 leaf fast paths. | +| `src/backend/access/transam/xact.c` | `0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch` | Adds top-level current-transaction shortcut for embedded WASIX. | +| `src/backend/access/transam/xlog.c` | `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch`, `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch`, `0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch` | Avoids expensive segment division under embedded WASIX. | +| `src/backend/commands/collationcmds.c` | `0038-oliphaunt-wasix-register-static-icu-data.patch` | Static ICU consumers register linked common data before collation commands call ICU locale APIs. | +| `src/backend/commands/copyfromparse.c` | `0006-oliphaunt-wasix-report-copy-protocol-state.patch` | Reports COPY protocol state to the host. | +| `src/backend/commands/copyto.c` | `0006-oliphaunt-wasix-report-copy-protocol-state.patch` | Reports COPY protocol state to the host. | +| `src/backend/libpq/be-secure.c` | `0002-oliphaunt-wasix-add-backend-host-io-hooks.patch` | Routes embedded protocol reads and writes through host-owned callbacks. | +| `src/backend/libpq/pqcomm.c` | `0002-oliphaunt-wasix-add-backend-host-io-hooks.patch` | Skips unavailable postmaster-death wait handles in embedded WASIX. | +| `src/backend/Makefile` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch` | Builds the dynamic-main backend module without changing other ports. | +| `src/backend/optimizer/plan/planner.c` | `0036-oliphaunt-wasix-skip-activity-id-reporting.patch` | Suppresses activity identifier reporting in embedded WASIX. | +| `src/backend/port/posix_sema.c` | `0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch` | Uses POSIX semaphore behavior selected by the WASIX template. | +| `src/backend/postmaster/checkpointer.c` | `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch` | Keeps checkpoint requests local to embedded WASIX. | +| `src/backend/postmaster/fork_process.c` | `0021-oliphaunt-wasix-declare-wasix-fork.patch` | Declares the WASIX fork boundary without enabling postmaster concurrency. | +| `src/backend/replication/walsender.c` | `0006-oliphaunt-wasix-report-copy-protocol-state.patch` | Suppresses activity identifier reporting in embedded WASIX. | +| `src/backend/storage/file/fd.c` | `0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch` | Treats data-directory ownership and directory sync as WASIX platform boundaries. | +| `src/backend/tcop/backend_startup.c` | `0003-oliphaunt-wasix-export-startup-packet-parser.patch` | Exports the startup packet parser for host-driven startup. | +| `src/backend/tcop/postgres.c` | `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch`, `0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch`, `0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`, `0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch`, `0036-oliphaunt-wasix-skip-activity-id-reporting.patch` | Owns embedded lifecycle, protocol loop, error recovery, and timing hooks. | +| `src/backend/utils/adt/like_match.c` | `0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch` | Adds guarded LIKE literal fast path for embedded WASIX. | +| `src/backend/utils/adt/like.c` | `0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch` | Adds guarded LIKE literal fast path for embedded WASIX. | +| `src/backend/utils/adt/pg_locale_icu.c` | `0038-oliphaunt-wasix-register-static-icu-data.patch` | Static ICU consumers register linked common data before PostgreSQL opens ICU collators or converters. | +| `src/backend/utils/adt/pg_locale.c` | `0038-oliphaunt-wasix-register-static-icu-data.patch` | Static ICU consumers register linked common data before PostgreSQL validates or canonicalizes ICU locales. | +| `src/backend/utils/init/miscinit.c` | `0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch` | Routes process identity through the WASIX port layer. | +| `src/backend/utils/init/postinit.c` | `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch` | Skips data-directory ownership checks under embedded WASIX. | +| `src/backend/utils/misc/guc.c` | `0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch` | Uses the embedded WASIX postmaster-style environment. | +| `src/backend/utils/mmgr/portalmem.c` | `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch` | Fails active portals on host-forced recovery. | +| `src/bin/pg_dump/connectdb.c` | `0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch` | Avoids pg_dump LTO symbol collisions. | +| `src/bin/pg_dump/connectdb.h` | `0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch` | Avoids pg_dump LTO symbol collisions. | +| `src/bin/pg_dump/parallel.c` | `0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch` | Stubs unavailable pg_dump parallel fork behavior under WASIX. | +| `src/bin/pg_dump/pg_dumpall.c` | `0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch` | Avoids pg_dump LTO symbol collisions. | +| `src/common/file_utils.c` | `0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch` | Treats EISDIR directory fsync as unsupported on WASIX. | +| `src/common/hashfn.c` | `0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch` | Uses defined unaligned load fast path under WASIX. | +| `src/include/libpq/libpq-be.h` | `0002-oliphaunt-wasix-add-backend-host-io-hooks.patch` | Adds the host I/O callback table to Port only for embedded WASIX. | +| `src/include/port/wasix-dl.h` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0009-oliphaunt-wasix-route-process-identity-through-port.patch`, `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch`, `0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`, `0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch`, `0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch`, `0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch`, `0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch` | Defines the embedded WASIX port header and ABI redirects. | +| `src/include/port/wasix-dl/sys/ipc.h` | `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch` | Provides the WASIX SysV IPC shim surface. | +| `src/include/port/wasix-dl/sys/shm.h` | `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch` | Provides the WASIX SysV shared-memory shim surface. | +| `src/include/utils/pg_locale.h` | `0038-oliphaunt-wasix-register-static-icu-data.patch` | Declares the generic static ICU data registration helper for PostgreSQL ICU call sites. | +| `src/Makefile.shlib` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch` | Defines the WASIX dynamic-link shared-library shape. | +| `src/makefiles/Makefile.wasix-dl` | `0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch` | Builds side modules and PGXS artifacts for WASIX dynamic linking. | +| `src/makefiles/pgxs.mk` | `0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch` | Installs PGXS extension artifacts for WASIX packaging. | +| `src/template/wasix-dl` | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch`, `0011-oliphaunt-wasix-prefer-posix-semaphores.patch` | Keeps the WASIX template and atomics invariants source-controlled. | + +## Audit Checklist + +| Requirement | Owning Patch(es) | Required Evidence | Review Posture | +| --- | --- | --- | --- | +| WASIX dynamic-main build spine is isolated | `0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch` | `PORTNAME), wasix-dl`, `oliphaunt: $(OBJS)` | Build plumbing lands before lifecycle behavior, so linker changes are reviewable alone. | +| Backend protocol I/O is host-owned without touching normal sockets | `0002-oliphaunt-wasix-add-backend-host-io-hooks.patch` | `OliphauntWasmHostIO`, `secure_raw_read`, `secure_raw_write` | Only OLIPHAUNT_WASM_SINGLE_USER installs the callback table. | +| Startup packet parsing remains PostgreSQL-owned | `0003-oliphaunt-wasix-export-startup-packet-parser.patch` | `ProcessStartupPacket`, `OLIPHAUNT_WASM_HOST_EXPORT("ProcessStartupPacket")` | The host can call the parser, but PostgreSQL still validates the startup packet. | +| Host lifecycle exports stay explicit | `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch` | `oliphaunt_wasix_start`, `oliphaunt_wasix_pq_flush`, `oliphaunt_wasix_get_proc_port` | Host-visible entry points are named exports instead of broad syscall remaps. | +| Protocol loop recovery remains at the PostgresMain boundary | `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch`, `0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch` | `PostgresMainLoopOnce`, `PostgresMainLongJmp`, `send_ready_for_query = true` | The host pumps PostgreSQL one loop at a time and recovery re-enters the upstream exception stack. | +| COPY protocol state is host-observable | `0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch` | `oliphaunt_wasix_protocol_report_copy_response`, `OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE` | COPY state is reported and cleared around PostgreSQL error recovery. | +| PGXS side modules use the WASIX dynamic-link contract | `0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch`, `0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch` | `PGXS`, `WASM_LD ?= $(shell $(CC) -print-prog-name=wasm-ld)` | Extension and backend side-module behavior is source-reviewed with the linker path. | +| Process identity and shared memory stay behind the port header | `0009-oliphaunt-wasix-route-process-identity-through-port.patch`, `0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`, `0011-oliphaunt-wasix-prefer-posix-semaphores.patch` | `oliphaunt_wasix_geteuid`, `oliphaunt_wasix_shmget`, `PREFERRED_SEMAPHORES=UNNAMED_POSIX` | WASIX platform gaps are explicit port-layer dependencies, not scattered runtime guesses. | +| Tool/runtime platform stubs fail closed | `0021-oliphaunt-wasix-declare-wasix-fork.patch`, `0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch`, `0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch` | `fork_process`, `oliphaunt_wasix_pgdump_fork`, `errno == EISDIR` | Unavailable WASIX behavior is explicit and narrow instead of silently emulated. | +| Static ICU data is registered before PostgreSQL calls ICU APIs | `0038-oliphaunt-wasix-register-static-icu-data.patch` | `pg_register_static_icu_data`, `udata_setCommonData`, `init_icu_converter` | The static WASIX ICU build can initialize PostgreSQL without mounting loose ICU data files. | + +## PostgreSQL Patch Symbols + +- `oliphaunt_wasix_backend_timing_end` (`0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`) +- `oliphaunt_wasix_backend_timing_start` (`0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch`) +- `oliphaunt_wasix_begin_startup_error_capture` (`0012-oliphaunt-wasix-capture-startup-errors.patch`) +- `oliphaunt_wasix_end_startup_error_capture` (`0012-oliphaunt-wasix-capture-startup-errors.patch`) +- `oliphaunt_wasix_get_proc_port` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_getegid` (`0009-oliphaunt-wasix-route-process-identity-through-port.patch`) +- `oliphaunt_wasix_geteuid` (`0009-oliphaunt-wasix-route-process-identity-through-port.patch`) +- `oliphaunt_wasix_getgid` (`0009-oliphaunt-wasix-route-process-identity-through-port.patch`) +- `oliphaunt_wasix_getpwuid` (`0009-oliphaunt-wasix-route-process-identity-through-port.patch`) +- `oliphaunt_wasix_getpwuid_r` (`0009-oliphaunt-wasix-route-process-identity-through-port.patch`) +- `oliphaunt_wasix_getuid` (`0009-oliphaunt-wasix-route-process-identity-through-port.patch`) +- `oliphaunt_wasix_hash_load32` (`0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch`) +- `oliphaunt_wasix_host_read` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_host_write` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_init_protocol_port` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`) +- `oliphaunt_wasix_io` (`0002-oliphaunt-wasix-add-backend-host-io-hooks.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_pgdump_fork` (`0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch`) +- `oliphaunt_wasix_pq_flush` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_process_startup_options` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_protocol_io` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_protocol_report_copy_response` (`0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`) +- `oliphaunt_wasix_send_conn_data` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_shmat` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) +- `oliphaunt_wasix_shmctl` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) +- `oliphaunt_wasix_shmdt` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) +- `oliphaunt_wasix_shmget` (`0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch`) +- `oliphaunt_wasix_start` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`) +- `oliphaunt_wasix_startup_error_capture_active` (`0012-oliphaunt-wasix-capture-startup-errors.patch`) +- `oliphaunt_wasix_startup_error_saved_dest` (`0012-oliphaunt-wasix-capture-startup-errors.patch`) +- `OLIPHAUNT_WASM_EXIT_ALIVE` (`0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) +- `OLIPHAUNT_WASM_HOST_EXPORT` (`0003-oliphaunt-wasix-export-startup-packet-parser.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) +- `OLIPHAUNT_WASM_SINGLE_USER` (`0002-oliphaunt-wasix-add-backend-host-io-hooks.patch`, `0003-oliphaunt-wasix-export-startup-packet-parser.patch`, `0004-oliphaunt-wasix-add-host-lifecycle-exports.patch`, `0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`, `0006-oliphaunt-wasix-report-copy-protocol-state.patch`, `0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch`, `0012-oliphaunt-wasix-capture-startup-errors.patch`, `0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch`, `0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch`, `0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch`, `0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch`, `0021-oliphaunt-wasix-declare-wasix-fork.patch`, `0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch`, `0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch`, `0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch`, `0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch`, `0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch`, `0036-oliphaunt-wasix-skip-activity-id-reporting.patch`) +- `PostgresMainLongJmp` (`0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) +- `PostgresMainLoopOnce` (`0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch`) +- `ProcessStartupPacket` (`0003-oliphaunt-wasix-export-startup-packet-parser.patch`) + +## Experiment Patch Disposition + +| Experiment Patch | Status | WASIX Runtime Decision | Rationale | +| --- | --- | --- | --- | +| `0001-wasix-use-posix-dsm-not-sysv.patch` | `not-carried` | replaced where relevant by 0010 and 0011 | The experiment patch changes full-concurrent PostgreSQL dynamic shared memory selection. The embedded WASIX runtime does not take postmaster DSM as a product constraint; it instead routes SysV shmem through the wasix-dl port header and explicitly selects POSIX semaphores. | +| `0003-wasix-libpq-static-encoding-shim.patch` | `covered-by-build-spine` | covered by the WASIX bridge aliases and standalone pg_dump build path | The released-lane bridge already provides weak pg_char_to_encoding and pg_encoding_to_char aliases for static pg_dump/libpq linkage. No PG18 source patch is needed unless a future configured build proves a tool-specific gap. | +| `0004-wasix-core-execbackend-initdb-runtime.patch` | `not-carried` | full-concurrent runtime blocker patch, not embedded product shape | The patch addresses EXEC_BACKEND, fork/exec, root checks, locale command probing, and directory fsync behavior for proper concurrent PostgreSQL under WASIX. The embedded WASIX runtime avoids the postmaster/fork lifecycle; any tool blocker found later should become a smaller tool-specific patch. | +| `0005-pg-dump-avoid-lto-executequery-collision.patch` | `ported` | ported as 0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch | This is a narrow pg_dump source hygiene patch that supports standalone WASIX tool builds under thin LTO without changing query behavior. | +| `0006-like-literal-substring-fast-path.patch` | `ported-with-tighter-guards` | ported as 0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch | The PG18 patch narrows the experiment shortcut to deterministic, case-sensitive LIKE matching for the simple %literal% shape and rejects escapes, _, inner %, lower/case-insensitive variants, and nondeterministic collations before using memchr/memcmp. | +| `0007-top-xid-current-transaction-fast-path.patch` | `ported` | ported as 0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch | The final PG18 patch keeps the upstream parallel and subtransaction paths and only short-circuits the ordinary top-level case after all alternate current-XID sources are absent. | +| `0008-btree-int4-compare-fast-path.patch` | `ported-with-tighter-guards` | ported as 0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch | The PG18 patch keeps index_getattr(), requires the built-in integer btree family, int4 input type, int4 or InvalidOid subtype, and InvalidOid collation. It does not assume every int4 opclass has the built-in ordering. | +| `0009-btree-delete-stack-state.patch` | `ported-with-single-user-gate` | ported as 0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch | The PG18 patch is restricted to __wasi__ and OLIPHAUNT_WASM_SINGLE_USER. It keeps deletion policy and tableam behavior unchanged while avoiding page-local allocator churn. | +| `0010-btree-bottomup-delete-runtime-toggle.patch` | `rejected-for-default-lane` | diagnostic toggle was tested with Oliphaunt env names but not kept in the patch stack | Disabling bottom-up deletion changes PostgreSQL's index maintenance behavior. A local PG18 WASIX port of the diagnostic hook made the default release-profile 9/10 run much slower before any override was enabled, so it is not a defensible carried patch. | +| `0011-btree-first-int4-compare-fast-path.patch` | `ported-with-tighter-guards` | ported as 0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch | The PG18 patch keeps the direct tuple-data read only for embedded WASIX, leaf pages, one-key non-null non-posting non-pivot tuples, built-in int4 btree family, int4 input type, InvalidOid collation, and non-DESC scan keys. Equal values still fall through to PostgreSQL's existing heap-TID and truncated-key tie-break logic. | +| `0012-hash-bytes-unaligned-load-fast-path.patch` | `ported` | ported as 0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch | The PG18 patch uses memcpy-based little-endian 32-bit loads under __wasi__ only, preserving defined C behavior while giving LLVM a wasm-load lowering opportunity. | + +## Guardrails + +- `source.toml` patch series exactly matches the patch directory. +- Every patch has a deterministic `From: Oliphaunt Maintainers ` header. +- Every patch has a deterministic `Subject: [PATCH] oliphaunt-wasix: ...` header and a rationale before the diff. +- Added PostgreSQL lines are checked for trailing whitespace and space-before-tab indentation. +- Changed upstream files must exactly match the expected touchpoint table above; new upstream touchpoints need an explicit rationale before landing. +- Required audit checks prove their evidence in the named owning patch or patches. +- Experiment patches can only be ported, rejected, or replaced with a recorded WASIX runtime decision and rationale. diff --git a/docs/maintainers/assets.md b/docs/maintainers/assets.md new file mode 100644 index 00000000..ba407cf4 --- /dev/null +++ b/docs/maintainers/assets.md @@ -0,0 +1,195 @@ +# Maintainer Asset Notes + +This page is maintainer documentation for packaged runtime assets, generated +payloads, and release provenance. It is not end-user product documentation. +Native application users should start with `README.md`, `src/docs/content/learn/native-runtime.md`, and +the SDK README for their platform. WASIX users should use +the public WASM SDK guide and `src/docs/content/sdk/wasm/runtime.md`. + +`oliphaunt-wasix` ships the database runtime as package-managed assets. Most users +do not need to download Postgres, run Docker, install LLVM, or configure a +runtime path. + +## What Ships + +With default features, the crate includes: + +- the portable Oliphaunt/Postgres WASIX runtime tree; +- a prepopulated PGDATA template for faster temporary databases; +- bundled extension archives for supported SQL extensions; +- the packaged `initdb` module used by asset CI and explicit fresh-initdb paths; +- the packaged `pg_dump` module used by the public dump API and CLI; +- a target-specific Wasmer AOT pack when the current host target is supported. + +The internal asset crates exist only because crates.io packages dependencies as +separate crates. Application code should depend on `oliphaunt-wasix`, not on +`oliphaunt-wasix-assets` or `oliphaunt-wasix-aot-*` directly. + +## Feature Flags + +Default install: + +```toml +oliphaunt-wasix = "0.5" +``` + +Default features include the packaged runtime/AOT assets and bundled extension +APIs: + +```toml +oliphaunt-wasix = { version = "0.5", default-features = false, features = ["bundled"] } +``` + +The `bundled` feature keeps the package-managed Oliphaunt/Postgres runtime and the +current platform's AOT crate, but leaves the public extension API disabled. +This is the "embedded Postgres without extension helpers" mode. + +Size-sensitive builds can opt out of packaged assets entirely: + +```toml +oliphaunt-wasix = { version = "0.5", default-features = false } +``` + +When bundled assets are disabled, normal database opens do not have packaged +runtime/AOT assets available. This mode is intended for specialized maintainer +and custom-runtime workflows. + +## Cache Behavior + +Runtime files are expanded into a cache and then composed with a small writable +per-root skeleton by default. Temporary and template-backed databases use a +cached PGDATA template as a lower filesystem and materialize files into the +database root only when PostgreSQL opens them for mutation. + +The runtime tree keeps both `/bin/oliphaunt` and `/bin/postgres`. They are the same +backend module; the `postgres` path exists so upstream `initdb` can discover and +spawn the backend through PostgreSQL's normal `find_other_exec()` path. + +The cache is content-addressed by the asset manifest and artifact hashes. If an +asset hash does not match the manifest, startup fails instead of using a mixed +or corrupted runtime. + +## Extension Assets + +Extensions are demand-driven. An extension archive is installed into the +database root only when the builder requests it or `enable_extension` is called: + +```rust,no_run +use oliphaunt_wasix::{extensions, Oliphaunt}; + +let mut db = Oliphaunt::builder() + .temporary() + .extension(extensions::VECTOR) + .open()?; + +db.enable_extension(extensions::PG_TRGM)?; +# Ok::<_, Box>(()) +``` + +Archive extraction rejects parent traversal, absolute paths, symlinks, +hardlinks, device nodes, and unsupported entry types. + +## Provenance + +Asset provenance is recorded in runtime source pins under +`src/sources/third-party/**`, extension-owned source pins under +`src/extensions/external/**/source.toml` and +`src/extensions/external/**/dependencies/**/source.toml`, +`src/sources/toolchains/**`, the committed asset input fingerprint, and the +generated runtime/AOT manifests produced by the +`Checks` workflow's WASM runtime lane. Generated manifests record source pins, +runtime hashes, `initdb` hashes, PGDATA template hashes, extension archive +hashes, target information, and Wasmer engine identity. PostgreSQL ICU support +uses the same provenance path: ICU is source-pinned in +`src/sources/third-party/shared/icu.toml`, checked out under +`target/oliphaunt-sources/checkouts/icu`, and built as target-specific static +libraries by the native and WASIX runtime builders. + +The public repository tracks source-controlled inputs and crate skeletons. It +does not track upstream source checkouts, generated PGDATA templates, portable +WASIX blobs, or native AOT binaries. +Maintainer source trees are fetched on demand into ignored +`target/oliphaunt-sources/checkouts/**` directories: + +```sh +cargo run -p xtask -- assets fetch +``` + +WASIX build and work trees are generated under +`target/oliphaunt-wasix/wasix-build/**`. The source tree +`src/runtimes/liboliphaunt/wasix/assets/build/**` is reserved for scripts, patches, +Docker inputs, and shims that should affect the committed asset fingerprint. + +Normal development and source-free validation do not clone upstream repositories +or run Docker. The source-free gate is: + +```sh +cargo run -p xtask -- assets verify-committed +``` + +It verifies source pins, source/build input fingerprints, extension +metadata/constants when generated manifests are installed, AOT crate templates, +and the absence of committed PGDATA template, portable WASIX, or native AOT +blobs. + +Release assets are built with the `release` profile by default: WASIX C code +uses `-O2 -g0`, and Binaryen runs the wasixcc default optimization plus +`--converge`, `--strip-debug`, and `--strip-producers`. The `release-o3` +profile remains available for explicit O3/ThinLTO comparison builds. + +Generated runtime hashes in package metadata are refreshed in the release +staging workspace. They are not a committed source-of-truth value in normal +development; `src/sources/third-party/**`, +`src/extensions/external/**/source.toml`, +`src/extensions/external/**/dependencies/**/source.toml`, +`src/sources/toolchains/**`, and +`src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256` are the +small committed provenance files. + +The `Checks` workflow's WASM runtime/AOT lane mirrors the release topology on +trusted producer runs: one Linux/Docker job builds portable WASIX modules from +`src/runtimes/liboliphaunt/wasix/assets/build` into `target/oliphaunt-wasix/assets`, +then native matrix jobs generate and package target-specific Wasmer AOT crates +into `target/oliphaunt-wasix/aot/`. Artifacts are uploaded with +checksums, manifests, and the committed asset-input fingerprint. + +Pull requests run a Moon-based asset plan instead of GitHub path-filtering the +workflow. The plan uses `moon query affected` for the PR base/head, plus the +asset producer path allowlist, to decide whether the expensive producer jobs are +required. Non-asset PRs become an explicit no-op after the source-controlled +asset-input checks. Asset-producing PRs run those input checks and the same full +portable/AOT producer path as `main`, scheduled runs, and explicit maintainer +dispatches. + +Manual `Checks` dispatches use the same producer path. Maintainers may select +one native target for focused validation, but the workflow still rebuilds +portable WASIX assets, generates AOT artifacts, runs the runtime gate, stages the +release workspace, package-checks the target crate, and uploads the canonical +release artifact shape. + +Native AOT generation intentionally installs Wasmer's LLVM 22.1.x custom build +only inside the `Checks` workflow's WASM AOT jobs or a maintainer's explicit +local artifact build. Normal contributors and end users never need LLVM; they +use committed Rust sources plus downloaded or released AOT payloads. + +The normal CI runtime matrix downloads the latest compatible `Checks` workflow +WASM runtime bundle, verifies that the downloaded fingerprint matches the +current source inputs, installs the payloads into ignored generated paths, and +runs runtime tests. Changes to source pins, WASIX patches, extension catalogs, +build scripts, or AOT crate templates are treated as asset-producing: pull +requests must pass the source-controlled asset-input gate and the full producer +workflow before merge, while `main`, scheduled runs, and explicit maintainer dispatches remain +trusted producer lanes for release artifacts. Release validation downloads the +exact-SHA portable and AOT bundles, stages them into a clean release workspace, +validates package contents, and only then publishes. + +Published releases also attach public `.tar.zst` mirrors of the validated +portable WASIX and target AOT bundles. `xtask assets download --release ` +installs those release assets directly and does not require the GitHub CLI. + +After an intentional asset-source change and regenerated artifacts, refresh the +committed input fingerprint: + +```sh +cargo run -p xtask -- assets input-fingerprint --write +``` diff --git a/docs/maintainers/compiler-caching.md b/docs/maintainers/compiler-caching.md new file mode 100644 index 00000000..e8cd4a16 --- /dev/null +++ b/docs/maintainers/compiler-caching.md @@ -0,0 +1,169 @@ +# Compiler Caching + +Oliphaunt uses three separate cache layers. Keep them separate: + +- Moon caches deterministic task outputs. +- Cargo, Gradle, pnpm, SwiftPM, and Xcode cache their own dependency/build + state through their native tools. +- Compiler caches reuse object-code compilation when a native lane has to run. + +Moon decides whether a product task runs. Compiler caches make the task cheaper +when it does run. Do not replace affectedness with compiler cache hits, and do +not treat compiler cache hits as release evidence. + +## Current Decision + +Use `ccache` for C/PostgreSQL native runtime builds on macOS, Linux, +iOS-simulator/device, and Android host builds when the build runs from a Unix +shell. These lanes compile PostgreSQL and liboliphaunt through clang or gcc, and +the build scripts already route `CC`/`CXX` through ccache when it is available. + +Use Cargo cache actions for Rust dependencies and `target` reuse. Do not enable +`sccache` by default yet. + +Use normal Gradle, SwiftPM, pnpm, Moon, and GitHub Actions caches for their +ecosystems. Do not put simulator state, device state, registry responses, +PostgreSQL source checkouts, or release artifacts into Moon's cache. + +Do not share native build roots across targets. Sharing object caches can be +reasonable when the compiler cache understands the compiler identity and flags, +but sharing build directories across macOS, Linux, iOS, Android, and Windows is +not. + +## Local Native Builds + +The liboliphaunt build scripts automatically use `ccache` when it is on `PATH`. + +```sh +brew install ccache +src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +ccache --show-stats +``` + +On Linux: + +```sh +sudo apt-get install ccache +src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh +ccache --show-stats +``` + +The iOS and Android native build scripts use the same `OLIPHAUNT_CCACHE` +contract because they are clang-based cross-builds launched from macOS. + +Override or disable it with: + +```sh +OLIPHAUNT_CCACHE=/opt/homebrew/bin/ccache src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +OLIPHAUNT_CCACHE=off src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +``` + +The build scripts use prefix mode (`CC="ccache cc"` style). Keep cache +configuration boring: set a local size, review `ccache --show-stats`, and avoid +broad `CCACHE_SLOPPINESS` settings unless a measured local experiment proves the +trade-off is worth it. + +Recommended local defaults: + +```sh +ccache --max-size=10G +ccache --set-config=compression=true +``` + +Use a per-workstation cache directory only when you need to isolate experiments: + +```sh +CCACHE_DIR="$HOME/.cache/oliphaunt-ccache" src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +``` + +## CI Native Builds + +CI restores `~/.ccache` for Unix-hosted native runtime lanes and scopes native +build-tree caches by runtime target: + +```text +liboliphaunt-native-ccache---- +release-native-assets-ccache---- +``` + +The target id is part of the key because the cached build root is target +specific. A Linux x64 build tree, Linux arm64 build tree, macOS build tree, iOS +build tree, Android build tree, and Windows build tree must not share the same +build-root cache. `ccache` itself is designed to key on compiler inputs, but the +surrounding build tree is not a generic object cache. + +The input hash must track the same source domains that drive the Moon native +runtime tasks: PostgreSQL pins, third-party pins, extension metadata, all +`src/runtimes/liboliphaunt/native/**` sources and scripts, `tools/xtask/**`, `Cargo.toml`, +`Cargo.lock`, and `rust-toolchain.toml`. A cache hit across any of those changes +is treated as stale build-root reuse, not an acceptable optimization. + +CI prints `ccache --show-stats` after native builds. Treat those stats as the +first signal before changing cache size, keys, or storage. + +## Windows + +The Windows liboliphaunt lane uses MSVC and Meson/Ninja. It currently gets +target-scoped build-root reuse through GitHub Actions cache but not object-code +reuse. `ccache` is not the right default there; if Windows object caching becomes +necessary, use `sccache` for MSVC in a dedicated CI experiment before promoting +it to the release path. + +Use a native Windows Perl for PostgreSQL's MSVC build, such as Strawberry Perl. +Do not let Meson discover Git/MSYS Perl for this lane: MSYS path rewriting can +turn native linker options into bogus paths before PostgreSQL's Windows export +generation calls `dumpbin`. + +## sccache + +`sccache` is attractive because it supports Rust, C/C++, MSVC, local caches, +GitHub Actions cache storage, cloud storage, and distributed compilation. It is +also a bigger operational choice: + +- Rust cache hits require `RUSTC_WRAPPER=sccache`, and incremental workspace + crates are not cacheable. +- C/C++ cache hits require build-system launcher integration per build path. +- Shared cache hit rates depend heavily on stable toolchains, SDKs, compiler + flags, absolute path normalization, and per-platform keys. +- Remote or GitHub-backed caches add a new failure mode and need stats before + being trusted. + +Recommended adoption path: + +1. Keep current `ccache` plus Cargo cache as the release path. +2. Add a manual `workflow_dispatch` experiment that enables + `mozilla-actions/sccache-action`, `SCCACHE_GHA_ENABLED=true`, and + `RUSTC_WRAPPER=sccache` for Rust-heavy lanes only. Set `CARGO_INCREMENTAL=0` + in that experiment so Rust compiler outputs are cacheable and comparable. +3. Compare wall time, cache hit rate, upload/download time, and flake rate + against the current CI for at least five same-SHA reruns. +4. If Rust results are good, add a second experiment for Windows MSVC and + Unix C/C++ builds through explicit compiler launchers. Keep target-specific + cache scopes; do not let Linux, macOS, iOS, Android, and Windows publish into + one undifferentiated cache namespace. +5. Promote sccache only after the experiment writes stable stats to the CI + summary and has a documented rollback switch. + +Use these environment variables only in the experiment workflow: + +```text +SCCACHE_GHA_ENABLED=true +RUSTC_WRAPPER=sccache +CARGO_INCREMENTAL=0 +``` + +For local Rust experimentation, keep it opt-in: + +```sh +brew install sccache +RUSTC_WRAPPER=sccache CARGO_INCREMENTAL=0 cargo test -p oliphaunt +sccache --show-stats +``` + +## References + +- ccache manual 4.13.6: +- sccache README: +- sccache GitHub Action: +- GitHub Actions cache: +- Meson machine files: diff --git a/docs/maintainers/development.md b/docs/maintainers/development.md new file mode 100644 index 00000000..be6da4a2 --- /dev/null +++ b/docs/maintainers/development.md @@ -0,0 +1,375 @@ +# Maintainer Development Guide + +This page is maintainer documentation for repository validation, generated +artifacts, and local release workflows. It is not end-user product +documentation. + +Run the local gates before opening a PR: + +```sh +moon run dev-tools:doctor +tools/dev/bootstrap-tools.sh +moon run :check && moon run :test +moon run ci-workflows:check +tools/policy/check-supply-chain.sh +``` + +Tool versions for Moon, Node, pnpm, Bun, and Deno are pinned in `.prototools`. +Bun is required for the TypeScript SDK checks because `@oliphaunt/ts` supports +Bun through the npm artifact; local checks use `tools/dev/bun.sh` when the shell +does not already provide the pinned Bun. Deno is optional for normal local checks +and uses `tools/dev/deno.sh` on demand for JSR package validation. + +Tool choices and rejected alternatives are recorded in +[tooling.md](tooling.md). Update that decision record before adding a new +repo-wide tool or hand-rolled release helper. + +Moon is the product graph and affected-task entrypoint. A fresh checkout should +install the pinned proto/Moon toolchain from `.prototools`, then call Moon +directly: + +```sh +moon query projects +moon query affected --upstream none --downstream deep +moon run :coverage --affected +``` + +Use `moon query affected` to inspect affectedness and `moon run ` for +explicit local targets. GitHub CI executes the exact planned target list with +Moon so jobs do not expand into unrelated downstream work. Normal commands use +Moon's own concurrency instead of a forced single-worker debug mode. + +The validation entrypoint is split by maintainer workflow: + +- `moon run liboliphaunt-native:test`: no-build host C ABI/runtime smoke for the + current native target. It compiles and runs the consumer-style ABI harness and + the full C smoke against the release-runtime artifact for macOS, Linux, or + Windows. `OLIPHAUNT_TRACK_BUILD=never` makes missing or stale artifacts fail + immediately instead of entering any build path; +- `moon run liboliphaunt-native:release-check`: extension-enabled native product + track. It runs the C smoke, Rust native runtime regression, and gated native + extension matrix while reusing existing native artifacts whenever their + fingerprints are current. Peer SDK release checks stay as first-class Moon + product tasks instead of being hidden inside this aggregate; +- `moon run repo:check`: file hygiene and formatting; +- `tools/policy/check-wasm-artifacts.sh`: source-controlled asset input verification + plus AOT crate template checks; +- `tools/policy/check-rust-lint.sh`: dependency invariants and clippy; +- `tools/policy/check-rust-test-topology.sh`: fast policy check proving Rust + doctests and executable tests are owned by product Moon tasks instead of a + broad root Cargo wrapper; +- `moon run ci-workflows:check`: local `actionlint` and `zizmor` checks using + the same zizmor config and severity/persona as CI. `actionlint` covers + workflow syntax, expression, and shell wiring; `zizmor` covers workflow + security findings. Keep both, but do not add another workflow linter unless + it replaces one of these responsibilities; +- `moon run liboliphaunt-wasix:smoke`: hard-requires portable assets plus host AOT, + installs them into ignored paths, and runs the real runtime tests; +- `moon run liboliphaunt-wasix:smoke`: the runtime smoke subset; +- `moon run integration-examples:check`: Tauri/Rust/frontend example checks; +- `moon run liboliphaunt-native:smoke`: native-only C ABI smoke and + Rust native SDK tests. This delegates to the same fast product-track harness + as `oliphaunt-track quick`, so it reuses `target/liboliphaunt-pg18` by default + and only builds missing artifacts. Set `OLIPHAUNT_TRACK_BUILD=never` to prove + the command will not rebuild, `missing` to build absent artifacts, or `always` + for a deliberate rebuild. Set `OLIPHAUNT_VALIDATE_EXTENSIONS=1` to switch the + command to the gated extension matrix. The native dylib is stamped and reused + unless edited C ABI sources, PostgreSQL embedded object inputs, compiler, or + patch/build inputs change; set `OLIPHAUNT_FORCE_RELINK=1` for a deliberate + relink. Extension artifact builds are separately fingerprinted and reused + across runs unless native C ABI, PostgreSQL patch/build, compiler, or + extension source inputs change. Set `OLIPHAUNT_FORCE_EXTENSION_REBUILD=1` for + a deliberate clean extension rebuild; +- `src/runtimes/liboliphaunt/native/tools/check-track.sh [host-smoke|quick|rust|extensions|sdks|external-pgrx|full]`: + native-only liboliphaunt product validation. This is the preferred iteration lane + for the new product track because it avoids the WASIX release lane, exports the + local `target/liboliphaunt-pg18` runtime for Rust/Swift/Kotlin/RN tests, and only + runs `src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh` when native artifacts are + missing. Extension/full modes first call the build script's no-build + `--check-extension-artifacts-current` freshness probe; they only enter the + normal build path when the stamped extension fingerprint or required artifacts + are stale or absent. The readiness check consumes + `src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --print-required-extension-artifacts`, + so it validates the complete artifact inventory used by the build instead of a + sample subset. Set + `OLIPHAUNT_TRACK_BUILD=never` to fail immediately instead of building, + `missing` to build only absent or stale required artifacts, or `always` for a + deliberate rebuild; +- `tools/perf/check-native-perf-harness.sh`: fast no-build guard + proving the native perf script plans direct/broker/server/native-PostgreSQL + work with explicit `perf-runner support, not the legacy WASIX lane; +- `moon run oliphaunt-rust:check`: Rust SDK tests and SDK shape checks for + `oliphaunt`, the Tauri/Rust desktop SDK. This command reuses an + existing `target/liboliphaunt-pg18` runtime only when the matching + liboliphaunt dylib, PostgreSQL tools, normal extension files, and embedded + extension modules are all present. That keeps env-gated native SQL and + opt-in extension tests from running against a partial native runtime; +- `tools/policy/check-sdk-parity.sh`: fast ownership guard that checks Rust, + Swift, Kotlin, and React Native docs/package boundaries stay aligned; +- `moon run oliphaunt-swift:check`: SwiftPM tests plus an iOS simulator + build when Xcode is available; +- `moon run oliphaunt-swift:smoke`: fast PostgreSQL 18 iOS + simulator compile probe for the embedded patch touchpoints. It reuses a + stamped cross-configured source tree and compiles only the backend objects + that carry liboliphaunt host I/O, lifecycle, static-extension, and mobile shell + command changes; +- `moon run oliphaunt-swift:package`: validates the Swift source package + shape without building platform release artifacts; +- `moon run liboliphaunt-native:build-ios-xcframework`: explicitly builds and + freshness-checks iOS simulator and device `liboliphaunt.dylib` slices from + the same PostgreSQL 18 patch stack, then packages them as + `liboliphaunt.xcframework`; +- `moon run oliphaunt-kotlin:smoke`: builds and + freshness-checks the Android arm64 `liboliphaunt.so` artifact used by the + Android SDK and React Native smoke lane; +- `moon run oliphaunt-kotlin:check`: Kotlin Multiplatform checks, + including native cinterop tests on the host platform; +- `moon run oliphaunt-react-native:smoke-android`: Android React Native + installed-app harness over the Expo development-client sample; +- `moon run oliphaunt-react-native:smoke-ios`: iOS React Native + installed-app harness over the Expo development-client sample; +- `moon run oliphaunt-react-native:check`: React Native TypeScript, + Codegen, packaging, and native source checks; +- `moon run oliphaunt-react-native:smoke-mobile`: aggregate local Expo + development-client installed-app lane. It runs both platform-specific smokes + against the packed SDK and real native artifacts; +- `pnpm --dir src/sdks/react-native/examples/expo run smoke:android`: real Android Expo + development-client smoke for the installed React Native package. It reuses + current native artifacts, generates the ignored Expo `android/` project only + when missing, packages `liboliphaunt.so` plus runtime/template resources, starts + Metro when needed, installs the app, and waits for + `OLIPHAUNT_EXPO_SMOKE_PASS`; +- `pnpm --dir src/sdks/react-native/examples/expo run smoke:ios`: real iOS Expo + development-client build/smoke harness for the installed React Native package. + For simulator builds it produces or reuses the current iOS simulator + `liboliphaunt.dylib` automatically when no explicit artifact override is set, + packages the same runtime/template resources, patches only the ignored + generated `ios/` Podfile for local Swift pods, rejects macOS dylibs, and can + run in `OLIPHAUNT_EXPO_IOS_BUILD_ONLY=1` mode when CoreSimulator is + unavailable; +- `tools/policy/check-crate-package.sh`: package all published crates and enforce + crates.io size limits; +- `tools/policy/check-feature-powerset.sh`: cargo-hack feature combination checks; +- `tools/policy/check-semver.sh`: cargo-semver-checks public API compatibility; +- `tools/policy/check-supply-chain.sh`: cargo-deny dependency policy checks; +- `moon run :check && moon run :test && moon run :package && moon run :coverage`: default PR parity lane; +- `moon run :check && moon run :test && moon run :smoke`: fast contributor lane for repo, lint, source + tests, and examples; +- `moon run :regression`: broader SQL, protocol, extension, and runtime regression suites; +- `tools/release/release.py publish-dry-run --wasm`: release-workspace package checks plus publish + dry-runs for internal crates after CI-generated AOT artifacts have been + downloaded. + +Moon caches deterministic task results when their declared source inputs and +task dependencies have not changed. Local `:smoke` targets use `cache: local`, +so repeated `moon run :smoke` runs can return a cached result for the same source +graph. Use `moon run :smoke --cache off` when you need a live +device, simulator, or runtime probe regardless of the cache. Generated report +aggregates, such as `repo:coverage`, depend on upstream task outputs with Moon +2.3 `cacheStrategy: outputs`, so downstream cache invalidation follows the +artifact contract instead of every private upstream source edit. + +Kotlin and React Native Android SDK validation uses Gradle's configuration +cache by default so repeated local runs do not reconfigure the same Android/KMP +graphs. Set `OLIPHAUNT_GRADLE_CONFIGURATION_CACHE=0` only when diagnosing +Gradle configuration-cache behavior itself. + +The hook split is intentionally small: + +- pre-commit: file hygiene and formatting +- release readiness: `tools/policy/check-rust-lint.sh`, + `tools/policy/check-rust-test-topology.sh`, and + `tools/policy/check-wasm-artifacts.sh` +- CI/release: path-aware combinations of the same validation modes, workflow + linting, feature powerset, public API compatibility, crate packaging, + native AOT runtime tests, Cargo publish dry-runs, and supply-chain + policy + +Install local hooks and pinned CLI tools when needed. The bootstrap installs +`cargo-binstall` first and uses binary installs for Rust tools before falling +back to source builds. + +```sh +tools/dev/bootstrap-tools.sh +tools/dev/install-hooks.sh +``` + +`src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs` starts the real WASM backend and +is intentionally slower than the protocol unit tests. + +## Maintenance Utilities + +The repository includes maintenance commands: + +- `oliphaunt-wasix-dump` is the logical dump CLI entry point. +- `oliphaunt-wasix-proxy` exposes a local PostgreSQL socket backed by the embedded + runtime. +- `xtask assets template` generates the architecture-independent PGDATA + template from the split WASIX `initdb` module. Portable WASIX, PGDATA + templates, and native AOT payloads remain generated-only. + +Asset and source checks: + +```sh +cargo run -p xtask -- assets verify-committed +cargo run -p xtask -- assets fetch +cargo run -p xtask -- assets check --strict-local +cargo run -p xtask -- assets check --strict-generated +cargo run -p xtask --features template-runner -- assets template +cargo run -p xtask -- assets source-spine --check-patch-applies +cargo run -p xtask -- assets audit-upstream --strict +cargo run -p xtask -- assets input-fingerprint --write +cargo run -p xtask -- package-size --enforce +``` + +## Local Runtime Development + +Local development has three supported modes. + +Fast contributor mode does not require Docker, upstream source checkouts, or +generated native AOT payloads. Use it for ordinary Rust, docs, tests, examples, +and workflow edits: + +```sh +moon run :check && moon run :test && moon run :smoke +cargo check --workspace --all-targets +cargo test --workspace --no-default-features +``` + +For the shortest source-only path, use: + +```sh +moon run :check && moon run :test +``` + +For native liboliphaunt work, prefer the native-only track. It keeps the C ABI, +Rust SDK, Swift/Kotlin/React Native SDK package lanes, extension matrix, and +local runtime smoke tests separated from the legacy WASIX release machinery: + +```sh +moon run liboliphaunt-native:test +moon run liboliphaunt-native:test +src/runtimes/liboliphaunt/native/tools/check-track.sh quick +src/runtimes/liboliphaunt/native/tools/check-track.sh sdks +src/runtimes/liboliphaunt/native/tools/check-track.sh full +``` + +`native-dev` is the normal inner loop for native code and docs: it uses the +Rust-only native validation lane, verifies SDK ownership/parity, and checks +whitespace without compiling the legacy product lane. `native-dev-no-build` +uses the same checks with `OLIPHAUNT_TRACK_BUILD=never`; use it when the local +runtime should already exist and an unexpected rebuild would hide iteration +cost. `quick` adds the C smoke: +it reuses `target/liboliphaunt-pg18` when present, runs the C smoke, and runs the +Rust native SDK tests. `rust` skips the C smoke but still exports or creates the +native runtime before Rust env-gated tests, so it is the faster Rust-only native +validation lane. `moon run oliphaunt-rust:regression` uses the basic native +runtime and runs SQL/protocol regression across direct, broker, and server mode. +`moon run oliphaunt-rust:extension-regression` is the separate +extension-artifact lane; it depends on `liboliphaunt-native:release-check` and is +intentionally not part of normal PR CI. `extensions` and `full` use the build +script's no-build extension freshness probe before running the matrix, which avoids both +unnecessary rebuilds and the failure mode where a core-only runtime is +accidentally treated as extension ready. `sdks` validates SDK ownership/parity, +then runs the Rust, Swift, Kotlin, and React Native package checks. See +[`docs/maintainers/sdk-parity-policy.md`](../../docs/maintainers/sdk-parity-policy.md) for the SDK ownership contract. `full` enables +native extension artifacts and the extension matrix in addition to the SDK +checks. Use +`OLIPHAUNT_TRACK_BUILD=never` when you want to prove the harness is not +rebuilding anything. + +Host-platform artifact mode is for runtime work on the current machine. It +builds or packages only the current host target, leaves all generated payloads +in ignored paths, and then runs the real runtime tests: + +```sh +host="$(rustc -vV | awk '/^host:/{print $2}')" +cargo run -p xtask -- assets fetch +cargo run -p xtask --features aot-serializer -- assets build-host +moon run liboliphaunt-wasix:smoke +``` + +Local AOT generation requires the Wasmer LLVM 22.1.x build for the +maintainer-only serializer. That build includes the LLVM target set Wasmer's +LLVM backend expects, including LoongArch and WebAssembly. Set +`LLVM_SYS_221_PREFIX` to an extracted +`wasmerio/llvm-custom-builds` 22.x archive, or use downloaded-artifact mode to +avoid local LLVM setup. + +When the portable WASIX assets are already current and only the host AOT crate +needs to be refreshed, skip the source/Docker build and generate host AOT from +the existing generated portable assets: + +```sh +host="$(rustc -vV | awk '/^host:/{print $2}')" +cargo run -p xtask -- assets aot --target-triple "$host" +cargo run -p xtask -- assets package-aot --target-triple "$host" +moon run liboliphaunt-wasix:smoke +``` + +Downloaded-artifact mode is the intended way to test a CI-produced runtime +locally without rebuilding Postgres/WASIX. Download the successful `Checks` +workflow runtime artifacts for the exact commit and install the host target +payloads into the same ignored generated locations used by the local build path: + +```sh +host="$(rustc -vV | awk '/^host:/{print $2}')" +cargo run -p xtask -- assets download --sha --target-triple "$host" +moon run liboliphaunt-wasix:smoke +``` + +For Rust-only work where the asset inputs have not changed, the same command +can install the latest compatible `main` bundle after verifying the +asset-input fingerprint: + +```sh +host="$(rustc -vV | awk '/^host:/{print $2}')" +cargo run -p xtask -- assets download --latest-compatible --target-triple "$host" +moon run liboliphaunt-wasix:smoke +``` + +Released artifact bundles can be installed without the GitHub CLI because they +are public GitHub release assets: + +```sh +host="$(rustc -vV | awk '/^host:/{print $2}')" +cargo run -p xtask -- assets download --release --target-triple "$host" +moon run liboliphaunt-wasix:smoke +``` + +Release validation can download every supported target from the exact `Checks` +workflow SHA: + +```sh +cargo run -p xtask -- assets download --sha --all-targets +tools/release/release.py publish-dry-run --wasm +``` + +Developers should not be expected to build every target locally. Local runtime +work validates the host target; the `Checks` workflow's WASM runtime/AOT lane is +the authority for the full macOS, Linux, and Windows AOT matrix. + +Contributors do not need upstream source checkouts for normal Rust, docs, +examples, or package validation. Maintainers fetch sources only when rebuilding +the portable WASIX runtime, extensions, `initdb`, `pg_dump`, or the generated +PGDATA template. Portable WASIX artifacts, generated PGDATA templates, and +native AOT artifacts are generated under `target/oliphaunt-wasix/**` locally or by +CI; they are not committed to git. + +Rust-only PRs download the latest compatible `Checks` workflow bundle, verify its +asset-input fingerprint, install it into ignored generated paths, and run the +runtime test suite on every supported host target. The `Checks` pull-request +job uses Moon affectedness over `postgres18`, `third-party`, +`source-toolchains`, `extensions`, and `oliphaunt-wasix:release-check`, plus a small producer path +allowlist, to decide whether the expensive asset build is required. Non-asset +PRs become an explicit no-op after source-controlled input checks. +Asset-producing PRs verify source pins, the committed asset-input fingerprint, +extension catalog metadata, generated metadata policy, and then run the full +portable/AOT producer workflow before merge. `main`, scheduled runs, and +explicit maintainer dispatches remain trusted producer lanes for release +artifacts. + +Release process details are tracked in [release.md](release.md). +Maintainer-only progress notes live under `docs/internal/`; completed +implementation work is summarized in [DONE.md](internal/DONE.md), and the +implementation backlog is tracked in [TODO.md](internal/TODO.md). diff --git a/docs/maintainers/extension-packaging-policy.md b/docs/maintainers/extension-packaging-policy.md new file mode 100644 index 00000000..516031af --- /dev/null +++ b/docs/maintainers/extension-packaging-policy.md @@ -0,0 +1,388 @@ +# Extensions + +Oliphaunt uses exact, opt-in PostgreSQL extension selection. App developers +select the SQL extension names they intend to ship, and the generated runtime +assets contain only those extension assets plus mandatory manifest +dependencies. `vector` means the PostgreSQL SQL extension named `vector`. +There is no selector expansion, alias, shorthand, or release selector that +expands to multiple extensions. Names such as `core`, `search`, or `geo` are +not Oliphaunt catalog entries or release units. A name is selectable only when +it is an exact PostgreSQL extension name from the built-in catalog or a +verified external artifact index. + +## Rust + +The release invariant is strict: generated app resources must contain only the +selected exact extensions plus mandatory manifest dependencies. + +```rust,no_run +use oliphaunt::{Extension, Oliphaunt}; + +# async fn demo() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .temporary() + .native_direct() + .extension(Extension::Vector) + .open() + .await?; + +db.execute("CREATE EXTENSION vector").await?; +db.close().await?; +# Ok(()) +# } +``` + +The same rule applies to package tooling: + +```sh +cargo run -p oliphaunt --bin oliphaunt-resources -- \ + --output target/oliphaunt-resources \ + --extension vector \ + --force +``` + +Selecting `vector` ships `vector`. It must not ship `hstore`, `pg_trgm`, +`cube`, `earthdistance`, pgGraph, ParadeDB, or any other unselected extension. +The only exception is a mandatory dependency declared by +`NATIVE_EXTENSION_MANIFEST`; for example `earthdistance` includes `cube`. + +End developers should not have to build PostgreSQL or extension source to know +what they can ship. The runtime-resource CLI exposes the release-ready prebuilt +catalog without requiring a local native build: + +```sh +cargo run -p oliphaunt --bin oliphaunt-resources -- --list-extensions +``` + +The catalog is TSV so CI, SwiftPM plugins, Gradle tasks, Expo config plugins, +and release automation can consume it directly. `desktop_prebuilt=yes` means +the extension is available for Rust/Tauri, macOS, Linux, and desktop resource +artifacts from Oliphaunt release artifacts. `mobile_prebuilt=yes` means iOS and +Android apps can include the extension from Oliphaunt prebuilt mobile artifacts +without compiling extension source. `mobile_prebuilt=no` is a hard release +boundary, not a hint to make app developers compile source locally. + +## Prebuilt Third-Party Artifacts + +The open-ended extension path is also exact-name based. A third-party +extension is selected by passing a prebuilt artifact directory or archive, not +by compiling source inside the app project: + +```sh +cargo run -p oliphaunt --bin oliphaunt-resources -- \ + --output target/oliphaunt-resources \ + --extension vector \ + --prebuilt-extension vendor/acme_ext.tar.zst \ + --force +``` + +Artifacts are produced from already-built PostgreSQL runtime files with the +Rust SDK artifact tool: + +```sh +cargo run -p oliphaunt --bin oliphaunt-extension-artifact -- \ + --runtime target/acme-pg18-runtime/files \ + --sql-name acme_ext \ + --native-module-stem acme_ext \ + --native-module-file acme_ext.so \ + --data-file data/acme_ext.rules \ + --output vendor/acme_ext.tar.zst \ + --format tar-zst \ + --force +``` + +That command copies exact runtime files into the artifact. It does not build +PostgreSQL or extension source. The producer and consumer share the same schema +validation, so the generated artifact is immediately consumable by +`oliphaunt-resources --prebuilt-extension`. + +For release distribution, publish an exact artifact index next to the binary +artifacts: + +```sh +cargo run -p oliphaunt --bin oliphaunt-extension-index -- \ + --output vendor/oliphaunt-extensions.toml \ + --target macos-arm64 \ + --artifact vendor/acme_ext-macos-arm64.tar.zst \ + --base-url https://cdn.example.com/oliphaunt/extensions/macos-arm64 \ + --signing-key-file acme-release-2026q2:keys/acme-extension-index.ed25519 \ + --force +``` + +The index producer validates each artifact manifest, rejects built-in extension +name overrides, computes byte counts and SHA-256 digests, and records relative +artifact paths plus catalog metadata such as dependencies, native module stem, +preload requirements, and mobile-prebuilt readiness. That metadata lets app +tooling list exact external extension names from the index without downloading +or building extension source. `--base-url` additionally records a URL for each +exact artifact row so release tooling can fetch missing artifacts into a cache +before verification. Release indexes should also publish a detached Ed25519 +sidecar signature at `.sig`; `--signing-key-file :` signs +the exact index bytes after writing the TOML. The signing key file contains a +hex-encoded 32-byte Ed25519 signing key. + +```toml +schema = "oliphaunt-extension-artifact-index-v1" +pg_major = 18 + +[[artifacts]] +sql_name = "acme_ext" +target = "macos-arm64" +creates_extension = true +native_module_stem = "acme_ext" +dependencies = [] +shared_preload_libraries = [] +mobile_prebuilt = true +mobile_static_archive_targets = ["ios-simulator", "ios-device", "arm64-v8a"] +path = "acme_ext-macos-arm64.tar.zst" +url = "https://cdn.example.com/oliphaunt/extensions/macos-arm64/acme_ext-macos-arm64.tar.zst" +sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +bytes = 123456 +``` + +Developers can inspect built-in plus signed external availability without a +native build: + +```sh +cargo run -p oliphaunt --bin oliphaunt-resources -- \ + --list-extensions \ + --extension-index vendor/oliphaunt-extensions.toml \ + --extension-target macos-arm64 \ + --trusted-extension-index-key-file acme-release-2026q2:keys/acme-extension-index.ed25519.pub +``` + +Then app/package tooling can select the external extension by exact SQL name: + +```sh +cargo run -p oliphaunt --bin oliphaunt-resources -- \ + --output target/oliphaunt-resources \ + --extension acme_ext \ + --extension-index vendor/oliphaunt-extensions.toml \ + --extension-target macos-arm64 \ + --extension-cache ~/.cache/oliphaunt/extensions \ + --trusted-extension-index-key-file acme-release-2026q2:keys/acme-extension-index.ed25519.pub \ + --force +``` + +`oliphaunt-resources` verifies the artifact byte count, SHA-256 digest, PG major, +target, and artifact manifest before consuming it. It also follows exact +extension dependencies from the index. Built-in release-ready extension names +cannot be overridden by index entries. Local sidecar artifacts next to the index +are preferred. If a URL-backed artifact is missing locally, `--extension-cache` +downloads it to a target-scoped cache and verifies bytes, SHA-256, and manifest +before packaging. HTTPS artifact downloads are a packaging-tool capability; Rust +SDK release binaries enable the `extension-download` feature, while the embedded +library remains usable without HTTP/TLS dependencies. Signed index verification +uses `--trusted-extension-index-key-file :`, which requires a +matching `.sig` sidecar before any indexed artifact can be used. The key +file contains a hex-encoded 32-byte Ed25519 public key. Signing and verification +are packaging-tool capabilities behind the `extension-signing` feature, so +embedded Rust/Tauri apps do not compile signing code unless they opt into it. + +`--prebuilt-extension` accepts an unpacked artifact directory, `.tar`, or +`.tar.zst`. The artifact root must contain `manifest.properties` plus a +`files/` runtime tree: + +```properties +packageLayout=oliphaunt-extension-artifact-v1 +pgMajor=18 +sqlName=acme_ext +createsExtension=true +nativeModuleStem=acme_ext +nativeModuleFile=acme_ext.so +dependencies= +dataFiles= +sharedPreloadLibraries= +mobilePrebuilt=yes +mobileStaticArchives=ios-simulator:mobile-static/ios-simulator/extensions/acme_ext/liboliphaunt_extension_acme_ext.a,ios-device:mobile-static/ios-device/extensions/acme_ext/liboliphaunt_extension_acme_ext.a,arm64-v8a:mobile-static/arm64-v8a/extensions/acme_ext/liboliphaunt_extension_acme_ext.a +mobileStaticDependencyArchives=ios-simulator:openssl:mobile-static/ios-simulator/dependencies/openssl/libcrypto.a,ios-device:openssl:mobile-static/ios-device/dependencies/openssl/libcrypto.a,arm64-v8a:openssl:mobile-static/arm64-v8a/dependencies/openssl/libcrypto.a +staticSymbolPrefix=acme_static +files=files +``` + +`files/` mirrors PostgreSQL runtime paths, for example +`files/share/postgresql/extension/acme_ext.control`, +`files/share/postgresql/extension/acme_ext--1.0.sql`, and +`files/lib/postgresql/acme_ext.dylib` on macOS. The runtime-resource generator copies only files +declared by the exact selected extension: matching control/SQL files, declared +`dataFiles`, and the declared native module. Extra files in the artifact are +ignored. A prebuilt artifact cannot override a built-in release-ready extension +name. Dependencies are exact extension names and must resolve either to the +built-in catalog or to another provided prebuilt artifact. + +For mobile, `mobilePrebuilt=yes` on a native-module artifact means the artifact +itself carries matching prebuilt static archives in `mobileStaticArchives`. +The runtime-resource generator copies only selected archives into +`static-registry/archives//extensions//`. Dependency-backed +mobile artifacts can also carry `mobileStaticDependencyArchives` entries, which +the runtime-resource generator copies into +`static-registry/archives//dependencies//`. Android SDK builds +link those dependency archives when present, and the iOS packaging helper emits +matching `liboliphaunt_dependency_.xcframework` outputs for Swift and +React Native CocoaPods consumers. The generated static-registry source uses +`staticSymbolPrefix` when present; missing selected archives remain build/link +errors. + +## Runtime Resources + +The Rust SDK owns the runtime-resource CLI and manifest contract. + +Runtime resources are shared by Swift, Kotlin, and React Native: + +```text +oliphaunt/ + runtime/ + manifest.properties + files/ + lib/postgresql/ + share/postgresql/ + template-pgdata/ + manifest.properties + files/ + PG_VERSION + package-size.tsv +``` + +The runtime manifest records exact extension names: + +```properties +schema=oliphaunt-runtime-resources-v1 +layout=postgres-runtime-files-v1 +extensions=vector +sharedPreloadLibraries= +mobileStaticRegistryState=complete +mobileStaticRegistryRegistered=vector +mobileStaticRegistryPending= +nativeModuleStems=vector +``` + +The manifest records exact extension names only. It has no selection alias, +provenance row, custom alias manifest, or catalog field that expands to multiple +extensions. +SDKs reject `open(... extensions: ["vector"])` when the selected runtime does +not advertise `vector`. + +The size report is exact-extension based: + +```text +kind id extensions files bytes +package total - 42 123456 +package runtime - 30 100000 +package template-pgdata - 10 20000 +package static-registry - 2 3456 +extensions selected - 3 63478 +extension vector - 3 63478 +``` + +Swift reads this through `OliphauntRuntimeResources.packageSizeReport()`; +Kotlin reads packaged app assets through +`OliphauntAndroid.packageSizeReport(context)` and unpacked smoke roots through +`OliphauntAndroid.packageSizeReport(resourceRoot)`; React Native delegates +`Oliphaunt.packageSizeReport(...)` to those platform SDK readers. + +## Mobile Static Registry + +iOS and Android cannot rely on arbitrary dynamic extension loading. A mobile +release package that includes module-backed extensions must also include and +register a matching static extension registry: + +```sh +cargo run -p oliphaunt --bin oliphaunt-resources -- \ + --output target/oliphaunt-resources \ + --extension vector \ + --mobile-static-module vector \ + --require-mobile-static-registry \ + --force +``` + +`--mobile-static-module` is an assertion that the platform build actually links +the selected module. Unknown or unselected stems fail the package build. +Mobile native build lanes emit one prebuilt archive per selected module at +`out/extensions//liboliphaunt_extension_.a`, so release packaging +can link only the extensions the app selected. +Android SDK builds first consume selected archives carried by the resource +package under `static-registry/archives`; `-PoliphauntAndroidExtensionArchivesDir=` +is the first-party build-output override. The Gradle/CMake build produces an +app-local `liboliphaunt_extensions.so` support library from prebuilt extension +objects plus generated registry glue. That build step links binary artifacts +only; it does not compile PostgreSQL or extension source in the app project. +The iOS XCFramework runtime-resource generator accepts the same Rust runtime-resource output via +`--runtime-resources ` and derives `nativeModuleStems` from +`runtime/manifest.properties`; it uses carried `ios-simulator`/`ios-device` +archives when present and otherwise falls back to first-party build outputs. +There is still only one extension selection list. +The generated registry source deliberately uses strong references for selected +extension magic and SQL entry points. A missing selected prebuilt archive must +fail the app build or link, not degrade into a late runtime `CREATE EXTENSION` +failure. + +## Manifest + +`NATIVE_EXTENSION_MANIFEST` is the PG18 source of truth. Each row records: + +- SQL extension name; +- required control, SQL, data, and native module assets; +- mandatory extension dependencies; +- smoke SQL strategy; +- direct, broker, and server coverage expectation; +- mobile static-link status; +- first-party or external artifact policy. + +`Extension::FIRST_PARTY_PG18_SUPPORTED` is the exact inventory of first-party +PG18 rows known to the native SDK. It is not a shipping promise. + +`Extension::RELEASE_READY_PG18_SUPPORTED` is the desktop native exact-extension +catalog for release packages. A row enters this catalog only when its native +desktop target is supported or, for PostgreSQL contrib-style rows without +separate target metadata, the generated catalog marks it promoted and stable. +Rows can be first-party inventory without being desktop release-ready. PostGIS +is target-specific rather than a blanket exception: native desktop, mobile, and +WASIX readiness are controlled by their target metadata and build recipes. +PostGIS mobile metadata is target-owned: a mobile row remains candidate until the selected iOS and Android static +dependency archives, hash-dependency sets, runtime data, and smoke evidence are +present. + +External candidates such as pgGraph and ParadeDB remain internal metadata until +they have pinned artifacts, redistribution clearance, and direct, broker, +server, restart, backup, restore, and mobile static-registry evidence. + +`Extension::MOBILE_RELEASE_READY_PG18_SUPPORTED` is the mobile exact-extension +catalog. Release readiness is target-specific: mobile can intentionally be +smaller than desktop native or WASIX support when static archives, dependency +archives, runtime data, or mobile smoke evidence are incomplete. The +runtime-resource CLI rejects attempts to mark a non-mobile-ready module as +complete with `--mobile-static-module`; that prevents apps from shipping a +manifest that claims an extension is linked when the prebuilt mobile artifact +does not exist. + +`pgcrypto` is mobile-prebuilt through the first-party OpenSSL for `pgcrypto` +static `libcrypto` archive. The Windows native producer also builds the pinned +OpenSSL checkout and links `pgcrypto` against the staged static `libcrypto`. +`uuid-ossp` is mobile-prebuilt through the first-party portable UUID static +`libuuid` archive. The Windows native producer links the same portable UUID +source directly into the `uuid-ossp` module and installs the matching +control/SQL files. The Windows native PostGIS producer builds the pinned +GEOS, PROJ, SQLite, json-c, and libxml2 dependency stack, links the generated +`postgis-3` module against those static archives, and stages the matching +extension SQL plus `proj/proj.db`. + +## Target-Specific PG18.4 Readiness + +The generated catalog is the local source of truth for Oliphaunt-compatible +PG18.4 extension metadata, but release readiness is target-specific. WASIX, +native desktop, and mobile can move independently when their build recipes, +artifacts, smoke evidence, or platform constraints differ. The invariant is +strict: a public selection surface may advertise only the exact extensions that +the selected target can actually package and run. + +Oliphaunt-listed extensions that are not stable stay out of every release-ready +catalog until their PG18.4 blockers are gone. The only current non-stable row is +Apache AGE, because the tracked source still calls PostgreSQL APIs removed in +PG18. PostgreSQL 18.4 can build `uuid-ossp` only with +`--with-uuid=bsd`, `--with-uuid=e2fs`, or `--with-uuid=ossp`. Oliphaunt carries +a first-party portable UUID compatibility source for the e2fs API under +`src/runtimes/liboliphaunt/native/portable-uuid`; the WASIX, Linux/macOS native, +iOS, Android, and Windows native build scripts compile and link it for +`uuid-ossp`. `uuid-ossp` is stable in the generated WASIX plan; WASIX side-module builds and packages with matching archive +and module hashes, has host AOT metadata, and has direct, server, restart, and +dump-restore smoke evidence recorded for the package. diff --git a/docs/maintainers/mobile-stability-model.md b/docs/maintainers/mobile-stability-model.md new file mode 100644 index 00000000..932b6c00 --- /dev/null +++ b/docs/maintainers/mobile-stability-model.md @@ -0,0 +1,167 @@ +# Mobile Stability Model + +This document is the stability contract for the Swift, Kotlin, and React Native +SDKs over `liboliphaunt`. + +The deeper iOS process-model investigation is captured in +`docs/architecture/ios.md`. That document is the source of +truth for why iOS direct mode is the universal fast path and why any robust iOS +broker must be an ExtensionFoundation/AppExtensionProcess design rather than a +normal helper daemon. + +## Current Truth + +`NativeDirect` embeds one PostgreSQL backend in the host app process. Swift and +Kotlin serialize all direct calls through one actor/owner dispatcher. React +Native delegates to those SDKs and uses a New Architecture JSI ArrayBuffer +transport for protocol bytes. + +This is fast, but it is not process isolated. A PostgreSQL `ERROR`, protocol +error, cancellation, or controlled `proc_exit` path can be surfaced as an SDK +error. A native crash such as memory corruption, abort, or unhandled signal is a +host-process crash in direct mode. + +Direct mode's reliability claim is crash consistency, not crash isolation. If +the host process dies, the next app launch must reopen the same root and let +PostgreSQL perform WAL recovery. Direct mode must not be documented or surfaced +as host-app-survivable after native PostgreSQL crashes. + +`close()` is a logical detach in mobile direct mode. It releases the SDK handle, +rolls back any active transaction, runs `DISCARD ALL`, and keeps the resident +backend alive so the same root can be reopened in the same process. It is not a +full PostgreSQL shutdown. Direct mode cannot switch to a different root after +that resident backend exists. + +The SDK should make this resident-runtime model explicit with an app-scope +manager/container. Developers should not need to discover by accident that +`close()` does not make the process reusable for another root. + +Temporary direct roots are therefore process-resident too. The SDKs now reuse one +process-lifetime temporary root so `open(temporary)`, `close()`, and +`open(temporary)` do not accidentally ask the C ABI to switch roots. + +React Native `protocolStream` means true chunked native streaming through JSI. +If the installed JSI transport only has owned-response `execProtocolRaw`, +`execProtocolStream(...)` remains callable as a fallback but reports +`protocolStream=false` and emits one owned response chunk. + +## Platform Constraints + +React Native New Architecture is the correct JS/native boundary. The official +architecture replaces the old asynchronous bridge with JSI and allows JS to hold +references to native objects without serialization costs for database-like +objects. TurboModule Codegen remains the typed lifecycle/control surface; bulk +bytes should stay on JSI. + +Android can support a real mobile broker. A bound service is explicitly designed +for long-lived interaction over `IBinder`, and Android services can be declared +with a separate `android:process`. That gives us a credible crash-isolated +database process for Android apps. + +iOS does not have the same general app-owned daemon model. Normal apps receive +only a short background window before suspension. App extensions do run in +separate processes, and ExtensionFoundation exposes host-launched app-extension +processes with XPC connections on iOS 26+, but this is not the same thing as a +macOS helper service or Android service process. It likely starts as a +single-root broker because one app-extension identity maps to one running +process and the embedded PostgreSQL runtime is still process-wide. Until that +feasibility track passes on real devices, iOS direct mode must be honest: fast +and ergonomic, not crash isolated. + +PostgreSQL's normal robustness assumes a supervisor process and WAL recovery. In +server mode, an immediate shutdown or crash leads to WAL replay on restart. In +direct mode there is no external supervisor around the embedded backend thread; +only a broker/server process can make backend death survivable for the app +process. + +## Product Direction + +### iOS + +Default to `NativeDirect` for the first shippable iOS SDK. Make the contract +explicit: + +- one resident backend per app process; +- one physical session; +- serialized requests; +- same-root logical reopen only; +- no true independent concurrent sessions; +- no crash isolation; +- backgrounding is handled by checkpoint/cancel/close guidance, not by keeping + arbitrary work alive while suspended. + +The robust iOS direction is an opt-in `NativeExtensionBroker` built on +ExtensionFoundation/AppExtensionProcess only if device/App Store testing proves +the extension lifecycle, crash/reconnect behavior, memory ceiling, background +behavior, App Group storage model, and XPC throughput are acceptable. If that +feasibility fails, iOS remains direct plus server unavailable. Even if it +succeeds, it must not advertise desktop-style multi-root broker semantics until +worker multiplicity is proven. + +The product must fail closed here: if the iOS broker extension target, App Group, +minimum OS, or device evidence is missing, `NativeExtensionBroker` is +unavailable. It must not silently alias to direct mode. + +### Android + +Add `NativeBroker` as the recommended robust Android mode: + +- a bound service in `:liboliphaunt` owns roots and direct C ABI handles; +- app/RN process talks to it over Binder; +- one worker per root, serialized per root; +- service crash is observed through binder death, then the SDK reconnects and + reopens the root after WAL recovery; +- in-flight requests fail with unknown commit state unless the SDK has an + explicit idempotent replay envelope; +- `NativeServer` is separate and only for true PostgreSQL client sessions. + +The direct Android mode remains the fastest single-session path and the fallback +where apps do not want a service process. + +### React Native + +React Native should remain an adapter over Swift/Kotlin, but the hot path should +eventually move one layer lower: + +- TurboModule Codegen for typed lifecycle, capabilities, open, close, cancel, + backup/restore metadata, and package-size reporting; +- JSI HostObject/ArrayBuffer for database handles and protocol bytes; +- native chunked JSI streaming for large protocol responses; +- no base64 and no bridge byte transport; +- no private RN database runtime divergent from Swift/Kotlin semantics. + +On Android broker, RN should talk to Kotlin broker handles. On iOS direct, RN +talks to Swift direct handles. If an iOS broker becomes viable, RN should inherit +it through Swift. + +## Required Follow-Up Changes + +1. Add Android `NativeBroker` with a remote-process bound service and binder + death/reconnect tests. +2. Run an iOS 26+ ExtensionFoundation broker feasibility spike on real devices + and document whether App Store-safe single-root process isolation is viable. +3. Add mobile crash drills: direct-mode controlled backend exit, Android broker + service kill/reconnect, app background/foreground with long query + cancellation, and WAL recovery after process death. +4. Keep lifecycle APIs around foreground/background transitions covered on every + SDK surface: `prepareForBackground`, `resumeFromBackground`, bounded + checkpoint, and explicit cancellation policy. + +## References + +- React Native New Architecture: https://reactnative.dev/architecture/landing-page +- React Native Turbo Native Modules: https://reactnative.dev/docs/turbo-native-modules-android +- Android services and bound services: + https://developer.android.com/develop/background-work/services +- Android service manifest attributes: + https://developer.android.com/guide/topics/manifest/service-element +- Apple background execution: + https://developer.apple.com/documentation/uikit/extending-your-app-s-background-execution-time +- Apple ExtensionFoundation: + https://developer.apple.com/documentation/extensionfoundation +- Apple AppExtensionProcess: + https://developer.apple.com/documentation/ExtensionFoundation/AppExtensionProcess +- PostgreSQL server shutdown and recovery: + https://www.postgresql.org/docs/current/server-shutdown.html +- PostgreSQL WAL: + https://www.postgresql.org/docs/current/wal-intro.html diff --git a/docs/maintainers/native-runtime-contract.md b/docs/maintainers/native-runtime-contract.md new file mode 100644 index 00000000..4b761b9a --- /dev/null +++ b/docs/maintainers/native-runtime-contract.md @@ -0,0 +1,191 @@ +# Native Runtime Guide + +This guide describes the native `oliphaunt` Rust SDK and `liboliphaunt` +runtime. WASIX runtime behavior is documented separately in +[`WASM runtime`](/sdk/wasm/runtime). + +## Choose A Mode + +`NativeDirect` is the lowest-latency embedded mode. It loads `liboliphaunt` in +the host process and owns one resident PostgreSQL backend for the process +lifetime. + +Use it when the Rust SDK owns the database calls and the application wants one +fast embedded PostgreSQL session: + +```rust,no_run +use oliphaunt::Oliphaunt; + +# async fn open_direct() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .open() + .await?; + +let rows = db.query("SELECT 1::text AS value").await?; +assert_eq!(rows.get_text(0, "value")?, Some("1")); + +db.close().await?; +# Ok(()) +# } +``` + +`NativeBroker` runs the same direct engine in a helper process. It is the robust +desktop/app mode for process isolation and multiple roots managed by one Rust +SDK runtime. Each broker-owned root still has one serialized physical +PostgreSQL backend session. + +Use it when process isolation and multi-root ownership matter more than absolute +minimum call overhead: + +```rust,no_run +use oliphaunt::Oliphaunt; + +# async fn open_broker() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_broker() + .broker_max_roots(4) + .open() + .await?; + +db.execute("CREATE TABLE IF NOT EXISTS events(id bigint PRIMARY KEY)").await?; +db.close().await?; +# Ok(()) +# } +``` + +`NativeServer` starts a real local PostgreSQL-compatible server process. It is +the only SDK mode for independent client sessions, connection pools, `psql`, +`pg_dump`, ORMs, and libraries that expect a PostgreSQL connection string: + +```rust,no_run +use oliphaunt::Oliphaunt; + +# async fn open_server() -> oliphaunt::Result { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_server() + .max_client_sessions(8) + .open() + .await?; + +Ok(db.connection_string().expect("server mode exposes a URL").to_owned()) +# } +``` + +## Runtime Semantics + +The three modes are intentionally different. The SDK must not fake server +semantics in direct or broker mode. + +| Mode | Process model | Session model | Root model | Reopen/crash behavior | +| --- | --- | --- | --- | --- | +| `NativeDirect` | in-process | one serialized physical session | one resident root per process | same-root logical reopen only; no crash isolation | +| `NativeBroker` | helper process per active root | one serialized physical session per root | multiple roots bounded by `broker_max_roots` | helper crash can be restarted; app process remains alive | +| `NativeServer` | PostgreSQL server process | independent PostgreSQL client sessions | one server root per opened handle | use normal server restart/recovery flows | + +`Oliphaunt` is cloneable as an SDK handle. Clones share the same owner executor, +FIFO queue, session pin, cancellation handle, and close state. Cloning is not a connection pool. +Direct and broker mode reject `max_client_sessions` values other than `1`; +server mode is the independent-session mode. + +Transactions and explicit session pins reserve the single SDK-owned physical +session. Unpinned database work, backup, restore-adjacent work, and checkpoints +are rejected while a pin is active so direct and broker calls cannot interleave +inside one transaction-sensitive PostgreSQL session. + +## Direct Lifecycle + +Direct mode is process-resident: + +- one resident backend per process; +- one physical session; +- serialized requests through the SDK owner executor; +- one root per process after the resident backend exists; +- `close()` is a logical detach, not full PostgreSQL shutdown; +- reopening is same-root only inside the same process; +- native PostgreSQL crashes terminate the host process. + +The reliability contract is crash consistency, not crash isolation. If the host +process dies, the next launch reopens the same root and PostgreSQL performs WAL +recovery. Applications that need app-process survival after database-process +death should use broker/server modes where the target platform supports them. + +## Storage + +Native live storage is a PostgreSQL root directory, not a single file. A root +contains PGDATA, Oliphaunt metadata, lock metadata, extension metadata, and +recovery state. + +Persistent roots use exclusive locking in direct mode. Broker and server modes +own their roots through the helper/server process. A second unsafe owner fails +instead of sharing a data directory. + +Use SDK backup/restore APIs for ergonomic export/import: + +- direct and broker support same-version physical archives; +- server supports same-version physical archives and SQL dumps through packaged + PostgreSQL tooling; +- physical archives are for same-version restore, not cross-version upgrades. + +## Startup Configuration + +`OliphauntBuilder::runtime_footprint(...)` selects the startup footprint before +PostgreSQL starts: + +- `RuntimeFootprintProfile::Throughput`: throughput defaults; +- `RuntimeFootprintProfile::BalancedMobile`: lower slot counts, smaller shared + buffers/WAL footprint, and PG18 sync I/O for resident mobile apps; +- `RuntimeFootprintProfile::SmallMobile`: the smallest supported resident + profile for memory-pressure experiments. + +`OliphauntBuilder::startup_guc(name, value)` and `startup_gucs(...)` append +validated PostgreSQL `-c name=value` overrides after durability and footprint +profiles. Later overrides win, matching PostgreSQL startup behavior. Server mode +then appends its configured `max_connections` from `max_client_sessions(...)` +because independent session count is the server-mode contract. + +## Extensions + +Extensions are opt-in. Select exact PostgreSQL extension names before opening: + +```rust,no_run +use oliphaunt::{Extension, Oliphaunt}; + +# async fn open_with_vector() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .extension(Extension::Vector) + .open() + .await?; + +db.execute("CREATE EXTENSION IF NOT EXISTS vector").await?; +db.close().await?; +# Ok(()) +# } +``` + +`CREATE EXTENSION` succeeds only when the selected runtime resources contains the extension +assets and, on mobile, when the required static registry entries are present. +Desktop dynamic extension loading is a future capability and must not replace +the current selected-resource release lane until signed loading is implemented +and tested. + +## Capabilities + +Use capabilities instead of assuming a mode can do everything: + +- `session_concurrency` distinguishes serialized SDK sessions from independent + server sessions; +- `multi_root` is broker-only today; +- `same_root_logical_reopen`, `root_switchable`, and `crash_restartable` + describe lifecycle semantics explicitly; +- `backup_formats` and `restore_formats` gate backup/restore UI before work is + queued. + +Swift, Kotlin, and React Native expose the same product concepts with +platform-native naming. Unsupported platform modes should report explicit +unsupported reasons rather than aliasing to direct mode. diff --git a/docs/maintainers/performance-evidence.md b/docs/maintainers/performance-evidence.md new file mode 100644 index 00000000..198ea7d7 --- /dev/null +++ b/docs/maintainers/performance-evidence.md @@ -0,0 +1,642 @@ +# Performance + +`oliphaunt-wasix` is built to stay close to native Postgres while keeping the +database embedded in the Rust process. + +This page tracks the repo benchmark matrix. The main comparison uses SQLx on +each wire-protocol path: + +- native Postgres with SQLx; +- `oliphaunt-wasix + SQLx`; +- vanilla `@electric/wasm` persisted with NodeFS and reached through + `@electric/wasm-socket`, then measured with SQLx. + +The native `oliphaunt` track has its own matrix for PostgreSQL 18 direct, +broker, and server modes. That matrix is the release gate for the native SDK and +must be used before claiming native parity: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh +``` + +Native server mode keeps the public PostgreSQL-compatible TCP connection string, +but SDK-owned protocol traffic uses Unix-domain sockets on Unix by default. Set +`OLIPHAUNT_SERVER_SDK_TRANSPORT=tcp` only when explicitly diagnosing TCP +transport behavior. + +It records p50/p90/p95/p99 latency, suite totals, throughput, `/usr/bin/time` +CPU/RSS/footprint metrics, child-process RSS for broker/server modes, artifact +sizes, native PostgreSQL controls, a SQLite embedded speed control, +prepared-update rows, and backup/restore timings for native PostgreSQL, SQLite, +NativeDirect, NativeBroker, and NativeServer. The speed and backup/restore +sections report p50 elapsed time, p90 elapsed time, p95 elapsed time, median +throughput, tail throughput, p99 tail latency, native-PostgreSQL p90 ratios, +and command-level CPU/RSS/footprint p90/p99 so transport and persistence +regressions are visible without opening the raw JSON files. + +When NativeDirect misses a native PostgreSQL gate, the generated report includes +a `Native Direct Regression Diagnostics` section with the missed gate, the +matching focused matrix command, and a repeated speed-case diagnostic wrapper +that runs NativeDirect as one fresh process per case/repeat before comparing it +with the native PostgreSQL control. The lower-level `perf diagnose-speed-cases` +commands remain available for one-off inspection. + +The native matrix is native-only by default. The script builds `xtask` with +the `perf` feature explicitly enabled, avoiding the legacy `oliphaunt-wasix` +runtime-control path while still building the native broker helper: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh \ + --rtt-repeats 1 \ + --speed-repeats 1 +``` + +For an even faster no-build sanity check of the benchmark plan: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh --quick --plan-only +tools/perf/matrix/run_native_oliphaunt_matrix.sh \ + --quick --plan-only --engines broker --suites streaming +tools/perf/check-native-perf-harness.sh +``` + +Use `--engines direct|broker|server|all` and +`--suites rtt|speed|streaming|prepared|backup|all` for focused diagnostic runs. +Focused runs still include the relevant native PostgreSQL control for the +selected suite, but the generated report marks them as partial coverage. They +are not release evidence. + +Use `--runtime-footprint throughput|balanced-mobile|small-mobile` and repeated +`--startup-guc name=value` flags for mobile footprint experiments. The same +tuning is passed to NativeDirect, NativeBroker, NativeServer, and the native +PostgreSQL control, and the JSON/report/provenance files record the effective +profile and overrides: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh \ + --quick \ + --durability balanced \ + --runtime-footprint balanced-mobile \ + --startup-guc shared_buffers=32MB \ + --startup-guc wal_buffers=-1 +``` + +The default PostgreSQL 18 template uses 16MB WAL segments, so +`min_wal_size=8MB` and `min_wal_size=16MB` are invalid for the default mobile +cluster. WAL segment size is an `initdb`/template-cluster property, not a +startup GUC. For the small-WAL mobile experiments, run the Expo matrix with a +matching template segment size: + +```sh +tools/perf/matrix/run_mobile_footprint_matrix.sh --quick --platform android \ + --wal-segsize 4 \ + --min-wal-size 8MB,16MB \ + --max-wal-size 32MB,64MB \ + --durability balanced \ + --crash-recovery off +``` + +The harness passes `OLIPHAUNT_EXPO_MOBILE_WAL_SEGSIZE_MB` into the Android/iOS +dev-client scripts, regenerates the packaged template PGDATA with +`initdb --wal-segsize`, records `walSegmentSizeMB` in the template manifest, and +captures PostgreSQL's effective read-only `wal_segment_size` setting in the +benchmark report. + +For Android/iOS device sweeps, use the Expo dev-client matrix wrapper. It emits +or runs the same runtime-footprint, shared-buffer, WAL-buffer, WAL-minimum, +WAL-maximum, and Safe/Balanced combinations against the installed React Native +app. The default profile is `balancedMobile`; pass `--runtime-footprint all` to +run `throughput`, `balancedMobile`, and `smallMobile` under the same GUC axes. +Non-plan runs store every case in its own scratch directory and write +`summary.json` plus `summary.md` under `target/perf/mobile-footprint-/` +with open time, warm query p50/p90/p95/p99, bulk insert/update, background +checkpoint latency, Android PSS/RSS, and iOS resident memory where the platform +harness can collect them. Package footprint is reported at three separate +levels: the Oliphaunt embedded payload reported by the app, the built Android +APK or iOS app bundle, and the local React Native package tarball used by the +dev-client app. Benchmark reports also include a same-device Expo SQLite WAL +baseline, including simple-query, parameterized-query, indexed lookup, indexed +aggregate, update, checkpoint, large-result, and insert-throughput measurements +using the same durability label, so mobile SQLite comparison is device evidence +instead of inferred from the host matrix. Each native benchmark report also +records effective PostgreSQL settings through `current_setting(..., true)`, and +the matrix summary surfaces the core effective GUCs next to the intended startup +overrides. Treat measurements without those effective settings as incomplete +tuning evidence. React Native benchmark reports include app-reported process +memory via `Oliphaunt.processMemory()`: iOS records Mach task resident and +physical-footprint bytes, and Android records `Debug.MemoryInfo` PSS plus heap +fields. The matrix summary prefers this in-app report and uses `devicectl` or +`adb` process scraping only as additional harness evidence. Missing process +memory data leaves iOS resident memory blank rather than recording a false zero. +By default safe-durability matrix cases +also run the installed-app +process-death recovery lane. Balanced cases keep `synchronous_commit=off`, so +they remain latency/footprint evidence rather than last-commit survival gates. +Use `--crash-recovery off` only for a diagnostic latency-only sweep: + +```sh +tools/perf/matrix/run_mobile_footprint_matrix.sh --plan-only --platform android +tools/perf/matrix/run_mobile_footprint_matrix.sh --plan-only --platform android --runtime-footprint all +tools/perf/matrix/run_mobile_footprint_matrix.sh --quick --platform android \ + --shared-buffers 8MB,32MB,128MB \ + --wal-buffers -1 \ + --min-wal-size 32MB \ + --max-wal-size 64MB \ + --durability balanced \ + --crash-recovery off +tools/perf/matrix/run_mobile_footprint_matrix.sh --quick --platform ios --crash-recovery off +tools/perf/matrix/run_mobile_footprint_matrix.sh --platform ios +``` + +`--quick` keeps the same GUC/profile axes but passes +`OLIPHAUNT_EXPO_MOBILE_BENCHMARK_PRESET=quick` into the Expo dev-client app so +the installed-app workload uses fewer warmup, latency, checkpoint, insert, and +large-result iterations. Use it for harness validation and emulator/simulator +sanity checks; use the default full preset for reportable numbers. +Use `--shared-buffers`, `--wal-buffers`, `--min-wal-size`, `--max-wal-size`, +`--wal-segsize`, and `--durability` to run a small slice with the same +installed-app harness before committing to the full device matrix. + +Current diagnostic Android emulator slice: + +- run id: `android-guc-slice-20260524T1750` +- report: `target/perf/mobile-footprint-android-guc-slice-20260524T1750/summary.md` +- platform: Android API 34 emulator through the Expo dev-client harness +- benchmark preset: `quick` +- fixed settings: `balancedMobile`, `balanced`, + `wal_buffers=-1`, `min_wal_size=32MB`, `max_wal_size=64MB` + +| shared_buffers | Android PSS | Android RSS | Open ms | Param p90 ms | Insert rows/s | Checkpoint p90 ms | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 8MB | 253.7 MB | 383.1 MB | 7695.68 | 39.38 | 57 | 1457.90 | +| 32MB | 256.6 MB | 386.3 MB | 6347.87 | 41.92 | 77 | 1480.58 | + +This is diagnostic emulator evidence, not a release claim. It does show that +lowering `shared_buffers` from 32MB to 8MB does not currently buy a proportional +resident-memory reduction in the React Native app process; fixed mappings, +runtime/template assets, extension registry, or other PostgreSQL/React Native +process costs are still dominating the measured PSS/RSS. Keep the full device +matrix and source/build-cut investigations separate from this quick slice. + +Current diagnostic Android emulator small-WAL slice: + +- run id: `android-small-wal-20260524T1833` +- report: `target/perf/mobile-footprint-android-small-wal-20260524T1833/summary.md` +- platform: Android API 34 emulator through the Expo dev-client harness +- benchmark preset: `quick` +- fixed settings: `balancedMobile`, `balanced`, `shared_buffers=32MB`, + `wal_buffers=-1`, `max_wal_size=32MB`, `--wal-segsize 4` + +| min_wal_size | Effective wal_segment_size | Android PSS | Open ms | Param p90 ms | Lookup p90 ms | Aggregate p90 ms | SQLite param p90 ms | SQLite lookup p90 ms | SQLite aggregate p90 ms | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 8MB | 4MB | 257.9 MB | 11223.12 | 45.02 | 40.72 | 26.98 | 79.90 | 67.94 | 733.46 | +| 16MB | 4MB | 260.5 MB | 22116.32 | 71.35 | 37.20 | 47.34 | 78.57 | 34.45 | 31.58 | + +This proves the harness can build and run an installed Android package against +template clusters with 4MB WAL segments and verify the effective PostgreSQL +`wal_segment_size`. The open-time spread is too noisy to treat this quick +emulator slice as a tuning decision; use it as harness evidence and run the full +physical-device matrix before picking the mobile default. + +Latest Android emulator retry caveat: + +- run id: `android-emulator-shared-minwal-slice-20260525T0325` +- report: + `target/perf/mobile-footprint-android-emulator-shared-minwal-slice-20260525T0325/summary.md` +- result: one quick `shared_buffers=8MB,min_wal_size=8MB` case passed with + app-reported `android-debug-memory-info`; the matching `min_wal_size=32MB` + case did not produce benchmark evidence. +- passed case: `271,565 KB` app PSS, `396,424 KB` host RSS, `41,415.89 ms` + open, `286.18 ms` parameterized p90, `7.98 rows/s` insert throughput, + `126.77 ms` checkpoint p90, and `34.1 MB` embedded payload. + +A focused `min_wal_size=32MB` retry after adding a bounded +`Linking.getInitialURL()` path in the Expo example still failed before the +React Native app attached: Android killed the app process for `failed to attach` +/ `start timeout`, and Metro never served a bundle. Treat this as local AVD +instability, not PostgreSQL tuning evidence. Physical Android device evidence is +still required before Android defaults can be selected. + +Current diagnostic iOS simulator small-WAL slice: + +- run id: `ios-small-wal-20260524T1855` +- report: `target/perf/mobile-footprint-ios-small-wal-20260524T1855/summary.md` +- platform: iOS 18.0 simulator through the Expo dev-client harness +- benchmark preset: `quick` +- fixed settings: `balancedMobile`, `balanced`, `shared_buffers=32MB`, + `wal_buffers=-1`, `max_wal_size=32MB`, `--wal-segsize 4` + +| min_wal_size | Effective wal_segment_size | iOS RSS | Open ms | Param p90 ms | Lookup p90 ms | Aggregate p90 ms | SQLite param p90 ms | SQLite lookup p90 ms | SQLite aggregate p90 ms | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 8MB | 4MB | 380.1 MB | 1109.52 | 0.50 | 0.67 | 0.95 | 0.94 | 0.84 | 0.94 | +| 16MB | 4MB | 382.2 MB | 1534.13 | 0.98 | 1.67 | 1.91 | 1.78 | 1.66 | 1.47 | + +This proves the iOS harness can package the same 4MB-WAL template and capture +effective GUCs, package size, resident memory, and same-device SQLite baselines. +It is simulator evidence only. Physical iOS benchmark runs additionally require +a valid Apple Development signing identity or a working Xcode account that can +create one through automatic provisioning. + +Current iPhoneOS build-only device-artifact evidence: + +- scratch: `target/oliphaunt-expo-ios-device-buildonly-20260524T1615` +- mode: `OLIPHAUNT_EXPO_IOS_SDK=iphoneos`, + `OLIPHAUNT_EXPO_IOS_BUILD_ONLY=1`, + `OLIPHAUNT_EXPO_IOS_CODE_SIGNING_ALLOWED=NO` +- result: Xcode `Debug-iphoneos` build succeeded using the local + `liboliphaunt.xcframework` iPhoneOS slice +- bundled Oliphaunt resources: 1,874 files, 35,800,256 bytes +- iOS app bundle: 184,075,464 bytes +- packed React Native package: 14,015,379 bytes + +This is compile/package evidence only. It proves the iPhoneOS artifact, +resource bundle, React Native local iOS pod integration, and New Architecture +generated code compile without relying on a runnable device. It is not runtime +performance evidence; physical install/launch still requires Developer Mode, +Developer Disk Image services, and valid signing on the paired phone. + +Current physical iPhone install/runtime/benchmark evidence: + +- scratch: `target/oliphaunt-expo-ios-device-crash-safe-smallwal-20260524T174847` +- runtime smoke scratch: + `target/oliphaunt-expo-ios-device-smoke-autolifecycle-20260524T0018` +- latest reuse-installed runtime smoke: + `target/oliphaunt-expo-ios-smoke/reports/smoke-report.json` +- quick footprint matrix scratch: + `target/perf/mobile-footprint-ios-physical-memory-retry-20260525T0230` +- full candidate footprint matrix scratch: + `target/perf/mobile-footprint-ios-physical-full-candidate-20260525T0200` +- device: iPhone 14 Pro, UDID `7C01EC26-8B01-56E6-872D-82BB72421567` +- mode: `OLIPHAUNT_EXPO_IOS_SDK=iphoneos`, + `OLIPHAUNT_EXPO_MOBILE_DURABILITY=safe`, + `OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT=balancedMobile`, + `OLIPHAUNT_EXPO_MOBILE_WAL_SEGSIZE_MB=4` +- startup GUCs: + `shared_buffers=32MB,wal_buffers=-1,min_wal_size=8MB,max_wal_size=32MB` +- result: Xcode `Debug-iphoneos` build succeeded and `devicectl device install + app` installed bundle ID `dev.oliphaunt.reactnative.example` +- bundled Oliphaunt resources: 1,871 files, 35,799,044 bytes +- selected extension: `vector`, 38 files, 63,478 bytes +- iOS app bundle: 183,420,535 bytes +- packed React Native package: 14,008,184 bytes +- crash recovery: passed on the physical iPhone with app-private + `app-support://...` storage; verify reopened the recovered root in + `146.99 ms` and read back `crash-ios-12452656` +- smoke/runtime: passed on the physical iPhone after the harness automatically + backgrounded the app through Safari and foregrounded it again. The smoke + covered `SELECT 1`, parameterized query, DDL, DDL event triggers, pgvector, + extension selection, transaction/savepoint recovery, constraint error + recovery, JSONB/arrays, recursive CTE/window functions, raw protocol + streaming, query cancellation/recovery, checkpoint/physical backup, and + background/foreground resume SQL. +- smoke timings: open `1360.33 ms`, select p50/p90/p99 + `0.23/0.25/0.57 ms`, backup payload `33,425,920` bytes, lifecycle SQL + after foreground `27.43 ms` +- latest reuse-installed smoke after the bounded launch-URL change opened in + `1357.67 ms`, reported select p90 `0.245 ms`, passed the + `active -> inactive -> background -> active` lifecycle SQL check, and reported + an embedded payload of `35,799,044` bytes. +- full candidate footprint matrix: passed two physical-device cases with + `shared_buffers=32MB`, `wal_buffers=-1`, `min_wal_size=8MB`, + `max_wal_size=32MB`, 4MB WAL segments, and the full benchmark preset. Safe + durability reported open `1386.29 ms`, raw p90 `0.04 ms`, typed p90 + `0.08 ms`, parameterized p90 `0.09 ms`, insert throughput `9686 rows/s`, + checkpoint p90 `1.13 ms`, large-result p90 `0.81 ms`, process-death + recovery elapsed `127.00 ms`, and recovery open `102.61 ms`. Balanced + durability reported open `1466.92 ms`, raw p90 `0.04 ms`, + typed p90 `0.09 ms`, parameterized p90 `0.10 ms`, insert throughput + `9726 rows/s`, checkpoint p90 `1.15 ms`, and large-result p90 `0.80 ms`. +- same-device SQLite baseline in that full candidate matrix: Safe open + `6.30 ms`, parameterized p90 `0.17 ms`, insert throughput `6855 rows/s`, + large-result p90 `4.90 ms`; Balanced open `6.16 ms`, parameterized p90 + `0.16 ms`, insert throughput `6910 rows/s`, large-result p90 `4.97 ms`. +- app-reported iOS process memory source: `ios-task-vm-info`. Safe reported + `253.3 MB` resident and `153.3 MB` physical footprint; Balanced reported + `199.8 MB` resident and `137.5 MB` physical footprint. + +Current physical iPhone shared-buffer/min-WAL tuning slice: + +- run id: `ios-physical-shared-minwal-slice-20260525T0300` +- report: + `target/perf/mobile-footprint-ios-physical-shared-minwal-slice-20260525T0300/summary.md` +- device: same iPhone 14 Pro physical dev-client install +- platform: iPhoneOS through the Expo dev-client harness +- benchmark preset: `quick` +- fixed settings: `balancedMobile`, `balanced`, `wal_buffers=-1`, + `max_wal_size=32MB`, `--wal-segsize 4`, process-death recovery off +- varied settings: `shared_buffers=8/16/32/64/128MB`, + `min_wal_size=8/16/32MB` +- result: 15 cases passed, 0 failed; every row recorded PostgreSQL effective + GUCs and app-reported `ios-task-vm-info` memory. + +| shared_buffers | effective wal_buffers | footprint median MB | footprint min-max MB | RSS median MB | open median ms | param p90 median ms | insert median rows/s | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 8MB | 256kB | 135.5 | 135.3-136.2 | 202.7 | 1341.12 | 0.10 | 8710 | +| 16MB | 512kB | 136.1 | 135.9-136.3 | 198.4 | 1331.04 | 0.10 | 8972 | +| 32MB | 1MB | 137.0 | 136.9-137.3 | 199.4 | 1310.48 | 0.10 | 8907 | +| 64MB | 2MB | 139.5 | 139.3-141.0 | 205.4 | 1346.28 | 0.10 | 8923 | +| 128MB | 4MB | 142.8 | 142.4-146.3 | 208.5 | 1363.92 | 0.10 | 8858 | + +This quick physical-device slice shows that `min_wal_size=8/16/32MB` does not +materially move app physical footprint for this small workload. Lowering +`shared_buffers` from 128MB to the 8-32MB band saves roughly 6-11MB of physical +footprint on this iPhone, but it does not collapse total process footprint +because the embedded Postgres/runtime/template baseline still dominates. Treat +this as tuning evidence for the mobile default shape, not as full release +evidence; Safe durability, additional `wal_buffers` values, runtime footprint +profiles, and physical Android still need corresponding device evidence. + +The iPhoneOS `liboliphaunt.xcframework` used for this run also has a stricter +artifact gate: the device and simulator slices are rejected if they import +mobile-forbidden SysV/POSIX shared-memory or semaphore APIs (`shm*`, +`shm_open`, or external `sem*`). This was added after a real-device `SIGSYS` +crash report showed PostgreSQL reaching `shmget` during embedded startup. + +By default the wrapper skips `min_wal_size` values below two WAL segments. With +the default `--wal-segsize 16`, that skips 8MB and 16MB. With +`--wal-segsize 4`, both become valid and are included. Pass +`--include-invalid-wal-min` only for negative validation. The wrapper also skips +impossible WAL ranges such as `max_wal_size=32MB` with `min_wal_size=80MB`, +while preserving a `max_wal_size=default` baseline for the current +throughput-sized WAL ceiling. + +Crash recovery after process death is measured by the installed-app crash lanes, +which write to a persistent app-private root, terminate the app without closing +the direct-mode database, relaunch, and verify committed data through +PostgreSQL recovery. The default crash invocation uses safe durability; do not +interpret balanced/synchronous-commit-off runs as committed-row survival +evidence: + +```sh +pnpm --dir src/sdks/react-native/examples/expo run crash:android +pnpm --dir src/sdks/react-native/examples/expo run crash:ios +``` + +Use the default script invocation for release evidence. Native release gates are +read against native PostgreSQL controls. The matrix plan labels runs with +`releaseEvidence`, `partialReport`, and `diagnosticRun` before any expensive +work starts. Default runs must meet the current release minimums: 100 RTT +samples, 10 fresh-process RTT repeats, 25,000 prepared-update rows, 10 +fresh-process prepared repeats, 20 fresh-process speed repeats, and 10 +fresh-process backup/restore repeats across the default direct/broker/server and +rtt/speed/streaming/prepared/backup matrix. Quick or focused runs are diagnostic +evidence only, even when they are useful for investigating a regression. + +Each native matrix run writes `provenance.json` next to `report.md`. The +provenance file records the benchmark source set, PostgreSQL patch/build inputs, +Rust SDK sources, `xtask`, and native artifacts by SHA-256. Verify an existing +run before using it as release evidence: + +```sh +OLIPHAUNT_PERF_RUN_DIR="$PWD/target/perf/native-liboliphaunt-" \ +tools/perf/check-native-perf-report.sh +``` + +This validation rejects diagnostic and partial reports by default. To verify +only the source/artifact provenance of a focused diagnostic run, set +`OLIPHAUNT_PERF_ALLOW_DIAGNOSTIC=1`; do not use that mode for release +claims or updates to the latest complete matrix section. + +Use the focused native diagnostic when a specific speed case misses the native +control and needs repeat evidence: + +```sh +tools/perf/matrix/run_native_speed_diagnostics.sh --ids 1,2,2.1 --repeats 10 --skip-build +``` + +It writes `summary.json` and `summary.md` under +`target/perf/native-speed-diagnostics-/`. Use the lower-level command +when you need a single raw diagnostic case: + +```sh +LIBOLIPHAUNT_PATH="$PWD/target/liboliphaunt-pg18/out/liboliphaunt.dylib" \ +OLIPHAUNT_INSTALL_DIR="$PWD/target/liboliphaunt-pg18/install" \ +cargo run -p oliphaunt-perf -- \ + diagnose-speed-cases --engine native-liboliphaunt --ids 3 +``` + +Native direct diagnostics run one case per process because the embedded backend +has a single safe process lifetime. Diagnostic output includes the engine +process model and key PostgreSQL GUCs so direct-mode misses can be separated +from control mismatch. The same command supports `--engine native-postgres`; it +uses `OLIPHAUNT_POSTGRES` / `OLIPHAUNT_INITDB` or the repo's +`target/liboliphaunt-pg18/install/bin` tools when present, with `--postgres-bin` +and `--initdb-bin` available for explicit overrides. PGDATA template hydration +defaults to physical byte-copy because local matrix evidence showed better p90 +stability than APFS clone-on-write. Set +`OLIPHAUNT_PGDATA_COPY_MODE=prefer-clone` only when investigating +clone-on-write behavior explicitly. + +The SQLite control is part of the same matrix by default and can be run +directly for a quick embedded baseline: + +```sh +cargo run -p oliphaunt-perf -- \ + sqlite --suite speed --speed-source oliphaunt --durability safe +``` + +`safe`, `balanced`, and `fast-dev` map to explicit SQLite PRAGMAs inside `oliphaunt-perf`, +so SQLite numbers are recorded as product comparison data rather than inferred +from a separate tool. + +Most recent recorded complete native track matrix: + +- run id: `20260524T090412Z` +- report: `target/perf/native-liboliphaunt-20260524T090412Z/report.md` +- provenance: `target/perf/native-liboliphaunt-20260524T090412Z/provenance.json` +- PostgreSQL control: `postgres (PostgreSQL) 18.4` +- native durability profile: `safe` +- native runtime footprint profile: `throughput` +- PGDATA template hydration: `copy` +- RTT samples: `100` +- RTT repeats: `10` +- prepared-update repeats: `10` +- speed repeats: `20` +- backup/restore repeats: `10` +- provenance verification: passed for that recorded source/artifact set; rerun + the full matrix before making current-checkout release claims after the + later backup ABI/tar-writer changes. + +Key results from that run: + +| Metric | NativeDirect | NativeBroker | NativeServer | Native Postgres control | SQLite embedded | +| --- | ---: | ---: | ---: | ---: | ---: | +| RTT repeat gate p90 | 107 us | 124 us | 127 us | 112 us | n/a | +| Speed suite p90 | 2.668 s | 2.629 s | 2.452 s | 2.419 s | 3.871 s | +| Backup/restore physical p90 | 0.558 s | 0.62 s | 0.567 s | 0.344 s | 0.005 s | +| Backup payload p50 | 56.17 MB | 56.17 MB | 56.17 MB | 56.17 MB | 1.31 MB | +| Backup tail throughput p10 | 201.3 MB/s | 181.1 MB/s | 198.1 MB/s | 326.5 MB/s | 483.9 MB/s | +| Open p90 | 440.28 ms | 384.11 ms | 423.18 ms | 576.4 ms | 0.89 ms | +| p90 RSS | 123.5 MB | 107.9 MB process / 109.5 MB observed helper | 93.1 MB process / 138.5 MB observed server | 90.6 MB process / 128.7 MB observed server | 36.9 MB | + +The packaged-template bootstrap path still fixes the prior native open-time +miss: direct open p90 is now `0.764x` native PostgreSQL control open p90, with +broker and server also below the native control. PGDATA template hydration +defaults to byte-copy because same-source evidence showed better p90 stability +than `prefer-clone`. + +This run is more defensible than the older one-shot RTT snapshots because the +harness now runs 10 fresh-process RTT repeats and gates RTT on p90 across +repeated median-p90 summaries. It also still requires at least 20 fresh-process +speed repeats before classifying speed tail stability as release-grade. Under +the speed-quality rule, direct, broker, native PostgreSQL tokio, and SQLite are +`stable`; server is also `stable` on this host run. + +Native direct passes the repeated RTT, open p90, and RSS gates, but it does not +yet pass the speed-suite or physical-backup gates. RTT gate p90 is `107 us` +versus native PostgreSQL tokio at `112 us` (`0.955x`). Speed-suite p90 is +`2.668 s` versus native PostgreSQL tokio at `2.419 s` (`1.103x`), and speed +tail throughput p10 is `63,420.4 ops/s` versus `69,938.9 ops/s` (`0.907x`). +Backup/restore now has a same-semantics native PostgreSQL physical control: +direct p90 is `0.558 s` versus native PostgreSQL physical at `0.344 s` +(`1.622x`), with equal `56.17 MB` p50 payloads. Direct backup tail throughput +p10 is `201.3 MB/s` versus native PostgreSQL physical at `326.5 MB/s` +(`0.617x`). The logical `pg_dump`/`pg_restore -Fc` control still appears in the +backup table as portability comparison data; its p90 is `0.149 s` with a +`1.27 MB` p50 payload. + +A focused post-report backup diagnostic at +`target/perf/native-liboliphaunt-20260524Tbackup-final-direct/report.md` +uses matching current-source provenance but is intentionally partial. It covers +only NativeDirect plus native PostgreSQL backup/restore with 10 repeats after +the C ABI gained `oliphaunt_backup_ex`, in-archive SDK metadata append, direct +`read(2)` file copying, and per-entry tar buffer reservation. That run improves +the direct physical backup/restore p90 to `0.534 s`, but native PostgreSQL +physical remains `0.324 s`, so the backup gate is still a real miss. Opt-in +`OLIPHAUNT_TRACE_BACKUP=1` phase tracing shows the remaining direct cost is +concentrated in PostgreSQL `pg_backup_start` and PGDATA archive generation, not +Rust-side metadata annotation or FFI response copying. + +Individual speed cases above the 5% tolerance in the complete matrix are `1`, +`2`, `2.1`, `3`, `3.1`, `4`, `5`, `10`, and `13`; the generated report includes +focused diagnostic commands for those ids. A follow-up fresh-process diagnostic run +stored at +`target/perf/native-speed-diagnostics-20260524T090412Z-speed-misses/summary.md` +reproduced stable misses for `1`, `2.1`, `3`, `4`, `10`, and `13`. Cases `2`, +`3.1`, and `5` did not reproduce above tolerance in that isolated per-case run +and should be rechecked only if they recur in the next complete matrix. +Compared with SQLite, native direct wins the total speed suite in this run +(`0.689x`) but still has much higher open p90 (`493.034x`) and RSS (`3.343x`). + +Prepared-update p90 rows now include sequential and pipelined direct, broker, +server, and native PostgreSQL controls. Direct sequential prepared p90 is +`0.775 s` numeric and `0.768 s` text versus native PostgreSQL tokio at +`0.867 s` and `0.879 s`. Direct pipelined prepared p90 is `0.337 s` numeric and +`0.34 s` text versus native PostgreSQL tokio at `0.341 s` and `0.359 s`. + +Artifact rows from the same run: + +| Artifact | Size | +| --- | ---: | +| `liboliphaunt.dylib` | 11.14 MB | +| Embedded extension modules | 2.32 MB | +| Native PostgreSQL install | 33.62 MB | +| Native PostgreSQL install tree | 33.42 MB | + +## Snapshot + +Snapshot run: `20260507T113000Z` + +Environment: + +- OS: `macOS 26.4.1 (Darwin 25.4.0 arm64)` +- CPU: `Apple M1 Pro` +- RAM: `16 GB` +- Logical cores: `10` +- Node: `v24.13.0` +- Node packages: `@electric/wasm@0.4.5`, + `@electric/wasm-socket@0.1.5` +- Native Postgres: `18.3 (Homebrew)` +- RTT iterations: `100` +- Speed source: exact upstream SQL from + `target/oliphaunt-sources/checkouts/oliphaunt/packages/benchmark/src` + +Every mode was run serially. + +## Representative Operations + +Lower is better. + +| Operation | native pg + SQLx | oliphaunt-wasix + SQLx | vanilla Oliphaunt + SQLx | +|---|---:|---:|---:| +| 25,000 INSERTs in one transaction | 132.36 ms | 149.54 ms | 257.02 ms | +| 25,000 INSERTs in one statement | 46.14 ms | 59.39 ms | 117.19 ms | +| 25,000 INSERTs into an indexed table | 188.72 ms | 253.38 ms | 352.64 ms | +| 5,000 indexed SELECTs | 81.39 ms | 125.31 ms | 203.05 ms | +| 25,000 indexed UPDATEs | 351.05 ms | 578.96 ms | 720.63 ms | + +## Full Operation Table + +| ID | Test | native pg + SQLx | oliphaunt-wasix + SQLx | vanilla Oliphaunt + SQLx | +|---|---|---:|---:|---:| +| 1 | Test 1: 1000 INSERTs | 9.13 ms | 19.76 ms | 15.66 ms | +| 2 | Test 2: 25000 INSERTs in a transaction | 132.36 ms | 149.54 ms | 257.02 ms | +| 2.1 | Test 2.1: 25000 INSERTs in single statement | 46.14 ms | 59.39 ms | 117.19 ms | +| 3 | Test 3: 25000 INSERTs into an indexed table | 188.72 ms | 253.38 ms | 352.64 ms | +| 3.1 | Test 3.1: 25000 INSERTs into an indexed table in single statement | 66.41 ms | 95.12 ms | 93.88 ms | +| 4 | Test 4: 100 SELECTs without an index | 107.63 ms | 162.89 ms | 242.03 ms | +| 5 | Test 5: 100 SELECTs on a string comparison | 305.38 ms | 338.01 ms | 434.63 ms | +| 6 | Test 6: Creating indexes | 9.94 ms | 13.08 ms | 17.12 ms | +| 7 | Test 7: 5000 SELECTs with an index | 81.39 ms | 125.31 ms | 203.05 ms | +| 8 | Test 8: 1000 UPDATEs without an index | 47.91 ms | 74.42 ms | 103.66 ms | +| 9 | Test 9: 25000 UPDATEs with an index | 351.05 ms | 578.96 ms | 720.63 ms | +| 10 | Test 10: 25000 text UPDATEs with an index | 471.74 ms | 712.38 ms | 858.95 ms | +| 11 | Test 11: INSERTs from a SELECT | 65.64 ms | 97.43 ms | 112.87 ms | +| 12 | Test 12: DELETE without an index | 7.54 ms | 9.74 ms | 11.69 ms | +| 13 | Test 13: DELETE with an index | 9.31 ms | 26.58 ms | 27.7 ms | +| 14 | Test 14: A big INSERT after a big DELETE | 53 ms | 71.6 ms | 87.72 ms | +| 15 | Test 15: A big DELETE followed by 12000 small INSERTs | 58.98 ms | 74.49 ms | 112.18 ms | +| 16 | Test 16: DROP TABLE | 3.43 ms | 10.17 ms | 6.74 ms | + +## Reproduce + +Run the native matrix plan locally: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh --plan-only +``` + +Run measured native results when the native runtime artifacts are present: + +```sh +tools/perf/matrix/run_native_oliphaunt_matrix.sh --engines direct,broker,server +``` + +That command covers: + +1. native direct, broker, and server Oliphaunt paths; +2. native PostgreSQL control runs; +3. SQLite embedded control runs for the speed suite; +4. p50/p90/p95 latency, throughput, RSS, CPU, and footprint report generation. + +The WASM product lane has its own perf smoke target: + +```sh +moon run oliphaunt-wasix:bench +``` + +Outputs land under `target/perf/`: + +- `bench-native-postgres-sqlx-.json` +- `bench-oliphaunt-native-direct-.json` +- `bench-oliphaunt-native-broker-.json` +- `bench-oliphaunt-native-server-.json` +- `bench-sqlite-.json` +- `bench-comparison-.md` + +Override the native Postgres binaries when needed: + +```sh +OLIPHAUNT_POSTGRES=/path/to/postgres \ +OLIPHAUNT_INITDB=/path/to/initdb \ +tools/perf/matrix/run_native_oliphaunt_matrix.sh --engines direct,broker,server +``` + +## Reading The Matrix + +- `oliphaunt-wasix + SQLx` is the product-style path for apps that connect through + standard Postgres clients. +- `vanilla Oliphaunt + SQLx` keeps upstream Oliphaunt on NodeFS, but uses the same Rust + SQLx client path as the other wire-protocol rows. +- These are machine-local numbers. Re-run the matrix before quoting them in a + release note or public comparison. diff --git a/docs/maintainers/release-setup.md b/docs/maintainers/release-setup.md new file mode 100644 index 00000000..5e36537d --- /dev/null +++ b/docs/maintainers/release-setup.md @@ -0,0 +1,446 @@ +# Release Setup + +This is the one-time external setup needed before Oliphaunt can publish from +GitHub Actions. Release-please configuration owns versions, changelogs, release +PRs, and product tags; Moon owns the product dependency graph; product-local +`release.toml` files own package and artifact metadata. This document covers +the accounts, registry settings, environments, and secrets that live outside +the repository. + +The canonical public repository identity is `f0rr0/oliphaunt`. Configure the +registries only after the GitHub repository has that name, because Cargo, npm +provenance, JSR provenance, SwiftPM Git tags, GitHub release URLs, and Maven POM +metadata all use that identity. + +Release setup is considered ready only when consumers install Oliphaunt through +normal platform package managers: + +- Rust/Tauri: `cargo add oliphaunt` +- iOS/macOS Swift: Xcode or SwiftPM using + `https://github.com/f0rr0/oliphaunt.git` +- Android/Kotlin: Maven Central plus `id("dev.oliphaunt.android")` +- React Native/Expo: `pnpm add @oliphaunt/react-native` plus the Expo config + plugin +- TypeScript/Node/Bun: `pnpm add @oliphaunt/ts` +- TypeScript/Deno: `deno add jsr:@oliphaunt/ts` +- WASM: crates.io/GitHub release assets for the WASM product lane + +Those paths may fetch checksum-covered GitHub release assets behind the scenes, +but app developers should not clone this repository, copy PostgreSQL resources, +manually download XCFrameworks, or publish/consume Oliphaunt through CocoaPods +trunk. +Normal app consumers must not install Rust, run Cargo, build PostgreSQL, or +compile Oliphaunt native artifacts from source unless they are intentionally +using the Rust SDK or contributing to this repository. + +## GitHub + +Create three environments under repository settings: + +| Environment | Purpose | Required secrets | +| --- | --- | --- | +| `release-pr` | Creates the generated release PR. | `RELEASE_PR_TOKEN` | +| `release-dry-run` | Runs publish dry-runs without registry write secrets. | none | +| `release-publish` | Publishes registries and release assets. | `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`, `MAVEN_GPG_PRIVATE_KEY`, `MAVEN_GPG_KEY_ID`, `MAVEN_GPG_PASSPHRASE` | + +Recommended environment protection: + +- `release-pr`: no reviewer requirement, but restrict to `main`. +- `release-dry-run`: no registry secrets; optional maintainer reviewer. +- `release-publish`: require maintainer review, prevent self-review, disallow + administrator bypass, and restrict deployment branches to `main`. + +Repository Actions settings: + +- Allow GitHub Actions to create pull requests. +- Keep workflow permissions at least read/write for the release workflow. +- Keep `id-token: write` and `attestations: write` on publish jobs. The + workflow already declares these permissions; the repository must not disable + Actions OIDC. + +`RELEASE_PR_TOKEN` should be a GitHub App installation token or maintainer bot +token that can push `release/-` release-intent branches +and open/update PRs. Do not use the default `GITHUB_TOKEN` for this path, +because PR workflows triggered by the default token do not run as normal +human-authored PR checks. + +The publish job still needs the repository-scoped `GITHUB_TOKEN` for GitHub +release asset uploads, artifact attestations, release-please release creation, +and the SwiftPM semver tag. The workflow passes that token automatically; local +release CLI experiments that touch asset-backed products must set `GH_TOKEN` or +`GITHUB_TOKEN`. + +Useful verification: + +```bash +gh repo view f0rr0/oliphaunt +gh workflow list --repo f0rr0/oliphaunt +tools/release/release.py plan --from-product-tags --include-current-tags --head-ref HEAD +tools/release/release.py check +``` + +## crates.io + +Products: + +- `oliphaunt` +- `oliphaunt-wasix` +- `oliphaunt-wasix-assets` +- `oliphaunt-wasix-aot-aarch64-apple-darwin` +- `oliphaunt-wasix-aot-x86_64-unknown-linux-gnu` +- `oliphaunt-wasix-aot-aarch64-unknown-linux-gnu` +- `oliphaunt-wasix-aot-x86_64-pc-windows-msvc` + +Setup: + +1. Create or log in to the crates.io account that will own the crates. +2. Perform the first publication manually for each new crate. crates.io trusted + publishing cannot create brand-new crates; it can publish later versions + after the crate exists. +3. Add all maintainers who should have owner access. +4. Configure trusted publishing for every crate: + - owner: `f0rr0` + - repository: `oliphaunt` + - workflow filename: `release.yml` + - environment: `release-publish` +5. Do not add `CARGO_REGISTRY_TOKEN` to the repository. Cargo publishing runs + inside the protected `Release` workflow and obtains short-lived crates.io + credentials through GitHub Actions OIDC when trusted publishing is + configured. + +Manual first-publish should happen from the exact release artifacts produced by +the release workflow or an equivalent local staged release workspace, not from a +hand-edited tree. After that bootstrap, all Cargo publishing should go through +the `Release` workflow. + +Manual registry bootstrap is a release-completion state, not a consumer install +path. If a registry forces a first manual package-version publish before trusted +publishing can be configured, create and push the matching product tag at the +same release commit before rerunning `Release` as a completion run. For example, +`oliphaunt-rust-v0.1.0` must point at the exact commit that produced +`oliphaunt 0.1.0`. Without that tag, release validation rejects the already +published version and tells maintainers to prepare a new version instead. + +## npm + +Product: + +- `@oliphaunt/react-native` +- `@oliphaunt/ts` + +Setup: + +1. Create or claim the `@oliphaunt` npm organization/scope. +2. Ensure the package metadata keeps: + - `repository.url`: `git+https://github.com/f0rr0/oliphaunt.git` + - `repository.directory`: `src/sdks/react-native` or + `src/sdks/js` + - `publishConfig.access`: `public` + - `publishConfig.provenance`: `true` +3. In the npm package settings for each package, add a trusted publisher: + - provider: GitHub Actions + - organization/user: `f0rr0` + - repository: `oliphaunt` + - workflow filename: `release.yml` + - environment: `release-publish` + - allowed action: `npm publish` +4. After trusted publishing works, set publishing access to require 2FA and + disallow classic tokens. + +npm trusted publishing requires npm CLI `11.5.1` or newer and Node `22.14.0` or +newer. The release workflow uses the repo-pinned Node `22.22.3`, installs npm +`11.5.1`, and checks the npm CLI version before packing or publishing. + +If npm requires a package to exist before its package settings page is +available, do one manual first publish from the exact packed release artifact, +configure trusted publishing immediately after that, and revoke any temporary +automation token. +That manual first publish has the same product-tag rule as crates.io: push the +matching `oliphaunt-react-native-v` or `oliphaunt-js-v` tag at +the release commit before rerunning the publish workflow as a completion run. + +## JSR + +Product: + +- `jsr:@oliphaunt/ts` + +Setup: + +1. Create or claim the `@oliphaunt` scope on JSR. +2. Create the `@oliphaunt/ts` package. +3. Link the package to GitHub repository `f0rr0/oliphaunt` from the package + settings. +4. Keep `src/sdks/js/jsr.json` as the JSR source of version and export + metadata. It is release-owned and is updated with `package.json`. +5. Do not add a `JSR_TOKEN` secret for GitHub Actions publish. The release + workflow uses JSR's GitHub Actions OIDC publishing path, so package versions + published from the release workflow receive JSR provenance. + +Local dry-run equivalent: + +```bash +pnpm --dir src/sdks/js install --frozen-lockfile +pnpm --dir src/sdks/js exec jsr publish --dry-run +``` + +The TypeScript SDK resolves desktop native assets the same way consumers expect +other native packages to behave: the published package pins the compatible +`liboliphaunt` version, downloads the matching `liboliphaunt-native-v*` GitHub release +asset on first use, verifies it against +`liboliphaunt--release-assets.sha256`, and caches the extracted +library plus PostgreSQL runtime directory. `libraryPath`, `runtimeDirectory`, +`LIBOLIPHAUNT_PATH`, and `OLIPHAUNT_RUNTIME_DIR` remain development overrides, +not the public install story. + +Deno can also consume npm packages through its npm compatibility layer, but the +native Deno release target is JSR. Keep JSR as the Deno-first registry because +it publishes TypeScript source, validates public types during +`jsr publish --dry-run`, and supports GitHub Actions OIDC provenance without a +long-lived token. + +Bun consumes the npm artifact, not a separate registry artifact. The release +workflow installs a pinned Bun toolchain and the clean-consumer gate runs +`bun add @oliphaunt/ts`, so the npm package is verified under Bun as a normal +app would use it. + +If JSR ever requires a one-time manual version publish to create the package +identity, use the exact generated release artifact and then push the matching +`oliphaunt-js-v` product tag at the release commit before the workflow +completion run. + +## Maven Central + +Product: + +- `dev.oliphaunt:oliphaunt` +- Gradle plugin marker for `dev.oliphaunt.android` + +Setup: + +1. Create a Sonatype Central Portal account. +2. Register and verify the `dev.oliphaunt` namespace. Because this group ID + comes from `oliphaunt.dev`, the publisher must control the domain/DNS. +3. Generate a Central Portal user token. +4. Create a GPG signing key dedicated to release signing. +5. Export the ASCII-armored private key for CI: + + ```bash + gpg --export-secret-keys --armor > maven-signing-key.asc + ``` + +6. Add these secrets to the `release-publish` environment: + - `MAVEN_CENTRAL_USERNAME`: Central Portal token username + - `MAVEN_CENTRAL_PASSWORD`: Central Portal token password + - `MAVEN_GPG_PRIVATE_KEY`: full ASCII-armored private key + - `MAVEN_GPG_KEY_ID`: signing key id + - `MAVEN_GPG_PASSPHRASE`: signing key passphrase + +The workflow maps these secrets to Vanniktech/Gradle properties: + +- `ORG_GRADLE_PROJECT_mavenCentralUsername` +- `ORG_GRADLE_PROJECT_mavenCentralPassword` +- `ORG_GRADLE_PROJECT_signingInMemoryKey` +- `ORG_GRADLE_PROJECT_signingInMemoryKeyId` +- `ORG_GRADLE_PROJECT_signingInMemoryKeyPassword` + +Local dry-run equivalent: + +```bash +src/sdks/kotlin/gradlew -p src/sdks/kotlin \ + :oliphaunt:publishToMavenLocal \ + :oliphaunt-android-gradle-plugin:publishToMavenLocal \ + -PoliphauntBuildRoot="$PWD/target/liboliphaunt-sdk-check/gradle/oliphaunt-kotlin-release" \ + -PoliphauntCxxBuildRoot="$PWD/target/liboliphaunt-sdk-check/cxx/oliphaunt-kotlin-release" \ + --project-cache-dir "$PWD/target/liboliphaunt-sdk-check/gradle-cache/oliphaunt-kotlin-release" \ + --configuration-cache +``` + +The Maven publication must contain the SDK artifact, the +`dev.oliphaunt:oliphaunt-android-gradle-plugin` artifact, and the +`dev.oliphaunt.android` plugin marker metadata. The plugin is the +consumer-facing asset resolver: apps apply `id("dev.oliphaunt.android")` and +select exact SQL extension names through its typed `oliphaunt { ... }` block +instead of copying `liboliphaunt.so`, PostgreSQL runtime resources, or +extension archives by hand. + +If Maven Central requires a first manual publication to make those coordinates +visible, publish the exact release artifacts and then push +`oliphaunt-kotlin-v` at the release commit before rerunning the publish +workflow as a completion run. + +## Apple / SwiftPM + +Product: + +- `Oliphaunt` + +Apple distribution is SwiftPM plus GitHub release assets. Do not set up +CocoaPods trunk credentials and do not publish `COliphaunt` or `Oliphaunt` pod +versions. CocoaPods trunk is scheduled to become read-only on December 2, 2026, +so it is not a durable release registry for this product. + +Setup: + +1. Keep a root `Package.swift` in the repository. SwiftPM consumers should be + able to use: + + ```swift + .package(url: "https://github.com/f0rr0/oliphaunt.git", exact: "") + ``` + +2. Ensure the Swift SDK version does not collide with existing semver tags in + the repository. The release workflow creates two tags: + - `oliphaunt-swift-v` for the product release identity. + - `` for SwiftPM package resolution. +3. Publish the compatible `liboliphaunt-native-v` GitHub release assets + before or during the same release plan. The Swift SDK pins that native core + version in `src/sdks/swift/LIBOLIPHAUNT_VERSION`. +4. Keep the SwiftPM-compatible Apple XCFramework zip, Apple runtime resources, + and exact-extension artifacts in GitHub release assets. End developers should + select exact extension names through package tooling; they should not copy + XCFrameworks or resource directories by hand. + +The SwiftPM release manifest is generated from the actual `liboliphaunt` +release asset checksum: + +```bash +tools/release/render_swiftpm_release_package.py \ + --asset-dir target/liboliphaunt/release-assets \ + --output target/oliphaunt-swift/Package.release.swift +``` + +The release workflow passes that generated manifest to +`tools/release/publish_swiftpm_source_tag.py --manifest ...`. The publisher creates +a release-only commit parented by the source release commit with only +`Package.swift` replaced, then tags that commit with the semver tag SwiftPM +resolves. The source checkout still keeps `src/sdks/swift/Package.swift` +and the root source `Package.swift` for local SDK development and tests. + +The React Native npm package includes iOS podspec integration files while the +current React Native New Architecture toolchain uses CocoaPods for generated iOS +integration. The package ships `COliphaunt` and `Oliphaunt` podspec shims under +`ios/podspecs/`; those shims resolve the released Swift SDK source tag through +CocoaPods without publishing to CocoaPods trunk and without vendoring Swift SDK +source into npm. The standalone Swift SDK remains SwiftPM-first and does not +publish separate trunk pods. + +## GitHub Release Assets And Attestations + +`liboliphaunt`, the `oliphaunt-broker` runtime assets, and +`oliphaunt-wasix` publish binary/runtime assets to GitHub Releases. No extra +registry secret is needed; the release job uses `GITHUB_TOKEN` with +`contents: write`. + +Asset provenance requires: + +- `id-token: write` +- `attestations: write` +- `contents: write` + +The release workflow already declares those permissions. Verification uses: + +```bash +tools/release/release.py verify-release --products-json '["liboliphaunt-native"]' --head-ref HEAD +tools/release/release.py verify-release --products-json '["oliphaunt-rust"]' --head-ref HEAD +tools/release/release.py verify-release --products-json '["oliphaunt-wasix-rust"]' --head-ref HEAD +``` + +## Setup Validation + +Run these locally before attempting the first real release. Consumer shape is +strict because it validates tracked package surfaces, not public +registry state: + +```bash +moon run dev-tools:doctor +tools/release/release.py check +tools/release/release.py plan --from-product-tags --include-current-tags --head-ref HEAD +tools/release/release.py check-registries --products-json '' --head-ref HEAD --require-identities +tools/release/release.py publish-dry-run --products-json '' --head-ref HEAD +tools/release/release.py consumer-shape --require-ready --format markdown +``` + +For the first public release, select every product that introduces a public +dependency edge in one release plan: + +```json +[ + "liboliphaunt-native", + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix" +] +``` + +That is deliberate. Swift, Kotlin, and TypeScript need the matching +`liboliphaunt-native-v*` assets; React Native needs the matching SwiftPM and Maven +SDKs; TypeScript broker mode needs the matching `oliphaunt-broker` runtime +assets. Later releases can be independent once those current-version +dependency tags, registry packages, and GitHub release assets already exist. +The `--require-identities` check is expected to fail until package identities +have been bootstrapped in their registries. Treat that as setup evidence: create +the npm/JSR packages, verify the Maven namespace/publication path, and manually +bootstrap any first Cargo crates that cannot be created by trusted publishing. +`check-registries --require-identities`, `publish-dry-run`, and `publish` run +that identity preflight for selected products, so a release cannot proceed while +the public package coordinates are only documented but not actually present. +The publish-environment check also rejects legacy long-lived publish secrets +such as `CARGO_REGISTRY_TOKEN`, `NPM_TOKEN`, `NODE_AUTH_TOKEN`, `JSR_TOKEN`, and +CocoaPods trunk credentials. Configure trusted publishing, Maven signing +secrets, and GitHub release permissions instead of adding those tokens. + +Run these from GitHub Actions after environments and secrets exist: + +1. `Release` with `prepare-release-pr` +2. merge the generated release PR after CI is green +3. `Release` with `publish-dry-run` +4. `Release` with `publish` +5. `tools/release/release.py verify-release --products-json '' --head-ref HEAD` +6. `tools/release/release.py consumer-shape --require-ready --products-json ''` + +Do not treat successful registry setup as full release readiness. The +consumer-shape report still has to be green: tracked package metadata, +install docs, SwiftPM/Gradle/Expo wiring, exact-extension selection, compatible +dependency pins, and install-script safety must match the consumer-shape +fixtures for the selected release products. +The `--require-ready` command enforces that targeted shape contract. It does +not run clean registry installs, and clean registry reinstalls are not a +standing release policy. +For independently released products, unchanged dependencies may keep their +current-version product tags at earlier release commits. The selected products +in the active release plan are the ones that must tag the current release commit; +the release workflow enforces that separately from the targeted consumer-shape +tag-existence check. + +## References + +- GitHub environments and environment secrets: + +- GitHub Actions OIDC permissions: + +- GitHub artifact attestations: + +- crates.io trusted publishing announcement: + +- crates.io trusted publishing: + +- npm trusted publishers: + +- JSR publishing packages: + +- JSR using packages: + +- Sonatype Central Portal namespace setup: + +- Sonatype Central Portal token setup: + +- Vanniktech Maven Central publishing: + +- SwiftPM binary target checksum tooling: + +- CocoaPods trunk read-only plan: + diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md new file mode 100644 index 00000000..885311b3 --- /dev/null +++ b/docs/maintainers/release.md @@ -0,0 +1,162 @@ +# Release Process + +Oliphaunt is released as independent product lanes. The repository version is +not a product version. + +The canonical public release repository is repository `f0rr0/oliphaunt`. + +## Products + +Release-please components define the public release products: + +- `liboliphaunt-native`: native C ABI runtime, PostgreSQL 18 patch stack, + platform libraries, runtime resources, and native exact-extension artifacts. +- `liboliphaunt-wasix`: WASIX runtime assets and AOT asset crates. +- `oliphaunt-rust`: Rust SDK crate. +- `oliphaunt-broker`: Rust broker helper runtime. +- `oliphaunt-node-direct`: Node direct native runtime. +- `oliphaunt-swift`: Swift SDK for iOS and macOS. +- `oliphaunt-kotlin`: Kotlin/Android SDK and Android Gradle plugin. +- `oliphaunt-react-native`: React Native New Architecture SDK. +- `oliphaunt-js`: TypeScript SDK for Node.js, Bun, Deno, and Tauri + JavaScript apps. +- `oliphaunt-wasix-rust`: Rust binding crate for the WASIX runtime. +- `oliphaunt-extension-*`: exact SQL extension artifact products. + +## Release Authority + +Release-please manifest mode owns product versions, changelogs, release PRs, +and product-scoped tags. Product-local `release.toml` files declare owner, kind, +publish targets, registry packages, release artifacts, and compatibility-version +files. Moon owns dependency scopes and path ownership. + +`tools/release/release.py plan` computes release impact as: + +1. map changed files to owning Moon projects; +2. follow Moon dependencies with `production` or `peer` scope; +3. map selected Moon projects to release-please products. + +Build/test-only Moon dependencies affect CI but do not force package releases. +Docs, root README, examples, fixtures, benchmark plans, CI policy, and +maintainer-only files do not trigger product releases unless they change a +product-owned package source that release-please tracks. + +## Commands + +Use these commands while preparing or checking releases: + +```sh +tools/release/release.py plan +tools/release/release.py check +tools/release/release.py check-registries +tools/release/release.py publish-dry-run +tools/release/release.py publish +tools/release/release.py verify-release +tools/release/release.py consumer-shape +``` + +`consumer-shape` validates tracked package metadata, install docs, SwiftPM, +Gradle, Expo, React Native, asset resolver hooks, exact-extension selection, +dependency pins, and install-script safety. It is a package-shape gate, not a +standing broad clean-registry reinstall policy. Final registry and asset proof +belongs to `check-registries`, package-native dry-runs, `publish`, and +`verify-release`. + +## Product Releases + +PRs that change release-affecting product surfaces must use a +release-producing Conventional Commit title: + +- `feat:` for user-facing additions; +- `fix:` for behavior fixes; +- `perf:` for performance improvements; +- `refactor:` for behavior-preserving product changes that still need a + release; +- `revert:` for reverted release-affecting changes; +- any type with `!` for breaking changes. + +Docs, CI, test, examples, fixtures, and maintainer-only PRs can use non-release +types such as `docs:`, `ci:`, `chore:`, `style:`, or `test:` when the release +plan selects no product. + +Feature and fix PRs must not edit package versions directly. Version bumps and +changelog entries belong to release-please release PRs. For a product with no +product-scoped tag yet, the release PR prepares the checked-in version as the +first release. After the first tag exists, release-please bumps that product +from release-affecting commits since its own latest product tag. + +## Tags + +Product tags are scoped by product: + +- `liboliphaunt-native-v0.1.0` +- `liboliphaunt-wasix-v0.5.1` +- `oliphaunt-rust-v0.1.0` +- `oliphaunt-broker-v0.1.0` +- `oliphaunt-node-direct-v0.1.0` +- `oliphaunt-swift-v0.1.0` +- `oliphaunt-kotlin-v0.1.0` +- `oliphaunt-react-native-v0.1.0` +- `oliphaunt-js-v0.1.0` +- `oliphaunt-wasix-rust-v0.5.1` +- `oliphaunt-extension-vector-v0.1.0` + +The WASIX Rust crate can read legacy unscoped tags for migration history, but +new product identity uses product-scoped tags. + +## Native Artifacts + +Native runtime artifacts are release assets consumed by SDK tooling. The active +native target matrix is declared under +`src/runtimes/liboliphaunt/native/targets/` and includes desktop/server targets +plus mobile targets that apps consume as prebuilt artifacts. + +Downstream SDKs must consume published native artifacts through normal +ecosystem mechanisms: + +- Rust/Tauri resolves the native runtime and broker helper through Rust SDK + tooling and GitHub release assets. +- Swift resolves Apple artifacts through SwiftPM-compatible release assets. +- Kotlin/Android resolves Android ABI artifacts through the Android Gradle + plugin and GitHub release assets. +- React Native delegates iOS to Swift and Android to Kotlin. +- TypeScript resolves Node direct and broker helper artifacts through npm/JSR + metadata and GitHub release assets. + +Developers must not need to clone this repository or compile PostgreSQL as the +normal install path. + +## Extensions + +Extensions are exact SQL extension artifact products. There are no extension +packs, aliases, or grouped selectors. + +Contrib extension metadata lives under `src/extensions/contrib/`. External +extensions live under `src/extensions/external//` and own their own +source pin, recipe, target metadata, tests, version, changelog, and +`release.toml`. + +An external extension source change releases that extension artifact product. +It does not release SDKs unless SDK-visible generated source or compatibility +metadata changes. The extension runtime contract is shared by native and WASIX; +changes to that contract correctly affect extension artifacts and runtime lanes. + +App developers select exactly the SQL extensions they use. Release artifacts +and SDK packaging checks must prove unselected extensions do not enter consumer +apps. + +## Provenance + +Every native runtime, broker helper, Node direct runtime, WASIX runtime, AOT +asset, and exact-extension release asset must be covered by: + +- checksum manifests; +- GitHub artifact attestations; +- product-local target metadata; +- package-size evidence where applicable; +- `tools/release/release.py verify-release`. + +Package-native publication remains package-native: Cargo publishes Rust crates, +npm publishes JavaScript/React Native packages, Gradle/Vanniktech publishes +Maven artifacts, SwiftPM resolves tags/assets, and GitHub Releases publish +binary assets. diff --git a/docs/maintainers/repo-structure.md b/docs/maintainers/repo-structure.md new file mode 100644 index 00000000..e30b07c2 --- /dev/null +++ b/docs/maintainers/repo-structure.md @@ -0,0 +1,278 @@ +# Repository Structure + +This repository is organized as a multi-product workspace, not as one Rust crate +with adjacent experiments. + +## Evidence + +- Cargo supports a virtual workspace when the root `Cargo.toml` has + `[workspace]` and no `[package]`. Cargo documents this as useful when there + is no primary package or packages should be kept in separate directories: + https://doc.rust-lang.org/cargo/reference/workspaces.html +- Cargo workspaces share one lockfile and one target directory, which keeps + cross-crate Rust development coherent while letting each package own its own + manifest and public boundary: + https://doc.rust-lang.org/cargo/reference/workspaces.html +- Swift Package Manager expects each package to own a `Package.swift`, products, + targets, and target-scoped resources. Future Swift work should therefore live + under `src/sdks/swift` as a normal Swift package instead of as ad hoc root files: + https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html +- Gradle's multi-project model uses a root build plus isolated subprojects + declared from settings, which maps cleanly to a future Kotlin SDK under + `src/sdks/kotlin`: + https://docs.gradle.org/current/userguide/multi_project_builds.html +- moon provides the product graph, affected-CI selection, and task discovery. + It does not replace package-native tools; Cargo, SwiftPM/Xcode, Gradle, + pnpm/Expo, PostgreSQL build scripts, and shell harnesses remain authoritative: + https://moonrepo.dev/docs + +## Top-Level Policy + +The repository root should contain shared metadata and entrypoints only. Product +source lives under `src//`. + +- `src/runtimes/liboliphaunt/native/` owns the C ABI and PostgreSQL patch stack. +- `src/sdks/rust/` owns the Rust SDK and Cargo package. +- `src/sdks/swift/`, `src/sdks/kotlin/`, + `src/sdks/react-native/`, and `src/sdks/js/` own platform and + runtime SDKs. +- `src/bindings/wasix-rust/` owns the first-class WASM/WASIX product lane. +- `src/*/moon.yml` is the canonical product graph. `tools/policy/sdk-manifest.toml` + remains a small SDK parity registry during the migration and must agree with + moon metadata. +- Tooling lives under `tools/`. +- Benchmarks live under `benchmarks/`. +- `src/docs/` is the public documentation product. It owns public SDK + docs under `src/docs/content/sdk`, generated matrices, tested + snippets, API-reference stubs, and LLM docs rendered into + `target/docs`. +- Cross-product architecture, performance, release, and maintainer source docs + live under `docs/`. +- Shared fixture corpora consumed by at least two product-native test suites + live under `src/shared/fixtures/` and are governed by + `src/shared/contracts/test-matrix.toml`. +- Pinned PostgreSQL source metadata, runtime-level third-party source pins, + toolchain pins, extension-owned source pins, and generated extension catalogs + live under `src/postgres/versions/18`, `src/sources/third-party`, + `src/sources/toolchains`, and `src/extensions`. + +There should be no tracked product source under retired roots such as +`crates/`, `sdks/`, root `liboliphaunt/`, or root product examples. + +Tests, fixtures, and benchmarks follow the consumer surface instead of a single +synthetic root: + +- Product-native tests live in each product's package-native test root: + `src/sdks/rust/tests/`, `src/sdks/swift/Tests/`, + `src/sdks/kotlin/oliphaunt/src/*Test/`, + `src/sdks/react-native/src/__tests__/`, + `src/sdks/js/src/__tests__/`, and + `src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/`. +- Rust SDK release-shape tests are split by contract: config and mode + capability contracts stay in `src/sdks/rust/tests/sdk_config_modes.rs`, + handle lifecycle behavior stays in `src/sdks/rust/tests/sdk_shape.rs`, + native-environment smokes stay in + `src/sdks/rust/tests/sdk_native_smoke.rs`, extension catalog and + release-ready extension selection stays in + `src/sdks/rust/tests/sdk_extensions.rs`, and shared backend protocol + fixtures stay in `src/sdks/rust/tests/protocol_query_fixtures.rs`. +- Product-private fixtures stay beside those tests. Shared fixtures move to + `src/shared/fixtures/` only when the contract is consumed by multiple + products, or when a product-specific boundary fixture needs central policy + enforcement. `src/shared/fixtures/protocol/query-response-cases.json` is the + current shared PostgreSQL backend-response corpus consumed from product-native + Rust, Swift, Kotlin, TypeScript, React Native, and WASM parser tests. +- Benchmark plans, datasets, and published reports live in `benchmarks/`. + Executable benchmark harnesses live in `tools/perf/` unless the harness is a + deliberate product API. + +## Product Boundaries + +- `liboliphaunt` is the native C boundary. It owns PostgreSQL source pins, patches, + exported headers, and native build harnesses. +- `src/sdks/rust` is the Rust-native SDK for Tauri and Rust desktop + apps. It should depend on `liboliphaunt` artifacts through explicit + runtime/build configuration, not on `oliphaunt-wasix` internals. +- `docs/maintainers/rust-sdk-policy.md` is the Rust SDK policy entrypoint. The package + source, tests, and release metadata live in `src/sdks/rust`. +- `src/bindings/wasix-rust/crates/oliphaunt-wasix` is the existing WASIX package. It stays intact as a + release lane and comparison target. It should not expose native engine + selection or link/load `liboliphaunt`; native Rust work belongs in + `src/sdks/rust`. +- `src/runtimes/liboliphaunt/wasix/assets/build` is source-only: scripts, patches, + Docker inputs, and shims. Generated WASIX build and work trees live under + `target/oliphaunt-wasix/wasix-build`. +- `src/sdks/swift` is a normal Swift package for iOS and macOS apps. It owns + `Oliphaunt`, a C header target, and Swift tests. +- `src/sdks/kotlin` is a Gradle multi-project Kotlin Multiplatform build for + Android apps. It owns the common suspend API, cinterop metadata, wrapper, and + Kotlin tests. +- `src/sdks/react-native` is a React Native New Architecture package. It owns the + TypeScript DX layer and TurboModule Codegen spec. Platform runtime behavior + belongs to the Swift and Kotlin SDKs; React Native native code should be + adapter glue, not a parallel PostgreSQL lifecycle implementation. +- `src/sdks/js` is the SDK for Node.js, Bun, Deno, and Tauri JavaScript + apps. It owns JavaScript runtime FFI adapters, npm/JSR package metadata, and + broker/server client orchestration. Its broker implementation depends on the + published `oliphaunt-broker` runtime and the shared `PGOB` protocol, + so that dependency must remain modeled in Moon and product-local release + metadata. + +All SDKs are product peers over the same native PostgreSQL boundary. They should +have parity wherever the target platform can support the behavior honestly; any +gap must be represented as an explicit unsupported error and justified in +`docs/maintainers/sdk-parity-policy.md`. + +## Internal Organization Rules + +- Product crates own their own runtime code. `oliphaunt-wasix` may depend on the + WASIX asset crates; `oliphaunt` may load `liboliphaunt`; neither crate + should call into the other's private modules. +- `tools/policy/check-native-boundaries.sh` enforces the native/legacy split: + the Rust-native SDK and Swift/Kotlin/React Native package manifests must not + depend on `oliphaunt-wasix`, WASIX AOT payload crates, or Wasmer runtime + packages. +- `tools/xtask` is shared repo automation for WASIX assets, release staging, + and optional performance diagnostics. Its default feature set is intentionally + empty; legacy WASIX runtime controls, perf harnesses, template running, and + AOT serializers must be enabled with explicit feature flags. +- `tools/xtask/src/main.rs` is the command router plus shared helpers. WASIX + asset build, packaging, generated manifest, AOT packaging, and staged metadata + orchestration lives in `tools/xtask/src/asset_pipeline.rs`. Source-controlled + asset verification, canonical generated-asset layout checks, asset input + fingerprinting, AOT target catalog checks, and upstream-fix audits live in + `tools/xtask/src/asset_checks.rs`. Generated asset manifest DTOs, AOT + manifest DTOs, asset packaging descriptors, and WASM link-metadata parsing + live in + `tools/xtask/src/asset_manifest.rs`. Asset download/install code lives in + `tools/xtask/src/asset_io.rs`, shared filesystem/archive/hash helpers live in + `tools/xtask/src/fs_utils.rs`, + release workspace assembly lives in `tools/xtask/src/release_workspace.rs`, + source-pin and source-spine handling lives in + `tools/xtask/src/source_spine.rs`, PostgreSQL source/patch-surface guards + live in `tools/xtask/src/postgres_guard.rs`, template execution lives in + `tools/xtask/src/template_runner.rs`, and AOT serialization lives in + `tools/xtask/src/aot_serializer.rs`. Performance benchmark workload/result + construction lives in `tools/perf/runner/src/benchmarks.rs`, report DTOs live + in `tools/perf/runner/src/report.rs`, and legacy WASIX cold/warm probes live + in `tools/perf/runner/src/legacy_wasix.rs`. Native liboliphaunt execution, + child-process entrypoints, and SDK-backed diagnostics live in + `tools/perf/runner/src/native_liboliphaunt.rs`. Native PostgreSQL process, + protocol, and backup/restore controls live in + `tools/perf/runner/src/native_postgres.rs`. Prepared-update benchmark + parsing, transport variants, gates, and native comparison live in + `tools/perf/runner/src/prepared_updates.rs`. Indexed-update, speed-hotspot, + and buffer-cache diagnostics live in `tools/perf/runner/src/diagnostics.rs`. + Benchmark execution should continue to split under `tools/perf/runner/src/` + by collection, aggregation, transport family, diagnostics, and report + rendering. +- Native C ABI concerns are split by layer: + - `src/runtimes/liboliphaunt/native/` for C, PostgreSQL patches, and platform build scripts. + - `src/sdks/rust/src/runtimes/liboliphaunt/native/ffi.rs` for Rust symbol loading and + ABI structs. + - `src/sdks/rust/src/runtimes/liboliphaunt/native/root.rs` for native root locking, + runtime materialization, and opt-in extension asset copying. + - `src/sdks/rust/src/runtimes/liboliphaunt/native/mod.rs` for the Rust runtime/session + implementation. +- Native runtime-resource packaging is split by release artifact concern: + - `src/sdks/rust/src/runtime_resources.rs` for the public resource + package API and selected extension resolution. + - `src/sdks/rust/src/runtime_resources/manifest.rs` for portable + manifest parsing, identifier validation, and runtime artifact path rules. + - `src/sdks/rust/src/runtime_resources/package.rs` for resource-tree + writing, portable tree copying, package manifests, and size reports. + - `src/sdks/rust/src/runtime_resources/extension_artifact.rs` for exact + prebuilt extension artifact creation, archive extraction, and artifact + manifest writing. + - `src/sdks/rust/src/runtime_resources/extension_index.rs` for external + extension artifact index creation, resolution, signing, download, and + checksum verification. + - `src/sdks/rust/src/runtime_resources/static_registry.rs` for iOS and + Android static extension registry metadata, generated C source, and mobile + static archive staging. +- WASM/WASIX runtime internals should keep VM orchestration separate from + reusable host adapters: + - `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs` for + install/root preparation, runtime layout selection, archive validation, and + PGDATA template orchestration. + - `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs` + for PGDATA template copy/clone mechanics, runtime-state exclusion, reflink + fallback, and symlink handling. + - `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs` + for PostgreSQL WASIX module lifecycle, exported function wiring, startup + protocol, and split-initdb command orchestration. + - `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs` + for WASIX virtual stdio adapters, protocol stream attachment, and bounded + process-output capture. + - `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs` + for host filesystem wrapping, `/dev` mounting, eager-copy PGDATA overlays, + and optional filesystem tracing. +- Public runtime and build controls should use `OLIPHAUNT_*`. Use + `LIBOLIPHAUNT_PATH` only for the literal native C library artifact path. +- Large files should have a reason. Once a module mixes lifecycle, packaging, + protocol, and CLI orchestration, split it along those responsibilities before + adding more behavior. + +## Tooling Rules + +- `.moon/` owns product graph, affected task selection, and shared toolchain + pins. Do not duplicate release dependency rules in ad hoc scripts when moon + metadata can express them. +- `package.json` owns JavaScript workspace metadata only. Do not add root + workflow aliases; run product and repo work through Moon targets directly. +- Release-please owns product versions, changelogs, release PRs, and + product-scoped tags. `tools/release/release.py` owns protected publish steps, + registry checks, and GitHub release assets. +- Cargo publishing runs through `tools/release/release.py` and `cargo publish` + from the protected Release workflow. Do not add a Rust-only release + orchestrator beside release-please. +- `tools/xtask` owns Rust-heavy automation and release asset orchestration. +- `tools/policy`, `tools/dev`, `tools/perf`, and `tools/release` own + shell/Python/Node entrypoints by responsibility. CI is thin workflow + orchestration over Moon tasks and the release CLI. +- `tools/policy/check-sdk-parity.sh` is the SDK contract orchestrator. Shared + shell assertions live in `tools/policy/sdk-check-lib.sh`; exact mobile + extension packaging checks live in + `tools/policy/check-sdk-mobile-extension-surface.sh`; React Native + private-runtime boundary checks live in + `tools/policy/check-react-native-boundary.sh`. +- `prek` owns Git hooks as a language-neutral runner for whitespace, format, + and commit-message guards. Heavy asset, lockfile, and workspace checks belong + in Moon tasks, product-local tools, release CLI subcommands, and CI, not + automatic pre-push hooks. +- `actionlint` and `zizmor` are intentionally paired: actionlint validates + GitHub Actions syntax and expression semantics; zizmor audits workflow + security posture. Do not add a third workflow linter without removing overlap. +- Package-native tools stay native: Cargo for Rust, SwiftPM/Xcode tooling for + Swift, Gradle for Kotlin/Android, and React Native's own Codegen/build flow + for React Native. + +## Current Tree + +```text +. +├── Cargo.toml +├── package.json +├── benchmarks/ +├── docs/ +├── examples/ +│ └── integration/ +├── src/ +│ ├── shared/ +│ │ ├── contracts/ +│ │ └── fixtures/ +│ ├── liboliphaunt/ +│ ├── oliphaunt-rust/ +│ ├── oliphaunt-swift/ +│ ├── oliphaunt-kotlin/ +│ ├── oliphaunt-react-native/ +│ ├── oliphaunt-js/ +│ ├── oliphaunt-wasix/ +│ └── docs/ +└── tools/ + ├── dev/ + ├── perf/ + ├── policy/ + ├── release/ + └── xtask/ +``` diff --git a/docs/maintainers/rust-sdk-policy.md b/docs/maintainers/rust-sdk-policy.md new file mode 100644 index 00000000..d1b41e27 --- /dev/null +++ b/docs/maintainers/rust-sdk-policy.md @@ -0,0 +1,59 @@ +# oliphaunt Rust SDK Policy + +The Rust SDK is a peer product SDK for Tauri and Rust desktop apps. Its package +source lives in `src/sdks/rust` rather than root docs so Cargo workspace +ownership, release metadata, examples, benches, and tests stay idiomatic. + +Target users: + +- Tauri desktop apps; +- Rust desktop apps that want embedded PostgreSQL without sidecars in direct + mode; +- Rust services or developer tools that want broker/server modes with local + PostgreSQL compatibility. + +Validate the Rust SDK with: + +```bash +moon run oliphaunt-rust:check +``` + +Other SDKs should match the shared Oliphaunt concepts where the platform allows it: + +- engine modes: native direct, native broker, native server; +- raw PostgreSQL protocol boundary; +- typed query helpers layered above raw protocol; +- transaction helpers that keep one physical session pinned and reject + unpinned interleaving, including backup/checkpoint work, while still allowing + pinned raw and streaming protocol calls. Use `transaction()` when you want an + explicit handle, or `with_transaction(async |tx| { ... })` for commit/rollback + closure ergonomics; +- `checkpoint()` for explicit PostgreSQL checkpoint requests through the opened + engine; +- startup identity through builder-level `username(...)` and `database(...)` + options that feed direct, broker, and server-owned PostgreSQL sessions; +- SDK-owned executable/tooling paths such as `initdb_tooling_only(...)`, + `broker_executable(...)`, and `server_executable(...)` are rejected when + empty or NUL-containing before process startup; +- structured PostgreSQL errors with SQLSTATE and raw `ErrorResponse` fields; +- exact extensions selected before open; +- physical backup/restore for same-version archives; +- capability reporting for raw and streaming protocol, cancellation, + backup/restore, simple-query execution, extensions, and session + semantics, including concrete backup and restore format support through + capability and opened-handle `supports_backup_format` and + `supports_restore_format` helpers; +- `max_client_sessions(...)` is an honest concurrency knob: direct and broker mode reject values other than `1`; server mode is the mode for independent + PostgreSQL client sessions and pools; +- SDK-boundary rejection for unsupported backup formats before work is queued + onto the engine executor, and unsupported restore formats before a target + root is materialized; +- explicit mode support discovery through + `EngineCapabilities::rust_sdk_support()`; +- cancellation and close semantics; +- packaged runtime/template resources. + +Swift, Kotlin, TypeScript, React Native, and WASM may expose platform-native +naming, async, and packaging conventions, but deviations from the shared +Oliphaunt contract should be documented and justified rather than allowed to +drift silently. diff --git a/docs/maintainers/sdk-api-surface.md b/docs/maintainers/sdk-api-surface.md new file mode 100644 index 00000000..fda7c9ea --- /dev/null +++ b/docs/maintainers/sdk-api-surface.md @@ -0,0 +1,809 @@ + +# SDK API Surface Inventory + +This no-build inventory makes public SDK drift visible in review. It is a symbol-level guard, not a replacement for full language reference documentation. + +Regenerate with: + +```sh +node tools/policy/generate-sdk-api-surface.mjs --write +``` + +## Rust: oliphaunt + +- `oliphaunt::BackgroundCheckpointSkipReason` +- `oliphaunt::BackgroundPreparationOptions` +- `oliphaunt::BackgroundPreparationResult` +- `oliphaunt::BackupArtifact` +- `oliphaunt::BackupFormat` +- `oliphaunt::BackupRequest` +- `oliphaunt::BenchmarkMetric` +- `oliphaunt::BenchmarkTarget` +- `oliphaunt::BootstrapStrategy` +- `oliphaunt::build_native_runtime_resources` +- `oliphaunt::create_prebuilt_extension_artifact` +- `oliphaunt::create_prebuilt_extension_artifact_index` +- `oliphaunt::DatabaseRoot` +- `oliphaunt::DEFAULT_DATABASE` +- `oliphaunt::DEFAULT_USERNAME` +- `oliphaunt::DurabilityProfile` +- `oliphaunt::EngineCancel` +- `oliphaunt::EngineCapabilities` +- `oliphaunt::EngineMode` +- `oliphaunt::EngineModeSupport` +- `oliphaunt::EngineSession` +- `oliphaunt::Error` +- `oliphaunt::Extension` +- `oliphaunt::ExtensionArtifactPolicy` +- `oliphaunt::ExtensionCoverage` +- `oliphaunt::ExtensionManifestEntry` +- `oliphaunt::ExtensionModuleAsset` +- `oliphaunt::ExtensionRedistribution` +- `oliphaunt::ExtensionSizeReport` +- `oliphaunt::ExtensionSmokeCoverage` +- `oliphaunt::ExtensionSmokePlan` +- `oliphaunt::ExtensionSourceKind` +- `oliphaunt::ExtensionSqlAsset` +- `oliphaunt::list_prebuilt_extension_artifact_index_catalog` +- `oliphaunt::MobileStaticLinkStatus` +- `oliphaunt::MobileStaticRegistryMetadata` +- `oliphaunt::MobileStaticRegistryState` +- `oliphaunt::NATIVE_EXTENSION_MANIFEST` +- `oliphaunt::NativeBrokerConfig` +- `oliphaunt::NativeBrokerRuntime` +- `oliphaunt::NativeDirectConfig` +- `oliphaunt::NativeExtensionArtifact` +- `oliphaunt::NativeExtensionArtifactFormat` +- `oliphaunt::NativeExtensionArtifactIndex` +- `oliphaunt::NativeExtensionArtifactIndexArtifact` +- `oliphaunt::NativeExtensionArtifactIndexCatalog` +- `oliphaunt::NativeExtensionArtifactIndexCatalogEntry` +- `oliphaunt::NativeExtensionArtifactIndexCreateOptions` +- `oliphaunt::NativeExtensionArtifactIndexOptions` +- `oliphaunt::NativeExtensionArtifactIndexResolution` +- `oliphaunt::NativeExtensionArtifactIndexSignature` +- `oliphaunt::NativeExtensionArtifactIndexSigningOptions` +- `oliphaunt::NativeExtensionArtifactIndexTrustRoot` +- `oliphaunt::NativeExtensionArtifactOptions` +- `oliphaunt::NativeExtensionMobileStaticArchive` +- `oliphaunt::NativeExtensionMobileStaticDependencyArchive` +- `oliphaunt::NativePrebuiltExtensionArtifact` +- `oliphaunt::NativeRuntime` +- `oliphaunt::NativeRuntimeResourceOptions` +- `oliphaunt::NativeRuntimeResources` +- `oliphaunt::NativeRuntimeResourceSizeReport` +- `oliphaunt::NativeServerConfig` +- `oliphaunt::NativeServerRuntime` +- `oliphaunt::Oliphaunt` +- `oliphaunt::OliphauntBuilder` +- `oliphaunt::OliphauntRuntime` +- `oliphaunt::OliphauntRuntimeSource` +- `oliphaunt::OpenConfig` +- `oliphaunt::parse_query_response` +- `oliphaunt::PerformanceGate` +- `oliphaunt::PerformanceGateSet` +- `oliphaunt::PerformanceOperator` +- `oliphaunt::PostgresError` +- `oliphaunt::PostgresErrorField` +- `oliphaunt::PostgresStartupGuc` +- `oliphaunt::ProtocolRequest` +- `oliphaunt::ProtocolResponse` +- `oliphaunt::QueryField` +- `oliphaunt::QueryFormat` +- `oliphaunt::QueryParam` +- `oliphaunt::QueryResult` +- `oliphaunt::QueryRow` +- `oliphaunt::required_shared_preload_libraries` +- `oliphaunt::resolve_extension_selection` +- `oliphaunt::resolve_prebuilt_extension_artifacts_from_indexes` +- `oliphaunt::RestoreRequest` +- `oliphaunt::RestoreTargetPolicy` +- `oliphaunt::Result` +- `oliphaunt::RootLockPolicy` +- `oliphaunt::RuntimeFootprintProfile` +- `oliphaunt::RuntimeUnavailable` +- `oliphaunt::SessionConcurrency` +- `oliphaunt::SessionPin` +- `oliphaunt::sign_prebuilt_extension_artifact_index` +- `oliphaunt::StorageConfig` +- `oliphaunt::Transaction` + +## Swift: Oliphaunt + +- `actor OliphauntDatabase` +- `enum OliphauntBackgroundCheckpointSkipReason` +- `enum OliphauntBackupFormat` +- `enum OliphauntDurability` +- `enum OliphauntEngineMode` +- `enum OliphauntError` +- `enum OliphauntProtocol` +- `enum OliphauntQueryFormat` +- `enum OliphauntQueryParam` +- `enum OliphauntRestoreTargetPolicy` +- `enum OliphauntRuntimeFootprintProfile` +- `enum OliphauntSDKSupport` +- `extension OliphauntDatabase` +- `extension OliphauntSession` +- `extension OliphauntTransaction` +- `OliphauntBackgroundPreparationOptions.cancelActiveWork` +- `OliphauntBackgroundPreparationOptions.checkpointWhenIdle` +- `OliphauntBackgroundPreparationOptions.init` +- `OliphauntBackgroundPreparationResult.cancelledActiveWork` +- `OliphauntBackgroundPreparationResult.checkpointed` +- `OliphauntBackgroundPreparationResult.init` +- `OliphauntBackgroundPreparationResult.skippedCheckpointReason` +- `OliphauntBackupArtifact.bytes` +- `OliphauntBackupArtifact.format` +- `OliphauntBackupArtifact.init` +- `OliphauntBackupRequest.format` +- `OliphauntBackupRequest.init` +- `OliphauntCapabilities.backupFormats` +- `OliphauntCapabilities.backupRestore` +- `OliphauntCapabilities.connectionString` +- `OliphauntCapabilities.crashRestartable` +- `OliphauntCapabilities.extensions` +- `OliphauntCapabilities.independentSessions` +- `OliphauntCapabilities.init` +- `OliphauntCapabilities.maxClientSessions` +- `OliphauntCapabilities.mode` +- `OliphauntCapabilities.multiRoot` +- `OliphauntCapabilities.processIsolated` +- `OliphauntCapabilities.protocolRaw` +- `OliphauntCapabilities.protocolStream` +- `OliphauntCapabilities.queryCancel` +- `OliphauntCapabilities.reopenable` +- `OliphauntCapabilities.restoreFormats` +- `OliphauntCapabilities.rootSwitchable` +- `OliphauntCapabilities.sameRootLogicalReopen` +- `OliphauntCapabilities.simpleQuery` +- `OliphauntCapabilities.supportsBackupFormat()` +- `OliphauntCapabilities.supportsRestoreFormat()` +- `OliphauntConfiguration.database` +- `OliphauntConfiguration.durability` +- `OliphauntConfiguration.extensions` +- `OliphauntConfiguration.init` +- `OliphauntConfiguration.mode` +- `OliphauntConfiguration.root` +- `OliphauntConfiguration.runtimeFootprint` +- `OliphauntConfiguration.startupGUCs` +- `OliphauntConfiguration.username` +- `OliphauntDatabase.backup()` +- `OliphauntDatabase.cancel()` +- `OliphauntDatabase.capabilities()` +- `OliphauntDatabase.checkpoint()` +- `OliphauntDatabase.close()` +- `OliphauntDatabase.connectionString()` +- `OliphauntDatabase.execProtocolRaw()` +- `OliphauntDatabase.execProtocolStream()` +- `OliphauntDatabase.execute()` +- `OliphauntDatabase.open()` +- `OliphauntDatabase.prepareForBackground()` +- `OliphauntDatabase.query()` +- `OliphauntDatabase.restore()` +- `OliphauntDatabase.resumeFromBackground()` +- `OliphauntDatabase.supportedModes()` +- `OliphauntDatabase.supportsBackupFormat()` +- `OliphauntDatabase.supportsRestoreFormat()` +- `OliphauntDatabase.transaction()` +- `OliphauntDefaultEngine.brokerUnavailableReason` +- `OliphauntDefaultEngine.init` +- `OliphauntDefaultEngine.open()` +- `OliphauntDefaultEngine.restore()` +- `OliphauntDefaultEngine.serverUnavailableReason` +- `OliphauntDefaultEngine.supportedModes` +- `OliphauntEngineModeSupport.available` +- `OliphauntEngineModeSupport.capabilities` +- `OliphauntEngineModeSupport.init` +- `OliphauntEngineModeSupport.mode` +- `OliphauntEngineModeSupport.unavailableReason` +- `OliphauntError.description` +- `OliphauntExtensionArtifactResolution.assets` +- `OliphauntExtensionArtifactResolution.init` +- `OliphauntExtensionArtifactResolution.requestedExtensions` +- `OliphauntExtensionArtifactResolution.resolvedExtensions` +- `OliphauntExtensionArtifactResolver.init` +- `OliphauntExtensionArtifactResolver.manifests` +- `OliphauntExtensionArtifactResolver.resolveNativeArtifacts()` +- `OliphauntExtensionReleaseAsset.family` +- `OliphauntExtensionReleaseAsset.init` +- `OliphauntExtensionReleaseAsset.kind` +- `OliphauntExtensionReleaseAsset.name` +- `OliphauntExtensionReleaseAsset.target` +- `OliphauntExtensionReleaseManifest.asset()` +- `OliphauntExtensionReleaseManifest.assets` +- `OliphauntExtensionReleaseManifest.dependencies` +- `OliphauntExtensionReleaseManifest.desktopReleaseReady` +- `OliphauntExtensionReleaseManifest.init` +- `OliphauntExtensionReleaseManifest.mobileReleaseReady` +- `OliphauntExtensionReleaseManifest.nativeModuleStem` +- `OliphauntExtensionReleaseManifest.product` +- `OliphauntExtensionReleaseManifest.requiredAsset()` +- `OliphauntExtensionReleaseManifest.sharedPreloadLibraries` +- `OliphauntExtensionReleaseManifest.sqlName` +- `OliphauntExtensionReleaseManifest.version` +- `OliphauntExtensionSizeReport.bytes` +- `OliphauntExtensionSizeReport.fileCount` +- `OliphauntExtensionSizeReport.init` +- `OliphauntExtensionSizeReport.name` +- `OliphauntNativeDirectEngine.database` +- `OliphauntNativeDirectEngine.init` +- `OliphauntNativeDirectEngine.libraryURL` +- `OliphauntNativeDirectEngine.open()` +- `OliphauntNativeDirectEngine.restore()` +- `OliphauntNativeDirectEngine.runtimeDirectory` +- `OliphauntNativeDirectEngine.runtimeResources` +- `OliphauntNativeDirectEngine.supportedModes` +- `OliphauntNativeDirectEngine.username` +- `OliphauntPostgresError.columnName` +- `OliphauntPostgresError.constraintName` +- `OliphauntPostgresError.dataTypeName` +- `OliphauntPostgresError.description` +- `OliphauntPostgresError.detail` +- `OliphauntPostgresError.fallback()` +- `OliphauntPostgresError.fields` +- `OliphauntPostgresError.hint` +- `OliphauntPostgresError.init` +- `OliphauntPostgresError.message` +- `OliphauntPostgresError.position` +- `OliphauntPostgresError.schemaName` +- `OliphauntPostgresError.severity` +- `OliphauntPostgresError.sqlstate` +- `OliphauntPostgresError.tableName` +- `OliphauntPostgresError.whereText` +- `OliphauntPostgresErrorField.code` +- `OliphauntPostgresErrorField.init` +- `OliphauntPostgresErrorField.value` +- `OliphauntProtocol.extendedQuery()` +- `OliphauntProtocol.simpleQuery()` +- `OliphauntQueryField.format` +- `OliphauntQueryField.name` +- `OliphauntQueryField.tableAttribute` +- `OliphauntQueryField.tableOID` +- `OliphauntQueryField.typeModifier` +- `OliphauntQueryField.typeOID` +- `OliphauntQueryField.typeSize` +- `OliphauntQueryParam.binary()` +- `OliphauntQueryResult.commandTag` +- `OliphauntQueryResult.fieldIndex()` +- `OliphauntQueryResult.fields` +- `OliphauntQueryResult.getText()` +- `OliphauntQueryResult.rowCount` +- `OliphauntQueryResult.rows` +- `OliphauntQueryRow.text()` +- `OliphauntQueryRow.values` +- `OliphauntResolvedExtensionAsset.asset` +- `OliphauntResolvedExtensionAsset.init` +- `OliphauntResolvedExtensionAsset.product` +- `OliphauntResolvedExtensionAsset.sqlName` +- `OliphauntResolvedExtensionAsset.version` +- `OliphauntRestoreRequest.artifact` +- `OliphauntRestoreRequest.init` +- `OliphauntRestoreRequest.replaceExisting()` +- `OliphauntRestoreRequest.root` +- `OliphauntRestoreRequest.targetPolicy` +- `OliphauntRuntimeResources.bundled()` +- `OliphauntRuntimeResources.cacheRoot` +- `OliphauntRuntimeResources.defaultCacheRoot()` +- `OliphauntRuntimeResources.init` +- `OliphauntRuntimeResources.materializeRuntime()` +- `OliphauntRuntimeResources.packageSizeReport()` +- `OliphauntRuntimeResources.preparePgdata()` +- `OliphauntRuntimeResources.resourceRoot` +- `OliphauntRuntimeResourceSizeReport.extensions` +- `OliphauntRuntimeResourceSizeReport.init` +- `OliphauntRuntimeResourceSizeReport.mobileStaticRegistryPending` +- `OliphauntRuntimeResourceSizeReport.mobileStaticRegistryRegistered` +- `OliphauntRuntimeResourceSizeReport.mobileStaticRegistryState` +- `OliphauntRuntimeResourceSizeReport.nativeModuleStems` +- `OliphauntRuntimeResourceSizeReport.packageBytes` +- `OliphauntRuntimeResourceSizeReport.runtimeBytes` +- `OliphauntRuntimeResourceSizeReport.selectedExtensionBytes` +- `OliphauntRuntimeResourceSizeReport.staticRegistryBytes` +- `OliphauntRuntimeResourceSizeReport.templatePgdataBytes` +- `OliphauntSDKSupport.allModes` +- `OliphauntSDKSupport.capabilities()` +- `OliphauntSDKSupport.nativeDirectOnly()` +- `OliphauntSDKSupport.unavailable()` +- `OliphauntSession.execProtocolStream()` +- `OliphauntStartupGUC.init` +- `OliphauntStartupGUC.name` +- `OliphauntStartupGUC.value` +- `OliphauntTransaction.execProtocolRaw()` +- `OliphauntTransaction.execProtocolStream()` +- `OliphauntTransaction.execute()` +- `OliphauntTransaction.query()` +- `parseOliphauntQueryResponse()` +- `protocol OliphauntEngine` +- `protocol OliphauntEngineSupportProvider` +- `protocol OliphauntSession` +- `RuntimeUnavailableEngine.init` +- `RuntimeUnavailableEngine.open()` +- `RuntimeUnavailableEngine.restore()` +- `RuntimeUnavailableEngine.supportedModes` +- `struct OliphauntBackgroundPreparationOptions` +- `struct OliphauntBackgroundPreparationResult` +- `struct OliphauntBackupArtifact` +- `struct OliphauntBackupRequest` +- `struct OliphauntCapabilities` +- `struct OliphauntConfiguration` +- `struct OliphauntDefaultEngine` +- `struct OliphauntEngineModeSupport` +- `struct OliphauntExtensionArtifactResolution` +- `struct OliphauntExtensionArtifactResolver` +- `struct OliphauntExtensionReleaseAsset` +- `struct OliphauntExtensionReleaseManifest` +- `struct OliphauntExtensionSizeReport` +- `struct OliphauntNativeDirectEngine` +- `struct OliphauntPostgresError` +- `struct OliphauntPostgresErrorField` +- `struct OliphauntQueryField` +- `struct OliphauntQueryResult` +- `struct OliphauntQueryRow` +- `struct OliphauntResolvedExtensionAsset` +- `struct OliphauntRestoreRequest` +- `struct OliphauntRuntimeResources` +- `struct OliphauntRuntimeResourceSizeReport` +- `struct OliphauntStartupGUC` +- `struct OliphauntTransaction` +- `struct RuntimeUnavailableEngine` + +## Kotlin: oliphaunt + +### commonMain + +- `class BackgroundPreparationOptions` +- `class BackgroundPreparationResult` +- `class BackupArtifact` +- `class BackupRequest` +- `class EngineCapabilities` +- `class EngineModeSupport` +- `class OliphauntConfig` +- `class OliphauntDatabase` +- `class OliphauntException` +- `class OliphauntTransaction` +- `class PostgresError` +- `class PostgresErrorField` +- `class PostgresException` +- `class PostgresStartupGuc` +- `class ProtocolRequest` +- `class ProtocolResponse` +- `class QueryField` +- `class QueryFormat` +- `class QueryFormat.Other` +- `class QueryParam` +- `class QueryParam.Binary` +- `class QueryParam.Text` +- `class QueryResult` +- `class QueryRow` +- `class RestoreRequest` +- `class RuntimeUnavailableEngine` +- `defaultOliphauntEngine()` +- `EngineCapabilities.supportsBackupFormat()` +- `EngineCapabilities.supportsRestoreFormat()` +- `enum class BackgroundCheckpointSkipReason` +- `enum class BackupFormat` +- `enum class DurabilityProfile` +- `enum class EngineMode` +- `enum class RestoreTargetPolicy` +- `enum class RuntimeFootprintProfile` +- `interface OliphauntEngine` +- `interface OliphauntSession` +- `object OliphauntRuntimeSupport` +- `object QueryFormat.Binary` +- `object QueryFormat.Text` +- `object QueryParam.Null` +- `OliphauntDatabase.backup()` +- `OliphauntDatabase.cancel()` +- `OliphauntDatabase.capabilities()` +- `OliphauntDatabase.checkpoint()` +- `OliphauntDatabase.close()` +- `OliphauntDatabase.connectionString()` +- `OliphauntDatabase.execProtocolRaw()` +- `OliphauntDatabase.execProtocolStream()` +- `OliphauntDatabase.execute()` +- `OliphauntDatabase.open()` +- `OliphauntDatabase.prepareForBackground()` +- `OliphauntDatabase.query()` +- `OliphauntDatabase.restore()` +- `OliphauntDatabase.resumeFromBackground()` +- `OliphauntDatabase.supportedModes()` +- `OliphauntDatabase.supportsBackupFormat()` +- `OliphauntDatabase.supportsRestoreFormat()` +- `OliphauntDatabase.transaction()` +- `OliphauntEngine.open()` +- `OliphauntEngine.restore()` +- `OliphauntEngine.supportedModes()` +- `OliphauntRuntimeSupport.allModes` +- `OliphauntRuntimeSupport.capabilitiesFor()` +- `OliphauntRuntimeSupport.nativeDirectOnly()` +- `OliphauntRuntimeSupport.unavailable()` +- `OliphauntSession.backup()` +- `OliphauntSession.cancel()` +- `OliphauntSession.capabilities()` +- `OliphauntSession.close()` +- `OliphauntSession.execProtocolRaw()` +- `OliphauntSession.execProtocolStream()` +- `OliphauntTransaction.execProtocolRaw()` +- `OliphauntTransaction.execProtocolStream()` +- `OliphauntTransaction.execute()` +- `OliphauntTransaction.query()` +- `parseQueryResponse()` +- `PostgresError.fallback()` +- `PostgresError.fromFields()` +- `PostgresException.postgresError` +- `ProtocolRequest.Companion.extendedQuery()` +- `ProtocolRequest.simpleQuery()` +- `QueryFormat.fromCode()` +- `QueryParam.binary()` +- `QueryParam.text()` +- `QueryParam.value` +- `QueryResult.fieldIndex()` +- `QueryResult.getText()` +- `QueryResult.rowCount` +- `QueryRow.text()` +- `QueryRow.values` +- `RestoreRequest.replaceExisting()` + +### androidMain + +- `AndroidNativeDirectEngine.packageSizeReport()` +- `class AndroidNativeDirectEngine` +- `class OliphauntExtensionSizeReport` +- `class OliphauntPackageSizeReport` +- `defaultOliphauntEngine()` +- `object OliphauntAndroid` +- `OliphauntAndroid.open()` +- `OliphauntAndroid.packageSizeReport()` +- `OliphauntAndroid.restore()` +- `OliphauntAndroid.supportedModes()` + +### jvmMain + +- `defaultOliphauntEngine()` + +### nativeMain + +- `class NativeDirectEngine` +- `defaultOliphauntEngine()` + +## React Native: @oliphaunt/react-native + +### Types + +- `BackgroundPreparationOptions` +- `BackgroundPreparationResult` +- `BackupArtifact` +- `BackupFormat` +- `BinaryInput` +- `DurabilityProfile` +- `EngineCapabilities` +- `EngineMode` +- `EngineModeSupport` +- `ExtensionSizeReport` +- `JsiRawProtocolTransport` +- `LatencySummary` +- `NativeCapabilities` +- `NativeEngineModeSupport` +- `NativeExtensionSizeReport` +- `NativeOpenConfig` +- `NativePackageSizeReport` +- `NativeProcessMemoryReport` +- `NativeResourceConfig` +- `OliphauntClient` +- `OliphauntTransaction` +- `OpenConfig` +- `PackageSizeReport` +- `PackageSizeReportOptions` +- `PostgresErrorField` +- `PostgresSettings` +- `PostgresStartupGUC` +- `ProcessMemoryReport` +- `ProtocolChunkCallback` +- `QueryBinaryInput` +- `QueryField` +- `QueryFormat` +- `QueryParam` +- `QueryResult` +- `QueryRow` +- `RawProtocolTransport` +- `ReactNativeBenchmarkOptions` +- `ReactNativeBenchmarkReport` +- `ReactNativeBenchmarkWorkload` +- `ReactNativeSmokeOptions` +- `ReactNativeSmokeReport` +- `RestoreOptions` +- `RuntimeFootprintProfile` +- `Spec` +- `ThroughputSummary` + +### Values + +- `createOliphauntClient` +- `extendedQuery` +- `Oliphaunt` +- `OliphauntDatabase` +- `parseQueryResponse` +- `PostgresError` +- `runInstalledOliphauntReactNativeBenchmark` +- `runInstalledOliphauntReactNativeSmoke` +- `runOliphauntReactNativeBenchmark` +- `runOliphauntReactNativeSmoke` +- `simpleQuery` +- `supportsBackupFormat` +- `supportsRestoreFormat` + +### Members + +- `BackgroundPreparationOptions.cancelActiveWork` +- `BackgroundPreparationOptions.checkpointWhenIdle` +- `BackgroundPreparationResult.cancelledActiveWork` +- `BackgroundPreparationResult.checkpointed` +- `BackgroundPreparationResult.skippedCheckpointReason` +- `BackupArtifact.bytes` +- `BackupArtifact.format` +- `createOliphauntClient()` +- `EngineCapabilities.backupFormats` +- `EngineCapabilities.backupRestore` +- `EngineCapabilities.connectionString` +- `EngineCapabilities.crashRestartable` +- `EngineCapabilities.engine` +- `EngineCapabilities.extensions` +- `EngineCapabilities.independentSessions` +- `EngineCapabilities.maxClientSessions` +- `EngineCapabilities.multiRoot` +- `EngineCapabilities.processIsolated` +- `EngineCapabilities.protocolRaw` +- `EngineCapabilities.protocolStream` +- `EngineCapabilities.queryCancel` +- `EngineCapabilities.rawProtocolTransport` +- `EngineCapabilities.reopenable` +- `EngineCapabilities.restoreFormats` +- `EngineCapabilities.rootSwitchable` +- `EngineCapabilities.sameRootLogicalReopen` +- `EngineCapabilities.simpleQuery` +- `EngineModeSupport.available` +- `EngineModeSupport.capabilities` +- `EngineModeSupport.engine` +- `EngineModeSupport.unavailableReason` +- `extendedQuery()` +- `ExtensionSizeReport.bytes` +- `ExtensionSizeReport.fileCount` +- `ExtensionSizeReport.name` +- `OliphauntClient.open()` +- `OliphauntClient.packageSizeReport()` +- `OliphauntClient.processMemory()` +- `OliphauntClient.restore()` +- `OliphauntClient.supportedModes()` +- `OliphauntDatabase.backup()` +- `OliphauntDatabase.cancel()` +- `OliphauntDatabase.capabilities()` +- `OliphauntDatabase.checkpoint()` +- `OliphauntDatabase.close()` +- `OliphauntDatabase.connectionString()` +- `OliphauntDatabase.execProtocolRaw()` +- `OliphauntDatabase.execProtocolStream()` +- `OliphauntDatabase.execute()` +- `OliphauntDatabase.handle` +- `OliphauntDatabase.prepareForBackground()` +- `OliphauntDatabase.query()` +- `OliphauntDatabase.resumeFromBackground()` +- `OliphauntDatabase.supportsBackupFormat()` +- `OliphauntDatabase.supportsRestoreFormat()` +- `OliphauntTransaction.execProtocolRaw()` +- `OliphauntTransaction.execProtocolStream()` +- `OliphauntTransaction.execute()` +- `OliphauntTransaction.query()` +- `OpenConfig.database` +- `OpenConfig.durability` +- `OpenConfig.engine` +- `OpenConfig.extensions` +- `OpenConfig.libraryPath` +- `OpenConfig.resourceRoot` +- `OpenConfig.root` +- `OpenConfig.runtimeDirectory` +- `OpenConfig.runtimeFootprint` +- `OpenConfig.startupGUCs` +- `OpenConfig.temporary` +- `OpenConfig.username` +- `PackageSizeReport.extensions` +- `PackageSizeReport.mobileStaticRegistryPending` +- `PackageSizeReport.mobileStaticRegistryRegistered` +- `PackageSizeReport.mobileStaticRegistryState` +- `PackageSizeReport.nativeModuleStems` +- `PackageSizeReport.packageBytes` +- `PackageSizeReport.runtimeBytes` +- `PackageSizeReport.selectedExtensionBytes` +- `PackageSizeReport.staticRegistryBytes` +- `PackageSizeReport.templatePgdataBytes` +- `PackageSizeReportOptions.resourceRoot` +- `parseQueryResponse()` +- `PostgresErrorField.code` +- `PostgresErrorField.value` +- `ProcessMemoryReport.nativeHeapAllocatedBytes` +- `ProcessMemoryReport.nativeHeapSizeBytes` +- `ProcessMemoryReport.peakResidentBytes` +- `ProcessMemoryReport.physicalFootprintBytes` +- `ProcessMemoryReport.residentBytes` +- `ProcessMemoryReport.runtimeFreeBytes` +- `ProcessMemoryReport.runtimeTotalBytes` +- `ProcessMemoryReport.source` +- `ProcessMemoryReport.totalPrivateDirtyKb` +- `ProcessMemoryReport.totalPssKb` +- `ProcessMemoryReport.totalSharedDirtyKb` +- `ProcessMemoryReport.virtualBytes` +- `QueryField.format` +- `QueryField.name` +- `QueryField.tableAttribute` +- `QueryField.tableOid` +- `QueryField.typeModifier` +- `QueryField.typeOid` +- `QueryField.typeSize` +- `QueryResult.commandTag` +- `QueryResult.fieldIndex()` +- `QueryResult.fields` +- `QueryResult.getText()` +- `QueryResult.rowCount` +- `QueryResult.rows` +- `QueryRow.text()` +- `QueryRow.values` +- `RestoreOptions.artifact` +- `RestoreOptions.libraryPath` +- `RestoreOptions.replaceExisting` +- `RestoreOptions.root` +- `simpleQuery()` +- `supportsBackupFormat()` +- `supportsRestoreFormat()` + +## TypeScript: @oliphaunt/ts + +### Types + +- `BackgroundPreparationOptions` +- `BackgroundPreparationResult` +- `BackupArtifact` +- `BackupFormat` +- `BinaryInput` +- `BrokerTransport` +- `DurabilityProfile` +- `EngineCapabilities` +- `EngineMode` +- `EngineModeSupport` +- `JavaScriptRuntime` +- `MaybePromise` +- `NativeBinding` +- `NativeBindingOptions` +- `NativeHandle` +- `NativeOpenConfig` +- `NativeRestoreOptions` +- `NormalizedOpenConfig` +- `OliphauntClient` +- `OliphauntTransaction` +- `OpenConfig` +- `PostgresStartupGUC` +- `ProtocolChunkCallback` +- `RawProtocolTransport` +- `RestoreOptions` +- `RuntimeBinding` +- `RuntimeFootprintProfile` +- `RuntimeHandle` +- `SupportedModesOptions` + +### Values + +- `assertSuccessfulQueryResponse` +- `createBunNativeBinding` +- `createDefaultNativeBinding` +- `createDenoNativeBinding` +- `createNodeNativeBinding` +- `createOliphauntClient` +- `extendedQuery` +- `nativeDirectCapabilities` +- `Oliphaunt` +- `OliphauntDatabase` +- `parseQueryResponse` +- `PostgresError` +- `simpleQuery` +- `supportsBackupFormat` +- `supportsRestoreFormat` +- `toUint8Array` +- `type NativeBindingFactory` +- `type PostgresErrorField` +- `type QueryBinaryInput` +- `type QueryField` +- `type QueryFormat` +- `type QueryParam` +- `type QueryResult` +- `type QueryRow` + +### Members + +- `assertSuccessfulQueryResponse()` +- `BackgroundPreparationOptions.cancelActiveWork` +- `BackgroundPreparationOptions.checkpointWhenIdle` +- `BackgroundPreparationResult.cancelledActiveWork` +- `BackgroundPreparationResult.checkpointed` +- `BackgroundPreparationResult.skippedCheckpointReason` +- `BackupArtifact.bytes` +- `BackupArtifact.format` +- `createOliphauntClient()` +- `EngineCapabilities.backupFormats` +- `EngineCapabilities.backupRestore` +- `EngineCapabilities.connectionString` +- `EngineCapabilities.crashRestartable` +- `EngineCapabilities.engine` +- `EngineCapabilities.extensions` +- `EngineCapabilities.independentSessions` +- `EngineCapabilities.maxClientSessions` +- `EngineCapabilities.multiRoot` +- `EngineCapabilities.processIsolated` +- `EngineCapabilities.protocolRaw` +- `EngineCapabilities.protocolStream` +- `EngineCapabilities.queryCancel` +- `EngineCapabilities.rawProtocolTransport` +- `EngineCapabilities.reopenable` +- `EngineCapabilities.restoreFormats` +- `EngineCapabilities.rootSwitchable` +- `EngineCapabilities.sameRootLogicalReopen` +- `EngineCapabilities.simpleQuery` +- `EngineModeSupport.available` +- `EngineModeSupport.capabilities` +- `EngineModeSupport.engine` +- `EngineModeSupport.unavailableReason` +- `extendedQuery()` +- `OliphauntClient.open()` +- `OliphauntClient.restore()` +- `OliphauntClient.supportedModes()` +- `OliphauntDatabase.backup()` +- `OliphauntDatabase.cancel()` +- `OliphauntDatabase.capabilities()` +- `OliphauntDatabase.checkpoint()` +- `OliphauntDatabase.close()` +- `OliphauntDatabase.connectionString()` +- `OliphauntDatabase.execProtocolRaw()` +- `OliphauntDatabase.execProtocolStream()` +- `OliphauntDatabase.execute()` +- `OliphauntDatabase.handle` +- `OliphauntDatabase.prepareForBackground()` +- `OliphauntDatabase.query()` +- `OliphauntDatabase.resumeFromBackground()` +- `OliphauntDatabase.supportsBackupFormat()` +- `OliphauntDatabase.supportsRestoreFormat()` +- `OliphauntTransaction.execProtocolRaw()` +- `OliphauntTransaction.execProtocolStream()` +- `OliphauntTransaction.execute()` +- `OliphauntTransaction.query()` +- `OpenConfig.brokerExecutable` +- `OpenConfig.brokerMaxRoots` +- `OpenConfig.brokerTransport` +- `OpenConfig.database` +- `OpenConfig.durability` +- `OpenConfig.engine` +- `OpenConfig.extensions` +- `OpenConfig.libraryPath` +- `OpenConfig.maxClientSessions` +- `OpenConfig.root` +- `OpenConfig.runtimeDirectory` +- `OpenConfig.runtimeFootprint` +- `OpenConfig.serverExecutable` +- `OpenConfig.serverPort` +- `OpenConfig.serverToolDirectory` +- `OpenConfig.startupGUCs` +- `OpenConfig.temporary` +- `OpenConfig.username` +- `parseQueryResponse()` +- `RestoreOptions.artifact` +- `RestoreOptions.brokerExecutable` +- `RestoreOptions.engine` +- `RestoreOptions.libraryPath` +- `RestoreOptions.replaceExisting` +- `RestoreOptions.root` +- `simpleQuery()` +- `SupportedModesOptions.brokerExecutable` +- `SupportedModesOptions.brokerTransport` +- `SupportedModesOptions.libraryPath` +- `SupportedModesOptions.runtimeDirectory` +- `SupportedModesOptions.serverExecutable` +- `SupportedModesOptions.serverToolDirectory` +- `supportsBackupFormat()` +- `supportsRestoreFormat()` +- `toUint8Array()` diff --git a/docs/maintainers/sdk-parity-policy.md b/docs/maintainers/sdk-parity-policy.md new file mode 100644 index 00000000..b3885b91 --- /dev/null +++ b/docs/maintainers/sdk-parity-policy.md @@ -0,0 +1,201 @@ +# SDK Parity + +`oliphaunt` is a native PostgreSQL product with peer SDK surfaces: + +- Rust: SDK for Tauri and Rust desktop apps; +- Swift: Apple SDK for iOS and macOS apps; +- Kotlin: Android SDK; +- React Native: TypeScript/TurboModule SDK over Swift and Kotlin. +- TypeScript: desktop JavaScript SDK for Node.js, Bun, Deno, and Tauri + JavaScript apps. + +The machine-checked SDK registry is +`tools/policy/sdk-manifest.toml`. It is the compact source +of truth for SDK classification, target platforms, runtime ownership, and +React Native delegation. The prose below explains the contract; the parity check +guards the registry and the docs together. + +The generated public surface inventory is +[`sdk-api-surface.md`](sdk-api-surface.md). It is intentionally no-build so +normal iteration stays fast, but it still makes public Rust, Swift, Kotlin, +React Native, and TypeScript symbol drift visible in review. + +Shared semantics use product-native tests fed by shared fixture corpora, not a +fake universal harness. `src/shared/fixtures/protocol/query-response-cases.json` is the +backend-response corpus consumed by Rust, Swift, Kotlin, React Native, +TypeScript, and the WASM wire parser. Additional shared contracts live under +`src/shared/fixtures/sdk-capabilities/`, `src/shared/fixtures/runtime-resources/`, +`src/shared/fixtures/backup/`, and `src/shared/fixtures/lifecycle/`; RN-specific binary transport +fixtures live under `src/shared/fixtures/react-native-jsi/`. + +Mobile crash/reopen/concurrency semantics are tracked separately in +[`Mobile Stability`](/docs/learn/mobile-stability) because they differ by platform +sandbox. + +The common product concepts are defined by `liboliphaunt`, the shared fixture +contracts, the public parity matrix, and the release metadata. Rust, Swift, +Kotlin, TypeScript, React Native, and WASM are peer products with ecosystem +contracts. Any deviation needs an explicit reason, not silent drift. + +## SDK Taxonomy + +SDK ownership is product ownership, not just source layout: + +- Rust is the Tauri/Rust desktop SDK. Its Cargo crate lives under + `src/sdks/rust`; its public docs live under + `src/docs/content/sdk/rust`. +- Swift owns iOS and macOS runtime behavior. +- Kotlin owns Android runtime behavior. +- React Native owns TypeScript DX and TurboModule transport, while delegating + runtime behavior to Swift on Apple platforms and Kotlin on Android. +- TypeScript owns desktop JavaScript runtime behavior for Node.js, Bun, Deno, + and Tauri JavaScript apps. Its broker mode consumes the published + `oliphaunt-broker` runtime and the shared `PGOB` protocol. + +The SDKs are peers over the same `liboliphaunt` C ABI and runtime-resource model. +React Native is not a fifth runtime. Its native modules are adapters over the +Swift and Kotlin SDKs so platform bugs, packaging, extension checks, +backup/restore behavior, and lifecycle semantics are fixed once in the platform +SDK that native app developers also use. + +The Rust SDK owns the runtime-resource producer contract. Generated manifests +must declare `schema=oliphaunt-runtime-resources-v1` and the expected +per-extension `layout`; Swift and Kotlin validate those fields before using +generated resources, and React Native inherits the same checks through those +platform SDKs. + +## Parity Bar + +Rust is classified as an SDK, not an internal implementation detail. Its release +contract matters in the same way as Swift, Kotlin, TypeScript, React Native, +and WASM contracts. It owns Rust/Tauri ergonomics, direct/broker/server APIs, +and the broker helper used by TypeScript; it is not the only proof layer for +shared semantics. + +Parity is required where the target platform can support the behavior without +lying about PostgreSQL semantics or degrading developer experience. A feature is +allowed to differ only when the difference is documented with a product reason: + +- impossible or inappropriate for the platform sandbox; +- better expressed through a platform-native API shape; +- intentionally not implemented yet because a fake implementation would be + worse than an explicit unsupported error. + +Unsupported does not mean undefined. Each SDK must expose a clear error for +unsupported modes or backup formats, and the parity matrix must explain why the +gap is acceptable. + +Mode support is part of the public contract, not tribal knowledge. Each SDK +must expose a `supportedModes`-style API that lists `nativeDirect`, +`nativeBroker`, and `nativeServer`, marks whether the current platform adapter +can open each mode, and carries the canonical capability shape plus the product +reason for any unavailable mode. + +## Required Concepts + +| Concept | Rust | Swift | Kotlin | React Native | +| --- | --- | --- | --- | --- | +| Native direct mode | yes | yes | yes | via Swift/Kotlin | +| Native broker mode | yes | future platform adapter | future platform adapter | via Swift/Kotlin | +| Native server mode | yes | future platform adapter | future platform adapter | via Swift/Kotlin | +| Raw protocol API | `exec_protocol_raw` | `execProtocolRaw` | `execProtocolRaw` | `execProtocolRaw` | +| Streaming protocol API | `exec_protocol_raw_stream` | `execProtocolStream` | `execProtocolStream` | `execProtocolStream` over the selected raw transport; New Architecture builds use `jsi-array-buffer` | +| Typed query helpers | yes | yes, simple and parameterized result parser | yes, simple and parameterized result parser | yes, JS simple and parameterized result parser | +| Simple-query SQL validation | simple-query builders reject NUL-containing SQL before frontend frame construction | simple-query builders reject NUL-containing SQL before frontend frame construction | simple-query builders reject NUL-containing SQL before frontend frame construction | simple-query builders reject NUL-containing SQL before frontend frame construction | +| Extended-query input validation | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol `Int16` limit before frontend frame construction | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol `Int16` limit before frontend frame construction | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol `Int16` limit before frontend frame construction | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol `Int16` limit before frontend frame construction | +| Backend UTF-8 parsing | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding | +| Backend response validation | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and `ReadyForQuery` transaction status, and reject unexpected backend tags instead of ignoring them | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and `ReadyForQuery` transaction status, and reject unexpected backend tags instead of ignoring them | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and `ReadyForQuery` transaction status, and reject unexpected backend tags instead of ignoring them | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and `ReadyForQuery` transaction status, and reject unexpected backend tags instead of ignoring them | +| Transaction helper | `transaction()` returns an explicit pinned handle; `with_transaction(...)` commits or rolls back an async closure; unpinned work is rejected | `transaction {}` uses the actor-owned session for raw and streaming work and rejects database work outside the active transaction handle | `transaction {}` uses the serialized session for raw and streaming work and rejects database work outside the active transaction handle | `transaction(async tx => ...)` preserves the platform session boundary for raw and streaming work and rejects database work outside the active transaction handle | +| Structured PostgreSQL errors | `Error::Postgres(PostgresError)` with SQLSTATE and raw ErrorResponse fields | `OliphauntError.postgres(OliphauntPostgresError)` with SQLSTATE and raw ErrorResponse fields | `PostgresException(PostgresError)` with SQLSTATE and raw ErrorResponse fields | `PostgresError` with SQLSTATE and raw ErrorResponse fields | +| Capability reporting | raw, stream, cancel, backup/restore, simple query, extensions, session model, multi-root support | same C ABI capability bits surfaced as Swift properties, including `multiRoot` | same C ABI capability bits surfaced as Kotlin properties, including `multiRoot` | same capability fields delegated from Swift/Kotlin, including `multiRoot` | +| Backup/restore format discovery | direct/broker: physical archive; server: SQL and physical archive backup; restore: physical archive; capability and handle `supports_backup_format`/`supports_restore_format` helpers | `backupFormats`, `restoreFormats`, and capability/database `supportsBackupFormat`/`supportsRestoreFormat` helpers | `backupFormats`, `restoreFormats`, and capability/database `supportsBackupFormat`/`supportsRestoreFormat` helpers | delegated `backupFormats` and `restoreFormats` capability fields plus TypeScript `supportsBackupFormat`/`supportsRestoreFormat` helpers and matching database methods | +| Backup format enforcement | `EngineExecutor::backup` rejects unsupported formats before the owner queue | `OliphauntDatabase.backup` rejects unsupported formats before the native session call | `OliphauntDatabase.backup` rejects unsupported formats before the platform session call | `OliphauntDatabase.backup` rejects unsupported formats before the TurboModule backup call | +| Checkpoint | `checkpoint()` sends PostgreSQL `CHECKPOINT` through the opened engine and rejects while a session pin is active | `checkpoint()` sends PostgreSQL `CHECKPOINT` through the actor-owned session and rejects while a transaction is active | `checkpoint()` sends PostgreSQL `CHECKPOINT` through the serialized session and rejects while a transaction is active | `checkpoint()` sends PostgreSQL `CHECKPOINT` through the delegated platform session and rejects while a transaction is active | +| Restore format enforcement | `Oliphaunt::restore` rejects non-physical artifacts before target materialization | `OliphauntDatabase.restore` rejects non-physical artifacts before the engine call | `OliphauntDatabase.restore` rejects non-physical artifacts before the platform engine call | `Oliphaunt.restore` rejects non-physical artifacts before the TurboModule restore call | +| Root validation | persistent roots are rejected when empty or NUL-containing before runtime selection; restore targets are rejected before materialization | roots must be file URLs and are rejected when empty or NUL-containing before engine calls | blank or NUL-containing open and restore roots are rejected before platform engine calls | blank or NUL-containing open and restore roots are rejected before TurboModule calls | +| Mode support discovery | `EngineCapabilities::rust_sdk_support()` | `OliphauntDatabase.supportedModes()` | `OliphauntDatabase.supportedModes()` and `OliphauntAndroid.supportedModes()` | `Oliphaunt.supportedModes()` delegated from Swift/Kotlin | +| Handle/executor ownership | Cloned Rust `Oliphaunt` handles share one SDK executor, FIFO owner queue, session pin, cancel handle, and close state in direct, broker, and server modes; cloning is not a connection pool | Swift database values are actor-owned session handles guarded by a FIFO async serial gate; additional references share the same actor/session and server-mode independent clients must use server support when implemented | Kotlin database values are coroutine session handles guarded by `executionMutex`; additional references share the same coroutine/session boundary and server-mode independent clients must use server support when implemented | React Native `OliphauntDatabase` objects wrap the delegated Swift/Kotlin session handle and delegate ordering to the platform serial session; JS references do not create independent sessions | +| Connection identity | `Oliphaunt::builder().username(...).database(...)` feeds direct, broker, and server startup identity; invalid empty/NUL values are rejected before runtime open | `OliphauntConfiguration(username:database:)` feeds native-direct startup identity and rejects invalid empty/NUL values before engine open | `OliphauntConfig(username, database)` feeds native-direct startup identity and rejects invalid empty/NUL values before engine open | `open({ username, database })` forwards the same identity through Swift/Kotlin and rejects invalid empty/NUL values before the TurboModule call | +| Runtime footprint profiles | `RuntimeFootprintProfile::{Throughput,BalancedMobile,SmallMobile}` defines the shared PostgreSQL startup-GUC contract; balanced/small mobile lower slot counts, shared buffers, WAL footprint, and PG18 AIO concurrency | `OliphauntRuntimeFootprintProfile` carries the same three profiles and generated startup args for Apple direct mode; the Apple SDK default is `balancedMobile` + `balanced` | `RuntimeFootprintProfile` carries the same three profiles and generated startup args for Android/Kotlin direct mode; the Android/Kotlin default is `BalancedMobile` + `Balanced` | `runtimeFootprint: 'throughput' | 'balancedMobile' | 'smallMobile'` forwards the selected profile through Swift/Kotlin; the TypeScript default is `balancedMobile` + `balanced` | +| Startup GUC overrides | `startup_guc`/`startup_gucs` append validated `name=value` overrides after durability and footprint profiles so benchmark/device sweeps can override profile defaults | `startupGUCs` appends validated overrides after the selected profile before the Swift engine call | `startupGucs` appends validated overrides after the selected profile before the Kotlin engine call | `startupGUCs` accepts validated string or object values in TypeScript and forwards string assignments through the TurboModule to Swift/Kotlin | +| Extensions | yes | yes | yes | via Swift/Kotlin | +| Packaged runtime resources | yes, producer | yes, consumer | yes, consumer | via platform SDK consumers | +| Package-size evidence | `NativeRuntimeResources::size_report` and `oliphaunt/package-size.tsv` producer | `OliphauntRuntimeResources.packageSizeReport()` parses the shared TSV | `OliphauntAndroid.packageSizeReport(context)` and `OliphauntAndroid.packageSizeReport(resourceRoot)` parse the shared TSV | `Oliphaunt.packageSizeReport(...)` delegates to Swift/Kotlin and returns the same typed report | +| Packaged native library | host library path today | XCFramework target | Android `jniLibs` | Swift/Kotlin package artifacts | +| Physical backup/restore | yes | yes | yes | via Swift/Kotlin | +| Cancellation | yes | yes | yes | via Swift/Kotlin | +| Close behavior | `Oliphaunt::close` rejects queued work, waits for active work, then closes/detaches; use `cancel()` explicitly to interrupt SQL | `OliphauntDatabase.close` rejects queued work, waits for active work, then detaches; use `cancel()` explicitly to interrupt SQL | `OliphauntDatabase.close` rejects queued work, waits for active work, then detaches; use `cancel()` explicitly to interrupt SQL | `OliphauntDatabase.close` delegates the same wait-and-detach behavior through Swift/Kotlin | +| True concurrent sessions | server mode only | server mode only | server mode only | server mode only | + +## Current Platform Stance + +| SDK | Primary app target | Runtime owner | Current native mode | Non-parity that is allowed today | +| --- | --- | --- | --- | --- | +| Rust | Tauri and Rust desktop apps | `oliphaunt` | direct, broker, server | none for the core SDK contract | +| Swift | iOS and macOS apps | `Oliphaunt` | direct | broker/server are explicit unsupported errors until platform runtimes exist; they must not be faked through direct mode | +| Kotlin | Android apps | `oliphaunt` | Android direct plus Kotlin/Native direct | Android common defaults require the `OliphauntAndroid` Context facade; JVM runtime is explicitly unavailable; Android broker/server must be separate platform adapters, not direct-mode aliases | +| React Native | React Native apps | Swift on Apple, Kotlin on Android | delegated direct | New Architecture JSI ArrayBuffer transport is required for protocol, backup, and restore bytes | + +## React Native Ownership + +React Native should not own a separate database runtime. It owns: + +- TypeScript types and ergonomic JS handles; +- TurboModule Codegen; +- versioned JSI ArrayBuffer transport installers for protocol, backup, and restore bytes; +- JS protocol/query helpers, including chunked JSI streaming when the installed + transport provides it and explicit `protocolStream=false` when a custom or + stale transport can only return one owned response; +- a typed `packageSizeReport(...)` facade over the Swift/Kotlin resource + package readers; +- error normalization for JS callers. + +Swift owns Apple runtime behavior for iOS and macOS. Kotlin owns Android runtime +behavior. The React Native native modules are adapters over those SDKs. RN iOS +delegates open, protocol execution, backup, restore, cancellation, and close to +`Oliphaunt`; any future RN macOS target must use the same Swift SDK boundary. +RN Android delegates the same operations to the Kotlin SDK through the +`OliphauntAndroid` facade, not by constructing a private native-direct runtime. + +### React Native Installed-App Harness + +The Expo dev-client example is the installed-app validation harness. The default +combined lane is `moon run oliphaunt-react-native:smoke-mobile`; +platform-specific local lanes are backed by +`src/sdks/react-native/tools/expo-android-runner.sh` and +`src/sdks/react-native/tools/expo-ios-runner.sh`. + +Local Expo MCP validation must run with `EXPO_UNSTABLE_MCP_SERVER=1` so the +example can be driven through the same dev-client app surface that developers +use during iteration. + +## Defensible Deviations + +- React Native keeps TurboModule Codegen for lifecycle/control calls while + requiring a New Architecture JSI ArrayBuffer transport for binary protocol, + backup, and restore traffic. +- Swift and Kotlin use platform-native async/actor/coroutine shapes rather than + copying Rust names exactly. +- Android requires packaged template PGDATA for new roots because mobile apps + cannot rely on executing `initdb` from writable app storage. + +## Release Rule + +An SDK feature is complete only when its SDK-specific tests prove the behavior +and the parity matrix either marks it present or documents a justified platform +deviation. Green Rust tests do not prove Swift, Kotlin, or React Native parity. +Green Swift/Kotlin tests do not prove React Native parity unless the RN adapter +tests demonstrate that calls route through those SDKs rather than through a +private runtime. + +The fast ownership guard is: + +```sh +tools/policy/check-sdk-parity.sh +``` + +The full SDK aggregate is: + +```sh +src/runtimes/liboliphaunt/native/tools/check-track.sh sdks +``` diff --git a/docs/maintainers/sdk-products-policy.md b/docs/maintainers/sdk-products-policy.md new file mode 100644 index 00000000..29f9793b --- /dev/null +++ b/docs/maintainers/sdk-products-policy.md @@ -0,0 +1,119 @@ +# SDK Products + +SDK source lives under `src/` with the product it releases. This document is +the cross-SDK policy and parity contract. + +These are product SDKs, not auxiliary bindings. Rust, Swift, Kotlin, React +Native, and TypeScript should expose the same product concepts where the target +platform can do so honestly: + +- Rust is the SDK for Tauri and Rust desktop apps. +- Swift is the SDK for iOS and macOS apps. +- Kotlin is the SDK for Android apps. +- React Native is the TypeScript/TurboModule SDK over the Swift and Kotlin SDKs. +- TypeScript is the SDK for Node.js, Bun, Deno, and Tauri JavaScript apps. + +`tools/policy/sdk-manifest.toml` is the repo-level SDK registry kept for parity +checks during the moon migration. The canonical product graph now lives in +`src/*/moon.yml`; both must agree. `tools/policy/check-sdk-parity.sh` treats the +registry as an ownership guard, so Rust cannot quietly become "just a crate" and +React Native cannot grow an independent PostgreSQL runtime. + +- `src/sdks/rust/`: canonical Rust SDK for Tauri and Rust desktop apps. +- `src/sdks/swift/`: Swift package with an actor-first `Oliphaunt` API and a + native-direct C ABI engine over `liboliphaunt`; `.nativeDirect` uses that engine + by default and can materialize packaged runtime/template resources for iOS and + macOS apps. +- `src/sdks/kotlin/`: Kotlin Multiplatform Gradle project with a suspend-first common + API, Kotlin/Native native-direct C ABI engine over `liboliphaunt`, and Android + native-direct JNI engine with packaged runtime/template asset materialization. +- `src/sdks/react-native/`: React Native New Architecture package. Its product contract + is a typed TypeScript/TurboModule layer over the Swift and Kotlin SDKs, with + no independent database semantics. +- `src/sdks/js/`: desktop JavaScript SDK for Node.js, Bun, Deno, and + Tauri JavaScript apps. `nativeDirect` is the default across all JavaScript + runtimes; Node.js uses the package-owned prebuilt Node direct adapter, and Bun + and Deno use their runtime FFI surfaces. TypeScript broker mode consumes the + published `oliphaunt-broker` runtime and the shared `PGOB` protocol + instead of inventing another broker runtime; app developers get verified + release assets by default instead of building Rust locally. + TypeScript broker mode consumes the published `oliphaunt-broker` runtime. + +The Rust SDK is canonical for now; Swift, Kotlin, React Native, and TypeScript +mirror its mode, raw protocol, typed query, transaction, checkpoint, structured PostgreSQL error, capabilities, backup, restore, exact extension, and resource packaging terminology unless a platform restriction is documented. +React Native must not duplicate database runtime behavior: iOS and macOS calls +flow through `Oliphaunt`, and Android calls flow through the `oliphaunt` +`OliphauntAndroid` facade. +Every SDK-facing feature must either be implemented with equivalent semantics or +fail with an explicit unsupported error that is justified in +[`sdk-parity-policy.md`](sdk-parity-policy.md). Silent drift between SDKs is a +release blocker. + +Validation is package-native: + +```sh +moon run oliphaunt-rust:check +moon run oliphaunt-swift:check +moon run oliphaunt-kotlin:check +moon run oliphaunt-react-native:check +moon run oliphaunt-js:check +tools/policy/check-sdk-parity.sh +``` + +The Kotlin and React Native Android validation scripts opt into Gradle +configuration cache by default. Set `OLIPHAUNT_GRADLE_CONFIGURATION_CACHE=0` +when debugging Gradle task configuration itself. + +When a local `target/liboliphaunt-pg18` build exists, the Swift and Kotlin lanes +automatically run their native-direct C ABI tests against that library and +runtime tree. + +Build app-bundle resources from the Rust/native track with: + +```sh +cargo run -p oliphaunt --bin oliphaunt-resources -- \ + --output target/oliphaunt-resources \ + --extension vector \ + --force +``` + +Extension selection is exact-name only. SDKs accept exact PostgreSQL extension +names; `vector` means only the SQL extension `vector`, and names like `core`, +`search`, or `geo` must not resolve to hidden extension sets. + +The generated `target/oliphaunt-resources/oliphaunt` directory is the resource +root consumed by Swift bundles, Android assets, and React Native apps. Android +Gradle builds also accept the parent directory through +`-PoliphauntRuntimeResourcesDir=target/oliphaunt-resources`. + +For iOS and Android release artifacts, build runtime resources with +`--require-mobile-static-registry` once the selected extension modules have +platform static registry rows. Swift, Kotlin, and React Native reject requested +extensions whose packaged runtime advertises pending mobile registry work. +The platform resource build must also pass each linked registry module stem with +`--mobile-static-module `; the Rust runtime-resource CLI rejects stems +that are not selected by the runtime resources. Those stems are declarations for +validation; mobile-ready output includes +`oliphaunt/static-registry/oliphaunt_static_registry.c`, which exports +`liboliphaunt_selected_static_extensions`. Platform bridges discover that symbol +and register the returned rows through `oliphaunt_register_static_extensions` +before the first database open. +Every SDK consumes the resulting runtime resources through the same manifest +fields. Generated manifests record +`schema=oliphaunt-runtime-resources-v1`, per-package `layout`, +`extensions`, and `sharedPreloadLibraries` so SDK-bound artifacts can be audited +independently of the local build path. +Swift and Kotlin reject unknown package layouts rather than silently accepting +stale app resources; React Native inherits those checks through the platform +SDKs. +The resource root also carries `package-size.tsv`. Swift exposes it through +`OliphauntRuntimeResources.packageSizeReport()`, Kotlin Android exposes it through +`OliphauntAndroid.packageSizeReport(context)` or +`OliphauntAndroid.packageSizeReport(resourceRoot)`, and React Native exposes the +same typed report through `Oliphaunt.packageSizeReport(...)` while still delegating +the actual resource lookup to Swift/Kotlin. + +Android packages the native C ABI library separately from runtime resources. +Pass a `jniLibs`-style directory with ABI subdirectories through +`-PoliphauntAndroidJniLibsDir=/path/to/jniLibs`; each packaged ABI must include +`liboliphaunt.so`. diff --git a/docs/maintainers/testing.md b/docs/maintainers/testing.md new file mode 100644 index 00000000..c2ff6f44 --- /dev/null +++ b/docs/maintainers/testing.md @@ -0,0 +1,313 @@ +# Testing Policy + +Oliphaunt is a polyglot product repo. Product-native tests stay in product-native test roots. +Each SDK is validated with the same tools its consumers use: + +- Rust SDK: `src/sdks/rust/tests/` +- WASM crate: `src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/` +- Swift SDK: `src/sdks/swift/Tests/` +- Kotlin SDK: `src/sdks/kotlin/oliphaunt/src/commonTest/`, + `src/sdks/kotlin/oliphaunt/src/androidUnitTest/`, and + `src/sdks/kotlin/oliphaunt/src/nativeTest/` +- React Native package: `src/sdks/react-native/src/__tests__/` +- Installed React Native app smoke and benchmark coverage: + `src/sdks/react-native/examples/expo/` + +Use the tier model below when deciding whether a check belongs in PR fast +feedback, affected integration, nightly, release dry-run, or post-publish +validation. + +- PR: `check`, `test`, `package`, coverage, release intent, and package-shape + checks selected by Moon affectedness. +- Main: PR checks plus selected runtime smokes and regressions for changed + products. +- Nightly/manual: full regressions, extension matrix, installed mobile app + smokes, lifecycle drills, and measured benchmark reports. +- Release: package-native dry-runs, artifact manifests, checksums, + attestations, registry checks, exact-extension evidence, and selected + regression/performance gates. + +Cross-product behavior belongs in `docs/maintainers/sdk-parity-policy.md` and executable parity +checks. Do not centralize platform tests into a fake shared test harness when a +native package manager, simulator, Gradle target, SwiftPM target, Cargo target, +or React Native Codegen path is the actual consumer contract. + +## Fixtures + +Product-private fixtures stay inside the product test root that consumes them. +Create a shared fixture root only after the same contract is consumed by at +least two products without platform-specific setup. Until then, colocated +fixtures are clearer and cheaper to maintain. + +Shared fixture domains are small, semantic contracts consumed by +product-native tests or policy checks: + +- `src/shared/fixtures/protocol/query-response-cases.json`: PostgreSQL backend-response + corpus consumed by Rust, Swift, Kotlin, React Native, TypeScript, and WASM + protocol tests. +- `src/shared/fixtures/sdk-capabilities/mode-support.json`: direct, broker, and server + capability expectations used to keep mode support assertions aligned. +- `src/shared/fixtures/runtime-resources/manifest.properties`, + `src/shared/fixtures/runtime-resources/template-pgdata-manifest.properties`, and + `src/shared/fixtures/runtime-resources/package-size.tsv`: runtime-resource and + exact-extension package-size contracts used by Rust, Swift, Kotlin, + TypeScript, and React Native packaging/resource tests. +- `src/shared/fixtures/backup/physical-archive-manifest.json`: physical archive metadata + expectations for backup/restore contract tests. +- `src/shared/fixtures/lifecycle/session-lifecycle.json`: close, cancel, + background/foreground, and transaction-pinning lifecycle expectations. +- `src/shared/fixtures/react-native-jsi/binary-transport.json`: RN-only JSI ArrayBuffer, + typed-array offset, stream chunk, callback, and handle-validation cases. + +Reusable benchmark datasets, benchmark plans, and published reports belong in +`benchmarks/`. Executable benchmark harnesses belong in `tools/perf/` unless +the harness is intentionally part of a product's public developer API. + +## Moon Tasks + +Moon task names are intentionally narrow: + +- `check`: static checks, typecheck, codegen, lint, or build-only validation. +- `test`: real unit or contract tests in the product-native runner. +- `package`: package-shape checks and publish dry-runs. +- `smoke`: one runtime happy path for that product. +- `regression`: broader SQL, protocol, extension, lifecycle, or runtime + regression suites. +- `bench`: benchmark plan/report validation only. +- `bench-run`: measured benchmark execution. +- `coverage`: runs product-native measured line coverage and writes + machine-readable reports under `target/coverage//`. + +`check` and `test` must not call the same command for SDK products. `test` +must run tests, not metadata-only checks. `smoke` targets must be explicit +runtime probes and must be run with `--cache off` in CI/release evidence lanes +where current device/simulator/runtime state matters. + +Runtime prerequisites are centralized in `tools/runtime/preflight.sh`. Rust, +Swift, Kotlin, TypeScript, and WASM smoke/regression lanes use that helper for +host liboliphaunt, Android liboliphaunt, iOS simulator probe, and WASIX +asset/AOT checks. Static, package, unit, and coverage lanes remain +artifact-light; they may warn about missing local runtimes but must not claim +runtime evidence. React Native installed-app smokes delegate runtime +materialization to the Expo platform scripts and hard-fail there if native +artifacts cannot be built or located. + +React Native installed-app smoke is split by platform: + +```sh +moon run oliphaunt-react-native:smoke-android +moon run oliphaunt-react-native:smoke-ios +moon run oliphaunt-react-native:smoke-mobile +``` + +PR jobs run RN static, unit, Codegen, JSI, config-plugin, and package checks. +Main/nightly/manual lanes run installed Android/iOS app smokes. + +Installed-app E2E runner choice is closed, not a recurring research task. +Decision (2026-06-08): Oliphaunt uses the pinned open-source Maestro CLI +through GitHub-hosted emulator/simulator jobs. This is not an open research loop. +Reopen that decision only when a written implementation proposal names an +installed-app E2E requirement that the pinned open-source Maestro CLI cannot +satisfy. Do not keep re-checking Maestro, Detox, Appium, EAS, Firebase Test +Lab, BrowserStack, Sauce, AWS Device Farm, or other hosted-device services while +implementing this plan. Routine maintenance verifies the pinned installer, flow +files, app artifacts, runner behavior, and CI logs for the selected Maestro +lanes; it does not revisit provider selection. + +Prior provider research is historical context, not a standing checklist. Maestro +pin upgrades are dependency maintenance; they do not reopen the runner decision +unless they expose a concrete installed-app E2E requirement this path cannot +meet. + +The default installed-app path must remain free and public-checkout +reproducible. Paid hosted-device providers, SaaS-only runners, and required +private runner infrastructure are not part of the default proof path. When +mobile E2E breaks, inspect the selected implementation first: app artifact shape, +simulator/emulator setup, Maestro flow files, logs, and CI runner assumptions. +Debug the chosen implementation first. Do not restart provider research unless +the failure proves a concrete requirement this model cannot satisfy. + +## Coverage + +Coverage is measured evidence, not a policy-only check. Product tasks run the +native reporter for their ecosystem: `cargo-llvm-cov` for Rust and WASM library +coverage, `swift test --enable-code-coverage` for Swift, Kover for Kotlin, and +Vitest V8 coverage for TypeScript and React Native TypeScript code. Each product writes +`target/coverage//summary.json` plus its native report formats, and +`moon run repo:coverage` aggregates those summaries into `target/coverage/summary.json` +and `target/coverage/summary.md`. + +Rust and WASM executable unit tests run through `cargo nextest` with the `ci` +profile. Unit lanes still run doctests through `cargo test --doc` because +nextest does not own doctest execution. Coverage lanes measure line coverage +through `cargo llvm-cov nextest` and then run `cargo test --doc` as stable-Rust +correctness evidence. Doctest coverage itself requires nightly rustdoc flags, so +it is not part of the default stable LCOV gate. +WASM library unit coverage intentionally uses `--no-default-features`, while +WASM doctests run with default features because the README extension examples +exercise the default extension surface. Runtime Postgres/WASIX execution stays +in `smoke` and `regression`, where missing runtime assets must fail or skip +explicitly according to the lane policy. + +TypeScript and React Native unit tests use the shared Vitest discovery runner +in `tools/test/run-js-tests.mjs`. Coverage calls the same runner with Vitest V8 +coverage enabled, so test discovery and coverage discovery cannot drift. React +Native native adapter compile checks, Codegen checks, Expo prebuild/app wiring, +and installed-device smokes remain separate package or runtime lanes; Vitest +coverage is only evidence for TypeScript API/config/JSI contract code. + +`coverage/baseline.toml` records product-owned `source_globs`, precise +`exclude_globs`, explicit waivers, the aggregate gate, and an initial per-file +floor. Every owned source file must be measured or waived with a reason and +replacement evidence; every waiver also carries an owner and expiry/review +horizon. Generated code, vendored code, PostgreSQL sources, native build +outputs, package `lib/` output, Gradle build directories, Xcode DerivedData, +and Codegen output are excluded from SDK wrapper coverage gates. +`measured_line_coverage` is an audit snapshot, not an exact equality gate. The +initial aggregate floor is 80 percent for SDK wrapper code, with a two-point +per-release ratchet until each SDK wrapper reaches 85 percent line coverage. +Use `moon run repo:coverage-policy` when you only need to validate the +coverage policy shape. + +The root coverage commands are: + +```sh +moon run :coverage +moon run :coverage --affected +``` + +## WASM Runtime Tests + +`oliphaunt-wasix` is intended for tests that need real Postgres semantics without +Docker. + +Use `Oliphaunt::temporary()` when the code under test can call the direct Rust +API: + +```rust,no_run +use oliphaunt_wasix::Oliphaunt; + +#[test] +fn stores_rows() -> Result<(), Box> { + let mut db = Oliphaunt::temporary()?; + + db.exec("CREATE TABLE items (id int primary key, name text)", None)?; + db.exec("INSERT INTO items VALUES (1, 'alpha')", None)?; + + let rows = db.query("SELECT name FROM items WHERE id = 1", &[], None)?; + assert_eq!(rows.rows[0].get("name").unwrap(), "alpha"); + + db.close()?; + Ok(()) +} +``` + +Use `fresh_temporary()` only when the test must validate fresh-cluster +initialization behavior: + +```rust,no_run +use oliphaunt_wasix::Oliphaunt; + +#[test] +fn fresh_cluster_path() -> Result<(), Box> { + let mut db = Oliphaunt::builder().fresh_temporary().open()?; + db.close()?; + Ok(()) +} +``` + +## Server Tests + +Use `OliphauntServer` when the application already talks to Postgres through a +client library: + +```rust,no_run +use oliphaunt_wasix::OliphauntServer; +use sqlx::{Connection, Row}; + +#[tokio::test] +async fn sqlx_query() -> Result<(), Box> { + let server = OliphauntServer::temporary_tcp()?; + let mut conn = sqlx::PgConnection::connect(&server.database_url()).await?; + + let row = sqlx::query("SELECT $1::int4 + 1 AS n") + .bind(41_i32) + .fetch_one(&mut conn) + .await?; + assert_eq!(row.try_get::("n")?, 42); + + conn.close().await?; + server.shutdown()?; + Ok(()) +} +``` + +Keep client pools at one connection. + +## Extension Tests + +Enable bundled extensions through the builder: + +```rust,no_run +use oliphaunt_wasix::{Oliphaunt, extensions}; + +#[test] +fn vector_query() -> Result<(), Box> { + let mut db = Oliphaunt::builder() + .temporary() + .extension(extensions::VECTOR) + .open()?; + + db.exec("CREATE TABLE items (embedding vector(3))", None)?; + db.exec("INSERT INTO items VALUES ('[1,2,3]')", None)?; + db.exec("SELECT embedding <-> '[1,2,4]' FROM items", None)?; + + db.close()?; + Ok(()) +} +``` + +When an extension has bundled dependencies, prefer the builder path over +post-open `enable_extension(...)`. + +## Snapshot Setup + +Use physical data-dir archives or `try_clone()` when a test suite needs a +pre-populated same-version fixture: + +```rust,no_run +use oliphaunt_wasix::Oliphaunt; + +#[test] +fn clone_fixture() -> Result<(), Box> { + let mut seed = Oliphaunt::temporary()?; + seed.exec("CREATE TABLE items(value TEXT)", None)?; + seed.exec("INSERT INTO items VALUES ('alpha')", None)?; + + let mut clone = seed.try_clone()?; + clone.exec("SELECT * FROM items", None)?; + + clone.close()?; + seed.close()?; + Ok(()) +} +``` + +Use logical dumps, not physical archives, when you need a portable export. + +## Cross-Language Clients + +Use `oliphaunt-wasix-proxy` when the test process lives outside Rust: + +```sh +oliphaunt-wasix-proxy --temporary --tcp 127.0.0.1:0 --print-uri +``` + +Pass the printed URI to Python `psycopg`, Go `pgx`, Node `pg`, or another +standard Postgres client. + +## COPY And Raw Protocol Tests + +Direct `Oliphaunt` supports `/dev/blob` for `COPY TO` and `COPY FROM`. Server +mode supports ordinary client-driven `COPY FROM STDIN` and other standard wire +protocol flows through the local Postgres endpoint. diff --git a/docs/maintainers/tooling.md b/docs/maintainers/tooling.md new file mode 100644 index 00000000..6211f96f --- /dev/null +++ b/docs/maintainers/tooling.md @@ -0,0 +1,200 @@ +# Tooling Decisions + +Oliphaunt is a polyglot product monorepo. Tooling has to make product work +predictable without hiding ecosystem-native behavior. + +## Roles + +- Moon is the product/task graph, affectedness engine, local cache, and CI task + executor. +- release-please manifest mode owns release PRs, versions, changelogs, and + product-scoped tags. +- Product-local `release.toml` files own package metadata release-please does + not model: owner, kind, publish targets, registry packages, release + artifacts, compatibility-version files, and derived version files. +- Product-local `targets/*.toml` files own platform artifact metadata. +- Product-native build tools own product behavior: Cargo, SwiftPM/Xcode, + Gradle, npm/JSR, Expo, React Native Codegen, and PostgreSQL build scripts. +- `tools/release/release.py` owns protected publish operations, registry + checks, checksums, attestations, and GitHub release asset verification. + +Do not add a second source graph, release graph, or root alias layer over Moon. +Do not add a repo-wide tool because it is popular in one language ecosystem. + +## Moon + +Install Moon through proto from `.prototools` and run `moon` directly: + +```sh +moon query projects +moon query tasks +moon query affected --upstream none --downstream deep +moon run :check +moon run :test +moon run :coverage +moon query affected --upstream none --downstream deep +``` + +Moon task names carry stable intent: + +- `check`: static, typecheck, lint, codegen, or build-only validation. +- `test`: product-native unit or contract tests. +- `package`: package-shape checks and publish dry-runs. +- `smoke`: one runtime happy path. +- `regression`: broader SQL, protocol, extension, lifecycle, or runtime + regressions. +- `bench`: benchmark plan/report validation. +- `bench-run`: measured benchmark execution. +- `coverage`: measured product-native line coverage. + +Every task must declare explicit inputs. Tasks with deterministic output that +other tasks consume must declare outputs. Use Moon tags for CI lanes and ad-hoc +selection; do not create root script aliases for new lanes. + +Moon dependency scopes are meaningful: + +- `production` and `peer` are release-affecting compatibility edges. +- `build` is for tests, fixtures, generated metadata, package-shape checks, and + other non-release coupling. + +## pnpm + +pnpm is not the global build orchestrator. Its repo-level role is: + +- install JavaScript-family workspace dependencies from `pnpm-lock.yaml`; +- provide JavaScript package-manager commands for docs, TypeScript, and React + Native packages; +- expose the root command card that delegates to Moon. + +Cargo, Gradle, SwiftPM, Xcode, npm/JSR publish, Expo, and PostgreSQL build +scripts stay product-owned and are invoked through Moon tasks where repository +or CI orchestration is needed. `node_modules/` directories are normal ignored +local install state; they must never be tracked. + +## CI + +GitHub Actions owns runners, credentials, artifact upload, and platform matrix +fan-out. Moon owns which tasks are affected and how tasks depend on each other. + +CI flow: + +1. The affected job uses Moon queries to select stable job names from task tags + named `ci-` and to emit the exact Moon task targets for each job. +2. Product jobs call `.github/scripts/run-planned-moon-job.sh `. +3. The planned-job wrapper reads the affected job target map, then delegates to + `.github/scripts/run-moon-targets.sh`, which runs + `moon run` with the selected targets. +4. GitHub matrix fans out only target dimensions such as OS, CPU, ABI, native + runtime target, broker target, Node direct target, WASIX AOT target, Android + emulator, and iOS simulator. + +Mobile CI target fan-out is derived from published +`liboliphaunt-native` artifact metadata. Android jobs use targets whose +`targets/*.toml` surfaces include `react-native-android`; iOS jobs use targets +whose surfaces include `react-native-ios`. Do not hardcode mobile ABI target +lists in CI planners. + +Keep workflow names and job names product-oriented. Put implementation details +in step names. + +## Moon Cache Policy + +Moon is allowed to cache task results when inputs, dependency task outputs, +toolchain-sensitive files, environment variables, and outputs represent the +work. It does not know about simulator/device state, installed apps, local +ports, Docker daemon state, code-signing identities, registry state, or copied +runtime artifacts unless those are modeled as inputs. + +Cache deterministic static checks, package-shape checks, generated freshness, +docs builds, unit tests, and coverage reports when they declare inputs and +outputs. + +Use `cache: local` for developer smoke tasks that are useful to replay when +local source inputs have not changed. + +Force live execution for CI/mobile/device proof with `MOON_CACHE=off`; those +lanes prove the current runner, simulator/device, signing environment, app +artifact, and runtime artifact. + +Cache benchmark plan checks, never measured benchmark runs. `bench` validates +matrix and report shape; `bench-run` measures current hardware and runtime +state. + +Use `runInCI: skip` for expensive dependency-only tasks that must stay valid in +CI action graphs but must not run as broad CI work. Use `runInCI: false` only +for tasks CI must never invoke. + +## Release Tooling + +Release-please is the release identity owner. It supports the monorepo +component model well enough for product versions, changelogs, release PRs, and +tags without forcing non-JavaScript products into fake `package.json` files. + +What release-please does not own: + +- platform binary builds; +- extension artifact builds; +- checksums and attestations; +- registry credential checks; +- package-native publish commands; +- verifying already-published GitHub release assets. + +Those stay in `tools/release/release.py` and product-native release tasks. + +Do not reintroduce release-plz, git-cliff product changelog ownership, a central +release graph, or broad clean-registry reinstall gates as routine CI policy. + +## Debugging + +Use Moon's graph and cache diagnostics before adding scripts: + +```sh +moon project-graph +moon action-graph oliphaunt-react-native:package-artifacts +moon hash +moon run --cache off --log trace +``` + +If a task is slow, first check whether its inputs are too broad, outputs are +missing, dependency scopes are wrong, or CI is proving runner state that cannot +be safely cached. + +Graph policy fixtures are split by contract: + +- `tools/graph/synthetic/affected.toml` checks Moon owner/downstream behavior. +- `tools/graph/synthetic/release.toml` checks release product selection. +- `tools/graph/synthetic/coverage.toml` checks coverage routing. + +Do not add mixed synthetic cases that assert unrelated contracts in one table. + +## Tool Ownership + +Keep implementation code split by responsibility instead of growing large +catch-all scripts: + +- `tools/xtask/src/template_runner.rs` +- `tools/xtask/src/asset_checks.rs` +- `tools/xtask/src/asset_manifest.rs` +- `tools/xtask/src/asset_io.rs` +- `tools/xtask/src/asset_pipeline.rs` +- `tools/xtask/src/fs_utils.rs` +- `tools/xtask/src/postgres_guard.rs` +- `tools/xtask/src/source_spine.rs` +- `tools/perf/runner/src/benchmarks.rs` +- `tools/perf/runner/src/diagnostics.rs` +- `tools/perf/runner/src/legacy_wasix.rs` +- `tools/perf/runner/src/native_liboliphaunt.rs` +- `tools/perf/runner/src/native_postgres.rs` +- `tools/perf/runner/src/prepared_updates.rs` +- `tools/perf/runner/src/report.rs` +- `tools/perf/runner/src/shared.rs` +- `tools/perf/runner/src/sqlite.rs` +- `src/sdks/rust/src/runtime_resources/extension_artifact.rs` +- `src/sdks/rust/src/runtime_resources/extension_index.rs` +- `src/sdks/rust/src/runtime_resources/manifest.rs` +- `src/sdks/rust/src/runtime_resources/package.rs` +- `src/sdks/rust/src/runtime_resources/static_registry.rs` +- `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs` +- `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs` +- `src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs` +- `tools/policy/check-sdk-mobile-extension-surface.sh` diff --git a/docs/USAGE.md b/docs/maintainers/wasm-usage-legacy.md similarity index 78% rename from docs/USAGE.md rename to docs/maintainers/wasm-usage-legacy.md index 77b20663..e29d5d1a 100644 --- a/docs/USAGE.md +++ b/docs/maintainers/wasm-usage-legacy.md @@ -1,18 +1,22 @@ -# Usage Guide +# WASIX Usage Guide -`pglite-oxide` has two primary entry points: +This legacy maintainer note describes the preserved `oliphaunt-wasix` crate. +Native Rust SDK work should start with the public native runtime docs and +`src/sdks/rust/README.md`. -- `Pglite` for direct embedded queries from Rust; -- `PgliteServer` for libraries that need a PostgreSQL connection URI. +`oliphaunt-wasix` has two primary entry points: -Prefer `Pglite` unless you specifically need a Postgres client connection. +- `Oliphaunt` for direct embedded queries from Rust; +- `OliphauntServer` for libraries that need a PostgreSQL connection URI. + +Prefer `Oliphaunt` unless you specifically need a Postgres client connection. ## Install Modes Most projects should use the default install: ```toml -pglite-oxide = "0.4" +oliphaunt-wasix = "0.5" ``` Default features include the packaged embedded Postgres runtime, the @@ -23,14 +27,14 @@ If you want embedded Postgres without the extension API, keep the packaged runtime and turn off defaults explicitly: ```toml -pglite-oxide = { version = "0.4", default-features = false, features = ["bundled"] } +oliphaunt-wasix = { version = "0.5", default-features = false, features = ["bundled"] } ``` Use the minimal mode only when you intentionally do not want packaged runtime assets in the dependency graph: ```toml -pglite-oxide = { version = "0.4", default-features = false } +oliphaunt-wasix = { version = "0.5", default-features = false } ``` Minimal mode is for specialized integrations and maintainer workflows. Normal @@ -41,10 +45,10 @@ database opens need the packaged runtime/AOT assets provided by `bundled`. Persistent database under an explicit path: ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; fn main() -> Result<(), Box> { - let mut db = Pglite::open("./.pglite")?; + let mut db = Oliphaunt::open("./.oliphaunt")?; db.close()?; Ok(()) } @@ -53,10 +57,10 @@ fn main() -> Result<(), Box> { Persistent database under the platform app-data directory: ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; fn main() -> Result<(), Box> { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .app("com", "example", "desktop-app") .open()?; db.close()?; @@ -67,10 +71,10 @@ fn main() -> Result<(), Box> { Fast temporary database for tests: ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; db.close()?; Ok(()) } @@ -79,10 +83,10 @@ fn main() -> Result<(), Box> { Explicit fresh-cluster temporary database: ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; fn main() -> Result<(), Box> { - let mut db = Pglite::builder().fresh_temporary().open()?; + let mut db = Oliphaunt::builder().fresh_temporary().open()?; db.close()?; Ok(()) } @@ -105,10 +109,10 @@ The direct builder also exposes: Use builder methods for startup-time database settings: ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; fn main() -> Result<(), Box> { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .postgres_config("synchronous_commit", "off") .postgres_config("work_mem", "8MB") @@ -133,8 +137,8 @@ Use `postgres_config` for ordinary GUCs. It follows PostgreSQL's normal `-c name=value` startup behavior, and explicit values override the default startup profile. -For `PgliteServer`, the same startup methods are available on -`PgliteServer::builder()`. The `pglite-proxy` CLI exposes startup GUCs with +For `OliphauntServer`, the same startup methods are available on +`OliphauntServer::builder()`. The `oliphaunt-wasix-proxy` CLI exposes startup GUCs with `--postgres-config NAME=VALUE`. ## Queries @@ -143,11 +147,11 @@ For `PgliteServer`, the same startup methods are available on JSON parameters. ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; use serde_json::json; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; db.exec("CREATE TABLE items(id INT PRIMARY KEY, value TEXT)", None)?; db.query( @@ -169,7 +173,7 @@ common Postgres scalar types, JSON, bytea, UUIDs, timestamps, and built-in arrays. When you add runtime-created array types such as arrays of enums, domains, or -composites, `pglite-oxide` usually discovers them lazily. If you want to refresh +composites, `oliphaunt-wasix` usually discovers them lazily. If you want to refresh that state explicitly, call `refresh_array_types()`. ## Query Options @@ -186,10 +190,10 @@ that state explicitly, call `refresh_array_types()`. Example: ```rust,no_run -use pglite_oxide::{Pglite, QueryOptions, RowMode}; +use oliphaunt_wasix::{Oliphaunt, QueryOptions, RowMode}; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; let options = QueryOptions { row_mode: Some(RowMode::Array), ..QueryOptions::default() @@ -211,11 +215,11 @@ without executing the query. Use `transaction` when several direct calls should commit or roll back together. ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; use serde_json::json; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; db.exec("CREATE TABLE items(value TEXT)", None)?; db.transaction(|tx| { @@ -238,10 +242,10 @@ Use `listen` when you want channel-specific `LISTEN/NOTIFY` callbacks, and `on_notification` when you want to observe every notification. ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; let specific = db.listen("events", |payload| { println!("events payload: {payload}"); @@ -263,14 +267,14 @@ fn main() -> Result<(), Box> { ## `/dev/blob` and COPY -Direct `Pglite` can send and receive bytes through the virtual `/dev/blob` +Direct `Oliphaunt` can send and receive bytes through the virtual `/dev/blob` device. ```rust,no_run -use pglite_oxide::{Pglite, QueryOptions}; +use oliphaunt_wasix::{Oliphaunt, QueryOptions}; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; db.exec("CREATE TABLE items(value TEXT)", None)?; let import = QueryOptions { @@ -288,7 +292,7 @@ fn main() -> Result<(), Box> { } ``` -If you already use a standard Postgres client, `PgliteServer` also supports +If you already use a standard Postgres client, `OliphauntServer` also supports client-driven `COPY FROM STDIN` through the normal wire protocol. ## SQL Helpers @@ -297,11 +301,11 @@ client-driven `COPY FROM STDIN` through the normal wire protocol. `quote_identifier` help build SQL while keeping identifiers and values separate. ```rust,no_run -use pglite_oxide::{Pglite, QueryTemplate, format_query, quote_identifier}; +use oliphaunt_wasix::{Oliphaunt, QueryTemplate, format_query, quote_identifier}; use serde_json::json; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; let sql = format_query(&mut db, "SELECT $1::int", &[json!(42)])?; assert_eq!(sql, "SELECT '42'::int"); @@ -323,15 +327,15 @@ fn main() -> Result<(), Box> { ## Server Mode -Use `PgliteServer` when another crate expects a PostgreSQL URL. +Use `OliphauntServer` when another crate expects a PostgreSQL URL. ```rust,no_run -use pglite_oxide::PgliteServer; +use oliphaunt_wasix::OliphauntServer; use sqlx::{Connection, Row}; #[tokio::main] async fn main() -> Result<(), Box> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.database_url()).await?; let row = sqlx::query("SELECT $1::int4 + 1 AS answer") @@ -346,11 +350,11 @@ async fn main() -> Result<(), Box> { } ``` -`PgliteServer::builder()` supports: +`OliphauntServer::builder()` supports: - `path(...)`, `temporary()`, and `fresh_temporary()`; - `tcp(...)`, and on Unix hosts `unix(...)`; -- the same startup configuration methods as `PgliteBuilder`; +- the same startup configuration methods as `OliphauntBuilder`; - bundled extensions with `extension(...)` and `extensions(...)`. Use `connection_uri()` or `database_url()` to hand a URI to a client library. @@ -365,10 +369,10 @@ SeaORM, `tokio-postgres`, and framework pools with one connection. parsed backend messages and still handles notices and notifications. ```rust,no_run -use pglite_oxide::{ExecProtocolOptions, Pglite}; +use oliphaunt_wasix::{ExecProtocolOptions, Oliphaunt}; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; let mut query = vec![b'Q']; query.extend_from_slice(&13_i32.to_be_bytes()); query.extend_from_slice(b"SELECT 1\0"); @@ -391,16 +395,16 @@ Use physical archives for same-version restore and fast cloning. They are not a cross-version backup protocol. ```rust,no_run -use pglite_oxide::Pglite; +use oliphaunt_wasix::Oliphaunt; fn main() -> Result<(), Box> { - let mut source = Pglite::temporary()?; + let mut source = Oliphaunt::temporary()?; source.exec("CREATE TABLE items(value TEXT)", None)?; source.exec("INSERT INTO items VALUES ('alpha')", None)?; let archive = source.dump_data_dir()?; - let mut restored = Pglite::builder() + let mut restored = Oliphaunt::builder() .temporary() .load_data_dir_archive(archive) .open()?; @@ -424,10 +428,10 @@ With the default feature set, both direct and server APIs expose logical dumps through `PgDumpOptions`. ```rust,no_run -use pglite_oxide::{PgDumpOptions, Pglite}; +use oliphaunt_wasix::{PgDumpOptions, Oliphaunt}; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; db.exec("CREATE TABLE items(value TEXT)", None)?; db.exec("INSERT INTO items VALUES ('alpha')", None)?; @@ -442,8 +446,8 @@ fn main() -> Result<(), Box> { CLI: ```sh -pglite-dump --root ./.pglite -pglite-dump --root ./.pglite -- --schema-only +oliphaunt-wasix-dump --root ./.oliphaunt +oliphaunt-wasix-dump --root ./.oliphaunt -- --schema-only ``` -See [PG_DUMP.md](PG_DUMP.md) for dump/restore and upgrade guidance. +See [Dump and restore](dump-restore.md) for dump/restore and upgrade guidance. diff --git a/examples/moon.yml b/examples/moon.yml new file mode 100644 index 00000000..bbd71ec8 --- /dev/null +++ b/examples/moon.yml @@ -0,0 +1,35 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "integration-examples" +language: "typescript" +layer: "application" +stack: "frontend" +tags: ["examples", "integration"] + +project: + title: "Integration Examples" + description: "Cross-product examples and installed-app validation entrypoints." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash examples/tools/check-examples.sh" + inputs: + - "/examples/**/*" + - "/src/sdks/react-native/examples/**/*" + - "!/src/sdks/react-native/examples/**/node_modules" + - "!/src/sdks/react-native/examples/**/node_modules/**" + - "/src/bindings/wasix-rust/examples/**/*" + - "/src/sdks/react-native/tools/mobile-e2e.sh" + - "/src/sdks/react-native/tools/expo-android-runner.sh" + - "/src/sdks/react-native/tools/expo-ios-runner.sh" + - "/examples/tools/check-examples.sh" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/examples/tauri-sqlx-vanilla/package-lock.json b/examples/tauri-sqlx-vanilla/package-lock.json deleted file mode 100644 index 273d9d7e..00000000 --- a/examples/tauri-sqlx-vanilla/package-lock.json +++ /dev/null @@ -1,1361 +0,0 @@ -{ - "name": "tauri-sqlx-vanilla", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "tauri-sqlx-vanilla", - "version": "0.1.0", - "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" - }, - "devDependencies": { - "@tauri-apps/cli": "^2", - "typescript": "~5.6.2", - "vite": "^6.0.3" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tauri-apps/api": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", - "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.1", - "@tauri-apps/cli-darwin-x64": "2.10.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", - "@tauri-apps/cli-linux-arm64-musl": "2.10.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-musl": "2.10.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", - "@tauri-apps/cli-win32-x64-msvc": "2.10.1" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", - "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", - "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", - "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", - "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", - "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", - "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", - "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", - "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", - "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", - "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", - "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", - "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - } - } -} diff --git a/examples/tools/check-examples.sh b/examples/tools/check-examples.sh new file mode 100755 index 00000000..5d11a0a3 --- /dev/null +++ b/examples/tools/check-examples.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +run examples/tools/check-lockfiles.sh --check + +allowed_root_examples='^(examples/moon\.yml|examples/tools/[^/]+)$' +violations="$( + git ls-files examples | grep -Ev "$allowed_root_examples" || true +)" +if [[ -n "$violations" ]]; then + echo "root examples/ may contain only cross-product example policy/tooling" >&2 + echo "$violations" >&2 + exit 1 +fi + +tracked_node_modules="$( + git ls-files 'examples/**/node_modules/**' 'src/**/examples/**/node_modules/**' || true +)" +if [[ -n "$tracked_node_modules" ]]; then + echo "example dependencies must not be tracked" >&2 + echo "$tracked_node_modules" >&2 + exit 1 +fi + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "missing required product-local example file: $path" >&2 + exit 1 + fi +} + +require_text() { + local path="$1" + local pattern="$2" + if ! grep -Eq "$pattern" "$path"; then + echo "missing required example scheduling pattern in $path: $pattern" >&2 + exit 1 + fi +} + +require_file "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/package.json" +require_file "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml" +require_text "src/bindings/wasix-rust/moon.yml" '^ example-check:$' +require_text "src/bindings/wasix-rust/moon.yml" 'tags: \["examples", "quality", "ci-wasm-regression"\]' + +require_file "src/sdks/react-native/examples/expo/package.json" +require_file "src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml" +require_text "src/sdks/react-native/moon.yml" '^ mobile-build-android:$' +require_text "src/sdks/react-native/moon.yml" '^ mobile-e2e-android:$' +require_text "src/sdks/react-native/moon.yml" '^ mobile-build-ios:$' +require_text "src/sdks/react-native/moon.yml" '^ mobile-e2e-ios:$' + +echo "example ownership and scheduling policy verified" diff --git a/scripts/check-example-lockfiles.sh b/examples/tools/check-lockfiles.sh similarity index 55% rename from scripts/check-example-lockfiles.sh rename to examples/tools/check-lockfiles.sh index e36fa645..2a4183b2 100755 --- a/scripts/check-example-lockfiles.sh +++ b/examples/tools/check-lockfiles.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} cd "$root" base_ref="${EXAMPLE_LOCK_BASE_REF:-}" @@ -22,11 +25,12 @@ changed="$( git diff --name-only "${base_ref}...HEAD" -- \ Cargo.toml \ Cargo.lock \ - crates/assets/Cargo.toml \ - crates/aot \ - examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock \ - scripts/check-example-lockfiles.sh \ - scripts/sync-example-lockfiles.py + src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml \ + src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml \ + src/runtimes/liboliphaunt/wasix/crates/aot \ + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock \ + examples/tools/check-lockfiles.sh \ + tools/release/sync-example-lockfiles.py )" if [[ -z "$changed" ]]; then @@ -34,4 +38,4 @@ if [[ -z "$changed" ]]; then exit 0 fi -scripts/sync-example-lockfiles.py --check +tools/release/sync-example-lockfiles.py --check diff --git a/moon.yml b/moon.yml new file mode 100644 index 00000000..92d49a74 --- /dev/null +++ b/moon.yml @@ -0,0 +1,399 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "repo" +language: "unknown" +layer: "configuration" +stack: "infrastructure" +tags: ["repo", "hygiene", "monorepo"] + +project: + title: "Repository" + description: "Root repository hygiene, workspace metadata, and cross-cutting checks." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "true" + deps: + - "repo:structure" + - "repo:tooling" + - "repo:docs-policy" + - "repo:release-policy" + - "repo:release-metadata" + - "repo:moon-graph" + - "repo:prek" + options: + cache: true + runFromWorkspaceRoot: true + structure: + tags: ["quality", "static"] + command: "bash tools/policy/check-repo-structure.sh" + inputs: + - "/.github/**/*" + - "/.gitignore" + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/benchmarks/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/README.md" + - "/coverage/**/*" + - "/docs/**/*" + - "/src/shared/fixtures/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/**/*" + options: + cache: true + runFromWorkspaceRoot: true + tooling: + tags: ["quality", "static"] + command: "bash tools/policy/check-tooling-stack.sh" + inputs: + - "/.github/**/*" + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/docs/maintainers/tooling.md" + - "/moon.yml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/**/moon.yml" + - "/tools/**/*" + options: + cache: true + runFromWorkspaceRoot: true + docs-policy: + tags: ["quality", "static"] + command: "bash tools/policy/check-docs.sh" + inputs: + - "/docs/**/*" + - "/src/docs/**/*" + - "!/src/docs/node_modules" + - "!/src/docs/node_modules/**" + - "!/src/docs/.next/**" + - "/tools/policy/check-docs.sh" + options: + cache: true + runFromWorkspaceRoot: true + release-policy: + tags: ["quality", "static"] + command: "python3 tools/policy/check-release-policy.py" + inputs: + - "/.github/**/*" + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/Cargo.lock" + - "/Cargo.toml" + - "/Package.swift" + - "/package.json" + - "/pnpm-lock.yaml" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/release/**/*" + - "/tools/policy/check-release-policy.py" + options: + cache: true + runFromWorkspaceRoot: true + release-metadata: + tags: ["quality", "static"] + command: "tools/release/check_release_metadata.py" + inputs: + - "/README.md" + - "/docs/**/*" + - "/Package.swift" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/release/**/*" + options: + cache: true + runFromWorkspaceRoot: true + moon-graph: + tags: ["quality", "static"] + command: "tools/policy/check-moon-product-graph.mjs" + inputs: + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/.release-please-manifest.json" + - "/benchmarks/moon.yml" + - "/coverage/**/*" + - "/moon.yml" + - "/release-please-config.json" + - "/src/**/release.toml" + - "/src/**/moon.yml" + - "/tools/**/moon.yml" + - "/tools/graph/**/*" + - "/tools/policy/check-moon-product-graph.mjs" + options: + cache: true + runFromWorkspaceRoot: true + prek: + tags: ["quality", "static"] + command: "bash tools/policy/check-prek.sh" + inputs: + - "/.config/nextest.toml" + - "/.lychee.toml" + - "/.markdownlint-cli2.jsonc" + - "/.typos.toml" + - "/biome.json" + - "/deny.toml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/prek.toml" + - "/renovate.json" + - "/rust-toolchain.toml" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/policy/check-prek.sh" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "unit"] + command: "bash tools/policy/check-rust-test-topology.sh" + inputs: + - "/.config/nextest.toml" + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/**/*.rs" + - "/src/**/Cargo.toml" + - "/src/**/moon.yml" + - "/src/**/tools/*.sh" + - "/tools/perf/runner/**/*.rs" + - "/tools/perf/runner/Cargo.toml" + - "/tools/xtask/**/*.rs" + - "/tools/xtask/Cargo.toml" + - "/tools/xtask/moon.yml" + - "/tools/policy/check-rust-test-topology.sh" + options: + cache: true + runFromWorkspaceRoot: true + smoke: + tags: ["runtime", "smoke"] + command: "bash examples/tools/check-examples.sh" + inputs: + - "/examples/**/*" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/rust-toolchain.toml" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + regression: + tags: ["regression", "runtime"] + command: "tools/policy/check-test-strategy.mjs" + inputs: + - "/coverage/**/*" + - "/src/shared/fixtures/**/*" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/policy/**/*" + - "/docs/maintainers/testing.md" + - "/package.json" + options: + cache: true + runFromWorkspaceRoot: true + package: + tags: ["package"] + command: "bash tools/policy/check-crate-package.sh --allow-dirty" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/**/Cargo.toml" + - "/src/**/*.rs" + - "/tools/policy/check-crate-size.sh" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false + coverage: + tags: ["coverage", "quality"] + command: "tools/coverage/summarize" + deps: + - target: "oliphaunt-rust:coverage" + cacheStrategy: "outputs" + - target: "oliphaunt-swift:coverage" + cacheStrategy: "outputs" + - target: "oliphaunt-kotlin:coverage" + cacheStrategy: "outputs" + - target: "oliphaunt-js:coverage" + cacheStrategy: "outputs" + - target: "oliphaunt-react-native:coverage" + cacheStrategy: "outputs" + - target: "oliphaunt-wasix-rust:coverage" + cacheStrategy: "outputs" + inputs: + - "/coverage/baseline.toml" + - "/tools/coverage/**/*" + outputs: + - "/target/coverage/summary.json" + - "/target/coverage/summary.md" + options: + cache: true + runFromWorkspaceRoot: true + coverage-policy: + tags: ["coverage", "policy"] + command: "bash tools/policy/check-coverage.sh all" + inputs: + - "/coverage/baseline.toml" + - "/src/**/moon.yml" + - "/src/bindings/*/moon.yml" + - "/tools/coverage/**/*" + - "/tools/policy/check-coverage.sh" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "tools/release/release.py check" + inputs: + - "/.github/**/*" + - "/benchmarks/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/**/*" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + bench-run: + tags: ["bench", "measured"] + command: "bash tools/perf/matrix/run_native_oliphaunt_matrix.sh" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/**/*" + - "/src/extensions/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/rust-toolchain.toml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + bench: + tags: ["bench", "plan"] + command: "bash tools/perf/check-native-perf-harness.sh" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/src/bindings/wasix-rust/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/**/*" + - "/src/extensions/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/rust-toolchain.toml" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false diff --git a/package.json b/package.json new file mode 100644 index 00000000..25195de1 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "oliphaunt-monorepo", + "private": true, + "packageManager": "pnpm@11.5.0", + "engines": { + "node": ">=22.13 <25", + "pnpm": "11.5.0" + }, + "scripts": {} +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..399c4cca --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,13724 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +catalogs: + default: + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.8 + tsx: + specifier: ^4.20.6 + version: 4.22.3 + typedoc: + specifier: ^0.28.16 + version: 0.28.19 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8 + +importers: + + .: {} + + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla: + dependencies: + '@tauri-apps/api': + specifier: ^2 + version: 2.11.0 + '@tauri-apps/plugin-opener': + specifier: ^2 + version: 2.5.4 + devDependencies: + '@tauri-apps/cli': + specifier: ^2 + version: 2.11.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^6.0.3 + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + + src/docs: + dependencies: + '@mdx-js/react': + specifier: ^3.1.0 + version: 3.1.1(@types/react@19.2.16)(react@19.2.7) + '@oliphaunt/react-native': + specifier: workspace:* + version: link:../sdks/react-native + '@oliphaunt/ts': + specifier: workspace:* + version: link:../sdks/js + clsx: + specifier: ^2.1.1 + version: 2.1.1 + fumadocs-core: + specifier: 16.9.3 + version: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-mdx: + specifier: 15.0.10 + version: 15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.16)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + fumadocs-ui: + specifier: 16.9.3 + version: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.0) + lucide-react: + specifier: ^1.17.0 + version: 1.17.0(react@19.2.7) + next: + specifier: 16.2.7 + version: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + smol-toml: + specifier: ^1.4.2 + version: 1.6.1 + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + devDependencies: + '@biomejs/biome': + specifier: ^2.4.16 + version: 2.4.16 + '@tailwindcss/postcss': + specifier: ^4.3.0 + version: 4.3.0 + '@types/mdx': + specifier: ^2.0.13 + version: 2.0.13 + '@types/node': + specifier: 22.19.19 + version: 22.19.19 + '@types/react': + specifier: 19.2.16 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + postcss: + specifier: ^8.5.15 + version: 8.5.15 + tailwindcss: + specifier: 4.3.0 + version: 4.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + src/runtimes/node-direct: + devDependencies: + node-api-headers: + specifier: 1.9.0 + version: 1.9.0 + + src/runtimes/node-direct/packages/darwin-arm64: {} + + src/runtimes/node-direct/packages/linux-arm64-gnu: {} + + src/runtimes/node-direct/packages/linux-x64-gnu: {} + + src/runtimes/node-direct/packages/win32-x64-msvc: {} + + src/sdks/js: + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.4 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + jsr: + specifier: ^0.14.3 + version: 0.14.3 + tsx: + specifier: 'catalog:' + version: 4.22.3 + typedoc: + specifier: 'catalog:' + version: 0.28.19(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + optionalDependencies: + '@oliphaunt/node-direct-darwin-arm64': + specifier: workspace:0.1.0 + version: link:../../runtimes/node-direct/packages/darwin-arm64 + '@oliphaunt/node-direct-linux-arm64-gnu': + specifier: workspace:0.1.0 + version: link:../../runtimes/node-direct/packages/linux-arm64-gnu + '@oliphaunt/node-direct-linux-x64-gnu': + specifier: workspace:0.1.0 + version: link:../../runtimes/node-direct/packages/linux-x64-gnu + '@oliphaunt/node-direct-win32-x64-msvc': + specifier: workspace:0.1.0 + version: link:../../runtimes/node-direct/packages/win32-x64-msvc + + src/sdks/react-native: + devDependencies: + '@react-native/codegen': + specifier: ^0.85.3 + version: 0.85.3 + '@react-native/typescript-config': + specifier: ^0.85.0 + version: 0.85.3 + '@types/node': + specifier: ^24.10.1 + version: 24.12.4 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + react: + specifier: ^19.2.0 + version: 19.2.3 + react-native: + specifier: ^0.85.0 + version: 0.85.3(@types/react@19.2.16)(react@19.2.3) + tsx: + specifier: 'catalog:' + version: 4.22.3 + typedoc: + specifier: 'catalog:' + version: 0.28.19(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + + src/sdks/react-native/examples/expo: + dependencies: + '@expo/ui': + specifier: ~56.0.13 + version: 56.0.13(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@oliphaunt/react-native': + specifier: workspace:* + version: link:../.. + expo: + specifier: ~56.0.4 + version: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-constants: + specifier: ~56.0.14 + version: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-dev-client: + specifier: ~56.0.15 + version: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-device: + specifier: ~56.0.4 + version: 56.0.4(expo@56.0.4) + expo-font: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-glass-effect: + specifier: ~56.0.4 + version: 56.0.4(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-image: + specifier: ~56.0.9 + version: 56.0.9(expo@56.0.4)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-linking: + specifier: ~56.0.11 + version: 56.0.11(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-router: + specifier: ~56.2.6 + version: 56.2.6(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-linking@56.0.11)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native-gesture-handler@2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-splash-screen: + specifier: ~56.0.10 + version: 56.0.10(expo@56.0.4)(typescript@6.0.3) + expo-sqlite: + specifier: ~56.0.4 + version: 56.0.4(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-status-bar: + specifier: ~56.0.4 + version: 56.0.4(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-symbols: + specifier: ~56.0.5 + version: 56.0.5(expo-font@56.0.5)(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-system-ui: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.4)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-web-browser: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + react-native: + specifier: 0.85.3 + version: 0.85.3(@types/react@19.2.15)(react@19.2.3) + react-native-gesture-handler: + specifier: ~2.31.1 + version: 2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-reanimated: + specifier: 4.3.1 + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-safe-area-context: + specifier: ~5.7.0 + version: 5.7.0(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-screens: + specifier: 4.25.2 + version: 4.25.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-web: + specifier: ~0.21.0 + version: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-native-worklets: + specifier: 0.8.3 + version: 0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + devDependencies: + '@types/react': + specifier: ~19.2.2 + version: 19.2.15 + eslint: + specifier: ^9.0.0 + version: 9.39.4(jiti@2.7.0) + eslint-config-expo: + specifier: ~56.0.4 + version: 56.0.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + expo-doctor: + specifier: ^1.19.7 + version: 1.19.8 + expo-mcp: + specifier: ~0.2.1 + version: 0.2.4 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.27.1': + resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.28.6': + resolution: {integrity: sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@2.4.16': + resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.16': + resolution: {integrity: sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.16': + resolution: {integrity: sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.16': + resolution: {integrity: sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.16': + resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.16': + resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.16': + resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.16': + resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.16': + resolution: {integrity: sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@expo-google-fonts/material-symbols@0.4.38': + resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} + + '@expo/cli@56.1.11': + resolution: {integrity: sha512-agoVjJ+cygAAjWSjk278a+UVcHDVZMKkBROkzWxpSzMK0tmRuQvtRZvAgFN/wvGZchxzs+zGur7j3txojVMVZw==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@56.0.8': + resolution: {integrity: sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==} + + '@expo/config-types@56.0.5': + resolution: {integrity: sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==} + + '@expo/config@56.0.9': + resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@56.0.2': + resolution: {integrity: sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@56.0.5': + resolution: {integrity: sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.3.0': + resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==} + engines: {node: '>=20.12.0'} + + '@expo/expo-modules-macros-plugin@0.0.9': + resolution: {integrity: sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA==} + + '@expo/fingerprint@0.19.2': + resolution: {integrity: sha512-+/cBrRHiHmldvT8ZPrrHobAOMTUTzOq6Qpr1YLSoIg0J9hbEkJOg9vUvpxiLNWSQY0eKtVTvMO03EIdPC2aQdQ==} + hasBin: true + + '@expo/image-utils@0.10.1': + resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==} + + '@expo/inline-modules@0.0.9': + resolution: {integrity: sha512-otMUXI4mOjytbe9OQ3i5X4SV0LP1GpzqLdr9+rdxUc1b0FjdvbTM/GkcbrwY4pU0fGSK0qFqX+jgSieyi+XbUA==} + + '@expo/json-file@10.2.0': + resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} + + '@expo/local-build-cache-provider@56.0.7': + resolution: {integrity: sha512-GedmHPUQeLKbRZNzxZ4ZiN7NKQw65MSOMMnIqJnbXySZYYeBWg5TMuCzafE0Pt0Tsd2vmp2F7OPpsgAFGFoaBw==} + + '@expo/log-box@56.0.12': + resolution: {integrity: sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/mcp-tunnel@0.2.4': + resolution: {integrity: sha512-hxFzqdUNKCt+8pbGV3oGcd/aBNA1mmhwh3DSeXoHReypxzsiLYLITJs1OctglaPecfMA9qFb+6z/RIkRSf5S4g==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.26.0 + + '@expo/metro-config@56.0.12': + resolution: {integrity: sha512-L9q423WwY6eUu4A3N8OaBDECuoUNukUQKomb0/LinwzG+QkU8cBvpELXwEngP7eTt1s6LB3tXcnPp/aMvLsojw==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-file-map@56.0.3': + resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==} + + '@expo/metro-runtime@56.0.12': + resolution: {integrity: sha512-7fWsZfIq+Kn6ilr5lx1YNQGJjukmvwnrl91cTRASdQIKXQoXF7AXRAU0CrDjA+dNMZ6UWDK3l8wpQjk7CA1Z/A==} + peerDependencies: + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@56.0.0': + resolution: {integrity: sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==} + + '@expo/osascript@2.6.0': + resolution: {integrity: sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==} + engines: {node: '>=12'} + + '@expo/package-manager@1.12.0': + resolution: {integrity: sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg==} + + '@expo/plist@0.7.0': + resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==} + + '@expo/prebuild-config@56.0.12': + resolution: {integrity: sha512-cMI1EwpVhVaZQ92VtkRGpyvBV/iC06NMBwi+p69mwvoQTJKqswgCwYK7txFH5KaavKMmYMUaZ1twiC7jd/jDRQ==} + + '@expo/require-utils@56.1.3': + resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@56.0.11': + resolution: {integrity: sha512-cyROEK3gibypiyz2QR7zm1+LMHUQEj7KQopwZ/Fip75MYrQ/SYOMRFSTvchZXEipwMRjwYecE4jsnqNKyYWFZg==} + peerDependencies: + '@expo/metro-runtime': ^56.0.11 + expo: '*' + expo-constants: ^56.0.14 + expo-font: ^56.0.5 + expo-router: '*' + expo-server: ^56.0.4 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@56.0.1': + resolution: {integrity: sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.8.0': + resolution: {integrity: sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/ui@56.0.13': + resolution: {integrity: sha512-Dx05pO3lo8lzWp0hgvJ011j/a5DD2BwHXtr08hdiRUc03KrWQJ3QzdbqPqNayrr+Usc2COC+bOkmPNX7N0k0+w==} + peerDependencies: + '@babel/core': '*' + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-reanimated: '*' + react-native-worklets: '*' + peerDependenciesMeta: + '@babel/core': + optional: true + react-dom: + optional: true + react-native-reanimated: + optional: true + react-native-worklets: + optional: true + + '@expo/ws-tunnel@1.0.6': + resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + + '@expo/xcpretty@4.4.4': + resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} + hasBin: true + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fumadocs/tailwind@0.0.5': + resolution: {integrity: sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ==} + peerDependencies: + '@tailwindcss/oxide': ^4.0.0 + tailwindcss: ^4.0.0 + peerDependenciesMeta: + '@tailwindcss/oxide': + optional: true + tailwindcss: + optional: true + + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@next/env@16.2.7': + resolution: {integrity: sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==} + + '@next/swc-darwin-arm64@16.2.7': + resolution: {integrity: sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.7': + resolution: {integrity: sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.7': + resolution: {integrity: sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.7': + resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.7': + resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.7': + resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.7': + resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.7': + resolution: {integrity: sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@orama/orama@3.1.18': + resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} + engines: {node: '>= 20.0.0'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@react-native-masked-view/masked-view@0.3.2': + resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} + peerDependencies: + react: '>=16' + react-native: '>=0.57' + + '@react-native/assets-registry@0.85.3': + resolution: {integrity: sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-plugin-codegen@0.85.3': + resolution: {integrity: sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/codegen@0.85.3': + resolution: {integrity: sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/community-cli-plugin@0.85.3': + resolution: {integrity: sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': 0.85.3 + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.85.3': + resolution: {integrity: sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/debugger-shell@0.85.3': + resolution: {integrity: sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/dev-middleware@0.85.3': + resolution: {integrity: sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/gradle-plugin@0.85.3': + resolution: {integrity: sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/js-polyfills@0.85.3': + resolution: {integrity: sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.85.3': + resolution: {integrity: sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw==} + + '@react-native/typescript-config@0.85.3': + resolution: {integrity: sha512-F2Ign3lv/99R5HMDiaQE6NpRdopn87VuXgfHABSk0iwzouLFk1fcwaMkJUmjhnxrQagsUwxOWp4WTPwEvRRazQ==} + + '@react-native/virtualized-lists@0.85.3': + resolution: {integrity: sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: 0.85.3 + peerDependenciesMeta: + '@types/react': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@shikijs/core@4.1.0': + resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.1.0': + resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/engine-oniguruma@4.1.0': + resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} + engines: {node: '>=20'} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/langs@4.1.0': + resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.1.0': + resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} + engines: {node: '>=20'} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/themes@4.1.0': + resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} + engines: {node: '>=20'} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/types@4.1.0': + resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.0': + resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + + '@tauri-apps/cli-darwin-arm64@2.11.2': + resolution: {integrity: sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.2': + resolution: {integrity: sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.2': + resolution: {integrity: sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.2': + resolution: {integrity: sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.2': + resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': + resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.2': + resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.2': + resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.2': + resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.2': + resolution: {integrity: sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.2': + resolution: {integrity: sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.2': + resolution: {integrity: sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-test-renderer@19.1.0': + resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} + + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} + + '@types/react@19.2.16': + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.3: + resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.33.3: + resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-expo@56.0.12: + resolution: {integrity: sha512-8sOIpdzMXgx81CcCF4wwAQC8xo9akFgy32pciVjHo/4tphLOXez7wfqv5p9StgyMLQPEF4qhXG2Rkbz1QAgu2A==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^56.0.14 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.3.0: + resolution: {integrity: sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dnssd-advertise@1.1.4: + resolution: {integrity: sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.361: + resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.22.1: + resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-config-expo@56.0.4: + resolution: {integrity: sha512-1OD7rJMxCchKHxq+U+OQsAxVtzAxeUb9875g6+15KsSD9fqKTgq7DEEWYwunzU9r9E8kYJ+mh7+j86vF9m9NMw==} + peerDependencies: + eslint: '>=8.10' + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-expo@1.0.3: + resolution: {integrity: sha512-C1v9NPvpDET36+7Klpp/+53Jl+VzOfpbDxpKtL/pAPhCDwTX0kW6Swo425PT0uc4AMT5jpQbB7hSKFjKOGMl4A==} + engines: {node: '>=18.0.0'} + peerDependencies: + eslint: '>=8.10' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-value-to-estree@3.5.0: + resolution: {integrity: sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + expo-asset@56.0.14: + resolution: {integrity: sha512-3rN/VGt4jhNOXbAz3gdSJzC/dSmX5ozHgtG/HQTCOzuRGBd1q2lzWoZewX8aU1fiWchQg9LasiLiAJi+u8oBbQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-constants@56.0.15: + resolution: {integrity: sha512-7187sd55swLX+CM0noAV5LreEgkUaDG/zEXy9quonfzKpJxy8zJAszp9S++xOx/FcqBVEEEcQhE8tKTujwL8fg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-client@56.0.15: + resolution: {integrity: sha512-YJBO0xMv0CRhVhZu4NPWoR0zS/nyhbjpBiEhEd4SOD/mcmW1I1ncURLn8Ej63yJjuCGL6pFQLkCskAak1OJyuA==} + peerDependencies: + expo: '*' + + expo-dev-launcher@56.0.15: + resolution: {integrity: sha512-KVG8haacJiYHu7wLJiDYQbKM0CqFBqf0BJ9YvWWBhxOZjNOtwspVIsKS4idiIOeQsFCRb2Axt8svQ+opvuvE6A==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-menu-interface@56.0.1: + resolution: {integrity: sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA==} + peerDependencies: + expo: '*' + + expo-dev-menu@56.0.14: + resolution: {integrity: sha512-4dx14nedjWSCdpPKj74IGIfuM5nd2ePMpD3vNraq+srsZzWfNMh9gLFwcXtfQIpgXkHavO5178bJ3VCJVsnNsg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-device@56.0.4: + resolution: {integrity: sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg==} + peerDependencies: + expo: '*' + + expo-doctor@1.19.8: + resolution: {integrity: sha512-ZHpQM+BfJe1DNaA+/ObtLYazC2x78tIV3kkfoSGR46Tj1EvzOFh1p9gFHsILI9TOyXPEcgxCkFw9AVvf8C4c1g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + expo-file-system@56.0.7: + resolution: {integrity: sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@56.0.5: + resolution: {integrity: sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@56.0.4: + resolution: {integrity: sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-image@56.0.9: + resolution: {integrity: sha512-FifiRehXnMul5XeUVHWv+COHFUeCAdsYf5MiCPUBlhr4pRb0sxjA4/floi/TEDpATOIw6GqxbrC4FdZBoyrJmw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-json-utils@56.0.0: + resolution: {integrity: sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg==} + + expo-keep-awake@56.0.3: + resolution: {integrity: sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@56.0.11: + resolution: {integrity: sha512-MEPgML2mqm2Y8rP6zTleOpCmYiFyfQfNSOBpDIb7CYpbDQleStugvceKsEsL4v8C0Dl5u7e8KkkrbqmgpOOIBw==} + peerDependencies: + react: '*' + react-native: '*' + + expo-manifests@56.0.4: + resolution: {integrity: sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw==} + peerDependencies: + expo: '*' + + expo-mcp@0.2.4: + resolution: {integrity: sha512-rBomlm+085wNa+UF9YC3bXGZR6LlYPfOlUXwKBB5R7+dnASk0VjWFETuxyApdtXw9OItmOsAXolUxrAlEYfqSA==} + hasBin: true + + expo-modules-autolinking@56.0.12: + resolution: {integrity: sha512-Sn/LiLSL4as/YOGoatsuRQYS7rxAQHK2oYbFMT/I3iIXHLSeb0DQeuAIytVQ9ypWWck9s2krH2T6NeztyftnaA==} + hasBin: true + + expo-modules-core@56.0.12: + resolution: {integrity: sha512-2Rf+FBU2EXe27km3m066xHu4kuUSpNT35nzk98fFxIV8B2Ah+FHub2rvAznEcGAUlDArVA2S/6+pMlHWijbicQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: ^0.7.4 || ^0.8.0 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-modules-jsi@56.0.7: + resolution: {integrity: sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA==} + peerDependencies: + react-native: '*' + + expo-router@56.2.6: + resolution: {integrity: sha512-KouVa/E2zQc1aALWSd5eZjbsLKldgozQ546p+bgAR2nGPRTO4WpMRKNIxjB89We1G4RwWpxQ5vgTU1WZ+FpqOg==} + peerDependencies: + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^56.0.15 + expo-linking: ^56.0.11 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: ^4.25.2 + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-server@56.0.4: + resolution: {integrity: sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@56.0.10: + resolution: {integrity: sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA==} + peerDependencies: + expo: '*' + + expo-sqlite@56.0.4: + resolution: {integrity: sha512-Ak8TUyrvK7C/J4BHBfcb8BacFrH8I+b+zqeSTKg5B02Z13lxljvuqI8UvKbRNa5BKprlxrqabZickGwacRkM9g==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-status-bar@56.0.4: + resolution: {integrity: sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-symbols@56.0.5: + resolution: {integrity: sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo-system-ui@56.0.5: + resolution: {integrity: sha512-n1MmnUArV4cc3gVed9fGtluPme00PE9axKVx+NHbKxHFMam5l4GcOI7PxbYKFNx8o7WA1LRD7eLW33agmZrxGg==} + peerDependencies: + expo: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-updates-interface@56.0.2: + resolution: {integrity: sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g==} + peerDependencies: + expo: '*' + + expo-web-browser@56.0.5: + resolution: {integrity: sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo@56.0.4: + resolution: {integrity: sha512-ZwoOkOTwITJrFQRRO5tUsBp6NlddvjWSs3ADb+zOu1UIQWBCk9dmwmSrdFxu0P+hYnU2hk5k/Y6xq6DPLNSKzg==} + hasBin: true + peerDependencies: + '@expo/metro-runtime': '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-web: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + react-dom: + optional: true + react-native-web: + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fumadocs-core@16.9.3: + resolution: {integrity: sha512-8RVzKnzBJR5o+tJCccY28ntekfMQYBoYiz7alnYb/d9YJc+XpnsINzTl63lQ1eBMZ9gdhm2MqRtgUjh/8rUrbw==} + peerDependencies: + '@mdx-js/mdx': '*' + '@mixedbread/sdk': 0.x.x + '@orama/core': 1.x.x + '@oramacloud/client': 2.x.x + '@tanstack/react-router': 1.x.x + '@types/estree-jsx': '*' + '@types/hast': '*' + '@types/mdast': '*' + '@types/react': '*' + algoliasearch: 5.x.x + flexsearch: '*' + lucide-react: '*' + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + react-router: 7.x.x + waku: '*' + zod: 4.x.x + peerDependenciesMeta: + '@mdx-js/mdx': + optional: true + '@mixedbread/sdk': + optional: true + '@orama/core': + optional: true + '@oramacloud/client': + optional: true + '@tanstack/react-router': + optional: true + '@types/estree-jsx': + optional: true + '@types/hast': + optional: true + '@types/mdast': + optional: true + '@types/react': + optional: true + algoliasearch: + optional: true + flexsearch: + optional: true + lucide-react: + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + react-router: + optional: true + waku: + optional: true + zod: + optional: true + + fumadocs-mdx@15.0.10: + resolution: {integrity: sha512-kH3S7ESS9yXTAaCkA8dDugsCK/MbnpgyZ5qBEL7cWoavV0O/T4+4YTYFkvNknz7cw+T/r+OG0p2BvlVhkk4fww==} + hasBin: true + peerDependencies: + '@types/mdast': '*' + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: ^16.7.0 + mdast-util-directive: '*' + next: ^15.3.0 || ^16.0.0 + react: ^19.2.0 + rolldown: '*' + vite: 7.x.x || 8.x.x + peerDependenciesMeta: + '@types/mdast': + optional: true + '@types/mdx': + optional: true + '@types/react': + optional: true + mdast-util-directive: + optional: true + next: + optional: true + react: + optional: true + rolldown: + optional: true + vite: + optional: true + + fumadocs-ui@16.9.3: + resolution: {integrity: sha512-eoVKj1H+ATut0su+WIoPWBLRqzPMGD0hekIBr4GopWvUg1lS997HL4kP+Leyf+3CYlZtFgyXb6ylbvRLFtEj6Q==} + peerDependencies: + '@takumi-rs/image-response': '*' + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: 16.9.3 + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@takumi-rs/image-response': + optional: true + '@types/mdx': + optional: true + '@types/react': + optional: true + next: + optional: true + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + hermes-compiler@250829098.0.10: + resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-estree@0.33.3: + resolution: {integrity: sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hermes-parser@0.33.3: + resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hono@4.12.22: + resolution: {integrity: sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==} + engines: {node: '>=16.9.0'} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsr@0.14.3: + resolution: {integrity: sha512-PGxnDepx7vwJoZQe2SHbyBiFfpGwsOKmX4kn/wZZqfMafV7fjXqTxSaX6lp9QHYkSTLKkER+P/wmrZY3gVJNzg==} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} + hasBin: true + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.84.4: + resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.84.4: + resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.84.4: + resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.84.4: + resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.84.4: + resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.84.4: + resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.84.4: + resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.84.4: + resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.84.4: + resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.84.4: + resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.84.4: + resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.84.4: + resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.84.4: + resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.84.4: + resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@2.0.1: + resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} + + multitars@1.0.0: + resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.2.7: + resolution: {integrity: sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-api-headers@1.9.0: + resolution: {integrity: sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + node-stream-zip@1.15.0: + resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} + engines: {node: '>=0.12.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + ob1@0.84.4: + resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + + react-native-drawer-layout@4.2.4: + resolution: {integrity: sha512-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + react-native-gesture-handler: '>= 2.0.0' + react-native-reanimated: '>= 2.0.0' + + react-native-gesture-handler@2.31.2: + resolution: {integrity: sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.3.1: + resolution: {integrity: sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==} + peerDependencies: + react: '*' + react-native: 0.81 - 0.85 + react-native-worklets: 0.8.x + + react-native-safe-area-context@5.7.0: + resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.25.2: + resolution: {integrity: sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==} + peerDependencies: + react: '*' + react-native: '>=0.82.0' + + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-worklets@0.8.3: + resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} + peerDependencies: + '@babel/core': '*' + '@react-native/metro-config': '*' + react: '*' + react-native: 0.81 - 0.85 + + react-native@0.85.3: + resolution: {integrity: sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + peerDependencies: + '@react-native/jest-preset': 0.85.3 + '@types/react': ^19.1.1 + react: ^19.2.3 + peerDependenciesMeta: + '@react-native/jest-preset': + optional: true + '@types/react': + optional: true + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} + hasBin: true + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + semiver@1.1.0: + resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} + engines: {node: '>=6'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + + shiki@4.1.0: + resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} + engines: {node: '>=20'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typedoc@0.28.19: + resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@0.7.41: + resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-url-minimum@0.1.2: + resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.11: + resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + + zx@8.8.5: + resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} + engines: {node: '>= 12.17.0'} + hasBin: true + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.4.16': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.16 + '@biomejs/cli-darwin-x64': 2.4.16 + '@biomejs/cli-linux-arm64': 2.4.16 + '@biomejs/cli-linux-arm64-musl': 2.4.16 + '@biomejs/cli-linux-x64': 2.4.16 + '@biomejs/cli-linux-x64-musl': 2.4.16 + '@biomejs/cli-win32-arm64': 2.4.16 + '@biomejs/cli-win32-x64': 2.4.16 + + '@biomejs/cli-darwin-arm64@2.4.16': + optional: true + + '@biomejs/cli-darwin-x64@2.4.16': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.16': + optional: true + + '@biomejs/cli-linux-arm64@2.4.16': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.16': + optional: true + + '@biomejs/cli-linux-x64@2.4.16': + optional: true + + '@biomejs/cli-win32-arm64@2.4.16': + optional: true + + '@biomejs/cli-win32-x64@2.4.16': + optional: true + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': + dependencies: + eslint: 9.39.4(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@expo-google-fonts/material-symbols@0.4.38': {} + + '@expo/cli@56.1.11(@expo/metro-runtime@56.0.12)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-router@56.2.6)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 56.0.9(typescript@6.0.3) + '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/devcert': 1.2.1 + '@expo/env': 2.3.0 + '@expo/image-utils': 0.10.1(typescript@6.0.3) + '@expo/inline-modules': 0.0.9(typescript@6.0.3) + '@expo/json-file': 10.2.0 + '@expo/log-box': 56.0.12(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@expo/metro': 56.0.0 + '@expo/metro-config': 56.0.12(expo@56.0.4)(typescript@6.0.3) + '@expo/metro-file-map': 56.0.3 + '@expo/osascript': 2.6.0 + '@expo/package-manager': 1.12.0 + '@expo/plist': 0.7.0 + '@expo/prebuild-config': 56.0.12(typescript@6.0.3) + '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/router-server': 56.0.11(@expo/metro-runtime@56.0.12)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-router@56.2.6)(expo-server@56.0.4)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@expo/schema-utils': 56.0.1 + '@expo/spawn-async': 1.8.0 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.4 + '@react-native/dev-middleware': 0.85.3 + accepts: 1.3.8 + arg: 5.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.4 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-server: 56.0.4 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.0 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.4 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + semver: 7.8.1 + send: 0.19.2 + slugify: 1.6.9 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.21.0 + zod: 3.25.76 + optionalDependencies: + expo-router: 56.2.6(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-linking@56.0.11)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native-gesture-handler@2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + transitivePeerDependencies: + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@56.0.8(typescript@6.0.3)': + dependencies: + '@expo/config-types': 56.0.5 + '@expo/json-file': 10.2.0 + '@expo/plist': 0.7.0 + '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + semver: 7.8.1 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/config-types@56.0.5': {} + + '@expo/config@56.0.9(typescript@6.0.3)': + dependencies: + '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/config-types': 56.0.5 + '@expo/json-file': 10.2.0 + '@expo/require-utils': 56.1.3(typescript@6.0.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.8.1 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/devcert@1.2.1': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/devtools@56.0.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + '@expo/dom-webview@56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + '@expo/env@2.3.0': + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/expo-modules-macros-plugin@0.0.9': {} + + '@expo/fingerprint@0.19.2': + dependencies: + '@expo/env': 2.3.0 + '@expo/spawn-async': 1.8.0 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.5 + resolve-from: 5.0.0 + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.10.1(typescript@6.0.3)': + dependencies: + '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/inline-modules@0.0.9(typescript@6.0.3)': + dependencies: + '@expo/config-plugins': 56.0.8(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/json-file@10.2.0': + dependencies: + '@babel/code-frame': 7.29.0 + json5: 2.2.3 + + '@expo/local-build-cache-provider@56.0.7(typescript@6.0.3)': + dependencies: + '@expo/config': 56.0.9(typescript@6.0.3) + chalk: 4.1.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/log-box@56.0.12(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + dependencies: + '@expo/dom-webview': 56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + anser: 1.4.10 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + stacktrace-parser: 0.1.11 + + '@expo/mcp-tunnel@0.2.4(@modelcontextprotocol/sdk@1.29.0)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0 + ws: 8.21.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@expo/metro-config@56.0.12(expo@56.0.4)(typescript@6.0.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@expo/config': 56.0.9(typescript@6.0.3) + '@expo/env': 2.3.0 + '@expo/json-file': 10.2.0 + '@expo/metro': 56.0.0 + '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + browserslist: 4.28.2 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.33.3 + jsc-safe-url: 0.2.4 + lightningcss: 1.32.0 + msgpackr: 2.0.1 + picomatch: 4.0.4 + postcss: 8.5.15 + resolve-from: 5.0.0 + optionalDependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-file-map@56.0.3': + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + '@expo/metro-runtime@56.0.12(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + dependencies: + '@expo/log-box': 56.0.12(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + anser: 1.4.10 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + pretty-format: 29.7.0 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + + '@expo/metro@56.0.0': + dependencies: + metro: 0.84.4 + metro-babel-transformer: 0.84.4 + metro-cache: 0.84.4 + metro-cache-key: 0.84.4 + metro-config: 0.84.4 + metro-core: 0.84.4 + metro-file-map: 0.84.4 + metro-minify-terser: 0.84.4 + metro-resolver: 0.84.4 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + metro-symbolicate: 0.84.4 + metro-transform-plugins: 0.84.4 + metro-transform-worker: 0.84.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/osascript@2.6.0': + dependencies: + '@expo/spawn-async': 1.8.0 + + '@expo/package-manager@1.12.0': + dependencies: + '@expo/json-file': 10.2.0 + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.7.0': + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@56.0.12(typescript@6.0.3)': + dependencies: + '@expo/config': 56.0.9(typescript@6.0.3) + '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/config-types': 56.0.5 + '@expo/image-utils': 0.10.1(typescript@6.0.3) + '@expo/json-file': 10.2.0 + '@react-native/normalize-colors': 0.85.3 + debug: 4.4.3 + expo-modules-autolinking: 56.0.12(typescript@6.0.3) + resolve-from: 5.0.0 + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@56.1.3(typescript@6.0.3)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@56.0.11(@expo/metro-runtime@56.0.12)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-router@56.2.6)(expo-server@56.0.4)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + debug: 4.4.3 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-font: 56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-server: 56.0.4 + react: 19.2.3 + optionalDependencies: + '@expo/metro-runtime': 56.0.12(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-router: 56.2.6(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-linking@56.0.11)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native-gesture-handler@2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@56.0.1': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.8.0': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/ui@56.0.13(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + sf-symbols-typescript: 2.2.0 + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + '@babel/core': 7.29.0 + react-dom: 19.2.3(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@expo/ws-tunnel@1.0.6': {} + + '@expo/xcpretty@4.4.4': + dependencies: + '@babel/code-frame': 7.29.0 + chalk: 4.1.2 + js-yaml: 4.1.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@fumadocs/tailwind@0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0)': + optionalDependencies: + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@hono/node-server@1.19.14(hono@4.12.22)': + dependencies: + hono: 4.12.22 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/cliui@9.0.0': {} + + '@isaacs/ttlcache@1.4.1': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.12.4 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + acorn: 8.16.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.16.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@mdx-js/react@3.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@types/mdx': 2.0.13 + '@types/react': 19.2.16 + react: 19.2.7 + + '@modelcontextprotocol/sdk@1.29.0': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.22) + ajv: 8.20.0 + ajv-formats: 3.0.1 + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.22 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@next/env@16.2.7': {} + + '@next/swc-darwin-arm64@16.2.7': + optional: true + + '@next/swc-darwin-x64@16.2.7': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.7': + optional: true + + '@next/swc-linux-arm64-musl@16.2.7': + optional: true + + '@next/swc-linux-x64-gnu@16.2.7': + optional: true + + '@next/swc-linux-x64-musl@16.2.7': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.7': + optional: true + + '@next/swc-win32-x64-msvc@16.2.7': + optional: true + + '@nolyfill/is-core-module@1.0.39': {} + + '@orama/orama@3.1.18': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.15)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.15)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.15)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.15)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.15)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/rect': 1.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.15)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.15)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.15)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.15)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.15)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.15)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.15)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.16)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@radix-ui/rect@1.1.1': {} + + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + '@react-native/assets-registry@0.85.3': {} + + '@react-native/babel-plugin-codegen@0.85.3': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.85.3 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.85.3': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + hermes-parser: 0.33.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.16 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + + '@react-native/community-cli-plugin@0.85.3': + dependencies: + '@react-native/dev-middleware': 0.85.3 + debug: 4.4.3 + invariant: 2.2.4 + metro: 0.84.4 + metro-config: 0.84.4 + metro-core: 0.84.4 + semver: 7.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.85.3': {} + + '@react-native/debugger-shell@0.85.3': + dependencies: + cross-spawn: 7.0.6 + debug: 4.4.3 + fb-dotslash: 0.5.8 + transitivePeerDependencies: + - supports-color + + '@react-native/dev-middleware@0.85.3': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.85.3 + '@react-native/debugger-shell': 0.85.3 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.3.0 + connect: 3.7.0 + debug: 4.4.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3 + ws: 7.5.11 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.85.3': {} + + '@react-native/js-polyfills@0.85.3': {} + + '@react-native/normalize-colors@0.74.89': {} + + '@react-native/normalize-colors@0.85.3': {} + + '@react-native/typescript-config@0.85.3': {} + + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.15)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.16)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.16 + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@rtsao/scc@1.1.0': {} + + '@shikijs/core@4.1.0': + dependencies: + '@shikijs/primitive': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/engine-oniguruma@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/langs@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/primitive@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/types@4.1.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sinclair/typebox@0.27.10': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.1 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/postcss@4.3.0': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + postcss: 8.5.15 + tailwindcss: 4.3.0 + + '@tauri-apps/api@2.11.0': {} + + '@tauri-apps/cli-darwin-arm64@2.11.2': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.2': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.2': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.2': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.2': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.2': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.2': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.2': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.2': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.2': + optional: true + + '@tauri-apps/cli@2.11.2': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.2 + '@tauri-apps/cli-darwin-x64': 2.11.2 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.2 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.2 + '@tauri-apps/cli-linux-arm64-musl': 2.11.2 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.2 + '@tauri-apps/cli-linux-x64-gnu': 2.11.2 + '@tauri-apps/cli-linux-x64-musl': 2.11.2 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.2 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.2 + '@tauri-apps/cli-win32-x64-msvc': 2.11.2 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/user-event@14.6.1': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hammerjs@2.0.46': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.13': {} + + '@types/ms@2.1.0': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.15)': + dependencies: + '@types/react': 19.2.15 + optional: true + + '@types/react-dom@19.2.3(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + + '@types/react-test-renderer@19.1.0': + dependencies: + '@types/react': 19.2.15 + + '@types/react@19.2.15': + dependencies: + csstype: 3.2.3 + + '@types/react@19.2.16': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 9.39.4(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.4': {} + + '@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + eslint: 9.39.4(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.1': {} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1: + dependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + anser@1.4.10: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.3: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astring@1.9.0: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + await-lock@2.2.2: {} + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.33.3: + dependencies: + hermes-parser: 0.33.3 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.0): + dependencies: + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-expo@56.0.12(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@56.0.4)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@react-native/babel-plugin-codegen': 0.85.3 + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.0) + debug: 4.4.3 + react-refresh: 0.14.2 + optionalDependencies: + '@babel/runtime': 7.29.2 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.32: {} + + big-integer@1.6.52: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.361 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001793: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chrome-launcher@0.15.2: + dependencies: + '@types/node': 24.12.4 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.3.0: + dependencies: + '@types/node': 24.12.4 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + comma-separated-tokens@2.0.3: {} + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + compute-scroll-into-view@3.1.1: {} + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + decode-uri-component@0.2.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dnssd-advertise@1.1.4: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.361: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.22.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.16.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-config-expo@56.0.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-expo: 1.0.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) + globals: 16.5.0 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + - typescript + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.2 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + get-tsconfig: 4.14.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.16 + unrs-resolver: 1.12.2 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-expo@1.0.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.39.4(jiti@2.7.0) + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + hasown: 2.0.3 + is-core-module: 2.16.2 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + eslint: 9.39.4(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4(jiti@2.7.0) + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-value-to-estree@3.5.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + + expect-type@1.3.0: {} + + expo-asset@56.0.14(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.10.1(typescript@6.0.3) + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + transitivePeerDependencies: + - supports-color + - typescript + + expo-constants@56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + '@expo/env': 2.3.0 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + transitivePeerDependencies: + - supports-color + + expo-dev-client@56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-dev-launcher: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-dev-menu: 56.0.14(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-dev-menu-interface: 56.0.1(expo@56.0.4) + expo-manifests: 56.0.4(expo@56.0.4) + expo-updates-interface: 56.0.2(expo@56.0.4) + transitivePeerDependencies: + - react-native + + expo-dev-launcher@56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + '@expo/schema-utils': 56.0.1 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-dev-menu: 56.0.14(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-manifests: 56.0.4(expo@56.0.4) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-dev-menu-interface@56.0.1(expo@56.0.4): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + + expo-dev-menu@56.0.14(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-dev-menu-interface: 56.0.1(expo@56.0.4) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-device@56.0.4(expo@56.0.4): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + ua-parser-js: 0.7.41 + + expo-doctor@1.19.8: {} + + expo-file-system@56.0.7(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-font@56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + fontfaceobserver: 2.3.0 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-glass-effect@56.0.4(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-image@56.0.9(expo@56.0.4)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + sf-symbols-typescript: 2.2.0 + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + expo-json-utils@56.0.0: {} + + expo-keep-awake@56.0.3(expo@56.0.4)(react@19.2.3): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + + expo-linking@56.0.11(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + expo-constants: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + transitivePeerDependencies: + - expo + - supports-color + + expo-manifests@56.0.4(expo@56.0.4): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-json-utils: 56.0.0 + + expo-mcp@0.2.4: + dependencies: + '@expo/mcp-tunnel': 0.2.4(@modelcontextprotocol/sdk@1.29.0) + '@modelcontextprotocol/sdk': 1.29.0 + debug: 4.4.3 + glob: 11.1.0 + jimp-compact: 0.16.1 + resolve-from: 5.0.0 + ws: 8.21.0 + xml2js: 0.6.2 + zod: 3.25.76 + zx: 8.8.5 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - supports-color + - utf-8-validate + + expo-modules-autolinking@56.0.12(typescript@6.0.3): + dependencies: + '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@56.0.12(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + '@expo/expo-modules-macros-plugin': 0.0.9 + expo-modules-jsi: 56.0.7(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + optionalDependencies: + react-native-worklets: 0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + + expo-modules-jsi@56.0.7(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-router@56.2.6(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-linking@56.0.11)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native-gesture-handler@2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-screens@4.25.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + '@expo/log-box': 56.0.12(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@expo/metro-runtime': 56.0.12(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@expo/schema-utils': 56.0.1 + '@expo/ui': 56.0.13(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.15)(react@19.2.3) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1 + client-only: 0.0.1 + color: 4.2.3 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-glass-effect: 56.0.4(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-linking: 56.0.11(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-server: 56.0.4 + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.12 + query-string: 7.1.3 + react: 19.2.3 + react-fast-compare: 3.2.2 + react-is: 19.2.6 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-screens: 4.25.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@babel/core' + - '@testing-library/dom' + - '@types/react' + - '@types/react-dom' + - expo-font + - react-native-worklets + - supports-color + + expo-server@56.0.4: {} + + expo-splash-screen@56.0.10(expo@56.0.4)(typescript@6.0.3): + dependencies: + '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/image-utils': 0.10.1(typescript@6.0.3) + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-sqlite@56.0.4(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + await-lock: 2.2.2 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-status-bar@56.0.4(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.38 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-font: 56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + sf-symbols-typescript: 2.2.0 + + expo-system-ui@56.0.5(expo@56.0.4)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + '@react-native/normalize-colors': 0.85.3 + debug: 4.4.3 + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - supports-color + + expo-updates-interface@56.0.2(expo@56.0.4): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + + expo-web-browser@56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)): + dependencies: + expo: 56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + expo@56.0.4(@babel/core@7.29.0)(@expo/metro-runtime@56.0.12)(expo-router@56.2.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.2 + '@expo/cli': 56.1.11(@expo/metro-runtime@56.0.12)(expo-constants@56.0.15)(expo-font@56.0.5)(expo-router@56.2.6)(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + '@expo/config': 56.0.9(typescript@6.0.3) + '@expo/config-plugins': 56.0.8(typescript@6.0.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@expo/fingerprint': 0.19.2 + '@expo/local-build-cache-provider': 56.0.7(typescript@6.0.3) + '@expo/log-box': 56.0.12(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + '@expo/metro': 56.0.0 + '@expo/metro-config': 56.0.12(expo@56.0.4)(typescript@6.0.3) + '@ungap/structured-clone': 1.3.1 + babel-preset-expo: 56.0.12(@babel/core@7.29.0)(@babel/runtime@7.29.2)(expo@56.0.4)(react-refresh@0.14.2) + expo-asset: 56.0.14(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.15(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-file-system: 56.0.7(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3)) + expo-font: 56.0.5(expo@56.0.4)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + expo-keep-awake: 56.0.3(expo@56.0.4)(react@19.2.3) + expo-modules-autolinking: 56.0.12(typescript@6.0.3) + expo-modules-core: 56.0.12(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + pretty-format: 29.7.0 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.2 + optionalDependencies: + '@expo/metro-runtime': 56.0.12(expo@56.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-dom: 19.2.3(react@19.2.3) + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + exponential-backoff@3.1.3: {} + + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.2: {} + + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-nodeshim@0.4.10: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forwarded@0.2.0: {} + + framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + fresh@0.5.2: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): + dependencies: + '@orama/orama': 3.1.18 + estree-util-value-to-estree: 3.5.0 + github-slugger: 2.0.0 + hast-util-to-estree: 3.1.3 + hast-util-to-jsx-runtime: 2.3.6 + js-yaml: 4.1.1 + mdast-util-mdx: 3.0.0 + mdast-util-to-markdown: 2.1.2 + remark: 15.0.1 + remark-gfm: 4.0.1 + remark-rehype: 11.1.2 + scroll-into-view-if-needed: 3.1.0 + shiki: 4.1.0 + tinyglobby: 0.2.16 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + optionalDependencies: + '@mdx-js/mdx': 3.1.1 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.16 + lucide-react: 1.17.0(react@19.2.7) + next: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + fumadocs-mdx@15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.16)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + dependencies: + '@mdx-js/mdx': 3.1.1 + '@standard-schema/spec': 1.1.0 + chokidar: 5.0.0 + esbuild: 0.28.0 + estree-util-value-to-estree: 3.5.0 + fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + js-yaml: 4.1.1 + mdast-util-mdx: 3.0.0 + picocolors: 1.1.1 + picomatch: 4.0.4 + tinyexec: 1.2.4 + tinyglobby: 0.2.16 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + zod: 4.4.3 + optionalDependencies: + '@types/mdast': 4.0.4 + '@types/mdx': 2.0.13 + '@types/react': 19.2.16 + next: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + transitivePeerDependencies: + - supports-color + + fumadocs-ui@16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.0): + dependencies: + '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + class-variance-authority: 0.7.1 + fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.16)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + lucide-react: 1.17.0(react@19.2.7) + motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next-themes: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + rehype-raw: 7.0.0 + scroll-into-view-if-needed: 3.1.0 + shiki: 4.1.0 + tailwind-merge: 3.6.0 + unist-util-visit: 5.1.0 + optionalDependencies: + '@types/mdx': 2.0.13 + '@types/react': 19.2.16 + next: 16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@tailwindcss/oxide' + - '@types/react-dom' + - tailwindcss + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + getenv@2.0.0: {} + + github-slugger@2.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@14.0.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + hermes-compiler@250829098.0.10: {} + + hermes-estree@0.25.1: {} + + hermes-estree@0.33.3: {} + + hermes-estree@0.35.0: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hermes-parser@0.33.3: + dependencies: + hermes-estree: 0.33.3 + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hono@4.12.22: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-escaper@2.0.2: {} + + html-void-elements@3.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + hyphenate-style-name@1.1.0: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-size@1.2.1: + dependencies: + queue: 6.0.2 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + inline-style-parser@0.2.7: {} + + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.3.4: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.8.1 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-decimal@2.0.1: {} + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jest-get-type@29.6.3: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.12.4 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-worker@29.7.0: + dependencies: + '@types/node': 24.12.4 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jimp-compact@0.16.1: {} + + jiti@2.7.0: {} + + jose@6.2.3: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsr@0.14.3: + dependencies: + node-stream-zip: 1.15.0 + semiver: 1.1.0 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + lan-network@0.2.1: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.merge@4.6.2: {} + + lodash.throttle@4.1.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.17.0(react@19.2.7): + dependencies: + react: 19.2.7 + + lunr@2.3.9: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + markdown-extensions@2.0.0: {} + + markdown-it@14.2.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-table@3.0.4: {} + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdurl@2.0.0: {} + + media-typer@1.1.0: {} + + memoize-one@5.2.1: {} + + memoize-one@6.0.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.84.4: + dependencies: + '@babel/core': 7.29.0 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.4 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.84.4: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.4 + transitivePeerDependencies: + - supports-color + + metro-config@0.84.4: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.4 + metro-cache: 0.84.4 + metro-core: 0.84.4 + metro-runtime: 0.84.4 + yaml: 2.9.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.4 + + metro-file-map@0.84.4: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.48.0 + + metro-resolver@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.4: + dependencies: + '@babel/runtime': 7.29.2 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.84.4: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.4 + nullthrows: 1.1.1 + ob1: 0.84.4 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.4 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.4: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.84.4: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.84.4 + metro-babel-transformer: 0.84.4 + metro-cache: 0.84.4 + metro-cache-key: 0.84.4 + metro-minify-terser: 0.84.4 + metro-source-map: 0.84.4 + metro-transform-plugins: 0.84.4 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.4: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.4 + metro-cache: 0.84.4 + metro-cache-key: 0.84.4 + metro-config: 0.84.4 + metro-core: 0.84.4 + metro-file-map: 0.84.4 + metro-resolver: 0.84.4 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + metro-symbolicate: 0.84.4 + metro-transform-plugins: 0.84.4 + metro-transform-worker: 0.84.4 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.11 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + ms@2.0.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@2.0.1: + optionalDependencies: + msgpackr-extract: 3.0.3 + + multitars@1.0.0: {} + + nanoid@3.3.12: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + next@16.2.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@next/env': 16.2.7 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + postcss: 8.4.31 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(react@19.2.7) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.7 + '@next/swc-darwin-x64': 16.2.7 + '@next/swc-linux-arm64-gnu': 16.2.7 + '@next/swc-linux-arm64-musl': 16.2.7 + '@next/swc-linux-x64-gnu': 16.2.7 + '@next/swc-linux-x64-musl': 16.2.7 + '@next/swc-win32-arm64-msvc': 16.2.7 + '@next/swc-win32-x64-msvc': 16.2.7 + babel-plugin-react-compiler: 1.0.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-api-headers@1.9.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-forge@1.4.0: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-int64@0.4.0: {} + + node-releases@2.0.46: {} + + node-stream-zip@1.15.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.8.1 + validate-npm-package-name: 5.0.1 + + nullthrows@1.1.1: {} + + ob1@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + obug@2.1.1: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.0 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pkce-challenge@5.0.1: {} + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@3.4.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@7.1.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + queue@6.0.2: + dependencies: + inherits: 2.0.4 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.8.4 + ws: 7.5.11 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.3): + dependencies: + react: 19.2.3 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.2.6: {} + + react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + color: 4.2.3 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + use-latest-callback: 0.2.6(react@19.2.3) + + react-native-gesture-handler@2.31.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + '@egjs/hammerjs': 2.0.17 + '@types/react-test-renderer': 19.1.0 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + semver: 7.8.1 + + react-native-safe-area-context@5.7.0(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + + react-native-screens@4.25.2(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-freeze: 1.0.4(react@19.2.3) + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + warn-once: 0.1.1 + + react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@babel/runtime': 7.29.2 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + + react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + convert-source-map: 2.0.0 + react: 19.2.3 + react-native: 0.85.3(@types/react@19.2.15)(react@19.2.3) + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + + react-native@0.85.3(@types/react@19.2.15)(react@19.2.3): + dependencies: + '@react-native/assets-registry': 0.85.3 + '@react-native/codegen': 0.85.3 + '@react-native/community-cli-plugin': 0.85.3 + '@react-native/gradle-plugin': 0.85.3 + '@react-native/js-polyfills': 0.85.3 + '@react-native/normalize-colors': 0.85.3 + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.15)(react-native@0.85.3(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.33.3 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.10 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.3 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.8.1 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.16 + whatwg-fetch: 3.6.20 + ws: 7.5.11 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.15 + transitivePeerDependencies: + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-native@0.85.3(@types/react@19.2.16)(react@19.2.3): + dependencies: + '@react-native/assets-registry': 0.85.3 + '@react-native/codegen': 0.85.3 + '@react-native/community-cli-plugin': 0.85.3 + '@react-native/gradle-plugin': 0.85.3 + '@react-native/js-polyfills': 0.85.3 + '@react-native/normalize-colors': 0.85.3 + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.33.3 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.10 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.3 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.8.1 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.16 + whatwg-fetch: 3.6.20 + ws: 7.5.11 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.16 + transitivePeerDependencies: + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-refresh@0.14.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.3): + dependencies: + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.15 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react-remove-scroll@2.7.2(@types/react@19.2.15)(react@19.2.3): + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.15)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.3) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.15)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.15)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.15 + + react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + + react-style-singleton@2.2.3(@types/react@19.2.15)(react@19.2.3): + dependencies: + get-nonce: 1.0.1 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.15 + + react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + react@19.2.3: {} + + react@19.2.7: {} + + readdirp@5.0.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.1: + dependencies: + jsesc: 3.1.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + scheduler@0.27.0: {} + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + semiver@1.1.0: {} + + semver@6.3.1: {} + + semver@7.8.1: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.4: {} + + shiki@4.1.0: + dependencies: + '@shikijs/core': 4.1.0 + '@shikijs/engine-javascript': 4.1.0 + '@shikijs/engine-oniguruma': 4.1.0 + '@shikijs/langs': 4.1.0 + '@shikijs/themes': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slugify@1.6.9: {} + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + split-on-first@1.1.0: {} + + stable-hash@0.0.5: {} + + stackback@0.0.2: {} + + stackframe@1.3.4: {} + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-buffers@2.2.0: {} + + strict-uri-encode@2.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + structured-headers@0.4.1: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + styled-jsx@5.1.6(react@19.2.7): + dependencies: + client-only: 0.0.1 + react: 19.2.7 + + styleq@0.1.3: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + throat@5.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toqr@0.1.1: {} + + tr46@0.0.3: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedoc@0.28.19(typescript@5.9.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.2.0 + minimatch: 10.2.5 + typescript: 5.9.3 + yaml: 2.9.0 + + typescript@5.6.3: {} + + typescript@5.9.3: {} + + typescript@6.0.3: {} + + ua-parser-js@0.7.41: {} + + ua-parser-js@1.0.41: {} + + uc.micro@2.1.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + undici-types@7.16.0: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unpipe@1.0.0: {} + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.15)(react@19.2.3): + dependencies: + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.15 + + use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + use-latest-callback@0.2.6(react@19.2.3): + dependencies: + react: 19.2.3 + + use-sidecar@1.1.3(@types/react@19.2.15)(react@19.2.3): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.15 + + use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + + utils-merge@1.0.1: {} + + uuid@7.0.3: {} + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + terser: 5.48.0 + tsx: 4.22.3 + yaml: 2.9.0 + + vitest@4.1.8(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vlq@1.0.1: {} + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-namespaces@2.0.1: {} + + webidl-conversions@3.0.1: {} + + whatwg-fetch@3.6.20: {} + + whatwg-url-minimum@0.1.2: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@7.5.11: {} + + ws@8.21.0: {} + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml2js@0.6.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@3.25.76: {} + + zod@4.4.3: {} + + zwitch@2.0.4: {} + + zx@8.8.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..2fbe8797 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,30 @@ +packages: + - "src/docs" + - "src/sdks/js" + - "src/runtimes/node-direct" + - "src/runtimes/node-direct/packages/*" + - "src/sdks/react-native" + - "src/sdks/react-native/examples/expo" + - "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla" + +catalog: + "@vitest/coverage-v8": ^4.1.8 + tsx: ^4.20.6 + typedoc: ^0.28.16 + typescript: ^5.9.3 + vitest: ^4.1.8 + +minimumReleaseAge: 1440 +nodeLinker: hoisted +confirmModulesPurge: false +autoInstallPeers: false +saveWorkspaceProtocol: rolling +updateNotifier: false +verifyDepsBeforeRun: false + +allowBuilds: + core-js: false + esbuild: true + msgpackr-extract: true + sharp: true + unrs-resolver: true diff --git a/prek.toml b/prek.toml index 941f4eb5..04f867fe 100644 --- a/prek.toml +++ b/prek.toml @@ -1,11 +1,11 @@ -minimum_prek_version = "0.3.10" -default_install_hook_types = ["pre-commit", "commit-msg", "pre-push"] +minimum_prek_version = "0.4.3" +default_install_hook_types = ["pre-commit", "commit-msg"] fail_fast = true exclude = { glob = [ "target/**", - "examples/tauri-sqlx-vanilla/node_modules/**", - "examples/tauri-sqlx-vanilla/dist/**", - "examples/tauri-sqlx-vanilla/src-tauri/target/**", + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/node_modules/**", + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/dist/**", + "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/target/**", ] } [[repos]] @@ -14,7 +14,7 @@ hooks = [ { id = "trailing-whitespace", args = ["--markdown-linebreak-ext=md"], stages = ["pre-commit"], types = ["text"] }, { id = "end-of-file-fixer", stages = ["pre-commit"], types = ["text"] }, { id = "check-toml", stages = ["pre-commit"] }, - { id = "check-yaml", stages = ["pre-commit"] }, + { id = "check-yaml", args = ["--allow-multiple-documents"], stages = ["pre-commit"] }, { id = "check-json", stages = ["pre-commit"], exclude = "(^|/)tsconfig\\.json$" }, { id = "check-merge-conflict", stages = ["pre-commit"] }, { id = "check-case-conflict", stages = ["pre-commit"] }, @@ -34,10 +34,5 @@ hooks = [ repo = "local" hooks = [ { id = "cargo-fmt", name = "cargo fmt", language = "system", entry = "cargo fmt --check", pass_filenames = false, files = "\\.(rs|toml)$", stages = ["pre-commit"] }, - { id = "tauri-cargo-fmt", name = "Tauri cargo fmt", language = "system", entry = "cargo fmt --manifest-path examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml --check", pass_filenames = false, files = "^examples/tauri-sqlx-vanilla/src-tauri/.*\\.(rs|toml)$", stages = ["pre-commit"] }, - { id = "git-diff-check", name = "git diff --check", language = "system", entry = "git diff --check", pass_filenames = false, always_run = true, stages = ["pre-push"] }, - { id = "pre-push-fmt", name = "pre-push cargo fmt", language = "system", entry = "cargo fmt --all --check", pass_filenames = false, always_run = true, stages = ["pre-push"] }, - { id = "asset-input-fingerprint", name = "asset input fingerprint", language = "system", entry = "scripts/check-asset-input-fingerprint.sh", pass_filenames = false, always_run = true, stages = ["pre-push"] }, - { id = "example-lockfiles", name = "example lockfiles", language = "system", entry = "scripts/check-example-lockfiles.sh", pass_filenames = false, always_run = true, stages = ["pre-push"] }, - { id = "pre-push-check", name = "pre-push cargo check", language = "system", entry = "cargo check --workspace --locked", pass_filenames = false, always_run = true, stages = ["pre-push"] }, + { id = "tauri-cargo-fmt", name = "Tauri cargo fmt", language = "system", entry = "cargo fmt --manifest-path src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml --check", pass_filenames = false, files = "^src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/.*\\.(rs|toml)$", stages = ["pre-commit"] }, ] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..f0f7d742 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,411 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "tag-separator": "-", + "pull-request-title-pattern": "chore${scope}: release${component} ${version}", + "group-pull-request-title-pattern": "chore(release): prepare product releases", + "plugins": [ + "node-workspace" + ], + "packages": { + "src/runtimes/liboliphaunt/native": { + "release-type": "simple", + "component": "liboliphaunt-native", + "package-name": "liboliphaunt-native", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/sdks/rust": { + "release-type": "rust", + "component": "oliphaunt-rust", + "package-name": "oliphaunt", + "changelog-path": "CHANGELOG.md" + }, + "src/runtimes/broker": { + "release-type": "rust", + "component": "oliphaunt-broker", + "package-name": "oliphaunt-broker", + "changelog-path": "CHANGELOG.md" + }, + "src/runtimes/node-direct": { + "release-type": "node", + "component": "oliphaunt-node-direct", + "package-name": "@oliphaunt/node-direct", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "json", + "path": "packages/darwin-arm64/package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "packages/linux-arm64-gnu/package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "packages/linux-x64-gnu/package.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "packages/win32-x64-msvc/package.json", + "jsonpath": "$.version" + } + ] + }, + "src/sdks/swift": { + "release-type": "simple", + "component": "oliphaunt-swift", + "package-name": "oliphaunt-swift", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/sdks/kotlin": { + "release-type": "simple", + "component": "oliphaunt-kotlin", + "package-name": "dev.oliphaunt:oliphaunt", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "generic", + "path": "gradle.properties" + } + ] + }, + "src/sdks/react-native": { + "release-type": "node", + "component": "oliphaunt-react-native", + "package-name": "@oliphaunt/react-native", + "changelog-path": "CHANGELOG.md" + }, + "src/sdks/js": { + "release-type": "node", + "component": "oliphaunt-js", + "package-name": "@oliphaunt/ts", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "json", + "path": "jsr.json", + "jsonpath": "$.version" + } + ] + }, + "src/extensions/contrib/amcheck": { + "release-type": "simple", + "component": "oliphaunt-extension-amcheck", + "package-name": "oliphaunt-extension-amcheck", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/auto_explain": { + "release-type": "simple", + "component": "oliphaunt-extension-auto-explain", + "package-name": "oliphaunt-extension-auto-explain", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/bloom": { + "release-type": "simple", + "component": "oliphaunt-extension-bloom", + "package-name": "oliphaunt-extension-bloom", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/btree_gin": { + "release-type": "simple", + "component": "oliphaunt-extension-btree-gin", + "package-name": "oliphaunt-extension-btree-gin", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/btree_gist": { + "release-type": "simple", + "component": "oliphaunt-extension-btree-gist", + "package-name": "oliphaunt-extension-btree-gist", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/citext": { + "release-type": "simple", + "component": "oliphaunt-extension-citext", + "package-name": "oliphaunt-extension-citext", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/cube": { + "release-type": "simple", + "component": "oliphaunt-extension-cube", + "package-name": "oliphaunt-extension-cube", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/dict_int": { + "release-type": "simple", + "component": "oliphaunt-extension-dict-int", + "package-name": "oliphaunt-extension-dict-int", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/dict_xsyn": { + "release-type": "simple", + "component": "oliphaunt-extension-dict-xsyn", + "package-name": "oliphaunt-extension-dict-xsyn", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/earthdistance": { + "release-type": "simple", + "component": "oliphaunt-extension-earthdistance", + "package-name": "oliphaunt-extension-earthdistance", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/file_fdw": { + "release-type": "simple", + "component": "oliphaunt-extension-file-fdw", + "package-name": "oliphaunt-extension-file-fdw", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/fuzzystrmatch": { + "release-type": "simple", + "component": "oliphaunt-extension-fuzzystrmatch", + "package-name": "oliphaunt-extension-fuzzystrmatch", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/hstore": { + "release-type": "simple", + "component": "oliphaunt-extension-hstore", + "package-name": "oliphaunt-extension-hstore", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/intarray": { + "release-type": "simple", + "component": "oliphaunt-extension-intarray", + "package-name": "oliphaunt-extension-intarray", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/isn": { + "release-type": "simple", + "component": "oliphaunt-extension-isn", + "package-name": "oliphaunt-extension-isn", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/lo": { + "release-type": "simple", + "component": "oliphaunt-extension-lo", + "package-name": "oliphaunt-extension-lo", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/ltree": { + "release-type": "simple", + "component": "oliphaunt-extension-ltree", + "package-name": "oliphaunt-extension-ltree", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pageinspect": { + "release-type": "simple", + "component": "oliphaunt-extension-pageinspect", + "package-name": "oliphaunt-extension-pageinspect", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pg_buffercache": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-buffercache", + "package-name": "oliphaunt-extension-pg-buffercache", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pg_freespacemap": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-freespacemap", + "package-name": "oliphaunt-extension-pg-freespacemap", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pg_surgery": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-surgery", + "package-name": "oliphaunt-extension-pg-surgery", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pg_trgm": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-trgm", + "package-name": "oliphaunt-extension-pg-trgm", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pg_visibility": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-visibility", + "package-name": "oliphaunt-extension-pg-visibility", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pg_walinspect": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-walinspect", + "package-name": "oliphaunt-extension-pg-walinspect", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/pgcrypto": { + "release-type": "simple", + "component": "oliphaunt-extension-pgcrypto", + "package-name": "oliphaunt-extension-pgcrypto", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/seg": { + "release-type": "simple", + "component": "oliphaunt-extension-seg", + "package-name": "oliphaunt-extension-seg", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/tablefunc": { + "release-type": "simple", + "component": "oliphaunt-extension-tablefunc", + "package-name": "oliphaunt-extension-tablefunc", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/tcn": { + "release-type": "simple", + "component": "oliphaunt-extension-tcn", + "package-name": "oliphaunt-extension-tcn", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/tsm_system_rows": { + "release-type": "simple", + "component": "oliphaunt-extension-tsm-system-rows", + "package-name": "oliphaunt-extension-tsm-system-rows", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/tsm_system_time": { + "release-type": "simple", + "component": "oliphaunt-extension-tsm-system-time", + "package-name": "oliphaunt-extension-tsm-system-time", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/unaccent": { + "release-type": "simple", + "component": "oliphaunt-extension-unaccent", + "package-name": "oliphaunt-extension-unaccent", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/contrib/uuid_ossp": { + "release-type": "simple", + "component": "oliphaunt-extension-uuid-ossp", + "package-name": "oliphaunt-extension-uuid-ossp", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/external/pg_hashids": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-hashids", + "package-name": "oliphaunt-extension-pg-hashids", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/external/pg_ivm": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-ivm", + "package-name": "oliphaunt-extension-pg-ivm", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/external/pg_textsearch": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-textsearch", + "package-name": "oliphaunt-extension-pg-textsearch", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/external/pg_uuidv7": { + "release-type": "simple", + "component": "oliphaunt-extension-pg-uuidv7", + "package-name": "oliphaunt-extension-pg-uuidv7", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/external/pgtap": { + "release-type": "simple", + "component": "oliphaunt-extension-pgtap", + "package-name": "oliphaunt-extension-pgtap", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/external/postgis": { + "release-type": "simple", + "component": "oliphaunt-extension-postgis", + "package-name": "oliphaunt-extension-postgis", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/extensions/external/vector": { + "release-type": "simple", + "component": "oliphaunt-extension-vector", + "package-name": "oliphaunt-extension-vector", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md" + }, + "src/runtimes/liboliphaunt/wasix": { + "release-type": "simple", + "component": "liboliphaunt-wasix", + "package-name": "liboliphaunt-wasix", + "version-file": "VERSION", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "toml", + "path": "crates/assets/Cargo.toml", + "jsonpath": "$.package.version" + }, + { + "type": "toml", + "path": "crates/aot/aarch64-apple-darwin/Cargo.toml", + "jsonpath": "$.package.version" + }, + { + "type": "toml", + "path": "crates/aot/aarch64-unknown-linux-gnu/Cargo.toml", + "jsonpath": "$.package.version" + }, + { + "type": "toml", + "path": "crates/aot/x86_64-pc-windows-msvc/Cargo.toml", + "jsonpath": "$.package.version" + }, + { + "type": "toml", + "path": "crates/aot/x86_64-unknown-linux-gnu/Cargo.toml", + "jsonpath": "$.package.version" + } + ] + }, + "src/bindings/wasix-rust/crates/oliphaunt-wasix": { + "release-type": "rust", + "component": "oliphaunt-wasix-rust", + "package-name": "oliphaunt-wasix", + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/release-plz.toml b/release-plz.toml deleted file mode 100644 index 8b2b7164..00000000 --- a/release-plz.toml +++ /dev/null @@ -1,91 +0,0 @@ -[workspace] -allow_dirty = true -changelog_update = true -dependencies_update = true -features_always_increment_minor = false -git_release_enable = false -git_release_name = "{{ package }} v{{ version }}" -git_tag_enable = false -git_tag_name = "{{ version }}" -pr_branch_prefix = "release-plz-" -pr_labels = ["release"] -pr_name = "chore(release): {{ version }}" -publish = true -publish_allow_dirty = true -publish_timeout = "30m" -release_always = true -repo_url = "https://github.com/f0rr0/oliphaunt" -semver_check = true - -[changelog] -protect_breaking_commits = true -sort_commits = "oldest" -tag_pattern = '^[0-9]+\.[0-9]+\.[0-9]+.*$' -commit_parsers = [ - { message = "^feat", group = "Added" }, - { message = "^fix", group = "Fixed" }, - { message = "^perf", group = "Performance" }, - { message = "^refactor", group = "Changed" }, - { message = "^revert", group = "Changed" }, - { message = "^.*!", group = "Breaking" }, - { message = "^docs", skip = true }, - { message = "^test", skip = true }, - { message = "^ci", skip = true }, - { message = "^chore", skip = true }, - { message = "^style", skip = true }, -] - -[[package]] -name = "pglite-oxide" -version_group = "pglite-oxide" -changelog_path = "CHANGELOG.md" -changelog_include = [ - "pglite-oxide-assets", - "pglite-oxide-aot-aarch64-apple-darwin", - "pglite-oxide-aot-x86_64-unknown-linux-gnu", - "pglite-oxide-aot-aarch64-unknown-linux-gnu", - "pglite-oxide-aot-x86_64-pc-windows-msvc", -] -git_release_enable = true -git_tag_enable = true -git_tag_name = "{{ version }}" - -[[package]] -name = "pglite-oxide-assets" -version_group = "pglite-oxide" -changelog_update = false -git_release_enable = false -git_tag_enable = false -semver_check = false - -[[package]] -name = "pglite-oxide-aot-aarch64-apple-darwin" -version_group = "pglite-oxide" -changelog_update = false -git_release_enable = false -git_tag_enable = false -semver_check = false - -[[package]] -name = "pglite-oxide-aot-x86_64-unknown-linux-gnu" -version_group = "pglite-oxide" -changelog_update = false -git_release_enable = false -git_tag_enable = false -semver_check = false - -[[package]] -name = "pglite-oxide-aot-aarch64-unknown-linux-gnu" -version_group = "pglite-oxide" -changelog_update = false -git_release_enable = false -git_tag_enable = false -semver_check = false - -[[package]] -name = "pglite-oxide-aot-x86_64-pc-windows-msvc" -version_group = "pglite-oxide" -changelog_update = false -git_release_enable = false -git_tag_enable = false -semver_check = false diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..eb3c4ea5 --- /dev/null +++ b/renovate.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":semanticCommits"], + "dependencyDashboard": true, + "labels": ["dependencies"], + "minimumReleaseAge": "3 days", + "rangeStrategy": "bump", + "enabledManagers": [ + "cargo", + "cocoapods", + "dockerfile", + "github-actions", + "gradle", + "gradle-wrapper", + "npm", + "pep621", + "regex", + "swift" + ], + "packageRules": [ + { + "matchManagers": ["github-actions"], + "groupName": "GitHub Actions" + }, + { + "matchManagers": ["cargo"], + "groupName": "Rust crates" + }, + { + "matchManagers": ["gradle", "gradle-wrapper"], + "groupName": "Android and Kotlin" + }, + { + "matchManagers": ["npm"], + "groupName": "JavaScript and React Native" + }, + { + "matchManagers": ["swift", "cocoapods"], + "groupName": "Apple SDK" + } + ], + "customManagers": [ + { + "customType": "regex", + "description": "Pinned Moon CLI", + "managerFilePatterns": ["/^\\.prototools$/"], + "matchStrings": ["moon = \"(?[^\"]+)\""], + "datasourceTemplate": "npm", + "depNameTemplate": "@moonrepo/cli" + }, + { + "customType": "regex", + "description": "Pinned Node runtime", + "managerFilePatterns": ["/^\\.prototools$/"], + "matchStrings": ["node = \"(?[^\"]+)\""], + "datasourceTemplate": "node-version", + "depNameTemplate": "node" + }, + { + "customType": "regex", + "description": "Pinned pnpm runtime", + "managerFilePatterns": ["/^\\.prototools$/"], + "matchStrings": ["pnpm = \"(?[^\"]+)\""], + "datasourceTemplate": "npm", + "depNameTemplate": "pnpm" + } + ] +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 36e244e4..e2b8a80b 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] channel = "1.93" -components = ["rustfmt", "clippy"] +components = ["rustfmt", "clippy", "llvm-tools-preview"] profile = "minimal" diff --git a/scripts/check-asset-input-fingerprint.sh b/scripts/check-asset-input-fingerprint.sh deleted file mode 100755 index 6581f210..00000000 --- a/scripts/check-asset-input-fingerprint.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "$root" - -base_ref="${ASSET_INPUT_BASE_REF:-}" -if [[ -z "$base_ref" ]]; then - if git rev-parse --verify -q '@{upstream}' >/dev/null; then - base_ref='@{upstream}' - else - base_ref='origin/main' - fi -fi - -if ! git rev-parse --verify -q "${base_ref}^{commit}" >/dev/null; then - echo "asset input fingerprint check skipped: ${base_ref} is not available" >&2 - exit 0 -fi - -changed="$( - git diff --name-only "${base_ref}...HEAD" -- \ - assets/sources.toml \ - assets/extensions.promoted.toml \ - assets/extensions.smoke.toml \ - assets/wasix-build \ - crates/assets/Cargo.toml \ - crates/assets/build.rs \ - crates/assets/src \ - crates/aot \ - xtask/src/main.rs \ - xtask/src/extension_catalog.rs \ - assets/generated/asset-inputs.sha256 -)" - -if [[ -z "$changed" ]]; then - echo "asset input fingerprint check skipped: no asset input changes" - exit 0 -fi - -cargo run -p xtask -- assets verify-committed diff --git a/scripts/ci-scope.sh b/scripts/ci-scope.sh deleted file mode 100755 index 936925af..00000000 --- a/scripts/ci-scope.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -base_ref="${1:-}" -head_ref="${2:-HEAD}" - -root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "$root" - -all_true=false -if [[ -z "$base_ref" ]] || ! git rev-parse --verify -q "$base_ref^{commit}" >/dev/null; then - all_true=true -fi -if ! git rev-parse --verify -q "$head_ref^{commit}" >/dev/null; then - all_true=true -fi - -if [[ "$all_true" == true ]]; then - changed_files="*" -else - changed_files="$(git diff --name-only "$base_ref...$head_ref" --)" -fi - -repo=false -rust=false -examples=false -package=false -assets=false -ci=false -docs=false - -set_all_true() { - repo=true - rust=true - examples=true - package=true - assets=true - ci=true - docs=true -} - -if [[ "$changed_files" == "*" ]]; then - set_all_true -else - while IFS= read -r file; do - [[ -z "$file" ]] && continue - - case "$file" in - .github/workflows/* | .github/scripts/* | .github/actions/* | .github/zizmor.yml | scripts/* | prek.toml | deny.toml | clippy.toml | rust-toolchain.toml) - repo=true - ci=true - ;; - .github/*) - repo=true - docs=true - ;; - README.md | CHANGELOG.md | docs/*) - repo=true - docs=true - ;; - esac - - case "$file" in - Cargo.toml | build.rs | crates/*/Cargo.toml | crates/aot/*/Cargo.toml) - repo=true - rust=true - package=true - ;; - Cargo.lock | src/* | tests/*) - repo=true - rust=true - ;; - xtask/*) - repo=true - rust=true - assets=true - ;; - esac - - case "$file" in - assets/* | crates/assets/* | crates/aot/*) - repo=true - rust=true - assets=true - ;; - esac - - case "$file" in - examples/*) - repo=true - examples=true - ;; - esac - done <<< "$changed_files" -fi - -if [[ "$assets" == true ]]; then - rust=true - package=true -fi - -docs_only=false -if [[ "$docs" == true && "$rust" == false && "$examples" == false && "$package" == false && "$assets" == false && "$ci" == false ]]; then - docs_only=true -fi - -emit() { - local key="$1" - local value="$2" - printf '%s=%s\n' "$key" "$value" - if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" - fi -} - -emit repo "$repo" -emit rust "$rust" -emit examples "$examples" -emit package "$package" -emit assets "$assets" -emit ci "$ci" -emit docs "$docs" -emit docs_only "$docs_only" diff --git a/scripts/perf/node-bench/package-lock.json b/scripts/perf/node-bench/package-lock.json deleted file mode 100644 index 7b3b8512..00000000 --- a/scripts/perf/node-bench/package-lock.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "pglite-oxide-node-bench", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pglite-oxide-node-bench", - "dependencies": { - "@electric-sql/pglite": "0.4.5", - "@electric-sql/pglite-socket": "0.1.5" - } - }, - "node_modules/@electric-sql/pglite": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.5.tgz", - "integrity": "sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@electric-sql/pglite-socket": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.5.tgz", - "integrity": "sha512-/RAye+3EPKfO9nY4tljzxXmkT7yIpFDm0L3F+c28b+Z6uxPOjy/Zz/QEHYHXcrfuUC88/a9S72EO0+3E0j97wQ==", - "license": "Apache-2.0", - "bin": { - "pglite-server": "dist/scripts/server.js" - }, - "peerDependencies": { - "@electric-sql/pglite": "0.4.5" - } - } - } -} diff --git a/scripts/perf/node-bench/package.json b/scripts/perf/node-bench/package.json deleted file mode 100644 index 9cffc3a9..00000000 --- a/scripts/perf/node-bench/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "pglite-oxide-node-bench", - "private": true, - "type": "module", - "dependencies": { - "@electric-sql/pglite": "0.4.5", - "@electric-sql/pglite-socket": "0.1.5" - } -} diff --git a/scripts/perf/node-bench/start_nodefs_socket.mjs b/scripts/perf/node-bench/start_nodefs_socket.mjs deleted file mode 100644 index 0b1db3e6..00000000 --- a/scripts/perf/node-bench/start_nodefs_socket.mjs +++ /dev/null @@ -1,115 +0,0 @@ -import { performance } from 'node:perf_hooks' -import fs from 'node:fs/promises' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { PGlite } from '@electric-sql/pglite' -import { PGLiteSocketServer } from '@electric-sql/pglite-socket' - -function parseArgs(argv) { - const args = {} - for (let index = 0; index < argv.length; index += 1) { - const key = argv[index] - if (!key.startsWith('--')) { - continue - } - const value = argv[index + 1] - if (value && !value.startsWith('--')) { - args[key] = value - index += 1 - } else { - args[key] = 'true' - } - } - return args -} - -function requireArg(args, key) { - const value = args[key] - if (!value) { - throw new Error(`${key} is required`) - } - return value -} - -function nowMicros() { - return Math.round(performance.now() * 1000) -} - -function elapsedMicros(startMicros) { - return nowMicros() - startMicros -} - -async function main() { - const args = parseArgs(process.argv.slice(2)) - const readyPath = requireArg(args, '--ready') - const runId = requireArg(args, '--run-id') - - const scriptDir = path.dirname(fileURLToPath(import.meta.url)) - const repoRoot = path.resolve(scriptDir, '../../..') - const dataDir = - args['--data-dir'] ?? - path.join(repoRoot, 'target/perf/node-bench/runtime', runId, 'pglite_nodefs_sqlx') - - await fs.mkdir(path.dirname(readyPath), { recursive: true }) - await fs.rm(readyPath, { force: true }) - await fs.rm(dataDir, { recursive: true, force: true }) - await fs.mkdir(dataDir, { recursive: true }) - - const openStarted = nowMicros() - const db = new PGlite(dataDir) - await db.waitReady - const server = new PGLiteSocketServer({ - db, - host: '127.0.0.1', - port: 0, - maxConnections: 1, - }) - await server.start() - const openMicros = elapsedMicros(openStarted) - - const [host, port] = server.getServerConn().split(':') - const databaseUrl = `postgresql://postgres:postgres@${host}:${port}/postgres?sslmode=disable` - const ready = { - databaseUrl, - host, - port: Number(port), - dataDir, - openMicros, - node: process.version, - package: '@electric-sql/pglite', - version: '0.4.5', - socketPackage: '@electric-sql/pglite-socket', - socketVersion: '0.1.5', - } - await fs.writeFile(readyPath, `${JSON.stringify(ready, null, 2)}\n`) - console.log(`PGlite NodeFS socket ready at ${host}:${port}`) - - let shuttingDown = false - const stop = async () => { - if (shuttingDown) { - return - } - shuttingDown = true - await server.stop() - await db.close() - } - - await new Promise((resolve) => { - for (const signal of ['SIGINT', 'SIGTERM']) { - process.once(signal, () => { - resolve() - }) - } - process.once('disconnect', () => { - resolve() - }) - }) - await stop() -} - -main().catch((error) => { - console.error(error) - process.exitCode = 1 -}) diff --git a/scripts/perf/run_bench_matrix.sh b/scripts/perf/run_bench_matrix.sh deleted file mode 100755 index e40f0428..00000000 --- a/scripts/perf/run_bench_matrix.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -TARGET_DIR="$REPO_ROOT/target/perf" -NODE_BENCH_DIR="$SCRIPT_DIR/node-bench" - -RUN_ID="${1:-$(date -u +%Y%m%dT%H%M%SZ)}" - -POSTGRES_BIN="${PGLITE_OXIDE_NATIVE_POSTGRES:-/opt/homebrew/opt/postgresql@18/bin/postgres}" -INITDB_BIN="${PGLITE_OXIDE_NATIVE_INITDB:-/opt/homebrew/opt/postgresql@18/bin/initdb}" - -if [[ ! -x "$POSTGRES_BIN" ]]; then - POSTGRES_BIN="$(command -v postgres)" -fi - -if [[ ! -x "$INITDB_BIN" ]]; then - INITDB_BIN="$(command -v initdb)" -fi - -mkdir -p "$TARGET_DIR" - -if [[ ! -d "$NODE_BENCH_DIR/node_modules" ]]; then - ( - cd "$NODE_BENCH_DIR" - npm install --no-fund --no-audit - ) -fi - -OXIDE_JSON="$TARGET_DIR/bench-oxide-$RUN_ID.json" -NATIVE_JSON="$TARGET_DIR/bench-native-postgres-sqlx-$RUN_ID.json" -NODE_JSON="$TARGET_DIR/bench-pglite-nodefs-sqlx-$RUN_ID.json" -NODE_READY_JSON="$TARGET_DIR/bench-pglite-nodefs-sqlx-ready-$RUN_ID.json" -NODE_LOG="$TARGET_DIR/bench-pglite-nodefs-sqlx-$RUN_ID.log" -REPORT_MD="$TARGET_DIR/bench-comparison-$RUN_ID.md" - -NATIVE_VERSION="$("$POSTGRES_BIN" --version | sed 's/^postgres (PostgreSQL) //')" -OS_LABEL="$(uname -smr)" -if command -v sw_vers >/dev/null 2>&1; then - OS_LABEL="$(sw_vers -productName) $(sw_vers -productVersion) (${OS_LABEL})" -fi -CPU_LABEL="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || uname -m)" -RAM_LABEL="$( - python3 - <<'PY' -import os -try: - mem = int(os.popen('sysctl -n hw.memsize').read().strip()) - print(f"{mem/1024/1024/1024:.0f} GB") -except Exception: - print("unknown") -PY -)" -CORES_LABEL="$(sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN || echo unknown)" - -echo "Running oxide benchmark suite..." -cargo run --release -p xtask -- perf bench \ - --suite all \ - --mode server-sqlx \ - --iterations 100 \ - --speed-source pglite \ - > "$OXIDE_JSON" - -echo "Running native Postgres SQLx benchmark suite..." -cargo run --release -p xtask -- perf native-postgres \ - --suite all \ - --iterations 100 \ - --speed-source pglite \ - --client sqlx \ - --postgres-bin "$POSTGRES_BIN" \ - --initdb-bin "$INITDB_BIN" \ - > "$NATIVE_JSON" - -echo "Starting PGlite NodeFS socket server..." -node "$NODE_BENCH_DIR/start_nodefs_socket.mjs" \ - --ready "$NODE_READY_JSON" \ - --run-id "$RUN_ID" \ - > "$NODE_LOG" 2>&1 & -NODE_PID="$!" -cleanup_node_server() { - if kill -0 "$NODE_PID" >/dev/null 2>&1; then - kill "$NODE_PID" >/dev/null 2>&1 || true - wait "$NODE_PID" >/dev/null 2>&1 || true - fi -} -trap cleanup_node_server EXIT - -for _ in $(seq 1 300); do - if [[ -s "$NODE_READY_JSON" ]]; then - break - fi - if ! kill -0 "$NODE_PID" >/dev/null 2>&1; then - cat "$NODE_LOG" >&2 || true - echo "PGlite NodeFS socket server exited before becoming ready" >&2 - exit 1 - fi - sleep 0.1 -done - -if [[ ! -s "$NODE_READY_JSON" ]]; then - cat "$NODE_LOG" >&2 || true - echo "Timed out waiting for PGlite NodeFS socket server" >&2 - exit 1 -fi - -NODE_DATABASE_URL="$(node -e "const fs=require('fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).databaseUrl)" "$NODE_READY_JSON")" -NODE_OPEN_MICROS="$(node -e "const fs=require('fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).openMicros)" "$NODE_READY_JSON")" - -echo "Running PGlite NodeFS SQLx benchmark suite..." -cargo run --release -p xtask -- perf pglite-nodefs-sqlx \ - --suite all \ - --iterations 100 \ - --speed-source pglite \ - --database-url "$NODE_DATABASE_URL" \ - --open-micros "$NODE_OPEN_MICROS" \ - > "$NODE_JSON" - -cleanup_node_server -trap - EXIT - -echo "Building comparison markdown..." -node "$SCRIPT_DIR/build_bench_matrix.mjs" \ - --output "$REPORT_MD" \ - --oxide "$OXIDE_JSON" \ - --native "$NATIVE_JSON" \ - --node "$NODE_JSON" \ - --node-server "$NODE_READY_JSON" \ - --run-id "$RUN_ID" \ - --native-version "$NATIVE_VERSION" \ - --machine-os "$OS_LABEL" \ - --machine-cpu "$CPU_LABEL" \ - --machine-ram "$RAM_LABEL" \ - --machine-cores "$CORES_LABEL" - -echo "$REPORT_MD" diff --git a/scripts/validate.sh b/scripts/validate.sh deleted file mode 100755 index eda7ba03..00000000 --- a/scripts/validate.sh +++ /dev/null @@ -1,451 +0,0 @@ -#!/usr/bin/env sh -set -eu - -mode="${1:-pre-push}" -shift || true - -if [ "${PGLITE_OXIDE_RELEASE_STAGED:-0}" = "1" ]; then - root="$(pwd)" -else - root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -fi -cd "$root" - -cargo_bin="${CARGO_HOME:-$HOME/.cargo}/bin" -if [ -d "$cargo_bin" ]; then - PATH="$cargo_bin:$PATH" - export PATH -fi - -allow_dirty=0 -for arg in "$@"; do - case "$arg" in - --allow-dirty) - allow_dirty=1 - ;; - --*) - echo "unknown flag for $mode: $arg" >&2 - exit 2 - ;; - *) - ;; - esac -done - -run() { - printf '\n==> %s\n' "$*" - "$@" -} - -require() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - echo "run scripts/bootstrap-tools.sh to install the pinned local toolchain" >&2 - exit 1 - fi -} - -run_xtask() { - if [ -n "${PGLITE_OXIDE_XTASK:-}" ]; then - xtask="$PGLITE_OXIDE_XTASK" - if command -v cygpath >/dev/null 2>&1; then - xtask="$(cygpath -u "$xtask" 2>/dev/null || printf '%s\n' "$xtask")" - fi - run "$xtask" "$@" - else - require cargo - run cargo run -p xtask -- "$@" - fi -} - -xtask_output() { - if [ -n "${PGLITE_OXIDE_XTASK:-}" ]; then - xtask="$PGLITE_OXIDE_XTASK" - if command -v cygpath >/dev/null 2>&1; then - xtask="$(cygpath -u "$xtask" 2>/dev/null || printf '%s\n' "$xtask")" - fi - "$xtask" "$@" - else - require cargo - cargo run --quiet -p xtask -- "$@" - fi -} - -run_prek() { - require prek - stage="${1:?run_prek requires a stage}" - shift - run prek run --all-files --stage "$stage" "$@" -} - -run_prek_tracked_files() { - require prek - stage="${1:?run_prek_tracked_files requires a stage}" - printf '\n==> prek run --tracked-files --stage %s\n' "$stage" - git ls-files | - while IFS= read -r file; do - [ -e "$file" ] && printf '%s\0' "$file" - done | - xargs -0 prek run --stage "$stage" --files -} - -cargo_publish_args() { - if [ "$allow_dirty" -eq 1 ]; then - printf '%s\n' --allow-dirty - fi -} - -cargo_package_args() { - if [ "$allow_dirty" -eq 1 ]; then - printf '%s\n' --allow-dirty - fi -} - -clean_package_artifacts() { - rm -f target/package/*.crate -} - -internal_packages() { - xtask_output assets internal-packages -} - -aot_targets() { - xtask_output assets aot-targets -} - -host_aot_manifest() { - host="$1" - if [ -f "target/pglite-oxide/aot/$host/manifest.json" ]; then - printf '%s\n' "target/pglite-oxide/aot/$host/manifest.json" - elif [ -f "crates/aot/$host/artifacts/manifest.json" ]; then - printf '%s\n' "crates/aot/$host/artifacts/manifest.json" - else - return 1 - fi -} - -run_root_publish_dry_run() { - tmp="$(mktemp)" - if cargo publish -p pglite-oxide --dry-run --locked $(cargo_publish_args) >"$tmp" 2>&1; then - cat "$tmp" - rm -f "$tmp" - return 0 - fi - - status=$? - if grep -Eq 'no matching package named `pglite-oxide-(assets|aot-[^`]+)` found|failed to select a version for the requirement `pglite-oxide-(assets|aot-[^`]+) = "=' "$tmp"; then - cat >&2 <<'MSG' -warning: root crate publish dry-run could not resolve exact internal crate -versions from crates.io. - -This is expected for same-release internal asset/AOT versions. release-plz owns -the actual publish order; this validation dry-runs every internal crate before -release-plz publish/dry-run is invoked. -MSG - rm -f "$tmp" - return 0 - fi - - cat "$tmp" >&2 - rm -f "$tmp" - return "$status" -} - -validate_repo() { - require prek - run prek validate-config prek.toml - run_prek_tracked_files pre-commit -} - -validate_artifacts() { - run_xtask assets verify-committed -} - -validate_workflows() { - require actionlint - require zizmor - run actionlint - run zizmor --config .github/zizmor.yml --min-severity medium --persona auditor .github/workflows .github/actions -} - -validate_lint() { - require cargo - run scripts/check-dependency-invariants.sh - run cargo clippy --workspace --all-targets --locked -- -D warnings -} - -validate_tests() { - require cargo - run cargo check --workspace --locked - run cargo check --workspace --no-default-features --all-targets --locked - run cargo test --doc --workspace --locked - run cargo test --workspace --all-targets --locked --no-run -} - -validate_dev() { - validate_repo - validate_artifacts - validate_lint - validate_tests -} - -require_host_runtime_artifacts() { - require cargo - host="$(rustc -vV | awk '/^host:/{print $2}')" - if ! host_aot_manifest "$host" >/dev/null 2>&1; then - cat >&2 < --target-triple $host - cargo run -p xtask -- assets download --latest-compatible --target-triple $host - cargo run -p xtask -- assets install-local --target-triple $host -MSG - exit 1 - fi - if [ ! -f "target/pglite-oxide/assets/manifest.json" ]; then - cat >&2 < --target-triple $host - cargo run -p xtask -- assets download --latest-compatible --target-triple $host - cargo run -p xtask -- assets install-local --target-triple $host -MSG - exit 1 - fi - run_xtask assets install-local --target-triple "$host" - export PGLITE_OXIDE_GENERATED_ASSETS_DIR="$root/target/pglite-oxide/assets" - export PGLITE_OXIDE_GENERATED_AOT_DIR="$root/target/pglite-oxide/aot" -} - -validate_runtime_smoke() { - require_host_runtime_artifacts - export RUST_BACKTRACE="${RUST_BACKTRACE:-full}" - run cargo test -p pglite-oxide --locked \ - --test runtime_smoke \ - --test proxy_smoke \ - --test cli_smoke \ - --test performance_smoke \ - --test extensions_smoke \ - --test postgres_regression \ - -- --nocapture - run cargo test -p pglite-oxide --locked --lib pg_dump -- --nocapture -} - -validate_runtime() { - require_host_runtime_artifacts - run cargo test --workspace --all-targets --locked -} - -validate_examples() { - require cargo - require npm - run scripts/sync-example-lockfiles.py --check - run cargo check --manifest-path examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml --locked - run npm --prefix examples/tauri-sqlx-vanilla ci - run npm --prefix examples/tauri-sqlx-vanilla run build -} - -validate_package() { - require cargo - clean_package_artifacts - run cargo package --workspace --exclude xtask --locked --no-verify $(cargo_package_args) - run scripts/check-crate-size.sh --enforce -} - -validate_feature_powerset() { - require cargo-hack - run cargo hack check --workspace --feature-powerset --no-dev-deps --exclude-features aot-serializer,template-runner -} - -validate_semver() { - require cargo-semver-checks - run cargo semver-checks check-release --package pglite-oxide --manifest-path Cargo.toml -} - -validate_supply_chain() { - require cargo-deny - run cargo deny check -} - -require_release_aot_artifacts() { - for target in $(aot_targets); do - manifest="$(host_aot_manifest "$target" 2>/dev/null || true)" - if [ -z "$manifest" ]; then - manifest="target/pglite-oxide/aot/$target/manifest.json" - fi - if [ ! -f "$manifest" ]; then - echo "missing release AOT artifacts for $target; download them from the successful Assets workflow before release validation" >&2 - exit 1 - fi - python3 - "$manifest" <<'PY' -import json -import sys -path = sys.argv[1] -with open(path, encoding="utf-8") as f: - manifest = json.load(f) -if not manifest.get("artifacts"): - raise SystemExit(f"{path} does not contain generated AOT artifacts") -PY - done -} - -require_release_portable_assets() { - if [ ! -f "target/pglite-oxide/assets/manifest.json" ]; then - echo "missing release portable assets; download or build Assets workflow outputs before release validation" >&2 - exit 1 - fi -} - -validate_release_aot_artifacts() { - for target in $(aot_targets); do - run_xtask assets check-aot --target-triple "$target" - done -} - -validate_release() { - require cargo - if [ "${PGLITE_OXIDE_RELEASE_STAGED:-0}" != "1" ]; then - require_release_portable_assets - require_release_aot_artifacts - run_xtask release stage - ( - cd target/pglite-oxide/release/workspace - PGLITE_OXIDE_RELEASE_STAGED=1 scripts/validate.sh release --allow-dirty - ) - return 0 - fi - require_release_portable_assets - require_release_aot_artifacts - run_xtask assets check --strict-generated - validate_release_aot_artifacts - validate_package - for package in $(internal_packages); do - run cargo publish -p "$package" --dry-run --locked $(cargo_publish_args) - done - printf '\n==> cargo publish -p pglite-oxide --dry-run --locked\n' - run_root_publish_dry_run -} - -case "$mode" in - commit-msg) - require prek - run prek run --stage commit-msg --commit-msg-filename "${1:?commit-msg mode requires a message file}" - ;; - - pre-commit) - run_prek pre-commit - ;; - - pre-push) - run_prek pre-push - ;; - - repo) - validate_repo - ;; - - artifacts) - validate_artifacts - ;; - - lint) - validate_lint - ;; - - test) - validate_tests - ;; - - workflows) - validate_workflows - ;; - - dev) - validate_dev - ;; - - runtime) - validate_runtime - ;; - - runtime-smoke) - validate_runtime_smoke - ;; - - examples) - validate_examples - ;; - - package) - validate_package - ;; - - feature-powerset) - validate_feature_powerset - ;; - - semver) - validate_semver - ;; - - supply-chain) - validate_supply_chain - ;; - - dev-ci) - validate_dev - validate_examples - ;; - - ci) - validate_dev - validate_workflows - validate_examples - validate_package - validate_feature_powerset - validate_semver - validate_supply_chain - ;; - - release) - validate_release - ;; - - *) - cat >&2 <<'MSG' -usage: scripts/validate.sh [--allow-dirty] - -modes: - commit-msg validate a Conventional Commit message with prek - pre-commit run all pre-commit prek hooks - pre-push run all pre-push prek hooks - repo repository hygiene and formatting - workflows actionlint and zizmor GitHub Actions checks - lint dependency invariants and clippy - test source-only checks, doctests, and test compilation - dev repo, source-only asset checks, lint, and tests/compile gate - runtime require host generated assets and run runtime tests - runtime-smoke require host generated assets and run runtime smoke tests only - examples Tauri/Rust/frontend example checks - package package all published crates and enforce size limits - feature-powerset cargo-hack feature combination checks - semver cargo-semver-checks public API compatibility - supply-chain cargo-deny dependency checks - dev-ci repo, artifacts, lint, test, and examples - ci full local CI parity lane - release package generated release workspace and publish-dry-run internals - artifacts verify source-controlled asset inputs and AOT crate templates -MSG - exit 2 - ;; -esac diff --git a/src/bindings/wasix-rust/CHANGELOG.md b/src/bindings/wasix-rust/CHANGELOG.md new file mode 100644 index 00000000..eefa6d92 --- /dev/null +++ b/src/bindings/wasix-rust/CHANGELOG.md @@ -0,0 +1,8 @@ +# oliphaunt-wasix Changelog + +This transitional changelog tracks the Rust binding crate while the WASIX +runtime artifacts move under `liboliphaunt-wasix`. + +## Unreleased + +- Split runtime artifact ownership from the Rust WASIX binding package. diff --git a/src/bindings/wasix-rust/THIRD_PARTY_NOTICES.md b/src/bindings/wasix-rust/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..17a90be2 --- /dev/null +++ b/src/bindings/wasix-rust/THIRD_PARTY_NOTICES.md @@ -0,0 +1,17 @@ +# oliphaunt-wasix Third-Party Notices + +`oliphaunt-wasix` ships WASIX PostgreSQL runtime assets, selected SQL extensions, +and target-specific Wasmer AOT artifacts. + +The PostgreSQL runtime is derived from PostgreSQL 18 source pinned under +`src/postgres/versions/18/` and built with the WASM/WASIX patch stack owned by +`src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/`. + +- PostgreSQL license: https://www.postgresql.org/about/licence/ +- ICU / Unicode License v3: https://github.com/unicode-org/icu/blob/main/LICENSE + +Third-party source pins for optional external extensions are maintained in +`src/sources/third-party/`, and WASIX toolchain inputs are maintained in +`src/sources/toolchains/`. Exact SQL extension selection is modeled in +`src/extensions/`; generated WASM assets must include only the +extension artifacts explicitly selected for the release payload. diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/CHANGELOG.md b/src/bindings/wasix-rust/crates/oliphaunt-wasix/CHANGELOG.md new file mode 100644 index 00000000..b7db1ef3 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/CHANGELOG.md @@ -0,0 +1,17 @@ +# oliphaunt-wasix Changelog + +This changelog tracks the Rust WASIX binding crate. + +## Unreleased + +## [0.6.0] - 2026-06-12 + +### Added + +- Split runtime artifact ownership from the Rust WASIX binding package. + +### Changed + +- Depend on the product-scoped `liboliphaunt-wasix` runtime crates at `0.6.0`. +- Rename the public Cargo package, binaries, and Rust import path to + `oliphaunt-wasix`/`oliphaunt_wasix`. diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml b/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml new file mode 100644 index 00000000..a816b4c0 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml @@ -0,0 +1,101 @@ +[package] +name = "oliphaunt-wasix" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +description = "Embedded Postgres for Rust tests and local apps. No Docker, works with SQLx and any Postgres client." +readme = "README.md" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +documentation = "https://docs.rs/oliphaunt-wasix" +keywords = ["postgres", "oliphaunt", "wasm", "database", "embedded"] +categories = ["database-implementations", "wasm", "development-tools::testing"] +license = "MIT AND Apache-2.0 AND PostgreSQL" +exclude = [ + "Cargo.toml.orig", +] + +[features] +default = ["bundled", "extensions"] +bundled = [ + "dep:oliphaunt-wasix-assets", + "dep:oliphaunt-wasix-aot-aarch64-apple-darwin", + "dep:oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "dep:oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "dep:oliphaunt-wasix-aot-x86_64-pc-windows-msvc", +] +extensions = ["bundled"] + +[package.metadata.oliphaunt-wasix.assets] +postgres-version = "18.4" +postgres-source-url = "https://ftp.postgresql.org/pub/source/v18.4/postgresql-18.4.tar.bz2" +postgres-source-sha256 = "81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +postgres-patch-count = "37" +oliphaunt-npm-version-checked = "0.4.5" +runtime-archive-sha256 = "810a238bbb430b24b9a606bcdf9c2346270d729530f24e5c61772fe69d070577" +oliphaunt-wasix-sha256 = "d6438a0dd57c13cd160d6f58de3c5549f5b94c8d99d834ebed63ade841716f72" +pgdata-template-archive-sha256 = "c525b376a9667fdc7b7beb74d902ab56da5b017a4571e5ab62cd1b1bb4c0d65a" +pg-dump-wasix-sha256 = "19579204268759917a3efafa81ae1de7f2e67c7e0f4de11ea8aa03f948bf15bd" +initdb-wasix-sha256 = "91cfb13243c371d4937d4e6fca513aaa82a33dfde42be17f04ad64c4cb75e6e1" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +tar = "0.4" +zstd = { version = "0.13", default-features = false } +directories = "6" +tracing = "0.1" +flate2 = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "1" +tempfile = "3" +hex = "0.4" +sha2 = "0.10" +dunce = "1" +filetime = "0.2" +oliphaunt-wasix-assets = { version = "=0.6.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/assets", optional = true } +tokio = { version = "1", features = ["io-util", "rt-multi-thread"] } +wasmer = { version = "7.2.0-alpha.3", default-features = false, features = [ + "sys", + "headless", + "compiler", + "wasmer-artifact-load", +] } +wasmer-config = "0.702.0-alpha.3" +wasmer-types = "7.2.0-alpha.3" +wasmer-wasix = { version = "0.702.0-alpha.3", default-features = false, features = [ + "sys-minimal", + "sys-poll", + "host-vnet", + "time", +] } +webc = "=12.0.0" + +[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies] +oliphaunt-wasix-aot-aarch64-apple-darwin = { version = "=0.6.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin", optional = true } + +[target.'cfg(all(target_os = "linux", target_arch = "x86_64"))'.dependencies] +oliphaunt-wasix-aot-x86_64-unknown-linux-gnu = { version = "=0.6.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu", optional = true } + +[target.'cfg(all(target_os = "linux", target_arch = "aarch64"))'.dependencies] +oliphaunt-wasix-aot-aarch64-unknown-linux-gnu = { version = "=0.6.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu", optional = true } + +[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] +oliphaunt-wasix-aot-x86_64-pc-windows-msvc = { version = "=0.6.0", path = "../../../../runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc", optional = true } + +[dev-dependencies] +sqlx = { version = "0.8", default-features = false, features = [ + "postgres", + "runtime-tokio", +] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +tokio-postgres = "0.7" + +[[bin]] +name = "oliphaunt-wasix-dump" +path = "src/bin/oliphaunt_wasix_dump.rs" + +[[bin]] +name = "oliphaunt-wasix-proxy" +path = "src/bin/oliphaunt_wasix_proxy.rs" diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md b/src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md new file mode 100644 index 00000000..01f995c6 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md @@ -0,0 +1,143 @@ +

oliphaunt-wasix

+ +

+ Embedded Postgres for Rust tests and local apps.
+ Real PostgreSQL. Instant testing. Packaged runtime. Direct Rust API or a local Postgres URL. +

+ +

+ Guide + · + Performance + · + Extensions + · + Dump & Upgrade + · + Runtime + · + Tauri +

+ +

+ CI + crates.io + docs.rs + MSRV + License +

+ +`oliphaunt-wasix` brings the packaged WASIX Oliphaunt/Postgres runtime to Rust with a +small API. Open a database directly with `Oliphaunt`, or hand `OliphauntServer` to +SQLx and any standard Postgres client. The packaged runtime is PostgreSQL 18.4. +No local Postgres install, no Docker, no runtime build toolchain. + +## Add Postgres In One Minute ⚡ + +Already using SQLx or another Postgres client? Add the crate and point your +client at an embedded database URL: + +```sh +cargo add oliphaunt-wasix +``` + +```rust,no_run +use oliphaunt_wasix::OliphauntServer; +use sqlx::{Connection, Row}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = OliphauntServer::temporary_tcp()?; + // For a persistent TCP server: + // let server = OliphauntServer::builder().path("./.oliphaunt").start()?; + let mut conn = sqlx::PgConnection::connect(&server.database_url()).await?; + + let row = sqlx::query("SELECT $1::int4 + 1 AS answer") + .bind(41_i32) + .fetch_one(&mut conn) + .await?; + assert_eq!(row.try_get::("answer")?, 42); + + conn.close().await?; + server.shutdown()?; + Ok(()) +} +``` + +That's it. Real PostgreSQL, no service setup. + +## Why oliphaunt-wasix ✨ + +Postgres should be as easy to add to a Rust project as SQLite. + +- ⚡ **No service tax**: no Docker, no local Postgres, no testcontainers. +- 🔌 **Use your real stack**: SQLx, `tokio-postgres`, CLIs, and other clients + connect through a normal local URL. +- 🌉 **Proxy included**: expose an embedded database to non-Rust tools with + `oliphaunt-wasix-proxy`. +- 🧪 **Clean tests**: temporary databases are isolated, fast, and removed on + drop. +- 💾 **Persistent apps**: keep local app data across restarts when you want it. +- 🧩 **Extensions included**: `pgvector`, `pg_trgm`, `hstore`, `citext`, and + more. +- 📦 **Portable dumps**: use bundled `pg_dump` for logical backups and upgrade + paths. +- 🚀 **Near-native feel**: close to native Postgres, fully embedded. + +## Near-Native Performance 🚀 + +Current local snapshot on `Apple M1 Pro`, `16 GB RAM`, and `macOS 26.4.1`. +Full numbers and reproduction steps live in the +[performance guide](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/reference/performance.md). Lower is better. + +| Operation | native pg + SQLx | oliphaunt-wasix + SQLx | vanilla Oliphaunt + SQLx | +|---|---:|---:|---:| +| 25,000 INSERTs in one transaction | 132.36 ms | 149.54 ms | 257.02 ms | +| 25,000 INSERTs in one statement | 46.14 ms | 59.39 ms | 117.19 ms | +| 25,000 INSERTs into an indexed table | 188.72 ms | 253.38 ms | 352.64 ms | +| 5,000 indexed SELECTs | 81.39 ms | 125.31 ms | 203.05 ms | +| 25,000 indexed UPDATEs | 351.05 ms | 578.96 ms | 720.63 ms | + +`oliphaunt-wasix` stays close to native Postgres while running entirely embedded +and consistently performs better than vanilla Oliphaunt. + +## Extensions 🧩 + +Bundled extensions are supported, including `pgvector`, `pg_trgm`, `hstore`, +`citext`, `ltree`, and more. See the +[extensions guide](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/reference/extensions.mdx) +for the full catalog and usage details. + +```rust,no_run +use oliphaunt_wasix::{extensions, OliphauntServer}; +use sqlx::Connection; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = OliphauntServer::builder() + .path("./.oliphaunt") + .extension(extensions::VECTOR) + .start()?; + let mut conn = sqlx::PgConnection::connect(&server.database_url()).await?; + + sqlx::query("CREATE TABLE IF NOT EXISTS items (embedding vector(3))") + .execute(&mut conn) + .await?; + sqlx::query("INSERT INTO items VALUES ('[1,2,3]')") + .execute(&mut conn) + .await?; + + conn.close().await?; + server.shutdown()?; + Ok(()) +} +``` + +## Docs + +- [WASM guide](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/sdk/wasm/guide.mdx) +- [Extensions](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/reference/extensions.mdx) +- [Performance guide](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/reference/performance.md) +- [Dump and upgrade guide](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/sdk/wasm/dump-restore.md) +- [Tauri usage](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/learn/tauri.md) +- [WASIX runtime guide](https://github.com/f0rr0/oliphaunt/blob/main/src/docs/content/sdk/wasm/runtime.md) diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml b/src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml new file mode 100644 index 00000000..43090316 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml @@ -0,0 +1,9 @@ +id = "oliphaunt-wasix-rust" +owner = "@oliphaunt/wasix-rust" +kind = "wasix-rust-binding" +publish_targets = ["crates-io"] +registry_packages = ["crates:oliphaunt-wasix"] +release_artifacts = ["cargo-crate"] +legacy_tag_prefixes = [""] +legacy_version_file = "Cargo.toml" +legacy_version_parser = "cargo" diff --git a/src/bin/pglite_dump.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs similarity index 77% rename from src/bin/pglite_dump.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs index 243c50a4..27095c3f 100644 --- a/src/bin/pglite_dump.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_dump.rs @@ -1,6 +1,6 @@ use anyhow::Result; #[cfg(feature = "extensions")] -use pglite_oxide::{PgDumpOptions, PgliteServer}; +use oliphaunt_wasix::{OliphauntServer, PgDumpOptions}; #[cfg(feature = "extensions")] use std::env; #[cfg(feature = "extensions")] @@ -16,12 +16,12 @@ struct Args { fn main() -> Result<()> { #[cfg(not(feature = "extensions"))] { - anyhow::bail!("pglite-dump requires the `extensions` feature"); + anyhow::bail!("oliphaunt-wasix-dump requires the `extensions` feature"); } #[cfg(feature = "extensions")] { let Args { root, passthrough } = parse_args()?; - let server = PgliteServer::builder().path(root).start()?; + let server = OliphauntServer::builder().path(root).start()?; let sql = server.dump_sql(PgDumpOptions::new().args(passthrough))?; print!("{sql}"); server.shutdown()?; @@ -31,7 +31,7 @@ fn main() -> Result<()> { #[cfg(feature = "extensions")] fn parse_args() -> Result { - let mut root = PathBuf::from("./.pglite"); + let mut root = PathBuf::from("./.oliphaunt"); let mut passthrough = Vec::new(); let mut args = env::args().skip(1); while let Some(arg) = args.next() { @@ -58,6 +58,6 @@ fn parse_args() -> Result { #[cfg(feature = "extensions")] fn print_usage() { - eprintln!("Usage: pglite-dump --root PATH -- [pg_dump args]"); - eprintln!("Example: pglite-dump --root ./.pglite -- --schema-only"); + eprintln!("Usage: oliphaunt-wasix-dump --root PATH -- [pg_dump args]"); + eprintln!("Example: oliphaunt-wasix-dump --root ./.oliphaunt -- --schema-only"); } diff --git a/src/bin/pglite_proxy.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_proxy.rs similarity index 89% rename from src/bin/pglite_proxy.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_proxy.rs index 8f9d9511..bd341f53 100644 --- a/src/bin/pglite_proxy.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/bin/oliphaunt_wasix_proxy.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result, bail}; -use pglite_oxide::PgliteServer; +use oliphaunt_wasix::OliphauntServer; #[cfg(feature = "extensions")] -use pglite_oxide::extensions; +use oliphaunt_wasix::extensions; use std::env; use std::net::SocketAddr; use std::path::PathBuf; @@ -26,11 +26,11 @@ struct Args { fn main() -> Result<()> { let args = parse_args()?; let mut builder = if args.temporary { - PgliteServer::builder().temporary() + OliphauntServer::builder().temporary() } else if let Some(root) = args.root { - PgliteServer::builder().path(root) + OliphauntServer::builder().path(root) } else { - PgliteServer::builder().path("./.pglite") + OliphauntServer::builder().path("./.oliphaunt") }; builder = match args.bind { @@ -50,7 +50,7 @@ fn main() -> Result<()> { } #[cfg(not(feature = "extensions"))] if !args.extensions.is_empty() { - bail!("this pglite-proxy build was compiled without bundled extension support"); + bail!("this oliphaunt-wasix-proxy build was compiled without bundled extension support"); } let server = builder.start()?; @@ -135,10 +135,10 @@ fn parse_args() -> Result { fn print_usage() { eprintln!( - "Usage: pglite-proxy [--temporary | --root PATH] [--tcp ADDR | --unix PATH] [--print-uri] [--postgres-config NAME=VALUE] [--extension NAME]" + "Usage: oliphaunt-wasix-proxy [--temporary | --root PATH] [--tcp ADDR | --unix PATH] [--print-uri] [--postgres-config NAME=VALUE] [--extension NAME]" ); eprintln!(" --temporary Use an ephemeral database removed on exit"); - eprintln!(" --root PATH Runtime and cluster root. Default: ./.pglite"); + eprintln!(" --root PATH Runtime and cluster root. Default: ./.oliphaunt"); eprintln!(" --tcp ADDR Listen on TCP. Use 127.0.0.1:0 for a random port"); #[cfg(unix)] eprintln!(" --unix PATH Listen on a Unix socket path"); diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs new file mode 100644 index 00000000..f07739a3 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs @@ -0,0 +1,34 @@ +#![doc = include_str!("../README.md")] +#![deny(unsafe_code)] + +mod oliphaunt; +mod protocol; + +#[cfg(feature = "extensions")] +pub use oliphaunt::extensions; + +#[cfg(feature = "extensions")] +pub use oliphaunt::PgDumpOptions; +pub use oliphaunt::{ + DataDirArchiveFormat, DataTransferContainer, DescribeQueryParam, DescribeQueryResult, + DescribeResultField, EngineCapabilities, ExecProtocolOptions, ExecProtocolResult, FieldInfo, + GlobalListenerHandle, ListenerHandle, NoticeCallback, Oliphaunt, OliphauntBuilder, + OliphauntError, OliphauntServer, OliphauntServerBuilder, ParserMap, PostgresConfig, + QueryOptions, QueryTemplate, Results, RowMode, Serializer, SerializerMap, TemplatedQuery, + Transaction, TypeParser, format_query, quote_identifier, +}; +pub use protocol::messages::{BackendMessage, DatabaseError, NoticeMessage}; + +#[cfg(feature = "bundled")] +#[doc(hidden)] +pub use oliphaunt::{AssetManifestMetadata, asset_manifest_metadata}; +#[doc(hidden)] +pub use oliphaunt::{ + DebugLevel, FsTraceSnapshot, InstallOptions, InstallOutcome, MountInfo, OliphauntPaths, + OliphauntProxy, PgDataTemplate, PgDataTemplateManifest, PhaseTiming, ProtocolStatsSnapshot, + build_pgdata_template, capture_phase_timings, disable_protocol_stats, ensure_cluster, + fs_trace_snapshot, install_and_init, install_and_init_in, install_default, + install_extension_archive, install_extension_bytes, install_into, install_with_options, + measure_phase, preload_runtime_module, protocol_stats_snapshot, record_phase_timing, + reset_fs_trace, reset_protocol_stats, +}; diff --git a/src/pglite/aot.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs similarity index 87% rename from src/pglite/aot.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs index 9a92f758..35ee5d0f 100644 --- a/src/pglite/aot.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs @@ -16,7 +16,7 @@ use zstd::stream::read::Decoder as ZstdDecoder; use super::extensions::Extension; use super::timing; -const RUNTIME_ARTIFACT: &str = "runtime:pglite"; +const RUNTIME_ARTIFACT: &str = "runtime:oliphaunt"; const EXPECTED_AOT_ENGINE: &str = "llvm-opta"; const EXPECTED_WASMER_VERSION: &str = "7.2.0-alpha.3"; const EXPECTED_WASMER_WASIX_VERSION: &str = "0.702.0-alpha.3"; @@ -80,6 +80,11 @@ pub(crate) fn preload_runtime_artifact() -> Result<()> { #[cfg(feature = "extensions")] pub(crate) fn preload_extension_artifact(extension: Extension) -> Result<()> { let engine = headless_engine(); + for module in extension.native_support_modules() { + if let Some(aot_name) = module.aot_name() { + let _ = load_artifact_module(&engine, aot_name)?; + } + } let _ = load_extension_module(&engine, extension)?; Ok(()) } @@ -357,7 +362,7 @@ fn expected_raw_hash( let Some(raw_sha256) = &manifest_artifact.raw_sha256 else { ensure!( verify_mode == AotVerifyMode::Full, - "AOT artifact '{name}' is missing raw-sha256 metadata; rebuild assets or set PGLITE_OXIDE_AOT_VERIFY=full for strict hash-derived cache keys" + "AOT artifact '{name}' is missing raw-sha256 metadata; rebuild assets or set OLIPHAUNT_WASM_AOT_VERIFY=full for strict hash-derived cache keys" ); return Ok(sha256_hex(raw)); }; @@ -394,6 +399,8 @@ fn target_manifest_artifact(name: &str) -> Result { "AOT manifest wasmer-wasix version mismatch: manifest={} expected={EXPECTED_WASMER_WASIX_VERSION}", manifest.wasmer_wasix_version ); + #[cfg(feature = "bundled")] + validate_aot_manifest_metadata(&manifest)?; let artifact = manifest .artifacts @@ -413,6 +420,47 @@ fn target_manifest_artifact(name: &str) -> Result { Ok(artifact) } +#[cfg(feature = "bundled")] +fn validate_aot_manifest_metadata(manifest: &AotManifest) -> Result<()> { + let metadata = super::assets::asset_manifest_metadata()?; + let asset_source_lane = metadata + .source_lane + .as_deref() + .context("asset manifest is missing source-lane metadata")?; + let aot_source_lane = manifest + .source_lane + .as_deref() + .context("AOT manifest is missing source-lane metadata")?; + ensure!( + aot_source_lane == asset_source_lane, + "AOT manifest source lane mismatch: manifest={} assets={asset_source_lane}", + aot_source_lane + ); + + if let Some(expected) = metadata.source_fingerprint.as_deref() { + ensure!( + manifest.source_fingerprint.as_deref() == Some(expected), + "AOT manifest source fingerprint mismatch: manifest={} assets={expected}", + manifest + .source_fingerprint + .as_deref() + .unwrap_or("") + ); + } + + let postgres_version = manifest + .postgres_version + .as_deref() + .context("AOT manifest is missing postgres-version metadata")?; + ensure!( + postgres_version == metadata.postgres_version, + "AOT manifest PostgreSQL version mismatch: manifest={postgres_version} assets={}", + metadata.postgres_version + ); + + Ok(()) +} + fn validate_compressed_artifact_manifest( name: &str, artifact: &AotManifestArtifact, @@ -439,8 +487,8 @@ fn target_aot_manifest() -> Result { fn cache_path(name: &str, hash: &str) -> Result { let safe_name = name.replace([':', '/', '\\'], "-"); - let dirs = ProjectDirs::from("dev", "pglite-oxide", "pglite-oxide") - .context("could not resolve pglite-oxide cache directory")?; + let dirs = ProjectDirs::from("dev", "oliphaunt-wasix", "oliphaunt-wasix") + .context("could not resolve oliphaunt-wasix cache directory")?; Ok(dirs .cache_dir() .join("wasmer-aot") @@ -552,14 +600,14 @@ fn sha256_file_with_len(path: &Path) -> Result<(String, u64)> { } fn aot_verify_mode() -> Result { - let Some(value) = std::env::var_os("PGLITE_OXIDE_AOT_VERIFY") else { + let Some(value) = std::env::var_os("OLIPHAUNT_WASM_AOT_VERIFY") else { return Ok(AotVerifyMode::Fast); }; let value = value.to_string_lossy().to_ascii_lowercase(); match value.as_str() { "" | "fast" | "metadata" | "receipt" | "0" | "false" | "off" => Ok(AotVerifyMode::Fast), "full" | "sha" | "sha256" | "strict" | "1" | "true" | "on" => Ok(AotVerifyMode::Full), - other => bail!("unsupported PGLITE_OXIDE_AOT_VERIFY={other}; use `fast` or `full`"), + other => bail!("unsupported OLIPHAUNT_WASM_AOT_VERIFY={other}; use `fast` or `full`"), } } @@ -612,19 +660,19 @@ fn target_triple() -> &'static str { fn target_artifact_bytes(_name: &str) -> Option<&'static [u8]> { #[cfg(all(feature = "bundled", target_os = "macos", target_arch = "aarch64"))] { - return pglite_oxide_aot_aarch64_apple_darwin::artifact_bytes(_name); + return oliphaunt_wasix_aot_aarch64_apple_darwin::artifact_bytes(_name); } #[cfg(all(feature = "bundled", target_os = "linux", target_arch = "x86_64"))] { - return pglite_oxide_aot_x86_64_unknown_linux_gnu::artifact_bytes(_name); + return oliphaunt_wasix_aot_x86_64_unknown_linux_gnu::artifact_bytes(_name); } #[cfg(all(feature = "bundled", target_os = "linux", target_arch = "aarch64"))] { - return pglite_oxide_aot_aarch64_unknown_linux_gnu::artifact_bytes(_name); + return oliphaunt_wasix_aot_aarch64_unknown_linux_gnu::artifact_bytes(_name); } #[cfg(all(feature = "bundled", target_os = "windows", target_arch = "x86_64"))] { - return pglite_oxide_aot_x86_64_pc_windows_msvc::artifact_bytes(_name); + return oliphaunt_wasix_aot_x86_64_pc_windows_msvc::artifact_bytes(_name); } #[allow(unreachable_code)] None @@ -633,27 +681,34 @@ fn target_artifact_bytes(_name: &str) -> Option<&'static [u8]> { fn target_aot_manifest_json() -> Option<&'static str> { #[cfg(all(feature = "bundled", target_os = "macos", target_arch = "aarch64"))] { - return Some(pglite_oxide_aot_aarch64_apple_darwin::MANIFEST_JSON); + return Some(oliphaunt_wasix_aot_aarch64_apple_darwin::MANIFEST_JSON); } #[cfg(all(feature = "bundled", target_os = "linux", target_arch = "x86_64"))] { - return Some(pglite_oxide_aot_x86_64_unknown_linux_gnu::MANIFEST_JSON); + return Some(oliphaunt_wasix_aot_x86_64_unknown_linux_gnu::MANIFEST_JSON); } #[cfg(all(feature = "bundled", target_os = "linux", target_arch = "aarch64"))] { - return Some(pglite_oxide_aot_aarch64_unknown_linux_gnu::MANIFEST_JSON); + return Some(oliphaunt_wasix_aot_aarch64_unknown_linux_gnu::MANIFEST_JSON); } #[cfg(all(feature = "bundled", target_os = "windows", target_arch = "x86_64"))] { - return Some(pglite_oxide_aot_x86_64_pc_windows_msvc::MANIFEST_JSON); + return Some(oliphaunt_wasix_aot_x86_64_pc_windows_msvc::MANIFEST_JSON); } #[allow(unreachable_code)] None } #[derive(Debug, Deserialize)] +#[cfg_attr(not(feature = "bundled"), allow(dead_code))] #[serde(rename_all = "kebab-case")] struct AotManifest { + #[serde(default)] + source_lane: Option, + #[serde(default)] + source_fingerprint: Option, + #[serde(default)] + postgres_version: Option, target_triple: String, engine: String, wasmer_version: String, @@ -691,19 +746,19 @@ struct AotCacheReceipt { mod tests { use super::*; - const ASSET_SOURCES: &str = include_str!("../../assets/sources.toml"); + const WASIX_TOOLCHAIN: &str = include_str!("../../../../../../sources/toolchains/wasix.toml"); #[test] fn runtime_aot_versions_match_asset_toolchain() { assert_eq!( EXPECTED_WASMER_VERSION, toolchain_value("wasmer"), - "runtime AOT Wasmer expectation must match assets/sources.toml" + "runtime AOT Wasmer expectation must match src/sources/toolchains/wasix.toml" ); assert_eq!( EXPECTED_WASMER_WASIX_VERSION, toolchain_value("wasmer-wasix"), - "runtime AOT WASIX expectation must match assets/sources.toml" + "runtime AOT WASIX expectation must match src/sources/toolchains/wasix.toml" ); } @@ -724,9 +779,9 @@ mod tests { } fn toolchain_value(key: &str) -> &str { - let rest = ASSET_SOURCES + let rest = WASIX_TOOLCHAIN .split_once("[toolchain]") - .expect("sources manifest has a [toolchain] section") + .expect("WASIX toolchain manifest has a [toolchain] section") .1; let section = rest.split_once("\n[").map_or(rest, |(section, _)| section); @@ -738,6 +793,6 @@ mod tests { return value.trim().trim_matches('"'); } } - panic!("sources manifest has toolchain.{key}"); + panic!("WASIX toolchain manifest has toolchain.{key}"); } } diff --git a/src/pglite/assets.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs similarity index 60% rename from src/pglite/assets.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs index bfe0ffee..a3a67726 100644 --- a/src/pglite/assets.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs @@ -5,14 +5,25 @@ use std::sync::{Arc, OnceLock}; #[cfg(feature = "bundled")] static ASSET_MANIFEST: OnceLock< - std::result::Result, String>, + std::result::Result, String>, > = OnceLock::new(); #[cfg(feature = "bundled")] -fn asset_manifest() -> Result> { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssetManifestMetadata { + pub source_lane: Option, + pub source_fingerprint: Option, + pub postgres_version: String, + pub pgdata_template_source_lane: Option, + pub pgdata_template_source_fingerprint: Option, + pub pgdata_template_postgres_version: Option, +} + +#[cfg(feature = "bundled")] +fn asset_manifest() -> Result> { ASSET_MANIFEST .get_or_init(|| { - pglite_oxide_assets::manifest() + oliphaunt_wasix_assets::manifest() .map(Arc::new) .map_err(|err| err.to_string()) }) @@ -20,9 +31,31 @@ fn asset_manifest() -> Result> { .map_err(|message| anyhow!(message)) } +#[cfg(feature = "bundled")] +pub fn asset_manifest_metadata() -> Result { + let manifest = asset_manifest().context("parse embedded asset manifest")?; + Ok(AssetManifestMetadata { + source_lane: manifest.source_lane.clone(), + source_fingerprint: manifest.source_fingerprint.clone(), + postgres_version: manifest.runtime.postgres_version.clone(), + pgdata_template_source_lane: manifest + .pgdata_template + .as_ref() + .and_then(|template| template.source_lane.clone()), + pgdata_template_source_fingerprint: manifest + .pgdata_template + .as_ref() + .and_then(|template| template.source_fingerprint.clone()), + pgdata_template_postgres_version: manifest + .pgdata_template + .as_ref() + .map(|template| template.postgres_version.clone()), + }) +} + #[cfg(feature = "bundled")] pub(crate) fn runtime_archive() -> Option<&'static [u8]> { - pglite_oxide_assets::runtime_archive() + oliphaunt_wasix_assets::runtime_archive() } #[cfg(not(feature = "bundled"))] @@ -32,7 +65,7 @@ pub(crate) fn runtime_archive() -> Option<&'static [u8]> { #[cfg(feature = "bundled")] pub(crate) fn pgdata_template_archive() -> Option<&'static [u8]> { - pglite_oxide_assets::pgdata_template_archive() + oliphaunt_wasix_assets::pgdata_template_archive() } #[cfg(not(feature = "bundled"))] @@ -42,7 +75,7 @@ pub(crate) fn pgdata_template_archive() -> Option<&'static [u8]> { #[cfg(feature = "bundled")] pub(crate) fn pgdata_template_manifest() -> Option<&'static [u8]> { - pglite_oxide_assets::pgdata_template_manifest() + oliphaunt_wasix_assets::pgdata_template_manifest() } #[cfg(not(feature = "bundled"))] @@ -53,7 +86,7 @@ pub(crate) fn pgdata_template_manifest() -> Option<&'static [u8]> { #[cfg(feature = "bundled")] #[allow(dead_code)] pub(crate) fn pg_dump_wasm() -> Option<&'static [u8]> { - pglite_oxide_assets::pg_dump_wasm() + oliphaunt_wasix_assets::pg_dump_wasm() } #[cfg(not(feature = "bundled"))] @@ -65,7 +98,7 @@ pub(crate) fn pg_dump_wasm() -> Option<&'static [u8]> { #[cfg(feature = "bundled")] #[allow(dead_code)] pub(crate) fn initdb_wasm() -> Option<&'static [u8]> { - pglite_oxide_assets::initdb_wasm() + oliphaunt_wasix_assets::initdb_wasm() } #[cfg(not(feature = "bundled"))] @@ -76,7 +109,7 @@ pub(crate) fn initdb_wasm() -> Option<&'static [u8]> { #[cfg(feature = "extensions")] pub(crate) fn extension_archive(sql_name: &str) -> Option<&'static [u8]> { - pglite_oxide_assets::extension_archive(sql_name) + oliphaunt_wasix_assets::extension_archive(sql_name) } #[cfg(feature = "bundled")] @@ -102,7 +135,7 @@ pub(crate) fn expected_extension_archive_sha256(sql_name: &str) -> Result Result { let manifest = asset_manifest().context("parse embedded asset manifest")?; - if name == "runtime:pglite" { + if name == "runtime:oliphaunt" { return Ok(manifest.runtime.module_sha256.clone()); } if let Some(name) = name.strip_prefix("runtime-support:") { @@ -130,6 +163,23 @@ pub(crate) fn expected_module_sha256(name: &str) -> Result { .ok_or_else(|| anyhow!("initdb is missing from asset manifest")); } if let Some(sql_name) = name.strip_prefix("extension:") { + if let Some((sql_name, module_name)) = sql_name.split_once(':') { + return manifest + .extensions + .iter() + .find(|extension| extension.sql_name == sql_name) + .and_then(|extension| { + extension.native_modules.iter().find(|module| { + module.name == module_name || module.path.ends_with(module_name) + }) + }) + .map(|module| module.module_sha256.clone()) + .ok_or_else(|| { + anyhow!( + "extension module '{sql_name}:{module_name}' is missing from asset manifest" + ) + }); + } let module_sha256 = manifest .extensions .iter() diff --git a/src/pglite/backend.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs similarity index 66% rename from src/pglite/backend.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs index 47919139..7cf3ad8e 100644 --- a/src/pglite/backend.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/backend.rs @@ -1,21 +1,22 @@ #[cfg(feature = "extensions")] -use anyhow::{Context, bail}; +use anyhow::Context; use anyhow::{Result, ensure}; use std::sync::{Mutex, MutexGuard, OnceLock}; -use crate::pglite::base::InstallOutcome; -use crate::pglite::config::{PostgresConfig, StartupConfig}; +use crate::oliphaunt::base::InstallOutcome; +use crate::oliphaunt::config::{PostgresConfig, StartupConfig}; +use crate::oliphaunt::engine::EngineCapabilities; #[cfg(feature = "extensions")] -use crate::pglite::extensions::{Extension, extension_session_setup_sql, extension_setup_sql}; -use crate::pglite::interface::DataTransferContainer; -use crate::pglite::postgres_mod::{ +use crate::oliphaunt::extensions::{Extension, extension_session_setup_sql, extension_setup_sql}; +use crate::oliphaunt::interface::DataTransferContainer; +use crate::oliphaunt::postgres_mod::{ PostgresMod, ProtocolPumpOutcome, ProtocolStream, StartupProtocolResponse, }; -use crate::pglite::timing; -use crate::pglite::transport::Transport; -use crate::pglite::wire::raw_protocol_message_len; +use crate::oliphaunt::timing; +use crate::oliphaunt::transport::Transport; +use crate::oliphaunt::wire::raw_protocol_message_len; #[cfg(feature = "extensions")] -use crate::pglite::wire::{response_contains_error, simple_query_message}; +use crate::oliphaunt::wire::{response_contains_error, simple_query_message}; static WASIX_BACKEND_OPEN_LOCK: OnceLock> = OnceLock::new(); @@ -25,7 +26,9 @@ pub(crate) enum BackendOpenKind { Proxy, } -pub(crate) struct BackendSession { +pub(crate) struct BackendSession(Box); + +pub(crate) struct WasixBackendSession { pg: PostgresMod, transport: Transport, outcome: InstallOutcome, @@ -38,7 +41,7 @@ pub(crate) struct BackendSession { preloaded_extensions: Vec, } -impl BackendSession { +impl WasixBackendSession { pub(crate) fn open( outcome: InstallOutcome, postgres_config: PostgresConfig, @@ -141,7 +144,7 @@ impl BackendSession { ) -> Result { let pg = { let _phase = timing::phase(match kind { - BackendOpenKind::Direct => "pglite.postgres_new", + BackendOpenKind::Direct => "oliphaunt.postgres_new", BackendOpenKind::Proxy => "proxy.backend_postgres_new", }); PostgresMod::new_prepared_with_config( @@ -157,14 +160,14 @@ impl BackendSession { fn finish_open(mut pg: PostgresMod, kind: BackendOpenKind) -> Result<(PostgresMod, Transport)> { { let _phase = timing::phase(match kind { - BackendOpenKind::Direct => "pglite.ensure_cluster", + BackendOpenKind::Direct => "oliphaunt.ensure_cluster", BackendOpenKind::Proxy => "proxy.backend_ensure_cluster", }); pg.ensure_cluster()?; } let transport = { let _phase = timing::phase(match kind { - BackendOpenKind::Direct => "pglite.transport_prepare", + BackendOpenKind::Direct => "oliphaunt.transport_prepare", BackendOpenKind::Proxy => "proxy.transport_prepare", }); Transport::prepare(&mut pg)? @@ -172,7 +175,7 @@ impl BackendSession { Ok((pg, transport)) } - pub(crate) fn paths(&self) -> &crate::pglite::base::PglitePaths { + pub(crate) fn paths(&self) -> &crate::oliphaunt::base::OliphauntPaths { self.pg.paths() } @@ -257,7 +260,7 @@ impl BackendSession { format!("enable bundled extension '{}'", extension.sql_name()) })?; if response_contains_error(&response) { - bail!( + anyhow::bail!( "enable bundled extension '{}' returned a Postgres error", extension.sql_name() ); @@ -320,6 +323,146 @@ impl BackendSession { } } +impl BackendSession { + pub(crate) fn open( + outcome: InstallOutcome, + postgres_config: PostgresConfig, + startup_config: StartupConfig, + kind: BackendOpenKind, + ) -> Result { + WasixBackendSession::open(outcome, postgres_config, startup_config, kind) + .map(Box::new) + .map(Self) + } + + #[cfg(feature = "extensions")] + pub(crate) fn open_with_extension_preload( + outcome: InstallOutcome, + postgres_config: PostgresConfig, + startup_config: StartupConfig, + kind: BackendOpenKind, + extensions: &[Extension], + ) -> Result { + WasixBackendSession::open_with_extension_preload( + outcome, + postgres_config, + startup_config, + kind, + extensions, + ) + .map(Box::new) + .map(Self) + } + + pub(crate) fn capabilities(&self) -> EngineCapabilities { + EngineCapabilities::wasix_legacy(self.0.supports_protocol_pump()) + } + + pub(crate) fn paths(&self) -> &crate::oliphaunt::base::OliphauntPaths { + self.0.paths() + } + + pub(crate) fn pgdata_template_root(&self) -> Option<&std::path::Path> { + self.0.pgdata_template_root() + } + + pub(crate) fn startup_config(&self) -> &StartupConfig { + self.0.startup_config() + } + + #[cfg(debug_assertions)] + pub(crate) fn guest_bridge_allocation_counts(&self) -> (u64, u64) { + self.0.guest_bridge_allocation_counts() + } + + pub(crate) fn send_buffered( + &mut self, + message: &[u8], + requested: Option, + ) -> Result> { + self.0.send_buffered(message, requested) + } + + pub(crate) fn with_buffered( + &mut self, + message: &[u8], + requested: Option, + f: F, + ) -> Result + where + F: FnOnce(&[u8]) -> Result, + { + let data = self.0.send_buffered(message, requested)?; + f(&data) + } + + pub(crate) fn send_framed_raw_stream( + &mut self, + message: &[u8], + requested: Option, + on_data: F, + ) -> Result<()> + where + F: FnMut(&[u8]) -> Result<()>, + { + self.0.send_framed_raw_stream(message, requested, on_data) + } + + pub(crate) fn startup_with_packet( + &mut self, + message: &[u8], + ) -> Result { + self.0.startup_with_packet(message) + } + + #[cfg(feature = "extensions")] + pub(crate) fn existing_startup_response(&self) -> Option> { + self.0.existing_startup_response() + } + + #[cfg(feature = "extensions")] + pub(crate) fn preload_extension_module(&mut self, extension: Extension) -> Result<()> { + self.0.preload_extension_module(extension) + } + + #[cfg(feature = "extensions")] + pub(crate) fn preload_installed_extension(&mut self, extension: Extension) -> Result<()> { + self.0.preload_installed_extension(extension) + } + + #[cfg(feature = "extensions")] + pub(crate) fn enable_extensions(&mut self, extensions: &[Extension]) -> Result<()> { + self.0.enable_extensions(extensions) + } + + pub(crate) fn supports_protocol_pump(&self) -> bool { + self.0.supports_protocol_pump() + } + + pub(crate) fn attach_protocol_stream(&mut self, stream: S) -> Result<()> + where + S: ProtocolStream + 'static, + { + self.0.attach_protocol_stream(stream) + } + + pub(crate) fn send_with_protocol_pump( + &mut self, + message: &[u8], + continuation_prefix: impl FnOnce() -> Vec, + ) -> Result { + self.0.send_with_protocol_pump(message, continuation_prefix) + } + + pub(crate) fn shutdown(&mut self) -> Result<()> { + self.0.shutdown() + } + + pub(crate) fn restart(&mut self) -> Result<()> { + self.0.restart() + } +} + fn wasix_backend_open_guard() -> MutexGuard<'static, ()> { // Wasmer/WASIX backend startup uses process-wide runtime and module-cache // state. Serialize creation and `_start`; already-open backends still run diff --git a/src/pglite/base.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs similarity index 80% rename from src/pglite/base.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs index 5d7c17c3..509ba349 100644 --- a/src/pglite/base.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs @@ -1,12 +1,9 @@ use std::collections::BTreeSet; -use std::ffi::OsStr; use std::fs::{self, File, OpenOptions}; use std::io::{Cursor, Read}; #[cfg(windows)] use std::os::windows::fs::OpenOptionsExt; use std::path::{Component, Path, PathBuf}; -#[cfg(any(target_os = "linux", target_os = "macos"))] -use std::process::Command; use std::sync::{Arc, Mutex, OnceLock}; use anyhow::{Context, Result, anyhow, bail, ensure}; @@ -20,26 +17,30 @@ use zstd::stream::read::Decoder as ZstdDecoder; use super::postgres_mod::PostgresMod; use super::timing; -use crate::pglite::assets; +use crate::oliphaunt::assets; #[cfg(feature = "extensions")] -use crate::pglite::client::Pglite; +use crate::oliphaunt::client::Oliphaunt; #[cfg(feature = "extensions")] -use crate::pglite::config::{PostgresConfig, StartupConfig}; -use crate::pglite::data_dir::unpack_pgdata_archive; +use crate::oliphaunt::config::{PostgresConfig, StartupConfig}; +use crate::oliphaunt::data_dir::unpack_pgdata_archive; #[cfg(feature = "extensions")] -use crate::pglite::extensions::Extension; +use crate::oliphaunt::extensions::Extension; use tempfile::TempDir; -const RUNTIME_ARCHIVE_NAME: &str = "pglite.wasix.tar.zst"; +mod template_clone; + +use template_clone::clone_pgdata_template_dir; + +const RUNTIME_ARCHIVE_NAME: &str = "oliphaunt.wasix.tar.zst"; const PGDATA_TEMPLATE_ARCHIVE_NAME: &str = "pgdata-template.tar.zst"; -const MOUNTFS_RUNTIME_MARKER: &str = ".pglite-oxide-mountfs-runtime"; -const RUNTIME_LAYOUT_MANIFEST_NAME: &str = ".pglite-oxide-runtime-layout.json"; -const PGDATA_OVERLAY_MANIFEST_NAME: &str = ".pglite-oxide-pgdata-overlay.json"; +const MOUNTFS_RUNTIME_MARKER: &str = ".oliphaunt-wasix-mountfs-runtime"; +const RUNTIME_LAYOUT_MANIFEST_NAME: &str = ".oliphaunt-wasix-runtime-layout.json"; +const PGDATA_OVERLAY_MANIFEST_NAME: &str = ".oliphaunt-wasix-pgdata-overlay.json"; // Bump these when cache materialization semantics change; old mutable PGDATA // template caches may have been modified by earlier clone strategies. const PGDATA_TEMPLATE_CACHE_FORMAT: &str = "v2"; #[cfg(feature = "extensions")] -const EXTENSION_PGDATA_TEMPLATE_CACHE_FORMAT: &str = "v4"; +const EXTENSION_PGDATA_TEMPLATE_CACHE_FORMAT: &str = "v5"; const DEFAULT_PASSWORD_FILE: &[u8] = b"password\n"; static RUNTIME_CACHE: OnceLock, String>> = OnceLock::new(); @@ -52,6 +53,13 @@ static EXTENSION_TEMPLATE_CACHE_LOCK: OnceLock> = OnceLock::new(); static ROOT_LOCKED_PATHS: OnceLock>> = OnceLock::new(); const TEMPLATE_RUNTIME_STATE_FILES: &[&str] = &["postmaster.pid", "postmaster.opts"]; +#[cfg(feature = "extensions")] +fn extension_template_cache_enabled() -> bool { + std::env::var_os("OLIPHAUNT_WASM_EXTENSION_TEMPLATE_CACHE") + .and_then(|value| value.into_string().ok()) + .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "on")) +} + #[derive(Debug)] struct CachedRuntime { runtime_root: PathBuf, @@ -70,7 +78,7 @@ struct CachedExtensionPgDataTemplate { } #[derive(Debug, Clone)] -pub struct PglitePaths { +pub struct OliphauntPaths { pub pgroot: PathBuf, pub pgdata: PathBuf, } @@ -123,6 +131,10 @@ struct PgDataOverlayManifest { template_archive_sha256: String, postgres_version: String, #[serde(default)] + source_lane: Option, + #[serde(default)] + source_fingerprint: Option, + #[serde(default)] extension_sql_names: Vec, } @@ -215,7 +227,7 @@ impl RootPrepareOptions { impl RuntimeLayout { pub(crate) fn module_path(&self) -> PathBuf { - self.module_root.join("bin/pglite") + self.module_root.join("bin/oliphaunt") } pub(crate) fn uses_shared_overlay(&self) -> bool { @@ -230,12 +242,16 @@ pub struct PgDataTemplate { pub manifest_path: PathBuf, } -/// Manifest that binds a PGDATA template to the PGlite WASIX runtime it was +/// Manifest that binds a PGDATA template to the Oliphaunt WASIX runtime it was /// created with. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PgDataTemplateManifest { pub postgres_version: String, + #[serde(default)] + pub source_lane: Option, + #[serde(default)] + pub source_fingerprint: Option, pub wasm_sha256: String, pub archive_sha256: String, #[serde(default)] @@ -248,6 +264,10 @@ pub struct PgDataTemplateManifest { struct ExtensionPgDataTemplateManifest { version: u32, postgres_version: String, + #[serde(default)] + source_lane: Option, + #[serde(default)] + source_fingerprint: Option, base_template_archive_sha256: String, base_template_wasm_sha256: String, extension_sql_names: Vec, @@ -256,7 +276,7 @@ struct ExtensionPgDataTemplateManifest { cache_key: String, } -impl PglitePaths { +impl OliphauntPaths { pub fn new(app_qual: (&str, &str, &str)) -> Result { let pd = ProjectDirs::from(app_qual.0, app_qual.1, app_qual.2) .context("could not resolve app data dir")?; @@ -267,7 +287,7 @@ impl PglitePaths { pub fn with_root(root: impl Into) -> Self { let base = root.into(); let pgroot = base.join("tmp"); - let pgdata = pgroot.join("pglite").join("base"); + let pgdata = pgroot.join("oliphaunt").join("base"); Self { pgroot, pgdata } } @@ -287,7 +307,7 @@ impl PglitePaths { } pub(crate) fn runtime_root(&self) -> PathBuf { - self.pgroot.join("pglite") + self.pgroot.join("oliphaunt") } pub fn with_temp_dir() -> Result<(TempDir, Self)> { @@ -312,7 +332,7 @@ impl PglitePaths { impl RootLock { pub(crate) fn acquire(root: &Path) -> Result { fs::create_dir_all(root) - .with_context(|| format!("create PGlite root {}", root.display()))?; + .with_context(|| format!("create Oliphaunt root {}", root.display()))?; let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); { let mut locked = ROOT_LOCKED_PATHS @@ -321,18 +341,18 @@ impl RootLock { .expect("root lock path set poisoned"); ensure!( locked.insert(canonical_root.clone()), - "PGlite root is already in use: {}", + "Oliphaunt root is already in use: {}", root.display() ); } - let path = root.join(".pglite-oxide.lock"); + let path = root.join(".oliphaunt-wasix.lock"); let file = match open_root_lock_file(&path) { Ok(file) => file, Err(err) => { release_root_lock_path(&canonical_root); return Err(err).with_context(|| { format!( - "PGlite root is already in use or unavailable: {}", + "Oliphaunt root is already in use or unavailable: {}", root.display() ) }); @@ -341,7 +361,7 @@ impl RootLock { if let Err(err) = file.try_lock() { release_root_lock_path(&canonical_root); return Err(err) - .with_context(|| format!("PGlite root is already in use: {}", root.display())); + .with_context(|| format!("Oliphaunt root is already in use: {}", root.display())); } Ok(Self { path: canonical_root, @@ -349,7 +369,7 @@ impl RootLock { }) } - pub(crate) fn acquire_for_paths(paths: &PglitePaths) -> Result { + pub(crate) fn acquire_for_paths(paths: &OliphauntPaths) -> Result { Self::acquire(paths.install_root()) } } @@ -399,18 +419,18 @@ impl CacheLock { } } -fn locate_runtime_module(paths: &PglitePaths) -> Option<(PathBuf, PathBuf)> { - let pglite_dir = paths.pgroot.join("pglite"); - if !pglite_dir.exists() { +fn locate_runtime_module(paths: &OliphauntPaths) -> Option<(PathBuf, PathBuf)> { + let oliphaunt_dir = paths.pgroot.join("oliphaunt"); + if !oliphaunt_dir.exists() { return None; } - let pglite_bin_dir = pglite_dir.join("bin"); - let module = pglite_bin_dir.join("pglite"); + let oliphaunt_bin_dir = oliphaunt_dir.join("bin"); + let module = oliphaunt_bin_dir.join("oliphaunt"); if !module.exists() { return None; } - let share = pglite_dir.join("share").join("postgresql"); + let share = oliphaunt_dir.join("share").join("postgresql"); let required_share_files = [ "postgres.bki", "timezonesets/Default", @@ -424,17 +444,19 @@ fn locate_runtime_module(paths: &PglitePaths) -> Option<(PathBuf, PathBuf)> { { return None; } - Some((module, pglite_bin_dir)) + Some((module, oliphaunt_bin_dir)) } -fn ensure_full_runtime(paths: &PglitePaths) -> Result { +fn ensure_full_runtime(paths: &OliphauntPaths) -> Result { let _phase = timing::phase("runtime.ensure"); + let source_key = runtime_cache_key()?; let existing_runtime = { let _phase = timing::phase("runtime.locate_existing"); locate_runtime_module(paths) }; if existing_runtime.is_some() { - let repaired_runtime = if runtime_support_files_need_repair(paths)? { + let source_key_matches = full_runtime_layout_matches_current(paths, &source_key)?; + let repaired_runtime = if !source_key_matches || runtime_support_files_need_repair(paths)? { install_runtime_from_tar(paths)? } else { false @@ -442,7 +464,7 @@ fn ensure_full_runtime(paths: &PglitePaths) -> Result { write_runtime_layout_manifest( &paths.runtime_root(), RuntimeLayoutKind::FullLocal, - &runtime_cache_key()?, + &source_key, )?; ensure_runtime_password_file(&paths.runtime_root())?; return Ok(repaired_runtime); @@ -465,14 +487,24 @@ fn ensure_full_runtime(paths: &PglitePaths) -> Result { write_runtime_layout_manifest( &paths.runtime_root(), RuntimeLayoutKind::FullLocal, - &runtime_cache_key()?, + &source_key, )?; ensure_runtime_password_file(&paths.runtime_root())?; Ok(true) } -fn runtime_support_files_need_repair(paths: &PglitePaths) -> Result { +fn full_runtime_layout_matches_current( + paths: &OliphauntPaths, + expected_source_key: &str, +) -> Result { + let Some(manifest) = read_runtime_layout_manifest(&paths.runtime_root())? else { + return Ok(false); + }; + Ok(manifest.kind == RuntimeLayoutKind::FullLocal && manifest.source_key == expected_source_key) +} + +fn runtime_support_files_need_repair(paths: &OliphauntPaths) -> Result { for relative in [ "password", "share/postgresql/postgres.bki", @@ -491,8 +523,8 @@ fn runtime_support_files_need_repair(paths: &PglitePaths) -> Result { } fn runtime_tar_path() -> Option { - if let Ok(path) = std::env::var("PGLITE_OXIDE_RUNTIME_ARCHIVE") - .or_else(|_| std::env::var("PGLITE_OXIDE_RUNTIME_TAR")) + if let Ok(path) = std::env::var("OLIPHAUNT_WASM_RUNTIME_ARCHIVE") + .or_else(|_| std::env::var("OLIPHAUNT_WASM_RUNTIME_TAR")) { let candidate = PathBuf::from(path); if candidate.exists() { @@ -503,7 +535,7 @@ fn runtime_tar_path() -> Option { None } -fn install_runtime_from_tar(paths: &PglitePaths) -> Result { +fn install_runtime_from_tar(paths: &OliphauntPaths) -> Result { let _phase = timing::phase("runtime.archive_install"); if let Some(tar_path) = runtime_tar_path() { info!("installing runtime from tar archive {}", tar_path.display()); @@ -520,7 +552,7 @@ fn install_runtime_from_tar(paths: &PglitePaths) -> Result { )?; } else { bail!( - "no embedded PGlite runtime assets are available; enable the `bundled` feature or set PGLITE_OXIDE_RUNTIME_ARCHIVE" + "no embedded Oliphaunt runtime assets are available; enable the `bundled` feature or set OLIPHAUNT_WASM_RUNTIME_ARCHIVE" ); } @@ -587,7 +619,7 @@ fn archive_destination(root: &Path, archive_path: &Path) -> Result { Ok(dest) } -fn install_extension_reader(paths: &PglitePaths, mut reader: R) -> Result<()> { +fn install_extension_reader(paths: &OliphauntPaths, mut reader: R) -> Result<()> { let _phase = timing::phase("extension.archive_install"); let mut bytes = Vec::new(); reader @@ -601,7 +633,7 @@ fn install_extension_reader(paths: &PglitePaths, mut reader: R) -> Resu Box::new(Cursor::new(bytes)) }; let mut ar = Archive::new(archive_reader); - let target = paths.pgroot.join("pglite"); + let target = paths.pgroot.join("oliphaunt"); std::fs::create_dir_all(&target) .with_context(|| format!("create extension target {}", target.display()))?; unpack_archive_entries(&mut ar, &target) @@ -609,19 +641,19 @@ fn install_extension_reader(paths: &PglitePaths, mut reader: R) -> Resu Ok(()) } -pub fn install_extension_archive(paths: &PglitePaths, archive_path: &Path) -> Result<()> { +pub fn install_extension_archive(paths: &OliphauntPaths, archive_path: &Path) -> Result<()> { let file = std::fs::File::open(archive_path) .with_context(|| format!("open extension archive {}", archive_path.display()))?; install_extension_reader(paths, file) } -pub fn install_extension_bytes(paths: &PglitePaths, bytes: &[u8]) -> Result<()> { +pub fn install_extension_bytes(paths: &OliphauntPaths, bytes: &[u8]) -> Result<()> { install_extension_reader(paths, std::io::Cursor::new(bytes)) } #[cfg(feature = "extensions")] pub(crate) fn install_bundled_extension_bytes( - paths: &PglitePaths, + paths: &OliphauntPaths, sql_name: &str, bytes: &[u8], ) -> Result<()> { @@ -670,7 +702,10 @@ pub fn build_pgdata_template(output_dir: impl AsRef) -> Result Result { +fn try_install_embedded_pgdata_template( + paths: &OliphauntPaths, + module_path: &Path, +) -> Result { let _phase = timing::phase("pgdata.embedded_template_install"); if cluster_is_complete(paths) { return Ok(false); @@ -700,7 +735,7 @@ fn try_install_embedded_pgdata_template(paths: &PglitePaths, module_path: &Path) } fn try_prepare_pgdata_template_overlay( - paths: &PglitePaths, + paths: &OliphauntPaths, module_path: &Path, runtime_layout: &mut RuntimeLayout, ) -> Result { @@ -771,7 +806,7 @@ fn install_extension_template_into_outcome( for extension in &normalized { let bytes = assets::extension_archive(extension.sql_name()).ok_or_else(|| { anyhow!( - "extension asset '{}' is not bundled in this pglite-oxide build", + "extension asset '{}' is not bundled in this oliphaunt-wasix build", extension.sql_name() ) })?; @@ -783,7 +818,7 @@ fn install_extension_template_into_outcome( #[cfg(feature = "extensions")] fn install_pgdata_template_overlay_from_extension_template( - paths: &PglitePaths, + paths: &OliphauntPaths, runtime_layout: &mut RuntimeLayout, template: &CachedExtensionPgDataTemplate, ) -> Result<()> { @@ -807,6 +842,8 @@ fn install_pgdata_template_overlay_from_extension_template( paths, &template.manifest.cache_key, &template.manifest.postgres_version, + template.manifest.source_lane.as_deref(), + template.manifest.source_fingerprint.as_deref(), &template.manifest.extension_sql_names, )?; remove_template_runtime_state(&paths.pgdata)?; @@ -816,7 +853,7 @@ fn install_pgdata_template_overlay_from_extension_template( #[cfg(feature = "extensions")] fn install_pgdata_template_clone_from_extension_template( - paths: &PglitePaths, + paths: &OliphauntPaths, template: &CachedExtensionPgDataTemplate, ) -> Result<()> { let _phase = timing::phase("pgdata.extension_template_clone"); @@ -837,15 +874,15 @@ fn install_pgdata_template_clone_from_extension_template( Ok(()) } -fn pgdata_overlay_manifest_path(paths: &PglitePaths) -> PathBuf { +fn pgdata_overlay_manifest_path(paths: &OliphauntPaths) -> PathBuf { paths.pgdata.join(PGDATA_OVERLAY_MANIFEST_NAME) } -fn pgdata_overlay_is_installed(paths: &PglitePaths) -> bool { +fn pgdata_overlay_is_installed(paths: &OliphauntPaths) -> bool { pgdata_overlay_manifest_path(paths).is_file() } -fn read_pgdata_overlay_manifest(paths: &PglitePaths) -> Result> { +fn read_pgdata_overlay_manifest(paths: &OliphauntPaths) -> Result> { let path = pgdata_overlay_manifest_path(paths); match fs::read(&path) { Ok(bytes) => { @@ -859,26 +896,32 @@ fn read_pgdata_overlay_manifest(paths: &PglitePaths) -> Result Result<()> { write_pgdata_overlay_manifest_values( paths, &manifest.archive_sha256, &manifest.postgres_version, + manifest.source_lane.as_deref(), + manifest.source_fingerprint.as_deref(), &[], ) } fn write_pgdata_overlay_manifest_values( - paths: &PglitePaths, + paths: &OliphauntPaths, template_archive_sha256: &str, postgres_version: &str, + source_lane: Option<&str>, + source_fingerprint: Option<&str>, extension_sql_names: &[String], ) -> Result<()> { let overlay = PgDataOverlayManifest { template_archive_sha256: template_archive_sha256.to_owned(), postgres_version: postgres_version.to_owned(), + source_lane: source_lane.map(str::to_owned), + source_fingerprint: source_fingerprint.map(str::to_owned), extension_sql_names: extension_sql_names.to_vec(), }; fs::write( @@ -901,7 +944,7 @@ fn ensure_module_matches_template( if !strict_asset_verification()? { #[cfg(feature = "bundled")] if runtime_tar_path().is_none() { - let expected = assets::expected_module_sha256("runtime:pglite")?; + let expected = assets::expected_module_sha256("runtime:oliphaunt")?; ensure!( expected.eq_ignore_ascii_case(&manifest.wasm_sha256), "embedded PGDATA template wasm hash mismatch: manifest={} assets={expected}", @@ -939,6 +982,8 @@ fn validated_embedded_pgdata_template_manifest() -> Result Result Result<()> { + let metadata = assets::asset_manifest_metadata()?; + let asset_source_lane = metadata + .source_lane + .as_deref() + .context("asset manifest is missing source-lane metadata")?; + let template_source_lane = manifest + .source_lane + .as_deref() + .context("embedded PGDATA template manifest is missing source-lane metadata")?; + ensure!( + template_source_lane == asset_source_lane, + "embedded PGDATA template source lane mismatch: template={} assets={asset_source_lane}", + template_source_lane + ); + if let Some(pgdata_source_lane) = metadata.pgdata_template_source_lane.as_deref() { + ensure!( + template_source_lane == pgdata_source_lane, + "embedded PGDATA template source lane mismatch: template={} asset-entry={pgdata_source_lane}", + template_source_lane + ); + } + + if let Some(expected) = metadata.pgdata_template_postgres_version.as_deref() { + ensure!( + manifest.postgres_version == expected, + "embedded PGDATA template PostgreSQL version mismatch: template={} asset-entry={expected}", + manifest.postgres_version + ); + } + + let expected_fingerprint = metadata + .pgdata_template_source_fingerprint + .as_deref() + .or(metadata.source_fingerprint.as_deref()); + if let Some(expected) = expected_fingerprint { + ensure!( + manifest.source_fingerprint.as_deref() == Some(expected), + "embedded PGDATA template source fingerprint mismatch: template={} assets={expected}", + manifest + .source_fingerprint + .as_deref() + .unwrap_or("") + ); + } + + Ok(()) +} + +#[cfg(not(feature = "bundled"))] +fn validate_pgdata_template_manifest_metadata(_manifest: &PgDataTemplateManifest) -> Result<()> { + Ok(()) +} + fn pgdata_template_cache() -> Result> { PGDATA_TEMPLATE_CACHE .get_or_init(|| { @@ -975,8 +1075,8 @@ fn build_pgdata_template_cache() -> Result { bail!("embedded PGDATA template archive is unavailable"); }; - let dirs = ProjectDirs::from("dev", "pglite-oxide", "pglite-oxide") - .context("could not resolve pglite-oxide cache directory")?; + let dirs = ProjectDirs::from("dev", "oliphaunt-wasix", "oliphaunt-wasix") + .context("could not resolve oliphaunt-wasix cache directory")?; let cache_root = dirs .cache_dir() .join("pgdata-template") @@ -1050,8 +1150,8 @@ fn build_extension_pgdata_template_cache( ensure_module_matches_template(module_path, &base_manifest)?; let manifest = extension_pgdata_template_manifest(&base_manifest, extensions, postgres_config)?; - let dirs = ProjectDirs::from("dev", "pglite-oxide", "pglite-oxide") - .context("could not resolve pglite-oxide cache directory")?; + let dirs = ProjectDirs::from("dev", "oliphaunt-wasix", "oliphaunt-wasix") + .context("could not resolve oliphaunt-wasix cache directory")?; let cache_root = dirs .cache_dir() .join("pgdata-extension-template") @@ -1086,7 +1186,7 @@ fn build_extension_pgdata_template_cache( let _ = fs::remove_dir_all(&staging_root); return Err(err); } - let staging_pgdata = PglitePaths::with_root(&staging_root).pgdata; + let staging_pgdata = OliphauntPaths::with_root(&staging_root).pgdata; validate_pgdata_template_dir(&staging_pgdata, &base_manifest)?; remove_template_runtime_state(&staging_pgdata)?; fs::rename(&staging_pgdata, &pgdata).with_context(|| { @@ -1125,7 +1225,7 @@ fn build_extension_pgdata_template_staging( fs::create_dir_all(staging_root) .with_context(|| format!("create build dir {}", staging_root.display()))?; - let paths = PglitePaths::with_root(staging_root); + let paths = OliphauntPaths::with_root(staging_root); let (runtime_layout, unpacked_runtime) = prepare_runtime_layout(&paths, RuntimeLayoutPolicy::FullLocal)?; let base_template = pgdata_template_cache()?; @@ -1138,7 +1238,7 @@ fn build_extension_pgdata_template_staging( runtime_layout, preinstalled_extensions: Vec::new(), }; - let mut db = Pglite::new_prepared_with_config( + let mut db = Oliphaunt::new_prepared_with_config( outcome, postgres_config.clone(), StartupConfig::default(), @@ -1189,9 +1289,19 @@ fn extension_pgdata_template_manifest( let postgres_config_entries = postgres_config.stable_entries(); let mut hasher = Sha256::new(); - hasher.update(b"pglite-oxide-extension-pgdata-template-v4-startup-config\n"); + hasher.update(b"oliphaunt-wasix-extension-pgdata-template-v5-source-metadata\n"); hasher.update(base_manifest.postgres_version.as_bytes()); hasher.update(b"\n"); + if let Some(source_lane) = &base_manifest.source_lane { + hasher.update(b"source-lane="); + hasher.update(source_lane.as_bytes()); + hasher.update(b"\n"); + } + if let Some(source_fingerprint) = &base_manifest.source_fingerprint { + hasher.update(b"source-fingerprint="); + hasher.update(source_fingerprint.as_bytes()); + hasher.update(b"\n"); + } hasher.update(base_manifest.archive_sha256.as_bytes()); hasher.update(b"\n"); hasher.update(base_manifest.wasm_sha256.as_bytes()); @@ -1214,8 +1324,10 @@ fn extension_pgdata_template_manifest( let cache_key = format!("{:x}", hasher.finalize()); Ok(ExtensionPgDataTemplateManifest { - version: 3, + version: 5, postgres_version: base_manifest.postgres_version.clone(), + source_lane: base_manifest.source_lane.clone(), + source_fingerprint: base_manifest.source_fingerprint.clone(), base_template_archive_sha256: base_manifest.archive_sha256.clone(), base_template_wasm_sha256: base_manifest.wasm_sha256.clone(), extension_sql_names, @@ -1313,12 +1425,83 @@ fn remove_template_runtime_state(pgdata: &Path) -> Result<()> { Ok(()) } -fn cluster_is_complete(paths: &PglitePaths) -> bool { +fn cluster_is_complete(paths: &OliphauntPaths) -> bool { (paths.marker_cluster().is_file() && paths.marker_control_file().is_file()) || pgdata_overlay_is_installed(paths) } -fn remove_interrupted_pgdata(paths: &PglitePaths) -> Result<()> { +fn ensure_existing_pgdata_matches_runtime(paths: &OliphauntPaths) -> Result<()> { + let Some(expected_major) = runtime_postgres_major()? else { + return Ok(()); + }; + ensure_pgdata_postgres_major_matches(paths, &expected_major) +} + +#[cfg(feature = "bundled")] +fn runtime_postgres_major() -> Result> { + if runtime_tar_path().is_some() { + return Ok(None); + } + let metadata = assets::asset_manifest_metadata()?; + Ok(Some(postgres_major_from_version( + &metadata.postgres_version, + ))) +} + +#[cfg(not(feature = "bundled"))] +fn runtime_postgres_major() -> Result> { + Ok(None) +} + +fn postgres_major_from_version(version: &str) -> String { + version + .trim() + .split('.') + .next() + .filter(|major| !major.is_empty()) + .unwrap_or(version.trim()) + .to_owned() +} + +fn ensure_pgdata_postgres_major_matches( + paths: &OliphauntPaths, + expected_major: &str, +) -> Result<()> { + ensure!( + !expected_major.trim().is_empty(), + "expected PostgreSQL major version must not be empty" + ); + + if let Some(overlay) = read_pgdata_overlay_manifest(paths)? { + let actual_major = postgres_major_from_version(&overlay.postgres_version); + ensure!( + actual_major == expected_major, + "existing PGDATA overlay at {} is PostgreSQL {}, but current Oliphaunt runtime is PostgreSQL {}; use a separate root or migrate the database before reusing this root", + paths.pgdata.display(), + actual_major, + expected_major + ); + return Ok(()); + } + + let pg_version_path = paths.marker_cluster(); + if !pg_version_path.is_file() { + return Ok(()); + } + let pg_version = fs::read_to_string(&pg_version_path) + .with_context(|| format!("read {}", pg_version_path.display()))?; + let actual_major = postgres_major_from_version(&pg_version); + ensure!( + actual_major == expected_major, + "existing PGDATA at {} is PostgreSQL {}, but current Oliphaunt runtime is PostgreSQL {}; use a separate root or migrate the database before reusing this root", + paths.pgdata.display(), + actual_major, + expected_major + ); + Ok(()) +} + +fn remove_interrupted_pgdata(paths: &OliphauntPaths) -> Result<()> { if paths.pgdata.exists() && !cluster_is_complete(paths) { fs::remove_dir_all(&paths.pgdata).with_context(|| { format!( @@ -1338,14 +1521,14 @@ fn tmp_suffix() -> u128 { } fn strict_asset_verification() -> Result { - let Some(value) = std::env::var_os("PGLITE_OXIDE_AOT_VERIFY") else { + let Some(value) = std::env::var_os("OLIPHAUNT_WASM_AOT_VERIFY") else { return Ok(false); }; let value = value.to_string_lossy().to_ascii_lowercase(); match value.as_str() { "" | "fast" | "metadata" | "receipt" | "0" | "false" | "off" => Ok(false), "full" | "sha" | "sha256" | "strict" | "1" | "true" | "on" => Ok(true), - other => bail!("unsupported PGLITE_OXIDE_AOT_VERIFY={other}; use `fast` or `full`"), + other => bail!("unsupported OLIPHAUNT_WASM_AOT_VERIFY={other}; use `fast` or `full`"), } } @@ -1360,26 +1543,26 @@ fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", hasher.finalize()) } -pub fn ensure_cluster(paths: &PglitePaths) -> Result<()> { +pub fn ensure_cluster(paths: &OliphauntPaths) -> Result<()> { ensure_cluster_with_template(paths, true) } -fn ensure_cluster_with_template(paths: &PglitePaths, use_template: bool) -> Result<()> { +fn ensure_cluster_with_template(paths: &OliphauntPaths, use_template: bool) -> Result<()> { let outcome = prepare_database_root(paths.clone(), prepare_options_for_template(use_template))?; let mut pg = PostgresMod::new_prepared(outcome.paths.clone(), outcome.runtime_layout.clone())?; pg.ensure_cluster() } -pub fn preload_runtime_module(paths: &PglitePaths) -> Result<()> { +pub fn preload_runtime_module(paths: &OliphauntPaths) -> Result<()> { let _ = paths; let cached_runtime = runtime_cache()?; - let module_path = cached_runtime.runtime_root.join("bin/pglite"); + let module_path = cached_runtime.runtime_root.join("bin/oliphaunt"); PostgresMod::preload_module(&module_path) } #[derive(Debug, Clone)] pub struct InstallOutcome { - pub paths: PglitePaths, + pub paths: OliphauntPaths, pub unpacked_runtime: bool, pub(crate) runtime_layout: RuntimeLayout, #[cfg_attr(not(feature = "extensions"), allow(dead_code))] @@ -1396,7 +1579,7 @@ impl InstallOutcome { } fn prepare_root_from_paths( - paths: PglitePaths, + paths: OliphauntPaths, root: PathBuf, temp_dir: Option, root_lock: Option, @@ -1414,7 +1597,7 @@ fn prepare_root_from_paths( pub(crate) fn prepare_root(plan: RootPlan) -> Result { let (paths, root, temp_dir, root_lock, temporary) = match plan.target { RootTarget::Path(root) => { - let paths = PglitePaths::with_root(&root); + let paths = OliphauntPaths::with_root(&root); let root_lock = RootLock::acquire(&root)?; (paths, root, None, Some(root_lock), false) } @@ -1423,7 +1606,7 @@ pub(crate) fn prepare_root(plan: RootPlan) -> Result { organization, application, } => { - let paths = PglitePaths::new(( + let paths = OliphauntPaths::new(( qualifier.as_str(), organization.as_str(), application.as_str(), @@ -1433,9 +1616,9 @@ pub(crate) fn prepare_root(plan: RootPlan) -> Result { (paths, root, None, Some(root_lock), false) } RootTarget::Temporary => { - let temp_dir = TempDir::new().context("create temporary pglite directory")?; + let temp_dir = TempDir::new().context("create temporary oliphaunt directory")?; let root = temp_dir.path().to_path_buf(); - let paths = PglitePaths::with_root(&root); + let paths = OliphauntPaths::with_root(&root); (paths, root, Some(temp_dir), None, true) } }; @@ -1450,7 +1633,7 @@ pub(crate) fn prepare_root(plan: RootPlan) -> Result { #[cfg(feature = "extensions")] { let mut prepared = prepared; - if temporary && use_template { + if temporary && use_template && extension_template_cache_enabled() { install_extension_template_into_outcome( &mut prepared.outcome, &plan.extensions, @@ -1469,7 +1652,7 @@ pub(crate) fn prepare_root(plan: RootPlan) -> Result { } fn prepare_root_from_data_dir_archive( - paths: PglitePaths, + paths: OliphauntPaths, root: PathBuf, temp_dir: Option, root_lock: Option, @@ -1490,6 +1673,7 @@ fn prepare_root_from_data_dir_archive( paths.marker_cluster().is_file() && paths.marker_control_file().is_file(), "loaded PGDATA archive did not contain PG_VERSION and global/pg_control" ); + ensure_existing_pgdata_matches_runtime(&paths)?; Ok(PreparedRoot { root, temp_dir, @@ -1514,7 +1698,7 @@ pub(crate) fn install_missing_extension_archives( } let bytes = assets::extension_archive(extension.sql_name()).ok_or_else(|| { anyhow!( - "extension asset '{}' is not bundled in this pglite-oxide build", + "extension asset '{}' is not bundled in this oliphaunt-wasix build", extension.sql_name() ) })?; @@ -1539,12 +1723,12 @@ impl Default for InstallOptions { #[derive(Debug, Clone)] pub struct MountInfo { mount: PathBuf, - paths: PglitePaths, + paths: OliphauntPaths, reused_existing: bool, } impl MountInfo { - pub fn into_paths(self) -> PglitePaths { + pub fn into_paths(self) -> OliphauntPaths { self.paths } @@ -1552,7 +1736,7 @@ impl MountInfo { &self.mount } - pub fn paths(&self) -> &PglitePaths { + pub fn paths(&self) -> &OliphauntPaths { &self.paths } @@ -1562,17 +1746,17 @@ impl MountInfo { } pub fn install_default(app_id: (&str, &str, &str)) -> Result { - let paths = PglitePaths::new(app_id)?; + let paths = OliphauntPaths::new(app_id)?; prepare_database_root(paths, RootPrepareOptions::template()) } pub fn install_into(root: &Path) -> Result { - let paths = PglitePaths::with_root(root); + let paths = OliphauntPaths::with_root(root); prepare_database_root(paths, RootPrepareOptions::template()) } pub(crate) fn prepare_database_root( - paths: PglitePaths, + paths: OliphauntPaths, options: RootPrepareOptions, ) -> Result { let (mut runtime_layout, unpacked_runtime) = prepare_runtime_layout(&paths, options.runtime)?; @@ -1586,7 +1770,7 @@ pub(crate) fn prepare_database_root( } fn prepare_pgdata( - paths: &PglitePaths, + paths: &OliphauntPaths, cluster_policy: ClusterPolicy, runtime_layout: &mut RuntimeLayout, ) -> Result<()> { @@ -1606,6 +1790,7 @@ fn prepare_pgdata( } } if cluster_is_complete(paths) { + ensure_existing_pgdata_matches_runtime(paths)?; remove_template_runtime_state(&paths.pgdata)?; return Ok(()); } @@ -1674,7 +1859,7 @@ pub fn install_and_init_in>(root: P) -> Result { }) } -pub fn install_with_options(paths: PglitePaths, options: InstallOptions) -> Result { +pub fn install_with_options(paths: OliphauntPaths, options: InstallOptions) -> Result { let outcome = prepare_database_root(paths, RootPrepareOptions::template())?; if options.ensure_cluster && !cluster_is_complete(&outcome.paths) { let mut pg = @@ -1708,7 +1893,7 @@ pub(crate) fn pgdata_overlay_enabled() -> bool { } fn prepare_runtime_layout( - paths: &PglitePaths, + paths: &OliphauntPaths, policy: RuntimeLayoutPolicy, ) -> Result<(RuntimeLayout, bool)> { match resolve_runtime_layout_kind(paths, policy)? { @@ -1754,7 +1939,7 @@ fn prepare_runtime_layout( } fn resolve_runtime_layout_kind( - paths: &PglitePaths, + paths: &OliphauntPaths, policy: RuntimeLayoutPolicy, ) -> Result { match policy { @@ -1819,12 +2004,12 @@ fn build_runtime_cache() -> Result { let _phase = timing::phase("runtime.cache_key"); runtime_cache_key()? }; - let dirs = ProjectDirs::from("dev", "pglite-oxide", "pglite-oxide") - .context("could not resolve pglite-oxide cache directory")?; + let dirs = ProjectDirs::from("dev", "oliphaunt-wasix", "oliphaunt-wasix") + .context("could not resolve oliphaunt-wasix cache directory")?; let cache_root = dirs.cache_dir().join("runtime"); let _cache_lock = CacheLock::acquire(&cache_root.join(".locks").join(format!("{key}.lock")))?; let root = cache_root.join(&key); - let paths = PglitePaths::with_root(root); + let paths = OliphauntPaths::with_root(root); { let _phase = timing::phase("runtime.cache_ensure_full"); ensure_full_runtime(&paths)?; @@ -1895,17 +2080,17 @@ fn ensure_runtime_password_file(runtime_root: &Path) -> Result<()> { } fn runtime_cache_key() -> Result { - if assets::runtime_archive().is_some() { - return embedded_runtime_archive_sha256(); - } if let Some(path) = runtime_tar_path() { if strict_asset_verification()? { return sha256_file(&path); } return file_metadata_cache_key(&path); } + if assets::runtime_archive().is_some() { + return embedded_runtime_archive_sha256(); + } bail!( - "no embedded PGlite runtime assets are available; enable the `bundled` feature or set PGLITE_OXIDE_RUNTIME_ARCHIVE" + "no embedded Oliphaunt runtime assets are available; enable the `bundled` feature or set OLIPHAUNT_WASM_RUNTIME_ARCHIVE" ) } @@ -1930,7 +2115,7 @@ fn file_metadata_cache_key(path: &Path) -> Result { Ok(format!("external-{}-{modified_nanos}", metadata.len())) } -fn prepare_shared_runtime_upper_root(src_runtime: &Path, paths: &PglitePaths) -> Result<()> { +fn prepare_shared_runtime_upper_root(src_runtime: &Path, paths: &OliphauntPaths) -> Result<()> { let _phase = timing::phase("runtime.mountfs_upper_root"); let dest_runtime = paths.runtime_root(); @@ -1993,320 +2178,10 @@ fn copy_runtime_file_if_exists(src: PathBuf, dest: PathBuf) -> Result<()> { Ok(()) } -#[cfg(test)] -fn copy_template_pgdata(template_root: &Path, dest_root: &Path) -> Result<()> { - let source_pgdata = template_root.join("tmp/pglite/base"); - clone_pgdata_template_dir(&source_pgdata, &dest_root.join("tmp/pglite/base")) -} - -fn clone_pgdata_template_dir(source_pgdata: &Path, dest_pgdata: &Path) -> Result<()> { - if try_clone_dir(source_pgdata, dest_pgdata)? { - return Ok(()); - } - copy_pgdata_template_dir_inner(source_pgdata, dest_pgdata) -} - -fn copy_pgdata_template_dir_inner(source_pgdata: &Path, dest_pgdata: &Path) -> Result<()> { - fs::create_dir_all(dest_pgdata) - .with_context(|| format!("create directory {}", dest_pgdata.display()))?; - - for entry in fs::read_dir(source_pgdata) - .with_context(|| format!("read directory {}", source_pgdata.display()))? - { - let entry = - entry.with_context(|| format!("read entry under {}", source_pgdata.display()))?; - let file_name = entry.file_name(); - if should_skip_template_entry(&file_name) { - continue; - } - - let src_path = entry.path(); - let dest_path = dest_pgdata.join(&file_name); - let file_type = entry - .file_type() - .with_context(|| format!("stat {}", src_path.display()))?; - - if file_type.is_dir() { - copy_pgdata_template_dir_inner(&src_path, &dest_path)?; - } else if file_type.is_file() { - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create directory {}", parent.display()))?; - } - clone_mutable_template_file(&src_path, &dest_path)?; - } else if file_type.is_symlink() { - copy_symlink(&src_path, &dest_path)?; - } - } - - Ok(()) -} - -fn clone_mutable_template_file(src: &Path, dest: &Path) -> Result<()> { - if std::env::var_os("PGLITE_OXIDE_TEMPLATE_REFLINK").is_some() && try_reflink_file(src, dest)? { - return Ok(()); - } - copy_template_file(src, dest) -} - -fn try_clone_dir(src: &Path, dest: &Path) -> Result { - if dest.exists() { - fs::remove_dir_all(dest).with_context(|| format!("remove {}", dest.display()))?; - } - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - } - - let status = clone_dir_command(src, dest); - match status { - Ok(status) if status.success() && dest.exists() => Ok(true), - Ok(_) | Err(_) => { - if dest.exists() { - fs::remove_dir_all(dest).with_context(|| { - format!("remove failed cloned directory {}", dest.display()) - })?; - } - Ok(false) - } - } -} - -#[cfg(target_os = "linux")] -fn clone_dir_command(src: &Path, dest: &Path) -> std::io::Result { - Command::new("cp") - .arg("-a") - .arg("--reflink=auto") - .arg("--") - .arg(src) - .arg(dest) - .status() -} - -#[cfg(target_os = "macos")] -fn clone_dir_command(src: &Path, dest: &Path) -> std::io::Result { - Command::new("cp").arg("-cR").arg(src).arg(dest).status() -} - -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -fn clone_dir_command(_src: &Path, _dest: &Path) -> std::io::Result { - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "directory clone is unsupported on this platform", - )) -} - -fn copy_template_file(src: &Path, dest: &Path) -> Result<()> { - fs::copy(src, dest).with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; - Ok(()) -} - -#[cfg(target_os = "linux")] -fn try_reflink_file(src: &Path, dest: &Path) -> Result { - let status = Command::new("cp") - .arg("--reflink=always") - .arg("--") - .arg(src) - .arg(dest) - .status(); - match status { - Ok(status) if status.success() && dest.exists() => Ok(true), - Ok(_) | Err(_) => { - let _ = fs::remove_file(dest); - Ok(false) - } - } -} - -#[cfg(target_os = "macos")] -fn try_reflink_file(src: &Path, dest: &Path) -> Result { - let status = Command::new("cp").arg("-c").arg(src).arg(dest).status(); - match status { - Ok(status) if status.success() && dest.exists() => Ok(true), - Ok(_) | Err(_) => { - let _ = fs::remove_file(dest); - Ok(false) - } - } -} - -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -fn try_reflink_file(_src: &Path, _dest: &Path) -> Result { - Ok(false) -} - -fn should_skip_template_entry(file_name: &OsStr) -> bool { - let name = file_name.to_string_lossy(); - name.starts_with(".s.PGSQL.") || TEMPLATE_RUNTIME_STATE_FILES.contains(&name.as_ref()) -} - -#[cfg(unix)] -fn copy_symlink(src: &Path, dest: &Path) -> Result<()> { - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create directory {}", parent.display()))?; - } - let target = fs::read_link(src).with_context(|| format!("read symlink {}", src.display()))?; - std::os::unix::fs::symlink(&target, dest) - .with_context(|| format!("create symlink {} -> {}", dest.display(), target.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -fn copy_symlink(src: &Path, dest: &Path) -> Result<()> { - let target = fs::read_link(src).with_context(|| format!("read symlink {}", src.display()))?; - let target_path = if target.is_absolute() { - target - } else { - src.parent().unwrap_or_else(|| Path::new(".")).join(target) - }; - - if target_path.is_dir() { - copy_pgdata_template_dir_inner(&target_path, dest) - } else { - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create directory {}", parent.display()))?; - } - fs::copy(&target_path, dest) - .with_context(|| format!("copy {} to {}", target_path.display(), dest.display()))?; - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn template_copy_keeps_cluster_files_and_skips_runtime_state() -> Result<()> { - let source = TempDir::new()?; - let pgdata = source.path().join("tmp/pglite/base"); - fs::create_dir_all(&pgdata)?; - fs::write(pgdata.join("PG_VERSION"), b"17\n")?; - fs::write(pgdata.join("postmaster.pid"), b"stale pid")?; - fs::write(pgdata.join("postmaster.opts"), b"stale opts")?; - fs::write(pgdata.join(".s.PGSQL.5432"), b"socket")?; - fs::write(pgdata.join(".s.PGSQL.5432.lock"), b"lock")?; - - let dest = TempDir::new()?; - let dest_pgdata = dest.path().join("tmp/pglite/base"); - copy_pgdata_template_dir_inner(&pgdata, &dest_pgdata)?; - - assert!( - dest_pgdata.join("PG_VERSION").exists(), - "destination entries: {}", - list_test_entries(dest.path())? - ); - assert!(!dest_pgdata.join("postmaster.pid").exists()); - assert!(!dest_pgdata.join("postmaster.opts").exists()); - assert!(!dest_pgdata.join(".s.PGSQL.5432").exists()); - assert!(!dest_pgdata.join(".s.PGSQL.5432.lock").exists()); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn template_clone_does_not_hardlink_mutable_pgdata_files() -> Result<()> { - use std::os::unix::fs::MetadataExt; - - let source = TempDir::new()?; - let pgdata = source.path().join("tmp/pglite/base"); - fs::create_dir_all(&pgdata)?; - fs::write(pgdata.join("PG_VERSION"), b"17\n")?; - - let dest = TempDir::new()?; - let dest_pgdata = dest.path().join("tmp/pglite/base"); - copy_pgdata_template_dir_inner(&pgdata, &dest_pgdata)?; - - let source_pg_version = pgdata.join("PG_VERSION"); - let dest_pg_version = dest_pgdata.join("PG_VERSION"); - assert!( - source_pg_version.exists(), - "source PG_VERSION should exist at {}", - source_pg_version.display() - ); - assert!( - dest_pg_version.exists(), - "cloned PG_VERSION should exist at {}; destination entries: {}", - dest_pg_version.display(), - list_test_entries(dest.path())? - ); - let source_meta = fs::metadata(&source_pg_version)?; - let dest_meta = fs::metadata(&dest_pg_version)?; - assert_ne!( - (source_meta.dev(), source_meta.ino()), - (dest_meta.dev(), dest_meta.ino()), - "mutable PGDATA template files must be copied or reflinked, not hardlinked" - ); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn fallback_template_pgdata_copy_does_not_hardlink_mutable_files() -> Result<()> { - use std::os::unix::fs::MetadataExt; - - let source = TempDir::new()?; - let pgdata = source.path().join("tmp/pglite/base"); - fs::create_dir_all(&pgdata)?; - fs::write(pgdata.join("PG_VERSION"), b"17\n")?; - - let dest = TempDir::new()?; - copy_template_pgdata(source.path(), dest.path())?; - - let source_pg_version = pgdata.join("PG_VERSION"); - let dest_pg_version = dest.path().join("tmp/pglite/base/PG_VERSION"); - assert!(dest_pg_version.exists()); - let source_meta = fs::metadata(&source_pg_version)?; - let dest_meta = fs::metadata(&dest_pg_version)?; - assert_ne!( - (source_meta.dev(), source_meta.ino()), - (dest_meta.dev(), dest_meta.ino()), - "fallback PGDATA template copy must not hardlink mutable files" - ); - Ok(()) - } - - #[test] - fn fallback_template_pgdata_copy_does_not_share_mutable_files() -> Result<()> { - let source = TempDir::new()?; - let pgdata = source.path().join("base"); - fs::create_dir_all(&pgdata)?; - fs::write(pgdata.join("PG_VERSION"), b"17\n")?; - - let dest = TempDir::new()?; - let cloned = dest.path().join("base"); - copy_pgdata_template_dir_inner(&pgdata, &cloned)?; - fs::write(cloned.join("PG_VERSION"), b"changed\n")?; - - assert_eq!( - fs::read(pgdata.join("PG_VERSION"))?, - b"17\n", - "fallback PGDATA template copy must not share mutable file storage with the source" - ); - Ok(()) - } - - fn list_test_entries(root: &Path) -> Result { - let mut entries = Vec::new(); - collect_test_entries(root, root, &mut entries)?; - entries.sort(); - Ok(entries.join(", ")) - } - - fn collect_test_entries(root: &Path, current: &Path, entries: &mut Vec) -> Result<()> { - for entry in fs::read_dir(current)? { - let entry = entry?; - let path = entry.path(); - let relative = path.strip_prefix(root).unwrap_or(&path); - entries.push(relative.display().to_string()); - if entry.file_type()?.is_dir() { - collect_test_entries(root, &path, entries)?; - } - } - Ok(()) - } - #[cfg(feature = "extensions")] #[test] fn embedded_pgdata_template_installs_valid_cluster() -> Result<()> { @@ -2315,7 +2190,7 @@ mod tests { } let temp_dir = TempDir::new()?; - let paths = PglitePaths::with_root(temp_dir.path()); + let paths = OliphauntPaths::with_root(temp_dir.path()); ensure_full_runtime(&paths)?; let (module_path, _) = @@ -2336,7 +2211,7 @@ mod tests { } let temp_dir = TempDir::new()?; - let paths = PglitePaths::with_root(temp_dir.path()); + let paths = OliphauntPaths::with_root(temp_dir.path()); ensure_full_runtime(&paths)?; fs::create_dir_all(paths.pgdata.join("global"))?; fs::write(paths.pgdata.join("postmaster.pid"), b"stale pid")?; @@ -2365,7 +2240,7 @@ mod tests { return Ok(()); } let temp_dir = TempDir::new()?; - let paths = PglitePaths::with_root(temp_dir.path()); + let paths = OliphauntPaths::with_root(temp_dir.path()); fs::create_dir_all(&paths.pgdata)?; fs::write(paths.pgdata.join("postmaster.pid"), b"stale pid")?; fs::write(paths.pgdata.join("partial"), b"interrupted initdb")?; @@ -2389,7 +2264,7 @@ mod tests { return Ok(()); } let temp_dir = TempDir::new()?; - let paths = PglitePaths::with_root(temp_dir.path()); + let paths = OliphauntPaths::with_root(temp_dir.path()); fs::create_dir_all(&paths.pgdata)?; fs::write(paths.pgdata.join("PG_VERSION"), b"17\n")?; fs::write( @@ -2412,11 +2287,11 @@ mod tests { fn root_lock_is_exclusive_until_dropped() -> Result<()> { let temp_dir = TempDir::new()?; let first = RootLock::acquire(temp_dir.path())?; - assert!(temp_dir.path().join(".pglite-oxide.lock").exists()); + assert!(temp_dir.path().join(".oliphaunt-wasix.lock").exists()); let err = RootLock::acquire(temp_dir.path()).expect_err("second root lock should be rejected"); - assert!(format!("{err:#}").contains("PGlite root is already in use")); + assert!(format!("{err:#}").contains("Oliphaunt root is already in use")); drop(first); let _second = RootLock::acquire(temp_dir.path())?; @@ -2430,6 +2305,73 @@ mod tests { assert!(err.to_string().contains("unsafe archive path")); } + #[test] + fn pgdata_major_guard_accepts_same_major_cluster() -> Result<()> { + let temp_dir = TempDir::new()?; + let paths = OliphauntPaths::with_root(temp_dir.path()); + fs::create_dir_all(paths.pgdata.join("global"))?; + fs::write(paths.pgdata.join("PG_VERSION"), b"18\n")?; + fs::write(paths.pgdata.join("global/pg_control"), b"control")?; + + ensure_pgdata_postgres_major_matches(&paths, "18")?; + Ok(()) + } + + #[test] + fn pgdata_major_guard_rejects_cross_major_cluster() -> Result<()> { + let temp_dir = TempDir::new()?; + let paths = OliphauntPaths::with_root(temp_dir.path()); + fs::create_dir_all(paths.pgdata.join("global"))?; + fs::write(paths.pgdata.join("PG_VERSION"), b"17\n")?; + fs::write(paths.pgdata.join("global/pg_control"), b"control")?; + + let err = ensure_pgdata_postgres_major_matches(&paths, "18") + .expect_err("cross-major PGDATA must be rejected"); + assert!( + format!("{err:#}").contains("existing PGDATA") + && format!("{err:#}").contains("PostgreSQL 17") + && format!("{err:#}").contains("PostgreSQL 18"), + "unexpected error: {err:#}" + ); + Ok(()) + } + + #[test] + fn pgdata_major_guard_rejects_cross_major_overlay() -> Result<()> { + let temp_dir = TempDir::new()?; + let paths = OliphauntPaths::with_root(temp_dir.path()); + fs::create_dir_all(&paths.pgdata)?; + fs::write( + pgdata_overlay_manifest_path(&paths), + br#"{ + "templateArchiveSha256": "old-template", + "postgresVersion": "17", + "extensionSqlNames": [] + }"#, + )?; + + let err = ensure_pgdata_postgres_major_matches(&paths, "18") + .expect_err("cross-major PGDATA overlay must be rejected"); + assert!( + format!("{err:#}").contains("existing PGDATA overlay") + && format!("{err:#}").contains("PostgreSQL 17") + && format!("{err:#}").contains("PostgreSQL 18"), + "unexpected error: {err:#}" + ); + Ok(()) + } + + #[test] + fn full_runtime_layout_requires_current_source_key() -> Result<()> { + let temp_dir = TempDir::new()?; + let paths = OliphauntPaths::with_root(temp_dir.path()); + write_runtime_layout_manifest(&paths.runtime_root(), RuntimeLayoutKind::FullLocal, "old")?; + + assert!(!full_runtime_layout_matches_current(&paths, "new")?); + assert!(full_runtime_layout_matches_current(&paths, "old")?); + Ok(()) + } + fn tar_bytes_with_entry(path: &[u8], entry_type: u8, body: &[u8], link_name: &[u8]) -> Vec { let mut header = [0u8; 512]; header[..path.len()].copy_from_slice(path); @@ -2462,7 +2404,7 @@ mod tests { fn extension_archive_rejects_parent_components() -> Result<()> { let bytes = tar_bytes_with_entry(b"../escape", b'0', b"nope", b""); let temp_dir = TempDir::new()?; - let paths = PglitePaths::with_root(temp_dir.path()); + let paths = OliphauntPaths::with_root(temp_dir.path()); let err = install_extension_bytes(&paths, &bytes).expect_err("unsafe archive must fail"); assert!(err.to_string().contains("unpack extension")); Ok(()) @@ -2477,7 +2419,7 @@ mod tests { b"/tmp/attacker-owned-vector.so", ); let temp_dir = TempDir::new()?; - let paths = PglitePaths::with_root(temp_dir.path()); + let paths = OliphauntPaths::with_root(temp_dir.path()); let err = install_extension_bytes(&paths, &bytes).expect_err("symlink archive must fail"); assert!( err.chain() diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs new file mode 100644 index 00000000..d3400b47 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs @@ -0,0 +1,326 @@ +use std::ffi::OsStr; +use std::fs; +use std::path::Path; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::process::Command; + +use anyhow::{Context, Result}; + +use super::TEMPLATE_RUNTIME_STATE_FILES; + +pub(super) fn clone_pgdata_template_dir(source_pgdata: &Path, dest_pgdata: &Path) -> Result<()> { + if try_clone_dir(source_pgdata, dest_pgdata)? { + return Ok(()); + } + copy_pgdata_template_dir_inner(source_pgdata, dest_pgdata) +} + +fn copy_pgdata_template_dir_inner(source_pgdata: &Path, dest_pgdata: &Path) -> Result<()> { + fs::create_dir_all(dest_pgdata) + .with_context(|| format!("create directory {}", dest_pgdata.display()))?; + + for entry in fs::read_dir(source_pgdata) + .with_context(|| format!("read directory {}", source_pgdata.display()))? + { + let entry = + entry.with_context(|| format!("read entry under {}", source_pgdata.display()))?; + let file_name = entry.file_name(); + if should_skip_template_entry(&file_name) { + continue; + } + + let src_path = entry.path(); + let dest_path = dest_pgdata.join(&file_name); + let file_type = entry + .file_type() + .with_context(|| format!("stat {}", src_path.display()))?; + + if file_type.is_dir() { + copy_pgdata_template_dir_inner(&src_path, &dest_path)?; + } else if file_type.is_file() { + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create directory {}", parent.display()))?; + } + clone_mutable_template_file(&src_path, &dest_path)?; + } else if file_type.is_symlink() { + copy_symlink(&src_path, &dest_path)?; + } + } + + Ok(()) +} + +fn clone_mutable_template_file(src: &Path, dest: &Path) -> Result<()> { + if std::env::var_os("OLIPHAUNT_WASM_TEMPLATE_REFLINK").is_some() && try_reflink_file(src, dest)? + { + return Ok(()); + } + copy_template_file(src, dest) +} + +fn try_clone_dir(src: &Path, dest: &Path) -> Result { + if dest.exists() { + fs::remove_dir_all(dest).with_context(|| format!("remove {}", dest.display()))?; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + + let status = clone_dir_command(src, dest); + match status { + Ok(status) if status.success() && dest.exists() => Ok(true), + Ok(_) | Err(_) => { + if dest.exists() { + fs::remove_dir_all(dest).with_context(|| { + format!("remove failed cloned directory {}", dest.display()) + })?; + } + Ok(false) + } + } +} + +#[cfg(target_os = "linux")] +fn clone_dir_command(src: &Path, dest: &Path) -> std::io::Result { + Command::new("cp") + .arg("-a") + .arg("--reflink=auto") + .arg("--") + .arg(src) + .arg(dest) + .status() +} + +#[cfg(target_os = "macos")] +fn clone_dir_command(src: &Path, dest: &Path) -> std::io::Result { + Command::new("cp").arg("-cR").arg(src).arg(dest).status() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn clone_dir_command(_src: &Path, _dest: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "directory clone is unsupported on this platform", + )) +} + +fn copy_template_file(src: &Path, dest: &Path) -> Result<()> { + fs::copy(src, dest).with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn try_reflink_file(src: &Path, dest: &Path) -> Result { + let status = Command::new("cp") + .arg("--reflink=always") + .arg("--") + .arg(src) + .arg(dest) + .status(); + match status { + Ok(status) if status.success() && dest.exists() => Ok(true), + Ok(_) | Err(_) => { + let _ = fs::remove_file(dest); + Ok(false) + } + } +} + +#[cfg(target_os = "macos")] +fn try_reflink_file(src: &Path, dest: &Path) -> Result { + let status = Command::new("cp").arg("-c").arg(src).arg(dest).status(); + match status { + Ok(status) if status.success() && dest.exists() => Ok(true), + Ok(_) | Err(_) => { + let _ = fs::remove_file(dest); + Ok(false) + } + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn try_reflink_file(_src: &Path, _dest: &Path) -> Result { + Ok(false) +} + +fn should_skip_template_entry(file_name: &OsStr) -> bool { + let name = file_name.to_string_lossy(); + name.starts_with(".s.PGSQL.") || TEMPLATE_RUNTIME_STATE_FILES.contains(&name.as_ref()) +} + +#[cfg(unix)] +fn copy_symlink(src: &Path, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create directory {}", parent.display()))?; + } + let target = fs::read_link(src).with_context(|| format!("read symlink {}", src.display()))?; + std::os::unix::fs::symlink(&target, dest) + .with_context(|| format!("create symlink {} -> {}", dest.display(), target.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +fn copy_symlink(src: &Path, dest: &Path) -> Result<()> { + let target = fs::read_link(src).with_context(|| format!("read symlink {}", src.display()))?; + let target_path = if target.is_absolute() { + target + } else { + src.parent().unwrap_or_else(|| Path::new(".")).join(target) + }; + + if target_path.is_dir() { + copy_pgdata_template_dir_inner(&target_path, dest) + } else { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create directory {}", parent.display()))?; + } + fs::copy(&target_path, dest) + .with_context(|| format!("copy {} to {}", target_path.display(), dest.display()))?; + Ok(()) + } +} + +#[cfg(test)] +fn copy_template_pgdata(template_root: &Path, dest_root: &Path) -> Result<()> { + let source_pgdata = template_root.join("tmp/oliphaunt/base"); + clone_pgdata_template_dir(&source_pgdata, &dest_root.join("tmp/oliphaunt/base")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn template_copy_keeps_cluster_files_and_skips_runtime_state() -> Result<()> { + let source = TempDir::new()?; + let pgdata = source.path().join("tmp/oliphaunt/base"); + fs::create_dir_all(&pgdata)?; + fs::write(pgdata.join("PG_VERSION"), b"17\n")?; + fs::write(pgdata.join("postmaster.pid"), b"stale pid")?; + fs::write(pgdata.join("postmaster.opts"), b"stale opts")?; + fs::write(pgdata.join(".s.PGSQL.5432"), b"socket")?; + fs::write(pgdata.join(".s.PGSQL.5432.lock"), b"lock")?; + + let dest = TempDir::new()?; + let dest_pgdata = dest.path().join("tmp/oliphaunt/base"); + copy_pgdata_template_dir_inner(&pgdata, &dest_pgdata)?; + + assert!( + dest_pgdata.join("PG_VERSION").exists(), + "destination entries: {}", + list_test_entries(dest.path())? + ); + assert!(!dest_pgdata.join("postmaster.pid").exists()); + assert!(!dest_pgdata.join("postmaster.opts").exists()); + assert!(!dest_pgdata.join(".s.PGSQL.5432").exists()); + assert!(!dest_pgdata.join(".s.PGSQL.5432.lock").exists()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn template_clone_does_not_hardlink_mutable_pgdata_files() -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let source = TempDir::new()?; + let pgdata = source.path().join("tmp/oliphaunt/base"); + fs::create_dir_all(&pgdata)?; + fs::write(pgdata.join("PG_VERSION"), b"17\n")?; + + let dest = TempDir::new()?; + let dest_pgdata = dest.path().join("tmp/oliphaunt/base"); + copy_pgdata_template_dir_inner(&pgdata, &dest_pgdata)?; + + let source_pg_version = pgdata.join("PG_VERSION"); + let dest_pg_version = dest_pgdata.join("PG_VERSION"); + assert!( + source_pg_version.exists(), + "source PG_VERSION should exist at {}", + source_pg_version.display() + ); + assert!( + dest_pg_version.exists(), + "cloned PG_VERSION should exist at {}; destination entries: {}", + dest_pg_version.display(), + list_test_entries(dest.path())? + ); + let source_meta = fs::metadata(&source_pg_version)?; + let dest_meta = fs::metadata(&dest_pg_version)?; + assert_ne!( + (source_meta.dev(), source_meta.ino()), + (dest_meta.dev(), dest_meta.ino()), + "mutable PGDATA template files must be copied or reflinked, not hardlinked" + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn fallback_template_pgdata_copy_does_not_hardlink_mutable_files() -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let source = TempDir::new()?; + let pgdata = source.path().join("tmp/oliphaunt/base"); + fs::create_dir_all(&pgdata)?; + fs::write(pgdata.join("PG_VERSION"), b"17\n")?; + + let dest = TempDir::new()?; + copy_template_pgdata(source.path(), dest.path())?; + + let source_pg_version = pgdata.join("PG_VERSION"); + let dest_pg_version = dest.path().join("tmp/oliphaunt/base/PG_VERSION"); + assert!(dest_pg_version.exists()); + let source_meta = fs::metadata(&source_pg_version)?; + let dest_meta = fs::metadata(&dest_pg_version)?; + assert_ne!( + (source_meta.dev(), source_meta.ino()), + (dest_meta.dev(), dest_meta.ino()), + "fallback PGDATA template copy must not hardlink mutable files" + ); + Ok(()) + } + + #[test] + fn fallback_template_pgdata_copy_does_not_share_mutable_files() -> Result<()> { + let source = TempDir::new()?; + let pgdata = source.path().join("base"); + fs::create_dir_all(&pgdata)?; + fs::write(pgdata.join("PG_VERSION"), b"17\n")?; + + let dest = TempDir::new()?; + let cloned = dest.path().join("base"); + copy_pgdata_template_dir_inner(&pgdata, &cloned)?; + fs::write(cloned.join("PG_VERSION"), b"changed\n")?; + + assert_eq!( + fs::read(pgdata.join("PG_VERSION"))?, + b"17\n", + "fallback PGDATA template copy must not share mutable file storage with the source" + ); + Ok(()) + } + + fn list_test_entries(root: &Path) -> Result { + let mut entries = Vec::new(); + collect_test_entries(root, root, &mut entries)?; + entries.sort(); + Ok(entries.join(", ")) + } + + fn collect_test_entries(root: &Path, current: &Path, entries: &mut Vec) -> Result<()> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let relative = path.strip_prefix(root).unwrap_or(&path); + entries.push(relative.display().to_string()); + if entry.file_type()?.is_dir() { + collect_test_entries(root, &path, entries)?; + } + } + Ok(()) + } +} diff --git a/src/pglite/builder.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/builder.rs similarity index 86% rename from src/pglite/builder.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/builder.rs index f9ed32a9..fbe8f26d 100644 --- a/src/pglite/builder.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/builder.rs @@ -2,17 +2,17 @@ use std::path::PathBuf; use anyhow::{Result, bail}; -use crate::pglite::base::{PreparedRoot, RootPlan, RootSource, RootTarget, prepare_root}; -use crate::pglite::client::Pglite; -use crate::pglite::config::{PostgresConfig, StartupConfig}; +use crate::oliphaunt::base::{PreparedRoot, RootPlan, RootSource, RootTarget, prepare_root}; +use crate::oliphaunt::client::Oliphaunt; +use crate::oliphaunt::config::{PostgresConfig, StartupConfig}; #[cfg(feature = "extensions")] -use crate::pglite::extensions::{Extension, resolve_extension_set}; -use crate::pglite::interface::DebugLevel; +use crate::oliphaunt::extensions::{Extension, resolve_extension_set}; +use crate::oliphaunt::interface::DebugLevel; -/// Builder for opening persistent or temporary [`Pglite`] databases. +/// Builder for opening persistent or temporary [`Oliphaunt`] databases. #[derive(Debug, Clone)] -pub struct PgliteBuilder { - target: Option, +pub struct OliphauntBuilder { + target: Option, template_cache: bool, postgres_config: PostgresConfig, startup_config: StartupConfig, @@ -22,7 +22,7 @@ pub struct PgliteBuilder { } #[derive(Debug, Clone)] -enum PgliteTarget { +enum OliphauntTarget { Path(PathBuf), AppId { qualifier: String, @@ -32,7 +32,7 @@ enum PgliteTarget { Temporary, } -impl Default for PgliteBuilder { +impl Default for OliphauntBuilder { fn default() -> Self { Self { target: None, @@ -46,7 +46,7 @@ impl Default for PgliteBuilder { } } -impl PgliteBuilder { +impl OliphauntBuilder { /// Create a builder. Call [`path`](Self::path), [`app_id`](Self::app_id), /// or [`temporary`](Self::temporary) before [`open`](Self::open). pub fn new() -> Self { @@ -55,7 +55,7 @@ impl PgliteBuilder { /// Open a persistent database rooted at `root`. pub fn path(mut self, root: impl Into) -> Self { - self.target = Some(PgliteTarget::Path(root.into())); + self.target = Some(OliphauntTarget::Path(root.into())); self } @@ -66,7 +66,7 @@ impl PgliteBuilder { organization: impl Into, application: impl Into, ) -> Self { - self.target = Some(PgliteTarget::AppId { + self.target = Some(OliphauntTarget::AppId { qualifier: qualifier.into(), organization: organization.into(), application: application.into(), @@ -84,7 +84,7 @@ impl PgliteBuilder { /// Temporary databases use the process-local template cluster cache by /// default, avoiding repeated `initdb` work in test suites. pub fn temporary(mut self) -> Self { - self.target = Some(PgliteTarget::Temporary); + self.target = Some(OliphauntTarget::Temporary); self } @@ -186,12 +186,12 @@ impl PgliteBuilder { } /// Install, initialize, and start the selected database. - pub fn open(self) -> Result { + pub fn open(self) -> Result { self.postgres_config.validate()?; self.startup_config.validate()?; let target = match self.target.clone() { - Some(PgliteTarget::Path(root)) => RootTarget::Path(root), - Some(PgliteTarget::AppId { + Some(OliphauntTarget::Path(root)) => RootTarget::Path(root), + Some(OliphauntTarget::AppId { qualifier, organization, application, @@ -200,10 +200,10 @@ impl PgliteBuilder { organization, application, }, - Some(PgliteTarget::Temporary) => RootTarget::Temporary, + Some(OliphauntTarget::Temporary) => RootTarget::Temporary, None => { bail!( - "PgliteBuilder target is not set; call path, app_id, or temporary before open" + "OliphauntBuilder target is not set; call path, app_id, or temporary before open" ) } }; @@ -234,7 +234,7 @@ impl PgliteBuilder { self, prepared: PreparedRoot, #[cfg(feature = "extensions")] extensions: Vec, - ) -> Result { + ) -> Result { let PreparedRoot { temp_dir, root_lock, @@ -243,8 +243,11 @@ impl PgliteBuilder { } = prepared; #[cfg(feature = "extensions")] let preinstalled_extensions = outcome.preinstalled_extensions.clone(); - let mut instance = - Pglite::new_prepared_with_config(outcome, self.postgres_config, self.startup_config)?; + let mut instance = Oliphaunt::new_prepared_with_config( + outcome, + self.postgres_config, + self.startup_config, + )?; if let Some(lock) = root_lock { instance.attach_root_lock(lock); } diff --git a/src/pglite/client.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs similarity index 84% rename from src/pglite/client.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs index 4c7b7cb1..4ef5f95d 100644 --- a/src/pglite/client.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/client.rs @@ -16,36 +16,40 @@ use wasmer_wasix::virtual_net::VirtualTcpSocket; #[cfg(feature = "extensions")] use wasmer_wasix::virtual_net::tcp_pair::TcpSocketHalfRx; -use crate::pglite::aot; +use crate::oliphaunt::aot; #[cfg(feature = "extensions")] -use crate::pglite::assets; -use crate::pglite::backend::{BackendOpenKind, BackendSession}; +use crate::oliphaunt::assets; +use crate::oliphaunt::backend::{BackendOpenKind, BackendSession}; #[cfg(feature = "extensions")] -use crate::pglite::base::install_bundled_extension_bytes; -use crate::pglite::base::{InstallOutcome, PglitePaths, RootLock}; -use crate::pglite::builder::PgliteBuilder; -use crate::pglite::config::{PostgresConfig, StartupConfig}; -use crate::pglite::data_dir::{DataDirArchiveFormat, dump_pgdata_archive}; -use crate::pglite::errors::PgliteError; +use crate::oliphaunt::base::install_bundled_extension_bytes; +use crate::oliphaunt::base::{InstallOutcome, OliphauntPaths, RootLock}; +use crate::oliphaunt::builder::OliphauntBuilder; +use crate::oliphaunt::config::{PostgresConfig, StartupConfig}; +use crate::oliphaunt::data_dir::{DataDirArchiveFormat, dump_pgdata_archive}; +use crate::oliphaunt::engine::EngineCapabilities; +use crate::oliphaunt::errors::OliphauntError; #[cfg(feature = "extensions")] -use crate::pglite::extensions::{ +use crate::oliphaunt::extensions::{ Extension, by_sql_name, extension_session_setup_sql, extension_setup_sql, resolve_extension_set, }; -use crate::pglite::interface::{ +use crate::oliphaunt::interface::{ DataTransferContainer, DescribeQueryParam, DescribeQueryResult, DescribeResultField, - ExecProtocolOptions, ExecProtocolResult, ParserMap, QueryOptions, Results, SerializerMap, + ExecProtocolOptions, ExecProtocolResult, NoticeCallback, ParserMap, QueryOptions, Results, + SerializerMap, +}; +use crate::oliphaunt::parse::{ + command_tag_row_count, parse_describe_statement_results, parse_results, }; -use crate::pglite::parse::{parse_describe_statement_results, parse_results}; #[cfg(feature = "extensions")] -use crate::pglite::pg_dump::{PgDumpOptions, PgDumpVirtualSocket, dump_direct_sql}; +use crate::oliphaunt::pg_dump::{PgDumpOptions, PgDumpVirtualSocket, dump_direct_sql}; #[cfg(feature = "extensions")] -use crate::pglite::postgres_mod::PostgresMod; -use crate::pglite::timing; -use crate::pglite::types::{ +use crate::oliphaunt::postgres_mod::PostgresMod; +use crate::oliphaunt::timing; +use crate::oliphaunt::types::{ ArrayTypeInfo, DEFAULT_PARSERS, DEFAULT_SERIALIZERS, TEXT, register_array_type, }; #[cfg(feature = "extensions")] -use crate::pglite::wire::{FrontendFrameKind, FrontendFrameReader, classify_frontend_message}; +use crate::oliphaunt::wire::{FrontendFrameKind, FrontendFrameReader, classify_frontend_message}; use crate::protocol::messages::{BackendMessage, DatabaseError}; use crate::protocol::parser::Parser as ProtocolParser; use crate::protocol::serializer::{BindConfig, BindValue, PortalTarget, Serialize}; @@ -91,8 +95,13 @@ struct GlobalListener { callback: GlobalCallback, } +enum ExecTransportResult { + CommandOnly(Vec), + Raw(Vec), +} + /// Primary entry point for interacting with the embedded Postgres runtime. -pub struct Pglite { +pub struct Oliphaunt { backend: BackendSession, _temp_dir: Option, _root_lock: Option, @@ -111,23 +120,23 @@ pub struct Pglite { next_global_listener_id: u64, } -impl Pglite { - /// Create a builder for opening persistent or temporary PGlite databases. - pub fn builder() -> PgliteBuilder { - PgliteBuilder::new() +impl Oliphaunt { + /// Create a builder for opening persistent or temporary Oliphaunt databases. + pub fn builder() -> OliphauntBuilder { + OliphauntBuilder::new() } - /// Open a persistent PGlite database rooted at `root`, installing and initializing it if needed. + /// Open a persistent Oliphaunt database rooted at `root`, installing and initializing it if needed. pub fn open(root: impl AsRef) -> Result { Self::builder().path(root.as_ref().to_path_buf()).open() } - /// Open a persistent PGlite database under the platform data directory for `app_id`. + /// Open a persistent Oliphaunt database under the platform data directory for `app_id`. pub fn open_app(app_id: (&str, &str, &str)) -> Result { Self::builder().app_id(app_id).open() } - /// Create an ephemeral PGlite database whose files are removed when the instance is dropped. + /// Create an ephemeral Oliphaunt database whose files are removed when the instance is dropped. pub fn temporary() -> Result { Self::builder().temporary().open() } @@ -136,11 +145,11 @@ impl Pglite { pub fn preload() -> Result<()> { let (temp_dir, paths) = { let _phase = timing::phase("preload.tempdir"); - PglitePaths::with_temp_dir()? + OliphauntPaths::with_temp_dir()? }; { let _phase = timing::phase("preload.runtime_module"); - crate::pglite::base::preload_runtime_module(&paths)?; + crate::oliphaunt::base::preload_runtime_module(&paths)?; } { let _phase = timing::phase("preload.aot_runtime"); @@ -158,17 +167,17 @@ impl Pglite { for extension in resolve_extension_set(&extensions)? { let bytes = assets::extension_archive(extension.sql_name()).ok_or_else(|| { anyhow!( - "extension asset '{}' is not bundled in this pglite-oxide build", + "extension asset '{}' is not bundled in this oliphaunt-wasix build", extension.sql_name() ) })?; let (temp_dir, paths) = { let _phase = timing::phase("preload.extension_tempdir"); - PglitePaths::with_temp_dir()? + OliphauntPaths::with_temp_dir()? }; { let _phase = timing::phase("preload.extension_runtime_module"); - crate::pglite::base::preload_runtime_module(&paths)?; + crate::oliphaunt::base::preload_runtime_module(&paths)?; } { let _phase = timing::phase("preload.extension_archive_install"); @@ -187,12 +196,12 @@ impl Pglite { Ok(()) } - /// Create a new Pglite instance backed by the provided runtime paths. + /// Create a new Oliphaunt instance backed by the provided runtime paths. #[doc(hidden)] - pub fn new(paths: PglitePaths) -> Result { - let outcome = crate::pglite::base::prepare_database_root( + pub fn new(paths: OliphauntPaths) -> Result { + let outcome = crate::oliphaunt::base::prepare_database_root( paths, - crate::pglite::base::RootPrepareOptions::template(), + crate::oliphaunt::base::RootPrepareOptions::template(), )?; Self::new_prepared(outcome) } @@ -206,7 +215,7 @@ impl Pglite { postgres_config: PostgresConfig, startup_config: StartupConfig, ) -> Result { - let _phase = timing::phase("pglite.open"); + let _phase = timing::phase("oliphaunt.open"); let session_startup_config = startup_config.clone(); let backend = BackendSession::open( outcome, @@ -216,7 +225,7 @@ impl Pglite { )?; let mut instance = { - let _phase = timing::phase("pglite.client_struct_init"); + let _phase = timing::phase("oliphaunt.client_struct_init"); Self { backend, _temp_dir: None, @@ -240,7 +249,7 @@ impl Pglite { if session_startup_config.username != "postgres" { let sql = format!( "SET ROLE {}", - crate::pglite::templating::quote_identifier(&session_startup_config.username) + crate::oliphaunt::templating::quote_identifier(&session_startup_config.username) ); instance .exec(&sql, None) @@ -256,7 +265,7 @@ impl Pglite { let _phase = timing::phase("extension.enable"); let bytes = assets::extension_archive(extension.sql_name()).ok_or_else(|| { anyhow!( - "extension asset '{}' is not bundled in this pglite-oxide build", + "extension asset '{}' is not bundled in this oliphaunt-wasix build", extension.sql_name() ) })?; @@ -280,7 +289,7 @@ impl Pglite { /// Refresh direct API array parser and serializer registrations. /// - /// This mirrors upstream PGlite's `refreshArrayTypes()` escape hatch. Most + /// This mirrors upstream Oliphaunt's `refreshArrayTypes()` escape hatch. Most /// applications should not need it because built-in arrays are registered /// statically and runtime custom arrays are discovered lazily when possible. pub fn refresh_array_types(&mut self) -> Result<()> { @@ -368,7 +377,8 @@ impl Pglite { if let Err(err) = result { match err.downcast::() { Ok(db_err) => { - let enriched = PgliteError::new(db_err, sql, params_snapshot, options_snapshot); + let enriched = + OliphauntError::new(db_err, sql, params_snapshot, options_snapshot); return Err(enriched.into()); } Err(err) => { @@ -388,9 +398,14 @@ impl Pglite { self.ready && !self.closing && !self.closed } + /// Return the capabilities of the active embedded engine. + pub fn engine_capabilities(&self) -> EngineCapabilities { + self.backend.capabilities() + } + /// Return the host-side runtime and data-directory paths backing this instance. #[doc(hidden)] - pub fn paths(&self) -> &PglitePaths { + pub fn paths(&self) -> &OliphauntPaths { self.backend.paths() } @@ -403,8 +418,8 @@ impl Pglite { /// Dump the physical PGDATA directory to a gzipped tar archive. /// - /// The archive is intended to be loaded back into pglite-oxide/PGlite with - /// the same PostgreSQL/PGlite version. Use [`dump_sql`](Self::dump_sql) for + /// The archive is intended to be loaded back into oliphaunt-wasix/Oliphaunt with + /// the same PostgreSQL/Oliphaunt version. Use [`dump_sql`](Self::dump_sql) for /// logical backups across versions. pub fn dump_data_dir(&mut self) -> Result> { self.dump_data_dir_with_format(DataDirArchiveFormat::TarGz) @@ -416,7 +431,7 @@ impl Pglite { self.archive_quiesced_pgdata("dump PGDATA archive", format) } - /// Clone this database into a new temporary [`Pglite`] instance. + /// Clone this database into a new temporary [`Oliphaunt`] instance. pub fn try_clone(&mut self) -> Result { #[cfg(feature = "extensions")] let extensions = self.bundled_extensions_in_database()?; @@ -496,7 +511,7 @@ impl Pglite { if username != "postgres" { let sql = format!( "SET ROLE {}", - crate::pglite::templating::quote_identifier(&username) + crate::oliphaunt::templating::quote_identifier(&username) ); self.exec(&sql, None).with_context(|| { format!("restore startup role {username} after backend restart") @@ -510,7 +525,7 @@ impl Pglite { .map(|(channel, _)| channel.clone()) .collect::>(); for channel in channels { - let quoted_channel = crate::pglite::templating::quote_identifier(&channel); + let quoted_channel = crate::oliphaunt::templating::quote_identifier(&channel); self.exec_internal(&format!("LISTEN {quoted_channel}"), None) .with_context(|| format!("restore LISTEN {channel} after backend restart"))?; } @@ -636,7 +651,7 @@ impl Pglite { return Ok(()); } if self.closing { - bail!("Pglite is closing"); + bail!("Oliphaunt is closing"); } self.closing = true; @@ -681,11 +696,22 @@ impl Pglite { let mut collected_messages: Vec = Vec::new(); let message = Serialize::query(sql); - let ExecProtocolResult { messages, .. } = match self.exec_protocol(&message, exec_opts) { - Ok(result) => result, + let transport_result = { + let _phase = timing::phase("client.protocol_transport_send"); + self.backend + .with_buffered(&message, exec_opts.data_transfer_container, |data| { + if let Some(affected_rows) = parse_command_only_result_counts(data) { + Ok(ExecTransportResult::CommandOnly(affected_rows)) + } else { + Ok(ExecTransportResult::Raw(data.to_vec())) + } + }) + }; + let transport_result = match transport_result { + Ok(data) => data, Err(err) => match err.downcast::() { Ok(db_err) => { - let enriched = PgliteError::new(db_err, sql, Vec::new(), options_snapshot); + let enriched = OliphauntError::new(db_err, sql, Vec::new(), options_snapshot); return Err(enriched.into()); } Err(err) => { @@ -693,9 +719,33 @@ impl Pglite { } }, }; + + let data = match transport_result { + ExecTransportResult::CommandOnly(affected_rows) => { + return self.finish_exec_command_only(affected_rows, options); + } + ExecTransportResult::Raw(data) => data, + }; + let ExecProtocolResult { messages, .. } = + match self.parse_protocol_data(data, exec_opts.throw_on_error, exec_opts.on_notice) { + Ok(result) => result, + Err(err) => match err.downcast::() { + Ok(db_err) => { + let enriched = + OliphauntError::new(db_err, sql, Vec::new(), options_snapshot); + return Err(enriched.into()); + } + Err(err) => { + return Err(err.context(format!("failed to execute simple query: {sql}"))); + } + }, + }; + let has_row_description = messages + .iter() + .any(|message| matches!(message, BackendMessage::RowDescription(_))); collected_messages.extend(messages); - self.finish_exec(collected_messages, options) + self.finish_exec(collected_messages, options, has_row_description) } /// Register a listener for `LISTEN channel`. Returns a handle that can be used to unlisten. @@ -705,7 +755,7 @@ impl Pglite { { self.check_ready()?; - let quoted_channel = crate::pglite::templating::quote_identifier(channel); + let quoted_channel = crate::oliphaunt::templating::quote_identifier(channel); let normalized = channel.to_string(); let should_listen = match self.notify_listeners.get(&normalized) { Some(existing) => existing.is_empty(), @@ -735,7 +785,8 @@ impl Pglite { listeners.retain(|listener| listener.id != handle.id); if listeners.is_empty() { self.notify_listeners.remove(&handle.normalized_channel); - let quoted_channel = crate::pglite::templating::quote_identifier(&handle.channel); + let quoted_channel = + crate::oliphaunt::templating::quote_identifier(&handle.channel); self.exec_internal(&format!("UNLISTEN {quoted_channel}"), None)?; } } @@ -744,7 +795,7 @@ impl Pglite { /// Remove all listeners for the specified channel. pub fn unlisten_channel(&mut self, channel: &str) -> Result<()> { - let quoted_channel = crate::pglite::templating::quote_identifier(channel); + let quoted_channel = crate::oliphaunt::templating::quote_identifier(channel); let normalized = channel.to_string(); if self.notify_listeners.remove(&normalized).is_some() { self.exec_internal(&format!("UNLISTEN {quoted_channel}"), None)?; @@ -816,7 +867,7 @@ impl Pglite { if let Err(err) = result { match err.downcast::() { Ok(db_err) => { - let enriched = PgliteError::new(db_err, sql, Vec::new(), options_snapshot); + let enriched = OliphauntError::new(db_err, sql, Vec::new(), options_snapshot); return Err(enriched.into()); } Err(err) => { @@ -1026,6 +1077,7 @@ impl Pglite { &mut self, messages: Vec, options: Option<&QueryOptions>, + has_row_description: bool, ) -> Result> { let blob = { let _phase = timing::phase("client.finish.blob_read"); @@ -1039,7 +1091,7 @@ impl Pglite { let _phase = timing::phase("client.finish.sync_to_fs"); self.sync_to_fs()?; } - { + if has_row_description { let _phase = timing::phase("client.finish.ensure_array_types"); self.ensure_array_types_for_result_messages(&messages, options)?; } @@ -1050,6 +1102,45 @@ impl Pglite { Ok(parsed) } + fn finish_exec_command_only( + &mut self, + affected_rows: Vec, + options: Option<&QueryOptions>, + ) -> Result> { + let blob = { + let _phase = timing::phase("client.finish.blob_read"); + self.get_written_blob()? + }; + { + let _phase = timing::phase("client.finish.blob_cleanup"); + self.cleanup_blob()?; + } + if !self.in_transaction { + let _phase = timing::phase("client.finish.sync_to_fs"); + self.sync_to_fs()?; + } + + let _ = options; + let mut results = Vec::with_capacity(affected_rows.len().max(1)); + for count in affected_rows { + results.push(Results { + rows: Vec::new(), + fields: Vec::new(), + affected_rows: Some(count), + blob: blob.clone(), + }); + } + if results.is_empty() { + results.push(Results { + rows: Vec::new(), + fields: Vec::new(), + affected_rows: Some(0), + blob, + }); + } + Ok(results) + } + /// Execute raw PostgreSQL frontend protocol bytes and parse backend /// protocol messages. pub fn exec_protocol( @@ -1068,7 +1159,15 @@ impl Pglite { let _phase = timing::phase("client.protocol_roundtrip"); self.exec_protocol_raw_inner(message, sync_to_fs, data_transfer_container)? }; + self.parse_protocol_data(data, throw_on_error, on_notice) + } + fn parse_protocol_data( + &mut self, + data: Vec, + throw_on_error: bool, + on_notice: Option, + ) -> Result { let mut messages = Vec::new(); let on_notice_cb = on_notice.clone(); let parse_result = { @@ -1228,7 +1327,7 @@ impl Pglite { ORDER BY e.oid "; let results = { - let _phase = timing::phase("pglite.array_type_catalog_query"); + let _phase = timing::phase("oliphaunt.array_type_catalog_query"); self.exec_internal(sql, None)? }; let result_set = results @@ -1237,7 +1336,7 @@ impl Pglite { .ok_or_else(|| anyhow!("array type discovery returned no results"))?; { - let _phase = timing::phase("pglite.array_type_register"); + let _phase = timing::phase("oliphaunt.array_type_register"); for row in result_set.rows { if let Some(info) = array_type_info_from_row(&row) { self.register_array_type(info); @@ -1264,7 +1363,7 @@ impl Pglite { AND a.typelem <> 0" ); let results = { - let _phase = timing::phase("pglite.array_type_targeted_lookup"); + let _phase = timing::phase("oliphaunt.array_type_targeted_lookup"); self.exec_internal(&sql, None)? }; let Some(result_set) = results.into_iter().next() else { @@ -1351,19 +1450,19 @@ impl Pglite { fn check_ready(&self) -> Result<()> { if self.closing { - bail!("Pglite instance is closing"); + bail!("Oliphaunt instance is closing"); } if self.closed { - bail!("Pglite instance is closed"); + bail!("Oliphaunt instance is closed"); } if !self.ready { - bail!("Pglite instance is not ready"); + bail!("Oliphaunt instance is not ready"); } Ok(()) } } -impl Drop for Pglite { +impl Drop for Oliphaunt { fn drop(&mut self) { if !self.closed { let _ = self.close(); @@ -1440,6 +1539,43 @@ fn flush_direct_pg_dump_socket( .context("flush direct pg_dump virtual socket") } +fn parse_command_only_result_counts(data: &[u8]) -> Option> { + let mut offset = 0usize; + let mut affected_total = 0usize; + let mut affected_rows = Vec::new(); + while offset + 5 <= data.len() { + let tag = data[offset]; + let length = u32::from_be_bytes([ + data[offset + 1], + data[offset + 2], + data[offset + 3], + data[offset + 4], + ]) as usize; + if length < 4 { + return None; + } + let frame_len = 1 + length; + if frame_len > data.len() - offset { + return None; + } + let body_start = offset + 5; + let body_end = offset + frame_len; + match tag { + b'C' => { + let command_tag = data[body_start..body_end] + .strip_suffix(&[0]) + .unwrap_or(&data[body_start..body_end]); + affected_total = affected_total.saturating_add(command_tag_row_count(command_tag)); + affected_rows.push(affected_total); + } + b'Z' => {} + _ => return None, + } + offset += frame_len; + } + (offset == data.len()).then_some(affected_rows) +} + fn value_to_i32(value: Option<&Value>) -> Option { match value? { Value::Number(number) => number.as_i64().map(|value| value as i32), @@ -1468,14 +1604,14 @@ fn array_type_info_from_row(row: &Value) -> Option { Some(ArrayTypeInfo::new(element_oid, array_oid, delimiter)) } -/// Transaction handle used within [`Pglite::transaction`]. +/// Transaction handle used within [`Oliphaunt::transaction`]. pub struct Transaction<'a> { - client: &'a mut Pglite, + client: &'a mut Oliphaunt, closed: bool, } impl<'a> Transaction<'a> { - fn new(client: &'a mut Pglite) -> Self { + fn new(client: &'a mut Oliphaunt) -> Self { Self { client, closed: false, diff --git a/src/pglite/config.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/config.rs similarity index 97% rename from src/pglite/config.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/config.rs index 081b9960..130422e0 100644 --- a/src/pglite/config.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/config.rs @@ -2,12 +2,12 @@ use std::collections::BTreeMap; use anyhow::{Result, bail, ensure}; -use crate::pglite::interface::DebugLevel; +use crate::oliphaunt::interface::DebugLevel; /// PostgreSQL startup configuration applied through normal `postgres -c` GUC /// handling before the embedded backend starts. /// -/// Settings added here override `pglite-oxide`'s default startup profile because +/// Settings added here override `oliphaunt-wasix`'s default startup profile because /// they are appended after the defaults in the generated PostgreSQL argv. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PostgresConfig { diff --git a/src/pglite/data_dir.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/data_dir.rs similarity index 99% rename from src/pglite/data_dir.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/data_dir.rs index 5376c0e4..f4c2ae3e 100644 --- a/src/pglite/data_dir.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/data_dir.rs @@ -9,7 +9,7 @@ use flate2::read::GzDecoder; use flate2::write::GzEncoder; use tar::{Archive, Builder, EntryType, Header}; -const PGDATA_OVERLAY_MANIFEST_NAME: &str = ".pglite-oxide-pgdata-overlay.json"; +const PGDATA_OVERLAY_MANIFEST_NAME: &str = ".oliphaunt-wasix-pgdata-overlay.json"; const RUNTIME_STATE_FILES: &[&str] = &["postmaster.pid", "postmaster.opts"]; const OVERLAY_WHITEOUT_PREFIX: &str = ".wh."; diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/engine.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/engine.rs new file mode 100644 index 00000000..e19fae04 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/engine.rs @@ -0,0 +1,25 @@ +/// Capabilities advertised by the packaged WASIX runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EngineCapabilities { + pub engine_name: &'static str, + pub version: String, + pub multi_instance: bool, + pub protocol_raw: bool, + pub protocol_stream: bool, + pub server_mode: bool, + pub extensions: bool, +} + +impl EngineCapabilities { + pub(crate) fn wasix_legacy(protocol_stream: bool) -> Self { + Self { + engine_name: "wasix-legacy", + version: crate::oliphaunt::aot::engine_identity().to_owned(), + multi_instance: true, + protocol_raw: true, + protocol_stream, + server_mode: true, + extensions: cfg!(feature = "extensions"), + } + } +} diff --git a/src/pglite/errors.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/errors.rs similarity index 80% rename from src/pglite/errors.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/errors.rs index 13e61232..3bcf0387 100644 --- a/src/pglite/errors.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/errors.rs @@ -3,19 +3,19 @@ use std::fmt; use serde_json::Value; -use crate::pglite::interface::QueryOptions; +use crate::oliphaunt::interface::QueryOptions; use crate::protocol::messages::DatabaseError; -/// Rich error type that mirrors the TypeScript `PGliteError` by carrying the +/// Rich error type that mirrors the TypeScript `OliphauntError` by carrying the /// original database error along with query context. -pub struct PgliteError { +pub struct OliphauntError { source: DatabaseError, query: String, params: Vec, query_options: Option, } -impl PgliteError { +impl OliphauntError { pub fn new( source: DatabaseError, query: impl Into, @@ -47,15 +47,15 @@ impl PgliteError { } } -impl fmt::Display for PgliteError { +impl fmt::Display for OliphauntError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.source) } } -impl fmt::Debug for PgliteError { +impl fmt::Debug for OliphauntError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PgliteError") + f.debug_struct("OliphauntError") .field("source", &self.source) .field("query", &self.query) .field("params", &self.params) @@ -64,7 +64,7 @@ impl fmt::Debug for PgliteError { } } -impl Error for PgliteError { +impl Error for OliphauntError { fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.source) } diff --git a/src/pglite/extensions.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/extensions.rs similarity index 68% rename from src/pglite/extensions.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/extensions.rs index f3d2ddd1..dfa8b41f 100644 --- a/src/pglite/extensions.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/extensions.rs @@ -7,12 +7,37 @@ mod generated; pub use generated::*; -/// A bundled Postgres extension that can be installed into a PGlite database. +/// A native WASIX side module required by a bundled extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExtensionNativeModule { + runtime_path: &'static str, + aot_name: Option<&'static str>, +} + +impl ExtensionNativeModule { + pub(crate) const fn new(runtime_path: &'static str, aot_name: Option<&'static str>) -> Self { + Self { + runtime_path, + aot_name, + } + } + + pub const fn runtime_path(self) -> &'static str { + self.runtime_path + } + + pub const fn aot_name(self) -> Option<&'static str> { + self.aot_name + } +} + +/// A bundled Postgres extension that can be installed into a Oliphaunt database. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Extension { name: &'static str, sql_name: &'static str, archive_name: &'static str, + native_support_modules: &'static [ExtensionNativeModule], native_module_file: Option<&'static str>, aot_name: Option<&'static str>, dependencies: &'static [&'static str], @@ -49,6 +74,7 @@ impl Extension { name: &'static str, sql_name: &'static str, archive_name: &'static str, + native_support_modules: &'static [ExtensionNativeModule], native_module_file: Option<&'static str>, aot_name: Option<&'static str>, dependencies: &'static [&'static str], @@ -58,6 +84,7 @@ impl Extension { name, sql_name, archive_name, + native_support_modules, native_module_file, aot_name, dependencies, @@ -91,6 +118,12 @@ impl Extension { self.native_module_file } + /// Support side modules that must be available before the extension module + /// is loaded. + pub const fn native_support_modules(self) -> &'static [ExtensionNativeModule] { + self.native_support_modules + } + /// SQL extension names that must be installed before this extension. pub const fn dependencies(self) -> &'static [&'static str] { self.dependencies @@ -144,7 +177,7 @@ fn visit_extension( for dependency in extension.dependencies() { let dependency_extension = candidate_by_sql_name(dependency).ok_or_else(|| { anyhow::anyhow!( - "bundled extension '{}' depends on missing packaged extension '{}'", + "selected extension '{}' depends on missing catalog extension '{}'", extension.sql_name(), dependency ) @@ -158,22 +191,27 @@ fn visit_extension( } pub(crate) fn extension_setup_sql(extension: Extension) -> Vec { + extension_setup_sql_with_schema_policy(extension) +} + +fn extension_setup_sql_with_schema_policy(extension: Extension) -> Vec { let setup = extension.setup(); let mut statements = Vec::new(); if setup.create_extension { - if let Some(schema) = setup.create_schema.filter(|schema| *schema != "pg_catalog") { + let create_schema = setup.create_schema; + if let Some(schema) = create_schema.filter(|schema| *schema != "pg_catalog") { statements.push(format!( "CREATE SCHEMA IF NOT EXISTS {};", - crate::pglite::templating::quote_identifier(schema) + crate::oliphaunt::templating::quote_identifier(schema) )); } let mut sql = format!( "CREATE EXTENSION IF NOT EXISTS {}", - crate::pglite::templating::quote_identifier(extension.sql_name()) + crate::oliphaunt::templating::quote_identifier(extension.sql_name()) ); - if let Some(schema) = setup.create_schema { + if let Some(schema) = create_schema { sql.push_str(" WITH SCHEMA "); - sql.push_str(&crate::pglite::templating::quote_identifier(schema)); + sql.push_str(&crate::oliphaunt::templating::quote_identifier(schema)); } sql.push(';'); statements.push(sql); @@ -194,7 +232,7 @@ pub(crate) fn extension_session_setup_sql(extension: Extension) -> Vec { #[cfg(all(test, feature = "extensions"))] mod candidate_tests { use super::*; - use crate::{Pglite, PgliteServer}; + use crate::{Oliphaunt, OliphauntServer, PgDumpOptions}; use anyhow::{Context, Result, ensure}; use sqlx::{Connection, PgConnection}; use std::collections::BTreeSet; @@ -215,24 +253,55 @@ mod candidate_tests { run_lifecycle_materialization_set(generated::ALL) } + #[test] + fn public_extensions_pass_direct_dump_restore_smoke() -> Result<()> { + run_direct_dump_restore_smoke_set(generated::ALL) + } + #[test] #[ignore = "promotion gate: run manually before marking packaged candidates stable"] fn packaged_candidate_extensions_pass_direct_and_restart_smoke() -> Result<()> { run_direct_and_restart_smoke_set(generated::CANDIDATES) } + #[test] + fn uuid_ossp_candidate_passes_direct_and_restart_smoke() -> Result<()> { + run_direct_and_restart_smoke_set(&[generated::CANDIDATE_UUID_OSSP]) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "promotion gate: run manually before marking packaged candidates stable"] async fn packaged_candidate_extensions_pass_server_smoke() -> Result<()> { run_server_smoke_set(generated::CANDIDATES).await } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn uuid_ossp_candidate_passes_server_smoke() -> Result<()> { + run_server_smoke_set(&[generated::CANDIDATE_UUID_OSSP]).await + } + #[test] #[ignore = "promotion gate: run manually before marking packaged candidates stable"] fn packaged_candidate_extensions_materialize_only_requested_libraries() -> Result<()> { run_lifecycle_materialization_set(generated::CANDIDATES) } + #[test] + fn uuid_ossp_candidate_materializes_only_requested_libraries() -> Result<()> { + run_lifecycle_materialization_set(&[generated::CANDIDATE_UUID_OSSP]) + } + + #[test] + #[ignore = "promotion gate: run manually before marking packaged candidates stable"] + fn packaged_candidate_extensions_pass_direct_dump_restore_smoke() -> Result<()> { + run_direct_dump_restore_smoke_set(generated::CANDIDATES) + } + + #[test] + fn uuid_ossp_candidate_passes_direct_dump_restore_smoke() -> Result<()> { + run_direct_dump_restore_smoke_set(&[generated::CANDIDATE_UUID_OSSP]) + } + fn run_direct_and_restart_smoke_set(extensions: &[Extension]) -> Result<()> { let mut failures = Vec::new(); for extension in extensions { @@ -251,7 +320,7 @@ mod candidate_tests { fn run_one_direct_and_restart_smoke(extension: Extension) -> Result<()> { let name = extension.sql_name(); { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extension) .open() @@ -264,7 +333,7 @@ mod candidate_tests { let root = tempfile::TempDir::new() .with_context(|| format!("create restart root for extension {name}"))?; { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .path(root.path()) .extension(extension) .open() @@ -277,7 +346,7 @@ mod candidate_tests { .with_context(|| format!("close persistent database with extension {name}"))?; } { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .path(root.path()) .extension(extension) .open() @@ -308,7 +377,7 @@ mod candidate_tests { async fn run_one_server_smoke(extension: Extension) -> Result<()> { let name = extension.sql_name(); - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .extension(extension) .start() @@ -344,7 +413,7 @@ mod candidate_tests { let root = tempfile::TempDir::new() .with_context(|| format!("create lifecycle root for extension {name}"))?; { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .path(root.path()) .extension(extension) .open() @@ -355,8 +424,94 @@ mod candidate_tests { assert_only_resolved_extension_libraries_are_materialized(root.path(), extension) } - fn run_direct_smoke(db: &mut Pglite, extension: Extension) -> Result<()> { - for statement in smoke_sql(extension.sql_name()) { + fn run_direct_dump_restore_smoke_set(extensions: &[Extension]) -> Result<()> { + let mut failures = Vec::new(); + for extension in extensions { + if let Err(error) = run_one_direct_dump_restore_smoke(*extension) { + failures.push(format!("{}: {error:?}", extension.sql_name())); + } + } + ensure!( + failures.is_empty(), + "extension direct dump/restore smoke failures:\n{}", + failures.join("\n\n") + ); + Ok(()) + } + + fn run_one_direct_dump_restore_smoke(extension: Extension) -> Result<()> { + let name = extension.sql_name(); + let dump = { + let mut db = Oliphaunt::builder() + .temporary() + .extension(extension) + .open() + .with_context(|| format!("open dump source database with extension {name}"))?; + assert_extension_catalog_state(&mut db, extension)?; + db.exec( + "CREATE TABLE oxide_extension_dump_marker(value text); + INSERT INTO oxide_extension_dump_marker VALUES ('restored');", + None, + ) + .with_context(|| format!("seed dump source database with extension {name}"))?; + let dump = db + .dump_sql(PgDumpOptions::new()) + .with_context(|| format!("dump source database with extension {name}"))?; + db.close() + .with_context(|| format!("close dump source database with extension {name}"))?; + dump + }; + + if extension.setup().create_extension { + let unquoted_needle = + format!("CREATE EXTENSION IF NOT EXISTS {}", extension.sql_name()); + let quoted_needle = format!( + "CREATE EXTENSION IF NOT EXISTS {}", + crate::oliphaunt::templating::quote_identifier(extension.sql_name()) + ); + ensure!( + dump.contains(&unquoted_needle) || dump.contains("ed_needle), + "pg_dump for extension {} should contain {:?} or {:?}; dump was:\n{}", + extension.sql_name(), + unquoted_needle, + quoted_needle, + dump + ); + } + + let mut restored = Oliphaunt::builder() + .temporary() + .extension(extension) + .open() + .with_context(|| format!("open dump restore database with extension {name}"))?; + restored + .exec(&dump, None) + .with_context(|| format!("restore dump SQL with extension {name}"))?; + restored + .exec("SET search_path TO public, pg_catalog", None) + .with_context(|| { + format!("reset restore session search_path after pg_dump SQL for extension {name}") + })?; + assert_extension_catalog_state(&mut restored, extension)?; + let marker = restored.query( + "SELECT value FROM public.oxide_extension_dump_marker", + &[], + None, + )?; + ensure!( + marker.rows[0]["value"] == serde_json::json!("restored"), + "extension {} dump marker did not restore", + extension.sql_name() + ); + run_direct_smoke(&mut restored, extension)?; + restored + .close() + .with_context(|| format!("close dump restore database with extension {name}"))?; + Ok(()) + } + + fn run_direct_smoke(db: &mut Oliphaunt, extension: Extension) -> Result<()> { + for statement in smoke_sql(extension.sql_name()).statements() { db.exec(statement, None).with_context(|| { format!( "direct smoke failed for extension {} while running:\n{}", @@ -369,7 +524,7 @@ mod candidate_tests { } async fn run_server_smoke(conn: &mut PgConnection, extension: Extension) -> Result<()> { - for statement in smoke_sql(extension.sql_name()) { + for statement in smoke_sql(extension.sql_name()).statements() { sqlx::query(statement) .fetch_all(&mut *conn) .await @@ -384,7 +539,7 @@ mod candidate_tests { Ok(()) } - fn assert_extension_catalog_state(db: &mut Pglite, extension: Extension) -> Result<()> { + fn assert_extension_catalog_state(db: &mut Oliphaunt, extension: Extension) -> Result<()> { if extension.setup().create_extension { let result = db.query( "SELECT count(*)::int4 AS count FROM pg_extension WHERE extname = $1", @@ -413,9 +568,24 @@ mod candidate_tests { ) -> Result<()> { let expected = resolve_extension_set(&[extension])? .into_iter() - .filter_map(|extension| extension.native_module_file().map(PathBuf::from)) + .flat_map(|extension| { + let mut modules = extension + .native_support_modules() + .iter() + .map(|module| { + PathBuf::from(module.runtime_path()) + .strip_prefix("lib/postgresql") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(module.runtime_path())) + }) + .collect::>(); + if let Some(module) = extension.native_module_file() { + modules.push(PathBuf::from(module)); + } + modules + }) .collect::>(); - let actual = relative_files(&root.join("tmp/pglite/lib/postgresql")) + let actual = relative_files(&root.join("tmp/oliphaunt/lib/postgresql")) .into_iter() .collect::>(); ensure!( @@ -454,9 +624,37 @@ mod candidate_tests { files } - fn smoke_sql(sql_name: &str) -> &'static [&'static str] { - // These are compact Rust ports of the PGlite extension smoke tests in - // assets/checkouts/pglite/packages/pglite/tests. + const POSTGIS_SMOKE_SQL: &str = + include_str!("../../../../../../extensions/external/postgis/tests/smoke.sql"); + + enum SmokeSql { + Inline(&'static [&'static str]), + Recipe(&'static str), + } + + impl SmokeSql { + fn statements(&self) -> Vec<&'static str> { + match self { + Self::Inline(statements) => statements.to_vec(), + Self::Recipe(sql) => sql + .split("-- oliphaunt-statement") + .map(str::trim) + .filter(|statement| !statement.is_empty()) + .collect(), + } + } + } + + fn smoke_sql(sql_name: &str) -> SmokeSql { + if sql_name == "postgis" { + return SmokeSql::Recipe(POSTGIS_SMOKE_SQL); + } + SmokeSql::Inline(inline_smoke_sql(sql_name)) + } + + fn inline_smoke_sql(sql_name: &str) -> &'static [&'static str] { + // These are compact Rust ports of the Oliphaunt extension smoke tests in + // src/extensions tests. match sql_name { "age" => &[ "SELECT ag_catalog.create_graph('oxide_graph')", @@ -584,6 +782,12 @@ mod candidate_tests { "INSERT INTO oxide_walinspect SELECT 'row ' || i::text FROM generate_series(1, 5) AS i", "SELECT * FROM pg_get_wal_block_info((SELECT before_lsn FROM oxide_walinspect_lsn), pg_current_wal_lsn()) ORDER BY start_lsn, block_id LIMIT 20", ], + "pgcrypto" => &[ + "DO $$ DECLARE hashed text; encrypted bytea; BEGIN IF encode(digest('abc', 'sha256'), 'hex') <> 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' THEN RAISE EXCEPTION 'sha256 digest failed'; END IF; IF length(gen_random_bytes(16)) <> 16 THEN RAISE EXCEPTION 'random bytes length failed'; END IF; SELECT crypt('secret', gen_salt('bf', 4)) INTO hashed; IF crypt('secret', hashed) <> hashed THEN RAISE EXCEPTION 'password hash verify failed'; END IF; SELECT pgp_sym_encrypt('oliphaunt secret', 'passphrase') INTO encrypted; IF pgp_sym_decrypt(encrypted, 'passphrase') <> 'oliphaunt secret' THEN RAISE EXCEPTION 'PGP symmetric decrypt failed'; END IF; END $$", + "DO $$ BEGIN IF encode(hmac('test', 'key', 'sha1'), 'hex') <> '671f54ce0c540f78ffe1e26dcf9c2a047aea4fda' THEN RAISE EXCEPTION 'hmac failed'; END IF; IF gen_random_uuid()::text !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN RAISE EXCEPTION 'random uuid format failed'; END IF; END $$", + "DO $$ DECLARE armored text; header_count int; BEGIN SELECT armor(digest('test', 'sha1'), ARRAY['Version'], ARRAY['oliphaunt']) INTO armored; IF position('Version: oliphaunt' in armored) = 0 THEN RAISE EXCEPTION 'armor header failed'; END IF; SELECT count(*) INTO header_count FROM pgp_armor_headers(armored); IF header_count <> 1 THEN RAISE EXCEPTION 'armor header count failed: %', header_count; END IF; END $$", + "DO $$ DECLARE encrypted bytea; crypto_key bytea := decode('000102030405060708090a0b0c0d0e0f', 'hex'); crypto_iv bytea := decode('101112131415161718191a1b1c1d1e1f', 'hex'); BEGIN SELECT pgp_sym_encrypt('oliphaunt secret', 'passphrase') INTO encrypted; IF pgp_key_id(encrypted) <> 'SYMKEY' THEN RAISE EXCEPTION 'PGP symmetric key id failed'; END IF; SELECT encrypt(convert_to('oliphaunt raw cipher', 'UTF8'), crypto_key, 'aes') INTO encrypted; IF convert_from(decrypt(encrypted, crypto_key, 'aes'), 'UTF8') <> 'oliphaunt raw cipher' THEN RAISE EXCEPTION 'raw decrypt failed'; END IF; SELECT encrypt_iv(convert_to('oliphaunt iv cipher', 'UTF8'), crypto_key, crypto_iv, 'aes-cbc') INTO encrypted; IF convert_from(decrypt_iv(encrypted, crypto_key, crypto_iv, 'aes-cbc'), 'UTF8') <> 'oliphaunt iv cipher' THEN RAISE EXCEPTION 'raw iv decrypt failed'; END IF; END $$", + ], "pgtap" => &[ "BEGIN", "SELECT plan(1)", @@ -614,12 +818,17 @@ mod candidate_tests { "unaccent" => &[ "DO $$ DECLARE lex text; BEGIN SELECT array_to_string(ts_lexize('unaccent', 'Hôtel'), ',') INTO lex; IF lex <> 'Hotel' THEN RAISE EXCEPTION 'unaccent failed: %', lex; END IF; END $$", ], + "uuid-ossp" => &[ + "DO $$ DECLARE id uuid; BEGIN SELECT uuid_generate_v1() INTO id; IF length(id::text) <> 36 THEN RAISE EXCEPTION 'uuid-ossp v1 length failed'; END IF; SELECT uuid_generate_v4() INTO id; IF length(id::text) <> 36 THEN RAISE EXCEPTION 'uuid-ossp v4 length failed'; END IF; END $$", + "DO $$ BEGIN IF uuid_generate_v3(uuid_ns_dns(), 'www.example.com')::text <> '5df41881-3aed-3515-88a7-2f4a814cf09e' THEN RAISE EXCEPTION 'uuid-ossp v3 failed'; END IF; IF uuid_generate_v5(uuid_ns_dns(), 'www.example.com')::text <> '2ed6657d-e927-568b-95e1-2665a8aea6a2' THEN RAISE EXCEPTION 'uuid-ossp v5 failed'; END IF; END $$", + "DO $$ BEGIN IF uuid_nil()::text <> '00000000-0000-0000-0000-000000000000' THEN RAISE EXCEPTION 'uuid-ossp nil failed'; END IF; IF uuid_ns_dns()::text <> '6ba7b810-9dad-11d1-80b4-00c04fd430c8' THEN RAISE EXCEPTION 'uuid-ossp dns namespace failed'; END IF; IF uuid_ns_oid()::text <> '6ba7b812-9dad-11d1-80b4-00c04fd430c8' THEN RAISE EXCEPTION 'uuid-ossp oid namespace failed'; END IF; END $$", + ], "vector" => &[ "CREATE TEMP TABLE oxide_vector (embedding vector(3))", "INSERT INTO oxide_vector VALUES ('[1,2,3]')", "DO $$ DECLARE d float8; BEGIN SELECT embedding <-> '[1,2,4]'::vector INTO d FROM oxide_vector; IF d <> 1 THEN RAISE EXCEPTION 'vector distance failed: %', d; END IF; END $$", ], - other => panic!("missing smoke SQL for packaged extension candidate {other}"), + other => panic!("missing smoke SQL for extension candidate {other}"), } } } diff --git a/src/pglite/generated_extensions.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/generated_extensions.rs similarity index 75% rename from src/pglite/generated_extensions.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/generated_extensions.rs index a1332489..d4b4fcd9 100644 --- a/src/pglite/generated_extensions.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/generated_extensions.rs @@ -1,38 +1,21 @@ // @generated by `cargo run -p xtask -- extensions generate` -use super::{Extension, ExtensionSetup}; +use super::{Extension, ExtensionNativeModule, ExtensionSetup}; const EMPTY_SQL_NAMES: &[&str] = &[]; const EMPTY_SQL: &[&str] = &[]; - -const CANDIDATE_AGE_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; -const CANDIDATE_AGE_LOAD_SQL: &[&str] = &["LOAD 'age';"]; -const CANDIDATE_AGE_POST_CREATE_SQL: &[&str] = - &["SET search_path = ag_catalog, \"$user\", public;"]; - -pub(crate) const CANDIDATE_AGE: Extension = Extension::new( - "Apache AGE", - "age", - "extensions/age.tar.zst", - Some("age.so"), - Some("extension:age"), - CANDIDATE_AGE_DEPENDENCIES, - ExtensionSetup::new( - true, - Some("ag_catalog"), - CANDIDATE_AGE_LOAD_SQL, - CANDIDATE_AGE_POST_CREATE_SQL, - ), -); +const EMPTY_NATIVE_MODULES: &[ExtensionNativeModule] = &[]; const CANDIDATE_AMCHECK_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_AMCHECK_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_AMCHECK_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_AMCHECK_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_AMCHECK: Extension = Extension::new( "amcheck", "amcheck", "extensions/amcheck.tar.zst", + CANDIDATE_AMCHECK_NATIVE_SUPPORT_MODULES, Some("amcheck.so"), Some("extension:amcheck"), CANDIDATE_AMCHECK_DEPENDENCIES, @@ -52,11 +35,14 @@ const CANDIDATE_AUTO_EXPLAIN_LOAD_SQL: &[&str] = &[ "SET auto_explain.log_level = 'NOTICE';", ]; const CANDIDATE_AUTO_EXPLAIN_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_AUTO_EXPLAIN_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_AUTO_EXPLAIN: Extension = Extension::new( "auto_explain", "auto_explain", "extensions/auto_explain.tar.zst", + CANDIDATE_AUTO_EXPLAIN_NATIVE_SUPPORT_MODULES, Some("auto_explain.so"), Some("extension:auto_explain"), CANDIDATE_AUTO_EXPLAIN_DEPENDENCIES, @@ -71,11 +57,13 @@ pub(crate) const CANDIDATE_AUTO_EXPLAIN: Extension = Extension::new( const CANDIDATE_BLOOM_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_BLOOM_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_BLOOM_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_BLOOM_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_BLOOM: Extension = Extension::new( "bloom", "bloom", "extensions/bloom.tar.zst", + CANDIDATE_BLOOM_NATIVE_SUPPORT_MODULES, Some("bloom.so"), Some("extension:bloom"), CANDIDATE_BLOOM_DEPENDENCIES, @@ -90,11 +78,13 @@ pub(crate) const CANDIDATE_BLOOM: Extension = Extension::new( const CANDIDATE_BTREE_GIN_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_BTREE_GIN_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_BTREE_GIN_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_BTREE_GIN_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_BTREE_GIN: Extension = Extension::new( "btree_gin", "btree_gin", "extensions/btree_gin.tar.zst", + CANDIDATE_BTREE_GIN_NATIVE_SUPPORT_MODULES, Some("btree_gin.so"), Some("extension:btree_gin"), CANDIDATE_BTREE_GIN_DEPENDENCIES, @@ -109,11 +99,13 @@ pub(crate) const CANDIDATE_BTREE_GIN: Extension = Extension::new( const CANDIDATE_BTREE_GIST_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_BTREE_GIST_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_BTREE_GIST_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_BTREE_GIST_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_BTREE_GIST: Extension = Extension::new( "btree_gist", "btree_gist", "extensions/btree_gist.tar.zst", + CANDIDATE_BTREE_GIST_NATIVE_SUPPORT_MODULES, Some("btree_gist.so"), Some("extension:btree_gist"), CANDIDATE_BTREE_GIST_DEPENDENCIES, @@ -128,11 +120,13 @@ pub(crate) const CANDIDATE_BTREE_GIST: Extension = Extension::new( const CANDIDATE_CITEXT_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_CITEXT_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_CITEXT_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_CITEXT_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_CITEXT: Extension = Extension::new( "citext", "citext", "extensions/citext.tar.zst", + CANDIDATE_CITEXT_NATIVE_SUPPORT_MODULES, Some("citext.so"), Some("extension:citext"), CANDIDATE_CITEXT_DEPENDENCIES, @@ -147,11 +141,13 @@ pub(crate) const CANDIDATE_CITEXT: Extension = Extension::new( const CANDIDATE_CUBE_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_CUBE_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_CUBE_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_CUBE_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_CUBE: Extension = Extension::new( "cube", "cube", "extensions/cube.tar.zst", + CANDIDATE_CUBE_NATIVE_SUPPORT_MODULES, Some("cube.so"), Some("extension:cube"), CANDIDATE_CUBE_DEPENDENCIES, @@ -166,11 +162,13 @@ pub(crate) const CANDIDATE_CUBE: Extension = Extension::new( const CANDIDATE_DICT_INT_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_DICT_INT_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_DICT_INT_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_DICT_INT_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_DICT_INT: Extension = Extension::new( "dict_int", "dict_int", "extensions/dict_int.tar.zst", + CANDIDATE_DICT_INT_NATIVE_SUPPORT_MODULES, Some("dict_int.so"), Some("extension:dict_int"), CANDIDATE_DICT_INT_DEPENDENCIES, @@ -185,11 +183,13 @@ pub(crate) const CANDIDATE_DICT_INT: Extension = Extension::new( const CANDIDATE_DICT_XSYN_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_DICT_XSYN_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_DICT_XSYN_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_DICT_XSYN_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_DICT_XSYN: Extension = Extension::new( "dict_xsyn", "dict_xsyn", "extensions/dict_xsyn.tar.zst", + CANDIDATE_DICT_XSYN_NATIVE_SUPPORT_MODULES, Some("dict_xsyn.so"), Some("extension:dict_xsyn"), CANDIDATE_DICT_XSYN_DEPENDENCIES, @@ -204,11 +204,14 @@ pub(crate) const CANDIDATE_DICT_XSYN: Extension = Extension::new( const CANDIDATE_EARTHDISTANCE_DEPENDENCIES: &[&str] = &["cube"]; const CANDIDATE_EARTHDISTANCE_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_EARTHDISTANCE_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_EARTHDISTANCE_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_EARTHDISTANCE: Extension = Extension::new( "earthdistance", "earthdistance", "extensions/earthdistance.tar.zst", + CANDIDATE_EARTHDISTANCE_NATIVE_SUPPORT_MODULES, Some("earthdistance.so"), Some("extension:earthdistance"), CANDIDATE_EARTHDISTANCE_DEPENDENCIES, @@ -223,11 +226,13 @@ pub(crate) const CANDIDATE_EARTHDISTANCE: Extension = Extension::new( const CANDIDATE_FILE_FDW_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_FILE_FDW_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_FILE_FDW_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_FILE_FDW_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_FILE_FDW: Extension = Extension::new( "file_fdw", "file_fdw", "extensions/file_fdw.tar.zst", + CANDIDATE_FILE_FDW_NATIVE_SUPPORT_MODULES, Some("file_fdw.so"), Some("extension:file_fdw"), CANDIDATE_FILE_FDW_DEPENDENCIES, @@ -242,11 +247,14 @@ pub(crate) const CANDIDATE_FILE_FDW: Extension = Extension::new( const CANDIDATE_FUZZYSTRMATCH_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_FUZZYSTRMATCH_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_FUZZYSTRMATCH_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_FUZZYSTRMATCH_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_FUZZYSTRMATCH: Extension = Extension::new( "fuzzystrmatch", "fuzzystrmatch", "extensions/fuzzystrmatch.tar.zst", + CANDIDATE_FUZZYSTRMATCH_NATIVE_SUPPORT_MODULES, Some("fuzzystrmatch.so"), Some("extension:fuzzystrmatch"), CANDIDATE_FUZZYSTRMATCH_DEPENDENCIES, @@ -261,11 +269,13 @@ pub(crate) const CANDIDATE_FUZZYSTRMATCH: Extension = Extension::new( const CANDIDATE_HSTORE_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_HSTORE_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_HSTORE_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_HSTORE_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_HSTORE: Extension = Extension::new( "hstore", "hstore", "extensions/hstore.tar.zst", + CANDIDATE_HSTORE_NATIVE_SUPPORT_MODULES, Some("hstore.so"), Some("extension:hstore"), CANDIDATE_HSTORE_DEPENDENCIES, @@ -280,11 +290,13 @@ pub(crate) const CANDIDATE_HSTORE: Extension = Extension::new( const CANDIDATE_INTARRAY_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_INTARRAY_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_INTARRAY_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_INTARRAY_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_INTARRAY: Extension = Extension::new( "intarray", "intarray", "extensions/intarray.tar.zst", + CANDIDATE_INTARRAY_NATIVE_SUPPORT_MODULES, Some("_int.so"), Some("extension:intarray"), CANDIDATE_INTARRAY_DEPENDENCIES, @@ -299,11 +311,13 @@ pub(crate) const CANDIDATE_INTARRAY: Extension = Extension::new( const CANDIDATE_ISN_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_ISN_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_ISN_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_ISN_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_ISN: Extension = Extension::new( "isn", "isn", "extensions/isn.tar.zst", + CANDIDATE_ISN_NATIVE_SUPPORT_MODULES, Some("isn.so"), Some("extension:isn"), CANDIDATE_ISN_DEPENDENCIES, @@ -318,11 +332,13 @@ pub(crate) const CANDIDATE_ISN: Extension = Extension::new( const CANDIDATE_LO_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_LO_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_LO_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_LO_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_LO: Extension = Extension::new( "lo", "lo", "extensions/lo.tar.zst", + CANDIDATE_LO_NATIVE_SUPPORT_MODULES, Some("lo.so"), Some("extension:lo"), CANDIDATE_LO_DEPENDENCIES, @@ -337,11 +353,13 @@ pub(crate) const CANDIDATE_LO: Extension = Extension::new( const CANDIDATE_LTREE_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_LTREE_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_LTREE_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_LTREE_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_LTREE: Extension = Extension::new( "ltree", "ltree", "extensions/ltree.tar.zst", + CANDIDATE_LTREE_NATIVE_SUPPORT_MODULES, Some("ltree.so"), Some("extension:ltree"), CANDIDATE_LTREE_DEPENDENCIES, @@ -356,11 +374,13 @@ pub(crate) const CANDIDATE_LTREE: Extension = Extension::new( const CANDIDATE_PAGEINSPECT_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PAGEINSPECT_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PAGEINSPECT_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PAGEINSPECT_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PAGEINSPECT: Extension = Extension::new( "pageinspect", "pageinspect", "extensions/pageinspect.tar.zst", + CANDIDATE_PAGEINSPECT_NATIVE_SUPPORT_MODULES, Some("pageinspect.so"), Some("extension:pageinspect"), CANDIDATE_PAGEINSPECT_DEPENDENCIES, @@ -375,11 +395,14 @@ pub(crate) const CANDIDATE_PAGEINSPECT: Extension = Extension::new( const CANDIDATE_PG_BUFFERCACHE_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_BUFFERCACHE_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_BUFFERCACHE_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_BUFFERCACHE_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_BUFFERCACHE: Extension = Extension::new( "pg_buffercache", "pg_buffercache", "extensions/pg_buffercache.tar.zst", + CANDIDATE_PG_BUFFERCACHE_NATIVE_SUPPORT_MODULES, Some("pg_buffercache.so"), Some("extension:pg_buffercache"), CANDIDATE_PG_BUFFERCACHE_DEPENDENCIES, @@ -394,11 +417,14 @@ pub(crate) const CANDIDATE_PG_BUFFERCACHE: Extension = Extension::new( const CANDIDATE_PG_FREESPACEMAP_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_FREESPACEMAP_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_FREESPACEMAP_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_FREESPACEMAP_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_FREESPACEMAP: Extension = Extension::new( "pg_freespacemap", "pg_freespacemap", "extensions/pg_freespacemap.tar.zst", + CANDIDATE_PG_FREESPACEMAP_NATIVE_SUPPORT_MODULES, Some("pg_freespacemap.so"), Some("extension:pg_freespacemap"), CANDIDATE_PG_FREESPACEMAP_DEPENDENCIES, @@ -413,11 +439,13 @@ pub(crate) const CANDIDATE_PG_FREESPACEMAP: Extension = Extension::new( const CANDIDATE_PG_HASHIDS_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_HASHIDS_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_HASHIDS_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_HASHIDS_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_HASHIDS: Extension = Extension::new( "pg_hashids", "pg_hashids", "extensions/pg_hashids.tar.zst", + CANDIDATE_PG_HASHIDS_NATIVE_SUPPORT_MODULES, Some("pg_hashids.so"), Some("extension:pg_hashids"), CANDIDATE_PG_HASHIDS_DEPENDENCIES, @@ -432,11 +460,13 @@ pub(crate) const CANDIDATE_PG_HASHIDS: Extension = Extension::new( const CANDIDATE_PG_IVM_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_IVM_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_IVM_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_IVM_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_IVM: Extension = Extension::new( "pg_ivm", "pg_ivm", "extensions/pg_ivm.tar.zst", + CANDIDATE_PG_IVM_NATIVE_SUPPORT_MODULES, Some("pg_ivm.so"), Some("extension:pg_ivm"), CANDIDATE_PG_IVM_DEPENDENCIES, @@ -451,11 +481,13 @@ pub(crate) const CANDIDATE_PG_IVM: Extension = Extension::new( const CANDIDATE_PG_SURGERY_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_SURGERY_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_SURGERY_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_SURGERY_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_SURGERY: Extension = Extension::new( "pg_surgery", "pg_surgery", "extensions/pg_surgery.tar.zst", + CANDIDATE_PG_SURGERY_NATIVE_SUPPORT_MODULES, Some("pg_surgery.so"), Some("extension:pg_surgery"), CANDIDATE_PG_SURGERY_DEPENDENCIES, @@ -470,11 +502,14 @@ pub(crate) const CANDIDATE_PG_SURGERY: Extension = Extension::new( const CANDIDATE_PG_TEXTSEARCH_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_TEXTSEARCH_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_TEXTSEARCH_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_TEXTSEARCH_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_TEXTSEARCH: Extension = Extension::new( "pg_textsearch", "pg_textsearch", "extensions/pg_textsearch.tar.zst", + CANDIDATE_PG_TEXTSEARCH_NATIVE_SUPPORT_MODULES, Some("pg_textsearch.so"), Some("extension:pg_textsearch"), CANDIDATE_PG_TEXTSEARCH_DEPENDENCIES, @@ -489,11 +524,13 @@ pub(crate) const CANDIDATE_PG_TEXTSEARCH: Extension = Extension::new( const CANDIDATE_PG_TRGM_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_TRGM_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_TRGM_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_TRGM_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_TRGM: Extension = Extension::new( "pg_trgm", "pg_trgm", "extensions/pg_trgm.tar.zst", + CANDIDATE_PG_TRGM_NATIVE_SUPPORT_MODULES, Some("pg_trgm.so"), Some("extension:pg_trgm"), CANDIDATE_PG_TRGM_DEPENDENCIES, @@ -508,11 +545,13 @@ pub(crate) const CANDIDATE_PG_TRGM: Extension = Extension::new( const CANDIDATE_PG_UUIDV7_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_UUIDV7_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_UUIDV7_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_UUIDV7_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_UUIDV7: Extension = Extension::new( "pg_uuidv7", "pg_uuidv7", "extensions/pg_uuidv7.tar.zst", + CANDIDATE_PG_UUIDV7_NATIVE_SUPPORT_MODULES, Some("pg_uuidv7.so"), Some("extension:pg_uuidv7"), CANDIDATE_PG_UUIDV7_DEPENDENCIES, @@ -527,11 +566,14 @@ pub(crate) const CANDIDATE_PG_UUIDV7: Extension = Extension::new( const CANDIDATE_PG_VISIBILITY_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_VISIBILITY_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_VISIBILITY_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_VISIBILITY_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_VISIBILITY: Extension = Extension::new( "pg_visibility", "pg_visibility", "extensions/pg_visibility.tar.zst", + CANDIDATE_PG_VISIBILITY_NATIVE_SUPPORT_MODULES, Some("pg_visibility.so"), Some("extension:pg_visibility"), CANDIDATE_PG_VISIBILITY_DEPENDENCIES, @@ -546,11 +588,14 @@ pub(crate) const CANDIDATE_PG_VISIBILITY: Extension = Extension::new( const CANDIDATE_PG_WALINSPECT_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PG_WALINSPECT_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PG_WALINSPECT_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PG_WALINSPECT_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PG_WALINSPECT: Extension = Extension::new( "pg_walinspect", "pg_walinspect", "extensions/pg_walinspect.tar.zst", + CANDIDATE_PG_WALINSPECT_NATIVE_SUPPORT_MODULES, Some("pg_walinspect.so"), Some("extension:pg_walinspect"), CANDIDATE_PG_WALINSPECT_DEPENDENCIES, @@ -562,14 +607,37 @@ pub(crate) const CANDIDATE_PG_WALINSPECT: Extension = Extension::new( ), ); +const CANDIDATE_PGCRYPTO_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; +const CANDIDATE_PGCRYPTO_LOAD_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PGCRYPTO_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PGCRYPTO_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; + +pub(crate) const CANDIDATE_PGCRYPTO: Extension = Extension::new( + "pgcrypto", + "pgcrypto", + "extensions/pgcrypto.tar.zst", + CANDIDATE_PGCRYPTO_NATIVE_SUPPORT_MODULES, + Some("pgcrypto.so"), + Some("extension:pgcrypto"), + CANDIDATE_PGCRYPTO_DEPENDENCIES, + ExtensionSetup::new( + true, + None, + CANDIDATE_PGCRYPTO_LOAD_SQL, + CANDIDATE_PGCRYPTO_POST_CREATE_SQL, + ), +); + const CANDIDATE_PGTAP_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_PGTAP_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_PGTAP_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_PGTAP_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_PGTAP: Extension = Extension::new( "pgtap", "pgtap", "extensions/pgtap.tar.zst", + CANDIDATE_PGTAP_NATIVE_SUPPORT_MODULES, None, None, CANDIDATE_PGTAP_DEPENDENCIES, @@ -581,14 +649,41 @@ pub(crate) const CANDIDATE_PGTAP: Extension = Extension::new( ), ); +const CANDIDATE_POSTGIS_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; +const CANDIDATE_POSTGIS_LOAD_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_POSTGIS_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_POSTGIS_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + &[ExtensionNativeModule::new( + "lib/postgresql/liboliphaunt_postgis_deps.so", + Some("extension:postgis:postgis_deps"), + )]; + +pub(crate) const CANDIDATE_POSTGIS: Extension = Extension::new( + "PostGIS", + "postgis", + "extensions/postgis.tar.zst", + CANDIDATE_POSTGIS_NATIVE_SUPPORT_MODULES, + Some("postgis-3.so"), + Some("extension:postgis"), + CANDIDATE_POSTGIS_DEPENDENCIES, + ExtensionSetup::new( + true, + None, + CANDIDATE_POSTGIS_LOAD_SQL, + CANDIDATE_POSTGIS_POST_CREATE_SQL, + ), +); + const CANDIDATE_SEG_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_SEG_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_SEG_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_SEG_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_SEG: Extension = Extension::new( "seg", "seg", "extensions/seg.tar.zst", + CANDIDATE_SEG_NATIVE_SUPPORT_MODULES, Some("seg.so"), Some("extension:seg"), CANDIDATE_SEG_DEPENDENCIES, @@ -603,11 +698,13 @@ pub(crate) const CANDIDATE_SEG: Extension = Extension::new( const CANDIDATE_TABLEFUNC_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_TABLEFUNC_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_TABLEFUNC_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_TABLEFUNC_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_TABLEFUNC: Extension = Extension::new( "tablefunc", "tablefunc", "extensions/tablefunc.tar.zst", + CANDIDATE_TABLEFUNC_NATIVE_SUPPORT_MODULES, Some("tablefunc.so"), Some("extension:tablefunc"), CANDIDATE_TABLEFUNC_DEPENDENCIES, @@ -622,11 +719,13 @@ pub(crate) const CANDIDATE_TABLEFUNC: Extension = Extension::new( const CANDIDATE_TCN_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_TCN_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_TCN_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_TCN_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_TCN: Extension = Extension::new( "tcn", "tcn", "extensions/tcn.tar.zst", + CANDIDATE_TCN_NATIVE_SUPPORT_MODULES, Some("tcn.so"), Some("extension:tcn"), CANDIDATE_TCN_DEPENDENCIES, @@ -641,11 +740,14 @@ pub(crate) const CANDIDATE_TCN: Extension = Extension::new( const CANDIDATE_TSM_SYSTEM_ROWS_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_TSM_SYSTEM_ROWS_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_TSM_SYSTEM_ROWS_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_TSM_SYSTEM_ROWS_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_TSM_SYSTEM_ROWS: Extension = Extension::new( "tsm_system_rows", "tsm_system_rows", "extensions/tsm_system_rows.tar.zst", + CANDIDATE_TSM_SYSTEM_ROWS_NATIVE_SUPPORT_MODULES, Some("tsm_system_rows.so"), Some("extension:tsm_system_rows"), CANDIDATE_TSM_SYSTEM_ROWS_DEPENDENCIES, @@ -660,11 +762,14 @@ pub(crate) const CANDIDATE_TSM_SYSTEM_ROWS: Extension = Extension::new( const CANDIDATE_TSM_SYSTEM_TIME_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_TSM_SYSTEM_TIME_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_TSM_SYSTEM_TIME_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_TSM_SYSTEM_TIME_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = + EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_TSM_SYSTEM_TIME: Extension = Extension::new( "tsm_system_time", "tsm_system_time", "extensions/tsm_system_time.tar.zst", + CANDIDATE_TSM_SYSTEM_TIME_NATIVE_SUPPORT_MODULES, Some("tsm_system_time.so"), Some("extension:tsm_system_time"), CANDIDATE_TSM_SYSTEM_TIME_DEPENDENCIES, @@ -679,11 +784,13 @@ pub(crate) const CANDIDATE_TSM_SYSTEM_TIME: Extension = Extension::new( const CANDIDATE_UNACCENT_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_UNACCENT_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_UNACCENT_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_UNACCENT_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_UNACCENT: Extension = Extension::new( "unaccent", "unaccent", "extensions/unaccent.tar.zst", + CANDIDATE_UNACCENT_NATIVE_SUPPORT_MODULES, Some("unaccent.so"), Some("extension:unaccent"), CANDIDATE_UNACCENT_DEPENDENCIES, @@ -695,14 +802,37 @@ pub(crate) const CANDIDATE_UNACCENT: Extension = Extension::new( ), ); +const CANDIDATE_UUID_OSSP_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; +const CANDIDATE_UUID_OSSP_LOAD_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_UUID_OSSP_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_UUID_OSSP_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; + +pub(crate) const CANDIDATE_UUID_OSSP: Extension = Extension::new( + "uuid-ossp", + "uuid-ossp", + "extensions/uuid-ossp.tar.zst", + CANDIDATE_UUID_OSSP_NATIVE_SUPPORT_MODULES, + Some("uuid-ossp.so"), + Some("extension:uuid-ossp"), + CANDIDATE_UUID_OSSP_DEPENDENCIES, + ExtensionSetup::new( + true, + Some("pg_catalog"), + CANDIDATE_UUID_OSSP_LOAD_SQL, + CANDIDATE_UUID_OSSP_POST_CREATE_SQL, + ), +); + const CANDIDATE_VECTOR_DEPENDENCIES: &[&str] = EMPTY_SQL_NAMES; const CANDIDATE_VECTOR_LOAD_SQL: &[&str] = EMPTY_SQL; const CANDIDATE_VECTOR_POST_CREATE_SQL: &[&str] = EMPTY_SQL; +const CANDIDATE_VECTOR_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES; pub(crate) const CANDIDATE_VECTOR: Extension = Extension::new( "pgvector", "vector", "extensions/vector.tar.zst", + CANDIDATE_VECTOR_NATIVE_SUPPORT_MODULES, Some("vector.so"), Some("extension:vector"), CANDIDATE_VECTOR_DEPENDENCIES, @@ -714,7 +844,6 @@ pub(crate) const CANDIDATE_VECTOR: Extension = Extension::new( ), ); -pub const AGE: Extension = CANDIDATE_AGE; pub const AMCHECK: Extension = CANDIDATE_AMCHECK; pub const AUTO_EXPLAIN: Extension = CANDIDATE_AUTO_EXPLAIN; pub const BLOOM: Extension = CANDIDATE_BLOOM; @@ -743,17 +872,19 @@ pub const PG_TRGM: Extension = CANDIDATE_PG_TRGM; pub const PG_UUIDV7: Extension = CANDIDATE_PG_UUIDV7; pub const PG_VISIBILITY: Extension = CANDIDATE_PG_VISIBILITY; pub const PG_WALINSPECT: Extension = CANDIDATE_PG_WALINSPECT; +pub const PGCRYPTO: Extension = CANDIDATE_PGCRYPTO; pub const PGTAP: Extension = CANDIDATE_PGTAP; +pub const POSTGIS: Extension = CANDIDATE_POSTGIS; pub const SEG: Extension = CANDIDATE_SEG; pub const TABLEFUNC: Extension = CANDIDATE_TABLEFUNC; pub const TCN: Extension = CANDIDATE_TCN; pub const TSM_SYSTEM_ROWS: Extension = CANDIDATE_TSM_SYSTEM_ROWS; pub const TSM_SYSTEM_TIME: Extension = CANDIDATE_TSM_SYSTEM_TIME; pub const UNACCENT: Extension = CANDIDATE_UNACCENT; +pub const UUID_OSSP: Extension = CANDIDATE_UUID_OSSP; pub const VECTOR: Extension = CANDIDATE_VECTOR; pub const ALL: &[Extension] = &[ - AGE, AMCHECK, AUTO_EXPLAIN, BLOOM, @@ -782,17 +913,19 @@ pub const ALL: &[Extension] = &[ PG_UUIDV7, PG_VISIBILITY, PG_WALINSPECT, + PGCRYPTO, PGTAP, + POSTGIS, SEG, TABLEFUNC, TCN, TSM_SYSTEM_ROWS, TSM_SYSTEM_TIME, UNACCENT, + UUID_OSSP, VECTOR, ]; pub(crate) const CANDIDATES: &[Extension] = &[ - CANDIDATE_AGE, CANDIDATE_AMCHECK, CANDIDATE_AUTO_EXPLAIN, CANDIDATE_BLOOM, @@ -821,12 +954,15 @@ pub(crate) const CANDIDATES: &[Extension] = &[ CANDIDATE_PG_UUIDV7, CANDIDATE_PG_VISIBILITY, CANDIDATE_PG_WALINSPECT, + CANDIDATE_PGCRYPTO, CANDIDATE_PGTAP, + CANDIDATE_POSTGIS, CANDIDATE_SEG, CANDIDATE_TABLEFUNC, CANDIDATE_TCN, CANDIDATE_TSM_SYSTEM_ROWS, CANDIDATE_TSM_SYSTEM_TIME, CANDIDATE_UNACCENT, + CANDIDATE_UUID_OSSP, CANDIDATE_VECTOR, ]; diff --git a/src/pglite/interface.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/interface.rs similarity index 100% rename from src/pglite/interface.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/interface.rs diff --git a/src/pglite/mod.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs similarity index 63% rename from src/pglite/mod.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs index 756516ee..456ff74f 100644 --- a/src/pglite/mod.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod builder; pub(crate) mod client; pub(crate) mod config; pub(crate) mod data_dir; +pub(crate) mod engine; pub(crate) mod errors; #[cfg(feature = "extensions")] pub mod extensions; @@ -23,17 +24,20 @@ pub(crate) mod transport; pub(crate) mod types; pub(crate) mod wire; +#[cfg(feature = "bundled")] +pub use assets::{AssetManifestMetadata, asset_manifest_metadata}; pub use base::{ - InstallOptions, InstallOutcome, MountInfo, PgDataTemplate, PgDataTemplateManifest, PglitePaths, - build_pgdata_template, ensure_cluster, install_and_init, install_and_init_in, install_default, - install_extension_archive, install_extension_bytes, install_into, install_with_options, - preload_runtime_module, + InstallOptions, InstallOutcome, MountInfo, OliphauntPaths, PgDataTemplate, + PgDataTemplateManifest, build_pgdata_template, ensure_cluster, install_and_init, + install_and_init_in, install_default, install_extension_archive, install_extension_bytes, + install_into, install_with_options, preload_runtime_module, }; -pub use builder::PgliteBuilder; -pub use client::{GlobalListenerHandle, ListenerHandle, Pglite, Transaction}; +pub use builder::OliphauntBuilder; +pub use client::{GlobalListenerHandle, ListenerHandle, Oliphaunt, Transaction}; pub use config::PostgresConfig; pub use data_dir::DataDirArchiveFormat; -pub use errors::PgliteError; +pub use engine::EngineCapabilities; +pub use errors::OliphauntError; pub use interface::{ DataTransferContainer, DebugLevel, DescribeQueryParam, DescribeQueryResult, DescribeResultField, ExecProtocolOptions, ExecProtocolResult, FieldInfo, NoticeCallback, @@ -44,9 +48,9 @@ pub use pg_dump::PgDumpOptions; #[doc(hidden)] pub use postgres_mod::{FsTraceSnapshot, fs_trace_snapshot, reset_fs_trace}; pub use proxy::{ - PgliteProxy, ProtocolStatsSnapshot, disable_protocol_stats, protocol_stats_snapshot, + OliphauntProxy, ProtocolStatsSnapshot, disable_protocol_stats, protocol_stats_snapshot, reset_protocol_stats, }; -pub use server::{PgliteServer, PgliteServerBuilder}; +pub use server::{OliphauntServer, OliphauntServerBuilder}; pub use templating::{QueryTemplate, TemplatedQuery, format_query, quote_identifier}; pub use timing::{PhaseTiming, capture_phase_timings, measure_phase, record_phase_timing}; diff --git a/src/pglite/parse.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/parse.rs similarity index 65% rename from src/pglite/parse.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/parse.rs index fd77b757..885284c4 100644 --- a/src/pglite/parse.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/parse.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use serde_json::Value; -use crate::pglite::interface::{FieldInfo, ParserMap, QueryOptions, Results, RowMode}; -use crate::pglite::types::ParserLookup; +use crate::oliphaunt::interface::{FieldInfo, ParserMap, QueryOptions, Results, RowMode}; +use crate::oliphaunt::types::ParserLookup; use crate::protocol::messages::{ BackendMessage, CommandCompleteMessage, DataRowMessage, RowDescriptionMessage, }; @@ -14,7 +14,12 @@ pub fn parse_results( options: Option<&QueryOptions>, blob: Option>, ) -> Vec { - let mut result_sets: Vec = Vec::new(); + let expected_result_sets = messages + .iter() + .filter(|message| matches!(message, BackendMessage::CommandComplete(_))) + .count() + .max(1); + let mut result_sets: Vec = Vec::with_capacity(expected_result_sets); let mut current_fields: Vec = Vec::new(); let mut current_rows: Vec = Vec::new(); let mut affected_rows = 0usize; @@ -119,12 +124,58 @@ fn parse_cell(value: Option<&str>, type_id: i32, parsers: &ParserLookup) -> Valu } fn retrieve_row_count(msg: &CommandCompleteMessage) -> usize { - let parts: Vec<&str> = msg.text.split(' ').collect(); - match parts.first().copied() { - Some("INSERT") => parts.get(2).and_then(|v| v.parse().ok()).unwrap_or(0), - Some("UPDATE") | Some("DELETE") | Some("COPY") | Some("MERGE") => { - parts.get(1).and_then(|v| v.parse().ok()).unwrap_or(0) + command_tag_row_count(msg.text.as_bytes()) +} + +pub(crate) fn command_tag_row_count(text: &[u8]) -> usize { + if text.starts_with(b"INSERT ") + || text.starts_with(b"UPDATE ") + || text.starts_with(b"DELETE ") + || text.starts_with(b"COPY ") + || text.starts_with(b"MERGE ") + { + parse_decimal_suffix(text).unwrap_or(0) + } else { + 0 + } +} + +fn parse_decimal_suffix(text: &[u8]) -> Option { + let mut start = text.len(); + while start > 0 && text[start - 1].is_ascii_digit() { + start -= 1; + } + if start == text.len() { + return None; + } + + let mut value = 0usize; + for digit in &text[start..] { + value = value + .checked_mul(10)? + .checked_add(usize::from(digit - b'0'))?; + } + Some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn command(text: &str) -> CommandCompleteMessage { + CommandCompleteMessage { + length: text.len() + 5, + text: text.to_owned(), } - _ => 0, + } + + #[test] + fn retrieves_row_counts_from_command_tags() { + assert_eq!(retrieve_row_count(&command("INSERT 0 25")), 25); + assert_eq!(retrieve_row_count(&command("UPDATE 10")), 10); + assert_eq!(retrieve_row_count(&command("DELETE 3")), 3); + assert_eq!(retrieve_row_count(&command("COPY 42")), 42); + assert_eq!(retrieve_row_count(&command("MERGE 7")), 7); + assert_eq!(retrieve_row_count(&command("CREATE TABLE")), 0); } } diff --git a/src/pglite/pg_dump.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs similarity index 93% rename from src/pglite/pg_dump.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs index f9a9e1d8..7dcd7e1b 100644 --- a/src/pglite/pg_dump.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/pg_dump.rs @@ -24,9 +24,9 @@ use wasmer_wasix::virtual_net::{ }; use wasmer_wasix::{LocalNetworking, PluggableRuntime, VirtualFile}; -use crate::pglite::sync_host_fs::SyncHostFileSystem; -use crate::pglite::timing; -use crate::pglite::{aot, assets}; +use crate::oliphaunt::sync_host_fs::SyncHostFileSystem; +use crate::oliphaunt::timing; +use crate::oliphaunt::{aot, assets}; /// Options for the bundled WASIX `pg_dump` runner. #[derive(Debug, Clone, PartialEq, Eq)] @@ -104,7 +104,7 @@ impl PgDumpOptions { fn validate_passthrough_arg(arg: &str) -> Result<()> { if let Some(flag) = disallowed_pg_dump_flag(arg) { anyhow::bail!( - "pg_dump argument '{arg}' conflicts with pglite-oxide's managed {flag}; use PgDumpOptions typed setters where available" + "pg_dump argument '{arg}' conflicts with oliphaunt-wasix's managed {flag}; use PgDumpOptions typed setters where available" ); } Ok(()) @@ -292,7 +292,7 @@ where .context("run WASIX pg_dump")?; } - { + let sql = { let _phase = timing::phase("pg_dump.read_output"); match std::fs::read_to_string(fs_root.path().join("out.sql")) { Ok(sql) => Ok(sql), @@ -316,7 +316,30 @@ where ) }), } + }?; + Ok(strip_pg_dump_restrict_meta_commands(sql)) +} + +fn strip_pg_dump_restrict_meta_commands(script: String) -> String { + let mut stripped = String::with_capacity(script.len()); + for line in script.split_inclusive('\n') { + let body = line.trim_end_matches(['\r', '\n']); + if is_pg_dump_restrict_meta_command(body) { + continue; + } + stripped.push_str(line); } + stripped +} + +fn is_pg_dump_restrict_meta_command(line: &str) -> bool { + let Some(key) = line + .strip_prefix("\\restrict ") + .or_else(|| line.strip_prefix("\\unrestrict ")) + else { + return false; + }; + !key.is_empty() && key.bytes().all(|byte| byte.is_ascii_alphanumeric()) } const DIRECT_PG_DUMP_PORT: u16 = 65_432; @@ -670,9 +693,9 @@ impl Seek for CaptureFile { #[cfg(all(test, feature = "extensions"))] mod tests { use super::*; - use crate::pglite::Pglite; - use crate::pglite::extensions; - use crate::pglite::server::PgliteServer; + use crate::oliphaunt::Oliphaunt; + use crate::oliphaunt::extensions; + use crate::oliphaunt::server::OliphauntServer; use serde_json::json; use sqlx::{Connection, Executor, Row}; @@ -708,7 +731,7 @@ mod tests { .validate() .expect_err("managed pg_dump arg should be rejected"); assert!( - err.to_string().contains("conflicts with pglite-oxide"), + err.to_string().contains("conflicts with oliphaunt-wasix"), "unexpected error for {arg}: {err:#}" ); } @@ -728,12 +751,24 @@ mod tests { .validate() } + #[test] + fn pg_dump_sql_strips_only_pg18_restrict_meta_commands() { + let script = "\\restrict AbC123\n\ + CREATE TABLE public.items(id integer);\n\ + \\copy public.items FROM stdin\n\ + \\unrestrict AbC123\n"; + assert_eq!( + strip_pg_dump_restrict_meta_commands(script.to_owned()), + "CREATE TABLE public.items(id integer);\n\\copy public.items FROM stdin\n" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pg_dump_round_trip_plain_sql() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.database_url()) .await - .context("connect to PGlite server")?; + .context("connect to Oliphaunt server")?; conn.execute( "CREATE TABLE dump_items(id INTEGER PRIMARY KEY, value TEXT); CREATE INDEX dump_items_value_idx ON dump_items(value); @@ -797,7 +832,7 @@ mod tests { server.shutdown()?; tokio::task::spawn_blocking(move || -> Result<()> { - let mut restored = Pglite::builder().temporary().open()?; + let mut restored = Oliphaunt::builder().temporary().open()?; restored.exec(&dump, None).context("restore pg_dump SQL")?; let result = restored.query( "SELECT value FROM public.dump_items WHERE id = $1", @@ -832,13 +867,13 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pg_dump_round_trip_vector_extension() -> Result<()> { - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .extension(extensions::VECTOR) .start()?; let mut conn = sqlx::PgConnection::connect(&server.database_url()) .await - .context("connect to extension-enabled PGlite server")?; + .context("connect to extension-enabled Oliphaunt server")?; conn.execute( "CREATE TABLE vector_dump_items(id INTEGER PRIMARY KEY, embedding vector(3)); INSERT INTO vector_dump_items(id, embedding) VALUES (1, '[1,2,3]');", @@ -863,7 +898,7 @@ mod tests { assert!(dump.contains("'[1,2,3]'")); tokio::task::spawn_blocking(move || -> Result<()> { - let mut restored = Pglite::builder() + let mut restored = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; @@ -892,7 +927,7 @@ mod tests { #[test] fn direct_pg_dump_public_api_round_trip() -> Result<()> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; db.exec("CREATE TABLE direct_dump_items(value TEXT)", None)?; db.exec("INSERT INTO direct_dump_items VALUES ('alpha')", None)?; @@ -916,7 +951,7 @@ mod tests { )?; assert_eq!(source_still_usable.rows[0]["count"], json!(1)); - let mut restored = Pglite::temporary()?; + let mut restored = Oliphaunt::temporary()?; restored.exec(&dump, None)?; let result = restored.query("SELECT value FROM public.direct_dump_items", &[], None)?; assert_eq!(result.rows[0]["value"], json!("alpha")); @@ -928,7 +963,7 @@ mod tests { #[test] fn direct_pg_dump_round_trip_vector_extension() -> Result<()> { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; @@ -942,7 +977,7 @@ mod tests { assert!(dump.contains("CREATE EXTENSION IF NOT EXISTS vector")); assert!(dump.contains("CREATE TABLE public.direct_vector_dump_items")); - let mut restored = Pglite::builder() + let mut restored = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; diff --git a/src/pglite/postgres_mod.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs similarity index 65% rename from src/pglite/postgres_mod.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs index d4c4d2e5..99792b77 100644 --- a/src/pglite/postgres_mod.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs @@ -1,21 +1,15 @@ #[cfg(debug_assertions)] use std::cell::Cell; -use std::collections::{HashSet, VecDeque}; +use std::collections::HashSet; use std::fmt; use std::fs; use std::future::Future; -use std::io::{self, Read, Write}; -use std::path::{Component, Path, PathBuf}; -use std::pin::Pin; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; -use std::task::{Context as TaskContext, Poll}; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::{Context, Result, ensure}; -use serde::Serialize; use sha2::{Digest, Sha256}; -use tokio::io::ReadBuf; use tokio::runtime::Runtime as TokioRuntime; use tracing::{debug, warn}; use wasmer::{Engine, Instance, Module, Store, TypedFunction, WasmTypeList}; @@ -35,173 +29,32 @@ use webc::metadata::Command as WebcCommand; use webc::metadata::annotations::{WASI_RUNNER_URI, Wasi}; use super::aot; -use super::base::{PglitePaths, RuntimeLayout}; +use super::base::{OliphauntPaths, RuntimeLayout}; use super::config::{PostgresConfig, StartupConfig}; #[cfg(feature = "extensions")] use super::extensions::Extension; -use super::sync_host_fs::SyncHostFileSystem; use super::timing; -const PGLITE_EXE_PATH: &str = "/bin/pglite"; +mod stdio; +mod wasix_fs; + +pub(crate) use stdio::ProtocolStream; +use stdio::{ProtocolStdioAttachment, ProtocolStdioFile, TailCaptureFile, TailCaptureHandle}; +use wasix_fs::{ + EagerCopyOverlayFileSystem, host_filesystem, maybe_trace_filesystem, wasi_root_with_devices, +}; +pub use wasix_fs::{FsTraceSnapshot, fs_trace_snapshot, reset_fs_trace}; + +const OLIPHAUNT_EXE_PATH: &str = "/bin/oliphaunt"; const PGDATA_DIR: &str = "/base"; const WASM_PREFIX: &str = "/"; const RUNTIME_SIDE_MODULES: &[(&str, &str)] = &[ ("plpgsql.so", "runtime-support:plpgsql"), ("dict_snowball.so", "runtime-support:dict_snowball"), ]; -const PGLITE_EXIT_ALIVE: i32 = 99; +const OLIPHAUNT_EXIT_ALIVE: i32 = 99; const POSTGRES_MAIN_LONGJMP: i32 = 100; -const WASIX_DEVICE_FILES: &[&str] = &[ - "null", "zero", "urandom", "stdin", "stdout", "stderr", "tty", -]; - -#[derive(Debug, Default)] -struct TailCaptureState { - bytes: VecDeque, -} - -#[derive(Debug, Clone)] -struct TailCaptureFile { - inner: Arc>, - limit: usize, -} - -#[derive(Debug, Clone)] -struct TailCaptureHandle { - inner: Arc>, -} - -impl TailCaptureFile { - fn new(limit: usize) -> (Self, TailCaptureHandle) { - let inner = Arc::new(Mutex::new(TailCaptureState::default())); - ( - Self { - inner: inner.clone(), - limit, - }, - TailCaptureHandle { inner }, - ) - } - - fn push_tail(&self, bytes: &[u8]) { - let Ok(mut state) = self.inner.lock() else { - return; - }; - for byte in bytes { - state.bytes.push_back(*byte); - while state.bytes.len() > self.limit { - state.bytes.pop_front(); - } - } - } -} - -impl TailCaptureHandle { - fn text(&self) -> String { - let Ok(state) = self.inner.lock() else { - return "".to_owned(); - }; - let bytes = state.bytes.iter().copied().collect::>(); - String::from_utf8_lossy(&bytes).into_owned() - } -} - -impl virtual_fs::AsyncSeek for TailCaptureFile { - fn start_seek(self: Pin<&mut Self>, _position: io::SeekFrom) -> io::Result<()> { - Ok(()) - } - - fn poll_complete(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - Poll::Ready(Ok(0)) - } -} -impl virtual_fs::AsyncRead for TailCaptureFile { - fn poll_read( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - _buf: &mut ReadBuf<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } -} - -impl virtual_fs::AsyncWrite for TailCaptureFile { - fn poll_write( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - self.push_tail(buf); - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_write_vectored( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - bufs: &[io::IoSlice<'_>], - ) -> Poll> { - let mut total = 0; - for buf in bufs { - self.push_tail(buf); - total += buf.len(); - } - Poll::Ready(Ok(total)) - } - - fn is_write_vectored(&self) -> bool { - true - } -} - -#[async_trait::async_trait] -impl virtual_fs::VirtualFile for TailCaptureFile { - fn last_accessed(&self) -> u64 { - 0 - } - - fn last_modified(&self) -> u64 { - 0 - } - - fn created_time(&self) -> u64 { - 0 - } - - fn size(&self) -> u64 { - self.inner - .lock() - .map(|state| state.bytes.len() as u64) - .unwrap_or(0) - } - - fn set_len(&mut self, _new_size: u64) -> virtual_fs::Result<()> { - Ok(()) - } - - fn unlink(&mut self) -> virtual_fs::Result<()> { - Ok(()) - } - - fn poll_read_ready(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - Poll::Ready(Ok(0)) - } - - fn poll_write_ready( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - ) -> Poll> { - Poll::Ready(Ok(self.limit)) - } -} const BACKEND_C_TIMINGS: &[(i32, &str)] = &[ (1, "postgres.backend.c.main_pre"), (2, "postgres.backend.c.restart_single_user_main"), @@ -251,143 +104,63 @@ const BACKEND_C_TIMINGS: &[(i32, &str)] = &[ (46, "postgres.backend.c.exec_finish_xact"), (47, "postgres.backend.c.exec_command_counter"), (48, "postgres.backend.c.exec_end_command"), + (49, "postgres.backend.c.heapam_tuple_update"), + (50, "postgres.backend.c.btree_doinsert"), + (51, "postgres.backend.c.xlog_insert_record"), + (52, "postgres.backend.c.btree_mkscankey"), + (53, "postgres.backend.c.btree_search_insert"), + (54, "postgres.backend.c.btree_check_unique"), + (55, "postgres.backend.c.btree_find_insertloc"), + (56, "postgres.backend.c.btree_insertonpg"), + (57, "postgres.backend.c.btree_split"), + (58, "postgres.backend.c.btree_binsrch_insert"), + (59, "postgres.backend.c.btree_compare"), + (60, "postgres.backend.c.heap_determine_columns"), + (61, "postgres.backend.c.heap_toast_update"), + (62, "postgres.backend.c.heap_get_buffer_for_tuple"), + (63, "postgres.backend.c.heap_put_tuple"), + (64, "postgres.backend.c.heap_log_update"), + (65, "postgres.backend.c.commit_record"), + (66, "postgres.backend.c.commit_procarray_end"), + (67, "postgres.backend.c.commit_callbacks"), + (68, "postgres.backend.c.commit_resource_before_locks"), + (69, "postgres.backend.c.commit_aio"), + (70, "postgres.backend.c.commit_buffers"), + (71, "postgres.backend.c.commit_relcache_typecache"), + (72, "postgres.backend.c.commit_inval"), + (73, "postgres.backend.c.commit_resource_locks"), + (74, "postgres.backend.c.commit_pending_deletes"), + (75, "postgres.backend.c.commit_notify"), + (76, "postgres.backend.c.commit_local_cleanup"), + (77, "postgres.backend.c.commit_memory"), + (78, "postgres.backend.c.commit_xlog_record"), + (79, "postgres.backend.c.commit_xlog_flush"), + (80, "postgres.backend.c.commit_clog_commit_tree"), + (81, "postgres.backend.c.commit_async_xact_lsn"), + (82, "postgres.backend.c.commit_async_commit_tree"), + (83, "postgres.backend.c.commit_sync_rep_wait"), + (84, "postgres.backend.c.xlog_write_pwrite"), + (85, "postgres.backend.c.xlog_write_pgstat_io"), + (86, "postgres.backend.c.xlog_flush_wait_insertions"), + (87, "postgres.backend.c.xlog_flush_wal_write_lock"), + (88, "postgres.backend.c.xlog_flush_xlog_write"), + (89, "postgres.backend.c.xlog_flush_walsnd_wakeup"), + (90, "postgres.backend.c.xlog_write_loop"), + (91, "postgres.backend.c.xlog_write_loop_scan"), + (92, "postgres.backend.c.xlog_write_before_pwrite"), + (93, "postgres.backend.c.xlog_write_after_pwrite"), + (94, "postgres.backend.c.xlog_write_fsync"), + (95, "postgres.backend.c.xlog_write_walsnd_request"), + (96, "postgres.backend.c.xlog_write_shared_status"), + (97, "postgres.backend.c.xlog_write_atomic_result"), + (98, "postgres.backend.c.xlog_write_loop_count"), + (99, "postgres.backend.c.xlog_write_group_count"), + (100, "postgres.backend.c.xlog_write_page_count"), + (101, "postgres.backend.c.xlog_write_pwrite_count"), + (102, "postgres.backend.c.xlog_write_pwrite_bytes"), + (103, "postgres.backend.c.xlog_write_request_bytes"), ]; -static FS_TRACE: FsTraceState = FsTraceState::new(); - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FsTraceSnapshot { - enabled: bool, - open_count: u64, - read_count: u64, - read_bytes: u64, - write_count: u64, - write_bytes: u64, - seek_count: u64, - metadata_count: u64, - read_dir_count: u64, - create_dir_count: u64, - remove_file_count: u64, - remove_dir_count: u64, - rename_count: u64, - set_len_count: u64, - unlink_count: u64, - total_elapsed_micros: u64, - read_elapsed_micros: u64, - write_elapsed_micros: u64, - seek_elapsed_micros: u64, -} - -struct FsTraceState { - open_count: AtomicU64, - read_count: AtomicU64, - read_bytes: AtomicU64, - write_count: AtomicU64, - write_bytes: AtomicU64, - seek_count: AtomicU64, - metadata_count: AtomicU64, - read_dir_count: AtomicU64, - create_dir_count: AtomicU64, - remove_file_count: AtomicU64, - remove_dir_count: AtomicU64, - rename_count: AtomicU64, - set_len_count: AtomicU64, - unlink_count: AtomicU64, - total_elapsed_micros: AtomicU64, - read_elapsed_micros: AtomicU64, - write_elapsed_micros: AtomicU64, - seek_elapsed_micros: AtomicU64, -} - -impl FsTraceState { - const fn new() -> Self { - Self { - open_count: AtomicU64::new(0), - read_count: AtomicU64::new(0), - read_bytes: AtomicU64::new(0), - write_count: AtomicU64::new(0), - write_bytes: AtomicU64::new(0), - seek_count: AtomicU64::new(0), - metadata_count: AtomicU64::new(0), - read_dir_count: AtomicU64::new(0), - create_dir_count: AtomicU64::new(0), - remove_file_count: AtomicU64::new(0), - remove_dir_count: AtomicU64::new(0), - rename_count: AtomicU64::new(0), - set_len_count: AtomicU64::new(0), - unlink_count: AtomicU64::new(0), - total_elapsed_micros: AtomicU64::new(0), - read_elapsed_micros: AtomicU64::new(0), - write_elapsed_micros: AtomicU64::new(0), - seek_elapsed_micros: AtomicU64::new(0), - } - } - - fn reset(&self) { - for counter in [ - &self.open_count, - &self.read_count, - &self.read_bytes, - &self.write_count, - &self.write_bytes, - &self.seek_count, - &self.metadata_count, - &self.read_dir_count, - &self.create_dir_count, - &self.remove_file_count, - &self.remove_dir_count, - &self.rename_count, - &self.set_len_count, - &self.unlink_count, - &self.total_elapsed_micros, - &self.read_elapsed_micros, - &self.write_elapsed_micros, - &self.seek_elapsed_micros, - ] { - counter.store(0, Ordering::Relaxed); - } - } - - fn record_total(&self, elapsed: Duration) { - self.total_elapsed_micros.fetch_add( - elapsed.as_micros().min(u64::MAX as u128) as u64, - Ordering::Relaxed, - ); - } - - fn snapshot(&self) -> FsTraceSnapshot { - FsTraceSnapshot { - enabled: fs_trace_enabled(), - open_count: self.open_count.load(Ordering::Relaxed), - read_count: self.read_count.load(Ordering::Relaxed), - read_bytes: self.read_bytes.load(Ordering::Relaxed), - write_count: self.write_count.load(Ordering::Relaxed), - write_bytes: self.write_bytes.load(Ordering::Relaxed), - seek_count: self.seek_count.load(Ordering::Relaxed), - metadata_count: self.metadata_count.load(Ordering::Relaxed), - read_dir_count: self.read_dir_count.load(Ordering::Relaxed), - create_dir_count: self.create_dir_count.load(Ordering::Relaxed), - remove_file_count: self.remove_file_count.load(Ordering::Relaxed), - remove_dir_count: self.remove_dir_count.load(Ordering::Relaxed), - rename_count: self.rename_count.load(Ordering::Relaxed), - set_len_count: self.set_len_count.load(Ordering::Relaxed), - unlink_count: self.unlink_count.load(Ordering::Relaxed), - total_elapsed_micros: self.total_elapsed_micros.load(Ordering::Relaxed), - read_elapsed_micros: self.read_elapsed_micros.load(Ordering::Relaxed), - write_elapsed_micros: self.write_elapsed_micros.load(Ordering::Relaxed), - seek_elapsed_micros: self.seek_elapsed_micros.load(Ordering::Relaxed), - } - } -} - -pub fn reset_fs_trace() { - FS_TRACE.reset(); -} - -pub fn fs_trace_snapshot() -> FsTraceSnapshot { - FS_TRACE.snapshot() -} static WASIX_PROCESS_RUNTIME: OnceLock, String>> = OnceLock::new(); static SEEDED_SIDE_MODULES: OnceLock>> = OnceLock::new(); @@ -410,14 +183,14 @@ pub struct PostgresMod { _instance: Instance, env: WasiFunctionEnv, guest_allocator: GuestAllocator, - io: WasixPgliteIo, - lifecycle: PgliteLifecycleExports, + io: WasixOliphauntIo, + lifecycle: OliphauntLifecycleExports, protocol: WasixProtocolExports, protocol_stdio: Option, protocol_stdio_file: ProtocolStdioFile, wasi_stderr: TailCaptureHandle, protocol_stdio_attachment: Option, - paths: PglitePaths, + paths: OliphauntPaths, pgdata_template_root: Option, startup_config: StartupConfig, startup_response: Option>, @@ -488,11 +261,11 @@ impl ProtocolTransportMode { } } -struct PgliteLifecycleExports { +struct OliphauntLifecycleExports { wasi_start: TypedFunction<(), ()>, set_force_host_error_recovery: Option>, set_active: TypedFunction, - start_pglite: TypedFunction<(), ()>, + start_oliphaunt: TypedFunction<(), ()>, #[cfg_attr(not(feature = "extensions"), allow(dead_code))] run_atexit_funcs: Option>, backend_timing_reset: Option>, @@ -516,7 +289,7 @@ struct WasixProtocolStdioExports { protocol_stream_active: TypedFunction<(), i32>, } -struct WasixPgliteIo { +struct WasixOliphauntIo { input_reset: TypedFunction<(), i32>, input_write: TypedFunction<(i32, i32), i32>, input_available: TypedFunction<(), i32>, @@ -534,276 +307,12 @@ struct GuestAllocator { frees: Cell, } -pub(crate) trait ProtocolStream: Read + Write + Send { - fn read_ready(&mut self) -> io::Result; -} - -#[derive(Clone)] -struct ProtocolStdioFile { - state: Arc, -} - -struct ProtocolStdioState { - inner: Mutex, -} - -#[derive(Default)] -struct ProtocolStdioInner { - stream: Option>, - prefix: Vec, - prefix_offset: usize, -} - -struct ProtocolStdioAttachment { - file: ProtocolStdioFile, -} - -impl ProtocolStdioFile { - fn new() -> Self { - Self { - state: Arc::new(ProtocolStdioState { - inner: Mutex::new(ProtocolStdioInner::default()), - }), - } - } - - fn attach(&self, stream: S) -> Result - where - S: ProtocolStream + 'static, - { - let mut guard = self - .state - .inner - .lock() - .map_err(|_| anyhow::anyhow!("protocol stdio lock poisoned"))?; - ensure!( - guard.stream.is_none(), - "WASIX protocol stdio stream is already attached" - ); - guard.stream = Some(Box::new(stream)); - guard.prefix.clear(); - guard.prefix_offset = 0; - Ok(ProtocolStdioAttachment { file: self.clone() }) - } - - fn detach(&self) { - if let Ok(mut guard) = self.state.inner.lock() { - guard.stream = None; - guard.prefix.clear(); - guard.prefix_offset = 0; - } - } - - fn set_prefix(&self, prefix: Vec) -> Result<()> { - let mut guard = self - .state - .inner - .lock() - .map_err(|_| anyhow::anyhow!("protocol stdio lock poisoned"))?; - guard.prefix = prefix; - guard.prefix_offset = 0; - Ok(()) - } - - fn clear_prefix(&self) -> Result<()> { - self.set_prefix(Vec::new()) - } - - fn with_inner( - &self, - f: impl FnOnce(&mut ProtocolStdioInner) -> io::Result, - ) -> io::Result { - let mut guard = self - .state - .inner - .lock() - .map_err(|_| io::Error::other("protocol stdio lock poisoned"))?; - f(&mut guard) - } -} - -impl Drop for ProtocolStdioAttachment { - fn drop(&mut self) { - self.file.detach(); - } -} - -impl fmt::Debug for ProtocolStdioFile { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ProtocolStdioFile").finish_non_exhaustive() - } -} - -impl virtual_fs::VirtualFile for ProtocolStdioFile { - fn last_accessed(&self) -> u64 { - 0 - } - - fn last_modified(&self) -> u64 { - 0 - } - - fn created_time(&self) -> u64 { - 0 - } - - fn size(&self) -> u64 { - 0 - } - - fn set_len(&mut self, _new_size: u64) -> virtual_fs::Result<()> { - Err(virtual_fs::FsError::PermissionDenied) - } - - fn unlink(&mut self) -> virtual_fs::Result<()> { - Ok(()) - } - - fn is_open(&self) -> bool { - self.state - .inner - .lock() - .map(|inner| inner.stream.is_some()) - .unwrap_or(false) - } - - fn poll_read_ready(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - match self.with_inner(|inner| { - if inner.prefix_offset < inner.prefix.len() { - return Ok(true); - } - let stream = inner.stream.as_mut().ok_or_else(|| { - io::Error::new( - io::ErrorKind::BrokenPipe, - "WASIX protocol stdio stream is not attached", - ) - })?; - stream.read_ready() - }) { - Ok(true) => Poll::Ready(Ok(1)), - Ok(false) => Poll::Pending, - Err(err) => Poll::Ready(Err(err)), - } - } - - fn poll_write_ready( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - ) -> Poll> { - match self.with_inner(|inner| { - if inner.stream.is_some() { - Ok(8192) - } else { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "WASIX protocol stdio stream is not attached", - )) - } - }) { - Ok(ready) => Poll::Ready(Ok(ready)), - Err(err) => Poll::Ready(Err(err)), - } - } -} - -impl virtual_fs::AsyncRead for ProtocolStdioFile { - fn poll_read( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - if buf.remaining() == 0 { - return Poll::Ready(Ok(())); - } - let read = self.with_inner(|inner| { - let unfilled = buf.initialize_unfilled(); - if inner.prefix_offset < inner.prefix.len() { - let remaining = &inner.prefix[inner.prefix_offset..]; - let read = remaining.len().min(unfilled.len()); - unfilled[..read].copy_from_slice(&remaining[..read]); - inner.prefix_offset += read; - if inner.prefix_offset == inner.prefix.len() { - inner.prefix.clear(); - inner.prefix_offset = 0; - } - return Ok(read); - } - let stream = inner.stream.as_mut().ok_or_else(|| { - io::Error::new( - io::ErrorKind::BrokenPipe, - "WASIX protocol stdio stream is not attached", - ) - })?; - stream.read(unfilled) - }); - match read { - Ok(read) => { - buf.advance(read); - Poll::Ready(Ok(())) - } - Err(err) => Poll::Ready(Err(err)), - } - } -} - -impl virtual_fs::AsyncWrite for ProtocolStdioFile { - fn poll_write( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - let written = self - .state - .inner - .lock() - .map_err(|_| io::Error::other("protocol stdio lock poisoned")) - .and_then(|mut inner| match inner.stream.as_mut() { - Some(stream) => stream.write(buf), - None => Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "WASIX protocol stdio stream is not attached", - )), - }); - Poll::Ready(written) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - let flushed = self - .state - .inner - .lock() - .map_err(|_| io::Error::other("protocol stdio lock poisoned")) - .and_then(|mut inner| match inner.stream.as_mut() { - Some(stream) => stream.flush(), - None => Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "WASIX protocol stdio stream is not attached", - )), - }); - Poll::Ready(flushed) - } - - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} - -impl virtual_fs::AsyncSeek for ProtocolStdioFile { - fn start_seek(self: Pin<&mut Self>, _position: io::SeekFrom) -> io::Result<()> { - Ok(()) - } - - fn poll_complete(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { - Poll::Ready(Ok(0)) - } -} - impl PostgresMod { pub(crate) fn preload_module(module_path: &std::path::Path) -> Result<()> { let runtime_root = module_path .parent() .and_then(Path::parent) - .context("runtime module path must be under bin/pglite")?; + .context("runtime module path must be under bin/oliphaunt")?; let (engine, _) = aot::load_runtime_module()?; let process_runtime = process_wasix_runtime(&engine)?; preload_runtime_side_modules( @@ -814,7 +323,10 @@ impl PostgresMod { ) } - pub(crate) fn new_prepared(paths: PglitePaths, runtime_layout: RuntimeLayout) -> Result { + pub(crate) fn new_prepared( + paths: OliphauntPaths, + runtime_layout: RuntimeLayout, + ) -> Result { Self::new_prepared_with_config( paths, runtime_layout, @@ -824,7 +336,7 @@ impl PostgresMod { } pub(crate) fn new_prepared_with_config( - paths: PglitePaths, + paths: OliphauntPaths, runtime_layout: RuntimeLayout, postgres_config: PostgresConfig, startup_config: StartupConfig, @@ -836,9 +348,9 @@ impl PostgresMod { let runtime_root = runtime_layout.local_root.clone(); let module_runtime_root = runtime_layout.module_root.clone(); ensure!( - module_runtime_root.join("bin/pglite").exists(), - "WASIX PGlite executable not found at {}", - module_runtime_root.join("bin/pglite").display() + module_runtime_root.join("bin/oliphaunt").exists(), + "WASIX Oliphaunt executable not found at {}", + module_runtime_root.join("bin/oliphaunt").display() ); let (engine, module) = aot::load_runtime_module()?; @@ -876,14 +388,20 @@ impl PostgresMod { startup_config: &startup_config, module: module.clone(), })?; - seed_exported_c_string_value(&mut store, &instance, &env, "my_exec_path", PGLITE_EXE_PATH)?; + seed_exported_c_string_value( + &mut store, + &instance, + &env, + "my_exec_path", + OLIPHAUNT_EXE_PATH, + )?; let (guest_allocator, io, lifecycle, protocol, protocol_stdio) = { let _phase = timing::phase("wasix.export_load"); let guest_allocator = GuestAllocator::load(&mut store, &instance)?; - let io = WasixPgliteIo::new(&mut store, &instance)?; - ensure_integrated_pglite_contract(&instance)?; - let lifecycle = PgliteLifecycleExports::load(&mut store, &instance)?; + let io = WasixOliphauntIo::new(&mut store, &instance)?; + ensure_integrated_oliphaunt_contract(&instance)?; + let lifecycle = OliphauntLifecycleExports::load(&mut store, &instance)?; let protocol = WasixProtocolExports::load(&mut store, &instance)?; let protocol_stdio = WasixProtocolStdioExports::load(&mut store, &instance)?; (guest_allocator, io, lifecycle, protocol, protocol_stdio) @@ -916,7 +434,7 @@ impl PostgresMod { Ok(pg) } - pub fn paths(&self) -> &PglitePaths { + pub fn paths(&self) -> &OliphauntPaths { &self.paths } @@ -959,18 +477,20 @@ impl PostgresMod { self.lifecycle .set_active .call(&mut self.store, 1) - .context("pgl_setPGliteActive(1)")?; + .context("oliphaunt_wasix_set_active(1)")?; } { let _phase = timing::phase("postgres.backend_start.single_user_main"); match self.lifecycle.wasi_start.call(&mut self.store) { Ok(()) => {} - Err(err) if runtime_error_exit_code(&err) == Some(PGLITE_EXIT_ALIVE) => {} - Err(err) => return self.startup_failure(err, "_start PGlite single-user backend"), + Err(err) if runtime_error_exit_code(&err) == Some(OLIPHAUNT_EXIT_ALIVE) => {} + Err(err) => { + return self.startup_failure(err, "_start Oliphaunt single-user backend"); + } } } - if let Err(err) = self.lifecycle.start_pglite.call(&mut self.store) { - return self.startup_failure(err, "pgl_startPGlite"); + if let Err(err) = self.lifecycle.start_oliphaunt.call(&mut self.store) { + return self.startup_failure(err, "oliphaunt_wasix_start"); } self.record_backend_c_timings()?; self.backend_started = true; @@ -982,7 +502,7 @@ impl PostgresMod { let Some(set_force) = &self.lifecycle.set_force_host_error_recovery else { if force { anyhow::bail!( - "WASIX runtime does not export pgl_set_force_host_error_recovery required by this host" + "WASIX runtime does not export oliphaunt_wasix_set_force_host_error_recovery required by this host" ); } return Ok(()); @@ -990,7 +510,7 @@ impl PostgresMod { set_force .call(&mut self.store, i32::from(force)) - .context("pgl_set_force_host_error_recovery")?; + .context("oliphaunt_wasix_set_force_host_error_recovery")?; Ok(()) } @@ -1042,11 +562,11 @@ impl PostgresMod { self.lifecycle .set_active .call(&mut self.store, 0) - .context("pgl_setPGliteActive(0)")?; + .context("oliphaunt_wasix_set_active(0)")?; if let Some(run_atexit_funcs) = &self.lifecycle.run_atexit_funcs { run_atexit_funcs .call(&mut self.store) - .context("pgl_run_atexit_funcs")?; + .context("oliphaunt_wasix_run_atexit_funcs")?; } self.backend_started = false; self.started = false; @@ -1063,7 +583,7 @@ impl PostgresMod { for &(id, name) in BACKEND_C_TIMINGS { let elapsed_micros = elapsed .call(&mut self.store, id) - .with_context(|| format!("pgl_backend_timing_elapsed_us({id})"))?; + .with_context(|| format!("oliphaunt_wasix_backend_timing_elapsed_us({id})"))?; if elapsed_micros > 0 { timing::record_phase_timing(name, Duration::from_micros(elapsed_micros as u64)); } @@ -1078,36 +598,39 @@ impl PostgresMod { reset .call(&mut self.store) - .context("pgl_backend_timing_reset")?; + .context("oliphaunt_wasix_backend_timing_reset")?; Ok(()) } #[cfg(feature = "extensions")] pub fn preload_extension_module(&self, extension: Extension) -> Result<()> { + let runtime_root = self.paths.runtime_root(); + for module in extension.native_support_modules() { + seed_extension_side_module( + &self.tokio_runtime, + &self.engine, + &self.wasix_module_cache, + &runtime_root, + module.runtime_path(), + module.aot_name(), + &format!( + "extension '{}' support module '{}'", + extension.sql_name(), + module.runtime_path() + ), + )?; + } + let Some(module_file) = extension.native_module_file() else { return Ok(()); }; - let Some(aot_name) = extension.aot_name() else { - return Ok(()); - }; - let runtime_root = self.paths.runtime_root(); - let library = runtime_root - .join("lib") - .join("postgresql") - .join(module_file); - ensure!( - library.exists(), - "extension library for '{}' is not installed at {}", - extension.sql_name(), - library.display() - ); - - seed_side_module_cache( + seed_extension_side_module( &self.tokio_runtime, &self.engine, &self.wasix_module_cache, - &library, - aot_name, + &runtime_root, + &format!("lib/postgresql/{module_file}"), + extension.aot_name(), &format!("extension '{}'", extension.sql_name()), )?; Ok(()) @@ -1115,41 +638,44 @@ impl PostgresMod { #[cfg(feature = "extensions")] pub(crate) fn preload_extension_module_from_paths( - paths: &PglitePaths, + paths: &OliphauntPaths, extension: Extension, ) -> Result<()> { + let (engine, _) = aot::load_runtime_module()?; + let process_runtime = process_wasix_runtime(&engine)?; + let runtime_root = paths.runtime_root(); + for module in extension.native_support_modules() { + seed_extension_side_module( + &process_runtime.tokio_runtime, + &engine, + &process_runtime.wasix_module_cache, + &runtime_root, + module.runtime_path(), + module.aot_name(), + &format!( + "extension '{}' support module '{}'", + extension.sql_name(), + module.runtime_path() + ), + )?; + } + let Some(module_file) = extension.native_module_file() else { return Ok(()); }; - let Some(aot_name) = extension.aot_name() else { - return Ok(()); - }; - let runtime_root = paths.runtime_root(); - let library = runtime_root - .join("lib") - .join("postgresql") - .join(module_file); - ensure!( - library.exists(), - "extension library for '{}' is not installed at {}", - extension.sql_name(), - library.display() - ); - - let (engine, _) = aot::load_runtime_module()?; - let process_runtime = process_wasix_runtime(&engine)?; - seed_side_module_cache( + seed_extension_side_module( &process_runtime.tokio_runtime, &engine, &process_runtime.wasix_module_cache, - &library, - aot_name, + &runtime_root, + &format!("lib/postgresql/{module_file}"), + extension.aot_name(), &format!("extension '{}'", extension.sql_name()), ) } pub(crate) fn run_split_initdb( - paths: &PglitePaths, + paths: &OliphauntPaths, runtime_layout: &RuntimeLayout, ) -> Result<()> { run_split_initdb(paths, runtime_layout) @@ -1285,7 +811,7 @@ impl PostgresMod { self.protocol .pq_flush .call(&mut self.store) - .context("pgl_pq_flush after protocol buffer")?; + .context("oliphaunt_wasix_pq_flush after protocol buffer")?; } let output = { let _phase = timing::phase("postgres.protocol.output_read"); @@ -1309,7 +835,7 @@ impl PostgresMod { self.reset_backend_c_timings()?; loop { if let Err(err) = self.protocol.main_loop.call(&mut self.store) { - if runtime_error_exit_code(&err) == Some(PGLITE_EXIT_ALIVE) { + if runtime_error_exit_code(&err) == Some(OLIPHAUNT_EXIT_ALIVE) { break; } if runtime_error_exit_code(&err) == Some(POSTGRES_MAIN_LONGJMP) { @@ -1337,7 +863,7 @@ impl PostgresMod { self.protocol .pq_flush .call(&mut self.store) - .context("pgl_pq_flush streaming protocol")?; + .context("oliphaunt_wasix_pq_flush streaming protocol")?; } self.record_backend_c_timings()?; Ok(()) @@ -1354,7 +880,7 @@ impl PostgresMod { let previous = stdio .set_protocol_transport .call(&mut self.store, mode as i32) - .context("pgl_set_protocol_transport")?; + .context("oliphaunt_wasix_set_protocol_transport")?; ProtocolTransportMode::from_i32(previous) } @@ -1362,7 +888,7 @@ impl PostgresMod { let current = self.set_protocol_transport(previous_mode)?; ensure!( current != previous_mode, - "pgl_set_protocol_transport restore observed unchanged current mode" + "oliphaunt_wasix_set_protocol_transport restore observed unchanged current mode" ); Ok(()) } @@ -1375,7 +901,7 @@ impl PostgresMod { Ok(stdio .protocol_stream_active .call(&mut self.store) - .context("pgl_protocol_stream_active")? + .context("oliphaunt_wasix_protocol_stream_active")? != 0) } @@ -1387,12 +913,12 @@ impl PostgresMod { let response = self.start_protocol_with_startup_packet(&startup)?; ensure!( response.accepted, - "PGlite WASIX startup packet was rejected: {}", + "Oliphaunt WASIX startup packet was rejected: {}", summarize_protocol(&response.output) ); ensure!( !protocol_response_contains_error(&response.output), - "PGlite WASIX startup packet returned an error: {}", + "Oliphaunt WASIX startup packet returned an error: {}", summarize_protocol(&response.output) ); Ok(()) @@ -1405,7 +931,7 @@ impl PostgresMod { self.ensure_cluster()?; ensure!( !self.started, - "PGlite WASIX protocol startup has already completed for this backend" + "Oliphaunt WASIX protocol startup has already completed for this backend" ); let _phase = timing::phase("postgres.startup_packet"); @@ -1427,9 +953,9 @@ impl PostgresMod { self.protocol .get_port .call(&mut self.store) - .context("pgl_getMyProcPort")? + .context("oliphaunt_wasix_get_proc_port")? }; - ensure!(port > 0, "pgl_getMyProcPort returned null"); + ensure!(port > 0, "oliphaunt_wasix_get_proc_port returned null"); let status = { let _phase = timing::phase("postgres.startup_packet.process_startup"); @@ -1455,14 +981,14 @@ impl PostgresMod { self.protocol .send_conn_data .call(&mut self.store) - .context("pgl_sendConnData")?; + .context("oliphaunt_wasix_send_conn_data")?; } { let _phase = timing::phase("postgres.startup_packet.pq_flush"); self.protocol .pq_flush .call(&mut self.store) - .context("pgl_pq_flush after startup")?; + .context("oliphaunt_wasix_pq_flush after startup")?; } { let _phase = timing::phase("postgres.startup_packet.output_read"); @@ -1533,7 +1059,7 @@ impl PostgresMod { self.protocol .pq_flush .call(&mut self.store) - .context("pgl_pq_flush after backend ErrorResponse recovery")?; + .context("oliphaunt_wasix_pq_flush after backend ErrorResponse recovery")?; let _ = self .io .take_output(&mut self.store, &self.env, &self.guest_allocator)?; @@ -1589,7 +1115,7 @@ struct WasixInstantiateInput<'a> { runtime: &'a TokioRuntime, wasix_runtime: &'a Arc, store: &'a mut Store, - paths: &'a PglitePaths, + paths: &'a OliphauntPaths, runtime_layout: &'a RuntimeLayout, postgres_config: &'a PostgresConfig, startup_config: &'a StartupConfig, @@ -1622,12 +1148,12 @@ fn instantiate_wasix_module( runner.with_stdin(Box::new(protocol_stdio_file.clone())); runner.with_stdout(Box::new(protocol_stdio_file.clone())); runner.with_stderr(Box::new(stderr_file)); - let wasi = Wasi::new(PGLITE_EXE_PATH); + let wasi = Wasi::new(OLIPHAUNT_EXE_PATH); let mut builder = { let _phase = timing::phase("wasix.instantiate.prepare_env"); runner .prepare_webc_env( - PGLITE_EXE_PATH, + OLIPHAUNT_EXE_PATH, &wasi, PackageOrHash::Hash(ModuleHash::random()), RuntimeOrEngine::Runtime(input.wasix_runtime.clone()), @@ -1639,14 +1165,14 @@ fn instantiate_wasix_module( let _phase = timing::phase("wasix.instantiate.pgdata_preopen"); add_pgdata_preopen(&mut builder)?; } - add_pglite_env(&mut builder, input.startup_config); - add_pglite_args(&mut builder, input.postgres_config, input.startup_config)?; + add_oliphaunt_env(&mut builder, input.startup_config); + add_oliphaunt_args(&mut builder, input.postgres_config, input.startup_config)?; { let _phase = timing::phase("wasix.instantiate.module"); builder .instantiate(input.module, input.store) - .context("instantiate PGlite WASIX module") + .context("instantiate Oliphaunt WASIX module") .map(|(instance, env)| (instance, env, protocol_stdio_file, stderr_capture)) } } @@ -1671,7 +1197,7 @@ fn host_wasi_root(runtime_root: &Path) -> Result { } fn mountfs_overlay_wasi_root( - paths: &PglitePaths, + paths: &OliphauntPaths, runtime_layout: &RuntimeLayout, ) -> Result { let _phase = timing::phase("wasix.mountfs_overlay_construct"); @@ -1693,7 +1219,7 @@ fn mountfs_overlay_wasi_root( } fn pgdata_overlay_filesystem( - paths: &PglitePaths, + paths: &OliphauntPaths, runtime_layout: &RuntimeLayout, ) -> Result>> { if let Some(pgdata_template_root) = &runtime_layout.pgdata_template_root { @@ -1714,511 +1240,6 @@ fn wasi_root_with_pgdata_mount( Ok(Arc::new(mount)) } -fn wasi_root_with_devices( - root: Arc, -) -> virtual_fs::Result> { - let devices: Arc = - Arc::new(virtual_fs::RootFileSystemBuilder::default().build_tmp_ext(&[])); - let root_with_default_dirs: Arc = - Arc::new(virtual_fs::OverlayFileSystem::new( - virtual_fs::ArcFileSystem::new(root), - [virtual_fs::ArcFileSystem::new(devices.clone())], - )); - let mount = virtual_fs::MountFileSystem::new(); - mount.mount(Path::new("/"), root_with_default_dirs)?; - for name in WASIX_DEVICE_FILES { - let path = Path::new("/dev").join(name); - mount.mount_with_source(&path, &path, devices.clone())?; - } - Ok(Arc::new(mount)) -} - -struct EagerCopyOverlayFileSystem { - upper_root: PathBuf, - lower_root: PathBuf, - overlay: - virtual_fs::OverlayFileSystem, -} - -impl fmt::Debug for EagerCopyOverlayFileSystem { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("EagerCopyOverlayFileSystem") - .field("upper_root", &self.upper_root) - .field("lower_root", &self.lower_root) - .finish_non_exhaustive() - } -} - -impl EagerCopyOverlayFileSystem { - fn new(upper_root: PathBuf, lower_root: PathBuf) -> Result { - fs::create_dir_all(&upper_root) - .with_context(|| format!("create PGDATA overlay upper {}", upper_root.display()))?; - let upper_root = upper_root.canonicalize().with_context(|| { - format!("canonicalize PGDATA overlay upper {}", upper_root.display()) - })?; - let lower_root = lower_root.canonicalize().with_context(|| { - format!("canonicalize PGDATA overlay lower {}", lower_root.display()) - })?; - let upper = virtual_fs::ArcFileSystem::new(host_filesystem(&upper_root)?); - let lower = virtual_fs::ArcFileSystem::new(host_filesystem(&lower_root)?); - Ok(Self { - upper_root, - lower_root, - overlay: virtual_fs::OverlayFileSystem::new(upper, [lower]), - }) - } - - fn ensure_upper_copy( - &self, - path: &Path, - conf: &virtual_fs::OpenOptionsConfig, - ) -> virtual_fs::Result<()> { - let Some(relative) = normalize_overlay_path(path)? else { - return Ok(()); - }; - - let upper = self.upper_root.join(&relative); - if upper.exists() { - return Ok(()); - } - - let lower = self.lower_root.join(&relative); - let metadata = match fs::symlink_metadata(&lower) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - if conf.create || conf.create_new { - self.ensure_upper_parent(&relative)?; - } - return Ok(()); - } - Err(err) => return Err(err.into()), - }; - - if conf.create_new { - return Err(virtual_fs::FsError::AlreadyExists); - } - if metadata.is_dir() { - return Ok(()); - } - if !metadata.is_file() { - return Err(virtual_fs::FsError::Unsupported); - } - - if let Some(parent) = upper.parent() { - fs::create_dir_all(parent).map_err(virtual_fs::FsError::from)?; - } - if conf.truncate && !conf.read && !conf.append { - fs::File::create(&upper).map_err(virtual_fs::FsError::from)?; - } else { - fs::copy(&lower, &upper).map_err(virtual_fs::FsError::from)?; - } - Ok(()) - } - - fn ensure_upper_parent(&self, relative: &Path) -> virtual_fs::Result<()> { - let Some(parent) = relative.parent() else { - return Ok(()); - }; - if parent.as_os_str().is_empty() { - return Ok(()); - } - - let upper_parent = self.upper_root.join(parent); - if upper_parent.is_dir() { - return Ok(()); - } - - let lower_parent = self.lower_root.join(parent); - let metadata = match fs::symlink_metadata(&lower_parent) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Err(virtual_fs::FsError::EntryNotFound); - } - Err(err) => return Err(err.into()), - }; - if !metadata.is_dir() { - return Err(virtual_fs::FsError::BaseNotDirectory); - } - - fs::create_dir_all(upper_parent).map_err(virtual_fs::FsError::from) - } -} - -impl virtual_fs::FileSystem for EagerCopyOverlayFileSystem { - fn readlink(&self, path: &Path) -> virtual_fs::Result { - self.overlay.readlink(path) - } - - fn read_dir(&self, path: &Path) -> virtual_fs::Result { - self.overlay.read_dir(path) - } - - fn create_dir(&self, path: &Path) -> virtual_fs::Result<()> { - if let Some(relative) = normalize_overlay_path(path)? { - self.ensure_upper_parent(&relative)?; - } - self.overlay.create_dir(path) - } - - fn create_symlink(&self, source: &Path, target: &Path) -> virtual_fs::Result<()> { - if let Some(relative) = normalize_overlay_path(target)? { - self.ensure_upper_parent(&relative)?; - } - self.overlay.create_symlink(source, target) - } - - fn remove_dir(&self, path: &Path) -> virtual_fs::Result<()> { - self.overlay.remove_dir(path) - } - - fn rename<'a>( - &'a self, - from: &'a Path, - to: &'a Path, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.ensure_upper_copy(from, &mutating_open_config())?; - if let Some(relative) = normalize_overlay_path(to)? { - self.ensure_upper_parent(&relative)?; - } - self.overlay.rename(from, to).await - }) - } - - fn metadata(&self, path: &Path) -> virtual_fs::Result { - self.overlay.metadata(path) - } - - fn symlink_metadata(&self, path: &Path) -> virtual_fs::Result { - self.overlay.symlink_metadata(path) - } - - fn remove_file(&self, path: &Path) -> virtual_fs::Result<()> { - self.overlay.remove_file(path) - } - - fn new_open_options(&self) -> virtual_fs::OpenOptions<'_> { - virtual_fs::OpenOptions::new(self) - } -} - -impl virtual_fs::FileOpener for EagerCopyOverlayFileSystem { - fn open( - &self, - path: &Path, - conf: &virtual_fs::OpenOptionsConfig, - ) -> virtual_fs::Result> { - if conf.would_mutate() { - self.ensure_upper_copy(path, conf)?; - } - virtual_fs::FileSystem::new_open_options(&self.overlay) - .options(conf.clone()) - .open(path) - } -} - -fn normalize_overlay_path(path: &Path) -> virtual_fs::Result> { - let mut relative = PathBuf::new(); - for component in path.components() { - match component { - Component::RootDir | Component::CurDir => {} - Component::Normal(part) => relative.push(part), - Component::ParentDir | Component::Prefix(_) => { - return Err(virtual_fs::FsError::PermissionDenied); - } - } - } - if relative.as_os_str().is_empty() { - Ok(None) - } else { - Ok(Some(relative)) - } -} - -fn mutating_open_config() -> virtual_fs::OpenOptionsConfig { - virtual_fs::OpenOptionsConfig { - read: true, - write: true, - create_new: false, - create: false, - append: false, - truncate: false, - } -} - -fn host_filesystem(host_path: &Path) -> Result> { - let host_fs = SyncHostFileSystem::new(host_path) - .with_context(|| format!("create host fs rooted at {}", host_path.display()))?; - Ok(Arc::new(host_fs) as Arc) -} - -fn fs_trace_enabled() -> bool { - env_flag_enabled("PGLITE_OXIDE_WASIX_FS_TRACE") -} - -fn env_flag_enabled(name: &str) -> bool { - let Some(value) = std::env::var_os(name) else { - return false; - }; - !matches!( - value.to_string_lossy().to_ascii_lowercase().as_str(), - "" | "0" | "false" | "off" | "no" - ) -} - -fn maybe_trace_filesystem( - inner: Arc, -) -> Arc { - if fs_trace_enabled() { - Arc::new(TracedFileSystem { inner }) as Arc - } else { - inner - } -} - -#[derive(Debug)] -struct TracedFileSystem { - inner: Arc, -} - -impl TracedFileSystem { - fn record(&self, counter: &AtomicU64, operation: impl FnOnce() -> T) -> T { - counter.fetch_add(1, Ordering::Relaxed); - let started = Instant::now(); - let result = operation(); - FS_TRACE.record_total(started.elapsed()); - result - } -} - -impl virtual_fs::FileSystem for TracedFileSystem { - fn readlink(&self, path: &Path) -> virtual_fs::Result { - self.record(&FS_TRACE.metadata_count, || self.inner.readlink(path)) - } - - fn read_dir(&self, path: &Path) -> virtual_fs::Result { - self.record(&FS_TRACE.read_dir_count, || self.inner.read_dir(path)) - } - - fn create_dir(&self, path: &Path) -> virtual_fs::Result<()> { - self.record(&FS_TRACE.create_dir_count, || self.inner.create_dir(path)) - } - - fn create_symlink(&self, source: &Path, target: &Path) -> virtual_fs::Result<()> { - self.record(&FS_TRACE.create_dir_count, || { - self.inner.create_symlink(source, target) - }) - } - - fn remove_dir(&self, path: &Path) -> virtual_fs::Result<()> { - self.record(&FS_TRACE.remove_dir_count, || self.inner.remove_dir(path)) - } - - fn rename<'a>( - &'a self, - from: &'a Path, - to: &'a Path, - ) -> Pin> + Send + 'a>> { - FS_TRACE.rename_count.fetch_add(1, Ordering::Relaxed); - Box::pin(async move { - let started = Instant::now(); - let result = self.inner.rename(from, to).await; - FS_TRACE.record_total(started.elapsed()); - result - }) - } - - fn metadata(&self, path: &Path) -> virtual_fs::Result { - self.record(&FS_TRACE.metadata_count, || self.inner.metadata(path)) - } - - fn symlink_metadata(&self, path: &Path) -> virtual_fs::Result { - self.record(&FS_TRACE.metadata_count, || { - self.inner.symlink_metadata(path) - }) - } - - fn remove_file(&self, path: &Path) -> virtual_fs::Result<()> { - self.record(&FS_TRACE.remove_file_count, || self.inner.remove_file(path)) - } - - fn new_open_options(&self) -> virtual_fs::OpenOptions<'_> { - virtual_fs::OpenOptions::new(self) - } -} - -impl virtual_fs::FileOpener for TracedFileSystem { - fn open( - &self, - path: &Path, - conf: &virtual_fs::OpenOptionsConfig, - ) -> virtual_fs::Result> { - FS_TRACE.open_count.fetch_add(1, Ordering::Relaxed); - let started = Instant::now(); - let file = virtual_fs::FileSystem::new_open_options(&self.inner) - .options(conf.clone()) - .open(path); - FS_TRACE.record_total(started.elapsed()); - file.map(|inner| Box::new(TracedVirtualFile { inner }) as _) - } -} - -#[derive(Debug)] -struct TracedVirtualFile { - inner: Box, -} - -impl virtual_fs::VirtualFile for TracedVirtualFile { - fn last_accessed(&self) -> u64 { - self.inner.last_accessed() - } - - fn last_modified(&self) -> u64 { - self.inner.last_modified() - } - - fn created_time(&self) -> u64 { - self.inner.created_time() - } - - fn set_times(&mut self, atime: Option, mtime: Option) -> virtual_fs::Result<()> { - self.inner.set_times(atime, mtime) - } - - fn size(&self) -> u64 { - self.inner.size() - } - - fn set_len(&mut self, new_size: u64) -> virtual_fs::Result<()> { - FS_TRACE.set_len_count.fetch_add(1, Ordering::Relaxed); - let started = Instant::now(); - let result = self.inner.set_len(new_size); - FS_TRACE.record_total(started.elapsed()); - result - } - - fn unlink(&mut self) -> virtual_fs::Result<()> { - FS_TRACE.unlink_count.fetch_add(1, Ordering::Relaxed); - let started = Instant::now(); - let result = self.inner.unlink(); - FS_TRACE.record_total(started.elapsed()); - result - } - - fn is_open(&self) -> bool { - self.inner.is_open() - } - - fn get_special_fd(&self) -> Option { - self.inner.get_special_fd() - } - - fn write_from_mmap(&mut self, offset: u64, len: u64) -> io::Result<()> { - self.inner.write_from_mmap(offset, len) - } - - fn poll_read_ready(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - let this = self.get_mut(); - Pin::new(&mut *this.inner).poll_read_ready(cx) - } - - fn poll_write_ready(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - let this = self.get_mut(); - Pin::new(&mut *this.inner).poll_write_ready(cx) - } -} - -impl virtual_fs::AsyncRead for TracedVirtualFile { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - let this = self.get_mut(); - let before = buf.filled().len(); - let started = Instant::now(); - let result = Pin::new(&mut *this.inner).poll_read(cx, buf); - if let Poll::Ready(Ok(())) = &result { - let bytes = buf.filled().len().saturating_sub(before) as u64; - FS_TRACE.read_count.fetch_add(1, Ordering::Relaxed); - FS_TRACE.read_bytes.fetch_add(bytes, Ordering::Relaxed); - let elapsed = started.elapsed(); - FS_TRACE.record_total(elapsed); - FS_TRACE.read_elapsed_micros.fetch_add( - elapsed.as_micros().min(u64::MAX as u128) as u64, - Ordering::Relaxed, - ); - } - result - } -} - -impl virtual_fs::AsyncWrite for TracedVirtualFile { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &[u8], - ) -> Poll> { - let this = self.get_mut(); - let started = Instant::now(); - let result = Pin::new(&mut *this.inner).poll_write(cx, buf); - if let Poll::Ready(Ok(bytes)) = &result { - FS_TRACE.write_count.fetch_add(1, Ordering::Relaxed); - FS_TRACE - .write_bytes - .fetch_add(*bytes as u64, Ordering::Relaxed); - let elapsed = started.elapsed(); - FS_TRACE.record_total(elapsed); - FS_TRACE.write_elapsed_micros.fetch_add( - elapsed.as_micros().min(u64::MAX as u128) as u64, - Ordering::Relaxed, - ); - } - result - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - let this = self.get_mut(); - Pin::new(&mut *this.inner).poll_flush(cx) - } - - fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - let this = self.get_mut(); - Pin::new(&mut *this.inner).poll_shutdown(cx) - } -} - -impl virtual_fs::AsyncSeek for TracedVirtualFile { - fn start_seek(self: Pin<&mut Self>, position: io::SeekFrom) -> io::Result<()> { - let this = self.get_mut(); - FS_TRACE.seek_count.fetch_add(1, Ordering::Relaxed); - let started = Instant::now(); - let result = Pin::new(&mut *this.inner).start_seek(position); - let elapsed = started.elapsed(); - FS_TRACE.record_total(elapsed); - FS_TRACE.seek_elapsed_micros.fetch_add( - elapsed.as_micros().min(u64::MAX as u128) as u64, - Ordering::Relaxed, - ); - result - } - - fn poll_complete(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - let this = self.get_mut(); - let started = Instant::now(); - let result = Pin::new(&mut *this.inner).poll_complete(cx); - if let Poll::Ready(Ok(_)) = &result { - let elapsed = started.elapsed(); - FS_TRACE.record_total(elapsed); - FS_TRACE.seek_elapsed_micros.fetch_add( - elapsed.as_micros().min(u64::MAX as u128) as u64, - Ordering::Relaxed, - ); - } - result - } -} - fn build_wasix_runtime( runtime: &TokioRuntime, engine: &Engine, @@ -2232,7 +1253,7 @@ fn build_wasix_runtime( Arc::new(wasix_runtime) } -fn run_split_initdb(paths: &PglitePaths, runtime_layout: &RuntimeLayout) -> Result<()> { +fn run_split_initdb(paths: &OliphauntPaths, runtime_layout: &RuntimeLayout) -> Result<()> { let _phase = timing::phase("initdb.split_wasix"); let initdb_module = runtime_layout.module_root.join("bin/initdb"); let postgres_module = runtime_layout.module_root.join("bin/postgres"); @@ -2265,7 +1286,7 @@ fn run_split_initdb(paths: &PglitePaths, runtime_layout: &RuntimeLayout) -> Resu &engine, &process_runtime.wasix_module_cache, &postgres_module, - "runtime:pglite", + "runtime:oliphaunt", "initdb child postgres command", )?; preload_runtime_side_modules( @@ -2343,7 +1364,7 @@ fn run_split_initdb(paths: &PglitePaths, runtime_layout: &RuntimeLayout) -> Resu } fn split_initdb_root_filesystem( - paths: &PglitePaths, + paths: &OliphauntPaths, runtime_layout: &RuntimeLayout, ) -> Result> { let root: Arc = @@ -2415,7 +1436,7 @@ fn run_package_command_with_root( Ok(()) } -fn split_initdb_diagnostics(paths: &PglitePaths, runtime_layout: &RuntimeLayout) -> String { +fn split_initdb_diagnostics(paths: &OliphauntPaths, runtime_layout: &RuntimeLayout) -> String { let pgdata_parent = paths.pgdata.parent().unwrap_or(&paths.pgdata); format!( "initdb diagnostics:\n layout_kind={:?}\n pgdata_host={}\n pgdata_parent={}\n runtime_root={}\n module_root={}\n pgdata_entries={}", @@ -2492,7 +1513,7 @@ fn split_initdb_binary_package( fs::read(postgres_module).with_context(|| format!("read {}", postgres_module.display()))?; let mut package_hash = Sha256::new(); - package_hash.update(b"pglite-oxide-split-initdb-package-v1\n"); + package_hash.update(b"oliphaunt-wasix-split-initdb-package-v1\n"); package_hash.update(&initdb_wasm); package_hash.update(&postgres_wasm); let package_hash: [u8; 32] = package_hash.finalize().into(); @@ -2569,15 +1590,39 @@ fn preload_installed_extension_side_modules( runtime_root: &Path, ) -> Result<()> { let _phase = timing::phase("wasix.seed_extension_side_modules"); - let lib_dir = runtime_root.join("lib/postgresql"); for extension in super::extensions::ALL { + for module in extension.native_support_modules() { + let library = runtime_root.join(module.runtime_path()); + if !library.exists() { + continue; + } + let Some(aot_name) = module.aot_name() else { + continue; + }; + seed_side_module_cache( + runtime, + engine, + module_cache, + &library, + aot_name, + &format!( + "installed extension '{}' support module '{}'", + extension.sql_name(), + module.runtime_path() + ), + )?; + } + let Some(module_file) = extension.native_module_file() else { continue; }; let Some(aot_name) = extension.aot_name() else { continue; }; - let library = lib_dir.join(module_file); + let library = runtime_root + .join("lib") + .join("postgresql") + .join(module_file); if !library.exists() { continue; } @@ -2593,6 +1638,28 @@ fn preload_installed_extension_side_modules( Ok(()) } +#[cfg(feature = "extensions")] +fn seed_extension_side_module( + runtime: &TokioRuntime, + engine: &Engine, + module_cache: &Arc, + runtime_root: &Path, + runtime_path: &str, + aot_name: Option<&'static str>, + label: &str, +) -> Result<()> { + let Some(aot_name) = aot_name else { + return Ok(()); + }; + let library = runtime_root.join(runtime_path); + ensure!( + library.exists(), + "{label} is not installed at {}", + library.display() + ); + seed_side_module_cache(runtime, engine, module_cache, &library, aot_name, label) +} + fn seed_side_module_cache( runtime: &TokioRuntime, engine: &Engine, @@ -2630,7 +1697,7 @@ fn seed_wasix_module_cache( } // Keep the process-wide seed check and SharedCache write atomic. Wasmer's - // shared cache is global to all concurrent PGlite instances in this process. + // shared cache is global to all concurrent Oliphaunt instances in this process. let module = { let _phase = timing::phase("wasix.seed_side_module.load_aot"); aot::load_artifact_module(engine, artifact_name)? @@ -2661,24 +1728,28 @@ where runtime.block_on(future) } -impl PgliteLifecycleExports { +impl OliphauntLifecycleExports { fn load(store: &mut Store, instance: &Instance) -> Result { let wasi_start = typed_export(store, instance, "_start")?; - let set_force_host_error_recovery = - optional_typed_export(store, instance, "pgl_set_force_host_error_recovery")?; - let set_active = typed_export(store, instance, "pgl_setPGliteActive")?; - let start_pglite = typed_export(store, instance, "pgl_startPGlite")?; - let run_atexit_funcs = optional_typed_export(store, instance, "pgl_run_atexit_funcs")?; + let set_force_host_error_recovery = optional_typed_export( + store, + instance, + "oliphaunt_wasix_set_force_host_error_recovery", + )?; + let set_active = typed_export(store, instance, "oliphaunt_wasix_set_active")?; + let start_oliphaunt = typed_export(store, instance, "oliphaunt_wasix_start")?; + let run_atexit_funcs = + optional_typed_export(store, instance, "oliphaunt_wasix_run_atexit_funcs")?; let backend_timing_reset = - optional_typed_export(store, instance, "pgl_backend_timing_reset")?; + optional_typed_export(store, instance, "oliphaunt_wasix_backend_timing_reset")?; let backend_timing_elapsed_us = - optional_typed_export(store, instance, "pgl_backend_timing_elapsed_us")?; + optional_typed_export(store, instance, "oliphaunt_wasix_backend_timing_elapsed_us")?; Ok(Self { wasi_start, set_force_host_error_recovery, set_active, - start_pglite, + start_oliphaunt, run_atexit_funcs, backend_timing_reset, backend_timing_elapsed_us, @@ -2688,10 +1759,10 @@ impl PgliteLifecycleExports { impl WasixProtocolExports { fn load(store: &mut Store, instance: &Instance) -> Result { - let get_port = typed_export(store, instance, "pgl_getMyProcPort")?; + let get_port = typed_export(store, instance, "oliphaunt_wasix_get_proc_port")?; let process_startup = typed_export(store, instance, "ProcessStartupPacket")?; - let send_conn_data = typed_export(store, instance, "pgl_sendConnData")?; - let pq_flush = typed_export(store, instance, "pgl_pq_flush")?; + let send_conn_data = typed_export(store, instance, "oliphaunt_wasix_send_conn_data")?; + let pq_flush = typed_export(store, instance, "oliphaunt_wasix_pq_flush")?; let pq_buffer_remaining_data = typed_export(store, instance, "pq_buffer_remaining_data")?; let main_loop = typed_export(store, instance, "PostgresMainLoopOnce")?; let send_ready = typed_export(store, instance, "PostgresSendReadyForQueryIfNecessary")?; @@ -2712,13 +1783,16 @@ impl WasixProtocolExports { impl WasixProtocolStdioExports { fn load(store: &mut Store, instance: &Instance) -> Result> { - let Some(set_protocol_transport) = - optional_typed_export::(store, instance, "pgl_set_protocol_transport")? + let Some(set_protocol_transport) = optional_typed_export::( + store, + instance, + "oliphaunt_wasix_set_protocol_transport", + )? else { return Ok(None); }; let protocol_stream_active = - typed_export::<(), i32>(store, instance, "pgl_protocol_stream_active")?; + typed_export::<(), i32>(store, instance, "oliphaunt_wasix_protocol_stream_active")?; Ok(Some(Self { set_protocol_transport, protocol_stream_active, @@ -2726,30 +1800,30 @@ impl WasixProtocolStdioExports { } } -fn ensure_integrated_pglite_contract(instance: &Instance) -> Result<()> { +fn ensure_integrated_oliphaunt_contract(instance: &Instance) -> Result<()> { for name in [ - "pgl_startPGlite", - "pgl_setPGliteActive", + "oliphaunt_wasix_start", + "oliphaunt_wasix_set_active", "PostgresMainLongJmp", ] { ensure!( instance.exports.get_function(name).is_ok() || instance.exports.get_function(&format!("_{name}")).is_ok(), - "WASIX runtime is missing integrated PGlite lifecycle export {name}" + "WASIX runtime is missing integrated Oliphaunt lifecycle export {name}" ); } Ok(()) } -impl WasixPgliteIo { +impl WasixOliphauntIo { fn new(store: &mut Store, instance: &Instance) -> Result { let io = Self { - input_reset: typed_export(store, instance, "pgl_wasix_input_reset")?, - input_write: typed_export(store, instance, "pgl_wasix_input_write")?, - input_available: typed_export(store, instance, "pgl_wasix_input_available")?, - output_reset: typed_export(store, instance, "pgl_wasix_output_reset")?, - output_len: typed_export(store, instance, "pgl_wasix_output_len")?, - output_read: typed_export(store, instance, "pgl_wasix_output_read")?, + input_reset: typed_export(store, instance, "oliphaunt_wasix_input_reset")?, + input_write: typed_export(store, instance, "oliphaunt_wasix_input_write")?, + input_available: typed_export(store, instance, "oliphaunt_wasix_input_available")?, + output_reset: typed_export(store, instance, "oliphaunt_wasix_output_reset")?, + output_len: typed_export(store, instance, "oliphaunt_wasix_output_len")?, + output_read: typed_export(store, instance, "oliphaunt_wasix_output_read")?, }; io.reset(store)?; Ok(io) @@ -2759,16 +1833,16 @@ impl WasixPgliteIo { ensure!( self.input_reset .call(&mut *store) - .context("pgl_wasix_input_reset")? + .context("oliphaunt_wasix_input_reset")? == 0, - "pgl_wasix_input_reset failed" + "oliphaunt_wasix_input_reset failed" ); ensure!( self.output_reset .call(&mut *store) - .context("pgl_wasix_output_reset")? + .context("oliphaunt_wasix_output_reset")? == 0, - "pgl_wasix_output_reset failed" + "oliphaunt_wasix_output_reset failed" ); Ok(()) } @@ -2786,11 +1860,11 @@ impl WasixPgliteIo { let written = allocator.with_bytes(store, env, bytes, |store, ptr| { self.input_write .call(&mut *store, ptr, bytes.len() as i32) - .context("pgl_wasix_input_write") + .context("oliphaunt_wasix_input_write") })?; ensure!( written == bytes.len() as i32, - "pgl_wasix_input_write wrote {written}, expected {}", + "oliphaunt_wasix_input_write wrote {written}, expected {}", bytes.len() ); Ok(()) @@ -2800,10 +1874,10 @@ impl WasixPgliteIo { let available = self .input_available .call(store) - .context("pgl_wasix_input_available")?; + .context("oliphaunt_wasix_input_available")?; ensure!( available >= 0, - "pgl_wasix_input_available returned negative length {available}" + "oliphaunt_wasix_input_available returned negative length {available}" ); Ok(available) } @@ -2817,10 +1891,10 @@ impl WasixPgliteIo { let len = self .output_len .call(&mut *store) - .context("pgl_wasix_output_len")?; + .context("oliphaunt_wasix_output_len")?; ensure!( len >= 0, - "pgl_wasix_output_len returned negative length {len}" + "oliphaunt_wasix_output_len returned negative length {len}" ); if len == 0 { return Ok(Vec::new()); @@ -2829,10 +1903,10 @@ impl WasixPgliteIo { let read = self .output_read .call(&mut *store, ptr, len) - .context("pgl_wasix_output_read")?; + .context("oliphaunt_wasix_output_read")?; ensure!( read >= 0 && read <= len, - "invalid pgl_wasix_output_read length {read}" + "invalid oliphaunt_wasix_output_read length {read}" ); let mut bytes = vec![0u8; read as usize]; @@ -2847,9 +1921,9 @@ impl WasixPgliteIo { ensure!( self.output_reset .call(&mut *store) - .context("pgl_wasix_output_reset after read")? + .context("oliphaunt_wasix_output_reset after read")? == 0, - "pgl_wasix_output_reset after read failed" + "oliphaunt_wasix_output_reset after read failed" ); Ok(bytes) } @@ -3002,7 +2076,7 @@ fn host_requires_process_exit_error_recovery() -> bool { cfg!(target_env = "msvc") } -fn add_pglite_env(builder: &mut wasmer_wasix::WasiEnvBuilder, startup_config: &StartupConfig) { +fn add_oliphaunt_env(builder: &mut wasmer_wasix::WasiEnvBuilder, startup_config: &StartupConfig) { for (key, value) in [ ("PREFIX", WASM_PREFIX), ("PGDATA", PGDATA_DIR), @@ -3016,12 +2090,13 @@ fn add_pglite_env(builder: &mut wasmer_wasix::WasiEnvBuilder, startup_config: &S ("TZ", "UTC"), ("PGTZ", "UTC"), ("PG_COLOR", "never"), + ("PROJ_DATA", "/share/proj"), ] { builder.add_env(key, value); } } -fn add_pglite_args( +fn add_oliphaunt_args( builder: &mut wasmer_wasix::WasiEnvBuilder, postgres_config: &PostgresConfig, startup_config: &StartupConfig, @@ -3060,15 +2135,19 @@ const DEFAULT_STARTUP_GUCS: &[(&str, &str)] = &[ ("search_path", "public"), ("exit_on_error", "false"), ("log_checkpoints", "false"), + ("max_wal_senders", "0"), ("max_worker_processes", "0"), ("max_parallel_workers", "0"), ("max_parallel_workers_per_gather", "0"), + // PostgreSQL 18 defaults io_method=worker, but the embedded WASIX + // single-user backend has no postmaster-managed IO worker process model. + ("io_method", "sync"), ("wal_buffers", "4MB"), ("min_wal_size", "80MB"), ("shared_buffers", "128MB"), ]; -fn ensure_runtime_dirs(paths: &PglitePaths) -> Result<()> { +fn ensure_runtime_dirs(paths: &OliphauntPaths) -> Result<()> { for path in [ paths.runtime_root(), paths.pgdata.clone(), @@ -3208,6 +2287,8 @@ fn summarize_protocol(bytes: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; + use std::io; + use std::pin::Pin; #[test] fn protocol_stdio_fails_closed_when_detached() -> Result<()> { diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs new file mode 100644 index 00000000..8e22f9fc --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs @@ -0,0 +1,422 @@ +use std::collections::VecDeque; +use std::fmt; +use std::io::{self, Read, Write}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context as TaskContext, Poll}; + +use anyhow::{Result, ensure}; +use tokio::io::ReadBuf; +use wasmer_wasix::virtual_fs; + +pub(crate) trait ProtocolStream: Read + Write + Send { + fn read_ready(&mut self) -> io::Result; +} + +#[derive(Debug, Default)] +struct TailCaptureState { + bytes: VecDeque, +} + +#[derive(Debug, Clone)] +pub(super) struct TailCaptureFile { + inner: Arc>, + limit: usize, +} + +#[derive(Debug, Clone)] +pub(super) struct TailCaptureHandle { + inner: Arc>, +} + +impl TailCaptureFile { + pub(super) fn new(limit: usize) -> (Self, TailCaptureHandle) { + let inner = Arc::new(Mutex::new(TailCaptureState::default())); + ( + Self { + inner: inner.clone(), + limit, + }, + TailCaptureHandle { inner }, + ) + } + + fn push_tail(&self, bytes: &[u8]) { + let Ok(mut state) = self.inner.lock() else { + return; + }; + for byte in bytes { + state.bytes.push_back(*byte); + while state.bytes.len() > self.limit { + state.bytes.pop_front(); + } + } + } +} + +impl TailCaptureHandle { + pub(super) fn text(&self) -> String { + let Ok(state) = self.inner.lock() else { + return "".to_owned(); + }; + let bytes = state.bytes.iter().copied().collect::>(); + String::from_utf8_lossy(&bytes).into_owned() + } +} + +impl virtual_fs::AsyncSeek for TailCaptureFile { + fn start_seek(self: Pin<&mut Self>, _position: io::SeekFrom) -> io::Result<()> { + Ok(()) + } + + fn poll_complete(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(0)) + } +} + +impl virtual_fs::AsyncRead for TailCaptureFile { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl virtual_fs::AsyncWrite for TailCaptureFile { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + self.push_tail(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + bufs: &[io::IoSlice<'_>], + ) -> Poll> { + let mut total = 0; + for buf in bufs { + self.push_tail(buf); + total += buf.len(); + } + Poll::Ready(Ok(total)) + } + + fn is_write_vectored(&self) -> bool { + true + } +} + +#[async_trait::async_trait] +impl virtual_fs::VirtualFile for TailCaptureFile { + fn last_accessed(&self) -> u64 { + 0 + } + + fn last_modified(&self) -> u64 { + 0 + } + + fn created_time(&self) -> u64 { + 0 + } + + fn size(&self) -> u64 { + self.inner + .lock() + .map(|state| state.bytes.len() as u64) + .unwrap_or(0) + } + + fn set_len(&mut self, _new_size: u64) -> virtual_fs::Result<()> { + Ok(()) + } + + fn unlink(&mut self) -> virtual_fs::Result<()> { + Ok(()) + } + + fn poll_read_ready(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(0)) + } + + fn poll_write_ready( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.limit)) + } +} + +#[derive(Clone)] +pub(super) struct ProtocolStdioFile { + state: Arc, +} + +struct ProtocolStdioState { + inner: Mutex, +} + +#[derive(Default)] +struct ProtocolStdioInner { + stream: Option>, + prefix: Vec, + prefix_offset: usize, +} + +pub(super) struct ProtocolStdioAttachment { + file: ProtocolStdioFile, +} + +impl ProtocolStdioFile { + pub(super) fn new() -> Self { + Self { + state: Arc::new(ProtocolStdioState { + inner: Mutex::new(ProtocolStdioInner::default()), + }), + } + } + + pub(super) fn attach(&self, stream: S) -> Result + where + S: ProtocolStream + 'static, + { + let mut guard = self + .state + .inner + .lock() + .map_err(|_| anyhow::anyhow!("protocol stdio lock poisoned"))?; + ensure!( + guard.stream.is_none(), + "WASIX protocol stdio stream is already attached" + ); + guard.stream = Some(Box::new(stream)); + guard.prefix.clear(); + guard.prefix_offset = 0; + Ok(ProtocolStdioAttachment { file: self.clone() }) + } + + fn detach(&self) { + if let Ok(mut guard) = self.state.inner.lock() { + guard.stream = None; + guard.prefix.clear(); + guard.prefix_offset = 0; + } + } + + pub(super) fn set_prefix(&self, prefix: Vec) -> Result<()> { + let mut guard = self + .state + .inner + .lock() + .map_err(|_| anyhow::anyhow!("protocol stdio lock poisoned"))?; + guard.prefix = prefix; + guard.prefix_offset = 0; + Ok(()) + } + + pub(super) fn clear_prefix(&self) -> Result<()> { + self.set_prefix(Vec::new()) + } + + fn with_inner( + &self, + f: impl FnOnce(&mut ProtocolStdioInner) -> io::Result, + ) -> io::Result { + let mut guard = self + .state + .inner + .lock() + .map_err(|_| io::Error::other("protocol stdio lock poisoned"))?; + f(&mut guard) + } +} + +impl Drop for ProtocolStdioAttachment { + fn drop(&mut self) { + self.file.detach(); + } +} + +impl fmt::Debug for ProtocolStdioFile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProtocolStdioFile").finish_non_exhaustive() + } +} + +impl virtual_fs::VirtualFile for ProtocolStdioFile { + fn last_accessed(&self) -> u64 { + 0 + } + + fn last_modified(&self) -> u64 { + 0 + } + + fn created_time(&self) -> u64 { + 0 + } + + fn size(&self) -> u64 { + 0 + } + + fn set_len(&mut self, _new_size: u64) -> virtual_fs::Result<()> { + Err(virtual_fs::FsError::PermissionDenied) + } + + fn unlink(&mut self) -> virtual_fs::Result<()> { + Ok(()) + } + + fn is_open(&self) -> bool { + self.state + .inner + .lock() + .map(|inner| inner.stream.is_some()) + .unwrap_or(false) + } + + fn poll_read_ready(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + match self.with_inner(|inner| { + if inner.prefix_offset < inner.prefix.len() { + return Ok(true); + } + let stream = inner.stream.as_mut().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "WASIX protocol stdio stream is not attached", + ) + })?; + stream.read_ready() + }) { + Ok(true) => Poll::Ready(Ok(1)), + Ok(false) => Poll::Pending, + Err(err) => Poll::Ready(Err(err)), + } + } + + fn poll_write_ready( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + ) -> Poll> { + match self.with_inner(|inner| { + if inner.stream.is_some() { + Ok(8192) + } else { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "WASIX protocol stdio stream is not attached", + )) + } + }) { + Ok(ready) => Poll::Ready(Ok(ready)), + Err(err) => Poll::Ready(Err(err)), + } + } +} + +impl virtual_fs::AsyncRead for ProtocolStdioFile { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } + let read = self.with_inner(|inner| { + let unfilled = buf.initialize_unfilled(); + if inner.prefix_offset < inner.prefix.len() { + let remaining = &inner.prefix[inner.prefix_offset..]; + let read = remaining.len().min(unfilled.len()); + unfilled[..read].copy_from_slice(&remaining[..read]); + inner.prefix_offset += read; + if inner.prefix_offset == inner.prefix.len() { + inner.prefix.clear(); + inner.prefix_offset = 0; + } + return Ok(read); + } + let stream = inner.stream.as_mut().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "WASIX protocol stdio stream is not attached", + ) + })?; + stream.read(unfilled) + }); + match read { + Ok(read) => { + buf.advance(read); + Poll::Ready(Ok(())) + } + Err(err) => Poll::Ready(Err(err)), + } + } +} + +impl virtual_fs::AsyncWrite for ProtocolStdioFile { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + let written = self + .state + .inner + .lock() + .map_err(|_| io::Error::other("protocol stdio lock poisoned")) + .and_then(|mut inner| match inner.stream.as_mut() { + Some(stream) => stream.write(buf), + None => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "WASIX protocol stdio stream is not attached", + )), + }); + Poll::Ready(written) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + let flushed = self + .state + .inner + .lock() + .map_err(|_| io::Error::other("protocol stdio lock poisoned")) + .and_then(|mut inner| match inner.stream.as_mut() { + Some(stream) => stream.flush(), + None => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "WASIX protocol stdio stream is not attached", + )), + }); + Poll::Ready(flushed) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl virtual_fs::AsyncSeek for ProtocolStdioFile { + fn start_seek(self: Pin<&mut Self>, _position: io::SeekFrom) -> io::Result<()> { + Ok(()) + } + + fn poll_complete(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(0)) + } +} diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs new file mode 100644 index 00000000..c0a73326 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs @@ -0,0 +1,664 @@ +use std::fmt; +use std::fs; +use std::future::Future; +use std::io; +use std::path::{Component, Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Context as TaskContext, Poll}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use serde::Serialize; +use tokio::io::ReadBuf; +use wasmer_wasix::virtual_fs; + +use super::super::sync_host_fs::SyncHostFileSystem; + +const WASIX_DEVICE_FILES: &[&str] = &[ + "null", "zero", "urandom", "stdin", "stdout", "stderr", "tty", +]; + +static FS_TRACE: FsTraceState = FsTraceState::new(); + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FsTraceSnapshot { + enabled: bool, + open_count: u64, + read_count: u64, + read_bytes: u64, + write_count: u64, + write_bytes: u64, + seek_count: u64, + metadata_count: u64, + read_dir_count: u64, + create_dir_count: u64, + remove_file_count: u64, + remove_dir_count: u64, + rename_count: u64, + set_len_count: u64, + unlink_count: u64, + total_elapsed_micros: u64, + read_elapsed_micros: u64, + write_elapsed_micros: u64, + seek_elapsed_micros: u64, +} + +struct FsTraceState { + open_count: AtomicU64, + read_count: AtomicU64, + read_bytes: AtomicU64, + write_count: AtomicU64, + write_bytes: AtomicU64, + seek_count: AtomicU64, + metadata_count: AtomicU64, + read_dir_count: AtomicU64, + create_dir_count: AtomicU64, + remove_file_count: AtomicU64, + remove_dir_count: AtomicU64, + rename_count: AtomicU64, + set_len_count: AtomicU64, + unlink_count: AtomicU64, + total_elapsed_micros: AtomicU64, + read_elapsed_micros: AtomicU64, + write_elapsed_micros: AtomicU64, + seek_elapsed_micros: AtomicU64, +} + +impl FsTraceState { + const fn new() -> Self { + Self { + open_count: AtomicU64::new(0), + read_count: AtomicU64::new(0), + read_bytes: AtomicU64::new(0), + write_count: AtomicU64::new(0), + write_bytes: AtomicU64::new(0), + seek_count: AtomicU64::new(0), + metadata_count: AtomicU64::new(0), + read_dir_count: AtomicU64::new(0), + create_dir_count: AtomicU64::new(0), + remove_file_count: AtomicU64::new(0), + remove_dir_count: AtomicU64::new(0), + rename_count: AtomicU64::new(0), + set_len_count: AtomicU64::new(0), + unlink_count: AtomicU64::new(0), + total_elapsed_micros: AtomicU64::new(0), + read_elapsed_micros: AtomicU64::new(0), + write_elapsed_micros: AtomicU64::new(0), + seek_elapsed_micros: AtomicU64::new(0), + } + } + + fn reset(&self) { + for counter in [ + &self.open_count, + &self.read_count, + &self.read_bytes, + &self.write_count, + &self.write_bytes, + &self.seek_count, + &self.metadata_count, + &self.read_dir_count, + &self.create_dir_count, + &self.remove_file_count, + &self.remove_dir_count, + &self.rename_count, + &self.set_len_count, + &self.unlink_count, + &self.total_elapsed_micros, + &self.read_elapsed_micros, + &self.write_elapsed_micros, + &self.seek_elapsed_micros, + ] { + counter.store(0, Ordering::Relaxed); + } + } + + fn record_total(&self, elapsed: Duration) { + self.total_elapsed_micros.fetch_add( + elapsed.as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + } + + fn snapshot(&self) -> FsTraceSnapshot { + FsTraceSnapshot { + enabled: fs_trace_enabled(), + open_count: self.open_count.load(Ordering::Relaxed), + read_count: self.read_count.load(Ordering::Relaxed), + read_bytes: self.read_bytes.load(Ordering::Relaxed), + write_count: self.write_count.load(Ordering::Relaxed), + write_bytes: self.write_bytes.load(Ordering::Relaxed), + seek_count: self.seek_count.load(Ordering::Relaxed), + metadata_count: self.metadata_count.load(Ordering::Relaxed), + read_dir_count: self.read_dir_count.load(Ordering::Relaxed), + create_dir_count: self.create_dir_count.load(Ordering::Relaxed), + remove_file_count: self.remove_file_count.load(Ordering::Relaxed), + remove_dir_count: self.remove_dir_count.load(Ordering::Relaxed), + rename_count: self.rename_count.load(Ordering::Relaxed), + set_len_count: self.set_len_count.load(Ordering::Relaxed), + unlink_count: self.unlink_count.load(Ordering::Relaxed), + total_elapsed_micros: self.total_elapsed_micros.load(Ordering::Relaxed), + read_elapsed_micros: self.read_elapsed_micros.load(Ordering::Relaxed), + write_elapsed_micros: self.write_elapsed_micros.load(Ordering::Relaxed), + seek_elapsed_micros: self.seek_elapsed_micros.load(Ordering::Relaxed), + } + } +} + +pub fn reset_fs_trace() { + FS_TRACE.reset(); +} + +pub fn fs_trace_snapshot() -> FsTraceSnapshot { + FS_TRACE.snapshot() +} + +pub(super) fn wasi_root_with_devices( + root: Arc, +) -> virtual_fs::Result> { + let devices: Arc = + Arc::new(virtual_fs::RootFileSystemBuilder::default().build_tmp_ext(&[])); + let root_with_default_dirs: Arc = + Arc::new(virtual_fs::OverlayFileSystem::new( + virtual_fs::ArcFileSystem::new(root), + [virtual_fs::ArcFileSystem::new(devices.clone())], + )); + let mount = virtual_fs::MountFileSystem::new(); + mount.mount(Path::new("/"), root_with_default_dirs)?; + for name in WASIX_DEVICE_FILES { + let path = Path::new("/dev").join(name); + mount.mount_with_source(&path, &path, devices.clone())?; + } + Ok(Arc::new(mount)) +} + +pub(super) struct EagerCopyOverlayFileSystem { + upper_root: PathBuf, + lower_root: PathBuf, + overlay: + virtual_fs::OverlayFileSystem, +} + +impl fmt::Debug for EagerCopyOverlayFileSystem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EagerCopyOverlayFileSystem") + .field("upper_root", &self.upper_root) + .field("lower_root", &self.lower_root) + .finish_non_exhaustive() + } +} + +impl EagerCopyOverlayFileSystem { + pub(super) fn new(upper_root: PathBuf, lower_root: PathBuf) -> Result { + fs::create_dir_all(&upper_root) + .with_context(|| format!("create PGDATA overlay upper {}", upper_root.display()))?; + let upper_root = upper_root.canonicalize().with_context(|| { + format!("canonicalize PGDATA overlay upper {}", upper_root.display()) + })?; + let lower_root = lower_root.canonicalize().with_context(|| { + format!("canonicalize PGDATA overlay lower {}", lower_root.display()) + })?; + let upper = virtual_fs::ArcFileSystem::new(host_filesystem(&upper_root)?); + let lower = virtual_fs::ArcFileSystem::new(host_filesystem(&lower_root)?); + Ok(Self { + upper_root, + lower_root, + overlay: virtual_fs::OverlayFileSystem::new(upper, [lower]), + }) + } + + fn ensure_upper_copy( + &self, + path: &Path, + conf: &virtual_fs::OpenOptionsConfig, + ) -> virtual_fs::Result<()> { + let Some(relative) = normalize_overlay_path(path)? else { + return Ok(()); + }; + + let upper = self.upper_root.join(&relative); + if upper.exists() { + return Ok(()); + } + + let lower = self.lower_root.join(&relative); + let metadata = match fs::symlink_metadata(&lower) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + if conf.create || conf.create_new { + self.ensure_upper_parent(&relative)?; + } + return Ok(()); + } + Err(err) => return Err(err.into()), + }; + + if conf.create_new { + return Err(virtual_fs::FsError::AlreadyExists); + } + if metadata.is_dir() { + return Ok(()); + } + if !metadata.is_file() { + return Err(virtual_fs::FsError::Unsupported); + } + + if let Some(parent) = upper.parent() { + fs::create_dir_all(parent).map_err(virtual_fs::FsError::from)?; + } + if conf.truncate && !conf.read && !conf.append { + fs::File::create(&upper).map_err(virtual_fs::FsError::from)?; + } else { + fs::copy(&lower, &upper).map_err(virtual_fs::FsError::from)?; + } + Ok(()) + } + + fn ensure_upper_parent(&self, relative: &Path) -> virtual_fs::Result<()> { + let Some(parent) = relative.parent() else { + return Ok(()); + }; + if parent.as_os_str().is_empty() { + return Ok(()); + } + + let upper_parent = self.upper_root.join(parent); + if upper_parent.is_dir() { + return Ok(()); + } + + let lower_parent = self.lower_root.join(parent); + let metadata = match fs::symlink_metadata(&lower_parent) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(virtual_fs::FsError::EntryNotFound); + } + Err(err) => return Err(err.into()), + }; + if !metadata.is_dir() { + return Err(virtual_fs::FsError::BaseNotDirectory); + } + + fs::create_dir_all(upper_parent).map_err(virtual_fs::FsError::from) + } +} + +impl virtual_fs::FileSystem for EagerCopyOverlayFileSystem { + fn readlink(&self, path: &Path) -> virtual_fs::Result { + self.overlay.readlink(path) + } + + fn read_dir(&self, path: &Path) -> virtual_fs::Result { + self.overlay.read_dir(path) + } + + fn create_dir(&self, path: &Path) -> virtual_fs::Result<()> { + if let Some(relative) = normalize_overlay_path(path)? { + self.ensure_upper_parent(&relative)?; + } + self.overlay.create_dir(path) + } + + fn create_symlink(&self, source: &Path, target: &Path) -> virtual_fs::Result<()> { + if let Some(relative) = normalize_overlay_path(target)? { + self.ensure_upper_parent(&relative)?; + } + self.overlay.create_symlink(source, target) + } + + fn remove_dir(&self, path: &Path) -> virtual_fs::Result<()> { + self.overlay.remove_dir(path) + } + + fn rename<'a>( + &'a self, + from: &'a Path, + to: &'a Path, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + self.ensure_upper_copy(from, &mutating_open_config())?; + if let Some(relative) = normalize_overlay_path(to)? { + self.ensure_upper_parent(&relative)?; + } + self.overlay.rename(from, to).await + }) + } + + fn metadata(&self, path: &Path) -> virtual_fs::Result { + self.overlay.metadata(path) + } + + fn symlink_metadata(&self, path: &Path) -> virtual_fs::Result { + self.overlay.symlink_metadata(path) + } + + fn remove_file(&self, path: &Path) -> virtual_fs::Result<()> { + self.overlay.remove_file(path) + } + + fn new_open_options(&self) -> virtual_fs::OpenOptions<'_> { + virtual_fs::OpenOptions::new(self) + } +} + +impl virtual_fs::FileOpener for EagerCopyOverlayFileSystem { + fn open( + &self, + path: &Path, + conf: &virtual_fs::OpenOptionsConfig, + ) -> virtual_fs::Result> { + if conf.would_mutate() { + self.ensure_upper_copy(path, conf)?; + } + virtual_fs::FileSystem::new_open_options(&self.overlay) + .options(conf.clone()) + .open(path) + } +} + +fn normalize_overlay_path(path: &Path) -> virtual_fs::Result> { + let mut relative = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir | Component::CurDir => {} + Component::Normal(part) => relative.push(part), + Component::ParentDir | Component::Prefix(_) => { + return Err(virtual_fs::FsError::PermissionDenied); + } + } + } + if relative.as_os_str().is_empty() { + Ok(None) + } else { + Ok(Some(relative)) + } +} + +fn mutating_open_config() -> virtual_fs::OpenOptionsConfig { + virtual_fs::OpenOptionsConfig { + read: true, + write: true, + create_new: false, + create: false, + append: false, + truncate: false, + } +} + +pub(super) fn host_filesystem( + host_path: &Path, +) -> Result> { + let host_fs = SyncHostFileSystem::new(host_path) + .with_context(|| format!("create host fs rooted at {}", host_path.display()))?; + Ok(Arc::new(host_fs) as Arc) +} + +fn fs_trace_enabled() -> bool { + env_flag_enabled("OLIPHAUNT_WASM_WASIX_FS_TRACE") +} + +fn env_flag_enabled(name: &str) -> bool { + let Some(value) = std::env::var_os(name) else { + return false; + }; + !matches!( + value.to_string_lossy().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "off" | "no" + ) +} + +pub(super) fn maybe_trace_filesystem( + inner: Arc, +) -> Arc { + if fs_trace_enabled() { + Arc::new(TracedFileSystem { inner }) as Arc + } else { + inner + } +} + +#[derive(Debug)] +struct TracedFileSystem { + inner: Arc, +} + +impl TracedFileSystem { + fn record(&self, counter: &AtomicU64, operation: impl FnOnce() -> T) -> T { + counter.fetch_add(1, Ordering::Relaxed); + let started = Instant::now(); + let result = operation(); + FS_TRACE.record_total(started.elapsed()); + result + } +} + +impl virtual_fs::FileSystem for TracedFileSystem { + fn readlink(&self, path: &Path) -> virtual_fs::Result { + self.record(&FS_TRACE.metadata_count, || self.inner.readlink(path)) + } + + fn read_dir(&self, path: &Path) -> virtual_fs::Result { + self.record(&FS_TRACE.read_dir_count, || self.inner.read_dir(path)) + } + + fn create_dir(&self, path: &Path) -> virtual_fs::Result<()> { + self.record(&FS_TRACE.create_dir_count, || self.inner.create_dir(path)) + } + + fn create_symlink(&self, source: &Path, target: &Path) -> virtual_fs::Result<()> { + self.record(&FS_TRACE.create_dir_count, || { + self.inner.create_symlink(source, target) + }) + } + + fn remove_dir(&self, path: &Path) -> virtual_fs::Result<()> { + self.record(&FS_TRACE.remove_dir_count, || self.inner.remove_dir(path)) + } + + fn rename<'a>( + &'a self, + from: &'a Path, + to: &'a Path, + ) -> Pin> + Send + 'a>> { + FS_TRACE.rename_count.fetch_add(1, Ordering::Relaxed); + Box::pin(async move { + let started = Instant::now(); + let result = self.inner.rename(from, to).await; + FS_TRACE.record_total(started.elapsed()); + result + }) + } + + fn metadata(&self, path: &Path) -> virtual_fs::Result { + self.record(&FS_TRACE.metadata_count, || self.inner.metadata(path)) + } + + fn symlink_metadata(&self, path: &Path) -> virtual_fs::Result { + self.record(&FS_TRACE.metadata_count, || { + self.inner.symlink_metadata(path) + }) + } + + fn remove_file(&self, path: &Path) -> virtual_fs::Result<()> { + self.record(&FS_TRACE.remove_file_count, || self.inner.remove_file(path)) + } + + fn new_open_options(&self) -> virtual_fs::OpenOptions<'_> { + virtual_fs::OpenOptions::new(self) + } +} + +impl virtual_fs::FileOpener for TracedFileSystem { + fn open( + &self, + path: &Path, + conf: &virtual_fs::OpenOptionsConfig, + ) -> virtual_fs::Result> { + FS_TRACE.open_count.fetch_add(1, Ordering::Relaxed); + let started = Instant::now(); + let file = virtual_fs::FileSystem::new_open_options(&self.inner) + .options(conf.clone()) + .open(path); + FS_TRACE.record_total(started.elapsed()); + file.map(|inner| Box::new(TracedVirtualFile { inner }) as _) + } +} + +#[derive(Debug)] +struct TracedVirtualFile { + inner: Box, +} + +impl virtual_fs::VirtualFile for TracedVirtualFile { + fn last_accessed(&self) -> u64 { + self.inner.last_accessed() + } + + fn last_modified(&self) -> u64 { + self.inner.last_modified() + } + + fn created_time(&self) -> u64 { + self.inner.created_time() + } + + fn set_times(&mut self, atime: Option, mtime: Option) -> virtual_fs::Result<()> { + self.inner.set_times(atime, mtime) + } + + fn size(&self) -> u64 { + self.inner.size() + } + + fn set_len(&mut self, new_size: u64) -> virtual_fs::Result<()> { + FS_TRACE.set_len_count.fetch_add(1, Ordering::Relaxed); + let started = Instant::now(); + let result = self.inner.set_len(new_size); + FS_TRACE.record_total(started.elapsed()); + result + } + + fn unlink(&mut self) -> virtual_fs::Result<()> { + FS_TRACE.unlink_count.fetch_add(1, Ordering::Relaxed); + let started = Instant::now(); + let result = self.inner.unlink(); + FS_TRACE.record_total(started.elapsed()); + result + } + + fn is_open(&self) -> bool { + self.inner.is_open() + } + + fn get_special_fd(&self) -> Option { + self.inner.get_special_fd() + } + + fn write_from_mmap(&mut self, offset: u64, len: u64) -> io::Result<()> { + self.inner.write_from_mmap(offset, len) + } + + fn poll_read_ready(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut *this.inner).poll_read_ready(cx) + } + + fn poll_write_ready(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut *this.inner).poll_write_ready(cx) + } +} + +impl virtual_fs::AsyncRead for TracedVirtualFile { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let before = buf.filled().len(); + let started = Instant::now(); + let result = Pin::new(&mut *this.inner).poll_read(cx, buf); + if let Poll::Ready(Ok(())) = &result { + let bytes = buf.filled().len().saturating_sub(before) as u64; + FS_TRACE.read_count.fetch_add(1, Ordering::Relaxed); + FS_TRACE.read_bytes.fetch_add(bytes, Ordering::Relaxed); + let elapsed = started.elapsed(); + FS_TRACE.record_total(elapsed); + FS_TRACE.read_elapsed_micros.fetch_add( + elapsed.as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + } + result + } +} + +impl virtual_fs::AsyncWrite for TracedVirtualFile { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + let started = Instant::now(); + let result = Pin::new(&mut *this.inner).poll_write(cx, buf); + if let Poll::Ready(Ok(bytes)) = &result { + FS_TRACE.write_count.fetch_add(1, Ordering::Relaxed); + FS_TRACE + .write_bytes + .fetch_add(*bytes as u64, Ordering::Relaxed); + let elapsed = started.elapsed(); + FS_TRACE.record_total(elapsed); + FS_TRACE.write_elapsed_micros.fetch_add( + elapsed.as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + } + result + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut *this.inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut *this.inner).poll_shutdown(cx) + } +} + +impl virtual_fs::AsyncSeek for TracedVirtualFile { + fn start_seek(self: Pin<&mut Self>, position: io::SeekFrom) -> io::Result<()> { + let this = self.get_mut(); + FS_TRACE.seek_count.fetch_add(1, Ordering::Relaxed); + let started = Instant::now(); + let result = Pin::new(&mut *this.inner).start_seek(position); + let elapsed = started.elapsed(); + FS_TRACE.record_total(elapsed); + FS_TRACE.seek_elapsed_micros.fetch_add( + elapsed.as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + result + } + + fn poll_complete(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + let started = Instant::now(); + let result = Pin::new(&mut *this.inner).poll_complete(cx); + if let Poll::Ready(Ok(_)) = &result { + let elapsed = started.elapsed(); + FS_TRACE.record_total(elapsed); + FS_TRACE.seek_elapsed_micros.fetch_add( + elapsed.as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + } + result + } +} diff --git a/src/pglite/proxy.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/proxy.rs similarity index 98% rename from src/pglite/proxy.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/proxy.rs index a4cae7b6..de8b5b4d 100644 --- a/src/pglite/proxy.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/proxy.rs @@ -11,18 +11,18 @@ use std::sync::{ mpsc::SyncSender, }; -use crate::pglite::backend::{BackendOpenKind, BackendSession}; +use crate::oliphaunt::backend::{BackendOpenKind, BackendSession}; #[cfg(feature = "extensions")] -use crate::pglite::base::install_missing_extension_archives; -use crate::pglite::base::{InstallOutcome, install_into}; -use crate::pglite::config::{PostgresConfig, StartupConfig}; +use crate::oliphaunt::base::install_missing_extension_archives; +use crate::oliphaunt::base::{InstallOutcome, install_into}; +use crate::oliphaunt::config::{PostgresConfig, StartupConfig}; #[cfg(feature = "extensions")] -use crate::pglite::extensions::Extension; -use crate::pglite::postgres_mod::{ +use crate::oliphaunt::extensions::Extension; +use crate::oliphaunt::postgres_mod::{ ProtocolPumpOutcome, ProtocolStream, StartupProtocolResponse, startup_error_response_output, }; -use crate::pglite::timing; -use crate::pglite::wire::{ +use crate::oliphaunt::timing; +use crate::oliphaunt::wire::{ FrontendFrameKind, FrontendFrameReader, classify_frontend_message, error_response, response_contains_error, simple_query_message, startup_config_for_message, startup_parameter, }; @@ -182,13 +182,13 @@ pub fn protocol_stats_snapshot() -> ProtocolStatsSnapshot { PROTOCOL_STATS.snapshot() } -/// Blocking PostgreSQL socket proxy for the embedded PGlite runtime. +/// Blocking PostgreSQL socket proxy for the embedded Oliphaunt runtime. /// /// The proxy intentionally runs each accepted connection on one blocking thread /// and does not call into the WASIX backend from an async runtime. That avoids /// nested runtime panics when an async wrapper blocks inside the embedded engine. #[derive(Debug, Clone)] -pub struct PgliteProxy { +pub struct OliphauntProxy { root: Arc, prepared_root: Option>, postgres_config: Arc, @@ -197,8 +197,8 @@ pub struct PgliteProxy { extensions: Arc>, } -impl PgliteProxy { - /// Create a proxy that stores the PGlite runtime and cluster under `root`. +impl OliphauntProxy { + /// Create a proxy that stores the Oliphaunt runtime and cluster under `root`. pub fn new(root: impl Into) -> Self { Self { root: Arc::new(root.into()), @@ -851,7 +851,7 @@ impl WireBackend { fn set_role(&mut self, user: &str) -> Result> { let sql = format!( "SET ROLE {}", - crate::pglite::templating::quote_identifier(user) + crate::oliphaunt::templating::quote_identifier(user) ); self.send(&simple_query_message(&sql)) } diff --git a/src/pglite/server.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs similarity index 89% rename from src/pglite/server.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs index 852f1673..5b353d56 100644 --- a/src/pglite/server.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/server.rs @@ -12,24 +12,26 @@ use std::thread::{self, JoinHandle}; use anyhow::{Context, Result, anyhow}; use tempfile::TempDir; -use crate::pglite::base::{PreparedRoot, RootLock, RootPlan, RootSource, RootTarget, prepare_root}; -use crate::pglite::config::{PostgresConfig, StartupConfig}; +use crate::oliphaunt::base::{ + PreparedRoot, RootLock, RootPlan, RootSource, RootTarget, prepare_root, +}; +use crate::oliphaunt::config::{PostgresConfig, StartupConfig}; #[cfg(feature = "extensions")] -use crate::pglite::extensions::{Extension, resolve_extension_set}; -use crate::pglite::interface::DebugLevel; +use crate::oliphaunt::extensions::{Extension, resolve_extension_set}; +use crate::oliphaunt::interface::DebugLevel; #[cfg(feature = "extensions")] -use crate::pglite::pg_dump::{PgDumpOptions, dump_server_sql}; -use crate::pglite::proxy::PgliteProxy; -use crate::pglite::timing; +use crate::oliphaunt::pg_dump::{PgDumpOptions, dump_server_sql}; +use crate::oliphaunt::proxy::OliphauntProxy; +use crate::oliphaunt::timing; -/// A supervised local PostgreSQL socket backed by one embedded PGlite runtime. +/// A supervised local PostgreSQL socket backed by one embedded Oliphaunt runtime. /// /// This is the compatibility entry point for code that expects a PostgreSQL URL, /// such as `tokio-postgres`, SQLx, or tools that speak the wire protocol. The /// server owns one embedded backend, so downstream pools should use a single /// connection. #[derive(Debug)] -pub struct PgliteServer { +pub struct OliphauntServer { root: PathBuf, _temp_dir: Option, _root_lock: Option, @@ -46,11 +48,11 @@ enum ServerEndpoint { Unix(PathBuf), } -impl PgliteServer { - /// Build a local PGlite server. The default is a cached temporary database +impl OliphauntServer { + /// Build a local Oliphaunt server. The default is a cached temporary database /// served on `127.0.0.1:0`. - pub fn builder() -> PgliteServerBuilder { - PgliteServerBuilder::new() + pub fn builder() -> OliphauntServerBuilder { + OliphauntServerBuilder::new() } /// Start a cached temporary database on a random local TCP port. @@ -110,7 +112,7 @@ impl PgliteServer { pub fn dump_sql(&self, options: PgDumpOptions) -> Result { let addr = self .tcp_addr() - .context("pg_dump currently requires a TCP PgliteServer endpoint")?; + .context("pg_dump currently requires a TCP OliphauntServer endpoint")?; dump_server_sql(addr, &options) } @@ -139,23 +141,23 @@ impl PgliteServer { let _phase = timing::phase("server.thread_join"); handle .join() - .map_err(|_| anyhow!("pglite server thread panicked"))??; + .map_err(|_| anyhow!("oliphaunt server thread panicked"))??; } Ok(()) } } -impl Drop for PgliteServer { +impl Drop for OliphauntServer { fn drop(&mut self) { if let Err(err) = self.stop() { - tracing::warn!("pglite server shutdown during drop failed: {err:#}"); + tracing::warn!("oliphaunt server shutdown during drop failed: {err:#}"); } } } -/// Builder for [`PgliteServer`]. +/// Builder for [`OliphauntServer`]. #[derive(Debug, Clone)] -pub struct PgliteServerBuilder { +pub struct OliphauntServerBuilder { root: ServerRoot, endpoint: ServerEndpointConfig, postgres_config: PostgresConfig, @@ -177,7 +179,7 @@ enum ServerEndpointConfig { Unix(PathBuf), } -impl Default for PgliteServerBuilder { +impl Default for OliphauntServerBuilder { fn default() -> Self { Self { root: ServerRoot::Temporary { @@ -192,7 +194,7 @@ impl Default for PgliteServerBuilder { } } -impl PgliteServerBuilder { +impl OliphauntServerBuilder { /// Create a builder. Defaults to a cached temporary database on /// `127.0.0.1:0`. pub fn new() -> Self { @@ -258,13 +260,13 @@ impl PgliteServerBuilder { self } - /// Default user encoded in [`PgliteServer::database_url`]. + /// Default user encoded in [`OliphauntServer::database_url`]. pub fn username(mut self, username: impl Into) -> Self { self.startup_config.username = username.into(); self } - /// Default database encoded in [`PgliteServer::database_url`]. + /// Default database encoded in [`OliphauntServer::database_url`]. pub fn database(mut self, database: impl Into) -> Self { self.startup_config.database = database.into(); self @@ -312,7 +314,7 @@ impl PgliteServerBuilder { } /// Install the runtime if needed, initialize the cluster, and start serving. - pub fn start(self) -> Result { + pub fn start(self) -> Result { self.postgres_config.validate()?; self.startup_config.validate()?; #[cfg(feature = "extensions")] @@ -345,7 +347,7 @@ impl PgliteServerBuilder { let plan = RootPlan::new(RootTarget::Temporary, source); #[cfg(feature = "extensions")] let plan = plan.with_extensions(extensions.clone(), postgres_config.clone()); - run_blocking("pglite-template-cache", move || prepare_root(plan))? + run_blocking("oliphaunt-template-cache", move || prepare_root(plan))? } } }; @@ -359,7 +361,7 @@ impl PgliteServerBuilder { let shutdown = Arc::new(AtomicBool::new(false)); let proxy = { let _phase = timing::phase("server.proxy_create"); - PgliteProxy::new(root.clone()).with_prepared_root(outcome) + OliphauntProxy::new(root.clone()).with_prepared_root(outcome) }; let proxy = proxy .with_postgres_config(postgres_config) @@ -373,7 +375,7 @@ impl PgliteServerBuilder { ServerEndpointConfig::Unix(path) => start_unix(proxy, path, shutdown.clone())?, }; - Ok(PgliteServer { + Ok(OliphauntServer { root, _temp_dir: temp_dir, _root_lock: root_lock, @@ -386,17 +388,19 @@ impl PgliteServerBuilder { } fn start_tcp( - proxy: PgliteProxy, + proxy: OliphauntProxy, addr: SocketAddr, shutdown: Arc, ) -> Result<(ServerEndpoint, JoinHandle>)> { let listener = { let _phase = timing::phase("server.tcp_bind"); - TcpListener::bind(addr).context("bind PGlite TCP server")? + TcpListener::bind(addr).context("bind Oliphaunt TCP server")? }; let addr = { let _phase = timing::phase("server.tcp_local_addr"); - listener.local_addr().context("read PGlite TCP address")? + listener + .local_addr() + .context("read Oliphaunt TCP address")? }; let (ready_tx, ready_rx) = sync_channel(1); let recorder = timing::current_recorder(); @@ -454,7 +458,7 @@ where #[cfg(unix)] fn start_unix( - proxy: PgliteProxy, + proxy: OliphauntProxy, path: PathBuf, shutdown: Arc, ) -> Result<(ServerEndpoint, JoinHandle>)> { @@ -473,7 +477,7 @@ fn start_unix( let listener = { let _phase = timing::phase("server.unix_bind"); UnixListener::bind(&path) - .with_context(|| format!("bind PGlite Unix socket {}", path.display()))? + .with_context(|| format!("bind Oliphaunt Unix socket {}", path.display()))? }; let endpoint = ServerEndpoint::Unix(path); let (ready_tx, ready_rx) = sync_channel(1); @@ -496,7 +500,7 @@ fn start_unix( fn wait_until_ready(ready_rx: &Receiver>) -> Result<()> { ready_rx .recv() - .context("PGlite server thread exited before reporting readiness")? + .context("Oliphaunt server thread exited before reporting readiness")? } fn wake_listener(endpoint: &ServerEndpoint) { @@ -540,8 +544,8 @@ mod tests { #[test] fn unix_socket_uri_host_is_query_encoded() { assert_eq!( - percent_encode_query_value("/tmp/Application Support/pglite"), - "/tmp/Application%20Support/pglite" + percent_encode_query_value("/tmp/Application Support/oliphaunt"), + "/tmp/Application%20Support/oliphaunt" ); } } diff --git a/src/pglite/sync_host_fs.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/sync_host_fs.rs similarity index 100% rename from src/pglite/sync_host_fs.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/sync_host_fs.rs diff --git a/src/pglite/templating.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/templating.rs similarity index 94% rename from src/pglite/templating.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/templating.rs index a00d27ce..13188cd4 100644 --- a/src/pglite/templating.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/templating.rs @@ -3,9 +3,9 @@ use regex::Regex; use serde_json::Value; use std::sync::LazyLock; -use crate::pglite::client::Pglite; -use crate::pglite::interface::QueryOptions; -use crate::pglite::types::TEXT; +use crate::oliphaunt::client::Oliphaunt; +use crate::oliphaunt::interface::QueryOptions; +use crate::oliphaunt::types::TEXT; #[derive(Debug, Clone)] pub struct TemplatedQuery { @@ -58,7 +58,7 @@ pub fn quote_identifier(ident: &str) -> String { format!("\"{}\"", escaped) } -pub fn format_query(pg: &mut Pglite, query: &str, params: &[Value]) -> Result { +pub fn format_query(pg: &mut Oliphaunt, query: &str, params: &[Value]) -> Result { if params.is_empty() { return Ok(query.to_string()); } diff --git a/src/pglite/timing.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/timing.rs similarity index 100% rename from src/pglite/timing.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/timing.rs diff --git a/src/pglite/transport.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/transport.rs similarity index 83% rename from src/pglite/transport.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/transport.rs index 8e6332ce..202f7007 100644 --- a/src/pglite/transport.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/transport.rs @@ -1,9 +1,9 @@ use anyhow::{Result, bail}; use super::postgres_mod::PostgresMod; -use crate::pglite::interface::DataTransferContainer; +use crate::oliphaunt::interface::DataTransferContainer; -/// Protocol transport for the WASIX PGlite backend. +/// Protocol transport for the WASIX Oliphaunt backend. pub enum Transport { Wasix, } diff --git a/src/pglite/types.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/types.rs similarity index 99% rename from src/pglite/types.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/types.rs index e5df4061..6888358d 100644 --- a/src/pglite/types.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/types.rs @@ -375,7 +375,7 @@ fn register_builtin_array_serializers(serializers: &mut SerializerMap) { } // Generated from PostgreSQL's built-in pg_type.dat OID assignments for the -// default PGlite/Postgres 17 catalog. Keep this list to built-in types only: +// default Oliphaunt/Postgres 17 catalog. Keep this list to built-in types only: // extension and runtime-created custom arrays are discovered through the direct // client type cache when they are actually used. const BUILTIN_ARRAY_TYPES: &[ArrayTypeInfo] = &[ diff --git a/src/pglite/wire.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/wire.rs similarity index 99% rename from src/pglite/wire.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/wire.rs index db45b7c5..401ccf9f 100644 --- a/src/pglite/wire.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/wire.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result, anyhow, bail}; -use crate::pglite::config::StartupConfig; +use crate::oliphaunt::config::StartupConfig; pub(crate) const SSL_REQUEST_CODE: i32 = 80_877_103; pub(crate) const GSSENC_REQUEST_CODE: i32 = 80_877_104; diff --git a/src/protocol/buffer_reader.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/buffer_reader.rs similarity index 100% rename from src/protocol/buffer_reader.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/buffer_reader.rs diff --git a/src/protocol/buffer_writer.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/buffer_writer.rs similarity index 100% rename from src/protocol/buffer_writer.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/buffer_writer.rs diff --git a/src/protocol/messages.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/messages.rs similarity index 100% rename from src/protocol/messages.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/messages.rs diff --git a/src/protocol/mod.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/mod.rs similarity index 94% rename from src/protocol/mod.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/mod.rs index d8d5054b..ef8aaf56 100644 --- a/src/protocol/mod.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/mod.rs @@ -10,6 +10,8 @@ pub(crate) mod serializer; pub(crate) mod string_utils; pub(crate) mod types; +#[cfg(test)] +mod shared_fixture_tests; #[cfg(test)] mod tests; diff --git a/src/protocol/parser.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/parser.rs similarity index 100% rename from src/protocol/parser.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/parser.rs diff --git a/src/protocol/serializer.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/serializer.rs similarity index 100% rename from src/protocol/serializer.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/serializer.rs diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs new file mode 100644 index 00000000..226cb8a9 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs @@ -0,0 +1,107 @@ +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, +}; + +use anyhow::Result; +use serde::Deserialize; + +use super::{messages::BackendMessage, parser::Parser}; + +#[test] +fn parser_matches_shared_protocol_wire_fixtures() -> Result<()> { + let Some(path) = shared_fixture_path() else { + eprintln!("skipping shared protocol fixtures outside the monorepo package"); + return Ok(()); + }; + let corpus: ProtocolFixtureCorpus = serde_json::from_str(&fs::read_to_string(path)?)?; + assert_eq!(corpus.schema_version, 1); + assert_eq!(corpus.kind, "postgres-backend-query-response"); + + let mut names = HashSet::new(); + let mut matched = 0usize; + for fixture in corpus.cases { + assert!( + names.insert(fixture.name.clone()), + "duplicate shared protocol fixture {}", + fixture.name + ); + let Some(wire) = fixture.wire_expectation else { + continue; + }; + matched += 1; + let messages = parse_vec_chunks(vec![decode_hex(&fixture.response_hex)])?; + let actual = messages + .iter() + .map(|message| message.name().to_string()) + .collect::>(); + assert_eq!(actual, wire.message_names, "fixture {}", fixture.name); + } + assert!(matched > 0, "shared protocol corpus had no wire fixtures"); + Ok(()) +} + +fn parse_vec_chunks(chunks: Vec>) -> Result> { + let mut parser = Parser::new(); + let mut messages = Vec::new(); + for chunk in chunks { + parser.parse(chunk.as_slice(), |message| { + messages.push(message); + Ok(()) + })?; + } + Ok(messages) +} + +fn shared_fixture_path() -> Option { + if let Some(root) = std::env::var_os("OLIPHAUNT_SHARED_FIXTURES") { + let path = PathBuf::from(root).join("protocol/query-response-cases.json"); + if path.is_file() { + return Some(path); + } + } + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../../src/shared/fixtures/protocol/query-response-cases.json"); + path.is_file().then_some(path) +} + +fn decode_hex(hex: &str) -> Vec { + let compact = hex + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::(); + assert!( + compact.len() % 2 == 0, + "hex fixture must have an even digit count" + ); + (0..compact.len()) + .step_by(2) + .map(|index| { + u8::from_str_radix(&compact[index..index + 2], 16) + .expect("hex fixture contains invalid byte") + }) + .collect() +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolFixtureCorpus { + schema_version: u32, + kind: String, + cases: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolFixtureCase { + name: String, + response_hex: String, + wire_expectation: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireExpectation { + message_names: Vec, +} diff --git a/src/protocol/string_utils.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/string_utils.rs similarity index 100% rename from src/protocol/string_utils.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/string_utils.rs diff --git a/src/protocol/tests.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/tests.rs similarity index 100% rename from src/protocol/tests.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/tests.rs diff --git a/src/protocol/types.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/types.rs similarity index 63% rename from src/protocol/types.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/types.rs index 04400f86..4292a42e 100644 --- a/src/protocol/types.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/types.rs @@ -33,3 +33,17 @@ impl Modes { } pub type BufferParameter<'a> = &'a [u8]; + +#[cfg(test)] +mod tests { + use super::{Mode, Modes}; + + #[test] + fn mode_round_trips_wire_format_codes() { + assert_eq!(Modes::TEXT.as_i16(), 0); + assert_eq!(Modes::BINARY.as_i16(), 1); + assert_eq!(Mode::try_from(0), Ok(Mode::Text)); + assert_eq!(Mode::try_from(1), Ok(Mode::Binary)); + assert_eq!(Mode::try_from(2), Err("invalid mode")); + } +} diff --git a/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/cli_smoke.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/cli_smoke.rs new file mode 100644 index 00000000..72a00fd8 --- /dev/null +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/cli_smoke.rs @@ -0,0 +1,96 @@ +#![cfg(feature = "extensions")] + +use anyhow::{Context, Result}; +use oliphaunt_wasix::{Oliphaunt, capture_phase_timings}; +use sqlx::{Connection, Row}; +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use tokio::time::{Duration, timeout}; + +mod support; +use support::{ChildGuard, TestTrace, trace_step}; + +fn direct_open_diagnostic() -> String { + let (result, phases) = capture_phase_timings(|| Oliphaunt::builder().temporary().open()); + let outcome = match result { + Ok(mut pg) => match pg.close() { + Ok(()) => "direct temporary Oliphaunt open succeeded".to_owned(), + Err(err) => format!("direct temporary Oliphaunt open succeeded, close failed: {err:#}"), + }, + Err(err) => format!("direct temporary Oliphaunt open failed: {err:#}"), + }; + format!("{outcome}\nphases:\n{phases:#?}") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oliphaunt_proxy_print_uri_accepts_sqlx_connection() -> Result<()> { + let _trace = TestTrace::new("oliphaunt_proxy_print_uri_accepts_sqlx_connection"); + let process = Command::new(env!("CARGO_BIN_EXE_oliphaunt-wasix-proxy")) + .args(["--temporary", "--tcp", "127.0.0.1:0", "--print-uri"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("spawn oliphaunt-wasix-proxy")?; + let mut child = ChildGuard::new(process, "oliphaunt-wasix-proxy")?; + + let stdout = child + .child_mut() + .stdout + .take() + .context("oliphaunt-wasix-proxy stdout pipe")?; + let read_uri = tokio::task::spawn_blocking(move || { + let mut reader = BufReader::new(stdout); + let mut uri = String::new(); + let bytes = reader + .read_line(&mut uri) + .context("read oliphaunt-wasix-proxy printed URI")?; + Ok::<_, anyhow::Error>((bytes, uri)) + }); + let (bytes, uri) = match timeout(Duration::from_secs(30), read_uri).await { + Ok(Ok(Ok(result))) => result, + Ok(Ok(Err(err))) => return Err(err), + Ok(Err(err)) => return Err(err).context("join URI reader task"), + Err(err) => { + let stderr = child.collect_stderr(); + anyhow::bail!( + "timed out waiting for oliphaunt-wasix-proxy URI: {err}\n\nstderr:\n{stderr}" + ); + } + }; + if bytes == 0 { + let stderr = child.collect_stderr(); + anyhow::bail!("oliphaunt-wasix-proxy exited before printing URI\n\nstderr:\n{stderr}"); + } + let uri = uri.trim(); + assert!( + uri.starts_with("postgresql://") || uri.starts_with("postgres://"), + "unexpected URI: {uri}" + ); + trace_step("oliphaunt_proxy printed URI"); + + let mut conn = match timeout(Duration::from_secs(30), sqlx::PgConnection::connect(uri)).await { + Ok(Ok(conn)) => conn, + Ok(Err(err)) => { + let stderr = child.collect_stderr(); + let direct = direct_open_diagnostic(); + anyhow::bail!( + "connect to oliphaunt-wasix-proxy failed: {err:#}\n\nstderr:\n{stderr}\n\ndirect backend diagnostic:\n{direct}" + ); + } + Err(err) => { + let stderr = child.collect_stderr(); + let direct = direct_open_diagnostic(); + anyhow::bail!( + "timed out connecting to oliphaunt-wasix-proxy: {err}\n\nstderr:\n{stderr}\n\ndirect backend diagnostic:\n{direct}" + ); + } + }; + let row = sqlx::query("SELECT $1::int4 + 1 AS answer") + .bind(41_i32) + .fetch_one(&mut conn) + .await?; + assert_eq!(row.try_get::("answer")?, 42); + + conn.close().await?; + Ok(()) +} diff --git a/tests/client_compat.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/client_compat.rs similarity index 96% rename from tests/client_compat.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/client_compat.rs index 96587bff..66f49f46 100644 --- a/tests/client_compat.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/client_compat.rs @@ -1,7 +1,7 @@ #![cfg(feature = "extensions")] use anyhow::{Context, Result}; -use pglite_oxide::{Pglite, PgliteServer}; +use oliphaunt_wasix::{Oliphaunt, OliphauntServer}; use sqlx::{Connection, Executor, Row}; use std::io::{Read, Write}; use std::net::TcpStream; @@ -15,7 +15,7 @@ const CANCEL_REQUEST_CODE: i32 = 80_877_102; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tokio_postgres_extended_query_works() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let (client, connection) = tokio_postgres::connect(&server.connection_uri(), NoTls) .await .context("connect with tokio-postgres")?; @@ -47,7 +47,7 @@ async fn tokio_postgres_extended_query_works() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tokio_postgres_extended_query_errors_recover_after_sync() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let (client, connection) = tokio_postgres::connect(&server.connection_uri(), NoTls) .await .context("connect with tokio-postgres")?; @@ -91,7 +91,7 @@ async fn tokio_postgres_extended_query_errors_recover_after_sync() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_bind_errors_recover_after_sync() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.connection_uri()) .await .context("connect with SQLx")?; @@ -127,7 +127,7 @@ async fn sqlx_bind_errors_recover_after_sync() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tokio_postgres_pipelined_extended_queries_keep_ready_state() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let (client, connection) = tokio_postgres::connect(&server.connection_uri(), NoTls) .await .context("connect with tokio-postgres")?; @@ -148,7 +148,7 @@ async fn tokio_postgres_pipelined_extended_queries_keep_ready_state() -> Result< #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tokio_postgres_mixed_pipelined_success_error_success_recovers() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let (client, connection) = tokio_postgres::connect(&server.connection_uri(), NoTls) .await .context("connect with tokio-postgres")?; @@ -177,7 +177,7 @@ async fn tokio_postgres_mixed_pipelined_success_error_success_recovers() -> Resu #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_wire_protocol_bind_errors_are_synchronized() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; tokio::task::spawn_blocking(move || -> Result<()> { @@ -259,7 +259,7 @@ async fn raw_wire_protocol_bind_errors_are_synchronized() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_wire_protocol_handles_partial_reads_and_pipelined_simple_queries() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; tokio::task::spawn_blocking(move || -> Result<()> { @@ -315,7 +315,7 @@ async fn raw_wire_protocol_handles_partial_reads_and_pipelined_simple_queries() #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_wire_copy_from_stdin_streams_through_backend_copy_state() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; tokio::task::spawn_blocking(move || -> Result<()> { @@ -367,7 +367,7 @@ async fn raw_wire_copy_from_stdin_streams_through_backend_copy_state() -> Result #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_wire_extended_copy_from_stdin_uses_backend_protocol_pump() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server .tcp_addr() .context("temporary TCP server should expose addr")?; @@ -424,7 +424,7 @@ async fn raw_wire_extended_copy_from_stdin_uses_backend_protocol_pump() -> Resul #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_wire_copy_variants_and_copyfail_are_backend_owned() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; tokio::task::spawn_blocking(move || -> Result<()> { @@ -493,8 +493,8 @@ async fn raw_wire_copy_variants_and_copyfail_are_backend_owned() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_wire_unix_socket_copy_uses_same_protocol_path() -> Result<()> { let dir = tempfile::TempDir::new().context("create Unix socket tempdir")?; - let socket_path = dir.path().join("pglite.sock"); - let server = PgliteServer::builder() + let socket_path = dir.path().join("oliphaunt.sock"); + let server = OliphauntServer::builder() .temporary() .unix(&socket_path) .start()?; @@ -530,7 +530,7 @@ async fn raw_wire_unix_socket_copy_uses_same_protocol_path() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_wire_disconnect_during_extended_query_does_not_poison_backend() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; tokio::task::spawn_blocking(move || -> Result<()> { @@ -559,7 +559,7 @@ async fn raw_wire_disconnect_during_extended_query_does_not_poison_backend() -> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_query_works() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.connection_uri()) .await .context("connect with SQLx")?; @@ -591,7 +591,7 @@ async fn sqlx_query_works() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tokio_postgres_prepared_statement_reuse_works() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let (client, connection) = tokio_postgres::connect(&server.connection_uri(), NoTls) .await .context("connect with tokio-postgres")?; @@ -611,7 +611,7 @@ async fn tokio_postgres_prepared_statement_reuse_works() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_transaction_error_recovers_after_rollback() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.connection_uri()) .await .context("connect with SQLx")?; @@ -643,7 +643,7 @@ async fn sqlx_transaction_error_recovers_after_rollback() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_transaction_commit_and_rollback_preserve_state() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.connection_uri()) .await .context("connect with SQLx")?; @@ -687,7 +687,7 @@ async fn sqlx_transaction_commit_and_rollback_preserve_state() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tokio_postgres_transaction_commit_rollback_and_error_recovery() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let (mut client, connection) = tokio_postgres::connect(&server.connection_uri(), NoTls) .await .context("connect with tokio-postgres")?; @@ -760,7 +760,7 @@ async fn tokio_postgres_transaction_commit_rollback_and_error_recovery() -> Resu #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_extended_query_errors_recover_after_sync() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.connection_uri()) .await .context("connect with SQLx")?; @@ -796,7 +796,7 @@ async fn sqlx_extended_query_errors_recover_after_sync() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_simple_query_timezone_errors_recover() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let mut conn = sqlx::PgConnection::connect(&server.connection_uri()) .await .context("connect with SQLx")?; @@ -850,7 +850,7 @@ async fn sqlx_simple_query_timezone_errors_recover() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_server_startup_postgres_config_uses_real_guc_handling() -> Result<()> { - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .postgres_config("synchronous_commit", "off") .postgres_config("work_mem", "8MB") @@ -890,7 +890,7 @@ async fn sqlx_server_startup_postgres_config_uses_real_guc_handling() -> Result< #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sqlx_server_relaxed_durability_is_idempotent_and_user_config_wins() -> Result<()> { - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .relaxed_durability(true) .relaxed_durability(false) @@ -905,7 +905,7 @@ async fn sqlx_server_relaxed_durability_is_idempotent_and_user_config_wins() -> conn.close().await?; server.shutdown()?; - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .relaxed_durability(true) .postgres_config("synchronous_commit", "on") @@ -928,7 +928,7 @@ async fn sqlx_server_startup_identity_can_select_existing_user_and_database() -> let seed_root = root.path().to_path_buf(); let seed_task_root = seed_root.clone(); tokio::task::spawn_blocking(move || -> Result<()> { - let mut admin = Pglite::builder().path(seed_task_root).open()?; + let mut admin = Oliphaunt::builder().path(seed_task_root).open()?; admin.exec("CREATE ROLE server_user LOGIN", None)?; admin.exec("CREATE DATABASE server_db OWNER server_user", None)?; admin.close()?; @@ -937,7 +937,7 @@ async fn sqlx_server_startup_identity_can_select_existing_user_and_database() -> .await .context("join startup identity seed task")??; - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .path(seed_root) .username("server_user") .database("server_db") @@ -958,7 +958,7 @@ async fn sqlx_server_startup_identity_can_select_existing_user_and_database() -> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tokio_postgres_startup_options_are_forwarded_to_postgres() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; let mut config = tokio_postgres::Config::new(); config @@ -991,7 +991,7 @@ async fn tokio_postgres_startup_options_are_forwarded_to_postgres() -> Result<() #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn postgres_control_packets_are_handled_safely() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; let ssl_response = tokio::task::spawn_blocking(move || -> Result { @@ -1034,7 +1034,7 @@ async fn postgres_control_packets_are_handled_safely() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn postgres_startup_identity_is_delegated_to_postgres() -> Result<()> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let addr = server.tcp_addr().context("server should use TCP")?; let bad_user = tokio::task::spawn_blocking(move || -> Result> { diff --git a/tests/extensions_smoke.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/extensions_smoke.rs similarity index 76% rename from tests/extensions_smoke.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/extensions_smoke.rs index b90012ca..9a937514 100644 --- a/tests/extensions_smoke.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/extensions_smoke.rs @@ -1,7 +1,7 @@ #![cfg(feature = "extensions")] use anyhow::Result; -use pglite_oxide::{Pglite, PgliteError, PgliteServer, extensions}; +use oliphaunt_wasix::{Oliphaunt, OliphauntError, OliphauntServer, extensions}; use serde_json::json; use sqlx::{Connection, Row}; use std::path::{Path, PathBuf}; @@ -27,13 +27,13 @@ fn trace_expected(label: &str) { eprintln!("extensions_smoke::expected_sql_error exercising {label}"); } -fn first_f64(result: &pglite_oxide::Results, column: &str) -> f64 { +fn first_f64(result: &oliphaunt_wasix::Results, column: &str) -> f64 { result.rows[0][column].as_f64().expect("floating result") } -fn assert_pglite_code(err: &anyhow::Error, expected_code: &str, message_contains: &str) { +fn assert_oliphaunt_code(err: &anyhow::Error, expected_code: &str, message_contains: &str) { let pg_err = err - .downcast_ref::() + .downcast_ref::() .expect("error should preserve Postgres fields"); assert_eq!(pg_err.database_error().code.as_deref(), Some(expected_code)); assert!( @@ -43,6 +43,57 @@ fn assert_pglite_code(err: &anyhow::Error, expected_code: &str, message_contains ); } +const PGCRYPTO_FUNCTIONAL_SMOKE_SQL: &str = "\ +DO $$ \ +DECLARE \ + hashed text; \ + encrypted bytea; \ + armored text; \ + header_count int; \ + crypto_key bytea := decode('000102030405060708090a0b0c0d0e0f', 'hex'); \ + crypto_iv bytea := decode('101112131415161718191a1b1c1d1e1f', 'hex'); \ +BEGIN \ + IF encode(digest('abc', 'sha256'), 'hex') <> 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' THEN \ + RAISE EXCEPTION 'sha256 digest failed'; \ + END IF; \ + IF encode(hmac('test', 'key', 'sha1'), 'hex') <> '671f54ce0c540f78ffe1e26dcf9c2a047aea4fda' THEN \ + RAISE EXCEPTION 'hmac failed'; \ + END IF; \ + IF length(gen_random_bytes(16)) <> 16 THEN \ + RAISE EXCEPTION 'random bytes length failed'; \ + END IF; \ + IF gen_random_uuid()::text !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN \ + RAISE EXCEPTION 'random uuid format failed'; \ + END IF; \ + SELECT crypt('secret', gen_salt('bf', 4)) INTO hashed; \ + IF crypt('secret', hashed) <> hashed THEN \ + RAISE EXCEPTION 'password hash verify failed'; \ + END IF; \ + SELECT armor(digest('test', 'sha1'), ARRAY['Version'], ARRAY['oliphaunt']) INTO armored; \ + IF position('Version: oliphaunt' in armored) = 0 THEN \ + RAISE EXCEPTION 'armor header failed'; \ + END IF; \ + SELECT count(*) INTO header_count FROM pgp_armor_headers(armored); \ + IF header_count <> 1 THEN \ + RAISE EXCEPTION 'armor header count failed: %', header_count; \ + END IF; \ + SELECT pgp_sym_encrypt('oliphaunt secret', 'passphrase') INTO encrypted; \ + IF pgp_sym_decrypt(encrypted, 'passphrase') <> 'oliphaunt secret' THEN \ + RAISE EXCEPTION 'PGP symmetric decrypt failed'; \ + END IF; \ + IF pgp_key_id(encrypted) <> 'SYMKEY' THEN \ + RAISE EXCEPTION 'PGP symmetric key id failed'; \ + END IF; \ + SELECT encrypt(convert_to('oliphaunt raw cipher', 'UTF8'), crypto_key, 'aes') INTO encrypted; \ + IF convert_from(decrypt(encrypted, crypto_key, 'aes'), 'UTF8') <> 'oliphaunt raw cipher' THEN \ + RAISE EXCEPTION 'raw decrypt failed'; \ + END IF; \ + SELECT encrypt_iv(convert_to('oliphaunt iv cipher', 'UTF8'), crypto_key, crypto_iv, 'aes-cbc') INTO encrypted; \ + IF convert_from(decrypt_iv(encrypted, crypto_key, crypto_iv, 'aes-cbc'), 'UTF8') <> 'oliphaunt iv cipher' THEN \ + RAISE EXCEPTION 'raw iv decrypt failed'; \ + END IF; \ +END $$"; + fn assert_sqlx_code(err: &sqlx::Error, expected_code: &str) { assert_eq!( err.as_database_error().and_then(|db| db.code()).as_deref(), @@ -55,7 +106,7 @@ fn assert_only_requested_extension_assets_are_materialized( requested: &str, unrequested: &str, ) { - let runtime = root.join("tmp/pglite"); + let runtime = root.join("tmp/oliphaunt"); assert!( runtime .join(format!("lib/postgresql/{requested}.so")) @@ -138,7 +189,7 @@ fn relative_files(root: &Path) -> Vec { #[test] fn vector_extension_direct_smoke() -> Result<()> { let _trace = TestTrace::new("vector_extension_direct_smoke"); - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; @@ -174,7 +225,7 @@ fn vector_extension_direct_smoke() -> Result<()> { None, ) .expect_err("division by zero after vector load should fail"); - assert_pglite_code(&err, "22012", "division by zero"); + assert_oliphaunt_code(&err, "22012", "division by zero"); let recovered = db.query("SELECT 13::int AS recovered_after_vector_error", &[], None)?; assert_eq!(recovered.rows[0]["recovered_after_vector_error"], json!(13)); @@ -186,7 +237,7 @@ fn vector_extension_direct_smoke() -> Result<()> { None, ) .expect_err("invalid vector literal should fail inside the vector extension"); - assert_pglite_code( + assert_oliphaunt_code( &invalid_vector, "22P02", "invalid input syntax for type vector", @@ -209,7 +260,7 @@ fn vector_extension_direct_smoke() -> Result<()> { None, ) .expect_err("vector distance should reject mismatched dimensions"); - assert_pglite_code(&dimension_mismatch, "22000", "different vector dimensions"); + assert_oliphaunt_code(&dimension_mismatch, "22000", "different vector dimensions"); let recovered = db.query( "SELECT 16::int AS recovered_after_dimension_mismatch", &[], @@ -229,7 +280,7 @@ fn pure_mountfs_materializes_only_requested_extension_assets() -> Result<()> { let _trace = TestTrace::new("pure_mountfs_materializes_only_requested_extension_assets"); let root = tempfile::TempDir::new()?; { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .path(root.path()) .extension(extensions::VECTOR) .open()?; @@ -249,7 +300,7 @@ fn pure_mountfs_materializes_only_requested_extension_assets() -> Result<()> { #[test] fn vector_extension_ports_pgvector_core_type_cases() -> Result<()> { let _trace = TestTrace::new("vector_extension_ports_pgvector_core_type_cases"); - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; @@ -289,7 +340,7 @@ fn vector_extension_ports_pgvector_core_type_cases() -> Result<()> { Ok(_) => panic!("{sql} should fail"), Err(err) => err, }; - assert_pglite_code(&err, code, message); + assert_oliphaunt_code(&err, code, message); let recovered = db.query("SELECT 17::int AS recovered", &[], None)?; assert_eq!(recovered.rows[0]["recovered"], json!(17)); } @@ -302,7 +353,7 @@ fn vector_extension_ports_pgvector_core_type_cases() -> Result<()> { fn vector_extension_direct_transaction_commit_rollback_and_error_recovery() -> Result<()> { let _trace = TestTrace::new("vector_extension_direct_transaction_commit_rollback_and_error_recovery"); - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; @@ -346,7 +397,7 @@ fn vector_extension_direct_transaction_commit_rollback_and_error_recovery() -> R Ok(()) }); let failed = failed.expect_err("invalid vector should fail inside transaction"); - assert_pglite_code(&failed, "22P02", "invalid input syntax for type vector"); + assert_oliphaunt_code(&failed, "22P02", "invalid input syntax for type vector"); let result = db.query( "SELECT count(*)::int AS count, \ @@ -371,11 +422,11 @@ fn vector_extension_install_is_demand_driven_idempotent_and_persistent() -> Resu TestTrace::new("vector_extension_install_is_demand_driven_idempotent_and_persistent"); let root = tempfile::TempDir::new()?; { - let mut db = Pglite::builder().path(root.path()).open()?; + let mut db = Oliphaunt::builder().path(root.path()).open()?; assert!( !db.paths() .pgroot - .join("pglite") + .join("oliphaunt") .join("lib/postgresql/vector.so") .exists(), "vector side module should not be installed before it is requested" @@ -386,7 +437,7 @@ fn vector_extension_install_is_demand_driven_idempotent_and_persistent() -> Resu assert!( db.paths() .pgroot - .join("pglite") + .join("oliphaunt") .join("lib/postgresql/vector.so") .exists(), "vector side module should be installed after enable_extension" @@ -402,7 +453,7 @@ fn vector_extension_install_is_demand_driven_idempotent_and_persistent() -> Resu } { - let mut reopened = Pglite::builder().path(root.path()).open()?; + let mut reopened = Oliphaunt::builder().path(root.path()).open()?; let result = reopened.query("SELECT '[1,2,3]'::vector::text AS value", &[], None)?; assert_eq!(result.rows[0]["value"], json!("[1,2,3]")); reopened.close()?; @@ -414,7 +465,7 @@ fn vector_extension_install_is_demand_driven_idempotent_and_persistent() -> Resu #[test] fn pg_trgm_extension_direct_smoke() -> Result<()> { let _trace = TestTrace::new("pg_trgm_extension_direct_smoke"); - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::PG_TRGM) .open()?; @@ -444,7 +495,7 @@ fn pg_trgm_extension_direct_smoke() -> Result<()> { #[test] fn hstore_extension_direct_smoke() -> Result<()> { let _trace = TestTrace::new("hstore_extension_direct_smoke"); - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::HSTORE) .open()?; @@ -484,7 +535,7 @@ fn hstore_extension_reopens_cleanly() -> Result<()> { let _trace = TestTrace::new("hstore_extension_reopens_cleanly"); let root = tempfile::TempDir::new()?; { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .path(root.path()) .extension(extensions::HSTORE) .open()?; @@ -497,7 +548,7 @@ fn hstore_extension_reopens_cleanly() -> Result<()> { } { - let mut reopened = Pglite::builder().path(root.path()).open()?; + let mut reopened = Oliphaunt::builder().path(root.path()).open()?; let result = reopened.query( "SELECT data -> 'name' AS name FROM oxide_hstore_restart", &[], @@ -510,10 +561,104 @@ fn hstore_extension_reopens_cleanly() -> Result<()> { Ok(()) } +#[test] +fn pgcrypto_extension_direct_smoke() -> Result<()> { + let _trace = TestTrace::new("pgcrypto_extension_direct_smoke"); + let mut db = Oliphaunt::builder() + .temporary() + .extension(extensions::PGCRYPTO) + .open()?; + + db.exec(PGCRYPTO_FUNCTIONAL_SMOKE_SQL, None)?; + + let installed = db.query( + "SELECT count(*)::int AS count, max(n.nspname) AS schema_name \ + FROM pg_extension e \ + JOIN pg_namespace n ON n.oid = e.extnamespace \ + WHERE e.extname = 'pgcrypto'", + &[], + None, + )?; + assert_eq!(installed.rows[0]["count"], json!(1)); + assert_eq!(installed.rows[0]["schema_name"], json!("public")); + + trace_expected("pgcrypto_direct invalid digest algorithm"); + let invalid_algorithm = db + .query("SELECT digest('abc', 'not-a-digest')", &[], None) + .expect_err("invalid pgcrypto digest algorithm should fail"); + assert_oliphaunt_code( + &invalid_algorithm, + "22023", + "Cannot use \"not-a-digest\": No such hash algorithm", + ); + let recovered = db.query( + "SELECT 23::int AS recovered_after_pgcrypto_error", + &[], + None, + )?; + assert_eq!( + recovered.rows[0]["recovered_after_pgcrypto_error"], + json!(23) + ); + + db.close()?; + Ok(()) +} + +#[test] +fn pgcrypto_extension_reopens_cleanly() -> Result<()> { + let _trace = TestTrace::new("pgcrypto_extension_reopens_cleanly"); + let root = tempfile::TempDir::new()?; + { + let mut db = Oliphaunt::builder() + .path(root.path()) + .extension(extensions::PGCRYPTO) + .open()?; + db.exec(PGCRYPTO_FUNCTIONAL_SMOKE_SQL, None)?; + db.exec( + "CREATE TABLE oxide_pgcrypto_restart AS \ + SELECT encode(digest('persisted', 'sha256'), 'hex') AS digest_hex", + None, + )?; + db.close()?; + } + + { + let mut reopened = Oliphaunt::builder().path(root.path()).open()?; + let result = reopened.query( + "SELECT digest_hex = encode(digest('persisted', 'sha256'), 'hex') AS ok \ + FROM oxide_pgcrypto_restart", + &[], + None, + )?; + assert_eq!(result.rows[0]["ok"], json!(true)); + reopened.close()?; + } + + Ok(()) +} + +#[test] +fn pgcrypto_extension_materializes_only_requested_assets() -> Result<()> { + let _trace = TestTrace::new("pgcrypto_extension_materializes_only_requested_assets"); + let root = tempfile::TempDir::new()?; + { + let mut db = Oliphaunt::builder() + .path(root.path()) + .extension(extensions::PGCRYPTO) + .open()?; + db.exec(PGCRYPTO_FUNCTIONAL_SMOKE_SQL, None)?; + db.close()?; + } + + assert_only_requested_extension_assets_are_materialized(root.path(), "pgcrypto", "vector"); + Ok(()) +} + #[test] fn multiple_extension_set_direct_smoke() -> Result<()> { let _trace = TestTrace::new("multiple_extension_set_direct_smoke"); - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extensions([extensions::VECTOR, extensions::PG_TRGM]) .open()?; @@ -544,7 +689,7 @@ fn multiple_extension_set_direct_smoke() -> Result<()> { #[tokio::test(flavor = "multi_thread")] async fn pg_trgm_extension_server_sqlx_smoke() -> Result<()> { let _trace = TestTrace::new("pg_trgm_extension_server_sqlx_smoke"); - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .extension(extensions::PG_TRGM) .start()?; @@ -569,7 +714,7 @@ async fn pg_trgm_extension_server_sqlx_smoke() -> Result<()> { #[tokio::test(flavor = "multi_thread")] async fn hstore_extension_server_sqlx_smoke() -> Result<()> { let _trace = TestTrace::new("hstore_extension_server_sqlx_smoke"); - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .extension(extensions::HSTORE) .start()?; @@ -596,10 +741,33 @@ async fn hstore_extension_server_sqlx_smoke() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn pgcrypto_extension_server_sqlx_smoke() -> Result<()> { + let _trace = TestTrace::new("pgcrypto_extension_server_sqlx_smoke"); + let server = OliphauntServer::builder() + .temporary() + .extension(extensions::PGCRYPTO) + .start()?; + let mut conn = sqlx::PgConnection::connect(&server.connection_uri()).await?; + + sqlx::query(PGCRYPTO_FUNCTIONAL_SMOKE_SQL) + .execute(&mut conn) + .await?; + let row = + sqlx::query("SELECT count(*)::int4 AS count FROM pg_extension WHERE extname = 'pgcrypto'") + .fetch_one(&mut conn) + .await?; + assert_eq!(row.try_get::("count")?, 1); + + conn.close().await?; + server.shutdown()?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn vector_extension_server_sqlx_smoke() -> Result<()> { let _trace = TestTrace::new("vector_extension_server_sqlx_smoke"); - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .extension(extensions::VECTOR) .start()?; @@ -669,7 +837,7 @@ async fn vector_extension_server_sqlx_transaction_commit_rollback_and_error_reco let _trace = TestTrace::new( "vector_extension_server_sqlx_transaction_commit_rollback_and_error_recovery", ); - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .extension(extensions::VECTOR) .start()?; @@ -758,7 +926,7 @@ async fn vector_extension_server_sqlx_transaction_commit_rollback_and_error_reco #[tokio::test(flavor = "multi_thread")] async fn multiple_extension_set_server_sqlx_smoke() -> Result<()> { let _trace = TestTrace::new("multiple_extension_set_server_sqlx_smoke"); - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .temporary() .extensions([extensions::VECTOR, extensions::PG_TRGM]) .start()?; diff --git a/tests/performance_smoke.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/performance_smoke.rs similarity index 73% rename from tests/performance_smoke.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/performance_smoke.rs index faedef6f..06b53632 100644 --- a/tests/performance_smoke.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/performance_smoke.rs @@ -1,28 +1,31 @@ #![cfg(feature = "extensions")] use anyhow::Result; -use pglite_oxide::PgliteServer; -use pglite_oxide::extensions; -use pglite_oxide::{Pglite, capture_phase_timings}; +use oliphaunt_wasix::OliphauntServer; +use oliphaunt_wasix::extensions; +use oliphaunt_wasix::{Oliphaunt, capture_phase_timings}; use serde_json::json; use std::time::Instant; -fn first_int(result: &pglite_oxide::Results, column: &str) -> i64 { +fn first_int(result: &oliphaunt_wasix::Results, column: &str) -> i64 { result.rows[0][column].as_i64().expect("integer result") } -fn phase_elapsed_micros(phases: &[pglite_oxide::PhaseTiming], name: &str) -> Option { +fn phase_elapsed_micros(phases: &[oliphaunt_wasix::PhaseTiming], name: &str) -> Option { phases .iter() .find(|phase| phase.name == name) .map(|phase| phase.elapsed_micros) } -fn assert_startup_xlog_fast_if_instrumented(phases: &[pglite_oxide::PhaseTiming], context: &str) { +fn assert_startup_xlog_fast_if_instrumented( + phases: &[oliphaunt_wasix::PhaseTiming], + context: &str, +) { let Some(startup_xlog) = phase_elapsed_micros(phases, "postgres.backend.c.startup_xlog") else { eprintln!( "{context}: C backend timing is not present; rebuild assets with \ - PGLITE_OXIDE_WASIX_BACKEND_TIMING=1 to assert StartupXLOG directly" + OLIPHAUNT_WASM_WASIX_BACKEND_TIMING=1 to assert StartupXLOG directly" ); return; }; @@ -33,14 +36,30 @@ fn assert_startup_xlog_fast_if_instrumented(phases: &[pglite_oxide::PhaseTiming] ); } +#[cfg(feature = "extensions")] +fn preload_vector_or_skip(context: &str) -> Result { + match Oliphaunt::preload_extensions([extensions::VECTOR]) { + Ok(()) => Ok(true), + Err(err) + if err + .to_string() + .contains("is not bundled in this oliphaunt-wasix build") => + { + eprintln!("skipping {context}; vector extension assets are not bundled"); + Ok(false) + } + Err(err) => Err(err), + } +} + #[test] fn preload_runtime_then_open_smoke() -> Result<()> { let preload_started = Instant::now(); - Pglite::preload()?; + Oliphaunt::preload()?; let preload_elapsed = preload_started.elapsed(); let open_started = Instant::now(); - let mut db = Pglite::builder().temporary().open()?; + let mut db = Oliphaunt::builder().temporary().open()?; let open_elapsed = open_started.elapsed(); let result = db.query("SELECT $1::int + 1 AS answer", &[json!(41)], None)?; @@ -58,7 +77,7 @@ fn preload_runtime_then_open_smoke() -> Result<()> { #[test] fn scalar_open_does_not_scan_array_catalog() -> Result<()> { let (result, phases) = capture_phase_timings(|| { - let mut db = Pglite::builder().temporary().open()?; + let mut db = Oliphaunt::builder().temporary().open()?; let result = db.query("SELECT $1::int + 1 AS answer", &[json!(41)], None)?; assert_eq!(first_int(&result, "answer"), 42); db.close() @@ -68,7 +87,7 @@ fn scalar_open_does_not_scan_array_catalog() -> Result<()> { assert!( !phases .iter() - .any(|phase| phase.name == "pglite.array_type_catalog_query"), + .any(|phase| phase.name == "oliphaunt.array_type_catalog_query"), "scalar open/query should not scan pg_type for array mappings: {phases:#?}" ); Ok(()) @@ -76,9 +95,9 @@ fn scalar_open_does_not_scan_array_catalog() -> Result<()> { #[test] fn preload_reuses_process_aot_module_cache() -> Result<()> { - let (first, first_phases) = capture_phase_timings(Pglite::preload); + let (first, first_phases) = capture_phase_timings(Oliphaunt::preload); first?; - let (second, second_phases) = capture_phase_timings(Pglite::preload); + let (second, second_phases) = capture_phase_timings(Oliphaunt::preload); second?; let first_deserialized = first_phases @@ -99,16 +118,16 @@ fn preload_reuses_process_aot_module_cache() -> Result<()> { #[test] fn shared_runtime_does_not_share_database_state_between_instances() -> Result<()> { - Pglite::preload()?; + Oliphaunt::preload()?; - let mut first = Pglite::builder().temporary().open()?; + let mut first = Oliphaunt::builder().temporary().open()?; first.exec( "CREATE TABLE process_cache_isolation(value int); \ INSERT INTO process_cache_isolation VALUES (42);", None, )?; - let mut second = Pglite::builder().temporary().open()?; + let mut second = Oliphaunt::builder().temporary().open()?; let missing = second .query("SELECT value FROM process_cache_isolation", &[], None) .expect_err("temporary database state must not leak across instances"); @@ -127,7 +146,7 @@ fn shared_runtime_does_not_share_database_state_between_instances() -> Result<() fn persistent_direct_close_avoids_startup_xlog_recovery() -> Result<()> { let root = tempfile::TempDir::new()?; { - let mut db = Pglite::builder().path(root.path()).open()?; + let mut db = Oliphaunt::builder().path(root.path()).open()?; db.exec( "CREATE TABLE clean_shutdown(value int); \ INSERT INTO clean_shutdown VALUES (42);", @@ -137,7 +156,7 @@ fn persistent_direct_close_avoids_startup_xlog_recovery() -> Result<()> { } let (result, phases) = capture_phase_timings(|| -> Result<()> { - let mut db = Pglite::open(root.path())?; + let mut db = Oliphaunt::open(root.path())?; let row = db.query("SELECT value FROM clean_shutdown", &[], None)?; assert_eq!(first_int(&row, "value"), 42); db.close() @@ -152,10 +171,12 @@ fn persistent_direct_close_avoids_startup_xlog_recovery() -> Result<()> { #[test] fn preload_extensions_reuses_extension_side_module_cache() -> Result<()> { let (first, first_phases) = - capture_phase_timings(|| Pglite::preload_extensions([extensions::VECTOR])); - first?; + capture_phase_timings(|| preload_vector_or_skip("extension preload cache smoke")); + if !first? { + return Ok(()); + } let (second, second_phases) = - capture_phase_timings(|| Pglite::preload_extensions([extensions::VECTOR])); + capture_phase_timings(|| Oliphaunt::preload_extensions([extensions::VECTOR])); second?; let first_deserialized = first_phases @@ -177,11 +198,13 @@ fn preload_extensions_reuses_extension_side_module_cache() -> Result<()> { #[cfg(feature = "extensions")] #[test] fn persistent_extension_server_reopen_uses_single_clean_backend() -> Result<()> { - Pglite::preload_extensions([extensions::VECTOR])?; + if !preload_vector_or_skip("persistent extension server reopen smoke")? { + return Ok(()); + } let root = tempfile::TempDir::new()?; { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .path(root.path()) .extension(extensions::VECTOR) .open()?; @@ -194,7 +217,7 @@ fn persistent_extension_server_reopen_uses_single_clean_backend() -> Result<()> } let (result, phases) = capture_phase_timings(|| -> Result<()> { - let server = PgliteServer::builder() + let server = OliphauntServer::builder() .path(root.path()) .extension(extensions::VECTOR) .start()?; @@ -234,10 +257,19 @@ fn persistent_extension_server_reopen_uses_single_clean_backend() -> Result<()> #[cfg(feature = "extensions")] #[test] fn cached_extension_template_opens_without_startup_xlog_recovery() -> Result<()> { - Pglite::preload_extensions([extensions::VECTOR])?; + if std::env::var_os("OLIPHAUNT_WASM_EXTENSION_TEMPLATE_CACHE").is_none() { + eprintln!( + "skipping cached extension template smoke; set OLIPHAUNT_WASM_EXTENSION_TEMPLATE_CACHE=1 to exercise the opt-in cache" + ); + return Ok(()); + } + + if !preload_vector_or_skip("cached extension template smoke")? { + return Ok(()); + } { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; @@ -250,7 +282,7 @@ fn cached_extension_template_opens_without_startup_xlog_recovery() -> Result<()> } let (result, phases) = capture_phase_timings(|| -> Result<()> { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .extension(extensions::VECTOR) .open()?; diff --git a/tests/postgres_regression.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/postgres_regression.rs similarity index 91% rename from tests/postgres_regression.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/postgres_regression.rs index c743308e..21e03fa7 100644 --- a/tests/postgres_regression.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/postgres_regression.rs @@ -1,7 +1,7 @@ #![cfg(feature = "extensions")] use anyhow::{Context, Result, anyhow}; -use pglite_oxide::{Pglite, QueryOptions}; +use oliphaunt_wasix::{Oliphaunt, QueryOptions}; use serde_json::{Map, Value, json}; struct TestTrace { @@ -21,7 +21,7 @@ impl Drop for TestTrace { } } -fn first_row(result: &pglite_oxide::Results) -> Result<&Map> { +fn first_row(result: &oliphaunt_wasix::Results) -> Result<&Map> { result .rows .first() @@ -29,7 +29,7 @@ fn first_row(result: &pglite_oxide::Results) -> Result<&Map> { .ok_or_else(|| anyhow!("expected at least one object row")) } -fn single_column_strings(result: &pglite_oxide::Results, column: &str) -> Result> { +fn single_column_strings(result: &oliphaunt_wasix::Results, column: &str) -> Result> { result .rows .iter() @@ -42,10 +42,14 @@ fn single_column_strings(result: &pglite_oxide::Results, column: &str) -> Result .collect() } +fn open_regression_db() -> Result { + Oliphaunt::builder().temporary().open() +} + #[test] -fn datatypes_cover_pglite_basic_surface() -> Result<()> { - let _trace = TestTrace::new("datatypes_cover_pglite_basic_surface"); - let mut db = Pglite::builder().temporary().open()?; +fn datatypes_cover_oliphaunt_basic_surface() -> Result<()> { + let _trace = TestTrace::new("datatypes_cover_oliphaunt_basic_surface"); + let mut db = open_regression_db()?; db.exec( "CREATE TABLE regression_types ( @@ -227,7 +231,7 @@ fn datatypes_cover_pglite_basic_surface() -> Result<()> { #[test] fn ddl_schema_view_trigger_and_rollback_behave_like_postgres() -> Result<()> { let _trace = TestTrace::new("ddl_schema_view_trigger_and_rollback_behave_like_postgres"); - let mut db = Pglite::builder().temporary().open()?; + let mut db = open_regression_db()?; db.exec( "CREATE SCHEMA reg; @@ -293,7 +297,7 @@ fn ddl_schema_view_trigger_and_rollback_behave_like_postgres() -> Result<()> { .expect_err("check constraint should reject negative balance"); eprintln!("postgres_regression::ddl_schema expected check-constraint error returned"); let pg_error = constraint_error - .downcast_ref::() + .downcast_ref::() .context("constraint error should preserve PostgreSQL fields")?; assert_eq!(pg_error.database_error().code.as_deref(), Some("23514")); @@ -332,7 +336,7 @@ fn ddl_schema_view_trigger_and_rollback_behave_like_postgres() -> Result<()> { #[test] fn transactions_savepoints_and_error_recovery_match_postgres() -> Result<()> { let _trace = TestTrace::new("transactions_savepoints_and_error_recovery_match_postgres"); - let mut db = Pglite::builder().temporary().open()?; + let mut db = open_regression_db()?; db.exec( "CREATE TABLE tx_items ( id integer PRIMARY KEY, @@ -372,7 +376,7 @@ fn transactions_savepoints_and_error_recovery_match_postgres() -> Result<()> { .expect_err("duplicate primary key should fail inside savepoint"); eprintln!("postgres_regression::transactions expected duplicate-key error returned"); let pg_error = duplicate - .downcast_ref::() + .downcast_ref::() .context("duplicate error should preserve PostgreSQL fields")?; assert_eq!(pg_error.database_error().code.as_deref(), Some("23505")); db.exec("ROLLBACK TO SAVEPOINT duplicate_guard", None)?; @@ -401,7 +405,7 @@ fn transactions_savepoints_and_error_recovery_match_postgres() -> Result<()> { #[test] fn expected_sql_error_recovery_stays_inside_protocol_loop() -> Result<()> { let _trace = TestTrace::new("expected_sql_error_recovery_stays_inside_protocol_loop"); - let mut db = Pglite::builder().temporary().open()?; + let mut db = open_regression_db()?; db.exec( "CREATE TABLE error_recovery ( id integer PRIMARY KEY, @@ -426,7 +430,7 @@ fn expected_sql_error_recovery_stays_inside_protocol_loop() -> Result<()> { eprintln!("postgres_regression::expected_sql_error exercising {label}"); let err = db.exec(sql, None).expect_err(label); let pg_error = err - .downcast_ref::() + .downcast_ref::() .with_context(|| format!("{label} should preserve PostgreSQL fields"))?; assert_eq!(pg_error.database_error().code.as_deref(), Some(code)); let recovered = db.query( @@ -441,9 +445,9 @@ fn expected_sql_error_recovery_stays_inside_protocol_loop() -> Result<()> { } #[test] -fn pg17_uuidv4_alias_error_is_recoverable() -> Result<()> { - let _trace = TestTrace::new("pg17_uuidv4_alias_error_is_recoverable"); - let mut db = Pglite::builder().temporary().open()?; +fn pg18_uuidv4_alias_is_available_and_session_recovers() -> Result<()> { + let _trace = TestTrace::new("pg18_uuidv4_alias_is_available_and_session_recovers"); + let mut db = open_regression_db()?; let built_in = db.query( "SELECT uuid_extract_version(gen_random_uuid())::int AS version", @@ -452,21 +456,20 @@ fn pg17_uuidv4_alias_error_is_recoverable() -> Result<()> { )?; assert_eq!(first_row(&built_in)?.get("version"), Some(&json!(4))); + let alias = db.query( + "SELECT uuid_extract_version(uuidv4())::int AS version", + &[], + None, + )?; + assert_eq!(first_row(&alias)?.get("version"), Some(&json!(4))); + let err = db - .query("SELECT uuidv4() AS id", &[], None) - .expect_err("PostgreSQL 17 should not expose the PostgreSQL 18 uuidv4 alias"); + .query("SELECT uuidv4(1) AS id", &[], None) + .expect_err("invalid uuidv4 arity should still exercise PostgreSQL error recovery"); let pg_error = err - .downcast_ref::() - .context("uuidv4 error should preserve PostgreSQL fields")?; + .downcast_ref::() + .context("uuidv4 arity error should preserve PostgreSQL fields")?; assert_eq!(pg_error.database_error().code.as_deref(), Some("42883")); - assert!( - pg_error - .database_error() - .message - .contains("function uuidv4() does not exist"), - "unexpected uuidv4 error: {}", - pg_error.database_error().message - ); let recovered = db.query("SELECT 7::int AS recovered", &[], None)?; assert_eq!(first_row(&recovered)?.get("recovered"), Some(&json!(7))); @@ -477,7 +480,7 @@ fn pg17_uuidv4_alias_error_is_recoverable() -> Result<()> { #[test] fn planner_uses_indexes_for_selective_queries_and_updates() -> Result<()> { let _trace = TestTrace::new("planner_uses_indexes_for_selective_queries_and_updates"); - let mut db = Pglite::builder().temporary().open()?; + let mut db = open_regression_db()?; db.exec( "CREATE TABLE plan_items ( id integer PRIMARY KEY, @@ -565,9 +568,9 @@ fn planner_uses_indexes_for_selective_queries_and_updates() -> Result<()> { } #[test] -fn direct_blob_copy_round_trips_csv_with_pglite_dev_blob_surface() -> Result<()> { - let _trace = TestTrace::new("direct_blob_copy_round_trips_csv_with_pglite_dev_blob_surface"); - let mut db = Pglite::builder().temporary().open()?; +fn direct_blob_copy_round_trips_csv_with_oliphaunt_dev_blob_surface() -> Result<()> { + let _trace = TestTrace::new("direct_blob_copy_round_trips_csv_with_oliphaunt_dev_blob_surface"); + let mut db = open_regression_db()?; db.exec( "CREATE TABLE blob_items ( id integer PRIMARY KEY, @@ -642,7 +645,7 @@ fn direct_blob_copy_round_trips_csv_with_pglite_dev_blob_surface() -> Result<()> Ok(()) } -fn explain_text(db: &mut Pglite, sql: &str) -> Result { +fn explain_text(db: &mut Oliphaunt, sql: &str) -> Result { let result = db.query(sql, &[], None)?; let lines = single_column_strings(&result, "QUERY PLAN")?; Ok(lines.join("\n")) diff --git a/tests/proxy_smoke.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/proxy_smoke.rs similarity index 96% rename from tests/proxy_smoke.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/proxy_smoke.rs index 368ecc8f..0600e6fc 100644 --- a/tests/proxy_smoke.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/proxy_smoke.rs @@ -1,7 +1,7 @@ #![cfg(feature = "extensions")] use anyhow::{Result, anyhow, bail, ensure}; -use pglite_oxide::PgliteProxy; +use oliphaunt_wasix::OliphauntProxy; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::thread; @@ -17,7 +17,8 @@ fn tcp_proxy_handles_psql_style_connections() -> Result<()> { let addr = listener.local_addr()?; let root = temp_dir.path().to_path_buf(); - let handle = thread::spawn(move || PgliteProxy::new(root).accept_tcp_connections(&listener, 2)); + let handle = + thread::spawn(move || OliphauntProxy::new(root).accept_tcp_connections(&listener, 2)); let first = query_proxy(addr, false, "SELECT 1 AS one")?; assert_eq!(first, vec!["1"]); @@ -151,7 +152,7 @@ fn startup_message() -> Vec { for (key, value) in [ ("user", "postgres"), ("database", "template1"), - ("application_name", "pglite-oxide-test"), + ("application_name", "oliphaunt-wasix-test"), ] { message.extend_from_slice(key.as_bytes()); message.push(0); diff --git a/tests/runtime_smoke.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs similarity index 83% rename from tests/runtime_smoke.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs index cd817fe4..60ad0792 100644 --- a/tests/runtime_smoke.rs +++ b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs @@ -1,8 +1,8 @@ #![cfg(feature = "extensions")] -use pglite_oxide::{ - DataDirArchiveFormat, ExecProtocolOptions, Pglite, PgliteError, PgliteServer, QueryOptions, - QueryTemplate, RowMode, format_query, quote_identifier, +use oliphaunt_wasix::{ + DataDirArchiveFormat, ExecProtocolOptions, Oliphaunt, OliphauntError, OliphauntServer, + QueryOptions, QueryTemplate, RowMode, format_query, quote_identifier, }; use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; @@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex}; mod support; use support::{ChildGuard, TestTrace, trace_step}; -fn first_row(result: &pglite_oxide::Results) -> anyhow::Result<&serde_json::Map> { +fn first_row(result: &oliphaunt_wasix::Results) -> anyhow::Result<&serde_json::Map> { result .rows .first() @@ -75,23 +75,23 @@ fn raw_message_tags_ignoring_parameter_status(bytes: &[u8]) -> Vec { .collect() } -fn raw_backend_message_name(message: &pglite_oxide::BackendMessage) -> &'static str { +fn raw_backend_message_name(message: &oliphaunt_wasix::BackendMessage) -> &'static str { match message { - pglite_oxide::BackendMessage::RowDescription(_) => "rowDescription", - pglite_oxide::BackendMessage::DataRow(_) => "dataRow", - pglite_oxide::BackendMessage::CommandComplete(_) => "commandComplete", - pglite_oxide::BackendMessage::ReadyForQuery(_) => "readyForQuery", - pglite_oxide::BackendMessage::Error(_) => "error", - pglite_oxide::BackendMessage::ParseComplete { .. } => "parseComplete", - pglite_oxide::BackendMessage::BindComplete { .. } => "bindComplete", + oliphaunt_wasix::BackendMessage::RowDescription(_) => "rowDescription", + oliphaunt_wasix::BackendMessage::DataRow(_) => "dataRow", + oliphaunt_wasix::BackendMessage::CommandComplete(_) => "commandComplete", + oliphaunt_wasix::BackendMessage::ReadyForQuery(_) => "readyForQuery", + oliphaunt_wasix::BackendMessage::Error(_) => "error", + oliphaunt_wasix::BackendMessage::ParseComplete { .. } => "parseComplete", + oliphaunt_wasix::BackendMessage::BindComplete { .. } => "bindComplete", _ => "other", } } fn assert_core_runtime_assets_stay_in_lower_mount(root: &std::path::Path) { - let runtime = root.join("tmp/pglite"); + let runtime = root.join("tmp/oliphaunt"); assert!( - runtime.join(".pglite-oxide-mountfs-runtime").is_file(), + runtime.join(".oliphaunt-wasix-mountfs-runtime").is_file(), "expected shared runtime overlay marker under {}", runtime.display() ); @@ -112,7 +112,10 @@ fn assert_core_runtime_assets_stay_in_lower_mount(root: &std::path::Path) { #[test] fn template_cache_false_runs_split_initdb() -> anyhow::Result<()> { - let mut db = Pglite::builder().temporary().template_cache(false).open()?; + let mut db = Oliphaunt::builder() + .temporary() + .template_cache(false) + .open()?; let result = db.query("SELECT 1 AS value", &[], None)?; assert_eq!(first_row(&result)?["value"], json!(1)); Ok(()) @@ -120,7 +123,7 @@ fn template_cache_false_runs_split_initdb() -> anyhow::Result<()> { #[test] fn gen_random_uuid_returns_fresh_values_across_queries() -> anyhow::Result<()> { - let mut db = Pglite::builder().temporary().open()?; + let mut db = Oliphaunt::builder().temporary().open()?; let mut ids = Vec::new(); for _ in 0..4 { @@ -144,7 +147,8 @@ fn gen_random_uuid_returns_fresh_values_across_queries() -> anyhow::Result<()> { #[test] fn direct_transaction_commit_rollback_and_error_recovery() -> anyhow::Result<()> { - let mut pg = Pglite::builder().temporary().open()?; + // OLIPHAUNT_DOCS_SNIPPET wasm-quickstart + let mut pg = Oliphaunt::builder().temporary().open()?; pg.exec( "CREATE TABLE direct_tx_items(id int PRIMARY KEY, value text)", None, @@ -185,7 +189,7 @@ fn direct_transaction_commit_rollback_and_error_recovery() -> anyhow::Result<()> }); let failed = failed.expect_err("transaction should return the SQL failure"); let pg_err = failed - .downcast_ref::() + .downcast_ref::() .expect("transaction SQL error should preserve Postgres fields"); assert_eq!(pg_err.database_error().code.as_deref(), Some("22012")); @@ -210,7 +214,7 @@ fn direct_transaction_commit_rollback_and_error_recovery() -> anyhow::Result<()> #[test] fn direct_startup_postgres_config_uses_real_guc_handling() -> anyhow::Result<()> { - let mut pg = Pglite::builder() + let mut pg = Oliphaunt::builder() .temporary() .postgres_config("synchronous_commit", "off") .postgres_config("work_mem", "8MB") @@ -256,7 +260,7 @@ fn direct_startup_postgres_config_uses_real_guc_handling() -> anyhow::Result<()> #[test] fn invalid_postgres_config_is_rejected_before_backend_startup() -> anyhow::Result<()> { - let err = match Pglite::builder() + let err = match Oliphaunt::builder() .temporary() .postgres_config("bad=name", "off") .open() @@ -275,13 +279,13 @@ fn invalid_postgres_config_is_rejected_before_backend_startup() -> anyhow::Resul fn direct_startup_identity_can_select_existing_user_and_database() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; { - let mut db = Pglite::builder().path(root.path()).open()?; + let mut db = Oliphaunt::builder().path(root.path()).open()?; db.exec("CREATE ROLE test_user LOGIN", None)?; db.exec("CREATE DATABASE test_db OWNER test_user", None)?; db.close()?; } - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .path(root.path()) .username("test_user") .database("test_db") @@ -300,7 +304,7 @@ fn direct_startup_identity_can_select_existing_user_and_database() -> anyhow::Re #[test] fn relaxed_durability_uses_postgres_guc() -> anyhow::Result<()> { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() .relaxed_durability(true) .open()?; @@ -316,7 +320,7 @@ fn relaxed_durability_uses_postgres_guc() -> anyhow::Result<()> { #[test] fn relaxed_durability_is_idempotent_and_user_config_wins() -> anyhow::Result<()> { - let mut disabled = Pglite::builder() + let mut disabled = Oliphaunt::builder() .temporary() .relaxed_durability(true) .relaxed_durability(false) @@ -329,7 +333,7 @@ fn relaxed_durability_is_idempotent_and_user_config_wins() -> anyhow::Result<()> assert_eq!(first_row(&result)?.get("sync_commit"), Some(&json!("on"))); disabled.close()?; - let mut overridden = Pglite::builder() + let mut overridden = Oliphaunt::builder() .temporary() .relaxed_durability(true) .postgres_config("synchronous_commit", "on") @@ -346,9 +350,9 @@ fn relaxed_durability_is_idempotent_and_user_config_wins() -> anyhow::Result<()> #[test] fn startup_args_are_passed_to_postgres() -> anyhow::Result<()> { - let mut db = Pglite::builder() + let mut db = Oliphaunt::builder() .temporary() - .startup_args(["-c", "application_name=pglite-oxide-test"]) + .startup_args(["-c", "application_name=oliphaunt-wasix-test"]) .open()?; let result = db.query( "SELECT current_setting('application_name') AS app", @@ -357,7 +361,7 @@ fn startup_args_are_passed_to_postgres() -> anyhow::Result<()> { )?; assert_eq!( first_row(&result)?.get("app"), - Some(&json!("pglite-oxide-test")) + Some(&json!("oliphaunt-wasix-test")) ); db.close()?; Ok(()) @@ -365,7 +369,7 @@ fn startup_args_are_passed_to_postgres() -> anyhow::Result<()> { #[test] fn data_dir_dump_load_and_clone_round_trip() -> anyhow::Result<()> { - let mut source = Pglite::builder().temporary().open()?; + let mut source = Oliphaunt::builder().temporary().open()?; source.exec( "CREATE TABLE data_dir_items(id serial PRIMARY KEY, value text); INSERT INTO data_dir_items(value) VALUES ('alpha'), ('beta');", @@ -379,7 +383,7 @@ fn data_dir_dump_load_and_clone_round_trip() -> anyhow::Result<()> { )?; let archive = source.dump_data_dir_with_format(DataDirArchiveFormat::Tar)?; - let mut loaded = Pglite::builder() + let mut loaded = Oliphaunt::builder() .temporary() .load_data_dir_archive(archive) .open()?; @@ -415,8 +419,8 @@ fn data_dir_dump_load_and_clone_round_trip() -> anyhow::Result<()> { } #[test] -fn direct_raw_protocol_api_matches_pglite_exec_protocol_cases() -> anyhow::Result<()> { - let mut db = Pglite::builder().temporary().open()?; +fn direct_raw_protocol_api_matches_oliphaunt_exec_protocol_cases() -> anyhow::Result<()> { + let mut db = Oliphaunt::builder().temporary().open()?; let simple = db.exec_protocol( &raw_query_message("SELECT 1"), @@ -431,7 +435,7 @@ fn direct_raw_protocol_api_matches_pglite_exec_protocol_cases() -> anyhow::Resul .messages .iter() .filter(|message| { - !matches!(message, pglite_oxide::BackendMessage::ParameterStatus(_)) + !matches!(message, oliphaunt_wasix::BackendMessage::ParameterStatus(_)) }) .map(raw_backend_message_name) .collect::>(), @@ -462,7 +466,8 @@ fn direct_raw_protocol_api_matches_pglite_exec_protocol_cases() -> anyhow::Resul ) .expect_err("throw_on_error should return the Postgres error"); assert!( - err.downcast_ref::().is_some(), + err.downcast_ref::() + .is_some(), "unexpected raw protocol error: {err:#}" ); @@ -499,7 +504,7 @@ fn direct_raw_protocol_api_matches_pglite_exec_protocol_cases() -> anyhow::Resul #[cfg(debug_assertions)] #[test] fn direct_protocol_bridge_guest_allocations_are_freed() -> anyhow::Result<()> { - let mut db = Pglite::builder().temporary().open()?; + let mut db = Oliphaunt::builder().temporary().open()?; let (allocations_before, frees_before) = db.guest_bridge_allocation_counts(); assert_eq!( allocations_before, frees_before, @@ -540,7 +545,7 @@ fn direct_protocol_bridge_guest_allocations_are_freed() -> anyhow::Result<()> { fn pure_mountfs_serves_core_runtime_assets_from_lower_cache() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; { - let mut pg = Pglite::builder().path(root.path()).open()?; + let mut pg = Oliphaunt::builder().path(root.path()).open()?; let result = pg.query( "SELECT count(*)::int AS utc_zones \ FROM pg_timezone_names \ @@ -560,22 +565,35 @@ fn pure_mountfs_serves_core_runtime_assets_from_lower_cache() -> anyhow::Result< fn server_drop_without_explicit_shutdown_releases_root() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; { - let server = PgliteServer::builder().path(root.path()).start()?; + let server = OliphauntServer::builder().path(root.path()).start()?; assert!(server.tcp_addr().is_some()); } - let mut db = Pglite::builder().path(root.path()).open()?; + let mut db = Oliphaunt::builder().path(root.path()).open()?; let result = db.query("SELECT 1 AS value", &[], None)?; assert_eq!(first_row(&result)?.get("value"), Some(&json!(1))); db.close()?; Ok(()) } +#[test] +fn pg18_embedded_backend_uses_sync_io_method() -> anyhow::Result<()> { + let mut pg = Oliphaunt::builder().temporary().open()?; + let result = pg.query("SHOW io_method", &[], None)?; + assert_eq!( + first_row(&result)?.get("io_method"), + Some(&json!("sync")), + "PostgreSQL 18 worker AIO requires postmaster-managed IO workers; embedded WASIX uses sync AIO" + ); + pg.close()?; + Ok(()) +} + #[test] fn persistent_template_survives_restart_and_stale_state_files() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; { - let mut pg = Pglite::builder().path(root.path()).open()?; + let mut pg = Oliphaunt::builder().path(root.path()).open()?; pg.exec("CREATE TABLE template_restart(value TEXT)", None)?; pg.query( "INSERT INTO template_restart(value) VALUES ($1)", @@ -585,7 +603,7 @@ fn persistent_template_survives_restart_and_stale_state_files() -> anyhow::Resul pg.close()?; } - let pgdata = root.path().join("tmp/pglite/base"); + let pgdata = root.path().join("tmp/oliphaunt/base"); std::fs::write( pgdata.join("postmaster.pid"), b"stale pid from interrupted run", @@ -595,7 +613,7 @@ fn persistent_template_survives_restart_and_stale_state_files() -> anyhow::Resul b"stale opts from interrupted run", )?; - let mut reopened = Pglite::builder().path(root.path()).open()?; + let mut reopened = Oliphaunt::builder().path(root.path()).open()?; let result = reopened.query("SELECT value FROM template_restart", &[], None)?; assert_eq!( first_row(&result)?.get("value"), @@ -610,12 +628,12 @@ fn persistent_template_survives_restart_and_stale_state_files() -> anyhow::Resul #[test] fn persistent_template_recovers_interrupted_pgdata_without_marker() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; - let pgdata = root.path().join("tmp/pglite/base"); + let pgdata = root.path().join("tmp/oliphaunt/base"); std::fs::create_dir_all(&pgdata)?; std::fs::write(pgdata.join("postmaster.pid"), b"interrupted pid")?; std::fs::write(pgdata.join("partial-bootstrap.sql"), b"interrupted initdb")?; - let mut pg = Pglite::builder().path(root.path()).open()?; + let mut pg = Oliphaunt::builder().path(root.path()).open()?; let result = pg.query("SELECT 1::int AS one", &[], None)?; assert_eq!(first_row(&result)?.get("one"), Some(&json!(1))); assert!(pgdata.join("PG_VERSION").exists()); @@ -628,12 +646,12 @@ fn persistent_template_recovers_interrupted_pgdata_without_marker() -> anyhow::R #[test] fn persistent_template_recovers_interrupted_pgdata_with_incomplete_markers() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; - let pgdata = root.path().join("tmp/pglite/base"); + let pgdata = root.path().join("tmp/oliphaunt/base"); std::fs::create_dir_all(&pgdata)?; std::fs::write(pgdata.join("PG_VERSION"), b"17\n")?; std::fs::write(pgdata.join("partial-bootstrap.sql"), b"interrupted initdb")?; - let mut pg = Pglite::builder().path(root.path()).open()?; + let mut pg = Oliphaunt::builder().path(root.path()).open()?; let result = pg.query("SELECT 2::int AS two", &[], None)?; assert_eq!(first_row(&result)?.get("two"), Some(&json!(2))); assert!(pgdata.join("PG_VERSION").exists()); @@ -646,16 +664,16 @@ fn persistent_template_recovers_interrupted_pgdata_with_incomplete_markers() -> #[test] fn persistent_root_lock_rejects_second_direct_open() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; - let mut first = Pglite::builder().path(root.path()).open()?; - let err = match Pglite::builder().path(root.path()).open() { + let mut first = Oliphaunt::builder().path(root.path()).open()?; + let err = match Oliphaunt::builder().path(root.path()).open() { Ok(_) => anyhow::bail!("second open must fail while the root lock is held"), Err(err) => err, }; - assert!(format!("{err:#}").contains("PGlite root is already in use")); + assert!(format!("{err:#}").contains("Oliphaunt root is already in use")); first.close()?; - let mut reopened = Pglite::builder().path(root.path()).open()?; + let mut reopened = Oliphaunt::builder().path(root.path()).open()?; let result = reopened.query("SELECT 1::int AS one", &[], None)?; assert_eq!(first_row(&result)?.get("one"), Some(&json!(1))); reopened.close()?; @@ -665,12 +683,12 @@ fn persistent_root_lock_rejects_second_direct_open() -> anyhow::Result<()> { #[test] fn persistent_root_lock_rejects_second_server_open() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; - let server = PgliteServer::builder().path(root.path()).start()?; - let err = match PgliteServer::builder().path(root.path()).start() { + let server = OliphauntServer::builder().path(root.path()).start()?; + let err = match OliphauntServer::builder().path(root.path()).start() { Ok(_) => anyhow::bail!("second server must fail while the root lock is held"), Err(err) => err, }; - assert!(format!("{err:#}").contains("PGlite root is already in use")); + assert!(format!("{err:#}").contains("Oliphaunt root is already in use")); server.shutdown()?; Ok(()) } @@ -678,15 +696,15 @@ fn persistent_root_lock_rejects_second_server_open() -> anyhow::Result<()> { #[test] fn persistent_root_lock_rejects_direct_open_while_server_runs() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; - let server = PgliteServer::builder().path(root.path()).start()?; - let err = match Pglite::builder().path(root.path()).open() { + let server = OliphauntServer::builder().path(root.path()).start()?; + let err = match Oliphaunt::builder().path(root.path()).open() { Ok(_) => anyhow::bail!("direct open must fail while the server owns the root lock"), Err(err) => err, }; - assert!(format!("{err:#}").contains("PGlite root is already in use")); + assert!(format!("{err:#}").contains("Oliphaunt root is already in use")); server.shutdown()?; - let mut reopened = Pglite::builder().path(root.path()).open()?; + let mut reopened = Oliphaunt::builder().path(root.path()).open()?; let result = reopened.query("SELECT 1::int AS one", &[], None)?; assert_eq!(first_row(&result)?.get("one"), Some(&json!(1))); reopened.close()?; @@ -696,29 +714,29 @@ fn persistent_root_lock_rejects_direct_open_while_server_runs() -> anyhow::Resul #[test] fn persistent_root_lock_rejects_cross_process_open() -> anyhow::Result<()> { let root = tempfile::TempDir::new()?; - let child = Command::new(env!("CARGO_BIN_EXE_pglite-proxy")) + let child = Command::new(env!("CARGO_BIN_EXE_oliphaunt-wasix-proxy")) .arg("--root") .arg(root.path()) .args(["--tcp", "127.0.0.1:0", "--print-uri"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; - let mut child = ChildGuard::new(child, "pglite-proxy")?; + let mut child = ChildGuard::new(child, "oliphaunt-wasix-proxy")?; let stdout = child .child_mut() .stdout .take() - .ok_or_else(|| anyhow::anyhow!("missing pglite-proxy stdout"))?; + .ok_or_else(|| anyhow::anyhow!("missing oliphaunt-wasix-proxy stdout"))?; let mut line = String::new(); let read = BufReader::new(stdout).read_line(&mut line)?; if read == 0 { let stderr = child.collect_stderr(); - anyhow::bail!("pglite-proxy exited before printing URI\n\nstderr:\n{stderr}"); + anyhow::bail!("oliphaunt-wasix-proxy exited before printing URI\n\nstderr:\n{stderr}"); } assert!(line.starts_with("postgresql://"), "{line:?}"); - let err = match Pglite::builder().path(root.path()).open() { + let err = match Oliphaunt::builder().path(root.path()).open() { Ok(mut db) => { let close = db.close(); let stderr = child.collect_stderr(); @@ -729,7 +747,7 @@ fn persistent_root_lock_rejects_cross_process_open() -> anyhow::Result<()> { Err(err) => err, }; let message = format!("{err:#}"); - if !message.contains("PGlite root is already in use") { + if !message.contains("Oliphaunt root is already in use") { let stderr = child.collect_stderr(); anyhow::bail!("unexpected cross-process root-lock error: {message}\n\nstderr:\n{stderr}"); } @@ -739,7 +757,7 @@ fn persistent_root_lock_rejects_cross_process_open() -> anyhow::Result<()> { #[test] fn runtime_smoke() -> anyhow::Result<()> { let _trace = TestTrace::new("runtime_smoke"); - let mut pg = Pglite::builder().temporary().open()?; + let mut pg = Oliphaunt::builder().temporary().open()?; assert!(pg.paths().pgdata.join("PG_VERSION").exists()); let version = pg.query( @@ -789,6 +807,31 @@ fn runtime_smoke() -> anyhow::Result<()> { assert_eq!(timezone_row.get("ny_summer_hour"), Some(&json!(8))); assert_eq!(timezone_row.get("ny_winter_hour"), Some(&json!(7))); + let icu_available = pg.query( + "SELECT (count(*) > 0) AS available FROM pg_collation WHERE collprovider = 'i'", + &[], + None, + )?; + assert_eq!( + first_row(&icu_available)?.get("available"), + Some(&json!(true)) + ); + pg.exec( + "CREATE COLLATION und_numeric \ + (provider = icu, locale = 'und-u-kn-true', deterministic = false)", + None, + )?; + let icu_ordered = pg.query( + "SELECT string_agg(value, ',' ORDER BY value COLLATE und_numeric) AS values \ + FROM (VALUES ('10'), ('2'), ('1')) AS input(value)", + &[], + None, + )?; + assert_eq!( + first_row(&icu_ordered)?.get("values"), + Some(&json!("1,2,10")) + ); + trace_step("runtime_smoke expected-error invalid-timezone"); pg.exec("SET TIME ZONE 'Missing/Zone'", None) .expect_err("invalid timezone should fail"); @@ -878,7 +921,7 @@ fn runtime_smoke() -> anyhow::Result<()> { &[ json!(41), json!(true), - json!({"name": "pglite", "ok": true}), + json!({"name": "oliphaunt", "ok": true}), json!(["alpha", "beta,gamma"]), json!([0, 1, 2, 255]), ], @@ -889,7 +932,7 @@ fn runtime_smoke() -> anyhow::Result<()> { assert_eq!(typed_row.get("flag"), Some(&json!(true))); assert_eq!( typed_row.get("doc").and_then(|value| value.get("name")), - Some(&json!("pglite")) + Some(&json!("oliphaunt")) ); assert_eq!( typed_row.get("labels"), @@ -961,7 +1004,7 @@ fn runtime_smoke() -> anyhow::Result<()> { .exec("SELECT +", None) .expect_err("syntax error should fail"); let syntax_pg_err = syntax_err - .downcast_ref::() + .downcast_ref::() .expect("syntax error should preserve Postgres error fields"); assert_eq!(syntax_pg_err.query(), "SELECT +"); assert_eq!( @@ -978,7 +1021,7 @@ fn runtime_smoke() -> anyhow::Result<()> { ) .expect_err("missing table should fail"); let missing_pg_err = missing_err - .downcast_ref::() + .downcast_ref::() .expect("extended query error should preserve Postgres error fields"); assert_eq!( missing_pg_err.query(), @@ -995,7 +1038,7 @@ fn runtime_smoke() -> anyhow::Result<()> { .query("SELECT $1::int4 AS value", &[json!("not_an_int")], None) .expect_err("invalid typed parameter should fail during extended-query bind"); let invalid_bind_pg_err = invalid_bind - .downcast_ref::() + .downcast_ref::() .expect("bind error should preserve Postgres error fields"); assert_eq!(invalid_bind_pg_err.query(), "SELECT $1::int4 AS value"); assert_eq!(invalid_bind_pg_err.params(), &[json!("not_an_int")]); @@ -1009,7 +1052,7 @@ fn runtime_smoke() -> anyhow::Result<()> { .query("SELECT $1::int4 + $2::int4 AS value", &[json!(1)], None) .expect_err("missing parameter should fail during extended-query bind"); let wrong_param_count_pg_err = wrong_param_count - .downcast_ref::() + .downcast_ref::() .expect("parameter count error should preserve Postgres error fields"); assert_eq!( wrong_param_count_pg_err.database_error().code.as_deref(), @@ -1022,7 +1065,7 @@ fn runtime_smoke() -> anyhow::Result<()> { pg.close()?; assert!(pg.is_closed()); - let mut restarted = Pglite::temporary()?; + let mut restarted = Oliphaunt::temporary()?; let restarted_result = restarted.query("SELECT 42::int AS answer", &[], None)?; assert_eq!( first_row(&restarted_result)?.get("answer"), @@ -1032,7 +1075,7 @@ fn runtime_smoke() -> anyhow::Result<()> { let persistent_dir = tempfile::TempDir::new()?; { - let mut persisted = Pglite::builder().path(persistent_dir.path()).open()?; + let mut persisted = Oliphaunt::builder().path(persistent_dir.path()).open()?; persisted.exec("CREATE TABLE persisted(value TEXT)", None)?; persisted.query( "INSERT INTO persisted(value) VALUES ($1)", @@ -1042,7 +1085,7 @@ fn runtime_smoke() -> anyhow::Result<()> { persisted.close()?; } { - let mut reopened = Pglite::open(persistent_dir.path())?; + let mut reopened = Oliphaunt::open(persistent_dir.path())?; let persisted_result = reopened.query("SELECT value FROM persisted", &[], None)?; assert_eq!( first_row(&persisted_result)?.get("value"), diff --git a/tests/support/mod.rs b/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/support/mod.rs similarity index 100% rename from tests/support/mod.rs rename to src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/support/mod.rs diff --git a/examples/build_pgdata_template.rs b/src/bindings/wasix-rust/examples/build_pgdata_template.rs similarity index 71% rename from examples/build_pgdata_template.rs rename to src/bindings/wasix-rust/examples/build_pgdata_template.rs index 87fe75de..e9d03b0b 100644 --- a/examples/build_pgdata_template.rs +++ b/src/bindings/wasix-rust/examples/build_pgdata_template.rs @@ -2,11 +2,11 @@ use std::env; use std::path::PathBuf; use anyhow::Result; -use pglite_oxide::build_pgdata_template; +use oliphaunt_wasix::build_pgdata_template; fn main() -> Result<()> { let output_dir = env::args_os().nth(1).map(PathBuf::from).unwrap_or_else(|| { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/pglite-oxide/assets/prepopulated") + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/oliphaunt-wasix/assets/prepopulated") }); let template = build_pgdata_template(&output_dir)?; diff --git a/examples/tauri-sqlx-vanilla/.gitignore b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/.gitignore similarity index 100% rename from examples/tauri-sqlx-vanilla/.gitignore rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/.gitignore diff --git a/examples/tauri-sqlx-vanilla/.vscode/extensions.json b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/.vscode/extensions.json similarity index 100% rename from examples/tauri-sqlx-vanilla/.vscode/extensions.json rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/.vscode/extensions.json diff --git a/examples/tauri-sqlx-vanilla/README.md b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md similarity index 70% rename from examples/tauri-sqlx-vanilla/README.md rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md index d3dca54c..ae7fbd91 100644 --- a/examples/tauri-sqlx-vanilla/README.md +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md @@ -1,13 +1,13 @@ -# pglite-oxide Tauri SQLx example +# oliphaunt-wasix Tauri SQLx example -This is a Tauri v2 example that keeps `pglite-oxide` in Rust state and talks to +This is a Tauri v2 example that keeps `oliphaunt-wasix` in Rust state and talks to it through a real one-connection `sqlx::PgPool`. ## Run the desktop app ```sh -npm install -npm run tauri dev +pnpm install +pnpm run tauri dev ``` The app opens first and runs the database profile only when the profile command @@ -17,7 +17,7 @@ is invoked from the UI. ```sh cd src-tauri -cargo run --release --bin profile_queries -- --fresh --rows 10000 --json-out /tmp/pglite-profile-release.json +cargo run --release --bin profile_queries -- --fresh --rows 10000 --json-out /tmp/oliphaunt-profile-release.json ``` Use `--fresh` to remove the profile data directory before the run. Omit it to @@ -26,6 +26,6 @@ measure a warm start with an existing cluster. ## What it demonstrates - storing the database in managed Rust state; -- using `PgliteServer` to hand SQLx a PostgreSQL URI; +- using `OliphauntServer` to hand SQLx a PostgreSQL URI; - configuring the SQLx pool with `max_connections(1)`; - creating schema, seeding rows, and profiling real SQL queries. diff --git a/examples/tauri-sqlx-vanilla/index.html b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/index.html similarity index 96% rename from examples/tauri-sqlx-vanilla/index.html rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/index.html index c2ea9cff..089ea325 100644 --- a/examples/tauri-sqlx-vanilla/index.html +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/index.html @@ -4,7 +4,7 @@ - pglite SQLx profile + oliphaunt SQLx profile @@ -12,7 +12,7 @@
-

pglite-oxide / Tauri / SQLx

+

oliphaunt-wasix / Tauri / SQLx

Embedded Postgres profile

Idle
diff --git a/examples/tauri-sqlx-vanilla/package.json b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/package.json similarity index 100% rename from examples/tauri-sqlx-vanilla/package.json rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/package.json diff --git a/examples/tauri-sqlx-vanilla/src-tauri/.gitignore b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/.gitignore similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/.gitignore rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/.gitignore diff --git a/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock similarity index 99% rename from examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock index 38ee4d1d..227aaf59 100644 --- a/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock @@ -3524,6 +3524,62 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "oliphaunt-wasix" +version = "0.6.0" +dependencies = [ + "anyhow", + "async-trait", + "directories", + "dunce", + "filetime", + "flate2", + "hex", + "oliphaunt-wasix-aot-aarch64-apple-darwin", + "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", + "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "oliphaunt-wasix-assets", + "regex", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", + "tracing", + "wasmer", + "wasmer-config", + "wasmer-types", + "wasmer-wasix", + "webc", + "zstd", +] + +[[package]] +name = "oliphaunt-wasix-aot-aarch64-apple-darwin" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-aot-x86_64-pc-windows-msvc" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.6.0" + +[[package]] +name = "oliphaunt-wasix-assets" +version = "0.6.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3654,62 +3710,6 @@ dependencies = [ "serde", ] -[[package]] -name = "pglite-oxide" -version = "0.5.1" -dependencies = [ - "anyhow", - "async-trait", - "directories", - "dunce", - "filetime", - "flate2", - "hex", - "pglite-oxide-aot-aarch64-apple-darwin", - "pglite-oxide-aot-aarch64-unknown-linux-gnu", - "pglite-oxide-aot-x86_64-pc-windows-msvc", - "pglite-oxide-aot-x86_64-unknown-linux-gnu", - "pglite-oxide-assets", - "regex", - "serde", - "serde_json", - "sha2 0.10.9", - "tar", - "tempfile", - "tokio", - "tracing", - "wasmer", - "wasmer-config", - "wasmer-types", - "wasmer-wasix", - "webc", - "zstd", -] - -[[package]] -name = "pglite-oxide-aot-aarch64-apple-darwin" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-aot-aarch64-unknown-linux-gnu" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-aot-x86_64-pc-windows-msvc" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-aot-x86_64-unknown-linux-gnu" -version = "0.5.1" - -[[package]] -name = "pglite-oxide-assets" -version = "0.5.1" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "phf" version = "0.11.3" @@ -5510,7 +5510,7 @@ name = "tauri-sqlx-vanilla" version = "0.1.0" dependencies = [ "anyhow", - "pglite-oxide", + "oliphaunt-wasix", "serde", "serde_json", "sqlx", diff --git a/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml similarity index 77% rename from examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml index 40a271da..982a9393 100644 --- a/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "tauri-sqlx-vanilla" version = "0.1.0" -description = "Vanilla Tauri v2 SQLx profiler for pglite-oxide" -authors = ["pglite-oxide contributors"] +description = "Vanilla Tauri v2 SQLx profiler for oliphaunt-wasix" +authors = ["oliphaunt-wasix contributors"] edition = "2021" publish = false @@ -17,7 +17,7 @@ tauri-build = { version = "2", features = [] } [dependencies] anyhow = "1" -pglite-oxide = { path = "../../.." } +oliphaunt-wasix = { path = "../../../crates/oliphaunt-wasix" } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" diff --git a/examples/tauri-sqlx-vanilla/src-tauri/build.rs b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/build.rs similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/build.rs rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/build.rs diff --git a/examples/tauri-sqlx-vanilla/src-tauri/capabilities/default.json b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/capabilities/default.json similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/capabilities/default.json rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/capabilities/default.json diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/128x128.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/128x128.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/128x128.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/128x128.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/128x128@2x.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/128x128@2x.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/128x128@2x.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/128x128@2x.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/32x32.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/32x32.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/32x32.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/32x32.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square107x107Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square107x107Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square107x107Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square107x107Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square142x142Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square142x142Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square142x142Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square142x142Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square150x150Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square150x150Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square150x150Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square150x150Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square284x284Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square284x284Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square284x284Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square284x284Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square30x30Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square30x30Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square30x30Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square30x30Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square310x310Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square310x310Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square310x310Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square310x310Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square44x44Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square44x44Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square44x44Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square44x44Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square71x71Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square71x71Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square71x71Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square71x71Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/Square89x89Logo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square89x89Logo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/Square89x89Logo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/Square89x89Logo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/StoreLogo.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/StoreLogo.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/StoreLogo.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/StoreLogo.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.icns b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.icns similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/icon.icns rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.icns diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.ico b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.ico similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/icon.ico rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.ico diff --git a/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.png b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.png similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/icons/icon.png rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/icons/icon.png diff --git a/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs similarity index 94% rename from examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs index 8c58ab99..997584ea 100644 --- a/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bench.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; use anyhow::{anyhow, bail, Context, Result}; -use pglite_oxide::{install_into, preload_runtime_module, PglitePaths, PgliteServer}; +use oliphaunt_wasix::{install_into, preload_runtime_module, OliphauntPaths, OliphauntServer}; use serde::Serialize; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; use sqlx::{PgPool, Row}; @@ -64,7 +64,7 @@ impl BenchState { let mut guard = self.inner.lock().await; if fresh && guard.is_some() { bail!( - "fresh profile requires restarting the app so the existing pglite proxy can exit" + "fresh profile requires restarting the app so the existing oliphaunt proxy can exit" ); } @@ -84,7 +84,7 @@ pub struct DatabaseHarness { root: PathBuf, database_url: String, pool: PgPool, - _server: PgliteServer, + _server: OliphauntServer, cold_start: bool, startup: Vec, } @@ -97,7 +97,7 @@ impl DatabaseHarness { } fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?; - let paths = PglitePaths::with_root(&root); + let paths = OliphauntPaths::with_root(&root); let cold_start = !paths.pgdata.join("PG_VERSION").exists(); let mut startup = Vec::new(); @@ -116,7 +116,7 @@ impl DatabaseHarness { .await?; let server_root = root.clone(); - let server = time_blocking(&mut startup, "start pglite server", move || { + let server = time_blocking(&mut startup, "start oliphaunt server", move || { preferred_server(server_root) }) .await?; @@ -124,14 +124,14 @@ impl DatabaseHarness { let pool = time_async(&mut startup, "sqlx pool connect", async { let options = - pg_connect_options(&server)?.application_name("pglite-oxide-tauri-sqlx-profile"); + pg_connect_options(&server)?.application_name("oliphaunt-wasix-tauri-sqlx-profile"); PgPoolOptions::new() .max_connections(1) .acquire_timeout(Duration::from_secs(30)) .connect_with(options) .await - .context("connect SQLx pool to pglite proxy") + .context("connect SQLx pool to oliphaunt proxy") }) .await?; @@ -306,7 +306,7 @@ impl DatabaseHarness { "The Tauri window is allowed to paint before this command initializes the database." .to_string(), "Fresh starts use the bundled prepopulated PGDATA template before the backend session starts.".to_string(), - "SQLx is configured with one PostgreSQL connection because the embedded pglite runtime is single-process." + "SQLx is configured with one PostgreSQL connection because the embedded oliphaunt runtime is single-process." .to_string(), "The SQLx pool connection phase includes the first backend wire-protocol handshake." .to_string(), @@ -329,8 +329,8 @@ impl DatabaseHarness { } } -fn preferred_server(root: PathBuf) -> Result { - let builder = PgliteServer::builder().path(&root); +fn preferred_server(root: PathBuf) -> Result { + let builder = OliphauntServer::builder().path(&root); #[cfg(unix)] { builder.unix(root.join(".s.PGSQL.5432")).start() @@ -341,7 +341,7 @@ fn preferred_server(root: PathBuf) -> Result { } } -fn pg_connect_options(server: &PgliteServer) -> Result { +fn pg_connect_options(server: &OliphauntServer) -> Result { let options = PgConnectOptions::new() .username("postgres") .database("template1") @@ -357,7 +357,7 @@ fn pg_connect_options(server: &PgliteServer) -> Result { let addr = server .tcp_addr() - .ok_or_else(|| anyhow!("PGlite server did not expose a TCP address"))?; + .ok_or_else(|| anyhow!("Oliphaunt server did not expose a TCP address"))?; Ok(options.host(&addr.ip().to_string()).port(addr.port())) } diff --git a/examples/tauri-sqlx-vanilla/src-tauri/src/bin/profile_queries.rs b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bin/profile_queries.rs similarity index 86% rename from examples/tauri-sqlx-vanilla/src-tauri/src/bin/profile_queries.rs rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bin/profile_queries.rs index 9b84fb33..a358bd85 100644 --- a/examples/tauri-sqlx-vanilla/src-tauri/src/bin/profile_queries.rs +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/bin/profile_queries.rs @@ -20,9 +20,9 @@ async fn main() -> anyhow::Result<()> { .and_then(|index| args.get(index + 1)) .map(PathBuf::from); - let root = env::var_os("PGLITE_OXIDE_TAURI_PROFILE_DIR") + let root = env::var_os("OLIPHAUNT_WASM_TAURI_PROFILE_DIR") .map(PathBuf::from) - .unwrap_or_else(|| env::temp_dir().join("pglite-oxide-tauri-sqlx-profile")); + .unwrap_or_else(|| env::temp_dir().join("oliphaunt-wasix-tauri-sqlx-profile")); let state = BenchState::new(root); let report = state.profile_queries(fresh, row_count).await?; diff --git a/examples/tauri-sqlx-vanilla/src-tauri/src/lib.rs b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/lib.rs similarity index 95% rename from examples/tauri-sqlx-vanilla/src-tauri/src/lib.rs rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/lib.rs index f871d8d0..e7d52d97 100644 --- a/examples/tauri-sqlx-vanilla/src-tauri/src/lib.rs +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/lib.rs @@ -46,7 +46,7 @@ pub fn run() { let root = app .path() .app_data_dir() - .map(|dir| dir.join("pglite-sqlx-profile"))?; + .map(|dir| dir.join("oliphaunt-sqlx-profile"))?; app.manage(BenchState::new(root)); Ok(()) }) diff --git a/examples/tauri-sqlx-vanilla/src-tauri/src/main.rs b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/main.rs similarity index 100% rename from examples/tauri-sqlx-vanilla/src-tauri/src/main.rs rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/src/main.rs diff --git a/examples/tauri-sqlx-vanilla/src-tauri/tauri.conf.json b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/tauri.conf.json similarity index 71% rename from examples/tauri-sqlx-vanilla/src-tauri/tauri.conf.json rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/tauri.conf.json index 49ec5917..3cff8824 100644 --- a/examples/tauri-sqlx-vanilla/src-tauri/tauri.conf.json +++ b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/tauri.conf.json @@ -1,19 +1,19 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "pglite SQLx Profile", + "productName": "oliphaunt SQLx Profile", "version": "0.1.0", - "identifier": "com.pgliteoxide.sqlxbench", + "identifier": "com.oliphauntoxide.sqlxbench", "build": { - "beforeDevCommand": "npm run dev", + "beforeDevCommand": "pnpm run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "npm run build", + "beforeBuildCommand": "pnpm run build", "frontendDist": "../dist" }, "app": { "withGlobalTauri": true, "windows": [ { - "title": "pglite SQLx Profile", + "title": "oliphaunt SQLx Profile", "width": 1080, "height": 760 } diff --git a/examples/tauri-sqlx-vanilla/src/assets/tauri.svg b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/assets/tauri.svg similarity index 100% rename from examples/tauri-sqlx-vanilla/src/assets/tauri.svg rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/assets/tauri.svg diff --git a/examples/tauri-sqlx-vanilla/src/assets/typescript.svg b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/assets/typescript.svg similarity index 100% rename from examples/tauri-sqlx-vanilla/src/assets/typescript.svg rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/assets/typescript.svg diff --git a/examples/tauri-sqlx-vanilla/src/assets/vite.svg b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/assets/vite.svg similarity index 100% rename from examples/tauri-sqlx-vanilla/src/assets/vite.svg rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/assets/vite.svg diff --git a/examples/tauri-sqlx-vanilla/src/main.ts b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/main.ts similarity index 100% rename from examples/tauri-sqlx-vanilla/src/main.ts rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/main.ts diff --git a/examples/tauri-sqlx-vanilla/src/styles.css b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/styles.css similarity index 100% rename from examples/tauri-sqlx-vanilla/src/styles.css rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src/styles.css diff --git a/examples/tauri-sqlx-vanilla/tsconfig.json b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/tsconfig.json similarity index 100% rename from examples/tauri-sqlx-vanilla/tsconfig.json rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/tsconfig.json diff --git a/examples/tauri-sqlx-vanilla/vite.config.ts b/src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/vite.config.ts similarity index 100% rename from examples/tauri-sqlx-vanilla/vite.config.ts rename to src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/vite.config.ts diff --git a/src/bindings/wasix-rust/moon.yml b/src/bindings/wasix-rust/moon.yml new file mode 100644 index 00000000..ff55d933 --- /dev/null +++ b/src/bindings/wasix-rust/moon.yml @@ -0,0 +1,202 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-wasix-rust" +language: "rust" +layer: "library" +stack: "systems" +tags: ["binding", "wasix", "rust", "postgres", "release-product"] +dependsOn: + - "liboliphaunt-wasix" + - id: "shared-fixtures" + scope: "build" + +project: + title: "Oliphaunt WASIX Rust" + description: "Rust binding over the liboliphaunt WASIX runtime." + owner: "oliphaunt" + release: + component: "oliphaunt-wasix-rust" + packagePath: "src/bindings/wasix-rust/crates/oliphaunt-wasix" + +owners: + defaultOwner: "@oliphaunt/wasix-rust" + paths: + "**/*.rs": ["@oliphaunt/wasix-rust"] + "crates/oliphaunt-wasix/**": ["@oliphaunt/wasix-rust"] + "examples/**": ["@oliphaunt/wasix-rust"] + "tools/**": ["@oliphaunt/wasix-rust"] + +tasks: + check: + tags: ["quality", "static"] + command: "cargo check -p oliphaunt-wasix --locked" + deps: + - "liboliphaunt-wasix:check" + - "shared-fixtures:check" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + test: + tags: ["quality", "unit"] + command: "bash src/bindings/wasix-rust/tools/check-unit.sh" + deps: + - "liboliphaunt-wasix:check" + - "shared-fixtures:check" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/test" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/shared/fixtures/**/*" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + package: + tags: ["package"] + command: "bash src/bindings/wasix-rust/tools/check-package.sh" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/package" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + - "/src/bindings/wasix-rust/tools/check-package.sh" + options: + cache: true + runFromWorkspaceRoot: true + + package-artifacts: + tags: ["release", "artifact-package", "ci-wasix-rust-package"] + command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-wasix-rust" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/package-artifacts" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + - "/src/bindings/wasix-rust/tools/check-package.sh" + - "/tools/release/build-sdk-ci-artifacts.sh" + outputs: + - "/target/sdk-artifacts/oliphaunt-wasix-rust/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + release-check: + tags: ["release", "package"] + command: "bash src/bindings/wasix-rust/tools/check-package.sh" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/release-check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + - "/src/bindings/wasix-rust/tools/check-package.sh" + options: + cache: true + runFromWorkspaceRoot: true + + example-check: + tags: ["examples", "quality"] + command: "bash src/bindings/wasix-rust/tools/check-examples.sh" + deps: + - "liboliphaunt-wasix:check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/crates/**/*" + - "/src/bindings/wasix-rust/examples/**/*" + - "!/src/bindings/wasix-rust/examples/**/node_modules" + - "!/src/bindings/wasix-rust/examples/**/node_modules/**" + - "/src/bindings/wasix-rust/tools/check-examples.sh" + - "/src/runtimes/liboliphaunt/wasix/**/*" + options: + cache: local + runFromWorkspaceRoot: true + + bench: + tags: ["bench", "plan"] + command: "cargo test -p oliphaunt-wasix --locked --test performance_smoke --no-run" + deps: + - "liboliphaunt-wasix:check" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/bench" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false + + coverage: + tags: ["coverage", "quality"] + command: "tools/coverage/run-product oliphaunt-wasix-rust" + deps: + - "liboliphaunt-wasix:check" + - "shared-fixtures:check" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/coverage" + inputs: + - "/.config/nextest.toml" + - "/Cargo.lock" + - "/Cargo.toml" + - "/coverage/baseline.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + - "/tools/coverage/**/*" + outputs: + - "/target/coverage/oliphaunt-wasix-rust/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + bench-run: + tags: ["bench", "measured"] + command: "cargo test -p oliphaunt-wasix --locked --test performance_smoke -- --nocapture" + deps: + - "liboliphaunt-wasix:check" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-wasix-rust/bench-run" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/bindings/wasix-rust/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false diff --git a/src/bindings/wasix-rust/tools/check-examples.sh b/src/bindings/wasix-rust/tools/check-examples.sh new file mode 100755 index 00000000..6ca5b38c --- /dev/null +++ b/src/bindings/wasix-rust/tools/check-examples.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +source_dir="src/bindings/wasix-rust/examples/tauri-sqlx-vanilla" +workspace="target/oliphaunt-wasix-rust/examples/tauri-sqlx-vanilla/workspaces/$$" +work="$workspace/$source_dir" +trap 'rm -rf "$workspace"' EXIT +rm -rf "$workspace" +mkdir -p "$work" "$workspace/src/bindings/wasix-rust" "$workspace/src/runtimes/liboliphaunt" + +rsync -a --delete \ + --exclude node_modules \ + --exclude dist \ + --exclude src-tauri/gen \ + --exclude src-tauri/target \ + "$source_dir/" "$work/" + +ln -s "$root/src/bindings/wasix-rust/crates" "$workspace/src/bindings/wasix-rust/crates" +ln -s "$root/src/runtimes/liboliphaunt/wasix" "$workspace/src/runtimes/liboliphaunt/wasix" + +run cargo check \ + --manifest-path "$work/src-tauri/Cargo.toml" \ + --target-dir target/oliphaunt-wasix-rust/examples/tauri-sqlx-vanilla/src-tauri \ + --locked + +cat >"$workspace/package.json" <<'JSON' +{ + "name": "oliphaunt-tauri-example-check-workspace", + "private": true, + "packageManager": "pnpm@11.5.0" +} +JSON +cat >"$workspace/pnpm-workspace.yaml" <<'YAML' +packages: + - "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla" +catalog: + "@vitest/coverage-v8": ^4.1.8 + tsx: ^4.20.6 + typedoc: ^0.28.16 + typescript: ^5.9.3 + vitest: ^4.1.8 +minimumReleaseAge: 1440 +nodeLinker: hoisted +confirmModulesPurge: false +autoInstallPeers: false +saveWorkspaceProtocol: rolling +updateNotifier: false +verifyDepsBeforeRun: false + +allowBuilds: + core-js: false + esbuild: true + msgpackr-extract: true + sharp: true + unrs-resolver: true +YAML +cp pnpm-lock.yaml "$workspace/pnpm-lock.yaml" + +run pnpm --dir "$work" install --frozen-lockfile +run pnpm --dir "$work" run build diff --git a/src/bindings/wasix-rust/tools/check-package.sh b/src/bindings/wasix-rust/tools/check-package.sh new file mode 100755 index 00000000..7f15aa8d --- /dev/null +++ b/src/bindings/wasix-rust/tools/check-package.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +out_dir="target/oliphaunt-wasix-rust/package" +listing="$out_dir/oliphaunt-wasix.package-files.txt" +mkdir -p "$out_dir" + +cargo package --list -p oliphaunt-wasix --locked --allow-dirty >"$listing" + +require_entry() { + local entry="$1" + if ! grep -Fxq "$entry" "$listing"; then + echo "oliphaunt-wasix package is missing required entry: $entry" >&2 + exit 1 + fi +} + +reject_pattern() { + local pattern="$1" + if grep -Eq "$pattern" "$listing"; then + echo "oliphaunt-wasix package contains forbidden runtime/build entry matching: $pattern" >&2 + grep -E "$pattern" "$listing" >&2 + exit 1 + fi +} + +require_entry "Cargo.toml" +require_entry "README.md" +require_entry "src/lib.rs" +require_entry "src/bin/oliphaunt_wasix_dump.rs" +require_entry "src/bin/oliphaunt_wasix_proxy.rs" +require_entry "src/oliphaunt/aot.rs" +require_entry "src/oliphaunt/assets.rs" +require_entry "src/protocol/parser.rs" + +reject_pattern '(^|/)(payload|artifacts|target)(/|$)' +reject_pattern '(^|/)assets/generated(/|$)' +reject_pattern '^src/runtimes/' +reject_pattern '^src/extensions/generated/' + +echo "oliphaunt-wasix package shape verified: $listing" diff --git a/src/bindings/wasix-rust/tools/check-unit.sh b/src/bindings/wasix-rust/tools/check-unit.sh new file mode 100755 index 00000000..d14aa2b5 --- /dev/null +++ b/src/bindings/wasix-rust/tools/check-unit.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +if ! cargo nextest --version >/dev/null 2>&1; then + echo "missing cargo-nextest; run tools/dev/bootstrap-tools.sh" >&2 + exit 1 +fi + +printf '\n==> cargo test -p oliphaunt-wasix --doc --locked\n' +cargo test -p oliphaunt-wasix --doc --locked + +printf '\n==> cargo nextest run -p oliphaunt-wasix --locked --profile ci --no-default-features --lib --no-tests=fail --test-threads=1\n' +cargo nextest run -p oliphaunt-wasix --locked --profile ci --no-default-features --lib --no-tests=fail --test-threads=1 diff --git a/src/docs/.gitignore b/src/docs/.gitignore new file mode 100644 index 00000000..8a11fd9b --- /dev/null +++ b/src/docs/.gitignore @@ -0,0 +1,26 @@ +# deps +/node_modules + +# generated content +.source + +# test & build +/coverage +/.next/ +/out/ +/build +*.tsbuildinfo + +# misc +.DS_Store +*.pem +/.pnp +.pnp.js +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# others +.env*.local +.vercel +next-env.d.ts diff --git a/src/docs/README.md b/src/docs/README.md new file mode 100644 index 00000000..f344aece --- /dev/null +++ b/src/docs/README.md @@ -0,0 +1,41 @@ +# docs + +This is a Next.js application generated with +[Create Fumadocs](https://github.com/fuma-nama/fumadocs). + +Run the development server from the repository root: + +```bash +pnpm docs:dev +``` + +Open http://localhost:3000 with your browser to see the result. + +## Explore + +In the project, you can see: + +- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content. +- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep. + +| Route | Description | +| ------------------------- | ------------------------------------------------------ | +| `app/(home)` | The route group for your landing page and other pages. | +| `app/docs` | The documentation layout and pages. | +| `app/api/search/route.ts` | The Route Handler for search. | + +### Fumadocs MDX + +A `source.config.ts` config file has been included, you can customise different options like frontmatter schema. + +Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details. + +## Learn More + +To learn more about Next.js and Fumadocs, take a look at the following +resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js + features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs diff --git a/src/docs/content/learn/embedded-postgres.mdx b/src/docs/content/learn/embedded-postgres.mdx new file mode 100644 index 00000000..eab4f4c8 --- /dev/null +++ b/src/docs/content/learn/embedded-postgres.mdx @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: Embedded PostgreSQL +description: Learn how PostgreSQL roots, WAL, lifecycle, extensions, backup, and restore fit inside an app. +--- + +# Embedded PostgreSQL + +Oliphaunt embeds PostgreSQL behind SDK-native APIs while keeping PostgreSQL's +storage, WAL, SQL, protocol, and extension model recognizable. The SDK boundary +owns lifecycle, packaged runtime assets, exact extension selection, and app-safe +defaults. + + + +The product line has two runtime families. + +| Runtime family | Use it for | +| --- | --- | +| Native `liboliphaunt` | Rust, Swift, Kotlin, React Native, TypeScript native paths, and C ABI consumers | +| `oliphaunt-wasix` | The first-class WASM/WASIX runtime family | + +Both families share public concepts such as roots, capabilities, exact +extensions, backup, and restore. Runtime details differ by family, so native and +WASM packages expose their own capabilities for app decisions. + +## Root Storage + +An Oliphaunt database root is a PostgreSQL directory. A persistent root contains +PostgreSQL data, WAL, Oliphaunt metadata, root locks, selected extension +metadata, and recovery state. Temporary roots are owned by the SDK and can be +removed after close. + +That root model is deliberate: app developers get PostgreSQL's recovery and SQL +behavior, while the SDK owns the app-facing safety rails around paths, locking, +backup, restore, and selected runtime assets. + +## Lifecycle Contract + +Every SDK exposes the same lifecycle phases with ecosystem-native names: + +| Phase | What the SDK owns | +| --- | --- | +| Open | Create or attach to the root, validate locks, and materialize selected runtime resources | +| Query | Route work through the selected engine and preserve mode-specific concurrency rules | +| Background | Checkpoint, cancel, or defer bounded work according to platform lifecycle hooks | +| Close | Reject new work, wait for active work, and detach cleanly from the selected runtime | +| Backup/restore | Validate archives and roots before moving PostgreSQL data | + +Mobile SDKs connect these phases to app background and foreground transitions. +Desktop SDKs add broker and server modes where a helper process or local server +is the better runtime shape. + +## Extension Selection + +Extensions are selected by exact SQL extension name before packaging or opening +the database. App artifacts include the selected extension and its declared +dependencies. An app selecting `vector` ships `vector` and only the dependency +files declared for `vector`. + +`CREATE EXTENSION` succeeds only when the selected runtime resources include +that extension for the target platform. See the +[extension reference](/docs/reference/extensions) for the distribution contract. + +## What is different from SQLite? + +Oliphaunt stores live data as a PostgreSQL root directory with PostgreSQL +recovery behavior. That is a larger runtime model than a single-file database, +but it enables PostgreSQL SQL, types, wire-protocol behavior, and extensions +inside apps that need those features. + +Use SQLite when a small single-file database is the better product fit. Use +Oliphaunt when PostgreSQL compatibility, extensions, or server-compatible +workflows are worth the extra runtime footprint. diff --git a/src/docs/content/learn/index.mdx b/src/docs/content/learn/index.mdx new file mode 100644 index 00000000..6be65609 --- /dev/null +++ b/src/docs/content/learn/index.mdx @@ -0,0 +1,40 @@ +--- +title: Learn +description: Understand Oliphaunt's runtime model before choosing production settings. +--- + +# Learn + +Use these pages after the first query works and you need to make production +choices: where data lives, which runtime boundary fits, how mobile lifecycle +behaves, and how to package the app without extra extension files. + + + +## Suggested Paths + + + + Read [Embedded PostgreSQL](/docs/learn/embedded-postgres), then + [Mobile Stability](/docs/learn/mobile-stability). React Native developers + then read the [React Native architecture page](/docs/sdk/react-native/architecture) + before wiring background hooks or streaming large protocol responses. + + + Read [Native Runtime](/docs/learn/native-runtime) first. Tauri developers + then read [Tauri Usage](/docs/learn/tauri) and keep database ownership in + Rust state. + + + Read [Moving From SQLite](/docs/learn/sqlite-upgrade), then use + [Extensions](/docs/reference/extensions) and + [Performance](/docs/reference/performance) while sizing the app artifact + and benchmark plan. + + + +## What Learn Covers + +Learn pages explain product behavior in app terms. Use the SDK page when you +need install steps and code. Use Reference when you need a matrix, catalog, or +version lookup. diff --git a/src/docs/content/learn/mobile-stability.mdx b/src/docs/content/learn/mobile-stability.mdx new file mode 100644 index 00000000..a36b524c --- /dev/null +++ b/src/docs/content/learn/mobile-stability.mdx @@ -0,0 +1,72 @@ +--- +title: Mobile Stability +description: Understand mobile direct mode, app backgrounding, relaunch, and crash consistency on iOS, Android, and React Native. +--- + +# Mobile Stability + +Mobile apps use a different runtime model from desktop apps. On iOS and +Android, Oliphaunt mobile SDKs start with native direct mode: one embedded +PostgreSQL backend lives inside the app process, and SDK calls are serialized +through the platform SDK. + + + +## What developers can rely on + +Direct mode is built for crash consistency: + +- database changes go through PostgreSQL storage and WAL; +- app relaunch reopens the same root and lets PostgreSQL recover; +- SDK calls are serialized so concurrent app tasks share one physical database + session safely; +- React Native delegates to Swift on Apple platforms and Kotlin on Android. + +Direct mode shares the app process. If the process exits, the next launch +reopens the same root and PostgreSQL recovery brings storage back to a +consistent state. Broker and server runtimes add a process boundary on targets +that advertise those modes. + +The direct-mode contract is: + +- one resident backend per app process; +- one physical session; +- serialized requests; +- same-root logical reopen only; +- app-process ownership; +- lifecycle hooks such as `prepareForBackground` and `resumeFromBackground` for + app foreground/background transitions. + +## Close and reopen + +On mobile direct mode, `close()` is a logical detach from the SDK handle. It +cleans up session state and allows the same database root to be reopened in the +same app process. A process owns one resident direct-mode root at a time. + +Use one app-owned persistent root for user data. Temporary roots are useful for +tests and short-lived work. Production app data lives in an app-controlled +directory with normal platform backup and data-protection choices. + +## Background and foreground + +Mobile operating systems can suspend apps while work is still queued. SDK +lifecycle hooks let apps prepare for backgrounding, cancel or checkpoint bounded +work, and resume cleanly when foregrounded. + +React Native apps get the same behavior through the platform SDKs. Bulk protocol +bytes use the New Architecture JSI path; lifecycle and configuration stay on the +typed native module surface. + +## Choosing the mode + +| Need | Mobile direct mode | Broker/server mode | +| --- | --- | --- | +| Lowest call latency | Best fit | Adds IPC or server overhead | +| One app-owned database root | Best fit | Works where supported | +| Independent PostgreSQL clients | Use server-capable runtime | Server mode | +| App survives database-process crash | App-process ownership | Broker/server where supported | +| React Native integration | Delegates to Swift/Kotlin | Delegates to platform support | + +Broker and server modes are platform-advertised capabilities. Android can use a +separate service process when that runtime is available. Apple platforms expose +only runtime modes that fit the app and extension model for that target. diff --git a/src/docs/content/learn/native-runtime.mdx b/src/docs/content/learn/native-runtime.mdx new file mode 100644 index 00000000..c15f8615 --- /dev/null +++ b/src/docs/content/learn/native-runtime.mdx @@ -0,0 +1,210 @@ +--- +title: Native Runtime +description: Direct, broker, and server runtime behavior for native Oliphaunt SDKs. +--- + +# Native Runtime + +This guide describes the native runtime family used by `liboliphaunt` and the +native SDKs. WASM runtime behavior is documented separately in +[`WASM runtime`](/docs/sdk/wasm/runtime). + +## Choose a mode + +Use the mode names as product boundaries: + +- choose `NativeDirect` for the lowest-latency embedded call path when one + application-owned session is the right model; +- choose `NativeBroker` when a desktop app wants the database runtime outside + the UI process or needs to manage several roots deliberately; +- choose `NativeServer` when existing PostgreSQL clients need independent + sessions, connection strings, pools, `psql`, `pg_dump`, or ORM compatibility. + +These are different runtime contracts. Direct mode is the embedded session, +broker mode adds a helper-process boundary, and server mode is the path for +independent PostgreSQL client concurrency. + + + +`NativeDirect` is the lowest-latency embedded mode. It loads `liboliphaunt` in +the host process and owns one resident PostgreSQL backend for the process +lifetime. + +Use it when the Rust SDK owns the database calls and the application wants one +fast embedded PostgreSQL session: + +```rust,no_run +use oliphaunt::Oliphaunt; + +# async fn open_direct() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .open() + .await?; + +let rows = db.query("SELECT 1::text AS value").await?; +assert_eq!(rows.get_text(0, "value")?, Some("1")); + +db.close().await?; +# Ok(()) +# } +``` + +`NativeBroker` runs the same direct engine in a helper process. It is the robust +desktop/app mode for process isolation and multiple roots managed by one Rust +SDK runtime. Each broker-owned root still has one serialized physical +PostgreSQL backend session. + +Use it when process isolation and multi-root ownership matter more than absolute +minimum call overhead: + +```rust,no_run +use oliphaunt::Oliphaunt; + +# async fn open_broker() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_broker() + .broker_max_roots(4) + .open() + .await?; + +db.execute("CREATE TABLE IF NOT EXISTS events(id bigint PRIMARY KEY)").await?; +db.close().await?; +# Ok(()) +# } +``` + +`NativeServer` starts a real local PostgreSQL-compatible server process. It is +the only SDK mode for independent client sessions, connection pools, `psql`, +`pg_dump`, ORMs, and libraries that expect a PostgreSQL connection string: + +```rust,no_run +use oliphaunt::Oliphaunt; + +# async fn open_server() -> oliphaunt::Result { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_server() + .max_client_sessions(8) + .open() + .await?; + +Ok(db.connection_string().expect("server mode exposes a URL").to_owned()) +# } +``` + +## Runtime Semantics + +The three modes are intentionally different. Direct and broker mode expose one +serialized SDK-owned database session; server mode is the runtime for +independent PostgreSQL clients. + +| Mode | Process model | Session model | Root model | Reopen/crash behavior | +| --- | --- | --- | --- | --- | +| `NativeDirect` | in-process | one serialized physical session | one resident root per process | same-root logical reopen; WAL recovery after process relaunch | +| `NativeBroker` | helper process per active root | one serialized physical session per root | multiple roots bounded by `broker_max_roots` | helper crash can be restarted; app process remains alive | +| `NativeServer` | PostgreSQL server process | independent PostgreSQL client sessions | one server root per opened handle | use normal server restart/recovery flows | + +`Oliphaunt` is cloneable as an SDK handle. Clones share the same owner executor, +FIFO queue, session pin, cancellation handle, and close state. Use server mode +when the application needs a connection pool or independent client sessions. +Direct and broker mode reject `max_client_sessions` values other than `1`. + +Transactions and explicit session pins reserve the single SDK-owned physical +session. While a pin is active, the owner executor keeps unrelated work outside +that transaction-sensitive session and returns a session-busy error for calls +that require a different database state. + +## Direct Lifecycle + +Direct mode is process-resident: + +- one resident backend per process; +- one physical session; +- serialized requests through the SDK owner executor; +- one root per process after the resident backend exists; +- `close()` detaches the SDK handle from the resident backend; +- reopening is same-root only inside the same process; +- native PostgreSQL crashes terminate the host process. + +Direct mode's reliability contract is crash-consistent storage. If the host +process exits, the next launch reopens the same root and PostgreSQL performs WAL +recovery. Applications that need the app process to survive database-process +death use broker or server mode where the target platform supports them. + +## Storage + +Native live storage is a PostgreSQL root directory. A root contains PGDATA, +Oliphaunt metadata, lock metadata, extension metadata, and recovery state. + +Persistent roots use exclusive locking in direct mode. Broker and server modes +own their roots through the helper/server process. A second unsafe owner fails +instead of sharing a data directory. + +Use SDK backup/restore APIs for ergonomic export/import: + +- direct and broker support same-version physical archives; +- server supports same-version physical archives and SQL dumps through packaged + PostgreSQL tooling; +- logical dumps are the portable cross-version upgrade format. + +## Startup Configuration + +`OliphauntBuilder::runtime_footprint(...)` selects the startup footprint before +PostgreSQL starts: + +- `RuntimeFootprintProfile::Throughput`: throughput defaults; +- `RuntimeFootprintProfile::BalancedMobile`: lower slot counts, smaller shared + buffers/WAL footprint, and PG18 sync I/O for resident mobile apps; +- `RuntimeFootprintProfile::SmallMobile`: the smallest supported resident + profile for memory-pressure experiments. + +`OliphauntBuilder::startup_guc(name, value)` and `startup_gucs(...)` append +validated PostgreSQL `-c name=value` overrides after durability and footprint +profiles. Later overrides win, matching PostgreSQL startup behavior. Server mode +then appends its configured `max_connections` from `max_client_sessions(...)` +because independent session count is the server-mode contract. + +## Extensions + +Extensions are opt-in. Select exact PostgreSQL extension names before opening: + +```rust,no_run +use oliphaunt::{Extension, Oliphaunt}; + +# async fn open_with_vector() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .extension(Extension::Vector) + .open() + .await?; + +db.execute("CREATE EXTENSION IF NOT EXISTS vector").await?; +db.close().await?; +# Ok(()) +# } +``` + +`CREATE EXTENSION` succeeds only when the selected runtime resources contain the +extension assets and, on mobile, when the required static registry entries are +present. Desktop runtimes advertise dynamic loading separately through +capabilities; the portable path is selected runtime resources first. + +## Capabilities + +Read capabilities before enabling mode-specific features: + +- `session_concurrency` distinguishes serialized SDK sessions from independent + server sessions; +- `multi_root` is broker-only today; +- `same_root_logical_reopen`, `root_switchable`, and `crash_restartable` + describe lifecycle semantics explicitly; +- `backup_formats` and `restore_formats` gate backup/restore UI before work is + queued. + +Swift, Kotlin, and React Native expose the same product concepts with +platform-native naming. Platform modes outside advertised capabilities return +explicit errors so app code can choose another mode or hide the option in UI. diff --git a/src/docs/content/learn/sqlite-upgrade.mdx b/src/docs/content/learn/sqlite-upgrade.mdx new file mode 100644 index 00000000..98b76f53 --- /dev/null +++ b/src/docs/content/learn/sqlite-upgrade.mdx @@ -0,0 +1,69 @@ +--- +sidebar_position: 1 +title: Moving From SQLite +description: Map SQLite storage, SQL, backup, and extension assumptions to embedded PostgreSQL. +--- + +# Moving From SQLite + +Oliphaunt is embedded PostgreSQL, so migration from SQLite starts by mapping a +single-file database model to PostgreSQL roots, WAL, extensions, and PostgreSQL +SQL semantics. + +Use this guide when an app already uses SQLite and you are evaluating whether a +PostgreSQL-compatible embedded runtime is worth the extra footprint. + + + +## Concept Map + +| SQLite concept | Oliphaunt concept | +| --- | --- | +| One database file | One PostgreSQL root directory | +| Pragmas | PostgreSQL settings and SDK durability profiles | +| SQLite transaction | PostgreSQL transaction | +| SQLite extension loading | Exact PostgreSQL extension selection before open | +| File copy backup | SDK backup/export API | +| Multiple library handles | Mode-specific Oliphaunt handles and session semantics | + +## Schema And SQL Differences + +PostgreSQL is stricter and richer than SQLite: + +- column types and casts matter more; +- `SERIAL`, `IDENTITY`, sequences, arrays, JSONB, and enums replace many + SQLite-specific conventions; +- PostgreSQL query parameters are `$1`, `$2`, and so on; +- constraints, indexes, and generated columns follow PostgreSQL syntax; +- extension-backed types and operators require exact extension selection. + +Start with a small schema slice. Port table definitions, then migrate one query +path at a time so type and constraint differences are visible early. + +## Storage And Backup + +SQLite apps often back up by copying one file. Oliphaunt live storage is a +PostgreSQL root directory, so data movement goes through SDK backup and restore +APIs. That keeps locks, WAL, selected extensions, archive format, and restore +target checks together. + +For mobile apps, keep the root in app-private storage and use the platform's +normal user-data protection choices. For desktop apps, choose direct, broker, or +server mode based on the concurrency and process-isolation model your app needs. + +## Migration Path + +1. Choose the SDK for the app target. +2. Open a temporary Oliphaunt root and port the schema. +3. Port read paths before write-heavy sync/import paths. +4. Add selected extensions explicitly. +5. Add backup/restore and package-size checks before shipping. +6. Compare app-start, first-query, memory, and package-size numbers against the + SQLite baseline. + +## When SQLite Is Still The Better Fit + +Use Oliphaunt when PostgreSQL compatibility, richer SQL, extensions, and +server-compatible workflows are worth the larger runtime and directory storage +model. Use SQLite when a tiny single-file dependency and SQLite-specific +semantics are the better fit. diff --git a/src/docs/content/learn/tauri.mdx b/src/docs/content/learn/tauri.mdx new file mode 100644 index 00000000..76893960 --- /dev/null +++ b/src/docs/content/learn/tauri.mdx @@ -0,0 +1,111 @@ +--- +title: Tauri Usage +description: Use the Rust SDK from Tauri state and expose app-specific database commands to the webview. +--- + +# Tauri Usage + +Use the Rust SDK from Tauri state. `oliphaunt` is the native SDK for Tauri and +Rust desktop apps; it owns direct embedded mode, broker mode, and server mode +over native PostgreSQL. + +WASM is a separate runtime family. Native Tauri apps start with the Rust SDK. + + + +## App Shape + +Keep the database handle in Rust state and expose narrow Tauri commands to the +webview. The webview calls app-specific commands such as `add_item` or +`search_items`; Rust owns the root directory, lock, runtime handle, lifecycle, +and backup APIs. + +| Need | Recommended mode | +| --- | --- | +| One embedded app database with lowest overhead | `NativeDirect` | +| Multiple roots or helper-process ownership | `NativeBroker` | +| SQLx pools, ORMs, `psql`, or `pg_dump` | `NativeServer` | + +## Direct Rust State + +Use `Oliphaunt` with `NativeDirect` when your Tauri commands own the database +calls and you want the lowest-latency embedded path: + +```rust,no_run +use oliphaunt::Oliphaunt; +use tauri::State; + +struct Db(Oliphaunt); + +#[tauri::command] +async fn add_item(db: State<'_, Db>, value: String) -> Result<(), String> { + db.0 + .query_params("INSERT INTO items(value) VALUES ($1)", [value]) + .await + .map_err(|err| err.to_string())?; + Ok(()) +} +``` + +Open the database under your app data directory during setup: + +```rust,no_run +use oliphaunt::Oliphaunt; + +async fn open_app_database(app_data_dir: std::path::PathBuf) -> oliphaunt::Result { + Oliphaunt::builder() + .path(app_data_dir.join("postgres")) + .native_direct() + .open() + .await +} +``` + +Store the resulting `Oliphaunt` handle with `tauri::State`. Cloned SDK handles +share the same executor and session semantics; server mode is the path for +independent PostgreSQL client sessions. + +## Existing Postgres Clients + +Use `NativeServer` when another Rust library expects a PostgreSQL URL, real +independent sessions, SQLx pools, `psql`, or `pg_dump`: + +```rust,no_run +use oliphaunt::Oliphaunt; + +async fn open_server_mode() -> oliphaunt::Result { + let db = Oliphaunt::builder() + .path("./.liboliphaunt") + .native_server() + .max_client_sessions(8) + .open() + .await?; + + Ok(db.connection_string().expect("server mode has a URL").to_owned()) +} +``` + +Use `NativeBroker` for desktop apps that want helper-process ownership or +multiple database roots managed by the Rust SDK. + +## Extensions And Assets + +Select exact SQL extension names in Rust configuration before opening the root. +Package only the extension artifacts your Tauri app uses, then verify the app +artifact before release. An app that selects `vector` ships `vector` and its +declared dependencies. + +## Backup And Restore + +Use the Rust SDK backup and restore APIs for app export/import flows. Keep live +PostgreSQL roots behind the Rust SDK. The SDK validates archive format, selected +extensions, locks, and restore targets before moving data. + +## Operational Guidance + +- Use `NativeDirect` for one embedded PostgreSQL session with minimal overhead. +- Use `NativeBroker` when helper-process ownership matters more than direct + call overhead. +- Use `NativeServer` for real concurrent PostgreSQL client sessions and pools. +- React Native apps use the React Native SDK, which delegates to Swift on + iOS/macOS and Kotlin on Android. diff --git a/src/docs/content/reference/capabilities.mdx b/src/docs/content/reference/capabilities.mdx new file mode 100644 index 00000000..9548b273 --- /dev/null +++ b/src/docs/content/reference/capabilities.mdx @@ -0,0 +1,74 @@ +--- +title: Capability Matrix +description: Compare SDK mode support, runtime boundaries, protocol features, extensions, backup, restore, and client compatibility. +--- + +# Capability Matrix + +Use this page to choose the right SDK and runtime mode. Oliphaunt SDKs are +peers: Rust is for Tauri and native Rust apps, Swift is for iOS and macOS, +Kotlin is for Android, React Native uses the Swift and Kotlin SDKs underneath, +TypeScript is for desktop JavaScript runtimes, and WASM is the WebAssembly +runtime family. + +Capability checks return explicit errors when a feature is absent. Use them to +show only the modes, controls, and workflows the installed SDK advertises for +the target. + + + +## SDKs + +| SDK | Primary use | Runtime owner | Notes | +| --- | --- | --- | --- | +| C ABI | Binding authors | `liboliphaunt` | Low-level raw protocol and lifecycle boundary | +| Rust | Tauri and native Rust apps | Rust SDK | Full direct, broker, and server model where supported | +| Swift | iOS and macOS apps | Swift SDK | Async Apple API over the native runtime | +| Kotlin | Android apps | Kotlin SDK | Coroutine Android API over the native runtime | +| React Native | Expo and New Architecture apps | Swift/Kotlin | TypeScript API, config plugin, TurboModule/JSI transport | +| TypeScript | Node.js, Bun, Deno, Tauri JS | TypeScript SDK | Desktop JavaScript API over native helpers where available | +| WASM | WebAssembly/WASIX apps | WASM SDK | Separate runtime family with WASM-owned assets | + +## Runtime Modes + +| Mode | Best for | Session model | SDK availability | +| --- | --- | --- | --- | +| Native direct | Lowest-latency embedded database | One serialized physical session | Rust, Swift, Kotlin, React Native through Swift/Kotlin, C ABI | +| Native broker | Desktop apps that need process isolation or multiple roots | Helper process owns roots and sessions | Rust first; TypeScript via helper integration where supported | +| Native server | `psql`, `pg_dump`, ORMs, pools, independent clients | PostgreSQL-compatible server process | Rust first; other SDKs only when they advertise support | +| WASM | WebAssembly/WASIX deployments | WASM runtime session model | WASM SDK | + +Mobile direct mode has one resident backend per app process and one physical +session. It is same-root logically reopenable inside that process. Use server +mode only where the SDK reports true server support. Mobile direct mode is not +a broker/server alias; it is not a crash-isolated server and does not provide +independent concurrent PostgreSQL client sessions. + +## Feature Support + +| Feature | Rust | Swift | Kotlin | React Native | TypeScript | WASM | +| --- | --- | --- | --- | --- | --- | --- | +| Open persistent root | Yes | Yes | Yes | Via Swift/Kotlin | Yes | Yes | +| Temporary root | Yes | Yes | Yes | Via Swift/Kotlin | Yes | Yes | +| Simple SQL helpers | Yes | Yes | Yes | Yes | Yes | Yes | +| Raw protocol bytes | Yes | Yes | Yes | Yes | Yes | Yes | +| Chunked protocol streaming | Yes | Yes where reported | Yes where reported | JSI ArrayBuffer stream where reported | Yes where reported | Yes where reported | +| Transactions | Yes | Yes | Yes | Yes | Yes | Yes | +| Exact extension selection | Yes | Yes | Yes | Via Swift/Kotlin | Yes | Yes | +| Physical backup/restore | Yes | Yes | Yes | Via Swift/Kotlin | Yes | Yes | +| PostgreSQL-compatible server | Yes | Where advertised | Where advertised | Where advertised through Swift/Kotlin | Via supported helper path | WASM-specific | +| Independent concurrent clients | Server mode only | Server mode only where available | Server mode only where available | Server mode only where available | Server mode only where available | Runtime-specific | + +## Choosing A Mode + +Choose native direct when app code owns one embedded database and latency is the +priority. + +Choose native broker when a desktop app needs stronger isolation, multiple +database roots, crash recovery, or a long-lived helper process. + +Choose native server when compatibility with existing PostgreSQL clients matters +more than embedding latency. + +Choose WASM when the deployment target is WASIX/WebAssembly. Choose a +native SDK when the app ships a `liboliphaunt` runtime. diff --git a/src/docs/content/reference/extensions.mdx b/src/docs/content/reference/extensions.mdx new file mode 100644 index 00000000..e405b557 --- /dev/null +++ b/src/docs/content/reference/extensions.mdx @@ -0,0 +1,79 @@ +--- +title: Extensions +description: Select exact PostgreSQL extension names and verify the artifacts that enter each app package. +--- + +# Extensions + +Oliphaunt uses exact, opt-in PostgreSQL extension selection. App developers +choose the SQL extension names their app needs, and release artifacts contain +only those selected extensions plus mandatory dependencies declared by the +extension metadata. + +There are no extension packs, aliases, grouped selectors, or implicit expansion. +Selecting `vector` means selecting the PostgreSQL extension named `vector`. + + + + + +## How Selection Works + +Select extensions before opening the database: + +```rust +use oliphaunt::{Extension, Oliphaunt}; + +# async fn demo() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .temporary() + .native_direct() + .extension(Extension::Vector) + .open() + .await?; + +db.execute("CREATE EXTENSION vector").await?; +# Ok(()) +# } +``` + +`CREATE EXTENSION` succeeds when the selected runtime resources contain that +extension for the target platform. The SDK loads only the selected extension +artifacts and their declared dependencies. + +## Platform Behavior + +| Platform | Expected behavior | +| --- | --- | +| Rust/Tauri desktop | SDK resolves selected runtime extension artifacts for the target | +| iOS/macOS Swift | App bundle includes selected extension artifacts and dependencies only | +| Android Kotlin | Android package includes selected extension artifacts and dependencies only | +| React Native | Config plugin delegates selection to Swift/Kotlin packaging | +| TypeScript desktop | SDK resolves selected runtime artifacts or helper-process resources | +| WASM | WASM assets include selected extension artifacts built for the WASIX runtime | + +## Dependencies + +Some PostgreSQL extensions depend on other extensions or runtime files. Those +dependencies are explicit metadata. If `earthdistance` declares `cube` as a +dependency, selecting `earthdistance` may include `cube`; selecting `vector` +includes `vector` and its declared dependencies only. + +## External Extensions + +External extensions are distributed as exact extension artifacts or indexes. +Consumer apps select them by SQL extension name. If a developer provides a +verified custom artifact for `acme_ext`, the app selects `acme_ext`. + +## Verifying App Artifacts + +Before release, app tooling reports: + +- selected SQL extension names; +- included extension files; +- mandatory dependencies; +- package-size contribution per extension; +- target platform and architecture. + +That report lets developers confirm that an app using only `vector` ships +`vector` and its declared dependencies, without unrelated extension artifacts. diff --git a/src/docs/content/reference/index.mdx b/src/docs/content/reference/index.mdx new file mode 100644 index 00000000..b228c9bf --- /dev/null +++ b/src/docs/content/reference/index.mdx @@ -0,0 +1,51 @@ +--- +title: Reference +description: Look up SDK support, runtime capabilities, extensions, releases, performance, and API surfaces. +--- + +# Reference + +Reference pages answer product lookup questions. Use them when you already know +the app target and need exact support, extension, package, release, or API +details. + + + +## How To Use Reference + + + + + ### Start with the app target + + Use [SDKs And Platforms](/docs/reference/sdk-products) to choose the package + for the app users install: Rust, Swift, Kotlin, React Native, TypeScript, + WASM, or the C ABI. + + + + + ### Check runtime capability + + Use [Capability Matrix](/docs/reference/capabilities) before enabling UI for + broker, server, raw protocol streaming, backup, restore, or independent client + sessions. + + + + + ### Select artifacts deliberately + + Use [Extensions](/docs/reference/extensions) and the generated catalog to + package only the SQL extensions your app selects. + + + + + ### Verify release fit + + Use [Performance](/docs/reference/performance), the generated version matrix, + and SDK API maps when preparing a release candidate. + + + diff --git a/src/docs/content/reference/performance.mdx b/src/docs/content/reference/performance.mdx new file mode 100644 index 00000000..58bd9716 --- /dev/null +++ b/src/docs/content/reference/performance.mdx @@ -0,0 +1,93 @@ +--- +title: Performance +description: Understand the latency, throughput, memory, package-size, and SQLite comparison measurements Oliphaunt publishes. +--- + +# Performance + +Oliphaunt is designed for app-embedded PostgreSQL. Performance work focuses on +the operations developers feel in production apps: open time, simple-query +latency, transaction throughput, bulk load speed, large result streaming, +backup/restore time, memory footprint, and packaged app size. + + + +## What to measure + +Use performance numbers in context: + +| Area | Why it matters | +| --- | --- | +| Cold open | App startup and first database access | +| Warm open | Reopening a database during normal app use | +| Query latency | UI responsiveness for small reads and writes | +| Transaction throughput | Sync, import, and local-first write workloads | +| Bulk load | Initial dataset import and cache hydration | +| Large result streaming | Reports, sync scans, and export flows | +| Backup and restore | User data migration and support workflows | +| RSS and package size | Mobile distribution and desktop app footprint | + +Native direct mode is the lowest-latency embedded path. Broker mode adds +an IPC boundary in exchange for process isolation and multi-root management. +Server mode is the right choice when an app needs real PostgreSQL client +connections, tools, pools, or ORMs. + +## Compare modes honestly + +Compare each mode against the problem it solves: + +- Use direct mode when one embedded database session is enough and latency is + the primary concern. +- Use broker mode when crash isolation, recovery, upgrades, or multiple roots + are more important than the last microseconds of latency. +- Use server mode when independent PostgreSQL clients are part of the product. + +For mobile apps, include startup time, memory footprint, selected extensions, +and app artifact size in the same report. The useful result is the one that +keeps latency, throughput, memory, and selected-extension packaging visible +together. + +## SQLite comparison + +SQLite is the baseline developers already trust for embedded storage. Oliphaunt +is measured against SQLite for: + +- first query after app launch; +- single-row lookup; +- batched insert/update; +- aggregate queries over realistic local datasets; +- transaction cost; +- package size and memory footprint. + +The comparison explains the workload and schema. PostgreSQL features such +as extensions, SQL compatibility, data types, and server-mode interoperability +are part of the value proposition alongside low latency, high throughput, and a +bounded footprint in common app workloads. + +## Release Measurements + +Published performance results include: + +- hardware and operating system; +- SDK and runtime mode; +- PostgreSQL and Oliphaunt versions; +- selected extensions; +- repeat count and percentile method; +- memory/RSS collection method; +- package-size method; +- links to reproducible benchmark workloads. + +Reports must show p50/p90/p95/p99 latency, suite totals, throughput, RSS, +CPU time, package size, and benchmark provenance. + +Native Direct Regression Diagnostics are included when direct mode misses a +gate, so the report links the failing suite back to repeatable diagnostic +commands rather than only showing a red/green result. + +PostgreSQL configuration sweeps must stay inside valid server settings. For +example, `min_wal_size=8MB` is the practical lower bound because values below a +WAL segment are invalid PostgreSQL experiments, not useful mobile footprint +tuning data. + +Public docs present stable methodology and release results. Raw run logs and +benchmark debugging notes stay out of app-developer setup guides. diff --git a/src/docs/content/reference/releases.mdx b/src/docs/content/reference/releases.mdx new file mode 100644 index 00000000..640714f7 --- /dev/null +++ b/src/docs/content/reference/releases.mdx @@ -0,0 +1,46 @@ +--- +sidebar_position: 1 +title: Releases +description: Match SDK versions, runtime artifacts, selected extensions, release notes, and docs versions. +--- + +# Releases + +Oliphaunt products are released independently. Start from the package your app +installs, then check the runtime artifacts and selected extensions that package +expects. + + + +## Version Families + +| Family | Products | Moves when | +| --- | --- | --- | +| Native runtime | C ABI, Rust, Swift, Kotlin, React Native, TypeScript native helpers | Native runtime, native PostgreSQL patches, or native extension artifacts change | +| WASM runtime | WASM SDK and WASIX runtime assets | WASM PostgreSQL patches, WASIX assets, or WASM extension artifacts change | +| Docs | Public documentation site | Guides, references, or release notes change without package artifacts | + +React Native spans two native platform SDKs. A JavaScript package release may +need matching Swift and Kotlin artifacts even when the TypeScript API shape is +unchanged. + +## What A Release Tells You + +Release notes answer these questions: + +- PostgreSQL baseline used by the runtime; +- SDK packages published in the release; +- platforms and architectures with artifacts; +- exact SQL extensions available for each target; +- direct, broker, server, raw protocol, streaming, backup, and restore support; +- migration or rebuild steps for app developers. + +## Docs Versioning + +The docs site has a `latest` channel for the current product shape. Package +versions, compatibility notes, and release notes tell developers which docs +match the SDK installed in an app. Versioned docs remain available by released +product version when a package line needs stable historical documentation. + +Documentation changes can update the docs site without changing Rust, Swift, +Kotlin, React Native, TypeScript, C ABI, or WASM package versions. diff --git a/src/docs/content/reference/sdk-products.mdx b/src/docs/content/reference/sdk-products.mdx new file mode 100644 index 00000000..9a03555a --- /dev/null +++ b/src/docs/content/reference/sdk-products.mdx @@ -0,0 +1,55 @@ +--- +title: SDKs And Platforms +description: Compare Oliphaunt SDK package names, target apps, runtime ownership, and platform responsibilities. +--- + +Oliphaunt ships as peer SDKs for the platforms where developers build apps. +Each SDK uses the conventions of its ecosystem while keeping the same database +concepts: open a root, run SQL, manage lifecycle, select exact extensions, and +back up or restore data. + +Rust is the SDK for Tauri and Rust desktop apps. +React Native is the TypeScript/TurboModule SDK over the Swift and Kotlin SDKs. +TypeScript is the SDK for Node.js, Bun, Deno, and Tauri JavaScript apps. + +## Choose an SDK + + + +React Native delegates database execution to the platform SDKs: Apple calls flow +through the Swift SDK, and Android calls flow through the Kotlin SDK. That keeps +resource packaging, lifecycle, and native crash behavior predictable for mobile +apps. + +TypeScript broker mode uses a published broker helper, so JavaScript desktop apps +resolve the helper through package configuration. + +## Shared Concepts + +The SDKs intentionally use matching names for the concepts developers need to +move between platforms: + +- `nativeDirect` for the lowest-latency embedded session. +- `nativeBroker` for helper-process isolation and multi-root desktop apps. +- `nativeServer` when real PostgreSQL clients, pools, or tools need independent + sessions. +- `capabilities()` to check what a selected runtime can do. +- `backup` and `restore` for app-friendly data movement. +- structured PostgreSQL errors with SQLSTATE and backend fields where available. + +SDK capability reports describe which modes are available on the selected +platform. Mobile SDKs expose direct mode first; broker and server support appear +when the platform SDK advertises those runtimes. + + + +## Extensions + + + +Extension selection is exact-name only: app configuration names PostgreSQL +extensions such as `vector` directly. + +That model matters for mobile and desktop distribution: app developers decide +which PostgreSQL extensions their app uses, and Oliphaunt packaging makes that +choice auditable before release. diff --git a/src/docs/content/sdk/c-abi/api-reference.md b/src/docs/content/sdk/c-abi/api-reference.md new file mode 100644 index 00000000..1b1aa7a0 --- /dev/null +++ b/src/docs/content/sdk/c-abi/api-reference.md @@ -0,0 +1,26 @@ +--- +title: API Reference +description: C ABI API map for native runtime initialization, protocol execution, response ownership, and lifecycle. +--- + +# API Reference + +Use the Doxygen reference for exact declarations. This page maps the C ABI by +task. + +| Area | Public surface | Use it for | +| --- | --- | --- | +| Initialization | `oliphaunt_init`, `OliphauntConfig` | Open a native direct backend with explicit root, durability, runtime resource, and extension settings | +| Versioning | `oliphaunt_version` | Report the runtime and PostgreSQL build identity | +| Capabilities | `oliphaunt_capabilities`, `OliphauntCapabilities` | Discover protocol, streaming, extension, backup, restore, lifecycle, and mode support | +| Raw protocol | `oliphaunt_exec_protocol` | Send PostgreSQL frontend protocol bytes and receive backend messages | +| Streaming | `oliphaunt_exec_protocol_stream`, response sink callbacks | Handle large protocol responses without forcing one contiguous response buffer | +| Simple SQL | SQL helper entry points where exposed | Run smoke and embedding checks without requiring a higher-level SDK parser | +| Response ownership | `OliphauntResponse`, `oliphaunt_free_response` | Free ABI-owned buffers exactly once | +| Errors | `oliphaunt_last_error`, structured error fields where available | Read the last SDK or PostgreSQL error for a handle | +| Data movement | backup and restore entry points where exposed | Move PostgreSQL roots through validated archives | +| Lifecycle | `oliphaunt_checkpoint`, `oliphaunt_close` | Flush, detach, and release the resident backend handle | + +Most app developers use a language SDK instead of calling the C ABI directly. +The C ABI is primarily for binding authors and applications that need the native +runtime boundary itself. diff --git a/src/docs/content/sdk/c-abi/guide.mdx b/src/docs/content/sdk/c-abi/guide.mdx new file mode 100644 index 00000000..e902de06 --- /dev/null +++ b/src/docs/content/sdk/c-abi/guide.mdx @@ -0,0 +1,116 @@ +--- +title: Build A Binding +description: Build a language binding over opaque C handles, raw protocol bytes, explicit response ownership, lifecycle, extensions, and backup APIs. +--- + +# Build A Binding + +Use the C ABI when building language bindings or platform SDKs. App developers +usually choose Rust, Swift, Kotlin, React Native, TypeScript, or WASM instead. + + +Use this surface when you need opaque handles, explicit response ownership, and +the native runtime boundary. App-facing SDKs own typed queries and platform +lifecycle integration. + + + + + + + +### Install + +Consume the released headers, libraries, and runtime assets for your target. +Language bindings package those artifacts through the target ecosystem so app +developers install one SDK surface. + + + + +### Open and query + +Open a root, send raw protocol bytes, read backend messages, and close the +handle. + + + +```c +#include +#include + +OliphauntConfig config = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .pgdata = "/app/data/main.oliphaunt/pgdata", + .username = "app", + .database = "app", +}; + +OliphauntHandle *db = NULL; +int rc = oliphaunt_init(&config, &db); +if (rc != 0) { + return rc; +} + +OliphauntResponse response = {0}; +const char *sql = "SELECT 1::text AS value"; +rc = oliphaunt_exec_simple_query(db, sql, strlen(sql), &response); +oliphaunt_free_response(&response); +oliphaunt_close(db); +``` + +Higher-level SDKs own SQL builders, typed parsing, async scheduling, resource +selection, and lifecycle integration. + + + + +### Configure + +Configure root, mode, selected extensions, runtime resource paths, startup +identity, durability, and owner identity through the ABI config surface. + + + + +### Choose a mode + +The ABI exposes capabilities for the selected target. Direct mode owns one +serialized embedded session. Broker and server support appear only when the +target runtime advertises those modes. + + + + +### Handle lifecycle + +Each binding runs calls through a single owner queue or equivalent serial +executor. Close rejects queued work and waits for active work according to the +SDK's platform contract. + + + + +### Select extensions + +Pass exact SQL extension names and resource paths. The ABI loads selected +extension artifacts and their declared dependencies. + + + + +### Back up and restore + +Use ABI backup and restore calls for supported physical archive formats. +Bindings validate user input before crossing the ABI boundary. + + + + + + +## Troubleshooting + +Inspect ABI error codes, `oliphaunt_last_error`/Oliphaunt error fields, missing +runtime resources, root locks, mode capability errors, and PostgreSQL SQLSTATE +data. diff --git a/src/docs/content/sdk/c-abi/index.mdx b/src/docs/content/sdk/c-abi/index.mdx new file mode 100644 index 00000000..f054eb23 --- /dev/null +++ b/src/docs/content/sdk/c-abi/index.mdx @@ -0,0 +1,88 @@ +--- +title: C ABI +description: Native runtime boundary for language bindings and direct C consumers. +--- + + + +`liboliphaunt` is the native runtime boundary for SDKs and direct C consumers. +Most app developers use a platform SDK, but binding authors use the C ABI as the +stable layer under Swift, Kotlin, React Native, TypeScript native adapters, and +other language bindings. + +Use this surface when you are writing a new SDK, integrating from a C or C++ +application, or validating the native runtime independently from a language +wrapper. + +## Install + +Consume the released headers, libraries, and runtime assets for the target you +are binding. Language SDKs package those artifacts through their own ecosystems, +so app developers usually install the Rust, Swift, Kotlin, React Native, +TypeScript, or WASM SDK. + +The public boundary is intentionally small: + +- initialize a native direct database session; +- execute raw PostgreSQL protocol bytes or simple SQL; +- stream large protocol responses; +- back up or restore supported archive formats; +- read capabilities and structured errors; +- close or detach according to the process lifecycle. + +The ABI owns handles and response buffers. Language bindings own serialization, +typed query helpers, task scheduling, and platform packaging. + +## Open And Query + +The C ABI uses opaque handles and explicit response ownership: + +```c +#include +#include + +OliphauntHandle *handle = NULL; +OliphauntConfig config = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .pgdata = "./app-data/main.oliphaunt/pgdata", + .username = "app", + .database = "app", +}; + +int32_t status = oliphaunt_init(&config, &handle); +if (status == 0) { + OliphauntResponse response = {0}; + const char *sql = "SELECT 1::text AS value"; + status = oliphaunt_exec_simple_query(handle, sql, strlen(sql), &response); + oliphaunt_free_response(&response); +} +oliphaunt_close(handle); +``` + +## Runtime Shape + +The C ABI is the native direct runtime boundary. It exposes capabilities for the +target artifact so bindings can report which runtime modes, backup formats, +streaming behavior, and extensions are available. + +Direct mode owns one serialized embedded PostgreSQL session. Broker and server +support appear through higher-level SDKs or helper processes only when the +target runtime advertises those capabilities. + +## App Responsibilities + +Bindings and direct C consumers own the app-facing contract above the ABI: + +- Keep all cross-language query transport on raw PostgreSQL protocol bytes or + simple SQL helpers provided by the ABI. +- Serialize work according to the selected runtime mode. +- Select exact SQL extension names before opening the database. +- Surface `capabilities()` and structured errors in ecosystem-native types. +- Use ABI backup and restore calls instead of copying live PostgreSQL roots. + +## First Query + +Use [Build a Binding](/docs/sdk/c-abi/guide) for open, query, close, +lifecycle, extension, and backup behavior. Use the +[API reference](/docs/sdk/c-abi/api-reference) for the public handle and +function map. diff --git a/src/docs/content/sdk/index.mdx b/src/docs/content/sdk/index.mdx new file mode 100644 index 00000000..31368b7a --- /dev/null +++ b/src/docs/content/sdk/index.mdx @@ -0,0 +1,82 @@ +--- +title: SDKs +description: Choose the Oliphaunt SDK for Rust, Apple, Android, React Native, TypeScript, WASM, or C ABI binding. +--- + +# SDKs + +Choose the SDK by the app target and delivery path. Oliphaunt is a family of +peer SDKs over the same embedded PostgreSQL runtime model: open an app-owned +root, select the runtime mode, choose exact SQL extensions, run SQL, read +capabilities, and use the SDK's lifecycle and backup APIs. + +Each SDK page starts with the package to install, the platform build step that +matters, a first query, and the runtime behavior that affects real apps. Use +the reference pages when you need exact capability or extension tables. + + + +## How To Choose + +Use the SDK that owns the app binary or helper process that carries the +database runtime: + +- choose Swift for iOS and macOS apps; +- choose Kotlin for Android apps; +- choose React Native when JavaScript owns the app surface and Swift/Kotlin + carry the native runtime underneath; +- choose Rust or TypeScript for desktop apps, Tauri apps, and local helper + processes; +- choose WASM when the runtime host is WASIX; +- choose C ABI only when building another language binding or integrating the + native runtime directly. + +React Native crosses two native platforms. Its public API is TypeScript, while +packaging, lifecycle, and binary transport are handled through Swift on Apple +platforms and Kotlin on Android. + +## Shared Contract + +All SDKs expose the same durable concepts where the platform supports them: + +- explicit runtime mode selection; +- app-owned persistent or temporary roots; +- exact SQL extension selection; +- `capabilities()` before relying on advanced features; +- SQL helpers plus raw protocol access for adapters; +- lifecycle, backup, restore, and structured PostgreSQL errors. + +Each SDK reports platform-specific mode support through capabilities. Mobile +SDKs expose direct mode first; broker and server support appear when a platform +SDK advertises those runtimes. Direct mode uses one physical session with +serialized work. + + + +## What Each SDK Page Answers + +Use the landing page for the package you are adding to an app. It answers the +questions that block the first useful integration: + +| Question | Why it matters | +| --- | --- | +| Which package do I install? | App users install your app; the SDK carries released runtime artifacts. | +| What build step changes my app binary? | Native runtime assets and selected extensions are packaged at build time. | +| Which runtime mode can I use? | Direct, broker, server, and WASM have different concurrency and process boundaries. | +| How do I ship only selected extensions? | App artifacts include exact SQL extension names and declared dependencies only. | +| What happens on close, backgrounding, relaunch, and backup? | Embedded database behavior has to match platform lifecycle expectations. | + +React Native covers the JavaScript boundary, config plugin, TurboModule, JSI +transport, and installed-app wiring. Swift and Kotlin cover platform lifecycle +and runtime resources. Rust and TypeScript cover their own desktop runtime +ownership. WASM remains a separate runtime family. + +## Where To Go Next + +- Use [Start](/docs/start) for the shortest first-query path. +- Use [Capability Matrix](/docs/reference/capabilities) before relying on a + mode or feature in a packaged app. +- Use [Extensions](/docs/reference/extensions) to understand exact extension + selection and app artifact behavior. +- Use [Native Runtime](/docs/learn/native-runtime) for direct, broker, and + server semantics. diff --git a/src/docs/content/sdk/kotlin/api-reference.md b/src/docs/content/sdk/kotlin/api-reference.md new file mode 100644 index 00000000..bdb7a71d --- /dev/null +++ b/src/docs/content/sdk/kotlin/api-reference.md @@ -0,0 +1,26 @@ +--- +title: API Reference +description: Kotlin and Android SDK API map for configuration, coroutine execution, lifecycle, and resources. +--- + +# API Reference + +Use the Dokka reference for exact declarations. This page maps the Kotlin SDK +surface by task. + +| Area | Public surface | Use it for | +| --- | --- | --- | +| Opening | `OliphauntDatabase.open`, `OliphauntConfig` | Open a persistent or temporary root from Kotlin code | +| Android facade | `OliphauntAndroid` | Resolve Android resources, ABI assets, and app-context defaults | +| Runtime mode | `EngineMode`, `supportedModes()` | Discover modes advertised by the selected Android target | +| Capabilities | `EngineCapabilities` | Check protocol, streaming, backup, restore, lifecycle, and extension support | +| SQL | `query`, `execute`, `QueryResult` | Run SQL and read typed values from coroutine code | +| Raw protocol | `execProtocolRaw`, `execProtocolStream` | Send PostgreSQL protocol bytes through the serialized session | +| Transactions | `transaction`, `OliphauntTransaction` | Keep transaction work inside the pinned session boundary | +| Lifecycle | `prepareForBackground`, `resumeFromBackground`, `cancel`, `close` | Coordinate database work with Android app lifecycle transitions | +| Data movement | `backup`, `restore`, `BackupRequest` | Move app data through validated archives and Android file APIs | +| Errors | `OliphauntException`, `PostgresException` | Handle SDK errors and PostgreSQL SQLSTATE data | + +Android apps use the Android facade for packaged runtime resources. It keeps +native library loading, selected extension assets, and app-private storage in +the platform layer. diff --git a/src/docs/content/sdk/kotlin/guide.mdx b/src/docs/content/sdk/kotlin/guide.mdx new file mode 100644 index 00000000..5c6093df --- /dev/null +++ b/src/docs/content/sdk/kotlin/guide.mdx @@ -0,0 +1,161 @@ +--- +title: Build With Kotlin +description: Add Oliphaunt to an Android app with Gradle, coroutines, app-private storage, lifecycle hooks, selected extensions, and backup APIs. +--- + +# Build With Kotlin + +Use the Kotlin SDK in Android apps. It provides coroutine-friendly APIs over the +native runtime and owns Android resource hydration, root validation, and +extension materialization. + + +React Native on Android delegates runtime behavior through this SDK. Native +Android apps use the Kotlin facade directly for resource hydration and ABI +selection. + + + + + + + +### Install + +Add the Android SDK and app-applied Gradle plugin. The plugin resolves and +packages native runtime artifacts, Android ABIs, and exact extension files. + +```kotlin +plugins { + id("com.android.application") + id("dev.oliphaunt.android") version "0.1.0" +} + +dependencies { + implementation("dev.oliphaunt:oliphaunt:0.1.0") +} + +oliphaunt { + extensions.add("vector") +} +``` + +Use app-private storage for persistent roots and temporary roots for tests. + + + + +### Open and query + +Create an `OliphauntConfig`, open a database, run SQL, and close it from +coroutines. + + + +```kotlin +val database = + OliphauntDatabase.open( + OliphauntConfig( + root = context.filesDir.resolve("main.oliphaunt").absolutePath, + mode = EngineMode.NativeDirect, + extensions = listOf("vector"), + ), + ) + +val rows = database.query("SELECT 1::text AS value") +val value = rows.getText(row = 0, column = "value") + +database.close() +``` + +Share the opened database object through your app's dependency graph. It +represents one serialized native session. + + + + +### Create app data + +Use coroutine-friendly SQL helpers from repositories or use cases. Keep +database ownership in an Android service object rather than inside a composable: + +```kotlin +database.execute( + """ + CREATE TABLE IF NOT EXISTS notes ( + id bigserial PRIMARY KEY, + title text NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ) + """.trimIndent(), +) + +database.query( + "INSERT INTO notes (title, body) VALUES ($1, $2) RETURNING id::text AS id", + listOf( + QueryParam.text("First note"), + QueryParam.text("Stored in an embedded PostgreSQL root"), + ), +) + +val notes = + database.query("SELECT id, title FROM notes ORDER BY id DESC LIMIT 20") +``` + +Expose app-specific suspend functions to UI code. The SDK owns the serialized +native session underneath those calls. + + + + +### Configure + +Configure root, mode, selected exact extensions, startup identity, Android asset +root, and durability through `OliphauntConfig` and the Android facade. Keep SQL +root selection separate from extension artifact selection. + + + + +### Choose a mode + +Android direct mode owns one resident backend per app process and one physical +session. Broker and server modes appear when the Android SDK reports platform +support for them. + + + + +### Handle lifecycle + +Database calls run through coroutine serialization. Close rejects queued work, +waits for active work, then detaches from the runtime. Use Android lifecycle +hooks to prepare for backgrounding and resume foreground work deliberately. + + + + +### Select extensions + +Select exact SQL extension names before opening the database. Android artifacts +contain only those selected extensions and mandatory dependencies. + + + + +### Back up and restore + +Use SDK backup and restore APIs with Android file/document APIs. The SDK +validates roots, formats, and archive metadata before materialization. + + + + + + +## Troubleshooting + +Check app storage permissions, root locks, missing native libraries, missing +runtime resources, mode capability errors, selected-extension artifacts, and +SQLSTATE-bearing PostgreSQL errors. diff --git a/src/docs/content/sdk/kotlin/index.mdx b/src/docs/content/sdk/kotlin/index.mdx new file mode 100644 index 00000000..9f675f7f --- /dev/null +++ b/src/docs/content/sdk/kotlin/index.mdx @@ -0,0 +1,88 @@ +--- +title: Kotlin SDK +description: Android SDK with coroutine-first database APIs and native resource handling. +--- + + + +The Kotlin SDK is the Android SDK for Oliphaunt. It provides coroutine-first +database APIs plus an Android facade for native library loading, runtime asset +materialization, ABI selection, and app-private storage. + +Use this SDK directly from Android apps written with Kotlin, Java interop, or +Jetpack Compose. React Native on Android delegates runtime work through this SDK, +so Android packaging, lifecycle, and resource selection are defined here. + +## Install + +Add the Android package to your app: + +```kotlin +plugins { + id("com.android.application") + id("dev.oliphaunt.android") version "0.1.0" +} + +dependencies { + implementation("dev.oliphaunt:oliphaunt:0.1.0") +} + +oliphaunt { + extensions.add("vector") +} +``` + +The Gradle plugin verifies and packages selected native runtime artifacts, +Android ABIs, and exact extension files. The app ships only the selected +extensions plus declared dependencies. + +## Open And Query + +Open a database from a coroutine: + +```kotlin +import dev.oliphaunt.EngineMode +import dev.oliphaunt.OliphauntAndroid +import dev.oliphaunt.OliphauntConfig + +val database = OliphauntAndroid.open( + context = applicationContext, + config = OliphauntConfig( + root = applicationContext.filesDir.resolve("main.oliphaunt").absolutePath, + mode = EngineMode.NativeDirect, + extensions = listOf("vector"), + ), +) + +val rows = database.query("SELECT 1::text AS value") +database.close() +``` + +## Runtime Shape + +Android direct mode owns one resident backend per app process and one serialized +physical session. The SDK reports capabilities for any additional runtime modes +available on the selected Android target. + +Coroutine callers may share a database handle. Direct mode queues work against +the resident backend and preserves transaction ordering. Android packaging owns +ABI selection, native library loading, and runtime resource hydration before the +first open. + +## App Responsibilities + +- Store persistent data in app-private storage unless the app deliberately + exports a backup. +- Select exact SQL extension names at build/configuration time so the APK or AAB + contains only selected extension artifacts and declared dependencies. +- Use lifecycle hooks around background and foreground transitions. +- Use SDK backup and restore APIs for archive validation and root + materialization. +- Check `capabilities()` before showing optional UI for streaming, backup + formats, or additional runtime modes. + +## First Query + +Use [Build With Kotlin](/docs/sdk/kotlin/guide) for open/query, +configuration, lifecycle, exact extensions, backup, restore, and troubleshooting. +Use the [API reference](/docs/sdk/kotlin/api-reference) for the public API map. diff --git a/src/docs/content/sdk/react-native/api-reference.md b/src/docs/content/sdk/react-native/api-reference.md new file mode 100644 index 00000000..ae89f79a --- /dev/null +++ b/src/docs/content/sdk/react-native/api-reference.md @@ -0,0 +1,27 @@ +--- +title: API Reference +description: React Native SDK API map for TypeScript, config plugin, TurboModule, JSI binary transport, and mobile lifecycle. +--- + +# API Reference + +Use the TypeDoc reference for exact declarations. This page maps the React Native +SDK by task. + +| Area | Public surface | Use it for | +| --- | --- | --- | +| Opening | `Oliphaunt.open`, `OpenConfig` | Open a database from TypeScript with root, mode, durability, and selected extensions | +| Config plugin | Expo plugin options | Include the selected native runtime and exact extension artifacts in iOS and Android builds | +| Platform support | `supportedModes()`, `capabilities()` | Read what the installed Swift or Kotlin runtime can actually do | +| Database handle | `OliphauntDatabase` | Keep the opened database in app state and route calls through one native handle | +| SQL | `query`, `execute`, `QueryResult` | Run SQL and read typed values from JavaScript | +| Raw protocol | `execProtocolRaw` | Send PostgreSQL protocol bytes through JSI `ArrayBuffer` transport | +| Streaming | `execProtocolStream` | Receive large protocol responses as native-backed chunks | +| Lifecycle | `prepareForBackground`, `resumeFromBackground`, `close` | Coordinate database work with app background and foreground transitions | +| Data movement | `backup`, `restore` | Delegate archive validation and root materialization to Swift or Kotlin | +| Package report | package-size and extension artifact reports | Verify that the app ships only selected extensions and target ABIs | +| Errors | `OliphauntError`, `PostgresError` | Handle SDK errors and PostgreSQL SQLSTATE data in TypeScript | + +The React Native SDK owns the JavaScript boundary. Runtime behavior remains +platform-native: Apple calls flow through Swift, Android calls flow through +Kotlin. diff --git a/src/docs/content/sdk/react-native/architecture.mdx b/src/docs/content/sdk/react-native/architecture.mdx new file mode 100644 index 00000000..16f84cab --- /dev/null +++ b/src/docs/content/sdk/react-native/architecture.mdx @@ -0,0 +1,137 @@ +--- +title: Architecture +description: Understand how the React Native SDK delegates runtime behavior to Swift and Kotlin while owning TypeScript, config plugin, TurboModule, and JSI transport. +--- + +# Architecture + +`@oliphaunt/react-native` is the React Native New Architecture SDK for +Oliphaunt. It gives JavaScript and TypeScript apps a native embedded PostgreSQL +database through the Swift and Kotlin SDKs. + +The package has three jobs: + +- provide the TypeScript API developers call from React Native; +- configure app builds so the selected native runtime and exact extensions are + packaged; +- move protocol bytes between JavaScript and the platform SDK. + + + +## Runtime Ownership + +Database runtime behavior belongs to the platform SDKs. Apple apps use the +Swift SDK. Android apps use the Kotlin SDK. + +| Platform | Runtime owner | React Native role | +| --- | --- | --- | +| iOS | Swift SDK | TypeScript API, config plugin, TurboModule, JSI byte transport | +| macOS | Swift SDK where supported | Same API shape as iOS | +| Android | Kotlin SDK | TypeScript API, config plugin, TurboModule, JSI byte transport | + +That keeps platform behavior consistent. Root validation, extension selection, +backup and restore, lifecycle, cancellation, and capability reporting live in +the same SDKs used by native Swift and Kotlin app developers. + +Android calls go through the Android `dev.oliphaunt.OliphauntAndroid` facade, +returning the Kotlin SDK `OliphauntDatabase` handle behind the React Native +handle. + +## JavaScript Shape + +The JavaScript API is handle-oriented: + +```ts +import { Oliphaunt } from '@oliphaunt/react-native'; + +const db = await Oliphaunt.open({ + engine: 'nativeDirect', + temporary: true, + runtimeFootprint: 'balancedMobile', +}); + +const result = await db.query('SELECT 1::text AS value'); +const value = result.getText(0, 'value'); + +await db.close(); +``` + +The query helpers are layered over PostgreSQL protocol bytes. Use +`query(sql, params)` for normal app work. Use raw protocol APIs when you need +custom frontend protocol behavior, COPY, or multi-result-set handling. + +## Binary Transport + +Oliphaunt uses React Native's New Architecture for module lifecycle and typed +native bindings. Bulk protocol bytes use the binary JSI path. + +The fast path is a versioned JSI ArrayBuffer transport: + +- JS accepts `Uint8Array`, `ArrayBuffer`, and typed-array views; +- native code returns binary responses as owned buffers; +- streaming APIs deliver chunks for large responses when the platform reports + `protocolStream=true`; +- the SDK verifies the JSI installer before opening a native database session. + +This is the performance boundary for large result sets, protocol round trips, +backup and restore bytes, and mobile latency. + +## Config Plugin And Packaging + +The React Native package makes native packaging predictable: + +- select exact SQL extension names; +- include only selected extension artifacts and declared dependencies; +- include the native library and runtime resources for the app target; +- expose package-size reporting so developers can verify what ships. + +An app that selects only `vector` ships `vector` and its declared dependencies. + +## Lifecycle + +Mobile direct mode uses one resident backend per app process and one physical +session. It is same-root logically reopenable inside that process. Broker and +server modes add a process boundary on targets that advertise those modes. + +Use the React Native lifecycle helpers around background and foreground +transitions. They delegate to Swift or Kotlin so platform storage and lifecycle +rules stay native. + +## Capabilities + +Read capabilities from the opened database or SDK support API. Capabilities are +the contract; platform names only identify the target. + +`supportedModes()` is delegated too: iOS reports Swift support, and Android +reports Kotlin support through the Android facade. + +Capabilities report: + +- raw protocol support; +- streaming support; +- backup and restore formats; +- selected extension support; +- process and root behavior; +- whether broker or server mode is available. + +Mode requests outside advertised capabilities fail with clear errors. Direct +mode remains one physical session; use a server-capable platform runtime when an +app needs independent PostgreSQL client sessions. + +`Oliphaunt.restore({ libraryPath, ... })` forwards the same native library +override that the platform SDKs use, so restore follows the selected native +runtime. + +## What The React Native SDK Owns + +React Native owns the React Native boundary: + +- TypeScript API and types; +- TurboModule schema; +- JSI ArrayBuffer transport; +- config plugin resource selection; +- installed iOS and Android app wiring for open, query, stream, backup, restore, + and close operations. + +Database runtime semantics follow the Swift and Kotlin SDKs. React Native docs +focus on packaging, transport, delegation, and installed-app behavior. diff --git a/src/docs/content/sdk/react-native/guide.mdx b/src/docs/content/sdk/react-native/guide.mdx new file mode 100644 index 00000000..7f52f4af --- /dev/null +++ b/src/docs/content/sdk/react-native/guide.mdx @@ -0,0 +1,200 @@ +--- +title: Build With React Native +description: Install the React Native package, build a native app binary, configure exact extensions, use JSI transport, and wire lifecycle APIs. +--- + +# Build With React Native + +Use the React Native SDK in Expo and New Architecture React Native apps. The JS +package owns TypeScript DX, config plugin behavior, TurboModule/JSI transport, +and installed-app integration. Runtime behavior is delegated to Swift on Apple +platforms and Kotlin on Android. + + +Oliphaunt includes Swift and Kotlin runtime code. Use an Expo development build +or a React Native app binary so the native runtime is present when JavaScript +loads. + + + + + + + +### Install + +Install the package for the app path you own, then build the native app binary. +The runtime uses Swift and Kotlin code, so package installation alone is not the +last step. The installed app must contain the native module and selected runtime +resources before JavaScript calls `Oliphaunt.open()`. + +Expo apps: + +```sh +npx expo install @oliphaunt/react-native +npx expo prebuild +npx expo run:ios +npx expo run:android +``` + +Expo apps use the config plugin so selected native runtime and extension +artifacts are included in the app build. + +```json +{ + "expo": { + "plugins": [ + [ + "@oliphaunt/react-native", + { + "extensions": ["vector"] + } + ] + ] + } +} +``` + +React Native apps without Expo still use the same package, but native projects +own the equivalent Pod, Gradle, and resource configuration. Rebuild after +changing native runtime or extension selection because those choices affect the +installed app binary. + + + + +### Open and query + +Open a database from TypeScript, run SQL, and close it when the app no longer +needs the handle. + + + +```ts +import { Oliphaunt } from '@oliphaunt/react-native'; + +const db = await Oliphaunt.open({ + root: 'main.oliphaunt', + engine: 'nativeDirect', + extensions: ['vector'], +}); + +const rows = await db.query('SELECT 1::text AS value'); +const value = rows.getText(0, 'value'); + +await db.close(); +``` + +Keep the database handle in app state or a service object. Multiple JavaScript +objects share the same mobile direct session through the platform SDK. + + + + +### Create app data + +Use the SQL helpers for application queries. This keeps most React Native code +away from PostgreSQL protocol details: + +```ts +await db.execute(` + CREATE TABLE IF NOT EXISTS notes ( + id bigserial PRIMARY KEY, + title text NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ) +`); + +await db.query( + 'INSERT INTO notes (title, body) VALUES ($1, $2) RETURNING id::text AS id', + ['First note', 'Stored in an embedded PostgreSQL root'], +); + +const notes = await db.query( + 'SELECT id, title FROM notes ORDER BY id DESC LIMIT 20', +); +const firstTitle = notes.getText(0, 'title'); +``` + +Reach for raw protocol APIs only when building adapters, COPY flows, or +streaming paths that need PostgreSQL wire messages directly. + + + + +### Configure + +Configure root, mode, selected exact extensions, durability, startup identity, +and native runtime overrides through the JS API and config plugin. Build-time +extension selection controls what ships in the app bundle; runtime root +configuration controls where app data lives. + +Keep build-time and runtime settings separate. The config plugin controls native +artifacts in the installed app. `Oliphaunt.open()` controls the database root, +mode, durability, and extension activation for that app run. + + + + +### Choose a mode + +React Native starts with `nativeDirect` on mobile. The database work is +delegated to Swift on Apple platforms and Kotlin on Android, so +`capabilities()` is the source of truth for additional broker or server modes. + + + + +### Use binary transport + +New Architecture builds use binary ArrayBuffer/JSI transport for raw protocol +bytes and chunked streaming. Bulk payloads stay in binary buffers. + +Treat JSI transport as the bulk-byte boundary. Small configuration and lifecycle +calls use the TurboModule API; protocol payloads and streamed chunks stay in +binary buffers. + + + + +### Handle lifecycle + +Use the SDK lifecycle hooks around background/foreground transitions. Direct +mobile mode is same-root logically reopenable inside one resident app process. +Broker and server runtimes add a process boundary on targets that advertise +those modes. + +Call the JavaScript lifecycle API from app-level lifecycle handlers. The Swift +and Kotlin SDKs own the platform-specific details underneath that call. + + + + +### Select extensions + +Select exact SQL extension names in configuration. The native app packages +include only selected extensions plus declared dependencies. `CREATE EXTENSION` +succeeds when the selected runtime resources contain that extension for the +target platform. + + + + +### Back up and restore + +Use the React Native backup and restore APIs instead of copying platform storage +directories from JavaScript. The SDK delegates archive validation and root +materialization to Swift or Kotlin, so platform storage rules stay native. + + + + + + +## Troubleshooting + +Check the development build, Expo config plugin output, autolinking, +TurboModule codegen, native module availability, selected extension artifacts, +and platform SDK errors. For database runtime behavior, follow the Swift or +Kotlin SDK page for the target platform. diff --git a/src/docs/content/sdk/react-native/index.mdx b/src/docs/content/sdk/react-native/index.mdx new file mode 100644 index 00000000..2158f6dc --- /dev/null +++ b/src/docs/content/sdk/react-native/index.mdx @@ -0,0 +1,98 @@ +--- +title: React Native SDK +description: New Architecture package with Expo config plugin, TurboModule, and JSI transport. +--- + + + +The React Native SDK is a New Architecture package over the Swift and Kotlin +SDKs. It provides TypeScript APIs, an Expo config plugin, TurboModule codegen, +and JSI ArrayBuffer transport while native execution stays in the platform SDKs. + +Use this package for Expo development builds and React Native New Architecture +apps on iOS and Android. Apple runtime behavior flows through the Swift SDK; +Android runtime behavior flows through the Kotlin SDK. The JavaScript package +owns TypeScript ergonomics, config plugin output, binary transport, and +installed-app integration. + +## Install + +Install the package and build a development client or native app binary: + +```sh +npx expo install @oliphaunt/react-native +``` + +Oliphaunt includes native Swift and Kotlin code, so React Native apps run it +from an Expo development build or a native app binary. The config plugin +selects the native runtime and exact extension artifacts that ship in the app. + +Configure selected extensions in app config: + +```json +{ + "expo": { + "plugins": [ + [ + "@oliphaunt/react-native", + { + "extensions": ["vector"] + } + ] + ] + } +} +``` + +Rebuild the development client or native app binary after changing native +runtime or extension selections. + +## Open And Query + +Open from TypeScript and keep the handle in app state, a data service, or a +provider that matches your navigation lifetime: + +```ts +import { Oliphaunt } from '@oliphaunt/react-native'; + +const db = await Oliphaunt.open({ + root: 'main.oliphaunt', + engine: 'nativeDirect', + extensions: ['vector'], +}); + +const rows = await db.query('SELECT 1::text AS value'); +await db.close(); +``` + +Use high-level SQL helpers for app code. Use raw protocol and streaming APIs +when building adapters that need PostgreSQL wire messages directly. + +## Runtime Shape + +React Native owns the JavaScript boundary: typed configuration, SQL helpers, +raw protocol bytes, chunked streaming, lifecycle hooks, and package reporting. +Apple calls flow through Swift; Android calls flow through Kotlin. + +Direct mobile mode owns one resident backend per app process and one serialized +physical PostgreSQL session. Multiple JavaScript calls can share a handle and +are queued through the platform SDK. Broker and server mode become available +when the platform SDK advertises them through `capabilities()`. + +## App Responsibilities + +- Build with a native app binary or development client. +- Select exact SQL extension names so only selected extension artifacts and + declared dependencies enter the iOS or Android app artifact. +- Call lifecycle hooks around background and foreground transitions. +- Check `capabilities()` before showing UI that depends on streaming, + extensions, backup formats, or additional runtime modes. +- Use SDK backup and restore APIs for user-visible export/import flows instead + of copying platform storage directories from JavaScript. + +## First Query + +Use [Build With React Native](/docs/sdk/react-native/guide) for Expo setup, first +query, config plugin options, JSI transport, lifecycle, extensions, and backup. +Use the [architecture](/docs/sdk/react-native/architecture) page for the native +boundary model. diff --git a/src/docs/content/sdk/rust/api-reference.md b/src/docs/content/sdk/rust/api-reference.md new file mode 100644 index 00000000..dc1ede0e --- /dev/null +++ b/src/docs/content/sdk/rust/api-reference.md @@ -0,0 +1,25 @@ +--- +title: API Reference +description: Rust SDK API map for builders, runtime modes, query results, lifecycle, and data movement. +--- + +# API Reference + +Use the Rust API reference for exact signatures. This page maps the public +surface so you can jump from a product concept to the item you need. + +| Area | Public surface | Use it for | +| --- | --- | --- | +| Opening | `Oliphaunt::builder()`, `OliphauntBuilder` | Choose root, mode, durability, runtime assets, startup identity, and extensions | +| Runtime mode | `EngineMode`, `native_direct()`, `native_broker()`, `native_server()` | Select direct, broker, or server behavior explicitly | +| Capabilities | `EngineCapabilities`, `supported_modes()` | Check protocol, streaming, backup, restore, extension, and session support | +| SQL | `query`, `execute`, `query_params` | Run simple and parameterized SQL through the selected runtime | +| Raw protocol | `exec_protocol_raw`, `exec_protocol_stream` | Send PostgreSQL protocol bytes or stream large responses | +| Transactions | `transaction`, `with_transaction`, `SessionPin` | Pin the physical session while a transaction is active | +| Lifecycle | `checkpoint`, `cancel`, `close` | Control active work and detach from the runtime cleanly | +| Data movement | `backup`, `restore`, `BackupRequest`, `RestoreRequest` | Export, import, and validate physical archives | +| Errors | `Error`, `PostgresError`, `RuntimeUnavailable` | Handle SDK errors and PostgreSQL SQLSTATE data | + +The Rust SDK is the full native mode surface for Tauri and Rust desktop apps. +Use server mode when you need independent PostgreSQL clients; cloned direct-mode +handles still share one serialized session. diff --git a/src/docs/content/sdk/rust/guide.mdx b/src/docs/content/sdk/rust/guide.mdx new file mode 100644 index 00000000..e6e62068 --- /dev/null +++ b/src/docs/content/sdk/rust/guide.mdx @@ -0,0 +1,163 @@ +--- +title: Build With Rust +description: Use the Rust SDK in Tauri or native Rust apps with direct, broker, server, app-owned roots, exact extensions, lifecycle, and backup APIs. +--- + +# Build With Rust + +Use the Rust SDK in Tauri backends and native Rust desktop apps. It owns the +complete native mode model: direct for lowest latency, broker for helper-process +isolation, and server when independent PostgreSQL clients are required. + + +Use this guide when the app runtime is Rust. Tauri webviews and desktop +JavaScript apps use their SDKs and call into Rust through app commands or helper +processes. + + + + + + + +### Install + +Add the crate and let the SDK resolve released runtime assets and helpers +through configuration: + +```toml +[dependencies] +oliphaunt = "0.1" +``` + + + + +### Open and query + +Create a builder, choose storage, select exact extensions, open, query, and +close. + + + +```rust +use oliphaunt::{Extension, Oliphaunt}; + +async fn open_database() -> oliphaunt::Result<()> { + let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .extension(Extension::Vector) + .open() + .await?; + + let rows = db.query("SELECT 1::text AS value").await?; + assert_eq!(rows.get_text(0, "value")?, Some("1")); + + db.close().await?; + Ok(()) +} +``` + +Keep the opened `Oliphaunt` handle in application state. Cloned handles point to +the same executor. Use server mode when the app needs a connection pool or +independent PostgreSQL client sessions. + + + + +### Create app data + +Use typed helpers for application queries and keep the database handle in Tauri +state, an async service, or another app-owned dependency container: + +```rust +db.execute( + r#" + CREATE TABLE IF NOT EXISTS notes ( + id bigserial PRIMARY KEY, + title text NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ) + "#, +) +.await?; + +db.query_params( + "INSERT INTO notes (title, body) VALUES ($1, $2) RETURNING id::text AS id", + ["First note", "Stored in an embedded PostgreSQL root"], +) +.await?; + +let notes = db + .query("SELECT id, title FROM notes ORDER BY id DESC LIMIT 20") + .await?; +``` + +Expose app-specific commands to a Tauri webview instead of exposing raw database +handles directly to frontend code. + + + + +### Configure + +Configure engine mode, root, durability, startup identity, selected extensions, +runtime assets, broker executable, and server executable through the builder. +Use a persistent app-owned directory for user data. Temporary roots are useful +for tests and short-lived tools. + + + + +### Choose a mode + +`NativeDirect` runs one serialized embedded session in the process. It gives the +lowest round-trip latency and rejects pool sizes above one. + +`NativeBroker` talks to a local helper process. Use it for desktop apps that need +process isolation, crash recovery, or several roots managed by one application. + +`NativeServer` starts a PostgreSQL-compatible server process. Use it when `psql`, +`pg_dump`, ORMs, or true independent client sessions matter. + + + + +### Handle lifecycle + +Direct-mode work queues fairly on one owner executor. Transactions pin the +physical session until commit or rollback. `close()` rejects queued work, waits +for active work, then closes or detaches from the selected runtime. Use explicit +cancellation for long-running SQL. + + + + +### Select extensions + +Select exact SQL extension names before open. There are no packs, aliases, or +implicit selectors. If you select `vector`, the generated artifacts include +`vector` and only its declared dependencies. + + + + +### Back up and restore + +Use SDK backup and restore APIs instead of copying PostgreSQL directories from +application code. The SDK validates formats, target roots, locks, and restore +options before materializing data. + + + + + + +## Troubleshooting + +Check root locks, missing runtime assets, mode capability errors, extension +selection errors, and SQLSTATE-bearing PostgreSQL errors. If concurrency looks +surprising, confirm the selected mode first: direct mode serializes work by +design, while independent sessions require server mode. diff --git a/src/docs/content/sdk/rust/index.mdx b/src/docs/content/sdk/rust/index.mdx new file mode 100644 index 00000000..7b13342e --- /dev/null +++ b/src/docs/content/sdk/rust/index.mdx @@ -0,0 +1,76 @@ +--- +title: Rust SDK +description: Tauri and native Rust SDK for direct, broker, and server runtime modes. +--- + + + +The Rust SDK targets Tauri apps, native Rust desktop apps, and Rust services +that want an embedded PostgreSQL runtime with explicit mode selection. + +Use this SDK when your application is written in Rust or when a Tauri app keeps +database ownership in the Rust sidecar. It owns the full native runtime model: +direct calls for embedded latency, broker helpers for desktop robustness, and +server mode for normal PostgreSQL clients. + +## Install + +Add the crate to your Rust app: + +```toml +[dependencies] +oliphaunt = "0.1" +``` + +## Open And Query + +Then open a root in app-owned storage: + +```rust +use oliphaunt::{Extension, Oliphaunt}; + +async fn open_database() -> oliphaunt::Result<()> { + let db = Oliphaunt::builder() + .path("./app-data/main.oliphaunt") + .native_direct() + .extension(Extension::Vector) + .open() + .await?; + + let rows = db.query("SELECT 1::text AS value").await?; + db.close().await?; + Ok(()) +} +``` + +## Runtime Shape + +Rust exposes the complete native mode model: + +| Mode | Use it for | +| --- | --- | +| Native direct | Lowest-latency embedded PostgreSQL session | +| Native broker | Helper-process isolation and multi-root desktop apps | +| Native server | `psql`, `pg_dump`, ORMs, pools, and independent clients | + +Direct and broker mode serialize work through one physical PostgreSQL session. +Server mode is the mode for independent concurrent PostgreSQL clients. + +Read `capabilities()` after opening when an app enables optional features such +as streaming, backup formats, server compatibility, or exact extensions. + +## App Responsibilities + +- Store persistent roots in an app-owned directory and use temporary roots for + tests. +- Select exact SQL extension names before opening the database. +- Use SDK backup and restore APIs for data movement instead of copying a live + PostgreSQL directory. +- Use broker or server mode when crash isolation or independent clients matter. + +## First Query + +Use [Build With Rust](/docs/sdk/rust/guide) for install, open, query, +configuration, lifecycle, exact extensions, backup, restore, and +troubleshooting. Use the [API reference](/docs/sdk/rust/api-reference) when you +need the public type map. diff --git a/src/docs/content/sdk/swift/api-reference.md b/src/docs/content/sdk/swift/api-reference.md new file mode 100644 index 00000000..583b79f2 --- /dev/null +++ b/src/docs/content/sdk/swift/api-reference.md @@ -0,0 +1,24 @@ +--- +title: API Reference +description: Swift SDK API map for Apple app storage, async database calls, lifecycle, and native resources. +--- + +# API Reference + +Use the Swift DocC reference for exact declarations. This page maps the Apple +SDK surface by task. + +| Area | Public surface | Use it for | +| --- | --- | --- | +| Opening | `OliphauntDatabase.open`, `OliphauntConfiguration` | Open a persistent or temporary root with Apple-friendly defaults | +| Runtime mode | `OliphauntEngineMode`, `supportedModes()` | Discover modes advertised by the selected Apple target | +| Capabilities | `OliphauntCapabilities` | Check protocol, streaming, backup, restore, lifecycle, and extension support | +| SQL | `query`, `execute`, `OliphauntQueryResult` | Run SQL and read typed values by row and column | +| Raw protocol | `execProtocolRaw`, `execProtocolStream` | Send PostgreSQL protocol bytes without blocking the main actor | +| Transactions | `transaction`, `OliphauntTransaction` | Keep transaction work on the actor-owned session | +| Lifecycle | `prepareForBackground`, `resumeFromBackground`, `cancel`, `close` | Coordinate database work with app lifecycle transitions | +| Data movement | `backup`, `restore`, `OliphauntBackupRequest` | Move user data through validated archives and app-owned file URLs | +| Errors | `OliphauntError`, `OliphauntPostgresError` | Handle Swift errors and PostgreSQL SQLSTATE data | + +iOS and macOS apps start with `OliphauntDatabase`. The C ABI remains the +lower-level boundary used by the Swift package. diff --git a/src/docs/content/sdk/swift/guide.mdx b/src/docs/content/sdk/swift/guide.mdx new file mode 100644 index 00000000..8859ffc1 --- /dev/null +++ b/src/docs/content/sdk/swift/guide.mdx @@ -0,0 +1,157 @@ +--- +title: Build With Swift +description: Add Oliphaunt to iOS or macOS with Swift concurrency, app-container storage, lifecycle hooks, exact extensions, and backup APIs. +--- + +# Build With Swift + +Use the Swift SDK in iOS and macOS apps. It wraps the native runtime behind +Swift async APIs and keeps database work off the main actor. + + +React Native on Apple platforms delegates runtime behavior through this SDK, but +Swift and SwiftUI apps use `OliphauntDatabase` directly. + + + + + + + +### Install + +Add the Swift package in Xcode or `Package.swift`. The package includes the +Swift API plus the platform runtime artifacts required for the selected target. + +```swift +dependencies: [ + .package(url: "https://github.com/f0rr0/oliphaunt.git", from: "0.1.0") +] +``` + +Persistent roots live under your app container. App users install your app; the +SDK package carries the runtime files it needs. + + + + +### Open and query + +Open an `OliphauntDatabase` with a file URL root, run SQL with async calls, and +close when the app no longer needs the handle. + + + +```swift +let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask +)[0] + +let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + root: appSupport.appending(path: "main.oliphaunt"), + mode: .nativeDirect, + extensions: ["vector"] + ) +) + +let rows = try await database.query("SELECT 1::text AS value") +let value = try rows.getText(row: 0, column: "value") + +try await database.close() +``` + +Keep one database handle in app state and share it through your app's dependency +model. The handle owns a serialized session boundary. + + + + +### Create app data + +Use async SQL helpers for application data. A SwiftUI model or app service can +own the database handle and expose app-specific methods: + +```swift +try await database.execute(""" +CREATE TABLE IF NOT EXISTS notes ( + id bigserial PRIMARY KEY, + title text NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +) +""") + +try await database.query( + "INSERT INTO notes (title, body) VALUES ($1, $2) RETURNING id::text AS id", + parameters: [ + .text("First note"), + .text("Stored in an embedded PostgreSQL root") + ] +) + +let notes = try await database.query( + "SELECT id, title FROM notes ORDER BY id DESC LIMIT 20" +) +``` + +Keep UI updates on the main actor, but keep database work behind the SDK's async +database actor. + + + + +### Configure + +Configure root URL, runtime mode, selected exact extensions, resource bundle, +startup identity, and durability through the Swift configuration API. Resource +locations are explicit for tests and advanced app layouts; normal apps use the +packaged defaults. + + + + +### Choose a mode + +Mobile direct mode is the primary Apple runtime. It uses one resident backend per +app process and one physical session. Desktop/server boundaries are available on +Apple targets only when `capabilities()` advertises them. + + + + +### Handle lifecycle + +Swift exposes lifecycle as async API over a serial execution model. Use the +provided background/foreground hooks around app lifecycle transitions so the SDK +can checkpoint, close transient work, and resume cleanly where supported. + + + + +### Select extensions + +Select exact SQL extension names in app configuration. The app bundle contains +selected extension artifacts plus required dependencies. `CREATE EXTENSION` +succeeds when the selected runtime resources contain that extension for the +Apple target. + + + + +### Back up and restore + +Use SDK backup and restore APIs with app-owned file URLs. The SDK validates +formats and target roots before calling into the native runtime. + + + + + + +## Troubleshooting + +Most Apple failures come from invalid file URLs, missing runtime resources, root +locks, mode capability errors, or extension selection mismatches. PostgreSQL +errors preserve SQLSTATE where the backend returns it. diff --git a/src/docs/content/sdk/swift/index.mdx b/src/docs/content/sdk/swift/index.mdx new file mode 100644 index 00000000..b47a23c9 --- /dev/null +++ b/src/docs/content/sdk/swift/index.mdx @@ -0,0 +1,76 @@ +--- +title: Swift SDK +description: Apple SDK for iOS and macOS apps using Swift concurrency. +--- + + + +The Swift SDK is the Apple SDK for iOS and macOS apps. It wraps the native +runtime with Swift concurrency, app-container storage defaults, resource bundle +handling, and platform lifecycle APIs. + +Use this SDK directly in SwiftUI, UIKit, AppKit, and app extension-safe code +where supported. React Native on Apple platforms delegates runtime work through +this SDK, so the Swift lifecycle model is the Apple behavior source. + +## Install + +Add the Swift package from Xcode or `Package.swift`: + +```swift +.package(url: "https://github.com/f0rr0/oliphaunt.git", from: "0.1.0") +``` + +The package carries the Swift API and the native runtime artifacts for supported +Apple targets. + +## Open And Query + +Open a database from an async context: + +```swift +import Oliphaunt + +let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask +)[0] + +let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + root: appSupport.appending(path: "main.oliphaunt"), + mode: .nativeDirect, + extensions: ["vector"] + ) +) + +let rows = try await database.query("SELECT 1::text AS value") +try await database.close() +``` + +## Runtime Shape + +Swift is async-first and actor-owned. Direct mode owns one resident backend per +app process and one serialized physical session. The SDK reports capabilities +for any additional modes available on the selected Apple target. + +The actor boundary keeps database calls off the main actor. App code can issue +concurrent Swift tasks against the same database handle; direct mode queues them +against the resident backend and preserves transaction ordering. + +## App Responsibilities + +- Keep user data in an app-owned persistent directory. +- Call lifecycle hooks around background and foreground transitions. +- Select exact SQL extension names so the app bundle contains only selected + extension artifacts and declared dependencies. +- Use backup and restore APIs for export, import, and user-visible data + movement. +- Check `capabilities()` before enabling optional UI for streaming, backup + formats, or additional runtime modes. + +## First Query + +Use [Build With Swift](/docs/sdk/swift/guide) for open/query, app lifecycle, +extension selection, and backup/restore. Use the +[API reference](/docs/sdk/swift/api-reference) for the public API map. diff --git a/src/docs/content/sdk/typescript/api-reference.md b/src/docs/content/sdk/typescript/api-reference.md new file mode 100644 index 00000000..e4e44b25 --- /dev/null +++ b/src/docs/content/sdk/typescript/api-reference.md @@ -0,0 +1,25 @@ +--- +title: API Reference +description: TypeScript SDK API map for desktop JavaScript, native assets, broker helpers, SQL, lifecycle, and data movement. +--- + +# API Reference + +Use the TypeDoc reference for exact declarations. This page maps the TypeScript +SDK by task. + +| Area | Public surface | Use it for | +| --- | --- | --- | +| Opening | `Oliphaunt.open`, `OpenConfig` | Open a native direct, broker, or server-backed database from Node.js, Bun, Deno, or Tauri JavaScript | +| Native assets | asset resolver exports | Locate released runtime artifacts from the package | +| Runtime mode | `engine`, `supportedModes()` | Choose direct, broker, or server where the desktop runtime supports it | +| Capabilities | `capabilities()` | Check protocol, streaming, backup, restore, extension, and lifecycle support | +| SQL | `query`, `execute`, typed result helpers | Run SQL and read typed values from JavaScript | +| Raw protocol | `execProtocolRaw`, protocol utilities | Send PostgreSQL protocol bytes through the selected native path | +| Streaming | `execProtocolStream` | Consume large result sets without materializing one huge JS buffer | +| Broker/server helpers | helper process APIs | Start or connect to a local helper when isolation or PostgreSQL-compatible clients are needed | +| Data movement | `backup`, `restore`, archive helpers | Move roots through validated physical archives | +| Errors | `OliphauntError`, `PostgresError` | Handle SDK errors and SQLSTATE data | + +React Native apps use `@oliphaunt/react-native`. This package is for desktop +JavaScript runtimes. diff --git a/src/docs/content/sdk/typescript/guide.mdx b/src/docs/content/sdk/typescript/guide.mdx new file mode 100644 index 00000000..63499cd1 --- /dev/null +++ b/src/docs/content/sdk/typescript/guide.mdx @@ -0,0 +1,142 @@ +--- +title: Build With TypeScript +description: Use the desktop JavaScript SDK in Node.js, Bun, Deno, or Tauri JavaScript with helper-backed runtimes and selected extensions. +--- + +# Build With TypeScript + +Use the TypeScript SDK in Node.js, Bun, Deno, and Tauri JavaScript apps. It +provides a JavaScript API over Oliphaunt runtime assets and broker/server +helpers where supported. + + +Use this package for Node.js, Bun, Deno, and Tauri JavaScript apps. React Native +apps use the React Native SDK because mobile runtime work flows through Swift +and Kotlin. + + + + + + + +### Install + +Install the npm package. Runtime assets and helper executables resolve through +package configuration. + +```sh +npm install @oliphaunt/ts +``` + + + + +### Open and query + +Create a client, open a root, run SQL, and close the handle. + + + +```ts +import { Oliphaunt } from '@oliphaunt/ts'; + +const db = await Oliphaunt.open({ + engine: 'nativeBroker', + root: './app-data/main.oliphaunt', + extensions: ['vector'], +}); + +const rows = await db.query('SELECT 1::text AS value'); +const value = rows.getText(0, 'value'); + +await db.close(); +``` + +Keep one client per app database root unless you intentionally use a mode that +supports independent sessions. + + + + +### Create app data + +Use the TypeScript query helpers for app code and keep the client in a service +or framework-owned dependency container: + +```ts +await db.execute(` + CREATE TABLE IF NOT EXISTS notes ( + id bigserial PRIMARY KEY, + title text NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ) +`); + +await db.query( + 'INSERT INTO notes (title, body) VALUES ($1, $2) RETURNING id::text AS id', + ['First note', 'Stored in an embedded PostgreSQL root'], +); + +const notes = await db.query( + 'SELECT id, title FROM notes ORDER BY id DESC LIMIT 20', +); +const firstTitle = notes.getText(0, 'title'); +``` + +Tauri apps can keep the database in the Rust side or use the TypeScript SDK +with a helper-backed runtime, depending on where the app wants ownership. + + + + +### Configure + +Configure mode, root, selected exact extensions, runtime asset locations, +durability, startup identity, broker helper path, and server helper path through +the JS configuration object. + + + + +### Choose a mode + +Direct mode is lowest-latency where a native binding is available. Broker mode +uses a helper process and is the preferred robust desktop JavaScript path. +Server mode is for PostgreSQL-compatible tools and independent clients. + + + + +### Handle lifecycle + +The client queues work through the selected runtime boundary. Close rejects +queued work and waits for active work. Use explicit cancellation for long SQL. + + + + +### Select extensions + +Select exact SQL extension names before open. Generated resources include only +selected extensions and mandatory dependencies. + + + + +### Back up and restore + +Use SDK backup and restore helpers for physical archives and supported server +flows. Keep live PostgreSQL directories behind the SDK data-movement APIs. + + + + + + +## Troubleshooting + +Check runtime asset resolution, helper executable availability, root locks, +mode capability errors, extension selection, and SQLSTATE-bearing PostgreSQL +errors. diff --git a/src/docs/content/sdk/typescript/index.mdx b/src/docs/content/sdk/typescript/index.mdx new file mode 100644 index 00000000..32b3f216 --- /dev/null +++ b/src/docs/content/sdk/typescript/index.mdx @@ -0,0 +1,71 @@ +--- +title: TypeScript SDK +description: Desktop JavaScript SDK for Node.js, Bun, Deno, and Tauri JavaScript apps. +--- + + + +The TypeScript SDK targets Node.js, Bun, Deno, and Tauri JavaScript apps. It is +the desktop JavaScript SDK for local native helpers, broker/server flows, and +runtime asset resolution. + +Use this SDK for desktop JavaScript and Tauri webview code that needs a local +PostgreSQL runtime packaged with the app. React Native apps use the React Native +SDK because mobile execution delegates through Swift and Kotlin. + +## Install + +Install the package from npm: + +```sh +npm install @oliphaunt/ts +``` + +Use this SDK for desktop JavaScript runtimes and Tauri frontends that talk to a +local native backend. React Native apps use `@oliphaunt/react-native` because +mobile execution delegates through Swift and Kotlin. + +## Open And Query + +Open a database from TypeScript: + +```ts +import { Oliphaunt } from '@oliphaunt/ts'; + +const db = await Oliphaunt.open({ + root: './app-data/main.oliphaunt', + engine: 'nativeBroker', + extensions: ['vector'], +}); + +const rows = await db.query('SELECT 1::text AS value'); +await db.close(); +``` + +## Runtime Shape + +Broker mode is the robust desktop JavaScript path. Direct mode is available only +where a native adapter is present. Server mode is for PostgreSQL-compatible +tools and independent clients. + +The SDK resolves local helper binaries and runtime assets for the selected +platform. App code keeps using TypeScript promises and typed result helpers while +the runtime mode determines whether calls use direct, broker, or server +semantics. + +## App Responsibilities + +- Ship the runtime helper and selected exact extension artifacts with the app. +- Pick broker mode when a helper process owns the database runtime. +- Use server mode when existing PostgreSQL clients, pools, or ORMs need + independent sessions. +- Use SDK backup and restore APIs for export/import flows. +- Check `capabilities()` before relying on direct adapters, streaming, backup + formats, or server compatibility. + +## First Query + +Use [Build With TypeScript](/docs/sdk/typescript/guide) for open/query, +configuration, helper resolution, lifecycle, exact extensions, backup, restore, +and troubleshooting. Use the [API reference](/docs/sdk/typescript/api-reference) +for the public API map. diff --git a/src/docs/content/sdk/wasm/api-reference.md b/src/docs/content/sdk/wasm/api-reference.md new file mode 100644 index 00000000..27751fc6 --- /dev/null +++ b/src/docs/content/sdk/wasm/api-reference.md @@ -0,0 +1,25 @@ +--- +title: API Reference +description: WASM SDK API map for the WASIX runtime family, protocol types, storage, extensions, and dump/restore. +--- + +# API Reference + +Use the WASM rustdoc reference for exact declarations. This page maps the WASM +SDK by task. + +| Area | Public surface | Use it for | +| --- | --- | --- | +| Opening | runtime builders and root options | Open persistent or temporary WASM roots | +| Runtime assets | asset loader and catalog APIs | Select the released WASIX PostgreSQL runtime artifacts | +| Capabilities | capability reporting | Check protocol, extension, storage, dump, restore, and server support | +| SQL | query and execute helpers | Run SQL through the WASM runtime | +| Raw protocol | protocol request and response types | Send PostgreSQL protocol bytes to the WASM backend | +| Server/proxy | WASM server helper APIs | Expose PostgreSQL-compatible access where the WASM runtime supports it | +| Extensions | exact extension selectors | Include only selected WASM-built extension artifacts | +| Dump/restore | dump and restore APIs | Move data between compatible roots or export logical dumps | +| Errors | WASM SDK and PostgreSQL error types | Handle runtime errors and SQLSTATE data | + +The WASM SDK is a first-class runtime family with its own packaged PostgreSQL +runtime assets. Native direct, broker, and server mode behavior is documented in +the native SDK sections. diff --git a/docs/PG_DUMP.md b/src/docs/content/sdk/wasm/dump-restore.mdx similarity index 60% rename from docs/PG_DUMP.md rename to src/docs/content/sdk/wasm/dump-restore.mdx index 2e90660b..06a8a272 100644 --- a/docs/PG_DUMP.md +++ b/src/docs/content/sdk/wasm/dump-restore.mdx @@ -1,34 +1,42 @@ +--- +title: Dump, Restore, And Upgrade +description: Use logical dumps, physical archives, CLI exports, and restore flows for the WASM runtime. +--- + # Dump, Restore, And Upgrade -`pglite-oxide` ships a bundled WASIX `pg_dump` path behind the `extensions` +`oliphaunt-wasix` ships a bundled WASIX `pg_dump` path behind the `extensions` feature, which is enabled by default. Use it for portable SQL exports, restores, and version-to-version upgrades. + + ## Choose The Right Export Format Use logical dumps when you need: - a portable SQL export; -- an upgrade path between `pglite-oxide` releases; +- an upgrade path between `oliphaunt-wasix` releases; - a way to move data between different roots safely. Use physical data-dir archives when you need: - a same-version clone; -- a same-runtime restore into another `pglite-oxide` root; +- a same-runtime restore into another `oliphaunt-wasix` root; - a fast local snapshot of the current cluster state. -Physical archives are not a cross-version upgrade path. +Use logical dumps for cross-version upgrades. Keep physical archives for +same-version clones and restores. ## Direct API -Dump an already-open `Pglite` database to SQL: +Dump an already-open `Oliphaunt` database to SQL: ```rust,no_run -use pglite_oxide::{PgDumpOptions, Pglite}; +use oliphaunt_wasix::{PgDumpOptions, Oliphaunt}; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; db.exec("CREATE TABLE items(value TEXT)", None)?; db.exec("INSERT INTO items VALUES ('alpha')", None)?; @@ -43,10 +51,10 @@ fn main() -> Result<(), Box> { Get UTF-8 bytes instead: ```rust,no_run -use pglite_oxide::{PgDumpOptions, Pglite}; +use oliphaunt_wasix::{PgDumpOptions, Oliphaunt}; fn main() -> Result<(), Box> { - let mut db = Pglite::temporary()?; + let mut db = Oliphaunt::temporary()?; let bytes = db.dump_bytes(PgDumpOptions::new())?; assert!(!bytes.is_empty()); db.close()?; @@ -55,19 +63,19 @@ fn main() -> Result<(), Box> { ``` Direct dumps run against the already-open embedded backend. If you need to dump -as a different user or from a different database, start a `PgliteServer` and +as a different user or from a different database, start a `OliphauntServer` and use the server dump path instead. ## Server API Dump through a local Postgres endpoint when another part of your workflow -already uses `PgliteServer`: +already uses `OliphauntServer`: ```rust,no_run -use pglite_oxide::{PgDumpOptions, PgliteServer}; +use oliphaunt_wasix::{PgDumpOptions, OliphauntServer}; fn main() -> Result<(), Box> { - let server = PgliteServer::temporary_tcp()?; + let server = OliphauntServer::temporary_tcp()?; let sql = server.dump_sql(PgDumpOptions::new().arg("--schema-only"))?; assert!(!sql.is_empty()); server.shutdown()?; @@ -75,14 +83,14 @@ fn main() -> Result<(), Box> { } ``` -`PgliteServer::dump_sql(...)` currently requires a TCP endpoint. +Use a TCP endpoint for `OliphauntServer::dump_sql(...)`. ## `PgDumpOptions` `PgDumpOptions` controls the managed parts of the dump command: ```rust,no_run -use pglite_oxide::PgDumpOptions; +use oliphaunt_wasix::PgDumpOptions; let options = PgDumpOptions::new() .username("postgres") @@ -97,23 +105,23 @@ Useful passthrough flags include dump-shaping options such as: - `-n ` - `-t ` -Managed connection and output flags are reserved by the API. Do not pass -`--file`, `--format`, `--host`, `--port`, `--username`, `--dbname`, or `--jobs` -through `arg(...)` or `args(...)`. +Managed connection and output flags are reserved by the API: +`--file`, `--format`, `--host`, `--port`, `--username`, `--dbname`, and +`--jobs` are configured by Oliphaunt instead of `arg(...)` or `args(...)`. ## CLI Dump a persistent root: ```sh -pglite-dump --root ./.pglite +oliphaunt-wasix-dump --root ./.oliphaunt ``` Pass through normal `pg_dump` shaping flags after `--`: ```sh -pglite-dump --root ./.pglite -- --schema-only -pglite-dump --root ./.pglite -- --quote-all-identifiers +oliphaunt-wasix-dump --root ./.oliphaunt -- --schema-only +oliphaunt-wasix-dump --root ./.oliphaunt -- --quote-all-identifiers ``` ## Restore @@ -121,15 +129,15 @@ pglite-dump --root ./.pglite -- --quote-all-identifiers Restore a logical dump by executing the SQL against a new database: ```rust,no_run -use pglite_oxide::{PgDumpOptions, Pglite}; +use oliphaunt_wasix::{PgDumpOptions, Oliphaunt}; fn main() -> Result<(), Box> { - let mut source = Pglite::temporary()?; + let mut source = Oliphaunt::temporary()?; source.exec("CREATE TABLE items(value TEXT)", None)?; source.exec("INSERT INTO items VALUES ('alpha')", None)?; let dump_sql = source.dump_sql(PgDumpOptions::new())?; - let mut restored = Pglite::temporary()?; + let mut restored = Oliphaunt::temporary()?; restored.exec(&dump_sql, None)?; source.close()?; @@ -143,13 +151,13 @@ For same-version root copies, prefer `dump_data_dir()` / ## Upgrade Guidance -Use logical dump and restore when upgrading between `pglite-oxide` versions or +Use logical dump and restore when upgrading between `oliphaunt-wasix` versions or changing packaged runtime assets: 1. Open the old database with the old crate/runtime. -2. Create a logical dump with `dump_sql(...)` or `pglite-dump`. +2. Create a logical dump with `dump_sql(...)` or `oliphaunt-wasix-dump`. 3. Open a fresh database with the new crate/runtime. 4. Execute the dump SQL into the new database. -Do not treat physical data-dir archives as a general upgrade mechanism. They are -for the same runtime family and database format. +Use logical dumps for general upgrades. Physical data-dir archives are for the +same runtime family and database format. diff --git a/src/docs/content/sdk/wasm/guide.mdx b/src/docs/content/sdk/wasm/guide.mdx new file mode 100644 index 00000000..68b0dbfe --- /dev/null +++ b/src/docs/content/sdk/wasm/guide.mdx @@ -0,0 +1,139 @@ +--- +title: Build With WASM +description: Use the WASM/WASIX runtime family with packaged runtime assets, selected extensions, app data, lifecycle, and dump/restore flows. +--- + +# Build With WASM + +Use the WASM SDK when an application needs the WebAssembly/WASIX runtime family. +It is first-class and independent from native `liboliphaunt` SDKs. + + +Use this guide for the WASM/WASIX runtime. Native desktop and mobile apps use the +native SDK pages when they embed `liboliphaunt`. + + + + + + + +### Install + +Install the WASM package. The package resolves the WASIX runtime assets required +by the selected environment. + +```toml +[dependencies] +oliphaunt-wasix = "0.1" +``` + + + + +### Open and query + +Create the WASM database handle, choose storage, run SQL, and close it when the +app no longer needs the runtime. + + + +```rust +use oliphaunt_wasix::{extensions, Oliphaunt}; + +fn main() -> anyhow::Result<()> { + let mut db = Oliphaunt::builder() + .temporary() + .extension(extensions::VECTOR) + .open()?; + + let rows = db.query("SELECT 1::text AS value", &[], None)?; + let value = rows.rows[0]["value"].as_str(); + + db.close()?; + Ok(()) +} +``` + + + + +### Create app data + +Use the WASM API for local SQL work in supported WASIX hosts: + +```rust +db.query( + r#" + CREATE TABLE IF NOT EXISTS notes ( + id bigserial PRIMARY KEY, + title text NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ) + "#, + &[], + None, +)?; + +db.query( + "INSERT INTO notes (title, body) VALUES ('First note', 'Stored in a WASM PostgreSQL root')", + &[], + None, +)?; + +let notes = db.query("SELECT id, title FROM notes ORDER BY id DESC LIMIT 20", &[], None)?; +``` + +Use dump/restore when moving data between WASM hosts or across runtime +families. + + + + +### Configure + +Configure persistent or temporary storage, runtime mode, selected exact +extensions, durability, and import/export paths through the WASM API. + + + + +### Choose a mode + +WASM runtime behavior differs from native runtime behavior. Use the runtime page +for storage, proxy/server compatibility, and host persistence details. + + + + +### Handle lifecycle + +Persistent roots are locked while open. A second open against the same root +returns a lock error so one runtime owns the directory at a time. + + + + +### Select extensions + +Select exact SQL extension names. WASM extension artifacts are built from the +shared extension catalog but use WASM-owned recipes and PostgreSQL patches. + + + + +### Back up, dump, and restore + +Use SDK APIs for backup/restore and dump/restore flows. Prefer dump/restore when +moving data across runtimes or versions. + + + + + + +## Troubleshooting + +Check storage backend behavior, runtime asset availability, selected extension +artifacts, host capability errors, and SQLSTATE-bearing PostgreSQL errors. diff --git a/src/docs/content/sdk/wasm/index.mdx b/src/docs/content/sdk/wasm/index.mdx new file mode 100644 index 00000000..c452060f --- /dev/null +++ b/src/docs/content/sdk/wasm/index.mdx @@ -0,0 +1,73 @@ +--- +title: WASM SDK +description: First-class WASM/WASIX runtime family for WebAssembly hosts. +--- + + + +`oliphaunt-wasix` is the first-class WASM/WASIX runtime family. It shares public +concepts with native Oliphaunt packages while keeping its own runtime assets, +WASM packaging story, and WebAssembly-specific behavior. + +Use this SDK when the deployment target is a WASM/WASIX host or when a Rust app +wants the packaged WASM runtime. Native desktop and mobile apps use the +native SDKs when the native runtime is available. + +## Install + +Use the WASM package when your deployment target is WebAssembly/WASIX. +The package includes the SDK surface and the runtime assets required by +supported WASM hosts. + +For Rust hosts: + +```sh +cargo add oliphaunt-wasix +``` + +## Open And Query + +Open a local server-compatible runtime when you want to use existing PostgreSQL +clients: + +```rust +use oliphaunt_wasix::OliphauntServer; + +fn open_server() -> Result<(), Box> { + let server = OliphauntServer::builder() + .path("./app-data/main.oliphaunt") + .extension("vector") + .start()?; + + let url = server.database_url(); + server.shutdown()?; + Ok(()) +} +``` + +## Runtime Shape + +WASM mode is separate from native direct, native broker, and native server. It +has its own filesystem, startup, server/proxy, dump/restore, and extension +behavior. Use capabilities from the selected WASM runtime before enabling +advanced features in app UI. + +The WASM product can expose direct Rust APIs or a local PostgreSQL-compatible +URL depending on the host and configuration. It keeps runtime assets separate +from native `liboliphaunt` while sharing exact extension names and public +database concepts. + +## App Responsibilities + +- Select exact SQL extension names for the WASM runtime assets. +- Use the WASM runtime guide for filesystem, server/proxy, and startup behavior. +- Use dump and restore flows for portable data movement and upgrades. +- Check capabilities before enabling extension, streaming, server/proxy, or + backup features. + +## First Query + +Use [Build With WASM](/docs/sdk/wasm/guide) for the first query and runtime +configuration. Use [WASM runtime](/docs/sdk/wasm/runtime) for server/proxy and +asset behavior, and [dump and restore](/docs/sdk/wasm/dump-restore) for data +movement. diff --git a/src/docs/content/sdk/wasm/runtime.mdx b/src/docs/content/sdk/wasm/runtime.mdx new file mode 100644 index 00000000..51e4b258 --- /dev/null +++ b/src/docs/content/sdk/wasm/runtime.mdx @@ -0,0 +1,118 @@ +--- +title: WASIX Runtime Guide +description: Understand WASM runtime modes, persistence choices, root locking, startup, supported targets, and server compatibility. +--- + +# WASIX Runtime Guide + +`oliphaunt-wasix` is the WASM/WASIX runtime family. It shares Oliphaunt's public +concepts with native SDKs while using WASIX runtime assets, WASM host targets, +and WebAssembly-specific persistence behavior. + + + +## Choose A Mode + +Use `Oliphaunt` when your Rust code owns the database calls: + +- direct function and method calls; +- no socket listener; +- best fit for tests, commands, jobs, and app-owned Rust state. + +Use `OliphauntServer` when a library expects a PostgreSQL URI: + +- SQLx, Diesel, SeaORM, `tokio-postgres`, or cross-language clients; +- local TCP or Unix socket listener; +- compatibility with existing Postgres clients inside the selected WASM host. + +Both modes share the same embedded backend for a root. + +## Persistence Modes + +Direct and server builders expose the same root choices: + +- `path(...)` for a persistent database under an explicit directory; +- `app(...)` or `app_id(...)` for a persistent database under app data; +- `temporary()` for a cached temporary database; +- `fresh_temporary()` for a brand-new cluster path. + +Choose `temporary()` for most tests. Choose `fresh_temporary()` when the test +needs a brand-new cluster and can pay the slower startup path. + +## Operational Limits + +The WASM runtime model owns one embedded backend per open root: + +- one `Oliphaunt` instance owns one embedded backend; +- one `OliphauntServer` exposes one embedded backend; +- downstream client pools use one connection; +- server mode gives local PostgreSQL client compatibility inside the selected + WASM host. + +Generated server URLs include `sslmode=disable`. `CancelRequest` and normal +startup packets are supported. The server-compatible API still runs against the +embedded WASM backend for that root. + +## Root Locking And Lifecycle + +Persistent roots are locked while open. A second direct or server open against +the same root returns a lock error so one runtime owns the directory at a time. + +Close database clients before calling `OliphauntServer::shutdown()`. The server +thread waits for active client work to finish before exiting. + +Use `dump_data_dir()`, `load_data_dir_archive(...)`, or `try_clone()` for a +same-version physical clone. Use logical dumps through `pg_dump` for portable +exports and upgrades. + +## Startup And Preload + +The crate exposes two preload hooks: + +```rust,no_run +use oliphaunt_wasix::{extensions, Oliphaunt}; + +fn main() -> Result<(), Box> { + Oliphaunt::preload()?; + Oliphaunt::preload_extensions([extensions::VECTOR])?; + Ok(()) +} +``` + +Call them before a visible startup path when you want to warm the packaged +runtime and selected extension artifacts. + +Startup configuration belongs on the builders: + +- `postgres_config(...)` for PostgreSQL GUCs; +- `username(...)` and `database(...)` for the session target; +- `relaxed_durability(true)` for cacheable local workloads; +- `startup_arg(...)` for advanced startup arguments. + +## Supported Targets + +Default builds include packaged runtime assets and host artifacts for: + +- macOS arm64; +- Linux x64; +- Linux arm64; +- Windows x64. + +Read capabilities and package reports before enabling target-specific UI. A host +target needs matching packaged runtime assets for the WASM runtime to open. + +Browser, worker, and mobile app topics belong to their platform SDK pages. +`oliphaunt-wasix` is the Rust package for local embedded and desktop/server WASM +hosts. + +## Server-Compatible Access + +Reach for `OliphauntServer` when you need client-library compatibility: + +- SQLx migrations and query APIs; +- ORMs that expect a PostgreSQL URI; +- Python, Go, or Node clients in local app tests; +- local tools that already speak the Postgres wire protocol. + +Reach for `Oliphaunt` when you control the Rust call site. It avoids the socket +layer and keeps the API surface smaller. diff --git a/src/docs/content/start/index.mdx b/src/docs/content/start/index.mdx new file mode 100644 index 00000000..482b76d8 --- /dev/null +++ b/src/docs/content/start/index.mdx @@ -0,0 +1,49 @@ +--- +sidebar_position: 1 +title: Start With Oliphaunt +description: Choose an Oliphaunt SDK, open an embedded PostgreSQL root, and run the first query. +--- + +Oliphaunt is embedded PostgreSQL for apps. Install the SDK for your app target, +open a PostgreSQL root inside app-owned storage, run SQL, and select only the +extensions your app ships. + +This page is the shortest tutorial path. Pick one app target, prove the runtime +with one query, then move into the SDK, runtime, extension, or storage page that +matches the next decision. + + + +## Start In One App Target + + + +## First Query Shape + +Every SDK uses ecosystem-native syntax. The application flow stays the same: +choose storage, select a runtime mode, select exact extensions, open, query, +and close or detach through the SDK lifecycle. + + + +Use the SDK page for your app target when you need platform setup, native build +consequences, lifecycle hooks, extension artifacts, backup, restore, and +troubleshooting. + +## Install, Configure, Verify + +Keep these three checks close to your first integration because they confirm the +SDK, package artifacts, and selected runtime all agree: + + + +React Native apps verify both JavaScript transport and native platform +delegation. iOS/macOS verification flows through Swift; Android verification +flows through Kotlin. + +## After The First Query + +Use these pages when the first runtime path works and the app needs the next +production decision. + + diff --git a/src/docs/docs-manifest.toml b/src/docs/docs-manifest.toml new file mode 100644 index 00000000..003a012f --- /dev/null +++ b/src/docs/docs-manifest.toml @@ -0,0 +1,323 @@ +schema_version = 1 +generated_root = "target/docs" +site_docs_root = "target/docs/site-docs" +static_root = "target/docs/static" + +[api_reference] +generated_root = "target/docs/generated/api" +summary = "target/docs/generated/api/summary.json" +strict_env = "OLIPHAUNT_DOCS_REQUIRE_NATIVE_API" + +[api_reference.c] +header = "src/runtimes/liboliphaunt/native/include/oliphaunt.h" +doxygen_config = "src/docs/reference/doxygen/Doxyfile" + +[api_reference.rust] +package = "oliphaunt" +docs_entry = "target/docs/generated/api/rust/doc/oliphaunt/index.html" + +[api_reference.swift] +package_path = "src/sdks/swift" +target = "Oliphaunt" +docs_entry = "target/docs/generated/api/swift/Oliphaunt.doccarchive" + +[api_reference.kotlin] +project_path = "src/sdks/kotlin" +task = ":oliphaunt:dokkaGeneratePublicationHtml" +docs_entry = "target/docs/generated/api/kotlin/html/index.html" + +[api_reference.typescript] +package_path = "src/sdks/js" +docs_entry = "target/docs/generated/api/typescript/html/index.html" + +[api_reference.react_native] +package_path = "src/sdks/react-native" +docs_entry = "target/docs/generated/api/react-native/html/index.html" + +[api_reference.wasm] +package = "oliphaunt-wasix" +docs_entry = "target/docs/generated/api/wasm/doc/oliphaunt_wasix/index.html" + +[[routes]] +id = "start" +title = "Start" +kind = "public" +route = "start" +source = "src/docs/content/start" +public = true +version_source = "current" +reference_kind = "none" +tested_snippet_path = "" +tested_snippet_marker = "" +page_order = ["index"] + +[[routes]] +id = "sdk" +title = "SDKs" +kind = "public" +route = "sdk" +source = "src/docs/content/sdk" +public = true +version_source = "current" +reference_kind = "none" +tested_snippet_path = "" +tested_snippet_marker = "" +page_order = ["index"] + +[[routes]] +id = "learn" +title = "Learn" +kind = "public" +route = "learn" +source = "src/docs/content/learn" +public = true +version_source = "current" +reference_kind = "none" +tested_snippet_path = "" +tested_snippet_marker = "" +page_order = [ + "index", + "embedded-postgres", + "native-runtime", + "mobile-stability", + "sqlite-upgrade", + "tauri", +] + +[[routes]] +id = "reference" +title = "Reference" +kind = "public" +route = "reference" +source = "src/docs/content/reference" +public = true +version_source = "current" +reference_kind = "none" +tested_snippet_path = "" +tested_snippet_marker = "" +page_order = [ + "index", + "sdk-products", + "capabilities", + "extensions", + "performance", + "releases", + "version-matrix", + "extension-catalog", + "api-reference", +] +sidebar_pages = [ + "index", + "capabilities", + "extensions", + "performance", +] + +[[routes]] +id = "oliphaunt-rust" +product_id = "oliphaunt-rust" +title = "Rust SDK" +kind = "sdk" +route = "sdk/rust" +source = "src/docs/content/sdk/rust" +public = true +version_source = "src/sdks/rust/release.toml:id" +reference_kind = "rustdoc" +tested_snippet_path = "src/sdks/rust/tests/sdk_shape.rs" +tested_snippet_marker = "OLIPHAUNT_DOCS_SNIPPET rust-quickstart" +required_pages = [ + "index", + "guide", + "api-reference", +] +page_order = [ + "index", + "guide", + "api-reference", +] +sidebar_pages = [ + "index", + "guide", +] +reference_artifact = "target/docs/generated/api/rust/doc/oliphaunt/index.html" +snippet_language = "rust" + +[[routes]] +id = "oliphaunt-swift" +product_id = "oliphaunt-swift" +title = "Swift SDK" +kind = "sdk" +route = "sdk/swift" +source = "src/docs/content/sdk/swift" +public = true +version_source = "src/sdks/swift/release.toml:id" +reference_kind = "swift-docc" +tested_snippet_path = "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift" +tested_snippet_marker = "OLIPHAUNT_DOCS_SNIPPET swift-quickstart" +required_pages = [ + "index", + "guide", + "api-reference", +] +page_order = [ + "index", + "guide", + "api-reference", +] +sidebar_pages = [ + "index", + "guide", +] +reference_artifact = "target/docs/generated/api/swift/Oliphaunt.doccarchive" +snippet_language = "swift" + +[[routes]] +id = "oliphaunt-kotlin" +product_id = "oliphaunt-kotlin" +title = "Kotlin SDK" +kind = "sdk" +route = "sdk/kotlin" +source = "src/docs/content/sdk/kotlin" +public = true +version_source = "src/sdks/kotlin/release.toml:id" +reference_kind = "dokka" +tested_snippet_path = "src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt" +tested_snippet_marker = "liboliphaunt-doc-example:kotlin-typed-query" +required_pages = [ + "index", + "guide", + "api-reference", +] +page_order = [ + "index", + "guide", + "api-reference", +] +sidebar_pages = [ + "index", + "guide", +] +reference_artifact = "target/docs/generated/api/kotlin/html/index.html" +snippet_language = "kotlin" + +[[routes]] +id = "oliphaunt-react-native" +product_id = "oliphaunt-react-native" +title = "React Native SDK" +kind = "sdk" +route = "sdk/react-native" +source = "src/docs/content/sdk/react-native" +public = true +version_source = "src/sdks/react-native/release.toml:id" +reference_kind = "typedoc" +tested_snippet_path = "src/sdks/react-native/src/__tests__/client.test.ts" +tested_snippet_marker = "OLIPHAUNT_DOCS_SNIPPET react-native-quickstart" +required_pages = [ + "index", + "guide", + "api-reference", +] +page_order = [ + "index", + "guide", + "architecture", + "api-reference", +] +sidebar_pages = [ + "index", + "guide", + "architecture", +] +reference_artifact = "target/docs/generated/api/react-native/html/index.html" +snippet_language = "typescript" + +[[routes]] +id = "oliphaunt-js" +product_id = "oliphaunt-js" +title = "TypeScript SDK" +kind = "sdk" +route = "sdk/typescript" +source = "src/docs/content/sdk/typescript" +public = true +version_source = "src/sdks/js/release.toml:id" +reference_kind = "typedoc" +tested_snippet_path = "src/sdks/js/src/__tests__/client.test.ts" +tested_snippet_marker = "OLIPHAUNT_DOCS_SNIPPET typescript-quickstart" +required_pages = [ + "index", + "guide", + "api-reference", +] +page_order = [ + "index", + "guide", + "api-reference", +] +sidebar_pages = [ + "index", + "guide", +] +reference_artifact = "target/docs/generated/api/typescript/html/index.html" +snippet_language = "typescript" + +[[routes]] +id = "oliphaunt-wasix" +product_id = "oliphaunt-wasix-rust" +title = "WASM SDK" +kind = "sdk" +route = "sdk/wasm" +source = "src/docs/content/sdk/wasm" +public = true +version_source = "src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml:id" +reference_kind = "rustdoc" +tested_snippet_path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/runtime_smoke.rs" +tested_snippet_marker = "OLIPHAUNT_DOCS_SNIPPET wasm-quickstart" +required_pages = [ + "index", + "guide", + "api-reference", +] +page_order = [ + "index", + "guide", + "runtime", + "dump-restore", + "api-reference", +] +sidebar_pages = [ + "index", + "guide", + "runtime", + "dump-restore", +] +reference_artifact = "target/docs/generated/api/wasm/doc/oliphaunt_wasix/index.html" +snippet_language = "rust" + +[[routes]] +id = "liboliphaunt-native" +product_id = "liboliphaunt-native" +title = "C ABI" +kind = "sdk" +route = "sdk/c-abi" +source = "src/docs/content/sdk/c-abi" +public = true +version_source = "src/runtimes/liboliphaunt/native/release.toml:id" +reference_kind = "doxygen" +tested_snippet_path = "src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c" +tested_snippet_marker = "OLIPHAUNT_DOCS_SNIPPET liboliphaunt-quickstart" +required_pages = [ + "index", + "guide", + "api-reference", +] +page_order = [ + "index", + "guide", + "api-reference", +] +sidebar_pages = [ + "index", + "guide", +] +reference_artifact = "target/docs/generated/api/c/xml" +snippet_language = "c" diff --git a/src/docs/moon.yml b/src/docs/moon.yml new file mode 100644 index 00000000..aa25d21d --- /dev/null +++ b/src/docs/moon.yml @@ -0,0 +1,162 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "docs" +language: "typescript" +layer: "application" +stack: "frontend" +tags: ["docs", "fumadocs", "next", "public"] +dependsOn: + - id: "postgres18" + scope: "build" + - id: "source-toolchains" + scope: "build" + - id: "third-party-shared" + scope: "build" + - id: "third-party-native" + scope: "build" + - id: "third-party-wasix" + scope: "build" + - id: "extensions" + scope: "build" + - id: "liboliphaunt-native" + scope: "build" + - id: "liboliphaunt-wasix" + scope: "build" + - id: "oliphaunt-rust" + scope: "build" + - id: "oliphaunt-swift" + scope: "build" + - id: "oliphaunt-kotlin" + scope: "build" + - id: "oliphaunt-react-native" + scope: "build" + - id: "oliphaunt-js" + scope: "build" + - id: "oliphaunt-wasix-rust" + scope: "build" + +project: + title: "Oliphaunt Docs" + description: "Public documentation site, generated SDK matrices, tested snippets, and release-readiness docs gates." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/docs" + paths: + "**/*.md": ["@oliphaunt/docs"] + "**/*.ts": ["@oliphaunt/docs"] + "**/*.tsx": ["@oliphaunt/docs"] + "docs-manifest.toml": ["@oliphaunt/docs"] + +tasks: + dev: + tags: ["dev"] + command: "pnpm --dir src/docs run dev" + inputs: + - "/docs/**/*" + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/**/release.toml" + - "/src/docs/**/*" + - "/src/extensions/**/*" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + check: + tags: ["quality", "static"] + command: "pnpm --dir src/docs run check" + inputs: + - "/README.md" + - "/.release-please-manifest.json" + - "/docs/**/*" + - "/release-please-config.json" + - "/src/*/README.md" + - "/src/*/typedoc.json" + - "/src/**/release.toml" + - "/src/docs/**/*" + - "/src/extensions/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "unit"] + command: "pnpm --dir src/docs run test" + deps: + - "docs:check" + inputs: + - "/src/*/typedoc.json" + - "/src/docs/**/*" + - "/src/runtimes/liboliphaunt/native/smoke/**/*" + - "/src/sdks/rust/tests/**/*" + - "/src/sdks/swift/Tests/**/*" + - "/src/sdks/kotlin/oliphaunt/src/**/*Test*/**/*" + - "/src/sdks/react-native/src/**/*" + - "/src/sdks/js/src/**/*" + - "/src/bindings/wasix-rust/crates/oliphaunt-wasix/tests/**/*" + - "/pnpm-lock.yaml" + options: + cache: true + runFromWorkspaceRoot: true + build: + tags: ["build", "package"] + command: "pnpm --dir src/docs run build" + deps: + - "docs:test" + inputs: + - "/docs/**/*" + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/**/release.toml" + - "/src/docs/**/*" + - "/src/extensions/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + outputs: + - "/target/docs/build/**/*" + options: + cache: local + runFromWorkspaceRoot: true + smoke: + tags: ["runtime", "smoke"] + command: "pnpm --dir src/docs run smoke" + deps: + - target: "docs:build" + cacheStrategy: "outputs" + inputs: + - "/target/docs/build/**/*" + - "/target/docs/static/**/*" + - "/src/docs/tools/smoke-built-site.mjs" + outputs: + - "/target/docs/build/**/*" + options: + cache: local + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "pnpm --dir src/docs run release-check" + deps: + - target: "docs:build" + cacheStrategy: "outputs" + inputs: + - "/README.md" + - "/.release-please-manifest.json" + - "/docs/**/*" + - "/release-please-config.json" + - "/src/*/CHANGELOG.md" + - "/src/*/typedoc.json" + - "/src/**/release.toml" + - "/src/docs/**/*" + - "/src/extensions/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: local + runFromWorkspaceRoot: true diff --git a/src/docs/next.config.mjs b/src/docs/next.config.mjs new file mode 100644 index 00000000..c70e9bb5 --- /dev/null +++ b/src/docs/next.config.mjs @@ -0,0 +1,16 @@ +import { createMDX } from 'fumadocs-mdx/next'; + +const withMDX = createMDX(); + +/** @type {import('next').NextConfig} */ +const config = { + output: 'export', + reactStrictMode: true, + trailingSlash: true, + images: { + unoptimized: true, + }, + basePath: process.env.OLIPHAUNT_DOCS_BASE_PATH || undefined, +}; + +export default withMDX(config); diff --git a/src/docs/package.json b/src/docs/package.json new file mode 100644 index 00000000..c06260d0 --- /dev/null +++ b/src/docs/package.json @@ -0,0 +1,47 @@ +{ + "name": "@oliphaunt/docs", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "node tools/run-docs-task.mjs generate && next dev --hostname 127.0.0.1", + "generate": "node tools/run-docs-task.mjs generate", + "api-reference": "node tools/generate-api-reference.mjs --mode=release", + "api-reference:check": "pnpm run api-reference && node tools/check-docs-product.mjs --api-reference", + "check": "node tools/run-docs-task.mjs check", + "test": "node tools/run-docs-task.mjs test", + "build": "node tools/run-docs-task.mjs build", + "smoke": "node tools/smoke-built-site.mjs", + "release-check": "node tools/run-docs-task.mjs release-check", + "start": "next start", + "types:check": "node tools/run-docs-task.mjs check", + "postinstall": "fumadocs-mdx", + "lint": "biome check", + "format": "biome format --write" + }, + "dependencies": { + "@mdx-js/react": "^3.1.0", + "@oliphaunt/react-native": "workspace:*", + "@oliphaunt/ts": "workspace:*", + "clsx": "^2.1.1", + "fumadocs-core": "16.9.3", + "fumadocs-mdx": "15.0.10", + "fumadocs-ui": "16.9.3", + "lucide-react": "^1.17.0", + "next": "16.2.7", + "react": "19.2.7", + "react-dom": "19.2.7", + "smol-toml": "^1.4.2", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/mdx": "^2.0.13", + "@types/node": "22.19.19", + "@types/react": "19.2.16", + "@types/react-dom": "^19.2.3", + "postcss": "^8.5.15", + "tailwindcss": "4.3.0", + "typescript": "^5.9.3", + "@biomejs/biome": "^2.4.16" + } +} diff --git a/src/docs/postcss.config.mjs b/src/docs/postcss.config.mjs new file mode 100644 index 00000000..297374d8 --- /dev/null +++ b/src/docs/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/src/docs/proxy.ts b/src/docs/proxy.ts new file mode 100644 index 00000000..22426816 --- /dev/null +++ b/src/docs/proxy.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation'; +import { docsContentRoute, docsRoute } from '@/lib/shared'; + +const { rewrite: rewriteDocs } = rewritePath( + `${docsRoute}{/*path}`, + `${docsContentRoute}{/*path}/content.md`, +); +const { rewrite: rewriteSuffix } = rewritePath( + `${docsRoute}{/*path}.md`, + `${docsContentRoute}{/*path}/content.md`, +); + +export default function proxy(request: NextRequest) { + const result = rewriteSuffix(request.nextUrl.pathname); + if (result) { + return NextResponse.rewrite(new URL(result, request.nextUrl.href)); + } + + if (isMarkdownPreferred(request)) { + const result = rewriteDocs(request.nextUrl.pathname); + + if (result) { + return NextResponse.rewrite(new URL(result, request.nextUrl.href)); + } + } + + return NextResponse.next(); +} diff --git a/src/docs/reference/doxygen/Doxyfile b/src/docs/reference/doxygen/Doxyfile new file mode 100644 index 00000000..54ba382a --- /dev/null +++ b/src/docs/reference/doxygen/Doxyfile @@ -0,0 +1,15 @@ +PROJECT_NAME = "Oliphaunt C ABI" +OUTPUT_DIRECTORY = target/docs/generated/api/c/doxygen +INPUT = src/runtimes/liboliphaunt/native/include/oliphaunt.h +FILE_PATTERNS = *.h +RECURSIVE = NO +GENERATE_HTML = NO +GENERATE_LATEX = NO +GENERATE_XML = YES +XML_OUTPUT = xml +QUIET = YES +WARN_IF_UNDOCUMENTED = NO +EXTRACT_ALL = YES +EXTRACT_STATIC = NO +MACRO_EXPANSION = YES +PREDEFINED = __cplusplus= diff --git a/src/docs/source.config.ts b/src/docs/source.config.ts new file mode 100644 index 00000000..d5990627 --- /dev/null +++ b/src/docs/source.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; +import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; +import indexFile from 'fumadocs-mdx/plugins/index-file'; + +export const docs = defineDocs({ + dir: '../../target/docs/site-docs', + docs: { + schema: pageSchema, + postprocess: { + includeProcessedMarkdown: true, + }, + }, + meta: { + schema: metaSchema, + }, +}); + +export default defineConfig({ + plugins: [indexFile()], + mdxOptions: { + // Keep MDX options centralized here; generated docs stay in target/. + }, +}); diff --git a/src/docs/src/app/(home)/layout.tsx b/src/docs/src/app/(home)/layout.tsx new file mode 100644 index 00000000..77379fac --- /dev/null +++ b/src/docs/src/app/(home)/layout.tsx @@ -0,0 +1,6 @@ +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/'>) { + return {children}; +} diff --git a/src/docs/src/app/(home)/page.tsx b/src/docs/src/app/(home)/page.tsx new file mode 100644 index 00000000..4822ce2b --- /dev/null +++ b/src/docs/src/app/(home)/page.tsx @@ -0,0 +1,373 @@ +import { + ArrowRight, + CheckCircle2, + Database, + GitBranch, + PackageCheck, + Route, + SearchCheck, + TerminalSquare, +} from 'lucide-react'; +import Link from 'next/link'; +import { productPillars, runtimeModes, sdkSurfaces } from '@/lib/docs-data'; + +const readerPaths = [ + { + label: 'Tutorial', + title: 'Run the first query', + href: '/docs/start', + description: 'Choose a platform SDK, open a root, run `SELECT 1`, and verify the runtime.', + icon: Route, + }, + { + label: 'How-to guides', + title: 'Build in your ecosystem', + href: '/docs/sdk', + description: 'Use Rust, Swift, Kotlin, React Native, TypeScript, WASM, or the C ABI.', + icon: PackageCheck, + }, + { + label: 'Explanation', + title: 'Understand runtime shape', + href: '/docs/learn/native-runtime', + description: 'Compare direct, broker, server, roots, lifecycle, capabilities, and backup behavior.', + icon: Database, + }, + { + label: 'Reference', + title: 'Look up exact support', + href: '/docs/reference', + description: 'Check modes, platform support, exact extensions, releases, and performance results.', + icon: SearchCheck, + }, +]; + +const integrationPaths = [ + { + title: 'Rust / Tauri', + packageName: 'oliphaunt', + install: 'cargo add oliphaunt', + verify: 'Direct, broker, or server mode', + href: '/docs/sdk/rust', + }, + { + title: 'Swift / Apple', + packageName: 'Oliphaunt', + install: 'Add package in Xcode', + verify: 'App storage and lifecycle', + href: '/docs/sdk/swift', + }, + { + title: 'Kotlin / Android', + packageName: 'dev.oliphaunt:oliphaunt', + install: 'id("dev.oliphaunt.android") + implementation("dev.oliphaunt:oliphaunt")', + verify: 'Gradle assets and ABI artifacts', + href: '/docs/sdk/kotlin', + }, + { + title: 'React Native', + packageName: '@oliphaunt/react-native', + install: 'npx expo install @oliphaunt/react-native', + verify: 'Development build and JSI bytes', + href: '/docs/sdk/react-native', + }, + { + title: 'TypeScript / Desktop JS', + packageName: '@oliphaunt/ts', + install: 'npm install @oliphaunt/ts', + verify: 'Helper assets and broker/server modes', + href: '/docs/sdk/typescript', + }, + { + title: 'WASM / WASIX', + packageName: 'oliphaunt-wasix', + install: 'cargo add oliphaunt-wasix', + verify: 'WASIX assets and dump/restore', + href: '/docs/sdk/wasm', + }, +]; + +export default function HomePage() { + return ( +
+
+
+
+

+ + Embedded PostgreSQL for app developers +

+

+ Oliphaunt brings PostgreSQL into app-owned storage. +

+

+ Build local-first desktop, mobile, React Native, TypeScript, and + WASM apps with PostgreSQL behavior, explicit runtime modes, exact + extension packaging, and SDK-owned backup and restore. +

+
+ + Start with the docs + + + + Choose an SDK + +
+
+ +
+
+
+ + First app paths +
+ + same database flow, target-native package + +
+
+ {integrationPaths.map((path) => ( + +
+
+

{path.title}

+ + {path.packageName} + +
+ +
+
+
+

+ Install +

+ + {path.install} + +
+
+

+ Verify +

+

+ {path.verify} +

+
+
+ + ))} +
+
+ {['Read capabilities', 'Use app-owned storage', 'Ship selected extensions'].map( + (item) => ( +
+ + {item} +
+ ), + )} +
+
+
+
+

Root

+ main.oliphaunt +
+
+

Runtime

+ nativeDirect +
+
+

+ First query +

+ SELECT 1 +
+
+

+ Building a new language binding? Start with the{' '} + + C ABI + + . +

+
+
+
+
+ +
+
+
+

Docs path

+

Use the docs by the job in front of you

+

+ Start with the shortest query path, then move into platform guides, + runtime model pages, and exact lookup pages as the app integration grows. +

+
+
+ {readerPaths.map((path) => { + const Icon = path.icon; + + return ( + +
+ + +
+

+ {path.label} +

+

{path.title}

+

+ {path.description} +

+ + ); + })} +
+
+
+ +
+
+
+

SDKs

+

Start from the app you ship

+

+ Each SDK uses the package manager, concurrency model, lifecycle, and + packaging rules developers already expect on that platform. +

+
+ + Compare SDKs + + +
+
+ {sdkSurfaces.map((sdk) => { + const Icon = sdk.icon; + + return ( + +
+ + +
+
+

{sdk.title}

+ + {sdk.packageName} + +
+

{sdk.target}

+

{sdk.startWith}

+ + {sdk.install} + +

{sdk.owns}

+
+

+ Verify first +

+

+ {sdk.verifyFirst} +

+
+ + ); + })} +
+
+ +
+
+ {productPillars.map((pillar) => { + const Icon = pillar.icon; + + return ( +
+ +

{pillar.title}

+

+ {pillar.description} +

+
+ ); + })} +
+
+ +
+
+

Runtime model

+

Use the right PostgreSQL shape

+

+ Oliphaunt makes runtime boundaries explicit. Pick the mode with the + concurrency, isolation, and compatibility your application uses. +

+
+
+ {runtimeModes.map((mode) => ( + +
+ {mode.name} +

{mode.label}

+
+

{mode.useWhen}

+ + ))} +
+
+ +
+
+
+

Pick a platform path

+

+ Start with the SDK page for your platform, then verify capabilities on the target. +

+
+ + Open Start + + +
+
+
+ ); +} diff --git a/src/docs/src/app/api/search/route.ts b/src/docs/src/app/api/search/route.ts new file mode 100644 index 00000000..92631c74 --- /dev/null +++ b/src/docs/src/app/api/search/route.ts @@ -0,0 +1,9 @@ +import { source } from '@/lib/source'; +import { createFromSource } from 'fumadocs-core/search/server'; + +export const dynamic = 'force-static'; + +export const { GET } = createFromSource(source, { + // https://docs.orama.com/docs/orama-js/supported-languages + language: 'english', +}); diff --git a/src/docs/src/app/docs/[[...slug]]/page.tsx b/src/docs/src/app/docs/[[...slug]]/page.tsx new file mode 100644 index 00000000..7b48db42 --- /dev/null +++ b/src/docs/src/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,63 @@ +import { getPageImage, getPageMarkdownUrl, source } from '@/lib/source'; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, + MarkdownCopyButton, + ViewOptionsPopover, +} from 'fumadocs-ui/layouts/docs/page'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/components/mdx'; +import type { Metadata } from 'next'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; + +function pageSlug(slug?: string[]) { + return slug && slug.length > 0 ? slug : ['start']; +} + +export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { + const params = await props.params; + const page = source.getPage(pageSlug(params.slug)); + if (!page) notFound(); + + const MDX = page.data.body; + const markdownUrl = getPageMarkdownUrl(page).url; + + return ( + + {page.data.title} + {page.data.description} +
+ + +
+ + + +
+ ); +} + +export async function generateStaticParams() { + return [{ slug: [] }, ...source.generateParams()]; +} + +export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise { + const params = await props.params; + const page = source.getPage(pageSlug(params.slug)); + if (!page) notFound(); + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + images: getPageImage(page).url, + }, + }; +} diff --git a/src/docs/src/app/docs/layout.tsx b/src/docs/src/app/docs/layout.tsx new file mode 100644 index 00000000..ea89ac8a --- /dev/null +++ b/src/docs/src/app/docs/layout.tsx @@ -0,0 +1,7 @@ +import { source } from '@/lib/source'; +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/docs'>) { + return {children}; +} diff --git a/src/docs/src/app/global.css b/src/docs/src/app/global.css new file mode 100644 index 00000000..4abd6f11 --- /dev/null +++ b/src/docs/src/app/global.css @@ -0,0 +1,211 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; + +@theme { + --font-sans: var(--font-oliphaunt-sans); + --font-mono: var(--font-oliphaunt-mono); + --color-fd-background: hsl(210, 24%, 98%); + --color-fd-foreground: hsl(218, 28%, 12%); + --color-fd-card: hsl(0, 0%, 100%); + --color-fd-card-foreground: hsl(218, 28%, 12%); + --color-fd-popover: hsl(0, 0%, 100%); + --color-fd-popover-foreground: hsl(218, 28%, 12%); + --color-fd-primary: hsl(162, 72%, 29%); + --color-fd-primary-foreground: hsl(0, 0%, 100%); + --color-fd-secondary: hsl(212, 22%, 94%); + --color-fd-secondary-foreground: hsl(218, 28%, 14%); + --color-fd-muted: hsl(210, 24%, 95%); + --color-fd-muted-foreground: hsl(218, 14%, 42%); + --color-fd-accent: hsl(204, 34%, 91%); + --color-fd-accent-foreground: hsl(218, 28%, 12%); + --color-fd-border: hsla(214, 20%, 72%, 0.55); + --color-fd-ring: hsl(162, 72%, 29%); +} + +.dark { + --color-fd-background: hsl(218, 18%, 7%); + --color-fd-foreground: hsl(45, 20%, 92%); + --color-fd-card: hsl(218, 16%, 10%); + --color-fd-card-foreground: hsl(45, 20%, 92%); + --color-fd-popover: hsl(218, 16%, 9%); + --color-fd-popover-foreground: hsl(45, 20%, 92%); + --color-fd-primary: hsl(158, 66%, 58%); + --color-fd-primary-foreground: hsl(218, 26%, 8%); + --color-fd-secondary: hsl(217, 13%, 16%); + --color-fd-secondary-foreground: hsl(45, 20%, 92%); + --color-fd-muted: hsl(217, 13%, 14%); + --color-fd-muted-foreground: hsl(213, 11%, 68%); + --color-fd-accent: hsl(37, 20%, 18%); + --color-fd-accent-foreground: hsl(45, 22%, 92%); + --color-fd-border: hsla(212, 16%, 46%, 0.35); + --color-fd-ring: hsl(158, 66%, 58%); +} + +html { + scrollbar-gutter: stable; +} + +body { + font-family: var(--font-oliphaunt-sans), ui-sans-serif, system-ui, sans-serif; + background: + linear-gradient( + 180deg, + color-mix(in oklab, var(--color-fd-background), var(--color-fd-accent) 16%) 0, + var(--color-fd-background) 340px + ), + var(--color-fd-background); + color: var(--color-fd-foreground); + font-feature-settings: + 'ss01' 1, + 'cv10' 1; + letter-spacing: 0; +} + +* { + box-sizing: border-box; + letter-spacing: 0 !important; +} + +*::before, +*::after { + box-sizing: border-box; +} + +code, +kbd, +pre, +samp { + font-family: var(--font-oliphaunt-mono), ui-monospace, SFMono-Regular, Menlo, monospace; +} + +html > body[data-scroll-locked] { + margin-right: 0px !important; + --removed-body-scroll-bar-size: 0px !important; +} + +#nd-sidebar { + background: + linear-gradient( + 180deg, + color-mix(in oklab, var(--color-fd-card), transparent 0%), + var(--color-fd-background) + ), + var(--color-fd-card); +} + +#nd-sidebar a, +#nd-sidebar button { + border-radius: 8px; +} + +[data-card], +.rounded-xl { + border-radius: 8px; +} + +.prose table { + font-size: 0.925rem; +} + +.prose .not-prose { + max-width: 100%; +} + +.prose .not-prose :where(a, div, pre, code) { + min-width: 0; +} + +.prose h2 { + margin-top: 2.2rem; + scroll-margin-top: 6rem; +} + +.prose h3 { + scroll-margin-top: 6rem; +} + +.prose a { + text-decoration-thickness: 0.08em; + text-underline-offset: 0.18em; +} + +.prose th { + background: var(--color-fd-muted); + color: var(--color-fd-foreground); + font-weight: 600; +} + +.prose td, +.prose th { + vertical-align: top; +} + +.prose :where(table) { + overflow-wrap: normal; +} + +@media (max-width: 640px) { + .prose { + overflow-wrap: anywhere; + } + + .prose table { + display: block; + max-width: 100%; + overflow-x: auto; + white-space: nowrap; + } +} + +.prose pre { + border: 1px solid var(--color-fd-border); + border-radius: 8px; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--color-fd-card), transparent 85%); +} + +.prose code:not(pre code) { + border: 1px solid color-mix(in oklab, var(--color-fd-border), transparent 25%); + border-radius: 5px; + background: color-mix(in oklab, var(--color-fd-muted), transparent 25%); + padding: 0.08rem 0.28rem; + font-size: 0.88em; +} + +.oliphaunt-hero { + background: + linear-gradient( + 90deg, + color-mix(in oklab, var(--color-fd-primary), transparent 92%) 1px, + transparent 1px + ), + linear-gradient( + 180deg, + color-mix(in oklab, var(--color-fd-primary), transparent 94%) 1px, + transparent 1px + ), + linear-gradient( + 180deg, + color-mix(in oklab, var(--color-fd-card), var(--color-fd-accent) 24%), + var(--color-fd-background) + ); + background-size: + 44px 44px, + 44px 44px, + auto; +} + +.oliphaunt-panel { + box-shadow: + 0 1px 0 color-mix(in oklab, var(--color-fd-card), transparent 80%) inset, + 0 18px 50px color-mix(in oklab, var(--color-fd-foreground), transparent 93%); +} + +.oliphaunt-section-kicker { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--color-fd-muted-foreground); + font-size: 0.8125rem; + font-weight: 500; +} diff --git a/src/docs/src/app/layout.tsx b/src/docs/src/app/layout.tsx new file mode 100644 index 00000000..8d224154 --- /dev/null +++ b/src/docs/src/app/layout.tsx @@ -0,0 +1,44 @@ +import { RootProvider } from 'fumadocs-ui/provider/next'; +import './global.css'; +import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google'; +import type { Metadata } from 'next'; + +const plexSans = IBM_Plex_Sans({ + subsets: ['latin'], + weight: ['400', '500', '600', '700'], + variable: '--font-oliphaunt-sans', +}); + +const plexMono = IBM_Plex_Mono({ + subsets: ['latin'], + weight: ['400', '500', '600'], + variable: '--font-oliphaunt-mono', +}); + +export const metadata: Metadata = { + metadataBase: new URL(process.env.NEXT_PUBLIC_OLIPHAUNT_DOCS_URL ?? 'https://oliphaunt.dev'), + title: { + default: 'Oliphaunt Docs', + template: '%s | Oliphaunt', + }, + description: + 'Embedded PostgreSQL SDKs for Rust, Swift, Kotlin, React Native, TypeScript, and WASM apps.', + icons: { + icon: [{ url: '/img/favicon.svg', type: 'image/svg+xml' }], + shortcut: '/img/favicon.svg', + }, +}; + +export default function Layout({ children }: LayoutProps<'/'>) { + return ( + + + {children} + + + ); +} diff --git a/src/docs/src/app/llms-full.txt/route.ts b/src/docs/src/app/llms-full.txt/route.ts new file mode 100644 index 00000000..d494d2cb --- /dev/null +++ b/src/docs/src/app/llms-full.txt/route.ts @@ -0,0 +1,10 @@ +import { getLLMText, source } from '@/lib/source'; + +export const revalidate = false; + +export async function GET() { + const scan = source.getPages().map(getLLMText); + const scanned = await Promise.all(scan); + + return new Response(scanned.join('\n\n')); +} diff --git a/src/docs/src/app/llms.mdx/docs/[[...slug]]/route.ts b/src/docs/src/app/llms.mdx/docs/[[...slug]]/route.ts new file mode 100644 index 00000000..250181a7 --- /dev/null +++ b/src/docs/src/app/llms.mdx/docs/[[...slug]]/route.ts @@ -0,0 +1,23 @@ +import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) { + const { slug } = await params; + const page = source.getPage(slug?.slice(0, -1)); + if (!page) notFound(); + + return new Response(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + lang: page.locale, + slug: getPageMarkdownUrl(page).segments, + })); +} diff --git a/src/docs/src/app/llms.txt/route.ts b/src/docs/src/app/llms.txt/route.ts new file mode 100644 index 00000000..fc80cb65 --- /dev/null +++ b/src/docs/src/app/llms.txt/route.ts @@ -0,0 +1,8 @@ +import { source } from '@/lib/source'; +import { llms } from 'fumadocs-core/source'; + +export const revalidate = false; + +export function GET() { + return new Response(llms(source).index()); +} diff --git a/src/docs/src/app/og/docs/[...slug]/route.tsx b/src/docs/src/app/og/docs/[...slug]/route.tsx new file mode 100644 index 00000000..877166d3 --- /dev/null +++ b/src/docs/src/app/og/docs/[...slug]/route.tsx @@ -0,0 +1,28 @@ +import { getPageImage, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { ImageResponse } from 'next/og'; +import { generate as DefaultImage } from 'fumadocs-ui/og'; +import { appName } from '@/lib/shared'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + + return new ImageResponse( + , + { + width: 1200, + height: 630, + }, + ); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + lang: page.locale, + slug: getPageImage(page).segments, + })); +} diff --git a/src/docs/src/components/mdx.tsx b/src/docs/src/components/mdx.tsx new file mode 100644 index 00000000..0a11907e --- /dev/null +++ b/src/docs/src/components/mdx.tsx @@ -0,0 +1,84 @@ +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import { Callout } from 'fumadocs-ui/components/callout'; +import { Card, Cards } from 'fumadocs-ui/components/card'; +import { File, Files, Folder } from 'fumadocs-ui/components/files'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import type { MDXComponents } from 'mdx/types'; +import { + CapabilitySnapshot, + EmbeddedPostgresModel, + ExactExtensionRule, + ExtensionArtifactFlow, + FirstQueryFlow, + LearnRouteMap, + MobileStabilityContract, + ModeMatrix, + PerformanceResultsGrid, + QuickstartPath, + ReactNativeApproachTable, + ReactNativeBoundaryMap, + ReferenceLookup, + ReleaseLookup, + SdkGuideSummary, + SdkGuideProof, + SdkLanding, + SdkChooser, + ShipChecklist, + SqliteMigrationMap, + StartNextSteps, + StartOutcome, + TauriAppPattern, + VerifyChecklist, + WasmDataMovement, + WasmRuntimeMap, +} from './oliphaunt'; + +export function getMDXComponents(components?: MDXComponents) { + return { + ...defaultMdxComponents, + Callout, + Card, + Cards, + CapabilitySnapshot, + EmbeddedPostgresModel, + File, + Files, + Folder, + Step, + Steps, + Tab, + Tabs, + ExactExtensionRule, + ExtensionArtifactFlow, + FirstQueryFlow, + LearnRouteMap, + MobileStabilityContract, + ModeMatrix, + PerformanceResultsGrid, + QuickstartPath, + ReactNativeApproachTable, + ReactNativeBoundaryMap, + ReferenceLookup, + ReleaseLookup, + SdkGuideSummary, + SdkGuideProof, + SdkLanding, + SdkChooser, + ShipChecklist, + SqliteMigrationMap, + StartNextSteps, + StartOutcome, + TauriAppPattern, + VerifyChecklist, + WasmDataMovement, + WasmRuntimeMap, + ...components, + } satisfies MDXComponents; +} + +export const useMDXComponents = getMDXComponents; + +declare global { + type MDXProvidedComponents = ReturnType; +} diff --git a/src/docs/src/components/oliphaunt.tsx b/src/docs/src/components/oliphaunt.tsx new file mode 100644 index 00000000..6aecb963 --- /dev/null +++ b/src/docs/src/components/oliphaunt.tsx @@ -0,0 +1,1751 @@ +import { + ArrowRight, + BookOpen, + CheckCircle2, + ClipboardCheck, + Database, + FileSearch, + Gauge, + GitBranch, + HardDriveDownload, + Layers, + ListChecks, + PackageCheck, + PlayCircle, + Route, + Settings2, + ShieldCheck, +} from 'lucide-react'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { runtimeModes, sdkSurfaces } from '@/lib/docs-data'; + +function SurfaceIcon({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function SdkChooser() { + return ( +
+ {sdkSurfaces.map((sdk) => { + const Icon = sdk.icon; + + return ( + +
+ + + + +
+
+
+

{sdk.title}

+ + {sdk.packageName} + +
+

{sdk.target}

+

{sdk.startWith}

+ + {sdk.install} + +

{sdk.owns}

+
+

+ Verify first +

+

+ {sdk.verifyFirst} +

+
+
+ {sdk.modes.map((mode) => ( + + {mode} + + ))} +
+
+ + ); + })} +
+ ); +} + +const reactNativeApproaches = [ + { + approach: 'Expo development build', + install: 'npx expo install @oliphaunt/react-native', + nativeBuild: 'Run prebuild, then build iOS or Android.', + bestFor: 'Expo apps that include native modules, selected extensions, and development tooling.', + verify: 'Config plugin output, native module loading, JSI ArrayBuffer roundtrip.', + }, + { + approach: 'React Native New Architecture app', + install: 'npm install @oliphaunt/react-native', + nativeBuild: 'Run CocoaPods and Gradle after package or extension changes.', + bestFor: 'Existing RN apps that already own native projects and New Architecture builds.', + verify: 'Autolinking, Codegen, TurboModule availability, platform resource packaging.', + }, + { + approach: 'Platform-native app', + install: 'Use the Swift or Kotlin SDK directly.', + nativeBuild: 'Build through Xcode or Gradle without the RN package.', + bestFor: 'iOS, macOS, or Android apps without a React Native JavaScript surface.', + verify: 'Swift actor or Kotlin coroutine lifecycle, app storage, selected extensions.', + }, +]; + +export function ReactNativeApproachTable() { + return ( +
+
+
+ + + +
+

Choose the native app path first

+

+ Oliphaunt ships native runtime code. The JavaScript bundle can call it only after the + installed app binary includes the Swift and Kotlin pieces. +

+
+
+
+
+ {reactNativeApproaches.map((row) => ( +
+

{row.approach}

+
+

+ Install +

+ + {row.install} + +
+
+

+ Native build +

+

{row.nativeBuild}

+
+
+

+ Best for +

+

{row.bestFor}

+
+
+

+ Verify first +

+

{row.verify}

+
+
+ ))} +
+
+ ); +} + +const learnRoutes = [ + { + title: 'Embedded PostgreSQL', + href: '/docs/learn/embedded-postgres', + question: 'What does my app own on disk?', + answer: 'Roots, WAL, extensions, lifecycle, backup, and restore live behind SDK APIs.', + icon: HardDriveDownload, + }, + { + title: 'Native Runtime', + href: '/docs/learn/native-runtime', + question: 'Which runtime boundary fits my app?', + answer: 'Direct gives one embedded session, broker adds a helper process, and server gives PostgreSQL client sessions.', + icon: GitBranch, + }, + { + title: 'Mobile Stability', + href: '/docs/learn/mobile-stability', + question: 'How does this behave on iOS and Android?', + answer: 'Mobile direct mode covers app storage, foreground/background transitions, relaunch, and WAL recovery.', + icon: ShieldCheck, + }, + { + title: 'Moving From SQLite', + href: '/docs/learn/sqlite-upgrade', + question: 'What changes when I move from one file to PostgreSQL?', + answer: 'Storage, schema features, extension selection, export/import, and package-size checks change first.', + icon: Route, + }, + { + title: 'Tauri Usage', + href: '/docs/learn/tauri', + question: 'Where does the database handle live in a Tauri app?', + answer: 'Rust state owns Oliphaunt. The webview calls narrow app commands instead of raw runtime handles.', + icon: BookOpen, + }, +]; + +export function LearnRouteMap() { + return ( +
+
+
+

Read by decision

+

+ Each page answers one production question after the first query works. +

+
+
+
+

Storage

+

Root directory, WAL, backup.

+
+
+

Runtime

+

Direct, broker, server, WASM.

+
+
+

App fit

+

Mobile, Tauri, SQLite migration.

+
+
+
+
+ {learnRoutes.map((route) => { + const Icon = route.icon; + + return ( + +
+ + + +

{route.title}

+
+

{route.question}

+

{route.answer}

+ + + ); + })} +
+
+ ); +} + +const embeddedModelRows = [ + { + title: 'Root directory', + description: 'Live data is PostgreSQL storage and WAL inside an app-owned root directory.', + icon: HardDriveDownload, + }, + { + title: 'SDK lifecycle', + description: 'Open, query, background, close, backup, and restore stay behind platform APIs.', + icon: Route, + }, + { + title: 'Exact extensions', + description: 'Apps select SQL extension names before packaging or opening the database.', + icon: ShieldCheck, + }, + { + title: 'Runtime family', + description: 'Native SDKs and WASM share concepts while advertising their own capabilities.', + icon: Database, + }, +]; + +export function EmbeddedPostgresModel() { + return ( +
+
+
+

Embedded PostgreSQL model

+

+ Oliphaunt keeps PostgreSQL behavior and puts app-facing ownership in SDKs. +

+
+
+
+

Native family

+

+ Rust, Swift, Kotlin, React Native, TypeScript, and C ABI over native runtime assets. +

+
+
+

WASM family

+

+ First-class WASM/WASIX runtime family with separate assets and capabilities. +

+
+
+
+
+ {embeddedModelRows.map((row) => { + const Icon = row.icon; + + return ( +
+ + + +

{row.title}

+

{row.description}

+
+ ); + })} +
+
+ ); +} + +const mobileContractRows = [ + { + title: 'One resident backend', + description: 'Mobile direct mode owns one embedded PostgreSQL backend in the app process.', + }, + { + title: 'Serialized work', + description: 'Concurrent app tasks share one physical session through the platform SDK.', + }, + { + title: 'WAL recovery', + description: 'After process exit, the next launch reopens the root and PostgreSQL recovers storage.', + }, + { + title: 'Platform lifecycle', + description: 'SDK hooks prepare backgrounding, resume foreground work, and close handles cleanly.', + }, +]; + +export function MobileStabilityContract() { + return ( +
+
+

Mobile direct-mode contract

+

+ Use this model for iOS, Android, and React Native until the target advertises another + runtime mode. +

+
+
+ {mobileContractRows.map((row) => ( +
+ +

{row.title}

+

{row.description}

+
+ ))} +
+
+ ); +} + +const sqliteMigrationRows = [ + { + sqlite: 'One database file', + oliphaunt: 'PostgreSQL root directory', + action: 'Move data movement to SDK backup and restore APIs.', + }, + { + sqlite: 'Pragmas', + oliphaunt: 'PostgreSQL settings and durability profiles', + action: 'Choose startup and durability configuration through the SDK.', + }, + { + sqlite: 'SQLite extensions', + oliphaunt: 'Exact PostgreSQL extension names', + action: 'Select extensions before opening and verify package contents.', + }, + { + sqlite: 'Multiple library handles', + oliphaunt: 'Mode-specific sessions', + action: 'Use server mode when independent PostgreSQL clients are required.', + }, +]; + +export function SqliteMigrationMap() { + return ( +
+
+

Migration map

+

+ Start by replacing SQLite assumptions with PostgreSQL and SDK-owned app boundaries. +

+
+
+ {sqliteMigrationRows.map((row) => ( +
+

+ SQLite assumption +

+

{row.sqlite}

+

+ Oliphaunt model +

+

{row.oliphaunt}

+

+ Migration action +

+

{row.action}

+
+ ))} +
+
+
+ + + + + + + + + {sqliteMigrationRows.map((row) => ( + + + + + + ))} + +
SQLite assumptionOliphaunt modelMigration action
{row.sqlite}{row.oliphaunt}{row.action}
+ + + ); +} + +const referenceRows = [ + { + need: 'Choose an SDK package', + answer: 'Compare package names, app targets, runtime owners, and first verification steps.', + href: '/docs/reference/sdk-products', + label: 'SDKs And Platforms', + icon: ListChecks, + }, + { + need: 'Gate a feature in UI', + answer: 'Check direct, broker, server, streaming, backup, restore, and client-session capability.', + href: '/docs/reference/capabilities', + label: 'Capability Matrix', + icon: FileSearch, + }, + { + need: 'Ship one extension', + answer: 'Select exact SQL extension names and verify the app artifact contains only selected files.', + href: '/docs/reference/extensions', + label: 'Extensions', + icon: ShieldCheck, + }, + { + need: 'Look up exact extension support', + answer: 'Use the generated catalog for extension status, dependencies, and runtime availability.', + href: '/docs/reference/extension-catalog', + label: 'Extension Catalog', + icon: FileSearch, + }, + { + need: 'Read performance claims', + answer: 'Use the measurement guide for latency, throughput, package size, memory, and comparison scope.', + href: '/docs/reference/performance', + label: 'Performance', + icon: Gauge, + }, + { + need: 'Update an installed app', + answer: 'Match SDK versions, runtime artifacts, selected extensions, docs versions, and release notes.', + href: '/docs/reference/releases', + label: 'Releases', + icon: PackageCheck, + }, + { + need: 'Match versions', + answer: 'Use the generated version matrix for product compatibility and release contents.', + href: '/docs/reference/version-matrix', + label: 'Version Matrix', + icon: GitBranch, + }, + { + need: 'Find language API details', + answer: 'Use each SDK API map for open, query, lifecycle, capabilities, extensions, and backup calls.', + href: '/docs/reference/api-reference', + label: 'API Reference', + icon: BookOpen, + }, +]; + +export function ReferenceLookup() { + return ( +
+
+
+ + + +
+

Use Reference as a lookup surface

+

+ These pages answer specific product questions. Start with the question, then open the + smallest page that gives the exact answer. +

+
+
+
+
+ {referenceRows.map((row) => { + const Icon = row.icon; + + return ( + +
+ + + +

{row.need}

+
+

{row.answer}

+

{row.label}

+ + + ); + })} +
+
+ ); +} + +const releaseLookupRows = [ + { + question: 'Which package version fits my app?', + answer: 'Start with the SDK package, then check the runtime dependency it carries.', + href: '/docs/reference/version-matrix', + label: 'Version Matrix', + icon: PackageCheck, + }, + { + question: 'Which SDKs move together?', + answer: 'Native SDKs follow the native runtime; React Native also follows Swift and Kotlin.', + href: '/docs/reference/sdk-products', + label: 'SDKs And Platforms', + icon: GitBranch, + }, + { + question: 'Which extensions can this release ship?', + answer: 'Check extension availability by SQL extension name and target runtime.', + href: '/docs/reference/extension-catalog', + label: 'Extension Catalog', + icon: FileSearch, + }, + { + question: 'Did performance or package size change?', + answer: 'Read release measurements by workload, target hardware, and selected extensions.', + href: '/docs/reference/performance', + label: 'Performance', + icon: Gauge, + }, +]; + +export function ReleaseLookup() { + return ( +
+
+
+ + + +
+

Read releases by the app artifact you ship

+

+ Match the SDK package, runtime artifacts, selected extensions, and performance notes + before updating an installed app. +

+
+
+
+
+ {releaseLookupRows.map((row) => { + const Icon = row.icon; + + return ( + +
+ + + +

{row.question}

+
+

{row.answer}

+

{row.label}

+ + + ); + })} +
+
+ ); +} + +const capabilityCards = [ + { + title: 'Direct mode', + value: 'one serialized session', + description: 'Use it when app code owns one embedded PostgreSQL root and latency matters.', + icon: Database, + }, + { + title: 'Broker mode', + value: 'helper-owned roots', + description: 'Use it when a desktop app wants process ownership, recovery, or multiple roots.', + icon: GitBranch, + }, + { + title: 'Server mode', + value: 'independent clients', + description: 'Use it for pools, ORMs, psql, pg_dump, and PostgreSQL connection strings.', + icon: ListChecks, + }, + { + title: 'Extension artifacts', + value: 'exact selection', + description: 'Use selected SQL extension names to decide what enters the app artifact.', + icon: ShieldCheck, + }, +]; + +export function CapabilitySnapshot() { + return ( +
+
+
+ + + +
+

Read capabilities before enabling a workflow

+

+ Capabilities describe what the installed SDK and runtime advertise on this target. +

+
+
+
+
+ {capabilityCards.map((card) => { + const Icon = card.icon; + + return ( +
+ + + +

{card.title}

+ + {card.value} + +

{card.description}

+
+ ); + })} +
+
+ ); +} + +const extensionFlow = [ + { + title: 'Select SQL names', + description: 'Choose extension names such as `vector` in SDK configuration before opening.', + }, + { + title: 'Resolve dependencies', + description: 'Include only dependencies declared by the selected extension metadata.', + }, + { + title: 'Package artifacts', + description: 'Swift, Kotlin, React Native, desktop, and WASM tooling package target artifacts.', + }, + { + title: 'Verify the app', + description: 'Report selected names, included files, dependency files, target, and package-size cost.', + }, +]; + +export function ExtensionArtifactFlow() { + return ( +
+
+

Extension packaging flow

+

+ The selector is the SQL extension name. Build tooling handles target artifacts and + dependency metadata. +

+
+
+ {extensionFlow.map((step, index) => ( +
+ + {index + 1} + +

{step.title}

+

{step.description}

+
+ ))} +
+
+ ); +} + +const performanceResults = [ + { + title: 'Open path', + metrics: 'cold open, warm open, first query', + description: 'Use these numbers for startup and resume behavior.', + }, + { + title: 'Interactive work', + metrics: 'simple query p50, p90, p99', + description: 'Use these numbers for UI reads, writes, and short transactions.', + }, + { + title: 'Bulk work', + metrics: 'batched insert, update, import', + description: 'Use these numbers for sync, preload, and local cache hydration.', + }, + { + title: 'Large reads', + metrics: 'stream setup, chunks, total time', + description: 'Use these numbers for reports, exports, and sync scans.', + }, + { + title: 'Footprint', + metrics: 'RSS, CPU, artifact size', + description: 'Use these numbers when mobile package size or desktop memory matters.', + }, + { + title: 'Data movement', + metrics: 'backup, restore, dump', + description: 'Use these numbers for user-visible export, import, and support flows.', + }, +]; + +export function PerformanceResultsGrid() { + return ( +
+
+
+ + + +
+

Use performance results by workload

+

+ A useful report names the app workload, runtime mode, selected extensions, target + hardware, and collection method. +

+
+
+
+
+ {performanceResults.map((item) => ( +
+

{item.title}

+ + {item.metrics} + +

{item.description}

+
+ ))} +
+
+ ); +} + +const tauriModeCards = [ + { + title: 'App commands own calls', + value: 'NativeDirect', + description: 'Use Rust state when Tauri commands own one app database and latency matters.', + icon: Database, + }, + { + title: 'Helper owns roots', + value: 'NativeBroker', + description: 'Use a broker when a desktop app wants process ownership or multiple roots.', + icon: GitBranch, + }, + { + title: 'Clients need a URL', + value: 'NativeServer', + description: 'Use server mode for pools, ORMs, psql, pg_dump, and independent sessions.', + icon: Route, + }, +]; + +export function TauriAppPattern() { + return ( +
+
+
+ + + +
+

Keep PostgreSQL ownership in Rust state

+

+ The webview calls app commands. Rust owns the database handle, root directory, + lifecycle, extension selection, and backup APIs. +

+
+
+
+
+ {tauriModeCards.map((card) => { + const Icon = card.icon; + + return ( +
+ + + +

{card.title}

+ + {card.value} + +

+ {card.description} +

+
+ ); + })} +
+
+ {[ + 'Expose narrow commands such as add_item or search_items.', + 'Keep roots, locks, and handles out of the webview.', + 'Use SDK backup and restore APIs for app import/export.', + ].map((item) => ( +
+ + {item} +
+ ))} +
+
+ ); +} + +const reactNativeBoundaryRows = [ + { + layer: 'TypeScript', + owns: 'API shape, handles, typed results, config plugin options, and lifecycle calls.', + transport: 'TurboModule for small calls; JSI ArrayBuffer for protocol bytes and chunks.', + }, + { + layer: 'Swift', + owns: 'Apple runtime resources, app storage, lifecycle, capabilities, backup, and restore.', + transport: 'Actor-owned native direct database handle on iOS and macOS targets.', + }, + { + layer: 'Kotlin', + owns: 'Android resources, ABI artifact selection, coroutine lifecycle, capabilities, backup, and restore.', + transport: 'Android facade over the Kotlin SDK database handle.', + }, +]; + +export function ReactNativeBoundaryMap() { + return ( +
+
+
+ + + +
+

React Native owns the JS boundary

+

+ Platform runtime behavior flows through Swift on Apple targets and Kotlin on + Android. JavaScript gets one consistent SDK surface over those native handles. +

+
+
+
+
+ + + + + + + + + + {reactNativeBoundaryRows.map((row) => ( + + + + + + ))} + +
LayerOwnsBoundary
{row.layer}{row.owns}{row.transport}
+
+
+ {[ + 'Use high-level query helpers for app code.', + 'Use raw protocol and streaming APIs for adapters and COPY-style flows.', + 'Read capabilities before enabling platform-specific UI.', + ].map((item) => ( +
+ + {item} +
+ ))} +
+
+ ); +} + +const wasmRuntimeCards = [ + { + title: 'Direct Rust API', + value: 'Oliphaunt', + description: 'Use direct calls when Rust code owns SQL work inside the WASM host.', + icon: Database, + }, + { + title: 'PostgreSQL URL', + value: 'OliphauntServer', + description: 'Use server-compatible mode when a library expects a local PostgreSQL endpoint.', + icon: Route, + }, + { + title: 'Runtime assets', + value: 'WASIX', + description: 'Package the WASIX runtime assets and exact extension files selected by the app.', + icon: PackageCheck, + }, + { + title: 'Data movement', + value: 'dump / restore', + description: 'Use logical dumps for portable exports and upgrades between runtime versions.', + icon: HardDriveDownload, + }, +]; + +export function WasmRuntimeMap() { + return ( +
+
+
+ + + +
+

Use WASM as its own runtime family

+

+ WASM shares Oliphaunt concepts with native SDKs, while packaging its own WASIX + runtime assets, host targets, persistence behavior, and extension artifacts. +

+
+
+
+
+ {wasmRuntimeCards.map((card) => { + const Icon = card.icon; + + return ( +
+ + + +

{card.title}

+ + {card.value} + +

+ {card.description} +

+
+ ); + })} +
+
+ ); +} + +const wasmDataMovementRows = [ + { + format: 'Logical dump', + use: 'Portable SQL export, version upgrade, runtime-to-runtime movement.', + api: 'dump_sql, dump_bytes, oliphaunt-wasix-dump', + }, + { + format: 'Physical archive', + use: 'Same-version clone or restore into another WASM root.', + api: 'dump_data_dir, load_data_dir_archive, try_clone', + }, + { + format: 'Server dump', + use: 'Workflows already using a local PostgreSQL endpoint.', + api: 'OliphauntServer::dump_sql', + }, +]; + +export function WasmDataMovement() { + return ( +
+
+
+ + + +
+

Choose the export format by destination

+

+ Logical dumps move across runtime versions. Physical archives are fast snapshots for + the same runtime family and database format. +

+
+
+
+
+ + + + + + + + + + {wasmDataMovementRows.map((row) => ( + + + + + + ))} + +
FormatUse it forAPI
{row.format}{row.use} + + {row.api} + +
+
+
+ ); +} + +export function SdkGuideSummary({ id }: { id: string }) { + const sdk = sdkSurfaces.find((surface) => surface.id === id); + + if (!sdk) { + return null; + } + + const Icon = sdk.icon; + + return ( +
+
+
+
+ + + +
+

{sdk.title} setup path

+

{sdk.startWith}

+
+
+
+ + {sdk.packageName} + +
+ {sdk.modes.map((mode) => ( + + {mode} + + ))} +
+
+
+
+
+
+

Install

+ + {sdk.install} + +
+
+

Target

+

{sdk.target}

+
+
+

SDK owns

+

{sdk.owns}

+
+
+

Verify first

+

{sdk.verifyFirst}

+
+
+
+ {sdk.guideOutcomes.map((outcome) => ( +
+ + {outcome} +
+ ))} +
+
+ ); +} + +const guideProofs: Record> = { + 'c-abi': [ + { + title: 'Handle lifecycle', + description: 'A binding can open an opaque handle, send protocol bytes, free responses, and close cleanly.', + }, + { + title: 'Ownership', + description: 'The binding exposes response ownership, last-error reads, capabilities, and close state directly.', + }, + { + title: 'Runtime assets', + description: 'The app carries only the native runtime and exact extension artifacts selected by the binding.', + }, + { + title: 'Language surface', + description: 'The public wrapper uses platform-native async, errors, and buffers over the C ABI.', + }, + ], + rust: [ + { + title: 'First query', + description: 'A Rust or Tauri app opens an app-owned root and runs a query through the chosen mode.', + }, + { + title: 'Mode choice', + description: 'Direct, broker, and server paths are chosen through builder configuration and capabilities.', + }, + { + title: 'Data movement', + description: 'Backup, restore, dump, or server tools stay behind Rust SDK APIs.', + }, + { + title: 'App boundary', + description: 'Tauri webviews call narrow Rust commands instead of owning database roots or raw handles.', + }, + ], + swift: [ + { + title: 'First query', + description: 'An iOS or macOS target opens from app storage and runs a query off the main actor.', + }, + { + title: 'Lifecycle', + description: 'The app calls lifecycle hooks around foreground, background, cancellation, and close.', + }, + { + title: 'Resources', + description: 'The Apple package carries the native runtime and only selected extension artifacts.', + }, + { + title: 'Concurrency', + description: 'Swift tasks share the actor-owned database handle and preserve transaction ordering.', + }, + ], + kotlin: [ + { + title: 'First query', + description: 'An Android app opens from app-private storage and runs a query from coroutine code.', + }, + { + title: 'Packaging', + description: 'The Gradle plugin resolves ABI assets, native libraries, and selected extension resources.', + }, + { + title: 'Lifecycle', + description: 'Android lifecycle calls prepare backgrounding, resume foreground work, and close handles.', + }, + { + title: 'App artifact', + description: 'The APK or AAB contains selected extension files and their declared dependencies only.', + }, + ], + 'react-native': [ + { + title: 'Native app binary', + description: 'The app runs in an Expo development build or React Native New Architecture binary.', + }, + { + title: 'Binary transport', + description: 'Raw protocol bytes and streamed chunks move through JSI ArrayBuffer paths.', + }, + { + title: 'Platform delegation', + description: 'Apple behavior flows through Swift, Android behavior flows through Kotlin, and JS owns DX.', + }, + { + title: 'Config output', + description: 'The config plugin selects exact extensions and native runtime assets for the app artifact.', + }, + ], + typescript: [ + { + title: 'Runtime resolver', + description: 'Node, Bun, Deno, or Tauri JavaScript resolves helper assets from the installed package.', + }, + { + title: 'Mode connection', + description: 'The app connects to broker or server mode where the selected runtime advertises it.', + }, + { + title: 'Query shape', + description: 'High-level query helpers and raw protocol APIs share one error and capability model.', + }, + { + title: 'Desktop packaging', + description: 'The app packages helper executables, selected extensions, and backup/restore flows together.', + }, + ], + wasm: [ + { + title: 'Runtime assets', + description: 'A WASM/WASIX host loads the WASM runtime assets before opening a root.', + }, + { + title: 'First query', + description: 'The app opens a WASM root and runs SQL through the WASM runtime.', + }, + { + title: 'Data movement', + description: 'Dump, restore, and upgrade flows use the WASM runtime tooling documented for that runtime.', + }, + { + title: 'Runtime family', + description: 'The app treats WASM as its own runtime family with separate assets and build rules.', + }, + ], +}; + +export function SdkGuideProof({ id }: { id: string }) { + const sdk = sdkSurfaces.find((surface) => surface.id === id); + const checks = guideProofs[id]; + + if (!sdk || !checks) { + return null; + } + + return ( +
+
+

This guide is complete when

+

+ Use these checks before moving from a first query to application code. +

+
+
+ {checks.map((check) => ( +
+ +

{check.title}

+

{check.description}

+
+ ))} +
+
+ + Open the {sdk.title} API map + + +
+
+ ); +} + +export function SdkLanding({ id }: { id: string }) { + const sdk = sdkSurfaces.find((surface) => surface.id === id); + + if (!sdk) { + return null; + } + + const Icon = sdk.icon; + const guideHref = `${sdk.href}/guide`; + const apiHref = `${sdk.href}/api-reference`; + const extraHref = id === 'react-native' ? `${sdk.href}/architecture` : undefined; + + return ( +
+
+
+
+ + + +
+

{sdk.title} at a glance

+

{sdk.target}

+
+
+

{sdk.startWith}

+
+
+

Install

+ + {sdk.install} + + + {sdk.packageName} + +
+
+
+
+

SDK owns

+

{sdk.owns}

+
+
+

Modes

+
+ {sdk.modes.map((mode) => ( + + {mode} + + ))} +
+
+
+

Verify first

+

{sdk.verifyFirst}

+
+
+
+ +

Build guide

+

+ Install, open, configure, select extensions, and verify lifecycle. +

+ + + +

API map

+

+ Find the public surface for open, query, lifecycle, capabilities, and backup. +

+ + + +

+ {extraHref ? 'Architecture' : 'Capabilities'} +

+

+ {extraHref + ? 'Understand the React Native, Swift, Kotlin, TurboModule, and JSI boundary.' + : 'Check mode, streaming, extension, backup, restore, and client-session support.'} +

+ + +
+
+ ); +} + +export function QuickstartPath() { + const steps = [ + { + title: 'Pick the SDK', + description: 'Choose the package for the app users install: Rust, Swift, Kotlin, React Native, TypeScript, WASM, or C ABI.', + }, + { + title: 'Install through the platform tool', + description: 'Use Cargo, SwiftPM/Xcode, Gradle, npm, Expo, or the released C artifacts. Native apps rebuild when runtime assets or selected extensions change.', + }, + { + title: 'Open an app-owned root', + description: 'Use persistent app storage for user data and a temporary root for tests. A root is a PostgreSQL directory managed through SDK APIs.', + }, + { + title: 'Run SQL and verify capabilities', + description: 'Run `SELECT 1`, read `capabilities()`, and create only the extensions selected for the app artifact.', + }, + ]; + + return ( +
+
+
+ +
+

Start in one app target

+

+ The first path is short: install, open, query, verify, then use the platform page for + lifecycle, packaging, and data movement. +

+
+
+
+
+ {steps.map((step, index) => ( +
+ + {index + 1} + +

{step.title}

+

{step.description}

+
+ ))} +
+
+ ); +} + +export function StartOutcome() { + const outcomes = [ + { + title: 'One SDK selected', + description: 'You have the package, runtime artifacts, and build path for the app users install.', + icon: PackageCheck, + }, + { + title: 'One root opened', + description: 'The database lives in app-owned storage and uses SDK lifecycle APIs.', + icon: HardDriveDownload, + }, + { + title: 'One query verified', + description: '`SELECT 1`, `capabilities()`, and selected extensions prove the runtime path.', + icon: ClipboardCheck, + }, + { + title: 'One next page', + description: 'You move to the platform guide, runtime model, extensions, or performance lookup.', + icon: Route, + }, + ]; + + return ( +
+
+
+

Finish this page with a working app path

+

+ Start proves one target. Platform guides handle deeper app wiring after that. +

+
+ + Tutorial + +
+
+ {outcomes.map((outcome) => { + const Icon = outcome.icon; + + return ( +
+ + + +
+

{outcome.title}

+

+ {outcome.description} +

+
+
+ ); + })} +
+
+ ); +} + +export function FirstQueryFlow() { + return ( +
+
+
+

First query shape

+

+ The syntax changes by SDK. The application flow stays recognizable. +

+
+
+          {`const db = await Oliphaunt.open({
+  root: 'main.oliphaunt',
+  engine: 'nativeDirect',
+  extensions: ['vector'],
+});
+
+const result = await db.query('SELECT 1::text AS value');
+const value = result.getText(0, 'value');
+
+await db.close();`}
+        
+
+
+ {['Choose storage', 'Select mode', 'Select extensions', 'Open and query', 'Close or detach'].map( + (item, index) => ( +
+ + {String(index + 1).padStart(2, '0')} + +

{item}

+
+ ), + )} +
+
+ ); +} + +const startNextSteps = [ + { + title: 'Choose the platform guide', + description: 'Install, build, query, lifecycle, extensions, backup, and troubleshooting for one SDK.', + href: '/docs/sdk', + label: 'SDKs', + icon: PackageCheck, + }, + { + title: 'Understand runtime modes', + description: 'Direct, broker, server, WASM, root ownership, sessions, and process boundaries.', + href: '/docs/learn/native-runtime', + label: 'Native Runtime', + icon: GitBranch, + }, + { + title: 'Select extensions exactly', + description: 'Choose SQL extension names and verify only selected files enter the app artifact.', + href: '/docs/reference/extensions', + label: 'Extensions', + icon: ShieldCheck, + }, + { + title: 'Plan storage and backup', + description: 'Use app-owned PostgreSQL roots, lifecycle APIs, backup, restore, and recovery behavior.', + href: '/docs/learn/embedded-postgres', + label: 'Embedded PostgreSQL', + icon: HardDriveDownload, + }, +]; + +export function StartNextSteps() { + return ( +
+ {startNextSteps.map((step) => { + const Icon = step.icon; + + return ( + +
+ + + + +
+

+ {step.label} +

+

{step.title}

+

+ {step.description} +

+ + ); + })} +
+ ); +} + +export function VerifyChecklist() { + const checks = [ + { + title: 'Install', + description: 'The package resolves through the normal package manager and platform build tool.', + icon: PackageCheck, + }, + { + title: 'Configure', + description: 'Runtime mode, root, selected extensions, and platform assets are explicit.', + icon: Settings2, + }, + { + title: 'Verify', + description: '`SELECT 1`, `capabilities()`, and selected extensions behave on the target.', + icon: ClipboardCheck, + }, + ]; + + return ( +
+ {checks.map((check) => { + const Icon = check.icon; + + return ( +
+ + + +

{check.title}

+

{check.description}

+
+ ); + })} +
+ ); +} + +export function ShipChecklist() { + const items = [ + { + title: 'Package', + description: 'Build the app binary or helper package that carries the selected runtime artifacts.', + }, + { + title: 'Lifecycle', + description: 'Wire close, foreground, background, cancellation, and restart behavior through the SDK.', + }, + { + title: 'Extensions', + description: 'Select SQL extension names explicitly and verify selected files in the app artifact.', + }, + { + title: 'Data movement', + description: 'Use SDK backup, restore, dump, or archive APIs for user-visible export and import.', + }, + { + title: 'Capabilities', + description: 'Read capability flags before enabling streaming, broker, server, or platform-specific UI.', + }, + ]; + + return ( +
+
+

Before shipping

+

+ The first query proves the runtime is present. These checks prove the app integration is + ready for users. +

+
+
+ {items.map((item) => ( +
+ +

{item.title}

+

{item.description}

+
+ ))} +
+
+ ); +} + +export function ModeMatrix() { + return ( +
+ {runtimeModes.map((mode) => { + const Icon = mode.icon; + + return ( +
+
+ + + +
+ {mode.name} +

{mode.label}

+
+
+
+

Use it when

+

{mode.useWhen}

+
+
+

Boundary

+

{mode.boundary}

+
+
+ ); + })} +
+ ); +} + +export function ExactExtensionRule() { + return ( +
+
+ +
+

Extension selection is exact SQL extension name only.

+

+ If an app selects vector, the app artifact contains vector{' '} + and its declared dependencies. Unrelated search, geo, graph, or development-only + extension files stay out of that app artifact. +

+
+
+
+ ); +} diff --git a/src/docs/src/lib/cn.ts b/src/docs/src/lib/cn.ts new file mode 100644 index 00000000..ba66fd25 --- /dev/null +++ b/src/docs/src/lib/cn.ts @@ -0,0 +1 @@ +export { twMerge as cn } from 'tailwind-merge'; diff --git a/src/docs/src/lib/docs-data.ts b/src/docs/src/lib/docs-data.ts new file mode 100644 index 00000000..453f067d --- /dev/null +++ b/src/docs/src/lib/docs-data.ts @@ -0,0 +1,225 @@ +import { + Boxes, + Braces, + CodeXml, + Database, + HardDrive, + Laptop, + Layers, + Network, + Server, + ShieldCheck, + Smartphone, + type LucideIcon, +} from 'lucide-react'; + +export type SdkSurface = { + id: string; + title: string; + href: string; + packageName: string; + install: string; + target: string; + startWith: string; + owns: string; + modes: string[]; + verifyFirst: string; + guideOutcomes: string[]; + icon: LucideIcon; +}; + +export const sdkSurfaces: SdkSurface[] = [ + { + id: 'rust', + title: 'Rust', + href: '/docs/sdk/rust', + packageName: 'oliphaunt', + install: 'cargo add oliphaunt', + target: 'Tauri and native Rust desktop apps', + startWith: 'Direct, broker, and server modes', + owns: 'Rust-native async APIs, helper processes, and desktop runtime selection.', + modes: ['direct', 'broker', 'server'], + verifyFirst: 'Run a direct query, then verify broker or server capability before using pools.', + guideOutcomes: [ + 'Open a persistent or temporary root from async Rust code.', + 'Choose direct, broker, or server mode deliberately.', + 'Select exact extensions and keep backup/restore behind SDK APIs.', + ], + icon: Laptop, + }, + { + id: 'swift', + title: 'Swift', + href: '/docs/sdk/swift', + packageName: 'Oliphaunt', + install: 'Add package in Xcode or Package.swift', + target: 'iOS and macOS apps', + startWith: 'Swift concurrency, app storage, and lifecycle', + owns: 'Apple app storage, actors, lifecycle hooks, and native runtime resources.', + modes: ['direct'], + verifyFirst: 'Open from app storage, run a query off the main actor, and exercise app lifecycle hooks.', + guideOutcomes: [ + 'Add the Swift package to an iOS or macOS app target.', + 'Open from Swift concurrency without blocking the main actor.', + 'Coordinate app lifecycle, exact extensions, and backup/restore.', + ], + icon: Smartphone, + }, + { + id: 'kotlin', + title: 'Kotlin', + href: '/docs/sdk/kotlin', + packageName: 'dev.oliphaunt:oliphaunt', + install: 'id("dev.oliphaunt.android") + implementation("dev.oliphaunt:oliphaunt:0.1.0")', + target: 'Android apps', + startWith: 'Coroutines, Android resources, and ABI artifacts', + owns: 'Android resource hydration, ABI selection, coroutines, and lifecycle.', + modes: ['direct'], + verifyFirst: 'Build the Android app, open from app-private storage, and confirm selected ABI assets.', + guideOutcomes: [ + 'Add the Android package through Gradle.', + 'Open from coroutine code using app-private storage.', + 'Package only selected extensions and use Android lifecycle hooks.', + ], + icon: Smartphone, + }, + { + id: 'react-native', + title: 'React Native', + href: '/docs/sdk/react-native', + packageName: '@oliphaunt/react-native', + install: 'npx expo install @oliphaunt/react-native', + target: 'Expo and React Native New Architecture apps', + startWith: 'Config plugin, TurboModule, and JSI transport', + owns: 'TypeScript DX, config plugin behavior, JSI bytes, and platform delegation.', + modes: ['direct'], + verifyFirst: 'Build a development client, confirm native module loading, and move bytes through JSI.', + guideOutcomes: [ + 'Install the package and build a native app binary or development client.', + 'Use the config plugin for exact extension artifacts.', + 'Move SQL, raw protocol bytes, streaming, and lifecycle through JSI/TurboModule APIs.', + ], + icon: Layers, + }, + { + id: 'typescript', + title: 'TypeScript', + href: '/docs/sdk/typescript', + packageName: '@oliphaunt/ts', + install: 'npm install @oliphaunt/ts', + target: 'Node.js, Bun, Deno, and Tauri JavaScript apps', + startWith: 'Desktop JavaScript over native helpers', + owns: 'JavaScript API shape, runtime asset resolution, and helper-backed modes.', + modes: ['broker', 'server', 'direct adapter'], + verifyFirst: 'Resolve helper assets, connect to broker or server mode, and run the same query path.', + guideOutcomes: [ + 'Install the desktop JavaScript package from npm.', + 'Resolve helper-backed runtime assets from the package.', + 'Choose broker or server mode for robust desktop JavaScript apps.', + ], + icon: Braces, + }, + { + id: 'wasm', + title: 'WASM', + href: '/docs/sdk/wasm', + packageName: 'oliphaunt-wasix', + install: 'cargo add oliphaunt-wasix', + target: 'WASM/WASIX hosts', + startWith: 'WASM/WASIX runtime family', + owns: 'WASM runtime behavior, WASIX assets, dump and restore flows.', + modes: ['WASIX'], + verifyFirst: 'Load WASM runtime assets, open a root, and prove dump or restore for data movement.', + guideOutcomes: [ + 'Install the WASM package for WASIX hosts.', + 'Open a WASM runtime root and run SQL through WebAssembly.', + 'Use dump/restore when moving data across runtimes or versions.', + ], + icon: Boxes, + }, + { + id: 'c-abi', + title: 'C ABI', + href: '/docs/sdk/c-abi', + packageName: 'liboliphaunt', + install: 'Use released headers, libraries, and runtime assets', + target: 'New language bindings', + startWith: 'Native runtime ownership and ABI rules', + owns: 'Opaque handles, raw protocol bytes, response ownership, and lifecycle.', + modes: ['direct ABI'], + verifyFirst: 'Open an opaque handle, send protocol bytes, free responses, and close cleanly.', + guideOutcomes: [ + 'Consume released headers, libraries, and native runtime assets.', + 'Open an opaque handle and manage response ownership explicitly.', + 'Build language bindings that expose capabilities, errors, lifecycle, and backup APIs.', + ], + icon: CodeXml, + }, +]; + +export type RuntimeMode = { + name: string; + label: string; + href: string; + useWhen: string; + boundary: string; + icon: LucideIcon; +}; + +export const runtimeModes: RuntimeMode[] = [ + { + name: 'nativeDirect', + label: 'Embedded latency', + href: '/docs/learn/native-runtime', + useWhen: 'One app database needs the lowest overhead path.', + boundary: 'One physical PostgreSQL session with serialized work.', + icon: Database, + }, + { + name: 'nativeBroker', + label: 'Desktop isolation', + href: '/docs/learn/native-runtime', + useWhen: 'A desktop app needs helper-process ownership, multiple roots, or recovery.', + boundary: 'Helper process boundary for desktop SDKs.', + icon: Network, + }, + { + name: 'nativeServer', + label: 'Client compatibility', + href: '/docs/learn/native-runtime', + useWhen: 'Existing PostgreSQL clients, ORMs, psql, or pg_dump need real sessions.', + boundary: 'PostgreSQL-compatible process boundary with independent client sessions.', + icon: Server, + }, + { + name: 'WASM', + label: 'WASIX runtime', + href: '/docs/sdk/wasm/runtime', + useWhen: 'The app targets a WASM/WASIX host.', + boundary: 'Separate build and packaging rules from native SDKs.', + icon: Boxes, + }, +]; + +export const productPillars = [ + { + title: 'PostgreSQL semantics', + description: 'Use PostgreSQL storage, WAL, SQL, protocol behavior, and selected extensions inside app-owned storage.', + icon: Database, + }, + { + title: 'Runtime modes with clear boundaries', + description: 'Direct optimizes embedded latency, broker optimizes desktop isolation, and server optimizes independent client sessions.', + icon: Server, + }, + { + title: 'Exact extension packaging', + description: 'Apps select SQL extension names explicitly so release artifacts include only what the app uses.', + icon: ShieldCheck, + }, + { + title: 'App-grade data movement', + description: 'SDK backup and restore APIs keep PostgreSQL directory mechanics out of application code.', + icon: HardDrive, + }, +]; diff --git a/src/docs/src/lib/layout.shared.tsx b/src/docs/src/lib/layout.shared.tsx new file mode 100644 index 00000000..94d976cb --- /dev/null +++ b/src/docs/src/lib/layout.shared.tsx @@ -0,0 +1,19 @@ +import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; +import { appName, gitConfig } from './shared'; + +export function baseOptions(): BaseLayoutProps { + return { + nav: { + title: appName, + url: '/', + }, + links: [ + { + text: 'GitHub', + url: `https://github.com/${gitConfig.user}/${gitConfig.repo}`, + external: true, + }, + ], + githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`, + }; +} diff --git a/src/docs/src/lib/shared.ts b/src/docs/src/lib/shared.ts new file mode 100644 index 00000000..94e6de5b --- /dev/null +++ b/src/docs/src/lib/shared.ts @@ -0,0 +1,10 @@ +export const appName = 'Oliphaunt'; +export const docsRoute = '/docs'; +export const docsImageRoute = '/og/docs'; +export const docsContentRoute = '/llms.mdx/docs'; + +export const gitConfig = { + user: 'f0rr0', + repo: 'oliphaunt', + branch: 'main', +}; diff --git a/src/docs/src/lib/source.ts b/src/docs/src/lib/source.ts new file mode 100644 index 00000000..a00a3fcf --- /dev/null +++ b/src/docs/src/lib/source.ts @@ -0,0 +1,37 @@ +import { docs } from 'collections/server'; +import { loader } from 'fumadocs-core/source'; +import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons'; +import { docsContentRoute, docsImageRoute, docsRoute } from './shared'; + +// See https://fumadocs.dev/docs/headless/source-api for more info +export const source = loader({ + baseUrl: docsRoute, + source: docs.toFumadocsSource(), + plugins: [lucideIconsPlugin()], +}); + +export function getPageImage(page: (typeof source)['$inferPage']) { + const segments = [...page.slugs, 'image.png']; + + return { + segments, + url: `${docsImageRoute}/${segments.join('/')}`, + }; +} + +export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) { + const segments = [...page.slugs, 'content.md']; + + return { + segments, + url: `${docsContentRoute}/${segments.join('/')}`, + }; +} + +export async function getLLMText(page: (typeof source)['$inferPage']) { + const processed = await page.data.getText('processed'); + + return `# ${page.data.title} (${page.url}) + +${processed}`; +} diff --git a/src/docs/static/img/favicon.svg b/src/docs/static/img/favicon.svg new file mode 100644 index 00000000..659180b0 --- /dev/null +++ b/src/docs/static/img/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/docs/tools/check-docs-product.mjs b/src/docs/tools/check-docs-product.mjs new file mode 100644 index 00000000..e24734d6 --- /dev/null +++ b/src/docs/tools/check-docs-product.mjs @@ -0,0 +1,1347 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { generateDocs } from './generate-content.mjs'; + +const args = new Set(process.argv.slice(2)); +const apiReferenceRequested = args.has('--api-reference'); +const result = generateDocs({ + apiMode: apiReferenceRequested ? 'release' : 'fast', + publishApiArtifacts: apiReferenceRequested, +}); +const { manifest, sdkManifest, releaseGraph, routeRecords, paths } = result; +const { repoRoot, siteDocsRoot, staticRoot, generatedMetaRoot } = paths; +const { apiSummary } = result; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function requireFile(relativePath) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + fail(`required docs file missing: ${relativePath}`); + } + return fullPath; +} + +function readText(relativePath) { + return fs.readFileSync(requireFile(relativePath), 'utf8'); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function routeSourcePagePath(route, page) { + const matches = ['.md', '.mdx'] + .map((extension) => path.join(route.source, `${page}${extension}`)) + .filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath))); + if (matches.length > 1) { + fail(`${route.id} docs contain duplicate source pages for ${page}: ${matches.join(', ')}`); + } + return matches[0] ?? null; +} + +function routePageSet(routeId) { + const route = manifest.routes.find((entry) => entry.id === routeId); + return new Set(route?.page_order ?? []); +} + +function sidebarPagesForRoute(route) { + return route.sidebar_pages ?? route.page_order ?? []; +} + +function gitTrackedFiles(pathspec) { + try { + return execFileSync('git', ['ls-files', pathspec], { + cwd: repoRoot, + encoding: 'utf8', + }) + .trim() + .split('\n') + .filter(Boolean); + } catch { + return []; + } +} + +function assertNoTrackedRootProductsDocs() { + const tracked = gitTrackedFiles('docs/products'); + if (tracked.length > 0) { + fail( + `public product docs must live under src/docs/content, found tracked docs/products files:\n${tracked.join('\n')}`, + ); + } +} + +function assertNoProductLocalPublicDocs() { + const tracked = gitTrackedFiles('src/*/docs/**').filter( + (file) => !file.startsWith('src/docs/') && /\.(md|mdx)$/u.test(file), + ); + if (tracked.length > 0) { + fail( + `public SDK docs must be centralized under src/docs/content; product-local docs require an explicit package-shipped exception:\n${tracked.join('\n')}`, + ); + } +} + +function assertNoTrackedRootPublicDocs() { + const tracked = gitTrackedFiles('docs').filter((file) => /^docs\/[^/]+\.md$/u.test(file)); + const unexpected = tracked.filter((file) => file !== 'docs/README.md'); + if (unexpected.length > 0) { + fail( + `top-level root docs are maintainer-only; move public docs into src/docs or docs subdirectories:\n${unexpected.join('\n')}`, + ); + } +} + +function assertRootDocsBuckets() { + for (const dir of ['docs/architecture', 'docs/maintainers', 'docs/internal']) { + if (!fs.existsSync(path.join(repoRoot, dir))) { + fail(`required root docs bucket missing: ${dir}`); + } + } +} + +function assertNoDocsMoonProject() { + if (fs.existsSync(path.join(repoRoot, 'docs/moon.yml'))) { + fail('docs/moon.yml must not exist; docs is the only docs project'); + } +} + +function assertDocsChromeDoesNotExposeSourcePaths() { + const pageShell = readText('src/docs/src/app/docs/[[...slug]]/page.tsx'); + if (/ViewOptionsPopover[\s\S]{0,240}\bgithubUrl\s*=/u.test(pageShell)) { + fail( + 'public docs page actions must not expose monorepo source-file links through ViewOptionsPopover', + ); + } + if (pageShell.includes('src/docs/content')) { + fail('public docs page actions must not construct GitHub links to source content paths'); + } +} + +function assertUniqueRoutes() { + const seen = new Set(); + for (const route of manifest.routes ?? []) { + if (!route.id || !route.route || !route.source) { + fail(`docs-manifest route is missing id, route, or source: ${JSON.stringify(route)}`); + } + if (route.route.startsWith('/') || route.route.includes('\\')) { + fail(`docs route must be relative and URL-safe: ${route.id}`); + } + if (seen.has(route.route)) { + fail(`duplicate docs route: ${route.route}`); + } + seen.add(route.route); + } +} + +function assertGeneratedFiles() { + const referencePages = routePageSet('reference'); + const generatedReferencePages = [ + 'sdk-matrix', + 'platforms', + 'extension-catalog', + 'api-reference', + 'tested-snippets', + 'artifact-provenance', + 'version-matrix', + ] + .filter((page) => referencePages.has(page)) + .map((page) => path.join(siteDocsRoot, 'reference', `${page}.md`)); + const required = [ + ...generatedReferencePages, + path.join(staticRoot, 'llms.txt'), + path.join(staticRoot, 'llms-full.txt'), + path.join(generatedMetaRoot, 'routes.json'), + path.join(generatedMetaRoot, 'navigation.json'), + path.join(repoRoot, 'target', 'docs', 'generated', 'api', 'summary.json'), + path.join(siteDocsRoot, 'meta.json'), + path.join(siteDocsRoot, 'sdk', 'meta.json'), + ]; + for (const file of required) { + if (!fs.existsSync(file)) { + fail(`generated docs artifact missing: ${path.relative(repoRoot, file)}`); + } + } +} + +function assertGeneratedFumadocsMetadata() { + const rootMeta = readJsonFile(path.join(siteDocsRoot, 'meta.json')); + const expectedRootPages = ['start', 'sdk', 'learn', 'reference']; + if (JSON.stringify(rootMeta.pages) !== JSON.stringify(expectedRootPages)) { + fail(`root Fumadocs metadata must keep compact public nav: ${expectedRootPages.join(', ')}`); + } + if (!rootMeta.description || rootMeta.description.length < 48) { + fail('root Fumadocs metadata must include a useful reader-facing description'); + } + + for (const route of manifest.routes ?? []) { + const metaPath = path.join(siteDocsRoot, route.route, 'meta.json'); + if (!fs.existsSync(metaPath)) { + fail(`generated Fumadocs metadata missing for route ${route.id}`); + } + const metadata = readJsonFile(metaPath); + if (!metadata.title) { + fail(`generated Fumadocs metadata missing title for route ${route.id}`); + } + if (!metadata.description || metadata.description === `${route.title} documentation`) { + fail(`generated Fumadocs metadata needs a real description for route ${route.id}`); + } + if (!metadata.icon) { + fail(`generated Fumadocs metadata needs an icon for route ${route.id}`); + } + if ((route.page_order ?? []).includes('index')) { + if (metadata.pagesIndex !== 'index') { + fail(`${route.id} metadata must expose index as the folder pagesIndex`); + } + if (metadata.pages?.includes('index')) { + fail(`${route.id} metadata must not duplicate index as a sidebar child page`); + } + } + if (route.kind === 'sdk') { + if (metadata.pagesIndex !== 'index') { + fail(`${route.id} SDK metadata must use the overview page as pagesIndex`); + } + if (!metadata.pages?.includes('guide')) { + fail(`${route.id} SDK metadata must expose guide in the SDK folder`); + } + if (metadata.pages?.includes('api-reference')) { + fail( + `${route.id} SDK metadata must keep API Reference out of the primary sidebar; link it from Reference and SDK page bodies`, + ); + } + for (const page of ['api-reference']) { + const routePath = page === 'index' ? `/${route.route}` : `/${route.route}/${page}`; + if (!routeRecords.some((record) => record.route === routePath)) { + fail(`${route.id} SDK metadata requires reachable ${page} route`); + } + } + } + } +} + +function assertSdkSidebarPages() { + const expectedOrder = [ + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-kotlin', + 'oliphaunt-react-native', + 'oliphaunt-js', + 'oliphaunt-wasix', + 'liboliphaunt-native', + ]; + const actualOrder = manifest.routes + .filter((entry) => entry.kind === 'sdk') + .map((entry) => entry.id); + if (JSON.stringify(actualOrder) !== JSON.stringify(expectedOrder)) { + fail(`SDK route order must stay app-developer first: ${expectedOrder.join(' -> ')}`); + } + + for (const route of manifest.routes.filter((entry) => entry.kind === 'sdk')) { + const expected = + route.id === 'oliphaunt-react-native' + ? ['index', 'guide', 'architecture'] + : route.id === 'oliphaunt-wasix' + ? ['index', 'guide', 'runtime', 'dump-restore'] + : ['index', 'guide']; + const actual = route.sidebar_pages ?? []; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(`${route.id} sidebar_pages must be ${expected.join(', ')}`); + } + if (actual.includes('api-reference')) { + fail(`${route.id} sidebar_pages must not expose API Reference as a primary SDK page`); + } + } +} + +function assertReferenceSidebarPages() { + const route = manifest.routes.find((entry) => entry.id === 'reference'); + if (!route) { + fail('docs manifest is missing reference route'); + } + const expected = ['index', 'capabilities', 'extensions', 'performance']; + const actual = route.sidebar_pages ?? []; + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(`reference sidebar_pages must stay focused: ${expected.join(', ')}`); + } + for (const reachable of [ + 'sdk-products', + 'releases', + 'version-matrix', + 'extension-catalog', + 'api-reference', + ]) { + if (!(route.page_order ?? []).includes(reachable)) { + fail(`reference page_order must keep ${reachable} reachable from lookup pages`); + } + if (actual.includes(reachable)) { + fail(`reference sidebar_pages must keep ${reachable} as a lookup page, not primary nav`); + } + } +} + +function assertNoStaleGeneratedNavigation() { + const stale = path.join(generatedMetaRoot, 'sidebars.json'); + if (fs.existsSync(stale)) { + fail( + 'stale generated sidebars.json must not exist; Fumadocs metadata is generated from meta.json and navigation.json', + ); + } +} + +function assertPublicContentIsMarkdownOnly() { + const contentRoot = path.join(repoRoot, 'src/docs/content'); + const unexpected = []; + function visit(dirPath) { + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (entry.isFile() && !/\.mdx?$/u.test(entry.name)) { + unexpected.push(path.relative(repoRoot, fullPath)); + } + } + } + visit(contentRoot); + if (unexpected.length > 0) { + fail( + `public docs content may only contain Markdown/MDX pages; move data or policy files out of src/docs/content:\n${unexpected.join('\n')}`, + ); + } +} + +function collectPublicContentPages() { + const contentRoot = path.join(repoRoot, 'src/docs/content'); + const pages = []; + function visit(dirPath) { + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (entry.isFile() && /\.mdx?$/u.test(entry.name)) { + pages.push(fullPath); + } + } + } + visit(contentRoot); + return pages.sort(); +} + +function frontmatterValue(markdown, key) { + const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u); + if (!frontmatter) { + return ''; + } + const match = frontmatter[1].match(new RegExp(`^${key}\\s*:\\s*(.+)$`, 'mu')); + return match?.[1]?.trim().replace(/^["']|["']$/gu, '') ?? ''; +} + +function assertPublicContentMetadata() { + const missing = []; + for (const file of collectPublicContentPages()) { + const relative = path.relative(repoRoot, file); + const markdown = fs.readFileSync(file, 'utf8'); + const title = frontmatterValue(markdown, 'title'); + const description = frontmatterValue(markdown, 'description'); + if (!title) { + missing.push(`${relative}: missing title frontmatter`); + } + if (!description) { + missing.push(`${relative}: missing description frontmatter`); + } else if (description.length < 24) { + missing.push(`${relative}: description is too terse for a docs page`); + } + } + if (missing.length > 0) { + fail(`public docs pages must have explicit reader-facing metadata:\n${missing.join('\n')}`); + } +} + +function assertApplicabilityMetadata() { + const missing = []; + for (const record of routeRecords) { + const markdown = fs.readFileSync(record.file, 'utf8'); + if (!record.appliesTo || !/^applies_to\s*:/mu.test(markdown)) { + missing.push(record.source); + } + } + if (missing.length > 0) { + fail(`public docs pages must declare generated applies_to metadata:\n${missing.join('\n')}`); + } +} + +function assertLightweightVersioning() { + const releaseIndex = readText('src/docs/content/reference/releases.mdx'); + for (const required of [ + '`latest` channel', + 'package versions', + 'compatibility notes', + 'release notes', + 'Versioned docs remain available', + 'Documentation changes can update the docs site', + ]) { + if (!releaseIndex.includes(required)) { + fail(`release docs must describe lightweight docs versioning policy: missing ${required}`); + } + } + const versionMatrix = readText( + path.relative(repoRoot, path.join(siteDocsRoot, 'reference', 'version-matrix.md')), + ); + for (const required of [ + '| Product | Publish targets | Tag prefix |', + 'Release coupling is derived from Moon production and peer dependency scopes', + 'liboliphaunt-native', + 'oliphaunt-react-native', + 'oliphaunt-wasix-rust', + ]) { + if (!versionMatrix.includes(required)) { + fail(`generated version matrix is missing compatibility/release data: ${required}`); + } + } +} + +function assertRouteCoverage() { + const routes = new Set(routeRecords.map((record) => record.route)); + const requiredRoutes = []; + for (const route of manifest.routes ?? []) { + for (const page of route.page_order ?? ['index']) { + requiredRoutes.push(page === 'index' ? `/${route.route}` : `/${route.route}/${page}`); + } + for (const page of route.required_pages ?? []) { + requiredRoutes.push(page === 'index' ? `/${route.route}` : `/${route.route}/${page}`); + } + } + for (const route of requiredRoutes) { + if (!routes.has(route)) { + fail(`generated docs route missing: ${route}`); + } + } +} + +function assertPublicRootLandingPages() { + for (const route of manifest.routes.filter((entry) => entry.kind === 'public')) { + if (!(route.page_order ?? []).includes('index')) { + fail(`${route.id} public docs section must include an index landing page`); + } + const pagePath = routeSourcePagePath(route, 'index'); + if (!pagePath) { + fail(`${route.id} public docs section is missing index.md or index.mdx`); + } + } +} + +function assertLlmRouteCoverage() { + const llms = readText(path.relative(repoRoot, path.join(staticRoot, 'llms.txt'))); + const full = readText(path.relative(repoRoot, path.join(staticRoot, 'llms-full.txt'))); + for (const record of routeRecords) { + if (!llms.includes(record.route)) { + fail(`llms.txt is missing route ${record.route}`); + } + if (!full.includes(`Route: ${record.route}`)) { + fail(`llms-full.txt is missing route ${record.route}`); + } + } +} + +function stripMarkdownCodeBlocks(markdown) { + return markdown.replace(/```[\s\S]*?```/gu, ''); +} + +function extractHrefTargets(text) { + const hrefs = []; + const stripped = stripMarkdownCodeBlocks(text); + const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/gu; + const mdxHrefPattern = /\bhref=(?:"([^"]+)"|'([^']+)')/gu; + for (const match of stripped.matchAll(markdownLinkPattern)) { + hrefs.push(match[1]); + } + for (const match of stripped.matchAll(mdxHrefPattern)) { + hrefs.push(match[1] ?? match[2]); + } + return hrefs; +} + +function normalizedDocsPath(href) { + if (!href || href.startsWith('#')) { + return null; + } + if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//iu.test(href) || /^[a-z][a-z0-9+.-]*:/iu.test(href)) { + return null; + } + if (!href.startsWith('/docs')) { + return null; + } + const [withoutHash] = href.split('#'); + const [withoutQuery] = withoutHash.split('?'); + return withoutQuery.replace(/\/+$/u, '') || '/docs'; +} + +function collectSourceTextFiles(dirPath, output = []) { + if (!fs.existsSync(dirPath)) { + return output; + } + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + if (!['node_modules', '.next', 'out'].includes(entry.name)) { + collectSourceTextFiles(fullPath, output); + } + continue; + } + if (entry.isFile() && /\.(?:md|mdx|ts|tsx|js|jsx)$/iu.test(entry.name)) { + output.push(fullPath); + } + } + return output; +} + +function assertDocsInternalLinksResolve() { + const validDocsPaths = new Set(['/docs']); + for (const record of routeRecords) { + validDocsPaths.add(`/docs${record.route}`); + } + + const failures = []; + const files = [ + ...routeRecords.map((record) => record.file), + ...collectSourceTextFiles(path.join(repoRoot, 'src/docs/src')), + ]; + for (const file of files) { + const relative = path.relative(repoRoot, file); + const text = fs.readFileSync(file, 'utf8'); + for (const href of extractHrefTargets(text)) { + const docsPath = normalizedDocsPath(href); + if (docsPath && !validDocsPaths.has(docsPath)) { + failures.push(`${relative}: unresolved docs link ${href}`); + } + } + } + if (failures.length > 0) { + fail(`public docs contain unresolved internal links:\n${failures.join('\n')}`); + } +} + +function assertSdkSectionCoverage() { + const guideSummaryIds = { + 'liboliphaunt-native': 'c-abi', + 'oliphaunt-rust': 'rust', + 'oliphaunt-swift': 'swift', + 'oliphaunt-kotlin': 'kotlin', + 'oliphaunt-react-native': 'react-native', + 'oliphaunt-js': 'typescript', + 'oliphaunt-wasix': 'wasm', + }; + const guideHeadingOrder = { + 'liboliphaunt-native': [ + 'Install', + 'Open and query', + 'Configure', + 'Choose a mode', + 'Handle lifecycle', + 'Select extensions', + 'Back up and restore', + ], + default: [ + 'Install', + 'Open and query', + 'Create app data', + 'Configure', + 'Choose a mode', + 'Handle lifecycle', + 'Select extensions', + 'Back up and restore', + ], + 'oliphaunt-wasix': [ + 'Install', + 'Open and query', + 'Create app data', + 'Configure', + 'Choose a mode', + 'Handle lifecycle', + 'Select extensions', + 'Back up, dump, and restore', + ], + }; + for (const route of manifest.routes.filter((entry) => entry.kind === 'sdk')) { + const requiredPages = route.required_pages ?? []; + for (const required of ['index', 'guide', 'api-reference']) { + if (!requiredPages.includes(required)) { + fail(`${route.id} docs must declare ${required} in docs-manifest.toml`); + } + } + if ((route.page_order ?? []).length > 6) { + fail( + `${route.id} docs sidebar is too granular; keep Overview, Guide, API Reference, and only justified deep pages`, + ); + } + for (const page of requiredPages) { + const pagePath = routeSourcePagePath(route, page); + if (!pagePath) { + fail(`${route.id} docs are missing required page ${page}.md or ${page}.mdx`); + } + } + const indexPath = routeSourcePagePath(route, 'index'); + const indexMarkdown = readText(indexPath); + const landingId = guideSummaryIds[route.id]; + if (!landingId || !indexMarkdown.includes(``)) { + fail(`${route.id} SDK overview is missing the SDK landing component`); + } + const requiredOverviewHeadings = [ + 'Install', + 'Open And Query', + 'Runtime Shape', + 'App Responsibilities', + 'First Query', + ]; + let previousHeadingIndex = -1; + for (const heading of requiredOverviewHeadings) { + const headingPattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'mu'); + const headingIndex = indexMarkdown.search(headingPattern); + if (!headingPattern.test(indexMarkdown)) { + fail(`${route.id} SDK overview is missing required section: ${heading}`); + } + if (headingIndex < previousHeadingIndex) { + fail( + `${route.id} SDK overview sections must use this order: ${requiredOverviewHeadings.join(' -> ')}`, + ); + } + previousHeadingIndex = headingIndex; + } + const guidePath = routeSourcePagePath(route, 'guide'); + const guideMarkdown = readText(guidePath); + const guideSummaryId = guideSummaryIds[route.id]; + const hasGuideSummary = + guideSummaryId && guideMarkdown.includes(``); + const hasEquivalentGuideSummary = + route.id === 'oliphaunt-react-native' && + guideMarkdown.includes(''); + if (!hasGuideSummary && !hasEquivalentGuideSummary) { + fail(`${route.id} developer guide is missing the SDK guide summary component`); + } + if (!guideMarkdown.includes(``)) { + fail(`${route.id} developer guide is missing the SDK guide proof component`); + } + const expectedGuideHeadings = guideHeadingOrder[route.id] ?? guideHeadingOrder.default; + let previousGuideHeadingIndex = -1; + for (const heading of expectedGuideHeadings) { + const headingIndex = guideMarkdown.search( + new RegExp(`^###\\s+${escapeRegExp(heading)}\\s*$`, 'mu'), + ); + if (headingIndex < 0) { + fail(`${route.id} developer guide is missing required step heading: ${heading}`); + } + if (headingIndex < previousGuideHeadingIndex) { + fail( + `${route.id} developer guide steps must use this order: ${expectedGuideHeadings.join(' -> ')}`, + ); + } + previousGuideHeadingIndex = headingIndex; + } + if (!/^##\s+Troubleshooting\s*$/mu.test(guideMarkdown)) { + fail(`${route.id} developer guide is missing Troubleshooting`); + } + const sourceFiles = requiredPages.map((page) => + readText(routeSourcePagePath(route, page)).toLowerCase(), + ); + const combined = sourceFiles.join('\n'); + if (!combined.includes('exact') || !combined.includes('extension')) { + fail(`${route.id} docs must explain exact extension selection across its SDK section`); + } + if (!combined.includes('backup') || !combined.includes('restore')) { + fail(`${route.id} docs must include backup and restore guidance`); + } + if (route.id === 'oliphaunt-react-native') { + const architecturePath = routeSourcePagePath(route, 'architecture'); + if (!architecturePath) { + fail('React Native SDK docs are missing architecture.md or architecture.mdx'); + } + const architectureMarkdown = readText(architecturePath); + if (!architectureMarkdown.includes('')) { + fail('React Native architecture docs are missing ReactNativeBoundaryMap'); + } + for (const heading of [ + 'Runtime Ownership', + 'JavaScript Shape', + 'Binary Transport', + 'Config Plugin And Packaging', + 'Lifecycle', + 'Capabilities', + 'What The React Native SDK Owns', + ]) { + if (!new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'mu').test(architectureMarkdown)) { + fail(`React Native architecture docs are missing required section: ${heading}`); + } + } + } + if (route.id === 'oliphaunt-wasix') { + const runtimePath = routeSourcePagePath(route, 'runtime'); + if (!runtimePath) { + fail('WASM SDK docs are missing runtime.md or runtime.mdx'); + } + const runtimeMarkdown = readText(runtimePath); + if (!runtimeMarkdown.includes('')) { + fail('WASM runtime docs are missing WasmRuntimeMap'); + } + for (const heading of [ + 'Choose A Mode', + 'Persistence Modes', + 'Operational Limits', + 'Root Locking And Lifecycle', + 'Startup And Preload', + 'Supported Targets', + 'Server-Compatible Access', + ]) { + if (!new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'mu').test(runtimeMarkdown)) { + fail(`WASM runtime docs are missing required section: ${heading}`); + } + } + + const dumpRestorePath = routeSourcePagePath(route, 'dump-restore'); + if (!dumpRestorePath) { + fail('WASM SDK docs are missing dump-restore.md or dump-restore.mdx'); + } + const dumpRestoreMarkdown = readText(dumpRestorePath); + if (!dumpRestoreMarkdown.includes('')) { + fail('WASM dump/restore docs are missing WasmDataMovement'); + } + for (const heading of [ + 'Choose The Right Export Format', + 'Direct API', + 'Server API', + '`PgDumpOptions`', + 'CLI', + 'Restore', + 'Upgrade Guidance', + ]) { + if (!new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'mu').test(dumpRestoreMarkdown)) { + fail(`WASM dump/restore docs are missing required section: ${heading}`); + } + } + } + } +} + +function assertStartPageCoverage() { + const startRoute = manifest.routes.find((entry) => entry.id === 'start'); + if (!startRoute) { + fail('docs manifest is missing the Start route'); + } + const startPath = routeSourcePagePath(startRoute, 'index'); + if (!startPath) { + fail('Start docs are missing index.md or index.mdx'); + } + const markdown = readText(startPath); + const requiredComponents = [ + 'StartOutcome', + 'QuickstartPath', + 'FirstQueryFlow', + 'VerifyChecklist', + 'StartNextSteps', + ]; + for (const component of requiredComponents) { + if (!markdown.includes(`<${component}`)) { + fail(`Start docs are missing ${component}`); + } + } + const requiredHeadings = [ + 'Start In One App Target', + 'First Query Shape', + 'Install, Configure, Verify', + 'After The First Query', + ]; + let previousHeadingIndex = -1; + for (const heading of requiredHeadings) { + const headingIndex = markdown.search(new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'mu')); + if (headingIndex < 0) { + fail(`Start docs are missing required section: ${heading}`); + } + if (headingIndex < previousHeadingIndex) { + fail(`Start docs sections must use this order: ${requiredHeadings.join(' -> ')}`); + } + previousHeadingIndex = headingIndex; + } +} + +function assertReferencePageCoverage() { + const referenceRoute = manifest.routes.find((entry) => entry.id === 'reference'); + if (!referenceRoute) { + fail('docs manifest is missing the Reference route'); + } + const requirements = [ + { + page: 'capabilities', + title: 'Capability Matrix', + components: ['CapabilitySnapshot'], + headings: ['SDKs', 'Runtime Modes', 'Feature Support', 'Choosing A Mode'], + }, + { + page: 'extensions', + title: 'Extensions', + components: ['ExactExtensionRule', 'ExtensionArtifactFlow'], + headings: [ + 'How Selection Works', + 'Platform Behavior', + 'Dependencies', + 'External Extensions', + 'Verifying App Artifacts', + ], + }, + { + page: 'performance', + title: 'Performance', + components: ['PerformanceResultsGrid'], + headings: [ + 'What to measure', + 'Compare modes honestly', + 'SQLite comparison', + 'Release Measurements', + ], + }, + { + page: 'releases', + title: 'Releases', + components: ['ReleaseLookup'], + headings: ['Version Families', 'What A Release Tells You', 'Docs Versioning'], + }, + ]; + for (const requirement of requirements) { + const pagePath = routeSourcePagePath(referenceRoute, requirement.page); + if (!pagePath) { + fail(`Reference docs are missing ${requirement.page}.md or ${requirement.page}.mdx`); + } + const markdown = readText(pagePath); + if (!new RegExp(`^#\\s+${escapeRegExp(requirement.title)}\\s*$`, 'mu').test(markdown)) { + fail(`Reference page ${requirement.page} is missing title heading: ${requirement.title}`); + } + for (const component of requirement.components) { + if (!markdown.includes(`<${component}`)) { + fail(`Reference page ${requirement.page} is missing ${component}`); + } + } + let previousHeadingIndex = -1; + for (const heading of requirement.headings) { + const headingIndex = markdown.search( + new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'mu'), + ); + if (headingIndex < 0) { + fail(`Reference page ${requirement.page} is missing required section: ${heading}`); + } + if (headingIndex < previousHeadingIndex) { + fail( + `Reference page ${requirement.page} sections must use this order: ${requirement.headings.join(' -> ')}`, + ); + } + previousHeadingIndex = headingIndex; + } + } +} + +function assertLearnPageCoverage() { + const learnRoute = manifest.routes.find((entry) => entry.id === 'learn'); + if (!learnRoute) { + fail('docs manifest is missing the Learn route'); + } + const requirements = [ + { + page: 'embedded-postgres', + title: 'Embedded PostgreSQL', + components: ['EmbeddedPostgresModel'], + headings: [ + 'Root Storage', + 'Lifecycle Contract', + 'Extension Selection', + 'What is different from SQLite?', + ], + }, + { + page: 'native-runtime', + title: 'Native Runtime', + components: ['ModeMatrix'], + headings: [ + 'Choose a mode', + 'Runtime Semantics', + 'Direct Lifecycle', + 'Storage', + 'Startup Configuration', + 'Extensions', + 'Capabilities', + ], + }, + { + page: 'mobile-stability', + title: 'Mobile Stability', + components: ['MobileStabilityContract'], + headings: [ + 'What developers can rely on', + 'Close and reopen', + 'Background and foreground', + 'Choosing the mode', + ], + }, + { + page: 'sqlite-upgrade', + title: 'Moving From SQLite', + components: ['SqliteMigrationMap'], + headings: [ + 'Concept Map', + 'Schema And SQL Differences', + 'Storage And Backup', + 'Migration Path', + 'When SQLite Is Still The Better Fit', + ], + }, + { + page: 'tauri', + title: 'Tauri Usage', + components: ['TauriAppPattern'], + headings: [ + 'App Shape', + 'Direct Rust State', + 'Existing Postgres Clients', + 'Extensions And Assets', + 'Backup And Restore', + 'Operational Guidance', + ], + }, + ]; + for (const requirement of requirements) { + const pagePath = routeSourcePagePath(learnRoute, requirement.page); + if (!pagePath) { + fail(`Learn docs are missing ${requirement.page}.md or ${requirement.page}.mdx`); + } + const markdown = readText(pagePath); + if (!new RegExp(`^#\\s+${escapeRegExp(requirement.title)}\\s*$`, 'mu').test(markdown)) { + fail(`Learn page ${requirement.page} is missing title heading: ${requirement.title}`); + } + for (const component of requirement.components) { + if (!markdown.includes(`<${component}`)) { + fail(`Learn page ${requirement.page} is missing ${component}`); + } + } + let previousHeadingIndex = -1; + for (const heading of requirement.headings) { + const headingIndex = markdown.search( + new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'mu'), + ); + if (headingIndex < 0) { + fail(`Learn page ${requirement.page} is missing required section: ${heading}`); + } + if (headingIndex < previousHeadingIndex) { + fail( + `Learn page ${requirement.page} sections must use this order: ${requirement.headings.join(' -> ')}`, + ); + } + previousHeadingIndex = headingIndex; + } + } +} + +function markerDisplayId(marker) { + if (!marker) { + return ''; + } + const colon = marker.lastIndexOf(':'); + if (colon >= 0) { + return marker.slice(colon + 1); + } + const parts = marker.trim().split(/\s+/); + return parts[parts.length - 1] ?? marker; +} + +function assertSnippetMarkers() { + for (const route of manifest.routes.filter((entry) => entry.kind === 'sdk')) { + const snippetPath = route.tested_snippet_path; + const marker = route.tested_snippet_marker; + if (!snippetPath || !marker) { + fail(`SDK route ${route.id} must declare tested snippet path and marker`); + } + const source = readText(snippetPath); + if (!source.includes(marker)) { + fail(`${route.id} snippet source is missing marker "${marker}" in ${snippetPath}`); + } + const guidePath = routeSourcePagePath(route, 'guide'); + if (!guidePath) { + fail(`${route.id} guide source is missing`); + } + const guide = readText(guidePath); + if (!guide.includes(`oliphaunt-snippet: ${route.id}`)) { + fail(`${route.id} guide must include the manifest-owned snippet directive`); + } + } +} + +function flattenNavigationItems(items, output = []) { + for (const item of items ?? []) { + if (typeof item === 'string') { + output.push(item); + } else if (item?.type === 'category') { + flattenNavigationItems(item.items, output); + } + } + return output; +} + +function assertFumadocsMetaCoverage() { + const rootMeta = JSON.parse(fs.readFileSync(path.join(siteDocsRoot, 'meta.json'), 'utf8')); + for (const section of ['start', 'sdk', 'learn', 'reference']) { + if (!(rootMeta.pages ?? []).includes(section)) { + fail(`Fumadocs root meta is missing section ${section}`); + } + } + for (const section of ['concepts', 'guides', 'releases']) { + if ((rootMeta.pages ?? []).includes(section)) { + fail(`Fumadocs root meta still exposes stale shallow section ${section}`); + } + } + + for (const route of manifest.routes ?? []) { + const metaPath = path.join(siteDocsRoot, route.route, 'meta.json'); + if (!fs.existsSync(metaPath)) { + fail(`Fumadocs meta missing for route ${route.route}`); + } + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + const firstSegments = new Set(sidebarPagesForRoute(route).map((page) => page.split('/')[0])); + for (const segment of firstSegments) { + const present = (meta.pages ?? []).includes(segment) || meta.pagesIndex === segment; + if (!present) { + fail(`Fumadocs meta for ${route.route} is missing page or folder ${segment}`); + } + } + } +} + +function assertNavigationCoverage() { + const navigationPath = path.join(generatedMetaRoot, 'navigation.json'); + const navigation = JSON.parse(fs.readFileSync(navigationPath, 'utf8')); + const navigationItems = new Set(flattenNavigationItems(navigation.docs)); + const docIds = new Set(routeRecords.map((record) => record.docId)); + for (const item of navigationItems) { + if (!docIds.has(item)) { + fail(`generated navigation references missing doc id: ${item}`); + } + } + for (const route of manifest.routes ?? []) { + for (const page of sidebarPagesForRoute(route)) { + const item = `${route.route}/${page}`; + if (!navigationItems.has(item)) { + fail(`generated navigation missing sidebar page ${item}`); + } + } + } +} + +function assertApiReferenceSummary({ requireGenerated = false } = {}) { + const apiFileNames = { + 'liboliphaunt-native': 'c-abi', + 'oliphaunt-rust': 'rust', + 'oliphaunt-swift': 'swift', + 'oliphaunt-kotlin': 'kotlin', + 'oliphaunt-react-native': 'react-native', + 'oliphaunt-js': 'typescript', + 'oliphaunt-wasix': 'wasm', + }; + const expected = new Set( + manifest.routes.filter((entry) => entry.kind === 'sdk').map((entry) => entry.id), + ); + const records = new Map((apiSummary.records ?? []).map((record) => [record.id, record])); + for (const id of expected) { + const record = records.get(id); + if (!record) { + fail(`API reference summary missing ${id}`); + } + if (!record.status || record.status === 'stub' || record.status === 'failed') { + fail(`API reference status for ${id} is not truthful`); + } + if (!record.artifact) { + fail(`API reference summary for ${id} is missing an artifact path`); + } + if (requireGenerated && record.status !== 'generated') { + fail( + `API reference generation did not complete for ${id}: ${record.reason ?? record.status}`, + ); + } + } + for (const record of records.values()) { + const apiPage = apiFileNames[record.id] ?? record.id; + if (!routePageSet('reference').has(`api/${apiPage}`)) { + continue; + } + const siteApiPage = path.join(siteDocsRoot, 'reference', 'api', `${apiPage}.md`); + if (!fs.existsSync(siteApiPage)) { + fail(`generated API reference site page missing for ${record.id}`); + } + } +} + +function assertSdkManifestCoverage() { + const manifestSdkIds = new Set(Object.keys(sdkManifest.sdks ?? {})); + const required = ['rust', 'swift', 'kotlin', 'react-native', 'typescript']; + for (const sdk of required) { + if (!manifestSdkIds.has(sdk)) { + fail(`SDK manifest missing ${sdk}`); + } + } +} + +function assertReleaseGraphPolicy() { + if (releaseGraph.products?.['docs']) { + fail('docs must not be a release product'); + } +} + +function assertNoNodeModulesGenerated() { + const bad = routeRecords.filter((record) => + record.file.includes(`${path.sep}node_modules${path.sep}`), + ); + if (bad.length > 0) { + fail(`docs generator traversed node_modules:\n${bad.map((record) => record.file).join('\n')}`); + } +} + +function assertMdxComponentPagesStayMdx() { + const componentPattern = + /<(SdkChooser|SdkLanding|SdkGuideProof|StartOutcome|StartNextSteps|EmbeddedPostgresModel|MobileStabilityContract|SqliteMigrationMap|TauriAppPattern|ReactNativeBoundaryMap|WasmRuntimeMap|WasmDataMovement|CapabilitySnapshot|ExtensionArtifactFlow|PerformanceResultsGrid|ReleaseLookup|QuickstartPath|FirstQueryFlow|VerifyChecklist|ModeMatrix|ExactExtensionRule|Steps|Step|Callout|Tabs|Tab|Cards|Card|Files|File|Folder)\b/u; + const bad = routeRecords.filter( + (record) => record.file.endsWith('.md') && componentPattern.test(readText(record.source)), + ); + if (bad.length > 0) { + fail( + `docs pages with React components must be emitted as .mdx, not .md:\n${bad.map((record) => record.source).join('\n')}`, + ); + } +} + +function assertPublicDocsLanguageHygiene() { + const disallowed = [ + { label: 'stale sdk-parity route', pattern: /sdk-parity/u }, + { label: 'source checkout', pattern: /\bsource checkout\b/iu }, + { label: 'stale Expo Go wording', pattern: /\bExpo Go\b/u }, + { label: 'stale base64 transport wording', pattern: /\bbase64\b/iu }, + { label: 'advisory should wording', pattern: /\bshould\b/iu }, + { label: 'defensive should-not wording', pattern: /\bshould not\b/iu }, + { label: 'planning phrase "not pretend"', pattern: /\bnot pretend\b/iu }, + { label: 'runtime smoke evidence', pattern: /\bruntime smoke evidence\b/iu }, + { label: 'package evidence', pattern: /\bpackage evidence\b/iu }, + { label: 'real device evidence', pattern: /\breal device evidence\b/iu }, + { label: 'internal evidence wording', pattern: /\bevidence\b/iu }, + { + label: 'future or placeholder language', + pattern: /\b(?:TODO|placeholder|not yet|coming soon|eventually|can be added later)\b/iu, + }, + { label: 'release metadata internals', pattern: /\brelease metadata\b/iu }, + { label: 'maintainer-facing language', pattern: /\bmaintainer\b/iu }, + { label: 'internal-facing language', pattern: /\binternal\b/iu }, + { label: 'CI internals', pattern: /\bCI\b/u }, + { label: 'tooling path', pattern: /tools\//u }, + { label: 'source path', pattern: /src\//u }, + { label: 'target path', pattern: /target\//u }, + { label: 'fixture path', pattern: /fixtures\//u }, + { label: 'repo-structure language', pattern: /\bmonorepo\b/iu }, + { label: 'pre-release status language', pattern: /\bbefore the first stable\b/iu }, + { label: 'publication timing language', pattern: /\bonce release artifacts are published\b/iu }, + { label: 'defensive fallback wording', pattern: /\bfallback paths\b/iu }, + { + label: 'stale unavailable extension wording', + pattern: /\b(?:not available|not selected|not a pack)\b/iu, + }, + { label: 'stale WASM comparison wording', pattern: /\bOlder WASM examples\b/u }, + { label: 'defensive crash isolation wording', pattern: /\bCrash isolation belongs\b/u }, + { + label: 'defensive unsupported wording', + pattern: /\bunsupported (?:operation|extension|extensions)\b/iu, + }, + { label: 'internal lane wording', pattern: /\blane\b/iu }, + ]; + const failures = []; + for (const record of routeRecords) { + const markdown = readText(record.source); + const lines = markdown.split('\n'); + lines.forEach((line, index) => { + for (const rule of disallowed) { + if (rule.pattern.test(line)) { + failures.push(`${record.route}:${index + 1}: ${rule.label}: ${line.trim()}`); + } + } + }); + } + if (failures.length > 0) { + fail(`public generated docs include maintainer or planning language:\n${failures.join('\n')}`); + } +} + +function walkPublicTextFiles(dirPath, output = []) { + if (!fs.existsSync(dirPath)) { + return output; + } + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + walkPublicTextFiles(fullPath, output); + continue; + } + if (entry.isFile() && /\.(?:html|json|md|mdx|txt|xml)$/iu.test(entry.name)) { + output.push(fullPath); + } + } + return output; +} + +function assertPublicGeneratedOutputHygiene() { + const publicApiArtifacts = path.join(staticRoot, 'api-artifacts'); + if (!apiReferenceRequested && fs.existsSync(publicApiArtifacts)) { + fail( + 'default docs builds must not publish API reference artifacts; run the explicit api-reference task when those artifacts are needed', + ); + } + + const disallowed = [ + { label: 'stale sdk-parity route', pattern: /sdk-parity/iu }, + { label: 'source checkout', pattern: /\bsource checkout\b/iu }, + { label: 'stale Expo Go wording', pattern: /\bExpo Go\b/u }, + { label: 'stale base64 transport wording', pattern: /\bbase64\b/iu }, + { label: 'advisory should wording', pattern: /\bshould\b/iu }, + { label: 'defensive should-not wording', pattern: /\bshould not\b/iu }, + { label: 'planning phrase "not pretend"', pattern: /\bnot pretend\b/iu }, + { label: 'runtime smoke evidence', pattern: /\bruntime smoke evidence\b/iu }, + { label: 'package evidence', pattern: /\bpackage evidence\b/iu }, + { label: 'real device evidence', pattern: /\breal device evidence\b/iu }, + { label: 'internal evidence wording', pattern: /\bevidence\b/iu }, + { + label: 'future or placeholder language', + pattern: /\b(?:TODO|placeholder|not yet|coming soon|eventually|can be added later)\b/iu, + }, + { label: 'release metadata internals', pattern: /\brelease metadata\b/iu }, + { label: 'maintainer-facing language', pattern: /\bmaintainer\b/iu }, + { label: 'internal-facing language', pattern: /\binternal\b/iu }, + { label: 'CI internals', pattern: /\bCI\b/u }, + { label: 'tooling path', pattern: /tools\//u }, + { label: 'source path', pattern: /src\//u }, + { label: 'target path', pattern: /target\//u }, + { label: 'fixture path', pattern: /fixtures\//u }, + { label: 'repo-structure language', pattern: /\bmonorepo\b/iu }, + { label: 'pre-release status language', pattern: /\bbefore the first stable\b/iu }, + { label: 'publication timing language', pattern: /\bonce release artifacts are published\b/iu }, + { label: 'defensive fallback wording', pattern: /\bfallback paths\b/iu }, + { + label: 'stale unavailable extension wording', + pattern: /\b(?:not available|not selected|not a pack)\b/iu, + }, + { label: 'stale WASM comparison wording', pattern: /\bOlder WASM examples\b/u }, + { label: 'defensive crash isolation wording', pattern: /\bCrash isolation belongs\b/u }, + { + label: 'defensive unsupported wording', + pattern: /\bunsupported (?:operation|extension|extensions)\b/iu, + }, + { label: 'internal lane wording', pattern: /\blane\b/iu }, + { + label: 'generated API field', + pattern: /\b(?:implementation_path|documentation_path|tested_snippet|reference_artifact)\b/iu, + }, + { label: 'raw extension source kind', pattern: /\boliphaunt-other-extension\b/iu }, + { label: 'unrendered extension placeholder', pattern: /@EXTVERSION@|@MODULEPATH@/u }, + { label: 'generated reference wording', pattern: /\bgenerated language reference/iu }, + { + label: 'removed upstream reference', + pattern: new RegExp(`\\b${'pg'}${'lite'}\\b`, 'iu'), + }, + ]; + const failures = []; + for (const file of [...walkPublicTextFiles(siteDocsRoot), ...walkPublicTextFiles(staticRoot)]) { + const relative = path.relative(repoRoot, file); + const lines = fs.readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, index) => { + for (const rule of disallowed) { + if (rule.pattern.test(line)) { + failures.push(`${relative}:${index + 1}: ${rule.label}: ${line.trim()}`); + } + } + }); + } + if (failures.length > 0) { + fail( + `public generated docs output includes maintainer or planning language:\n${failures.join('\n')}`, + ); + } +} + +function assertReleaseReadinessDocs() { + for (const route of manifest.routes.filter((entry) => entry.kind === 'sdk')) { + const productId = route.product_id; + const product = releaseGraph.products?.[productId]; + if (!product) { + fail(`release metadata missing docs product ${productId}`); + } + if (product.changelog_path) { + requireFile(product.changelog_path); + } + for (const page of ['index', 'guide', 'api-reference']) { + const pagePath = routeSourcePagePath(route, page); + if (!pagePath) { + fail(`${productId} release docs are missing ${page}.md or ${page}.mdx`); + } + const markdown = readText(pagePath); + if (!markdown.includes('# ')) { + fail(`${productId} release docs page ${page}.md is missing a title heading`); + } + } + } +} + +assertNoTrackedRootProductsDocs(); +assertNoProductLocalPublicDocs(); +assertNoTrackedRootPublicDocs(); +assertRootDocsBuckets(); +assertNoDocsMoonProject(); +assertDocsChromeDoesNotExposeSourcePaths(); +assertUniqueRoutes(); +assertGeneratedFiles(); +assertGeneratedFumadocsMetadata(); +assertSdkSidebarPages(); +assertReferenceSidebarPages(); +assertNoStaleGeneratedNavigation(); +assertPublicContentIsMarkdownOnly(); +assertPublicContentMetadata(); +assertApplicabilityMetadata(); +assertLightweightVersioning(); +assertRouteCoverage(); +assertPublicRootLandingPages(); +assertLlmRouteCoverage(); +assertDocsInternalLinksResolve(); +assertStartPageCoverage(); +assertLearnPageCoverage(); +assertReferencePageCoverage(); +assertSdkSectionCoverage(); +assertSnippetMarkers(); +assertSdkManifestCoverage(); +assertReleaseGraphPolicy(); +assertNoNodeModulesGenerated(); +assertMdxComponentPagesStayMdx(); +assertPublicDocsLanguageHygiene(); +assertPublicGeneratedOutputHygiene(); +assertFumadocsMetaCoverage(); +assertNavigationCoverage(); +assertApiReferenceSummary({ requireGenerated: apiReferenceRequested }); + +if (args.has('--release')) { + assertReleaseReadinessDocs(); +} + +if (args.has('--snippets')) { + assertSnippetMarkers(); +} + +console.log(`docs product checks passed (${routeRecords.length} routes)`); diff --git a/src/docs/tools/check-fumadocs-source.mjs b/src/docs/tools/check-fumadocs-source.mjs new file mode 100644 index 00000000..a6f19fa1 --- /dev/null +++ b/src/docs/tools/check-fumadocs-source.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const docsRoot = path.resolve(scriptDir, '..'); +const sourceRoot = path.join(docsRoot, '.source'); + +const requiredFiles = [ + { + file: 'server.ts', + pattern: /\bexport\s+const\s+docs\b/u, + }, + { + file: 'browser.ts', + pattern: /\bexport\s+default\s+browserCollections\b/u, + }, + { + file: 'dynamic.ts', + pattern: /\bdynamic<.*\bConfig\b/su, + }, +]; + +for (const required of requiredFiles) { + const filePath = path.join(sourceRoot, required.file); + if (!fs.existsSync(filePath)) { + console.error(`Fumadocs generated source is missing: .source/${required.file}`); + process.exit(1); + } + const text = fs.readFileSync(filePath, 'utf8'); + if (text.trim().length === 0) { + console.error(`Fumadocs generated source is empty: .source/${required.file}`); + process.exit(1); + } + if (!required.pattern.test(text)) { + console.error(`Fumadocs generated source is malformed: .source/${required.file}`); + process.exit(1); + } +} diff --git a/src/docs/tools/generate-api-reference.mjs b/src/docs/tools/generate-api-reference.mjs new file mode 100644 index 00000000..f817ef35 --- /dev/null +++ b/src/docs/tools/generate-api-reference.mjs @@ -0,0 +1,462 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { parse as parseToml } from 'smol-toml'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const docsRoot = path.resolve(scriptDir, '..'); +const repoRoot = path.resolve(scriptDir, '../../..'); +const manifestPath = path.join(docsRoot, 'docs-manifest.toml'); +const apiRoot = path.join(repoRoot, 'target', 'docs', 'generated', 'api'); +const summaryPath = path.join(apiRoot, 'summary.json'); +const defaultCommandTimeoutMs = Number.parseInt( + process.env.OLIPHAUNT_DOCS_API_TIMEOUT_MS ?? '600000', + 10, +); + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function parseTomlFile(filePath) { + return parseToml(readText(filePath)); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function relative(filePath) { + return path.relative(repoRoot, filePath).replaceAll(path.sep, '/'); +} + +function commandExists(command) { + try { + execFileSync('sh', ['-c', `command -v ${command}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return true; + } catch { + return false; + } +} + +function run(command, args, options = {}) { + execFileSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + timeout: options.timeout ?? defaultCommandTimeoutMs, + env: { + ...process.env, + ...options.env, + }, + }); +} + +function commandFailureStatus(error) { + if (error?.signal === 'SIGTERM' || error?.killed || error?.code === 'ETIMEDOUT') { + return 'skipped'; + } + return 'failed'; +} + +function commandFailureReason(error) { + if (error?.signal === 'SIGTERM' || error?.killed || error?.code === 'ETIMEDOUT') { + return `timed out after ${defaultCommandTimeoutMs}ms`; + } + return error?.message ?? 'command failed'; +} + +function statusRecord(route, status, details) { + return { + id: route.id, + productId: route.product_id, + title: route.title, + referenceKind: route.reference_kind, + status, + ...details, + }; +} + +function parseCHeader(headerPath) { + const header = readText(headerPath); + const withoutComments = header.replace(/\/\*[\s\S]*?\*\//g, ''); + const functions = [ + ...withoutComments.matchAll( + /\b(?:int32_t|uint64_t|void|const\s+char\s+\*)\s+(oliphaunt_[a-z0-9_]+)\s*\(([\s\S]*?)\);/g, + ), + ].map((match) => ({ + name: match[1], + args: match[2].replace(/\s+/g, ' ').trim(), + })); + const constants = [ + ...header.matchAll(/^#define[ \t]+(OLIPHAUNT_[A-Z0-9_]+)(?:[ \t]+([^\r\n]+))?$/gm), + ] + .map((match) => ({ + name: match[1], + value: (match[2] ?? '').trim(), + })) + .filter((constant) => constant.value.length > 0); + return { functions, constants }; +} + +function escapeXml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function writeCReference(manifest, route, fullMode) { + const config = manifest.api_reference?.c ?? {}; + const headerPath = path.join( + repoRoot, + config.header ?? 'src/runtimes/liboliphaunt/native/include/oliphaunt.h', + ); + const outputRoot = path.join(apiRoot, 'c'); + const xmlRoot = path.join(outputRoot, 'xml'); + ensureDir(xmlRoot); + + const parsed = parseCHeader(headerPath); + const xml = ` + + + ${escapeXml(relative(headerPath))} + +${parsed.constants + .map( + (constant) => + ` ${escapeXml(constant.name)}${escapeXml(constant.value)}`, + ) + .join('\n')} + + +${parsed.functions + .map( + (fn) => + ` ${escapeXml(fn.name)}(${escapeXml(fn.args)})`, + ) + .join('\n')} + + + +`; + const fallbackXmlPath = path.join(xmlRoot, 'oliphaunt-header.xml'); + fs.writeFileSync(fallbackXmlPath, xml); + + const markdownPath = path.join(outputRoot, 'reference.md'); + fs.writeFileSync( + markdownPath, + `# C ABI Reference + +Generated from \`${relative(headerPath)}\`. + +## Functions + +${parsed.functions.map((fn) => `- \`${fn.name}(${fn.args})\``).join('\n')} + +## Constants + +${parsed.constants.map((constant) => `- \`${constant.name}\` = \`${constant.value}\``).join('\n')} +`, + ); + + let doxygenStatus = 'not-run'; + let doxygenXmlPath = ''; + let doxygenFailureReason = ''; + const doxygenConfig = config.doxygen_config; + const expectedDoxygenXml = path.join(apiRoot, 'c', 'doxygen', 'xml', 'index.xml'); + if (fullMode && doxygenConfig) { + if (commandExists('doxygen')) { + try { + run('doxygen', [doxygenConfig]); + if (fs.existsSync(expectedDoxygenXml)) { + doxygenStatus = 'generated'; + doxygenXmlPath = relative(expectedDoxygenXml); + } else { + doxygenStatus = 'failed: expected Doxygen XML index missing'; + doxygenFailureReason = 'Doxygen completed but expected XML index is missing'; + } + } catch (error) { + doxygenStatus = `failed: ${error.message}`; + doxygenFailureReason = commandFailureReason(error); + } + } else { + doxygenStatus = 'failed: doxygen not installed'; + doxygenFailureReason = 'doxygen not installed'; + } + } else if (doxygenConfig && fs.existsSync(expectedDoxygenXml)) { + doxygenStatus = 'generated'; + doxygenXmlPath = relative(expectedDoxygenXml); + } + const fullModeRequiresDoxygen = fullMode && Boolean(doxygenConfig); + const generatedByDoxygen = doxygenStatus === 'generated'; + + return statusRecord( + route, + fullModeRequiresDoxygen && !generatedByDoxygen ? 'failed' : 'generated', + { + artifact: relative(markdownPath), + machineReadableArtifact: relative(fallbackXmlPath), + docsEntry: relative(markdownPath), + symbolCount: parsed.functions.length, + constantCount: parsed.constants.length, + generator: generatedByDoxygen ? 'doxygen+xml' : 'header-parser+xml', + doxygenStatus, + doxygenXmlPath, + reason: doxygenFailureReason, + }, + ); +} + +function runCargoDoc(route, packageName, outputKey, fullMode) { + const outputRoot = path.join(apiRoot, outputKey); + ensureDir(outputRoot); + const docsEntry = path.join(outputRoot, 'doc', packageName.replaceAll('-', '_'), 'index.html'); + if (!fullMode) { + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'configured', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'cargo doc', + reason: fs.existsSync(docsEntry) + ? 'using existing generated rustdoc artifact' + : 'full rustdoc generation runs in release documentation checks', + }); + } + if (!commandExists('cargo')) { + return statusRecord(route, 'skipped', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'cargo doc', + reason: 'cargo not installed', + }); + } + try { + run('cargo', [ + 'doc', + '--no-deps', + '--package', + packageName, + '--target-dir', + relative(outputRoot), + ]); + run('cargo', ['test', '--doc', '--package', packageName], { env: { RUSTDOCFLAGS: '' } }); + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'failed', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'cargo doc', + reason: fs.existsSync(docsEntry) + ? '' + : 'cargo doc completed but expected index.html is missing', + }); + } catch (error) { + return statusRecord(route, commandFailureStatus(error), { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'cargo doc', + reason: commandFailureReason(error), + }); + } +} + +function runSwiftDocC(manifest, route, fullMode) { + const config = manifest.api_reference?.swift ?? {}; + const outputRoot = path.join(apiRoot, 'swift'); + ensureDir(outputRoot); + const docsEntry = path.join(outputRoot, 'Oliphaunt.doccarchive'); + if (!fullMode) { + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'configured', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Swift-DocC', + reason: fs.existsSync(docsEntry) + ? 'using existing generated DocC archive' + : 'full DocC generation runs in release documentation checks', + }); + } + if (!commandExists('swift')) { + return statusRecord(route, 'skipped', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Swift-DocC', + reason: 'swift not installed', + }); + } + try { + fs.rmSync(docsEntry, { force: true, recursive: true }); + run('swift', [ + 'package', + '--package-path', + config.package_path ?? 'src/sdks/swift', + '--allow-writing-to-directory', + relative(outputRoot), + 'generate-documentation', + '--target', + config.target ?? 'Oliphaunt', + '--output-path', + relative(docsEntry), + '--disable-indexing', + ]); + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'failed', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Swift-DocC', + reason: fs.existsSync(docsEntry) + ? '' + : 'Swift-DocC completed but expected archive is missing', + }); + } catch (error) { + return statusRecord(route, commandFailureStatus(error), { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Swift-DocC', + reason: commandFailureReason(error), + }); + } +} + +function runKotlinDokka(manifest, route, fullMode) { + const config = manifest.api_reference?.kotlin ?? {}; + const projectPath = path.join(repoRoot, config.project_path ?? 'src/sdks/kotlin'); + const gradlew = path.join(projectPath, 'gradlew'); + const docsEntry = path.join(apiRoot, 'kotlin', 'html', 'index.html'); + if (!fullMode) { + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'configured', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Dokka v2', + reason: fs.existsSync(docsEntry) + ? 'using existing generated Dokka artifact' + : 'full Dokka generation runs in release documentation checks', + }); + } + if (!fs.existsSync(gradlew)) { + return statusRecord(route, 'skipped', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Dokka v2', + reason: 'Gradle wrapper missing', + }); + } + try { + execFileSync( + gradlew, + ['--no-daemon', config.task ?? ':oliphaunt:dokkaGeneratePublicationHtml'], + { + cwd: projectPath, + stdio: 'inherit', + timeout: defaultCommandTimeoutMs, + env: { + ...process.env, + OLIPHAUNT_GRADLE_BUILD_ROOT: path.join(repoRoot, 'target', 'oliphaunt-gradle-build'), + }, + }, + ); + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'failed', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Dokka v2', + reason: fs.existsSync(docsEntry) ? '' : 'Dokka completed but expected index.html is missing', + }); + } catch (error) { + return statusRecord(route, commandFailureStatus(error), { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'Dokka v2', + reason: commandFailureReason(error), + }); + } +} + +function runTypeDoc(route, packagePath, outputKey, fullMode) { + const docsEntry = path.join(apiRoot, outputKey, 'html', 'index.html'); + if (!fullMode) { + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'configured', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'TypeDoc', + reason: fs.existsSync(docsEntry) + ? 'using existing generated TypeDoc artifact' + : 'full TypeDoc generation runs in release documentation checks', + }); + } + try { + run('pnpm', ['--dir', packagePath, 'run', 'docs:api']); + return statusRecord(route, fs.existsSync(docsEntry) ? 'generated' : 'failed', { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'TypeDoc', + reason: fs.existsSync(docsEntry) + ? '' + : 'TypeDoc completed but expected index.html is missing', + }); + } catch (error) { + return statusRecord(route, commandFailureStatus(error), { + artifact: relative(docsEntry), + docsEntry: relative(docsEntry), + generator: 'TypeDoc', + reason: commandFailureReason(error), + }); + } +} + +function routeById(manifest, id) { + return manifest.routes.find((route) => route.id === id); +} + +export function generateApiReferenceArtifacts(options = {}) { + const manifest = options.manifest ?? parseTomlFile(manifestPath); + const fullMode = options.mode === 'release' || options.mode === 'full'; + ensureDir(apiRoot); + fs.rmSync(summaryPath, { force: true }); + + const records = [ + writeCReference(manifest, routeById(manifest, 'liboliphaunt-native'), fullMode), + runCargoDoc(routeById(manifest, 'oliphaunt-rust'), 'oliphaunt', 'rust', fullMode), + runSwiftDocC(manifest, routeById(manifest, 'oliphaunt-swift'), fullMode), + runKotlinDokka(manifest, routeById(manifest, 'oliphaunt-kotlin'), fullMode), + runTypeDoc( + routeById(manifest, 'oliphaunt-react-native'), + 'src/sdks/react-native', + 'react-native', + fullMode, + ), + runTypeDoc(routeById(manifest, 'oliphaunt-js'), 'src/sdks/js', 'typescript', fullMode), + runCargoDoc(routeById(manifest, 'oliphaunt-wasix'), 'oliphaunt-wasix', 'wasm', fullMode), + ]; + + const summary = { + mode: fullMode ? 'release' : 'fast', + generatedAt: new Date().toISOString(), + records, + }; + fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); + return summary; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')); + const mode = modeArg ? modeArg.split('=')[1] : 'fast'; + const summary = generateApiReferenceArtifacts({ mode }); + console.log( + `generated API reference status for ${summary.records.length} surfaces (${summary.mode})`, + ); + const requireGenerated = + mode === 'release' || process.env.OLIPHAUNT_DOCS_REQUIRE_NATIVE_API === '1'; + const failed = summary.records.filter((record) => + requireGenerated ? record.status !== 'generated' : record.status === 'failed', + ); + if (failed.length > 0) { + for (const record of failed) { + console.error(`${record.id}: ${record.status}: ${record.reason || record.doxygenStatus}`); + } + process.exit(1); + } +} diff --git a/src/docs/tools/generate-content.mjs b/src/docs/tools/generate-content.mjs new file mode 100644 index 00000000..b46268b7 --- /dev/null +++ b/src/docs/tools/generate-content.mjs @@ -0,0 +1,1215 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { parse as parseToml } from 'smol-toml'; + +import { generateApiReferenceArtifacts } from './generate-api-reference.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const docsRoot = path.resolve(scriptDir, '..'); +const repoRoot = path.resolve(scriptDir, '../../..'); +const manifestPath = path.join(docsRoot, 'docs-manifest.toml'); +const generatedRoot = path.join(repoRoot, 'target', 'docs'); +const siteDocsRoot = path.join(generatedRoot, 'site-docs'); +const staticRoot = path.join(generatedRoot, 'static'); +const staticApiArtifactsRoot = path.join(staticRoot, 'api-artifacts'); +const generatedMetaRoot = path.join(generatedRoot, 'generated'); +const generationLockDir = path.join(generatedRoot, '.generate.lock'); +const generationLockMetadata = path.join(generationLockDir, 'owner.json'); + +const SKIP_DIRS = new Set(['node_modules', '.git', '.moon', '.docusaurus', 'build', 'target']); + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function parseTomlFile(filePath) { + return parseToml(readText(filePath)); +} + +function parseJsonFile(filePath) { + return JSON.parse(readText(filePath)); +} + +function releaseProductMetadata() { + const releasePlease = parseJsonFile(path.join(repoRoot, 'release-please-config.json')); + const packages = releasePlease.packages ?? {}; + const tagSeparator = releasePlease['tag-separator'] ?? '-'; + const tagVersionPrefix = releasePlease['include-v-in-tag'] === false ? '' : 'v'; + const products = {}; + for (const [packagePath, packageConfig] of Object.entries(packages)) { + const productId = packageConfig.component; + if (!productId) { + throw new Error(`release-please package ${packagePath} is missing component`); + } + const metadata = parseTomlFile(path.join(repoRoot, packagePath, 'release.toml')); + products[productId] = { + ...metadata, + tag_prefix: `${productId}${tagSeparator}${tagVersionPrefix}`, + }; + } + return { + policy: { + repository: 'f0rr0/oliphaunt', + default_branch: 'main', + versioning: 'independent', + extension_selection: 'exact-sql-extension', + }, + input_groups: {}, + products, + }; +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function resetDir(dirPath) { + fs.rmSync(dirPath, { force: true, recursive: true }); + ensureDir(dirPath); +} + +function resetGeneratedMetadata() { + ensureDir(generatedMetaRoot); + for (const entry of fs.readdirSync(generatedMetaRoot, { withFileTypes: true })) { + if (entry.name === 'api') { + continue; + } + fs.rmSync(path.join(generatedMetaRoot, entry.name), { force: true, recursive: true }); + } +} + +function sleep(milliseconds) { + const buffer = new SharedArrayBuffer(4); + const view = new Int32Array(buffer); + Atomics.wait(view, 0, 0, milliseconds); +} + +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function removeStaleGenerationLock() { + if (!fs.existsSync(generationLockDir)) { + return false; + } + try { + if (fs.existsSync(generationLockMetadata)) { + const metadata = JSON.parse(readText(generationLockMetadata)); + if (!processIsAlive(metadata.pid)) { + fs.rmSync(generationLockDir, { force: true, recursive: true }); + return true; + } + return false; + } + const stat = fs.statSync(generationLockDir); + if (Date.now() - stat.mtimeMs > 120_000) { + fs.rmSync(generationLockDir, { force: true, recursive: true }); + return true; + } + } catch { + fs.rmSync(generationLockDir, { force: true, recursive: true }); + return true; + } + return false; +} + +function withGenerationLock(callback) { + ensureDir(generatedRoot); + const started = Date.now(); + while (true) { + try { + fs.mkdirSync(generationLockDir); + fs.writeFileSync( + generationLockMetadata, + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2)}\n`, + ); + break; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + if (removeStaleGenerationLock()) { + continue; + } + if (Date.now() - started > 120_000) { + throw new Error('timed out waiting for docs generation lock'); + } + sleep(100); + } + } + try { + return callback(); + } finally { + fs.rmSync(generationLockDir, { force: true, recursive: true }); + } +} + +function assertInsideRepo(relativePath, label) { + if (!relativePath || path.isAbsolute(relativePath) || relativePath.includes('\0')) { + throw new Error(`${label} must be a repository-relative path`); + } + const resolved = path.resolve(repoRoot, relativePath); + if (!resolved.startsWith(repoRoot + path.sep)) { + throw new Error(`${label} escapes the repository: ${relativePath}`); + } + return resolved; +} + +function replaceSnippetDirectives(markdown, context) { + return markdown.replace( + //giu, + (match, routeId) => { + if (!context.sdkRoutesById.has(routeId)) { + throw new Error(`unknown docs snippet route id: ${routeId}`); + } + return ''; + }, + ); +} + +function yamlString(value) { + return JSON.stringify(String(value ?? '')); +} + +function ensureTitleFrontmatter(markdown, fallbackTitle) { + const title = firstHeading(markdown, fallbackTitle); + const frontmatterMatch = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u); + if (!frontmatterMatch) { + return `---\ntitle: ${yamlString(title)}\n---\n\n${markdown}`; + } + if (/^title\s*:/mu.test(frontmatterMatch[1])) { + return markdown; + } + return markdown.replace(/^---\r?\n/u, `---\ntitle: ${yamlString(title)}\n`); +} + +function stripMatchingLeadingTitleHeading(markdown) { + const title = frontmatterValue(markdown, 'title'); + if (!title) { + return markdown; + } + const frontmatterMatch = markdown.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/u); + const prefix = frontmatterMatch ? frontmatterMatch[1] : ''; + const body = frontmatterMatch ? frontmatterMatch[2] : markdown; + const headingPattern = /^(\s*)#\s+(.+?)\s*#?\s*(?:\r?\n|$)/u; + const headingMatch = body.match(headingPattern); + if (!headingMatch || headingMatch[1].trim().length > 0) { + return markdown; + } + if (headingMatch[2].trim() !== title) { + return markdown; + } + const strippedBody = body.slice(headingMatch[0].length).replace(/^\r?\n/u, ''); + return `${prefix}${strippedBody}`; +} + +function normalizePageMarkdown(markdown, fallbackTitle) { + return stripMatchingLeadingTitleHeading(ensureTitleFrontmatter(markdown, fallbackTitle)); +} + +function normalizeCodeFenceInfoStrings(markdown) { + return markdown.replace( + /^(`{3,})([A-Za-z0-9_+-]+),([^\r\n]*)$/gmu, + (_match, fence, lang, meta) => { + return `${fence}${lang} ${meta.trim()}`; + }, + ); +} + +function copyDir(source, destination, context) { + if (!fs.existsSync(source)) { + throw new Error(`docs source does not exist: ${path.relative(repoRoot, source)}`); + } + ensureDir(destination); + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) { + continue; + } + const from = path.join(source, entry.name); + const to = path.join(destination, entry.name); + if (entry.isDirectory()) { + copyDir(from, to, context); + } else if (entry.isFile()) { + if (/\.mdx?$/u.test(entry.name)) { + const markdown = normalizeCodeFenceInfoStrings( + replaceSnippetDirectives(readText(from), context), + ); + const fallbackTitle = path.basename(entry.name, path.extname(entry.name)); + fs.writeFileSync(to, normalizePageMarkdown(markdown, fallbackTitle)); + } else { + fs.copyFileSync(from, to); + } + } + } +} + +function routeSourcePagePath(source, page) { + for (const extension of ['.md', '.mdx']) { + const candidate = path.join(source, `${page}${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function copyMarkdownPage(from, to, context) { + const markdown = normalizeCodeFenceInfoStrings(replaceSnippetDirectives(readText(from), context)); + const fallbackTitle = path.basename(from, path.extname(from)); + ensureDir(path.dirname(to)); + fs.writeFileSync(to, normalizePageMarkdown(markdown, fallbackTitle)); +} + +function copyRoutePages(route, context) { + const source = assertInsideRepo(route.source, `source for ${route.id}`); + const destination = path.join(siteDocsRoot, route.route); + ensureDir(destination); + for (const page of uniqueInOrder([ + ...(route.page_order ?? []), + ...(route.required_pages ?? []), + ])) { + const from = routeSourcePagePath(source, page); + if (!from) { + continue; + } + const to = path.join(destination, `${page}${path.extname(from)}`); + copyMarkdownPage(from, to, context); + } +} + +function copyStaticPath(source, destination) { + if (!fs.existsSync(source)) { + return false; + } + ensureDir(path.dirname(destination)); + const stat = fs.statSync(source); + if (stat.isDirectory()) { + fs.cpSync(source, destination, { force: true, recursive: true }); + } else if (stat.isFile()) { + fs.copyFileSync(source, destination); + } + return true; +} + +function escapeMarkdown(value) { + return String(value ?? '') + .replaceAll('\\', '\\\\') + .replaceAll('|', '\\|') + .replaceAll('\n', ' '); +} + +function firstHeading(markdown, fallback) { + const match = markdown.match(/^#\s+(.+)$/m); + return match ? match[1].trim() : fallback; +} + +function frontmatterValue(markdown, key) { + const frontmatterMatch = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u); + if (!frontmatterMatch) { + return ''; + } + const match = frontmatterMatch[1].match(new RegExp(`^${key}\\s*:\\s*(.+)$`, 'mu')); + if (!match) { + return ''; + } + return match[1].trim().replace(/^["']|["']$/gu, ''); +} + +function collectMarkdownFiles(root) { + const files = []; + function visit(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) { + continue; + } + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (entry.isFile() && /\.mdx?$/.test(entry.name)) { + files.push(fullPath); + } + } + } + if (fs.existsSync(root)) { + visit(root); + } + return files.sort(); +} + +function markdownRouteFor(filePath) { + const relative = path.relative(siteDocsRoot, filePath).replaceAll(path.sep, '/'); + const withoutExtension = relative.replace(/\.mdx?$/, ''); + const route = withoutExtension.replace(/\/index$/, ''); + return `/${route}`; +} + +function markdownDocIdFor(filePath) { + return path + .relative(siteDocsRoot, filePath) + .replaceAll(path.sep, '/') + .replace(/\.mdx?$/, ''); +} + +function releaseProducts(releaseGraph) { + return Object.entries(releaseGraph.products ?? {}).sort(([left], [right]) => + left.localeCompare(right), + ); +} + +function sdkRows(sdkManifest) { + return Object.entries(sdkManifest.sdks ?? {}).sort(([left], [right]) => + left.localeCompare(right), + ); +} + +function generateSdkMatrix(sdkManifest) { + const rows = sdkRows(sdkManifest).map(([id, sdk]) => { + const modes = (sdk.available_modes ?? []).join(', '); + return `| ${escapeMarkdown(id)} | ${escapeMarkdown(sdk.package_name)} | ${escapeMarkdown((sdk.primary_targets ?? []).join(', '))} | ${escapeMarkdown(sdk.runtime_boundary)} | ${escapeMarkdown(modes)} |`; + }); + return `--- +title: SDK Matrix +--- + +# SDK Matrix + +Use this matrix to compare package names, primary targets, runtime boundaries, +and advertised modes. + +| SDK | Package | Targets | Runtime boundary | Advertised modes | +| --- | --- | --- | --- | --- | +${rows.join('\n')} +`; +} + +function generatePlatformMatrix(sdkManifest) { + const rows = sdkRows(sdkManifest).flatMap(([id, sdk]) => + (sdk.primary_targets ?? []).map( + (target) => + `| ${escapeMarkdown(target)} | ${escapeMarkdown(id)} | ${escapeMarkdown(sdk.package_name)} |`, + ), + ); + return `--- +title: Platform And Package Matrix +--- + +# Platform And Package Matrix + +Use this matrix to pick the package for each app target. + +| Platform target | SDK | Package | +| --- | --- | --- | +${rows.join('\n')} +`; +} + +function generateExtensionCatalog() { + const catalogPath = path.join(repoRoot, 'src/extensions/generated/extensions.catalog.json'); + if (!fs.existsSync(catalogPath)) { + throw new Error('extension catalog source is required for public docs generation'); + } + const catalog = JSON.parse(readText(catalogPath)); + const rows = (catalog.extensions ?? []) + .filter((extension) => { + const promotion = extension.promotion ?? {}; + return promotion.stable && promotion.packaged && promotion.promoted; + }) + .sort((left, right) => + String(left['sql-name'] ?? left.id).localeCompare(String(right['sql-name'] ?? right.id)), + ) + .map((extension) => { + const control = extension.control ?? {}; + return `| ${escapeMarkdown(extension['sql-name'] ?? extension.id)} | ${escapeMarkdown(extension['display-name'] ?? extension.id)} | ${escapeMarkdown(extensionVersion(control['default-version']))} | ${escapeMarkdown(extensionFamily(extension['source-kind']))} | ${escapeMarkdown(extensionActivation(extension))} |`; + }); + return `--- +title: Extension Catalog +--- + +# Extension Catalog + +Use this table to find exact SQL extension names. SDK and app packaging +selection uses the SQL extension name. The table shows stable packaged +extensions only. + +| SQL extension | Display name | Version | Family | Activation | +| --- | --- | --- | --- | --- | +${rows.join('\n')} +`; +} + +function extensionVersion(version) { + if (!version || String(version).includes('@')) { + return 'Packaged with runtime'; + } + return version; +} + +function extensionFamily(sourceKind) { + const labels = { + 'postgres-contrib': 'PostgreSQL contrib', + 'oliphaunt-other-extension': 'External extension', + postgis: 'PostGIS', + }; + return labels[sourceKind] ?? 'Extension artifact'; +} + +function extensionActivation(extension) { + if (extension.lifecycle?.['create-extension'] === false) { + return 'Runtime module'; + } + return 'CREATE EXTENSION'; +} + +function statusLabel(status) { + if (status === 'generated') { + return 'generated'; + } + if (status === 'configured') { + return 'configured'; + } + if (status === 'skipped') { + return 'skipped'; + } + return status || 'unknown'; +} + +function referenceLabel(referenceKind) { + const labels = { + doxygen: 'Doxygen header reference', + rustdoc: 'Rustdoc', + 'swift-docc': 'Swift DocC', + dokka: 'Dokka', + typedoc: 'TypeDoc', + }; + return labels[referenceKind] ?? referenceKind ?? 'API reference'; +} + +function link(label, href) { + return href ? `[${label}](${href})` : ''; +} + +function apiArtifactHref(relativePath) { + return `/api-artifacts/${relativePath}`; +} + +function staticArtifactPlan(record) { + const plans = { + 'liboliphaunt-native': [ + { + source: record.artifact, + destination: 'c/reference.md', + href: apiArtifactHref('c/reference.md'), + label: 'Open C ABI Markdown', + }, + { + source: record.machineReadableArtifact, + destination: 'c/xml/oliphaunt-header.xml', + href: apiArtifactHref('c/xml/oliphaunt-header.xml'), + label: 'Open C ABI XML', + }, + { + source: record.doxygenXmlPath, + destination: 'c/doxygen/xml/index.xml', + href: apiArtifactHref('c/doxygen/xml/index.xml'), + label: 'Open Doxygen XML index', + }, + ], + 'oliphaunt-rust': [ + { + source: 'target/docs/generated/api/rust/doc', + destination: 'rust/doc', + href: apiArtifactHref('rust/doc/oliphaunt/index.html'), + label: 'Open rustdoc', + }, + ], + 'oliphaunt-swift': [ + { + source: record.artifact, + destination: 'swift/Oliphaunt.doccarchive', + href: apiArtifactHref('swift/Oliphaunt.doccarchive/index.html'), + label: 'Open Swift DocC archive', + }, + ], + 'oliphaunt-kotlin': [ + { + source: 'target/docs/generated/api/kotlin/html', + destination: 'kotlin/html', + href: apiArtifactHref('kotlin/html/index.html'), + label: 'Open Dokka reference', + }, + ], + 'oliphaunt-react-native': [ + { + source: 'target/docs/generated/api/react-native/html', + destination: 'react-native/html', + href: apiArtifactHref('react-native/html/index.html'), + label: 'Open TypeDoc reference', + }, + ], + 'oliphaunt-js': [ + { + source: 'target/docs/generated/api/typescript/html', + destination: 'typescript/html', + href: apiArtifactHref('typescript/html/index.html'), + label: 'Open TypeDoc reference', + }, + ], + 'oliphaunt-wasix': [ + { + source: 'target/docs/generated/api/wasm/doc', + destination: 'wasm/doc', + href: apiArtifactHref('wasm/doc/oliphaunt_wasix/index.html'), + label: 'Open WASM rustdoc', + }, + ], + }; + return plans[record.id] ?? []; +} + +function copyApiArtifactsToStatic(apiSummary) { + const linksByRecordId = new Map(); + for (const record of apiSummary.records ?? []) { + const links = []; + for (const plan of staticArtifactPlan(record)) { + if (!plan.source) { + continue; + } + const source = assertInsideRepo(plan.source, `API artifact for ${record.id}`); + const destination = path.join(staticApiArtifactsRoot, plan.destination); + if (copyStaticPath(source, destination)) { + links.push({ + label: plan.label, + href: plan.href, + }); + } + } + linksByRecordId.set(record.id, links); + } + return linksByRecordId; +} + +function cReferenceBody(record) { + if (record.id !== 'liboliphaunt-native' || !record.artifact) { + return ''; + } + const artifactPath = path.join(repoRoot, record.artifact); + if (!fs.existsSync(artifactPath)) { + return ''; + } + return readText(artifactPath) + .replace(/^# C ABI Reference\s*/u, '') + .trim(); +} + +function generateApiReference(manifest) { + const rows = manifest.routes + .filter((route) => route.kind === 'sdk') + .map((route) => { + const reference = referenceLabel(route.reference_kind); + return `| ${escapeMarkdown(route.title)} | [Open](/docs/${route.route}/api-reference) | ${escapeMarkdown(reference)} |`; + }); + return `--- +title: API Reference +--- + +# API Reference + +Use this page when you know the SDK and need the API surface by task. SDK guides +show the first integration path. These maps point to the language reference for +configuration, query results, lifecycle, extension selection, backup and +restore, and error handling. + +## Choose By Task + +| Task | Look for | +| --- | --- | +| Open a database | builder or open configuration, root storage, runtime mode, durability | +| Run SQL | query, execute, parameters, row access, result typing | +| Use raw protocol | raw bytes, streaming, response ownership, cancellation | +| Manage lifecycle | close, background, foreground, cancellation, capability checks | +| Move data | backup, restore, dump, archive validation | +| Ship extensions | exact SQL extension names, dependency files, artifact reports | +| Handle errors | SDK errors, PostgreSQL SQLSTATE data, capability errors | + +## Language References + +| Surface | Reference page | Native reference format | +| --- | --- | --- | +${rows.join('\n')} +`; +} + +function generateSdkApiReferencePage(record, artifactLinks = []) { + const cBody = cReferenceBody(record); + const links = artifactLinks + .map((artifactLink) => `- ${link(artifactLink.label, artifactLink.href)}`) + .join('\n'); + const reference = referenceLabel(record.referenceKind); + return `--- +title: ${record.title} +--- + +# ${record.title} + +Use this page with the ${reference}. Product guides explain runtime behavior; +the API reference gives exact declarations for the released SDK. + +${statusLabel(record.status) === 'generated' && links ? `## Reference\n\n${links}\n` : ''} +${cBody ? `## Symbols\n\n${cBody}\n` : ''} +`; +} + +function apiReferenceFileName(record) { + const names = { + 'liboliphaunt-native': 'c-abi', + 'oliphaunt-rust': 'rust', + 'oliphaunt-swift': 'swift', + 'oliphaunt-kotlin': 'kotlin', + 'oliphaunt-react-native': 'react-native', + 'oliphaunt-js': 'typescript', + 'oliphaunt-wasix': 'wasm', + }; + return names[record.id] ?? record.id; +} + +function generateTestedSnippets(manifest) { + const rows = manifest.routes + .filter((route) => route.kind === 'sdk') + .map( + (route) => + `| ${escapeMarkdown(route.title)} | ${escapeMarkdown(route.tested_snippet_marker)} | ${escapeMarkdown(route.tested_snippet_path)} |`, + ); + return `--- +title: Tested Snippets +--- + +# Tested Snippets + +Public SDK snippets are tied to executable product tests or smoke files by +marker. The docs checker fails when a marker disappears. + +| Surface | Marker | Executable source | +| --- | --- | --- | +${rows.join('\n')} +`; +} + +function generateArtifactProvenance(releaseGraph) { + const rows = releaseProducts(releaseGraph).map(([id, product]) => { + return `| ${escapeMarkdown(id)} | ${escapeMarkdown((product.publish_targets ?? []).join(', '))} | ${escapeMarkdown((product.release_artifacts ?? []).join(', '))} | ${escapeMarkdown(product.tag_prefix)} |`; + }); + return `--- +title: Artifact And Provenance Matrix +--- + +# Artifact And Provenance Matrix + +Release verification checks asset checksums, attestations, and registry +publication for these surfaces. + +| Product | Publish targets | Release artifacts | Tag prefix | +| --- | --- | --- | --- | +${rows.join('\n')} +`; +} + +function generateVersionMatrix(releaseGraph) { + const rows = releaseProducts(releaseGraph).map(([id, product]) => { + return `| ${escapeMarkdown(id)} | ${escapeMarkdown((product.publish_targets ?? []).join(', ') || 'none')} | ${escapeMarkdown(product.tag_prefix)} |`; + }); + return `--- +title: Version Matrix +--- + +# Version Matrix + +Products are versioned independently. + +Use this matrix before upgrading an app dependency. Start with the package your +app installs, then read the products it depends on for runtime artifact, +extension, and compatibility notes. + +Release coupling is derived from Moon production and peer dependency scopes. + +| Product | Publish targets | Tag prefix | +| --- | --- | --- | +${rows.join('\n')} +`; +} + +function routePageSet(manifest, routeId) { + const route = (manifest.routes ?? []).find((entry) => entry.id === routeId); + return new Set(route?.page_order ?? []); +} + +function writeGeneratedReferencePages( + manifest, + sdkManifest, + releaseGraph, + apiSummary, + artifactLinksByRecordId, +) { + const referenceRoot = path.join(siteDocsRoot, 'reference'); + const apiRootForSite = path.join(referenceRoot, 'api'); + const referencePages = routePageSet(manifest, 'reference'); + ensureDir(referenceRoot); + if (referencePages.has('sdk-matrix')) { + fs.writeFileSync(path.join(referenceRoot, 'sdk-matrix.md'), generateSdkMatrix(sdkManifest)); + } + if (referencePages.has('platforms')) { + fs.writeFileSync(path.join(referenceRoot, 'platforms.md'), generatePlatformMatrix(sdkManifest)); + } + if (referencePages.has('extension-catalog')) { + fs.writeFileSync(path.join(referenceRoot, 'extension-catalog.md'), generateExtensionCatalog()); + } + if (referencePages.has('api-reference')) { + fs.writeFileSync(path.join(referenceRoot, 'api-reference.md'), generateApiReference(manifest)); + } + const apiPages = [...referencePages] + .filter((page) => page.startsWith('api/')) + .map((page) => page.slice('api/'.length)); + if (apiPages.length > 0) { + ensureDir(apiRootForSite); + for (const record of apiSummary.records ?? []) { + const fileName = apiReferenceFileName(record); + if (!apiPages.includes(fileName)) { + continue; + } + fs.writeFileSync( + path.join(apiRootForSite, `${fileName}.md`), + generateSdkApiReferencePage(record, artifactLinksByRecordId.get(record.id) ?? []), + ); + } + } + if (referencePages.has('tested-snippets')) { + fs.writeFileSync( + path.join(referenceRoot, 'tested-snippets.md'), + generateTestedSnippets(manifest), + ); + } + if (referencePages.has('artifact-provenance')) { + fs.writeFileSync( + path.join(referenceRoot, 'artifact-provenance.md'), + generateArtifactProvenance(releaseGraph), + ); + } + if (referencePages.has('version-matrix')) { + fs.writeFileSync( + path.join(referenceRoot, 'version-matrix.md'), + generateVersionMatrix(releaseGraph), + ); + } +} + +function writeMetadata(routeRecords) { + ensureDir(generatedMetaRoot); + fs.writeFileSync( + path.join(generatedMetaRoot, 'routes.json'), + `${JSON.stringify({ routes: routeRecords }, null, 2)}\n`, + ); +} + +function itemForPage(route, page) { + return `${route.route}/${page}`; +} + +const routePresentation = { + start: { + description: 'Install an SDK, open app-owned storage, and run the first PostgreSQL query.', + icon: 'Route', + defaultOpen: true, + collapsible: false, + }, + sdk: { + description: 'Choose Rust, Swift, Kotlin, React Native, TypeScript, WASM, or the C ABI.', + icon: 'PackageCheck', + defaultOpen: false, + }, + learn: { + description: + 'Understand embedded PostgreSQL storage, lifecycle, runtime modes, and migrations.', + icon: 'BookOpen', + defaultOpen: false, + }, + reference: { + description: 'Look up capabilities, extensions, releases, performance results, and API links.', + icon: 'SearchCheck', + defaultOpen: false, + }, + 'liboliphaunt-native': { + description: 'Stable C ABI, opaque handles, raw protocol bytes, and binding rules.', + icon: 'CodeXml', + }, + 'oliphaunt-rust': { + description: 'Rust and Tauri SDK with direct, broker, and server runtime modes.', + icon: 'Laptop', + }, + 'oliphaunt-swift': { + description: 'Apple SDK for iOS and macOS apps using Swift concurrency.', + icon: 'Smartphone', + }, + 'oliphaunt-kotlin': { + description: 'Android SDK with coroutine-first APIs and exact native resource packaging.', + icon: 'Smartphone', + }, + 'oliphaunt-react-native': { + description: 'New Architecture package with Expo config plugin, TurboModule, and JSI bytes.', + icon: 'Layers', + }, + 'oliphaunt-js': { + description: 'TypeScript SDK for Node.js, Bun, Deno, and Tauri JavaScript apps.', + icon: 'Braces', + }, + 'oliphaunt-wasix': { + description: 'First-class WASM/WASIX runtime family and dump/restore flows.', + icon: 'Boxes', + }, +}; + +function metadataForRoute(route) { + const presentation = routePresentation[route.id] ?? {}; + return Object.fromEntries( + Object.entries(presentation).filter(([, value]) => value !== undefined), + ); +} + +function category(label, items) { + return { + type: 'category', + label, + items, + }; +} + +function sidebarPagesForRoute(route) { + return route.sidebar_pages ?? route.page_order ?? []; +} + +function orderedItemsForRoute(route, routeRecords) { + const available = new Set( + routeRecords + .filter( + (record) => + record.route === `/${route.route}` || record.route.startsWith(`/${route.route}/`), + ) + .map((record) => record.docId), + ); + const declared = sidebarPagesForRoute(route); + const declaredItems = declared.map((page) => itemForPage(route, page)); + if (declared.length > 0) { + return declaredItems.filter((item) => available.has(item)); + } + return [...available].sort((left, right) => left.localeCompare(right)); +} + +function writeJson(filePath, value) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function uniqueInOrder(values) { + const seen = new Set(); + const output = []; + for (const value of values) { + if (!seen.has(value)) { + seen.add(value); + output.push(value); + } + } + return output; +} + +function pageOrderForFumadocs(route) { + return uniqueInOrder( + sidebarPagesForRoute(route).map((page) => { + const [first] = page.split('/'); + return first; + }), + ); +} + +function titleForPathSegment(segment) { + return segment + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function writeRouteMeta(route) { + const routeRoot = path.join(siteDocsRoot, route.route); + const metadata = { + title: route.title, + ...metadataForRoute(route), + pages: pageOrderForFumadocs(route), + }; + if (metadata.pages.includes('index')) { + metadata.pagesIndex = 'index'; + metadata.pages = metadata.pages.filter((page) => page !== 'index'); + } + if (route.kind === 'public') { + metadata.root = true; + metadata.description ??= `${route.title} documentation`; + } + writeJson(path.join(routeRoot, 'meta.json'), metadata); + + const nested = new Map(); + for (const page of sidebarPagesForRoute(route)) { + const parts = page.split('/'); + if (parts.length < 2) { + continue; + } + const [folder, child] = parts; + const children = nested.get(folder) ?? []; + children.push(child); + nested.set(folder, children); + } + + for (const [folder, pages] of nested) { + writeJson(path.join(routeRoot, folder, 'meta.json'), { + title: titleForPathSegment(folder), + pages: uniqueInOrder(pages), + }); + } +} + +function writeFumadocsMeta(manifest) { + const sdkRoutes = (manifest.routes ?? []).filter((route) => route.kind === 'sdk'); + writeJson(path.join(siteDocsRoot, 'meta.json'), { + title: 'Oliphaunt', + description: + 'Embedded PostgreSQL SDK documentation for native, mobile, desktop, React Native, TypeScript, and WASM apps.', + pages: ['start', 'sdk', 'learn', 'reference'], + }); + for (const route of manifest.routes ?? []) { + if (route.id !== 'sdk') { + writeRouteMeta(route); + } + } + writeJson(path.join(siteDocsRoot, 'sdk', 'meta.json'), { + title: 'SDKs', + description: routePresentation.sdk.description, + icon: routePresentation.sdk.icon, + root: true, + defaultOpen: routePresentation.sdk.defaultOpen, + pagesIndex: 'index', + pages: sdkRoutes.map((route) => route.route.replace(/^sdk\//u, '')), + }); +} + +function writeNavigationMetadata(manifest, routeRecords) { + const byId = new Map((manifest.routes ?? []).map((route) => [route.id, route])); + const sdkRoutes = (manifest.routes ?? []).filter((route) => route.kind === 'sdk'); + const navigation = { + docs: [ + 'start/index', + category('SDKs', [ + 'sdk/index', + ...sdkRoutes.map((route) => + category(route.title, orderedItemsForRoute(route, routeRecords)), + ), + ]), + category('Learn', orderedItemsForRoute(byId.get('learn'), routeRecords)), + category('Reference', orderedItemsForRoute(byId.get('reference'), routeRecords)), + ], + }; + writeJson(path.join(generatedMetaRoot, 'navigation.json'), navigation); + writeFumadocsMeta(manifest); +} + +function stripFrontmatter(markdown) { + return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/u, ''); +} + +function writeLlmFiles(routeRecords) { + ensureDir(staticRoot); + const summary = [ + '# Oliphaunt Docs', + '', + 'Oliphaunt is embedded PostgreSQL for native, mobile, desktop, React Native, TypeScript, and WASM apps.', + '', + '## Public routes', + ...routeRecords.map((record) => `- ${record.title}: ${record.route}`), + '', + ].join('\n'); + fs.writeFileSync(path.join(staticRoot, 'llms.txt'), summary); + + const full = routeRecords + .map((record) => { + const markdown = stripFrontmatter(readText(record.file)); + return `# ${record.title}\n\nRoute: ${record.route}\n\n${markdown}`; + }) + .join('\n\n---\n\n'); + fs.writeFileSync(path.join(staticRoot, 'llms-full.txt'), full); +} + +function appliesToForRoute(route) { + if (route.applies_to) { + return String(route.applies_to); + } + if (route.kind === 'sdk' && route.product_id) { + return `current ${route.product_id}`; + } + return 'current'; +} + +function routeForGeneratedPage(manifest, pageRoute) { + const candidates = (manifest.routes ?? []) + .filter((route) => { + const root = `/${route.route}`; + return pageRoute === root || pageRoute.startsWith(`${root}/`); + }) + .sort((left, right) => right.route.length - left.route.length); + return candidates[0]; +} + +function ensureApplicabilityFrontmatter(markdown, appliesTo) { + const escaped = yamlString(appliesTo); + const frontmatterMatch = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u); + if (!frontmatterMatch) { + return `---\napplies_to: ${escaped}\n---\n\n${markdown}`; + } + if (/^applies_to\s*:/mu.test(frontmatterMatch[1])) { + return markdown; + } + return markdown.replace(/^---\r?\n/u, `---\napplies_to: ${escaped}\n`); +} + +function stampApplicabilityMetadata(manifest) { + for (const file of collectMarkdownFiles(siteDocsRoot)) { + const pageRoute = markdownRouteFor(file); + const route = routeForGeneratedPage(manifest, pageRoute); + if (!route) { + throw new Error(`no docs manifest route owns generated page ${pageRoute}`); + } + const markdown = readText(file); + fs.writeFileSync( + file, + stripMatchingLeadingTitleHeading( + ensureApplicabilityFrontmatter(markdown, appliesToForRoute(route)), + ), + ); + } +} + +function currentGitSha() { + try { + return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + } catch { + return 'unknown'; + } +} + +function generateDocsUnlocked(options = {}) { + const manifest = parseTomlFile(manifestPath); + const sdkManifest = parseTomlFile(path.join(repoRoot, 'tools/policy/sdk-manifest.toml')); + const releaseGraph = releaseProductMetadata(); + const apiSummary = generateApiReferenceArtifacts({ + manifest, + mode: options.apiMode ?? 'fast', + }); + + resetDir(siteDocsRoot); + resetDir(staticRoot); + resetGeneratedMetadata(); + copyDir(path.join(docsRoot, 'static'), staticRoot); + const artifactLinksByRecordId = options.publishApiArtifacts + ? copyApiArtifactsToStatic(apiSummary) + : new Map(); + + const context = { + sdkRoutesById: new Map( + (manifest.routes ?? []) + .filter((route) => route.kind === 'sdk') + .map((route) => [route.id, route]), + ), + }; + + for (const route of manifest.routes ?? []) { + copyRoutePages(route, context); + } + + writeGeneratedReferencePages( + manifest, + sdkManifest, + releaseGraph, + apiSummary, + artifactLinksByRecordId, + ); + stampApplicabilityMetadata(manifest); + + const routeRecords = collectMarkdownFiles(siteDocsRoot).map((file) => { + const markdown = readText(file); + return { + route: markdownRouteFor(file), + docId: markdownDocIdFor(file), + title: + frontmatterValue(markdown, 'title') || + firstHeading(markdown, path.basename(file, path.extname(file))), + appliesTo: frontmatterValue(markdown, 'applies_to'), + file, + source: path.relative(repoRoot, file), + }; + }); + + writeMetadata(routeRecords); + writeNavigationMetadata(manifest, routeRecords); + writeLlmFiles(routeRecords); + fs.writeFileSync( + path.join(generatedMetaRoot, 'build-metadata.json'), + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + gitSha: currentGitSha(), + routeCount: routeRecords.length, + apiReferenceMode: apiSummary.mode, + apiArtifactsPublished: Boolean(options.publishApiArtifacts), + }, + null, + 2, + )}\n`, + ); + + return { + manifest, + sdkManifest, + releaseGraph, + apiSummary, + routeRecords, + paths: { + repoRoot, + docsRoot, + siteDocsRoot, + staticRoot, + generatedMetaRoot, + }, + }; +} + +export function generateDocs(options = {}) { + return withGenerationLock(() => generateDocsUnlocked(options)); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const modeArg = process.argv.find((arg) => arg.startsWith('--api-mode=')); + const apiMode = modeArg ? modeArg.split('=')[1] : 'fast'; + const publishApiArtifacts = process.argv.includes('--publish-api-artifacts'); + const result = generateDocs({ apiMode, publishApiArtifacts }); + console.log( + `generated ${result.routeRecords.length} docs routes in target/docs (api artifacts published: ${publishApiArtifacts})`, + ); +} diff --git a/src/docs/tools/publish-next-export.mjs b/src/docs/tools/publish-next-export.mjs new file mode 100644 index 00000000..5f4f7589 --- /dev/null +++ b/src/docs/tools/publish-next-export.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const docsRoot = path.resolve(scriptDir, '..'); +const repoRoot = path.resolve(scriptDir, '../../..'); +const nextExportRoot = path.join(docsRoot, 'out'); +const generatedStaticRoot = path.join(repoRoot, 'target', 'docs', 'static'); +const buildRoot = path.join(repoRoot, 'target', 'docs', 'build'); + +function fail(message) { + console.error(message); + process.exit(1); +} + +if (!fs.existsSync(nextExportRoot)) { + fail('Next static export is missing; run next build before publishing docs output'); +} + +fs.rmSync(buildRoot, { force: true, recursive: true }); +fs.mkdirSync(path.dirname(buildRoot), { recursive: true }); +fs.cpSync(nextExportRoot, buildRoot, { force: true, recursive: true }); + +if (fs.existsSync(generatedStaticRoot)) { + fs.cpSync(generatedStaticRoot, buildRoot, { force: true, recursive: true }); +} + +console.log(`published docs static export to ${path.relative(repoRoot, buildRoot)}`); diff --git a/src/docs/tools/run-docs-task.mjs b/src/docs/tools/run-docs-task.mjs new file mode 100644 index 00000000..01003679 --- /dev/null +++ b/src/docs/tools/run-docs-task.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const docsRoot = path.resolve(scriptDir, '..'); +const repoRoot = path.resolve(scriptDir, '../../..'); +const generatedRoot = path.join(repoRoot, 'target', 'docs'); +const lockDir = path.join(generatedRoot, '.docs-task.lock'); +const lockMetadata = path.join(lockDir, 'owner.json'); +const lockTimeoutMs = Number.parseInt(process.env.OLIPHAUNT_DOCS_LOCK_TIMEOUT_MS ?? '120000', 10); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function sleep(milliseconds) { + const buffer = new SharedArrayBuffer(4); + const view = new Int32Array(buffer); + Atomics.wait(view, 0, 0, milliseconds); +} + +function processIsAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function removeStaleLock() { + if (!fs.existsSync(lockDir)) { + return false; + } + try { + if (fs.existsSync(lockMetadata)) { + const metadata = JSON.parse(fs.readFileSync(lockMetadata, 'utf8')); + if (!processIsAlive(metadata.pid)) { + fs.rmSync(lockDir, { force: true, recursive: true }); + return true; + } + return false; + } + const stat = fs.statSync(lockDir); + if (Date.now() - stat.mtimeMs > lockTimeoutMs) { + fs.rmSync(lockDir, { force: true, recursive: true }); + return true; + } + } catch { + fs.rmSync(lockDir, { force: true, recursive: true }); + return true; + } + return false; +} + +function acquireLock() { + fs.mkdirSync(generatedRoot, { recursive: true }); + const started = Date.now(); + while (Date.now() - started <= lockTimeoutMs) { + try { + fs.mkdirSync(lockDir); + fs.writeFileSync( + lockMetadata, + `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`, + ); + return () => fs.rmSync(lockDir, { force: true, recursive: true }); + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + removeStaleLock(); + sleep(100); + } + } + fail(`timed out waiting for docs generation lock: ${path.relative(repoRoot, lockDir)}`); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: docsRoot, + env: process.env, + stdio: 'inherit', + }); + if (result.error) { + fail(`could not run ${command}: ${result.error.message}`); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +const task = process.argv[2]; +const fumadocsMdx = ['pnpm', ['exec', 'fumadocs-mdx']]; +const checkFumadocsSource = ['node', ['tools/check-fumadocs-source.mjs']]; +const tasks = { + generate: [['node', ['tools/generate-content.mjs']], fumadocsMdx, checkFumadocsSource], + check: [ + ['node', ['tools/check-docs-product.mjs']], + fumadocsMdx, + checkFumadocsSource, + ['pnpm', ['exec', 'next', 'typegen']], + fumadocsMdx, + checkFumadocsSource, + ['pnpm', ['exec', 'tsc', '--noEmit']], + ], + test: [['node', ['tools/check-docs-product.mjs', '--snippets']]], + build: [ + ['node', ['tools/check-docs-product.mjs']], + fumadocsMdx, + checkFumadocsSource, + ['pnpm', ['exec', 'next', 'build']], + fumadocsMdx, + checkFumadocsSource, + ['node', ['tools/publish-next-export.mjs']], + ], + 'release-check': [['node', ['tools/check-docs-product.mjs', '--release']]], +}; + +if (!Object.hasOwn(tasks, task)) { + fail(`usage: node tools/run-docs-task.mjs ${Object.keys(tasks).join('|')}`); +} + +const releaseLock = acquireLock(); +try { + for (const [command, args] of tasks[task]) { + run(command, args); + } +} finally { + releaseLock(); +} diff --git a/src/docs/tools/smoke-built-site.mjs b/src/docs/tools/smoke-built-site.mjs new file mode 100644 index 00000000..862951d3 --- /dev/null +++ b/src/docs/tools/smoke-built-site.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '../../..'); +const buildRoot = path.join(repoRoot, 'target', 'docs', 'build'); +const routesPath = path.join(repoRoot, 'target', 'docs', 'generated', 'routes.json'); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function findFiles(root, predicate) { + const files = []; + function visit(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (entry.isFile() && predicate(fullPath)) { + files.push(fullPath); + } + } + } + if (fs.existsSync(root)) { + visit(root); + } + return files; +} + +if (!fs.existsSync(buildRoot)) { + fail('docs build output is missing; run pnpm docs:build first'); +} + +if (!fs.existsSync(routesPath)) { + fail('generated docs route metadata is missing; run docs generation before smoke'); +} + +const htmlFiles = findFiles(buildRoot, (file) => file.endsWith('.html')); +if (htmlFiles.length === 0) { + fail('docs build produced no HTML files'); +} + +function routeHtmlPath(route) { + const normalized = route === '/' ? 'index' : route.replace(/^\/+/u, '').replace(/\/$/u, ''); + return path.join(buildRoot, normalized, 'index.html'); +} + +const routeMetadata = JSON.parse(fs.readFileSync(routesPath, 'utf8')); +for (const record of routeMetadata.routes ?? []) { + const route = record.route === '/' ? '/' : `/docs${record.route}`; + const htmlPath = routeHtmlPath(route); + if (!fs.existsSync(htmlPath)) { + fail( + `docs build did not export generated route ${route}: ${path.relative(repoRoot, htmlPath)}`, + ); + } +} + +const combined = htmlFiles.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); +for (const phrase of ['Oliphaunt', 'Rust SDK', 'Extension Catalog', 'SQLite']) { + if (!combined.includes(phrase)) { + fail(`docs build output missing phrase: ${phrase}`); + } +} + +const disallowedHtml = [ + { label: 'directory listing', pattern: /Directory listing for/iu }, + { + label: 'removed upstream reference', + pattern: new RegExp(`\\b${'pg'}${'lite'}\\b`, 'iu'), + }, + { label: 'internal lane wording', pattern: /\blane\b/iu }, + { label: 'internal evidence wording', pattern: /\bevidence\b/iu }, + { label: 'future planning language', pattern: /\b(?:TODO|coming soon)\b/iu }, +]; +for (const file of htmlFiles) { + const html = fs.readFileSync(file, 'utf8'); + for (const rule of disallowedHtml) { + if (rule.pattern.test(html)) { + fail(`${path.relative(repoRoot, file)} contains ${rule.label}`); + } + } +} + +for (const staticFile of ['llms.txt', 'llms-full.txt']) { + const fullPath = path.join(buildRoot, staticFile); + if (!fs.existsSync(fullPath)) { + fail(`docs build did not publish ${staticFile}`); + } +} + +const faviconPath = path.join(buildRoot, 'img', 'favicon.svg'); +if (!fs.existsSync(faviconPath)) { + fail('docs build did not publish img/favicon.svg'); +} + +const hasFaviconLink = htmlFiles.some((file) => { + const html = fs.readFileSync(file, 'utf8'); + return /]+rel="(?:shortcut icon|icon)"[^>]+href="\/img\/favicon\.svg"/u.test(html); +}); +if (!hasFaviconLink) { + fail('docs build output is missing a favicon link'); +} + +console.log(`docs smoke passed (${htmlFiles.length} HTML files)`); diff --git a/src/docs/tsconfig.json b/src/docs/tsconfig.json new file mode 100644 index 00000000..bac1d839 --- /dev/null +++ b/src/docs/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "tsBuildInfoFile": "../../target/docs/tsconfig.tsbuildinfo", + "paths": { + "@/*": [ + "./src/*" + ], + "collections/*": [ + "./.source/*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/src/extensions/artifacts/native/moon.yml b/src/extensions/artifacts/native/moon.yml new file mode 100644 index 00000000..ee467b7f --- /dev/null +++ b/src/extensions/artifacts/native/moon.yml @@ -0,0 +1,83 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extension-artifacts-native" +language: "unknown" +layer: "tool" +stack: "systems" +tags: ["extensions", "artifacts", "native"] +dependsOn: + - id: "extension-model" + scope: "build" + - id: "extensions" + scope: "build" + - id: "liboliphaunt-native" + scope: "build" + - id: "source-inputs" + scope: "build" + +project: + title: "Native Extension Artifacts" + description: "Publishable exact-extension artifact checks for native liboliphaunt targets." + owner: "oliphaunt" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-model.py --check" + deps: + - "extension-model:check" + inputs: + - "/src/extensions/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "bash src/extensions/artifacts/native/tools/check-release-artifacts.sh" + env: + OLIPHAUNT_TRACK_BUILD: "missing" + deps: + - "extension-artifacts-native:check" + - "source-inputs:source-fetch-native-runtime" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/extensions/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/tools/xtask/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true + build-target: + tags: ["release", "artifact-builder", "ci-extension-artifacts-native"] + command: "bash src/extensions/artifacts/native/tools/package-release-assets.sh" + deps: + - "extension-artifacts-native:check" + - "source-inputs:source-fetch-native-runtime" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/extensions/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/tools/xtask/**/*" + outputs: + - "/target/extensions/native/release-assets/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true diff --git a/src/extensions/artifacts/native/tools/check-release-artifacts.sh b/src/extensions/artifacts/native/tools/check-release-artifacts.sh new file mode 100755 index 00000000..8085b1f4 --- /dev/null +++ b/src/extensions/artifacts/native/tools/check-release-artifacts.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$root" ]; then + root="$(cd "$script_dir/../../../../.." && pwd)" +fi +cd "$root" + +target="${OLIPHAUNT_EXTENSION_TARGET:-macos-arm64}" +case "$target" in + macos-arm64|linux-x64-gnu|linux-arm64-gnu|windows-x64-msvc|ios-xcframework|android-arm64-v8a|android-x86_64) + exec src/extensions/artifacts/native/tools/package-release-assets.sh + ;; + *) + cat >&2 < typeof item === 'string' && item.length > 0).sort(); +} + +function splitCsv(value) { + if (value === undefined || value === null || value === '' || value === '-') { + return []; + } + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function sortedDeduped(values) { + return [...new Set(values.filter((item) => item !== undefined && item !== null && String(item).length > 0).map(String))].sort(); +} + +function dashIfEmpty(value) { + if (Array.isArray(value)) { + return value.length === 0 ? '-' : value.join(','); + } + return value === undefined || value === null || value === '' ? '-' : String(value); +} + +function yesNo(value) { + return value ? 'yes' : 'no'; +} + +function validatePortableId(value, label) { + if (!/^[A-Za-z0-9._-]{1,128}$/.test(value)) { + fail(`${label} '${value}' must contain 1 to 128 ASCII letters, digits, '.', '_' or '-'`); + } +} + +function validateCIdentifier(value, label) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + fail(`${label} '${value}' must be a portable C identifier`); + } +} + +function validateRelativeArtifactPath(value, label) { + if (!value || path.isAbsolute(value)) { + fail(`${label} '${value}' must be a relative path`); + } + const parts = value.split(/[\\/]+/); + if (parts.some((part) => part === '' || part === '.' || part === '..')) { + fail(`${label} '${value}' must not contain '.', '..', or empty path components`); + } + return parts.join('/'); +} + +function nativeModuleStem(extension) { + const moduleFile = extension['native-module-file'] ?? extension['module-file']; + if (typeof moduleFile !== 'string' || moduleFile.length === 0) { + return ''; + } + for (const suffix of ['.so', '.dylib', '.dll']) { + if (moduleFile.endsWith(suffix)) { + return moduleFile.slice(0, -suffix.length); + } + } + return moduleFile; +} + +function sharedPreloadLibraries(extension) { + const startupConfig = extension.lifecycle?.['startup-config'] ?? []; + const libraries = []; + for (const assignment of startupConfig) { + if (typeof assignment !== 'string') { + continue; + } + const separator = assignment.indexOf('='); + if (separator <= 0) { + continue; + } + if (assignment.slice(0, separator) === 'shared_preload_libraries') { + libraries.push(...splitCsv(assignment.slice(separator + 1))); + } + } + return sortedDeduped(libraries); +} + +async function externalRecipe(sqlName) { + const recipePath = path.join(root, 'src/extensions/external', sqlName, 'recipe.toml'); + if (!(await isFile(recipePath))) { + return null; + } + return readToml(recipePath); +} + +async function extensionDataFiles(extension, contribRows) { + const sqlName = extension['sql-name'] ?? extension.id; + const recipe = await externalRecipe(sqlName); + if (recipe !== null) { + return stringList(recipe.artifacts?.data_files); + } + const row = contribRows.find((item) => item?.['sql-name'] === sqlName); + return stringList(row?.['data-files']); +} + +function runtimeShareDataFiles(dataFiles) { + const prefix = 'share/postgresql/'; + return dataFiles.map((item) => (item.startsWith(prefix) ? item.slice(prefix.length) : item)).sort(); +} + +async function extensionArtifactList(sqlName, field) { + const recipe = await externalRecipe(sqlName); + if (recipe === null) { + return []; + } + return stringList(recipe.artifacts?.[field]); +} + +async function externalTargetStatus(sqlName, target) { + const targetPath = path.join(root, 'src/extensions/external', sqlName, 'targets', `${target}.toml`); + if (!(await isFile(targetPath))) { + return null; + } + const data = await readToml(targetPath); + return typeof data.status === 'string' ? data.status : null; +} + +async function extensionSupportStatuses(sqlName, family) { + const recipe = await externalRecipe(sqlName); + const support = recipe?.support?.[family]; + if (support === undefined || support === null || typeof support !== 'object') { + return []; + } + return Object.values(support).filter((value) => typeof value === 'string'); +} + +async function mobileReleaseReady(sqlName) { + const targetStatus = await externalTargetStatus(sqlName, 'mobile'); + if (targetStatus !== null) { + return targetStatus === 'supported'; + } + const statuses = await extensionSupportStatuses(sqlName, 'mobile'); + return statuses.length === 0 || statuses.every((status) => status === 'supported'); +} + +async function desktopReleaseReady(sqlName, promotion) { + if (!(promotion?.promoted === true && promotion?.stable === true)) { + return false; + } + const targetStatus = await externalTargetStatus(sqlName, 'native'); + if (targetStatus !== null) { + return targetStatus === 'supported'; + } + const statuses = await extensionSupportStatuses(sqlName, 'native'); + return statuses.length === 0 || statuses.every((status) => status === 'supported'); +} + +async function catalogRows() { + const catalog = await readJson(CATALOG_PATH); + const contrib = await readToml(CONTRIB_RECIPE_PATH); + const contribRows = Array.isArray(contrib.extensions) ? contrib.extensions : []; + const extensions = Array.isArray(catalog.extensions) ? catalog.extensions : []; + const publicSqlNames = new Set( + extensions + .filter((extension) => extension.promotion?.promoted === true) + .map((extension) => extension['sql-name'] ?? extension.id), + ); + const rows = []; + for (const extension of extensions) { + const promotion = extension.promotion ?? {}; + if (!(promotion.promoted === true && promotion.stable === true)) { + continue; + } + const sqlName = extension['sql-name'] ?? extension.id; + const dependencies = stringList(extension.dependencies).filter((dependency) => publicSqlNames.has(dependency)); + const dataFiles = runtimeShareDataFiles(await extensionDataFiles(extension, contribRows)); + const stem = nativeModuleStem(extension); + rows.push({ + sqlName, + pgMajor: '18', + createsExtension: Boolean(extension.lifecycle?.['create-extension']), + stem, + dependencies, + sharedPreload: sharedPreloadLibraries(extension), + desktopPrebuilt: await desktopReleaseReady(sqlName, promotion), + mobilePrebuilt: await mobileReleaseReady(sqlName), + mobileStaticRequired: stem.length > 0, + mobileStaticTargets: [], + dataFiles, + artifact: 'first-party', + }); + } + rows.sort((left, right) => left.sqlName.localeCompare(right.sqlName)); + return rows; +} + +async function listCatalog() { + const header = [ + 'sql_name', + 'pg_major', + 'creates_extension', + 'native_module_stem', + 'dependencies', + 'shared_preload', + 'desktop_prebuilt', + 'mobile_prebuilt', + 'mobile_static_registry_required', + 'mobile_static_archive_targets', + 'data_files', + 'artifact', + ]; + console.log(header.join('\t')); + for (const row of await catalogRows()) { + console.log( + [ + row.sqlName, + row.pgMajor, + yesNo(row.createsExtension), + dashIfEmpty(row.stem), + dashIfEmpty(row.dependencies), + dashIfEmpty(row.sharedPreload), + yesNo(row.desktopPrebuilt), + yesNo(row.mobilePrebuilt), + yesNo(row.mobileStaticRequired), + dashIfEmpty(row.mobileStaticTargets), + dashIfEmpty(row.dataFiles), + row.artifact, + ].join('\t'), + ); + } +} + +async function releasePackageByProduct(product) { + const releaseConfig = await readJson(RELEASE_CONFIG_PATH); + const packages = releaseConfig.packages ?? {}; + for (const [packagePath, config] of Object.entries(packages)) { + if (config?.component === product) { + return { packagePath, config }; + } + } + fail(`unknown release product '${product}'`); +} + +async function productReleaseMetadata(product) { + const { packagePath } = await releasePackageByProduct(product); + const releaseTomlPath = path.join(root, packagePath, 'release.toml'); + const metadata = await readToml(releaseTomlPath); + if (metadata.id !== product) { + fail(`${path.relative(root, releaseTomlPath)} must declare id = '${product}'`); + } + return metadata; +} + +async function selectedSqlNames(productsCsv) { + const products = sortedDeduped(splitCsv(productsCsv)); + if (products.length === 0) { + fail('no exact-extension products were selected'); + } + const sqlNames = []; + for (const product of products) { + const metadata = await productReleaseMetadata(product); + if (metadata.kind !== 'exact-extension-artifact') { + fail(`${product} is not an exact-extension artifact product`); + } + if (typeof metadata.extension_sql_name !== 'string' || metadata.extension_sql_name.length === 0) { + fail(`${product} release metadata must declare extension_sql_name`); + } + sqlNames.push(metadata.extension_sql_name); + } + console.log(sortedDeduped(sqlNames).join(',')); +} + +function parseCargoVersion(text) { + let inPackage = false; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (line === '[package]') { + inPackage = true; + continue; + } + if (inPackage && line.startsWith('[')) { + break; + } + if (inPackage) { + const match = /^version\s*=\s*"([^"]+)"/.exec(line); + if (match) { + return match[1]; + } + } + } + return ''; +} + +function parseJsonPath(text, dotted) { + let value = JSON.parse(text); + for (const key of dotted.split('.')) { + if (value === null || typeof value !== 'object' || !(key in value)) { + return ''; + } + value = value[key]; + } + return String(value); +} + +async function productVersion(product) { + const { packagePath, config } = await releasePackageByProduct(product); + const releaseType = config['release-type']; + const relativeVersionFile = + typeof config['version-file'] === 'string' && config['version-file'].length > 0 + ? config['version-file'] + : releaseType === 'rust' + ? 'Cargo.toml' + : releaseType === 'node' || releaseType === 'expo' + ? 'package.json' + : null; + if (relativeVersionFile === null) { + fail(`${product} release-please config must declare version-file for release type '${releaseType}'`); + } + const versionFile = path.join(root, packagePath, relativeVersionFile); + const text = await readText(versionFile); + const parser = + path.basename(versionFile) === 'Cargo.toml' + ? 'cargo' + : path.basename(versionFile) === 'package.json' + ? 'json:version' + : 'raw'; + const version = parser === 'cargo' ? parseCargoVersion(text) : parser.startsWith('json:') ? parseJsonPath(text, parser.slice(5)) : text.trim(); + if (!/^[0-9]+[.][0-9]+[.][0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(version)) { + fail(`${product} version is not semver-like: '${version}'`); + } + console.log(version); +} + +function parseArgs(argv) { + const args = { + dependencies: [], + dataFiles: [], + sharedPreloadLibraries: [], + mobileStaticArchives: [], + mobileStaticDependencyArchives: [], + staticSymbolAliases: [], + createsExtension: true, + mobilePrebuilt: false, + format: 'directory', + force: false, + }; + for (let index = 0; index < argv.length; index += 1) { + let arg = argv[index]; + let value = null; + const equals = arg.indexOf('='); + if (arg.startsWith('--') && equals > 0) { + value = arg.slice(equals + 1); + arg = arg.slice(0, equals); + } + const nextValue = () => { + if (value !== null) { + return value; + } + index += 1; + if (index >= argv.length) { + fail(`${arg} requires a value`); + } + return argv[index]; + }; + switch (arg) { + case '--force': + args.force = true; + break; + case '--no-create-extension': + args.createsExtension = false; + break; + case '--mobile-prebuilt': + args.mobilePrebuilt = value === null ? true : parseBoolean(nextValue(), arg); + break; + case '--no-mobile-prebuilt': + args.mobilePrebuilt = false; + break; + case '--runtime': + args.runtime = nextValue(); + break; + case '--sql-name': + args.sqlName = nextValue(); + break; + case '--creates-extension': + args.createsExtension = parseBoolean(nextValue(), arg); + break; + case '--target': + case '--native-target': + args.nativeTarget = nextValue(); + break; + case '--output': + case '-o': + args.output = nextValue(); + break; + case '--stage-root': + args.stageRoot = nextValue(); + break; + case '--format': + args.format = nextValue(); + break; + case '--native-module-stem': + args.nativeModuleStem = nextValue(); + break; + case '--native-module-file': + args.nativeModuleFile = nextValue(); + break; + case '--dependency': + case '--dependencies': + args.dependencies.push(...splitCsv(nextValue())); + break; + case '--data-file': + case '--data-files': + args.dataFiles.push(...splitCsv(nextValue()).map((item) => validateRelativeArtifactPath(item, 'data file'))); + break; + case '--shared-preload-library': + case '--shared-preload-libraries': + args.sharedPreloadLibraries.push(...splitCsv(nextValue())); + break; + case '--mobile-static-archive': + case '--mobile-static-archives': + args.mobileStaticArchives.push(...splitCsv(nextValue()).map(parseMobileStaticArchive)); + break; + case '--mobile-static-dependency-archive': + case '--mobile-static-dependency-archives': + args.mobileStaticDependencyArchives.push(...splitCsv(nextValue()).map(parseMobileStaticDependencyArchive)); + break; + case '--static-symbol-prefix': + args.staticSymbolPrefix = nextValue(); + break; + case '--static-symbol-alias': + case '--static-symbol-aliases': + args.staticSymbolAliases.push(...splitCsv(nextValue()).map(parseStaticSymbolAlias)); + break; + default: + fail(`unknown argument '${arg}'`); + } + } + return args; +} + +function parseBoolean(value, label) { + if (['true', 'yes', '1'].includes(value)) { + return true; + } + if (['false', 'no', '0'].includes(value)) { + return false; + } + fail(`${label} expected true/false, got '${value}'`); +} + +function parseMobileStaticArchive(value) { + const separator = value.includes('=') ? value.indexOf('=') : value.indexOf(':'); + if (separator <= 0) { + fail('--mobile-static-archive values must use : or ='); + } + const target = value.slice(0, separator).trim(); + const archive = value.slice(separator + 1).trim(); + if (target.length === 0 || archive.length === 0) { + fail('--mobile-static-archive values must include both target and archive path'); + } + return { target, archive }; +} + +function parseMobileStaticDependencyArchive(value) { + if (value.includes('=')) { + const [left, archive] = value.split(/=(.*)/s); + const [target, name] = left.split(':'); + if (!target || !name || !archive) { + fail('--mobile-static-dependency-archive values must use :: or :='); + } + return { target: target.trim(), name: name.trim(), archive: archive.trim() }; + } + const parts = value.split(':'); + if (parts.length < 3) { + fail('--mobile-static-dependency-archive values must use :: or :='); + } + const target = parts.shift().trim(); + const name = parts.shift().trim(); + const archive = parts.join(':').trim(); + if (!target || !name || !archive) { + fail('--mobile-static-dependency-archive values must include target, name, and archive path'); + } + return { target, name, archive }; +} + +function parseStaticSymbolAlias(value) { + const separator = value.includes('=') ? value.indexOf('=') : value.indexOf(':'); + if (separator <= 0) { + fail('--static-symbol-alias values must use : or ='); + } + const sqlSymbol = value.slice(0, separator).trim(); + const linkedSymbol = value.slice(separator + 1).trim(); + if (sqlSymbol.length === 0 || linkedSymbol.length === 0) { + fail('--static-symbol-alias values must include both SQL and linked C symbols'); + } + return { sqlSymbol, linkedSymbol }; +} + +async function validateArtifactArgs(args) { + for (const [value, label] of [ + [args.sqlName, 'prebuilt extension sqlName'], + [args.nativeModuleStem, 'prebuilt extension native module stem'], + [args.nativeModuleFile, 'prebuilt extension native module file'], + [args.nativeTarget, 'prebuilt extension native target'], + ]) { + if (value !== undefined) { + validatePortableId(value, label); + } + } + if (args.output === undefined) { + fail('missing required --output '); + } + if (args.runtime === undefined) { + fail('missing required --runtime '); + } + if (args.sqlName === undefined) { + fail('missing required --sql-name '); + } + if (!(await isDirectory(args.runtime))) { + fail(`prebuilt extension artifact runtime root ${args.runtime} must be an existing directory`); + } + if (args.nativeModuleFile !== undefined && args.nativeModuleStem === undefined) { + fail('prebuilt extension nativeModuleFile requires nativeModuleStem'); + } + if (args.nativeModuleStem !== undefined && args.nativeTarget === undefined) { + fail('prebuilt extension artifacts with nativeModuleStem must declare nativeTarget'); + } + if (args.staticSymbolPrefix !== undefined) { + validateCIdentifier(args.staticSymbolPrefix, 'prebuilt extension static symbol prefix'); + } + const aliasSqlSymbols = new Set(); + for (const alias of args.staticSymbolAliases) { + validateCIdentifier(alias.sqlSymbol, 'prebuilt extension static symbol alias'); + validateCIdentifier(alias.linkedSymbol, 'prebuilt extension static symbol alias target'); + if (aliasSqlSymbols.has(alias.sqlSymbol)) { + fail(`prebuilt extension repeats static symbol alias for '${alias.sqlSymbol}'`); + } + aliasSqlSymbols.add(alias.sqlSymbol); + } + if (args.mobileStaticArchives.length > 0 && args.nativeModuleStem === undefined) { + fail('prebuilt extension mobile static archives require nativeModuleStem'); + } + const mobilePrebuilt = artifactMobilePrebuilt(args); + if (mobilePrebuilt && args.nativeModuleStem !== undefined && args.mobileStaticArchives.length === 0) { + fail('mobilePrebuilt native-module artifacts must carry at least one mobile static archive'); + } + const mobileTargets = new Set(); + for (const archive of args.mobileStaticArchives) { + validatePortableId(archive.target, 'prebuilt extension mobile static archive target'); + if (mobileTargets.has(archive.target)) { + fail(`prebuilt extension mobile static archives repeat target '${archive.target}'`); + } + mobileTargets.add(archive.target); + if (!(await isFile(archive.archive))) { + fail(`prebuilt extension mobile static archive for target '${archive.target}' must be a file: ${archive.archive}`); + } + } + const mobileDependencyKeys = new Set(); + for (const archive of args.mobileStaticDependencyArchives) { + validatePortableId(archive.target, 'prebuilt extension mobile static dependency archive target'); + validatePortableId(archive.name, 'prebuilt extension mobile static dependency archive name'); + if (!mobileTargets.has(archive.target)) { + fail(`prebuilt extension mobile static dependency archive '${archive.name}' for target '${archive.target}' requires a matching mobile static archive target`); + } + const key = `${archive.target}:${archive.name}`; + if (mobileDependencyKeys.has(key)) { + fail(`prebuilt extension mobile static dependency archives repeat '${archive.name}' for target '${archive.target}'`); + } + mobileDependencyKeys.add(key); + validatePortableId(path.basename(archive.archive), 'prebuilt extension mobile static dependency archive file'); + if (!(await isFile(archive.archive))) { + fail(`prebuilt extension mobile static dependency archive '${archive.name}' for target '${archive.target}' must be a file: ${archive.archive}`); + } + } + for (const dataFile of args.dataFiles) { + validateRelativeArtifactPath(dataFile, 'data file'); + if (dataFile.split('/')[0] === 'extension') { + fail(`prebuilt extension data file '${dataFile}' must not be under share/postgresql/extension; control and SQL files are selected from sqlName`); + } + } +} + +function artifactMobilePrebuilt(args) { + return args.mobilePrebuilt || args.mobileStaticArchives.length > 0; +} + +function extensionSqlFileBelongs(sqlName, fileName, extraSql) { + return ( + fileName === `${sqlName}.control` || + fileName === `${sqlName}.sql` || + (fileName.startsWith(`${sqlName}--`) && fileName.endsWith('.sql')) || + extraSql.names.includes(fileName) || + extraSql.prefixes.some((prefix) => fileName.startsWith(prefix)) + ); +} + +async function ensureParent(file) { + await fs.mkdir(path.dirname(file), { recursive: true }); +} + +async function copyFileChecked(sourceRoot, source, destination) { + const sourceReal = await fs.realpath(source); + const rootReal = await fs.realpath(sourceRoot); + if (!sourceReal.startsWith(`${rootReal}${path.sep}`) && sourceReal !== rootReal) { + fail(`selected extension runtime symlink ${source} resolves outside runtime root ${sourceRoot}`); + } + const stat = await fs.stat(source); + if (!stat.isFile()) { + fail(`prebuilt extension artifact source runtime file ${source} must be a regular file`); + } + await ensureParent(destination); + await fs.copyFile(source, destination); + await fs.chmod(destination, stat.mode & 0o111 ? 0o755 : 0o644); +} + +async function copyRuntimeRelativeFile(runtime, artifactFiles, relative) { + const normalized = validateRelativeArtifactPath(relative, 'runtime file'); + const source = path.join(runtime, normalized); + if (!(await isFile(source))) { + fail(`prebuilt extension artifact source runtime is missing declared file ${source}`); + } + await copyFileChecked(runtime, source, path.join(artifactFiles, normalized)); +} + +async function copySqlFiles(args, artifactRoot, artifactFiles) { + const sourceDir = path.join(args.runtime, 'share/postgresql/extension'); + const targetDir = path.join(artifactFiles, 'share/postgresql/extension'); + if (!(await isDirectory(sourceDir))) { + if (args.createsExtension) { + fail(`prebuilt extension artifact source runtime ${args.runtime} is missing share/postgresql/extension for '${args.sqlName}'`); + } + return; + } + const extraSql = { + prefixes: await extensionArtifactList(args.sqlName, 'extension_sql_file_prefixes'), + names: await extensionArtifactList(args.sqlName, 'extension_sql_file_names'), + }; + let copied = 0; + let copiedControl = false; + let copiedSql = false; + const entries = (await fs.readdir(sourceDir)).sort(); + for (const entry of entries) { + if (!extensionSqlFileBelongs(args.sqlName, entry, extraSql)) { + continue; + } + copied += 1; + if (entry === `${args.sqlName}.control`) { + copiedControl = true; + } else if (entry.endsWith('.sql')) { + copiedSql = true; + } + await copyFileChecked(args.runtime, path.join(sourceDir, entry), path.join(targetDir, entry)); + } + if (args.createsExtension && (!copiedControl || !copiedSql)) { + fail(`prebuilt extension artifact ${artifactRoot} for '${args.sqlName}' must include a control file and at least one SQL install file`); + } + if (!args.createsExtension && copied === 0) { + return; + } +} + +function mobileStaticArchiveRelativePath(target, stem) { + return `mobile-static/${target}/extensions/${stem}/liboliphaunt_extension_${stem}.a`; +} + +function mobileStaticDependencyArchiveRelativePath(target, name, archivePath) { + return `mobile-static/${target}/dependencies/${name}/${path.basename(archivePath)}`; +} + +async function copyStandaloneFile(source, destination) { + const stat = await fs.stat(source); + if (!stat.isFile()) { + fail(`prebuilt extension artifact source file ${source} must be a regular file`); + } + await ensureParent(destination); + await fs.copyFile(source, destination); + await fs.chmod(destination, stat.mode & 0o111 ? 0o755 : 0o644); +} + +function extensionMetadata(args) { + const dependencies = sortedDeduped(args.dependencies); + const dataFiles = sortedDeduped(args.dataFiles); + const sharedPreloadLibraries = sortedDeduped(args.sharedPreloadLibraries); + const mobileStaticArchives = args.nativeModuleStem === undefined + ? [] + : args.mobileStaticArchives + .map((archive) => ({ + target: archive.target, + source: archive.archive, + relativePath: mobileStaticArchiveRelativePath(archive.target, args.nativeModuleStem), + })) + .sort((left, right) => left.target.localeCompare(right.target)); + const mobileStaticDependencyArchives = args.mobileStaticDependencyArchives + .map((archive) => ({ + target: archive.target, + name: archive.name, + source: archive.archive, + relativePath: mobileStaticDependencyArchiveRelativePath(archive.target, archive.name, archive.archive), + })) + .sort((left, right) => left.target.localeCompare(right.target) || left.name.localeCompare(right.name)); + const staticSymbolAliases = [...args.staticSymbolAliases].sort( + (left, right) => left.sqlSymbol.localeCompare(right.sqlSymbol) || left.linkedSymbol.localeCompare(right.linkedSymbol), + ); + return { + dependencies, + dataFiles, + sharedPreloadLibraries, + mobileStaticArchives, + mobileStaticDependencyArchives, + staticSymbolAliases, + mobilePrebuilt: artifactMobilePrebuilt(args), + nativeModuleFile: args.nativeModuleStem === undefined ? '' : args.nativeModuleFile ?? args.nativeModuleStem, + }; +} + +async function writeArtifactDirectory(artifactRoot, args) { + const filesRoot = path.join(artifactRoot, 'files'); + const metadata = extensionMetadata(args); + await copySqlFiles(args, artifactRoot, filesRoot); + for (const dataFile of metadata.dataFiles) { + await copyRuntimeRelativeFile(args.runtime, filesRoot, `share/postgresql/${dataFile}`); + } + if (metadata.nativeModuleFile.length > 0) { + await copyRuntimeRelativeFile(args.runtime, filesRoot, `lib/postgresql/${metadata.nativeModuleFile}`); + } + for (const archive of metadata.mobileStaticArchives) { + await copyStandaloneFile(archive.source, path.join(artifactRoot, archive.relativePath)); + } + for (const archive of metadata.mobileStaticDependencyArchives) { + await copyStandaloneFile(archive.source, path.join(artifactRoot, archive.relativePath)); + } + const manifest = [ + 'packageLayout=oliphaunt-extension-artifact-v1', + 'pgMajor=18', + `sqlName=${args.sqlName}`, + `createsExtension=${yesNo(args.createsExtension)}`, + `nativeModuleStem=${args.nativeModuleStem ?? ''}`, + `nativeModuleFile=${metadata.nativeModuleFile}`, + `nativeTarget=${args.nativeTarget ?? ''}`, + `dependencies=${metadata.dependencies.join(',')}`, + `dataFiles=${metadata.dataFiles.join(',')}`, + `sharedPreloadLibraries=${metadata.sharedPreloadLibraries.join(',')}`, + `mobilePrebuilt=${yesNo(metadata.mobilePrebuilt)}`, + `mobileStaticArchives=${metadata.mobileStaticArchives.map((archive) => `${archive.target}:${archive.relativePath}`).join(',')}`, + `mobileStaticDependencyArchives=${metadata.mobileStaticDependencyArchives.map((archive) => `${archive.target}:${archive.name}:${archive.relativePath}`).join(',')}`, + `staticSymbolPrefix=${args.staticSymbolPrefix ?? ''}`, + `staticSymbolAliases=${metadata.staticSymbolAliases.map((alias) => `${alias.sqlSymbol}:${alias.linkedSymbol}`).join(',')}`, + 'files=files', + '', + ].join('\n'); + await fs.mkdir(artifactRoot, { recursive: true }); + await fs.writeFile(path.join(artifactRoot, 'manifest.properties'), manifest); +} + +async function prepareOutputFile(output, force) { + if (await exists(output)) { + if (!force) { + fail(`prebuilt extension artifact output ${output} already exists; pass --force`); + } + const stat = await fs.lstat(output); + if (stat.isDirectory()) { + await fs.rm(output, { recursive: true, force: true }); + } else { + await fs.unlink(output); + } + } + await ensureParent(output); +} + +async function createArtifact(argv) { + const args = parseArgs(argv); + await validateArtifactArgs(args); + if (!['directory', 'dir', 'tar', 'tar-gz', 'tar.gz', 'tgz', 'gz'].includes(args.format)) { + fail(`unknown extension artifact format '${args.format}'`); + } + const output = path.resolve(args.output); + if (args.format === 'directory' || args.format === 'dir') { + if (await exists(output)) { + if (!args.force) { + fail(`prebuilt extension artifact output ${output} already exists; pass --force`); + } + await fs.rm(output, { recursive: true, force: true }); + } + await writeArtifactDirectory(output, args); + console.log(`path=${output}`); + console.log(`sqlName=${args.sqlName}`); + console.log('format=directory'); + console.log(`manifest=${path.join(output, 'manifest.properties')}`); + return; + } + await prepareOutputFile(output, args.force); + const stageRoot = path.resolve(args.stageRoot ?? path.join(root, 'target/extensions/native/release-stage/local')); + const artifactRoot = path.join(stageRoot, `.artifact-${args.sqlName}-${process.pid}-${Date.now()}`); + const formatLabel = args.format === 'tar' ? 'tar' : 'tar-gz'; + await fs.rm(artifactRoot, { recursive: true, force: true }); + await fs.mkdir(artifactRoot, { recursive: true }); + try { + await writeArtifactDirectory(artifactRoot, args); + if (args.format === 'tar') { + await fs.writeFile(output, await createTar(artifactRoot)); + } else { + await fs.writeFile(output, Bun.gzipSync(await createTar(artifactRoot))); + } + } finally { + await fs.rm(artifactRoot, { recursive: true, force: true }); + } + console.log(`path=${output}`); + console.log(`sqlName=${args.sqlName}`); + console.log(`format=${formatLabel}`); + console.log('manifest='); +} + +async function listFilesRecursive(base, current = base) { + const entries = (await fs.readdir(current, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + fail(`prebuilt extension artifact archives do not support symlinks: ${fullPath}`); + } + if (entry.isDirectory()) { + files.push(...(await listFilesRecursive(base, fullPath))); + continue; + } + if (!entry.isFile()) { + fail(`prebuilt extension artifact archives only support files and directories: ${fullPath}`); + } + files.push(fullPath); + } + return files; +} + +function tarPathParts(relativePath) { + const normalized = relativePath.split(path.sep).join('/'); + const bytes = Buffer.byteLength(normalized); + if (bytes <= 100) { + return { name: normalized, prefix: '' }; + } + const parts = normalized.split('/'); + for (let index = 1; index < parts.length; index += 1) { + const prefix = parts.slice(0, index).join('/'); + const name = parts.slice(index).join('/'); + if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) { + return { name, prefix }; + } + } + fail(`prebuilt extension artifact archive path is too long for ustar: ${normalized}`); +} + +function writeString(buffer, offset, length, value) { + const bytes = Buffer.from(value); + if (bytes.length > length) { + fail(`tar header field overflow for '${value}'`); + } + bytes.copy(buffer, offset); +} + +function writeOctal(buffer, offset, length, value) { + const text = value.toString(8).padStart(length - 1, '0').slice(-(length - 1)); + writeString(buffer, offset, length, `${text}\0`); +} + +function tarHeader(relativePath, size, mode) { + const header = Buffer.alloc(512, 0); + const { name, prefix } = tarPathParts(relativePath); + writeString(header, 0, 100, name); + writeOctal(header, 100, 8, mode); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, size); + writeOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + writeString(header, 156, 1, '0'); + writeString(header, 257, 6, 'ustar\0'); + writeString(header, 263, 2, '00'); + writeString(header, 265, 32, 'root'); + writeString(header, 297, 32, 'root'); + writeString(header, 345, 155, prefix); + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + const checksumText = checksum.toString(8).padStart(6, '0'); + writeString(header, 148, 8, `${checksumText}\0 `); + return header; +} + +async function createTar(base) { + const chunks = []; + const files = await listFilesRecursive(base); + for (const file of files) { + const relative = validateRelativeArtifactPath(path.relative(base, file).split(path.sep).join('/'), 'archive file'); + const stat = await fs.stat(file); + const mode = stat.mode & 0o111 ? 0o755 : 0o644; + const data = await fs.readFile(file); + chunks.push(tarHeader(relative, data.length, mode)); + chunks.push(data); + const remainder = data.length % 512; + if (remainder !== 0) { + chunks.push(Buffer.alloc(512 - remainder, 0)); + } + } + chunks.push(Buffer.alloc(1024, 0)); + return Buffer.concat(chunks); +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + switch (command) { + case 'list-catalog': + await listCatalog(); + break; + case 'selected-sql-names': + await selectedSqlNames(args[0] ?? ''); + break; + case 'product-version': + await productVersion(args[0] ?? ''); + break; + case 'create-artifact': + await createArtifact(args); + break; + default: + fail('usage: extension-artifact-packager.mjs [options]'); + } +} + +main().catch((error) => { + console.error(`extension-artifact-packager.mjs: ${error.message}`); + process.exit(2); +}); diff --git a/src/extensions/artifacts/native/tools/package-release-assets.sh b/src/extensions/artifacts/native/tools/package-release-assets.sh new file mode 100755 index 00000000..50ee0740 --- /dev/null +++ b/src/extensions/artifacts/native/tools/package-release-assets.sh @@ -0,0 +1,588 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$root" ]; then + root="$(cd "$script_dir/../../../../.." && pwd)" +fi +cd "$root" + +fail() { + echo "package-native-extension-assets.sh: $*" >&2 + exit 1 +} + +require() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +source "$root/src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh" +packager="src/extensions/artifacts/native/tools/extension-artifact-packager.mjs" + +target_id="${OLIPHAUNT_EXTENSION_TARGET:-${1:-}}" +case "$target_id" in + macos-arm64|linux-x64-gnu|linux-arm64-gnu|windows-x64-msvc|ios-xcframework|android-arm64-v8a|android-x86_64) + ;; + "") + fail "usage: OLIPHAUNT_EXTENSION_TARGET= $0, where target is macos-arm64, linux-x64-gnu, linux-arm64-gnu, windows-x64-msvc, ios-xcframework, android-arm64-v8a, or android-x86_64" + ;; + *) + fail "unsupported native extension artifact target: $target_id" + ;; +esac + +require awk +require bun + +if [ "$target_id" = "ios-xcframework" ]; then + require ditto + require rsync +fi + +extension_product="${OLIPHAUNT_EXTENSION_PRODUCT:-${2:-}}" +extension_products="${OLIPHAUNT_EXTENSION_PRODUCTS:-}" +if [ -n "$extension_product" ]; then + if [ -n "$extension_products" ]; then + extension_products="$extension_products,$extension_product" + else + extension_products="$extension_product" + fi +fi +selected_sql_names="" +if [ -n "$extension_products" ]; then + selected_sql_names="$(bun "$packager" selected-sql-names "$extension_products")" +fi + +version="${OLIPHAUNT_EXTENSION_RELEASE_VERSION:-$(bun "$packager" product-version liboliphaunt-native)}" +default_out_dir="$root/target/extensions/native/release-assets/$target_id" +default_stage_root="$root/target/extensions/native/release-stage/$target_id" +if [ -n "$extension_product" ] && [ -z "${OLIPHAUNT_EXTENSION_PRODUCTS:-}" ]; then + default_out_dir="$default_out_dir/$extension_product" + default_stage_root="$default_stage_root/$extension_product" +fi +out_dir="${OLIPHAUNT_EXTENSION_RELEASE_ASSET_DIR:-$default_out_dir}" +stage_root="${OLIPHAUNT_EXTENSION_RELEASE_STAGE_ROOT:-$default_stage_root}" +catalog_file="$stage_root/extension-catalog.tsv" +legacy_extension_index="$out_dir/liboliphaunt-${version}-extension-assets.tsv" +native_asset_index="$out_dir/liboliphaunt-${version}-native-extension-assets.tsv" + +rm -rf "$stage_root" +mkdir -p "$out_dir" "$stage_root" + +csv_join() { + paste -sd ',' - +} + +catalog_rows() { + awk -F '\t' 'NR > 1 { print }' "$catalog_file" +} + +mobile_module_extensions_csv() { + catalog_rows | awk -F '\t' -v selected="$selected_sql_names" ' + function selected_match(sql_name, selected, parts, count, i) { + if (selected == "") { + return 1 + } + count = split(selected, parts, ",") + for (i = 1; i <= count; i++) { + if (parts[i] == sql_name) { + return 1 + } + } + return 0 + } + $8 == "yes" && $9 == "yes" && $4 != "-" && selected_match($1, selected) { print $1 } + ' | csv_join +} + +selected_sql_name_matches() { + local sql_name="$1" + local selected + [ -n "$selected_sql_names" ] || return 0 + IFS=',' read -r -a selected <<<"$selected_sql_names" + local item + for item in "${selected[@]}"; do + [ "$item" = "$sql_name" ] && return 0 + done + return 1 +} + +require_file() { + local path="$1" + local description="$2" + [ -f "$path" ] || fail "missing $description at $path" +} + +require_dir() { + local path="$1" + local description="$2" + [ -d "$path" ] || fail "missing $description at $path" +} + +artifact_bytes() { + local artifact="$1" + require_file "$out_dir/$artifact" "release artifact $artifact" + wc -c <"$out_dir/$artifact" | awk '{ print $1 }' +} + +artifact_bytes_or_dash() { + local artifact="${1:-}" + if [ -z "$artifact" ] || [ "$artifact" = "-" ]; then + printf '%s\n' '-' + return 0 + fi + artifact_bytes "$artifact" +} + +write_indexes() { + printf 'sql_name\tcreates_extension\tnative_module_stem\tdependencies\tshared_preload\tmobile_prebuilt\tmobile_static_archive_targets\truntime_artifact\tios_xcframework_artifact\tandroid_arm64_artifact\tandroid_x86_64_artifact\truntime_artifact_bytes\tios_xcframework_artifact_bytes\tandroid_arm64_artifact_bytes\tandroid_x86_64_artifact_bytes\tdata_files\n' >"$legacy_extension_index" + printf 'sql_name\ttarget\tkind\tartifact\tartifact_bytes\n' >"$native_asset_index" +} + +append_legacy_index_row() { + local sql_name="$1" + local creates_extension="$2" + local stem="$3" + local dependencies="$4" + local shared_preload="$5" + local mobile_prebuilt="$6" + local mobile_targets="$7" + local runtime_artifact="$8" + local ios_artifact="${9:-}" + local android_arm64_artifact="${10:-}" + local android_x86_64_artifact="${11:-}" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$sql_name" \ + "$creates_extension" \ + "$stem" \ + "$dependencies" \ + "$shared_preload" \ + "$mobile_prebuilt" \ + "$mobile_targets" \ + "${runtime_artifact:--}" \ + "${ios_artifact:--}" \ + "${android_arm64_artifact:--}" \ + "${android_x86_64_artifact:--}" \ + "$(artifact_bytes_or_dash "${runtime_artifact:-}")" \ + "$(artifact_bytes_or_dash "${ios_artifact:-}")" \ + "$(artifact_bytes_or_dash "${android_arm64_artifact:-}")" \ + "$(artifact_bytes_or_dash "${android_x86_64_artifact:-}")" \ + "${12:-}" >>"$legacy_extension_index" +} + +append_native_asset_index_row() { + local sql_name="$1" + local kind="$2" + local artifact="$3" + [ -n "$artifact" ] && [ "$artifact" != "-" ] || return 0 + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$sql_name" \ + "$target_id" \ + "$kind" \ + "$artifact" \ + "$(artifact_bytes "$artifact")" >>"$native_asset_index" +} + +fetch_extension_source_assets() { + if [ "${OLIPHAUNT_RELEASE_FETCH_ASSETS:-0}" != "1" ]; then + echo "==> Source asset fetch handled by the source-inputs Moon dependency; set OLIPHAUNT_RELEASE_FETCH_ASSETS=1 for standalone refresh" + return 0 + fi + echo "==> Fetching pinned native runtime and extension source assets" + bun tools/policy/fetch-sources.mjs native-runtime >/tmp/liboliphaunt-release-extension-assets-fetch.log +} + +archive_swiftpm_xcframework() { + local xcframework="$1" + local output="$2" + [ -d "$xcframework" ] || fail "missing SwiftPM XCFramework input at $xcframework" + rm -f "$output" + ( + cd "$(dirname "$xcframework")" + ditto -c -k --keepParent "$(basename "$xcframework")" "$output" + ) +} + +mobile_static_dependency_archive() { + local work_root="$1" + local dependency="$2" + local dependency_root="$work_root/out/dependencies" + local archive + if archive="$(oliphaunt_mobile_static_dependency_archive_for_root "$dependency_root" "$dependency")"; then + printf '%s\n' "$archive" + return 0 + fi + return 1 +} + +mobile_dependency_args=() + +collect_mobile_static_dependency_archive_args() { + local target="$1" + local work_root="$2" + local sql_name="$3" + local dependency archive + mobile_dependency_args=() + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + if ! archive="$(mobile_static_dependency_archive "$work_root" "$dependency")"; then + fail "missing $target static dependency archive for $sql_name dependency $dependency under $work_root/out/dependencies" + fi + mobile_dependency_args+=(--mobile-static-dependency-archive "$target:$dependency:$archive") + done < <(oliphaunt_mobile_static_extension_dependencies_for_target "$sql_name" "$target" || true) +} + +module_suffix_for_target() { + case "$target_id" in + macos-*|ios-xcframework) printf 'dylib\n' ;; + android-*) printf 'so\n' ;; + linux-*) printf 'so\n' ;; + windows-*) printf 'dll\n' ;; + *) fail "no module suffix for target $target_id" ;; + esac +} + +host_extension_runtime_root() { + case "$target_id" in + macos-arm64) + printf '%s\n' "${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18-extension-release-$target_id}/install" + ;; + linux-x64-gnu|linux-arm64-gnu) + printf '%s\n' "${OLIPHAUNT_LINUX_WORK_ROOT:-$root/target/liboliphaunt-pg18-$target_id-extension-release}/install" + ;; + windows-x64-msvc) + printf '%s\n' "${OLIPHAUNT_WINDOWS_WORK_ROOT:-$root/target/liboliphaunt-pg18-$target_id-extension-release}/install" + ;; + ios-xcframework|android-arm64-v8a|android-x86_64) + case "$target_id" in + ios-xcframework) + printf '%s\n' "${OLIPHAUNT_EXTENSION_HOST_RUNTIME_ROOT:-$root/target/liboliphaunt-pg18-extension-release-$target_id/install}" + ;; + android-*) + printf '%s\n' "${OLIPHAUNT_EXTENSION_HOST_RUNTIME_ROOT:-$root/target/liboliphaunt-pg18-linux-x64-gnu-extension-release/install}" + ;; + esac + ;; + esac +} + +build_desktop_extension_runtime() { + case "$target_id" in + macos-arm64) + [ "$(uname -s)" = "Darwin" ] || fail "$target_id extension artifacts must be built on macOS" + env \ + OLIPHAUNT_WORK_ROOT="${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18-extension-release-$target_id}" \ + OLIPHAUNT_BUILD_EXTENSIONS=1 \ + OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES="$selected_sql_names" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh >/tmp/liboliphaunt-release-"$target_id"-extensions.log + ;; + linux-x64-gnu|linux-arm64-gnu) + [ "$(uname -s)" = "Linux" ] || fail "$target_id extension artifacts must be built on Linux" + env \ + OLIPHAUNT_LINUX_WORK_ROOT="${OLIPHAUNT_LINUX_WORK_ROOT:-$root/target/liboliphaunt-pg18-$target_id-extension-release}" \ + OLIPHAUNT_BUILD_EXTENSIONS=1 \ + OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES="$selected_sql_names" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh >/tmp/liboliphaunt-release-"$target_id"-extensions.log + ;; + windows-x64-msvc) + env \ + OLIPHAUNT_WINDOWS_WORK_ROOT="${OLIPHAUNT_WINDOWS_WORK_ROOT:-$root/target/liboliphaunt-pg18-$target_id-extension-release}" \ + OLIPHAUNT_BUILD_EXTENSIONS=1 \ + OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES="$selected_sql_names" \ + pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass \ + -File src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 >/tmp/liboliphaunt-release-"$target_id"-extensions.log + ;; + *) + fail "desktop extension runtime builder called for non-desktop target $target_id" + ;; + esac +} + +build_mobile_host_extension_runtime() { + case "$target_id" in + ios-xcframework) + [ "$(uname -s)" = "Darwin" ] || fail "$target_id host extension runtime must be built on macOS" + env \ + OLIPHAUNT_WORK_ROOT="${OLIPHAUNT_EXTENSION_MACOS_RUNTIME_ROOT:-$root/target/liboliphaunt-pg18-extension-release-$target_id}" \ + OLIPHAUNT_BUILD_EXTENSIONS=1 \ + OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES="$selected_sql_names" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh >/tmp/liboliphaunt-release-mobile-host-extensions.log + ;; + android-*) + [ "$(uname -s)" = "Linux" ] || fail "$target_id host extension runtime must be built on Linux" + env \ + OLIPHAUNT_LINUX_WORK_ROOT="${OLIPHAUNT_EXTENSION_LINUX_RUNTIME_ROOT:-$root/target/liboliphaunt-pg18-linux-x64-gnu-extension-release}" \ + OLIPHAUNT_BUILD_EXTENSIONS=1 \ + OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES="$selected_sql_names" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh >/tmp/liboliphaunt-release-mobile-host-extensions.log + ;; + *) + fail "mobile host extension runtime requested for non-mobile target $target_id" + ;; + esac +} + +build_mobile_static_artifacts() { + local mobile_extensions="$1" + [ -n "$mobile_extensions" ] || return 0 + case "$target_id" in + ios-xcframework) + [ "$(uname -s)" = "Darwin" ] || fail "$target_id extension artifacts must be built on macOS" + env \ + OLIPHAUNT_IOS_SIMULATOR_ROOT="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-simulator" \ + OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$mobile_extensions" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh >/tmp/liboliphaunt-release-ios-simulator-extensions.log + env \ + OLIPHAUNT_IOS_DEVICE_ROOT="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-device" \ + OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$mobile_extensions" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh >/tmp/liboliphaunt-release-ios-device-extensions.log + env \ + OLIPHAUNT_IOS_SIMULATOR_OUT="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-simulator/out" \ + OLIPHAUNT_IOS_DEVICE_OUT="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-device/out" \ + OLIPHAUNT_IOS_EXTENSION_XCFRAMEWORK_ROOT="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-extension-xcframeworks" \ + OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$mobile_extensions" \ + src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh >/tmp/liboliphaunt-release-ios-extension-xcframeworks.log + ;; + android-arm64-v8a) + env \ + OLIPHAUNT_ANDROID_ARM64_ROOT="$root/target/liboliphaunt-mobile-extension-release/$target_id/android-arm64" \ + OLIPHAUNT_ANDROID_ABI=arm64-v8a \ + OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$mobile_extensions" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh >/tmp/liboliphaunt-release-android-arm64-extensions.log + ;; + android-x86_64) + env \ + OLIPHAUNT_ANDROID_X86_64_ROOT="$root/target/liboliphaunt-mobile-extension-release/$target_id/android-x86_64" \ + OLIPHAUNT_ANDROID_ABI=x86_64 \ + OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$mobile_extensions" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh >/tmp/liboliphaunt-release-android-x86_64-extensions.log + ;; + esac +} + +desktop_runtime_artifact_name() { + local sql_name="$1" + printf 'liboliphaunt-%s-extension-%s-%s-runtime.tar.gz\n' "$version" "$sql_name" "$target_id" +} + +mobile_runtime_artifact_name() { + local sql_name="$1" + printf 'liboliphaunt-%s-extension-%s-%s-runtime.tar.gz\n' "$version" "$sql_name" "$target_id" +} + +make_extension_artifact() { + local runtime="$1" + local sql_name="$2" + local creates_extension="$3" + local stem="$4" + local dependencies="$5" + local shared_preload="$6" + local data_files="$7" + local output="$8" + shift 8 + + local -a artifact_args=( + "$packager" create-artifact + --runtime "$runtime" + --sql-name "$sql_name" + --creates-extension "$creates_extension" + --target "$target_id" + --output "$out_dir/$output" + --stage-root "$stage_root" + --format tar-gz + --force + ) + if [ "$stem" != "-" ]; then + artifact_args+=(--native-module-stem "$stem" --native-module-file "$stem.$(module_suffix_for_target)") + fi + if [ "$dependencies" != "-" ]; then + artifact_args+=(--dependency "$dependencies") + fi + if [ "$shared_preload" != "-" ]; then + artifact_args+=(--shared-preload-library "$shared_preload") + fi + if [ "$data_files" != "-" ]; then + IFS=',' read -r -a data_file_array <<<"$data_files" + for data_file in "${data_file_array[@]}"; do + [ -n "$data_file" ] || continue + artifact_args+=(--data-file "$data_file") + done + fi + if [ "$#" -gt 0 ]; then + artifact_args+=("$@") + fi + bun "${artifact_args[@]}" >/tmp/liboliphaunt-release-extension-artifact-"$target_id"-"$sql_name".log +} + +package_desktop_target() { + local runtime + build_desktop_extension_runtime + runtime="$(host_extension_runtime_root)" + require_dir "$runtime" "$target_id extension runtime" + + local module_suffix + module_suffix="$(module_suffix_for_target)" + local sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy runtime_artifact + while IFS=$'\t' read -r sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy; do + [ -n "$sql_name" ] || continue + selected_sql_name_matches "$sql_name" || continue + [ "$pg_major" = "18" ] || fail "extension catalog row for $sql_name targets PostgreSQL $pg_major" + [ "$desktop_prebuilt" = "yes" ] || continue + runtime_artifact="$(desktop_runtime_artifact_name "$sql_name")" + make_extension_artifact \ + "$runtime" \ + "$sql_name" \ + "$creates_extension" \ + "$stem" \ + "$dependencies" \ + "$shared_preload" \ + "$data_files" \ + "$runtime_artifact" + append_native_asset_index_row "$sql_name" runtime "$runtime_artifact" + append_legacy_index_row "$sql_name" "$creates_extension" "$stem" "$dependencies" "$shared_preload" "$mobile_prebuilt" "-" "$runtime_artifact" "-" "-" "-" "$data_files" + done < <(catalog_rows) + printf '%s\n' "$module_suffix" >/dev/null +} + +package_ios_target() { + local runtime mobile_extensions ios_sim_root ios_device_root ios_xcframework_root + build_mobile_host_extension_runtime + mobile_extensions="$(mobile_module_extensions_csv)" + build_mobile_static_artifacts "$mobile_extensions" + runtime="$(host_extension_runtime_root)" + require_dir "$runtime" "mobile host extension runtime" + ios_sim_root="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-simulator" + ios_device_root="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-device" + ios_xcframework_root="$root/target/liboliphaunt-mobile-extension-release/$target_id/ios-extension-xcframeworks" + + local sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy runtime_artifact ios_artifact static_prefix + while IFS=$'\t' read -r sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy; do + [ -n "$sql_name" ] || continue + selected_sql_name_matches "$sql_name" || continue + [ "$pg_major" = "18" ] || fail "extension catalog row for $sql_name targets PostgreSQL $pg_major" + [ "$mobile_prebuilt" = "yes" ] || continue + + runtime_artifact="$(mobile_runtime_artifact_name "$sql_name")" + extra_args=() + if [ "$stem" != "-" ]; then + ios_sim_archive="$ios_sim_root/out/extensions/$stem/liboliphaunt_extension_$stem.a" + ios_device_archive="$ios_device_root/out/extensions/$stem/liboliphaunt_extension_$stem.a" + static_prefix="$(oliphaunt_static_symbol_prefix "$stem")" + require_file "$ios_sim_archive" "iOS simulator static archive for $sql_name" + require_file "$ios_device_archive" "iOS device static archive for $sql_name" + extra_args+=( + --mobile-static-archive "ios-simulator:$ios_sim_archive" + --mobile-static-archive "ios-device:$ios_device_archive" + --static-symbol-prefix "$static_prefix" + ) + if [ "$sql_name" = "postgis" ]; then + extra_args+=( + --static-symbol-alias "difference:${static_prefix}_difference" + --static-symbol-alias "pg_finfo_difference:pg_finfo_${static_prefix}_difference" + ) + fi + collect_mobile_static_dependency_archive_args ios-simulator "$ios_sim_root" "$sql_name" + extra_args+=(${mobile_dependency_args[@]+"${mobile_dependency_args[@]}"}) + collect_mobile_static_dependency_archive_args ios-device "$ios_device_root" "$sql_name" + extra_args+=(${mobile_dependency_args[@]+"${mobile_dependency_args[@]}"}) + + stage_ios_extension="$stage_root/liboliphaunt-${version}-ios-extension-$stem" + rm -rf "$stage_ios_extension" + mkdir -p "$stage_ios_extension" + rsync -a --delete \ + "$ios_xcframework_root/out/$stem/liboliphaunt_extension_$stem.xcframework" \ + "$stage_ios_extension/" + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + dependency_xcframework="$ios_xcframework_root/out/dependencies/$dependency/liboliphaunt_dependency_$dependency.xcframework" + require_dir "$dependency_xcframework" "iOS dependency XCFramework for $sql_name dependency $dependency" + mkdir -p "$stage_ios_extension/dependencies/$dependency" + rsync -a --delete "$dependency_xcframework" "$stage_ios_extension/dependencies/$dependency/" + done < <(oliphaunt_mobile_static_extension_dependencies_for_target "$sql_name" ios || true) + archive_swiftpm_xcframework \ + "$stage_ios_extension/liboliphaunt_extension_$stem.xcframework" \ + "$out_dir/liboliphaunt-${version}-apple-spm-extension-$stem.zip" + ios_artifact="liboliphaunt-${version}-apple-spm-extension-$stem.zip" + else + ios_artifact="-" + fi + make_extension_artifact "$runtime" "$sql_name" "$creates_extension" "$stem" "$dependencies" "$shared_preload" "$data_files" "$runtime_artifact" ${extra_args[@]+"${extra_args[@]}"} + append_native_asset_index_row "$sql_name" runtime "$runtime_artifact" + append_native_asset_index_row "$sql_name" ios-xcframework "$ios_artifact" + append_legacy_index_row "$sql_name" "$creates_extension" "$stem" "$dependencies" "$shared_preload" "$mobile_prebuilt" "ios-simulator,ios-device" "$runtime_artifact" "$ios_artifact" "-" "-" "$data_files" + done < <(catalog_rows) +} + +package_android_target() { + local runtime mobile_extensions android_root android_static_target + build_mobile_host_extension_runtime + mobile_extensions="$(mobile_module_extensions_csv)" + build_mobile_static_artifacts "$mobile_extensions" + runtime="$(host_extension_runtime_root)" + require_dir "$runtime" "mobile host extension runtime" + case "$target_id" in + android-arm64-v8a) + android_root="$root/target/liboliphaunt-mobile-extension-release/$target_id/android-arm64" + android_static_target="android-arm64-v8a" + ;; + android-x86_64) + android_root="$root/target/liboliphaunt-mobile-extension-release/$target_id/android-x86_64" + android_static_target="android-x86_64" + ;; + *) fail "Android target packager called for $target_id" ;; + esac + + local sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy runtime_artifact android_archive static_prefix + while IFS=$'\t' read -r sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy; do + [ -n "$sql_name" ] || continue + selected_sql_name_matches "$sql_name" || continue + [ "$pg_major" = "18" ] || fail "extension catalog row for $sql_name targets PostgreSQL $pg_major" + [ "$mobile_prebuilt" = "yes" ] || continue + + runtime_artifact="$(mobile_runtime_artifact_name "$sql_name")" + extra_args=() + if [ "$stem" != "-" ]; then + android_archive="$android_root/out/extensions/$stem/liboliphaunt_extension_$stem.a" + static_prefix="$(oliphaunt_static_symbol_prefix "$stem")" + require_file "$android_archive" "Android static archive for $sql_name" + extra_args+=( + --mobile-static-archive "$android_static_target:$android_archive" + --static-symbol-prefix "$static_prefix" + ) + if [ "$sql_name" = "postgis" ]; then + extra_args+=( + --static-symbol-alias "difference:${static_prefix}_difference" + --static-symbol-alias "pg_finfo_difference:pg_finfo_${static_prefix}_difference" + ) + fi + collect_mobile_static_dependency_archive_args "$android_static_target" "$android_root" "$sql_name" + extra_args+=(${mobile_dependency_args[@]+"${mobile_dependency_args[@]}"}) + fi + make_extension_artifact "$runtime" "$sql_name" "$creates_extension" "$stem" "$dependencies" "$shared_preload" "$data_files" "$runtime_artifact" ${extra_args[@]+"${extra_args[@]}"} + append_native_asset_index_row "$sql_name" runtime "$runtime_artifact" + append_legacy_index_row "$sql_name" "$creates_extension" "$stem" "$dependencies" "$shared_preload" "$mobile_prebuilt" "$android_static_target" "$runtime_artifact" "-" "-" "-" "$data_files" + done < <(catalog_rows) +} + +fetch_extension_source_assets +echo "==> Reading exact extension catalog" +bun "$packager" list-catalog >"$catalog_file" +write_indexes + +case "$target_id" in + macos-arm64|linux-x64-gnu|linux-arm64-gnu|windows-x64-msvc) + package_desktop_target + ;; + ios-xcframework) + package_ios_target + ;; + android-arm64-v8a|android-x86_64) + package_android_target + ;; +esac + +[ "$(wc -l <"$native_asset_index" | awk '{ print $1 }')" -gt 1 ] || + fail "no native exact-extension artifacts were produced for target $target_id${extension_product:+ product $extension_product}" + +echo "extensionReleaseAssetDir=$out_dir" diff --git a/src/extensions/artifacts/packages/moon.yml b/src/extensions/artifacts/packages/moon.yml new file mode 100644 index 00000000..a243dcf6 --- /dev/null +++ b/src/extensions/artifacts/packages/moon.yml @@ -0,0 +1,62 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extension-packages" +language: "python" +layer: "tool" +stack: "systems" +tags: ["extensions", "artifacts", "release"] +dependsOn: + - id: "extension-artifacts-native" + scope: "build" + - id: "extension-artifacts-wasix" + scope: "build" + - id: "extensions" + scope: "build" + +project: + title: "Extension Packages" + description: "Publishable exact SQL extension artifacts staged per release product." + owner: "oliphaunt" + +tasks: + assemble-mobile: + tags: ["release", "artifact-package", "ci-mobile-extension-packages"] + command: "bash src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/targets/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + outputs: + - "/target/extension-artifacts/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true + + assemble-release: + tags: ["release", "artifact-package", "ci-extension-packages"] + command: "python3 tools/release/build-extension-ci-artifacts.py --all --require-native --require-wasix" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/targets/**/*" + - "/src/runtimes/liboliphaunt/wasix/targets/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true diff --git a/src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh b/src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh new file mode 100755 index 00000000..707eee8d --- /dev/null +++ b/src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "package-mobile-release-assets.sh: $*" >&2 + exit 1 +} + +targets_csv="${OLIPHAUNT_EXTENSION_PACKAGE_NATIVE_TARGETS:-}" +[ -n "$targets_csv" ] || fail "OLIPHAUNT_EXTENSION_PACKAGE_NATIVE_TARGETS must list one or more native targets" + +products_csv="${OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS:-}" +args=() +validation_args=() +if [ -z "$products_csv" ]; then + fail "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS must list selected exact-extension products for mobile packaging" +fi +IFS=',' read -r -a products <<<"$products_csv" +for product in "${products[@]}"; do + product="$(printf '%s' "$product" | xargs)" + [ -n "$product" ] || continue + args+=("$product") + validation_args+=(--require-extension-product "$product") +done + +[ "${#args[@]}" -gt 0 ] || fail "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS did not contain any products" + +IFS=',' read -r -a targets <<<"$targets_csv" +seen="," +for target in "${targets[@]}"; do + target="$(printf '%s' "$target" | xargs)" + [ -n "$target" ] || continue + case "$target" in + android-arm64-v8a|android-x86_64|ios-xcframework) + ;; + *) + fail "mobile extension package target must be android-arm64-v8a, android-x86_64, or ios-xcframework; got $target" + ;; + esac + case "$seen" in + *",$target,"*) + ;; + *) + seen="$seen$target," + args+=(--require-native-target "$target") + ;; + esac +done + +case " ${args[*]} " in + *" --require-native-target "*) + ;; + *) + fail "OLIPHAUNT_EXTENSION_PACKAGE_NATIVE_TARGETS did not contain any targets" + ;; +esac + +python3 tools/release/build-extension-ci-artifacts.py "${args[@]}" +python3 tools/release/check_staged_artifacts.py "${validation_args[@]}" diff --git a/src/extensions/artifacts/wasix/moon.yml b/src/extensions/artifacts/wasix/moon.yml new file mode 100644 index 00000000..e191d7ac --- /dev/null +++ b/src/extensions/artifacts/wasix/moon.yml @@ -0,0 +1,58 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extension-artifacts-wasix" +language: "unknown" +layer: "tool" +stack: "systems" +tags: ["extensions", "artifacts", "wasix"] +dependsOn: + - id: "extension-model" + scope: "build" + - id: "extensions" + scope: "build" + - id: "liboliphaunt-wasix" + scope: "build" + - id: "source-inputs" + scope: "build" + +project: + title: "WASIX Extension Artifacts" + description: "Publishable exact-extension artifact checks for the WASIX runtime." + owner: "oliphaunt" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-model.py --check" + deps: + - "extension-model:check" + inputs: + - "/src/extensions/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + options: + cache: true + runFromWorkspaceRoot: true + build-target: + tags: ["release", "artifact-builder", "ci-extension-artifacts-wasix"] + command: "bash src/extensions/artifacts/wasix/tools/package-release-assets.sh" + deps: + - "extension-artifacts-wasix:check" + - "liboliphaunt-wasix:runtime-portable" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/extensions/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/xtask/**/*" + outputs: + - "/target/extensions/wasix/release-assets/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true diff --git a/src/extensions/artifacts/wasix/tools/package-release-assets.sh b/src/extensions/artifacts/wasix/tools/package-release-assets.sh new file mode 100755 index 00000000..b53ea4af --- /dev/null +++ b/src/extensions/artifacts/wasix/tools/package-release-assets.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$root" ]; then + root="$(cd "$script_dir/../../../../.." && pwd -P)" +fi +[ -f "$root/package.json" ] && [ -d "$root/src/extensions/artifacts/wasix" ] || { + echo "package-wasix-extension-assets.sh: must run inside the Oliphaunt workspace" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "package-wasix-extension-assets.sh: $*" >&2 + exit 1 +} + +raw_target="${OLIPHAUNT_EXTENSION_TARGET:-portable}" +case "$raw_target" in + portable | wasix-portable) target_id="wasix-portable" ;; + *) fail "WASIX exact-extension artifacts are portable; unsupported target '$raw_target'" ;; +esac + +extension_product="${OLIPHAUNT_EXTENSION_PRODUCT:-${1:-}}" +extension_products="${OLIPHAUNT_EXTENSION_PRODUCTS:-}" +if [ -n "$extension_product" ]; then + if [ -n "$extension_products" ]; then + extension_products="$extension_products,$extension_product" + else + extension_products="$extension_product" + fi +fi +selected_sql_names="" +if [ -n "$extension_products" ]; then + selected_sql_names="$( + python3 - "$extension_products" <<'PY' +import sys +from pathlib import Path + +root = Path.cwd() +sys.path.insert(0, str(root / "tools" / "release")) +import product_metadata + +products = sorted({item.strip() for item in sys.argv[1].split(",") if item.strip()}) +if not products: + raise SystemExit("no exact-extension products were selected") +sql_names = [] +for product in products: + config = product_metadata.product_config(product) + if config.get("kind") != "exact-extension-artifact": + raise SystemExit(f"{product} is not an exact-extension artifact product") + sql_name = config.get("extension_sql_name") + if not isinstance(sql_name, str) or not sql_name: + raise SystemExit(f"{product} release metadata must declare extension_sql_name") + sql_names.append(sql_name) +print(",".join(sorted(set(sql_names)))) +PY + )" +fi + +version="$(python3 tools/release/product_metadata.py version liboliphaunt-wasix)" +asset_root="$root/target/oliphaunt-wasix/assets" +generated_metadata="$root/src/extensions/generated/wasix/extensions.json" +default_out_dir="$root/target/extensions/wasix/release-assets/$target_id" +if [ -n "$extension_product" ] && [ -z "${OLIPHAUNT_EXTENSION_PRODUCTS:-}" ]; then + default_out_dir="$default_out_dir/$extension_product" +fi +out_dir="${OLIPHAUNT_WASIX_EXTENSION_RELEASE_ASSET_DIR:-$default_out_dir}" +asset_index="$out_dir/liboliphaunt-wasix-${version}-wasix-extension-assets.tsv" + +[ -f "$generated_metadata" ] || fail "missing generated WASIX extension metadata: ${generated_metadata#$root/}" +[ -d "$asset_root/extensions" ] || fail "missing WASIX extension asset directory: ${asset_root#$root/}/extensions" + +rm -rf "$out_dir" +mkdir -p "$out_dir" + +python3 - "$root" "$asset_root" "$generated_metadata" "$out_dir" "$version" "$target_id" "$asset_index" "$selected_sql_names" <<'PY' +from __future__ import annotations + +import csv +import json +import shutil +import sys +from pathlib import Path + + +root = Path(sys.argv[1]) +asset_root = Path(sys.argv[2]) +metadata_path = Path(sys.argv[3]) +out_dir = Path(sys.argv[4]) +version = sys.argv[5] +target_id = sys.argv[6] +asset_index = Path(sys.argv[7]) +selected_sql_names = {item.strip() for item in sys.argv[8].split(",") if item.strip()} + + +def fail(message: str) -> None: + raise SystemExit(f"package-wasix-extension-assets.sh: {message}") + + +data = json.loads(metadata_path.read_text(encoding="utf-8")) +extensions = data.get("extensions") +if not isinstance(extensions, list) or not extensions: + fail(f"{metadata_path.relative_to(root)} must contain a non-empty extensions array") + +rows: list[dict[str, object]] = [] +for item in extensions: + if not isinstance(item, dict): + fail(f"{metadata_path.relative_to(root)} contains a non-object extension row") + sql_name = item.get("sql-name") + archive = item.get("archive") + if not isinstance(sql_name, str) or not sql_name: + fail(f"{metadata_path.relative_to(root)} contains an extension row without sql-name") + if selected_sql_names and sql_name not in selected_sql_names: + continue + if not isinstance(archive, str) or not archive: + fail(f"{metadata_path.relative_to(root)} row for {sql_name} is missing archive") + source = asset_root / archive + if not source.is_file(): + fail(f"missing WASIX extension archive for {sql_name}: {source.relative_to(root)}") + if source.stat().st_size == 0: + fail(f"WASIX extension archive for {sql_name} is empty: {source.relative_to(root)}") + destination_name = f"liboliphaunt-wasix-{version}-extension-{sql_name}-{target_id}.tar.zst" + destination = out_dir / destination_name + shutil.copy2(source, destination) + rows.append( + { + "sql_name": sql_name, + "target": target_id, + "kind": "wasix-runtime", + "artifact": destination_name, + "artifact_bytes": destination.stat().st_size, + } + ) + +if not rows: + fail("no WASIX extension artifacts were staged") + +with asset_index.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter( + handle, + delimiter="\t", + fieldnames=["sql_name", "target", "kind", "artifact", "artifact_bytes"], + lineterminator="\n", + ) + writer.writeheader() + writer.writerows(rows) + +print(f"staged {len(rows)} WASIX exact-extension artifact(s) in {out_dir.relative_to(root)}") +PY + +echo "wasixExtensionReleaseAssetDir=$out_dir" diff --git a/assets/extensions.promoted.toml b/src/extensions/catalog/extensions.promoted.toml similarity index 81% rename from assets/extensions.promoted.toml rename to src/extensions/catalog/extensions.promoted.toml index 7844e8d7..a4befb01 100644 --- a/assets/extensions.promoted.toml +++ b/src/extensions/catalog/extensions.promoted.toml @@ -2,7 +2,9 @@ format-version = 1 [[extensions]] id = "age" -stable = true +build = false +stable = false +blocker = "Apache AGE does not currently build against PostgreSQL 18.4 in the WASIX lane; ag_label.c still calls ExecInitExtraTupleSlot, which is not available in PG18. Keep graph/pgGraph out of release artifacts until there is an official PG18-compatible upstream." [[extensions]] id = "amcheck" @@ -118,9 +120,7 @@ stable = true [[extensions]] id = "pgcrypto" -build = false -stable = false -blocker = "Requires a pinned WASIX OpenSSL/libcrypto sysroot; current generic contrib build fails on openssl/evp.h." +stable = true [[extensions]] id = "pgtap" @@ -128,9 +128,7 @@ stable = true [[extensions]] id = "postgis" -build = false -stable = false -blocker = "Requires a pinned WASIX geospatial dependency stack and PostGIS configure/install-delta packaging before smoke." +stable = true [[extensions]] id = "seg" @@ -158,9 +156,7 @@ stable = true [[extensions]] id = "uuid_ossp" -build = false -stable = false -blocker = "Requires a pinned WASIX OSSP UUID/libuuid sysroot; upstream Emscripten builder provides this separately." +stable = true [[extensions]] id = "vector" diff --git a/assets/extensions.smoke.toml b/src/extensions/catalog/extensions.smoke.toml similarity index 73% rename from assets/extensions.smoke.toml rename to src/extensions/catalog/extensions.smoke.toml index d2ae5dcf..0406c8ec 100644 --- a/assets/extensions.smoke.toml +++ b/src/extensions/catalog/extensions.smoke.toml @@ -2,9 +2,9 @@ format-version = 1 [[extensions]] id = "age" -direct = "passed" -server = "passed" -restart = "passed" +direct = "not-run" +server = "not-run" +restart = "not-run" dump-restore = "not-run" [[extensions]] @@ -12,245 +12,266 @@ id = "amcheck" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "auto_explain" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "bloom" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "btree_gin" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "btree_gist" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "citext" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "cube" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "dict_int" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "dict_xsyn" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "earthdistance" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "file_fdw" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "fuzzystrmatch" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "hstore" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "intarray" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "isn" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "lo" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "ltree" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pageinspect" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_buffercache" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_freespacemap" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_hashids" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_ivm" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_surgery" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_textsearch" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_trgm" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_uuidv7" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_visibility" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "pg_walinspect" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" + +[[extensions]] +id = "pgcrypto" +direct = "passed" +server = "passed" +restart = "passed" +dump-restore = "passed" [[extensions]] id = "pgtap" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" + +[[extensions]] +id = "postgis" +direct = "passed" +server = "passed" +restart = "passed" +dump-restore = "passed" [[extensions]] id = "seg" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "tablefunc" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "tcn" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "tsm_system_rows" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "tsm_system_time" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" [[extensions]] id = "unaccent" direct = "passed" server = "passed" restart = "passed" -dump-restore = "not-run" +dump-restore = "passed" + +[[extensions]] +id = "uuid_ossp" +direct = "passed" +server = "passed" +restart = "passed" +dump-restore = "passed" [[extensions]] id = "vector" diff --git a/src/extensions/contrib/amcheck/CHANGELOG.md b/src/extensions/contrib/amcheck/CHANGELOG.md new file mode 100644 index 00000000..af366cae --- /dev/null +++ b/src/extensions/contrib/amcheck/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib amcheck exact-extension artifact release component. diff --git a/src/extensions/contrib/amcheck/VERSION b/src/extensions/contrib/amcheck/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/amcheck/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/amcheck/moon.yml b/src/extensions/contrib/amcheck/moon.yml new file mode 100644 index 00000000..2a2466c7 --- /dev/null +++ b/src/extensions/contrib/amcheck/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-amcheck" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-amcheck" + packagePath: "src/extensions/contrib/amcheck" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/amcheck" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/amcheck/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-amcheck --require-native --require-wasix" + deps: + - "oliphaunt-extension-amcheck:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/amcheck/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-amcheck/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/amcheck/release.toml b/src/extensions/contrib/amcheck/release.toml new file mode 100644 index 00000000..4c19bc1e --- /dev/null +++ b/src/extensions/contrib/amcheck/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-amcheck" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "amcheck" diff --git a/src/extensions/contrib/amcheck/targets/artifacts.toml b/src/extensions/contrib/amcheck/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/amcheck/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/auto_explain/CHANGELOG.md b/src/extensions/contrib/auto_explain/CHANGELOG.md new file mode 100644 index 00000000..07ae8dee --- /dev/null +++ b/src/extensions/contrib/auto_explain/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib auto_explain exact-extension artifact release component. diff --git a/src/extensions/contrib/auto_explain/VERSION b/src/extensions/contrib/auto_explain/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/auto_explain/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/auto_explain/moon.yml b/src/extensions/contrib/auto_explain/moon.yml new file mode 100644 index 00000000..4e234189 --- /dev/null +++ b/src/extensions/contrib/auto_explain/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-auto-explain" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-auto-explain" + packagePath: "src/extensions/contrib/auto_explain" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/auto_explain" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/auto_explain/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-auto-explain --require-native --require-wasix" + deps: + - "oliphaunt-extension-auto-explain:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/auto_explain/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-auto-explain/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/auto_explain/release.toml b/src/extensions/contrib/auto_explain/release.toml new file mode 100644 index 00000000..726e0fba --- /dev/null +++ b/src/extensions/contrib/auto_explain/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-auto-explain" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "auto_explain" diff --git a/src/extensions/contrib/auto_explain/targets/artifacts.toml b/src/extensions/contrib/auto_explain/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/auto_explain/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/bloom/CHANGELOG.md b/src/extensions/contrib/bloom/CHANGELOG.md new file mode 100644 index 00000000..9f6d1cc2 --- /dev/null +++ b/src/extensions/contrib/bloom/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib bloom exact-extension artifact release component. diff --git a/src/extensions/contrib/bloom/VERSION b/src/extensions/contrib/bloom/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/bloom/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/bloom/moon.yml b/src/extensions/contrib/bloom/moon.yml new file mode 100644 index 00000000..774a5c3e --- /dev/null +++ b/src/extensions/contrib/bloom/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-bloom" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-bloom" + packagePath: "src/extensions/contrib/bloom" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/bloom" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/bloom/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-bloom --require-native --require-wasix" + deps: + - "oliphaunt-extension-bloom:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/bloom/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-bloom/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/bloom/release.toml b/src/extensions/contrib/bloom/release.toml new file mode 100644 index 00000000..4173b427 --- /dev/null +++ b/src/extensions/contrib/bloom/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-bloom" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "bloom" diff --git a/src/extensions/contrib/bloom/targets/artifacts.toml b/src/extensions/contrib/bloom/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/bloom/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/btree_gin/CHANGELOG.md b/src/extensions/contrib/btree_gin/CHANGELOG.md new file mode 100644 index 00000000..dc9a2c2d --- /dev/null +++ b/src/extensions/contrib/btree_gin/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib btree_gin exact-extension artifact release component. diff --git a/src/extensions/contrib/btree_gin/VERSION b/src/extensions/contrib/btree_gin/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/btree_gin/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/btree_gin/moon.yml b/src/extensions/contrib/btree_gin/moon.yml new file mode 100644 index 00000000..55261e35 --- /dev/null +++ b/src/extensions/contrib/btree_gin/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-btree-gin" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-btree-gin" + packagePath: "src/extensions/contrib/btree_gin" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/btree_gin" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/btree_gin/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-btree-gin --require-native --require-wasix" + deps: + - "oliphaunt-extension-btree-gin:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/btree_gin/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-btree-gin/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/btree_gin/release.toml b/src/extensions/contrib/btree_gin/release.toml new file mode 100644 index 00000000..1832003f --- /dev/null +++ b/src/extensions/contrib/btree_gin/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-btree-gin" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "btree_gin" diff --git a/src/extensions/contrib/btree_gin/targets/artifacts.toml b/src/extensions/contrib/btree_gin/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/btree_gin/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/btree_gist/CHANGELOG.md b/src/extensions/contrib/btree_gist/CHANGELOG.md new file mode 100644 index 00000000..9f63accf --- /dev/null +++ b/src/extensions/contrib/btree_gist/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib btree_gist exact-extension artifact release component. diff --git a/src/extensions/contrib/btree_gist/VERSION b/src/extensions/contrib/btree_gist/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/btree_gist/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/btree_gist/moon.yml b/src/extensions/contrib/btree_gist/moon.yml new file mode 100644 index 00000000..c5eb4d95 --- /dev/null +++ b/src/extensions/contrib/btree_gist/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-btree-gist" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-btree-gist" + packagePath: "src/extensions/contrib/btree_gist" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/btree_gist" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/btree_gist/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-btree-gist --require-native --require-wasix" + deps: + - "oliphaunt-extension-btree-gist:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/btree_gist/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-btree-gist/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/btree_gist/release.toml b/src/extensions/contrib/btree_gist/release.toml new file mode 100644 index 00000000..291ff344 --- /dev/null +++ b/src/extensions/contrib/btree_gist/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-btree-gist" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "btree_gist" diff --git a/src/extensions/contrib/btree_gist/targets/artifacts.toml b/src/extensions/contrib/btree_gist/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/btree_gist/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/citext/CHANGELOG.md b/src/extensions/contrib/citext/CHANGELOG.md new file mode 100644 index 00000000..d6cf9c9e --- /dev/null +++ b/src/extensions/contrib/citext/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib citext exact-extension artifact release component. diff --git a/src/extensions/contrib/citext/VERSION b/src/extensions/contrib/citext/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/citext/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/citext/moon.yml b/src/extensions/contrib/citext/moon.yml new file mode 100644 index 00000000..fd0c6c47 --- /dev/null +++ b/src/extensions/contrib/citext/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-citext" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-citext" + packagePath: "src/extensions/contrib/citext" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/citext" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/citext/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-citext --require-native --require-wasix" + deps: + - "oliphaunt-extension-citext:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/citext/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-citext/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/citext/release.toml b/src/extensions/contrib/citext/release.toml new file mode 100644 index 00000000..61607f37 --- /dev/null +++ b/src/extensions/contrib/citext/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-citext" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "citext" diff --git a/src/extensions/contrib/citext/targets/artifacts.toml b/src/extensions/contrib/citext/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/citext/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/cube/CHANGELOG.md b/src/extensions/contrib/cube/CHANGELOG.md new file mode 100644 index 00000000..88d0220c --- /dev/null +++ b/src/extensions/contrib/cube/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib cube exact-extension artifact release component. diff --git a/src/extensions/contrib/cube/VERSION b/src/extensions/contrib/cube/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/cube/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/cube/moon.yml b/src/extensions/contrib/cube/moon.yml new file mode 100644 index 00000000..478d8c10 --- /dev/null +++ b/src/extensions/contrib/cube/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-cube" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-cube" + packagePath: "src/extensions/contrib/cube" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/cube" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/cube/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-cube --require-native --require-wasix" + deps: + - "oliphaunt-extension-cube:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/cube/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-cube/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/cube/release.toml b/src/extensions/contrib/cube/release.toml new file mode 100644 index 00000000..081a74c8 --- /dev/null +++ b/src/extensions/contrib/cube/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-cube" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "cube" diff --git a/src/extensions/contrib/cube/targets/artifacts.toml b/src/extensions/contrib/cube/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/cube/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/dict_int/CHANGELOG.md b/src/extensions/contrib/dict_int/CHANGELOG.md new file mode 100644 index 00000000..bd807f76 --- /dev/null +++ b/src/extensions/contrib/dict_int/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib dict_int exact-extension artifact release component. diff --git a/src/extensions/contrib/dict_int/VERSION b/src/extensions/contrib/dict_int/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/dict_int/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/dict_int/moon.yml b/src/extensions/contrib/dict_int/moon.yml new file mode 100644 index 00000000..c4992f61 --- /dev/null +++ b/src/extensions/contrib/dict_int/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-dict-int" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-dict-int" + packagePath: "src/extensions/contrib/dict_int" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/dict_int" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/dict_int/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-dict-int --require-native --require-wasix" + deps: + - "oliphaunt-extension-dict-int:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/dict_int/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-dict-int/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/dict_int/release.toml b/src/extensions/contrib/dict_int/release.toml new file mode 100644 index 00000000..79e4785d --- /dev/null +++ b/src/extensions/contrib/dict_int/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-dict-int" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "dict_int" diff --git a/src/extensions/contrib/dict_int/targets/artifacts.toml b/src/extensions/contrib/dict_int/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/dict_int/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/dict_xsyn/CHANGELOG.md b/src/extensions/contrib/dict_xsyn/CHANGELOG.md new file mode 100644 index 00000000..ea7ac105 --- /dev/null +++ b/src/extensions/contrib/dict_xsyn/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib dict_xsyn exact-extension artifact release component. diff --git a/src/extensions/contrib/dict_xsyn/VERSION b/src/extensions/contrib/dict_xsyn/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/dict_xsyn/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/dict_xsyn/moon.yml b/src/extensions/contrib/dict_xsyn/moon.yml new file mode 100644 index 00000000..67e796b8 --- /dev/null +++ b/src/extensions/contrib/dict_xsyn/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-dict-xsyn" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-dict-xsyn" + packagePath: "src/extensions/contrib/dict_xsyn" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/dict_xsyn" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/dict_xsyn/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-dict-xsyn --require-native --require-wasix" + deps: + - "oliphaunt-extension-dict-xsyn:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/dict_xsyn/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-dict-xsyn/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/dict_xsyn/release.toml b/src/extensions/contrib/dict_xsyn/release.toml new file mode 100644 index 00000000..d1f0184b --- /dev/null +++ b/src/extensions/contrib/dict_xsyn/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-dict-xsyn" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "dict_xsyn" diff --git a/src/extensions/contrib/dict_xsyn/targets/artifacts.toml b/src/extensions/contrib/dict_xsyn/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/dict_xsyn/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/earthdistance/CHANGELOG.md b/src/extensions/contrib/earthdistance/CHANGELOG.md new file mode 100644 index 00000000..81e6252e --- /dev/null +++ b/src/extensions/contrib/earthdistance/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib earthdistance exact-extension artifact release component. diff --git a/src/extensions/contrib/earthdistance/VERSION b/src/extensions/contrib/earthdistance/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/earthdistance/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/earthdistance/moon.yml b/src/extensions/contrib/earthdistance/moon.yml new file mode 100644 index 00000000..d573e58b --- /dev/null +++ b/src/extensions/contrib/earthdistance/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-earthdistance" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-earthdistance" + packagePath: "src/extensions/contrib/earthdistance" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/earthdistance" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/earthdistance/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-earthdistance --require-native --require-wasix" + deps: + - "oliphaunt-extension-earthdistance:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/earthdistance/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-earthdistance/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/earthdistance/release.toml b/src/extensions/contrib/earthdistance/release.toml new file mode 100644 index 00000000..082d84f1 --- /dev/null +++ b/src/extensions/contrib/earthdistance/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-earthdistance" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "earthdistance" diff --git a/src/extensions/contrib/earthdistance/targets/artifacts.toml b/src/extensions/contrib/earthdistance/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/earthdistance/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/file_fdw/CHANGELOG.md b/src/extensions/contrib/file_fdw/CHANGELOG.md new file mode 100644 index 00000000..540b4bd6 --- /dev/null +++ b/src/extensions/contrib/file_fdw/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib file_fdw exact-extension artifact release component. diff --git a/src/extensions/contrib/file_fdw/VERSION b/src/extensions/contrib/file_fdw/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/file_fdw/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/file_fdw/moon.yml b/src/extensions/contrib/file_fdw/moon.yml new file mode 100644 index 00000000..8938faf7 --- /dev/null +++ b/src/extensions/contrib/file_fdw/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-file-fdw" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-file-fdw" + packagePath: "src/extensions/contrib/file_fdw" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/file_fdw" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/file_fdw/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-file-fdw --require-native --require-wasix" + deps: + - "oliphaunt-extension-file-fdw:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/file_fdw/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-file-fdw/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/file_fdw/release.toml b/src/extensions/contrib/file_fdw/release.toml new file mode 100644 index 00000000..e8bc1df0 --- /dev/null +++ b/src/extensions/contrib/file_fdw/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-file-fdw" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "file_fdw" diff --git a/src/extensions/contrib/file_fdw/targets/artifacts.toml b/src/extensions/contrib/file_fdw/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/file_fdw/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/fuzzystrmatch/CHANGELOG.md b/src/extensions/contrib/fuzzystrmatch/CHANGELOG.md new file mode 100644 index 00000000..215cee83 --- /dev/null +++ b/src/extensions/contrib/fuzzystrmatch/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib fuzzystrmatch exact-extension artifact release component. diff --git a/src/extensions/contrib/fuzzystrmatch/VERSION b/src/extensions/contrib/fuzzystrmatch/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/fuzzystrmatch/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/fuzzystrmatch/moon.yml b/src/extensions/contrib/fuzzystrmatch/moon.yml new file mode 100644 index 00000000..1a9c5716 --- /dev/null +++ b/src/extensions/contrib/fuzzystrmatch/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-fuzzystrmatch" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-fuzzystrmatch" + packagePath: "src/extensions/contrib/fuzzystrmatch" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/fuzzystrmatch" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/fuzzystrmatch/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-fuzzystrmatch --require-native --require-wasix" + deps: + - "oliphaunt-extension-fuzzystrmatch:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/fuzzystrmatch/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-fuzzystrmatch/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/fuzzystrmatch/release.toml b/src/extensions/contrib/fuzzystrmatch/release.toml new file mode 100644 index 00000000..80794b95 --- /dev/null +++ b/src/extensions/contrib/fuzzystrmatch/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-fuzzystrmatch" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "fuzzystrmatch" diff --git a/src/extensions/contrib/fuzzystrmatch/targets/artifacts.toml b/src/extensions/contrib/fuzzystrmatch/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/fuzzystrmatch/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/hstore/CHANGELOG.md b/src/extensions/contrib/hstore/CHANGELOG.md new file mode 100644 index 00000000..1acba0bf --- /dev/null +++ b/src/extensions/contrib/hstore/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib hstore exact-extension artifact release component. diff --git a/src/extensions/contrib/hstore/VERSION b/src/extensions/contrib/hstore/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/hstore/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/hstore/moon.yml b/src/extensions/contrib/hstore/moon.yml new file mode 100644 index 00000000..6833a8a5 --- /dev/null +++ b/src/extensions/contrib/hstore/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-hstore" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-hstore" + packagePath: "src/extensions/contrib/hstore" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/hstore" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/hstore/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-hstore --require-native --require-wasix" + deps: + - "oliphaunt-extension-hstore:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/hstore/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-hstore/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/hstore/release.toml b/src/extensions/contrib/hstore/release.toml new file mode 100644 index 00000000..330793a3 --- /dev/null +++ b/src/extensions/contrib/hstore/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-hstore" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "hstore" diff --git a/src/extensions/contrib/hstore/targets/artifacts.toml b/src/extensions/contrib/hstore/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/hstore/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/intarray/CHANGELOG.md b/src/extensions/contrib/intarray/CHANGELOG.md new file mode 100644 index 00000000..2d8ec064 --- /dev/null +++ b/src/extensions/contrib/intarray/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib intarray exact-extension artifact release component. diff --git a/src/extensions/contrib/intarray/VERSION b/src/extensions/contrib/intarray/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/intarray/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/intarray/moon.yml b/src/extensions/contrib/intarray/moon.yml new file mode 100644 index 00000000..5bbf59c7 --- /dev/null +++ b/src/extensions/contrib/intarray/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-intarray" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-intarray" + packagePath: "src/extensions/contrib/intarray" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/intarray" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/intarray/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-intarray --require-native --require-wasix" + deps: + - "oliphaunt-extension-intarray:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/intarray/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-intarray/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/intarray/release.toml b/src/extensions/contrib/intarray/release.toml new file mode 100644 index 00000000..79ae05d6 --- /dev/null +++ b/src/extensions/contrib/intarray/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-intarray" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "intarray" diff --git a/src/extensions/contrib/intarray/targets/artifacts.toml b/src/extensions/contrib/intarray/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/intarray/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/isn/CHANGELOG.md b/src/extensions/contrib/isn/CHANGELOG.md new file mode 100644 index 00000000..6cb543c9 --- /dev/null +++ b/src/extensions/contrib/isn/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib isn exact-extension artifact release component. diff --git a/src/extensions/contrib/isn/VERSION b/src/extensions/contrib/isn/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/isn/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/isn/moon.yml b/src/extensions/contrib/isn/moon.yml new file mode 100644 index 00000000..b476e094 --- /dev/null +++ b/src/extensions/contrib/isn/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-isn" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-isn" + packagePath: "src/extensions/contrib/isn" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/isn" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/isn/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-isn --require-native --require-wasix" + deps: + - "oliphaunt-extension-isn:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/isn/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-isn/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/isn/release.toml b/src/extensions/contrib/isn/release.toml new file mode 100644 index 00000000..83d4a88d --- /dev/null +++ b/src/extensions/contrib/isn/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-isn" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "isn" diff --git a/src/extensions/contrib/isn/targets/artifacts.toml b/src/extensions/contrib/isn/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/isn/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/lo/CHANGELOG.md b/src/extensions/contrib/lo/CHANGELOG.md new file mode 100644 index 00000000..5e9e8fe1 --- /dev/null +++ b/src/extensions/contrib/lo/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib lo exact-extension artifact release component. diff --git a/src/extensions/contrib/lo/VERSION b/src/extensions/contrib/lo/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/lo/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/lo/moon.yml b/src/extensions/contrib/lo/moon.yml new file mode 100644 index 00000000..2d9e6fef --- /dev/null +++ b/src/extensions/contrib/lo/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-lo" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-lo" + packagePath: "src/extensions/contrib/lo" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/lo" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/lo/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-lo --require-native --require-wasix" + deps: + - "oliphaunt-extension-lo:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/lo/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-lo/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/lo/release.toml b/src/extensions/contrib/lo/release.toml new file mode 100644 index 00000000..efe6e089 --- /dev/null +++ b/src/extensions/contrib/lo/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-lo" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "lo" diff --git a/src/extensions/contrib/lo/targets/artifacts.toml b/src/extensions/contrib/lo/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/lo/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/ltree/CHANGELOG.md b/src/extensions/contrib/ltree/CHANGELOG.md new file mode 100644 index 00000000..c271c95d --- /dev/null +++ b/src/extensions/contrib/ltree/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib ltree exact-extension artifact release component. diff --git a/src/extensions/contrib/ltree/VERSION b/src/extensions/contrib/ltree/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/ltree/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/ltree/moon.yml b/src/extensions/contrib/ltree/moon.yml new file mode 100644 index 00000000..2f37c7c0 --- /dev/null +++ b/src/extensions/contrib/ltree/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-ltree" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-ltree" + packagePath: "src/extensions/contrib/ltree" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/ltree" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/ltree/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-ltree --require-native --require-wasix" + deps: + - "oliphaunt-extension-ltree:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/ltree/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-ltree/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/ltree/release.toml b/src/extensions/contrib/ltree/release.toml new file mode 100644 index 00000000..210e3060 --- /dev/null +++ b/src/extensions/contrib/ltree/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-ltree" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "ltree" diff --git a/src/extensions/contrib/ltree/targets/artifacts.toml b/src/extensions/contrib/ltree/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/ltree/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/moon.yml b/src/extensions/contrib/moon.yml new file mode 100644 index 00000000..0d6943a3 --- /dev/null +++ b/src/extensions/contrib/moon.yml @@ -0,0 +1,31 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extension-contrib-postgres18" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "pg18"] +dependsOn: + - "postgres18" + - "extension-runtime-contract" + +project: + title: "PostgreSQL 18 Contrib Extensions" + description: "PostgreSQL contrib extension definitions tied to the active PostgreSQL 18.4 source baseline." + owner: "oliphaunt" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib" + deps: + - "postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/postgres/versions/18/**/*" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pageinspect/CHANGELOG.md b/src/extensions/contrib/pageinspect/CHANGELOG.md new file mode 100644 index 00000000..55cb9330 --- /dev/null +++ b/src/extensions/contrib/pageinspect/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pageinspect exact-extension artifact release component. diff --git a/src/extensions/contrib/pageinspect/VERSION b/src/extensions/contrib/pageinspect/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pageinspect/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pageinspect/moon.yml b/src/extensions/contrib/pageinspect/moon.yml new file mode 100644 index 00000000..f85d602e --- /dev/null +++ b/src/extensions/contrib/pageinspect/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pageinspect" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pageinspect" + packagePath: "src/extensions/contrib/pageinspect" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pageinspect" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pageinspect/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pageinspect --require-native --require-wasix" + deps: + - "oliphaunt-extension-pageinspect:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pageinspect/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pageinspect/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pageinspect/release.toml b/src/extensions/contrib/pageinspect/release.toml new file mode 100644 index 00000000..d3e905a6 --- /dev/null +++ b/src/extensions/contrib/pageinspect/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pageinspect" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pageinspect" diff --git a/src/extensions/contrib/pageinspect/targets/artifacts.toml b/src/extensions/contrib/pageinspect/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pageinspect/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/pg_buffercache/CHANGELOG.md b/src/extensions/contrib/pg_buffercache/CHANGELOG.md new file mode 100644 index 00000000..9eb7f352 --- /dev/null +++ b/src/extensions/contrib/pg_buffercache/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pg_buffercache exact-extension artifact release component. diff --git a/src/extensions/contrib/pg_buffercache/VERSION b/src/extensions/contrib/pg_buffercache/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pg_buffercache/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pg_buffercache/moon.yml b/src/extensions/contrib/pg_buffercache/moon.yml new file mode 100644 index 00000000..58458294 --- /dev/null +++ b/src/extensions/contrib/pg_buffercache/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-buffercache" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pg-buffercache" + packagePath: "src/extensions/contrib/pg_buffercache" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_buffercache" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pg_buffercache/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-buffercache --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-buffercache:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pg_buffercache/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-buffercache/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pg_buffercache/release.toml b/src/extensions/contrib/pg_buffercache/release.toml new file mode 100644 index 00000000..c3e1e826 --- /dev/null +++ b/src/extensions/contrib/pg_buffercache/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-buffercache" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_buffercache" diff --git a/src/extensions/contrib/pg_buffercache/targets/artifacts.toml b/src/extensions/contrib/pg_buffercache/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pg_buffercache/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/pg_freespacemap/CHANGELOG.md b/src/extensions/contrib/pg_freespacemap/CHANGELOG.md new file mode 100644 index 00000000..fed5ea84 --- /dev/null +++ b/src/extensions/contrib/pg_freespacemap/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pg_freespacemap exact-extension artifact release component. diff --git a/src/extensions/contrib/pg_freespacemap/VERSION b/src/extensions/contrib/pg_freespacemap/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pg_freespacemap/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pg_freespacemap/moon.yml b/src/extensions/contrib/pg_freespacemap/moon.yml new file mode 100644 index 00000000..13f88196 --- /dev/null +++ b/src/extensions/contrib/pg_freespacemap/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-freespacemap" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pg-freespacemap" + packagePath: "src/extensions/contrib/pg_freespacemap" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_freespacemap" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pg_freespacemap/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-freespacemap --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-freespacemap:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pg_freespacemap/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-freespacemap/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pg_freespacemap/release.toml b/src/extensions/contrib/pg_freespacemap/release.toml new file mode 100644 index 00000000..d4544322 --- /dev/null +++ b/src/extensions/contrib/pg_freespacemap/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-freespacemap" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_freespacemap" diff --git a/src/extensions/contrib/pg_freespacemap/targets/artifacts.toml b/src/extensions/contrib/pg_freespacemap/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pg_freespacemap/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/pg_surgery/CHANGELOG.md b/src/extensions/contrib/pg_surgery/CHANGELOG.md new file mode 100644 index 00000000..5bbbbac2 --- /dev/null +++ b/src/extensions/contrib/pg_surgery/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pg_surgery exact-extension artifact release component. diff --git a/src/extensions/contrib/pg_surgery/VERSION b/src/extensions/contrib/pg_surgery/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pg_surgery/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pg_surgery/moon.yml b/src/extensions/contrib/pg_surgery/moon.yml new file mode 100644 index 00000000..6490cc31 --- /dev/null +++ b/src/extensions/contrib/pg_surgery/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-surgery" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pg-surgery" + packagePath: "src/extensions/contrib/pg_surgery" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_surgery" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pg_surgery/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-surgery --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-surgery:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pg_surgery/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-surgery/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pg_surgery/release.toml b/src/extensions/contrib/pg_surgery/release.toml new file mode 100644 index 00000000..b947ac40 --- /dev/null +++ b/src/extensions/contrib/pg_surgery/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-surgery" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_surgery" diff --git a/src/extensions/contrib/pg_surgery/targets/artifacts.toml b/src/extensions/contrib/pg_surgery/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pg_surgery/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/pg_trgm/CHANGELOG.md b/src/extensions/contrib/pg_trgm/CHANGELOG.md new file mode 100644 index 00000000..dd48b0fe --- /dev/null +++ b/src/extensions/contrib/pg_trgm/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pg_trgm exact-extension artifact release component. diff --git a/src/extensions/contrib/pg_trgm/VERSION b/src/extensions/contrib/pg_trgm/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pg_trgm/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pg_trgm/moon.yml b/src/extensions/contrib/pg_trgm/moon.yml new file mode 100644 index 00000000..ea61e66f --- /dev/null +++ b/src/extensions/contrib/pg_trgm/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-trgm" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pg-trgm" + packagePath: "src/extensions/contrib/pg_trgm" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_trgm" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pg_trgm/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-trgm --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-trgm:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pg_trgm/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-trgm/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pg_trgm/release.toml b/src/extensions/contrib/pg_trgm/release.toml new file mode 100644 index 00000000..5bc7bac6 --- /dev/null +++ b/src/extensions/contrib/pg_trgm/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-trgm" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_trgm" diff --git a/src/extensions/contrib/pg_trgm/targets/artifacts.toml b/src/extensions/contrib/pg_trgm/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pg_trgm/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/pg_visibility/CHANGELOG.md b/src/extensions/contrib/pg_visibility/CHANGELOG.md new file mode 100644 index 00000000..17ffcfb2 --- /dev/null +++ b/src/extensions/contrib/pg_visibility/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pg_visibility exact-extension artifact release component. diff --git a/src/extensions/contrib/pg_visibility/VERSION b/src/extensions/contrib/pg_visibility/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pg_visibility/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pg_visibility/moon.yml b/src/extensions/contrib/pg_visibility/moon.yml new file mode 100644 index 00000000..a4dba50b --- /dev/null +++ b/src/extensions/contrib/pg_visibility/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-visibility" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pg-visibility" + packagePath: "src/extensions/contrib/pg_visibility" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_visibility" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pg_visibility/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-visibility --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-visibility:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pg_visibility/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-visibility/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pg_visibility/release.toml b/src/extensions/contrib/pg_visibility/release.toml new file mode 100644 index 00000000..71a887c9 --- /dev/null +++ b/src/extensions/contrib/pg_visibility/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-visibility" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_visibility" diff --git a/src/extensions/contrib/pg_visibility/targets/artifacts.toml b/src/extensions/contrib/pg_visibility/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pg_visibility/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/pg_walinspect/CHANGELOG.md b/src/extensions/contrib/pg_walinspect/CHANGELOG.md new file mode 100644 index 00000000..2fc6c818 --- /dev/null +++ b/src/extensions/contrib/pg_walinspect/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pg_walinspect exact-extension artifact release component. diff --git a/src/extensions/contrib/pg_walinspect/VERSION b/src/extensions/contrib/pg_walinspect/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pg_walinspect/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pg_walinspect/moon.yml b/src/extensions/contrib/pg_walinspect/moon.yml new file mode 100644 index 00000000..5f57aa15 --- /dev/null +++ b/src/extensions/contrib/pg_walinspect/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-walinspect" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pg-walinspect" + packagePath: "src/extensions/contrib/pg_walinspect" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pg_walinspect" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pg_walinspect/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-walinspect --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-walinspect:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pg_walinspect/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-walinspect/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pg_walinspect/release.toml b/src/extensions/contrib/pg_walinspect/release.toml new file mode 100644 index 00000000..e1a2d199 --- /dev/null +++ b/src/extensions/contrib/pg_walinspect/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-walinspect" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_walinspect" diff --git a/src/extensions/contrib/pg_walinspect/targets/artifacts.toml b/src/extensions/contrib/pg_walinspect/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pg_walinspect/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/pgcrypto/CHANGELOG.md b/src/extensions/contrib/pgcrypto/CHANGELOG.md new file mode 100644 index 00000000..8409857a --- /dev/null +++ b/src/extensions/contrib/pgcrypto/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib pgcrypto exact-extension artifact release component. diff --git a/src/extensions/contrib/pgcrypto/VERSION b/src/extensions/contrib/pgcrypto/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/pgcrypto/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/pgcrypto/moon.yml b/src/extensions/contrib/pgcrypto/moon.yml new file mode 100644 index 00000000..674ec516 --- /dev/null +++ b/src/extensions/contrib/pgcrypto/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pgcrypto" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-pgcrypto" + packagePath: "src/extensions/contrib/pgcrypto" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/pgcrypto" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/pgcrypto/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pgcrypto --require-native --require-wasix" + deps: + - "oliphaunt-extension-pgcrypto:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/pgcrypto/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pgcrypto/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/pgcrypto/release.toml b/src/extensions/contrib/pgcrypto/release.toml new file mode 100644 index 00000000..bc118def --- /dev/null +++ b/src/extensions/contrib/pgcrypto/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pgcrypto" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pgcrypto" diff --git a/src/extensions/contrib/pgcrypto/targets/artifacts.toml b/src/extensions/contrib/pgcrypto/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/pgcrypto/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/postgres18.toml b/src/extensions/contrib/postgres18.toml new file mode 100644 index 00000000..350db327 --- /dev/null +++ b/src/extensions/contrib/postgres18.toml @@ -0,0 +1,205 @@ +format-version = 1 +postgres-version = "18.4" +source-kind = "postgres-contrib" +source-root = "src/postgres/versions/18/contrib" + +[[extensions]] +id = "amcheck" +sql-name = "amcheck" +contrib-dir = "amcheck" +module-file = "amcheck.so" + +[[extensions]] +id = "auto_explain" +sql-name = "auto_explain" +contrib-dir = "auto_explain" +module-file = "auto_explain.so" + +[[extensions]] +id = "bloom" +sql-name = "bloom" +contrib-dir = "bloom" +module-file = "bloom.so" + +[[extensions]] +id = "btree_gin" +sql-name = "btree_gin" +contrib-dir = "btree_gin" +module-file = "btree_gin.so" + +[[extensions]] +id = "btree_gist" +sql-name = "btree_gist" +contrib-dir = "btree_gist" +module-file = "btree_gist.so" + +[[extensions]] +id = "citext" +sql-name = "citext" +contrib-dir = "citext" +module-file = "citext.so" + +[[extensions]] +id = "cube" +sql-name = "cube" +contrib-dir = "cube" +module-file = "cube.so" + +[[extensions]] +id = "dict_int" +sql-name = "dict_int" +contrib-dir = "dict_int" +module-file = "dict_int.so" + +[[extensions]] +id = "dict_xsyn" +sql-name = "dict_xsyn" +contrib-dir = "dict_xsyn" +module-file = "dict_xsyn.so" +data-files = ["share/postgresql/tsearch_data/xsyn_sample.rules"] + +[[extensions]] +id = "earthdistance" +sql-name = "earthdistance" +contrib-dir = "earthdistance" +module-file = "earthdistance.so" + +[[extensions]] +id = "file_fdw" +sql-name = "file_fdw" +contrib-dir = "file_fdw" +module-file = "file_fdw.so" + +[[extensions]] +id = "fuzzystrmatch" +sql-name = "fuzzystrmatch" +contrib-dir = "fuzzystrmatch" +module-file = "fuzzystrmatch.so" + +[[extensions]] +id = "hstore" +sql-name = "hstore" +contrib-dir = "hstore" +module-file = "hstore.so" + +[[extensions]] +id = "intarray" +sql-name = "intarray" +contrib-dir = "intarray" +module-file = "_int.so" + +[[extensions]] +id = "isn" +sql-name = "isn" +contrib-dir = "isn" +module-file = "isn.so" + +[[extensions]] +id = "lo" +sql-name = "lo" +contrib-dir = "lo" +module-file = "lo.so" + +[[extensions]] +id = "ltree" +sql-name = "ltree" +contrib-dir = "ltree" +module-file = "ltree.so" + +[[extensions]] +id = "pageinspect" +sql-name = "pageinspect" +contrib-dir = "pageinspect" +module-file = "pageinspect.so" + +[[extensions]] +id = "pg_buffercache" +sql-name = "pg_buffercache" +contrib-dir = "pg_buffercache" +module-file = "pg_buffercache.so" + +[[extensions]] +id = "pg_freespacemap" +sql-name = "pg_freespacemap" +contrib-dir = "pg_freespacemap" +module-file = "pg_freespacemap.so" + +[[extensions]] +id = "pg_surgery" +sql-name = "pg_surgery" +contrib-dir = "pg_surgery" +module-file = "pg_surgery.so" + +[[extensions]] +id = "pg_trgm" +sql-name = "pg_trgm" +contrib-dir = "pg_trgm" +module-file = "pg_trgm.so" + +[[extensions]] +id = "pg_visibility" +sql-name = "pg_visibility" +contrib-dir = "pg_visibility" +module-file = "pg_visibility.so" + +[[extensions]] +id = "pg_walinspect" +sql-name = "pg_walinspect" +contrib-dir = "pg_walinspect" +module-file = "pg_walinspect.so" + +[[extensions]] +id = "pgcrypto" +sql-name = "pgcrypto" +contrib-dir = "pgcrypto" +module-file = "pgcrypto.so" +mobile-static-dependencies = ["openssl"] +mobile-static-include-dependencies = ["openssl"] +mobile-static-hash-source-dependencies = ["openssl"] + +[[extensions]] +id = "seg" +sql-name = "seg" +contrib-dir = "seg" +module-file = "seg.so" + +[[extensions]] +id = "tablefunc" +sql-name = "tablefunc" +contrib-dir = "tablefunc" +module-file = "tablefunc.so" + +[[extensions]] +id = "tcn" +sql-name = "tcn" +contrib-dir = "tcn" +module-file = "tcn.so" + +[[extensions]] +id = "tsm_system_rows" +sql-name = "tsm_system_rows" +contrib-dir = "tsm_system_rows" +module-file = "tsm_system_rows.so" + +[[extensions]] +id = "tsm_system_time" +sql-name = "tsm_system_time" +contrib-dir = "tsm_system_time" +module-file = "tsm_system_time.so" + +[[extensions]] +id = "unaccent" +sql-name = "unaccent" +contrib-dir = "unaccent" +module-file = "unaccent.so" +data-files = ["share/postgresql/tsearch_data/unaccent.rules"] + +[[extensions]] +id = "uuid_ossp" +sql-name = "uuid-ossp" +contrib-dir = "uuid-ossp" +module-file = "uuid-ossp.so" +mobile-static-dependencies = ["uuid"] +mobile-static-include-dirs = ["src/runtimes/liboliphaunt/native/portable-uuid/include"] +mobile-static-cflags = ["-DHAVE_UUID_E2FS=1", "-DHAVE_UUID_UUID_H=1"] +mobile-static-hash-dirs = ["src/runtimes/liboliphaunt/native/portable-uuid"] diff --git a/src/extensions/contrib/seg/CHANGELOG.md b/src/extensions/contrib/seg/CHANGELOG.md new file mode 100644 index 00000000..a91d66c6 --- /dev/null +++ b/src/extensions/contrib/seg/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib seg exact-extension artifact release component. diff --git a/src/extensions/contrib/seg/VERSION b/src/extensions/contrib/seg/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/seg/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/seg/moon.yml b/src/extensions/contrib/seg/moon.yml new file mode 100644 index 00000000..24800e60 --- /dev/null +++ b/src/extensions/contrib/seg/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-seg" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-seg" + packagePath: "src/extensions/contrib/seg" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/seg" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/seg/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-seg --require-native --require-wasix" + deps: + - "oliphaunt-extension-seg:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/seg/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-seg/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/seg/release.toml b/src/extensions/contrib/seg/release.toml new file mode 100644 index 00000000..76fa3e31 --- /dev/null +++ b/src/extensions/contrib/seg/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-seg" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "seg" diff --git a/src/extensions/contrib/seg/targets/artifacts.toml b/src/extensions/contrib/seg/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/seg/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/tablefunc/CHANGELOG.md b/src/extensions/contrib/tablefunc/CHANGELOG.md new file mode 100644 index 00000000..de97b0bb --- /dev/null +++ b/src/extensions/contrib/tablefunc/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib tablefunc exact-extension artifact release component. diff --git a/src/extensions/contrib/tablefunc/VERSION b/src/extensions/contrib/tablefunc/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/tablefunc/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/tablefunc/moon.yml b/src/extensions/contrib/tablefunc/moon.yml new file mode 100644 index 00000000..eb27863a --- /dev/null +++ b/src/extensions/contrib/tablefunc/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-tablefunc" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-tablefunc" + packagePath: "src/extensions/contrib/tablefunc" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tablefunc" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/tablefunc/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tablefunc --require-native --require-wasix" + deps: + - "oliphaunt-extension-tablefunc:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/tablefunc/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-tablefunc/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/tablefunc/release.toml b/src/extensions/contrib/tablefunc/release.toml new file mode 100644 index 00000000..86ebff8a --- /dev/null +++ b/src/extensions/contrib/tablefunc/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-tablefunc" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "tablefunc" diff --git a/src/extensions/contrib/tablefunc/targets/artifacts.toml b/src/extensions/contrib/tablefunc/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/tablefunc/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/tcn/CHANGELOG.md b/src/extensions/contrib/tcn/CHANGELOG.md new file mode 100644 index 00000000..b34b36e9 --- /dev/null +++ b/src/extensions/contrib/tcn/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib tcn exact-extension artifact release component. diff --git a/src/extensions/contrib/tcn/VERSION b/src/extensions/contrib/tcn/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/tcn/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/tcn/moon.yml b/src/extensions/contrib/tcn/moon.yml new file mode 100644 index 00000000..e29ef95e --- /dev/null +++ b/src/extensions/contrib/tcn/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-tcn" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-tcn" + packagePath: "src/extensions/contrib/tcn" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tcn" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/tcn/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tcn --require-native --require-wasix" + deps: + - "oliphaunt-extension-tcn:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/tcn/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-tcn/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/tcn/release.toml b/src/extensions/contrib/tcn/release.toml new file mode 100644 index 00000000..266fd7c2 --- /dev/null +++ b/src/extensions/contrib/tcn/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-tcn" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "tcn" diff --git a/src/extensions/contrib/tcn/targets/artifacts.toml b/src/extensions/contrib/tcn/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/tcn/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/tsm_system_rows/CHANGELOG.md b/src/extensions/contrib/tsm_system_rows/CHANGELOG.md new file mode 100644 index 00000000..30c03fe8 --- /dev/null +++ b/src/extensions/contrib/tsm_system_rows/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib tsm_system_rows exact-extension artifact release component. diff --git a/src/extensions/contrib/tsm_system_rows/VERSION b/src/extensions/contrib/tsm_system_rows/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/tsm_system_rows/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/tsm_system_rows/moon.yml b/src/extensions/contrib/tsm_system_rows/moon.yml new file mode 100644 index 00000000..46066e4a --- /dev/null +++ b/src/extensions/contrib/tsm_system_rows/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-tsm-system-rows" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-tsm-system-rows" + packagePath: "src/extensions/contrib/tsm_system_rows" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tsm_system_rows" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/tsm_system_rows/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tsm-system-rows --require-native --require-wasix" + deps: + - "oliphaunt-extension-tsm-system-rows:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/tsm_system_rows/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-tsm-system-rows/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/tsm_system_rows/release.toml b/src/extensions/contrib/tsm_system_rows/release.toml new file mode 100644 index 00000000..887aae5f --- /dev/null +++ b/src/extensions/contrib/tsm_system_rows/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-tsm-system-rows" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "tsm_system_rows" diff --git a/src/extensions/contrib/tsm_system_rows/targets/artifacts.toml b/src/extensions/contrib/tsm_system_rows/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/tsm_system_rows/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/tsm_system_time/CHANGELOG.md b/src/extensions/contrib/tsm_system_time/CHANGELOG.md new file mode 100644 index 00000000..8d16a97f --- /dev/null +++ b/src/extensions/contrib/tsm_system_time/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib tsm_system_time exact-extension artifact release component. diff --git a/src/extensions/contrib/tsm_system_time/VERSION b/src/extensions/contrib/tsm_system_time/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/tsm_system_time/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/tsm_system_time/moon.yml b/src/extensions/contrib/tsm_system_time/moon.yml new file mode 100644 index 00000000..9ca2310d --- /dev/null +++ b/src/extensions/contrib/tsm_system_time/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-tsm-system-time" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-tsm-system-time" + packagePath: "src/extensions/contrib/tsm_system_time" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/tsm_system_time" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/tsm_system_time/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-tsm-system-time --require-native --require-wasix" + deps: + - "oliphaunt-extension-tsm-system-time:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/tsm_system_time/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-tsm-system-time/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/tsm_system_time/release.toml b/src/extensions/contrib/tsm_system_time/release.toml new file mode 100644 index 00000000..80f57570 --- /dev/null +++ b/src/extensions/contrib/tsm_system_time/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-tsm-system-time" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "tsm_system_time" diff --git a/src/extensions/contrib/tsm_system_time/targets/artifacts.toml b/src/extensions/contrib/tsm_system_time/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/tsm_system_time/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/unaccent/CHANGELOG.md b/src/extensions/contrib/unaccent/CHANGELOG.md new file mode 100644 index 00000000..d0f2e0db --- /dev/null +++ b/src/extensions/contrib/unaccent/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib unaccent exact-extension artifact release component. diff --git a/src/extensions/contrib/unaccent/VERSION b/src/extensions/contrib/unaccent/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/unaccent/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/unaccent/moon.yml b/src/extensions/contrib/unaccent/moon.yml new file mode 100644 index 00000000..5efa12df --- /dev/null +++ b/src/extensions/contrib/unaccent/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-unaccent" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-unaccent" + packagePath: "src/extensions/contrib/unaccent" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/unaccent" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/unaccent/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-unaccent --require-native --require-wasix" + deps: + - "oliphaunt-extension-unaccent:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/unaccent/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-unaccent/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/unaccent/release.toml b/src/extensions/contrib/unaccent/release.toml new file mode 100644 index 00000000..fc83effa --- /dev/null +++ b/src/extensions/contrib/unaccent/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-unaccent" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "unaccent" diff --git a/src/extensions/contrib/unaccent/targets/artifacts.toml b/src/extensions/contrib/unaccent/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/unaccent/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/contrib/uuid_ossp/CHANGELOG.md b/src/extensions/contrib/uuid_ossp/CHANGELOG.md new file mode 100644 index 00000000..518cdbae --- /dev/null +++ b/src/extensions/contrib/uuid_ossp/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostgreSQL contrib uuid-ossp exact-extension artifact release component. diff --git a/src/extensions/contrib/uuid_ossp/VERSION b/src/extensions/contrib/uuid_ossp/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/contrib/uuid_ossp/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/contrib/uuid_ossp/moon.yml b/src/extensions/contrib/uuid_ossp/moon.yml new file mode 100644 index 00000000..4ccc72d1 --- /dev/null +++ b/src/extensions/contrib/uuid_ossp/moon.yml @@ -0,0 +1,55 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-uuid-ossp" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contrib", "postgres18", "release-product"] +dependsOn: + - id: "extension-contrib-postgres18" + scope: "production" + - id: "extension-runtime-contract" + scope: "production" + +project: + release: + component: "oliphaunt-extension-uuid-ossp" + packagePath: "src/extensions/contrib/uuid_ossp" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/contrib/uuid_ossp" + deps: + - "extension-contrib-postgres18:check" + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/contrib/uuid_ossp/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-uuid-ossp --require-native --require-wasix" + deps: + - "oliphaunt-extension-uuid-ossp:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/contrib/uuid_ossp/**/*" + - "/src/extensions/contrib/postgres18.toml" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-uuid-ossp/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/contrib/uuid_ossp/release.toml b/src/extensions/contrib/uuid_ossp/release.toml new file mode 100644 index 00000000..4b29acc1 --- /dev/null +++ b/src/extensions/contrib/uuid_ossp/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-uuid-ossp" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "uuid-ossp" diff --git a/src/extensions/contrib/uuid_ossp/targets/artifacts.toml b/src/extensions/contrib/uuid_ossp/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/contrib/uuid_ossp/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/evidence/matrix.toml b/src/extensions/evidence/matrix.toml new file mode 100644 index 00000000..8cd3cc7e --- /dev/null +++ b/src/extensions/evidence/matrix.toml @@ -0,0 +1,437 @@ +format-version = 1 +source-digest-inputs = [ + "src/postgres/versions/18/source.toml", + "src/extensions/catalog/extensions.promoted.toml", + "src/extensions/catalog/extensions.smoke.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/generated/extensions.catalog.json", + "src/extensions/generated/extensions.build-plan.json", + "src/extensions/generated/contrib-build.tsv", + "src/extensions/generated/pgxs-build.tsv", + "src/extensions/external/age/source.toml", + "src/extensions/external/pg_hashids/source.toml", + "src/extensions/external/pg_ivm/source.toml", + "src/extensions/external/pg_textsearch/source.toml", + "src/extensions/external/pg_uuidv7/source.toml", + "src/extensions/external/pgtap/source.toml", + "src/extensions/external/postgis/dependencies/geos/source.toml", + "src/extensions/external/postgis/dependencies/json-c/source.toml", + "src/extensions/external/postgis/dependencies/libiconv/source.toml", + "src/extensions/external/postgis/dependencies/libxml2/source.toml", + "src/extensions/external/postgis/dependencies/proj/source.toml", + "src/extensions/external/postgis/dependencies/sqlite/source.toml", + "src/extensions/external/postgis/source.toml", + "src/extensions/external/vector/source.toml", + "src/sources/third-party/shared/icu.toml", + "src/sources/third-party/shared/openssl.toml", + "src/extensions/external/README.md", + "src/extensions/external/age/moon.yml", + "src/extensions/external/pg_hashids/CHANGELOG.md", + "src/extensions/external/pg_hashids/VERSION", + "src/extensions/external/pg_hashids/moon.yml", + "src/extensions/external/pg_hashids/release.toml", + "src/extensions/external/pg_hashids/targets/artifacts.toml", + "src/extensions/external/pg_ivm/CHANGELOG.md", + "src/extensions/external/pg_ivm/VERSION", + "src/extensions/external/pg_ivm/moon.yml", + "src/extensions/external/pg_ivm/release.toml", + "src/extensions/external/pg_ivm/targets/artifacts.toml", + "src/extensions/external/pg_ivm/targets/native-static-registry.toml", + "src/extensions/external/pg_textsearch/CHANGELOG.md", + "src/extensions/external/pg_textsearch/VERSION", + "src/extensions/external/pg_textsearch/moon.yml", + "src/extensions/external/pg_textsearch/recipe.toml", + "src/extensions/external/pg_textsearch/release.toml", + "src/extensions/external/pg_textsearch/targets/artifacts.toml", + "src/extensions/external/pg_textsearch/targets/native-static-registry.toml", + "src/extensions/external/pg_textsearch/tests/smoke.sql", + "src/extensions/external/pg_textsearch/tests/upstream.toml", + "src/extensions/external/pg_uuidv7/CHANGELOG.md", + "src/extensions/external/pg_uuidv7/VERSION", + "src/extensions/external/pg_uuidv7/moon.yml", + "src/extensions/external/pg_uuidv7/release.toml", + "src/extensions/external/pg_uuidv7/targets/artifacts.toml", + "src/extensions/external/pgtap/CHANGELOG.md", + "src/extensions/external/pgtap/VERSION", + "src/extensions/external/pgtap/moon.yml", + "src/extensions/external/pgtap/recipe.toml", + "src/extensions/external/pgtap/release.toml", + "src/extensions/external/pgtap/targets/artifacts.toml", + "src/extensions/external/pgtap/targets/native-static-registry.toml", + "src/extensions/external/pgtap/targets/native.toml", + "src/extensions/external/pgtap/targets/wasix.toml", + "src/extensions/external/pgtap/tests/smoke.sql", + "src/extensions/external/pgtap/tests/upstream.toml", + "src/extensions/external/postgis/CHANGELOG.md", + "src/extensions/external/postgis/VERSION", + "src/extensions/external/postgis/blockers.toml", + "src/extensions/external/postgis/deps.toml", + "src/extensions/external/postgis/moon.yml", + "src/extensions/external/postgis/patches/README.md", + "src/extensions/external/postgis/recipe.toml", + "src/extensions/external/postgis/release.toml", + "src/extensions/external/postgis/targets/artifacts.toml", + "src/extensions/external/postgis/targets/native-static-registry.toml", + "src/extensions/external/postgis/targets/native.toml", + "src/extensions/external/postgis/targets/wasix.toml", + "src/extensions/external/postgis/tests/regression.sql", + "src/extensions/external/postgis/tests/smoke.sql", + "src/extensions/external/postgis/tests/upstream.toml", + "src/extensions/external/postgis/tools/build_wasix.sh", + "src/extensions/external/vector/CHANGELOG.md", + "src/extensions/external/vector/VERSION", + "src/extensions/external/vector/moon.yml", + "src/extensions/external/vector/release.toml", + "src/extensions/external/vector/targets/artifacts.toml", +] + +[[claims]] +extension = "amcheck" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "auto_explain" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "bloom" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "btree_gin" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "btree_gist" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "citext" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "cube" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "dict_int" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "dict_xsyn" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "earthdistance" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "file_fdw" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "fuzzystrmatch" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "hstore" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "intarray" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "isn" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "lo" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "ltree" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pageinspect" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_buffercache" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_freespacemap" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_hashids" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_ivm" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_surgery" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_textsearch" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_trgm" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_uuidv7" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_visibility" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pg_walinspect" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pgcrypto" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "pgtap" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "postgis" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "seg" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "tablefunc" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "tcn" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "tsm_system_rows" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "tsm_system_time" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "unaccent" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "uuid_ossp" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true + +[[claims]] +extension = "vector" +postgres-major = 18 +artifact-family = "wasix-runtime" +platform-targets = ["portable"] +runtime-modes = ["direct", "server", "restart", "dump-restore"] +evidence-required = ["transitional-catalog-smoke"] +public = true diff --git a/src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json b/src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json new file mode 100644 index 00000000..385073f0 --- /dev/null +++ b/src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json @@ -0,0 +1,604 @@ +{ + "collector": "src/extensions/tools/check-extension-model.py --write-evidence", + "evidenceTier": "transitional-catalog-smoke", + "id": "2026-06-07-transitional-catalog-smoke", + "notes": "Transitional evidence imported from extensions.smoke.toml while per-recipe evidence runs are introduced.", + "observedAt": "2026-06-07T00:00:00Z", + "results": [ + { + "artifactFamily": "wasix-runtime", + "extension": "amcheck", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "amcheck" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "auto_explain", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "auto_explain" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "bloom", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "bloom" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "btree_gin", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "btree_gin" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "btree_gist", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "btree_gist" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "citext", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "citext" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "cube", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "cube" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "dict_int", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "dict_int" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "dict_xsyn", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "dict_xsyn" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "earthdistance", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "earthdistance" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "file_fdw", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "file_fdw" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "fuzzystrmatch", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "fuzzystrmatch" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "hstore", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "hstore" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "intarray", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "intarray" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "isn", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "isn" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "lo", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "lo" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "ltree", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "ltree" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pageinspect", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pageinspect" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_buffercache", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_buffercache" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_freespacemap", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_freespacemap" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_hashids", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_hashids" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_ivm", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_ivm" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_surgery", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_surgery" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_textsearch", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_textsearch" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_trgm", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_trgm" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_uuidv7", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_uuidv7" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_visibility", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_visibility" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pg_walinspect", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pg_walinspect" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pgcrypto", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pgcrypto" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "pgtap", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "pgtap" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "postgis", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "postgis" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "seg", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "seg" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "tablefunc", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "tablefunc" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "tcn", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "tcn" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "tsm_system_rows", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "tsm_system_rows" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "tsm_system_time", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "tsm_system_time" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "unaccent", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "unaccent" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "uuid_ossp", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "uuid-ossp" + }, + { + "artifactFamily": "wasix-runtime", + "extension": "vector", + "platformTarget": "portable", + "postgresMajor": 18, + "runtimeModeStatuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sqlName": "vector" + } + ], + "schema": "oliphaunt-extension-evidence-v1", + "sourceDigest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3", + "sourceDigestInputs": [ + "src/postgres/versions/18/source.toml", + "src/extensions/catalog/extensions.promoted.toml", + "src/extensions/catalog/extensions.smoke.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/generated/extensions.catalog.json", + "src/extensions/generated/extensions.build-plan.json", + "src/extensions/generated/contrib-build.tsv", + "src/extensions/generated/pgxs-build.tsv", + "src/extensions/external/age/source.toml", + "src/extensions/external/pg_hashids/source.toml", + "src/extensions/external/pg_ivm/source.toml", + "src/extensions/external/pg_textsearch/source.toml", + "src/extensions/external/pg_uuidv7/source.toml", + "src/extensions/external/pgtap/source.toml", + "src/extensions/external/postgis/dependencies/geos/source.toml", + "src/extensions/external/postgis/dependencies/json-c/source.toml", + "src/extensions/external/postgis/dependencies/libiconv/source.toml", + "src/extensions/external/postgis/dependencies/libxml2/source.toml", + "src/extensions/external/postgis/dependencies/proj/source.toml", + "src/extensions/external/postgis/dependencies/sqlite/source.toml", + "src/extensions/external/postgis/source.toml", + "src/extensions/external/vector/source.toml", + "src/sources/third-party/shared/icu.toml", + "src/sources/third-party/shared/openssl.toml", + "src/extensions/external/README.md", + "src/extensions/external/age/moon.yml", + "src/extensions/external/pg_hashids/CHANGELOG.md", + "src/extensions/external/pg_hashids/VERSION", + "src/extensions/external/pg_hashids/moon.yml", + "src/extensions/external/pg_hashids/release.toml", + "src/extensions/external/pg_hashids/targets/artifacts.toml", + "src/extensions/external/pg_ivm/CHANGELOG.md", + "src/extensions/external/pg_ivm/VERSION", + "src/extensions/external/pg_ivm/moon.yml", + "src/extensions/external/pg_ivm/release.toml", + "src/extensions/external/pg_ivm/targets/artifacts.toml", + "src/extensions/external/pg_ivm/targets/native-static-registry.toml", + "src/extensions/external/pg_textsearch/CHANGELOG.md", + "src/extensions/external/pg_textsearch/VERSION", + "src/extensions/external/pg_textsearch/moon.yml", + "src/extensions/external/pg_textsearch/recipe.toml", + "src/extensions/external/pg_textsearch/release.toml", + "src/extensions/external/pg_textsearch/targets/artifacts.toml", + "src/extensions/external/pg_textsearch/targets/native-static-registry.toml", + "src/extensions/external/pg_textsearch/tests/smoke.sql", + "src/extensions/external/pg_textsearch/tests/upstream.toml", + "src/extensions/external/pg_uuidv7/CHANGELOG.md", + "src/extensions/external/pg_uuidv7/VERSION", + "src/extensions/external/pg_uuidv7/moon.yml", + "src/extensions/external/pg_uuidv7/release.toml", + "src/extensions/external/pg_uuidv7/targets/artifacts.toml", + "src/extensions/external/pgtap/CHANGELOG.md", + "src/extensions/external/pgtap/VERSION", + "src/extensions/external/pgtap/moon.yml", + "src/extensions/external/pgtap/recipe.toml", + "src/extensions/external/pgtap/release.toml", + "src/extensions/external/pgtap/targets/artifacts.toml", + "src/extensions/external/pgtap/targets/native-static-registry.toml", + "src/extensions/external/pgtap/targets/native.toml", + "src/extensions/external/pgtap/targets/wasix.toml", + "src/extensions/external/pgtap/tests/smoke.sql", + "src/extensions/external/pgtap/tests/upstream.toml", + "src/extensions/external/postgis/CHANGELOG.md", + "src/extensions/external/postgis/VERSION", + "src/extensions/external/postgis/blockers.toml", + "src/extensions/external/postgis/deps.toml", + "src/extensions/external/postgis/moon.yml", + "src/extensions/external/postgis/patches/README.md", + "src/extensions/external/postgis/recipe.toml", + "src/extensions/external/postgis/release.toml", + "src/extensions/external/postgis/targets/artifacts.toml", + "src/extensions/external/postgis/targets/native-static-registry.toml", + "src/extensions/external/postgis/targets/native.toml", + "src/extensions/external/postgis/targets/wasix.toml", + "src/extensions/external/postgis/tests/regression.sql", + "src/extensions/external/postgis/tests/smoke.sql", + "src/extensions/external/postgis/tests/upstream.toml", + "src/extensions/external/postgis/tools/build_wasix.sh", + "src/extensions/external/vector/CHANGELOG.md", + "src/extensions/external/vector/VERSION", + "src/extensions/external/vector/moon.yml", + "src/extensions/external/vector/release.toml", + "src/extensions/external/vector/targets/artifacts.toml" + ], + "status": "passed" +} diff --git a/src/extensions/evidence/schemas/matrix.schema.json b/src/extensions/evidence/schemas/matrix.schema.json new file mode 100644 index 00000000..c0c2e943 --- /dev/null +++ b/src/extensions/evidence/schemas/matrix.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oliphaunt.dev/schemas/extensions/evidence-matrix.schema.json", + "title": "Oliphaunt Extension Evidence Matrix", + "type": "object", + "additionalProperties": false, + "required": ["format-version", "source-digest-inputs", "claims"], + "properties": { + "format-version": { "const": 1 }, + "source-digest-inputs": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "claims": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "extension", + "postgres-major", + "artifact-family", + "platform-targets", + "runtime-modes", + "evidence-required", + "public" + ], + "properties": { + "extension": { "type": "string" }, + "postgres-major": { "type": "integer" }, + "artifact-family": { "type": "string" }, + "platform-targets": { "type": "array", "items": { "type": "string" } }, + "runtime-modes": { "type": "array", "items": { "type": "string" } }, + "evidence-required": { "type": "array", "items": { "type": "string" } }, + "public": { "type": "boolean" } + } + } + } + } +} diff --git a/src/extensions/evidence/schemas/run.schema.json b/src/extensions/evidence/schemas/run.schema.json new file mode 100644 index 00000000..233522dc --- /dev/null +++ b/src/extensions/evidence/schemas/run.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oliphaunt.dev/schemas/extensions/evidence-run.schema.json", + "title": "Oliphaunt Extension Evidence Run", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "id", + "evidenceTier", + "status", + "sourceDigest", + "sourceDigestInputs", + "observedAt", + "collector", + "results" + ], + "properties": { + "schema": { "const": "oliphaunt-extension-evidence-v1" }, + "id": { "type": "string" }, + "evidenceTier": { "type": "string" }, + "status": { "enum": ["passed", "failed", "blocked"] }, + "sourceDigest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "sourceDigestInputs": { "type": "array", "items": { "type": "string" } }, + "observedAt": { "type": "string" }, + "collector": { "type": "string" }, + "notes": { "type": "string" }, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "extension", + "sqlName", + "postgresMajor", + "artifactFamily", + "platformTarget", + "runtimeModeStatuses" + ], + "properties": { + "extension": { "type": "string" }, + "sqlName": { "type": "string" }, + "postgresMajor": { "type": "integer" }, + "artifactFamily": { "type": "string" }, + "platformTarget": { "type": "string" }, + "runtimeModeStatuses": { + "type": "object", + "additionalProperties": { "enum": ["passed", "failed", "blocked", "not-run"] } + } + } + } + } + } +} diff --git a/src/extensions/external/README.md b/src/extensions/external/README.md new file mode 100644 index 00000000..d3f6930b --- /dev/null +++ b/src/extensions/external/README.md @@ -0,0 +1,8 @@ +# Shared External Extension Recipes + +This directory is the tracked source root for shared external extension recipe +metadata. Per-extension recipes move here as they graduate from the transitional +catalog rows to source-owned recipes. + +Do not place generated checkouts here. Source checkouts stay under +`target/oliphaunt-sources/checkouts/`. diff --git a/src/extensions/external/age/moon.yml b/src/extensions/external/age/moon.yml new file mode 100644 index 00000000..15dbb950 --- /dev/null +++ b/src/extensions/external/age/moon.yml @@ -0,0 +1,23 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extension-age" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external"] +dependsOn: + - "extension-runtime-contract" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/age" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/age/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/extensions/external/age/source.toml b/src/extensions/external/age/source.toml new file mode 100644 index 00000000..5ce659c5 --- /dev/null +++ b/src/extensions/external/age/source.toml @@ -0,0 +1,4 @@ +name = "age" +url = "https://github.com/apache/age.git" +branch = "PG17" +commit = "e1467f12e0b1d15dd35d3ab93f057a7112d425b8" diff --git a/src/extensions/external/pg_hashids/CHANGELOG.md b/src/extensions/external/pg_hashids/CHANGELOG.md new file mode 100644 index 00000000..f23ad0d9 --- /dev/null +++ b/src/extensions/external/pg_hashids/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt pg_hashids extension artifact release component. diff --git a/src/extensions/external/pg_hashids/VERSION b/src/extensions/external/pg_hashids/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/external/pg_hashids/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/external/pg_hashids/moon.yml b/src/extensions/external/pg_hashids/moon.yml new file mode 100644 index 00000000..c9ac00c8 --- /dev/null +++ b/src/extensions/external/pg_hashids/moon.yml @@ -0,0 +1,49 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-hashids" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external", "release-product"] +dependsOn: + - "extension-runtime-contract" + +project: + release: + component: "oliphaunt-extension-pg-hashids" + packagePath: "src/extensions/external/pg_hashids" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_hashids" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/pg_hashids/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-hashids --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-hashids:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/external/pg_hashids/**/*" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-hashids/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/external/pg_hashids/release.toml b/src/extensions/external/pg_hashids/release.toml new file mode 100644 index 00000000..c5b5f255 --- /dev/null +++ b/src/extensions/external/pg_hashids/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-hashids" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_hashids" diff --git a/src/extensions/external/pg_hashids/source.toml b/src/extensions/external/pg_hashids/source.toml new file mode 100644 index 00000000..50d94dfa --- /dev/null +++ b/src/extensions/external/pg_hashids/source.toml @@ -0,0 +1,4 @@ +name = "pg_hashids" +url = "https://github.com/iCyberon/pg_hashids" +branch = "pinned" +commit = "8c404dd86408f3a987a3ff6825ac7e42bd618b98" diff --git a/src/extensions/external/pg_hashids/targets/artifacts.toml b/src/extensions/external/pg_hashids/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/external/pg_hashids/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/external/pg_ivm/CHANGELOG.md b/src/extensions/external/pg_ivm/CHANGELOG.md new file mode 100644 index 00000000..b5a09480 --- /dev/null +++ b/src/extensions/external/pg_ivm/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt pg_ivm extension artifact release component. diff --git a/src/extensions/external/pg_ivm/VERSION b/src/extensions/external/pg_ivm/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/external/pg_ivm/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/external/pg_ivm/moon.yml b/src/extensions/external/pg_ivm/moon.yml new file mode 100644 index 00000000..a5d2b817 --- /dev/null +++ b/src/extensions/external/pg_ivm/moon.yml @@ -0,0 +1,49 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-ivm" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external", "release-product"] +dependsOn: + - "extension-runtime-contract" + +project: + release: + component: "oliphaunt-extension-pg-ivm" + packagePath: "src/extensions/external/pg_ivm" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_ivm" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/pg_ivm/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-ivm --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-ivm:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/external/pg_ivm/**/*" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-ivm/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/external/pg_ivm/release.toml b/src/extensions/external/pg_ivm/release.toml new file mode 100644 index 00000000..d515f5c5 --- /dev/null +++ b/src/extensions/external/pg_ivm/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-ivm" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_ivm" diff --git a/src/extensions/external/pg_ivm/source.toml b/src/extensions/external/pg_ivm/source.toml new file mode 100644 index 00000000..aa3263c5 --- /dev/null +++ b/src/extensions/external/pg_ivm/source.toml @@ -0,0 +1,4 @@ +name = "pg_ivm" +url = "https://github.com/sraoss/pg_ivm.git" +branch = "pinned" +commit = "b66487f7a6f8deee3998e858d773e19923e4bd4b" diff --git a/src/extensions/external/pg_ivm/targets/artifacts.toml b/src/extensions/external/pg_ivm/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/external/pg_ivm/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/external/pg_ivm/targets/native-static-registry.toml b/src/extensions/external/pg_ivm/targets/native-static-registry.toml new file mode 100644 index 00000000..7118eeea --- /dev/null +++ b/src/extensions/external/pg_ivm/targets/native-static-registry.toml @@ -0,0 +1,11 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "native-static-registry" +status = "supported" +build_kind = "pgxs-static-registry" +source_files = [ + "createas.c", + "matview.c", + "pg_ivm.c", + "ruleutils.c", + "subselect.c", +] diff --git a/src/extensions/external/pg_textsearch/CHANGELOG.md b/src/extensions/external/pg_textsearch/CHANGELOG.md new file mode 100644 index 00000000..cddee1ec --- /dev/null +++ b/src/extensions/external/pg_textsearch/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt pg_textsearch extension artifact release component. diff --git a/src/extensions/external/pg_textsearch/VERSION b/src/extensions/external/pg_textsearch/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/external/pg_textsearch/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/external/pg_textsearch/moon.yml b/src/extensions/external/pg_textsearch/moon.yml new file mode 100644 index 00000000..f07a12a3 --- /dev/null +++ b/src/extensions/external/pg_textsearch/moon.yml @@ -0,0 +1,49 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-textsearch" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external", "release-product"] +dependsOn: + - "extension-runtime-contract" + +project: + release: + component: "oliphaunt-extension-pg-textsearch" + packagePath: "src/extensions/external/pg_textsearch" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_textsearch" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/pg_textsearch/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-textsearch --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-textsearch:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/external/pg_textsearch/**/*" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-textsearch/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/external/pg_textsearch/recipe.toml b/src/extensions/external/pg_textsearch/recipe.toml new file mode 100644 index 00000000..ba9ff8c2 --- /dev/null +++ b/src/extensions/external/pg_textsearch/recipe.toml @@ -0,0 +1,43 @@ +schema = "oliphaunt-extension-recipe-v1" +sql_name = "pg_textsearch" +display_name = "pg_textsearch" +kind = "external-simple-pgxs" +license = "PostgreSQL" +source = "pg_textsearch" +postgres_majors = [18] + +[lifecycle] +creates_extension = true +requires = [] +implicit_sql_dependencies = [] +load_sql = [] +post_create_sql = [] +shared_preload_libraries = [] +restart_required = false +background_workers = false +shared_memory = false +session_load_required = false +needs_superuser = false +trusted = false + +[artifacts] +control_files = ["share/postgresql/extension/pg_textsearch.control"] +sql_globs = ["share/postgresql/extension/pg_textsearch--*.sql"] +native_modules = [] +native_dependency_modules = [] +data_files = [] +headers = [] +licenses = ["share/licenses/pg_textsearch/LICENSE"] + +[support.native] +direct = "supported" +broker = "supported" +server = "supported" + +[support.wasix] +direct = "supported" +server = "supported" + +[support.mobile] +ios = "supported" +android = "supported" diff --git a/src/extensions/external/pg_textsearch/release.toml b/src/extensions/external/pg_textsearch/release.toml new file mode 100644 index 00000000..eb577217 --- /dev/null +++ b/src/extensions/external/pg_textsearch/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-textsearch" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_textsearch" diff --git a/src/extensions/external/pg_textsearch/source.toml b/src/extensions/external/pg_textsearch/source.toml new file mode 100644 index 00000000..3e8d9f06 --- /dev/null +++ b/src/extensions/external/pg_textsearch/source.toml @@ -0,0 +1,4 @@ +name = "pg_textsearch" +url = "https://github.com/timescale/pg_textsearch.git" +branch = "pinned" +commit = "5c5147bf2610d786f1bd139951b9fb7fe4ac68fb" diff --git a/src/extensions/external/pg_textsearch/targets/artifacts.toml b/src/extensions/external/pg_textsearch/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/external/pg_textsearch/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/external/pg_textsearch/targets/native-static-registry.toml b/src/extensions/external/pg_textsearch/targets/native-static-registry.toml new file mode 100644 index 00000000..b206cc4f --- /dev/null +++ b/src/extensions/external/pg_textsearch/targets/native-static-registry.toml @@ -0,0 +1,6 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "native-static-registry" +status = "supported" +build_kind = "pgxs-static-registry" +source_recursive_dirs = ["src"] +include_dirs = ["source:src"] diff --git a/src/extensions/external/pg_textsearch/tests/smoke.sql b/src/extensions/external/pg_textsearch/tests/smoke.sql new file mode 100644 index 00000000..a232ab82 --- /dev/null +++ b/src/extensions/external/pg_textsearch/tests/smoke.sql @@ -0,0 +1,5 @@ +-- oliphaunt-statement +CREATE EXTENSION IF NOT EXISTS pg_textsearch; + +-- oliphaunt-statement +SELECT extname FROM pg_extension WHERE extname = 'pg_textsearch'; diff --git a/src/extensions/external/pg_textsearch/tests/upstream.toml b/src/extensions/external/pg_textsearch/tests/upstream.toml new file mode 100644 index 00000000..5fe1e901 --- /dev/null +++ b/src/extensions/external/pg_textsearch/tests/upstream.toml @@ -0,0 +1,6 @@ +schema = "oliphaunt-extension-upstream-tests-v1" +runner = "pgxs-installcheck" +status = "candidate" +reason = "pg_textsearch is currently covered by Oliphaunt direct/server/restart/dump smoke evidence. Upstream PGXS installcheck should be wired before using the full upstream suite as release evidence." +included_suites = ["pg_textsearch"] +excluded_suites = [] diff --git a/src/extensions/external/pg_uuidv7/CHANGELOG.md b/src/extensions/external/pg_uuidv7/CHANGELOG.md new file mode 100644 index 00000000..cb47762e --- /dev/null +++ b/src/extensions/external/pg_uuidv7/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt pg_uuidv7 extension artifact release component. diff --git a/src/extensions/external/pg_uuidv7/VERSION b/src/extensions/external/pg_uuidv7/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/external/pg_uuidv7/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/external/pg_uuidv7/moon.yml b/src/extensions/external/pg_uuidv7/moon.yml new file mode 100644 index 00000000..afe2d6fc --- /dev/null +++ b/src/extensions/external/pg_uuidv7/moon.yml @@ -0,0 +1,49 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pg-uuidv7" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external", "release-product"] +dependsOn: + - "extension-runtime-contract" + +project: + release: + component: "oliphaunt-extension-pg-uuidv7" + packagePath: "src/extensions/external/pg_uuidv7" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pg_uuidv7" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/pg_uuidv7/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pg-uuidv7 --require-native --require-wasix" + deps: + - "oliphaunt-extension-pg-uuidv7:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/external/pg_uuidv7/**/*" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pg-uuidv7/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/external/pg_uuidv7/release.toml b/src/extensions/external/pg_uuidv7/release.toml new file mode 100644 index 00000000..5add4249 --- /dev/null +++ b/src/extensions/external/pg_uuidv7/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pg-uuidv7" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pg_uuidv7" diff --git a/src/extensions/external/pg_uuidv7/source.toml b/src/extensions/external/pg_uuidv7/source.toml new file mode 100644 index 00000000..c5f5e2e3 --- /dev/null +++ b/src/extensions/external/pg_uuidv7/source.toml @@ -0,0 +1,4 @@ +name = "pg_uuidv7" +url = "https://github.com/fboulnois/pg_uuidv7/" +branch = "pinned" +commit = "c707aae2411181be4802f5fa565b44d9c0bcbc29" diff --git a/src/extensions/external/pg_uuidv7/targets/artifacts.toml b/src/extensions/external/pg_uuidv7/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/external/pg_uuidv7/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/external/pgtap/CHANGELOG.md b/src/extensions/external/pgtap/CHANGELOG.md new file mode 100644 index 00000000..5bb84a53 --- /dev/null +++ b/src/extensions/external/pgtap/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt pgtap extension artifact release component. diff --git a/src/extensions/external/pgtap/VERSION b/src/extensions/external/pgtap/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/external/pgtap/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/external/pgtap/moon.yml b/src/extensions/external/pgtap/moon.yml new file mode 100644 index 00000000..4db77639 --- /dev/null +++ b/src/extensions/external/pgtap/moon.yml @@ -0,0 +1,49 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-pgtap" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external", "release-product"] +dependsOn: + - "extension-runtime-contract" + +project: + release: + component: "oliphaunt-extension-pgtap" + packagePath: "src/extensions/external/pgtap" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/pgtap" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/pgtap/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-pgtap --require-native --require-wasix" + deps: + - "oliphaunt-extension-pgtap:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/external/pgtap/**/*" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-pgtap/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/external/pgtap/recipe.toml b/src/extensions/external/pgtap/recipe.toml new file mode 100644 index 00000000..03810e6d --- /dev/null +++ b/src/extensions/external/pgtap/recipe.toml @@ -0,0 +1,48 @@ +schema = "oliphaunt-extension-recipe-v1" +sql_name = "pgtap" +display_name = "pgTAP" +kind = "external-simple-pgxs" +license = "PostgreSQL" +source = "pgtap" +postgres_majors = [18] + +[lifecycle] +creates_extension = true +requires = ["plpgsql"] +implicit_sql_dependencies = [] +load_sql = [] +post_create_sql = [] +shared_preload_libraries = [] +restart_required = false +background_workers = false +shared_memory = false +session_load_required = false +needs_superuser = false +trusted = false + +[artifacts] +control_files = ["share/postgresql/extension/pgtap.control"] +sql_globs = ["share/postgresql/extension/pgtap--*.sql"] +extension_sql_file_prefixes = [ + "pgtap-core", + "pgtap-schema", +] +extension_sql_file_names = ["uninstall_pgtap.sql"] +native_modules = [] +native_dependency_modules = [] +data_files = [] +headers = [] +licenses = [] + +[support.native] +direct = "supported" +broker = "supported" +server = "supported" + +[support.wasix] +direct = "supported" +server = "supported" + +[support.mobile] +ios = "supported" +android = "supported" diff --git a/src/extensions/external/pgtap/release.toml b/src/extensions/external/pgtap/release.toml new file mode 100644 index 00000000..3828a9dc --- /dev/null +++ b/src/extensions/external/pgtap/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-pgtap" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "pgtap" diff --git a/src/extensions/external/pgtap/source.toml b/src/extensions/external/pgtap/source.toml new file mode 100644 index 00000000..dbbbfa33 --- /dev/null +++ b/src/extensions/external/pgtap/source.toml @@ -0,0 +1,4 @@ +name = "pgtap" +url = "https://github.com/theory/pgtap.git" +branch = "pinned" +commit = "b89585a64ffef012ff0f219de9197c669aa8485b" diff --git a/src/extensions/external/pgtap/targets/artifacts.toml b/src/extensions/external/pgtap/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/external/pgtap/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/external/pgtap/targets/native-static-registry.toml b/src/extensions/external/pgtap/targets/native-static-registry.toml new file mode 100644 index 00000000..21489afc --- /dev/null +++ b/src/extensions/external/pgtap/targets/native-static-registry.toml @@ -0,0 +1,13 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "sql-only" +status = "supported" +build_kind = "pgxs-sql-only" +platform_targets = [ + "ios-arm64", + "ios-simulator-arm64", + "android-arm64-v8a", + "android-x86_64", +] +dependencies = [] +module_files = [] +data_files = [] diff --git a/src/extensions/external/pgtap/targets/native.toml b/src/extensions/external/pgtap/targets/native.toml new file mode 100644 index 00000000..cea227e2 --- /dev/null +++ b/src/extensions/external/pgtap/targets/native.toml @@ -0,0 +1,7 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "sql-only" +status = "supported" +build_kind = "pgxs-sql-only" +dependencies = [] +module_files = [] +data_files = [] diff --git a/src/extensions/external/pgtap/targets/wasix.toml b/src/extensions/external/pgtap/targets/wasix.toml new file mode 100644 index 00000000..cea227e2 --- /dev/null +++ b/src/extensions/external/pgtap/targets/wasix.toml @@ -0,0 +1,7 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "sql-only" +status = "supported" +build_kind = "pgxs-sql-only" +dependencies = [] +module_files = [] +data_files = [] diff --git a/src/extensions/external/pgtap/tests/smoke.sql b/src/extensions/external/pgtap/tests/smoke.sql new file mode 100644 index 00000000..635a7482 --- /dev/null +++ b/src/extensions/external/pgtap/tests/smoke.sql @@ -0,0 +1,7 @@ +SELECT plan(2); +-- oliphaunt-statement +SELECT ok(true, 'pgTAP ok() executes'); +-- oliphaunt-statement +SELECT is(1, 1, 'pgTAP is() compares values'); +-- oliphaunt-statement +SELECT * FROM finish(); diff --git a/src/extensions/external/pgtap/tests/upstream.toml b/src/extensions/external/pgtap/tests/upstream.toml new file mode 100644 index 00000000..385c41a6 --- /dev/null +++ b/src/extensions/external/pgtap/tests/upstream.toml @@ -0,0 +1,6 @@ +schema = "oliphaunt-extension-upstream-tests-v1" +runner = "pgxs-installcheck" +status = "candidate" +reason = "pgTAP is currently covered by Oliphaunt direct/server/restart/dump smoke evidence. Upstream PGXS installcheck should be wired before using upstream pgTAP's full suite as release evidence." +included_suites = ["pgtap"] +excluded_suites = [] diff --git a/src/extensions/external/postgis/CHANGELOG.md b/src/extensions/external/postgis/CHANGELOG.md new file mode 100644 index 00000000..f09d6f15 --- /dev/null +++ b/src/extensions/external/postgis/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt PostGIS extension artifact release component. diff --git a/src/extensions/external/postgis/VERSION b/src/extensions/external/postgis/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/external/postgis/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/external/postgis/blockers.toml b/src/extensions/external/postgis/blockers.toml new file mode 100644 index 00000000..1f9c7087 --- /dev/null +++ b/src/extensions/external/postgis/blockers.toml @@ -0,0 +1,11 @@ +schema = "oliphaunt-extension-blockers-v1" + +[[blockers]] +target = "native" +status = "candidate" +reason = "Native dynamic PostGIS artifacts need release-shaped target metadata and full upstream regression evidence before supported status." + +[[blockers]] +target = "mobile" +status = "candidate" +reason = "Mobile static PostGIS artifacts need selected-only package inspection, static-registry evidence, and installed-app smoke evidence before supported status." diff --git a/src/extensions/external/postgis/dependencies/geos/source.toml b/src/extensions/external/postgis/dependencies/geos/source.toml new file mode 100644 index 00000000..a066a551 --- /dev/null +++ b/src/extensions/external/postgis/dependencies/geos/source.toml @@ -0,0 +1,4 @@ +name = "geos" +url = "https://github.com/libgeos/geos.git" +branch = "3.14.1" +commit = "c389f532d25fe6228861d9b19339f9cb57ca4bdb" diff --git a/src/extensions/external/postgis/dependencies/json-c/source.toml b/src/extensions/external/postgis/dependencies/json-c/source.toml new file mode 100644 index 00000000..9e386232 --- /dev/null +++ b/src/extensions/external/postgis/dependencies/json-c/source.toml @@ -0,0 +1,4 @@ +name = "json-c" +url = "https://github.com/json-c/json-c.git" +branch = "json-c-0.18-20240915" +commit = "41a55cfcedb54d9c1874f2f0eb07b504091d7e37" diff --git a/src/extensions/external/postgis/dependencies/libiconv/source.toml b/src/extensions/external/postgis/dependencies/libiconv/source.toml new file mode 100644 index 00000000..2dc016a5 --- /dev/null +++ b/src/extensions/external/postgis/dependencies/libiconv/source.toml @@ -0,0 +1,7 @@ +name = "libiconv" +kind = "archive" +url = "https://ftp.gnu.org/gnu/libiconv/libiconv-1.19.tar.gz" +branch = "1.19" +commit = "88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6" +sha256 = "88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6" +strip_prefix = "libiconv-1.19" diff --git a/src/extensions/external/postgis/dependencies/libxml2/source.toml b/src/extensions/external/postgis/dependencies/libxml2/source.toml new file mode 100644 index 00000000..75c55755 --- /dev/null +++ b/src/extensions/external/postgis/dependencies/libxml2/source.toml @@ -0,0 +1,4 @@ +name = "libxml2" +url = "https://gitlab.gnome.org/GNOME/libxml2.git" +branch = "v2.14.6" +commit = "d23960a130c5bb82779c9405fbbf85e65fb3c57c" diff --git a/src/extensions/external/postgis/dependencies/proj/source.toml b/src/extensions/external/postgis/dependencies/proj/source.toml new file mode 100644 index 00000000..4e7134dd --- /dev/null +++ b/src/extensions/external/postgis/dependencies/proj/source.toml @@ -0,0 +1,4 @@ +name = "proj" +url = "https://github.com/OSGeo/PROJ.git" +branch = "9.8.1" +commit = "f08fa86c478c4bbbf003b1ec751dd84aa6eca486" diff --git a/src/extensions/external/postgis/dependencies/sqlite/source.toml b/src/extensions/external/postgis/dependencies/sqlite/source.toml new file mode 100644 index 00000000..61eab363 --- /dev/null +++ b/src/extensions/external/postgis/dependencies/sqlite/source.toml @@ -0,0 +1,4 @@ +name = "sqlite" +url = "https://github.com/sqlite/sqlite.git" +branch = "version-3.53.1" +commit = "ccd445d76a9362c63add000354fac84ba9022176" diff --git a/src/extensions/external/postgis/deps.toml b/src/extensions/external/postgis/deps.toml new file mode 100644 index 00000000..52004b0e --- /dev/null +++ b/src/extensions/external/postgis/deps.toml @@ -0,0 +1,49 @@ +schema = "oliphaunt-extension-deps-v1" + +[[dependencies]] +name = "geos" +source = "geos" +version = "3.14.1" +linkage = "static" +license = "LGPL-2.1-or-later" +mobile-static-dependencies = ["geos-c", "geos"] + +[[dependencies]] +name = "proj" +source = "proj" +version = "9.8.1" +linkage = "static" +license = "MIT" +mobile-static-dependencies = ["proj"] + +[[dependencies]] +name = "sqlite" +source = "sqlite" +version = "3.53.1" +linkage = "static" +license = "blessing" +mobile-static-dependencies = ["sqlite"] + +[[dependencies]] +name = "libxml2" +source = "libxml2" +version = "2.14.6" +linkage = "static" +license = "MIT" +mobile-static-dependencies = ["libxml2"] + +[[dependencies]] +name = "json-c" +source = "json-c" +version = "0.18" +linkage = "static" +license = "MIT" +mobile-static-dependencies = ["json-c"] + +[[dependencies]] +name = "libiconv" +source = "libiconv" +version = "1.19" +linkage = "static" +license = "LGPL-2.1-or-later" +mobile-static-dependencies = ["libiconv", "libcharset"] diff --git a/src/extensions/external/postgis/moon.yml b/src/extensions/external/postgis/moon.yml new file mode 100644 index 00000000..ff942aaa --- /dev/null +++ b/src/extensions/external/postgis/moon.yml @@ -0,0 +1,49 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-postgis" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external", "postgis", "release-product"] +dependsOn: + - "extension-runtime-contract" + +project: + release: + component: "oliphaunt-extension-postgis" + packagePath: "src/extensions/external/postgis" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/postgis" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/postgis/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-postgis --require-native --require-wasix" + deps: + - "oliphaunt-extension-postgis:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/external/postgis/**/*" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-postgis/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/external/postgis/patches/README.md b/src/extensions/external/postgis/patches/README.md new file mode 100644 index 00000000..c797b150 --- /dev/null +++ b/src/extensions/external/postgis/patches/README.md @@ -0,0 +1,3 @@ +PostGIS patches for Oliphaunt live here when they are introduced. + +The current reduced build uses build-system flags instead of source patches. diff --git a/src/extensions/external/postgis/recipe.toml b/src/extensions/external/postgis/recipe.toml new file mode 100644 index 00000000..6517000a --- /dev/null +++ b/src/extensions/external/postgis/recipe.toml @@ -0,0 +1,72 @@ +schema = "oliphaunt-extension-recipe-v1" +sql_name = "postgis" +display_name = "PostGIS" +kind = "external-complex" +license = "GPL-2.0-or-later" +source = "postgis" +postgres_majors = [18] + +[lifecycle] +creates_extension = true +requires = [] +implicit_sql_dependencies = [] +load_sql = [] +post_create_sql = [] +shared_preload_libraries = [] +restart_required = false +background_workers = false +shared_memory = false +session_load_required = false +needs_superuser = true +trusted = false + +[artifacts] +control_files = ["share/postgresql/extension/postgis.control"] +sql_globs = ["share/postgresql/extension/postgis--*.sql"] +extension_sql_file_prefixes = [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis", +] +extension_sql_file_names = ["uninstall_postgis.sql"] +native_modules = ["lib/postgresql/postgis-3.so"] +native_dependency_modules = ["lib/postgresql/liboliphaunt_postgis_deps.so"] +data_files = [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db", +] +headers = [] +licenses = [ + "share/licenses/postgis/COPYING", + "share/licenses/geos/COPYING", + "share/licenses/proj/COPYING", + "share/licenses/sqlite/LICENSE.md", + "share/licenses/libxml2/Copyright", + "share/licenses/json-c/COPYING", + "share/licenses/libiconv/COPYING", +] + +[[runtime_environment]] +name = "PROJ_DATA" +path = "share/postgresql/proj" +required_file = "proj.db" + +[support.native] +direct = "supported" +broker = "supported" +server = "supported" + +[support.wasix] +direct = "supported" +server = "supported" + +[support.mobile] +ios = "supported" +android = "supported" diff --git a/src/extensions/external/postgis/release.toml b/src/extensions/external/postgis/release.toml new file mode 100644 index 00000000..6d5c2041 --- /dev/null +++ b/src/extensions/external/postgis/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-postgis" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "postgis" diff --git a/src/extensions/external/postgis/source.toml b/src/extensions/external/postgis/source.toml new file mode 100644 index 00000000..a8030441 --- /dev/null +++ b/src/extensions/external/postgis/source.toml @@ -0,0 +1,4 @@ +name = "postgis" +url = "https://github.com/postgis/postgis.git" +branch = "3.6.3" +commit = "3d12666588a84b23a3147618eaa9b40b0fe5e796" diff --git a/src/extensions/external/postgis/targets/artifacts.toml b/src/extensions/external/postgis/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/external/postgis/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/src/extensions/external/postgis/targets/native-static-registry.toml b/src/extensions/external/postgis/targets/native-static-registry.toml new file mode 100644 index 00000000..1dcad367 --- /dev/null +++ b/src/extensions/external/postgis/targets/native-static-registry.toml @@ -0,0 +1,40 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "native-static-registry" +status = "supported" +build_kind = "autotools-static-registry" +platform_targets = [ + "ios-arm64", + "ios-simulator-arm64", + "android-arm64-v8a", + "android-x86_64", +] +dependencies = ["geos", "proj", "sqlite", "libxml2", "json-c", "libiconv"] +ios_dependencies = ["geos", "proj", "sqlite", "libxml2", "json-c"] +android_dependencies = ["geos", "proj", "sqlite", "libxml2", "json-c", "libiconv"] +module_files = ["postgis-3"] +data_files = [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db", +] +configure_flags = [ + "--without-raster", + "--without-topology", + "--without-sfcgal", + "--without-address-standardizer", + "--without-protobuf", +] +excluded_sql_extensions = [ + "postgis_raster", + "postgis_topology", + "postgis_sfcgal", + "postgis_tiger_geocoder", + "address_standardizer", + "address_standardizer_data_us", +] diff --git a/src/extensions/external/postgis/targets/native.toml b/src/extensions/external/postgis/targets/native.toml new file mode 100644 index 00000000..7c6cbf89 --- /dev/null +++ b/src/extensions/external/postgis/targets/native.toml @@ -0,0 +1,35 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "native-dynamic" +status = "supported" +build_kind = "autotools" +dependencies = ["geos", "proj", "sqlite", "libxml2", "json-c", "libiconv"] +module_files = [ + "lib/postgresql/postgis-3.so", + "lib/postgresql/liboliphaunt_postgis_deps.so", +] +data_files = [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db", +] +configure_flags = [ + "--without-raster", + "--without-topology", + "--without-sfcgal", + "--without-address-standardizer", + "--without-protobuf", +] +excluded_sql_extensions = [ + "postgis_raster", + "postgis_topology", + "postgis_sfcgal", + "postgis_tiger_geocoder", + "address_standardizer", + "address_standardizer_data_us", +] diff --git a/src/extensions/external/postgis/targets/wasix.toml b/src/extensions/external/postgis/targets/wasix.toml new file mode 100644 index 00000000..818c728b --- /dev/null +++ b/src/extensions/external/postgis/targets/wasix.toml @@ -0,0 +1,62 @@ +schema = "oliphaunt-extension-target-v1" +artifact_family = "wasix-side-module" +status = "supported" +build_kind = "autotools" +build_script = "src/extensions/external/postgis/tools/build_wasix.sh" +dependencies = ["geos", "proj", "sqlite", "libxml2", "json-c", "libiconv"] +module_files = [ + "lib/postgresql/postgis-3.so", + "lib/postgresql/liboliphaunt_postgis_deps.so", +] +data_files = [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db", +] +configure_flags = [ + "--without-raster", + "--without-topology", + "--without-sfcgal", + "--without-address-standardizer", + "--without-tiger", + "--without-protobuf", + "--disable-nls", +] +required_build_files = [ + "postgis/postgis-3.so", + "postgis/liboliphaunt_postgis_deps.so", + "extensions/postgis/postgis.control", + "share/proj/proj.db", +] +required_build_globs = [ + "extensions/postgis/sql/postgis--*.sql", +] +excluded_sql_extensions = [ + "postgis_raster", + "postgis_topology", + "postgis_sfcgal", + "postgis_tiger_geocoder", + "address_standardizer", + "address_standardizer_data_us", +] + +[[native_support_modules]] +name = "postgis_deps" +runtime_path = "lib/postgresql/liboliphaunt_postgis_deps.so" +build_path = "postgis/postgis/liboliphaunt_postgis_deps.so" +aot_file = "postgis_deps-llvm-opta.bin.zst" + +[staging] +module_source_dir = "postgis/postgis" +control_source = "postgis/extensions/postgis/postgis.control" +sql_source_dir = "postgis/extensions/postgis/sql" + +[[staging.data_dirs]] +source = "postgis/share/proj" +destination = "share/proj" diff --git a/src/extensions/external/postgis/tests/regression.sql b/src/extensions/external/postgis/tests/regression.sql new file mode 100644 index 00000000..243a42b5 --- /dev/null +++ b/src/extensions/external/postgis/tests/regression.sql @@ -0,0 +1,3 @@ +-- Full upstream PostGIS regression coverage is declared in upstream.toml. +-- This file reserves the Oliphaunt-owned regression slot for runtime-specific +-- additions that are not covered by the smoke path. diff --git a/src/extensions/external/postgis/tests/smoke.sql b/src/extensions/external/postgis/tests/smoke.sql new file mode 100644 index 00000000..4f05b7be --- /dev/null +++ b/src/extensions/external/postgis/tests/smoke.sql @@ -0,0 +1,61 @@ +DROP TABLE IF EXISTS liboliphaunt_postgis_points; +-- oliphaunt-statement +CREATE TEMP TABLE liboliphaunt_postgis_points(id int PRIMARY KEY, geom geometry(Point, 4326)); +-- oliphaunt-statement +INSERT INTO liboliphaunt_postgis_points VALUES + (1, ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326)), + (2, ST_SetSRID(ST_MakePoint(-71.061, 48.433), 4326)); +-- oliphaunt-statement +CREATE INDEX liboliphaunt_postgis_points_gix ON liboliphaunt_postgis_points USING GIST (geom); +-- oliphaunt-statement +DO $$ +DECLARE + distance float8; + srid int; + area float8; + polygons int; + nearby int; +BEGIN + SELECT ST_Distance(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(3 4)')) INTO distance; + IF distance <> 5 THEN + RAISE EXCEPTION 'postgis geometry distance failed: %', distance; + END IF; + IF ST_AsText(ST_Buffer(ST_GeomFromText('POINT(0 0)'), 1, 'quad_segs=1')) IS NULL THEN + RAISE EXCEPTION 'postgis buffer failed'; + END IF; + IF NOT ST_Within( + ST_GeomFromText('POINT(0.5 0.5)'), + ST_GeomFromText('POLYGON((0 0,0 1,1 1,1 0,0 0))') + ) THEN + RAISE EXCEPTION 'postgis within failed'; + END IF; + SELECT ST_SRID(ST_Transform(ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326), 3857)) INTO srid; + IF srid <> 3857 THEN + RAISE EXCEPTION 'postgis transform failed: %', srid; + END IF; + SELECT ST_Area(ST_Transform(ST_SetSRID(ST_MakePolygon(ST_GeomFromText('LINESTRING(-71.1776848522251 42.3902896512902,-71.1776843766797 42.3903701743239,-71.1775844305465 42.3903829478009,-71.1775825927231 42.3902893647987,-71.1776848522251 42.3902896512902)')), 4326), 26986)) INTO area; + IF area <= 0 THEN + RAISE EXCEPTION 'postgis projected area failed: %', area; + END IF; + SELECT ST_NumGeometries(ST_Polygonize(ARRAY[ + ST_GeomFromText('LINESTRING(0 0, 1 0)'), + ST_GeomFromText('LINESTRING(1 0, 1 1)'), + ST_GeomFromText('LINESTRING(1 1, 0 1)'), + ST_GeomFromText('LINESTRING(0 1, 0 0)') + ])) INTO polygons; + IF polygons <> 1 THEN + RAISE EXCEPTION 'postgis polygonize failed: %', polygons; + END IF; + SELECT count(*) INTO nearby + FROM liboliphaunt_postgis_points + WHERE ST_DWithin( + geom::geography, + ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326)::geography, + 200 + ); + IF nearby <> 2 THEN + RAISE EXCEPTION 'postgis dwithin failed: %', nearby; + END IF; +END $$; +-- oliphaunt-statement +DROP TABLE IF EXISTS liboliphaunt_postgis_points; diff --git a/src/extensions/external/postgis/tests/upstream.toml b/src/extensions/external/postgis/tests/upstream.toml new file mode 100644 index 00000000..5fb15934 --- /dev/null +++ b/src/extensions/external/postgis/tests/upstream.toml @@ -0,0 +1,12 @@ +schema = "oliphaunt-extension-upstream-tests-v1" +runner = "postgis-installcheck" +status = "candidate" +reason = "The reduced WASIX/mobile PostGIS surface is smoke-gated today. Upstream PostGIS installcheck is required before broadening the public evidence tier beyond transitional catalog smoke." +included_suites = ["postgis"] +excluded_suites = [ + "raster", + "topology", + "sfcgal", + "tiger_geocoder", + "address_standardizer", +] diff --git a/src/extensions/external/postgis/tools/build_wasix.sh b/src/extensions/external/postgis/tools/build_wasix.sh new file mode 100755 index 00000000..a8d0fadd --- /dev/null +++ b/src/extensions/external/postgis/tools/build_wasix.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${REPO_ROOT:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)}" +ROOT="${OLIPHAUNT_WASIX_BUILD_ROOT:-$REPO_ROOT/src/runtimes/liboliphaunt/wasix/assets/build}" +. "$ROOT/wasix_third_party.sh" +. "$ROOT/source_lane.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" +JOBS="${JOBS:-4}" +oliphaunt_wasix_run_extension_build_in_docker_if_needed \ + "$ROOT" \ + "$REPO_ROOT" \ + "$SOURCE_LANE" \ + "src/extensions/external/postgis/tools/build_wasix.sh" + +POSTGIS_SOURCE_DIR="${POSTGIS_SOURCE_DIR:-$(oliphaunt_wasix_extension_source_dir "$REPO_ROOT" postgis)}" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +export CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-$GENERATED_ROOT}" +BUILD_DIR="${BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +PGSRC="${PGSRC:-$(SOURCE_CACHE="${SOURCE_CACHE:-$REPO_ROOT/target/liboliphaunt-pg18/source}" "$ROOT/prepare_postgres_source.sh")}" +POSTGIS_BUILD_DIR="${POSTGIS_BUILD_DIR:-$(oliphaunt_wasix_extension_build_dir "$BUILD_DIR" postgis)}" +oliphaunt_wasix_export_extension_dependency_prefixes "$ROOT" "$REPO_ROOT" postgis +postgis_configure_flags=() +while IFS= read -r flag; do + [ -n "$flag" ] && postgis_configure_flags+=("$flag") +done < <(oliphaunt_wasix_extension_wasix_configure_flags "$REPO_ROOT" postgis) + +if [ ! -f "$POSTGIS_SOURCE_DIR/configure.ac" ]; then + echo "missing PostGIS source checkout at $POSTGIS_SOURCE_DIR; run assets fetch/source-spine first" >&2 + exit 1 +fi +if [ ! -f "$BUILD_DIR/config.status" ]; then + echo "missing WASIX PostgreSQL build at $BUILD_DIR; run docker_oliphaunt.sh first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(oliphaunt_wasix_source_commit "$POSTGIS_SOURCE_DIR")" +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +dependency_stamp_block="$(oliphaunt_wasix_extension_dependency_stamp_block "$REPO_ROOT" postgis)" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="source=$source_commit +script=$script_sha256 +helper=$helper_sha256 +$dependency_stamp_block +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +configure=$(oliphaunt_wasix_extension_configure_signature "$REPO_ROOT" postgis) +link=postgis-plus-selected-support-side-module-v2" + +if [ -f "$POSTGIS_BUILD_DIR/.oliphaunt-wasix-postgis-build" ] && + oliphaunt_wasix_extension_build_outputs_exist "$REPO_ROOT" postgis "$POSTGIS_BUILD_DIR" --quiet && + [ "$(cat "$POSTGIS_BUILD_DIR/.oliphaunt-wasix-postgis-build")" = "$stamp" ]; then + echo "$POSTGIS_BUILD_DIR" + exit 0 +fi + +{ + rm -rf "$POSTGIS_BUILD_DIR" + mkdir -p "$(dirname "$POSTGIS_BUILD_DIR")" + oliphaunt_wasix_copy_source_clean "$POSTGIS_SOURCE_DIR" "$POSTGIS_BUILD_DIR" + + geos_config="$POSTGIS_BUILD_DIR/oliphaunt-geos-config" + cat >"$geos_config" <"$pkg_config" <"$pg_config" <postgis/oliphaunt_postgis_deps_stubs.c <<'EOF' +/* + * WASIX C++ dependency objects can emit process/TLS lifecycle hooks that are + * normally supplied by a full C++ runtime. The selected PostGIS dependency side + * module is process-owned and lives for the duration of the embedded runtime, + * so there is no separate thread-local destructor lifecycle to register here. + */ +__attribute__((visibility("hidden"))) void _ZTH5errno(void) {} + +__attribute__((visibility("hidden"))) int __cxa_thread_atexit_impl( + void (*destructor)(void *), + void *object, + void *dso_symbol) +{ + (void)destructor; + (void)object; + (void)dso_symbol; + return 0; +} +EOF + wasixcc $CFLAGS -c \ + postgis/oliphaunt_postgis_deps_stubs.c \ + -o postgis/oliphaunt_postgis_deps_stubs.o + + wasm_ld="$WASIX_HOME/llvm/bin/wasm-ld" + wasix_sysroot_lib="$WASIX_HOME/sysroot/sysroot-exnref-ehpic/lib/wasm32-wasi" + postgis_deps_module="$POSTGIS_BUILD_DIR/postgis/liboliphaunt_postgis_deps.so" + "$wasm_ld" \ + --shared \ + --shared-memory \ + --experimental-pic \ + --unresolved-symbols=import-dynamic \ + --extra-features=atomics,bulk-memory,mutable-globals \ + --export=__wasm_call_ctors \ + --export-if-defined=__wasm_apply_data_relocs \ + --export-all \ + --no-gc-sections \ + "$POSTGIS_BUILD_DIR/postgis/oliphaunt_postgis_deps_stubs.o" \ + --whole-archive \ + "$GEOS_PREFIX/lib/libgeos_c.a" \ + "$GEOS_PREFIX/lib/libgeos.a" \ + "$PROJ_PREFIX/lib/libproj.a" \ + "$SQLITE_PREFIX/lib/libsqlite3.a" \ + "$JSONC_PREFIX/lib/libjson-c.a" \ + "$LIBXML2_PREFIX/lib/libxml2.a" \ + "$LIBICONV_PREFIX/lib/libiconv.a" \ + "$wasix_sysroot_lib/libc++.a" \ + "$wasix_sysroot_lib/libc++abi.a" \ + "$wasix_sysroot_lib/libunwind.a" \ + --no-whole-archive \ + "$wasix_sysroot_lib/libm.a" \ + "$wasix_sysroot_lib/libc.a" \ + "$wasix_sysroot_lib/libclang_rt.builtins-wasm32.a" \ + -o "$postgis_deps_module" + + export OLIPHAUNT_POSTGIS_STATIC_ARCHIVES="$GEOS_PREFIX/lib/libgeos_c.a $GEOS_PREFIX/lib/libgeos.a $PROJ_PREFIX/lib/libproj.a $SQLITE_PREFIX/lib/libsqlite3.a $JSONC_PREFIX/lib/libjson-c.a $LIBXML2_PREFIX/lib/libxml2.a $LIBICONV_PREFIX/lib/libiconv.a $LIBICONV_PREFIX/lib/libcharset.a $wasix_sysroot_lib/libc++.a $wasix_sysroot_lib/libc++abi.a $wasix_sysroot_lib/libunwind.a $wasix_sysroot_lib/libm.a $wasix_sysroot_lib/libc.a $wasix_sysroot_lib/libclang_rt.builtins-wasm32.a" + perl -0pi -e ' + s|^OBJS=\$\(PG_OBJS\)$|OBJS=\$(PG_OBJS) oliphaunt_postgis_deps_stubs.o $ENV{OLIPHAUNT_POSTGIS_STATIC_ARCHIVES}|m; + s|^FLATGEOBUF_LIB = .*$|FLATGEOBUF_LIB = ../deps/flatgeobuf/flatgeobuf_c.o ../deps/flatgeobuf/geometrywriter.o ../deps/flatgeobuf/geometryreader.o ../deps/flatgeobuf/packedrtree.o -lc++|m; + s|^(SHLIB_LINK := .*)$|$1 -lc|m; + s|^(LDFLAGS = .*)$|$1 -lc|m; + ' postgis/Makefile + perl -0pi -e ' + s|^(CXXFLAGS =.*)$|$1 -fvisibility=hidden -fvisibility-inlines-hidden|m; + ' deps/flatgeobuf/Makefile + + oliphaunt_wasix_apply_wasix_profile build + make -s -j"$JOBS" -C liblwgeom liblwgeom.la + make -s -j"$JOBS" -C libpgcommon libpgcommon.a + make -s -j"$JOBS" -C postgis all + postgis_objects=() + while IFS= read -r object; do + [ -n "$object" ] && postgis_objects+=("$object") + done < <(find "$POSTGIS_BUILD_DIR/postgis" -maxdepth 1 -name '*.o' ! -name 'oliphaunt_errno_tls_init_stub.o' | sort) + "$wasm_ld" \ + --shared \ + --shared-memory \ + --experimental-pic \ + --unresolved-symbols=import-dynamic \ + --extra-features=atomics,bulk-memory,mutable-globals \ + --export=__wasm_call_ctors \ + --export-if-defined=__wasm_apply_data_relocs \ + --export-all \ + --no-gc-sections \ + "${postgis_objects[@]}" \ + "$POSTGIS_BUILD_DIR/deps/flatgeobuf/flatgeobuf_c.o" \ + "$POSTGIS_BUILD_DIR/deps/flatgeobuf/geometrywriter.o" \ + "$POSTGIS_BUILD_DIR/deps/flatgeobuf/geometryreader.o" \ + "$POSTGIS_BUILD_DIR/deps/flatgeobuf/packedrtree.o" \ + "$POSTGIS_BUILD_DIR/libpgcommon/libpgcommon.a" \ + "$POSTGIS_BUILD_DIR/liblwgeom/.libs/liblwgeom.a" \ + --Bdynamic \ + -L"$POSTGIS_BUILD_DIR/postgis" \ + -loliphaunt_postgis_deps \ + -rpath '$ORIGIN' \ + -o "$POSTGIS_BUILD_DIR/postgis/postgis-3.so" + # PostGIS core upgrade SQL still includes raster-unpackage stubs even when + # raster support is disabled. Generate those SQL inputs as a best-effort + # prerequisite before packaging PostGIS. Keep this serial: the + # PostGIS SQL Makefiles use generated .tmp files and have incomplete + # parallel dependency edges. + make -s -j1 raster-sql || true + make -s -j1 -C raster/rt_pg sql_objs + make -s -j1 -C extensions postgis_extension_helper.sql + make -s -j1 -C extensions/postgis postgis.control all + mkdir -p "$POSTGIS_BUILD_DIR/share" + rm -rf "$POSTGIS_BUILD_DIR/share/proj" + cp -R "$PROJ_PREFIX/share/proj" "$POSTGIS_BUILD_DIR/share/proj" +} >&2 + +oliphaunt_wasix_extension_build_outputs_exist "$REPO_ROOT" postgis "$POSTGIS_BUILD_DIR" +printf '%s\n' "$stamp" > "$POSTGIS_BUILD_DIR/.oliphaunt-wasix-postgis-build" +echo "$POSTGIS_BUILD_DIR" diff --git a/src/extensions/external/vector/CHANGELOG.md b/src/extensions/external/vector/CHANGELOG.md new file mode 100644 index 00000000..a67aac9b --- /dev/null +++ b/src/extensions/external/vector/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Oliphaunt pgvector extension artifact release component. diff --git a/src/extensions/external/vector/VERSION b/src/extensions/external/vector/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/extensions/external/vector/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/extensions/external/vector/moon.yml b/src/extensions/external/vector/moon.yml new file mode 100644 index 00000000..686ef3df --- /dev/null +++ b/src/extensions/external/vector/moon.yml @@ -0,0 +1,49 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-extension-vector" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "external", "vector", "release-product"] +dependsOn: + - "extension-runtime-contract" + +project: + release: + component: "oliphaunt-extension-vector" + packagePath: "src/extensions/external/vector" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-tree.py src/extensions/external/vector" + deps: + - "extension-runtime-contract:check" + inputs: + - "/src/extensions/external/vector/**/*" + - "/src/extensions/tools/check-extension-tree.py" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + assemble-release: + tags: ["release", "artifact-package"] + command: "python3 tools/release/build-extension-ci-artifacts.py oliphaunt-extension-vector --require-native --require-wasix" + deps: + - "oliphaunt-extension-vector:check" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/external/vector/**/*" + - "/src/extensions/generated/wasix/extensions.json" + - "/tools/release/build-extension-ci-artifacts.py" + - "/tools/release/extension_artifact_targets.py" + - "/tools/release/product_metadata.py" + - "/target/extensions/native/release-assets/**/*" + - "/target/extensions/wasix/release-assets/**/*" + outputs: + - "/target/extension-artifacts/oliphaunt-extension-vector/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/extensions/external/vector/release.toml b/src/extensions/external/vector/release.toml new file mode 100644 index 00000000..c27cf665 --- /dev/null +++ b/src/extensions/external/vector/release.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-extension-vector" +owner = "@oliphaunt/extensions" +kind = "exact-extension-artifact" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["exact-extension-artifacts"] +extension_sql_name = "vector" diff --git a/src/extensions/external/vector/source.toml b/src/extensions/external/vector/source.toml new file mode 100644 index 00000000..441b2f50 --- /dev/null +++ b/src/extensions/external/vector/source.toml @@ -0,0 +1,4 @@ +name = "pgvector" +url = "https://github.com/pgvector/pgvector.git" +branch = "master" +commit = "d238409becebb8172fe696ffa776badfad4b631c" diff --git a/src/extensions/external/vector/targets/artifacts.toml b/src/extensions/external/vector/targets/artifacts.toml new file mode 100644 index 00000000..40e255b5 --- /dev/null +++ b/src/extensions/external/vector/targets/artifacts.toml @@ -0,0 +1,57 @@ +schema = "oliphaunt-extension-artifact-targets-v1" + +[[targets]] +target = "macos-arm64" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-x64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "linux-arm64-gnu" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "ios-xcframework" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-arm64-v8a" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "android-x86_64" +family = "native" +kind = "native-static-registry" +status = "supported" +published = true + +[[targets]] +target = "windows-x64-msvc" +family = "native" +kind = "native-dynamic" +status = "supported" +published = true + +[[targets]] +target = "wasix-portable" +family = "wasix" +kind = "wasix-runtime" +status = "supported" +published = true diff --git a/assets/generated/contrib-build.tsv b/src/extensions/generated/contrib-build.tsv similarity index 93% rename from assets/generated/contrib-build.tsv rename to src/extensions/generated/contrib-build.tsv index fd3fab14..02fe670c 100644 --- a/assets/generated/contrib-build.tsv +++ b/src/extensions/generated/contrib-build.tsv @@ -23,9 +23,11 @@ pg_surgery pg_surgery pg_surgery pg_surgery.so extensions/pg_surgery.tar.zst tru pg_trgm pg_trgm pg_trgm pg_trgm.so extensions/pg_trgm.tar.zst true pg_visibility pg_visibility pg_visibility pg_visibility.so extensions/pg_visibility.tar.zst true pg_walinspect pg_walinspect pg_walinspect pg_walinspect.so extensions/pg_walinspect.tar.zst true +pgcrypto pgcrypto pgcrypto pgcrypto.so extensions/pgcrypto.tar.zst true seg seg seg seg.so extensions/seg.tar.zst true tablefunc tablefunc tablefunc tablefunc.so extensions/tablefunc.tar.zst true tcn tcn tcn tcn.so extensions/tcn.tar.zst true tsm_system_rows tsm_system_rows tsm_system_rows tsm_system_rows.so extensions/tsm_system_rows.tar.zst true tsm_system_time tsm_system_time tsm_system_time tsm_system_time.so extensions/tsm_system_time.tar.zst true unaccent unaccent unaccent unaccent.so extensions/unaccent.tar.zst true +uuid_ossp uuid-ossp uuid-ossp uuid-ossp.so extensions/uuid-ossp.tar.zst true diff --git a/src/extensions/generated/docs/extension-evidence.json b/src/extensions/generated/docs/extension-evidence.json new file mode 100644 index 00000000..ab96b40e --- /dev/null +++ b/src/extensions/generated/docs/extension-evidence.json @@ -0,0 +1,1509 @@ +{ + "claims": [ + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "amcheck", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "amcheck" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "auto_explain", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "auto_explain" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "bloom", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "bloom" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "btree_gin", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "btree_gin" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "btree_gist", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "btree_gist" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "citext", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "citext" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "cube", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "cube" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "dict_int", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "dict_int" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "dict_xsyn", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "dict_xsyn" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "earthdistance", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "earthdistance" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "file_fdw", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "file_fdw" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "fuzzystrmatch", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "fuzzystrmatch" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "hstore", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "hstore" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "intarray", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "intarray" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "isn", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "isn" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "lo", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "lo" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "ltree", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "ltree" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pageinspect", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pageinspect" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_buffercache", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_buffercache" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_freespacemap", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_freespacemap" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_hashids", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_hashids" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_ivm", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_ivm" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_surgery", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_surgery" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_textsearch", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_textsearch" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_trgm", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_trgm" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_uuidv7", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_uuidv7" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_visibility", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_visibility" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pg_walinspect", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pg_walinspect" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pgcrypto", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pgcrypto" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "pgtap", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "pgtap" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "postgis", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "postgis" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "seg", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "seg" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "tablefunc", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "tablefunc" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "tcn", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "tcn" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "tsm_system_rows", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "tsm_system_rows" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "tsm_system_time", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "tsm_system_time" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "unaccent", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "unaccent" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "uuid_ossp", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "uuid-ossp" + }, + { + "artifact-family": "wasix-runtime", + "evidence-required": [ + "transitional-catalog-smoke" + ], + "extension": "vector", + "latest-accepted-evidence": [ + { + "artifact-family": "wasix-runtime", + "evidence-tier": "transitional-catalog-smoke", + "observed-at": "2026-06-07T00:00:00Z", + "platform-target": "portable", + "run-id": "2026-06-07-transitional-catalog-smoke", + "run-path": "src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json", + "runtime-mode-statuses": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3" + } + ], + "platform-targets": [ + "portable" + ], + "postgres-major": 18, + "public": true, + "runtime-modes": [ + "direct", + "server", + "restart", + "dump-restore" + ], + "sql-name": "vector" + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "evidence-matrix", + "path": "src/extensions/evidence/matrix.toml" + }, + { + "name": "evidence-runs", + "path": "src/extensions/evidence/runs" + } + ], + "source-digest": "sha256:87561718b08d063e1913f59676d28bc661fe1299cd534c408057a56878c8e1a3", + "source-digest-inputs": [ + "src/postgres/versions/18/source.toml", + "src/extensions/catalog/extensions.promoted.toml", + "src/extensions/catalog/extensions.smoke.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/generated/extensions.catalog.json", + "src/extensions/generated/extensions.build-plan.json", + "src/extensions/generated/contrib-build.tsv", + "src/extensions/generated/pgxs-build.tsv", + "src/extensions/external/age/source.toml", + "src/extensions/external/pg_hashids/source.toml", + "src/extensions/external/pg_ivm/source.toml", + "src/extensions/external/pg_textsearch/source.toml", + "src/extensions/external/pg_uuidv7/source.toml", + "src/extensions/external/pgtap/source.toml", + "src/extensions/external/postgis/dependencies/geos/source.toml", + "src/extensions/external/postgis/dependencies/json-c/source.toml", + "src/extensions/external/postgis/dependencies/libiconv/source.toml", + "src/extensions/external/postgis/dependencies/libxml2/source.toml", + "src/extensions/external/postgis/dependencies/proj/source.toml", + "src/extensions/external/postgis/dependencies/sqlite/source.toml", + "src/extensions/external/postgis/source.toml", + "src/extensions/external/vector/source.toml", + "src/sources/third-party/shared/icu.toml", + "src/sources/third-party/shared/openssl.toml", + "src/extensions/external/README.md", + "src/extensions/external/age/moon.yml", + "src/extensions/external/pg_hashids/CHANGELOG.md", + "src/extensions/external/pg_hashids/VERSION", + "src/extensions/external/pg_hashids/moon.yml", + "src/extensions/external/pg_hashids/release.toml", + "src/extensions/external/pg_hashids/targets/artifacts.toml", + "src/extensions/external/pg_ivm/CHANGELOG.md", + "src/extensions/external/pg_ivm/VERSION", + "src/extensions/external/pg_ivm/moon.yml", + "src/extensions/external/pg_ivm/release.toml", + "src/extensions/external/pg_ivm/targets/artifacts.toml", + "src/extensions/external/pg_ivm/targets/native-static-registry.toml", + "src/extensions/external/pg_textsearch/CHANGELOG.md", + "src/extensions/external/pg_textsearch/VERSION", + "src/extensions/external/pg_textsearch/moon.yml", + "src/extensions/external/pg_textsearch/recipe.toml", + "src/extensions/external/pg_textsearch/release.toml", + "src/extensions/external/pg_textsearch/targets/artifacts.toml", + "src/extensions/external/pg_textsearch/targets/native-static-registry.toml", + "src/extensions/external/pg_textsearch/tests/smoke.sql", + "src/extensions/external/pg_textsearch/tests/upstream.toml", + "src/extensions/external/pg_uuidv7/CHANGELOG.md", + "src/extensions/external/pg_uuidv7/VERSION", + "src/extensions/external/pg_uuidv7/moon.yml", + "src/extensions/external/pg_uuidv7/release.toml", + "src/extensions/external/pg_uuidv7/targets/artifacts.toml", + "src/extensions/external/pgtap/CHANGELOG.md", + "src/extensions/external/pgtap/VERSION", + "src/extensions/external/pgtap/moon.yml", + "src/extensions/external/pgtap/recipe.toml", + "src/extensions/external/pgtap/release.toml", + "src/extensions/external/pgtap/targets/artifacts.toml", + "src/extensions/external/pgtap/targets/native-static-registry.toml", + "src/extensions/external/pgtap/targets/native.toml", + "src/extensions/external/pgtap/targets/wasix.toml", + "src/extensions/external/pgtap/tests/smoke.sql", + "src/extensions/external/pgtap/tests/upstream.toml", + "src/extensions/external/postgis/CHANGELOG.md", + "src/extensions/external/postgis/VERSION", + "src/extensions/external/postgis/blockers.toml", + "src/extensions/external/postgis/deps.toml", + "src/extensions/external/postgis/moon.yml", + "src/extensions/external/postgis/patches/README.md", + "src/extensions/external/postgis/recipe.toml", + "src/extensions/external/postgis/release.toml", + "src/extensions/external/postgis/targets/artifacts.toml", + "src/extensions/external/postgis/targets/native-static-registry.toml", + "src/extensions/external/postgis/targets/native.toml", + "src/extensions/external/postgis/targets/wasix.toml", + "src/extensions/external/postgis/tests/regression.sql", + "src/extensions/external/postgis/tests/smoke.sql", + "src/extensions/external/postgis/tests/upstream.toml", + "src/extensions/external/postgis/tools/build_wasix.sh", + "src/extensions/external/vector/CHANGELOG.md", + "src/extensions/external/vector/VERSION", + "src/extensions/external/vector/moon.yml", + "src/extensions/external/vector/release.toml", + "src/extensions/external/vector/targets/artifacts.toml" + ] +} diff --git a/src/extensions/generated/docs/extensions.json b/src/extensions/generated/docs/extensions.json new file mode 100644 index 00000000..c39d7de5 --- /dev/null +++ b/src/extensions/generated/docs/extensions.json @@ -0,0 +1,1358 @@ +{ + "extensions": [ + { + "activation": "CREATE EXTENSION + LOAD", + "archive": "extensions/age.tar.zst", + "blocker": "Apache AGE does not currently build against PostgreSQL 18.4 in the WASIX lane; ag_label.c still calls ExecInitExtraTupleSlot, which is not available in PG18. Keep graph/pgGraph out of release artifacts until there is an official PG18-compatible upstream.", + "dependencies": [], + "desktop-release-ready": false, + "display-name": "Apache AGE", + "family": "External PGXS", + "id": "age", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": false, + "preload-required": false, + "promoted": false, + "public": false, + "restart-required": false, + "smoke": { + "direct": "not-run", + "dump-restore": "not-run", + "restart": "not-run", + "server": "not-run" + }, + "sql-name": "age", + "stable": false, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.7.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/amcheck.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "family": "PostgreSQL contrib", + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.4" + }, + { + "activation": "LOAD", + "archive": "extensions/auto_explain.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "family": "PostgreSQL contrib", + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/bloom.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "family": "PostgreSQL contrib", + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/btree_gin.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "family": "PostgreSQL contrib", + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.3" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/btree_gist.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "family": "PostgreSQL contrib", + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.7" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/citext.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "family": "PostgreSQL contrib", + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.6" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/cube.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "family": "PostgreSQL contrib", + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.5" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/dict_int.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "family": "PostgreSQL contrib", + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/dict_xsyn.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "family": "PostgreSQL contrib", + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/earthdistance.tar.zst", + "blocker": "", + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "family": "PostgreSQL contrib", + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.2" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/file_fdw.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "family": "PostgreSQL contrib", + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/fuzzystrmatch.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "family": "PostgreSQL contrib", + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.2" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/hstore.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "family": "PostgreSQL contrib", + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.8" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/intarray.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "family": "PostgreSQL contrib", + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.5" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/isn.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "family": "PostgreSQL contrib", + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.2" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/lo.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "family": "PostgreSQL contrib", + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.1" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/ltree.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "family": "PostgreSQL contrib", + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.3" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pageinspect.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "family": "PostgreSQL contrib", + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.12" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_buffercache.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "family": "PostgreSQL contrib", + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.5" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_freespacemap.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "family": "PostgreSQL contrib", + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.2" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_hashids.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "family": "External PGXS", + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.3" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_ivm.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "family": "External PGXS", + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.13" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_surgery.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "family": "PostgreSQL contrib", + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_textsearch.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "family": "External PGXS", + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "0.5.1" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_trgm.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "family": "PostgreSQL contrib", + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.6" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_uuidv7.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "family": "External PGXS", + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.7" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_visibility.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "family": "PostgreSQL contrib", + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.2" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pg_walinspect.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "family": "PostgreSQL contrib", + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.1" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pgcrypto.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "family": "PostgreSQL contrib", + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.3" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/pgtap.tar.zst", + "blocker": "", + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "family": "External PGXS", + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + }, + "version": "1.3.5" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/postgis.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "family": "Complex external", + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + }, + "version": "" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/seg.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "family": "PostgreSQL contrib", + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.4" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/tablefunc.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "family": "PostgreSQL contrib", + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/tcn.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "family": "PostgreSQL contrib", + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/tsm_system_rows.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "family": "PostgreSQL contrib", + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/tsm_system_time.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "family": "PostgreSQL contrib", + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.0" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/unaccent.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "family": "PostgreSQL contrib", + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.1" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/uuid-ossp.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "family": "PostgreSQL contrib", + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "1.1" + }, + { + "activation": "CREATE EXTENSION", + "archive": "extensions/vector.tar.zst", + "blocker": "", + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "family": "External PGXS", + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "packaged": true, + "preload-required": false, + "promoted": true, + "public": true, + "restart-required": false, + "smoke": { + "direct": "passed", + "dump-restore": "passed", + "restart": "passed", + "server": "passed" + }, + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + }, + "version": "0.8.2" + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-build-plan", + "path": "src/extensions/generated/extensions.build-plan.json" + }, + { + "name": "promotion-config", + "path": "src/extensions/catalog/extensions.promoted.toml" + }, + { + "name": "smoke-evidence", + "path": "src/extensions/catalog/extensions.smoke.toml" + } + ] +} diff --git a/assets/generated/extensions.build-plan.json b/src/extensions/generated/extensions.build-plan.json similarity index 67% rename from assets/generated/extensions.build-plan.json rename to src/extensions/generated/extensions.build-plan.json index e72df8b5..0d67dbdc 100644 --- a/assets/generated/extensions.build-plan.json +++ b/src/extensions/generated/extensions.build-plan.json @@ -3,70 +3,29 @@ "generated-from": [ { "name": "extension-catalog", - "path": "assets/generated/extensions.catalog.json" + "path": "src/extensions/generated/extensions.catalog.json" }, { "name": "promotion-config", - "path": "assets/extensions.promoted.toml" + "path": "src/extensions/catalog/extensions.promoted.toml" }, { "name": "asset-manifest-evidence", - "path": "target/pglite-oxide/assets/manifest.json" + "path": "target/oliphaunt-wasix/assets/manifest.json" } ], "extensions": [ - { - "id": "age", - "sql-name": "age", - "display-name": "Apache AGE", - "source-kind": "pglite-other-extension", - "build-kind": "pgxs-external", - "source-dir": "assets/checkouts/age", - "make-args": [ - "SIZEOF_DATUM=4" - ], - "module-file": "age.so", - "archive": "extensions/age.tar.zst", - "control-file": "assets/checkouts/age/age.control", - "stable": true, - "dependencies": [], - "native-dependencies": [], - "load-order": [], - "lifecycle": { - "create-extension": true, - "create-schema": "ag_catalog", - "load-sql": [ - "LOAD 'age';" - ], - "post-create-sql": [ - "SET search_path = ag_catalog, \"$user\", public;" - ], - "startup-config": [], - "preload-required": false, - "restart-required": false, - "shared-memory-required": false - }, - "smoke": { - "direct": "passed", - "server": "passed", - "restart": "passed", - "dump-restore": "not-run" - }, - "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/age.test.ts" - ] - }, { "id": "amcheck", "sql-name": "amcheck", "display-name": "amcheck", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/amcheck", + "source-dir": "src/postgres/versions/18/contrib/amcheck", "contrib-dir": "amcheck", "module-file": "amcheck.so", "archive": "extensions/amcheck.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/amcheck/amcheck.control", + "control-file": "src/postgres/versions/18/contrib/amcheck/amcheck.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -85,10 +44,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/amcheck.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/amcheck.test.js" ] }, { @@ -97,7 +56,7 @@ "display-name": "auto_explain", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/auto_explain", + "source-dir": "src/postgres/versions/18/contrib/auto_explain", "contrib-dir": "auto_explain", "module-file": "auto_explain.so", "archive": "extensions/auto_explain.tar.zst", @@ -123,10 +82,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/auto_explain.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/auto_explain.test.js" ] }, { @@ -135,11 +94,11 @@ "display-name": "bloom", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/bloom", + "source-dir": "src/postgres/versions/18/contrib/bloom", "contrib-dir": "bloom", "module-file": "bloom.so", "archive": "extensions/bloom.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/bloom/bloom.control", + "control-file": "src/postgres/versions/18/contrib/bloom/bloom.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -158,10 +117,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/bloom.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/bloom.test.js" ] }, { @@ -170,11 +129,11 @@ "display-name": "btree_gin", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/btree_gin", + "source-dir": "src/postgres/versions/18/contrib/btree_gin", "contrib-dir": "btree_gin", "module-file": "btree_gin.so", "archive": "extensions/btree_gin.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/btree_gin/btree_gin.control", + "control-file": "src/postgres/versions/18/contrib/btree_gin/btree_gin.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -193,10 +152,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/btree_gin.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/btree_gin.test.js" ] }, { @@ -205,11 +164,11 @@ "display-name": "btree_gist", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/btree_gist", + "source-dir": "src/postgres/versions/18/contrib/btree_gist", "contrib-dir": "btree_gist", "module-file": "btree_gist.so", "archive": "extensions/btree_gist.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/btree_gist/btree_gist.control", + "control-file": "src/postgres/versions/18/contrib/btree_gist/btree_gist.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -228,10 +187,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/btree_gist.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/btree_gist.test.js" ] }, { @@ -240,11 +199,11 @@ "display-name": "citext", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/citext", + "source-dir": "src/postgres/versions/18/contrib/citext", "contrib-dir": "citext", "module-file": "citext.so", "archive": "extensions/citext.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/citext/citext.control", + "control-file": "src/postgres/versions/18/contrib/citext/citext.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -263,10 +222,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/citext.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/citext.test.js" ] }, { @@ -275,11 +234,11 @@ "display-name": "cube", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/cube", + "source-dir": "src/postgres/versions/18/contrib/cube", "contrib-dir": "cube", "module-file": "cube.so", "archive": "extensions/cube.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/cube/cube.control", + "control-file": "src/postgres/versions/18/contrib/cube/cube.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -298,10 +257,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/cube.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/cube.test.js" ] }, { @@ -310,11 +269,11 @@ "display-name": "dict_int", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/dict_int", + "source-dir": "src/postgres/versions/18/contrib/dict_int", "contrib-dir": "dict_int", "module-file": "dict_int.so", "archive": "extensions/dict_int.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/dict_int/dict_int.control", + "control-file": "src/postgres/versions/18/contrib/dict_int/dict_int.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -333,10 +292,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/dict_int.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/dict_int.test.js" ] }, { @@ -345,11 +304,11 @@ "display-name": "dict_xsyn", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/dict_xsyn", + "source-dir": "src/postgres/versions/18/contrib/dict_xsyn", "contrib-dir": "dict_xsyn", "module-file": "dict_xsyn.so", "archive": "extensions/dict_xsyn.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/dict_xsyn/dict_xsyn.control", + "control-file": "src/postgres/versions/18/contrib/dict_xsyn/dict_xsyn.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -368,10 +327,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/dict_xsyn.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/dict_xsyn.test.ts" ] }, { @@ -380,11 +339,11 @@ "display-name": "earthdistance", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/earthdistance", + "source-dir": "src/postgres/versions/18/contrib/earthdistance", "contrib-dir": "earthdistance", "module-file": "earthdistance.so", "archive": "extensions/earthdistance.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/earthdistance/earthdistance.control", + "control-file": "src/postgres/versions/18/contrib/earthdistance/earthdistance.control", "stable": true, "dependencies": [ "cube" @@ -405,10 +364,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/earthdistance.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/earthdistance.test.js" ] }, { @@ -417,11 +376,11 @@ "display-name": "file_fdw", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/file_fdw", + "source-dir": "src/postgres/versions/18/contrib/file_fdw", "contrib-dir": "file_fdw", "module-file": "file_fdw.so", "archive": "extensions/file_fdw.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/file_fdw/file_fdw.control", + "control-file": "src/postgres/versions/18/contrib/file_fdw/file_fdw.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -440,10 +399,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/file_fdw.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/file_fdw.test.js" ] }, { @@ -452,11 +411,11 @@ "display-name": "fuzzystrmatch", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/fuzzystrmatch", + "source-dir": "src/postgres/versions/18/contrib/fuzzystrmatch", "contrib-dir": "fuzzystrmatch", "module-file": "fuzzystrmatch.so", "archive": "extensions/fuzzystrmatch.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/fuzzystrmatch/fuzzystrmatch.control", + "control-file": "src/postgres/versions/18/contrib/fuzzystrmatch/fuzzystrmatch.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -475,10 +434,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/fuzzystrmatch.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/fuzzystrmatch.test.js" ] }, { @@ -487,11 +446,11 @@ "display-name": "hstore", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/hstore", + "source-dir": "src/postgres/versions/18/contrib/hstore", "contrib-dir": "hstore", "module-file": "hstore.so", "archive": "extensions/hstore.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/hstore/hstore.control", + "control-file": "src/postgres/versions/18/contrib/hstore/hstore.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -510,10 +469,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/hstore.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/hstore.test.js" ] }, { @@ -522,11 +481,11 @@ "display-name": "intarray", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/intarray", + "source-dir": "src/postgres/versions/18/contrib/intarray", "contrib-dir": "intarray", "module-file": "_int.so", "archive": "extensions/intarray.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/intarray/intarray.control", + "control-file": "src/postgres/versions/18/contrib/intarray/intarray.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -545,10 +504,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/intarray.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/intarray.test.js" ] }, { @@ -557,11 +516,11 @@ "display-name": "isn", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/isn", + "source-dir": "src/postgres/versions/18/contrib/isn", "contrib-dir": "isn", "module-file": "isn.so", "archive": "extensions/isn.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/isn/isn.control", + "control-file": "src/postgres/versions/18/contrib/isn/isn.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -580,10 +539,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/isn.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/isn.test.js" ] }, { @@ -592,11 +551,11 @@ "display-name": "lo", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/lo", + "source-dir": "src/postgres/versions/18/contrib/lo", "contrib-dir": "lo", "module-file": "lo.so", "archive": "extensions/lo.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/lo/lo.control", + "control-file": "src/postgres/versions/18/contrib/lo/lo.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -615,10 +574,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/lo.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/lo.test.js" ] }, { @@ -627,11 +586,11 @@ "display-name": "ltree", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/ltree", + "source-dir": "src/postgres/versions/18/contrib/ltree", "contrib-dir": "ltree", "module-file": "ltree.so", "archive": "extensions/ltree.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/ltree/ltree.control", + "control-file": "src/postgres/versions/18/contrib/ltree/ltree.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -650,10 +609,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/ltree.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/ltree.test.js" ] }, { @@ -662,11 +621,11 @@ "display-name": "pageinspect", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/pageinspect", + "source-dir": "src/postgres/versions/18/contrib/pageinspect", "contrib-dir": "pageinspect", "module-file": "pageinspect.so", "archive": "extensions/pageinspect.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/pageinspect/pageinspect.control", + "control-file": "src/postgres/versions/18/contrib/pageinspect/pageinspect.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -685,10 +644,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pageinspect.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pageinspect.test.js" ] }, { @@ -697,11 +656,11 @@ "display-name": "pg_buffercache", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/pg_buffercache", + "source-dir": "src/postgres/versions/18/contrib/pg_buffercache", "contrib-dir": "pg_buffercache", "module-file": "pg_buffercache.so", "archive": "extensions/pg_buffercache.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_buffercache/pg_buffercache.control", + "control-file": "src/postgres/versions/18/contrib/pg_buffercache/pg_buffercache.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -720,10 +679,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_buffercache.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_buffercache.test.js" ] }, { @@ -732,11 +691,11 @@ "display-name": "pg_freespacemap", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/pg_freespacemap", + "source-dir": "src/postgres/versions/18/contrib/pg_freespacemap", "contrib-dir": "pg_freespacemap", "module-file": "pg_freespacemap.so", "archive": "extensions/pg_freespacemap.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_freespacemap/pg_freespacemap.control", + "control-file": "src/postgres/versions/18/contrib/pg_freespacemap/pg_freespacemap.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -755,22 +714,22 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_freespacemap.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_freespacemap.test.ts" ] }, { "id": "pg_hashids", "sql-name": "pg_hashids", "display-name": "pg_hashids", - "source-kind": "pglite-other-extension", + "source-kind": "oliphaunt-other-extension", "build-kind": "pgxs-external", - "source-dir": "assets/checkouts/pg_hashids", + "source-dir": "target/oliphaunt-sources/checkouts/pg_hashids", "module-file": "pg_hashids.so", "archive": "extensions/pg_hashids.tar.zst", - "control-file": "assets/checkouts/pg_hashids/pg_hashids.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_hashids/pg_hashids.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -789,22 +748,22 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_hashids.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_hashids.test.ts" ] }, { "id": "pg_ivm", "sql-name": "pg_ivm", "display-name": "pg_ivm", - "source-kind": "pglite-other-extension", + "source-kind": "oliphaunt-other-extension", "build-kind": "pgxs-external", - "source-dir": "assets/checkouts/pg_ivm", + "source-dir": "target/oliphaunt-sources/checkouts/pg_ivm", "module-file": "pg_ivm.so", "archive": "extensions/pg_ivm.tar.zst", - "control-file": "assets/checkouts/pg_ivm/pg_ivm.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_ivm/pg_ivm.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -823,10 +782,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_ivm.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_ivm.test.ts" ] }, { @@ -835,11 +794,11 @@ "display-name": "pg_surgery", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/pg_surgery", + "source-dir": "src/postgres/versions/18/contrib/pg_surgery", "contrib-dir": "pg_surgery", "module-file": "pg_surgery.so", "archive": "extensions/pg_surgery.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_surgery/pg_surgery.control", + "control-file": "src/postgres/versions/18/contrib/pg_surgery/pg_surgery.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -858,22 +817,22 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_surgery.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_surgery.test.js" ] }, { "id": "pg_textsearch", "sql-name": "pg_textsearch", "display-name": "pg_textsearch", - "source-kind": "pglite-other-extension", + "source-kind": "oliphaunt-other-extension", "build-kind": "pgxs-external", - "source-dir": "assets/checkouts/pg_textsearch", + "source-dir": "target/oliphaunt-sources/checkouts/pg_textsearch", "module-file": "pg_textsearch.so", "archive": "extensions/pg_textsearch.tar.zst", - "control-file": "assets/checkouts/pg_textsearch/pg_textsearch.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_textsearch/pg_textsearch.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -892,10 +851,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_textsearch.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_textsearch.test.ts" ] }, { @@ -904,11 +863,11 @@ "display-name": "pg_trgm", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/pg_trgm", + "source-dir": "src/postgres/versions/18/contrib/pg_trgm", "contrib-dir": "pg_trgm", "module-file": "pg_trgm.so", "archive": "extensions/pg_trgm.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_trgm/pg_trgm.control", + "control-file": "src/postgres/versions/18/contrib/pg_trgm/pg_trgm.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -927,22 +886,22 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_trgm.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_trgm.test.js" ] }, { "id": "pg_uuidv7", "sql-name": "pg_uuidv7", "display-name": "pg_uuidv7", - "source-kind": "pglite-other-extension", + "source-kind": "oliphaunt-other-extension", "build-kind": "pgxs-external", - "source-dir": "assets/checkouts/pg_uuidv7", + "source-dir": "target/oliphaunt-sources/checkouts/pg_uuidv7", "module-file": "pg_uuidv7.so", "archive": "extensions/pg_uuidv7.tar.zst", - "control-file": "assets/checkouts/pg_uuidv7/pg_uuidv7.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_uuidv7/pg_uuidv7.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -961,10 +920,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_uuidv7.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_uuidv7.test.ts" ] }, { @@ -973,11 +932,11 @@ "display-name": "pg_visibility", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/pg_visibility", + "source-dir": "src/postgres/versions/18/contrib/pg_visibility", "contrib-dir": "pg_visibility", "module-file": "pg_visibility.so", "archive": "extensions/pg_visibility.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_visibility/pg_visibility.control", + "control-file": "src/postgres/versions/18/contrib/pg_visibility/pg_visibility.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -996,10 +955,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_visibility.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_visibility.test.js" ] }, { @@ -1008,11 +967,11 @@ "display-name": "pg_walinspect", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/pg_walinspect", + "source-dir": "src/postgres/versions/18/contrib/pg_walinspect", "contrib-dir": "pg_walinspect", "module-file": "pg_walinspect.so", "archive": "extensions/pg_walinspect.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_walinspect/pg_walinspect.control", + "control-file": "src/postgres/versions/18/contrib/pg_walinspect/pg_walinspect.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1031,21 +990,57 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_walinspect.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_walinspect.test.js" + ] + }, + { + "id": "pgcrypto", + "sql-name": "pgcrypto", + "display-name": "pgcrypto", + "source-kind": "postgres-contrib", + "build-kind": "postgres-contrib", + "source-dir": "src/postgres/versions/18/contrib/pgcrypto", + "contrib-dir": "pgcrypto", + "module-file": "pgcrypto.so", + "archive": "extensions/pgcrypto.tar.zst", + "control-file": "src/postgres/versions/18/contrib/pgcrypto/pgcrypto.control", + "stable": true, + "dependencies": [], + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "load-order": [], + "lifecycle": { + "create-extension": true, + "load-sql": [], + "post-create-sql": [], + "startup-config": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false + }, + "smoke": { + "direct": "passed", + "server": "passed", + "restart": "passed", + "dump-restore": "passed" + }, + "tests": [ + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pgcrypto.test.js" ] }, { "id": "pgtap", "sql-name": "pgtap", "display-name": "pgtap", - "source-kind": "pglite-other-extension", - "build-kind": "pgxs-external", - "source-dir": "assets/checkouts/pgtap", + "source-kind": "oliphaunt-other-extension", + "build-kind": "pgxs-sql-only", + "source-dir": "target/oliphaunt-sources/checkouts/pgtap", "archive": "extensions/pgtap.tar.zst", - "control-file": "assets/checkouts/pgtap/pgtap.control", + "control-file": "target/oliphaunt-sources/checkouts/pgtap/pgtap.control", "stable": true, "dependencies": [ "plpgsql" @@ -1066,10 +1061,89 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pgtap.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pgtap.test.ts" + ] + }, + { + "id": "postgis", + "sql-name": "postgis", + "display-name": "PostGIS", + "source-kind": "postgis", + "build-kind": "autotools", + "build-script": "src/extensions/external/postgis/tools/build_wasix.sh", + "required-build-files": [ + "postgis/postgis-3.so", + "postgis/liboliphaunt_postgis_deps.so", + "extensions/postgis/postgis.control", + "share/proj/proj.db" + ], + "required-build-globs": [ + "extensions/postgis/sql/postgis--*.sql" + ], + "source-dir": "target/oliphaunt-sources/checkouts/postgis", + "module-file": "postgis-3.so", + "archive": "extensions/postgis.tar.zst", + "control-file": "target/oliphaunt-sources/checkouts/postgis/extensions/postgis/postgis.control.in", + "stable": true, + "dependencies": [], + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-support-modules": [ + { + "name": "postgis_deps", + "runtime-path": "lib/postgresql/liboliphaunt_postgis_deps.so", + "build-path": "postgis/postgis/liboliphaunt_postgis_deps.so", + "aot-file": "postgis_deps-llvm-opta.bin.zst" + } + ], + "excluded-sql-extensions": [ + "address_standardizer", + "address_standardizer_data_us", + "postgis_raster", + "postgis_sfcgal", + "postgis_tiger_geocoder", + "postgis_topology" + ], + "staging": { + "module-source-dir": "postgis/postgis", + "control-source": "postgis/extensions/postgis/postgis.control", + "sql-source-dir": "postgis/extensions/postgis/sql", + "data-dirs": [ + { + "source": "postgis/share/proj", + "destination": "share/proj" + } + ] + }, + "load-order": [ + "lib/postgresql/postgis-3.so" + ], + "lifecycle": { + "create-extension": true, + "load-sql": [], + "post-create-sql": [], + "startup-config": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false + }, + "smoke": { + "direct": "passed", + "server": "passed", + "restart": "passed", + "dump-restore": "passed" + }, + "tests": [ + "target/oliphaunt-sources/checkouts/src/extensions/tests/postgis/postgis.test.ts" ] }, { @@ -1078,11 +1152,11 @@ "display-name": "seg", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/seg", + "source-dir": "src/postgres/versions/18/contrib/seg", "contrib-dir": "seg", "module-file": "seg.so", "archive": "extensions/seg.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/seg/seg.control", + "control-file": "src/postgres/versions/18/contrib/seg/seg.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1101,10 +1175,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/seg.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/seg.test.js" ] }, { @@ -1113,11 +1187,11 @@ "display-name": "tablefunc", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/tablefunc", + "source-dir": "src/postgres/versions/18/contrib/tablefunc", "contrib-dir": "tablefunc", "module-file": "tablefunc.so", "archive": "extensions/tablefunc.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/tablefunc/tablefunc.control", + "control-file": "src/postgres/versions/18/contrib/tablefunc/tablefunc.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1136,10 +1210,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tablefunc.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tablefunc.test.js" ] }, { @@ -1148,11 +1222,11 @@ "display-name": "tcn", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/tcn", + "source-dir": "src/postgres/versions/18/contrib/tcn", "contrib-dir": "tcn", "module-file": "tcn.so", "archive": "extensions/tcn.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/tcn/tcn.control", + "control-file": "src/postgres/versions/18/contrib/tcn/tcn.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1171,10 +1245,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tcn.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tcn.test.js" ] }, { @@ -1183,11 +1257,11 @@ "display-name": "tsm_system_rows", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/tsm_system_rows", + "source-dir": "src/postgres/versions/18/contrib/tsm_system_rows", "contrib-dir": "tsm_system_rows", "module-file": "tsm_system_rows.so", "archive": "extensions/tsm_system_rows.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/tsm_system_rows/tsm_system_rows.control", + "control-file": "src/postgres/versions/18/contrib/tsm_system_rows/tsm_system_rows.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1206,10 +1280,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tsm_system_rows.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tsm_system_rows.test.js" ] }, { @@ -1218,11 +1292,11 @@ "display-name": "tsm_system_time", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/tsm_system_time", + "source-dir": "src/postgres/versions/18/contrib/tsm_system_time", "contrib-dir": "tsm_system_time", "module-file": "tsm_system_time.so", "archive": "extensions/tsm_system_time.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/tsm_system_time/tsm_system_time.control", + "control-file": "src/postgres/versions/18/contrib/tsm_system_time/tsm_system_time.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1241,10 +1315,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tsm_system_time.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tsm_system_time.test.js" ] }, { @@ -1253,11 +1327,46 @@ "display-name": "unaccent", "source-kind": "postgres-contrib", "build-kind": "postgres-contrib", - "source-dir": "assets/checkouts/postgres-pglite/contrib/unaccent", + "source-dir": "src/postgres/versions/18/contrib/unaccent", "contrib-dir": "unaccent", "module-file": "unaccent.so", "archive": "extensions/unaccent.tar.zst", - "control-file": "assets/checkouts/postgres-pglite/contrib/unaccent/unaccent.control", + "control-file": "src/postgres/versions/18/contrib/unaccent/unaccent.control", + "stable": true, + "dependencies": [], + "native-dependencies": [], + "load-order": [], + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "startup-config": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false + }, + "smoke": { + "direct": "passed", + "server": "passed", + "restart": "passed", + "dump-restore": "passed" + }, + "tests": [ + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/unaccent.test.js" + ] + }, + { + "id": "uuid_ossp", + "sql-name": "uuid-ossp", + "display-name": "uuid-ossp", + "source-kind": "postgres-contrib", + "build-kind": "postgres-contrib", + "source-dir": "src/postgres/versions/18/contrib/uuid-ossp", + "contrib-dir": "uuid-ossp", + "module-file": "uuid-ossp.so", + "archive": "extensions/uuid-ossp.tar.zst", + "control-file": "src/postgres/versions/18/contrib/uuid-ossp/uuid-ossp.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1276,22 +1385,22 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/unaccent.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/uuid_ossp.test.js" ] }, { "id": "vector", "sql-name": "vector", "display-name": "pgvector", - "source-kind": "pglite-other-extension", + "source-kind": "oliphaunt-other-extension", "build-kind": "pgxs-external", - "source-dir": "assets/checkouts/pgvector", + "source-dir": "target/oliphaunt-sources/checkouts/pgvector", "module-file": "vector.so", "archive": "extensions/vector.tar.zst", - "control-file": "assets/checkouts/pgvector/vector.control", + "control-file": "target/oliphaunt-sources/checkouts/pgvector/vector.control", "stable": true, "dependencies": [], "native-dependencies": [], @@ -1313,7 +1422,7 @@ "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pgvector.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pgvector.test.ts" ] } ] diff --git a/assets/generated/extensions.catalog.json b/src/extensions/generated/extensions.catalog.json similarity index 72% rename from assets/generated/extensions.catalog.json rename to src/extensions/generated/extensions.catalog.json index 7f19ee16..2b8c256a 100644 --- a/assets/generated/extensions.catalog.json +++ b/src/extensions/generated/extensions.catalog.json @@ -2,40 +2,32 @@ "format-version": 1, "generated-from": [ { - "name": "pglite-repl-exports", - "path": "assets/checkouts/pglite/docs/repl/allExtensions.ts" + "name": "postgres18-source", + "path": "src/postgres/versions/18/source.toml" }, { - "name": "pglite-docs-catalog", - "path": "assets/checkouts/pglite/docs/extensions/extensions.data.ts" - }, - { - "name": "pglite-package-exports", - "path": "assets/checkouts/pglite/packages/pglite/package.json" - }, - { - "name": "pglite-contrib-modules", - "path": "assets/checkouts/pglite/packages/pglite/src/contrib" + "name": "extension-catalog", + "path": "src/extensions/catalog" }, { "name": "postgres-contrib", - "path": "assets/checkouts/postgres-pglite/contrib" + "path": "src/postgres/versions/18/contrib" }, { - "name": "postgres-pglite-other-extensions", - "path": "assets/checkouts/postgres-pglite/pglite/other_extensions" + "name": "external-extension-recipes", + "path": "src/extensions/external" }, { "name": "extension-promotion-config", - "path": "assets/extensions.promoted.toml" + "path": "src/extensions/catalog/extensions.promoted.toml" }, { "name": "extension-smoke-evidence", - "path": "assets/extensions.smoke.toml" + "path": "src/extensions/catalog/extensions.smoke.toml" }, { "name": "asset-manifest-evidence", - "path": "target/pglite-oxide/assets/manifest.json" + "path": "target/oliphaunt-wasix/assets/manifest.json" } ], "extensions": [ @@ -44,15 +36,14 @@ "sql-name": "age", "rust-constant": "AGE", "display-name": "Apache AGE", - "source-kind": "pglite-other-extension", - "pglite-import-name": "age", - "pglite-import-path": "@electric-sql/pglite/age", + "source-kind": "oliphaunt-other-extension", + "upstream-import-name": "age", "package-export": "./age", "tags": [ "postgres extension" ], "bundle-size": 141551, - "control-file": "assets/checkouts/age/age.control", + "control-file": "target/oliphaunt-sources/checkouts/age/age.control", "control": { "default-version": "1.7.0", "module-pathname": "$libdir/age", @@ -77,26 +68,26 @@ "shared-memory-required": false }, "smoke": { - "direct": "passed", - "server": "passed", - "restart": "passed", + "direct": "not-run", + "server": "not-run", + "restart": "not-run", "dump-restore": "not-run" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/age.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/age.test.ts" ], "native-module-file": "age.so", "promotion": { "configured": true, - "requested": true, - "packaged": true, - "promoted": true, - "stable": true, + "requested": false, + "packaged": false, + "promoted": false, + "stable": false, "archive": "extensions/age.tar.zst", - "module-sha256": "db961a6cd82c7712c1270c4fa6ccb2dccdb24d83b6a3d2781af2d62e8685de0a" + "blocker": "Apache AGE does not currently build against PostgreSQL 18.4 in the WASIX lane; ag_label.c still calls ExecInitExtraTupleSlot, which is not available in PG18. Keep graph/pgGraph out of release artifacts until there is an official PG18-compatible upstream." }, "notes": [ - "postgres-pglite submodule https://github.com/apache/age.git pinned at e1467f12e0b1d15dd35d3ab93f057a7112d425b8" + "promotion blocker: Apache AGE does not currently build against PostgreSQL 18.4 in the WASIX lane; ag_label.c still calls ExecInitExtraTupleSlot, which is not available in PG18. Keep graph/pgGraph out of release artifacts until there is an official PG18-compatible upstream." ] }, { @@ -105,15 +96,14 @@ "rust-constant": "AMCHECK", "display-name": "amcheck", "source-kind": "postgres-contrib", - "pglite-import-name": "amcheck", - "pglite-import-path": "@electric-sql/pglite/contrib/amcheck", + "upstream-import-name": "amcheck", "package-export": "./contrib/amcheck", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 18815, - "control-file": "assets/checkouts/postgres-pglite/contrib/amcheck/amcheck.control", + "control-file": "src/postgres/versions/18/contrib/amcheck/amcheck.control", "control": { "default-version": "1.4", "module-pathname": "$libdir/amcheck", @@ -137,10 +127,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/amcheck.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/amcheck.test.js" ], "native-module-file": "amcheck.so", "promotion": { @@ -150,7 +140,7 @@ "promoted": true, "stable": true, "archive": "extensions/amcheck.tar.zst", - "module-sha256": "46f8569941bd68b3660bf0239017c5818f13d0d4b6109b25ef701a6886a17d45" + "module-sha256": "bf278ec30e1bb1abee705a74bed8d73df00ef0f93f0423dc36631fb5dc3ac283" }, "notes": [] }, @@ -160,8 +150,7 @@ "rust-constant": "AUTO_EXPLAIN", "display-name": "auto_explain", "source-kind": "postgres-contrib", - "pglite-import-name": "auto_explain", - "pglite-import-path": "@electric-sql/pglite/contrib/auto_explain", + "upstream-import-name": "auto_explain", "package-export": "./contrib/auto_explain", "tags": [ "postgres extension", @@ -189,10 +178,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/auto_explain.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/auto_explain.test.js" ], "native-module-file": "auto_explain.so", "promotion": { @@ -202,7 +191,7 @@ "promoted": true, "stable": true, "archive": "extensions/auto_explain.tar.zst", - "module-sha256": "cd4e51ac361ee24a9e7ce798d44be00994db625deccda7f510d57b486e072354" + "module-sha256": "7753c981916345ff17fe1af012c25f1b669a09e2bef0707fd5b9186ebf1c3c63" }, "notes": [] }, @@ -212,15 +201,14 @@ "rust-constant": "BLOOM", "display-name": "bloom", "source-kind": "postgres-contrib", - "pglite-import-name": "bloom", - "pglite-import-path": "@electric-sql/pglite/contrib/bloom", + "upstream-import-name": "bloom", "package-export": "./contrib/bloom", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 6197, - "control-file": "assets/checkouts/postgres-pglite/contrib/bloom/bloom.control", + "control-file": "src/postgres/versions/18/contrib/bloom/bloom.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/bloom", @@ -244,10 +232,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/bloom.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/bloom.test.js" ], "native-module-file": "bloom.so", "promotion": { @@ -257,7 +245,7 @@ "promoted": true, "stable": true, "archive": "extensions/bloom.tar.zst", - "module-sha256": "62b098457eca4dc5559adda558e090379949948a179b25840931d86dcd0f0461" + "module-sha256": "c579d5cf88563ede721ccea6c1fed87f8d6f768406e94527e4f45da9592f75d2" }, "notes": [] }, @@ -267,15 +255,14 @@ "rust-constant": "BTREE_GIN", "display-name": "btree_gin", "source-kind": "postgres-contrib", - "pglite-import-name": "btree_gin", - "pglite-import-path": "@electric-sql/pglite/contrib/btree_gin", + "upstream-import-name": "btree_gin", "package-export": "./contrib/btree_gin", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 6347, - "control-file": "assets/checkouts/postgres-pglite/contrib/btree_gin/btree_gin.control", + "control-file": "src/postgres/versions/18/contrib/btree_gin/btree_gin.control", "control": { "default-version": "1.3", "module-pathname": "$libdir/btree_gin", @@ -299,10 +286,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/btree_gin.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/btree_gin.test.js" ], "native-module-file": "btree_gin.so", "promotion": { @@ -312,7 +299,7 @@ "promoted": true, "stable": true, "archive": "extensions/btree_gin.tar.zst", - "module-sha256": "3ef71d9930b3bba882c67a8981d45a72c91d7ac547cc476ffef9539bc30cb678" + "module-sha256": "5a968545bfaee911c613c942334ea2a3a33d35cff9c4b36b3f7c2f328680c0d1" }, "notes": [] }, @@ -322,15 +309,14 @@ "rust-constant": "BTREE_GIST", "display-name": "btree_gist", "source-kind": "postgres-contrib", - "pglite-import-name": "btree_gist", - "pglite-import-path": "@electric-sql/pglite/contrib/btree_gist", + "upstream-import-name": "btree_gist", "package-export": "./contrib/btree_gist", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 24181, - "control-file": "assets/checkouts/postgres-pglite/contrib/btree_gist/btree_gist.control", + "control-file": "src/postgres/versions/18/contrib/btree_gist/btree_gist.control", "control": { "default-version": "1.7", "module-pathname": "$libdir/btree_gist", @@ -354,10 +340,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/btree_gist.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/btree_gist.test.js" ], "native-module-file": "btree_gist.so", "promotion": { @@ -367,7 +353,7 @@ "promoted": true, "stable": true, "archive": "extensions/btree_gist.tar.zst", - "module-sha256": "1074bc00fef8fa8631e5b01bd1811b15cba12e203cc5fff6f58f43dbba97fd87" + "module-sha256": "76680a643f9634d57baa1b653ea31758d9bb20fd689cf2675879f2e4a87d81e5" }, "notes": [] }, @@ -377,15 +363,14 @@ "rust-constant": "CITEXT", "display-name": "citext", "source-kind": "postgres-contrib", - "pglite-import-name": "citext", - "pglite-import-path": "@electric-sql/pglite/contrib/citext", + "upstream-import-name": "citext", "package-export": "./contrib/citext", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 4983, - "control-file": "assets/checkouts/postgres-pglite/contrib/citext/citext.control", + "control-file": "src/postgres/versions/18/contrib/citext/citext.control", "control": { "default-version": "1.6", "module-pathname": "$libdir/citext", @@ -409,10 +394,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/citext.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/citext.test.js" ], "native-module-file": "citext.so", "promotion": { @@ -422,7 +407,7 @@ "promoted": true, "stable": true, "archive": "extensions/citext.tar.zst", - "module-sha256": "63f974647782a25b233ba787d7fcbab24a672234d534c5f25425878e1cdf22ec" + "module-sha256": "6153d92b5256e998a1fb86b0308e78f720727fc273b678b5886c809e96469a59" }, "notes": [] }, @@ -432,15 +417,14 @@ "rust-constant": "CUBE", "display-name": "cube", "source-kind": "postgres-contrib", - "pglite-import-name": "cube", - "pglite-import-path": "@electric-sql/pglite/contrib/cube", + "upstream-import-name": "cube", "package-export": "./contrib/cube", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 15104, - "control-file": "assets/checkouts/postgres-pglite/contrib/cube/cube.control", + "control-file": "src/postgres/versions/18/contrib/cube/cube.control", "control": { "default-version": "1.5", "module-pathname": "$libdir/cube", @@ -464,10 +448,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/cube.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/cube.test.js" ], "native-module-file": "cube.so", "promotion": { @@ -477,7 +461,7 @@ "promoted": true, "stable": true, "archive": "extensions/cube.tar.zst", - "module-sha256": "e63cd4629af7d6a94dd5c4443c828211162bfa0321b5f4c8b136e00d1fe4fe13" + "module-sha256": "23a003d739659616a7f79a4b119a24cdfd2cb55eca10779ba8e1d2a4619201c7" }, "notes": [] }, @@ -487,15 +471,14 @@ "rust-constant": "DICT_INT", "display-name": "dict_int", "source-kind": "postgres-contrib", - "pglite-import-name": "dict_int", - "pglite-import-path": "@electric-sql/pglite/contrib/dict_int", + "upstream-import-name": "dict_int", "package-export": "./contrib/dict_int", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 1361, - "control-file": "assets/checkouts/postgres-pglite/contrib/dict_int/dict_int.control", + "control-file": "src/postgres/versions/18/contrib/dict_int/dict_int.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/dict_int", @@ -519,10 +502,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/dict_int.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/dict_int.test.js" ], "native-module-file": "dict_int.so", "promotion": { @@ -532,7 +515,7 @@ "promoted": true, "stable": true, "archive": "extensions/dict_int.tar.zst", - "module-sha256": "44955defc617a97878d7ad47fb37eb2a6b4d62d6566256f3852d468afc14a009" + "module-sha256": "a7c56355a069fe86de74c225d35a080f031cc9dd01a32919431fb90d62a59da1" }, "notes": [] }, @@ -542,15 +525,14 @@ "rust-constant": "DICT_XSYN", "display-name": "dict_xsyn", "source-kind": "postgres-contrib", - "pglite-import-name": "dict_xsyn", - "pglite-import-path": "@electric-sql/pglite/contrib/dict_xsyn", + "upstream-import-name": "dict_xsyn", "package-export": "./contrib/dict_xsyn", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 1948, - "control-file": "assets/checkouts/postgres-pglite/contrib/dict_xsyn/dict_xsyn.control", + "control-file": "src/postgres/versions/18/contrib/dict_xsyn/dict_xsyn.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/dict_xsyn", @@ -574,10 +556,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/dict_xsyn.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/dict_xsyn.test.ts" ], "native-module-file": "dict_xsyn.so", "promotion": { @@ -587,7 +569,7 @@ "promoted": true, "stable": true, "archive": "extensions/dict_xsyn.tar.zst", - "module-sha256": "ed92af122102ebcf520801456bfb90231dcce50495aa59cba7a3464fceff77f9" + "module-sha256": "c7868984e329691c3135c2d1bc0c538bcdb3a04733a1f2ac08ce9f85d5bbaf5d" }, "notes": [] }, @@ -597,15 +579,14 @@ "rust-constant": "EARTHDISTANCE", "display-name": "earthdistance", "source-kind": "postgres-contrib", - "pglite-import-name": "earthdistance", - "pglite-import-path": "@electric-sql/pglite/contrib/earthdistance", + "upstream-import-name": "earthdistance", "package-export": "./contrib/earthdistance", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 2220, - "control-file": "assets/checkouts/postgres-pglite/contrib/earthdistance/earthdistance.control", + "control-file": "src/postgres/versions/18/contrib/earthdistance/earthdistance.control", "control": { "default-version": "1.2", "module-pathname": "$libdir/earthdistance", @@ -633,10 +614,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/earthdistance.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/earthdistance.test.js" ], "native-module-file": "earthdistance.so", "promotion": { @@ -646,7 +627,7 @@ "promoted": true, "stable": true, "archive": "extensions/earthdistance.tar.zst", - "module-sha256": "a2fd0268a07e14df2b8cd0624f4c37d806aad5ce0a82f40bb8622cb1500af5c4" + "module-sha256": "bc3ac31e73fc60f6d651b7b6c11ab6f33ca1f9a28943d889e11c59d3122f39f8" }, "notes": [] }, @@ -656,15 +637,14 @@ "rust-constant": "FILE_FDW", "display-name": "file_fdw", "source-kind": "postgres-contrib", - "pglite-import-name": "file_fdw", - "pglite-import-path": "@electric-sql/pglite/contrib/file_fdw", + "upstream-import-name": "file_fdw", "package-export": "./contrib/file_fdw", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 4467, - "control-file": "assets/checkouts/postgres-pglite/contrib/file_fdw/file_fdw.control", + "control-file": "src/postgres/versions/18/contrib/file_fdw/file_fdw.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/file_fdw", @@ -688,10 +668,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/file_fdw.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/file_fdw.test.js" ], "native-module-file": "file_fdw.so", "promotion": { @@ -701,7 +681,7 @@ "promoted": true, "stable": true, "archive": "extensions/file_fdw.tar.zst", - "module-sha256": "7bd1a071edb2596f389ebb8c0a486da368c7e00191b0987c536fadf061256d50" + "module-sha256": "76408a7f107282951b0068638cd8739f6e065d2b7941becbc02e505e7f37a247" }, "notes": [] }, @@ -711,15 +691,14 @@ "rust-constant": "FUZZYSTRMATCH", "display-name": "fuzzystrmatch", "source-kind": "postgres-contrib", - "pglite-import-name": "fuzzystrmatch", - "pglite-import-path": "@electric-sql/pglite/contrib/fuzzystrmatch", + "upstream-import-name": "fuzzystrmatch", "package-export": "./contrib/fuzzystrmatch", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 12026, - "control-file": "assets/checkouts/postgres-pglite/contrib/fuzzystrmatch/fuzzystrmatch.control", + "control-file": "src/postgres/versions/18/contrib/fuzzystrmatch/fuzzystrmatch.control", "control": { "default-version": "1.2", "module-pathname": "$libdir/fuzzystrmatch", @@ -743,10 +722,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/fuzzystrmatch.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/fuzzystrmatch.test.js" ], "native-module-file": "fuzzystrmatch.so", "promotion": { @@ -756,7 +735,7 @@ "promoted": true, "stable": true, "archive": "extensions/fuzzystrmatch.tar.zst", - "module-sha256": "329cbb5f46e13529987934094660b827e65552cbeaa5f2b2c0405a193ae75687" + "module-sha256": "e1efa7136ae9845f34ef6079f124d305959af74233dc51f756ce21ba8f506959" }, "notes": [] }, @@ -766,15 +745,14 @@ "rust-constant": "HSTORE", "display-name": "hstore", "source-kind": "postgres-contrib", - "pglite-import-name": "hstore", - "pglite-import-path": "@electric-sql/pglite/contrib/hstore", + "upstream-import-name": "hstore", "package-export": "./contrib/hstore", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 21380, - "control-file": "assets/checkouts/postgres-pglite/contrib/hstore/hstore.control", + "control-file": "src/postgres/versions/18/contrib/hstore/hstore.control", "control": { "default-version": "1.8", "module-pathname": "$libdir/hstore", @@ -798,10 +776,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/hstore.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/hstore.test.js" ], "native-module-file": "hstore.so", "promotion": { @@ -811,7 +789,7 @@ "promoted": true, "stable": true, "archive": "extensions/hstore.tar.zst", - "module-sha256": "51359ef4a23523ccf0cedcf26ac47e3594f6b22bedc8d6d5d9a41dce066e0ba6" + "module-sha256": "a78967a5a7c956720bc5498de4e3208de3c270df0951b6b25ff38eb3ce389fef" }, "notes": [] }, @@ -821,15 +799,14 @@ "rust-constant": "INTARRAY", "display-name": "intarray", "source-kind": "postgres-contrib", - "pglite-import-name": "intarray", - "pglite-import-path": "@electric-sql/pglite/contrib/intarray", + "upstream-import-name": "intarray", "package-export": "./contrib/intarray", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 14712, - "control-file": "assets/checkouts/postgres-pglite/contrib/intarray/intarray.control", + "control-file": "src/postgres/versions/18/contrib/intarray/intarray.control", "control": { "default-version": "1.5", "module-pathname": "$libdir/_int", @@ -853,10 +830,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/intarray.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/intarray.test.js" ], "native-module-file": "_int.so", "promotion": { @@ -866,7 +843,7 @@ "promoted": true, "stable": true, "archive": "extensions/intarray.tar.zst", - "module-sha256": "77abeef93372180e39e969a955f7c8201e34b08319ea429e7b07703970c8cc4e" + "module-sha256": "f89d422a0830d63744c505103bcdd0271fd787c3c1c44a3ea1c3960701047db1" }, "notes": [] }, @@ -876,15 +853,14 @@ "rust-constant": "ISN", "display-name": "isn", "source-kind": "postgres-contrib", - "pglite-import-name": "isn", - "pglite-import-path": "@electric-sql/pglite/contrib/isn", + "upstream-import-name": "isn", "package-export": "./contrib/isn", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 31417, - "control-file": "assets/checkouts/postgres-pglite/contrib/isn/isn.control", + "control-file": "src/postgres/versions/18/contrib/isn/isn.control", "control": { "default-version": "1.2", "module-pathname": "$libdir/isn", @@ -908,10 +884,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/isn.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/isn.test.js" ], "native-module-file": "isn.so", "promotion": { @@ -921,7 +897,7 @@ "promoted": true, "stable": true, "archive": "extensions/isn.tar.zst", - "module-sha256": "cc0e86b65c59209df4e7c12fd8b2fedfd52de08117f597c73d5d04ef989275e5" + "module-sha256": "b3533bd943d666fe2464ed7d16b00c0a6d1daa86b9d8621eab033b04e29c7b41" }, "notes": [] }, @@ -931,15 +907,14 @@ "rust-constant": "LO", "display-name": "lo", "source-kind": "postgres-contrib", - "pglite-import-name": "lo", - "pglite-import-path": "@electric-sql/pglite/contrib/lo", + "upstream-import-name": "lo", "package-export": "./contrib/lo", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 1822, - "control-file": "assets/checkouts/postgres-pglite/contrib/lo/lo.control", + "control-file": "src/postgres/versions/18/contrib/lo/lo.control", "control": { "default-version": "1.1", "module-pathname": "$libdir/lo", @@ -963,10 +938,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/lo.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/lo.test.js" ], "native-module-file": "lo.so", "promotion": { @@ -976,7 +951,7 @@ "promoted": true, "stable": true, "archive": "extensions/lo.tar.zst", - "module-sha256": "36a2862ccc365f83ad5808eaa496e79d34d006d05b353bd1121b04246ea78ce8" + "module-sha256": "b0192b73ef0615f88148ac83ec34be449c942f0d6c7550b9ebfc4bce92bcd1e3" }, "notes": [] }, @@ -986,15 +961,14 @@ "rust-constant": "LTREE", "display-name": "ltree", "source-kind": "postgres-contrib", - "pglite-import-name": "ltree", - "pglite-import-path": "@electric-sql/pglite/contrib/ltree", + "upstream-import-name": "ltree", "package-export": "./contrib/ltree", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 19553, - "control-file": "assets/checkouts/postgres-pglite/contrib/ltree/ltree.control", + "control-file": "src/postgres/versions/18/contrib/ltree/ltree.control", "control": { "default-version": "1.3", "module-pathname": "$libdir/ltree", @@ -1018,10 +992,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/ltree.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/ltree.test.js" ], "native-module-file": "ltree.so", "promotion": { @@ -1031,7 +1005,7 @@ "promoted": true, "stable": true, "archive": "extensions/ltree.tar.zst", - "module-sha256": "56507f0debc47adda14f5d35a3265f8f00f2471c023ecc1fbaffc704779c321e" + "module-sha256": "be272de20224b03b301fc65231b4bb97ebaa1815129387ce446c3aa1df6079ec" }, "notes": [] }, @@ -1041,15 +1015,14 @@ "rust-constant": "PAGEINSPECT", "display-name": "pageinspect", "source-kind": "postgres-contrib", - "pglite-import-name": "pageinspect", - "pglite-import-path": "@electric-sql/pglite/contrib/pageinspect", + "upstream-import-name": "pageinspect", "package-export": "./contrib/pageinspect", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 15923, - "control-file": "assets/checkouts/postgres-pglite/contrib/pageinspect/pageinspect.control", + "control-file": "src/postgres/versions/18/contrib/pageinspect/pageinspect.control", "control": { "default-version": "1.12", "module-pathname": "$libdir/pageinspect", @@ -1073,10 +1046,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pageinspect.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pageinspect.test.js" ], "native-module-file": "pageinspect.so", "promotion": { @@ -1086,7 +1059,7 @@ "promoted": true, "stable": true, "archive": "extensions/pageinspect.tar.zst", - "module-sha256": "9c5fc1aa2243810a123d2868c2405227d7d25d9866e273b3a6f24797115f8322" + "module-sha256": "1fef5827902d161b1d55f87b4a4df390ced16b13150d4be59b45618b29f3bf9a" }, "notes": [] }, @@ -1096,15 +1069,14 @@ "rust-constant": "PG_BUFFERCACHE", "display-name": "pg_buffercache", "source-kind": "postgres-contrib", - "pglite-import-name": "pg_buffercache", - "pglite-import-path": "@electric-sql/pglite/contrib/pg_buffercache", + "upstream-import-name": "pg_buffercache", "package-export": "./contrib/pg_buffercache", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 3133, - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_buffercache/pg_buffercache.control", + "control-file": "src/postgres/versions/18/contrib/pg_buffercache/pg_buffercache.control", "control": { "default-version": "1.5", "module-pathname": "$libdir/pg_buffercache", @@ -1128,10 +1100,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_buffercache.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_buffercache.test.js" ], "native-module-file": "pg_buffercache.so", "promotion": { @@ -1141,7 +1113,7 @@ "promoted": true, "stable": true, "archive": "extensions/pg_buffercache.tar.zst", - "module-sha256": "3ac0bed8c504ed7af629f32454e323924bb28fcaec28da403dd8e4fc50e0f2e6" + "module-sha256": "cda0de1b917fc04b483a9c67dfce5f7b6e483a7cc306360e4c336b88f93c5e9c" }, "notes": [] }, @@ -1151,15 +1123,14 @@ "rust-constant": "PG_FREESPACEMAP", "display-name": "pg_freespacemap", "source-kind": "postgres-contrib", - "pglite-import-name": "pg_freespacemap", - "pglite-import-path": "@electric-sql/pglite/contrib/pg_freespacemap", + "upstream-import-name": "pg_freespacemap", "package-export": "./contrib/pg_freespacemap", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 1485, - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_freespacemap/pg_freespacemap.control", + "control-file": "src/postgres/versions/18/contrib/pg_freespacemap/pg_freespacemap.control", "control": { "default-version": "1.2", "module-pathname": "$libdir/pg_freespacemap", @@ -1183,10 +1154,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_freespacemap.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_freespacemap.test.ts" ], "native-module-file": "pg_freespacemap.so", "promotion": { @@ -1196,7 +1167,7 @@ "promoted": true, "stable": true, "archive": "extensions/pg_freespacemap.tar.zst", - "module-sha256": "38809d986cd421965060d030a59102aa44dbe1707a90ad74e322ce41318b4ec7" + "module-sha256": "e392cfd2bd06a2520bd09d8ecc7061e81c611dacfb413b590b081049347cbffc" }, "notes": [] }, @@ -1205,15 +1176,14 @@ "sql-name": "pg_hashids", "rust-constant": "PG_HASHIDS", "display-name": "pg_hashids", - "source-kind": "pglite-other-extension", - "pglite-import-name": "pg_hashids", - "pglite-import-path": "@electric-sql/pglite/pg_hashids", + "source-kind": "oliphaunt-other-extension", + "upstream-import-name": "pg_hashids", "package-export": "./pg_hashids", "tags": [ "postgres extension" ], "bundle-size": 4212, - "control-file": "assets/checkouts/pg_hashids/pg_hashids.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_hashids/pg_hashids.control", "control": { "default-version": "1.3", "module-pathname": "$libdir/pg_hashids", @@ -1237,10 +1207,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_hashids.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_hashids.test.ts" ], "native-module-file": "pg_hashids.so", "promotion": { @@ -1250,26 +1220,23 @@ "promoted": true, "stable": true, "archive": "extensions/pg_hashids.tar.zst", - "module-sha256": "b7bdf822c03ff5fcba6ba2a89f6dcaab90d30bf1c531062165bb3d125b4b95ee" + "module-sha256": "b995931f524dc4a5eae7314d2229f47d2f92135d7273f49609f244859adf845d" }, - "notes": [ - "postgres-pglite submodule https://github.com/iCyberon/pg_hashids pinned at 8c404dd86408f3a987a3ff6825ac7e42bd618b98" - ] + "notes": [] }, { "id": "pg_ivm", "sql-name": "pg_ivm", "rust-constant": "PG_IVM", "display-name": "pg_ivm", - "source-kind": "pglite-other-extension", - "pglite-import-name": "pg_ivm", - "pglite-import-path": "@electric-sql/pglite/pg_ivm", + "source-kind": "oliphaunt-other-extension", + "upstream-import-name": "pg_ivm", "package-export": "./pg_ivm", "tags": [ "postgres extension" ], "bundle-size": 24865, - "control-file": "assets/checkouts/pg_ivm/pg_ivm.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_ivm/pg_ivm.control", "control": { "default-version": "1.13", "module-pathname": "$libdir/pg_ivm", @@ -1294,10 +1261,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_ivm.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_ivm.test.ts" ], "native-module-file": "pg_ivm.so", "promotion": { @@ -1307,11 +1274,9 @@ "promoted": true, "stable": true, "archive": "extensions/pg_ivm.tar.zst", - "module-sha256": "7aefdd437c0a3ee8b4f246b9fadf01734ad991c505dff8f6d004ac6e74a9154a" + "module-sha256": "b9180a0a82d92f522a30ef4490286db90bf53b641bdada594f60023f69cea8ba" }, - "notes": [ - "postgres-pglite submodule https://github.com/sraoss/pg_ivm.git pinned at b66487f7a6f8deee3998e858d773e19923e4bd4b" - ] + "notes": [] }, { "id": "pg_surgery", @@ -1319,15 +1284,14 @@ "rust-constant": "PG_SURGERY", "display-name": "pg_surgery", "source-kind": "postgres-contrib", - "pglite-import-name": "pg_surgery", - "pglite-import-path": "@electric-sql/pglite/contrib/pg_surgery", + "upstream-import-name": "pg_surgery", "package-export": "./contrib/pg_surgery", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 2635, - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_surgery/pg_surgery.control", + "control-file": "src/postgres/versions/18/contrib/pg_surgery/pg_surgery.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/pg_surgery", @@ -1351,10 +1315,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_surgery.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_surgery.test.js" ], "native-module-file": "pg_surgery.so", "promotion": { @@ -1364,7 +1328,7 @@ "promoted": true, "stable": true, "archive": "extensions/pg_surgery.tar.zst", - "module-sha256": "dd1b8daebf7da458eec04b9ba5733913fd1f0d9bad41447e7ee3e0d3611f0ae2" + "module-sha256": "45c9750f40fece7b5532c953d66253aa35a37d13a52f0acc5e1050ceaf196e5d" }, "notes": [] }, @@ -1373,16 +1337,15 @@ "sql-name": "pg_textsearch", "rust-constant": "PG_TEXTSEARCH", "display-name": "pg_textsearch", - "source-kind": "pglite-other-extension", - "pglite-import-name": "pg_textsearch", - "pglite-import-path": "@electric-sql/pglite/pg_textsearch", + "source-kind": "oliphaunt-other-extension", + "upstream-import-name": "pg_textsearch", "package-export": "./pg_textsearch", "tags": [ "postgres extension", "experimental" ], "bundle-size": 55062, - "control-file": "assets/checkouts/pg_textsearch/pg_textsearch.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_textsearch/pg_textsearch.control", "control": { "default-version": "0.5.1", "module-pathname": "$libdir/pg_textsearch", @@ -1406,10 +1369,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_textsearch.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_textsearch.test.ts" ], "native-module-file": "pg_textsearch.so", "promotion": { @@ -1419,11 +1382,9 @@ "promoted": true, "stable": true, "archive": "extensions/pg_textsearch.tar.zst", - "module-sha256": "70566a2191130c20f703aa20016dcc0a2c90312d6b05b0c04a5c8fc4a1e1b896" + "module-sha256": "6c7e6a948fd00c065070b60bc804b417dff384a6af4783589f735a0474cbefb3" }, - "notes": [ - "postgres-pglite submodule https://github.com/timescale/pg_textsearch.git pinned at 5c5147bf2610d786f1bd139951b9fb7fe4ac68fb" - ] + "notes": [] }, { "id": "pg_trgm", @@ -1431,15 +1392,14 @@ "rust-constant": "PG_TRGM", "display-name": "pg_trgm", "source-kind": "postgres-contrib", - "pglite-import-name": "pg_trgm", - "pglite-import-path": "@electric-sql/pglite/contrib/pg_trgm", + "upstream-import-name": "pg_trgm", "package-export": "./contrib/pg_trgm", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 16208, - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_trgm/pg_trgm.control", + "control-file": "src/postgres/versions/18/contrib/pg_trgm/pg_trgm.control", "control": { "default-version": "1.6", "module-pathname": "$libdir/pg_trgm", @@ -1463,10 +1423,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_trgm.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_trgm.test.js" ], "native-module-file": "pg_trgm.so", "promotion": { @@ -1476,7 +1436,7 @@ "promoted": true, "stable": true, "archive": "extensions/pg_trgm.tar.zst", - "module-sha256": "997b99a690c538d0034997c3f9224c2d6e108438e679535a5fa67ecaec476863" + "module-sha256": "d48f1d8a6c338d945c4bd11789751cb5c36035bc90ea7d61d6505bec0ae48972" }, "notes": [] }, @@ -1485,15 +1445,14 @@ "sql-name": "pg_uuidv7", "rust-constant": "PG_UUIDV7", "display-name": "pg_uuidv7", - "source-kind": "pglite-other-extension", - "pglite-import-name": "pg_uuidv7", - "pglite-import-path": "@electric-sql/pglite/pg_uuidv7", + "source-kind": "oliphaunt-other-extension", + "upstream-import-name": "pg_uuidv7", "package-export": "./pg_uuidv7", "tags": [ "postgres extension" ], "bundle-size": 1522, - "control-file": "assets/checkouts/pg_uuidv7/pg_uuidv7.control", + "control-file": "target/oliphaunt-sources/checkouts/pg_uuidv7/pg_uuidv7.control", "control": { "default-version": "1.7", "module-pathname": "$libdir/pg_uuidv7", @@ -1517,10 +1476,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pg_uuidv7.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pg_uuidv7.test.ts" ], "native-module-file": "pg_uuidv7.so", "promotion": { @@ -1530,11 +1489,9 @@ "promoted": true, "stable": true, "archive": "extensions/pg_uuidv7.tar.zst", - "module-sha256": "96e9903d134288ca7f7f5e094dd3c09ec83bd6ce5dc2d87c3d93732c4b4040b4" + "module-sha256": "8b1f9d686f2feac4dc7b807365e7a00163cd0dfe5f857723bd24e2c6eb65cfa6" }, - "notes": [ - "postgres-pglite submodule https://github.com/fboulnois/pg_uuidv7/ pinned at c707aae2411181be4802f5fa565b44d9c0bcbc29" - ] + "notes": [] }, { "id": "pg_visibility", @@ -1542,15 +1499,14 @@ "rust-constant": "PG_VISIBILITY", "display-name": "pg_visibility", "source-kind": "postgres-contrib", - "pglite-import-name": "pg_visibility", - "pglite-import-path": "@electric-sql/pglite/contrib/pg_visibility", + "upstream-import-name": "pg_visibility", "package-export": "./contrib/pg_visibility", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 4159, - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_visibility/pg_visibility.control", + "control-file": "src/postgres/versions/18/contrib/pg_visibility/pg_visibility.control", "control": { "default-version": "1.2", "module-pathname": "$libdir/pg_visibility", @@ -1574,10 +1530,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_visibility.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_visibility.test.js" ], "native-module-file": "pg_visibility.so", "promotion": { @@ -1587,7 +1543,7 @@ "promoted": true, "stable": true, "archive": "extensions/pg_visibility.tar.zst", - "module-sha256": "0c5fcce9a85cb57422a7c201352fe30848ba8d67714e49283515f343c41401d6" + "module-sha256": "adbb9fedcfad207ee2f5e9b37c625bfb964e946df4715f360ce6fa52cf26e99d" }, "notes": [] }, @@ -1597,15 +1553,14 @@ "rust-constant": "PG_WALINSPECT", "display-name": "pg_walinspect", "source-kind": "postgres-contrib", - "pglite-import-name": "pg_walinspect", - "pglite-import-path": "@electric-sql/pglite/contrib/pg_walinspect", + "upstream-import-name": "pg_walinspect", "package-export": "./contrib/pg_walinspect", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 4689, - "control-file": "assets/checkouts/postgres-pglite/contrib/pg_walinspect/pg_walinspect.control", + "control-file": "src/postgres/versions/18/contrib/pg_walinspect/pg_walinspect.control", "control": { "default-version": "1.1", "module-pathname": "$libdir/pg_walinspect", @@ -1629,10 +1584,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pg_walinspect.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pg_walinspect.test.js" ], "native-module-file": "pg_walinspect.so", "promotion": { @@ -1642,7 +1597,7 @@ "promoted": true, "stable": true, "archive": "extensions/pg_walinspect.tar.zst", - "module-sha256": "7a6ce46998a15d4487b51d3a0c7125f3468372c1e2ddbeb31b66a8ad106a41e8" + "module-sha256": "916b8a98aab6e1d3b2f36ea4027822186786ed72ad8a2cc306c52db4b1c5f369" }, "notes": [] }, @@ -1652,15 +1607,14 @@ "rust-constant": "PGCRYPTO", "display-name": "pgcrypto", "source-kind": "postgres-contrib", - "pglite-import-name": "pgcrypto", - "pglite-import-path": "@electric-sql/pglite/contrib/pgcrypto", + "upstream-import-name": "pgcrypto", "package-export": "./contrib/pgcrypto", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 1148162, - "control-file": "assets/checkouts/postgres-pglite/contrib/pgcrypto/pgcrypto.control", + "control-file": "src/postgres/versions/18/contrib/pgcrypto/pgcrypto.control", "control": { "default-version": "1.3", "module-pathname": "$libdir/pgcrypto", @@ -1668,11 +1622,12 @@ "relocatable": "true" }, "dependencies": [], - "native-dependencies": [], + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], "load-order": [], "lifecycle": { "create-extension": true, - "create-schema": "pg_catalog", "load-sql": [], "post-create-sql": [], "startup-config": [], @@ -1681,42 +1636,39 @@ "shared-memory-required": false }, "smoke": { - "direct": "not-run", - "server": "not-run", - "restart": "not-run", - "dump-restore": "not-run" + "direct": "passed", + "server": "passed", + "restart": "passed", + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/pgcrypto.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/pgcrypto.test.js" ], "native-module-file": "pgcrypto.so", "promotion": { "configured": true, - "requested": false, - "packaged": false, - "promoted": false, - "stable": false, + "requested": true, + "packaged": true, + "promoted": true, + "stable": true, "archive": "extensions/pgcrypto.tar.zst", - "blocker": "Requires a pinned WASIX OpenSSL/libcrypto sysroot; current generic contrib build fails on openssl/evp.h." + "module-sha256": "426bf253c5f4385eb0cfabe0b6b149d28f87f3417c729a2650381d16261ce93f" }, - "notes": [ - "promotion blocker: Requires a pinned WASIX OpenSSL/libcrypto sysroot; current generic contrib build fails on openssl/evp.h." - ] + "notes": [] }, { "id": "pgtap", "sql-name": "pgtap", "rust-constant": "PGTAP", "display-name": "pgtap", - "source-kind": "pglite-other-extension", - "pglite-import-name": "pgtap", - "pglite-import-path": "@electric-sql/pglite/pgtap", + "source-kind": "oliphaunt-other-extension", + "upstream-import-name": "pgtap", "package-export": "./pgtap", "tags": [ "postgres extension" ], "bundle-size": 239428, - "control-file": "assets/checkouts/pgtap/pgtap.control", + "control-file": "target/oliphaunt-sources/checkouts/pgtap/pgtap.control", "control": { "default-version": "1.3.5", "requires": [ @@ -1743,10 +1695,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pgtap.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pgtap.test.ts" ], "promotion": { "configured": true, @@ -1757,9 +1709,7 @@ "archive": "extensions/pgtap.tar.zst", "module-sha256": "" }, - "notes": [ - "postgres-pglite submodule https://github.com/theory/pgtap.git pinned at b89585a64ffef012ff0f219de9197c669aa8485b" - ] + "notes": [] }, { "id": "postgis", @@ -1767,23 +1717,33 @@ "rust-constant": "POSTGIS", "display-name": "PostGIS", "source-kind": "postgis", - "pglite-import-name": "postgis", - "pglite-import-path": "@electric-sql/pglite-postgis", + "upstream-import-name": "postgis", "tags": [ "postgres extension", "experimental" ], "bundle-size": 8551161, + "control-file": "target/oliphaunt-sources/checkouts/postgis/extensions/postgis/postgis.control.in", + "control": { + "default-version": "@EXTVERSION@", + "module-pathname": "@MODULEPATH@", + "requires": [], + "relocatable": "false" + }, "dependencies": [], - "native-dependencies": [], + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], "load-order": [ - "lib/postgresql/postgis-3.so", - "lib/postgresql/postgis_topology-3.so", - "lib/postgresql/postgis_raster-3.so" + "lib/postgresql/postgis-3.so" ], "lifecycle": { "create-extension": true, - "create-schema": "pg_catalog", "load-sql": [], "post-create-sql": [], "startup-config": [], @@ -1792,28 +1752,25 @@ "shared-memory-required": false }, "smoke": { - "direct": "not-run", - "server": "not-run", - "restart": "not-run", - "dump-restore": "not-run" + "direct": "passed", + "server": "passed", + "restart": "passed", + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite-postgis/tests/postgis.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/postgis/postgis.test.ts" ], + "native-module-file": "postgis-3.so", "promotion": { "configured": true, - "requested": false, - "packaged": false, - "promoted": false, - "stable": false, + "requested": true, + "packaged": true, + "promoted": true, + "stable": true, "archive": "extensions/postgis.tar.zst", - "blocker": "Requires a pinned WASIX geospatial dependency stack and PostGIS configure/install-delta packaging before smoke." + "module-sha256": "251f7115009a45f528eacf870a65bb26025714ba374f51e210c5ec1ed8a4f4bf" }, - "notes": [ - "control file unavailable in current checkout; source submodule may not be initialized", - "postgres-pglite submodule https://github.com/postgis/postgis.git pinned at 08d9b9f749fa3531591055db2a736bfb6df47006", - "promotion blocker: Requires a pinned WASIX geospatial dependency stack and PostGIS configure/install-delta packaging before smoke." - ] + "notes": [] }, { "id": "seg", @@ -1821,15 +1778,14 @@ "rust-constant": "SEG", "display-name": "seg", "source-kind": "postgres-contrib", - "pglite-import-name": "seg", - "pglite-import-path": "@electric-sql/pglite/contrib/seg", + "upstream-import-name": "seg", "package-export": "./contrib/seg", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 10426, - "control-file": "assets/checkouts/postgres-pglite/contrib/seg/seg.control", + "control-file": "src/postgres/versions/18/contrib/seg/seg.control", "control": { "default-version": "1.4", "module-pathname": "$libdir/seg", @@ -1853,10 +1809,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/seg.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/seg.test.js" ], "native-module-file": "seg.so", "promotion": { @@ -1866,7 +1822,7 @@ "promoted": true, "stable": true, "archive": "extensions/seg.tar.zst", - "module-sha256": "bf0650bbc399138f0b62c25bff3f5f57dc17e216fa0325b5b109eb31015682fe" + "module-sha256": "8de6090da8f297b49b1183102b8f4200e11b79251fac001ce165c1fdc1f9dfc4" }, "notes": [] }, @@ -1876,15 +1832,14 @@ "rust-constant": "TABLEFUNC", "display-name": "tablefunc", "source-kind": "postgres-contrib", - "pglite-import-name": "tablefunc", - "pglite-import-path": "@electric-sql/pglite/contrib/tablefunc", + "upstream-import-name": "tablefunc", "package-export": "./contrib/tablefunc", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 5824, - "control-file": "assets/checkouts/postgres-pglite/contrib/tablefunc/tablefunc.control", + "control-file": "src/postgres/versions/18/contrib/tablefunc/tablefunc.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/tablefunc", @@ -1908,10 +1863,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tablefunc.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tablefunc.test.js" ], "native-module-file": "tablefunc.so", "promotion": { @@ -1921,7 +1876,7 @@ "promoted": true, "stable": true, "archive": "extensions/tablefunc.tar.zst", - "module-sha256": "c15351f6a1dad0b1ff43a20c8966be666a0d4a6dc0ef2d87b9aae8d04671aa11" + "module-sha256": "2664083fc238d5dda4204ef3f0bf689feb0406e78caca9aa0195d6610459e4b0" }, "notes": [] }, @@ -1931,15 +1886,14 @@ "rust-constant": "TCN", "display-name": "tcn", "source-kind": "postgres-contrib", - "pglite-import-name": "tcn", - "pglite-import-path": "@electric-sql/pglite/contrib/tcn", + "upstream-import-name": "tcn", "package-export": "./contrib/tcn", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 1914, - "control-file": "assets/checkouts/postgres-pglite/contrib/tcn/tcn.control", + "control-file": "src/postgres/versions/18/contrib/tcn/tcn.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/tcn", @@ -1963,10 +1917,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tcn.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tcn.test.js" ], "native-module-file": "tcn.so", "promotion": { @@ -1976,7 +1930,7 @@ "promoted": true, "stable": true, "archive": "extensions/tcn.tar.zst", - "module-sha256": "ed94b83055df5cb5e64884cc556f76e17ef663fd272adfb4fbcba80f894fb604" + "module-sha256": "1d6a19a4b684fd1d0443a31220afe2ed7f401a55e13ba5cca3b0015dbfee020e" }, "notes": [] }, @@ -1986,15 +1940,14 @@ "rust-constant": "TSM_SYSTEM_ROWS", "display-name": "tsm_system_rows", "source-kind": "postgres-contrib", - "pglite-import-name": "tsm_system_rows", - "pglite-import-path": "@electric-sql/pglite/contrib/tsm_system_rows", + "upstream-import-name": "tsm_system_rows", "package-export": "./contrib/tsm_system_rows", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 2048, - "control-file": "assets/checkouts/postgres-pglite/contrib/tsm_system_rows/tsm_system_rows.control", + "control-file": "src/postgres/versions/18/contrib/tsm_system_rows/tsm_system_rows.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/tsm_system_rows", @@ -2018,10 +1971,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tsm_system_rows.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tsm_system_rows.test.js" ], "native-module-file": "tsm_system_rows.so", "promotion": { @@ -2031,7 +1984,7 @@ "promoted": true, "stable": true, "archive": "extensions/tsm_system_rows.tar.zst", - "module-sha256": "d66231a731c3a66e16226ef90023208c8659133c1af5059ddf7d09a29f4b56a3" + "module-sha256": "7f271afd0574aa6cba5b85b103140c2cbf84143d113ff43f26aab1754e26b645" }, "notes": [] }, @@ -2041,15 +1994,14 @@ "rust-constant": "TSM_SYSTEM_TIME", "display-name": "tsm_system_time", "source-kind": "postgres-contrib", - "pglite-import-name": "tsm_system_time", - "pglite-import-path": "@electric-sql/pglite/contrib/tsm_system_time", + "upstream-import-name": "tsm_system_time", "package-export": "./contrib/tsm_system_time", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 2099, - "control-file": "assets/checkouts/postgres-pglite/contrib/tsm_system_time/tsm_system_time.control", + "control-file": "src/postgres/versions/18/contrib/tsm_system_time/tsm_system_time.control", "control": { "default-version": "1.0", "module-pathname": "$libdir/tsm_system_time", @@ -2073,10 +2025,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/tsm_system_time.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/tsm_system_time.test.js" ], "native-module-file": "tsm_system_time.so", "promotion": { @@ -2086,7 +2038,7 @@ "promoted": true, "stable": true, "archive": "extensions/tsm_system_time.tar.zst", - "module-sha256": "c4b0a9770859c988e7c278d8a6518a0e892bcae96cf218c337a0aef188bcb8c8" + "module-sha256": "b1c1898ab13d905f2b05f4137b74f4747d14a20959bbc20c7ab0150b81355e4e" }, "notes": [] }, @@ -2096,15 +2048,14 @@ "rust-constant": "UNACCENT", "display-name": "unaccent", "source-kind": "postgres-contrib", - "pglite-import-name": "unaccent", - "pglite-import-path": "@electric-sql/pglite/contrib/unaccent", + "upstream-import-name": "unaccent", "package-export": "./contrib/unaccent", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 9323, - "control-file": "assets/checkouts/postgres-pglite/contrib/unaccent/unaccent.control", + "control-file": "src/postgres/versions/18/contrib/unaccent/unaccent.control", "control": { "default-version": "1.1", "module-pathname": "$libdir/unaccent", @@ -2128,10 +2079,10 @@ "direct": "passed", "server": "passed", "restart": "passed", - "dump-restore": "not-run" + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/unaccent.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/unaccent.test.js" ], "native-module-file": "unaccent.so", "promotion": { @@ -2141,7 +2092,7 @@ "promoted": true, "stable": true, "archive": "extensions/unaccent.tar.zst", - "module-sha256": "f966e59cf9431a5b880d4a5e091c6a3eb1eac0c84173466631216bc0c8c0ea67" + "module-sha256": "9a873164201fe57e351999559446f788a5206bc870d6375ad989c554631307a1" }, "notes": [] }, @@ -2151,15 +2102,14 @@ "rust-constant": "UUID_OSSP", "display-name": "uuid-ossp", "source-kind": "postgres-contrib", - "pglite-import-name": "uuid_ossp", - "pglite-import-path": "@electric-sql/pglite/contrib/uuid_ossp", + "upstream-import-name": "uuid_ossp", "package-export": "./contrib/uuid_ossp", "tags": [ "postgres extension", "postgres/contrib" ], "bundle-size": 17936, - "control-file": "assets/checkouts/postgres-pglite/contrib/uuid-ossp/uuid-ossp.control", + "control-file": "src/postgres/versions/18/contrib/uuid-ossp/uuid-ossp.control", "control": { "default-version": "1.1", "module-pathname": "$libdir/uuid-ossp", @@ -2180,42 +2130,39 @@ "shared-memory-required": false }, "smoke": { - "direct": "not-run", - "server": "not-run", - "restart": "not-run", - "dump-restore": "not-run" + "direct": "passed", + "server": "passed", + "restart": "passed", + "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/contrib/uuid_ossp.test.js" + "target/oliphaunt-sources/checkouts/src/extensions/tests/contrib/uuid_ossp.test.js" ], "native-module-file": "uuid-ossp.so", "promotion": { "configured": true, - "requested": false, - "packaged": false, - "promoted": false, - "stable": false, + "requested": true, + "packaged": true, + "promoted": true, + "stable": true, "archive": "extensions/uuid-ossp.tar.zst", - "blocker": "Requires a pinned WASIX OSSP UUID/libuuid sysroot; upstream Emscripten builder provides this separately." + "module-sha256": "2b323b7a442dba7f751744e3fa207bd04dab9be0dbc310b704ed03a2e2d6085e" }, - "notes": [ - "promotion blocker: Requires a pinned WASIX OSSP UUID/libuuid sysroot; upstream Emscripten builder provides this separately." - ] + "notes": [] }, { "id": "vector", "sql-name": "vector", "rust-constant": "VECTOR", "display-name": "pgvector", - "source-kind": "pglite-other-extension", - "pglite-import-name": "vector", - "pglite-import-path": "@electric-sql/pglite/vector", + "source-kind": "oliphaunt-other-extension", + "upstream-import-name": "vector", "package-export": "./vector", "tags": [ "postgres extension" ], "bundle-size": 43953, - "control-file": "assets/checkouts/pgvector/vector.control", + "control-file": "target/oliphaunt-sources/checkouts/pgvector/vector.control", "control": { "default-version": "0.8.2", "module-pathname": "$libdir/vector", @@ -2242,7 +2189,7 @@ "dump-restore": "passed" }, "tests": [ - "assets/checkouts/pglite/packages/pglite/tests/pgvector.test.ts" + "target/oliphaunt-sources/checkouts/src/extensions/tests/pgvector.test.ts" ], "native-module-file": "vector.so", "promotion": { @@ -2252,11 +2199,9 @@ "promoted": true, "stable": true, "archive": "extensions/vector.tar.zst", - "module-sha256": "860a9dda5d86bfb5008fa0281a30454b8de6b0754e6fd25be5d1cd6308a36629" + "module-sha256": "f6bb2e87d2c23513ace39cd3b0f77f727031cdb65b7c9a945e7c395d92595937" }, - "notes": [ - "postgres-pglite submodule https://github.com/pgvector/pgvector.git pinned at 35ab919bf5da677709b2ebb8be07480bb25e97cf" - ] + "notes": [] } ] } diff --git a/src/extensions/generated/mobile/static-extensions.tsv b/src/extensions/generated/mobile/static-extensions.tsv new file mode 100644 index 00000000..19cb885c --- /dev/null +++ b/src/extensions/generated/mobile/static-extensions.tsv @@ -0,0 +1,40 @@ +# @generated by src/extensions/tools/check-extension-model.py --write +sql-name native-module-stem source-kind source-rel mobile-static-dependencies ios-static-dependencies android-static-dependencies include-dependencies include-dirs cflags hash-source-dependencies ios-hash-source-dependencies android-hash-source-dependencies hash-dirs source-files source-recursive-dirs +amcheck amcheck contrib contrib/amcheck +auto_explain auto_explain contrib contrib/auto_explain +bloom bloom contrib contrib/bloom +btree_gin btree_gin contrib contrib/btree_gin +btree_gist btree_gist contrib contrib/btree_gist +citext citext contrib contrib/citext +cube cube contrib contrib/cube +dict_int dict_int contrib contrib/dict_int +dict_xsyn dict_xsyn contrib contrib/dict_xsyn +earthdistance earthdistance contrib contrib/earthdistance +file_fdw file_fdw contrib contrib/file_fdw +fuzzystrmatch fuzzystrmatch contrib contrib/fuzzystrmatch +hstore hstore contrib contrib/hstore +intarray _int contrib contrib/intarray +isn isn contrib contrib/isn +lo lo contrib contrib/lo +ltree ltree contrib contrib/ltree +pageinspect pageinspect contrib contrib/pageinspect +pg_buffercache pg_buffercache contrib contrib/pg_buffercache +pg_freespacemap pg_freespacemap contrib contrib/pg_freespacemap +pg_hashids pg_hashids external target/oliphaunt-sources/checkouts/pg_hashids +pg_ivm pg_ivm external target/oliphaunt-sources/checkouts/pg_ivm createas.c,matview.c,pg_ivm.c,ruleutils.c,subselect.c +pg_surgery pg_surgery contrib contrib/pg_surgery +pg_textsearch pg_textsearch external target/oliphaunt-sources/checkouts/pg_textsearch source:src src +pg_trgm pg_trgm contrib contrib/pg_trgm +pg_uuidv7 pg_uuidv7 external target/oliphaunt-sources/checkouts/pg_uuidv7 +pg_visibility pg_visibility contrib contrib/pg_visibility +pg_walinspect pg_walinspect contrib contrib/pg_walinspect +pgcrypto pgcrypto contrib contrib/pgcrypto openssl openssl openssl openssl openssl openssl openssl +postgis postgis-3 external target/oliphaunt-sources/checkouts/postgis geos,geos-c,json-c,libcharset,libiconv,libxml2,proj,sqlite geos,geos-c,json-c,libxml2,proj,sqlite geos,geos-c,json-c,libcharset,libiconv,libxml2,proj,sqlite geos,json-c,libiconv,libxml2,proj,sqlite geos,json-c,libxml2,proj,sqlite geos,json-c,libiconv,libxml2,proj,sqlite +seg seg contrib contrib/seg +tablefunc tablefunc contrib contrib/tablefunc +tcn tcn contrib contrib/tcn +tsm_system_rows tsm_system_rows contrib contrib/tsm_system_rows +tsm_system_time tsm_system_time contrib contrib/tsm_system_time +unaccent unaccent contrib contrib/unaccent +uuid-ossp uuid-ossp contrib contrib/uuid-ossp uuid uuid uuid src/runtimes/liboliphaunt/native/portable-uuid/include -DHAVE_UUID_E2FS=1,-DHAVE_UUID_UUID_H=1 src/runtimes/liboliphaunt/native/portable-uuid +vector vector external target/oliphaunt-sources/checkouts/pgvector diff --git a/src/extensions/generated/mobile/static-registry.json b/src/extensions/generated/mobile/static-registry.json new file mode 100644 index 00000000..405a0943 --- /dev/null +++ b/src/extensions/generated/mobile/static-registry.json @@ -0,0 +1,342 @@ +{ + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-definitions", + "path": "src/extensions/external" + } + ], + "modules": [ + { + "data-files": [], + "id": "amcheck", + "native-dependencies": [], + "native-module-stem": "amcheck", + "sql-name": "amcheck", + "static-registry-required": true + }, + { + "data-files": [], + "id": "auto_explain", + "native-dependencies": [], + "native-module-stem": "auto_explain", + "sql-name": "auto_explain", + "static-registry-required": true + }, + { + "data-files": [], + "id": "bloom", + "native-dependencies": [], + "native-module-stem": "bloom", + "sql-name": "bloom", + "static-registry-required": true + }, + { + "data-files": [], + "id": "btree_gin", + "native-dependencies": [], + "native-module-stem": "btree_gin", + "sql-name": "btree_gin", + "static-registry-required": true + }, + { + "data-files": [], + "id": "btree_gist", + "native-dependencies": [], + "native-module-stem": "btree_gist", + "sql-name": "btree_gist", + "static-registry-required": true + }, + { + "data-files": [], + "id": "citext", + "native-dependencies": [], + "native-module-stem": "citext", + "sql-name": "citext", + "static-registry-required": true + }, + { + "data-files": [], + "id": "cube", + "native-dependencies": [], + "native-module-stem": "cube", + "sql-name": "cube", + "static-registry-required": true + }, + { + "data-files": [], + "id": "dict_int", + "native-dependencies": [], + "native-module-stem": "dict_int", + "sql-name": "dict_int", + "static-registry-required": true + }, + { + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "id": "dict_xsyn", + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "sql-name": "dict_xsyn", + "static-registry-required": true + }, + { + "data-files": [], + "id": "earthdistance", + "native-dependencies": [], + "native-module-stem": "earthdistance", + "sql-name": "earthdistance", + "static-registry-required": true + }, + { + "data-files": [], + "id": "file_fdw", + "native-dependencies": [], + "native-module-stem": "file_fdw", + "sql-name": "file_fdw", + "static-registry-required": true + }, + { + "data-files": [], + "id": "fuzzystrmatch", + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "sql-name": "fuzzystrmatch", + "static-registry-required": true + }, + { + "data-files": [], + "id": "hstore", + "native-dependencies": [], + "native-module-stem": "hstore", + "sql-name": "hstore", + "static-registry-required": true + }, + { + "data-files": [], + "id": "intarray", + "native-dependencies": [], + "native-module-stem": "_int", + "sql-name": "intarray", + "static-registry-required": true + }, + { + "data-files": [], + "id": "isn", + "native-dependencies": [], + "native-module-stem": "isn", + "sql-name": "isn", + "static-registry-required": true + }, + { + "data-files": [], + "id": "lo", + "native-dependencies": [], + "native-module-stem": "lo", + "sql-name": "lo", + "static-registry-required": true + }, + { + "data-files": [], + "id": "ltree", + "native-dependencies": [], + "native-module-stem": "ltree", + "sql-name": "ltree", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pageinspect", + "native-dependencies": [], + "native-module-stem": "pageinspect", + "sql-name": "pageinspect", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_buffercache", + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "sql-name": "pg_buffercache", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_freespacemap", + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "sql-name": "pg_freespacemap", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_hashids", + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "sql-name": "pg_hashids", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_ivm", + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "sql-name": "pg_ivm", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_surgery", + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "sql-name": "pg_surgery", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_textsearch", + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "sql-name": "pg_textsearch", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_trgm", + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "sql-name": "pg_trgm", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_uuidv7", + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "sql-name": "pg_uuidv7", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_visibility", + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "sql-name": "pg_visibility", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pg_walinspect", + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "sql-name": "pg_walinspect", + "static-registry-required": true + }, + { + "data-files": [], + "id": "pgcrypto", + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "sql-name": "pgcrypto", + "static-registry-required": true + }, + { + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "id": "postgis", + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "sql-name": "postgis", + "static-registry-required": true + }, + { + "data-files": [], + "id": "seg", + "native-dependencies": [], + "native-module-stem": "seg", + "sql-name": "seg", + "static-registry-required": true + }, + { + "data-files": [], + "id": "tablefunc", + "native-dependencies": [], + "native-module-stem": "tablefunc", + "sql-name": "tablefunc", + "static-registry-required": true + }, + { + "data-files": [], + "id": "tcn", + "native-dependencies": [], + "native-module-stem": "tcn", + "sql-name": "tcn", + "static-registry-required": true + }, + { + "data-files": [], + "id": "tsm_system_rows", + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "sql-name": "tsm_system_rows", + "static-registry-required": true + }, + { + "data-files": [], + "id": "tsm_system_time", + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "sql-name": "tsm_system_time", + "static-registry-required": true + }, + { + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "id": "unaccent", + "native-dependencies": [], + "native-module-stem": "unaccent", + "sql-name": "unaccent", + "static-registry-required": true + }, + { + "data-files": [], + "id": "uuid_ossp", + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "sql-name": "uuid-ossp", + "static-registry-required": true + }, + { + "data-files": [], + "id": "vector", + "native-dependencies": [], + "native-module-stem": "vector", + "sql-name": "vector", + "static-registry-required": true + } + ] +} diff --git a/src/extensions/generated/pgxs-build.tsv b/src/extensions/generated/pgxs-build.tsv new file mode 100644 index 00000000..b6452c27 --- /dev/null +++ b/src/extensions/generated/pgxs-build.tsv @@ -0,0 +1,7 @@ +# id sql_name source_dir module_file archive stable make_args +pg_hashids pg_hashids target/oliphaunt-sources/checkouts/pg_hashids pg_hashids.so extensions/pg_hashids.tar.zst true - +pg_ivm pg_ivm target/oliphaunt-sources/checkouts/pg_ivm pg_ivm.so extensions/pg_ivm.tar.zst true - +pg_textsearch pg_textsearch target/oliphaunt-sources/checkouts/pg_textsearch pg_textsearch.so extensions/pg_textsearch.tar.zst true - +pg_uuidv7 pg_uuidv7 target/oliphaunt-sources/checkouts/pg_uuidv7 pg_uuidv7.so extensions/pg_uuidv7.tar.zst true - +pgtap pgtap target/oliphaunt-sources/checkouts/pgtap - extensions/pgtap.tar.zst true - +vector vector target/oliphaunt-sources/checkouts/pgvector vector.so extensions/vector.tar.zst true - diff --git a/src/extensions/generated/sdk/js.json b/src/extensions/generated/sdk/js.json new file mode 100644 index 00000000..3c8fa4a0 --- /dev/null +++ b/src/extensions/generated/sdk/js.json @@ -0,0 +1,1249 @@ +{ + "consumer": "js", + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "amcheck", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/auto_explain.tar.zst", + "creates-extension": false, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "auto_explain", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/bloom.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "bloom", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gin.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gin", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gist.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gist", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/citext.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "citext", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/cube.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "cube", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_int.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/xsyn_sample.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/earthdistance.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "earthdistance", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [ + "cube" + ], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/file_fdw.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "file_fdw", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/hstore.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "hstore", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/intarray.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/isn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "isn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/lo.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "lo", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/ltree.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "ltree", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pageinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pageinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgtap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "extension-sql-file-names": [ + "uninstall_pgtap.sql" + ], + "extension-sql-file-prefixes": [ + "pgtap-core", + "pgtap-schema" + ], + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": null, + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/postgis.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "extension-sql-file-names": [ + "uninstall_postgis.sql" + ], + "extension-sql-file-prefixes": [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis" + ], + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "postgres-major": 18, + "public": true, + "runtime-environment": [ + { + "name": "PROJ_DATA", + "path": "share/postgresql/proj", + "required_file": "proj.db" + } + ], + "runtime-share-data-files": [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgis", + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/seg.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "seg", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tablefunc.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tablefunc", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tcn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tcn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/unaccent.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "unaccent", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/unaccent.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/vector.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "vector", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-evidence", + "path": "src/extensions/generated/docs/extension-evidence.json" + } + ] +} diff --git a/src/extensions/generated/sdk/kotlin.json b/src/extensions/generated/sdk/kotlin.json new file mode 100644 index 00000000..d39eb34a --- /dev/null +++ b/src/extensions/generated/sdk/kotlin.json @@ -0,0 +1,1249 @@ +{ + "consumer": "kotlin", + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "amcheck", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/auto_explain.tar.zst", + "creates-extension": false, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "auto_explain", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/bloom.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "bloom", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gin.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gin", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gist.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gist", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/citext.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "citext", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/cube.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "cube", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_int.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/xsyn_sample.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/earthdistance.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "earthdistance", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [ + "cube" + ], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/file_fdw.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "file_fdw", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/hstore.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "hstore", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/intarray.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/isn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "isn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/lo.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "lo", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/ltree.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "ltree", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pageinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pageinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgtap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "extension-sql-file-names": [ + "uninstall_pgtap.sql" + ], + "extension-sql-file-prefixes": [ + "pgtap-core", + "pgtap-schema" + ], + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": null, + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/postgis.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "extension-sql-file-names": [ + "uninstall_postgis.sql" + ], + "extension-sql-file-prefixes": [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis" + ], + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "postgres-major": 18, + "public": true, + "runtime-environment": [ + { + "name": "PROJ_DATA", + "path": "share/postgresql/proj", + "required_file": "proj.db" + } + ], + "runtime-share-data-files": [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgis", + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/seg.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "seg", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tablefunc.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tablefunc", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tcn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tcn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/unaccent.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "unaccent", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/unaccent.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/vector.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "vector", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-evidence", + "path": "src/extensions/generated/docs/extension-evidence.json" + } + ] +} diff --git a/src/extensions/generated/sdk/react-native.json b/src/extensions/generated/sdk/react-native.json new file mode 100644 index 00000000..641b2756 --- /dev/null +++ b/src/extensions/generated/sdk/react-native.json @@ -0,0 +1,1249 @@ +{ + "consumer": "react-native", + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "amcheck", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/auto_explain.tar.zst", + "creates-extension": false, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "auto_explain", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/bloom.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "bloom", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gin.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gin", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gist.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gist", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/citext.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "citext", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/cube.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "cube", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_int.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/xsyn_sample.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/earthdistance.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "earthdistance", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [ + "cube" + ], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/file_fdw.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "file_fdw", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/hstore.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "hstore", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/intarray.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/isn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "isn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/lo.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "lo", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/ltree.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "ltree", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pageinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pageinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgtap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "extension-sql-file-names": [ + "uninstall_pgtap.sql" + ], + "extension-sql-file-prefixes": [ + "pgtap-core", + "pgtap-schema" + ], + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": null, + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/postgis.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "extension-sql-file-names": [ + "uninstall_postgis.sql" + ], + "extension-sql-file-prefixes": [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis" + ], + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "postgres-major": 18, + "public": true, + "runtime-environment": [ + { + "name": "PROJ_DATA", + "path": "share/postgresql/proj", + "required_file": "proj.db" + } + ], + "runtime-share-data-files": [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgis", + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/seg.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "seg", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tablefunc.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tablefunc", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tcn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tcn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/unaccent.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "unaccent", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/unaccent.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/vector.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "vector", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-evidence", + "path": "src/extensions/generated/docs/extension-evidence.json" + } + ] +} diff --git a/src/extensions/generated/sdk/rust.json b/src/extensions/generated/sdk/rust.json new file mode 100644 index 00000000..8ddb9928 --- /dev/null +++ b/src/extensions/generated/sdk/rust.json @@ -0,0 +1,1249 @@ +{ + "consumer": "rust", + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "amcheck", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/auto_explain.tar.zst", + "creates-extension": false, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "auto_explain", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/bloom.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "bloom", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gin.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gin", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gist.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gist", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/citext.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "citext", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/cube.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "cube", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_int.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/xsyn_sample.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/earthdistance.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "earthdistance", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [ + "cube" + ], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/file_fdw.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "file_fdw", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/hstore.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "hstore", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/intarray.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/isn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "isn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/lo.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "lo", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/ltree.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "ltree", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pageinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pageinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgtap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "extension-sql-file-names": [ + "uninstall_pgtap.sql" + ], + "extension-sql-file-prefixes": [ + "pgtap-core", + "pgtap-schema" + ], + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": null, + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/postgis.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "extension-sql-file-names": [ + "uninstall_postgis.sql" + ], + "extension-sql-file-prefixes": [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis" + ], + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "postgres-major": 18, + "public": true, + "runtime-environment": [ + { + "name": "PROJ_DATA", + "path": "share/postgresql/proj", + "required_file": "proj.db" + } + ], + "runtime-share-data-files": [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgis", + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/seg.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "seg", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tablefunc.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tablefunc", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tcn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tcn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/unaccent.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "unaccent", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/unaccent.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/vector.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "vector", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-evidence", + "path": "src/extensions/generated/docs/extension-evidence.json" + } + ] +} diff --git a/src/extensions/generated/sdk/swift.json b/src/extensions/generated/sdk/swift.json new file mode 100644 index 00000000..9fca2440 --- /dev/null +++ b/src/extensions/generated/sdk/swift.json @@ -0,0 +1,1249 @@ +{ + "consumer": "swift", + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "amcheck", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/auto_explain.tar.zst", + "creates-extension": false, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "auto_explain", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/bloom.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "bloom", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gin.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gin", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gist.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gist", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/citext.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "citext", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/cube.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "cube", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_int.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/xsyn_sample.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/earthdistance.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "earthdistance", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [ + "cube" + ], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/file_fdw.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "file_fdw", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/hstore.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "hstore", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/intarray.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/isn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "isn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/lo.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "lo", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/ltree.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "ltree", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pageinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pageinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgtap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "extension-sql-file-names": [ + "uninstall_pgtap.sql" + ], + "extension-sql-file-prefixes": [ + "pgtap-core", + "pgtap-schema" + ], + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": null, + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/postgis.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "extension-sql-file-names": [ + "uninstall_postgis.sql" + ], + "extension-sql-file-prefixes": [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis" + ], + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "postgres-major": 18, + "public": true, + "runtime-environment": [ + { + "name": "PROJ_DATA", + "path": "share/postgresql/proj", + "required_file": "proj.db" + } + ], + "runtime-share-data-files": [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgis", + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/seg.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "seg", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tablefunc.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tablefunc", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tcn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tcn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/unaccent.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "unaccent", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/unaccent.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/vector.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "vector", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-evidence", + "path": "src/extensions/generated/docs/extension-evidence.json" + } + ] +} diff --git a/src/extensions/generated/wasix/extensions.json b/src/extensions/generated/wasix/extensions.json new file mode 100644 index 00000000..1f51932e --- /dev/null +++ b/src/extensions/generated/wasix/extensions.json @@ -0,0 +1,771 @@ +{ + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "dependencies": [], + "id": "amcheck", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "amcheck.so", + "native-support-modules": [], + "sql-name": "amcheck" + }, + { + "archive": "extensions/auto_explain.tar.zst", + "dependencies": [], + "id": "auto_explain", + "lifecycle": { + "create-extension": false, + "load-sql": [ + "LOAD 'auto_explain';", + "SET auto_explain.log_min_duration = '0';", + "SET auto_explain.log_analyze = 'true';", + "SET auto_explain.log_level = 'NOTICE';" + ], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "auto_explain.so", + "native-support-modules": [], + "sql-name": "auto_explain" + }, + { + "archive": "extensions/bloom.tar.zst", + "dependencies": [], + "id": "bloom", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "bloom.so", + "native-support-modules": [], + "sql-name": "bloom" + }, + { + "archive": "extensions/btree_gin.tar.zst", + "dependencies": [], + "id": "btree_gin", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "btree_gin.so", + "native-support-modules": [], + "sql-name": "btree_gin" + }, + { + "archive": "extensions/btree_gist.tar.zst", + "dependencies": [], + "id": "btree_gist", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "btree_gist.so", + "native-support-modules": [], + "sql-name": "btree_gist" + }, + { + "archive": "extensions/citext.tar.zst", + "dependencies": [], + "id": "citext", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "citext.so", + "native-support-modules": [], + "sql-name": "citext" + }, + { + "archive": "extensions/cube.tar.zst", + "dependencies": [], + "id": "cube", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "cube.so", + "native-support-modules": [], + "sql-name": "cube" + }, + { + "archive": "extensions/dict_int.tar.zst", + "dependencies": [], + "id": "dict_int", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "dict_int.so", + "native-support-modules": [], + "sql-name": "dict_int" + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "dependencies": [], + "id": "dict_xsyn", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "dict_xsyn.so", + "native-support-modules": [], + "sql-name": "dict_xsyn" + }, + { + "archive": "extensions/earthdistance.tar.zst", + "dependencies": [ + "cube" + ], + "id": "earthdistance", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "earthdistance.so", + "native-support-modules": [], + "sql-name": "earthdistance" + }, + { + "archive": "extensions/file_fdw.tar.zst", + "dependencies": [], + "id": "file_fdw", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "file_fdw.so", + "native-support-modules": [], + "sql-name": "file_fdw" + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "dependencies": [], + "id": "fuzzystrmatch", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "fuzzystrmatch.so", + "native-support-modules": [], + "sql-name": "fuzzystrmatch" + }, + { + "archive": "extensions/hstore.tar.zst", + "dependencies": [], + "id": "hstore", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "hstore.so", + "native-support-modules": [], + "sql-name": "hstore" + }, + { + "archive": "extensions/intarray.tar.zst", + "dependencies": [], + "id": "intarray", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "_int.so", + "native-support-modules": [], + "sql-name": "intarray" + }, + { + "archive": "extensions/isn.tar.zst", + "dependencies": [], + "id": "isn", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "isn.so", + "native-support-modules": [], + "sql-name": "isn" + }, + { + "archive": "extensions/lo.tar.zst", + "dependencies": [], + "id": "lo", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "lo.so", + "native-support-modules": [], + "sql-name": "lo" + }, + { + "archive": "extensions/ltree.tar.zst", + "dependencies": [], + "id": "ltree", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "ltree.so", + "native-support-modules": [], + "sql-name": "ltree" + }, + { + "archive": "extensions/pageinspect.tar.zst", + "dependencies": [], + "id": "pageinspect", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pageinspect.so", + "native-support-modules": [], + "sql-name": "pageinspect" + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "dependencies": [], + "id": "pg_buffercache", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_buffercache.so", + "native-support-modules": [], + "sql-name": "pg_buffercache" + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "dependencies": [], + "id": "pg_freespacemap", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_freespacemap.so", + "native-support-modules": [], + "sql-name": "pg_freespacemap" + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "dependencies": [], + "id": "pg_hashids", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_hashids.so", + "native-support-modules": [], + "sql-name": "pg_hashids" + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "dependencies": [], + "id": "pg_ivm", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_ivm.so", + "native-support-modules": [], + "sql-name": "pg_ivm" + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "dependencies": [], + "id": "pg_surgery", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_surgery.so", + "native-support-modules": [], + "sql-name": "pg_surgery" + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "dependencies": [], + "id": "pg_textsearch", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_textsearch.so", + "native-support-modules": [], + "sql-name": "pg_textsearch" + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "dependencies": [], + "id": "pg_trgm", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_trgm.so", + "native-support-modules": [], + "sql-name": "pg_trgm" + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "dependencies": [], + "id": "pg_uuidv7", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_uuidv7.so", + "native-support-modules": [], + "sql-name": "pg_uuidv7" + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "dependencies": [], + "id": "pg_visibility", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_visibility.so", + "native-support-modules": [], + "sql-name": "pg_visibility" + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "dependencies": [], + "id": "pg_walinspect", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pg_walinspect.so", + "native-support-modules": [], + "sql-name": "pg_walinspect" + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "dependencies": [], + "id": "pgcrypto", + "lifecycle": { + "create-extension": true, + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "pgcrypto.so", + "native-support-modules": [], + "sql-name": "pgcrypto" + }, + { + "archive": "extensions/pgtap.tar.zst", + "dependencies": [ + "plpgsql" + ], + "id": "pgtap", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": null, + "native-support-modules": [], + "sql-name": "pgtap" + }, + { + "archive": "extensions/postgis.tar.zst", + "dependencies": [], + "id": "postgis", + "lifecycle": { + "create-extension": true, + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [ + "lib/postgresql/postgis-3.so" + ], + "native-module-file": "postgis-3.so", + "native-support-modules": [ + { + "aot-file": "postgis_deps-llvm-opta.bin.zst", + "build-path": "postgis/postgis/liboliphaunt_postgis_deps.so", + "name": "postgis_deps", + "runtime-path": "lib/postgresql/liboliphaunt_postgis_deps.so" + } + ], + "sql-name": "postgis" + }, + { + "archive": "extensions/seg.tar.zst", + "dependencies": [], + "id": "seg", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "seg.so", + "native-support-modules": [], + "sql-name": "seg" + }, + { + "archive": "extensions/tablefunc.tar.zst", + "dependencies": [], + "id": "tablefunc", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "tablefunc.so", + "native-support-modules": [], + "sql-name": "tablefunc" + }, + { + "archive": "extensions/tcn.tar.zst", + "dependencies": [], + "id": "tcn", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "tcn.so", + "native-support-modules": [], + "sql-name": "tcn" + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "dependencies": [], + "id": "tsm_system_rows", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "tsm_system_rows.so", + "native-support-modules": [], + "sql-name": "tsm_system_rows" + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "dependencies": [], + "id": "tsm_system_time", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "tsm_system_time.so", + "native-support-modules": [], + "sql-name": "tsm_system_time" + }, + { + "archive": "extensions/unaccent.tar.zst", + "dependencies": [], + "id": "unaccent", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "unaccent.so", + "native-support-modules": [], + "sql-name": "unaccent" + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "dependencies": [], + "id": "uuid_ossp", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "uuid-ossp.so", + "native-support-modules": [], + "sql-name": "uuid-ossp" + }, + { + "archive": "extensions/vector.tar.zst", + "dependencies": [], + "id": "vector", + "lifecycle": { + "create-extension": true, + "create-schema": "pg_catalog", + "load-sql": [], + "post-create-sql": [], + "preload-required": false, + "restart-required": false, + "shared-memory-required": false, + "startup-config": [] + }, + "load-order": [], + "native-module-file": "vector.so", + "native-support-modules": [], + "sql-name": "vector" + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-definitions", + "path": "src/extensions/external" + } + ] +} diff --git a/src/extensions/model/moon.yml b/src/extensions/model/moon.yml new file mode 100644 index 00000000..5e55e6eb --- /dev/null +++ b/src/extensions/model/moon.yml @@ -0,0 +1,31 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extension-model" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "model"] +dependsOn: + - id: "extensions" + scope: "build" + +project: + title: "Extension Model" + description: "Exact SQL extension catalog, recipe, generated metadata, and policy checks." + owner: "oliphaunt" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/extensions/tools/check-extension-model.py --check" + deps: + - "extensions:check" + inputs: + - "/src/extensions/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/extensions/moon.yml b/src/extensions/moon.yml new file mode 100644 index 00000000..000af1f1 --- /dev/null +++ b/src/extensions/moon.yml @@ -0,0 +1,164 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extensions" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["extensions", "catalog", "assets"] +dependsOn: + - id: "postgres18" + scope: "build" + - id: "extension-runtime-contract" + scope: "build" + - id: "extension-contrib-postgres18" + scope: "build" + - id: "extension-age" + scope: "build" + - id: "oliphaunt-extension-amcheck" + scope: "build" + - id: "oliphaunt-extension-auto-explain" + scope: "build" + - id: "oliphaunt-extension-bloom" + scope: "build" + - id: "oliphaunt-extension-btree-gin" + scope: "build" + - id: "oliphaunt-extension-btree-gist" + scope: "build" + - id: "oliphaunt-extension-citext" + scope: "build" + - id: "oliphaunt-extension-cube" + scope: "build" + - id: "oliphaunt-extension-dict-int" + scope: "build" + - id: "oliphaunt-extension-dict-xsyn" + scope: "build" + - id: "oliphaunt-extension-earthdistance" + scope: "build" + - id: "oliphaunt-extension-file-fdw" + scope: "build" + - id: "oliphaunt-extension-fuzzystrmatch" + scope: "build" + - id: "oliphaunt-extension-hstore" + scope: "build" + - id: "oliphaunt-extension-intarray" + scope: "build" + - id: "oliphaunt-extension-isn" + scope: "build" + - id: "oliphaunt-extension-lo" + scope: "build" + - id: "oliphaunt-extension-ltree" + scope: "build" + - id: "oliphaunt-extension-pageinspect" + scope: "build" + - id: "oliphaunt-extension-pg-buffercache" + scope: "build" + - id: "oliphaunt-extension-pg-freespacemap" + scope: "build" + - id: "oliphaunt-extension-pg-surgery" + scope: "build" + - id: "oliphaunt-extension-pg-trgm" + scope: "build" + - id: "oliphaunt-extension-pg-visibility" + scope: "build" + - id: "oliphaunt-extension-pg-walinspect" + scope: "build" + - id: "oliphaunt-extension-pgcrypto" + scope: "build" + - id: "oliphaunt-extension-seg" + scope: "build" + - id: "oliphaunt-extension-tablefunc" + scope: "build" + - id: "oliphaunt-extension-tcn" + scope: "build" + - id: "oliphaunt-extension-tsm-system-rows" + scope: "build" + - id: "oliphaunt-extension-tsm-system-time" + scope: "build" + - id: "oliphaunt-extension-unaccent" + scope: "build" + - id: "oliphaunt-extension-uuid-ossp" + scope: "build" + - id: "oliphaunt-extension-pg-hashids" + scope: "build" + - id: "oliphaunt-extension-pg-ivm" + scope: "build" + - id: "oliphaunt-extension-pg-textsearch" + scope: "build" + - id: "oliphaunt-extension-pg-uuidv7" + scope: "build" + - id: "oliphaunt-extension-pgtap" + scope: "build" + - id: "oliphaunt-extension-postgis" + scope: "build" + - id: "oliphaunt-extension-vector" + scope: "build" + +project: + title: "Extension Catalog" + description: "Exact SQL extension catalog, build plans, and generated tables." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "node tools/policy/check-source-inputs.mjs extensions" + deps: + - "postgres18:check" + - "extension-runtime-contract:check" + - "extension-contrib-postgres18:check" + - "extension-age:check" + - "oliphaunt-extension-amcheck:check" + - "oliphaunt-extension-auto-explain:check" + - "oliphaunt-extension-bloom:check" + - "oliphaunt-extension-btree-gin:check" + - "oliphaunt-extension-btree-gist:check" + - "oliphaunt-extension-citext:check" + - "oliphaunt-extension-cube:check" + - "oliphaunt-extension-dict-int:check" + - "oliphaunt-extension-dict-xsyn:check" + - "oliphaunt-extension-earthdistance:check" + - "oliphaunt-extension-file-fdw:check" + - "oliphaunt-extension-fuzzystrmatch:check" + - "oliphaunt-extension-hstore:check" + - "oliphaunt-extension-intarray:check" + - "oliphaunt-extension-isn:check" + - "oliphaunt-extension-lo:check" + - "oliphaunt-extension-ltree:check" + - "oliphaunt-extension-pageinspect:check" + - "oliphaunt-extension-pg-buffercache:check" + - "oliphaunt-extension-pg-freespacemap:check" + - "oliphaunt-extension-pg-surgery:check" + - "oliphaunt-extension-pg-trgm:check" + - "oliphaunt-extension-pg-visibility:check" + - "oliphaunt-extension-pg-walinspect:check" + - "oliphaunt-extension-pgcrypto:check" + - "oliphaunt-extension-seg:check" + - "oliphaunt-extension-tablefunc:check" + - "oliphaunt-extension-tcn:check" + - "oliphaunt-extension-tsm-system-rows:check" + - "oliphaunt-extension-tsm-system-time:check" + - "oliphaunt-extension-unaccent:check" + - "oliphaunt-extension-uuid-ossp:check" + - "oliphaunt-extension-pg-hashids:check" + - "oliphaunt-extension-pg-ivm:check" + - "oliphaunt-extension-pg-textsearch:check" + - "oliphaunt-extension-pg-uuidv7:check" + - "oliphaunt-extension-pgtap:check" + - "oliphaunt-extension-postgis:check" + - "oliphaunt-extension-vector:check" + inputs: + - "/src/postgres/versions/18/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/extensions/**/*" + - "/tools/xtask/**/*" + - "/tools/policy/check-source-inputs.mjs" + - "/Cargo.lock" + - "/Cargo.toml" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/extensions/schemas/recipe.schema.json b/src/extensions/schemas/recipe.schema.json new file mode 100644 index 00000000..ffe970c2 --- /dev/null +++ b/src/extensions/schemas/recipe.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oliphaunt.dev/schemas/extensions/recipe.schema.json", + "title": "Oliphaunt Extension Recipe", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "sql_name", + "display_name", + "kind", + "license", + "source", + "postgres_majors", + "lifecycle", + "artifacts", + "support" + ], + "properties": { + "schema": { "const": "oliphaunt-extension-recipe-v1" }, + "sql_name": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*$" }, + "display_name": { "type": "string", "minLength": 1 }, + "kind": { + "type": "string", + "enum": ["external-simple-pgxs", "external-complex", "postgres-contrib"] + }, + "license": { "type": "string", "minLength": 1 }, + "source": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*$" }, + "postgres_majors": { + "type": "array", + "minItems": 1, + "items": { "type": "integer", "minimum": 18 } + }, + "lifecycle": { "type": "object" }, + "artifacts": { "type": "object" }, + "support": { + "type": "object", + "additionalProperties": { "type": "object" } + } + } +} diff --git a/src/extensions/schemas/support-table.schema.json b/src/extensions/schemas/support-table.schema.json new file mode 100644 index 00000000..863e67af --- /dev/null +++ b/src/extensions/schemas/support-table.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oliphaunt.dev/schemas/extensions/support-table.schema.json", + "title": "Oliphaunt Extension Support Table", + "type": "object", + "additionalProperties": false, + "required": ["format-version", "generated-from", "extensions"], + "properties": { + "format-version": { "const": 1 }, + "generated-from": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "path"], + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" } + } + } + }, + "extensions": { + "type": "array", + "items": { + "type": "object", + "required": ["sql-name", "display-name", "family", "public", "stable", "smoke"], + "properties": { + "id": { "type": "string" }, + "sql-name": { "type": "string" }, + "display-name": { "type": "string" }, + "version": { "type": "string" }, + "family": { "type": "string" }, + "public": { "type": "boolean" }, + "stable": { "type": "boolean" }, + "packaged": { "type": "boolean" }, + "promoted": { "type": "boolean" }, + "desktop-release-ready": { "type": "boolean" }, + "mobile-release-ready": { "type": "boolean" }, + "target-status": { + "type": "object", + "additionalProperties": false, + "properties": { + "native": { "type": ["string", "null"] }, + "wasix": { "type": ["string", "null"] }, + "mobile": { "type": ["string", "null"] } + } + }, + "support": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "archive": { "type": "string" }, + "blocker": { "type": "string" }, + "activation": { "type": "string" }, + "dependencies": { "type": "array", "items": { "type": "string" } }, + "native-dependencies": { "type": "array", "items": { "type": "string" } }, + "smoke": { "type": "object" } + } + } + } + } +} diff --git a/src/extensions/tools/check-extension-model.py b/src/extensions/tools/check-extension-model.py new file mode 100755 index 00000000..96f8bb1d --- /dev/null +++ b/src/extensions/tools/check-extension-model.py @@ -0,0 +1,2095 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path +from tempfile import TemporaryDirectory + +ROOT = Path(__file__).resolve().parents[3] +PROMOTED = ROOT / "src/extensions/catalog/extensions.promoted.toml" +SMOKE = ROOT / "src/extensions/catalog/extensions.smoke.toml" +CATALOG = ROOT / "src/extensions/generated/extensions.catalog.json" +BUILD_PLAN = ROOT / "src/extensions/generated/extensions.build-plan.json" +CONTRIB_RECIPE = ROOT / "src/extensions/contrib/postgres18.toml" +RECIPE_SCHEMA = ROOT / "src/extensions/schemas/recipe.schema.json" +SUPPORT_SCHEMA = ROOT / "src/extensions/schemas/support-table.schema.json" +SUPPORT_TABLE = ROOT / "src/extensions/generated/docs/extensions.json" +EVIDENCE_MATRIX = ROOT / "src/extensions/evidence/matrix.toml" +EVIDENCE_RUN_SCHEMA = ROOT / "src/extensions/evidence/schemas/run.schema.json" +EVIDENCE_MATRIX_SCHEMA = ROOT / "src/extensions/evidence/schemas/matrix.schema.json" +EVIDENCE_RUNS = ROOT / "src/extensions/evidence/runs" +EVIDENCE_TABLE = ROOT / "src/extensions/generated/docs/extension-evidence.json" +THIRD_PARTY_ROOT = ROOT / "src/sources/third-party" +EXTERNAL_ROOT = ROOT / "src/extensions/external" +GENERATED_SDKS = { + "rust": ROOT / "src/extensions/generated/sdk/rust.json", + "swift": ROOT / "src/extensions/generated/sdk/swift.json", + "kotlin": ROOT / "src/extensions/generated/sdk/kotlin.json", + "js": ROOT / "src/extensions/generated/sdk/js.json", + "react-native": ROOT / "src/extensions/generated/sdk/react-native.json", +} +GENERATED_RUST_SDK_MODULE = ROOT / "src/sdks/rust/src/generated/extensions.rs" +GENERATED_TS_SDK_MODULE = ROOT / "src/sdks/js/src/generated/extensions.ts" +GENERATED_KOTLIN_SDK_METADATA = ROOT / "src/sdks/kotlin/oliphaunt/src/generated/extensions.json" +GENERATED_RN_SDK_MODULE = ROOT / "src/sdks/react-native/src/generated/extensions.ts" +GENERATED_RN_PLUGIN_METADATA = ROOT / "src/sdks/react-native/src/generated/extensions.json" +GENERATED_MOBILE_REGISTRY = ROOT / "src/extensions/generated/mobile/static-registry.json" +GENERATED_MOBILE_STATIC_SPECS = ROOT / "src/extensions/generated/mobile/static-extensions.tsv" +GENERATED_WASIX_METADATA = ROOT / "src/extensions/generated/wasix/extensions.json" +BIOME_VERSION = "2.4.16" + +RUST_INTERNAL_EXTENSION_CANDIDATES = [ + { + "id": "graph", + "sql-name": "graph", + "rust-constant": "GRAPH", + "creates-extension": True, + "native-module-stem": "graph", + "selected-extension-dependencies": [], + "runtime-share-data-files": [], + "shared-preload-libraries": [], + "first-party": False, + "mobile-release-ready": False, + "external-policy": { + "upstream": "https://github.com/evokoa/pggraph", + "license": "Apache-2.0", + "source-kind": "Pgrx", + "redistribution": "Allowed", + "requires-shared-preload": False, + "notes": "Optional shared_preload_libraries='graph' enables startup _PG_init behavior; background-worker maintenance paths must be tested per engine mode.", + }, + }, + { + "id": "pg_search", + "sql-name": "pg_search", + "rust-constant": "PG_SEARCH", + "creates-extension": True, + "native-module-stem": "pg_search", + "selected-extension-dependencies": [], + "runtime-share-data-files": [], + "shared-preload-libraries": ["pg_search"], + "first-party": False, + "mobile-release-ready": False, + "external-policy": { + "upstream": "https://github.com/paradedb/paradedb", + "license": "AGPL-3.0 community edition", + "source-kind": "Pgrx", + "redistribution": "RequiresCommercialLicense", + "requires-shared-preload": True, + "notes": "ParadeDB pg_search requires shared_preload_libraries='pg_search', registers preload-time WAL machinery, and uses PostgreSQL parallel workers.", + }, + }, +] + +BASE_SOURCE_DIGEST_INPUTS = [ + "src/postgres/versions/18/source.toml", + "src/extensions/catalog/extensions.promoted.toml", + "src/extensions/catalog/extensions.smoke.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/generated/extensions.catalog.json", + "src/extensions/generated/extensions.build-plan.json", + "src/extensions/generated/contrib-build.tsv", + "src/extensions/generated/pgxs-build.tsv", +] + +ID_RE = re.compile(r"^[a-z][a-z0-9_]*$") +SQL_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$") +SMOKE_STATUSES = {"passed", "failed", "not-run", "blocked"} +SUPPORT_STATUSES = {"unsupported", "candidate", "experimental", "supported"} + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def ensure_trailing_newline(text: str) -> str: + return text if text.endswith("\n") else f"{text}\n" + + +def format_rust_source(source: str) -> str: + try: + return ensure_trailing_newline( + subprocess.check_output( + ["rustfmt", "--emit", "stdout"], + cwd=ROOT, + input=source, + text=True, + ) + ) + except (FileNotFoundError, subprocess.CalledProcessError) as error: + fail(f"failed to format generated Rust extension metadata with rustfmt: {error}") + + +def format_typescript_source(source: str, path: Path) -> str: + pnpm = shutil.which("pnpm") or shutil.which("pnpm.cmd") + if pnpm is None: + fail(f"failed to format generated TypeScript extension metadata with Biome {BIOME_VERSION}: pnpm was not found") + try: + return ensure_trailing_newline( + subprocess.check_output( + [ + pnpm, + f"--package=@biomejs/biome@{BIOME_VERSION}", + "dlx", + "biome", + "format", + "--stdin-file-path", + rel(path), + ], + cwd=ROOT, + input=source, + text=True, + ) + ) + except (FileNotFoundError, subprocess.CalledProcessError) as error: + fail(f"failed to format generated TypeScript extension metadata with Biome {BIOME_VERSION}: {error}") + + +def rel(path: Path) -> str: + try: + return path.relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def read_toml(path: Path) -> dict: + try: + with path.open("rb") as handle: + return tomllib.load(handle) + except tomllib.TOMLDecodeError as error: + fail(f"{rel(path)} is invalid TOML: {error}") + + +def read_json(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + fail(f"{rel(path)} is invalid JSON: {error}") + + +def source_pin_paths() -> list[Path]: + if not THIRD_PARTY_ROOT.is_dir(): + fail(f"{rel(THIRD_PARTY_ROOT)} must exist") + if not EXTERNAL_ROOT.is_dir(): + fail(f"{rel(EXTERNAL_ROOT)} must exist") + paths = [ + path + for path in THIRD_PARTY_ROOT.glob("**/*.toml") + if path.is_file() + ] + paths.extend( + path + for path in EXTERNAL_ROOT.glob("**/source.toml") + if path.is_file() + ) + return sorted(paths, key=rel) + + +def normalized_rel_list(values: object, label: str) -> list[str]: + if not isinstance(values, list) or not all(isinstance(value, str) for value in values): + fail(f"{label} must be a list of repository-relative paths") + return [value.replace("\\", "/") for value in values] + + +def load_source_names() -> set[str]: + source_names: set[str] = set() + for path in source_pin_paths(): + data = read_toml(path) + name = data.get("name") + if not isinstance(name, str) or not name: + fail(f"{rel(path)} must declare a source name") + if name in source_names: + fail(f"duplicate source pin {name} across source metadata") + source_names.add(name) + if not source_names: + fail("source metadata must contain at least one source pin") + return source_names + + +def source_digest_inputs() -> list[str]: + source_files = [rel(path) for path in source_pin_paths()] + recipe_files = sorted( + rel(path) + for path in EXTERNAL_ROOT.glob("**/*") + if path.is_file() + and path.name != "source.toml" + ) + return [*BASE_SOURCE_DIGEST_INPUTS, *source_files, *recipe_files] + + +def source_digest(paths: list[str] | None = None) -> str: + paths = source_digest_inputs() if paths is None else paths + digest = hashlib.sha256() + for relative in paths: + path = ROOT / relative + if not path.exists(): + fail(f"source digest input is missing: {relative}") + contents = path.read_bytes().replace(b"\r\n", b"\n").replace(b"\r", b"\n") + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(contents) + digest.update(b"\0") + return f"sha256:{digest.hexdigest()}" + + +def validate_id(value: object, label: str) -> str: + if not isinstance(value, str) or ID_RE.fullmatch(value) is None: + fail(f"{label} must be a lower snake-case extension id, got {value!r}") + return value + + +def validate_sql_name(value: object, label: str) -> str: + if not isinstance(value, str) or SQL_NAME_RE.fullmatch(value) is None: + fail(f"{label} must be an exact SQL extension name, got {value!r}") + return value + + +def extension_rows(path: Path) -> list[dict]: + data = read_toml(path) + if data.get("format-version") != 1: + fail(f"{rel(path)} must use format-version = 1") + rows = data.get("extensions") + if not isinstance(rows, list) or not rows: + fail(f"{rel(path)} must define [[extensions]] rows") + return rows + + +def validate_catalog_rows() -> None: + promoted = extension_rows(PROMOTED) + smoke = extension_rows(SMOKE) + promoted_ids: set[str] = set() + for row in promoted: + extension_id = validate_id(row.get("id"), f"{rel(PROMOTED)} row id") + if extension_id in promoted_ids: + fail(f"{rel(PROMOTED)} has duplicate extension id {extension_id}") + promoted_ids.add(extension_id) + unexpected = sorted(key for key in row if "pack" in key or "bundle" in key or "alias" in key) + if unexpected: + fail(f"{rel(PROMOTED)} row {extension_id} must not use pack/bundle/alias keys: {unexpected}") + build = row.get("build", True) + stable = row.get("stable", False) + blocker = row.get("blocker") + if not isinstance(build, bool): + fail(f"{rel(PROMOTED)} row {extension_id} build must be boolean when present") + if not isinstance(stable, bool): + fail(f"{rel(PROMOTED)} row {extension_id} stable must be boolean when present") + if (not build or not stable) and not isinstance(blocker, str): + fail(f"{rel(PROMOTED)} row {extension_id} must explain non-release status with blocker") + + smoke_ids: set[str] = set() + for row in smoke: + extension_id = validate_id(row.get("id"), f"{rel(SMOKE)} row id") + if extension_id in smoke_ids: + fail(f"{rel(SMOKE)} has duplicate extension id {extension_id}") + smoke_ids.add(extension_id) + for field in ("direct", "server", "restart", "dump-restore"): + status = row.get(field, "not-run") + if status not in SMOKE_STATUSES: + fail(f"{rel(SMOKE)} row {extension_id} has invalid {field} status {status!r}") + + missing_smoke = sorted(promoted_ids - smoke_ids) + extra_smoke = sorted(smoke_ids - promoted_ids) + if missing_smoke: + fail(f"{rel(SMOKE)} is missing rows for promoted catalog ids: {missing_smoke}") + if extra_smoke: + fail(f"{rel(SMOKE)} has rows for unknown promoted catalog ids: {extra_smoke}") + + +def validate_contrib_recipe(build_plan: dict) -> None: + data = read_toml(CONTRIB_RECIPE) + if data.get("format-version") != 1: + fail(f"{rel(CONTRIB_RECIPE)} must use format-version = 1") + if data.get("postgres-version") != "18.4": + fail(f"{rel(CONTRIB_RECIPE)} must target PostgreSQL 18.4") + if data.get("source-kind") != "postgres-contrib": + fail(f"{rel(CONTRIB_RECIPE)} must declare source-kind = postgres-contrib") + if data.get("source-root") != "src/postgres/versions/18/contrib": + fail(f"{rel(CONTRIB_RECIPE)} must point at src/postgres/versions/18/contrib") + rows = data.get("extensions") + if not isinstance(rows, list) or not rows: + fail(f"{rel(CONTRIB_RECIPE)} must declare contrib extension rows") + recipe_by_id: dict[str, dict] = {} + for row in rows: + extension_id = validate_id(row.get("id"), f"{rel(CONTRIB_RECIPE)} row id") + validate_sql_name(row.get("sql-name"), f"{rel(CONTRIB_RECIPE)} row {extension_id} sql-name") + for field in ("contrib-dir", "module-file"): + if not isinstance(row.get(field), str) or not row[field]: + fail(f"{rel(CONTRIB_RECIPE)} row {extension_id} must define {field}") + data_files = row.get("data-files", []) + if not isinstance(data_files, list) or not all(isinstance(value, str) for value in data_files): + fail(f"{rel(CONTRIB_RECIPE)} row {extension_id} data-files must be an array of strings when present") + for recipe_field in ( + "mobile-static-dependencies", + "mobile-static-include-dependencies", + "mobile-static-include-dirs", + "mobile-static-cflags", + "mobile-static-hash-source-dependencies", + "mobile-static-hash-dirs", + ): + values = row.get(recipe_field, []) + if not isinstance(values, list) or not all(isinstance(value, str) and value for value in values): + fail( + f"{rel(CONTRIB_RECIPE)} row {extension_id} {recipe_field} " + "must be an array of strings when present" + ) + if extension_id in recipe_by_id: + fail(f"{rel(CONTRIB_RECIPE)} has duplicate extension id {extension_id}") + recipe_by_id[extension_id] = row + + plan_rows = [ + row for row in build_plan.get("extensions", []) if row.get("build-kind") == "postgres-contrib" + ] + plan_by_id = {validate_id(row.get("id"), f"{rel(BUILD_PLAN)} row id"): row for row in plan_rows} + if sorted(recipe_by_id) != sorted(plan_by_id): + fail( + f"{rel(CONTRIB_RECIPE)} ids must match generated contrib build plan; " + f"recipe-only={sorted(set(recipe_by_id) - set(plan_by_id))}, " + f"plan-only={sorted(set(plan_by_id) - set(recipe_by_id))}" + ) + for extension_id, plan in plan_by_id.items(): + recipe = recipe_by_id[extension_id] + expected = { + "sql-name": plan.get("sql-name"), + "contrib-dir": plan.get("contrib-dir"), + "module-file": plan.get("module-file"), + } + for field, value in expected.items(): + if recipe.get(field) != value: + fail( + f"{rel(CONTRIB_RECIPE)} row {extension_id} {field}={recipe.get(field)!r} " + f"does not match generated build plan {value!r}" + ) + + +def validate_external_recipes() -> None: + source_names = load_source_names() + build_plan = read_json(BUILD_PLAN) + build_by_sql_name = { + row.get("sql-name", row.get("id")): row + for row in build_plan.get("extensions", []) + if isinstance(row, dict) + } + validate_external_source_pins(build_by_sql_name, source_names) + for recipe in sorted(EXTERNAL_ROOT.glob("*/recipe.toml")): + data = read_toml(recipe) + if data.get("schema") != "oliphaunt-extension-recipe-v1": + fail(f"{rel(recipe)} must use schema = oliphaunt-extension-recipe-v1") + sql_name = validate_sql_name(data.get("sql_name"), f"{rel(recipe)} sql_name") + if recipe.parent.name != sql_name: + fail(f"{rel(recipe)} directory name must match sql_name {sql_name}") + kind = data.get("kind") + if kind not in {"external-simple-pgxs", "external-complex"}: + fail(f"{rel(recipe)} kind must be external-simple-pgxs or external-complex") + source = data.get("source") + if source not in source_names: + fail(f"{rel(recipe)} source {source!r} must reference source metadata") + majors = data.get("postgres_majors") + if not isinstance(majors, list) or 18 not in majors: + fail(f"{rel(recipe)} must explicitly support postgres_majors including 18") + if not isinstance(data.get("license"), str) or not data["license"]: + fail(f"{rel(recipe)} must declare license metadata") + lifecycle = data.get("lifecycle") + artifacts = data.get("artifacts") + support = data.get("support") + if not isinstance(lifecycle, dict) or not isinstance(artifacts, dict) or not isinstance(support, dict): + fail(f"{rel(recipe)} must declare lifecycle, artifacts, and support tables") + runtime_environment = data.get("runtime_environment") or [] + if not isinstance(runtime_environment, list): + fail(f"{rel(recipe)} runtime_environment must be an array when present") + for index, entry in enumerate(runtime_environment): + if not isinstance(entry, dict): + fail(f"{rel(recipe)} runtime_environment[{index}] must be a table") + for field in ("name", "path", "required_file"): + if not isinstance(entry.get(field), str) or not entry[field]: + fail(f"{rel(recipe)} runtime_environment[{index}].{field} must be a non-empty string") + for field in ( + "requires", + "implicit_sql_dependencies", + "load_sql", + "post_create_sql", + "shared_preload_libraries", + ): + if not isinstance(lifecycle.get(field), list): + fail(f"{rel(recipe)} lifecycle.{field} must be an array") + for field in ( + "creates_extension", + "restart_required", + "background_workers", + "shared_memory", + "session_load_required", + "needs_superuser", + "trusted", + ): + if not isinstance(lifecycle.get(field), bool): + fail(f"{rel(recipe)} lifecycle.{field} must be boolean") + for field in ( + "control_files", + "sql_globs", + "native_modules", + "native_dependency_modules", + "data_files", + "headers", + "licenses", + ): + if not isinstance(artifacts.get(field), list): + fail(f"{rel(recipe)} artifacts.{field} must be an array") + for field in ("extension_sql_file_prefixes", "extension_sql_file_names"): + if field in artifacts and not isinstance(artifacts.get(field), list): + fail(f"{rel(recipe)} artifacts.{field} must be an array when present") + for family, claims in support.items(): + if not isinstance(claims, dict): + fail(f"{rel(recipe)} support.{family} must be a table") + for mode, status in claims.items(): + if status not in SUPPORT_STATUSES: + fail(f"{rel(recipe)} support.{family}.{mode} has invalid status {status!r}") + + tests = recipe.parent / "tests" + for path in (tests / "smoke.sql", tests / "upstream.toml"): + if not path.exists(): + fail(f"{rel(recipe)} must provide {rel(path)}") + if "-- oliphaunt-statement" not in (tests / "smoke.sql").read_text(encoding="utf-8"): + fail(f"{rel(tests / 'smoke.sql')} must include explicit statement delimiters") + + if kind == "external-complex": + for path in ( + recipe.parent / "deps.toml", + recipe.parent / "targets/native.toml", + recipe.parent / "targets/wasix.toml", + recipe.parent / "targets/native-static-registry.toml", + recipe.parent / "patches/README.md", + recipe.parent / "blockers.toml", + ): + if not path.exists(): + fail(f"{rel(recipe)} complex recipe is missing {rel(path)}") + deps = read_toml(recipe.parent / "deps.toml") + declared_deps = [ + row.get("name") + for row in deps.get("dependencies", []) + if isinstance(row, dict) and isinstance(row.get("name"), str) + ] + if len(declared_deps) != len(set(declared_deps)): + fail(f"{rel(recipe.parent / 'deps.toml')} has duplicate dependency names") + missing_source_pins = sorted(set(declared_deps) - source_names) + if missing_source_pins: + fail( + f"{rel(recipe.parent / 'deps.toml')} references sources missing from source metadata: " + f"{missing_source_pins}" + ) + for dependency in deps.get("dependencies", []): + if not isinstance(dependency, dict) or not dependency.get("license"): + fail(f"{rel(recipe.parent / 'deps.toml')} dependencies must include license metadata") + + generated = build_by_sql_name.get(sql_name) + if generated is None: + fail(f"{rel(recipe)} has no matching generated build-plan row") + if generated.get("source-kind") != "postgis" and kind == "external-complex": + fail(f"{rel(recipe)} complex recipe must match generated source-kind postgis") + generated_modules = set(generated.get("load-order") or []) + for module in artifacts.get("native_modules", []): + if module not in generated_modules: + fail(f"{rel(recipe)} native module {module!r} must match generated load-order") + + +def validate_external_source_pins(build_by_sql_name: dict[str, dict], source_names: set[str]) -> None: + for source_path in sorted(EXTERNAL_ROOT.glob("*/source.toml")): + extension_dir = source_path.parent + sql_name = validate_sql_name(extension_dir.name, f"{rel(source_path)} directory") + source = read_toml(source_path) + name = source.get("name") + if not isinstance(name, str) or name not in source_names: + fail(f"{rel(source_path)} must declare a valid source name") + if sql_name not in build_by_sql_name: + continue + generated = build_by_sql_name[sql_name] + generated_source_dir = generated.get("source-dir") + if isinstance(generated_source_dir, str) and generated_source_dir: + expected_checkout = f"target/oliphaunt-sources/checkouts/{name}" + if generated_source_dir != expected_checkout: + fail( + f"{rel(source_path)} source name {name!r} implies checkout " + f"{expected_checkout}, but generated build plan uses {generated_source_dir}" + ) + + +def extension_family(source_kind: object) -> str: + return { + "postgres-contrib": "PostgreSQL contrib", + "oliphaunt-other-extension": "External PGXS", + "postgis": "Complex external", + }.get(str(source_kind), "Other") + + +def extension_activation(extension: dict) -> str: + lifecycle = extension.get("lifecycle", {}) + create_extension = bool(lifecycle.get("create-extension")) + load_sql = lifecycle.get("load-sql") or [] + if create_extension and load_sql: + return "CREATE EXTENSION + LOAD" + if create_extension: + return "CREATE EXTENSION" + if load_sql: + return "LOAD" + return "manual" + + +def extension_version(extension: dict) -> str: + control = extension.get("control") + if isinstance(control, dict): + version = control.get("default-version") + if isinstance(version, str) and "@" not in version: + return version + return "" + + +def native_module_stem(extension: dict) -> str | None: + module_file = extension.get("native-module-file") or extension.get("module-file") + if not isinstance(module_file, str) or not module_file: + return None + for suffix in (".so", ".dylib", ".dll"): + if module_file.endswith(suffix): + return module_file[: -len(suffix)] + return module_file + + +def shared_preload_libraries(extension: dict) -> list[str]: + lifecycle = extension.get("lifecycle") or {} + values = [] + for assignment in lifecycle.get("startup-config") or []: + if not isinstance(assignment, str): + continue + key, separator, value = assignment.partition("=") + if separator and key == "shared_preload_libraries": + values.extend(part.strip() for part in value.split(",") if part.strip()) + return sorted(set(values)) + + +def extension_data_files_from_recipe(extension: dict) -> list[str]: + sql_name = extension.get("sql-name", extension.get("id")) + if not isinstance(sql_name, str): + return [] + recipe = ROOT / "src/extensions/external" / sql_name / "recipe.toml" + if not recipe.exists(): + contrib_rows = read_toml(CONTRIB_RECIPE).get("extensions") or [] + for row in contrib_rows: + if isinstance(row, dict) and row.get("sql-name") == sql_name: + data_files = row.get("data-files") or [] + return sorted(value for value in data_files if isinstance(value, str)) + return [] + artifacts = read_toml(recipe).get("artifacts") or {} + data_files = artifacts.get("data_files") or [] + return sorted(value for value in data_files if isinstance(value, str)) + + +def extension_artifact_list_from_recipe(extension: dict, field: str) -> list[str]: + sql_name = extension.get("sql-name", extension.get("id")) + if not isinstance(sql_name, str): + return [] + recipe = ROOT / "src/extensions/external" / sql_name / "recipe.toml" + if not recipe.exists(): + return [] + artifacts = read_toml(recipe).get("artifacts") or {} + values = artifacts.get(field) or [] + return sorted(value for value in values if isinstance(value, str)) + + +def extension_runtime_environment_from_recipe(extension: dict) -> list[dict[str, str]]: + sql_name = extension.get("sql-name", extension.get("id")) + if not isinstance(sql_name, str): + return [] + recipe = ROOT / "src/extensions/external" / sql_name / "recipe.toml" + if not recipe.exists(): + return [] + rows = read_toml(recipe).get("runtime_environment") or [] + env = [] + for row in rows: + if not isinstance(row, dict): + continue + name = row.get("name") + path = row.get("path") + required_file = row.get("required_file") + if all(isinstance(value, str) and value for value in (name, path, required_file)): + env.append({"name": name, "path": path, "required_file": required_file}) + return sorted(env, key=lambda row: (row["name"], row["path"], row["required_file"])) + + +def runtime_share_data_files(data_files: list[str]) -> list[str]: + prefix = "share/postgresql/" + return sorted(value[len(prefix) :] if value.startswith(prefix) else value for value in data_files) + + +def contrib_recipe_row(sql_name: str) -> dict | None: + for row in read_toml(CONTRIB_RECIPE).get("extensions") or []: + if isinstance(row, dict) and row.get("sql-name") == sql_name: + return row + return None + + +def validate_string_list(values: object, label: str) -> list[str]: + if values is None: + return [] + if not isinstance(values, list) or not all(isinstance(value, str) and value for value in values): + fail(f"{label} must be an array of non-empty strings") + return values + + +def external_mobile_dependency_archive_map(sql_name: str) -> dict[str, list[str]]: + deps_path = ROOT / "src/extensions/external" / sql_name / "deps.toml" + if not deps_path.exists(): + return {} + archive_map: dict[str, list[str]] = {} + for row in read_toml(deps_path).get("dependencies") or []: + if not isinstance(row, dict): + fail(f"{rel(deps_path)} dependencies must be tables") + name = row.get("name") + if not isinstance(name, str) or not name: + fail(f"{rel(deps_path)} dependency rows must define a name") + archives = validate_string_list( + row.get("mobile-static-dependencies"), + f"{rel(deps_path)} dependency {name} mobile-static-dependencies", + ) + archive_map[name] = archives or [name] + return archive_map + + +def expand_mobile_static_dependencies( + sql_name: str, + dependency_names: list[str], + archive_map: dict[str, list[str]], +) -> list[str]: + archives: list[str] = [] + for dependency in dependency_names: + if dependency not in archive_map: + fail(f"mobile static dependency {dependency!r} for {sql_name} has no archive mapping") + archives.extend(archive_map[dependency]) + return sorted(dict.fromkeys(archives)) + + +def external_mobile_static_dependencies(sql_name: str, field: str) -> list[str]: + target_path = ROOT / "src/extensions/external" / sql_name / "targets/native-static-registry.toml" + if not target_path.exists(): + return [] + target = read_toml(target_path) + dependencies = validate_string_list(target.get(field), f"{rel(target_path)} {field}") + if not dependencies and field != "dependencies": + dependencies = validate_string_list(target.get("dependencies"), f"{rel(target_path)} dependencies") + return expand_mobile_static_dependencies( + sql_name, + dependencies, + external_mobile_dependency_archive_map(sql_name), + ) + + +def contrib_mobile_static_dependencies(sql_name: str) -> list[str]: + row = contrib_recipe_row(sql_name) + if row is None: + return [] + return sorted( + dict.fromkeys( + validate_string_list( + row.get("mobile-static-dependencies"), + f"{rel(CONTRIB_RECIPE)} row {sql_name} mobile-static-dependencies", + ) + ) + ) + + +def mobile_static_dependencies(sql_name: str, field: str = "dependencies") -> list[str]: + external = external_mobile_static_dependencies(sql_name, field) + if external: + return external + return contrib_mobile_static_dependencies(sql_name) + + +def contrib_mobile_static_list(sql_name: str, recipe_field: str) -> list[str]: + row = contrib_recipe_row(sql_name) + if row is None: + return [] + return sorted( + dict.fromkeys( + validate_string_list( + row.get(recipe_field), + f"{rel(CONTRIB_RECIPE)} row {sql_name} {recipe_field}", + ) + ) + ) + + +def external_mobile_target_list(sql_name: str, field: str) -> list[str]: + target_path = ROOT / "src/extensions/external" / sql_name / "targets/native-static-registry.toml" + if not target_path.exists(): + return [] + target = read_toml(target_path) + return sorted( + dict.fromkeys( + validate_string_list(target.get(field), f"{rel(target_path)} {field}") + ) + ) + + +def mobile_static_include_dependencies(sql_name: str) -> list[str]: + external = external_mobile_target_list(sql_name, "include_dependencies") + if external: + return external + return contrib_mobile_static_list(sql_name, "mobile-static-include-dependencies") + + +def mobile_static_include_dirs(sql_name: str) -> list[str]: + external = external_mobile_target_list(sql_name, "include_dirs") + if external: + return external + return contrib_mobile_static_list(sql_name, "mobile-static-include-dirs") + + +def mobile_static_cflags(sql_name: str) -> list[str]: + external = external_mobile_target_list(sql_name, "cflags") + if external: + return external + return contrib_mobile_static_list(sql_name, "mobile-static-cflags") + + +def mobile_static_hash_source_dependencies(sql_name: str, field: str = "dependencies") -> list[str]: + target_path = ROOT / "src/extensions/external" / sql_name / "targets/native-static-registry.toml" + if target_path.exists(): + target_field = { + "dependencies": "dependencies", + "ios_dependencies": "ios_dependencies", + "android_dependencies": "android_dependencies", + }[field] + target = read_toml(target_path) + dependencies = validate_string_list(target.get(target_field), f"{rel(target_path)} {target_field}") + if not dependencies and target_field != "dependencies": + dependencies = validate_string_list(target.get("dependencies"), f"{rel(target_path)} dependencies") + return sorted( + dict.fromkeys( + dependencies + ) + ) + return contrib_mobile_static_list(sql_name, "mobile-static-hash-source-dependencies") + + +def mobile_static_hash_dirs(sql_name: str) -> list[str]: + external = external_mobile_target_list(sql_name, "hash_dirs") + if external: + return external + return contrib_mobile_static_list(sql_name, "mobile-static-hash-dirs") + + +def mobile_static_source_files(sql_name: str) -> list[str]: + return external_mobile_target_list(sql_name, "source_files") + + +def mobile_static_source_recursive_dirs(sql_name: str) -> list[str]: + return external_mobile_target_list(sql_name, "source_recursive_dirs") + + +def external_target_data(sql_name: str, target: str) -> dict | None: + path = ROOT / "src/extensions/external" / sql_name / "targets" / f"{target}.toml" + if not path.exists(): + return None + data = read_toml(path) + status = data.get("status") + if status not in SUPPORT_STATUSES: + fail(f"{rel(path)} status has invalid value {status!r}") + return data + + +def external_target_status(sql_name: str, target: str) -> str | None: + data = external_target_data(sql_name, target) + if data is None: + return None + return str(data["status"]) + + +def external_recipe_support(sql_name: str) -> dict: + recipe = ROOT / "src/extensions/external" / sql_name / "recipe.toml" + if not recipe.exists(): + return {} + support = read_toml(recipe).get("support") or {} + return support if isinstance(support, dict) else {} + + +def extension_target_statuses(sql_name: str) -> dict[str, str | None]: + return { + "native": external_target_status(sql_name, "native"), + "wasix": external_target_status(sql_name, "wasix"), + "mobile": external_target_status(sql_name, "mobile"), + } + + +def extension_support_statuses(sql_name: str) -> dict: + support = external_recipe_support(sql_name) + return { + family: { + mode: status + for mode, status in claims.items() + if isinstance(mode, str) and status in SUPPORT_STATUSES + } + for family, claims in support.items() + if isinstance(family, str) and isinstance(claims, dict) + } + + +def mobile_release_ready(sql_name: str) -> bool: + target_status = external_target_status(sql_name, "mobile") + if target_status is not None: + return target_status == "supported" + + mobile_support = extension_support_statuses(sql_name).get("mobile") + if mobile_support: + return all(status == "supported" for status in mobile_support.values()) + + return True + + +def desktop_release_ready(sql_name: str, promotion: dict) -> bool: + if not (bool(promotion.get("promoted")) and bool(promotion.get("stable"))): + return False + + target_status = external_target_status(sql_name, "native") + if target_status is not None: + return target_status == "supported" + + native_support = extension_support_statuses(sql_name).get("native") + if native_support: + return all(status == "supported" for status in native_support.values()) + + return True + + +def target_native_support_modules(sql_name: str, target: str) -> list[dict]: + path = ROOT / "src/extensions/external" / sql_name / "targets" / f"{target}.toml" + if not path.exists(): + return [] + rows = read_toml(path).get("native_support_modules") or [] + modules = [] + for index, row in enumerate(rows): + if not isinstance(row, dict): + fail(f"{rel(path)} native_support_modules[{index}] must be a table") + module = {} + for field in ("name", "runtime_path", "build_path", "aot_file"): + value = row.get(field) + if not isinstance(value, str) or not value: + fail(f"{rel(path)} native_support_modules[{index}] must define {field}") + module[field.replace("_", "-")] = value + modules.append(module) + modules.sort(key=lambda module: module["name"]) + return modules + + +def generated_sdk_metadata(catalog: dict, sdk: str) -> dict: + rows = [] + public_sql_names = { + extension.get("sql-name", extension.get("id")) + for extension in catalog.get("extensions", []) + if (extension.get("promotion") or {}).get("promoted") is True + } + for extension in catalog.get("extensions", []): + promotion = extension.get("promotion") or {} + if promotion.get("promoted") is not True: + continue + data_files = extension_data_files_from_recipe(extension) + dependencies = extension.get("dependencies") or [] + sql_name = str(extension.get("sql-name", extension.get("id"))) + rows.append( + { + "id": extension.get("id"), + "sql-name": sql_name, + "display-name": extension.get("display-name", extension.get("id")), + "postgres-major": 18, + "creates-extension": bool((extension.get("lifecycle") or {}).get("create-extension")), + "native-module-stem": native_module_stem(extension), + "dependencies": dependencies, + "selected-extension-dependencies": sorted( + dependency for dependency in dependencies if dependency in public_sql_names + ), + "native-dependencies": extension.get("native-dependencies") or [], + "shared-preload-libraries": shared_preload_libraries(extension), + "data-files": data_files, + "runtime-share-data-files": runtime_share_data_files(data_files), + "extension-sql-file-prefixes": extension_artifact_list_from_recipe( + extension, "extension_sql_file_prefixes" + ), + "extension-sql-file-names": extension_artifact_list_from_recipe( + extension, "extension_sql_file_names" + ), + "runtime-environment": extension_runtime_environment_from_recipe(extension), + "public": bool(promotion.get("promoted")), + "stable": bool(promotion.get("stable")), + "desktop-release-ready": desktop_release_ready(sql_name, promotion), + "mobile-release-ready": mobile_release_ready(sql_name), + "target-status": extension_target_statuses(sql_name), + "support": extension_support_statuses(sql_name), + "source-kind": extension.get("source-kind"), + "archive": promotion.get("archive") or "", + } + ) + rows.sort(key=lambda row: (str(row["sql-name"]), str(row["id"]))) + return { + "format-version": 1, + "consumer": sdk, + "generated-from": [ + {"name": "extension-catalog", "path": rel(CATALOG)}, + {"name": "extension-evidence", "path": rel(EVIDENCE_TABLE)}, + ], + "extensions": rows, + } + + +def generated_typescript_extension_module(metadata: dict) -> str: + def camel(row: dict) -> dict: + return { + "id": row["id"], + "sqlName": row["sql-name"], + "displayName": row["display-name"], + "postgresMajor": row["postgres-major"], + "createsExtension": row["creates-extension"], + "nativeModuleStem": row["native-module-stem"], + "dependencies": row["dependencies"], + "selectedExtensionDependencies": row["selected-extension-dependencies"], + "nativeDependencies": row["native-dependencies"], + "sharedPreloadLibraries": row["shared-preload-libraries"], + "dataFiles": row["data-files"], + "runtimeShareDataFiles": row["runtime-share-data-files"], + "public": row["public"], + "stable": row["stable"], + "desktopReleaseReady": row["desktop-release-ready"], + "mobileReleaseReady": row["mobile-release-ready"], + "targetStatus": row["target-status"], + "support": row["support"], + "sourceKind": row["source-kind"], + "archive": row["archive"], + } + + rows = [camel(row) for row in metadata.get("extensions", [])] + source = ( + "// This file is generated by src/extensions/tools/check-extension-model.py.\n" + "// Do not edit by hand.\n\n" + "export type GeneratedExtensionMetadata = {\n" + " readonly id: string;\n" + " readonly sqlName: string;\n" + " readonly displayName: string;\n" + " readonly postgresMajor: number;\n" + " readonly createsExtension: boolean;\n" + " readonly nativeModuleStem: string | null;\n" + " readonly dependencies: readonly string[];\n" + " readonly selectedExtensionDependencies: readonly string[];\n" + " readonly nativeDependencies: readonly string[];\n" + " readonly sharedPreloadLibraries: readonly string[];\n" + " readonly dataFiles: readonly string[];\n" + " readonly runtimeShareDataFiles: readonly string[];\n" + " readonly public: boolean;\n" + " readonly stable: boolean;\n" + " readonly desktopReleaseReady: boolean;\n" + " readonly mobileReleaseReady: boolean;\n" + " readonly targetStatus: { readonly native?: string | null; readonly wasix?: string | null; readonly mobile?: string | null };\n" + " readonly support: Readonly>>>;\n" + " readonly sourceKind: string;\n" + " readonly archive: string;\n" + "};\n\n" + f"export const GENERATED_EXTENSION_METADATA = {json.dumps(rows, indent=2, sort_keys=True)} as const satisfies readonly GeneratedExtensionMetadata[];\n\n" + "export function generatedExtensionBySqlName(sqlName: string): GeneratedExtensionMetadata | undefined {\n" + " return GENERATED_EXTENSION_METADATA.find((extension) => extension.sqlName === sqlName);\n" + "}\n\n" + "export function generatedSharedPreloadLibraries(extensionSqlNames: readonly string[]): string[] {\n" + " const libraries = new Set();\n" + " for (const sqlName of extensionSqlNames) {\n" + " const extension = generatedExtensionBySqlName(sqlName);\n" + " for (const library of extension?.sharedPreloadLibraries ?? []) {\n" + " libraries.add(library);\n" + " }\n" + " }\n" + " return [...libraries].sort();\n" + "}\n" + ) + return format_typescript_source(source, GENERATED_TS_SDK_MODULE) + + +def rust_string_literal(value: str) -> str: + return json.dumps(value) + + +def rust_variant_from_constant(value: str) -> str: + parts = [part for part in value.split("_") if part] + if not parts: + fail(f"invalid rust extension constant {value!r}") + return "".join(part.lower().capitalize() for part in parts) + + +def rust_extension_expr(row: dict) -> str: + return f"Extension::{rust_variant_from_constant(str(row['rust-constant']))}" + + +def rust_doc_comment(text: str, *, indent: str = "") -> str: + escaped = text.replace("*/", "* /") + return "\n".join(f"{indent}/// {line}" if line else f"{indent}///" for line in escaped.splitlines()) + + +def rust_array( + values: list[str], + *, + item_indent: str = " ", + closing_indent: str = "", +) -> str: + if not values: + return "&[]" + if len(values) <= 2 and all(len(value) <= 72 for value in values): + return f"&[{', '.join(values)}]" + rendered = "".join(f"{item_indent}{value},\n" for value in values) + return "&[\n" + rendered + closing_indent + "]" + + +def rust_extension_slice( + rows: list[dict], + *, + item_indent: str = " ", + closing_indent: str = "", +) -> str: + return rust_array( + [rust_extension_expr(row) for row in rows], + item_indent=item_indent, + closing_indent=closing_indent, + ) + + +def rust_option_string(value: object) -> str: + if value is None or value == "": + return "None" + if not isinstance(value, str): + fail(f"Rust string option must be a string or null, got {value!r}") + return f"Some({rust_string_literal(value)})" + + +def rust_string_slice( + values: list[str], + *, + item_indent: str = " ", + closing_indent: str = "", +) -> str: + return rust_array( + [rust_string_literal(value) for value in values], + item_indent=item_indent, + closing_indent=closing_indent, + ) + + +def rust_runtime_environment_slice( + values: list[dict], + *, + item_indent: str = " ", + closing_indent: str = "", +) -> str: + if len(values) == 1: + value = values[0] + field_indent = item_indent + return ( + "&[ExtensionRuntimeEnvironment {\n" + f"{field_indent}name: {rust_string_literal(value['name'])},\n" + f"{field_indent}relative_path: {rust_string_literal(value['path'])},\n" + f"{field_indent}required_file: {rust_string_literal(value['required_file'])},\n" + f"{closing_indent}}}]" + ) + return rust_array( + [ + "ExtensionRuntimeEnvironment { " + f"name: {rust_string_literal(value['name'])}, " + f"relative_path: {rust_string_literal(value['path'])}, " + f"required_file: {rust_string_literal(value['required_file'])} " + "}" + for value in values + ], + item_indent=item_indent, + closing_indent=closing_indent, + ) + + +def rust_extension_dependency_slice( + values: list[str], + rows_by_sql_name: dict[str, dict], + *, + item_indent: str = " ", + closing_indent: str = "", +) -> str: + if not values: + return "&[]" + dependencies = [] + for value in values: + dependency = rows_by_sql_name.get(value) + if dependency is None: + fail(f"generated Rust dependency {value!r} is not a known Rust extension row") + dependencies.append(rust_extension_expr(dependency)) + return rust_array( + dependencies, + item_indent=item_indent, + closing_indent=closing_indent, + ) + + +def generated_rust_extension_rows(catalog: dict) -> list[dict]: + rows = [] + public_sql_names = { + extension.get("sql-name", extension.get("id")) + for extension in catalog.get("extensions", []) + if (extension.get("promotion") or {}).get("promoted") is True + } + for extension in catalog.get("extensions", []): + promotion = extension.get("promotion") or {} + if promotion.get("promoted") is not True: + continue + sql_name = str(extension.get("sql-name", extension.get("id"))) + rows.append( + { + "id": extension.get("id"), + "sql-name": sql_name, + "rust-constant": extension.get("rust-constant"), + "creates-extension": bool((extension.get("lifecycle") or {}).get("create-extension")), + "native-module-stem": native_module_stem(extension), + "selected-extension-dependencies": sorted( + dependency + for dependency in (extension.get("dependencies") or []) + if dependency in public_sql_names + ), + "runtime-share-data-files": runtime_share_data_files( + extension_data_files_from_recipe(extension) + ), + "shared-preload-libraries": shared_preload_libraries(extension), + "first-party": True, + "desktop-release-ready": desktop_release_ready(sql_name, promotion), + "mobile-release-ready": mobile_release_ready(sql_name), + "extension-sql-file-prefixes": extension_artifact_list_from_recipe( + extension, "extension_sql_file_prefixes" + ), + "extension-sql-file-names": extension_artifact_list_from_recipe( + extension, "extension_sql_file_names" + ), + "runtime-environment": extension_runtime_environment_from_recipe(extension), + "external-policy": None, + } + ) + rows.extend(RUST_INTERNAL_EXTENSION_CANDIDATES) + rows.sort(key=lambda row: str(row["sql-name"])) + for row in rows: + if not isinstance(row.get("rust-constant"), str) or not row["rust-constant"]: + fail(f"Rust generated extension row {row.get('id')} must define rust-constant") + return rows + + +def rust_match( + function_name: str, + return_type: str, + rows: list[dict], + value_for_row, +) -> str: + arms = [ + f" {rust_extension_expr(row)} => {value_for_row(row)}," + for row in rows + ] + signature = f"pub(super) const fn {function_name}(extension: Extension) -> {return_type} {{" + if len(signature) > 100: + signature = ( + f"pub(super) const fn {function_name}(\n" + " extension: Extension,\n" + f") -> {return_type} {{" + ) + return ( + "/// Generated extension metadata accessor.\n" + f"{signature}\n" + " match extension {\n" + + "\n".join(arms) + + "\n }\n" + "}\n" + ) + + +def generated_rust_extension_module(catalog: dict) -> str: + rows = generated_rust_extension_rows(catalog) + rows_by_sql_name = {str(row["sql-name"]): row for row in rows} + first_party_rows = [row for row in rows if row["first-party"]] + release_ready_rows = [row for row in rows if row.get("desktop-release-ready")] + external_rows = [row for row in rows if not row["first-party"]] + mobile_ready_rows = [row for row in rows if row["mobile-release-ready"]] + + for row in rows: + if len(row["shared-preload-libraries"]) > 1: + fail( + f"Rust Extension::required_shared_preload_library supports one library; " + f"{row['sql-name']} declared {row['shared-preload-libraries']}" + ) + + text = [ + "// @generated by src/extensions/tools/check-extension-model.py --write", + "// Do not edit by hand.", + "", + "use super::{", + " ExtensionArtifactPolicy, ExtensionCoverage, ExtensionManifestEntry, ExtensionModuleAsset,", + " ExtensionRedistribution, ExtensionRuntimeEnvironment, ExtensionSmokePlan, ExtensionSourceKind,", + " ExtensionSqlAsset, MobileStaticLinkStatus,", + "};", + "", + "/// Native PostgreSQL 18 extension that can be explicitly selected by an app.", + "#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]", + "pub enum Extension {", + ] + + for row in rows: + doc_prefix = "PostgreSQL" + policy = row.get("external-policy") + if isinstance(policy, dict): + upstream = str(policy.get("upstream", "")) + if "pggraph" in upstream: + doc_prefix = "pgGraph" + elif "paradedb" in upstream: + doc_prefix = "ParadeDB" + text.extend( + [ + rust_doc_comment(f"{doc_prefix} `{row['sql-name']}`.", indent=" "), + f" {rust_variant_from_constant(str(row['rust-constant']))},", + ] + ) + + text.extend( + [ + "}", + "", + "/// First-party PostgreSQL 18 extensions generated from the shared catalog.", + f"pub(super) const FIRST_PARTY_PG18_SUPPORTED: &[Extension] = {rust_extension_slice(first_party_rows)};", + "/// Public release-ready PostgreSQL 18 extensions generated from the shared catalog.", + f"pub(super) const RELEASE_READY_PG18_SUPPORTED: &[Extension] = {rust_extension_slice(release_ready_rows)};", + "/// Mobile release-ready PostgreSQL 18 extensions generated from the shared catalog.", + f"pub(super) const MOBILE_RELEASE_READY_PG18_SUPPORTED: &[Extension] = {rust_extension_slice(mobile_ready_rows)};", + "/// External PostgreSQL 18 extension candidates generated from explicit metadata.", + f"pub(super) const EXTERNAL_PG18_SUPPORTED: &[Extension] = {rust_extension_slice(external_rows)};", + "/// All PostgreSQL 18 extension rows known to the Rust SDK.", + f"pub(super) const ALL_PG18_SUPPORTED: &[Extension] = {rust_extension_slice(rows)};", + "", + rust_match("sql_name", "&'static str", rows, lambda row: rust_string_literal(row["sql-name"])), + rust_match( + "native_module_stem", + "Option<&'static str>", + rows, + lambda row: rust_option_string(row["native-module-stem"]), + ), + rust_match( + "creates_extension", + "bool", + rows, + lambda row: "true" if row["creates-extension"] else "false", + ), + rust_match( + "dependencies", + "&'static [Extension]", + rows, + lambda row: rust_extension_dependency_slice( + row["selected-extension-dependencies"], + rows_by_sql_name, + item_indent=" ", + closing_indent=" ", + ), + ), + rust_match( + "desktop_release_ready", + "bool", + rows, + lambda row: "true" if row.get("desktop-release-ready") else "false", + ), + rust_match( + "mobile_release_ready", + "bool", + rows, + lambda row: "true" if row["mobile-release-ready"] else "false", + ), + rust_match( + "required_shared_preload_library", + "Option<&'static str>", + rows, + lambda row: rust_option_string( + row["shared-preload-libraries"][0] + if row["shared-preload-libraries"] + else None + ), + ), + rust_match( + "extension_data_files", + "&'static [&'static str]", + rows, + lambda row: rust_string_slice( + row["runtime-share-data-files"], + item_indent=" ", + closing_indent=" ", + ), + ), + rust_match( + "extension_sql_file_prefixes", + "&'static [&'static str]", + rows, + lambda row: rust_string_slice( + row.get("extension-sql-file-prefixes") or [], + item_indent=" ", + closing_indent=" ", + ), + ), + rust_match( + "extension_sql_file_names", + "&'static [&'static str]", + rows, + lambda row: rust_string_slice( + row.get("extension-sql-file-names") or [], + item_indent=" ", + closing_indent=" ", + ), + ), + rust_match( + "runtime_environment", + "&'static [ExtensionRuntimeEnvironment]", + rows, + lambda row: rust_runtime_environment_slice( + row.get("runtime-environment") or [], + item_indent=" ", + closing_indent=" ", + ), + ), + ] + ) + + artifact_arms = [] + for row in rows: + policy = row.get("external-policy") + if policy is None: + artifact_arms.append(f" {rust_extension_expr(row)} => ExtensionArtifactPolicy::FirstParty,") + continue + artifact_arms.append( + "\n".join( + [ + f" {rust_extension_expr(row)} => ExtensionArtifactPolicy::External {{", + f" upstream: {rust_string_literal(policy['upstream'])},", + f" license: {rust_string_literal(policy['license'])},", + f" source_kind: ExtensionSourceKind::{policy['source-kind']},", + f" redistribution: ExtensionRedistribution::{policy['redistribution']},", + f" requires_shared_preload: {'true' if policy['requires-shared-preload'] else 'false'},", + f" notes: {rust_string_literal(policy['notes'])},", + " },", + ] + ) + ) + text.append( + "/// Generated extension packaging policy accessor.\n" + "pub(super) const fn artifact_policy(extension: Extension) -> ExtensionArtifactPolicy {\n" + " match extension {\n" + + "\n".join(artifact_arms) + + "\n }\n" + "}\n" + ) + + manifest_rows = ",\n".join( + f" manifest_entry({rust_extension_expr(row)})" for row in rows + ) + text.append( + "/// Static native extension manifest generated from the shared catalog.\n" + "pub(super) const NATIVE_EXTENSION_MANIFEST: &[ExtensionManifestEntry] = &[\n" + f"{manifest_rows},\n" + "];\n" + ) + text.append( + "const fn manifest_entry(extension: Extension) -> ExtensionManifestEntry {\n" + " let module = match native_module_stem(extension) {\n" + " Some(stem) => ExtensionModuleAsset::NativeModule { stem },\n" + " None => ExtensionModuleAsset::SqlOnly,\n" + " };\n" + " let sql_assets = if creates_extension(extension) {\n" + " ExtensionSqlAsset::ControlAndSql\n" + " } else {\n" + " ExtensionSqlAsset::LoadableModuleOnly\n" + " };\n" + " let smoke = if creates_extension(extension) {\n" + " ExtensionSmokePlan::CreateExtensionCascade\n" + " } else {\n" + " ExtensionSmokePlan::LoadSharedLibrary\n" + " };\n" + " let mobile_static_link = match module {\n" + " ExtensionModuleAsset::NativeModule { .. } => MobileStaticLinkStatus::PendingRegistry,\n" + " ExtensionModuleAsset::SqlOnly => MobileStaticLinkStatus::NotRequiredSqlOnly,\n" + " };\n" + " ExtensionManifestEntry {\n" + " extension,\n" + " sql_name: sql_name(extension),\n" + " pg_major: 18,\n" + " pg18_supported: true,\n" + " creates_extension: creates_extension(extension),\n" + " sql_assets,\n" + " module,\n" + " dependencies: dependencies(extension),\n" + " data_files: extension_data_files(extension),\n" + " smoke,\n" + " coverage: ExtensionCoverage::GATED_RELEASE_MATRIX,\n" + " mobile_static_link,\n" + " artifact_policy: artifact_policy(extension),\n" + " }\n" + "}\n" + ) + + return format_rust_source("\n".join(text)) + + +def validate_generated_text_file(path: Path, expected: str, write: bool) -> None: + if write: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(expected, encoding="utf-8") + return + if not path.exists(): + fail(f"{rel(path)} is missing; run src/extensions/tools/check-extension-model.py --write") + if path.read_text(encoding="utf-8") != expected: + fail(f"{rel(path)} is stale; run src/extensions/tools/check-extension-model.py --write") + + +def generated_mobile_registry(catalog: dict) -> dict: + rows = [] + for extension in catalog.get("extensions", []): + promotion = extension.get("promotion") or {} + if promotion.get("promoted") is not True: + continue + stem = native_module_stem(extension) + if stem is None: + continue + rows.append( + { + "id": extension.get("id"), + "sql-name": extension.get("sql-name", extension.get("id")), + "native-module-stem": stem, + "data-files": extension_data_files_from_recipe(extension), + "native-dependencies": extension.get("native-dependencies") or [], + "static-registry-required": True, + } + ) + rows.sort(key=lambda row: (str(row["sql-name"]), str(row["id"]))) + return { + "format-version": 1, + "generated-from": [ + {"name": "extension-catalog", "path": rel(CATALOG)}, + {"name": "extension-definitions", "path": "src/extensions/external"}, + ], + "modules": rows, + } + + +def generated_mobile_static_specs(catalog: dict, build_plan: dict) -> str: + plan_by_sql_name = { + row.get("sql-name", row.get("id")): row + for row in build_plan.get("extensions", []) + if isinstance(row, dict) + } + rows = [] + for module in generated_mobile_registry(catalog)["modules"]: + sql_name = module["sql-name"] + plan = plan_by_sql_name.get(sql_name) + if plan is None: + fail(f"mobile static module {sql_name} has no generated build-plan row") + build_kind = plan.get("build-kind") + if build_kind == "postgres-contrib": + contrib_dir = plan.get("contrib-dir") + if not isinstance(contrib_dir, str) or not contrib_dir: + fail(f"mobile static contrib module {sql_name} is missing contrib-dir") + source_kind = "contrib" + source_rel = f"contrib/{contrib_dir}" + else: + source_dir = plan.get("source-dir") + if not isinstance(source_dir, str) or not source_dir: + fail(f"mobile static external module {sql_name} is missing source-dir") + source_kind = "external" + source_rel = source_dir + static_dependencies = ",".join(mobile_static_dependencies(sql_name)) + ios_static_dependencies = ",".join(mobile_static_dependencies(sql_name, "ios_dependencies")) + android_static_dependencies = ",".join(mobile_static_dependencies(sql_name, "android_dependencies")) + include_dependencies = ",".join(mobile_static_include_dependencies(sql_name)) + include_dirs = ",".join(mobile_static_include_dirs(sql_name)) + cflags = ",".join(mobile_static_cflags(sql_name)) + hash_source_dependencies = ",".join(mobile_static_hash_source_dependencies(sql_name)) + ios_hash_source_dependencies = ",".join( + mobile_static_hash_source_dependencies(sql_name, "ios_dependencies") + ) + android_hash_source_dependencies = ",".join( + mobile_static_hash_source_dependencies(sql_name, "android_dependencies") + ) + hash_dirs = ",".join(mobile_static_hash_dirs(sql_name)) + source_files = ",".join(mobile_static_source_files(sql_name)) + source_recursive_dirs = ",".join(mobile_static_source_recursive_dirs(sql_name)) + rows.append( + [ + sql_name, + module["native-module-stem"], + source_kind, + source_rel, + static_dependencies, + ios_static_dependencies, + android_static_dependencies, + include_dependencies, + include_dirs, + cflags, + hash_source_dependencies, + ios_hash_source_dependencies, + android_hash_source_dependencies, + hash_dirs, + source_files, + source_recursive_dirs, + ] + ) + rows.sort(key=lambda row: row[0]) + lines = [ + "# @generated by src/extensions/tools/check-extension-model.py --write", + ( + "sql-name\tnative-module-stem\tsource-kind\tsource-rel" + "\tmobile-static-dependencies\tios-static-dependencies\tandroid-static-dependencies" + "\tinclude-dependencies\tinclude-dirs\tcflags" + "\thash-source-dependencies\tios-hash-source-dependencies" + "\tandroid-hash-source-dependencies\thash-dirs" + "\tsource-files\tsource-recursive-dirs" + ), + *["\t".join(row).rstrip("\t") for row in rows], + "", + ] + return "\n".join(lines) + + +def generated_wasix_metadata(catalog: dict) -> dict: + rows = [] + for extension in catalog.get("extensions", []): + promotion = extension.get("promotion") or {} + if promotion.get("promoted") is not True: + continue + rows.append( + { + "id": extension.get("id"), + "sql-name": extension.get("sql-name", extension.get("id")), + "archive": promotion.get("archive") or extension.get("archive", ""), + "native-module-file": extension.get("native-module-file") or extension.get("module-file"), + "native-support-modules": target_native_support_modules( + str(extension.get("sql-name", extension.get("id"))), + "wasix", + ), + "dependencies": extension.get("dependencies") or [], + "load-order": extension.get("load-order") or [], + "lifecycle": extension.get("lifecycle") or {}, + } + ) + rows.sort(key=lambda row: (str(row["sql-name"]), str(row["id"]))) + return { + "format-version": 1, + "generated-from": [ + {"name": "extension-catalog", "path": rel(CATALOG)}, + {"name": "extension-definitions", "path": "src/extensions/external"}, + ], + "extensions": rows, + } + + +def validate_generated_file(path: Path, expected: dict, write: bool) -> None: + text = json_text(expected) + if write: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return + if not path.exists(): + fail(f"{rel(path)} is missing; run src/extensions/tools/check-extension-model.py --write") + if path.read_text(encoding="utf-8") != text: + fail(f"{rel(path)} is stale; run src/extensions/tools/check-extension-model.py --write") + parsed = read_json(path) + if parsed.get("format-version") != 1: + fail(f"{rel(path)} must use format-version 1") + + +def validate_generated_sdk_metadata(catalog: dict, build_plan: dict, write: bool) -> None: + for sdk, path in GENERATED_SDKS.items(): + validate_generated_file(path, generated_sdk_metadata(catalog, sdk), write) + js_metadata = generated_sdk_metadata(catalog, "js") + kotlin_metadata = generated_sdk_metadata(catalog, "kotlin") + rn_metadata = generated_sdk_metadata(catalog, "react-native") + validate_generated_text_file( + GENERATED_RUST_SDK_MODULE, + generated_rust_extension_module(catalog), + write, + ) + validate_generated_text_file( + GENERATED_TS_SDK_MODULE, + generated_typescript_extension_module(js_metadata), + write, + ) + validate_generated_text_file( + GENERATED_RN_SDK_MODULE, + generated_typescript_extension_module(rn_metadata), + write, + ) + validate_generated_file(GENERATED_KOTLIN_SDK_METADATA, kotlin_metadata, write) + validate_generated_file(GENERATED_RN_PLUGIN_METADATA, rn_metadata, write) + validate_generated_file(GENERATED_MOBILE_REGISTRY, generated_mobile_registry(catalog), write) + validate_generated_text_file( + GENERATED_MOBILE_STATIC_SPECS, + generated_mobile_static_specs(catalog, build_plan), + write, + ) + validate_generated_file(GENERATED_WASIX_METADATA, generated_wasix_metadata(catalog), write) + + +def generated_support_table(catalog: dict) -> dict: + rows = [] + for extension in catalog.get("extensions", []): + promotion = extension.get("promotion") or {} + lifecycle = extension.get("lifecycle") or {} + smoke = extension.get("smoke") or {} + sql_name = str(extension.get("sql-name", extension.get("id"))) + rows.append( + { + "id": extension.get("id"), + "sql-name": sql_name, + "display-name": extension.get("display-name", extension.get("id")), + "version": extension_version(extension), + "family": extension_family(extension.get("source-kind")), + "public": bool(promotion.get("promoted")), + "stable": bool(promotion.get("stable")), + "packaged": bool(promotion.get("packaged")), + "promoted": bool(promotion.get("promoted")), + "desktop-release-ready": desktop_release_ready(sql_name, promotion), + "mobile-release-ready": mobile_release_ready(sql_name), + "target-status": extension_target_statuses(sql_name), + "support": extension_support_statuses(sql_name), + "archive": promotion.get("archive") or "", + "blocker": promotion.get("blocker") or "", + "activation": extension_activation(extension), + "dependencies": extension.get("dependencies") or [], + "native-dependencies": extension.get("native-dependencies") or [], + "preload-required": bool(lifecycle.get("preload-required")), + "restart-required": bool(lifecycle.get("restart-required")), + "smoke": { + "direct": smoke.get("direct", "not-run"), + "server": smoke.get("server", "not-run"), + "restart": smoke.get("restart", "not-run"), + "dump-restore": smoke.get("dump-restore", "not-run"), + }, + } + ) + rows.sort(key=lambda row: (str(row["sql-name"]), str(row["id"]))) + return { + "format-version": 1, + "generated-from": [ + {"name": "extension-catalog", "path": rel(CATALOG)}, + {"name": "extension-build-plan", "path": rel(BUILD_PLAN)}, + {"name": "promotion-config", "path": rel(PROMOTED)}, + {"name": "smoke-evidence", "path": rel(SMOKE)}, + ], + "extensions": rows, + } + + +def json_text(value: dict) -> str: + return json.dumps(value, indent=2, sort_keys=True) + "\n" + + +def validate_support_table(catalog: dict, write: bool) -> None: + expected = json_text(generated_support_table(catalog)) + if write: + SUPPORT_TABLE.parent.mkdir(parents=True, exist_ok=True) + SUPPORT_TABLE.write_text(expected, encoding="utf-8") + return + if not SUPPORT_TABLE.exists(): + fail(f"{rel(SUPPORT_TABLE)} is missing; run src/extensions/tools/check-extension-model.py --write") + actual = SUPPORT_TABLE.read_text(encoding="utf-8") + if actual != expected: + fail(f"{rel(SUPPORT_TABLE)} is stale; run src/extensions/tools/check-extension-model.py --write") + table = read_json(SUPPORT_TABLE) + if table.get("format-version") != 1: + fail(f"{rel(SUPPORT_TABLE)} must use format-version 1") + if not table.get("extensions"): + fail(f"{rel(SUPPORT_TABLE)} must define extension rows") + + +def public_extensions(catalog: dict) -> list[dict]: + rows = [ + extension + for extension in catalog.get("extensions", []) + if (extension.get("promotion") or {}).get("promoted") is True + ] + rows.sort(key=lambda row: (str(row.get("sql-name", row.get("id"))), str(row.get("id")))) + return rows + + +def format_toml_string_list(values: list[str]) -> str: + return "[" + ", ".join(json.dumps(value) for value in values) + "]" + + +def write_evidence_files(catalog: dict) -> None: + public_rows = public_extensions(catalog) + matrix_lines = [ + "format-version = 1", + "source-digest-inputs = [", + *[f' "{path}",' for path in source_digest_inputs()], + "]", + "", + ] + for extension in public_rows: + extension_id = validate_id(extension.get("id"), "public extension id") + matrix_lines.extend( + [ + "[[claims]]", + f'extension = "{extension_id}"', + "postgres-major = 18", + 'artifact-family = "wasix-runtime"', + 'platform-targets = ["portable"]', + 'runtime-modes = ["direct", "server", "restart", "dump-restore"]', + 'evidence-required = ["transitional-catalog-smoke"]', + "public = true", + "", + ] + ) + EVIDENCE_MATRIX.parent.mkdir(parents=True, exist_ok=True) + EVIDENCE_MATRIX.write_text("\n".join(matrix_lines).rstrip() + "\n", encoding="utf-8") + + results = [] + for extension in public_rows: + smoke = extension.get("smoke") or {} + statuses = { + "direct": smoke.get("direct", "not-run"), + "server": smoke.get("server", "not-run"), + "restart": smoke.get("restart", "not-run"), + "dump-restore": smoke.get("dump-restore", "not-run"), + } + results.append( + { + "extension": extension.get("id"), + "sqlName": extension.get("sql-name", extension.get("id")), + "postgresMajor": 18, + "artifactFamily": "wasix-runtime", + "platformTarget": "portable", + "runtimeModeStatuses": statuses, + } + ) + run = { + "schema": "oliphaunt-extension-evidence-v1", + "id": "2026-06-07-transitional-catalog-smoke", + "evidenceTier": "transitional-catalog-smoke", + "status": "passed", + "sourceDigest": source_digest(), + "sourceDigestInputs": source_digest_inputs(), + "observedAt": "2026-06-07T00:00:00Z", + "collector": "src/extensions/tools/check-extension-model.py --write-evidence", + "notes": ( + "Transitional evidence imported from extensions.smoke.toml while " + "per-recipe evidence runs are introduced." + ), + "results": results, + } + EVIDENCE_RUNS.mkdir(parents=True, exist_ok=True) + (EVIDENCE_RUNS / "2026-06-07-transitional-catalog-smoke.json").write_text( + json_text(run), + encoding="utf-8", + ) + + +def validate_evidence(catalog: dict) -> dict: + for path in (EVIDENCE_MATRIX, EVIDENCE_RUN_SCHEMA, EVIDENCE_MATRIX_SCHEMA): + if not path.exists(): + fail(f"missing required extension evidence file: {rel(path)}") + matrix = read_toml(EVIDENCE_MATRIX) + if matrix.get("format-version") != 1: + fail(f"{rel(EVIDENCE_MATRIX)} must use format-version = 1") + digest_inputs = normalized_rel_list( + matrix.get("source-digest-inputs"), + f"{rel(EVIDENCE_MATRIX)} source-digest-inputs", + ) + if digest_inputs != source_digest_inputs(): + fail(f"{rel(EVIDENCE_MATRIX)} source-digest-inputs must match the checker contract") + public_ids = {validate_id(row.get("id"), "public catalog extension") for row in public_extensions(catalog)} + claims = matrix.get("claims") + if not isinstance(claims, list) or not claims: + fail(f"{rel(EVIDENCE_MATRIX)} must declare [[claims]]") + claim_ids: set[str] = set() + for claim in claims: + extension_id = validate_id(claim.get("extension"), f"{rel(EVIDENCE_MATRIX)} claim extension") + if claim.get("public") is not True: + continue + if extension_id in claim_ids: + fail(f"{rel(EVIDENCE_MATRIX)} has duplicate public claim for {extension_id}") + claim_ids.add(extension_id) + if claim.get("postgres-major") != 18: + fail(f"{rel(EVIDENCE_MATRIX)} claim {extension_id} must target postgres-major = 18") + for field in ("artifact-family", "platform-targets", "runtime-modes", "evidence-required"): + if field not in claim: + fail(f"{rel(EVIDENCE_MATRIX)} claim {extension_id} is missing {field}") + missing_claims = sorted(public_ids - claim_ids) + extra_claims = sorted(claim_ids - public_ids) + if missing_claims: + fail(f"{rel(EVIDENCE_MATRIX)} is missing public claims for {missing_claims}") + if extra_claims: + fail(f"{rel(EVIDENCE_MATRIX)} claims public support for non-public extensions {extra_claims}") + + current_digest = source_digest(digest_inputs) + evidence: dict[tuple[str, str, str, str], dict[str, str]] = {} + latest: dict[tuple[str, str, str, str], dict] = {} + run_files = sorted(EVIDENCE_RUNS.glob("*.json")) + if not run_files: + fail(f"{rel(EVIDENCE_RUNS)} must contain evidence run JSON files") + for run_file in run_files: + run = read_json(run_file) + if run.get("schema") != "oliphaunt-extension-evidence-v1": + fail(f"{rel(run_file)} has unsupported evidence schema") + if run.get("sourceDigest") != current_digest: + fail( + f"{rel(run_file)} sourceDigest is stale; expected {current_digest}, " + f"got {run.get('sourceDigest')!r}" + ) + run_digest_inputs = normalized_rel_list( + run.get("sourceDigestInputs"), + f"{rel(run_file)} sourceDigestInputs", + ) + if run_digest_inputs != digest_inputs: + fail(f"{rel(run_file)} sourceDigestInputs must match {rel(EVIDENCE_MATRIX)}") + if run.get("status") != "passed": + continue + tier = run.get("evidenceTier") + if not isinstance(tier, str) or not tier: + fail(f"{rel(run_file)} must define evidenceTier") + results = run.get("results") + if not isinstance(results, list) or not results: + fail(f"{rel(run_file)} must define evidence results") + for result in results: + extension_id = validate_id(result.get("extension"), f"{rel(run_file)} result extension") + if result.get("postgresMajor") != 18: + continue + family = result.get("artifactFamily") + target = result.get("platformTarget") + statuses = result.get("runtimeModeStatuses") + if not isinstance(family, str) or not isinstance(target, str) or not isinstance(statuses, dict): + fail(f"{rel(run_file)} result {extension_id} must define family, target, and runtimeModeStatuses") + evidence[(extension_id, tier, family, target)] = statuses + latest[(extension_id, tier, family, target)] = { + "run-id": run.get("id", run_file.stem), + "run-path": rel(run_file), + "evidence-tier": tier, + "artifact-family": family, + "platform-target": target, + "source-digest": current_digest, + "observed-at": run.get("observedAt", ""), + "runtime-mode-statuses": statuses, + } + + claim_rows = [] + for claim in claims: + if claim.get("public") is not True: + continue + extension_id = claim["extension"] + tiers = claim["evidence-required"] + targets = claim["platform-targets"] + modes = claim["runtime-modes"] + family = claim["artifact-family"] + if not isinstance(tiers, list) or not isinstance(targets, list) or not isinstance(modes, list): + fail(f"{rel(EVIDENCE_MATRIX)} claim {extension_id} has invalid evidence target arrays") + accepted = [] + for tier in tiers: + for target in targets: + statuses = evidence.get((extension_id, tier, family, target)) + if statuses is None: + fail(f"public extension claim {extension_id} lacks evidence tier {tier} for {family}/{target}") + for mode in modes: + if statuses.get(mode) != "passed": + fail( + f"public extension claim {extension_id} lacks passing {mode} evidence " + f"for tier {tier} on {family}/{target}" + ) + accepted.append(latest[(extension_id, tier, family, target)]) + catalog_row = next((row for row in catalog.get("extensions", []) if row.get("id") == extension_id), {}) + claim_rows.append( + { + "extension": extension_id, + "sql-name": catalog_row.get("sql-name", extension_id), + "public": True, + "postgres-major": claim.get("postgres-major"), + "artifact-family": family, + "platform-targets": targets, + "runtime-modes": modes, + "evidence-required": tiers, + "latest-accepted-evidence": accepted, + } + ) + + claim_rows.sort(key=lambda row: (str(row["sql-name"]), str(row["extension"]))) + return { + "format-version": 1, + "generated-from": [ + {"name": "extension-catalog", "path": rel(CATALOG)}, + {"name": "evidence-matrix", "path": rel(EVIDENCE_MATRIX)}, + {"name": "evidence-runs", "path": rel(EVIDENCE_RUNS)}, + ], + "source-digest": current_digest, + "source-digest-inputs": digest_inputs, + "claims": claim_rows, + } + + +def validate_evidence_table(catalog: dict, write: bool) -> None: + expected = json_text(validate_evidence(catalog)) + if write: + EVIDENCE_TABLE.parent.mkdir(parents=True, exist_ok=True) + EVIDENCE_TABLE.write_text(expected, encoding="utf-8") + return + if not EVIDENCE_TABLE.exists(): + fail(f"{rel(EVIDENCE_TABLE)} is missing; run src/extensions/tools/check-extension-model.py --write") + actual = EVIDENCE_TABLE.read_text(encoding="utf-8") + if actual != expected: + fail(f"{rel(EVIDENCE_TABLE)} is stale; run src/extensions/tools/check-extension-model.py --write") + table = read_json(EVIDENCE_TABLE) + if table.get("format-version") != 1: + fail(f"{rel(EVIDENCE_TABLE)} must use format-version 1") + if not table.get("claims"): + fail(f"{rel(EVIDENCE_TABLE)} must define public evidence claims") + + +def run_xtask_check() -> None: + result = subprocess.run( + ["cargo", "run", "-p", "xtask", "--", "extensions", "check"], + cwd=ROOT, + check=False, + ) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def self_test() -> None: + with TemporaryDirectory() as tmp: + bad = Path(tmp) / "bad.toml" + bad.write_text( + 'format-version = 1\n\n[[extensions]]\nid = "vector"\n\n[[extensions]]\nid = "vector"\n', + encoding="utf-8", + ) + try: + original = globals()["PROMOTED"] + globals()["PROMOTED"] = bad + validate_catalog_rows() + except SystemExit: + pass + else: + fail("self-test expected duplicate extension id to fail") + finally: + globals()["PROMOTED"] = original + + originals = { + "EVIDENCE_MATRIX": globals()["EVIDENCE_MATRIX"], + "EVIDENCE_RUN_SCHEMA": globals()["EVIDENCE_RUN_SCHEMA"], + "EVIDENCE_MATRIX_SCHEMA": globals()["EVIDENCE_MATRIX_SCHEMA"], + "EVIDENCE_RUNS": globals()["EVIDENCE_RUNS"], + } + catalog = {"extensions": [{"id": "vector", "sql-name": "vector", "promotion": {"promoted": True}}]} + try: + with TemporaryDirectory() as tmp: + root = Path(tmp) + globals()["EVIDENCE_MATRIX"] = root / "missing.toml" + globals()["EVIDENCE_RUN_SCHEMA"] = root / "run.schema.json" + globals()["EVIDENCE_MATRIX_SCHEMA"] = root / "matrix.schema.json" + globals()["EVIDENCE_RUNS"] = root / "runs" + globals()["EVIDENCE_RUN_SCHEMA"].write_text("{}\n", encoding="utf-8") + globals()["EVIDENCE_MATRIX_SCHEMA"].write_text("{}\n", encoding="utf-8") + globals()["EVIDENCE_RUNS"].mkdir() + try: + validate_evidence(catalog) + except SystemExit: + pass + else: + fail("self-test expected missing evidence matrix to fail") + + with TemporaryDirectory() as tmp: + root = Path(tmp) + globals()["EVIDENCE_MATRIX"] = root / "matrix.toml" + globals()["EVIDENCE_RUN_SCHEMA"] = root / "run.schema.json" + globals()["EVIDENCE_MATRIX_SCHEMA"] = root / "matrix.schema.json" + globals()["EVIDENCE_RUNS"] = root / "runs" + globals()["EVIDENCE_RUNS"].mkdir() + globals()["EVIDENCE_RUN_SCHEMA"].write_text("{}\n", encoding="utf-8") + globals()["EVIDENCE_MATRIX_SCHEMA"].write_text("{}\n", encoding="utf-8") + globals()["EVIDENCE_MATRIX"].write_text( + "\n".join( + [ + "format-version = 1", + "source-digest-inputs = [", + *[f' "{path}",' for path in source_digest_inputs()], + "]", + "", + "[[claims]]", + 'extension = "vector"', + "postgres-major = 18", + 'artifact-family = "wasix-runtime"', + 'platform-targets = ["portable"]', + 'runtime-modes = ["direct"]', + 'evidence-required = ["self-test"]', + "public = true", + "", + ] + ), + encoding="utf-8", + ) + (globals()["EVIDENCE_RUNS"] / "stale.json").write_text( + json_text( + { + "schema": "oliphaunt-extension-evidence-v1", + "id": "stale", + "evidenceTier": "self-test", + "status": "passed", + "sourceDigest": "sha256:stale", + "sourceDigestInputs": source_digest_inputs(), + "results": [ + { + "extension": "vector", + "postgresMajor": 18, + "artifactFamily": "wasix-runtime", + "platformTarget": "portable", + "runtimeModeStatuses": {"direct": "passed"}, + } + ], + } + ), + encoding="utf-8", + ) + try: + validate_evidence(catalog) + except SystemExit: + pass + else: + fail("self-test expected stale evidence digest to fail") + finally: + for name, value in originals.items(): + globals()[name] = value + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--write", action="store_true", help="regenerate derived support-table JSON") + parser.add_argument("--write-evidence", action="store_true", help="regenerate transitional evidence matrix/run files") + parser.add_argument("--check", action="store_true", help="validate generated files without writing") + parser.add_argument("--self-test", action="store_true", help="run negative validation tests") + args = parser.parse_args() + + if args.self_test: + self_test() + + for path in (RECIPE_SCHEMA, SUPPORT_SCHEMA, PROMOTED, SMOKE, CATALOG, BUILD_PLAN, CONTRIB_RECIPE): + if not path.exists(): + fail(f"missing required extension model file: {rel(path)}") + + validate_catalog_rows() + catalog = read_json(CATALOG) + build_plan = read_json(BUILD_PLAN) + if args.write_evidence: + write_evidence_files(catalog) + validate_contrib_recipe(build_plan) + validate_external_recipes() + validate_support_table(catalog, write=args.write) + validate_evidence_table(catalog, write=args.write or args.write_evidence) + validate_generated_sdk_metadata(catalog, build_plan, write=args.write) + if not args.write: + run_xtask_check() + print("extension model checks passed") + + +if __name__ == "__main__": + main() diff --git a/src/extensions/tools/check-extension-tree.py b/src/extensions/tools/check-extension-tree.py new file mode 100644 index 00000000..c8c73e5a --- /dev/null +++ b/src/extensions/tools/check-extension-tree.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import pathlib +import sys +import tomllib + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +EXTENSION_ARTIFACT_TARGET_SCHEMA = "oliphaunt-extension-artifact-targets-v1" + + +def fail(message: str) -> None: + raise SystemExit(f"extension-tree: {message}") + + +def parse_toml(path: pathlib.Path) -> object: + try: + return tomllib.loads(path.read_text(encoding="utf-8")) + except Exception as error: + fail(f"cannot parse {path.relative_to(ROOT)}: {error}") + + +def check_external(path: pathlib.Path) -> None: + source = path / "source.toml" + if not source.is_file(): + fail(f"{path.relative_to(ROOT)} must own source.toml") + source_data = parse_toml(source) + for key in ("name", "url"): + if not isinstance(source_data.get(key), str) or not source_data[key]: + fail(f"{source.relative_to(ROOT)} must define non-empty {key}") + + release = path / "release.toml" + if release.is_file(): + release_data = parse_toml(release) + if release_data.get("kind") == "exact-extension-artifact": + artifact_targets = path / "targets" / "artifacts.toml" + if not artifact_targets.is_file(): + fail(f"{release.relative_to(ROOT)} exact-extension-artifact must declare {artifact_targets.relative_to(ROOT)}") + target_data = parse_toml(artifact_targets) + if target_data.get("schema") != EXTENSION_ARTIFACT_TARGET_SCHEMA: + fail( + f"{artifact_targets.relative_to(ROOT)} must use schema = " + f"{EXTENSION_ARTIFACT_TARGET_SCHEMA!r}" + ) + if not isinstance(target_data.get("targets"), list) or not target_data["targets"]: + fail(f"{artifact_targets.relative_to(ROOT)} must define [[targets]] rows") + + for toml_file in sorted(path.rglob("*.toml")): + parse_toml(toml_file) + + +def check_contrib(path: pathlib.Path) -> None: + manifest = path / "postgres18.toml" + if not manifest.is_file(): + fail(f"{path.relative_to(ROOT)} must contain postgres18.toml") + data = parse_toml(manifest) + if data.get("format-version") != 1: + fail(f"{manifest.relative_to(ROOT)} must use format-version = 1") + if data.get("postgres-version") != "18.4": + fail(f"{manifest.relative_to(ROOT)} must target PostgreSQL 18.4") + if data.get("source-kind") != "postgres-contrib": + fail(f"{manifest.relative_to(ROOT)} must describe postgres-contrib") + if not isinstance(data.get("extensions"), list) or not data["extensions"]: + fail(f"{manifest.relative_to(ROOT)} must define extension rows") + for toml_file in sorted(path.rglob("*.toml")): + parse_toml(toml_file) + + +def contrib_manifest_rows() -> dict[str, dict]: + manifest = ROOT / "src/extensions/contrib/postgres18.toml" + data = parse_toml(manifest) + rows = data.get("extensions") + if not isinstance(rows, list): + fail(f"{manifest.relative_to(ROOT)} must define extension rows") + parsed: dict[str, dict] = {} + for row in rows: + if not isinstance(row, dict): + continue + extension_id = row.get("id") + if isinstance(extension_id, str) and extension_id: + parsed[extension_id] = row + return parsed + + +def check_artifact_product(path: pathlib.Path, *, family: str) -> None: + release = path / "release.toml" + if not release.is_file(): + fail(f"{path.relative_to(ROOT)} must own release.toml") + release_data = parse_toml(release) + if release_data.get("kind") != "exact-extension-artifact": + fail(f"{release.relative_to(ROOT)} must declare kind = 'exact-extension-artifact'") + sql_name = release_data.get("extension_sql_name") + if not isinstance(sql_name, str) or not sql_name: + fail(f"{release.relative_to(ROOT)} must declare extension_sql_name") + artifact_targets = path / "targets" / "artifacts.toml" + if not artifact_targets.is_file(): + fail(f"{release.relative_to(ROOT)} exact-extension-artifact must declare {artifact_targets.relative_to(ROOT)}") + target_data = parse_toml(artifact_targets) + if target_data.get("schema") != EXTENSION_ARTIFACT_TARGET_SCHEMA: + fail( + f"{artifact_targets.relative_to(ROOT)} must use schema = " + f"{EXTENSION_ARTIFACT_TARGET_SCHEMA!r}" + ) + if not isinstance(target_data.get("targets"), list) or not target_data["targets"]: + fail(f"{artifact_targets.relative_to(ROOT)} must define [[targets]] rows") + if family == "contrib": + extension_id = path.name + row = contrib_manifest_rows().get(extension_id) + if row is None: + fail(f"{path.relative_to(ROOT)} must match a row in src/extensions/contrib/postgres18.toml") + if row.get("sql-name") != sql_name: + fail( + f"{release.relative_to(ROOT)} extension_sql_name {sql_name!r} " + f"must match contrib manifest sql-name {row.get('sql-name')!r}" + ) + for toml_file in sorted(path.rglob("*.toml")): + parse_toml(toml_file) + + +def main(argv: list[str]) -> None: + if len(argv) != 2: + fail("usage: check-extension-tree.py }>") + path = (ROOT / argv[1]).resolve() + try: + path.relative_to(ROOT) + except ValueError: + fail(f"path is outside repository: {path}") + if not path.is_dir(): + fail(f"path does not exist: {path.relative_to(ROOT)}") + if path == ROOT / "src/extensions/contrib": + check_contrib(path) + elif path.parent == ROOT / "src/extensions/contrib": + check_artifact_product(path, family="contrib") + elif path.parent == ROOT / "src/extensions/external": + check_external(path) + release = path / "release.toml" + if release.is_file() and parse_toml(release).get("kind") == "exact-extension-artifact": + check_artifact_product(path, family="external") + else: + fail(f"unsupported extension tree path: {path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 3a3c750b..00000000 --- a/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -#![doc = include_str!("../README.md")] -#![deny(unsafe_code)] - -mod pglite; -mod protocol; - -#[cfg(feature = "extensions")] -pub use pglite::extensions; - -#[cfg(feature = "extensions")] -pub use pglite::PgDumpOptions; -pub use pglite::{ - DataDirArchiveFormat, DataTransferContainer, DescribeQueryParam, DescribeQueryResult, - DescribeResultField, ExecProtocolOptions, ExecProtocolResult, FieldInfo, GlobalListenerHandle, - ListenerHandle, NoticeCallback, ParserMap, Pglite, PgliteBuilder, PgliteError, PgliteServer, - PgliteServerBuilder, PostgresConfig, QueryOptions, QueryTemplate, Results, RowMode, Serializer, - SerializerMap, TemplatedQuery, Transaction, TypeParser, format_query, quote_identifier, -}; -pub use protocol::messages::{BackendMessage, DatabaseError, NoticeMessage}; - -#[doc(hidden)] -pub use pglite::{ - DebugLevel, FsTraceSnapshot, InstallOptions, InstallOutcome, MountInfo, PgDataTemplate, - PgDataTemplateManifest, PglitePaths, PgliteProxy, PhaseTiming, ProtocolStatsSnapshot, - build_pgdata_template, capture_phase_timings, disable_protocol_stats, ensure_cluster, - fs_trace_snapshot, install_and_init, install_and_init_in, install_default, - install_extension_archive, install_extension_bytes, install_into, install_with_options, - measure_phase, preload_runtime_module, protocol_stats_snapshot, record_phase_timing, - reset_fs_trace, reset_protocol_stats, -}; diff --git a/src/postgres/versions/18/moon.yml b/src/postgres/versions/18/moon.yml new file mode 100644 index 00000000..482f178c --- /dev/null +++ b/src/postgres/versions/18/moon.yml @@ -0,0 +1,28 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "postgres18" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["postgres", "source", "pg18"] + +project: + title: "PostgreSQL 18 Source" + description: "Shared PostgreSQL 18.4 source pin and checksum." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "node tools/policy/check-source-inputs.mjs postgres18" + inputs: + - "/src/postgres/versions/18/**/*" + - "/tools/policy/check-source-inputs.mjs" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/postgres/versions/18/source.toml b/src/postgres/versions/18/source.toml new file mode 100644 index 00000000..37ba2c32 --- /dev/null +++ b/src/postgres/versions/18/source.toml @@ -0,0 +1,4 @@ +[postgresql] +version = "18.4" +url = "https://ftp.postgresql.org/pub/source/v18.4/postgresql-18.4.tar.bz2" +sha256 = "81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" diff --git a/src/runtimes/broker/CHANGELOG.md b/src/runtimes/broker/CHANGELOG.md new file mode 100644 index 00000000..cd2f2c63 --- /dev/null +++ b/src/runtimes/broker/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## Unreleased + + +## [0.1.0] - 2026-06-01 + +### Changed + +- Initial broker helper runtime release lane. diff --git a/src/runtimes/broker/Cargo.toml b/src/runtimes/broker/Cargo.toml new file mode 100644 index 00000000..8dd8c795 --- /dev/null +++ b/src/runtimes/broker/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "oliphaunt-broker" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "Oliphaunt broker helper process for process-isolated embedded PostgreSQL." +readme = "README.md" +repository.workspace = true +homepage.workspace = true +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = false + +[[bin]] +name = "oliphaunt-broker" +path = "src/main.rs" + +[dependencies] +oliphaunt = { path = "../../sdks/rust", version = "=0.1.0" } diff --git a/src/runtimes/broker/README.md b/src/runtimes/broker/README.md new file mode 100644 index 00000000..109d296b --- /dev/null +++ b/src/runtimes/broker/README.md @@ -0,0 +1,9 @@ +# oliphaunt-broker + +`oliphaunt-broker` is the helper process used by broker mode. It owns one +native database root per process, serves the Oliphaunt broker IPC protocol, and +is packaged as platform-specific release assets for SDKs that need process +isolation. + +Application developers should use their SDK's broker mode instead of invoking +this binary directly. diff --git a/src/runtimes/broker/moon.yml b/src/runtimes/broker/moon.yml new file mode 100644 index 00000000..12a25407 --- /dev/null +++ b/src/runtimes/broker/moon.yml @@ -0,0 +1,118 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-broker" +language: "rust" +layer: "library" +stack: "systems" +tags: ["runtime", "broker", "native", "postgres", "release-product"] +dependsOn: + - "oliphaunt-rust" + - "liboliphaunt-native" + +project: + title: "Oliphaunt Broker" + description: "Process-isolated broker helper runtime used by Rust and TypeScript SDKs." + owner: "oliphaunt" + release: + component: "oliphaunt-broker" + packagePath: "src/runtimes/broker" + +owners: + defaultOwner: "@oliphaunt/broker" + paths: + "**/*.rs": ["@oliphaunt/broker"] + "Cargo.toml": ["@oliphaunt/broker"] + +tasks: + check: + tags: ["quality", "static"] + command: "cargo check -p oliphaunt-broker --locked" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-broker/check" + deps: + - "oliphaunt-rust:check" + - "liboliphaunt-native:check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/runtimes/broker/**/*" + - "/src/sdks/rust/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + test: + tags: ["quality", "unit"] + command: "cargo test -p oliphaunt-broker --locked" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-broker/test" + deps: + - "oliphaunt-rust:check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/runtimes/broker/**/*" + - "/src/sdks/rust/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + package: + tags: ["package"] + command: "bash src/runtimes/broker/tools/check-package.sh" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-broker/package" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/runtimes/broker/**/*" + - "/src/sdks/rust/**/*" + - "/src/runtimes/broker/tools/check-package.sh" + options: + cache: true + runFromWorkspaceRoot: true + + release-check: + tags: ["release", "package"] + command: "bash src/runtimes/broker/tools/check-package.sh" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-broker/release-check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/runtimes/broker/**/*" + - "/src/sdks/rust/**/*" + - "/src/runtimes/broker/tools/check-package.sh" + options: + cache: true + runFromWorkspaceRoot: true + + release-assets: + tags: ["release", "artifact", "ci-broker-runtime"] + command: "bash tools/release/package-broker-assets.sh" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-broker/release-assets" + OLIPHAUNT_RELEASE_ASSET_PARTIAL: "1" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/runtimes/broker/**/*" + - "/src/sdks/rust/**/*" + - "/tools/release/package-broker-assets.sh" + - "/tools/release/check_broker_release_assets.py" + - "/tools/release/artifact_target_matrix.py" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + outputs: + - "/target/oliphaunt-broker/release-assets/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/runtimes/broker/release.toml b/src/runtimes/broker/release.toml new file mode 100644 index 00000000..138466ee --- /dev/null +++ b/src/runtimes/broker/release.toml @@ -0,0 +1,6 @@ +id = "oliphaunt-broker" +owner = "@oliphaunt/broker" +kind = "runtime" +publish_targets = ["github-release-assets"] +registry_packages = [] +release_artifacts = ["broker-helper-binary"] diff --git a/src/runtimes/broker/src/main.rs b/src/runtimes/broker/src/main.rs new file mode 100644 index 00000000..90dde682 --- /dev/null +++ b/src/runtimes/broker/src/main.rs @@ -0,0 +1,599 @@ +use std::env; +use std::fs; +use std::io::{self, Read, Write}; +use std::net::TcpListener; +#[cfg(unix)] +use std::os::unix::net::UnixListener; +use std::path::PathBuf; +use std::process; +use std::sync::Arc; +use std::thread; + +use oliphaunt::{ + BackupArtifact, BackupFormat, BootstrapStrategy, DEFAULT_DATABASE, DEFAULT_USERNAME, + DatabaseRoot, DurabilityProfile, EngineCancel, EngineMode, Extension, NativeDirectConfig, + NativeRuntime, Oliphaunt, OliphauntRuntime, OpenConfig, PostgresStartupGuc, RestoreRequest, + RootLockPolicy, RuntimeFootprintProfile, StorageConfig, +}; + +const ENV_BROKER_AUTH_TOKEN: &str = "OLIPHAUNT_BROKER_AUTH_TOKEN"; + +fn main() { + if let Err(error) = run() { + println!("OLIPHAUNT_BROKER_ERROR {error}"); + process::exit(2); + } +} + +fn run() -> oliphaunt::Result<()> { + let args: Vec = env::args().skip(1).collect(); + if matches!(args.first().map(String::as_str), Some("restore")) { + return RestoreArgs::parse(args.into_iter().skip(1).collect())?.run(); + } + let args = BrokerArgs::parse(args)?; + let config = OpenConfig { + mode: EngineMode::NativeDirect, + storage: StorageConfig { + root: DatabaseRoot::Path(args.root), + bootstrap: args.bootstrap, + lock_policy: RootLockPolicy::ExclusiveProcess, + }, + direct: NativeDirectConfig::default(), + broker: Default::default(), + server: Default::default(), + durability: args.durability, + runtime_footprint: args.runtime_footprint, + startup_gucs: args.startup_gucs, + username: args.username, + database: args.database, + extensions: args.extensions, + }; + config.validate()?; + let mut session = OliphauntRuntime::from_env().open(config)?; + let cancel = session.cancel_handle().ok_or_else(|| { + oliphaunt::Error::Engine( + "native broker direct session does not expose cancellation".to_owned(), + ) + })?; + let listener = BrokerListener::bind(args.endpoint)?; + let cancel_listener = BrokerListener::bind(args.cancel_endpoint)?; + let cancel_ready_endpoint = cancel_listener.ready_endpoint(); + start_cancel_listener(cancel_listener, cancel, args.auth_token.clone()); + println!( + "OLIPHAUNT_BROKER_READY {} cancel={}", + listener.ready_endpoint(), + cancel_ready_endpoint + ); + io::stdout() + .flush() + .map_err(|err| oliphaunt::Error::Engine(format!("flush broker ready line: {err}")))?; + + let mut stream = listener.accept()?; + authenticate_client(&mut stream, &args.auth_token)?; + loop { + let request = oliphaunt::broker_ipc_read_request(&mut stream)?; + match request { + oliphaunt::BrokerIpcRequest::Authenticate(_) => { + oliphaunt::broker_ipc_write_error( + &mut stream, + "broker client is already authenticated".to_owned(), + )?; + break; + } + oliphaunt::BrokerIpcRequest::ExecProtocol(bytes) => { + let response = session.exec_protocol_raw(bytes.into()); + write_broker_response(&mut stream, response.map(|response| response.into_bytes()))?; + } + oliphaunt::BrokerIpcRequest::ExecSimpleQuery(sql) => { + let response = session.exec_simple_query(&sql); + write_broker_response(&mut stream, response.map(|response| response.into_bytes()))?; + } + oliphaunt::BrokerIpcRequest::ExecProtocolStream(bytes) => { + let result = session.exec_protocol_stream(bytes.into(), &mut |chunk| { + oliphaunt::broker_ipc_write_chunk(&mut stream, chunk) + }); + match result { + Ok(()) => oliphaunt::broker_ipc_write_ok(&mut stream, Vec::new())?, + Err(error) => { + oliphaunt::broker_ipc_write_error(&mut stream, error.to_string())? + } + } + } + oliphaunt::BrokerIpcRequest::Checkpoint => { + write_broker_response(&mut stream, session.checkpoint().map(|()| Vec::new()))?; + } + oliphaunt::BrokerIpcRequest::Backup(request) => { + write_broker_response( + &mut stream, + session.backup(request).map(|artifact| artifact.bytes), + )?; + } + oliphaunt::BrokerIpcRequest::Cancel => { + write_broker_response( + &mut stream, + Err(oliphaunt::Error::Engine( + "broker cancellation must use the cancel endpoint".to_owned(), + )), + )?; + } + oliphaunt::BrokerIpcRequest::Close => { + let result = session.close().map(|()| Vec::new()); + write_broker_response(&mut stream, result)?; + break; + } + } + } + Ok(()) +} + +struct RestoreArgs { + root: PathBuf, + artifact: PathBuf, + replace_existing: bool, +} + +impl RestoreArgs { + fn parse(args: Vec) -> oliphaunt::Result { + let mut root = None; + let mut artifact = None; + let mut replace_existing = false; + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => { + root = Some(iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "restore --root requires a filesystem path".to_owned(), + ) + })?); + } + "--artifact" => { + artifact = Some(iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "restore --artifact requires a physical archive path".to_owned(), + ) + })?); + } + "--replace-existing" => replace_existing = true, + _ => { + return Err(oliphaunt::Error::InvalidConfig(format!( + "unknown broker restore argument '{arg}'" + ))); + } + } + } + + Ok(Self { + root: root + .ok_or_else(|| { + oliphaunt::Error::InvalidConfig("restore --root is required".to_owned()) + })? + .into(), + artifact: artifact + .ok_or_else(|| { + oliphaunt::Error::InvalidConfig("restore --artifact is required".to_owned()) + })? + .into(), + replace_existing, + }) + } + + fn run(self) -> oliphaunt::Result<()> { + let bytes = fs::read(&self.artifact).map_err(|err| { + oliphaunt::Error::Engine(format!( + "read restore artifact {}: {err}", + self.artifact.display() + )) + })?; + let artifact = BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + }; + let mut request = RestoreRequest::physical_archive(self.root, artifact); + if self.replace_existing { + request = request.replace_existing(); + } + Oliphaunt::restore_blocking(request)?; + Ok(()) + } +} + +fn start_cancel_listener( + listener: BrokerListener, + cancel: Arc, + expected_token: String, +) { + thread::Builder::new() + .name("oliphaunt-broker-cancel".to_owned()) + .spawn(move || { + loop { + match listener.accept() { + Ok(mut stream) => { + if let Err(error) = + handle_cancel_client(&mut stream, cancel.as_ref(), &expected_token) + { + eprintln!("OLIPHAUNT_BROKER_CANCEL_ERROR {error}"); + } + } + Err(error) => { + eprintln!("OLIPHAUNT_BROKER_CANCEL_ERROR {error}"); + break; + } + } + } + }) + .expect("spawn native broker cancel listener"); +} + +fn handle_cancel_client( + stream: &mut Box, + cancel: &dyn EngineCancel, + expected_token: &str, +) -> oliphaunt::Result<()> { + authenticate_client(stream, expected_token)?; + match oliphaunt::broker_ipc_read_request(stream)? { + oliphaunt::BrokerIpcRequest::Cancel => { + write_broker_response(stream, cancel.cancel().map(|()| Vec::new())) + } + oliphaunt::BrokerIpcRequest::Authenticate(_) => oliphaunt::broker_ipc_write_error( + stream, + "broker cancel client is already authenticated".to_owned(), + ), + _ => oliphaunt::broker_ipc_write_error( + stream, + "broker cancel endpoint only accepts cancellation requests".to_owned(), + ), + } +} + +fn authenticate_client( + stream: &mut Box, + expected_token: &str, +) -> oliphaunt::Result<()> { + match oliphaunt::broker_ipc_read_request(stream)? { + oliphaunt::BrokerIpcRequest::Authenticate(token) if token == expected_token => { + oliphaunt::broker_ipc_write_ok(stream, Vec::new()) + } + oliphaunt::BrokerIpcRequest::Authenticate(_) => { + oliphaunt::broker_ipc_write_error( + stream, + "invalid broker authentication token".to_owned(), + )?; + Err(oliphaunt::Error::Engine( + "invalid broker authentication token".to_owned(), + )) + } + _ => { + oliphaunt::broker_ipc_write_error( + stream, + "broker client must authenticate before sending requests".to_owned(), + )?; + Err(oliphaunt::Error::Engine( + "broker client did not authenticate".to_owned(), + )) + } + } +} + +fn write_broker_response( + stream: &mut impl Write, + result: oliphaunt::Result>, +) -> oliphaunt::Result<()> { + match result { + Ok(bytes) => oliphaunt::broker_ipc_write_ok(stream, bytes), + Err(error) => oliphaunt::broker_ipc_write_error(stream, error.to_string()), + } +} + +struct BrokerArgs { + root: std::path::PathBuf, + endpoint: BrokerListenEndpoint, + cancel_endpoint: BrokerListenEndpoint, + bootstrap: BootstrapStrategy, + durability: DurabilityProfile, + runtime_footprint: RuntimeFootprintProfile, + startup_gucs: Vec, + username: String, + database: String, + extensions: Vec, + auth_token: String, +} + +impl BrokerArgs { + fn parse(args: Vec) -> oliphaunt::Result { + let mut root = None; + let mut endpoint = BrokerListenEndpoint::Tcp("127.0.0.1:0".to_owned()); + let mut cancel_endpoint = BrokerListenEndpoint::Tcp("127.0.0.1:0".to_owned()); + let mut bootstrap = "packaged-template".to_owned(); + let mut initdb = None; + let mut durability = DurabilityProfile::Safe; + let mut runtime_footprint = RuntimeFootprintProfile::Throughput; + let mut startup_gucs = Vec::new(); + let mut username = DEFAULT_USERNAME.to_owned(); + let mut database = DEFAULT_DATABASE.to_owned(); + let mut extensions = Vec::new(); + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => root = iter.next().map(Into::into), + "--listen" => { + let listen = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig("--listen requires an address".to_owned()) + })?; + endpoint = BrokerListenEndpoint::Tcp(listen); + } + "--cancel-listen" => { + let listen = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--cancel-listen requires an address".to_owned(), + ) + })?; + cancel_endpoint = BrokerListenEndpoint::Tcp(listen); + } + "--socket" => { + let socket = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--socket requires a filesystem path".to_owned(), + ) + })?; + endpoint = BrokerListenEndpoint::unix(socket)?; + } + "--cancel-socket" => { + let socket = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--cancel-socket requires a filesystem path".to_owned(), + ) + })?; + cancel_endpoint = BrokerListenEndpoint::unix(socket)?; + } + "--bootstrap" => { + bootstrap = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig("--bootstrap requires a value".to_owned()) + })?; + } + "--initdb" => { + initdb = Some(iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--initdb requires a filesystem path".to_owned(), + ) + })?); + } + "--durability" => { + durability = parse_durability(&iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig("--durability requires a value".to_owned()) + })?)?; + } + "--runtime-footprint" => { + runtime_footprint = + parse_runtime_footprint(&iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--runtime-footprint requires a value".to_owned(), + ) + })?)?; + } + "--startup-guc" => { + let assignment = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--startup-guc requires name=value".to_owned(), + ) + })?; + startup_gucs.push(parse_startup_guc(&assignment)?); + } + "--username" => { + username = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--username requires a PostgreSQL role".to_owned(), + ) + })?; + } + "--database" => { + database = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--database requires a PostgreSQL database name".to_owned(), + ) + })?; + } + "--extension" => { + let sql_name = iter.next().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--extension requires a SQL extension name".to_owned(), + ) + })?; + let extension = Extension::by_sql_name(&sql_name).ok_or_else(|| { + oliphaunt::Error::InvalidConfig(format!( + "unsupported native extension '{sql_name}'" + )) + })?; + extensions.push(extension); + } + _ => { + return Err(oliphaunt::Error::InvalidConfig(format!( + "unknown broker argument '{arg}'" + ))); + } + } + } + let bootstrap = parse_bootstrap(&bootstrap, initdb)?; + let auth_token = env::var(ENV_BROKER_AUTH_TOKEN).map_err(|_| { + oliphaunt::Error::InvalidConfig(format!("{ENV_BROKER_AUTH_TOKEN} is required")) + })?; + if auth_token.is_empty() { + return Err(oliphaunt::Error::InvalidConfig(format!( + "{ENV_BROKER_AUTH_TOKEN} must not be empty" + ))); + } + + Ok(Self { + root: root + .ok_or_else(|| oliphaunt::Error::InvalidConfig("--root is required".to_owned()))?, + endpoint, + cancel_endpoint, + bootstrap, + durability, + runtime_footprint, + startup_gucs, + username, + database, + extensions, + auth_token, + }) + } +} + +fn parse_bootstrap(value: &str, initdb: Option) -> oliphaunt::Result { + match value { + "packaged-template" => { + if initdb.is_some() { + return Err(oliphaunt::Error::InvalidConfig( + "--initdb is only valid with --bootstrap initdb-tooling-only".to_owned(), + )); + } + Ok(BootstrapStrategy::PackagedTemplate) + } + "existing-only" => { + if initdb.is_some() { + return Err(oliphaunt::Error::InvalidConfig( + "--initdb is only valid with --bootstrap initdb-tooling-only".to_owned(), + )); + } + Ok(BootstrapStrategy::ExistingOnly) + } + "initdb-tooling-only" => { + let initdb = initdb.ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--bootstrap initdb-tooling-only requires --initdb".to_owned(), + ) + })?; + Ok(BootstrapStrategy::InitdbToolingOnly { + initdb: initdb.into(), + }) + } + _ => Err(oliphaunt::Error::InvalidConfig(format!( + "unknown bootstrap strategy '{value}'" + ))), + } +} + +fn parse_durability(value: &str) -> oliphaunt::Result { + match value { + "safe" => Ok(DurabilityProfile::Safe), + "balanced" => Ok(DurabilityProfile::Balanced), + "fast-dev" => Ok(DurabilityProfile::FastDev), + _ => Err(oliphaunt::Error::InvalidConfig(format!( + "unknown durability profile '{value}'" + ))), + } +} + +fn parse_runtime_footprint(value: &str) -> oliphaunt::Result { + match value { + "throughput" => Ok(RuntimeFootprintProfile::Throughput), + "balanced-mobile" => Ok(RuntimeFootprintProfile::BalancedMobile), + "small-mobile" => Ok(RuntimeFootprintProfile::SmallMobile), + _ => Err(oliphaunt::Error::InvalidConfig(format!( + "unknown runtime footprint profile '{value}'" + ))), + } +} + +fn parse_startup_guc(value: &str) -> oliphaunt::Result { + let Some((name, guc_value)) = value.split_once('=') else { + return Err(oliphaunt::Error::InvalidConfig( + "--startup-guc requires name=value".to_owned(), + )); + }; + Ok(PostgresStartupGuc::new(name, guc_value)) +} + +enum BrokerListenEndpoint { + Tcp(String), + #[cfg(unix)] + Unix(std::path::PathBuf), +} + +impl BrokerListenEndpoint { + #[cfg(unix)] + fn unix(path: impl Into) -> oliphaunt::Result { + Ok(Self::Unix(path.into())) + } + + #[cfg(not(unix))] + fn unix(_path: impl Into) -> oliphaunt::Result { + Err(oliphaunt::Error::InvalidConfig( + "Unix-domain broker sockets are not supported on this platform".to_owned(), + )) + } +} + +trait BrokerTransport: Read + Write {} + +impl BrokerTransport for T where T: Read + Write {} + +enum BrokerListener { + Tcp(TcpListener), + #[cfg(unix)] + Unix { + listener: UnixListener, + path: std::path::PathBuf, + }, +} + +impl BrokerListener { + fn bind(endpoint: BrokerListenEndpoint) -> oliphaunt::Result { + match endpoint { + BrokerListenEndpoint::Tcp(listen) => { + TcpListener::bind(&listen).map(Self::Tcp).map_err(|err| { + oliphaunt::Error::Engine(format!("bind broker TCP listener {listen}: {err}")) + }) + } + #[cfg(unix)] + BrokerListenEndpoint::Unix(path) => { + if path.exists() { + std::fs::remove_file(&path).map_err(|err| { + oliphaunt::Error::Engine(format!( + "remove stale broker socket {}: {err}", + path.display() + )) + })?; + } + UnixListener::bind(&path) + .map(|listener| Self::Unix { listener, path }) + .map_err(|err| { + oliphaunt::Error::Engine(format!("bind broker Unix socket: {err}")) + }) + } + } + } + + fn ready_endpoint(&self) -> String { + match self { + Self::Tcp(listener) => listener + .local_addr() + .map(|addr| format!("tcp:{addr}")) + .unwrap_or_else(|_| "tcp:".to_owned()), + #[cfg(unix)] + Self::Unix { path, .. } => format!("unix:{}", path.display()), + } + } + + fn accept(&self) -> oliphaunt::Result> { + match self { + Self::Tcp(listener) => listener + .accept() + .map(|(stream, _)| Box::new(stream) as Box) + .map_err(|err| { + oliphaunt::Error::Engine(format!("accept broker TCP client: {err}")) + }), + #[cfg(unix)] + Self::Unix { listener, path } => listener + .accept() + .map(|(stream, _)| Box::new(stream) as Box) + .map_err(|err| { + oliphaunt::Error::Engine(format!( + "accept broker Unix client on {}: {err}", + path.display() + )) + }), + } + } +} diff --git a/src/runtimes/broker/targets/checksums.toml b/src/runtimes/broker/targets/checksums.toml new file mode 100644 index 00000000..9d8d2ca7 --- /dev/null +++ b/src/runtimes/broker/targets/checksums.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-broker.checksums" +product = "oliphaunt-broker" +kind = "checksums" +target = "portable" +asset = "oliphaunt-broker-{version}-release-assets.sha256" +surfaces = ["github-release", "rust-broker", "typescript-broker"] +published = true diff --git a/src/runtimes/broker/targets/linux-arm64-gnu.toml b/src/runtimes/broker/targets/linux-arm64-gnu.toml new file mode 100644 index 00000000..30ebb711 --- /dev/null +++ b/src/runtimes/broker/targets/linux-arm64-gnu.toml @@ -0,0 +1,10 @@ +id = "oliphaunt-broker.linux-arm64-gnu" +product = "oliphaunt-broker" +kind = "broker-helper" +target = "linux-arm64-gnu" +triple = "aarch64-unknown-linux-gnu" +runner = "ubuntu-24.04-arm" +asset = "oliphaunt-broker-{version}-linux-arm64-gnu.tar.gz" +executable_relative_path = "bin/oliphaunt-broker" +surfaces = ["github-release", "rust-broker", "typescript-broker"] +published = true diff --git a/src/runtimes/broker/targets/linux-x64-gnu.toml b/src/runtimes/broker/targets/linux-x64-gnu.toml new file mode 100644 index 00000000..c0d1e091 --- /dev/null +++ b/src/runtimes/broker/targets/linux-x64-gnu.toml @@ -0,0 +1,10 @@ +id = "oliphaunt-broker.linux-x64-gnu" +product = "oliphaunt-broker" +kind = "broker-helper" +target = "linux-x64-gnu" +triple = "x86_64-unknown-linux-gnu" +runner = "ubuntu-latest" +asset = "oliphaunt-broker-{version}-linux-x64-gnu.tar.gz" +executable_relative_path = "bin/oliphaunt-broker" +surfaces = ["github-release", "rust-broker", "typescript-broker"] +published = true diff --git a/src/runtimes/broker/targets/macos-arm64.toml b/src/runtimes/broker/targets/macos-arm64.toml new file mode 100644 index 00000000..bf32afed --- /dev/null +++ b/src/runtimes/broker/targets/macos-arm64.toml @@ -0,0 +1,10 @@ +id = "oliphaunt-broker.macos-arm64" +product = "oliphaunt-broker" +kind = "broker-helper" +target = "macos-arm64" +triple = "aarch64-apple-darwin" +runner = "macos-latest" +asset = "oliphaunt-broker-{version}-macos-arm64.tar.gz" +executable_relative_path = "bin/oliphaunt-broker" +surfaces = ["github-release", "rust-broker", "typescript-broker"] +published = true diff --git a/src/runtimes/broker/targets/windows-x64-msvc.toml b/src/runtimes/broker/targets/windows-x64-msvc.toml new file mode 100644 index 00000000..004e77c7 --- /dev/null +++ b/src/runtimes/broker/targets/windows-x64-msvc.toml @@ -0,0 +1,10 @@ +id = "oliphaunt-broker.windows-x64-msvc" +product = "oliphaunt-broker" +kind = "broker-helper" +target = "windows-x64-msvc" +triple = "x86_64-pc-windows-msvc" +runner = "windows-latest" +asset = "oliphaunt-broker-{version}-windows-x64-msvc.zip" +executable_relative_path = "bin/oliphaunt-broker.exe" +surfaces = ["github-release", "rust-broker", "typescript-broker"] +published = true diff --git a/src/runtimes/broker/tools/check-package.sh b/src/runtimes/broker/tools/check-package.sh new file mode 100755 index 00000000..78034740 --- /dev/null +++ b/src/runtimes/broker/tools/check-package.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +out_dir="target/oliphaunt-broker/package" +listing="$out_dir/oliphaunt-broker.package-files.txt" +mkdir -p "$out_dir" + +cargo package --list -p oliphaunt-broker --locked --allow-dirty >"$listing" + +require_entry() { + local entry="$1" + if ! grep -Fxq "$entry" "$listing"; then + echo "oliphaunt-broker package is missing required entry: $entry" >&2 + exit 1 + fi +} + +reject_pattern() { + local pattern="$1" + if grep -Eq "$pattern" "$listing"; then + echo "oliphaunt-broker package contains forbidden entry matching: $pattern" >&2 + grep -E "$pattern" "$listing" >&2 + exit 1 + fi +} + +require_entry "Cargo.toml" +require_entry "README.md" +require_entry "src/main.rs" +require_entry "targets/checksums.toml" +require_entry "targets/linux-arm64-gnu.toml" +require_entry "targets/linux-x64-gnu.toml" +require_entry "targets/macos-arm64.toml" +require_entry "targets/windows-x64-msvc.toml" + +reject_pattern '(^|/)(target|release-assets|release-stage)(/|$)' +reject_pattern '^src/runtimes/liboliphaunt/' +reject_pattern '^src/sdks/rust/' + +echo "oliphaunt-broker package shape verified: $listing" diff --git a/src/runtimes/liboliphaunt/native/CHANGELOG.md b/src/runtimes/liboliphaunt/native/CHANGELOG.md new file mode 100644 index 00000000..2bd56a5a --- /dev/null +++ b/src/runtimes/liboliphaunt/native/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + + +## [0.1.0] - 2026-06-01 + +### Changed + +- Initial native `liboliphaunt` release lane. +- Rename project to Oliphaunt diff --git a/src/runtimes/liboliphaunt/native/README.md b/src/runtimes/liboliphaunt/native/README.md new file mode 100644 index 00000000..2f855771 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/README.md @@ -0,0 +1,219 @@ +# liboliphaunt + +`liboliphaunt` is the native C boundary for embedded PostgreSQL. It owns the +PostgreSQL 18 source pin, upstreamable patch stack, C ABI header, native shim, +and local smoke/build scripts. + +This directory is intentionally not the Rust SDK. Rust lives in +`src/sdks/rust`; future Swift, Kotlin, React Native, and other targets +should bind to the same C ABI instead of reaching into PostgreSQL internals. + +## Layout + +- `include/oliphaunt.h`: public C ABI. +- `src/runtimes/liboliphaunt/native_native.c`: direct-mode lifecycle, backend thread ownership, + and non-query public ABI entrypoints. +- `src/runtimes/liboliphaunt/native_runtime.c`: embedded backend argv/default-GUC construction + and backend thread stack sizing policy. +- `src/runtimes/liboliphaunt/native_protocol.c`: raw protocol execution, streaming backpressure, + readiness scanning, and embedded backend read/write callbacks. +- `src/runtimes/liboliphaunt/native_bootstrap.c`: PGDATA bootstrap, desktop/tooling `initdb` + process execution, runtime-tool discovery, and startup argument copying. + Apple mobile targets compile this path as template-only because apps cannot + rely on spawning `initdb` from app storage. +- `src/runtimes/liboliphaunt/native_process.c`: process-wide direct-mode instance guard. +- `src/runtimes/liboliphaunt/native_static_extensions.c`: process-wide static extension registry + used by mobile-style builds that link extension modules into the app binary. +- `src/runtimes/liboliphaunt/native_trace.c`: low-overhead protocol timing counters. +- `src/runtimes/liboliphaunt/native_archive.c`: backup/restore lifecycle over the C ABI. +- `src/runtimes/liboliphaunt/native_archive_tar.c`: private ustar read/write implementation for + same-version physical archives. +- `src/runtimes/liboliphaunt/native_fs.c`: private filesystem/path helpers shared by archive and + restore code. +- `src/runtimes/liboliphaunt/native_internal.h`: private helpers shared between C translation + units; not part of the public ABI. +- `patches/postgresql-18.4/`: minimal PostgreSQL patch stack. +- `postgres18/source.toml`: pinned PostgreSQL source manifest. +- `postgres18/external-extensions.toml`: pinned external PG18 extension + candidate manifest for pgrx-backed extensions such as pgGraph and ParadeDB + `pg_search`. +- `bin/build-postgres18-macos.sh`: macOS build harness. +- `bin/check-external-extension-pins.sh`: no-network source-pin checker for + external extension candidates. +- `bin/build-external-pgrx-extensions-macos.sh`: opt-in pgrx artifact harness + for SDK-known external extension candidates, producing both normal server modules and + liboliphaunt-linked embedded modules. +- `bin/check-c-abi-conformance.sh`: consumer-style C ABI check that includes + only `oliphaunt.h`, links the public dylib, and verifies stable constants, + structs, exported symbols, and safe global calls. +- `bin/smoke-host-happy-path.sh`: host C ABI smoke harness for macOS, Linux, + and Windows. `bin/smoke-macos-happy-path.sh` remains as a compatibility + wrapper. + +## Build + +```sh +src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh +``` + +The default output root is `target/liboliphaunt-pg18`. Use `OLIPHAUNT_*` for runtime and build controls. `LIBOLIPHAUNT_PATH` is reserved +for the literal C library artifact path. + +The direct build produces PostgreSQL runtime artifacts without optional +extension artifacts by default. Set `OLIPHAUNT_BUILD_EXTENSIONS=1` only when +refreshing or validating exact extension artifacts; the +`src/runtimes/liboliphaunt/native/tools/check-track.sh extensions` and `full` lanes set that +flag for you. + +External pgrx extensions are not folded into the first-party extension build by +default. Their source pins live in +`src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml`; the native validation wrapper +runs `src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh` without network access and +verifies any local checkout that exists under `target/oliphaunt-sources/checkouts`. Use +`src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh --online` when intentionally +refreshing the pins against upstream refs. + +Build the opt-in pgrx artifacts with: + +```sh +src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh --fetch +src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh +``` + +The harness requires the manifest-pinned `cargo-pgrx` version and automatically +uses `target/liboliphaunt-tools/bin/cargo-pgrx` when it exists. It packages each +selected extension once for the normal PostgreSQL server module path and once +with linker flags that bind PostgreSQL symbols to `@rpath/liboliphaunt.dylib` for +direct/broker embedded loading. Use +`OLIPHAUNT_EXTERNAL_PGRX_EXTENSIONS=pggraph` or +`OLIPHAUNT_EXTERNAL_PGRX_EXTENSIONS=paradedb-pg-search` to restrict the build. +The ParadeDB lane is intentionally disk-guarded because `pg_search` pulls a +large DataFusion/Tantivy release build; free target space first, or set +`OLIPHAUNT_EXTERNAL_PGRX_SKIP_DISK_PREFLIGHT=1` only for local experiments. +`src/runtimes/liboliphaunt/native/tools/check-track.sh external-pgrx` runs the no-build +`--check-current` gate for those artifacts. +The currentness fingerprint excludes harness prose and other non-build text. +When only the fingerprint schema changes, use `--refresh-current-stamps` to +validate the existing normal/embedded payloads and restamp them without running +the expensive pgrx packaging step. + +`OLIPHAUNT_STARTUP_TIMEOUT_MS` bounds only initial backend startup readiness. +Normal `oliphaunt_exec_protocol`, `oliphaunt_exec_simple_query`, and streaming +execution do not impose a synthetic query timeout; callers should use +`oliphaunt_cancel` to interrupt long-running SQL. Ordinary SDK close is a +lifecycle detach/wait boundary, not an implicit query cancellation primitive. +The legacy `OLIPHAUNT_TIMEOUT_MS` name remains a startup-time fallback during +the migration. + +The C runtime keeps throughput-oriented PostgreSQL defaults for direct callers: +`shared_buffers=128MB`, `wal_buffers=4MB`, and `min_wal_size=80MB`. SDKs that +need mobile-sized resident footprints do not need a new C ABI; they pass +validated PostgreSQL `-c name=value` startup arguments through +`OliphauntConfig.startup_args`. Later arguments win, so Rust/Swift/Kotlin/RN +can apply balanced/small mobile profiles and benchmark-specific overrides above +the stable C boundary. + +Mobile builds must hydrate PGDATA from a packaged template before calling +`oliphaunt_init`. On Apple mobile platforms the C layer compiles out the +`fork`/`exec` `initdb` fallback and returns a direct error if `PG_VERSION` is +missing. `tools/run-host-c-smoke.mjs` includes a fast iOS simulator syntax +check over the liboliphaunt C shim files. `bin/check-postgres18-ios-simulator.sh` +then validates the upstream PostgreSQL patch touchpoints that matter for the +embedded path: host I/O callbacks, the embedded backend entrypoint, lifecycle +cleanup, static extension lookup, and shell-command exclusion on Apple mobile +SDKs. +`bin/build-postgres18-ios-simulator.sh` is the fast simulator artifact lane for +Expo/RN and Swift validation. `bin/build-postgres18-ios-device.sh` builds the +matching `IOS` device slice, and `bin/build-ios-xcframework.sh` packages both +validated dylibs with public headers as +`target/liboliphaunt-ios-xcframework/out/liboliphaunt.xcframework`. Each lane +cross-builds the patched PostgreSQL backend object graph, tolerates the final +PostgreSQL executable/tool link failure after the embedded objects exist, +links target-specific static ICU libraries for PostgreSQL collation support, +validates the exported C ABI symbols, and reuses the result through stamped +ccache-friendly paths. + +## Static Extension Registry + +Mobile-style packages cannot rely on PostgreSQL dynamically loading every +extension module from the app bundle. `oliphaunt_register_static_extensions` +registers statically linked modules before `oliphaunt_init`, and the PostgreSQL +`dfmgr` patch resolves those entries through the same normal `CREATE +EXTENSION`/`LOAD` path that dynamic modules use. The registry is process-wide, +validates extension names, magic functions, symbol names, duplicate symbols, +and ABI versions, and becomes immutable at backend startup. + +The runtime-resource `--mobile-static-module ` flag is only release +metadata. It must match modules that the platform package actually links and +registers through this C ABI before opening the database. + +The iOS simulator, iOS device, and Android arm64 build lanes also emit +per-extension static archives beside the generated object lists: +`out/extensions//liboliphaunt_extension_.a`. Those archives are the +release artifact boundary for exact mobile extension selection; SDK packaging +can link only the archives for the extensions an app requested instead of +shipping one bundled extension set or rebuilding extension source in the app. +`bin/build-ios-extension-xcframeworks.sh` packages selected simulator/device +archives into per-extension XCFrameworks for Apple SDK and Xcode consumers +without rebuilding extension sources. + +## Root Ownership + +`oliphaunt_init` takes a non-blocking stable sibling filesystem lease for +`` by default and creates +`/.oliphaunt.lock` as the visible root marker. `oliphaunt_restore` +takes the same stable lease before staging or publishing a restored root. This +keeps plain C, Swift, Kotlin, React Native platform adapters, and any future +direct C ABI caller from accidentally opening, replacing, or restoring the same +embedded root concurrently. +Stable lease filenames live beside the root directory and use the same +`.oliphaunt-root-.lock` algorithm as the Rust SDK, so direct C, +Rust, Swift, Kotlin, and React Native platform adapters contend on the same root +identity instead of merely enforcing per-SDK locks. + +Callers that already own an equivalent root coordinator may set +`OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK` in `OliphauntConfig.reserved_flags`. Today the +Rust SDK uses that flag because `oliphaunt` coordinates direct, broker, +server, backup, and restore through its own process registry plus stable +filesystem root leases. SDKs must not set the flag unless they can prove the +same root cannot be opened or replaced concurrently. + +## Physical Archive Contract + +`oliphaunt_backup(..., OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, ...)` emits a +same-version concrete root archive. `oliphaunt_backup_ex` uses the same archive +writer and can append generated metadata entries such as `manifest.properties` +or `.oliphaunt/backup-manifest.properties` while the archive is being produced, +which avoids a second full archive copy in SDKs. The C ABI accepts only regular +files and directories under `pgdata`; symlinks, hardlinks, device nodes, FIFOs, +sockets, sparse/special tar records, external tablespaces, and linked WAL +directories are rejected. `oliphaunt_restore` enforces the same rule before +publishing a restored root, so Swift, Kotlin, React Native, and Rust SDK callers +inherit one portable archive contract instead of platform-specific tar behavior. + +## Fast Native Iteration + +For product-track work, prefer the native-only validation wrapper instead of the +workspace-wide WASIX lanes: + +```sh +moon run liboliphaunt-native:test +src/runtimes/liboliphaunt/native/tools/check-track.sh quick +src/runtimes/liboliphaunt/native/tools/check-track.sh sdks +src/runtimes/liboliphaunt/native/tools/check-track.sh full +``` + +`liboliphaunt-native:test` is the no-build host C ABI smoke for the current platform. +It reuses the release-runtime artifact produced for macOS, Linux, or Windows +and fails if that artifact is missing or stale. `quick` reuses the existing +native runtime when it is present, then runs the C ABI smoke and Rust native SDK +tests. `sdks` +validates Swift, Kotlin, and React Native package checks against the same +runtime. `full` enables native extension artifacts and the extension matrix; in +the default `missing` policy it first runs the build script's no-build +`--check-extension-artifacts-current` probe and only rebuilds when the extension +fingerprint or required artifacts are stale or absent. Set +`OLIPHAUNT_TRACK_BUILD=never` to prove the command will not rebuild native +PostgreSQL; the native track also runs the no-build +`--check-oliphaunt-current` probe so stale C ABI sources fail before tests trust +an old dylib. Use `OLIPHAUNT_TRACK_BUILD=always` for an intentional rebuild. diff --git a/src/runtimes/liboliphaunt/native/THIRD_PARTY_NOTICES.md b/src/runtimes/liboliphaunt/native/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..be970c50 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/THIRD_PARTY_NOTICES.md @@ -0,0 +1,16 @@ +# liboliphaunt Third-Party Notices + +`liboliphaunt` ships native embedded PostgreSQL runtime artifacts, selected SQL +extensions, and supporting runtime resources. + +The PostgreSQL runtime is derived from PostgreSQL 18 source pinned under +`src/postgres/versions/18/` and built with the native patch stack owned by +`src/runtimes/liboliphaunt/native/`. + +- PostgreSQL license: https://www.postgresql.org/about/licence/ +- ICU / Unicode License v3: https://github.com/unicode-org/icu/blob/main/LICENSE + +Third-party source pins for optional external extensions and supporting native +libraries are maintained in `src/sources/third-party/`. Exact SQL extension selection is +modeled in `src/extensions/`; release artifacts must include only the extension +artifacts explicitly selected by the application developer. diff --git a/src/runtimes/liboliphaunt/native/VERSION b/src/runtimes/liboliphaunt/native/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/runtimes/liboliphaunt/native/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh b/src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh new file mode 100755 index 00000000..79415d59 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh @@ -0,0 +1,592 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +work_root="${OLIPHAUNT_WORK_ROOT:-$repo_root/target/liboliphaunt-pg18}" +repo_tools_bin="$repo_root/target/liboliphaunt-tools/bin" +install_dir="$work_root/install" +out_dir="$work_root/out" +lib_out="$out_dir/liboliphaunt.dylib" +embedded_modules_dir="$out_dir/modules" +package_root="$work_root/external-pgrx/packages" +target_root="$work_root/external-pgrx/target" +source_stage_root="$work_root/external-pgrx/sources" +pgrx_home="${PGRX_HOME:-$work_root/external-pgrx/pgrx-home}" +stamp_root="$out_dir/external-pgrx" +script_mode="${1:-build}" +selected_extensions="${OLIPHAUNT_EXTERNAL_PGRX_EXTENSIONS:-all}" +build_fingerprint_schema="liboliphaunt-external-pgrx-build-v3" + +if [ -x "$repo_tools_bin/cargo-pgrx" ]; then + case ":$PATH:" in + *":$repo_tools_bin:"*) ;; + *) export PATH="$repo_tools_bin:$PATH" ;; + esac +fi + +ids=(pggraph paradedb-pg-search) +sql_names=(graph pg_search) +module_stems=(graph pg_search) +repos=( + https://github.com/evokoa/pggraph.git + https://github.com/paradedb/paradedb.git +) +refs=(HEAD refs/tags/v0.23.4) +commits=( + 4ea3c3206811deda03de136b4f465a2cf9bc8e72 + c07921a78f3d24cbb0251b31a1150a7db600af5a +) +checkouts=( + "$repo_root/target/oliphaunt-sources/checkouts/pggraph" + "$repo_root/target/oliphaunt-sources/checkouts/paradedb" +) +source_subdirs=(graph pg_search) +pgrx_versions=(0.18.0 0.18.0) +pg_features=(pg18 pg18) +min_free_kib=(2097152 12582912) + +usage() { + cat >&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh [build|--fetch|--check-current|--refresh-current-stamps|--print-required-artifacts] + +Environment: + OLIPHAUNT_EXTERNAL_PGRX_EXTENSIONS=all|pggraph,paradedb-pg-search + OLIPHAUNT_EXTERNAL_PGRX_SKIP_DISK_PREFLIGHT=1 to bypass disk checks + +The build mode requires cargo-pgrx. The default fast native validation does not +run this expensive lane; it is the opt-in artifact builder for SDK-known pgrx +extensions. +MSG +} + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command for external pgrx extension build: $1" >&2 + exit 1 + fi +} + +available_kib_for_path() { + local path="$1" + mkdir -p "$path" + df -Pk "$path" | awk 'NR == 2 { print $4 }' +} + +format_gib_from_kib() { + awk -v kib="$1" 'BEGIN { printf "%.1f GiB", kib / 1048576 }' +} + +require_free_space_for_candidate() { + local index="$1" + [ "${OLIPHAUNT_EXTERNAL_PGRX_SKIP_DISK_PREFLIGHT:-0}" = "1" ] && return 0 + + local required="${min_free_kib[$index]}" + local available + available="$(available_kib_for_path "$work_root")" + if [ -z "$available" ] || [ "$available" -lt "$required" ]; then + echo "external pgrx build for ${ids[$index]} needs at least $(format_gib_from_kib "$required") free under $work_root; available: $(format_gib_from_kib "${available:-0}")" >&2 + echo "free disk space or set OLIPHAUNT_EXTERNAL_PGRX_SKIP_DISK_PREFLIGHT=1 for a local experiment" >&2 + exit 1 + fi +} + +candidate_selected() { + local id="$1" + local raw="$selected_extensions" + [ "$raw" = "all" ] && return 0 + IFS=',' read -r -a selected <<< "$raw" + local candidate + for candidate in "${selected[@]}"; do + candidate="${candidate#"${candidate%%[![:space:]]*}"}" + candidate="${candidate%"${candidate##*[![:space:]]}"}" + if [ "$candidate" = "$id" ]; then + return 0 + fi + done + return 1 +} + +selected_indices() { + local index + for index in "${!ids[@]}"; do + if candidate_selected "${ids[$index]}"; then + printf '%s\n' "$index" + fi + done +} + +assert_known_selection() { + [ "$selected_extensions" = "all" ] && return 0 + IFS=',' read -r -a selected <<< "$selected_extensions" + local candidate + for candidate in "${selected[@]}"; do + candidate="${candidate#"${candidate%%[![:space:]]*}"}" + candidate="${candidate%"${candidate##*[![:space:]]}"}" + [ -n "$candidate" ] || continue + local found=0 + local id + for id in "${ids[@]}"; do + if [ "$candidate" = "$id" ]; then + found=1 + fi + done + if [ "$found" -eq 0 ]; then + echo "unknown external pgrx extension selection: $candidate" >&2 + exit 2 + fi + done +} + +module_depends_on_liboliphaunt() { + local module="$1" + [ -f "$module" ] || return 1 + case "$(otool -L "$module" 2>/dev/null || true)" in + *"@rpath/liboliphaunt.dylib"*) return 0 ;; + *) return 1 ;; + esac +} + +module_has_postgres_symbols_bound_to_liboliphaunt() { + local module="$1" + nm -m "$module" 2>/dev/null | + awk 'index($0, "(from liboliphaunt)") { found = 1 } END { exit found ? 0 : 1 }' +} + +normal_pgrx_rustflags() { + printf '%s -C link-arg=-Wl,-undefined,dynamic_lookup' "${RUSTFLAGS:-}" +} + +embedded_pgrx_rustflags() { + printf '%s -C link-arg=-L%s -C link-arg=-loliphaunt -C link-arg=-Wl,-rpath,%s' \ + "${RUSTFLAGS:-}" "$out_dir" "$out_dir" +} + +checkout_clean_or_allowed() { + local checkout="$1" + [ "${OLIPHAUNT_EXTERNAL_PGRX_ALLOW_DIRTY:-0}" = "1" ] && return 0 + if [ -n "$(git -C "$checkout" status --porcelain)" ]; then + echo "external extension checkout has local changes: $checkout" >&2 + echo "set OLIPHAUNT_EXTERNAL_PGRX_ALLOW_DIRTY=1 only for local experiments" >&2 + exit 1 + fi +} + +fetch_candidate() { + local index="$1" + local id="${ids[$index]}" + local checkout="${checkouts[$index]}" + local repo="${repos[$index]}" + local ref="${refs[$index]}" + local commit="${commits[$index]}" + + if [ ! -d "$checkout/.git" ]; then + mkdir -p "$(dirname "$checkout")" + run git clone "$repo" "$checkout" + fi + checkout_clean_or_allowed "$checkout" + run git -C "$checkout" fetch --tags "$repo" "$ref" + run git -C "$checkout" checkout --detach "$commit" + checkout_clean_or_allowed "$checkout" + echo "external pgrx checkout ready for $id at $commit" +} + +fingerprint_source_state() { + local root="$1" + printf 'checkout_head=%s\n' "$(git -C "$root" rev-parse HEAD)" + if [ -n "$(git -C "$root" status --porcelain=v1)" ]; then + printf 'checkout_dirty=1\n' + git -C "$root" status --porcelain=v1 | LC_ALL=C sort | sed 's/^/checkout_status=/' + git -C "$root" diff --binary HEAD -- | shasum -a 256 | awk '{ print "checkout_diff_sha256=" $1 }' + else + printf 'checkout_dirty=0\n' + fi +} + +prepare_source_stage() { + local index="$1" + local id="${ids[$index]}" + local checkout="${checkouts[$index]}" + local source_subdir="${source_subdirs[$index]}" + local stage="$source_stage_root/$id" + + rm -rf "$stage" + mkdir -p "$stage" + rsync -a \ + --exclude .git \ + --exclude target \ + --exclude .pgrx \ + "$checkout/" "$stage/" + if [ ! -f "$stage/Cargo.toml" ]; then + cat > "$stage/Cargo.toml" < "$tmp" + shasum -a 256 "$tmp" | awk '{print $1}' > "$stamp" + mv "$tmp" "$inputs" +} + +find_one_packaged_file() { + local root="$1" + local name="$2" + find "$root" -type f -name "$name" -print | LC_ALL=C sort | head -n 1 +} + +copy_sql_assets() { + local package_dir="$1" + local sql_name="$2" + local target="$install_dir/share/postgresql/extension" + mkdir -p "$target" + + local control + control="$(find_one_packaged_file "$package_dir" "$sql_name.control")" + if [ -z "$control" ]; then + echo "pgrx package did not produce $sql_name.control under $package_dir" >&2 + exit 1 + fi + cp -p "$control" "$target/$sql_name.control" + + local copied=0 + while IFS= read -r sql_file; do + [ -n "$sql_file" ] || continue + cp -p "$sql_file" "$target/$(basename "$sql_file")" + copied=$((copied + 1)) + done < <(find "$package_dir" -type f -name "$sql_name--*.sql" -print | LC_ALL=C sort) + if [ "$copied" -eq 0 ]; then + echo "pgrx package did not produce any $sql_name--*.sql files under $package_dir" >&2 + exit 1 + fi +} + +find_packaged_module() { + local package_dir="$1" + local module_stem="$2" + local module + module="$(find_one_packaged_file "$package_dir" "$module_stem.dylib")" + if [ -n "$module" ]; then + printf '%s\n' "$module" + return 0 + fi + module="$(find_one_packaged_file "$package_dir" "lib$module_stem.dylib")" + if [ -n "$module" ]; then + printf '%s\n' "$module" + return 0 + fi + return 1 +} + +copy_module_asset() { + local package_dir="$1" + local module_stem="$2" + local target="$3" + local module + if ! module="$(find_packaged_module "$package_dir" "$module_stem")"; then + echo "pgrx package did not produce module $module_stem.dylib under $package_dir" >&2 + exit 1 + fi + mkdir -p "$(dirname "$target")" + cp -p "$module" "$target" +} + +artifact_payload_ready() { + local index="$1" + local sql_name="${sql_names[$index]}" + local module_stem="${module_stems[$index]}" + + [ -f "$install_dir/share/postgresql/extension/$sql_name.control" ] || return 1 + compgen -G "$install_dir/share/postgresql/extension/$sql_name--*.sql" >/dev/null || return 1 + [ -f "$install_dir/lib/postgresql/$module_stem.dylib" ] || return 1 + [ -f "$embedded_modules_dir/$module_stem.dylib" ] || return 1 + module_depends_on_liboliphaunt "$install_dir/lib/postgresql/$module_stem.dylib" && return 1 + module_depends_on_liboliphaunt "$embedded_modules_dir/$module_stem.dylib" || return 1 + module_has_postgres_symbols_bound_to_liboliphaunt "$embedded_modules_dir/$module_stem.dylib" || return 1 +} + +artifact_ready() { + local index="$1" + local id="${ids[$index]}" + local stamp + stamp="$(artifact_stamp "$id")" + + artifact_payload_ready "$index" || return 1 + [ -f "$stamp" ] || return 1 + [ "$(cat "$stamp")" = "$(build_fingerprint "$index")" ] || return 1 +} + +refresh_candidate_stamp() { + local index="$1" + local id="${ids[$index]}" + if ! artifact_payload_ready "$index"; then + echo "external pgrx payload artifacts are missing or invalid for $id; rebuild before refreshing stamps" >&2 + exit 1 + fi + write_artifact_stamp "$index" + echo "external pgrx stamp refreshed for $id" +} + +require_core_runtime() { + if ! "$repo_root/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh" --check-oliphaunt-current >/dev/null; then + echo "native liboliphaunt core runtime is missing or stale; refreshing core runtime first" + OLIPHAUNT_BUILD_EXTENSIONS=0 "$repo_root/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh" + fi + [ -x "$install_dir/bin/pg_config" ] || { + echo "native PostgreSQL install is missing pg_config at $install_dir/bin/pg_config" >&2 + exit 1 + } +} + +require_pgrx_toolchain() { + require_command cargo + require_command rustc + require_command rsync + export PGRX_HOME="$pgrx_home" + cargo pgrx --version >/dev/null 2>&1 || { + cat >&2 <<'MSG' +missing cargo-pgrx. Install the version declared in +src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml, for example: + + cargo install --locked cargo-pgrx --version 0.18.0 --root target/liboliphaunt-tools +MSG + exit 1 + } +} + +ensure_pgrx_home() { + export PGRX_HOME="$pgrx_home" + mkdir -p "$PGRX_HOME" + if [ ! -f "$PGRX_HOME/config.toml" ] || + ! grep -q "$install_dir/bin/pg_config" "$PGRX_HOME/config.toml"; then + run cargo pgrx init --pg18 "$install_dir/bin/pg_config" + fi +} + +verify_pgrx_version() { + local expected="$1" + local actual + actual="$(cargo pgrx --version | awk '{print $2}')" + if [ "$actual" != "$expected" ]; then + echo "cargo-pgrx version mismatch: expected $expected, got $actual" >&2 + exit 1 + fi +} + +build_candidate() { + local index="$1" + local id="${ids[$index]}" + local checkout="${checkouts[$index]}" + local source_dir + local sql_name="${sql_names[$index]}" + local module_stem="${module_stems[$index]}" + local feature="${pg_features[$index]}" + local pgrx_version="${pgrx_versions[$index]}" + local normal_package="$package_root/$id/normal" + local embedded_package="$package_root/$id/embedded" + local normal_target="$target_root/$id/normal" + local embedded_target="$target_root/$id/embedded" + local stamp + stamp="$(artifact_stamp "$id")" + + [ -d "$checkout/.git" ] || { + echo "external pgrx checkout is missing for $id: $checkout" >&2 + echo "run: src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh --fetch" >&2 + exit 1 + } + checkout_clean_or_allowed "$checkout" + if [ "$(git -C "$checkout" rev-parse HEAD)" != "${commits[$index]}" ]; then + echo "external pgrx checkout for $id is not at pinned commit ${commits[$index]}" >&2 + exit 1 + fi + [ -f "$checkout/${source_subdirs[$index]}/Cargo.toml" ] || { + echo "external pgrx source for $id is missing Cargo.toml at $checkout/${source_subdirs[$index]}" >&2 + exit 1 + } + + verify_pgrx_version "$pgrx_version" + local desired_hash + desired_hash="$(build_fingerprint "$index")" + if [ "${OLIPHAUNT_FORCE_EXTERNAL_PGRX_REBUILD:-0}" != "1" ] && + [ -f "$stamp" ] && + [ "$(cat "$stamp")" = "$desired_hash" ] && + artifact_ready "$index"; then + echo "reusing external pgrx artifacts for $id" + return + fi + + require_free_space_for_candidate "$index" + rm -rf "$normal_package" "$embedded_package" + mkdir -p "$normal_package" "$embedded_package" "$normal_target" "$embedded_target" "$stamp_root" + source_dir="$(prepare_source_stage "$index")" + + run env CARGO_TARGET_DIR="$normal_target" \ + RUSTFLAGS="$(normal_pgrx_rustflags)" \ + cargo pgrx package \ + --manifest-path "$source_dir/Cargo.toml" \ + --pg-config "$install_dir/bin/pg_config" \ + --out-dir "$normal_package" \ + --no-default-features \ + --features "$feature" + + copy_sql_assets "$normal_package" "$sql_name" + copy_module_asset "$normal_package" "$module_stem" "$install_dir/lib/postgresql/$module_stem.dylib" + if module_depends_on_liboliphaunt "$install_dir/lib/postgresql/$module_stem.dylib"; then + echo "normal server module for $id unexpectedly links against liboliphaunt" >&2 + exit 1 + fi + + run env CARGO_TARGET_DIR="$embedded_target" \ + RUSTFLAGS="$(embedded_pgrx_rustflags)" \ + cargo pgrx package \ + --manifest-path "$source_dir/Cargo.toml" \ + --pg-config "$install_dir/bin/pg_config" \ + --out-dir "$embedded_package" \ + --no-default-features \ + --features "$feature" + + copy_module_asset "$embedded_package" "$module_stem" "$embedded_modules_dir/$module_stem.dylib" + if ! module_depends_on_liboliphaunt "$embedded_modules_dir/$module_stem.dylib"; then + echo "embedded module for $id is not linked against @rpath/liboliphaunt.dylib" >&2 + exit 1 + fi + if ! module_has_postgres_symbols_bound_to_liboliphaunt "$embedded_modules_dir/$module_stem.dylib"; then + echo "embedded module for $id does not bind PostgreSQL symbols to liboliphaunt" >&2 + exit 1 + fi + + write_artifact_stamp "$index" + artifact_ready "$index" || { + echo "external pgrx artifact validation failed for $id after build" >&2 + exit 1 + } +} + +assert_manifest_and_pins() { + run "$repo_root/src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh" +} + +if [ "$(uname -s)" != "Darwin" ]; then + echo "external pgrx extension build currently targets the macOS native liboliphaunt lane" >&2 + exit 2 +fi + +assert_known_selection + +case "$script_mode" in + --print-required-artifacts) + while IFS= read -r index; do + printf 'control:%s\n' "${sql_names[$index]}" + printf 'module:%s\n' "${module_stems[$index]}" + done < <(selected_indices) + exit 0 + ;; + --fetch) + assert_manifest_and_pins + while IFS= read -r index; do + fetch_candidate "$index" + done < <(selected_indices) + assert_manifest_and_pins + exit 0 + ;; + --check-current) + assert_manifest_and_pins + require_core_runtime + require_pgrx_toolchain + ensure_pgrx_home + while IFS= read -r index; do + if ! artifact_ready "$index"; then + echo "external pgrx artifacts are missing or stale for ${ids[$index]}" >&2 + exit 1 + fi + done < <(selected_indices) + echo "external pgrx artifacts are current" + exit 0 + ;; + --refresh-current-stamps) + assert_manifest_and_pins + require_core_runtime + require_pgrx_toolchain + ensure_pgrx_home + while IFS= read -r index; do + refresh_candidate_stamp "$index" + done < <(selected_indices) + echo "external pgrx artifact stamps are current" + exit 0 + ;; + build) + assert_manifest_and_pins + require_core_runtime + require_pgrx_toolchain + ensure_pgrx_home + while IFS= read -r index; do + build_candidate "$index" + done < <(selected_indices) + echo "external pgrx artifacts are ready" + ;; + *) + usage + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh b/src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh new file mode 100755 index 00000000..359cfe5c --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh @@ -0,0 +1,556 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/mobile-static-extensions.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" + +simulator_out="${OLIPHAUNT_IOS_SIMULATOR_OUT:-$repo_root/target/liboliphaunt-ios-simulator/out}" +device_out="${OLIPHAUNT_IOS_DEVICE_OUT:-$repo_root/target/liboliphaunt-ios-device/out}" +work_root="${OLIPHAUNT_IOS_EXTENSION_XCFRAMEWORK_ROOT:-$repo_root/target/liboliphaunt-ios-extension-xcframeworks}" +out_dir="$work_root/out" +headers_dir="$repo_root/src/runtimes/liboliphaunt/native/include" +runtime_resources_dir="${OLIPHAUNT_IOS_RUNTIME_RESOURCES_DIR:-${OLIPHAUNT_RUNTIME_RESOURCES_DIR:-}}" +manifest_file="$out_dir/manifest.properties" + +usage() { + cat >&2 <] + +Packages selected prebuilt mobile extension archives into per-extension iOS +XCFrameworks. Prefer passing the Rust runtime-resource output so the selected +native modules are derived from runtime/manifest.properties: + + src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh \\ + --runtime-resources target/oliphaunt-resources + +For release automation, OLIPHAUNT_MOBILE_STATIC_EXTENSIONS may still provide a +comma-separated exact extension or module-stem list: + + OLIPHAUNT_MOBILE_STATIC_EXTENSIONS=vector,pg_trgm \\ + src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh + +Inputs: + OLIPHAUNT_IOS_SIMULATOR_OUT default target/liboliphaunt-ios-simulator/out + OLIPHAUNT_IOS_DEVICE_OUT default target/liboliphaunt-ios-device/out + OLIPHAUNT_RUNTIME_RESOURCES_DIR optional Rust runtime-resource output + OLIPHAUNT_IOS_RUNTIME_RESOURCES_DIR optional iOS-specific runtime-resource output +Output: + target/liboliphaunt-ios-extension-xcframeworks/out//liboliphaunt_extension_.xcframework + target/liboliphaunt-ios-extension-xcframeworks/out/dependencies//liboliphaunt_dependency_.xcframework + target/liboliphaunt-ios-extension-xcframeworks/out/manifest.properties +USAGE +} + +mode="build" +while [ "$#" -gt 0 ]; do + case "$1" in + --check-current) + mode="check" + shift + ;; + --runtime-resources) + [ "$#" -ge 2 ] || { + usage + exit 2 + } + runtime_resources_dir="$2" + shift 2 + ;; + --runtime-resources=*) + runtime_resources_dir="${1#--runtime-resources=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 2 + ;; + esac +done + +selected_extensions=() +selected_stems=() +selected_dependencies=() + +join_csv() { + local old_ifs="$IFS" + IFS="," + printf '%s' "$*" + IFS="$old_ifs" +} + +runtime_resources_root() { + local root="$1" + if [ -f "$root/oliphaunt/runtime/manifest.properties" ]; then + printf '%s\n' "$root/oliphaunt" + elif [ -f "$root/runtime/manifest.properties" ]; then + printf '%s\n' "$root" + else + echo "iOS extension runtime resources are not an Oliphaunt resource root: $root" >&2 + exit 2 + fi +} + +resource_manifest_value() { + local root="$1" + local key="$2" + local manifest="$root/runtime/manifest.properties" + awk -F '=' -v key="$key" '$1 == key { print substr($0, length(key) + 2); found = 1; exit } END { exit found ? 0 : 1 }' "$manifest" || true +} + +static_registry_manifest_value() { + local root="$1" + local key="$2" + local manifest="$root/static-registry/manifest.properties" + [ -f "$manifest" ] || return 1 + awk -F '=' -v key="$key" '$1 == key { print substr($0, length(key) + 2); found = 1; exit } END { exit found ? 0 : 1 }' "$manifest" || true +} + +selected_raw_from_runtime_resources() { + [ -n "$runtime_resources_dir" ] || return 0 + local package_root schema state raw + package_root="$(runtime_resources_root "$runtime_resources_dir")" + schema="$(resource_manifest_value "$package_root" "schema")" + if [ "$schema" != "oliphaunt-runtime-resources-v1" ]; then + echo "iOS extension runtime resources have unsupported schema '${schema:-}'; expected oliphaunt-runtime-resources-v1" >&2 + exit 2 + fi + state="$(resource_manifest_value "$package_root" "mobileStaticRegistryState")" + raw="$(resource_manifest_value "$package_root" "nativeModuleStems")" + if [ "$state" = "pending" ] && [ -n "$raw" ]; then + echo "runtime resources have pending mobile static-registry modules; rebuild them with --mobile-static-module before iOS extension packaging" >&2 + exit 2 + fi + printf '%s\n' "$raw" +} + +portable_id() { + case "$1" in + ""|*[!A-Za-z0-9._-]*) + return 1 + ;; + *) + [ "${#1}" -le 128 ] + ;; + esac +} + +add_selected_pair() { + local sql_name="$1" + local stem="$2" + portable_id "$sql_name" || { + echo "unsupported iOS mobile static extension name: $sql_name" >&2 + exit 2 + } + portable_id "$stem" || { + echo "unsupported iOS mobile static module stem: $stem" >&2 + exit 2 + } + local index + for index in "${!selected_extensions[@]}"; do + if [ "${selected_extensions[$index]}" = "$sql_name" ]; then + if [ "${selected_stems[$index]}" != "$stem" ]; then + echo "iOS mobile static extension $sql_name maps to multiple module stems: ${selected_stems[$index]},$stem" >&2 + exit 2 + fi + return 0 + fi + done + selected_extensions+=("$sql_name") + selected_stems+=("$stem") + local dependency + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + add_selected_dependency "$dependency" + done < <(oliphaunt_mobile_static_extension_dependencies_for_target "$sql_name" ios || true) +} + +add_selected_extension() { + local extension="$1" + local sql_name stem + extension="$(printf '%s\n' "$extension" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -n "$extension" ] || return 0 + if ! oliphaunt_mobile_static_extension_spec "$extension" >/dev/null; then + echo "unsupported iOS mobile static extension from OLIPHAUNT_MOBILE_STATIC_EXTENSIONS: $extension" >&2 + echo "for custom prebuilt extensions, pass --runtime-resources so nativeModuleStems are read from the exact resource manifest" >&2 + printf 'supported built-in iOS mobile static extensions: ' >&2 + oliphaunt_mobile_static_supported_extensions | paste -sd ',' - >&2 + exit 2 + fi + sql_name="$(oliphaunt_mobile_static_extension_sql_name "$extension")" + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + add_selected_pair "$sql_name" "$stem" +} + +add_selected_from_runtime_resources() { + [ -n "$runtime_resources_dir" ] || return 0 + local package_root raw old_ifs stems stem sql_name + package_root="$(runtime_resources_root "$runtime_resources_dir")" + raw="$(selected_raw_from_runtime_resources)" + [ -n "$raw" ] || return 0 + old_ifs="$IFS" + IFS="," + read -r -a stems <<< "$raw" + IFS="$old_ifs" + for stem in "${stems[@]}"; do + stem="$(printf '%s\n' "$stem" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -n "$stem" ] || continue + sql_name="$(static_registry_manifest_value "$package_root" "module.$stem.extension" || true)" + [ -n "$sql_name" ] || sql_name="$stem" + add_selected_pair "$sql_name" "$stem" + done +} + +add_selected_dependency() { + local dependency="$1" + dependency="$(printf '%s\n' "$dependency" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -n "$dependency" ] || return 0 + portable_id "$dependency" || { + echo "unsupported iOS mobile static dependency name: $dependency" >&2 + exit 2 + } + local existing + for existing in ${selected_dependencies[@]+"${selected_dependencies[@]}"}; do + [ "$existing" = "$dependency" ] && return 0 + done + selected_dependencies+=("$dependency") +} + +add_selected_dependencies_from_runtime_resources() { + [ -n "$runtime_resources_dir" ] || return 0 + local package_root raw old_ifs dependencies dependency + package_root="$(runtime_resources_root "$runtime_resources_dir")" + raw="$(static_registry_manifest_value "$package_root" "dependencyArchives" || true)" + [ -n "$raw" ] || return 0 + old_ifs="$IFS" + IFS="," + read -r -a dependencies <<< "$raw" + IFS="$old_ifs" + for dependency in "${dependencies[@]}"; do + add_selected_dependency "$dependency" + done +} + +parse_selected_extensions() { + local raw="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" + if [ -z "$raw" ]; then + add_selected_from_runtime_resources + return 0 + fi + [ -n "$raw" ] || return 0 + local old_ifs="$IFS" + IFS="," + read -r -a requested <<< "$raw" + IFS="$old_ifs" + local extension + for extension in "${requested[@]}"; do + add_selected_extension "$extension" + done +} + +parse_selected_dependencies() { + add_selected_dependencies_from_runtime_resources +} + +stem_for_extension() { + local extension="$1" + local index + for index in "${!selected_extensions[@]}"; do + if [ "${selected_extensions[$index]}" = "$extension" ]; then + printf '%s\n' "${selected_stems[$index]}" + return 0 + fi + done + echo "internal error: missing iOS mobile static module stem for $extension" >&2 + exit 2 +} + +static_registry_archive_candidate() { + local package_root="$1" + local relative="$2" + [ -n "$relative" ] || return 1 + local candidate="$package_root/static-registry/$relative" + [ -f "$candidate" ] || return 1 + printf '%s\n' "$candidate" +} + +archive_for() { + local platform_out="$1" + local extension="$2" + local platform="$3" + local stem + stem="$(stem_for_extension "$extension")" + if [ -n "$runtime_resources_dir" ]; then + local package_root candidate target + package_root="$(runtime_resources_root "$runtime_resources_dir")" + case "$platform" in + simulator) + for target in ios-simulator iphonesimulator aarch64-apple-ios-sim x86_64-apple-ios-sim; do + candidate="$package_root/static-registry/archives/$target/extensions/$stem/liboliphaunt_extension_$stem.a" + if [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + ;; + device) + for target in ios-device iphoneos aarch64-apple-ios; do + candidate="$package_root/static-registry/archives/$target/extensions/$stem/liboliphaunt_extension_$stem.a" + if [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + ;; + esac + fi + printf '%s\n' "$platform_out/extensions/$stem/liboliphaunt_extension_$stem.a" +} + +dependency_archive_for() { + local dependency="$1" + local platform="$2" + local package_root candidate relative target search_root platform_out + if [ -n "$runtime_resources_dir" ]; then + package_root="$(runtime_resources_root "$runtime_resources_dir")" + case "$platform" in + simulator) + for target in ios-simulator iphonesimulator aarch64-apple-ios-sim x86_64-apple-ios-sim; do + relative="$(static_registry_manifest_value "$package_root" "dependency.$dependency.archive.$target" || true)" + if candidate="$(static_registry_archive_candidate "$package_root" "$relative")"; then + printf '%s\n' "$candidate" + return 0 + fi + search_root="$package_root/static-registry/archives/$target/dependencies/$dependency" + if [ -d "$search_root" ]; then + candidate="$(find "$search_root" -maxdepth 1 -type f -name '*.a' | sort | head -n 1)" + if [ -n "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + fi + done + ;; + device) + for target in ios-device iphoneos aarch64-apple-ios; do + relative="$(static_registry_manifest_value "$package_root" "dependency.$dependency.archive.$target" || true)" + if candidate="$(static_registry_archive_candidate "$package_root" "$relative")"; then + printf '%s\n' "$candidate" + return 0 + fi + search_root="$package_root/static-registry/archives/$target/dependencies/$dependency" + if [ -d "$search_root" ]; then + candidate="$(find "$search_root" -maxdepth 1 -type f -name '*.a' | sort | head -n 1)" + if [ -n "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + fi + done + ;; + esac + fi + case "$platform" in + simulator) platform_out="$simulator_out" ;; + device) platform_out="$device_out" ;; + *) platform_out="" ;; + esac + if [ -n "$platform_out" ]; then + if candidate="$(oliphaunt_mobile_static_dependency_archive_for_root "$platform_out/dependencies" "$dependency")"; then + printf '%s\n' "$candidate" + return 0 + fi + fi + echo "internal error: missing iOS mobile static dependency archive for $dependency on $platform" >&2 + exit 2 +} + +xcframework_for() { + local extension="$1" + local stem + stem="$(stem_for_extension "$extension")" + printf '%s\n' "$out_dir/$stem/liboliphaunt_extension_$stem.xcframework" +} + +dependency_xcframework_for() { + local dependency="$1" + printf '%s\n' "$out_dir/dependencies/$dependency/liboliphaunt_dependency_$dependency.xcframework" +} + +require_inputs() { + local extension="$1" + local simulator_archive device_archive + simulator_archive="$(archive_for "$simulator_out" "$extension" simulator)" + device_archive="$(archive_for "$device_out" "$extension" device)" + [ -f "$simulator_archive" ] || { + echo "missing iOS simulator extension archive for $extension: $simulator_archive" >&2 + exit 1 + } + [ -f "$device_archive" ] || { + echo "missing iOS device extension archive for $extension: $device_archive" >&2 + exit 1 + } +} + +require_dependency_inputs() { + local dependency="$1" + local simulator_archive device_archive + simulator_archive="$(dependency_archive_for "$dependency" simulator)" + device_archive="$(dependency_archive_for "$dependency" device)" + [ -f "$simulator_archive" ] || { + echo "missing iOS simulator dependency archive for $dependency: $simulator_archive" >&2 + exit 1 + } + [ -f "$device_archive" ] || { + echo "missing iOS device dependency archive for $dependency: $device_archive" >&2 + exit 1 + } +} + +xcframework_ready() { + local extension="$1" + local xcframework + xcframework="$(xcframework_for "$extension")" + [ -d "$xcframework" ] || return 1 + [ -f "$xcframework/Info.plist" ] || return 1 + plutil -extract AvailableLibraries raw "$xcframework/Info.plist" >/dev/null 2>&1 || return 1 +} + +dependency_xcframework_ready() { + local dependency="$1" + local xcframework + xcframework="$(dependency_xcframework_for "$dependency")" + [ -d "$xcframework" ] || return 1 + [ -f "$xcframework/Info.plist" ] || return 1 + plutil -extract AvailableLibraries raw "$xcframework/Info.plist" >/dev/null 2>&1 || return 1 +} + +build_extension_xcframework() { + local extension="$1" + local stem simulator_archive device_archive xcframework + stem="$(stem_for_extension "$extension")" + simulator_archive="$(archive_for "$simulator_out" "$extension" simulator)" + device_archive="$(archive_for "$device_out" "$extension" device)" + xcframework="$(xcframework_for "$extension")" + rm -rf "$out_dir/$stem" + mkdir -p "$out_dir/$stem" + xcodebuild -create-xcframework \ + -library "$simulator_archive" -headers "$headers_dir" \ + -library "$device_archive" -headers "$headers_dir" \ + -output "$xcframework" >/dev/null +} + +build_dependency_xcframework() { + local dependency="$1" + local simulator_archive device_archive xcframework + simulator_archive="$(dependency_archive_for "$dependency" simulator)" + device_archive="$(dependency_archive_for "$dependency" device)" + xcframework="$(dependency_xcframework_for "$dependency")" + rm -rf "$out_dir/dependencies/$dependency" + mkdir -p "$out_dir/dependencies/$dependency" + xcodebuild -create-xcframework \ + -library "$simulator_archive" -headers "$headers_dir" \ + -library "$device_archive" -headers "$headers_dir" \ + -output "$xcframework" >/dev/null +} + +write_manifest() { + { + printf 'packageLayout=oliphaunt-ios-extension-xcframeworks-v1\n' + printf 'extensions=%s\n' "$(join_csv ${selected_extensions[@]+"${selected_extensions[@]}"})" + printf 'nativeModuleStems=%s\n' "$(join_csv ${selected_stems[@]+"${selected_stems[@]}"})" + printf 'dependencies=%s\n' "$(join_csv ${selected_dependencies[@]+"${selected_dependencies[@]}"})" + printf 'simulatorOut=%s\n' "$simulator_out" + printf 'deviceOut=%s\n' "$device_out" + printf 'runtimeResources=%s\n' "$runtime_resources_dir" + local extension stem + for extension in ${selected_extensions[@]+"${selected_extensions[@]}"}; do + stem="$(stem_for_extension "$extension")" + printf 'extension.%s.xcframework=%s/liboliphaunt_extension_%s.xcframework\n' "$extension" "$stem" "$stem" + printf 'extension.%s.simulatorArchive=%s\n' "$extension" "$(archive_for "$simulator_out" "$extension" simulator)" + printf 'extension.%s.deviceArchive=%s\n' "$extension" "$(archive_for "$device_out" "$extension" device)" + done + local dependency + for dependency in ${selected_dependencies[@]+"${selected_dependencies[@]}"}; do + printf 'dependency.%s.xcframework=dependencies/%s/liboliphaunt_dependency_%s.xcframework\n' "$dependency" "$dependency" "$dependency" + printf 'dependency.%s.simulatorArchive=%s\n' "$dependency" "$(dependency_archive_for "$dependency" simulator)" + printf 'dependency.%s.deviceArchive=%s\n' "$dependency" "$(dependency_archive_for "$dependency" device)" + done + } > "$manifest_file" +} + +manifest_ready() { + [ -f "$manifest_file" ] || return 1 + grep -Fx "packageLayout=oliphaunt-ios-extension-xcframeworks-v1" "$manifest_file" >/dev/null || return 1 + grep -Fx "extensions=$(join_csv ${selected_extensions[@]+"${selected_extensions[@]}"})" "$manifest_file" >/dev/null || return 1 + grep -Fx "nativeModuleStems=$(join_csv ${selected_stems[@]+"${selected_stems[@]}"})" "$manifest_file" >/dev/null || return 1 + grep -Fx "dependencies=$(join_csv ${selected_dependencies[@]+"${selected_dependencies[@]}"})" "$manifest_file" >/dev/null || return 1 +} + +parse_selected_extensions +parse_selected_dependencies +if [ "$mode" = "build" ]; then + rm -rf "$out_dir" +fi +mkdir -p "$out_dir" + +if [ "${#selected_extensions[@]}" -eq 0 ]; then + case "$mode" in + build) write_manifest ;; + check) manifest_ready || { + echo "iOS extension XCFramework manifest is missing or stale" >&2 + exit 1 + } ;; + esac + printf '%s\n' "$out_dir" + exit 0 +fi + +for extension in "${selected_extensions[@]}"; do + require_inputs "$extension" + case "$mode" in + check) + xcframework_ready "$extension" || { + echo "iOS extension XCFramework for $extension is missing or stale" >&2 + exit 1 + } + ;; + build) + build_extension_xcframework "$extension" + ;; + esac +done + +for dependency in ${selected_dependencies[@]+"${selected_dependencies[@]}"}; do + require_dependency_inputs "$dependency" + case "$mode" in + check) + dependency_xcframework_ready "$dependency" || { + echo "iOS dependency XCFramework for $dependency is missing or stale" >&2 + exit 1 + } + ;; + build) + build_dependency_xcframework "$dependency" + ;; + esac +done + +case "$mode" in + check) + manifest_ready || { + echo "iOS extension XCFramework manifest is missing or stale" >&2 + exit 1 + } + ;; + build) + write_manifest + ;; +esac + +printf '%s\n' "$out_dir" diff --git a/src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh b/src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh new file mode 100755 index 00000000..805c485a --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/build-output.bash" +script_path="$script_dir/build-ios-xcframework.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +work_root="${OLIPHAUNT_IOS_XCFRAMEWORK_ROOT:-$repo_root/target/liboliphaunt-ios-xcframework}" +out_dir="$work_root/out" +headers_dir="$work_root/include" +xcframework_out="$out_dir/liboliphaunt.xcframework" +stamp="$work_root/.liboliphaunt-ios-xcframework.sha256" +script_mode="${1:-build}" +runtime_resources_root="${OLIPHAUNT_IOS_RUNTIME_RESOURCES_ROOT:-}" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "liboliphaunt iOS XCFramework build requires Darwin" >&2 + exit 2 +fi + +for cmd in install_name_tool nm plutil rg shasum xcodebuild xcrun; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "missing required command: $cmd" >&2 + exit 1 + fi +done + +simulator_script="$repo_root/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh" +device_script="$repo_root/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh" +macos_script="$repo_root/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh" +public_header="$repo_root/src/runtimes/liboliphaunt/native/include/oliphaunt.h" +default_macos_library="$repo_root/target/liboliphaunt-pg18/out/liboliphaunt.dylib" +default_simulator_library="$repo_root/target/liboliphaunt-ios-simulator/out/liboliphaunt.dylib" +default_device_library="$repo_root/target/liboliphaunt-ios-device/out/liboliphaunt.dylib" +default_simulator_static_registry="$repo_root/target/liboliphaunt-ios-simulator/out/liboliphaunt_mobile_static_registry.c" +default_device_static_registry="$repo_root/target/liboliphaunt-ios-device/out/liboliphaunt_mobile_static_registry.c" +xcframework_static_registry="$out_dir/liboliphaunt_mobile_static_registry.c" +framework_root="$work_root/frameworks" +macos_framework="$framework_root/macos-arm64/liboliphaunt.framework" +simulator_framework="$framework_root/ios-arm64-simulator/liboliphaunt.framework" +device_framework="$framework_root/ios-arm64/liboliphaunt.framework" + +usage() { + cat >&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh [--check-current] +MSG +} + +desired_hash() { + { + printf 'mobile_static_extensions=%s\n' "${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" + printf 'runtime_resources_root=%s\n' "$runtime_resources_root" + printf 'script_sha256=%s\n' "$(shasum -a 256 "$script_path" | awk '{print $1}')" + shasum -a 256 "$macos_script" "$simulator_script" "$device_script" "$public_header" + if [ -d "$runtime_resources_root" ]; then + find "$runtime_resources_root" -type f -print0 | sort -z | xargs -0 shasum -a 256 + fi + if [ -f "$default_simulator_library" ]; then + shasum -a 256 "$default_simulator_library" + fi + if [ -f "$default_device_library" ]; then + shasum -a 256 "$default_device_library" + fi + if [ -f "$default_macos_library" ]; then + shasum -a 256 "$default_macos_library" + fi + if [ -f "$xcframework_static_registry" ]; then + shasum -a 256 "$xcframework_static_registry" + fi + } | shasum -a 256 | awk '{print $1}' +} + +library_platform() { + xcrun vtool -show-build "$1" 2>/dev/null | awk '/platform /{print $2; exit}' +} + +assert_library_slice() { + local library="$1" + local expected_platform="$2" + [ -f "$library" ] || { + echo "missing liboliphaunt library: $library" >&2 + return 1 + } + local platform + platform="$(library_platform "$library")" + [ "$platform" = "$expected_platform" ] || { + echo "liboliphaunt library has platform $platform, expected $expected_platform: $library" >&2 + return 1 + } + local symbols + symbols="$(nm -g "$library" 2>/dev/null || true)" + if [ "$expected_platform" != "MACOS" ]; then + local undefined_symbols + undefined_symbols="$(nm -u "$library" 2>/dev/null || true)" + if printf '%s\n' "$undefined_symbols" | rg -q '_shm(get|ctl|dt)|_shm_open|_sem(get|ctl|op|open|close|unlink|wait|post|trywait|init|destroy)'; then + echo "liboliphaunt library imports mobile-forbidden shared-memory/semaphore APIs: $library" >&2 + return 1 + fi + fi + local symbol + for symbol in \ + _oliphaunt_init \ + _oliphaunt_exec_protocol \ + _oliphaunt_exec_protocol_stream \ + _oliphaunt_backup \ + _oliphaunt_backup_ex \ + _oliphaunt_restore \ + _oliphaunt_cancel \ + _oliphaunt_detach \ + _oliphaunt_close \ + _oliphaunt_register_static_extensions \ + _oliphaunt_last_error \ + _oliphaunt_version \ + _oliphaunt_capabilities \ + _oliphaunt_free_response + do + case "$symbols" in + *"$symbol"*) ;; + *) + echo "liboliphaunt library is missing symbol $symbol: $library" >&2 + return 1 + ;; + esac + done +} + +xcframework_ready() { + [ -d "$xcframework_out" ] || return 1 + [ -f "$xcframework_out/Info.plist" ] || return 1 + plutil -extract AvailableLibraries raw "$xcframework_out/Info.plist" >/dev/null 2>&1 || return 1 + + local ios_library="" + local simulator_library="" + local macos_library="" + while IFS= read -r library; do + case "$(library_platform "$library")" in + MACOS) macos_library="$library" ;; + IOS) ios_library="$library" ;; + IOSSIMULATOR) simulator_library="$library" ;; + esac + done < <(find "$xcframework_out" -type f \( -name 'liboliphaunt.dylib' -o -name 'liboliphaunt' \) ! -path '*/Headers/*' | sort) + + [ -n "$macos_library" ] || return 1 + [ -n "$ios_library" ] || return 1 + [ -n "$simulator_library" ] || return 1 + assert_library_slice "$macos_library" MACOS || return 1 + assert_library_slice "$ios_library" IOS || return 1 + assert_library_slice "$simulator_library" IOSSIMULATOR || return 1 +} + +write_framework_info_plist() { + local plist="$1" + local platform="$2" + local platform_family="$3" + if [ "$platform" = "MacOSX" ]; then + cat >"$plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + liboliphaunt + CFBundleIdentifier + dev.oliphaunt.liboliphaunt + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + liboliphaunt + CFBundlePackageType + FMWK + CFBundleShortVersionString + 0.1.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + MinimumOSVersion + 14.0 + + +PLIST + return + fi + cat >"$plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + liboliphaunt + CFBundleIdentifier + dev.oliphaunt.liboliphaunt + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + liboliphaunt + CFBundlePackageType + FMWK + CFBundleShortVersionString + 0.1.0 + CFBundleSupportedPlatforms + + ${platform} + + CFBundleVersion + 1 + MinimumOSVersion + 17.0 + UIDeviceFamily + + ${platform_family} + + + +PLIST +} + +expected_library_platform_for_framework_platform() { + case "$1" in + MacOSX) printf 'MACOS\n' ;; + iPhoneOS) printf 'IOS\n' ;; + iPhoneSimulator) printf 'IOSSIMULATOR\n' ;; + *) echo "unsupported framework platform $1" >&2; return 1 ;; + esac +} + +prepare_framework_slice() { + local library="$1" + local framework="$2" + local platform="$3" + local platform_family="$4" + rm -rf "$framework" + mkdir -p "$framework/Headers" "$framework/Modules" + cp "$library" "$framework/liboliphaunt" + install_name_tool -id "@rpath/liboliphaunt.framework/liboliphaunt" "$framework/liboliphaunt" + rsync -a --delete "$headers_dir/" "$framework/Headers/" + if [ -n "$runtime_resources_root" ]; then + [ -d "$runtime_resources_root" ] || { + echo "OLIPHAUNT_IOS_RUNTIME_RESOURCES_ROOT does not exist: $runtime_resources_root" >&2 + exit 1 + } + mkdir -p "$framework/Resources/oliphaunt" + rsync -a --delete "$runtime_resources_root/" "$framework/Resources/oliphaunt/" + fi + cat >"$framework/Modules/module.modulemap" <<'MODULEMAP' +framework module liboliphaunt { + umbrella header "oliphaunt.h" + export * + module * { export * } +} +MODULEMAP + write_framework_info_plist "$framework/Info.plist" "$platform" "$platform_family" + assert_library_slice "$framework/liboliphaunt" "$(expected_library_platform_for_framework_platform "$platform")" +} + +build_xcframework() { + mkdir -p "$out_dir" "$headers_dir" + rsync -a --delete "$repo_root/src/runtimes/liboliphaunt/native/include/" "$headers_dir/" + + local macos_library="$default_macos_library" + if ! "$macos_script" --check-oliphaunt-current >/dev/null 2>&1 || + ! assert_library_slice "$macos_library" MACOS >/dev/null 2>&1; then + macos_library="$(oliphaunt_capture_build_artifact_path \ + "macOS liboliphaunt build" \ + "$work_root/logs/build-macos.log" \ + "$macos_script")" + fi + + local simulator_library="$default_simulator_library" + local device_library="$default_device_library" + if ! OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" \ + "$simulator_script" --check-current >/dev/null 2>&1 || + ! assert_library_slice "$simulator_library" IOSSIMULATOR >/dev/null 2>&1; then + simulator_library="$(oliphaunt_capture_build_artifact_path \ + "iOS simulator liboliphaunt build" \ + "$work_root/logs/build-ios-simulator.log" \ + env OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" "$simulator_script")" + fi + if ! OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" \ + "$device_script" --check-current >/dev/null 2>&1 || + ! assert_library_slice "$device_library" IOS >/dev/null 2>&1; then + device_library="$(oliphaunt_capture_build_artifact_path \ + "iOS device liboliphaunt build" \ + "$work_root/logs/build-ios-device.log" \ + env OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" "$device_script")" + fi + assert_library_slice "$macos_library" MACOS + assert_library_slice "$simulator_library" IOSSIMULATOR + assert_library_slice "$device_library" IOS + prepare_framework_slice "$macos_library" "$macos_framework" "MacOSX" "0" + prepare_framework_slice "$device_library" "$device_framework" "iPhoneOS" "1" + prepare_framework_slice "$simulator_library" "$simulator_framework" "iPhoneSimulator" "1" + + rm -rf "$xcframework_out" + xcodebuild -create-xcframework \ + -framework "$macos_framework" \ + -framework "$device_framework" \ + -framework "$simulator_framework" \ + -output "$xcframework_out" >/dev/null + + if [ -f "$default_simulator_static_registry" ] && [ -f "$default_device_static_registry" ]; then + if ! cmp -s "$default_simulator_static_registry" "$default_device_static_registry"; then + echo "iOS simulator/device static registry sources differ" >&2 + exit 1 + fi + cp "$default_simulator_static_registry" "$xcframework_static_registry" + else + rm -f "$xcframework_static_registry" + fi + + xcframework_ready + desired_hash > "$stamp" + printf '%s\n' "$xcframework_out" +} + +case "$script_mode" in + build) + if xcframework_ready && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + printf '%s\n' "$xcframework_out" + exit 0 + fi + build_xcframework + ;; + --check-current) + if xcframework_ready && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "iOS liboliphaunt XCFramework is current" + exit 0 + fi + echo "iOS liboliphaunt XCFramework is missing or stale" >&2 + exit 1 + ;; + *) + usage + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh b/src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh new file mode 100755 index 00000000..46a3b419 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-macos-happy-path.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$script_dir/build-postgres18-macos.sh" "$@" diff --git a/src/runtimes/liboliphaunt/native/bin/build-output.bash b/src/runtimes/liboliphaunt/native/bin/build-output.bash new file mode 100644 index 00000000..2ad94b2f --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-output.bash @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +oliphaunt_capture_build_artifact_path() { + local description="${1:?oliphaunt_capture_build_artifact_path requires a description}" + shift + local log_file="${1:?oliphaunt_capture_build_artifact_path requires a log file}" + shift + local log_dir tmp status artifact + + log_dir="$(dirname "$log_file")" + mkdir -p "$log_dir" + tmp="$(mktemp "${TMPDIR:-/tmp}/oliphaunt-build-output.XXXXXX")" + + set +e + "$@" 2>&1 | tee "$tmp" | tee "$log_file" >&2 + status="${PIPESTATUS[0]}" + set -e + + if [ "$status" -ne 0 ]; then + rm -f "$tmp" + echo "error: $description failed; see $log_file" >&2 + return "$status" + fi + + artifact="" + while IFS= read -r line; do + [ -n "$line" ] || continue + if [ -e "$line" ]; then + artifact="$line" + fi + done < "$tmp" + if [ -z "$artifact" ]; then + artifact="$(awk 'NF { line = $0 } END { if (line != "") print line }' "$tmp")" + fi + rm -f "$tmp" + if [ -z "$artifact" ]; then + echo "error: $description did not print an artifact path; see $log_file" >&2 + return 1 + fi + + printf '%s\n' "$artifact" +} diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh new file mode 100755 index 00000000..20be0900 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh @@ -0,0 +1,985 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/icu.sh" +. "$script_dir/mobile-static-extensions.sh" +. "$script_dir/mobile-postgis-extensions.sh" +script_path="$script_dir/$(basename "$0")" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +pg_version="18.4" +pg_sha256="81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +pg_url="https://ftp.postgresql.org/pub/source/v${pg_version}/postgresql-${pg_version}.tar.bz2" +source_manifest="$repo_root/src/runtimes/liboliphaunt/native/postgres18/source.toml" +patch_dir="$repo_root/src/runtimes/liboliphaunt/native/patches/postgresql-${pg_version}" +android_abi="${OLIPHAUNT_ANDROID_ABI:-arm64-v8a}" +case "$android_abi" in + arm64-v8a) + android_host="aarch64-linux-android" + android_readelf_arch_regex="AArch64" + oliphaunt_mobile_target="android-arm64" + android_work_root="${OLIPHAUNT_ANDROID_WORK_ROOT:-${OLIPHAUNT_ANDROID_ROOT:-${OLIPHAUNT_ANDROID_ARM64_ROOT:-$repo_root/target/liboliphaunt-pg18-android-arm64}}}" + ;; + x86_64) + android_host="x86_64-linux-android" + android_readelf_arch_regex="X86-64|Advanced Micro Devices X86-64" + oliphaunt_mobile_target="android-x86_64" + android_work_root="${OLIPHAUNT_ANDROID_WORK_ROOT:-${OLIPHAUNT_ANDROID_ROOT:-${OLIPHAUNT_ANDROID_X86_64_ROOT:-$repo_root/target/liboliphaunt-pg18-android-x86_64}}}" + ;; + *) + echo "error: unsupported Android ABI '$android_abi'; expected arm64-v8a or x86_64" >&2 + exit 1 + ;; +esac +work_root="$android_work_root" +source_cache="$work_root/source" +tarball="$source_cache/postgresql-${pg_version}.tar.bz2" +build_dir="$work_root/postgresql-${pg_version}" +install_dir="$work_root/install" +out_dir="$work_root/out" +stamp="$build_dir/.liboliphaunt-android-${android_abi}-build.sha256" +configure_log="$work_root/configure.log" +make_log="$work_root/make.log" +objects_rsp="$out_dir/liboliphaunt-android-${android_abi}-objects.rsp" +lib_out="$out_dir/liboliphaunt.so" +mobile_static_registry_source="$out_dir/liboliphaunt_mobile_static_registry.c" +mobile_static_registry_object="$out_dir/liboliphaunt_mobile_static_registry.o" +script_mode="${1:-build}" +icu_source_dir="$(oliphaunt_icu_source_dir "$repo_root")" +icu_native_build_dir="$work_root/icu-native" +icu_build_dir="$work_root/icu-$oliphaunt_mobile_target-build" +icu_prefix="$work_root/icu-$oliphaunt_mobile_target" +icu_cflags="$(oliphaunt_icu_cflags "$icu_prefix")" +icu_static_libs="$(oliphaunt_icu_static_libs "$icu_prefix")" +icu_cpp_libs="-lc++_static -lc++abi" +icu_libs="$icu_static_libs $icu_cpp_libs" + +liboliphaunt_sources=( + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c" +) + +plpgsql_objects=( + src/pl/plpgsql/src/pl_comp.o + src/pl/plpgsql/src/pl_exec.o + src/pl/plpgsql/src/pl_funcs.o + src/pl/plpgsql/src/pl_gram.o + src/pl/plpgsql/src/pl_handler.o + src/pl/plpgsql/src/pl_scanner.o +) + +jit_objects=( + src/backend/jit/jit.o +) + +mobile_static_extensions=() +mobile_static_objects=() +mobile_static_dependency_archives=() +mobile_static_dependency_root="$out_dir/dependencies" +export OLIPHAUNT_MOBILE_STATIC_DEPENDENCY_ROOT="$mobile_static_dependency_root" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +for cmd in curl git patch perl rg shasum; do + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd" +done + +if [ -z "${ANDROID_HOME:-}" ] && [ -d "$HOME/Library/Android/sdk" ]; then + export ANDROID_HOME="$HOME/Library/Android/sdk" +fi +[ -n "${ANDROID_HOME:-}" ] || fail "ANDROID_HOME is not set" + +ndk_root="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}" +if [ -z "$ndk_root" ]; then + ndk_root="$(find "$ANDROID_HOME/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1 || true)" +fi +[ -n "$ndk_root" ] && [ -d "$ndk_root" ] || fail "Android NDK not found under ANDROID_HOME=$ANDROID_HOME" + +android_ndk_prebuilt_candidates() { + case "$(uname -s):$(uname -m)" in + Darwin:arm64 | Darwin:aarch64) + printf '%s\n' darwin-arm64 darwin-x86_64 + ;; + Darwin:x86_64) + printf '%s\n' darwin-x86_64 + ;; + Linux:x86_64 | Linux:amd64) + printf '%s\n' linux-x86_64 + ;; + Linux:aarch64 | Linux:arm64) + printf '%s\n' linux-aarch64 linux-x86_64 + ;; + *) + return 1 + ;; + esac +} + +toolchain_dir="" +while IFS= read -r prebuilt_host; do + candidate="$ndk_root/toolchains/llvm/prebuilt/$prebuilt_host" + if [ -d "$candidate/bin" ]; then + toolchain_dir="$candidate" + break + fi +done < <(android_ndk_prebuilt_candidates || true) +[ -n "$toolchain_dir" ] || fail "Android NDK LLVM toolchain not found under $ndk_root for host $(uname -s)/$(uname -m)" + +android_api="${OLIPHAUNT_ANDROID_API_LEVEL:-24}" +clang_path="$toolchain_dir/bin/${android_host}${android_api}-clang" +clangxx_path="$toolchain_dir/bin/${android_host}${android_api}-clang++" +cpp_path="$clang_path -E" +llvm_nm="$toolchain_dir/bin/llvm-nm" +llvm_ar="$toolchain_dir/bin/llvm-ar" +llvm_ranlib="$toolchain_dir/bin/llvm-ranlib" +[ -x "$clang_path" ] || fail "Android clang not found: $clang_path" +[ -x "$clangxx_path" ] || fail "Android clang++ not found: $clangxx_path" +[ -x "$llvm_nm" ] || fail "Android llvm-nm not found: $llvm_nm" +[ -x "$llvm_ar" ] || fail "Android llvm-ar not found: $llvm_ar" +[ -x "$llvm_ranlib" ] || fail "Android llvm-ranlib not found: $llvm_ranlib" +oliphaunt_icu_require_source "$icu_source_dir" + +cc=("$clang_path") +cxx=("$clangxx_path") +ccache_mode="${OLIPHAUNT_CCACHE:-auto}" +if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then + ccache_bin="" + if [ "$ccache_mode" = "auto" ]; then + ccache_bin="$(command -v ccache || true)" + else + ccache_bin="$ccache_mode" + fi + if [ -n "$ccache_bin" ]; then + cc=("$ccache_bin" "${cc[@]}") + cxx=("$ccache_bin" "${cxx[@]}") + fi +fi + +cc_string="${cc[*]}" +cxx_string="${cxx[*]}" +postgres_cppflags="-D_GNU_SOURCE" +native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM -Wno-unused-command-line-argument" +liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" +pg_extension_cflags="$native_cflags $postgres_cppflags $icu_cflags" +jobs="${OLIPHAUNT_JOBS:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)}" + +parse_mobile_static_extensions() { + local raw="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" + [ -n "$raw" ] || return 0 + local extension + while IFS= read -r extension; do + extension="$(printf '%s' "$extension" | xargs)" + [ -n "$extension" ] || continue + if ! oliphaunt_mobile_static_extension_spec "$extension" >/dev/null; then + printf 'supported Android mobile static extensions: ' >&2 + oliphaunt_mobile_static_supported_extensions | paste -sd ',' - >&2 + fail "unsupported Android mobile static extension: $extension" + fi + mobile_static_extensions+=("$(oliphaunt_mobile_static_extension_sql_name "$extension")") + done < <(printf '%s\n' "$raw" | tr ',' '\n') +} + +mobile_static_extensions_include() { + local wanted="$1" + local extension + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + [ "$extension" = "$wanted" ] && return 0 + done + return 1 +} + +mobile_static_dependency_selected() { + local wanted="$1" + local extension dependency + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + [ "$dependency" = "$wanted" ] && return 0 + done < <(oliphaunt_mobile_static_extension_dependencies "$extension") + done + return 1 +} + +hash_mobile_static_extension_sources() { + local extension file + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + while IFS= read -r file; do + [ -n "$file" ] || continue + shasum -a 256 "$file" + done < <(oliphaunt_mobile_static_extension_hash_inputs "$repo_root" "$build_dir" "$extension") + done +} + +patch_series() { + sed -n '/series = \[/,/\]/p' "$source_manifest" | + sed -n 's/.*"\([^"]*\.patch\)".*/\1/p' +} + +patch_series_hash() { + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + shasum -a 256 "$patch_dir/$patch_name" + done < <(patch_series) | shasum -a 256 | awk '{print $1}' +} + +desired_hash() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'pg_sha256=%s\n' "$pg_sha256" + printf 'android_abi=%s\n' "$android_abi" + printf 'android_host=%s\n' "$android_host" + printf 'ndk_root=%s\n' "$ndk_root" + printf 'toolchain_dir=%s\n' "$toolchain_dir" + printf 'android_api=%s\n' "$android_api" + printf 'cc=%s\n' "$cc_string" + printf 'cxx=%s\n' "$cxx_string" + printf 'ar=%s\n' "$llvm_ar" + printf 'ranlib=%s\n' "$llvm_ranlib" + printf 'icu_source=%s\n' "$(oliphaunt_icu_source_commit "$icu_source_dir")" + printf 'icu_script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'native_cflags=%s\n' "$native_cflags" + printf 'liboliphaunt_cflags=%s\n' "$liboliphaunt_cflags" + printf 'pg_extension_cflags=%s\n' "$pg_extension_cflags" + printf 'mobile_static_extensions=%s\n' "${mobile_static_extensions[*]-}" + printf 'patch_series_hash=%s\n' "$(patch_series_hash)" + printf 'liboliphaunt_sources=%s\n' "${liboliphaunt_sources[*]}" + printf 'plpgsql_objects=%s\n' "${plpgsql_objects[*]}" + printf 'jit_objects=%s\n' "${jit_objects[*]}" + printf 'script_sha256=%s\n' "$(shasum -a 256 "$script_path" | awk '{print $1}')" + shasum -a 256 "$script_dir/mobile-static-extensions.sh" "$script_dir/mobile-postgis-extensions.sh" + shasum -a 256 "$source_manifest" + shasum -a 256 "${liboliphaunt_sources[@]}" + hash_mobile_static_extension_sources + } | shasum -a 256 | awk '{print $1}' +} + +apply_patch_series() { + local patch_name + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + GIT_CEILING_DIRECTORIES="$work_root" git apply --recount --whitespace=nowarn "$patch_dir/$patch_name" >/dev/null + done < <(patch_series) +} + +patched_source_ready() { + grep -Fq 'OliphauntEmbeddedIO' "$build_dir/src/include/libpq/libpq-be.h" && + grep -Fq 'oliphaunt_embedded_main' "$build_dir/src/backend/tcop/postgres.c" && + grep -Fq 'oliphaunt_embedded' "$build_dir/meson_options.txt" && + grep -Fq 'OLIPHAUNT_EMBEDDED' "$build_dir/meson.build" +} + +artifact_ready() { + [ -f "$lib_out" ] || return 1 + oliphaunt_icu_artifacts_ready "$icu_prefix" || return 1 + "$toolchain_dir/bin/llvm-readelf" -h "$lib_out" 2>/dev/null | rg -q "$android_readelf_arch_regex" || return 1 + local symbols + symbols="$("$llvm_nm" -D --defined-only "$lib_out" 2>/dev/null || true)" + local linked_symbols + linked_symbols="$("$llvm_nm" --defined-only "$lib_out" 2>/dev/null || true)" + oliphaunt_icu_linked_symbols_ready "$linked_symbols" || return 1 + local undefined_symbols + undefined_symbols="$("$llvm_nm" -D --undefined-only "$lib_out" 2>/dev/null || true)" + if printf '%s\n' "$undefined_symbols" | rg -q 'shm(get|ctl|dt)|shm_open|sem(get|ctl|op|open|close|unlink|wait|post|trywait|init|destroy)'; then + return 1 + fi + local symbol + for symbol in \ + oliphaunt_init \ + oliphaunt_exec_protocol \ + oliphaunt_exec_simple_query \ + oliphaunt_exec_protocol_stream \ + oliphaunt_backup \ + oliphaunt_backup_ex \ + oliphaunt_restore \ + oliphaunt_cancel \ + oliphaunt_detach \ + oliphaunt_close \ + oliphaunt_register_static_extensions \ + oliphaunt_last_error \ + oliphaunt_version \ + oliphaunt_capabilities \ + oliphaunt_free_response + do + case "$symbols" in *" T $symbol"*|*" D $symbol"*|*" B $symbol"*) ;; *) return 1 ;; esac + done + if [ "${#mobile_static_extensions[@]}" -gt 0 ]; then + case "$symbols" in *" T liboliphaunt_selected_static_extensions"*|*" D liboliphaunt_selected_static_extensions"*|*" B liboliphaunt_selected_static_extensions"*) ;; *) return 1 ;; esac + local extension stem prefix + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + [ -f "$out_dir/extensions/$stem/liboliphaunt_extension_$stem.a" ] || return 1 + case "$symbols" in *" T ${prefix}_Pg_magic_func"*|*" D ${prefix}_Pg_magic_func"*|*" B ${prefix}_Pg_magic_func"*) ;; *) return 1 ;; esac + done + fi +} + +report_artifact_not_ready() { + echo "Android $android_abi liboliphaunt shared library failed validation" >&2 + if [ ! -f "$lib_out" ]; then + echo "missing shared library: $lib_out" >&2 + return 0 + fi + + echo "shared library: $lib_out" >&2 + "$toolchain_dir/bin/llvm-readelf" -h "$lib_out" >&2 || true + + local symbols undefined_symbols linked_symbols + symbols="$("$llvm_nm" -D --defined-only "$lib_out" 2>/dev/null || true)" + undefined_symbols="$("$llvm_nm" -D --undefined-only "$lib_out" 2>/dev/null || true)" + linked_symbols="$("$llvm_nm" --defined-only "$lib_out" 2>/dev/null || true)" + echo "defined Oliphaunt API symbols:" >&2 + printf '%s\n' "$symbols" | rg ' oliphaunt_| liboliphaunt_selected_static_extensions' >&2 || true + echo "unexpected Android IPC/POSIX shared-memory undefined symbols:" >&2 + printf '%s\n' "$undefined_symbols" | rg 'shm(get|ctl|dt)|shm_open|sem(get|ctl|op|open|close|unlink|wait|post|trywait|init|destroy)' >&2 || true + if ! oliphaunt_icu_linked_symbols_ready "$linked_symbols"; then + echo "ICU static link validation failed" >&2 + fi + if [ -f "$make_log" ]; then + echo "tail of PostgreSQL Android $android_abi make log:" >&2 + tail -120 "$make_log" >&2 || true + fi +} + +backend_objects_ready() { + for required in \ + src/backend/tcop/postgres.o \ + src/backend/libpq/be-secure.o \ + src/backend/libpq/pqcomm.o \ + src/backend/port/oliphaunt_embedded_sema.o \ + src/backend/port/oliphaunt_embedded_shmem.o \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a + do + [ -f "$required" ] || return 1 + done + for objfile in src/backend/*/objfiles.txt; do + [ -s "$objfile" ] || return 1 + done + "$llvm_nm" -g src/backend/tcop/postgres.o | rg -q "oliphaunt_embedded_main" || return 1 +} + +plpgsql_objects_ready() { + local object + for object in "${plpgsql_objects[@]}"; do + [ -f "$object" ] || return 1 + done + "$llvm_nm" -g src/pl/plpgsql/src/pl_handler.o | rg -q "plpgsql_call_handler" || return 1 +} + +jit_objects_ready() { + local object + for object in "${jit_objects[@]}"; do + [ -f "$object" ] || return 1 + done + "$llvm_nm" -g src/backend/jit/jit.o | rg -q "pg_jit_available" || return 1 +} + +prepare_source() { + mkdir -p "$source_cache" "$work_root" "$out_dir" + + if [ ! -f "$tarball" ]; then + curl -L --fail --silent --show-error "$pg_url" -o "$tarball" + fi + ( + cd "$source_cache" + printf '%s %s\n' "$pg_sha256" "postgresql-${pg_version}.tar.bz2" | shasum -a 256 -c - + ) >&2 + + local wanted + wanted="$(desired_hash)" + if [ -d "$build_dir" ] && { [ ! -f "$stamp" ] || [ "$(cat "$stamp")" != "$wanted" ]; }; then + rm -rf "$build_dir" + fi + if [ ! -d "$build_dir" ]; then + tar -xjf "$tarball" -C "$work_root" + ( + cd "$build_dir" + git init -q + apply_patch_series + ) + fi + patched_source_ready || fail "PostgreSQL embedded patch verification failed" +} + +configure_source() { + export CC="$cc_string" + export CXX="$cxx_string" + export CPP="$cpp_path" + export AR="$llvm_ar" + export RANLIB="$llvm_ranlib" + export CFLAGS="$native_cflags" + export CPPFLAGS="$postgres_cppflags $icu_cflags" + export LDFLAGS="-L$icu_prefix/lib" + export ICU_CFLAGS="$icu_cflags" + export ICU_LIBS="$icu_libs" + + if [ ! -f "$build_dir/config.status" ]; then + local build_alias + build_alias="$(sh "$build_dir/config/config.guess")" + ( + cd "$build_dir" + ./configure \ + --host="$android_host" \ + --build="$build_alias" \ + --prefix="$install_dir" \ + --without-readline \ + --with-icu \ + --without-llvm \ + --without-pam \ + --with-openssl=no \ + --without-zlib \ + --disable-nls + ) > "$configure_log" 2>&1 + fi +} + +build_icu() { + oliphaunt_icu_build_target \ + "$icu_source_dir" \ + "$script_dir" \ + "$icu_native_build_dir" \ + "$icu_build_dir" \ + "$icu_prefix" \ + "$jobs" \ + "$oliphaunt_mobile_target" \ + "$android_host" \ + "$cc_string" \ + "$cxx_string" \ + "$llvm_ar" \ + "$llvm_ranlib" \ + "$native_cflags" \ + "$native_cflags" \ + "" +} + +build_backend_objects() { + ( + cd "$build_dir" + if backend_objects_ready; then + echo "reusing PostgreSQL Android $android_abi backend objects" >&2 + return + fi + + : > "$make_log" + rm -f src/include/nodes/header-stamp src/include/utils/header-stamp + make -C src/backend generated-headers CC="$cc_string" >> "$make_log" 2>&1 + + set +e + make -j"$jobs" -C src/backend \ + CC="$cc_string" \ + AR="$llvm_ar" \ + RANLIB="$llvm_ranlib" \ + CFLAGS="$native_cflags" \ + postgres >> "$make_log" 2>&1 + local make_status=$? + set -e + + make -C src/common \ + CC="$cc_string" \ + AR="$llvm_ar" \ + RANLIB="$llvm_ranlib" \ + libpgcommon_srv.a >> "$make_log" 2>&1 + make -C src/port \ + CC="$cc_string" \ + AR="$llvm_ar" \ + RANLIB="$llvm_ranlib" \ + libpgport_srv.a >> "$make_log" 2>&1 + + if ! backend_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL Android $android_abi backend objects are incomplete" >&2 + exit 1 + fi + if [ "$make_status" -ne 0 ]; then + echo "PostgreSQL Android $android_abi executable/tool build failed after embedded objects were produced; continuing with shared library link" >&2 + fi + ) +} + +build_timezone_objects() { + ( + cd "$build_dir" + make -C src/timezone \ + CC="$cc_string" \ + AR="$llvm_ar" \ + RANLIB="$llvm_ranlib" \ + CFLAGS="$native_cflags" \ + localtime.o pgtz.o strftime.o >> "$make_log" 2>&1 + ) +} + +build_plpgsql_objects() { + ( + cd "$build_dir" + if plpgsql_objects_ready; then + echo "reusing PostgreSQL Android $android_abi PL/pgSQL objects" >&2 + return + fi + + make -C src/pl/plpgsql/src clean >> "$make_log" 2>&1 + make -C src/pl/plpgsql/src \ + CC="$cc_string" \ + AR="$llvm_ar" \ + RANLIB="$llvm_ranlib" \ + CFLAGS="$native_cflags" \ + pl_comp.o pl_exec.o pl_funcs.o pl_gram.o pl_handler.o pl_scanner.o >> "$make_log" 2>&1 + + if ! plpgsql_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL Android $android_abi PL/pgSQL objects are incomplete" >&2 + exit 1 + fi + ) +} + +build_jit_objects() { + ( + cd "$build_dir" + if jit_objects_ready; then + echo "reusing PostgreSQL Android $android_abi JIT stub objects" >&2 + return + fi + + make -C src/backend/jit \ + CC="$cc_string" \ + AR="$llvm_ar" \ + RANLIB="$llvm_ranlib" \ + CFLAGS="$native_cflags" \ + jit.o >> "$make_log" 2>&1 + + if ! jit_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL Android $android_abi JIT stub objects are incomplete" >&2 + exit 1 + fi + ) +} + +build_liboliphaunt_objects() { + local source object + liboliphaunt_objects=() + for source in "${liboliphaunt_sources[@]}"; do + object="$out_dir/$(basename "${source%.c}").o" + liboliphaunt_objects+=("$object") + "${cc[@]}" $liboliphaunt_cflags \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -I"$repo_root/src/runtimes/liboliphaunt/native/src" \ + -c "$source" \ + -o "$object" + done +} + +build_openssl_dependency() { + mobile_static_dependency_selected openssl || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/openssl" + local dependency_dir="$mobile_static_dependency_root/openssl" + local build_root="$work_root/openssl-$oliphaunt_mobile_target" + local install_root="$work_root/openssl-$oliphaunt_mobile_target-install" + local installed_archive="" + local archive="$dependency_dir/libcrypto.a" + local configure_target + + if [ -f "$archive" ] && [ -d "$dependency_dir/include/openssl" ]; then + mobile_static_dependency_archives+=("$archive") + return 0 + fi + [ -d "$source_dir" ] || fail "OpenSSL checkout is missing: $source_dir" + case "$android_abi" in + arm64-v8a) configure_target="android-arm64" ;; + x86_64) configure_target="android-x86_64" ;; + *) fail "unsupported Android ABI for OpenSSL: $android_abi" ;; + esac + + rm -rf "$build_root" "$install_root" "$dependency_dir" + mkdir -p "$(dirname "$build_root")" "$dependency_dir/include" + cp -a "$source_dir/." "$build_root/" + rm -rf "$build_root/.git" + ( + cd "$build_root" + PATH="$toolchain_dir/bin:$PATH" \ + ANDROID_NDK_ROOT="$ndk_root" \ + ./Configure "$configure_target" \ + -D__ANDROID_API__="$android_api" \ + no-shared no-tests no-apps no-docs no-engine no-module \ + --prefix="$install_root" \ + --openssldir="$install_root/ssl" >> "$make_log" 2>&1 + PATH="$toolchain_dir/bin:$PATH" make -j"$jobs" build_generated libcrypto.a >> "$make_log" 2>&1 + PATH="$toolchain_dir/bin:$PATH" make install_dev >> "$make_log" 2>&1 + ) + for candidate in "$install_root/lib/libcrypto.a" "$install_root/lib64/libcrypto.a"; do + if [ -f "$candidate" ]; then + installed_archive="$candidate" + break + fi + done + [ -n "$installed_archive" ] || fail "OpenSSL Android $android_abi build did not produce libcrypto.a" + cp -R "$install_root/include/openssl" "$dependency_dir/include/" + cp -p "$installed_archive" "$archive" + mobile_static_dependency_archives+=("$archive") +} + +build_uuid_dependency() { + mobile_static_dependency_selected uuid || return 0 + local source_dir="$repo_root/src/runtimes/liboliphaunt/native/portable-uuid" + local dependency_dir="$mobile_static_dependency_root/uuid" + local archive="$dependency_dir/lib/libuuid.a" + local object="$dependency_dir/portable_uuid.o" + + if [ -f "$archive" ] && [ -d "$dependency_dir/include/uuid" ]; then + mobile_static_dependency_archives+=("$archive") + return 0 + fi + [ -f "$source_dir/portable_uuid.c" ] || fail "portable UUID source is missing: $source_dir" + + rm -rf "$dependency_dir" + mkdir -p "$dependency_dir/include" "$dependency_dir/lib" + cp -R "$source_dir/include/uuid" "$dependency_dir/include/" + "${cc[@]}" $pg_extension_cflags \ + -I"$source_dir/include" \ + -I"$build_dir/src/include" \ + -I"$build_dir/src/include/port" \ + -c "$source_dir/portable_uuid.c" \ + -o "$object" + "$llvm_ar" crs "$archive" "$object" + "$llvm_ranlib" "$archive" + [ -s "$archive" ] || fail "portable UUID Android $android_abi build did not produce $archive" + mobile_static_dependency_archives+=("$archive") +} + +build_mobile_static_dependencies() { + build_openssl_dependency + build_uuid_dependency + build_postgis_mobile_static_dependencies +} + +build_mobile_static_extension_objects() { + local extension source source_dir source_rel object object_dir objects_file stem prefix + local -a compile_args extension_include_args extension_cflags + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + if [ "$extension" = "postgis" ]; then + build_postgis_mobile_static_extension_objects "$extension" + continue + fi + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + source_dir="$(oliphaunt_mobile_static_extension_source_dir "$repo_root" "$build_dir" "$extension")" + extension_include_args=() + while IFS= read -r include_dir; do + [ -n "$include_dir" ] || continue + extension_include_args+=("-I$include_dir") + done < <(oliphaunt_mobile_static_extension_include_dirs "$repo_root" "$build_dir" "$extension") + extension_cflags=() + while IFS= read -r cflag; do + [ -n "$cflag" ] || continue + extension_cflags+=("$cflag") + done < <(oliphaunt_mobile_static_extension_cflags "$extension") + if [ "$(oliphaunt_mobile_static_extension_kind "$extension")" = "contrib" ]; then + ( + cd "$build_dir" + make -C "$(oliphaunt_mobile_static_extension_source_rel "$extension")" \ + CC="$cc_string" \ + AR="$llvm_ar" \ + RANLIB="$llvm_ranlib" \ + CFLAGS="$pg_extension_cflags" \ + all >> "$make_log" 2>&1 || true + ) + fi + object_dir="$out_dir/extensions/$stem" + objects_file="$object_dir/objects.list" + mkdir -p "$object_dir" + : > "$objects_file" + while IFS= read -r source; do + [ -n "$source" ] || continue + source_rel="${source#$source_dir/}" + object="$object_dir/${source_rel%.c}.o" + mkdir -p "$(dirname "$object")" + mobile_static_objects+=("$object") + printf '%s\n' "$object" >> "$objects_file" + compile_args=( + "${cc[@]}" $pg_extension_cflags + -DPg_magic_func="${prefix}_Pg_magic_func" \ + -D_PG_init="${prefix}__PG_init" + ) + if [ "${#extension_cflags[@]}" -gt 0 ]; then + compile_args+=("${extension_cflags[@]}") + fi + if [ "${#extension_include_args[@]}" -gt 0 ]; then + compile_args+=("${extension_include_args[@]}") + fi + compile_args+=( + -I"$build_dir/src/include" + -I"$build_dir/src/include/port" + -c "$source" + -o "$object" + ) + "${compile_args[@]}" + done < <(oliphaunt_mobile_static_extension_source_files "$repo_root" "$build_dir" "$extension") + if [ ! -s "$objects_file" ]; then + fail "mobile static extension $extension did not produce object inputs" + fi + archive_mobile_static_extension_objects "$extension" "$object_dir" "$objects_file" + done +} + +archive_mobile_static_extension_objects() { + local extension="$1" + local object_dir="$2" + local objects_file="$3" + local stem archive + local -a archive_objects + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + archive="$object_dir/liboliphaunt_extension_$stem.a" + while IFS= read -r object; do + [ -n "$object" ] && archive_objects+=("$object") + done < "$objects_file" + rm -f "$archive" + "$llvm_ar" crs "$archive" "${archive_objects[@]}" + "$llvm_ranlib" "$archive" + if [ ! -s "$archive" ]; then + fail "mobile static extension $extension did not produce archive $archive" + fi +} + +defined_c_symbols() { + local stem="$1" + local prefix="$2" + local objects_file="$out_dir/extensions/$stem/objects.list" + # shellcheck disable=SC2046 + "$llvm_nm" -g --defined-only $(cat "$objects_file") | + awk ' + $2 == "T" { + symbol = $3 + if (symbol == "" || + index(symbol, prefix "_") == 1 || + symbol == "Pg_magic_func" || + symbol == "_PG_init") { + next + } + if (symbol ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { + print symbol + } + } + ' prefix="$prefix" | + LC_ALL=C sort -u +} + +module_has_c_symbol() { + local stem="$1" + local symbol="$2" + local objects_file="$out_dir/extensions/$stem/objects.list" + # shellcheck disable=SC2046 + "$llvm_nm" -g --defined-only $(cat "$objects_file") | awk -v wanted="$symbol" '$3 == wanted { found = 1 } END { exit found ? 0 : 1 }' +} + +write_mobile_static_registry_source() { + [ "${#mobile_static_extensions[@]}" -gt 0 ] || return 0 + local extension stem prefix symbols_file alias_file init_symbol symbols_expr symbol_count_expr + { + cat <<'HEADER' +/* Generated by Oliphaunt mobile build. Do not edit by hand. */ +#include +#include +#include "oliphaunt.h" + +HEADER + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + defined_c_symbols "$stem" "$prefix" > "$symbols_file" + if [ ! -s "$symbols_file" ] && ! module_has_c_symbol "$stem" "${prefix}__PG_init"; then + fail "mobile static extension $extension did not produce exported C symbols or an init hook" + fi + printf 'extern const void *%s_Pg_magic_func(void);\n' "$prefix" + if module_has_c_symbol "$stem" "${prefix}__PG_init"; then + printf 'extern void %s__PG_init(void);\n' "$prefix" + fi + while IFS= read -r symbol; do + printf 'extern void %s(void);\n' "$symbol" + done < "$symbols_file" + if [ -s "$alias_file" ]; then + while IFS=$'\t' read -r _ linked_symbol; do + [ -n "$linked_symbol" ] || continue + printf 'extern void %s(void);\n' "$linked_symbol" + done < "$alias_file" + fi + printf '\n' + done + + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + [ -s "$symbols_file" ] || [ -s "$alias_file" ] || continue + printf 'static const OliphauntStaticExtensionSymbol %s_symbols[] = {\n' "$prefix" + while IFS= read -r symbol; do + printf ' { .name = "%s", .address = (void *)%s },\n' "$symbol" "$symbol" + done < "$symbols_file" + if [ -s "$alias_file" ]; then + while IFS=$'\t' read -r sql_symbol linked_symbol; do + [ -n "$sql_symbol" ] || continue + [ -n "$linked_symbol" ] || continue + printf ' { .name = "%s", .address = (void *)%s },\n' "$sql_symbol" "$linked_symbol" + done < "$alias_file" + fi + printf '};\n\n' + done + + printf 'static const OliphauntStaticExtension liboliphaunt_static_extensions[] = {\n' + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + init_symbol="NULL" + if module_has_c_symbol "$stem" "${prefix}__PG_init"; then + init_symbol="${prefix}__PG_init" + fi + symbols_expr="NULL" + symbol_count_expr="0" + if [ -s "$symbols_file" ] || [ -s "$alias_file" ]; then + symbols_expr="${prefix}_symbols" + symbol_count_expr="sizeof(${prefix}_symbols) / sizeof(${prefix}_symbols[0])" + fi + cat < "$mobile_static_registry_source" +} + +build_mobile_static_registry_object() { + [ "${#mobile_static_extensions[@]}" -gt 0 ] || return 0 + "${cc[@]}" $native_cflags \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -c "$mobile_static_registry_source" \ + -o "$mobile_static_registry_object" + mobile_static_objects+=("$mobile_static_registry_object") +} + +write_objects_response_file() { + ( + cd "$build_dir" + { + cat src/backend/*/objfiles.txt + printf '%s\n' "${jit_objects[@]}" + printf 'src/timezone/localtime.o src/timezone/pgtz.o src/timezone/strftime.o\n' + printf '%s\n' "${plpgsql_objects[@]}" + } | tr '[:space:]' '\n' | sed '/^$/d' | awk '!seen[$0]++' > "$objects_rsp" + ) +} + +link_liboliphaunt() { + local -a postgis_link_args + local link_arg + while IFS= read -r link_arg; do + [ -n "$link_arg" ] && postgis_link_args+=("$link_arg") + done < <(oliphaunt_postgis_extra_link_args) + ( + cd "$build_dir" + set +u + "${cxx[@]}" -shared \ + -Wl,-soname,liboliphaunt.so \ + -Wl,-z,defs \ + -Wl,-z,max-page-size=16384 \ + -o "$lib_out" \ + "${liboliphaunt_objects[@]}" \ + "${mobile_static_objects[@]}" \ + "${mobile_static_dependency_archives[@]}" \ + @"$objects_rsp" \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a \ + $icu_libs \ + "${postgis_link_args[@]}" \ + -lm \ + -ldl + set -u + ) +} + +usage() { + cat >&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh [--check-current] + +Environment: + OLIPHAUNT_ANDROID_ABI=arm64-v8a|x86_64 +MSG +} + +parse_mobile_static_extensions + +case "$script_mode" in + build) + prepare_source + build_icu + configure_source + if artifact_ready && (cd "$build_dir" && backend_objects_ready && plpgsql_objects_ready && jit_objects_ready) && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "$lib_out" + exit 0 + fi + build_backend_objects + build_jit_objects + build_timezone_objects + build_plpgsql_objects + build_liboliphaunt_objects + build_mobile_static_dependencies + build_mobile_static_extension_objects + write_mobile_static_registry_source + build_mobile_static_registry_object + write_objects_response_file + link_liboliphaunt + if ! artifact_ready; then + report_artifact_not_ready + exit 1 + fi + desired_hash > "$stamp" + echo "$lib_out" + ;; + --check-current) + if artifact_ready && (cd "$build_dir" && backend_objects_ready && plpgsql_objects_ready && jit_objects_ready) && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "Android $android_abi liboliphaunt shared library is current" + exit 0 + fi + report_artifact_not_ready + echo "Android $android_abi liboliphaunt shared library is missing or stale" >&2 + exit 1 + ;; + *) + usage + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh new file mode 100755 index 00000000..d3398737 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export OLIPHAUNT_ANDROID_ABI="${OLIPHAUNT_ANDROID_ABI:-x86_64}" +exec "$script_dir/build-postgres18-android-arm64.sh" "$@" diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh new file mode 100755 index 00000000..ddb2898a --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh @@ -0,0 +1,901 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/icu.sh" +. "$script_dir/mobile-static-extensions.sh" +. "$script_dir/mobile-postgis-extensions.sh" +script_path="$script_dir/$(basename "$0")" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +oliphaunt_mobile_target="ios-device" +pg_version="18.4" +pg_sha256="81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +pg_url="https://ftp.postgresql.org/pub/source/v${pg_version}/postgresql-${pg_version}.tar.bz2" +source_manifest="$repo_root/src/runtimes/liboliphaunt/native/postgres18/source.toml" +patch_dir="$repo_root/src/runtimes/liboliphaunt/native/patches/postgresql-${pg_version}" +work_root="${OLIPHAUNT_IOS_DEVICE_ROOT:-$repo_root/target/liboliphaunt-ios-device}" +source_cache="$work_root/source" +tarball="$source_cache/postgresql-${pg_version}.tar.bz2" +build_dir="$work_root/postgresql-${pg_version}" +install_dir="$work_root/install" +out_dir="$work_root/out" +stamp="$build_dir/.liboliphaunt-ios-device-build.sha256" +configure_log="$work_root/configure.log" +make_log="$work_root/make.log" +objects_rsp="$out_dir/liboliphaunt-ios-objects.rsp" +lib_out="$out_dir/liboliphaunt.dylib" +mobile_static_registry_source="$out_dir/liboliphaunt_mobile_static_registry.c" +mobile_static_registry_object="$out_dir/liboliphaunt_mobile_static_registry.o" +script_mode="${1:-build}" +icu_source_dir="$(oliphaunt_icu_source_dir "$repo_root")" +icu_native_build_dir="$work_root/icu-native" +icu_build_dir="$work_root/icu-ios-device-build" +icu_prefix="$work_root/icu-ios-device" +icu_cflags="$(oliphaunt_icu_cflags "$icu_prefix")" +icu_static_libs="$(oliphaunt_icu_static_libs "$icu_prefix")" +icu_cpp_libs="-lc++" +icu_libs="$icu_static_libs $icu_cpp_libs" + +liboliphaunt_sources=( + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c" +) + +plpgsql_objects=( + src/pl/plpgsql/src/pl_comp.o + src/pl/plpgsql/src/pl_exec.o + src/pl/plpgsql/src/pl_funcs.o + src/pl/plpgsql/src/pl_gram.o + src/pl/plpgsql/src/pl_handler.o + src/pl/plpgsql/src/pl_scanner.o +) + +jit_objects=( + src/backend/jit/jit.o +) + +mobile_static_extensions=() +mobile_static_objects=() +mobile_static_dependency_archives=() +mobile_static_dependency_root="$out_dir/dependencies" +export OLIPHAUNT_MOBILE_STATIC_DEPENDENCY_ROOT="$mobile_static_dependency_root" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "PostgreSQL iOS device build requires Darwin" >&2 + exit 2 +fi + +for cmd in curl git nm patch perl rg shasum xcrun; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "missing required command: $cmd" >&2 + exit 1 + fi +done + +sdk_path="$(xcrun --sdk iphoneos --show-sdk-path 2>/dev/null || true)" +clang_path="$(xcrun --find --sdk iphoneos clang 2>/dev/null || true)" +clangxx_path="$(xcrun --find --sdk iphoneos clang++ 2>/dev/null || true)" +ar_path="$(xcrun --find --sdk iphoneos ar 2>/dev/null || true)" +ranlib_path="$(xcrun --find --sdk iphoneos ranlib 2>/dev/null || true)" +libtool_path="$(xcrun --find --sdk iphoneos libtool 2>/dev/null || true)" +if [ -z "$sdk_path" ] || [ -z "$clang_path" ] || [ -z "$clangxx_path" ] || [ -z "$ar_path" ] || [ -z "$ranlib_path" ] || [ -z "$libtool_path" ]; then + echo "iPhoneOS SDK is unavailable" >&2 + exit 1 +fi +oliphaunt_icu_require_source "$icu_source_dir" + +min_ios="${OLIPHAUNT_IOS_MIN_VERSION:-17.0}" +cc=("$clang_path" -target "arm64-apple-ios${min_ios}" "-miphoneos-version-min=${min_ios}" -isysroot "$sdk_path") +cxx=("$clangxx_path" -target "arm64-apple-ios${min_ios}" "-miphoneos-version-min=${min_ios}" -isysroot "$sdk_path") +ccache_mode="${OLIPHAUNT_CCACHE:-auto}" +if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then + ccache_bin="" + if [ "$ccache_mode" = "auto" ]; then + ccache_bin="$(command -v ccache || true)" + else + ccache_bin="$ccache_mode" + fi + if [ -n "$ccache_bin" ]; then + cc=("$ccache_bin" "${cc[@]}") + cxx=("$ccache_bin" "${cxx[@]}") + fi +fi +cc_string="${cc[*]}" +cxx_string="${cxx[*]}" +native_cflags="-O2 -g -fPIC -march=armv8-a+crc -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM" +liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" +pg_extension_cflags="$native_cflags $icu_cflags" +jobs="${OLIPHAUNT_JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || echo 4)}" + +parse_mobile_static_extensions() { + local raw="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" + [ -n "$raw" ] || return 0 + local extension + while IFS= read -r extension; do + extension="$(printf '%s' "$extension" | xargs)" + [ -n "$extension" ] || continue + if ! oliphaunt_mobile_static_extension_spec "$extension" >/dev/null; then + echo "unsupported iOS mobile static extension: $extension" >&2 + printf 'supported iOS mobile static extensions: ' >&2 + oliphaunt_mobile_static_supported_extensions | paste -sd ',' - >&2 + exit 2 + fi + mobile_static_extensions+=("$(oliphaunt_mobile_static_extension_sql_name "$extension")") + done < <(printf '%s\n' "$raw" | tr ',' '\n') +} + +mobile_static_extensions_include() { + local wanted="$1" + local extension + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + [ "$extension" = "$wanted" ] && return 0 + done + return 1 +} + +mobile_static_dependency_selected() { + local wanted="$1" + local extension dependency + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + [ "$dependency" = "$wanted" ] && return 0 + done < <(oliphaunt_mobile_static_extension_dependencies "$extension") + done + return 1 +} + +hash_mobile_static_extension_sources() { + local extension file + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + while IFS= read -r file; do + [ -n "$file" ] || continue + shasum -a 256 "$file" + done < <(oliphaunt_mobile_static_extension_hash_inputs "$repo_root" "$build_dir" "$extension") + done +} + +patch_series() { + sed -n '/series = \[/,/\]/p' "$source_manifest" | + sed -n 's/.*"\([^"]*\.patch\)".*/\1/p' +} + +patch_series_hash() { + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + shasum -a 256 "$patch_dir/$patch_name" + done < <(patch_series) | shasum -a 256 | awk '{print $1}' +} + +desired_hash() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'pg_sha256=%s\n' "$pg_sha256" + printf 'sdk_path=%s\n' "$sdk_path" + printf 'clang_path=%s\n' "$clang_path" + printf 'clangxx_path=%s\n' "$clangxx_path" + printf 'min_ios=%s\n' "$min_ios" + printf 'cc=%s\n' "$cc_string" + printf 'cxx=%s\n' "$cxx_string" + printf 'ar=%s\n' "$ar_path" + printf 'ranlib=%s\n' "$ranlib_path" + printf 'icu_source=%s\n' "$(oliphaunt_icu_source_commit "$icu_source_dir")" + printf 'icu_script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'native_cflags=%s\n' "$native_cflags" + printf 'liboliphaunt_cflags=%s\n' "$liboliphaunt_cflags" + printf 'pg_extension_cflags=%s\n' "$pg_extension_cflags" + printf 'mobile_static_extensions=%s\n' "${mobile_static_extensions[*]-}" + printf 'patch_series_hash=%s\n' "$(patch_series_hash)" + printf 'liboliphaunt_sources=%s\n' "${liboliphaunt_sources[*]}" + printf 'plpgsql_objects=%s\n' "${plpgsql_objects[*]}" + printf 'jit_objects=%s\n' "${jit_objects[*]}" + printf 'script_sha256=%s\n' "$(shasum -a 256 "$script_path" | awk '{print $1}')" + shasum -a 256 "$script_dir/mobile-static-extensions.sh" "$script_dir/mobile-postgis-extensions.sh" + shasum -a 256 "$source_manifest" + shasum -a 256 "${liboliphaunt_sources[@]}" + hash_mobile_static_extension_sources + } | shasum -a 256 | awk '{print $1}' +} + +apply_patch_series() { + local patch_name + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + GIT_CEILING_DIRECTORIES="$work_root" git apply --recount --whitespace=nowarn "$patch_dir/$patch_name" >/dev/null + done < <(patch_series) +} + +patched_source_ready() { + grep -Fq 'OliphauntEmbeddedIO' "$build_dir/src/include/libpq/libpq-be.h" && + grep -Fq 'oliphaunt_embedded_main' "$build_dir/src/backend/tcop/postgres.c" && + grep -Fq 'oliphaunt_embedded' "$build_dir/meson_options.txt" && + grep -Fq 'OLIPHAUNT_EMBEDDED' "$build_dir/meson.build" +} + +artifact_ready() { + [ -f "$lib_out" ] || return 1 + oliphaunt_icu_artifacts_ready "$icu_prefix" || return 1 + xcrun vtool -show-build "$lib_out" 2>/dev/null | rg -q "platform IOS" || return 1 + local symbols + symbols="$(nm -g "$lib_out" 2>/dev/null || true)" + local linked_symbols + linked_symbols="$(nm "$lib_out" 2>/dev/null || true)" + oliphaunt_icu_linked_symbols_ready "$linked_symbols" || return 1 + local undefined_symbols + undefined_symbols="$(nm -u "$lib_out" 2>/dev/null || true)" + if printf '%s\n' "$undefined_symbols" | rg -q '_shm(get|ctl|dt)|_shm_open|_sem(get|ctl|op|open|close|unlink|wait|post|trywait|init|destroy)'; then + return 1 + fi + local symbol + for symbol in \ + _oliphaunt_init \ + _oliphaunt_exec_protocol \ + _oliphaunt_exec_protocol_stream \ + _oliphaunt_backup \ + _oliphaunt_backup_ex \ + _oliphaunt_restore \ + _oliphaunt_cancel \ + _oliphaunt_detach \ + _oliphaunt_close \ + _oliphaunt_register_static_extensions \ + _oliphaunt_last_error \ + _oliphaunt_version \ + _oliphaunt_capabilities \ + _oliphaunt_free_response + do + case "$symbols" in *"$symbol"*) ;; *) return 1 ;; esac + done + if [ "${#mobile_static_extensions[@]}" -gt 0 ]; then + case "$symbols" in *"_liboliphaunt_selected_static_extensions"*) ;; *) return 1 ;; esac + local extension stem prefix + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + [ -f "$out_dir/extensions/$stem/liboliphaunt_extension_$stem.a" ] || return 1 + case "$symbols" in *"_${prefix}_Pg_magic_func"*) ;; *) return 1 ;; esac + done + fi +} + +backend_objects_ready() { + for required in \ + src/backend/tcop/postgres.o \ + src/backend/libpq/be-secure.o \ + src/backend/libpq/pqcomm.o \ + src/backend/port/oliphaunt_embedded_sema.o \ + src/backend/port/oliphaunt_embedded_shmem.o + do + [ -f "$required" ] || return 1 + done + for objfile in src/backend/*/objfiles.txt; do + [ -s "$objfile" ] || return 1 + done + nm -g src/backend/tcop/postgres.o | rg -q "_oliphaunt_embedded_main" || return 1 +} + +support_libraries_ready() { + for required in \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a + do + [ -f "$required" ] || return 1 + done +} + +plpgsql_objects_ready() { + local object + for object in "${plpgsql_objects[@]}"; do + [ -f "$object" ] || return 1 + done + nm -g src/pl/plpgsql/src/pl_handler.o | rg -q "_plpgsql_call_handler" || return 1 +} + +jit_objects_ready() { + local object + for object in "${jit_objects[@]}"; do + [ -f "$object" ] || return 1 + done + nm -g src/backend/jit/jit.o | rg -q "_pg_jit_available" || return 1 +} + +prepare_source() { + mkdir -p "$source_cache" "$work_root" "$out_dir" + + if [ ! -f "$tarball" ]; then + curl -L --fail --silent --show-error "$pg_url" -o "$tarball" + fi + ( + cd "$source_cache" + printf '%s %s\n' "$pg_sha256" "postgresql-${pg_version}.tar.bz2" | shasum -a 256 -c - + ) >&2 + + local wanted + wanted="$(desired_hash)" + if [ -d "$build_dir" ] && { [ ! -f "$stamp" ] || [ "$(cat "$stamp")" != "$wanted" ]; }; then + rm -rf "$build_dir" + fi + if [ ! -d "$build_dir" ]; then + tar -xjf "$tarball" -C "$work_root" + ( + cd "$build_dir" + git init -q + apply_patch_series + ) + fi + if ! patched_source_ready; then + echo "PostgreSQL embedded patch verification failed" >&2 + exit 1 + fi +} + +configure_source() { + export CC="$cc_string" + export CXX="$cxx_string" + export CFLAGS="$native_cflags" + export CPPFLAGS="-isysroot $sdk_path $icu_cflags" + export LDFLAGS="-isysroot $sdk_path -L$icu_prefix/lib" + export ICU_CFLAGS="$icu_cflags" + export ICU_LIBS="$icu_libs" + + if [ ! -f "$build_dir/config.status" ]; then + ( + cd "$build_dir" + ./configure \ + --host=aarch64-apple-darwin \ + --prefix="$install_dir" \ + --without-readline \ + --with-icu \ + --without-llvm \ + --without-pam \ + --with-openssl=no \ + --without-zlib \ + --disable-nls \ + ac_cv_file__dev_urandom=yes + ) > "$configure_log" 2>&1 + fi +} + +build_icu() { + oliphaunt_icu_build_target \ + "$icu_source_dir" \ + "$script_dir" \ + "$icu_native_build_dir" \ + "$icu_build_dir" \ + "$icu_prefix" \ + "$jobs" \ + "ios-device" \ + "aarch64-apple-darwin" \ + "$cc_string" \ + "$cxx_string" \ + "$ar_path" \ + "$ranlib_path" \ + "$native_cflags" \ + "$native_cflags" \ + "-isysroot $sdk_path" +} + +build_backend_objects() { + ( + cd "$build_dir" + if backend_objects_ready; then + echo "reusing PostgreSQL iOS device backend objects" >&2 + return + fi + + : > "$make_log" + rm -f src/include/nodes/header-stamp src/include/utils/header-stamp + make -C src/backend generated-headers \ + OLIPHAUNT_EMBEDDED_MOBILE_SHMEM=1 \ + CC="$cc_string" >> "$make_log" 2>&1 + + set +e + make -j"$jobs" -C src/backend \ + OLIPHAUNT_EMBEDDED_MOBILE_SHMEM=1 \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + postgres >> "$make_log" 2>&1 + local make_status=$? + set -e + + if ! backend_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL iOS device backend objects are incomplete" >&2 + exit 1 + fi + if [ "$make_status" -ne 0 ]; then + echo "PostgreSQL iOS device executable/tool build failed after embedded objects were produced; continuing with dylib link" >&2 + fi + ) +} + +build_support_libraries() { + ( + cd "$build_dir" + make -C src/common \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + libpgcommon_srv.a >> "$make_log" 2>&1 + make -C src/port \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + libpgport_srv.a >> "$make_log" 2>&1 + ) +} + +build_timezone_objects() { + ( + cd "$build_dir" + make -C src/timezone \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + localtime.o pgtz.o strftime.o >> "$make_log" 2>&1 + ) +} + +build_plpgsql_objects() { + ( + cd "$build_dir" + if plpgsql_objects_ready; then + echo "reusing PostgreSQL iOS device PL/pgSQL objects" >&2 + return + fi + + make -C src/pl/plpgsql/src clean >> "$make_log" 2>&1 + make -C src/pl/plpgsql/src \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + pl_comp.o pl_exec.o pl_funcs.o pl_gram.o pl_handler.o pl_scanner.o >> "$make_log" 2>&1 + + if ! plpgsql_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL iOS device PL/pgSQL objects are incomplete" >&2 + exit 1 + fi + ) +} + +build_jit_objects() { + ( + cd "$build_dir" + if jit_objects_ready; then + echo "reusing PostgreSQL iOS device JIT stub objects" >&2 + return + fi + + make -C src/backend/jit \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + jit.o >> "$make_log" 2>&1 + + if ! jit_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL iOS device JIT stub objects are incomplete" >&2 + exit 1 + fi + ) +} + +build_liboliphaunt_objects() { + local source object + liboliphaunt_objects=() + for source in "${liboliphaunt_sources[@]}"; do + object="$out_dir/$(basename "${source%.c}").o" + liboliphaunt_objects+=("$object") + "${cc[@]}" $liboliphaunt_cflags \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -I"$repo_root/src/runtimes/liboliphaunt/native/src" \ + -c "$source" \ + -o "$object" + done +} + +build_openssl_dependency() { + mobile_static_dependency_selected openssl || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/openssl" + local dependency_dir="$mobile_static_dependency_root/openssl" + local build_root="$work_root/openssl-ios-device" + local install_root="$work_root/openssl-ios-device-install" + local installed_archive="" + local archive="$dependency_dir/libcrypto.a" + + if [ -f "$archive" ] && [ -d "$dependency_dir/include/openssl" ]; then + mobile_static_dependency_archives+=("$archive") + return 0 + fi + [ -d "$source_dir" ] || { + echo "OpenSSL checkout is missing: $source_dir" >&2 + exit 1 + } + + rm -rf "$build_root" "$install_root" "$dependency_dir" + mkdir -p "$(dirname "$build_root")" "$dependency_dir/include" + cp -a "$source_dir/." "$build_root/" + rm -rf "$build_root/.git" + ( + cd "$build_root" + CFLAGS="-O2 -fPIC -miphoneos-version-min=${min_ios}" \ + ./Configure ios64-xcrun \ + no-shared no-tests no-apps no-docs no-engine no-module \ + --prefix="$install_root" \ + --openssldir="$install_root/ssl" >> "$make_log" 2>&1 + make -j"$jobs" build_generated libcrypto.a >> "$make_log" 2>&1 + make install_dev >> "$make_log" 2>&1 + ) + for candidate in "$install_root/lib/libcrypto.a" "$install_root/lib64/libcrypto.a"; do + if [ -f "$candidate" ]; then + installed_archive="$candidate" + break + fi + done + if [ -z "$installed_archive" ]; then + echo "OpenSSL iOS device build did not produce libcrypto.a" >&2 + exit 1 + fi + cp -R "$install_root/include/openssl" "$dependency_dir/include/" + cp -p "$installed_archive" "$archive" + mobile_static_dependency_archives+=("$archive") +} + +build_uuid_dependency() { + mobile_static_dependency_selected uuid || return 0 + local source_dir="$repo_root/src/runtimes/liboliphaunt/native/portable-uuid" + local dependency_dir="$mobile_static_dependency_root/uuid" + local archive="$dependency_dir/lib/libuuid.a" + local object="$dependency_dir/portable_uuid.o" + + if [ -f "$archive" ] && [ -d "$dependency_dir/include/uuid" ]; then + mobile_static_dependency_archives+=("$archive") + return 0 + fi + [ -f "$source_dir/portable_uuid.c" ] || { + echo "portable UUID source is missing: $source_dir" >&2 + exit 1 + } + + rm -rf "$dependency_dir" + mkdir -p "$dependency_dir/include" "$dependency_dir/lib" + cp -R "$source_dir/include/uuid" "$dependency_dir/include/" + "${cc[@]}" $native_cflags \ + -I"$source_dir/include" \ + -I"$build_dir/src/include" \ + -I"$build_dir/src/include/port" \ + -c "$source_dir/portable_uuid.c" \ + -o "$object" + "$libtool_path" -static -o "$archive" "$object" + [ -s "$archive" ] || { + echo "portable UUID iOS device build did not produce $archive" >&2 + exit 1 + } + mobile_static_dependency_archives+=("$archive") +} + +build_mobile_static_dependencies() { + build_openssl_dependency + build_uuid_dependency + build_postgis_mobile_static_dependencies +} + +build_mobile_static_extension_objects() { + local extension source source_dir source_rel object object_dir objects_file stem prefix + local -a compile_args extension_include_args extension_cflags + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + if [ "$extension" = "postgis" ]; then + build_postgis_mobile_static_extension_objects "$extension" + continue + fi + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + source_dir="$(oliphaunt_mobile_static_extension_source_dir "$repo_root" "$build_dir" "$extension")" + extension_include_args=() + while IFS= read -r include_dir; do + [ -n "$include_dir" ] || continue + extension_include_args+=("-I$include_dir") + done < <(oliphaunt_mobile_static_extension_include_dirs "$repo_root" "$build_dir" "$extension") + extension_cflags=() + while IFS= read -r cflag; do + [ -n "$cflag" ] || continue + extension_cflags+=("$cflag") + done < <(oliphaunt_mobile_static_extension_cflags "$extension") + if [ "$(oliphaunt_mobile_static_extension_kind "$extension")" = "contrib" ]; then + ( + cd "$build_dir" + make -C "$(oliphaunt_mobile_static_extension_source_rel "$extension")" \ + CC="$cc_string" \ + CFLAGS="$pg_extension_cflags" \ + all >> "$make_log" 2>&1 || true + ) + fi + object_dir="$out_dir/extensions/$stem" + objects_file="$object_dir/objects.list" + mkdir -p "$object_dir" + : > "$objects_file" + while IFS= read -r source; do + [ -n "$source" ] || continue + source_rel="${source#$source_dir/}" + object="$object_dir/${source_rel%.c}.o" + mkdir -p "$(dirname "$object")" + mobile_static_objects+=("$object") + printf '%s\n' "$object" >> "$objects_file" + compile_args=( + "${cc[@]}" $pg_extension_cflags + -DPg_magic_func="${prefix}_Pg_magic_func" \ + -D_PG_init="${prefix}__PG_init" + ) + if [ "${#extension_cflags[@]}" -gt 0 ]; then + compile_args+=("${extension_cflags[@]}") + fi + if [ "${#extension_include_args[@]}" -gt 0 ]; then + compile_args+=("${extension_include_args[@]}") + fi + compile_args+=( + -I"$build_dir/src/include" + -I"$build_dir/src/include/port" + -c "$source" + -o "$object" + ) + "${compile_args[@]}" + done < <(oliphaunt_mobile_static_extension_source_files "$repo_root" "$build_dir" "$extension") + if [ ! -s "$objects_file" ]; then + echo "mobile static extension $extension did not produce object inputs" >&2 + exit 1 + fi + archive_mobile_static_extension_objects "$extension" "$object_dir" "$objects_file" + done +} + +archive_mobile_static_extension_objects() { + local extension="$1" + local object_dir="$2" + local objects_file="$3" + local stem archive + local -a archive_objects + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + archive="$object_dir/liboliphaunt_extension_$stem.a" + while IFS= read -r object; do + [ -n "$object" ] && archive_objects+=("$object") + done < "$objects_file" + rm -f "$archive" + "$libtool_path" -static -o "$archive" "${archive_objects[@]}" + if [ ! -s "$archive" ]; then + echo "mobile static extension $extension did not produce archive $archive" >&2 + exit 1 + fi +} + +defined_c_symbols() { + local stem="$1" + local prefix="$2" + local objects_file="$out_dir/extensions/$stem/objects.list" + # shellcheck disable=SC2046 + nm -g $(cat "$objects_file") | + awk ' + $2 == "T" { + symbol = $3 + sub(/^_/, "", symbol) + if (symbol == "" || + index(symbol, prefix "_") == 1 || + symbol == "Pg_magic_func" || + symbol == "_PG_init") { + next + } + if (symbol ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { + print symbol + } + } + ' prefix="$prefix" | + LC_ALL=C sort -u +} + +module_has_c_symbol() { + local stem="$1" + local symbol="$2" + local objects_file="$out_dir/extensions/$stem/objects.list" + # shellcheck disable=SC2046 + nm -g $(cat "$objects_file") | awk -v wanted="_$symbol" '$3 == wanted { found = 1 } END { exit found ? 0 : 1 }' +} + +write_mobile_static_registry_source() { + [ "${#mobile_static_extensions[@]}" -gt 0 ] || return 0 + local extension stem prefix symbols_file alias_file init_symbol symbols_expr symbol_count_expr + { + cat <<'HEADER' +/* Generated by Oliphaunt mobile build. Do not edit by hand. */ +#include +#include +#include "oliphaunt.h" + +HEADER + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + defined_c_symbols "$stem" "$prefix" > "$symbols_file" + if [ ! -s "$symbols_file" ] && ! module_has_c_symbol "$stem" "${prefix}__PG_init"; then + echo "mobile static extension $extension did not produce exported C symbols or an init hook" >&2 + exit 1 + fi + printf 'extern const void *%s_Pg_magic_func(void);\n' "$prefix" + if module_has_c_symbol "$stem" "${prefix}__PG_init"; then + printf 'extern void %s__PG_init(void);\n' "$prefix" + fi + while IFS= read -r symbol; do + printf 'extern void %s(void);\n' "$symbol" + done < "$symbols_file" + if [ -s "$alias_file" ]; then + while IFS=$'\t' read -r _ linked_symbol; do + [ -n "$linked_symbol" ] || continue + printf 'extern void %s(void);\n' "$linked_symbol" + done < "$alias_file" + fi + printf '\n' + done + + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + [ -s "$symbols_file" ] || [ -s "$alias_file" ] || continue + printf 'static const OliphauntStaticExtensionSymbol %s_symbols[] = {\n' "$prefix" + while IFS= read -r symbol; do + printf ' { .name = "%s", .address = (void *)%s },\n' "$symbol" "$symbol" + done < "$symbols_file" + if [ -s "$alias_file" ]; then + while IFS=$'\t' read -r sql_symbol linked_symbol; do + [ -n "$sql_symbol" ] || continue + [ -n "$linked_symbol" ] || continue + printf ' { .name = "%s", .address = (void *)%s },\n' "$sql_symbol" "$linked_symbol" + done < "$alias_file" + fi + printf '};\n\n' + done + + printf 'static const OliphauntStaticExtension liboliphaunt_static_extensions[] = {\n' + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + init_symbol="NULL" + if module_has_c_symbol "$stem" "${prefix}__PG_init"; then + init_symbol="${prefix}__PG_init" + fi + symbols_expr="NULL" + symbol_count_expr="0" + if [ -s "$symbols_file" ] || [ -s "$alias_file" ]; then + symbols_expr="${prefix}_symbols" + symbol_count_expr="sizeof(${prefix}_symbols) / sizeof(${prefix}_symbols[0])" + fi + cat < "$mobile_static_registry_source" +} + +build_mobile_static_registry_object() { + [ "${#mobile_static_extensions[@]}" -gt 0 ] || return 0 + "${cc[@]}" $native_cflags \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -c "$mobile_static_registry_source" \ + -o "$mobile_static_registry_object" + mobile_static_objects+=("$mobile_static_registry_object") +} + +write_objects_response_file() { + ( + cd "$build_dir" + { + cat src/backend/*/objfiles.txt + printf '%s\n' "${jit_objects[@]}" + printf 'src/timezone/localtime.o src/timezone/pgtz.o src/timezone/strftime.o\n' + printf '%s\n' "${plpgsql_objects[@]}" + } | tr '[:space:]' '\n' | sed '/^$/d' | awk '!seen[$0]++' > "$objects_rsp" + ) +} + +link_liboliphaunt() { + local -a postgis_link_args + local link_arg + while IFS= read -r link_arg; do + [ -n "$link_arg" ] && postgis_link_args+=("$link_arg") + done < <(oliphaunt_postgis_extra_link_args) + ( + cd "$build_dir" + set +u + "${cc[@]}" -dynamiclib \ + -Wl,-install_name,@rpath/liboliphaunt.dylib \ + -o "$lib_out" \ + "${liboliphaunt_objects[@]}" \ + "${mobile_static_objects[@]}" \ + "${mobile_static_dependency_archives[@]}" \ + @"$objects_rsp" \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a \ + $icu_libs \ + "${postgis_link_args[@]}" \ + -lpthread + set -u + ) +} + +usage() { + cat >&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh [--check-current] +MSG +} + +parse_mobile_static_extensions + +if [ "${OLIPHAUNT_PRINT_DESIRED_HASH:-0}" = "1" ]; then + desired_hash + exit 0 +fi + +case "$script_mode" in + build) + prepare_source + build_icu + configure_source + if artifact_ready && (cd "$build_dir" && backend_objects_ready && support_libraries_ready && plpgsql_objects_ready && jit_objects_ready) && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "$lib_out" + exit 0 + fi + build_backend_objects + build_support_libraries + build_jit_objects + build_timezone_objects + build_plpgsql_objects + build_liboliphaunt_objects + build_mobile_static_dependencies + build_mobile_static_extension_objects + write_mobile_static_registry_source + build_mobile_static_registry_object + write_objects_response_file + link_liboliphaunt + artifact_ready + desired_hash > "$stamp" + echo "$lib_out" + ;; + --check-current) + if artifact_ready && (cd "$build_dir" && backend_objects_ready && support_libraries_ready && plpgsql_objects_ready && jit_objects_ready) && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "iOS device liboliphaunt dylib is current" + exit 0 + fi + echo "iOS device liboliphaunt dylib is missing or stale" >&2 + exit 1 + ;; + *) + usage + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh new file mode 100755 index 00000000..9d6458db --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh @@ -0,0 +1,901 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/icu.sh" +. "$script_dir/mobile-static-extensions.sh" +. "$script_dir/mobile-postgis-extensions.sh" +script_path="$script_dir/$(basename "$0")" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +oliphaunt_mobile_target="ios-simulator" +pg_version="18.4" +pg_sha256="81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +pg_url="https://ftp.postgresql.org/pub/source/v${pg_version}/postgresql-${pg_version}.tar.bz2" +source_manifest="$repo_root/src/runtimes/liboliphaunt/native/postgres18/source.toml" +patch_dir="$repo_root/src/runtimes/liboliphaunt/native/patches/postgresql-${pg_version}" +work_root="${OLIPHAUNT_IOS_SIMULATOR_ROOT:-$repo_root/target/liboliphaunt-ios-simulator}" +source_cache="$work_root/source" +tarball="$source_cache/postgresql-${pg_version}.tar.bz2" +build_dir="$work_root/postgresql-${pg_version}" +install_dir="$work_root/install" +out_dir="$work_root/out" +stamp="$build_dir/.liboliphaunt-ios-simulator-build.sha256" +configure_log="$work_root/configure.log" +make_log="$work_root/make.log" +objects_rsp="$out_dir/liboliphaunt-ios-objects.rsp" +lib_out="$out_dir/liboliphaunt.dylib" +mobile_static_registry_source="$out_dir/liboliphaunt_mobile_static_registry.c" +mobile_static_registry_object="$out_dir/liboliphaunt_mobile_static_registry.o" +script_mode="${1:-build}" +icu_source_dir="$(oliphaunt_icu_source_dir "$repo_root")" +icu_native_build_dir="$work_root/icu-native" +icu_build_dir="$work_root/icu-ios-simulator-build" +icu_prefix="$work_root/icu-ios-simulator" +icu_cflags="$(oliphaunt_icu_cflags "$icu_prefix")" +icu_static_libs="$(oliphaunt_icu_static_libs "$icu_prefix")" +icu_cpp_libs="-lc++" +icu_libs="$icu_static_libs $icu_cpp_libs" + +liboliphaunt_sources=( + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c" +) + +plpgsql_objects=( + src/pl/plpgsql/src/pl_comp.o + src/pl/plpgsql/src/pl_exec.o + src/pl/plpgsql/src/pl_funcs.o + src/pl/plpgsql/src/pl_gram.o + src/pl/plpgsql/src/pl_handler.o + src/pl/plpgsql/src/pl_scanner.o +) + +jit_objects=( + src/backend/jit/jit.o +) + +mobile_static_extensions=() +mobile_static_objects=() +mobile_static_dependency_archives=() +mobile_static_dependency_root="$out_dir/dependencies" +export OLIPHAUNT_MOBILE_STATIC_DEPENDENCY_ROOT="$mobile_static_dependency_root" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "PostgreSQL iOS simulator build requires Darwin" >&2 + exit 2 +fi + +for cmd in curl git nm patch perl rg shasum xcrun; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "missing required command: $cmd" >&2 + exit 1 + fi +done + +sdk_path="$(xcrun --sdk iphonesimulator --show-sdk-path 2>/dev/null || true)" +clang_path="$(xcrun --find --sdk iphonesimulator clang 2>/dev/null || true)" +clangxx_path="$(xcrun --find --sdk iphonesimulator clang++ 2>/dev/null || true)" +ar_path="$(xcrun --find --sdk iphonesimulator ar 2>/dev/null || true)" +ranlib_path="$(xcrun --find --sdk iphonesimulator ranlib 2>/dev/null || true)" +libtool_path="$(xcrun --find --sdk iphonesimulator libtool 2>/dev/null || true)" +if [ -z "$sdk_path" ] || [ -z "$clang_path" ] || [ -z "$clangxx_path" ] || [ -z "$ar_path" ] || [ -z "$ranlib_path" ] || [ -z "$libtool_path" ]; then + echo "iPhoneSimulator SDK is unavailable" >&2 + exit 1 +fi +oliphaunt_icu_require_source "$icu_source_dir" + +min_ios="${OLIPHAUNT_IOS_SIMULATOR_MIN_VERSION:-17.0}" +cc=("$clang_path" -target "arm64-apple-ios${min_ios}-simulator" "-mios-simulator-version-min=${min_ios}" -isysroot "$sdk_path") +cxx=("$clangxx_path" -target "arm64-apple-ios${min_ios}-simulator" "-mios-simulator-version-min=${min_ios}" -isysroot "$sdk_path") +ccache_mode="${OLIPHAUNT_CCACHE:-auto}" +if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then + ccache_bin="" + if [ "$ccache_mode" = "auto" ]; then + ccache_bin="$(command -v ccache || true)" + else + ccache_bin="$ccache_mode" + fi + if [ -n "$ccache_bin" ]; then + cc=("$ccache_bin" "${cc[@]}") + cxx=("$ccache_bin" "${cxx[@]}") + fi +fi +cc_string="${cc[*]}" +cxx_string="${cxx[*]}" +native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED -DOLIPHAUNT_EMBEDDED_MOBILE_SHMEM" +liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" +pg_extension_cflags="$native_cflags $icu_cflags" +jobs="${OLIPHAUNT_JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || echo 4)}" + +parse_mobile_static_extensions() { + local raw="${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}" + [ -n "$raw" ] || return 0 + local extension + while IFS= read -r extension; do + extension="$(printf '%s' "$extension" | xargs)" + [ -n "$extension" ] || continue + if ! oliphaunt_mobile_static_extension_spec "$extension" >/dev/null; then + echo "unsupported iOS mobile static extension: $extension" >&2 + printf 'supported iOS mobile static extensions: ' >&2 + oliphaunt_mobile_static_supported_extensions | paste -sd ',' - >&2 + exit 2 + fi + mobile_static_extensions+=("$(oliphaunt_mobile_static_extension_sql_name "$extension")") + done < <(printf '%s\n' "$raw" | tr ',' '\n') +} + +mobile_static_extensions_include() { + local wanted="$1" + local extension + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + [ "$extension" = "$wanted" ] && return 0 + done + return 1 +} + +mobile_static_dependency_selected() { + local wanted="$1" + local extension dependency + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + [ "$dependency" = "$wanted" ] && return 0 + done < <(oliphaunt_mobile_static_extension_dependencies "$extension") + done + return 1 +} + +hash_mobile_static_extension_sources() { + local extension file + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + while IFS= read -r file; do + [ -n "$file" ] || continue + shasum -a 256 "$file" + done < <(oliphaunt_mobile_static_extension_hash_inputs "$repo_root" "$build_dir" "$extension") + done +} + +patch_series() { + sed -n '/series = \[/,/\]/p' "$source_manifest" | + sed -n 's/.*"\([^"]*\.patch\)".*/\1/p' +} + +patch_series_hash() { + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + shasum -a 256 "$patch_dir/$patch_name" + done < <(patch_series) | shasum -a 256 | awk '{print $1}' +} + +desired_hash() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'pg_sha256=%s\n' "$pg_sha256" + printf 'sdk_path=%s\n' "$sdk_path" + printf 'clang_path=%s\n' "$clang_path" + printf 'clangxx_path=%s\n' "$clangxx_path" + printf 'min_ios=%s\n' "$min_ios" + printf 'cc=%s\n' "$cc_string" + printf 'cxx=%s\n' "$cxx_string" + printf 'ar=%s\n' "$ar_path" + printf 'ranlib=%s\n' "$ranlib_path" + printf 'icu_source=%s\n' "$(oliphaunt_icu_source_commit "$icu_source_dir")" + printf 'icu_script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'native_cflags=%s\n' "$native_cflags" + printf 'liboliphaunt_cflags=%s\n' "$liboliphaunt_cflags" + printf 'pg_extension_cflags=%s\n' "$pg_extension_cflags" + printf 'mobile_static_extensions=%s\n' "${mobile_static_extensions[*]-}" + printf 'patch_series_hash=%s\n' "$(patch_series_hash)" + printf 'liboliphaunt_sources=%s\n' "${liboliphaunt_sources[*]}" + printf 'plpgsql_objects=%s\n' "${plpgsql_objects[*]}" + printf 'jit_objects=%s\n' "${jit_objects[*]}" + printf 'script_sha256=%s\n' "$(shasum -a 256 "$script_path" | awk '{print $1}')" + shasum -a 256 "$script_dir/mobile-static-extensions.sh" "$script_dir/mobile-postgis-extensions.sh" + shasum -a 256 "$source_manifest" + shasum -a 256 "${liboliphaunt_sources[@]}" + hash_mobile_static_extension_sources + } | shasum -a 256 | awk '{print $1}' +} + +apply_patch_series() { + local patch_name + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + GIT_CEILING_DIRECTORIES="$work_root" git apply --recount --whitespace=nowarn "$patch_dir/$patch_name" >/dev/null + done < <(patch_series) +} + +patched_source_ready() { + grep -Fq 'OliphauntEmbeddedIO' "$build_dir/src/include/libpq/libpq-be.h" && + grep -Fq 'oliphaunt_embedded_main' "$build_dir/src/backend/tcop/postgres.c" && + grep -Fq 'oliphaunt_embedded' "$build_dir/meson_options.txt" && + grep -Fq 'OLIPHAUNT_EMBEDDED' "$build_dir/meson.build" +} + +artifact_ready() { + [ -f "$lib_out" ] || return 1 + oliphaunt_icu_artifacts_ready "$icu_prefix" || return 1 + xcrun vtool -show-build "$lib_out" 2>/dev/null | rg -q "platform IOSSIMULATOR" || return 1 + local symbols + symbols="$(nm -g "$lib_out" 2>/dev/null || true)" + local linked_symbols + linked_symbols="$(nm "$lib_out" 2>/dev/null || true)" + oliphaunt_icu_linked_symbols_ready "$linked_symbols" || return 1 + local undefined_symbols + undefined_symbols="$(nm -u "$lib_out" 2>/dev/null || true)" + if printf '%s\n' "$undefined_symbols" | rg -q '_shm(get|ctl|dt)|_shm_open|_sem(get|ctl|op|open|close|unlink|wait|post|trywait|init|destroy)'; then + return 1 + fi + local symbol + for symbol in \ + _oliphaunt_init \ + _oliphaunt_exec_protocol \ + _oliphaunt_exec_protocol_stream \ + _oliphaunt_backup \ + _oliphaunt_backup_ex \ + _oliphaunt_restore \ + _oliphaunt_cancel \ + _oliphaunt_detach \ + _oliphaunt_close \ + _oliphaunt_register_static_extensions \ + _oliphaunt_last_error \ + _oliphaunt_version \ + _oliphaunt_capabilities \ + _oliphaunt_free_response + do + case "$symbols" in *"$symbol"*) ;; *) return 1 ;; esac + done + if [ "${#mobile_static_extensions[@]}" -gt 0 ]; then + case "$symbols" in *"_liboliphaunt_selected_static_extensions"*) ;; *) return 1 ;; esac + local extension stem prefix + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + [ -f "$out_dir/extensions/$stem/liboliphaunt_extension_$stem.a" ] || return 1 + case "$symbols" in *"_${prefix}_Pg_magic_func"*) ;; *) return 1 ;; esac + done + fi +} + +backend_objects_ready() { + for required in \ + src/backend/tcop/postgres.o \ + src/backend/libpq/be-secure.o \ + src/backend/libpq/pqcomm.o \ + src/backend/port/oliphaunt_embedded_sema.o \ + src/backend/port/oliphaunt_embedded_shmem.o + do + [ -f "$required" ] || return 1 + done + for objfile in src/backend/*/objfiles.txt; do + [ -s "$objfile" ] || return 1 + done + nm -g src/backend/tcop/postgres.o | rg -q "_oliphaunt_embedded_main" || return 1 +} + +support_libraries_ready() { + for required in \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a + do + [ -f "$required" ] || return 1 + done +} + +plpgsql_objects_ready() { + local object + for object in "${plpgsql_objects[@]}"; do + [ -f "$object" ] || return 1 + done + nm -g src/pl/plpgsql/src/pl_handler.o | rg -q "_plpgsql_call_handler" || return 1 +} + +jit_objects_ready() { + local object + for object in "${jit_objects[@]}"; do + [ -f "$object" ] || return 1 + done + nm -g src/backend/jit/jit.o | rg -q "_pg_jit_available" || return 1 +} + +prepare_source() { + mkdir -p "$source_cache" "$work_root" "$out_dir" + + if [ ! -f "$tarball" ]; then + curl -L --fail --silent --show-error "$pg_url" -o "$tarball" + fi + ( + cd "$source_cache" + printf '%s %s\n' "$pg_sha256" "postgresql-${pg_version}.tar.bz2" | shasum -a 256 -c - + ) >&2 + + local wanted + wanted="$(desired_hash)" + if [ -d "$build_dir" ] && { [ ! -f "$stamp" ] || [ "$(cat "$stamp")" != "$wanted" ]; }; then + rm -rf "$build_dir" + fi + if [ ! -d "$build_dir" ]; then + tar -xjf "$tarball" -C "$work_root" + ( + cd "$build_dir" + git init -q + apply_patch_series + ) + fi + if ! patched_source_ready; then + echo "PostgreSQL embedded patch verification failed" >&2 + exit 1 + fi +} + +configure_source() { + export CC="$cc_string" + export CXX="$cxx_string" + export CFLAGS="$native_cflags" + export CPPFLAGS="-isysroot $sdk_path $icu_cflags" + export LDFLAGS="-isysroot $sdk_path -L$icu_prefix/lib" + export ICU_CFLAGS="$icu_cflags" + export ICU_LIBS="$icu_libs" + + if [ ! -f "$build_dir/config.status" ]; then + ( + cd "$build_dir" + ./configure \ + --host=aarch64-apple-darwin \ + --prefix="$install_dir" \ + --without-readline \ + --with-icu \ + --without-llvm \ + --without-pam \ + --with-openssl=no \ + --without-zlib \ + --disable-nls \ + ac_cv_file__dev_urandom=yes + ) > "$configure_log" 2>&1 + fi +} + +build_icu() { + oliphaunt_icu_build_target \ + "$icu_source_dir" \ + "$script_dir" \ + "$icu_native_build_dir" \ + "$icu_build_dir" \ + "$icu_prefix" \ + "$jobs" \ + "ios-simulator" \ + "aarch64-apple-darwin" \ + "$cc_string" \ + "$cxx_string" \ + "$ar_path" \ + "$ranlib_path" \ + "$native_cflags" \ + "$native_cflags" \ + "-isysroot $sdk_path" +} + +build_backend_objects() { + ( + cd "$build_dir" + if backend_objects_ready; then + echo "reusing PostgreSQL iOS simulator backend objects" >&2 + return + fi + + : > "$make_log" + rm -f src/include/nodes/header-stamp src/include/utils/header-stamp + make -C src/backend generated-headers \ + OLIPHAUNT_EMBEDDED_MOBILE_SHMEM=1 \ + CC="$cc_string" >> "$make_log" 2>&1 + + set +e + make -j"$jobs" -C src/backend \ + OLIPHAUNT_EMBEDDED_MOBILE_SHMEM=1 \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + postgres >> "$make_log" 2>&1 + local make_status=$? + set -e + + if ! backend_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL iOS simulator backend objects are incomplete" >&2 + exit 1 + fi + if [ "$make_status" -ne 0 ]; then + echo "PostgreSQL iOS simulator executable/tool build failed after embedded objects were produced; continuing with dylib link" >&2 + fi + ) +} + +build_support_libraries() { + ( + cd "$build_dir" + make -C src/common \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + libpgcommon_srv.a >> "$make_log" 2>&1 + make -C src/port \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + libpgport_srv.a >> "$make_log" 2>&1 + ) +} + +build_timezone_objects() { + ( + cd "$build_dir" + make -C src/timezone \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + localtime.o pgtz.o strftime.o >> "$make_log" 2>&1 + ) +} + +build_plpgsql_objects() { + ( + cd "$build_dir" + if plpgsql_objects_ready; then + echo "reusing PostgreSQL iOS simulator PL/pgSQL objects" >&2 + return + fi + + make -C src/pl/plpgsql/src clean >> "$make_log" 2>&1 + make -C src/pl/plpgsql/src \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + pl_comp.o pl_exec.o pl_funcs.o pl_gram.o pl_handler.o pl_scanner.o >> "$make_log" 2>&1 + + if ! plpgsql_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL iOS simulator PL/pgSQL objects are incomplete" >&2 + exit 1 + fi + ) +} + +build_jit_objects() { + ( + cd "$build_dir" + if jit_objects_ready; then + echo "reusing PostgreSQL iOS simulator JIT stub objects" >&2 + return + fi + + make -C src/backend/jit \ + CC="$cc_string" \ + CFLAGS="$native_cflags" \ + jit.o >> "$make_log" 2>&1 + + if ! jit_objects_ready; then + tail -120 "$make_log" >&2 || true + echo "PostgreSQL iOS simulator JIT stub objects are incomplete" >&2 + exit 1 + fi + ) +} + +build_liboliphaunt_objects() { + local source object + liboliphaunt_objects=() + for source in "${liboliphaunt_sources[@]}"; do + object="$out_dir/$(basename "${source%.c}").o" + liboliphaunt_objects+=("$object") + "${cc[@]}" $liboliphaunt_cflags \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -I"$repo_root/src/runtimes/liboliphaunt/native/src" \ + -c "$source" \ + -o "$object" + done +} + +build_openssl_dependency() { + mobile_static_dependency_selected openssl || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/openssl" + local dependency_dir="$mobile_static_dependency_root/openssl" + local build_root="$work_root/openssl-ios-simulator" + local install_root="$work_root/openssl-ios-simulator-install" + local installed_archive="" + local archive="$dependency_dir/libcrypto.a" + + if [ -f "$archive" ] && [ -d "$dependency_dir/include/openssl" ]; then + mobile_static_dependency_archives+=("$archive") + return 0 + fi + [ -d "$source_dir" ] || { + echo "OpenSSL checkout is missing: $source_dir" >&2 + exit 1 + } + + rm -rf "$build_root" "$install_root" "$dependency_dir" + mkdir -p "$(dirname "$build_root")" "$dependency_dir/include" + cp -a "$source_dir/." "$build_root/" + rm -rf "$build_root/.git" + ( + cd "$build_root" + CFLAGS="-O2 -fPIC -mios-simulator-version-min=${min_ios}" \ + ./Configure iossimulator-arm64-xcrun \ + no-shared no-tests no-apps no-docs no-engine no-module \ + --prefix="$install_root" \ + --openssldir="$install_root/ssl" >> "$make_log" 2>&1 + make -j"$jobs" build_generated libcrypto.a >> "$make_log" 2>&1 + make install_dev >> "$make_log" 2>&1 + ) + for candidate in "$install_root/lib/libcrypto.a" "$install_root/lib64/libcrypto.a"; do + if [ -f "$candidate" ]; then + installed_archive="$candidate" + break + fi + done + if [ -z "$installed_archive" ]; then + echo "OpenSSL iOS simulator build did not produce libcrypto.a" >&2 + exit 1 + fi + cp -R "$install_root/include/openssl" "$dependency_dir/include/" + cp -p "$installed_archive" "$archive" + mobile_static_dependency_archives+=("$archive") +} + +build_uuid_dependency() { + mobile_static_dependency_selected uuid || return 0 + local source_dir="$repo_root/src/runtimes/liboliphaunt/native/portable-uuid" + local dependency_dir="$mobile_static_dependency_root/uuid" + local archive="$dependency_dir/lib/libuuid.a" + local object="$dependency_dir/portable_uuid.o" + + if [ -f "$archive" ] && [ -d "$dependency_dir/include/uuid" ]; then + mobile_static_dependency_archives+=("$archive") + return 0 + fi + [ -f "$source_dir/portable_uuid.c" ] || { + echo "portable UUID source is missing: $source_dir" >&2 + exit 1 + } + + rm -rf "$dependency_dir" + mkdir -p "$dependency_dir/include" "$dependency_dir/lib" + cp -R "$source_dir/include/uuid" "$dependency_dir/include/" + "${cc[@]}" $native_cflags \ + -I"$source_dir/include" \ + -I"$build_dir/src/include" \ + -I"$build_dir/src/include/port" \ + -c "$source_dir/portable_uuid.c" \ + -o "$object" + "$libtool_path" -static -o "$archive" "$object" + [ -s "$archive" ] || { + echo "portable UUID iOS simulator build did not produce $archive" >&2 + exit 1 + } + mobile_static_dependency_archives+=("$archive") +} + +build_mobile_static_dependencies() { + build_openssl_dependency + build_uuid_dependency + build_postgis_mobile_static_dependencies +} + +build_mobile_static_extension_objects() { + local extension source source_dir source_rel object object_dir objects_file stem prefix + local -a compile_args extension_include_args extension_cflags + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + if [ "$extension" = "postgis" ]; then + build_postgis_mobile_static_extension_objects "$extension" + continue + fi + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + source_dir="$(oliphaunt_mobile_static_extension_source_dir "$repo_root" "$build_dir" "$extension")" + extension_include_args=() + while IFS= read -r include_dir; do + [ -n "$include_dir" ] || continue + extension_include_args+=("-I$include_dir") + done < <(oliphaunt_mobile_static_extension_include_dirs "$repo_root" "$build_dir" "$extension") + extension_cflags=() + while IFS= read -r cflag; do + [ -n "$cflag" ] || continue + extension_cflags+=("$cflag") + done < <(oliphaunt_mobile_static_extension_cflags "$extension") + if [ "$(oliphaunt_mobile_static_extension_kind "$extension")" = "contrib" ]; then + ( + cd "$build_dir" + make -C "$(oliphaunt_mobile_static_extension_source_rel "$extension")" \ + CC="$cc_string" \ + CFLAGS="$pg_extension_cflags" \ + all >> "$make_log" 2>&1 || true + ) + fi + object_dir="$out_dir/extensions/$stem" + objects_file="$object_dir/objects.list" + mkdir -p "$object_dir" + : > "$objects_file" + while IFS= read -r source; do + [ -n "$source" ] || continue + source_rel="${source#$source_dir/}" + object="$object_dir/${source_rel%.c}.o" + mkdir -p "$(dirname "$object")" + mobile_static_objects+=("$object") + printf '%s\n' "$object" >> "$objects_file" + compile_args=( + "${cc[@]}" $pg_extension_cflags + -DPg_magic_func="${prefix}_Pg_magic_func" \ + -D_PG_init="${prefix}__PG_init" + ) + if [ "${#extension_cflags[@]}" -gt 0 ]; then + compile_args+=("${extension_cflags[@]}") + fi + if [ "${#extension_include_args[@]}" -gt 0 ]; then + compile_args+=("${extension_include_args[@]}") + fi + compile_args+=( + -I"$build_dir/src/include" + -I"$build_dir/src/include/port" + -c "$source" + -o "$object" + ) + "${compile_args[@]}" + done < <(oliphaunt_mobile_static_extension_source_files "$repo_root" "$build_dir" "$extension") + if [ ! -s "$objects_file" ]; then + echo "mobile static extension $extension did not produce object inputs" >&2 + exit 1 + fi + archive_mobile_static_extension_objects "$extension" "$object_dir" "$objects_file" + done +} + +archive_mobile_static_extension_objects() { + local extension="$1" + local object_dir="$2" + local objects_file="$3" + local stem archive + local -a archive_objects + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + archive="$object_dir/liboliphaunt_extension_$stem.a" + while IFS= read -r object; do + [ -n "$object" ] && archive_objects+=("$object") + done < "$objects_file" + rm -f "$archive" + "$libtool_path" -static -o "$archive" "${archive_objects[@]}" + if [ ! -s "$archive" ]; then + echo "mobile static extension $extension did not produce archive $archive" >&2 + exit 1 + fi +} + +defined_c_symbols() { + local stem="$1" + local prefix="$2" + local objects_file="$out_dir/extensions/$stem/objects.list" + # shellcheck disable=SC2046 + nm -g $(cat "$objects_file") | + awk ' + $2 == "T" { + symbol = $3 + sub(/^_/, "", symbol) + if (symbol == "" || + index(symbol, prefix "_") == 1 || + symbol == "Pg_magic_func" || + symbol == "_PG_init") { + next + } + if (symbol ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { + print symbol + } + } + ' prefix="$prefix" | + LC_ALL=C sort -u +} + +module_has_c_symbol() { + local stem="$1" + local symbol="$2" + local objects_file="$out_dir/extensions/$stem/objects.list" + # shellcheck disable=SC2046 + nm -g $(cat "$objects_file") | awk -v wanted="_$symbol" '$3 == wanted { found = 1 } END { exit found ? 0 : 1 }' +} + +write_mobile_static_registry_source() { + [ "${#mobile_static_extensions[@]}" -gt 0 ] || return 0 + local extension stem prefix symbols_file alias_file init_symbol symbols_expr symbol_count_expr + { + cat <<'HEADER' +/* Generated by Oliphaunt mobile build. Do not edit by hand. */ +#include +#include +#include "oliphaunt.h" + +HEADER + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + defined_c_symbols "$stem" "$prefix" > "$symbols_file" + if [ ! -s "$symbols_file" ] && ! module_has_c_symbol "$stem" "${prefix}__PG_init"; then + echo "mobile static extension $extension did not produce exported C symbols or an init hook" >&2 + exit 1 + fi + printf 'extern const void *%s_Pg_magic_func(void);\n' "$prefix" + if module_has_c_symbol "$stem" "${prefix}__PG_init"; then + printf 'extern void %s__PG_init(void);\n' "$prefix" + fi + while IFS= read -r symbol; do + printf 'extern void %s(void);\n' "$symbol" + done < "$symbols_file" + if [ -s "$alias_file" ]; then + while IFS=$'\t' read -r _ linked_symbol; do + [ -n "$linked_symbol" ] || continue + printf 'extern void %s(void);\n' "$linked_symbol" + done < "$alias_file" + fi + printf '\n' + done + + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + [ -s "$symbols_file" ] || [ -s "$alias_file" ] || continue + printf 'static const OliphauntStaticExtensionSymbol %s_symbols[] = {\n' "$prefix" + while IFS= read -r symbol; do + printf ' { .name = "%s", .address = (void *)%s },\n' "$symbol" "$symbol" + done < "$symbols_file" + if [ -s "$alias_file" ]; then + while IFS=$'\t' read -r sql_symbol linked_symbol; do + [ -n "$sql_symbol" ] || continue + [ -n "$linked_symbol" ] || continue + printf ' { .name = "%s", .address = (void *)%s },\n' "$sql_symbol" "$linked_symbol" + done < "$alias_file" + fi + printf '};\n\n' + done + + printf 'static const OliphauntStaticExtension liboliphaunt_static_extensions[] = {\n' + for extension in ${mobile_static_extensions[@]+"${mobile_static_extensions[@]}"}; do + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + symbols_file="$out_dir/extensions/$stem/symbols.list" + alias_file="$out_dir/extensions/$stem/symbol-aliases.list" + init_symbol="NULL" + if module_has_c_symbol "$stem" "${prefix}__PG_init"; then + init_symbol="${prefix}__PG_init" + fi + symbols_expr="NULL" + symbol_count_expr="0" + if [ -s "$symbols_file" ] || [ -s "$alias_file" ]; then + symbols_expr="${prefix}_symbols" + symbol_count_expr="sizeof(${prefix}_symbols) / sizeof(${prefix}_symbols[0])" + fi + cat < "$mobile_static_registry_source" +} + +build_mobile_static_registry_object() { + [ "${#mobile_static_extensions[@]}" -gt 0 ] || return 0 + "${cc[@]}" $native_cflags \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -c "$mobile_static_registry_source" \ + -o "$mobile_static_registry_object" + mobile_static_objects+=("$mobile_static_registry_object") +} + +write_objects_response_file() { + ( + cd "$build_dir" + { + cat src/backend/*/objfiles.txt + printf '%s\n' "${jit_objects[@]}" + printf 'src/timezone/localtime.o src/timezone/pgtz.o src/timezone/strftime.o\n' + printf '%s\n' "${plpgsql_objects[@]}" + } | tr '[:space:]' '\n' | sed '/^$/d' | awk '!seen[$0]++' > "$objects_rsp" + ) +} + +link_liboliphaunt() { + local -a postgis_link_args + local link_arg + while IFS= read -r link_arg; do + [ -n "$link_arg" ] && postgis_link_args+=("$link_arg") + done < <(oliphaunt_postgis_extra_link_args) + ( + cd "$build_dir" + set +u + "${cc[@]}" -dynamiclib \ + -Wl,-install_name,@rpath/liboliphaunt.dylib \ + -o "$lib_out" \ + "${liboliphaunt_objects[@]}" \ + "${mobile_static_objects[@]}" \ + "${mobile_static_dependency_archives[@]}" \ + @"$objects_rsp" \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a \ + $icu_libs \ + "${postgis_link_args[@]}" \ + -lpthread + set -u + ) +} + +usage() { + cat >&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh [--check-current] +MSG +} + +parse_mobile_static_extensions + +if [ "${OLIPHAUNT_PRINT_DESIRED_HASH:-0}" = "1" ]; then + desired_hash + exit 0 +fi + +case "$script_mode" in + build) + prepare_source + build_icu + configure_source + if artifact_ready && (cd "$build_dir" && backend_objects_ready && support_libraries_ready && plpgsql_objects_ready && jit_objects_ready) && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "$lib_out" + exit 0 + fi + build_backend_objects + build_support_libraries + build_jit_objects + build_timezone_objects + build_plpgsql_objects + build_liboliphaunt_objects + build_mobile_static_dependencies + build_mobile_static_extension_objects + write_mobile_static_registry_source + build_mobile_static_registry_object + write_objects_response_file + link_liboliphaunt + artifact_ready + desired_hash > "$stamp" + echo "$lib_out" + ;; + --check-current) + if artifact_ready && (cd "$build_dir" && backend_objects_ready && support_libraries_ready && plpgsql_objects_ready && jit_objects_ready) && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "iOS simulator liboliphaunt dylib is current" + exit 0 + fi + echo "iOS simulator liboliphaunt dylib is missing or stale" >&2 + exit 1 + ;; + *) + usage + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh new file mode 100755 index 00000000..e30e89d1 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh @@ -0,0 +1,1448 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/icu.sh" + +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +pg_version="18.4" +pg_sha256="81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +pg_url="https://ftp.postgresql.org/pub/source/v${pg_version}/postgresql-${pg_version}.tar.bz2" +source_manifest="$repo_root/src/runtimes/liboliphaunt/native/postgres18/source.toml" +patch_dir="$repo_root/src/runtimes/liboliphaunt/native/patches/postgresql-${pg_version}" + +case "$(uname -s):$(uname -m)" in + Linux:x86_64|Linux:amd64) + target_id="linux-x64-gnu" + linux_host="x86_64-unknown-linux-gnu" + ;; + Linux:aarch64|Linux:arm64) + target_id="linux-arm64-gnu" + linux_host="aarch64-unknown-linux-gnu" + ;; + *) + echo "build-postgres18-linux.sh: unsupported Linux target $(uname -s)/$(uname -m)" >&2 + exit 2 + ;; +esac + +work_root="${OLIPHAUNT_LINUX_WORK_ROOT:-${OLIPHAUNT_WORK_ROOT:-$repo_root/target/liboliphaunt-pg18-$target_id}}" +source_cache="$work_root/source" +tarball="$source_cache/postgresql-${pg_version}.tar.bz2" +build_dir="$work_root/postgresql-${pg_version}" +install_dir="$work_root/install" +out_dir="$work_root/out" +embedded_modules_dir="$out_dir/modules" +objects_rsp="$out_dir/liboliphaunt-$target_id-objects.rsp" +lib_out="$out_dir/liboliphaunt.so" +stamp="$build_dir/.liboliphaunt-$target_id-build.sha256" +runtime_stamp="$install_dir/.oliphaunt-postgres-runtime.sha256" +extension_build_stamp="$out_dir/native-extension-artifacts.sha256" +postgis_dependency_log="$work_root/postgis-native-dependencies.log" +configure_log="$work_root/configure.log" +make_log="$work_root/make.log" +script_mode="${1:-build}" + +icu_source_dir="$(oliphaunt_icu_source_dir "$repo_root")" +icu_native_build_dir="$work_root/icu-native" +icu_build_dir="$work_root/icu-$target_id-build" +icu_prefix="$work_root/icu-$target_id" +icu_cflags="$(oliphaunt_icu_cflags "$icu_prefix")" +icu_static_libs="$(oliphaunt_icu_static_libs "$icu_prefix")" +icu_cpp_libs="-lstdc++" +icu_libs="$icu_static_libs $icu_cpp_libs" + +liboliphaunt_sources=( + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c" +) + +plpgsql_objects=( + src/pl/plpgsql/src/pl_comp.o + src/pl/plpgsql/src/pl_exec.o + src/pl/plpgsql/src/pl_funcs.o + src/pl/plpgsql/src/pl_gram.o + src/pl/plpgsql/src/pl_handler.o + src/pl/plpgsql/src/pl_scanner.o +) + +jit_objects=(src/backend/jit/jit.o) + +contrib_extensions=( + amcheck + auto_explain + bloom + btree_gin + btree_gist + citext + cube + dict_int + dict_xsyn + earthdistance + file_fdw + fuzzystrmatch + hstore + intarray + isn + lo + ltree + pageinspect + pg_buffercache + pg_freespacemap + pgcrypto + uuid-ossp + pg_surgery + pg_trgm + pg_visibility + pg_walinspect + seg + tablefunc + tcn + tsm_system_rows + tsm_system_time + unaccent +) + +external_extensions=( + pg_ivm + pg_hashids + pg_uuidv7 + pgtap + pgvector + pg_textsearch + postgis +) + +required_extension_controls=( + amcheck + bloom + btree_gin + btree_gist + citext + cube + dict_int + dict_xsyn + earthdistance + file_fdw + fuzzystrmatch + hstore + intarray + isn + lo + ltree + pageinspect + pg_buffercache + pg_freespacemap + pg_hashids + pg_ivm + pgcrypto + postgis + pg_surgery + pg_textsearch + pg_trgm + pg_uuidv7 + pg_visibility + pg_walinspect + pgtap + seg + tablefunc + tcn + tsm_system_rows + tsm_system_time + unaccent + uuid-ossp + vector +) + +required_extension_modules=( + _int + amcheck + auto_explain + bloom + btree_gin + btree_gist + citext + cube + dict_int + dict_xsyn + earthdistance + file_fdw + fuzzystrmatch + hstore + isn + lo + ltree + pageinspect + pg_buffercache + pg_freespacemap + pg_hashids + pg_ivm + pgcrypto + postgis-3 + pg_surgery + pg_textsearch + pg_trgm + pg_uuidv7 + pg_visibility + pg_walinspect + seg + tablefunc + tcn + tsm_system_rows + tsm_system_time + unaccent + uuid-ossp + vector +) + +native_extension_filter_fail() { + echo "build-postgres18-linux.sh: $*" >&2 + exit 1 +} + +native_extensions_include_postgis() { + local extension + for extension in "${external_extensions[@]}"; do + [ "$extension" != "postgis" ] || return 0 + done + return 1 +} + +native_extension_has_control_artifacts() { + case "$1" in + auto_explain) return 1 ;; + *) return 0 ;; + esac +} + +filter_native_extension_selection() { + local raw="${OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES:-${OLIPHAUNT_EXTENSION_SQL_NAMES:-}}" + [ -n "$raw" ] || return 0 + + local contrib_plan="$repo_root/src/extensions/generated/contrib-build.tsv" + local pgxs_plan="$repo_root/src/extensions/generated/pgxs-build.tsv" + local -a selected_contrib=() + local -a selected_external=() + local -a selected_controls=() + local -a selected_modules=() + local sql contrib_dir external_id module_file module_stem + + while IFS= read -r sql; do + sql="$(printf '%s' "$sql" | xargs)" + [ -n "$sql" ] || continue + if native_extension_has_control_artifacts "$sql"; then + selected_controls+=("$sql") + fi + + contrib_dir="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $3; found = 1; exit } END { exit found ? 0 : 1 }' "$contrib_plan" || true)" + if [ -n "$contrib_dir" ]; then + selected_contrib+=("$contrib_dir") + module_file="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $4; found = 1; exit } END { exit found ? 0 : 1 }' "$contrib_plan" || true)" + else + case "$sql" in + postgis) + selected_external+=(postgis) + module_file="postgis-3.so" + ;; + *) + external_id="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $1; found = 1; exit } END { exit found ? 0 : 1 }' "$pgxs_plan" || true)" + [ -n "$external_id" ] || native_extension_filter_fail "unknown native extension selection: $sql" + selected_external+=("$external_id") + module_file="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $4; found = 1; exit } END { exit found ? 0 : 1 }' "$pgxs_plan" || true)" + ;; + esac + fi + + if [ -n "$module_file" ] && [ "$module_file" != "-" ]; then + module_stem="${module_file%.*}" + selected_modules+=("$module_stem") + fi + done < <(printf '%s\n' "$raw" | tr ',' '\n') + + [ "${#selected_controls[@]}" -gt 0 ] || + [ "${#selected_modules[@]}" -gt 0 ] || + native_extension_filter_fail "OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES did not select any extensions" + contrib_extensions=("${selected_contrib[@]}") + external_extensions=("${selected_external[@]}") + required_extension_controls=("${selected_controls[@]}") + required_extension_modules=("${selected_modules[@]}") +} + +filter_native_extension_selection + +if [ "$script_mode" = "--print-required-extension-artifacts" ]; then + for extension in "${required_extension_controls[@]}"; do + printf 'control:%s\n' "$extension" + done + for module in "${required_extension_modules[@]}"; do + printf 'module:%s\n' "$module" + done + exit 0 +fi + +fail() { + echo "build-postgres18-linux.sh: $*" >&2 + exit 1 +} + +for cmd in cc c++ curl git make nm patch perl readelf rg shasum tar; do + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd" +done + +[ "$(uname -s)" = "Linux" ] || fail "Linux native build must run on Linux" +oliphaunt_icu_require_source "$icu_source_dir" + +native_cc="${OLIPHAUNT_CC:-cc}" +native_cxx="${OLIPHAUNT_CXX:-c++}" +cc=("$native_cc") +cxx=("$native_cxx") +ccache_mode="${OLIPHAUNT_CCACHE:-auto}" +if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then + ccache_bin="" + if [ "$ccache_mode" = "auto" ]; then + ccache_bin="$(command -v ccache || true)" + else + ccache_bin="$ccache_mode" + fi + if [ -n "$ccache_bin" ]; then + cc=("$ccache_bin" "${cc[@]}") + cxx=("$ccache_bin" "${cxx[@]}") + fi +fi +cc_string="${cc[*]}" +cxx_string="${cxx[*]}" +native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED" +postgres_embedded_copt="-g -fPIC -DOLIPHAUNT_EMBEDDED" +liboliphaunt_cflags="$native_cflags -DOLIPHAUNT_BUILTIN_PLPGSQL" +embedded_module_be_dllibs="-Wl,--no-as-needed -Wl,-z,defs -L$out_dir -Wl,-rpath,$out_dir -loliphaunt" +normal_module_be_dllibs="" +jobs="${OLIPHAUNT_JOBS:-$(nproc 2>/dev/null || echo 4)}" +portable_uuid_dir="$repo_root/src/runtimes/liboliphaunt/native/portable-uuid" +native_uuid_dependency_dir="$work_root/portable-uuid-native" +native_uuid_archive="$native_uuid_dependency_dir/lib/libuuid.a" + +patch_series() { + sed -n '/series = \[/,/\]/p' "$source_manifest" | + sed -n 's/.*"\([^"]*\.patch\)".*/\1/p' +} + +patch_series_hash() { + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + shasum -a 256 "$patch_dir/$patch_name" + done < <(patch_series) | shasum -a 256 | awk '{print $1}' +} + +desired_hash() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'pg_sha256=%s\n' "$pg_sha256" + printf 'target_id=%s\n' "$target_id" + printf 'linux_host=%s\n' "$linux_host" + printf 'cc=%s\n' "$cc_string" + printf 'cxx=%s\n' "$cxx_string" + printf 'icu_source=%s\n' "$(oliphaunt_icu_source_commit "$icu_source_dir")" + printf 'icu_script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'native_cflags=%s\n' "$native_cflags" + printf 'postgres_embedded_copt=%s\n' "$postgres_embedded_copt" + printf 'liboliphaunt_cflags=%s\n' "$liboliphaunt_cflags" + printf 'embedded_module_be_dllibs=%s\n' "$embedded_module_be_dllibs" + printf 'patch_series_hash=%s\n' "$(patch_series_hash)" + shasum -a 256 "$script_dir/$(basename "$0")" + printf 'liboliphaunt_sources=%s\n' "${liboliphaunt_sources[*]}" + shasum -a 256 "$source_manifest" + shasum -a 256 "${liboliphaunt_sources[@]}" + } | shasum -a 256 | awk '{print $1}' +} + +hash_extension_source_tree() { + local source_dir="$1" + [ -d "$source_dir" ] || return 0 + find "$source_dir" -type f \( \ + -name "CMakeLists.txt" -o \ + -name "configure" -o \ + -name "*.c" -o \ + -name "*.cc" -o \ + -name "*.cpp" -o \ + -name "*.h" -o \ + -name "*.hpp" -o \ + -name "*.sql" -o \ + -name "*.control" -o \ + -name "*.in" -o \ + -name "Makefile" -o \ + -name "*.mk" \ + \) -print | + LC_ALL=C sort | + while IFS= read -r file; do + shasum -a 256 "$file" + done +} + +extension_build_fingerprint() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'target_id=%s\n' "$target_id" + printf 'cc=%s\n' "$cc_string" + printf 'cxx=%s\n' "$cxx_string" + printf 'normal_be_dllibs=%s\n' "$normal_module_be_dllibs" + printf 'embedded_be_dllibs=%s\n' "$embedded_module_be_dllibs" + printf 'base_hash=%s\n' "$(desired_hash)" + shasum -a 256 "$script_dir/$(basename "$0")" + shasum -a 256 "$repo_root/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + shasum -a 256 "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h" + printf 'contrib_extensions=%s\n' "${contrib_extensions[*]}" + printf 'external_extensions=%s\n' "${external_extensions[*]}" + local extension dependency + for extension in "${contrib_extensions[@]}"; do + printf 'contrib:%s\n' "$extension" + hash_extension_source_tree "$build_dir/contrib/$extension" + done + for extension in "${external_extensions[@]}"; do + printf 'external:%s\n' "$extension" + hash_extension_source_tree "$repo_root/target/oliphaunt-sources/checkouts/$extension" + done + if native_extensions_include_postgis; then + for dependency in geos proj sqlite json-c libxml2; do + if [ -d "$repo_root/target/oliphaunt-sources/checkouts/$dependency" ]; then + printf 'postgis-dependency:%s\n' "$dependency" + hash_extension_source_tree "$repo_root/target/oliphaunt-sources/checkouts/$dependency" + fi + done + fi + hash_extension_source_tree "$portable_uuid_dir" + } | shasum -a 256 | awk '{print $1}' +} + +apply_patch_series() { + local patch_name + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + GIT_CEILING_DIRECTORIES="$work_root" git apply --recount --whitespace=nowarn "$patch_dir/$patch_name" >/dev/null + done < <(patch_series) +} + +patched_source_ready() { + grep -Fq 'OliphauntEmbeddedIO' "$build_dir/src/include/libpq/libpq-be.h" && + grep -Fq 'oliphaunt_embedded_main' "$build_dir/src/backend/tcop/postgres.c" && + grep -Fq 'oliphaunt_embedded' "$build_dir/meson_options.txt" && + grep -Fq 'OLIPHAUNT_EMBEDDED' "$build_dir/meson.build" +} + +prepare_source() { + mkdir -p "$source_cache" "$work_root" "$out_dir" + if [ ! -f "$tarball" ]; then + curl -L --fail --silent --show-error "$pg_url" -o "$tarball" + fi + ( + cd "$source_cache" + printf '%s %s\n' "$pg_sha256" "postgresql-${pg_version}.tar.bz2" | shasum -a 256 -c - + ) >&2 + + local wanted + wanted="$(desired_hash)" + if [ -d "$build_dir" ] && { [ ! -f "$stamp" ] || [ "$(cat "$stamp")" != "$wanted" ]; }; then + rm -rf "$build_dir" + fi + if [ ! -d "$build_dir" ]; then + tar -xjf "$tarball" -C "$work_root" + ( + cd "$build_dir" + git init -q + apply_patch_series + ) + fi + patched_source_ready || fail "PostgreSQL embedded patch verification failed" +} + +build_icu() { + oliphaunt_icu_build_target \ + "$icu_source_dir" \ + "$script_dir" \ + "$icu_native_build_dir" \ + "$icu_build_dir" \ + "$icu_prefix" \ + "$jobs" \ + "$target_id" \ + "$linux_host" \ + "$cc_string" \ + "$cxx_string" \ + "ar" \ + "ranlib" \ + "$native_cflags" \ + "$native_cflags -std=c++17" \ + "" +} + +configure_source() { + if [ ! -f "$build_dir/config.status" ]; then + ( + cd "$build_dir" + CPPFLAGS="$icu_cflags" \ + LDFLAGS="-L$icu_prefix/lib" \ + ICU_CFLAGS="$icu_cflags" \ + ICU_LIBS="$icu_libs" \ + CC="$cc_string" \ + CXX="$cxx_string" \ + ./configure \ + --prefix="$install_dir" \ + --without-readline \ + --with-icu \ + --without-llvm \ + --without-pam \ + --with-openssl=no \ + --without-zlib \ + --disable-nls + ) > "$configure_log" 2>&1 + fi +} + +runtime_installed() { + [ -x "$install_dir/bin/initdb" ] && + [ -x "$install_dir/bin/postgres" ] && + [ -f "$install_dir/share/postgresql/postgresql.conf.sample" ] && + [ -f "$runtime_stamp" ] && + [ "$(cat "$runtime_stamp")" = "$(desired_hash)" ] && + "$install_dir/bin/pg_config" --configure 2>/dev/null | rg -q -- "--with-icu" && + { [ "${OLIPHAUNT_BUILD_EXTENSIONS:-0}" != "0" ] || base_runtime_optional_extensions_absent; } +} + +explain_runtime_install_state() { + local wanted + wanted="$(desired_hash)" + for required in \ + "$install_dir/bin/initdb" \ + "$install_dir/bin/postgres" \ + "$install_dir/bin/pg_config" + do + if [ ! -x "$required" ]; then + echo "missing executable runtime file: $required" >&2 + fi + done + if [ ! -f "$install_dir/share/postgresql/postgresql.conf.sample" ]; then + echo "missing runtime file: $install_dir/share/postgresql/postgresql.conf.sample" >&2 + fi + if [ ! -f "$runtime_stamp" ]; then + echo "missing runtime stamp: $runtime_stamp" >&2 + elif [ "$(cat "$runtime_stamp")" != "$wanted" ]; then + echo "stale runtime stamp: $runtime_stamp" >&2 + fi + if [ -x "$install_dir/bin/pg_config" ] && + ! "$install_dir/bin/pg_config" --configure 2>/dev/null | rg -q -- "--with-icu"; then + echo "installed PostgreSQL runtime was not configured with ICU" >&2 + "$install_dir/bin/pg_config" --configure >&2 || true + fi +} + +install_runtime() { + runtime_installed && return 0 + ( + cd "$build_dir" + : > "$make_log" + make clean CC="$cc_string" >> "$make_log" 2>&1 || true + make -j"$jobs" CC="$cc_string" >> "$make_log" 2>&1 + make install CC="$cc_string" >> "$make_log" 2>&1 + ) + prune_base_runtime_optional_extensions + desired_hash > "$runtime_stamp" + if ! runtime_installed; then + explain_runtime_install_state + tail -120 "$make_log" >&2 || true + fail "PostgreSQL Linux runtime install is incomplete; see $make_log" + fi +} + +backend_objects_ready() { + for required in \ + src/backend/tcop/postgres.o \ + src/backend/libpq/be-secure.o \ + src/backend/libpq/pqcomm.o \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a + do + [ -f "$required" ] || return 1 + done + for objfile in src/backend/*/objfiles.txt; do + [ -s "$objfile" ] || return 1 + done +} + +explain_backend_object_state() { + local required objfile + for required in \ + src/backend/tcop/postgres.o \ + src/backend/libpq/be-secure.o \ + src/backend/libpq/pqcomm.o \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a + do + if [ ! -f "$required" ]; then + echo "missing backend object/library: $build_dir/$required" >&2 + fi + done + + for objfile in src/backend/*/objfiles.txt; do + if [ ! -s "$objfile" ]; then + echo "missing or empty backend object list: $build_dir/$objfile" >&2 + fi + done + + echo "backend objects were built, but required object files or object lists are incomplete" >&2 +} + +plpgsql_objects_ready() { + local object + for object in "${plpgsql_objects[@]}"; do + [ -f "$object" ] || return 1 + done + nm -g src/pl/plpgsql/src/pl_handler.o | rg -q "plpgsql_call_handler" || return 1 +} + +jit_objects_ready() { + local object + for object in "${jit_objects[@]}"; do + [ -f "$object" ] || return 1 + done + nm -g src/backend/jit/jit.o | rg -q "pg_jit_available" || return 1 +} + +build_backend_objects() { + ( + cd "$build_dir" + : > "$make_log" + make -C src/backend clean >> "$make_log" 2>&1 || true + make -C src/common clean >> "$make_log" 2>&1 || true + make -C src/port clean >> "$make_log" 2>&1 || true + rm -f src/include/nodes/header-stamp src/include/utils/header-stamp + make -C src/backend generated-headers CC="$cc_string" >> "$make_log" 2>&1 + set +e + make -j"$jobs" -C src/backend \ + CC="$cc_string" \ + CUSTOM_COPT="$postgres_embedded_copt" \ + postgres >> "$make_log" 2>&1 + local make_status=$? + set -e + make -C src/common CC="$cc_string" CUSTOM_COPT="$postgres_embedded_copt" libpgcommon_srv.a >> "$make_log" 2>&1 + make -C src/port CC="$cc_string" CUSTOM_COPT="$postgres_embedded_copt" libpgport_srv.a >> "$make_log" 2>&1 + backend_objects_ready || { + explain_backend_object_state + tail -120 "$make_log" >&2 || true + fail "PostgreSQL Linux backend objects are incomplete" + } + if [ "$make_status" -ne 0 ]; then + echo "PostgreSQL $target_id executable link failed after objects were produced; continuing with shared library link" >&2 + fi + ) +} + +build_timezone_objects() { + ( + cd "$build_dir" + make -C src/timezone clean >> "$make_log" 2>&1 || true + make -C src/timezone CC="$cc_string" CUSTOM_COPT="$postgres_embedded_copt" localtime.o pgtz.o strftime.o >> "$make_log" 2>&1 + ) +} + +build_plpgsql_objects() { + ( + cd "$build_dir" + if plpgsql_objects_ready; then + echo "reusing PostgreSQL $target_id PL/pgSQL objects" >&2 + return + fi + make -C src/pl/plpgsql/src clean >> "$make_log" 2>&1 + make -C src/pl/plpgsql/src \ + CC="$cc_string" \ + CUSTOM_COPT="$postgres_embedded_copt" \ + pl_comp.o pl_exec.o pl_funcs.o pl_gram.o pl_handler.o pl_scanner.o >> "$make_log" 2>&1 + plpgsql_objects_ready || { + tail -120 "$make_log" >&2 || true + fail "PostgreSQL Linux PL/pgSQL objects are incomplete" + } + ) +} + +module_depends_on_liboliphaunt() { + local module="$1" + [ -f "$module" ] || return 1 + readelf -d "$module" 2>/dev/null | + rg -q 'Shared library: \[liboliphaunt\.so\]' +} + +native_extension_artifacts_ready() { + local extension module + for extension in "${required_extension_controls[@]}"; do + [ -f "$install_dir/share/postgresql/extension/$extension.control" ] || return 1 + compgen -G "$install_dir/share/postgresql/extension/$extension--*.sql" >/dev/null || return 1 + done + + for module in "${required_extension_modules[@]}"; do + [ -f "$install_dir/lib/postgresql/$module.so" ] || return 1 + module_depends_on_liboliphaunt "$install_dir/lib/postgresql/$module.so" && return 1 + [ -f "$embedded_modules_dir/$module.so" ] || return 1 + module_depends_on_liboliphaunt "$embedded_modules_dir/$module.so" || return 1 + done + if native_extensions_include_postgis; then + [ -f "$install_dir/share/postgresql/proj/proj.db" ] || return 1 + fi +} + +base_runtime_optional_extensions_absent() { + local extension module + for extension in "${required_extension_controls[@]}"; do + [ ! -f "$install_dir/share/postgresql/extension/$extension.control" ] || return 1 + done + for module in "${required_extension_modules[@]}"; do + [ ! -f "$install_dir/lib/postgresql/$module.so" ] || return 1 + done + [ ! -d "$install_dir/share/postgresql/contrib" ] || return 1 + [ ! -d "$install_dir/share/postgresql/proj" ] || return 1 +} + +prune_base_runtime_optional_extensions() { + [ "${OLIPHAUNT_BUILD_EXTENSIONS:-0}" = "0" ] || return 0 + + local extension_dir="$install_dir/share/postgresql/extension" + local module_dir="$install_dir/lib/postgresql" + local extension module + if [ -d "$extension_dir" ]; then + for extension in "${required_extension_controls[@]}"; do + rm -f "$extension_dir/$extension.control" + rm -f "$extension_dir/$extension--"*.sql + done + rm -f "$extension_dir/postgis"*.sql "$extension_dir/rtpostgis"*.sql + rm -f "$extension_dir/uninstall_postgis.sql" "$extension_dir/uninstall_legacy.sql" + rm -f "$extension_dir/pgtap-"*.sql "$extension_dir/uninstall_pgtap.sql" + fi + if [ -d "$module_dir" ]; then + for module in "${required_extension_modules[@]}"; do + rm -f "$module_dir/$module.so" + done + fi + rm -rf "$install_dir/share/postgresql/contrib" "$install_dir/share/postgresql/proj" +} + +native_extension_artifacts_current() { + [ -d "$build_dir" ] || return 1 + [ -x "$install_dir/bin/postgres" ] || return 1 + [ -f "$lib_out" ] || return 1 + [ -f "$extension_build_stamp" ] || return 1 + local wanted + wanted="$(extension_build_fingerprint)" || return 1 + [ "$(cat "$extension_build_stamp")" = "$wanted" ] || return 1 + native_extension_artifacts_ready || return 1 +} + +embedded_plpgsql_module_ready() { + module_depends_on_liboliphaunt "$embedded_modules_dir/plpgsql.so" +} + +build_embedded_plpgsql_module() { + ( + cd "$build_dir" + if embedded_plpgsql_module_ready; then + echo "reusing PostgreSQL $target_id embedded PL/pgSQL module" >&2 + return + fi + make -C src/pl/plpgsql/src clean >> "$make_log" 2>&1 + make -C src/pl/plpgsql/src \ + CC="$cc_string" \ + CUSTOM_COPT="$postgres_embedded_copt" \ + BE_DLLLIBS="$embedded_module_be_dllibs" \ + all >> "$make_log" 2>&1 + mkdir -p "$embedded_modules_dir" + cp -p src/pl/plpgsql/src/plpgsql.so "$embedded_modules_dir/plpgsql.so" + embedded_plpgsql_module_ready || { + tail -120 "$make_log" >&2 || true + fail "PostgreSQL Linux embedded PL/pgSQL module is not linked against liboliphaunt" + } + ) +} + +copy_embedded_modules_from_dir() { + local source_dir="$1" + mkdir -p "$embedded_modules_dir" + while IFS= read -r module; do + [ -n "$module" ] || continue + cp -p "$module" "$embedded_modules_dir/$(basename "$module")" + done < <(find "$source_dir" -maxdepth 1 -type f -name "*.so" -print) +} + +native_openssl_prefix() { + local candidate + for candidate in "${OLIPHAUNT_OPENSSL_PREFIX:-}" /usr/local /usr; do + [ -n "$candidate" ] || continue + if [ -f "$candidate/include/openssl/evp.h" ] && + { [ -f "$candidate/lib/libcrypto.a" ] || [ -f "$candidate/lib64/libcrypto.a" ] || [ -f "$candidate/lib/libcrypto.so" ] || [ -f "$candidate/lib64/libcrypto.so" ]; }; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +configure_pgcrypto_make_args() { + pgcrypto_make_args=() + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists openssl; then + pgcrypto_make_args=( + "PG_CPPFLAGS=$(pkg-config --cflags openssl)" + "PG_LDFLAGS=$(pkg-config --libs openssl)" + ) + return + fi + + local openssl_prefix lib_dir + openssl_prefix="$(native_openssl_prefix)" || fail "pgcrypto requires OpenSSL headers and libcrypto; install openssl-devel/libssl-dev or set OLIPHAUNT_OPENSSL_PREFIX" + lib_dir="$openssl_prefix/lib" + [ -d "$openssl_prefix/lib64" ] && lib_dir="$openssl_prefix/lib64" + if [ -f "$lib_dir/libcrypto.a" ]; then + pgcrypto_make_args=("PG_CPPFLAGS=-I$openssl_prefix/include" "PG_LDFLAGS=$lib_dir/libcrypto.a") + else + pgcrypto_make_args=("PG_CPPFLAGS=-I$openssl_prefix/include" "PG_LDFLAGS=-L$lib_dir" "LIBS=-lcrypto") + fi +} + +build_native_uuid_dependency() { + local object="$native_uuid_dependency_dir/portable_uuid.o" + if [ -f "$native_uuid_archive" ] && [ -d "$native_uuid_dependency_dir/include/uuid" ]; then + return 0 + fi + [ -f "$portable_uuid_dir/portable_uuid.c" ] || fail "portable UUID source is missing: $portable_uuid_dir" + rm -rf "$native_uuid_dependency_dir" + mkdir -p "$native_uuid_dependency_dir/include" "$native_uuid_dependency_dir/lib" + cp -R "$portable_uuid_dir/include/uuid" "$native_uuid_dependency_dir/include/" + "${cc[@]}" $native_cflags \ + -I"$portable_uuid_dir/include" \ + -I"$build_dir/src/include" \ + -I"$build_dir/src/include/port" \ + -c "$portable_uuid_dir/portable_uuid.c" \ + -o "$object" + ar crs "$native_uuid_archive" "$object" + ranlib "$native_uuid_archive" + [ -s "$native_uuid_archive" ] || fail "portable UUID native build did not produce $native_uuid_archive" +} + +build_contrib_extension() { + local extension="$1" + local -a extra_make_args=() + local -a embedded_extra_make_args=() + local embedded_pg_ldflags="$embedded_module_be_dllibs" + local arg + if [ "$extension" = "pgcrypto" ]; then + configure_pgcrypto_make_args + extra_make_args=("${pgcrypto_make_args[@]}") + elif [ "$extension" = "uuid-ossp" ]; then + build_native_uuid_dependency + extra_make_args=( + "PG_CPPFLAGS=-I$portable_uuid_dir/include -DHAVE_UUID_E2FS=1 -DHAVE_UUID_UUID_H=1" + "UUID_LIBS=$native_uuid_archive" + ) + fi + for arg in "${extra_make_args[@]}"; do + case "$arg" in + PG_LDFLAGS=*) + embedded_pg_ldflags="${arg#PG_LDFLAGS=} $embedded_pg_ldflags" + ;; + *) + embedded_extra_make_args+=("$arg") + ;; + esac + done + embedded_extra_make_args+=("PG_LDFLAGS=$embedded_pg_ldflags") + + ( + cd "$build_dir" + make -C "contrib/$extension" clean >> "$make_log" 2>&1 || true + make -C "contrib/$extension" \ + CC="$cc_string" \ + BE_DLLLIBS="$normal_module_be_dllibs" \ + "${extra_make_args[@]}" \ + install >> "$make_log" 2>&1 + make -C "contrib/$extension" clean >> "$make_log" 2>&1 || true + make -C "contrib/$extension" \ + CC="$cc_string" \ + "${embedded_extra_make_args[@]}" \ + all >> "$make_log" 2>&1 + copy_embedded_modules_from_dir "contrib/$extension" + ) +} + +pgxs_extension_link_args() { + local extension="$1" + local be_dllibs="$2" + [ -z "$be_dllibs" ] || printf '%s\n' "PG_LDFLAGS=$be_dllibs" + case "$extension" in + pg_textsearch|pgvector|vector) + printf '%s\n' "SHLIB_LINK=-lm" + ;; + *) + ;; + esac +} + +pgxs_extension_source_rel() { + local extension="$1" + local pgxs_plan="$repo_root/src/extensions/generated/pgxs-build.tsv" + awk -F '\t' -v extension="$extension" ' + NR > 1 && ($1 == extension || $3 == "target/oliphaunt-sources/checkouts/" extension) { + print $3 + found = 1 + exit + } + END { exit found ? 0 : 1 } + ' "$pgxs_plan" +} + +build_pgxs_extension() { + local extension="$1" + local source_rel + source_rel="$(pgxs_extension_source_rel "$extension" || true)" + [ -n "$source_rel" ] || native_extension_filter_fail "unknown PGXS extension source mapping: $extension" + local checkout="$repo_root/$source_rel" + local build_checkout="$work_root/external-$extension" + local -a normal_link_args=() + local -a embedded_link_args=() + while IFS= read -r arg; do + [ -n "$arg" ] || continue + normal_link_args+=("$arg") + done < <(pgxs_extension_link_args "$extension" "$normal_module_be_dllibs") + while IFS= read -r arg; do + [ -n "$arg" ] || continue + embedded_link_args+=("$arg") + done < <(pgxs_extension_link_args "$extension" "$embedded_module_be_dllibs") + [ -d "$checkout" ] || fail "native extension checkout is missing: $checkout" + rm -rf "$build_checkout" + mkdir -p "$(dirname "$build_checkout")" + cp -a "$checkout/." "$build_checkout/" + rm -rf "$build_checkout/.git" + make -C "$build_checkout" PG_CONFIG="$install_dir/bin/pg_config" clean >> "$make_log" 2>&1 || true + make -C "$build_checkout" \ + PG_CONFIG="$install_dir/bin/pg_config" \ + CC="$cc_string" \ + OPTFLAGS="" \ + "${normal_link_args[@]}" \ + install >> "$make_log" 2>&1 + make -C "$build_checkout" PG_CONFIG="$install_dir/bin/pg_config" clean >> "$make_log" 2>&1 || true + make -C "$build_checkout" \ + PG_CONFIG="$install_dir/bin/pg_config" \ + CC="$cc_string" \ + OPTFLAGS="" \ + "${embedded_link_args[@]}" \ + all >> "$make_log" 2>&1 + copy_embedded_modules_from_dir "$build_checkout" +} + +native_postgis_dependency_root="${OLIPHAUNT_NATIVE_POSTGIS_DEPENDENCY_ROOT:-$work_root/postgis-native-dependencies}" +postgis_configure_args=() +postgis_configure_env=() +postgis_make_args=() +postgis_proj_prefix="" + +native_postgis_require_tools() { + local cmd + for cmd in cmake rsync sqlite3; do + command -v "$cmd" >/dev/null 2>&1 || fail "PostGIS native dependency build missing required command: $cmd" + done +} + +native_postgis_cmake_install() { + local source_dir="$1" + local build_root="$2" + local dependency_dir="$3" + shift 3 + cmake -S "$source_dir" -B "$build_root" \ + -DCMAKE_INSTALL_PREFIX="$dependency_dir" \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + "$@" >> "$postgis_dependency_log" 2>&1 + cmake --build "$build_root" --target install -- -j"$jobs" >> "$postgis_dependency_log" 2>&1 +} + +build_native_postgis_jsonc_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/json-c" + local dependency_dir="$native_postgis_dependency_root/json-c" + local build_root="$work_root/json-c-native-build" + local archive="$dependency_dir/lib/libjson-c.a" + [ -f "$archive" ] && [ -d "$dependency_dir/include/json-c" ] && return 0 + [ -f "$source_dir/CMakeLists.txt" ] || fail "missing JSON-C checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_STATIC_LIBS=ON \ + -DBUILD_APPS=OFF \ + -DBUILD_TESTING=OFF \ + -DDISABLE_WERROR=ON + [ -f "$archive" ] || fail "JSON-C build did not produce $archive" +} + +build_native_postgis_sqlite_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/sqlite" + local dependency_dir="$native_postgis_dependency_root/sqlite" + local build_root="$work_root/sqlite-native-build" + local archive="$dependency_dir/lib/libsqlite3.a" + [ -f "$archive" ] && [ -f "$dependency_dir/include/sqlite3.h" ] && return 0 + [ -x "$source_dir/configure" ] || fail "missing SQLite checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + mkdir -p "$build_root" "$dependency_dir/include" "$dependency_dir/lib" + rsync -a --delete --exclude .git "$source_dir/" "$build_root/" + ( + cd "$build_root" + CC="$native_cc" CFLAGS="-O2 -g -fPIC" ./configure \ + --disable-shared \ + --enable-static \ + --prefix="$dependency_dir" >> "$postgis_dependency_log" 2>&1 + make -j"$jobs" sqlite3.c >> "$postgis_dependency_log" 2>&1 + "$native_cc" -O2 -g -fPIC \ + -DSQLITE_THREADSAFE=0 \ + -DSQLITE_OMIT_LOAD_EXTENSION \ + -c sqlite3.c \ + -o sqlite3.o >> "$postgis_dependency_log" 2>&1 + ar crs "$archive" sqlite3.o >> "$postgis_dependency_log" 2>&1 + ranlib "$archive" >> "$postgis_dependency_log" 2>&1 + cp -p sqlite3.h sqlite3ext.h "$dependency_dir/include/" + ) + [ -f "$archive" ] || fail "SQLite build did not produce $archive" +} + +build_native_postgis_geos_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/geos" + local dependency_dir="$native_postgis_dependency_root/geos" + local build_root="$work_root/geos-native-build" + [ -f "$dependency_dir/lib/libgeos_c.a" ] && [ -f "$dependency_dir/lib/libgeos.a" ] && [ -f "$dependency_dir/include/geos_c.h" ] && return 0 + [ -f "$source_dir/CMakeLists.txt" ] || fail "missing GEOS checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_TESTING=OFF \ + -DBUILD_BENCHMARKS=OFF \ + -DBUILD_GEOSOP=OFF \ + -DGEOS_BUILD_DEVELOPER=OFF + [ -f "$dependency_dir/lib/libgeos_c.a" ] || fail "GEOS build did not produce libgeos_c.a" + [ -f "$dependency_dir/lib/libgeos.a" ] || fail "GEOS build did not produce libgeos.a" +} + +build_native_postgis_libxml2_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/libxml2" + local dependency_dir="$native_postgis_dependency_root/libxml2" + local build_root="$work_root/libxml2-native-build" + local archive="$dependency_dir/lib/libxml2.a" + [ -f "$archive" ] && [ -x "$dependency_dir/bin/xml2-config" ] && return 0 + [ -f "$source_dir/CMakeLists.txt" ] || fail "missing libxml2 checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + -DLIBXML2_WITH_PROGRAMS=OFF \ + -DLIBXML2_WITH_TESTS=OFF \ + -DLIBXML2_WITH_PYTHON=OFF \ + -DLIBXML2_WITH_THREADS=OFF \ + -DLIBXML2_WITH_MODULES=OFF \ + -DLIBXML2_WITH_ICONV=OFF \ + -DLIBXML2_WITH_ZLIB=OFF \ + -DLIBXML2_WITH_LZMA=OFF \ + -DLIBXML2_WITH_HTTP=OFF + [ -f "$archive" ] || fail "libxml2 build did not produce $archive" +} + +build_native_postgis_proj_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/proj" + local dependency_dir="$native_postgis_dependency_root/proj" + local sqlite_dir="$native_postgis_dependency_root/sqlite" + local build_root="$work_root/proj-native-build" + local archive="$dependency_dir/lib/libproj.a" + [ -f "$archive" ] && [ -f "$dependency_dir/share/proj/proj.db" ] && return 0 + [ -f "$source_dir/CMakeLists.txt" ] || fail "missing PROJ checkout: $source_dir" + [ -f "$sqlite_dir/lib/libsqlite3.a" ] || fail "PROJ dependency requires SQLite archive first" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + "-DSQLite3_INCLUDE_DIR=$sqlite_dir/include" \ + "-DSQLite3_LIBRARY=$sqlite_dir/lib/libsqlite3.a" \ + "-DEXE_SQLITE3=$(command -v sqlite3)" \ + -DENABLE_TIFF=OFF \ + -DENABLE_CURL=OFF \ + -DENABLE_EMSCRIPTEN_FETCH=OFF \ + -DHAVE_LIBDL=OFF \ + -DBUILD_APPS=OFF \ + -DBUILD_TESTING=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DEMBED_RESOURCE_FILES=ON \ + -DUSE_ONLY_EMBEDDED_RESOURCE_FILES=ON + mkdir -p "$dependency_dir/share/proj" + if [ -f "$build_root/data/proj.db" ]; then + cp -p "$build_root/data/proj.db" "$dependency_dir/share/proj/proj.db" + fi + [ -f "$archive" ] || fail "PROJ build did not produce $archive" + [ -f "$dependency_dir/share/proj/proj.db" ] || fail "PROJ build did not produce proj.db" +} + +build_native_postgis_dependencies() { + native_postgis_require_tools + mkdir -p "$(dirname "$postgis_dependency_log")" + : > "$postgis_dependency_log" + build_native_postgis_jsonc_dependency + build_native_postgis_sqlite_dependency + build_native_postgis_geos_dependency + build_native_postgis_libxml2_dependency + build_native_postgis_proj_dependency +} + +native_postgis_geos_config_script() { + local path="$1" + cat > "$path" < "$path" <> "$make_log" 2>&1 + fi + ./configure \ + --prefix="$install_dir" \ + --with-pgconfig="$install_dir/bin/pg_config" \ + "${postgis_configure_args[@]}" \ + --without-protobuf \ + --without-raster \ + --without-topology \ + --without-sfcgal \ + --without-address-standardizer \ + --without-tiger \ + --disable-nls >> "$make_log" 2>&1 + patch_postgis_generated_makefiles "$postgis_build_dir" + make postgis_revision.h >> "$make_log" 2>&1 + make clean >> "$make_log" 2>&1 || true + make postgis_revision.h >> "$make_log" 2>&1 + make -C doc CC="$native_cc" "${postgis_make_args[@]}" comments-install >> "$make_log" 2>&1 + make -j"$jobs" -C postgis CC="$native_cc" "${postgis_make_args[@]}" install >> "$make_log" 2>&1 + make -j1 -C extensions CC="$native_cc" "${postgis_make_args[@]}" all >> "$make_log" 2>&1 + make -j1 -C extensions CC="$native_cc" "${postgis_make_args[@]}" install >> "$make_log" 2>&1 + make -C postgis clean >> "$make_log" 2>&1 || true + make postgis_revision.h >> "$make_log" 2>&1 + make -j"$jobs" -C postgis CC="$native_cc" BE_DLLLIBS="$embedded_module_be_dllibs" "${postgis_make_args[@]}" all >> "$make_log" 2>&1 + ) + copy_embedded_postgis_module "$postgis_build_dir/postgis" + stage_postgis_data_files "$postgis_build_dir" +} + +build_native_extension_artifacts() { + [ "${OLIPHAUNT_BUILD_EXTENSIONS:-0}" != "0" ] || return 0 + local wanted extension + wanted="$(extension_build_fingerprint)" + if [ "${OLIPHAUNT_FORCE_EXTENSION_REBUILD:-0}" != "1" ] && + [ -f "$extension_build_stamp" ] && + [ "$(cat "$extension_build_stamp")" = "$wanted" ] && + native_extension_artifacts_ready; then + echo "reusing Linux $target_id native extension artifacts" + return 0 + fi + + rm -f "$extension_build_stamp" + mkdir -p "$embedded_modules_dir" + for extension in "${contrib_extensions[@]}"; do + build_contrib_extension "$extension" + done + for extension in "${external_extensions[@]}"; do + if [ "$extension" = "postgis" ]; then + build_postgis_extension + else + build_pgxs_extension "$extension" + fi + done + native_extension_artifacts_ready || { + tail -160 "$make_log" >&2 || true + fail "Linux $target_id native extension build did not produce required artifacts" + } + extension_build_fingerprint > "$extension_build_stamp" +} + +build_jit_objects() { + ( + cd "$build_dir" + if jit_objects_ready; then + echo "reusing PostgreSQL $target_id JIT stub objects" >&2 + return + fi + make -C src/backend/jit CC="$cc_string" CUSTOM_COPT="$postgres_embedded_copt" jit.o >> "$make_log" 2>&1 + jit_objects_ready || { + tail -120 "$make_log" >&2 || true + fail "PostgreSQL Linux JIT stub objects are incomplete" + } + ) +} + +build_liboliphaunt_objects() { + local source object + liboliphaunt_objects=() + for source in "${liboliphaunt_sources[@]}"; do + object="$out_dir/$(basename "${source%.c}").o" + liboliphaunt_objects+=("$object") + "${cc[@]}" $liboliphaunt_cflags \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -I"$repo_root/src/runtimes/liboliphaunt/native/src" \ + -c "$source" \ + -o "$object" + done +} + +write_objects_response_file() { + ( + cd "$build_dir" + { + cat src/backend/*/objfiles.txt + printf '%s\n' "${jit_objects[@]}" + printf 'src/timezone/localtime.o src/timezone/pgtz.o src/timezone/strftime.o\n' + printf '%s\n' "${plpgsql_objects[@]}" + } | tr '[:space:]' '\n' | sed '/^$/d' | awk '!seen[$0]++' > "$objects_rsp" + ) +} + +link_liboliphaunt() { + ( + cd "$build_dir" + "${cc[@]}" -shared \ + -Wl,-soname,liboliphaunt.so \ + -Wl,-z,defs \ + -o "$lib_out" \ + "${liboliphaunt_objects[@]}" \ + @"$objects_rsp" \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a \ + $icu_libs \ + -lpthread \ + -lm \ + -ldl + ) +} + +artifact_ready() { + [ -f "$lib_out" ] || return 1 + embedded_plpgsql_module_ready || return 1 + oliphaunt_icu_artifacts_ready "$icu_prefix" || return 1 + local dynamic_symbols linked_symbols + dynamic_symbols="$(nm -D --defined-only "$lib_out" 2>/dev/null || true)" + linked_symbols="$(nm --defined-only "$lib_out" 2>/dev/null || true)" + oliphaunt_icu_linked_symbols_ready "$linked_symbols" || return 1 + local symbol + for symbol in \ + oliphaunt_init \ + oliphaunt_exec_protocol \ + oliphaunt_exec_simple_query \ + oliphaunt_exec_protocol_stream \ + oliphaunt_backup \ + oliphaunt_backup_ex \ + oliphaunt_restore \ + oliphaunt_cancel \ + oliphaunt_detach \ + oliphaunt_close \ + oliphaunt_register_static_extensions \ + oliphaunt_last_error \ + oliphaunt_version \ + oliphaunt_capabilities \ + oliphaunt_free_response + do + case "$dynamic_symbols" in *" $symbol"*) ;; *) return 1 ;; esac + done +} + +usage() { + cat >&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh [--runtime-only|--print-required-extension-artifacts|--check-current|--check-extension-artifacts-current] +MSG +} + +case "$script_mode" in + build) + prepare_source + build_icu + configure_source + install_runtime + if artifact_ready && + (cd "$build_dir" && backend_objects_ready && plpgsql_objects_ready && jit_objects_ready) && + [ -f "$stamp" ] && + [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "$lib_out" + exit 0 + fi + build_backend_objects + build_jit_objects + build_timezone_objects + build_plpgsql_objects + build_liboliphaunt_objects + write_objects_response_file + link_liboliphaunt + build_embedded_plpgsql_module + build_native_extension_artifacts + artifact_ready || fail "Linux liboliphaunt shared library did not pass export checks" + desired_hash > "$stamp" + echo "$lib_out" + ;; + --runtime-only) + prepare_source + build_icu + configure_source + install_runtime + echo "$install_dir" + ;; + --check-current) + if artifact_ready && + (cd "$build_dir" && backend_objects_ready && plpgsql_objects_ready && jit_objects_ready) && + runtime_installed && + [ -f "$stamp" ] && + [ "$(cat "$stamp")" = "$(desired_hash)" ]; then + echo "Linux $target_id liboliphaunt shared library is current" + exit 0 + fi + echo "Linux $target_id liboliphaunt shared library is missing or stale" >&2 + exit 1 + ;; + --check-extension-artifacts-current) + if native_extension_artifacts_current; then + echo "Linux $target_id native extension artifacts are current" + exit 0 + fi + echo "Linux $target_id native extension artifacts are missing or stale" >&2 + exit 1 + ;; + *) + usage + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh b/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh new file mode 100755 index 00000000..e721446a --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh @@ -0,0 +1,1698 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/icu.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +pg_version="18.4" +pg_sha256="81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +pg_url="https://ftp.postgresql.org/pub/source/v${pg_version}/postgresql-${pg_version}.tar.bz2" +source_manifest="$repo_root/src/runtimes/liboliphaunt/native/postgres18/source.toml" +patch_dir="$repo_root/src/runtimes/liboliphaunt/native/patches/postgresql-${pg_version}" +work_root="${OLIPHAUNT_WORK_ROOT:-$repo_root/target/liboliphaunt-pg18}" +source_cache="$work_root/source" +tarball="$source_cache/postgresql-${pg_version}.tar.bz2" +build_dir="$work_root/postgresql-${pg_version}" +install_dir="$work_root/install" +out_dir="$work_root/out" +embedded_modules_dir="$out_dir/modules" +postgis_dependency_log="$work_root/postgis-native-dependencies.log" +liboliphaunt_build_stamp="$out_dir/liboliphaunt.dylib.inputs.sha256" +extension_build_stamp="$out_dir/native-extension-artifacts.sha256" +postgres_runtime_stamp="$install_dir/.oliphaunt-postgres-runtime.sha256" +liboliphaunt_sources=( + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c" + "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c" +) +liboliphaunt_objects=() +lib_out="$out_dir/liboliphaunt.dylib" +objects_rsp="$out_dir/liboliphaunt_objects.rsp" +build_stamp="$build_dir/.liboliphaunt-build.sha256" +script_mode="${1:-build}" +icu_source_dir="$(oliphaunt_icu_source_dir "$repo_root")" +icu_native_build_dir="$work_root/icu-native" +icu_build_dir="$work_root/icu-macos-build" +icu_prefix="$work_root/icu-macos" +icu_cflags="$(oliphaunt_icu_cflags "$icu_prefix")" +icu_static_libs="$(oliphaunt_icu_static_libs "$icu_prefix")" +icu_cpp_libs="-lc++" +icu_libs="$icu_static_libs $icu_cpp_libs" + +contrib_extensions=( + amcheck + auto_explain + bloom + btree_gin + btree_gist + citext + cube + dict_int + dict_xsyn + earthdistance + file_fdw + fuzzystrmatch + hstore + intarray + isn + lo + ltree + pageinspect + pg_buffercache + pg_freespacemap + pgcrypto + uuid-ossp + pg_surgery + pg_trgm + pg_visibility + pg_walinspect + seg + tablefunc + tcn + tsm_system_rows + tsm_system_time + unaccent +) + +external_extensions=( + pg_ivm + pg_hashids + pg_uuidv7 + pgtap + pgvector + pg_textsearch + postgis +) + +required_extension_controls=( + amcheck + bloom + btree_gin + btree_gist + citext + cube + dict_int + dict_xsyn + earthdistance + file_fdw + fuzzystrmatch + hstore + intarray + isn + lo + ltree + pageinspect + pg_buffercache + pg_freespacemap + pg_hashids + pg_ivm + pgcrypto + postgis + pg_surgery + pg_textsearch + pg_trgm + pg_uuidv7 + pg_visibility + pg_walinspect + pgtap + seg + tablefunc + tcn + tsm_system_rows + tsm_system_time + unaccent + uuid-ossp + vector +) + +required_extension_modules=( + _int + amcheck + auto_explain + bloom + btree_gin + btree_gist + citext + cube + dict_int + dict_xsyn + earthdistance + file_fdw + fuzzystrmatch + hstore + isn + lo + ltree + pageinspect + pg_buffercache + pg_freespacemap + pg_hashids + pg_ivm + pgcrypto + postgis-3 + pg_surgery + pg_textsearch + pg_trgm + pg_uuidv7 + pg_visibility + pg_walinspect + seg + tablefunc + tcn + tsm_system_rows + tsm_system_time + unaccent + uuid-ossp + vector +) + +native_extension_filter_fail() { + echo "build-postgres18-macos.sh: $*" >&2 + exit 1 +} + +native_extensions_include_postgis() { + local extension + for extension in "${external_extensions[@]}"; do + [ "$extension" != "postgis" ] || return 0 + done + return 1 +} + +native_extension_has_control_artifacts() { + case "$1" in + auto_explain) return 1 ;; + *) return 0 ;; + esac +} + +filter_native_extension_selection() { + local raw="${OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES:-${OLIPHAUNT_EXTENSION_SQL_NAMES:-}}" + [ -n "$raw" ] || return 0 + + local contrib_plan="$repo_root/src/extensions/generated/contrib-build.tsv" + local pgxs_plan="$repo_root/src/extensions/generated/pgxs-build.tsv" + local -a selected_contrib=() + local -a selected_external=() + local -a selected_controls=() + local -a selected_modules=() + local sql contrib_dir external_id module_file module_stem + + while IFS= read -r sql; do + sql="$(printf '%s' "$sql" | xargs)" + [ -n "$sql" ] || continue + if native_extension_has_control_artifacts "$sql"; then + selected_controls+=("$sql") + fi + + contrib_dir="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $3; found = 1; exit } END { exit found ? 0 : 1 }' "$contrib_plan" || true)" + if [ -n "$contrib_dir" ]; then + selected_contrib+=("$contrib_dir") + module_file="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $4; found = 1; exit } END { exit found ? 0 : 1 }' "$contrib_plan" || true)" + else + case "$sql" in + postgis) + selected_external+=(postgis) + module_file="postgis-3.so" + ;; + *) + external_id="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $1; found = 1; exit } END { exit found ? 0 : 1 }' "$pgxs_plan" || true)" + [ -n "$external_id" ] || native_extension_filter_fail "unknown native extension selection: $sql" + selected_external+=("$external_id") + module_file="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $4; found = 1; exit } END { exit found ? 0 : 1 }' "$pgxs_plan" || true)" + ;; + esac + fi + + if [ -n "$module_file" ] && [ "$module_file" != "-" ]; then + module_stem="${module_file%.*}" + selected_modules+=("$module_stem") + fi + done < <(printf '%s\n' "$raw" | tr ',' '\n') + + [ "${#selected_controls[@]}" -gt 0 ] || + [ "${#selected_modules[@]}" -gt 0 ] || + native_extension_filter_fail "OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES did not select any extensions" + contrib_extensions=("${selected_contrib[@]}") + external_extensions=("${selected_external[@]}") + required_extension_controls=("${selected_controls[@]}") + required_extension_modules=("${selected_modules[@]}") +} + +filter_native_extension_selection + +if [ "$script_mode" = "--print-required-extension-artifacts" ]; then + for extension in "${required_extension_controls[@]}"; do + printf 'control:%s\n' "$extension" + done + for module in "${required_extension_modules[@]}"; do + printf 'module:%s\n' "$module" + done + exit 0 +fi + +verify_source_manifest() { + grep -q "version = \"$pg_version\"" "$source_manifest" && + grep -q "url = \"$pg_url\"" "$source_manifest" && + grep -q "sha256 = \"$pg_sha256\"" "$source_manifest" +} + +patch_series_hash() { + shasum -a 256 "$patch_dir"/*.patch | shasum -a 256 | awk '{print $1}' +} + +module_depends_on_liboliphaunt() { + local module="$1" + [ -f "$module" ] || return 1 + case "$(otool -L "$module" 2>/dev/null || true)" in + *"@rpath/liboliphaunt.dylib"*) return 0 ;; + *) return 1 ;; + esac +} + +liboliphaunt_artifact_ready() { + [ -f "$lib_out" ] || return 1 + local symbols + symbols="$(nm -g "$lib_out" 2>/dev/null || true)" + local symbol + for symbol in \ + _oliphaunt_init \ + _oliphaunt_exec_protocol \ + _oliphaunt_exec_simple_query \ + _oliphaunt_exec_protocol_stream \ + _oliphaunt_backup \ + _oliphaunt_backup_ex \ + _oliphaunt_restore \ + _oliphaunt_cancel \ + _oliphaunt_detach \ + _oliphaunt_close \ + _oliphaunt_register_static_extensions \ + _oliphaunt_last_error \ + _oliphaunt_version \ + _oliphaunt_capabilities \ + _oliphaunt_free_response + do + case "$symbols" in *"$symbol"*) ;; *) return 1 ;; esac + done + oliphaunt_icu_linked_symbols_ready "$symbols" || return 1 +} + +postgres_install_icu_ready() { + [ -x "$install_dir/bin/pg_config" ] || return 1 + local configure_args + configure_args="$("$install_dir/bin/pg_config" --configure 2>/dev/null || true)" + case "$configure_args" in + *"--with-icu"*) ;; + *) return 1 ;; + esac + case "$configure_args" in + *"U_STATIC_IMPLEMENTATION"*) ;; + *) return 1 ;; + esac + [ -f "$install_dir/include/pg_config.h" ] || return 1 + grep -Eq '^#define USE_ICU 1\b' "$install_dir/include/pg_config.h" && + grep -q 'U_STATIC_IMPLEMENTATION' "$install_dir/include/pg_config.h" +} + +hash_object_response_file_inputs() { + [ -f "$objects_rsp" ] || return 1 + while IFS= read -r object; do + [ -n "$object" ] || continue + if [ ! -f "$object" ]; then + echo "liboliphaunt link object is missing: $object" >&2 + return 1 + fi + stat -f '%m %z %N' "$object" + done < "$objects_rsp" +} + +liboliphaunt_build_fingerprint() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'cc=%s\n' "$CC" + printf 'native_cflags=%s\n' "$native_cflags" + printf 'build_hash=%s\n' "$desired_build_hash" + printf 'install_name=%s\n' '@rpath/liboliphaunt.dylib' + printf 'liboliphaunt_sources=%s\n' "${liboliphaunt_sources[*]}" + shasum -a 256 "$repo_root/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + shasum -a 256 "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h" + local source + for source in "${liboliphaunt_sources[@]}"; do + shasum -a 256 "$source" + done + shasum -a 256 "$objects_rsp" + hash_object_response_file_inputs + stat -f '%m %z %N' src/common/libpgcommon_srv.a + stat -f '%m %z %N' src/port/libpgport_srv.a + } | shasum -a 256 | awk '{print $1}' +} + +liboliphaunt_artifacts_current() { + [ -d "$build_dir" ] || return 1 + [ -f "$build_stamp" ] || return 1 + [ "$(cat "$build_stamp")" = "$desired_build_hash" ] || return 1 + oliphaunt_icu_artifacts_ready "$icu_prefix" || return 1 + [ -f "$liboliphaunt_build_stamp" ] || return 1 + [ -x "$install_dir/bin/initdb" ] || return 1 + [ -x "$install_dir/bin/postgres" ] || return 1 + postgres_install_icu_ready || return 1 + [ -f "$objects_rsp" ] || return 1 + [ -f src/common/libpgcommon_srv.a ] || return 1 + [ -f src/port/libpgport_srv.a ] || return 1 + liboliphaunt_artifact_ready || return 1 + + local desired_liboliphaunt_hash + if ! desired_liboliphaunt_hash="$(liboliphaunt_build_fingerprint)"; then + return 1 + fi + [ "$(cat "$liboliphaunt_build_stamp")" = "$desired_liboliphaunt_hash" ] || return 1 +} + +hash_extension_source_tree() { + local source_dir="$1" + find "$source_dir" -type f \( \ + -name "CMakeLists.txt" -o \ + -name "configure" -o \ + -name "*.c" -o \ + -name "*.cc" -o \ + -name "*.cpp" -o \ + -name "*.h" -o \ + -name "*.hpp" -o \ + -name "*.sql" -o \ + -name "*.control" -o \ + -name "*.in" -o \ + -name "Makefile" -o \ + -name "*.mk" \ + \) -print | + LC_ALL=C sort | + while IFS= read -r file; do + shasum -a 256 "$file" + done +} + +extension_build_fingerprint() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'cc=%s\n' "$CC" + printf 'postgis_cc=%s\n' "$postgis_cc" + printf 'build_hash=%s\n' "$desired_build_hash" + printf 'normal_be_dllibs=%s\n' "$normal_module_be_dllibs" + printf 'embedded_be_dllibs=%s\n' "$embedded_module_be_dllibs" + stat -f '%m %z %N' "$install_dir/bin/postgres" + shasum -a 256 "$repo_root/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + shasum -a 256 "$repo_root/src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h" + local source + for source in "${liboliphaunt_sources[@]}"; do + shasum -a 256 "$source" + done + printf 'contrib_extensions=%s\n' "${contrib_extensions[*]}" + printf 'external_extensions=%s\n' "${external_extensions[*]}" + + local extension + for extension in "${contrib_extensions[@]}"; do + printf 'contrib:%s\n' "$extension" + hash_extension_source_tree "$build_dir/contrib/$extension" + done + for extension in "${external_extensions[@]}"; do + printf 'external:%s\n' "$extension" + hash_extension_source_tree "$repo_root/target/oliphaunt-sources/checkouts/$extension" + done + if native_extensions_include_postgis; then + local dependency + for dependency in geos proj sqlite json-c libxml2; do + if [ -d "$repo_root/target/oliphaunt-sources/checkouts/$dependency" ]; then + printf 'postgis-dependency:%s\n' "$dependency" + hash_extension_source_tree "$repo_root/target/oliphaunt-sources/checkouts/$dependency" + fi + done + fi + hash_extension_source_tree "$repo_root/src/runtimes/liboliphaunt/native/portable-uuid" + } | shasum -a 256 | awk '{print $1}' +} + +native_extension_artifacts_ready() { + local extension + for extension in "${required_extension_controls[@]}"; do + if [ ! -f "$install_dir/share/postgresql/extension/$extension.control" ]; then + return 1 + fi + if ! compgen -G "$install_dir/share/postgresql/extension/$extension--*.sql" >/dev/null; then + return 1 + fi + done + + local module + for module in "${required_extension_modules[@]}"; do + if [ ! -f "$install_dir/lib/postgresql/$module.dylib" ]; then + return 1 + fi + if module_depends_on_liboliphaunt "$install_dir/lib/postgresql/$module.dylib"; then + return 1 + fi + if [ ! -f "$embedded_modules_dir/$module.dylib" ]; then + return 1 + fi + done + if native_extensions_include_postgis; then + [ -f "$install_dir/share/postgresql/proj/proj.db" ] || return 1 + fi +} + +base_runtime_optional_extensions_absent() { + local extension + for extension in "${required_extension_controls[@]}"; do + if [ -f "$install_dir/share/postgresql/extension/$extension.control" ]; then + return 1 + fi + done + + local module + for module in "${required_extension_modules[@]}"; do + if [ -f "$install_dir/lib/postgresql/$module.dylib" ]; then + return 1 + fi + done + + [ ! -d "$install_dir/share/postgresql/contrib" ] || return 1 + [ ! -d "$install_dir/share/postgresql/proj" ] || return 1 +} + +prune_base_runtime_optional_extensions() { + if [ "${OLIPHAUNT_BUILD_EXTENSIONS:-0}" != "0" ]; then + return 0 + fi + + local extension_dir="$install_dir/share/postgresql/extension" + local module_dir="$install_dir/lib/postgresql" + local extension + if [ -d "$extension_dir" ]; then + for extension in "${required_extension_controls[@]}"; do + rm -f "$extension_dir/$extension.control" + rm -f "$extension_dir/$extension--"*.sql + done + rm -f "$extension_dir/postgis"*.sql "$extension_dir/rtpostgis"*.sql + rm -f "$extension_dir/uninstall_postgis.sql" "$extension_dir/uninstall_legacy.sql" + rm -f "$extension_dir/pgtap-"*.sql "$extension_dir/uninstall_pgtap.sql" + fi + + local module + if [ -d "$module_dir" ]; then + for module in "${required_extension_modules[@]}"; do + rm -f "$module_dir/$module.dylib" + done + fi + + rm -rf "$install_dir/share/postgresql/contrib" "$install_dir/share/postgresql/proj" +} + +native_extension_artifacts_current() { + [ -d "$build_dir" ] || return 1 + [ -f "$build_stamp" ] || return 1 + [ "$(cat "$build_stamp")" = "$desired_build_hash" ] || return 1 + [ -x "$install_dir/bin/postgres" ] || return 1 + [ -f "$lib_out" ] || return 1 + [ -d "$build_dir/contrib" ] || return 1 + [ -f "$extension_build_stamp" ] || return 1 + + local desired_extension_hash + if ! desired_extension_hash="$(extension_build_fingerprint)"; then + return 1 + fi + [ "$(cat "$extension_build_stamp")" = "$desired_extension_hash" ] || return 1 + native_extension_artifacts_ready || return 1 +} + +if [ "$(uname -s)" != "Darwin" ]; then + echo "native PostgreSQL 18 liboliphaunt build currently targets macOS only" >&2 + exit 2 +fi + +if ! verify_source_manifest; then + echo "native liboliphaunt source manifest does not match build constants: $source_manifest" >&2 + exit 1 +fi +oliphaunt_icu_require_source "$icu_source_dir" + +native_cc="${OLIPHAUNT_CC:-cc}" +native_cxx="${OLIPHAUNT_CXX:-c++}" +ccache_mode="${OLIPHAUNT_CCACHE:-auto}" +if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then + if [ "$ccache_mode" != "auto" ]; then + ccache_bin="$ccache_mode" + else + ccache_bin="$(command -v ccache || true)" + fi + if [ -n "$ccache_bin" ]; then + export CC="$ccache_bin $native_cc" + export CXX="$ccache_bin $native_cxx" + else + export CC="$native_cc" + export CXX="$native_cxx" + fi +else + export CC="$native_cc" + export CXX="$native_cxx" +fi + +desired_patch_hash="$(patch_series_hash)" +desired_build_hash="$( + { + printf 'patches=%s\n' "$desired_patch_hash" + printf 'cc=%s\n' "$CC" + printf 'cxx=%s\n' "$CXX" + printf 'icu_source=%s\n' "$(oliphaunt_icu_source_commit "$icu_source_dir")" + printf 'icu_script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'postgres_configure=with-icu\n' + } | shasum -a 256 | awk '{print $1}' +)" +current_build_hash="" +if [ -f "$build_stamp" ]; then + current_build_hash="$(cat "$build_stamp")" +fi + +native_cflags="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED" +normal_module_be_dllibs="-bundle_loader $install_dir/bin/postgres" +embedded_module_be_dllibs="-bundle_loader $lib_out -Wl,-rpath,$out_dir" +postgis_cc="${OLIPHAUNT_POSTGIS_CC:-$native_cc}" +portable_uuid_dir="$repo_root/src/runtimes/liboliphaunt/native/portable-uuid" +native_uuid_dependency_dir="$work_root/portable-uuid-native" +native_uuid_archive="$native_uuid_dependency_dir/lib/libuuid.a" + +if [ "$script_mode" = "--check-oliphaunt-current" ]; then + if [ -d "$build_dir" ] && (cd "$build_dir" && liboliphaunt_artifacts_current); then + echo "native liboliphaunt dylib is current" + exit 0 + fi + echo "native liboliphaunt dylib is missing or stale" >&2 + exit 1 +fi + +if [ "$script_mode" = "--check-extension-artifacts-current" ]; then + if native_extension_artifacts_current; then + echo "native extension artifacts are current" + exit 0 + fi + echo "native extension artifacts are missing or stale" >&2 + exit 1 +fi + +if [ "$script_mode" != "build" ] && [ "$script_mode" != "--runtime-only" ]; then + cat >&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh [--runtime-only|--print-required-extension-artifacts|--check-oliphaunt-current|--check-extension-artifacts-current] +MSG + exit 2 +fi + +jobs="${OLIPHAUNT_JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || echo 4)}" +mkdir -p "$source_cache" "$out_dir" +icu_host="$(sh "$icu_source_dir/config.guess")" +oliphaunt_icu_build_target \ + "$icu_source_dir" \ + "$script_dir" \ + "$icu_native_build_dir" \ + "$icu_build_dir" \ + "$icu_prefix" \ + "$jobs" \ + "macos" \ + "$icu_host" \ + "$CC" \ + "$CXX" \ + "ar" \ + "ranlib" \ + "$native_cflags" \ + "$native_cflags -std=c++17" \ + "" + +if [ ! -f "$tarball" ]; then + curl -L --fail --silent --show-error "$pg_url" -o "$tarball" +fi + +( + cd "$source_cache" + printf '%s %s\n' "$pg_sha256" "postgresql-${pg_version}.tar.bz2" | shasum -a 256 -c - +) + +if [ -d "$build_dir" ] && [ "$current_build_hash" != "$desired_build_hash" ]; then + rm -rf "$build_dir" +fi + +postgres_source_configure_complete() { + [ -f "$build_dir/config.status" ] && + [ -f "$build_dir/src/include/pg_config.h" ] +} + +postgres_source_configure_reusable() { + if postgres_source_configure_complete; then + return 0 + fi + [ ! -f "$build_dir/config.status" ] && + [ ! -f "$build_dir/config.log" ] +} + +if [ -d "$build_dir" ] && ! postgres_source_configure_reusable; then + echo "discarding incomplete PostgreSQL configure tree at $build_dir" >&2 + rm -rf "$build_dir" +fi + +if [ ! -d "$build_dir" ]; then + tar -xjf "$tarball" -C "$work_root" +fi + +cd "$build_dir" + +patches_applied() { + grep -q 'OliphauntEmbeddedIO' src/include/libpq/libpq-be.h && + grep -q 'oliphaunt_io' src/backend/libpq/be-secure.c && + grep -q 'oliphaunt_embedded_main' src/backend/tcop/postgres.c && + grep -q 'oliphaunt_embedded' meson_options.txt && + grep -q 'OLIPHAUNT_EMBEDDED' meson.build && + grep -q 'OLIPHAUNT_EMBEDDED' src/include/tcop/tcopprot.h && + grep -q 'oliphaunt_embedded_proc_exit' src/include/storage/ipc.h && + grep -q 'original_cwd' src/backend/tcop/postgres.c && + grep -q 'oliphaunt_static_extension_lookup' src/backend/utils/fmgr/dfmgr.c && + grep -q 'OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS' src/backend/archive/shell_archive.c && + grep -q 'OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS' src/backend/access/transam/xlogarchive.c +} + +if ! patches_applied; then + git init -q + for patch_file in "$patch_dir"/*.patch; do + GIT_CEILING_DIRECTORIES="$work_root" git apply --recount --whitespace=nowarn "$patch_file" + done + printf '%s\n' "$desired_build_hash" > "$build_stamp" +fi + +if ! patches_applied; then + echo "PostgreSQL embedded patch verification failed" >&2 + exit 1 +fi + +if [ ! -f "$build_stamp" ]; then + printf '%s\n' "$desired_build_hash" > "$build_stamp" +fi + +if [ ! -f config.status ]; then + echo "Using CC=$CC" + CPPFLAGS="$icu_cflags" \ + LDFLAGS="-L$icu_prefix/lib" \ + ICU_CFLAGS="$icu_cflags" \ + ICU_LIBS="$icu_libs" \ + ./configure \ + --prefix="$install_dir" \ + --without-readline \ + --with-icu \ + --without-llvm \ + --without-pam \ + --with-openssl=no \ + --without-zlib \ + --disable-nls +fi + +if ! postgres_source_configure_complete; then + echo "PostgreSQL configure did not produce config.status and src/include/pg_config.h" >&2 + exit 1 +fi + +runtime_installed() { + [ -x "$install_dir/bin/initdb" ] && + [ -x "$install_dir/bin/postgres" ] && + [ -f "$install_dir/share/postgresql/postgresql.conf.sample" ] && + [ -f "$postgres_runtime_stamp" ] && + [ "$(cat "$postgres_runtime_stamp")" = "$desired_build_hash" ] && + postgres_install_icu_ready && + { [ "${OLIPHAUNT_BUILD_EXTENSIONS:-0}" != "0" ] || base_runtime_optional_extensions_absent; } +} + +install_normal_plpgsql_module() { + make -C src/pl/plpgsql/src clean + make -C src/pl/plpgsql/src \ + CC="$CC" \ + BE_DLLLIBS="$normal_module_be_dllibs" \ + install +} + +copy_embedded_modules_from_dir() { + local source_dir="$1" + mkdir -p "$embedded_modules_dir" + while IFS= read -r module; do + cp -p "$module" "$embedded_modules_dir/$(basename "$module")" + done < <(find "$source_dir" -maxdepth 1 -type f -name "*.dylib" -print) +} + +audit_embedded_module() { + local module="$1" + if nm -m "$module" 2>/dev/null | + awk '/_(hash_create|hash_search) \(from libSystem\)/ { found = 1 } END { exit found ? 0 : 1 }'; then + echo "embedded module bound PostgreSQL hash symbols to libSystem: $module" >&2 + exit 1 + fi +} + +compile_liboliphaunt_objects() { + local index + for index in "${!liboliphaunt_sources[@]}"; do + $CC -O2 -g -fPIC \ + -I"$repo_root/src/runtimes/liboliphaunt/native/include" \ + -I"$repo_root/src/runtimes/liboliphaunt/native/src" \ + -c "${liboliphaunt_sources[$index]}" \ + -o "${liboliphaunt_objects[$index]}" + done +} + +link_liboliphaunt_dylib() { + $CC -dynamiclib -undefined dynamic_lookup \ + -Wl,-install_name,@rpath/liboliphaunt.dylib \ + -o "$lib_out" \ + "${liboliphaunt_objects[@]}" \ + @"$objects_rsp" \ + src/common/libpgcommon_srv.a \ + src/port/libpgport_srv.a \ + $icu_libs \ + -lpthread +} + +build_liboliphaunt_dylib() { + local desired_liboliphaunt_hash + desired_liboliphaunt_hash="$(liboliphaunt_build_fingerprint)" + + if [ "${OLIPHAUNT_FORCE_RELINK:-0}" != "1" ] && + [ -f "$liboliphaunt_build_stamp" ] && + [ "$(cat "$liboliphaunt_build_stamp")" = "$desired_liboliphaunt_hash" ] && + liboliphaunt_artifact_ready; then + echo "reusing native liboliphaunt dylib" + return + fi + + rm -f "$liboliphaunt_build_stamp" + compile_liboliphaunt_objects + link_liboliphaunt_dylib + if ! liboliphaunt_artifact_ready; then + echo "native liboliphaunt dylib did not export the required C ABI symbols" >&2 + exit 1 + fi + printf '%s\n' "$desired_liboliphaunt_hash" > "$liboliphaunt_build_stamp" +} + +audit_embedded_extension_modules() { + local module + for module in "$embedded_modules_dir"/*.dylib; do + [ -e "$module" ] || continue + audit_embedded_module "$module" + done +} + +native_openssl_prefix() { + local candidate + for candidate in \ + "${OLIPHAUNT_OPENSSL_PREFIX:-}" \ + "${OPENSSL_PREFIX:-}" \ + /opt/homebrew/opt/openssl@3 \ + /usr/local/opt/openssl@3 \ + /opt/homebrew/opt/openssl \ + /usr/local/opt/openssl + do + [ -n "$candidate" ] || continue + if [ -f "$candidate/include/openssl/evp.h" ] && + { [ -f "$candidate/lib/libcrypto.a" ] || [ -f "$candidate/lib/libcrypto.dylib" ]; }; then + printf '%s\n' "$candidate" + return 0 + fi + done + if command -v brew >/dev/null 2>&1; then + for candidate in "$(brew --prefix openssl@3 2>/dev/null || true)" "$(brew --prefix openssl 2>/dev/null || true)"; do + [ -n "$candidate" ] || continue + if [ -f "$candidate/include/openssl/evp.h" ] && + { [ -f "$candidate/lib/libcrypto.a" ] || [ -f "$candidate/lib/libcrypto.dylib" ]; }; then + printf '%s\n' "$candidate" + return 0 + fi + done + fi + echo "pgcrypto requires OpenSSL headers and libcrypto; set OLIPHAUNT_OPENSSL_PREFIX to a prefix containing include/openssl/evp.h and lib/libcrypto.{a,dylib}" >&2 + return 1 +} + +native_brew_prefix() { + local formula="$1" + local candidate + if command -v brew >/dev/null 2>&1; then + candidate="$(brew --prefix "$formula" 2>/dev/null || true)" + if [ -n "$candidate" ] && [ -d "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + fi + return 1 +} + +native_dependency_prefix() { + local env_var="$1" + local formula="$2" + shift 2 + local override="${!env_var:-}" + local candidate + for candidate in "$override" "$@"; do + [ -n "$candidate" ] || continue + if [ -d "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + if native_brew_prefix "$formula"; then + return 0 + fi + return 1 +} + +native_dependency_tool() { + local env_var="$1" + local tool="$2" + local formula="$3" + shift 3 + local override="${!env_var:-}" + local candidate + if [ -n "$override" ] && [ -x "$override" ]; then + printf '%s\n' "$override" + return 0 + fi + if command -v "$tool" >/dev/null 2>&1; then + command -v "$tool" + return 0 + fi + for candidate in "$@"; do + [ -n "$candidate" ] || continue + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + candidate="$(native_brew_prefix "$formula" || true)" + if [ -n "$candidate" ] && [ -x "$candidate/bin/$tool" ]; then + printf '%s\n' "$candidate/bin/$tool" + return 0 + fi + return 1 +} + +native_postgis_dependency_root="${OLIPHAUNT_NATIVE_POSTGIS_DEPENDENCY_ROOT:-$work_root/postgis-native-dependencies}" +postgis_configure_env=() +postgis_make_args=() + +native_postgis_fail() { + echo "PostGIS native dependency build: $*" >&2 + exit 1 +} + +native_postgis_require_tools() { + local cmd + for cmd in cmake rsync; do + command -v "$cmd" >/dev/null 2>&1 || native_postgis_fail "missing required command: $cmd" + done +} + +native_postgis_dependency_archive() { + local name="$1" + local archive="$2" + [ -f "$archive" ] || native_postgis_fail "missing dependency archive for $name: $archive" +} + +native_postgis_cmake_install() { + local source_dir="$1" + local build_root="$2" + local dependency_dir="$3" + shift 3 + cmake -S "$source_dir" -B "$build_root" \ + -DCMAKE_INSTALL_PREFIX="$dependency_dir" \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + "$@" >> "$postgis_dependency_log" 2>&1 + cmake --build "$build_root" --target install -- -j"$jobs" >> "$postgis_dependency_log" 2>&1 +} + +build_native_postgis_jsonc_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/json-c" + local dependency_dir="$native_postgis_dependency_root/json-c" + local build_root="$work_root/json-c-native-build" + local archive="$dependency_dir/lib/libjson-c.a" + if [ -f "$archive" ] && [ -d "$dependency_dir/include/json-c" ]; then + native_postgis_dependency_archive json-c "$archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || native_postgis_fail "missing JSON-C checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_STATIC_LIBS=ON \ + -DBUILD_APPS=OFF \ + -DBUILD_TESTING=OFF \ + -DDISABLE_WERROR=ON + [ -f "$archive" ] || native_postgis_fail "JSON-C build did not produce $archive" + native_postgis_dependency_archive json-c "$archive" +} + +build_native_postgis_sqlite_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/sqlite" + local dependency_dir="$native_postgis_dependency_root/sqlite" + local build_root="$work_root/sqlite-native-build" + local archive="$dependency_dir/lib/libsqlite3.a" + if [ -f "$archive" ] && [ -f "$dependency_dir/include/sqlite3.h" ]; then + native_postgis_dependency_archive sqlite "$archive" + return 0 + fi + [ -x "$source_dir/configure" ] || native_postgis_fail "missing SQLite checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + mkdir -p "$build_root" "$dependency_dir/include" "$dependency_dir/lib" + rsync -a --delete --exclude .git "$source_dir/" "$build_root/" + ( + cd "$build_root" + CC="$native_cc" CFLAGS="-O2 -g -fPIC" ./configure \ + --disable-shared \ + --enable-static \ + --prefix="$dependency_dir" >> "$postgis_dependency_log" 2>&1 + make -j"$jobs" sqlite3.c >> "$postgis_dependency_log" 2>&1 + "$native_cc" -O2 -g -fPIC \ + -DSQLITE_THREADSAFE=0 \ + -DSQLITE_OMIT_LOAD_EXTENSION \ + -c sqlite3.c \ + -o sqlite3.o >> "$postgis_dependency_log" 2>&1 + ar crs "$archive" sqlite3.o >> "$postgis_dependency_log" 2>&1 + ranlib "$archive" >> "$postgis_dependency_log" 2>&1 + cp -p sqlite3.h sqlite3ext.h "$dependency_dir/include/" + ) + [ -f "$archive" ] || native_postgis_fail "SQLite build did not produce $archive" + native_postgis_dependency_archive sqlite "$archive" +} + +build_native_postgis_geos_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/geos" + local dependency_dir="$native_postgis_dependency_root/geos" + local build_root="$work_root/geos-native-build" + local geos_c_archive="$dependency_dir/lib/libgeos_c.a" + local geos_archive="$dependency_dir/lib/libgeos.a" + if [ -f "$geos_c_archive" ] && [ -f "$geos_archive" ] && [ -f "$dependency_dir/include/geos_c.h" ]; then + native_postgis_dependency_archive geos-c "$geos_c_archive" + native_postgis_dependency_archive geos "$geos_archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || native_postgis_fail "missing GEOS checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_TESTING=OFF \ + -DBUILD_BENCHMARKS=OFF \ + -DBUILD_GEOSOP=OFF \ + -DGEOS_BUILD_DEVELOPER=OFF + [ -f "$geos_c_archive" ] || native_postgis_fail "GEOS build did not produce $geos_c_archive" + [ -f "$geos_archive" ] || native_postgis_fail "GEOS build did not produce $geos_archive" + native_postgis_dependency_archive geos-c "$geos_c_archive" + native_postgis_dependency_archive geos "$geos_archive" +} + +build_native_postgis_libxml2_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/libxml2" + local dependency_dir="$native_postgis_dependency_root/libxml2" + local build_root="$work_root/libxml2-native-build" + local archive="$dependency_dir/lib/libxml2.a" + if [ -f "$archive" ] && [ -x "$dependency_dir/bin/xml2-config" ]; then + native_postgis_dependency_archive libxml2 "$archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || native_postgis_fail "missing libxml2 checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + -DLIBXML2_WITH_PROGRAMS=OFF \ + -DLIBXML2_WITH_TESTS=OFF \ + -DLIBXML2_WITH_PYTHON=OFF \ + -DLIBXML2_WITH_THREADS=OFF \ + -DLIBXML2_WITH_MODULES=OFF \ + -DLIBXML2_WITH_ICONV=OFF \ + -DLIBXML2_WITH_ZLIB=OFF \ + -DLIBXML2_WITH_LZMA=OFF \ + -DLIBXML2_WITH_HTTP=OFF + [ -f "$archive" ] || native_postgis_fail "libxml2 build did not produce $archive" + native_postgis_dependency_archive libxml2 "$archive" +} + +build_native_postgis_proj_dependency() { + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/proj" + local dependency_dir="$native_postgis_dependency_root/proj" + local sqlite_dir="$native_postgis_dependency_root/sqlite" + local build_root="$work_root/proj-native-build" + local archive="$dependency_dir/lib/libproj.a" + if [ -f "$archive" ] && [ -f "$dependency_dir/share/proj/proj.db" ]; then + native_postgis_dependency_archive proj "$archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || native_postgis_fail "missing PROJ checkout: $source_dir" + [ -f "$sqlite_dir/lib/libsqlite3.a" ] || native_postgis_fail "PROJ dependency requires SQLite archive first" + rm -rf "$build_root" "$dependency_dir" + native_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + "-DSQLite3_INCLUDE_DIR=$sqlite_dir/include" \ + "-DSQLite3_LIBRARY=$sqlite_dir/lib/libsqlite3.a" \ + "-DEXE_SQLITE3=$(command -v sqlite3)" \ + -DENABLE_TIFF=OFF \ + -DENABLE_CURL=OFF \ + -DENABLE_EMSCRIPTEN_FETCH=OFF \ + -DHAVE_LIBDL=OFF \ + -DBUILD_APPS=OFF \ + -DBUILD_TESTING=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DEMBED_RESOURCE_FILES=ON \ + -DUSE_ONLY_EMBEDDED_RESOURCE_FILES=ON + mkdir -p "$dependency_dir/share/proj" + if [ -f "$build_root/data/proj.db" ]; then + cp -p "$build_root/data/proj.db" "$dependency_dir/share/proj/proj.db" + fi + [ -f "$archive" ] || native_postgis_fail "PROJ build did not produce $archive" + [ -f "$dependency_dir/share/proj/proj.db" ] || native_postgis_fail "PROJ build did not produce proj.db" + native_postgis_dependency_archive proj "$archive" +} + +build_native_postgis_dependencies() { + native_postgis_require_tools + mkdir -p "$(dirname "$postgis_dependency_log")" + : > "$postgis_dependency_log" + build_native_postgis_jsonc_dependency + build_native_postgis_sqlite_dependency + build_native_postgis_geos_dependency + build_native_postgis_libxml2_dependency + build_native_postgis_proj_dependency +} + +native_postgis_geos_config_script() { + local path="$1" + cat > "$path" < "$path" <&2 + use_native_postgis_dependency_bundle + return + fi + + postgis_proj_prefix="$proj_prefix" + postgis_configure_args=( + "--with-geosconfig=$geos_config" + "--with-projdir=$proj_prefix" + "--with-jsondir=$json_prefix" + ) +} + +build_native_uuid_dependency() { + local object="$native_uuid_dependency_dir/portable_uuid.o" + + if [ -f "$native_uuid_archive" ] && [ -d "$native_uuid_dependency_dir/include/uuid" ]; then + return 0 + fi + [ -f "$portable_uuid_dir/portable_uuid.c" ] || { + echo "portable UUID source is missing: $portable_uuid_dir" >&2 + exit 1 + } + + rm -rf "$native_uuid_dependency_dir" + mkdir -p "$native_uuid_dependency_dir/include" "$native_uuid_dependency_dir/lib" + cp -R "$portable_uuid_dir/include/uuid" "$native_uuid_dependency_dir/include/" + $CC $native_cflags \ + -I"$portable_uuid_dir/include" \ + -I"$build_dir/src/include" \ + -I"$build_dir/src/include/port" \ + -c "$portable_uuid_dir/portable_uuid.c" \ + -o "$object" + ar crs "$native_uuid_archive" "$object" + ranlib "$native_uuid_archive" + [ -s "$native_uuid_archive" ] || { + echo "portable UUID native build did not produce $native_uuid_archive" >&2 + exit 1 + } +} + +build_contrib_extension() { + local extension="$1" + local -a extra_make_args + local -a embedded_extra_make_args + local extra_make_args_count=0 + local embedded_extra_make_args_count=0 + local embedded_pg_ldflags="" + local arg + if [ "$extension" = "pgcrypto" ]; then + configure_pgcrypto_make_args + extra_make_args=("${pgcrypto_make_args[@]}") + extra_make_args_count="${#pgcrypto_make_args[@]}" + elif [ "$extension" = "uuid-ossp" ]; then + build_native_uuid_dependency + extra_make_args=( + "PG_CPPFLAGS=-I$portable_uuid_dir/include -DHAVE_UUID_E2FS=1 -DHAVE_UUID_UUID_H=1" + "UUID_LIBS=$native_uuid_archive" + ) + extra_make_args_count=2 + fi + if [ "$extra_make_args_count" -gt 0 ]; then + for arg in "${extra_make_args[@]}"; do + case "$arg" in + PG_LDFLAGS=*) + embedded_pg_ldflags="${arg#PG_LDFLAGS=} $embedded_pg_ldflags" + ;; + *) + embedded_extra_make_args+=("$arg") + embedded_extra_make_args_count=$((embedded_extra_make_args_count + 1)) + ;; + esac + done + fi + if [ -n "$embedded_pg_ldflags" ]; then + embedded_extra_make_args+=("PG_LDFLAGS=$embedded_pg_ldflags") + embedded_extra_make_args_count=$((embedded_extra_make_args_count + 1)) + fi + + make -C "contrib/$extension" clean + if [ "$extra_make_args_count" -gt 0 ]; then + make -C "contrib/$extension" \ + CC="$CC" \ + BE_DLLLIBS="$normal_module_be_dllibs" \ + "${extra_make_args[@]}" \ + install + else + make -C "contrib/$extension" \ + CC="$CC" \ + BE_DLLLIBS="$normal_module_be_dllibs" \ + install + fi + make -C "contrib/$extension" clean + if [ "$embedded_extra_make_args_count" -gt 0 ]; then + make -C "contrib/$extension" \ + CC="$CC" \ + BE_DLLLIBS="$embedded_module_be_dllibs" \ + "${embedded_extra_make_args[@]}" \ + all + else + make -C "contrib/$extension" \ + CC="$CC" \ + BE_DLLLIBS="$embedded_module_be_dllibs" \ + all + fi + copy_embedded_modules_from_dir "contrib/$extension" +} + +pgxs_extension_link_args() { + local extension="$1" + local be_dllibs="$2" + case "$extension" in + pg_textsearch|pgvector|vector) + if [ -n "$be_dllibs" ]; then + printf '%s\n' "BE_DLLLIBS=$be_dllibs -lm" + else + printf '%s\n' "SHLIB_LINK=-lm" + fi + ;; + *) + [ -z "$be_dllibs" ] || printf '%s\n' "BE_DLLLIBS=$be_dllibs" + ;; + esac +} + +pgxs_extension_source_rel() { + local extension="$1" + local pgxs_plan="$repo_root/src/extensions/generated/pgxs-build.tsv" + awk -F '\t' -v extension="$extension" ' + NR > 1 && ($1 == extension || $3 == "target/oliphaunt-sources/checkouts/" extension) { + print $3 + found = 1 + exit + } + END { exit found ? 0 : 1 } + ' "$pgxs_plan" +} + +build_pgxs_extension() { + local extension="$1" + local source_rel + source_rel="$(pgxs_extension_source_rel "$extension" || true)" + [ -n "$source_rel" ] || native_extension_filter_fail "unknown PGXS extension source mapping: $extension" + local checkout="$repo_root/$source_rel" + local build_checkout="$work_root/external-$extension" + local -a normal_link_args=() + local -a embedded_link_args=() + while IFS= read -r arg; do + [ -n "$arg" ] || continue + normal_link_args+=("$arg") + done < <(pgxs_extension_link_args "$extension" "$normal_module_be_dllibs") + while IFS= read -r arg; do + [ -n "$arg" ] || continue + embedded_link_args+=("$arg") + done < <(pgxs_extension_link_args "$extension" "$embedded_module_be_dllibs") + if [ ! -d "$checkout" ]; then + echo "native extension checkout is missing: $checkout" >&2 + exit 1 + fi + rm -rf "$build_checkout" + mkdir -p "$(dirname "$build_checkout")" + cp -a "$checkout/." "$build_checkout/" + rm -rf "$build_checkout/.git" + make -C "$build_checkout" \ + PG_CONFIG="$install_dir/bin/pg_config" \ + clean + make -C "$build_checkout" \ + PG_CONFIG="$install_dir/bin/pg_config" \ + CC="$CC" \ + OPTFLAGS="" \ + "${normal_link_args[@]}" \ + install + make -C "$build_checkout" \ + PG_CONFIG="$install_dir/bin/pg_config" \ + clean + make -C "$build_checkout" \ + PG_CONFIG="$install_dir/bin/pg_config" \ + CC="$CC" \ + OPTFLAGS="" \ + "${embedded_link_args[@]}" \ + all + copy_embedded_modules_from_dir "$build_checkout" +} + +normalize_installed_module_suffix() { + local stem="$1" + local module_dir="$install_dir/lib/postgresql" + if [ ! -f "$module_dir/$stem.dylib" ] && [ -f "$module_dir/$stem.so" ]; then + cp -p "$module_dir/$stem.so" "$module_dir/$stem.dylib" + fi +} + +copy_embedded_postgis_module() { + local source_dir="$1" + local candidate + mkdir -p "$embedded_modules_dir" + for candidate in "$source_dir/postgis-3.dylib" "$source_dir/postgis-3.so"; do + if [ -f "$candidate" ]; then + cp -p "$candidate" "$embedded_modules_dir/postgis-3.dylib" + if ! module_depends_on_liboliphaunt "$embedded_modules_dir/postgis-3.dylib"; then + echo "embedded PostGIS is not linked against liboliphaunt: $embedded_modules_dir/postgis-3.dylib" >&2 + exit 1 + fi + return + fi + done + echo "PostGIS embedded module was not produced under $source_dir" >&2 + exit 1 +} + +stage_postgis_data_files() { + local postgis_build_dir="$1" + local proj_db="" + local candidate + for candidate in \ + "$postgis_build_dir/share/proj/proj.db" \ + "${postgis_proj_prefix:-}/share/proj/proj.db" \ + "${OLIPHAUNT_PROJ_PREFIX:-}/share/proj/proj.db" \ + "${OLIPHAUNT_PROJ_DATADIR:-}/proj.db" \ + /opt/homebrew/opt/proj/share/proj/proj.db \ + /usr/local/opt/proj/share/proj/proj.db \ + /opt/homebrew/share/proj/proj.db \ + /usr/local/share/proj/proj.db + do + [ -n "$candidate" ] || continue + if [ -f "$candidate" ]; then + proj_db="$candidate" + break + fi + done + if [ -z "$proj_db" ]; then + echo "PostGIS requires proj/proj.db; set OLIPHAUNT_PROJ_DATADIR or install PROJ data files" >&2 + exit 1 + fi + mkdir -p "$install_dir/share/postgresql/proj" + cp -p "$proj_db" "$install_dir/share/postgresql/proj/proj.db" +} + +patch_postgis_generated_makefiles() { + local postgis_build_dir="$1" + local makefile + while IFS= read -r makefile; do + perl -0pi -e 's/\$\(LIBTOOL\) --mode=compile \$\(CC\)/\$(LIBTOOL) --tag=CC --mode=compile \$(CC)/g' "$makefile" + done < <(find "$postgis_build_dir" -name Makefile -type f -print) +} + +build_postgis_extension() { + local checkout="$repo_root/target/oliphaunt-sources/checkouts/postgis" + local postgis_build_dir="$work_root/postgis-native" + if [ ! -f "$checkout/configure.ac" ]; then + echo "native PostGIS checkout is missing or incomplete: $checkout" >&2 + echo "run the source-spine fetch before building native PostGIS artifacts" >&2 + exit 1 + fi + configure_postgis_configure_args + + rm -rf "$postgis_build_dir" + mkdir -p "$(dirname "$postgis_build_dir")" + cp -a "$checkout/." "$postgis_build_dir/" + rm -rf "$postgis_build_dir/.git" + + ( + cd "$postgis_build_dir" + export CC="$postgis_cc" + if [ ! -f configure ]; then + ./autogen.sh + fi + if [ "${#postgis_configure_env[@]}" -gt 0 ]; then + export "${postgis_configure_env[@]}" + fi + ./configure \ + --prefix="$install_dir" \ + --with-pgconfig="$install_dir/bin/pg_config" \ + "${postgis_configure_args[@]}" \ + --without-protobuf \ + --without-raster \ + --without-topology \ + --without-sfcgal \ + --without-address-standardizer \ + --without-tiger \ + --disable-nls + + patch_postgis_generated_makefiles "$postgis_build_dir" + # PostGIS' generated revision header is referenced through postgis_config.h. + # Build it before parallel sub-makes so compiler jobs cannot race the + # top-level header generation target. + make postgis_revision.h + make clean || true + make postgis_revision.h + make -C doc CC="$postgis_cc" "${postgis_make_args[@]}" comments-install + make -j"$jobs" -C postgis CC="$postgis_cc" "${postgis_make_args[@]}" install + # PostGIS extension SQL generation has shared raster helper outputs even + # when raster support is disabled, so keep this packaging phase serial. + make -j1 -C extensions CC="$postgis_cc" "${postgis_make_args[@]}" all + make -j1 -C extensions CC="$postgis_cc" "${postgis_make_args[@]}" install + make -C postgis clean || true + make postgis_revision.h + make -j"$jobs" -C postgis CC="$postgis_cc" BE_DLLLIBS="$embedded_module_be_dllibs" "${postgis_make_args[@]}" all + ) + + normalize_installed_module_suffix postgis-3 + copy_embedded_postgis_module "$postgis_build_dir/postgis" + stage_postgis_data_files "$postgis_build_dir" +} + +build_embedded_plpgsql_module() { + local module="$embedded_modules_dir/plpgsql.dylib" + if module_depends_on_liboliphaunt "$module"; then + return + fi + make -C src/pl/plpgsql/src clean + make -C src/pl/plpgsql/src \ + CC="$CC" \ + BE_DLLLIBS="$embedded_module_be_dllibs" \ + all + mkdir -p "$embedded_modules_dir" + cp -p src/pl/plpgsql/src/plpgsql.dylib "$module" + if ! module_depends_on_liboliphaunt "$module"; then + echo "embedded plpgsql is not linked against liboliphaunt: $module" >&2 + exit 1 + fi + audit_embedded_module "$module" +} + +build_native_extension_artifacts() { + if [ "${OLIPHAUNT_BUILD_EXTENSIONS:-0}" = "0" ]; then + return + fi + + local desired_extension_hash + desired_extension_hash="$(extension_build_fingerprint)" + + if [ "${OLIPHAUNT_FORCE_EXTENSION_REBUILD:-0}" != "1" ] && + [ -f "$extension_build_stamp" ] && + [ "$(cat "$extension_build_stamp")" = "$desired_extension_hash" ] && + native_extension_artifacts_ready; then + audit_embedded_extension_modules + echo "reusing native extension artifacts" + return + fi + + rm -f "$extension_build_stamp" + rm -f "$embedded_modules_dir/age.dylib" "$embedded_modules_dir/pg_hashids.dylib" + + local extension + for extension in "${contrib_extensions[@]}"; do + build_contrib_extension "$extension" + done + + for extension in "${external_extensions[@]}"; do + if [ "$extension" = "postgis" ]; then + build_postgis_extension + else + build_pgxs_extension "$extension" + fi + done + + audit_embedded_extension_modules + if ! native_extension_artifacts_ready; then + echo "native extension build did not produce the required normal and embedded artifacts" >&2 + exit 1 + fi + + # Some extension builds generate source/header files inside their checkout or + # contrib directory. Stamp the post-build fingerprint so --check-current + # verifies the actual artifact-producing tree instead of the pre-build input + # shape. + desired_extension_hash="$(extension_build_fingerprint)" + printf '%s\n' "$desired_extension_hash" > "$extension_build_stamp" +} + +# Build and install a normal PostgreSQL tree first. initdb needs the matching +# sibling postgres binary and the installed share/lib tree needs core modules +# such as dict_snowball and plpgsql. Keep this separate from the embedded/PIC +# object pass so the runtime tools stay normal PostgreSQL while backend modules +# use embedded-friendly Darwin symbol lookup. +if ! runtime_installed; then + rm -f "$postgres_runtime_stamp" + # The embedded dylib pass intentionally rebuilds backend objects with + # OLIPHAUNT_EMBEDDED. If the normal runtime install later becomes stale, do + # not let make reuse those objects for the postgres/initdb toolchain. + make clean CC="$CC" + make -j"$jobs" CC="$CC" + make install CC="$CC" +fi + +if module_depends_on_liboliphaunt "$install_dir/lib/postgresql/plpgsql.dylib"; then + install_normal_plpgsql_module +fi +prune_base_runtime_optional_extensions +printf '%s\n' "$desired_build_hash" > "$postgres_runtime_stamp" + +if [ "$script_mode" = "--runtime-only" ]; then + echo "$install_dir" + exit 0 +fi + +regenerate_backend_headers() { + rm -f src/include/nodes/header-stamp src/include/utils/header-stamp + make -C src/backend generated-headers CC="$CC" +} + +native_backend_objects_ready() { + for required in \ + src/backend/tcop/postgres.o \ + src/backend/libpq/be-secure.o \ + src/backend/libpq/pqcomm.o + do + if [ ! -f "$required" ]; then + return 1 + fi + done + for objfile in src/backend/*/objfiles.txt; do + if [ ! -s "$objfile" ]; then + return 1 + fi + done + nm -g src/backend/tcop/postgres.o | grep -q '_oliphaunt_embedded_main' || return 1 +} + +validate_native_objects() { + if ! native_backend_objects_ready; then + echo "native backend object build did not produce the required embedded objects" >&2 + exit 1 + fi +} + +liboliphaunt_objects=() +for source in "${liboliphaunt_sources[@]}"; do + object="$out_dir/$(basename "${source%.c}").o" + liboliphaunt_objects+=("$object") +done + +# Rebuild backend objects for the dylib only when the patched backend object +# tree is missing. C ABI iteration should normally recompile and relink only the +# liboliphaunt translation units above. +if native_backend_objects_ready; then + echo "reusing native PostgreSQL backend objects" +else + make -C src/backend clean + regenerate_backend_headers + set +e + make -j"$jobs" -C src/backend \ + CC="$CC" \ + CFLAGS="$native_cflags" \ + postgres + native_make_status=$? + set -e + validate_native_objects + if [ "$native_make_status" -ne 0 ]; then + echo "native backend executable link failed after objects were produced; continuing with dylib link" >&2 + fi +fi + +make -C src/timezone CC="$CC" CFLAGS="$native_cflags" localtime.o pgtz.o strftime.o + +{ + cat src/backend/*/objfiles.txt + printf 'src/timezone/localtime.o src/timezone/pgtz.o src/timezone/strftime.o\n' +} | tr '[:space:]' '\n' | sed '/^$/d' > "$objects_rsp" + +build_liboliphaunt_dylib +build_embedded_plpgsql_module +build_native_extension_artifacts + +echo "$lib_out" +echo "Set LIBOLIPHAUNT_PATH=$lib_out" +echo "Set OLIPHAUNT_INITDB=$install_dir/bin/initdb" +echo "Set OLIPHAUNT_POSTGRES=$install_dir/bin/postgres" diff --git a/src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 b/src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 new file mode 100644 index 00000000..1ea4795b --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 @@ -0,0 +1,2581 @@ +param( + [Alias("check-current")] + [switch]$CheckCurrent +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = $null +try { + $RepoRoot = & git -C $ScriptDir rev-parse --show-toplevel 2>$null +} catch { + $RepoRoot = $null +} +if (-not $RepoRoot) { + $RepoRoot = (Resolve-Path (Join-Path $ScriptDir "../../../../..")).Path +} else { + $RepoRoot = (Resolve-Path $RepoRoot).Path +} +$PgVersion = "18.4" +$PgSha256 = "81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +$PgUrl = "https://ftp.postgresql.org/pub/source/v$PgVersion/postgresql-$PgVersion.tar.bz2" +$SourceManifest = Join-Path $RepoRoot "src/runtimes/liboliphaunt/native/postgres18/source.toml" +$PatchDir = Join-Path $RepoRoot "src/runtimes/liboliphaunt/native/patches/postgresql-$PgVersion" +$TargetId = "windows-x64-msvc" +$WorkRoot = if ($env:OLIPHAUNT_WINDOWS_WORK_ROOT) { + $env:OLIPHAUNT_WINDOWS_WORK_ROOT +} elseif ($env:OLIPHAUNT_WORK_ROOT) { + $env:OLIPHAUNT_WORK_ROOT +} else { + Join-Path $RepoRoot "target/liboliphaunt-pg18-$TargetId" +} +$SourceCache = Join-Path $WorkRoot "source" +$Tarball = Join-Path $SourceCache "postgresql-$PgVersion.tar.bz2" +$BuildDir = Join-Path $WorkRoot "postgresql-$PgVersion" +$RuntimeBuildDir = Join-Path $WorkRoot "meson-runtime" +$EmbeddedBuildDir = Join-Path $WorkRoot "meson-embedded" +$RuntimeNativeFile = Join-Path $WorkRoot "meson-runtime-native.ini" +$EmbeddedNativeFile = Join-Path $WorkRoot "meson-embedded-native.ini" +$InstallDir = Join-Path $WorkRoot "install" +$OutDir = Join-Path $WorkRoot "out" +$ObjDir = Join-Path $OutDir "obj" +$DllOut = Join-Path $OutDir "bin/oliphaunt.dll" +$ImportLibOut = Join-Path $OutDir "lib/oliphaunt.lib" +$Stamp = Join-Path $OutDir "oliphaunt-windows.inputs.sha256" +$ExternalCheckoutRoot = Join-Path $RepoRoot "target/oliphaunt-sources/checkouts" +$OpenSslSourceManifest = Join-Path $RepoRoot "src/sources/third-party/shared/openssl.toml" +$PgxsBuildPlan = Join-Path $RepoRoot "src/extensions/generated/pgxs-build.tsv" +$PortableUuidDir = Join-Path $RepoRoot "src/runtimes/liboliphaunt/native/portable-uuid" +$PortableUuidIncludeDir = Join-Path $PortableUuidDir "include" +$OpenSslDependencyPrefix = Join-Path $WorkRoot "windows-dependencies/openssl" +$PostgisDependencyPrefix = Join-Path $WorkRoot "windows-dependencies/postgis" +$OliphauntContribDir = Join-Path $BuildDir "contrib/oliphaunt_external" +$BuildExtensions = if ($env:OLIPHAUNT_BUILD_EXTENSIONS) { $env:OLIPHAUNT_BUILD_EXTENSIONS } else { "0" } +$NativeExtensionSqlNames = if ($env:OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES) { + $env:OLIPHAUNT_NATIVE_EXTENSION_SQL_NAMES +} elseif ($env:OLIPHAUNT_EXTENSION_SQL_NAMES) { + $env:OLIPHAUNT_EXTENSION_SQL_NAMES +} else { + "" +} +$SelectedNativeExtensionSqlNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +foreach ($name in ($NativeExtensionSqlNames -split ",")) { + $trimmed = $name.Trim() + if ($trimmed) { + [void]$SelectedNativeExtensionSqlNames.Add($trimmed) + } +} + +$LiboliphauntSources = @( + "src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c", + "src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c" +) | ForEach-Object { Join-Path $RepoRoot $_ } + +function Fail($Message) { + Write-Error "build-postgres18-windows.ps1: $Message" + exit 1 +} + +function Require-Command($Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + Fail "missing required command: $Name" + } +} + +function Normalize-PathEntry([string]$PathEntry) { + $trimmed = $PathEntry.Trim().TrimEnd([char[]]@('\', '/')) + if (-not $trimmed) { + return "" + } + try { + [System.IO.Path]::GetFullPath($trimmed).TrimEnd([char[]]@('\', '/')).ToLowerInvariant() + } catch { + $trimmed.ToLowerInvariant() + } +} + +function Set-ProcessPath([string[]]$Entries) { + $seen = @{} + $clean = New-Object System.Collections.Generic.List[string] + foreach ($entry in $Entries) { + if ([string]::IsNullOrWhiteSpace($entry)) { + continue + } + $trimmed = $entry.Trim() + $key = Normalize-PathEntry $trimmed + if ($key -and -not $seen.ContainsKey($key)) { + $seen[$key] = $true + $clean.Add($trimmed) | Out-Null + } + } + Set-Item -Path Env:Path -Value ([string]::Join(";", $clean)) +} + +function Prepend-ProcessPath([string[]]$Entries) { + Set-ProcessPath (@($Entries) + @($env:Path -split ";")) +} + +function Is-MsysToolPath([string]$PathEntry) { + $normalized = Normalize-PathEntry $PathEntry + return $normalized -match '\\git\\usr\\bin$' -or + $normalized -match '\\git\\mingw64\\bin$' -or + $normalized -match '\\mingw64\\bin$' +} + +function Resolve-ApplicationPath([string]$Name) { + $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $command) { + Fail "missing required command: $Name" + } + $command.Source +} + +function Get-PythonCommand { + $python = Get-Command python -ErrorAction SilentlyContinue + if ($python) { + return [PSCustomObject]@{ + Command = $python.Source + Arguments = @() + } + } + $py = Get-Command py -ErrorAction SilentlyContinue + if ($py) { + return [PSCustomObject]@{ + Command = $py.Source + Arguments = @("-3") + } + } + Fail "missing required command: python" +} + +function Invoke-Python([string[]]$Arguments) { + $python = Get-PythonCommand + & $python.Command @($python.Arguments) @Arguments + if ($LASTEXITCODE -ne 0) { + Fail "python command failed: $($Arguments -join ' ')" + } +} + +function Add-PythonUserScriptsToPath { + $python = Get-PythonCommand + $script = @" +import os +import site +import sysconfig + +paths = [] +for scheme in (None, "nt_user"): + try: + path = sysconfig.get_path("scripts", scheme=scheme) if scheme else sysconfig.get_path("scripts") + except Exception: + path = None + if path: + paths.append(path) +user_base = getattr(site, "USER_BASE", None) +if user_base: + paths.append(os.path.join(user_base, "Scripts")) +seen = set() +for path in paths: + normalized = os.path.normcase(os.path.normpath(path)) + if normalized not in seen: + seen.add(normalized) + print(path) +"@ + $scriptPaths = & $python.Command @($python.Arguments) -c $script + foreach ($scripts in $scriptPaths) { + if ($scripts -and (Test-Path $scripts)) { + Prepend-ProcessPath @($scripts) + } + } +} + +function Ensure-MesonTools { + Add-PythonUserScriptsToPath + if (-not (Get-Command meson -ErrorAction SilentlyContinue) -or -not (Get-Command ninja -ErrorAction SilentlyContinue)) { + Invoke-Python @("-m", "pip", "install", "--user", "meson==1.10.0", "ninja==1.13.0") + Add-PythonUserScriptsToPath + } + Require-Command meson + Require-Command ninja +} + +function Import-MsvcEnvironment { + if (Get-Command cl.exe -ErrorAction SilentlyContinue) { + return + } + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe" + if (-not (Test-Path $vswhere)) { + Fail "vswhere.exe was not found; install Visual Studio Build Tools with MSVC x64 tools" + } + $vsRoot = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsRoot) { + Fail "Visual Studio Build Tools with MSVC x64 tools were not found" + } + $vsDevCmd = Join-Path $vsRoot "Common7/Tools/VsDevCmd.bat" + if (-not (Test-Path $vsDevCmd)) { + Fail "VsDevCmd.bat was not found at $vsDevCmd" + } + cmd.exe /s /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 >nul && set" | + ForEach-Object { + if ($_ -match "^(.*?)=(.*)$") { + Set-Item -Path "Env:$($Matches[1])" -Value $Matches[2] + } + } + Require-Command cl.exe + Require-Command link.exe + Require-Command dumpbin.exe +} + +function Configure-MsvcToolchainPath { + if (-not $env:VCToolsInstallDir) { + Fail "VCToolsInstallDir is not set; run from an MSVC developer environment" + } + $msvcBin = Join-Path $env:VCToolsInstallDir "bin/HostX64/x64" + $requiredTools = @("cl.exe", "link.exe", "lib.exe", "dumpbin.exe") + foreach ($tool in $requiredTools) { + $toolPath = Join-Path $msvcBin $tool + if (-not (Test-Path $toolPath)) { + Fail "MSVC tool was not found at $toolPath" + } + } + + $filteredPath = @($env:Path -split ";") | Where-Object { -not (Is-MsysToolPath $_) } + Set-ProcessPath (@($msvcBin) + $filteredPath) + + foreach ($tool in $requiredTools) { + $resolved = Resolve-ApplicationPath $tool + if (-not $resolved.StartsWith($msvcBin, [System.StringComparison]::OrdinalIgnoreCase)) { + Fail "$tool resolved to $resolved instead of the MSVC tool directory $msvcBin" + } + } + + $env:CC = "cl.exe" + $env:CXX = "cl.exe" + $env:AR = "lib.exe" + Write-Host "Using MSVC tools from $msvcBin" +} + +function Prefer-NativePerl { + $candidateDirs = @( + "C:\Strawberry\perl\bin", + "C:\Perl64\bin" + ) + foreach ($dir in $candidateDirs) { + if (Test-Path (Join-Path $dir "perl.exe")) { + Prepend-ProcessPath @($dir) + break + } + } + $perl = Get-Command perl.exe -ErrorAction SilentlyContinue + if (-not $perl) { + Fail "missing required command: perl.exe" + } + if ($perl.Source -like "*\Git\usr\bin\perl.exe") { + Fail "Git/MSYS Perl cannot drive PostgreSQL's MSVC build because it rewrites native tool arguments; install Strawberry Perl or another native Windows Perl" + } +} + +function Get-FileSha256($Path) { + (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLowerInvariant() +} + +function NativeExtension-Selected([string]$SqlName) { + if ($BuildExtensions -eq "0") { + return $false + } + if ($SelectedNativeExtensionSqlNames.Count -eq 0) { + return $true + } + $SelectedNativeExtensionSqlNames.Contains($SqlName) +} + +function Assert-WindowsNativeExtensionSelectionSupported { +} + +function Meson-Quote([string]$Value) { + "'" + $Value.Replace("\", "/").Replace("'", "\'") + "'" +} + +function Meson-Path([string]$Path) { + ([System.IO.Path]::GetFullPath($Path)).Replace("\", "/") +} + +function Meson-List([string[]]$Values, [string]$Indent = " ") { + if ($Values.Count -eq 0) { + return "" + } + (($Values | ForEach-Object { "$Indent$(Meson-Quote $_)" }) -join ",`n") +} + +function Meson-DataInstall([string[]]$Files) { + $fileList = Meson-List $Files +@" +install_data( +$fileList, + kwargs: contrib_data_args, +) +"@ +} + +function Copy-SourceTree([string]$Source, [string]$Destination) { + if (-not (Test-Path $Source)) { + Fail "missing source checkout: $Source" + } + Remove-Item -Recurse -Force $Destination -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force + Remove-Item -Recurse -Force (Join-Path $Destination ".git") -ErrorAction SilentlyContinue +} + +function External-Checkout([string]$CheckoutName) { + Join-Path $ExternalCheckoutRoot $CheckoutName +} + +function Get-PatchSeries { + $inSeries = $false + foreach ($line in Get-Content $SourceManifest) { + if ($line -match "series\s*=\s*\[") { + $inSeries = $true + continue + } + if ($inSeries -and $line -match "\]") { + break + } + if ($inSeries -and $line -match '"([^"]+\.patch)"') { + $Matches[1] + } + } +} + +function Get-DesiredHash { + $parts = New-Object System.Collections.Generic.List[string] + $parts.Add("pg_version=$PgVersion") + $parts.Add("pg_sha256=$PgSha256") + $parts.Add("target_id=$TargetId") + $parts.Add("build_extensions=$BuildExtensions") + $parts.Add("native_extension_sql_names=$NativeExtensionSqlNames") + $parts.Add("script=$(Get-FileSha256 $PSCommandPath)") + $parts.Add("source_manifest=$(Get-FileSha256 $SourceManifest)") + foreach ($patch in Get-PatchSeries) { + $parts.Add("patch:$patch=$(Get-FileSha256 (Join-Path $PatchDir $patch))") + } + foreach ($source in $LiboliphauntSources) { + $parts.Add("source:$source=$(Get-FileSha256 $source)") + } + foreach ($source in @( + $OpenSslSourceManifest, + $PgxsBuildPlan, + (Join-Path $PortableUuidDir "portable_uuid.c"), + (Join-Path $PortableUuidIncludeDir "uuid/uuid.h"), + (Join-Path $RepoRoot "src/extensions/external/pg_hashids/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/pg_ivm/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/pg_textsearch/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/pg_uuidv7/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/postgis/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/postgis/deps.toml"), + (Join-Path $RepoRoot "src/extensions/external/postgis/dependencies/geos/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/postgis/dependencies/json-c/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/postgis/dependencies/libxml2/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/postgis/dependencies/proj/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/postgis/dependencies/sqlite/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/pgtap/source.toml"), + (Join-Path $RepoRoot "src/extensions/external/vector/source.toml") + )) { + if (Test-Path $source) { + $parts.Add("source-input:$source=$(Get-FileSha256 $source)") + } + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes(($parts -join "`n") + "`n") + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + (($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha.Dispose() + } +} + +function Invoke-Logged([string]$LogName, [scriptblock]$Block) { + $log = Join-Path $WorkRoot $LogName + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $log) | Out-Null + $global:LASTEXITCODE = 0 + & $Block *> $log + if ($LASTEXITCODE -ne 0) { + [Console]::Error.WriteLine("==== $LogName tail ====") + if (Test-Path $log) { + Get-Content $log -Tail 160 | ForEach-Object { [Console]::Error.WriteLine($_) } + } else { + [Console]::Error.WriteLine("(log file was not created: $log)") + } + [Console]::Error.WriteLine("==== end $LogName tail ====") + Fail "$LogName failed; see $log" + } +} + +function Expand-PostgresSourceArchive { + $script = @' +import sys +import tarfile +from pathlib import Path + +archive = Path(sys.argv[1]) +destination = Path(sys.argv[2]).resolve() +with tarfile.open(archive, "r:bz2") as source: + members = source.getmembers() + for member in members: + target = (destination / member.name).resolve() + if target != destination and destination not in target.parents: + raise SystemExit(f"archive member escapes extraction root: {member.name}") + try: + source.extractall(destination, members=members, filter="data") + except TypeError: + source.extractall(destination, members=members) +'@ + Invoke-Python @("-c", $script, $Tarball, $WorkRoot) +} + +function Prepare-Source([string]$DesiredHash) { + New-Item -ItemType Directory -Force -Path $SourceCache, $WorkRoot, $OutDir, $ObjDir | Out-Null + if (-not (Test-Path $Tarball)) { + curl.exe -L --fail --silent --show-error $PgUrl -o $Tarball + if ($LASTEXITCODE -ne 0) { + Fail "failed to download PostgreSQL $PgVersion" + } + } + $actual = Get-FileSha256 $Tarball + if ($actual -ne $PgSha256) { + Fail "PostgreSQL source checksum mismatch: expected $PgSha256, got $actual" + } + $current = if (Test-Path $Stamp) { (Get-Content $Stamp -Raw).Trim() } else { "" } + if ((Test-Path $BuildDir) -and $current -ne $DesiredHash) { + Remove-Item -Recurse -Force $BuildDir, $RuntimeBuildDir, $EmbeddedBuildDir, $InstallDir, $OutDir -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $OutDir, $ObjDir | Out-Null + } + if (-not (Test-Path $BuildDir)) { + Expand-PostgresSourceArchive + Push-Location $BuildDir + try { + git init -q + foreach ($patch in Get-PatchSeries) { + git apply --recount --whitespace=nowarn (Join-Path $PatchDir $patch) + if ($LASTEXITCODE -ne 0) { + Fail "failed to apply PostgreSQL patch $patch" + } + } + } finally { + Pop-Location + } + } + Assert-PatchedSource +} + +function Assert-FileContains([string]$Path, [string]$Needle) { + if (-not (Test-Path $Path)) { + Fail "missing patched PostgreSQL source file $Path" + } + $text = Get-Content -Raw -Path $Path + if (-not $text.Contains($Needle)) { + Fail "patched PostgreSQL source file $Path does not contain required marker $Needle" + } +} + +function Assert-PatchedSource { + Assert-FileContains (Join-Path $BuildDir "src/include/libpq/libpq-be.h") "OliphauntEmbeddedIO" + Assert-FileContains (Join-Path $BuildDir "src/backend/tcop/postgres.c") "oliphaunt_embedded_main" + Assert-FileContains (Join-Path $BuildDir "meson_options.txt") "oliphaunt_embedded" + Assert-FileContains (Join-Path $BuildDir "meson.build") "OLIPHAUNT_EMBEDDED" +} + +function Append-OliphauntContribSubdir([string]$Subdir) { + $contribMeson = Join-Path $BuildDir "contrib/meson.build" + $line = "subdir('oliphaunt_external/$Subdir')" + $text = Get-Content -Raw -Path $contribMeson + if (-not $text.Contains($line)) { + Add-Content -Path $contribMeson -Value $line + } +} + +function Write-OliphauntMesonModule( + [string]$Subdir, + [string]$ModuleName, + [string[]]$Sources, + [string[]]$DataFiles, + [string[]]$CArgs = @(), + [string[]]$LinkArgs = @(), + [string[]]$LocalIncludeDirs = @() +) { + $destination = Join-Path $OliphauntContribDir $Subdir + New-Item -ItemType Directory -Force -Path $destination | Out-Null + $variable = $Subdir.Replace("-", "_") + $sourceList = Meson-List $Sources + $extraKwargs = New-Object System.Collections.Generic.List[string] + if ($CArgs.Count -gt 0) { + $extraKwargs.Add("'c_args': [`n$(Meson-List $CArgs " ")`n ]") + } + if ($LinkArgs.Count -gt 0) { + $extraKwargs.Add("'link_args': [`n$(Meson-List $LinkArgs " ")`n ]") + } + $extraKwargsText = "" + if ($extraKwargs.Count -gt 0) { + $extraKwargsText = " + {`n $($extraKwargs -join ",`n ")`n}" + } + $includeText = "" + if ($LocalIncludeDirs.Count -gt 0) { + $includeText = " include_directories: [$((($LocalIncludeDirs | ForEach-Object { "include_directories($(Meson-Quote $_))" }) -join ', '))],`n" + } + $dataInstall = Meson-DataInstall $DataFiles + $meson = @" +$variable = shared_module( + $(Meson-Quote $ModuleName), + files( +$sourceList, + ), + c_pch: pch_postgres_h, +$includeText kwargs: contrib_mod_args$extraKwargsText, +) +contrib_targets += $variable + +$dataInstall +"@ + Set-Content -Path (Join-Path $destination "meson.build") -Value $meson -Encoding UTF8 + Append-OliphauntContribSubdir $Subdir +} + +function Build-WindowsOpenSslDependency { + if (-not (NativeExtension-Selected "pgcrypto")) { + return + } + $includeDir = Join-Path $OpenSslDependencyPrefix "include/openssl" + $libCrypto = Join-Path $OpenSslDependencyPrefix "lib/libcrypto.lib" + if ((Test-Path $includeDir) -and (Test-Path $libCrypto)) { + return + } + Require-Command nmake.exe + $sourceDir = External-Checkout "openssl" + if (-not (Test-Path (Join-Path $sourceDir "Configure"))) { + Fail "missing OpenSSL checkout for pgcrypto: $sourceDir" + } + $buildRoot = Join-Path $WorkRoot "openssl-windows-build" + Remove-Item -Recurse -Force $buildRoot, $OpenSslDependencyPrefix -ErrorAction SilentlyContinue + Copy-SourceTree $sourceDir $buildRoot + Invoke-Logged "openssl-windows-build.log" { + Push-Location $buildRoot + try { + & perl Configure VC-WIN64A no-shared no-tests no-apps no-module no-asm ` + "--prefix=$OpenSslDependencyPrefix" ` + "--openssldir=$(Join-Path $OpenSslDependencyPrefix "ssl")" + if ($LASTEXITCODE -ne 0) { return } + & nmake.exe /nologo build_generated libcrypto.lib + if ($LASTEXITCODE -ne 0) { return } + & nmake.exe /nologo install_sw + } finally { + Pop-Location + } + } + if (-not (Test-Path $includeDir) -or -not (Test-Path $libCrypto)) { + Fail "OpenSSL Windows build did not produce include/openssl and lib/libcrypto.lib under $OpenSslDependencyPrefix" + } +} + +function Find-FirstFileOrNull([string]$Root, [string[]]$Filters) { + if (-not (Test-Path $Root)) { + return $null + } + foreach ($filter in $Filters) { + $item = Get-ChildItem -Path $Root -Recurse -Filter $filter -File -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($item) { + return $item.FullName + } + } + $null +} + +function Invoke-CmakeInstall( + [string]$Name, + [string]$SourceDir, + [string]$BuildRoot, + [string]$Prefix, + [string[]]$ConfigureArgs = @() +) { + Require-Command cmake + Require-Command ninja + Remove-Item -Recurse -Force $BuildRoot, $Prefix -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $BuildRoot, $Prefix | Out-Null + $cmakeArgs = @( + "-S", $SourceDir, + "-B", $BuildRoot, + "-G", "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + "-DCMAKE_INSTALL_PREFIX=$Prefix", + "-DCMAKE_C_COMPILER=cl.exe", + "-DCMAKE_CXX_COMPILER=cl.exe", + "-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL" + ) + $ConfigureArgs + Invoke-Logged "postgis-$Name-cmake-configure.log" { cmake @cmakeArgs } + Invoke-Logged "postgis-$Name-cmake-install.log" { cmake --build $BuildRoot --config Release --target install } +} + +function Build-WindowsPostgisJsonCDependency { + if (-not (NativeExtension-Selected "postgis")) { + return + } + $prefix = Join-Path $PostgisDependencyPrefix "json-c" + $archive = Find-FirstFileOrNull $prefix @("json-c.lib", "json-c-static.lib") + if ((Test-Path (Join-Path $prefix "include/json-c")) -and $archive) { + return + } + $sourceDir = External-Checkout "json-c" + if (-not (Test-Path (Join-Path $sourceDir "CMakeLists.txt"))) { + Fail "missing JSON-C checkout for PostGIS: $sourceDir" + } + Invoke-CmakeInstall "json-c" $sourceDir (Join-Path $WorkRoot "json-c-windows-build") $prefix @( + "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + "-DBUILD_SHARED_LIBS=OFF", + "-DBUILD_STATIC_LIBS=ON", + "-DBUILD_APPS=OFF", + "-DBUILD_TESTING=OFF", + "-DDISABLE_WERROR=ON" + ) + [void](First-File $prefix @("json-c.lib", "json-c-static.lib")) +} + +function Build-WindowsPostgisSqliteDependency { + if (-not (NativeExtension-Selected "postgis")) { + return + } + $prefix = Join-Path $PostgisDependencyPrefix "sqlite" + $archive = Join-Path $prefix "lib/sqlite3.lib" + $shell = Join-Path $prefix "bin/sqlite3.exe" + if ((Test-Path $archive) -and (Test-Path $shell) -and (Test-Path (Join-Path $prefix "include/sqlite3.h"))) { + return + } + Require-Command nmake.exe + $sourceDir = External-Checkout "sqlite" + if (-not (Test-Path (Join-Path $sourceDir "Makefile.msc"))) { + Fail "missing SQLite checkout for PostGIS: $sourceDir" + } + $buildRoot = Join-Path $WorkRoot "sqlite-windows-build" + Remove-Item -Recurse -Force $buildRoot, $prefix -ErrorAction SilentlyContinue + Copy-SourceTree $sourceDir $buildRoot + Invoke-Logged "postgis-sqlite-windows-build.log" { + Push-Location $buildRoot + try { + # SQLite's MSVC makefile still injects /NODEFAULTLIB:msvcrt into + # host-tool links. Let the runner's MSVC/UCRT defaults resolve CRT + # symbols instead, and skip TCL artifacts that PostGIS does not use. + & nmake.exe /nologo /f Makefile.msc libsqlite3.lib sqlite3.exe ` + USE_CRT_DLL=1 NO_TCL=1 LDFLAGS= ` + OPTS="-DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION" + } finally { + Pop-Location + } + } + New-Item -ItemType Directory -Force -Path (Join-Path $prefix "include"), (Join-Path $prefix "lib"), (Join-Path $prefix "bin") | Out-Null + Copy-Item -Force (Join-Path $buildRoot "libsqlite3.lib") $archive + Copy-Item -Force (Join-Path $buildRoot "sqlite3.exe") $shell + Copy-Item -Force (Join-Path $buildRoot "sqlite3.h"), (Join-Path $buildRoot "sqlite3ext.h") (Join-Path $prefix "include") + if (-not (Test-Path $archive) -or -not (Test-Path $shell) -or -not (Test-Path (Join-Path $prefix "include/sqlite3.h"))) { + Fail "SQLite Windows build did not produce sqlite3.lib, sqlite3.exe, and headers under $prefix" + } +} + +function Build-WindowsPostgisGeosDependency { + if (-not (NativeExtension-Selected "postgis")) { + return + } + $prefix = Join-Path $PostgisDependencyPrefix "geos" + if ((Test-Path (Join-Path $prefix "include/geos_c.h")) -and + (Find-FirstFileOrNull $prefix @("geos_c.lib")) -and + (Find-FirstFileOrNull $prefix @("geos.lib"))) { + return + } + $sourceDir = External-Checkout "geos" + if (-not (Test-Path (Join-Path $sourceDir "CMakeLists.txt"))) { + Fail "missing GEOS checkout for PostGIS: $sourceDir" + } + Invoke-CmakeInstall "geos" $sourceDir (Join-Path $WorkRoot "geos-windows-build") $prefix @( + "-DBUILD_SHARED_LIBS=OFF", + "-DBUILD_TESTING=OFF", + "-DBUILD_BENCHMARKS=OFF", + "-DBUILD_GEOSOP=OFF", + "-DGEOS_BUILD_DEVELOPER=OFF" + ) + [void](First-File $prefix @("geos_c.lib")) + [void](First-File $prefix @("geos.lib")) +} + +function Build-WindowsPostgisLibxml2Dependency { + if (-not (NativeExtension-Selected "postgis")) { + return + } + $prefix = Join-Path $PostgisDependencyPrefix "libxml2" + if ((Test-Path (Join-Path $prefix "include/libxml2/libxml/parser.h")) -and + (Find-FirstFileOrNull $prefix @("libxml2s.lib", "libxml2.lib", "xml2.lib"))) { + return + } + $sourceDir = External-Checkout "libxml2" + if (-not (Test-Path (Join-Path $sourceDir "CMakeLists.txt"))) { + Fail "missing libxml2 checkout for PostGIS: $sourceDir" + } + Invoke-CmakeInstall "libxml2" $sourceDir (Join-Path $WorkRoot "libxml2-windows-build") $prefix @( + "-DBUILD_SHARED_LIBS=OFF", + "-DLIBXML2_WITH_PROGRAMS=OFF", + "-DLIBXML2_WITH_TESTS=OFF", + "-DLIBXML2_WITH_PYTHON=OFF", + "-DLIBXML2_WITH_THREADS=OFF", + "-DLIBXML2_WITH_MODULES=OFF", + "-DLIBXML2_WITH_ICONV=OFF", + "-DLIBXML2_WITH_ZLIB=OFF", + "-DLIBXML2_WITH_LZMA=OFF", + "-DLIBXML2_WITH_HTTP=OFF" + ) + [void](First-File $prefix @("libxml2s.lib", "libxml2.lib", "xml2.lib")) +} + +function Build-WindowsPostgisProjDependency { + if (-not (NativeExtension-Selected "postgis")) { + return + } + Build-WindowsPostgisSqliteDependency + $prefix = Join-Path $PostgisDependencyPrefix "proj" + if ((Test-Path (Join-Path $prefix "include/proj.h")) -and + (Test-Path (Join-Path $prefix "share/proj/proj.db")) -and + (Find-FirstFileOrNull $prefix @("proj.lib", "libproj.lib"))) { + return + } + $sourceDir = External-Checkout "proj" + if (-not (Test-Path (Join-Path $sourceDir "CMakeLists.txt"))) { + Fail "missing PROJ checkout for PostGIS: $sourceDir" + } + $sqlitePrefix = Join-Path $PostgisDependencyPrefix "sqlite" + $sqliteInclude = Join-Path $sqlitePrefix "include" + $sqliteLib = Join-Path $sqlitePrefix "lib/sqlite3.lib" + $sqliteExe = Join-Path $sqlitePrefix "bin/sqlite3.exe" + $buildRoot = Join-Path $WorkRoot "proj-windows-build" + Invoke-CmakeInstall "proj" $sourceDir $buildRoot $prefix @( + "-DBUILD_SHARED_LIBS=OFF", + "-DSQLite3_INCLUDE_DIR=$sqliteInclude", + "-DSQLite3_LIBRARY=$sqliteLib", + "-DEXE_SQLITE3=$sqliteExe", + "-DENABLE_TIFF=OFF", + "-DENABLE_CURL=OFF", + "-DENABLE_EMSCRIPTEN_FETCH=OFF", + "-DHAVE_LIBDL=OFF", + "-DBUILD_APPS=OFF", + "-DBUILD_TESTING=OFF", + "-DBUILD_EXAMPLES=OFF", + "-DEMBED_RESOURCE_FILES=ON", + "-DUSE_ONLY_EMBEDDED_RESOURCE_FILES=ON" + ) + $projDb = Join-Path $prefix "share/proj/proj.db" + if (-not (Test-Path $projDb) -and (Test-Path (Join-Path $buildRoot "data/proj.db"))) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $projDb) | Out-Null + Copy-Item -Force (Join-Path $buildRoot "data/proj.db") $projDb + } + [void](First-File $prefix @("proj.lib", "libproj.lib")) + if (-not (Test-Path $projDb)) { + Fail "PROJ Windows build did not produce proj.db under $prefix" + } +} + +function Build-WindowsPostgisDependencies { + if (-not (NativeExtension-Selected "postgis")) { + return + } + Build-WindowsPostgisSqliteDependency + Build-WindowsPostgisJsonCDependency + Build-WindowsPostgisGeosDependency + Build-WindowsPostgisLibxml2Dependency + Build-WindowsPostgisProjDependency +} + +function Read-PostgisVersionConfig([string]$PostgisSourceDir) { + $versionPath = Join-Path $PostgisSourceDir "Version.config" + if (-not (Test-Path $versionPath)) { + Fail "missing PostGIS Version.config: $versionPath" + } + $values = @{} + foreach ($line in Get-Content $versionPath) { + if ($line -match "^([A-Z0-9_]+)=(.*)$") { + $values[$Matches[1]] = $Matches[2].Trim() + } + } + foreach ($key in @("POSTGIS_MAJOR_VERSION", "POSTGIS_MINOR_VERSION", "POSTGIS_MICRO_VERSION")) { + if (-not $values.ContainsKey($key)) { + Fail "PostGIS Version.config does not define $key" + } + } + [PSCustomObject]@{ + Major = $values["POSTGIS_MAJOR_VERSION"] + Minor = $values["POSTGIS_MINOR_VERSION"] + Micro = $values["POSTGIS_MICRO_VERSION"] + Version = "$($values["POSTGIS_MAJOR_VERSION"]).$($values["POSTGIS_MINOR_VERSION"]).$($values["POSTGIS_MICRO_VERSION"])" + MajorMinor = "$($values["POSTGIS_MAJOR_VERSION"]).$($values["POSTGIS_MINOR_VERSION"])" + } +} + +function Get-PostgisSourceRevision([string]$PostgisSourceDir, [string]$FallbackVersion) { + $revision = "" + try { + $revision = (& git -C $PostgisSourceDir describe --always --dirty=never 2>$null | Select-Object -First 1).Trim() + } catch { + $revision = "" + } + if (-not $revision) { + $revision = $FallbackVersion + } + $revision +} + +function Expand-PostgisTemplate([string]$InputPath, [string]$OutputPath, [hashtable]$Values) { + $text = Get-Content -Raw -Path $InputPath + foreach ($key in $Values.Keys) { + $text = $text.Replace("@$key@", [string]$Values[$key]) + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputPath) | Out-Null + Set-Content -Path $OutputPath -Encoding UTF8 -Value $text +} + +function Initialize-WindowsPostgisGeneratedSource([string]$PostgisDir, [string]$OriginalSourceDir) { + $version = Read-PostgisVersionConfig $PostgisDir + $revision = Get-PostgisSourceRevision $OriginalSourceDir $version.Version + $buildDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss") + $geosVersionNumber = "31401" + $projVersionNumber = "90801" + $libXmlVersion = "2.14.6" + $postgisVersion = "$($version.Major).$($version.Minor) USE_GEOS=1 USE_PROJ=1 USE_STATS=1" + $localeDir = (Meson-Path (Join-Path $InstallDir "share/locale")) + + Set-Content -Path (Join-Path $PostgisDir "postgis_revision.h") -Encoding UTF8 -Value "#define POSTGIS_REVISION $revision" + Set-Content -Path (Join-Path $PostgisDir "postgis_config.h") -Encoding UTF8 -Value @" +/* postgis_config.h. Generated by Oliphaunt's Windows native producer. */ +#ifndef POSTGIS_CONFIG_H +#define POSTGIS_CONFIG_H 1 + +#include "postgis_revision.h" + +#define POSTGIS_DEBUG_LEVEL 0 +/* #undef ENABLE_NLS */ +/* #undef HAVE_GETTEXT */ +/* #undef WORDS_BIGENDIAN */ +/* #undef HAVE_ICONV */ +/* #undef HAVE_ICONVCTL */ +#define HAVE_IEEEFP_H 0 +#define HAVE_LIBGEOS_C 1 +/* #undef HAVE_LIBICONVCTL */ +/* #undef HAVE_LIBPROTOBUF */ +/* #undef LIBPROTOBUF_VERSION */ +#define HAVE_LIBJSON 1 +#define HAVE_LIBPQ 1 +#define HAVE_LIBPROJ 1 +#define HAVE_LIBXML2 1 +#define HAVE_LIBXML_PARSER_H 1 +#define HAVE_LIBXML_TREE_H 1 +#define HAVE_LIBXML_XPATHINTERNALS_H 1 +#define HAVE_LIBXML_XPATH_H 1 +/* #undef HAVE_UNISTD_H */ +/* #undef HAVE_SFCGAL */ +#define LT_OBJDIR ".libs/" +#define PGSQL_LOCALEDIR "$localeDir" +#define POSTGIS_BUILD_DATE "$buildDate" +/* #undef POSTGIS_SFCGAL_VERSION */ +/* #undef POSTGIS_GDAL_VERSION */ +#define POSTGIS_GEOS_VERSION $geosVersionNumber +#define POSTGIS_LIBXML2_VERSION "$libXmlVersion" +#define POSTGIS_LIB_VERSION "$($version.Version)" +#define POSTGIS_MAJOR_VERSION "$($version.Major)" +#define POSTGIS_MINOR_VERSION "$($version.Minor)" +#define POSTGIS_MICRO_VERSION "$($version.Micro)" +#define POSTGIS_PGSQL_VERSION 180 +#define POSTGIS_PROJ_VERSION $projVersionNumber +/* #undef POSTGIS_RASTER_WARN_ON_TRUNCATION */ +#define POSTGIS_SCRIPTS_VERSION "$($version.Version)" +#define POSTGIS_VERSION "$postgisVersion" +#define STDC_HEADERS 1 +#define YYTEXT_POINTER 1 + +#endif /* POSTGIS_CONFIG_H */ +"@ + + $templateValues = @{ + POSTGIS_PGSQL_VERSION = "180" + POSTGIS_PGSQL_HR_VERSION = "18.0" + POSTGIS_GEOS_VERSION = $geosVersionNumber + POSTGIS_PROJ_VERSION = $projVersionNumber + POSTGIS_LIB_VERSION = $version.Version + POSTGIS_LIBXML2_VERSION = $libXmlVersion + POSTGIS_SFCGAL_VERSION = "0" + POSTGIS_VERSION = $postgisVersion + POSTGIS_BUILD_DATE = $buildDate + POSTGIS_SCRIPTS_VERSION = $version.Version + SRID_MAX = "999999" + SRID_USR_MAX = "998999" + POSTGIS_MAJOR_VERSION = $version.Major + POSTGIS_MINOR_VERSION = $version.Minor + } + Expand-PostgisTemplate (Join-Path $PostgisDir "postgis/sqldefines.h.in") (Join-Path $PostgisDir "postgis/sqldefines.h") $templateValues + Expand-PostgisTemplate (Join-Path $PostgisDir "liblwgeom/liblwgeom.h.in") (Join-Path $PostgisDir "liblwgeom/liblwgeom.h") $templateValues + Expand-PostgisTemplate (Join-Path $PostgisDir "extensions/postgis/postgis.control.in") (Join-Path $PostgisDir "extensions/postgis/postgis.control") @{ + EXTVERSION = $version.Version + EXTENSION = "postgis" + MODULEPATH = '$libdir/postgis-3' + } + $version +} + +function Invoke-PostgisSqlPreprocessor([string]$InputPath, [string]$OutputPath, [string[]]$IncludeDirs) { + $script = @' +import pathlib +import re +import sys + +source = pathlib.Path(sys.argv[1]).resolve() +output = pathlib.Path(sys.argv[2]).resolve() +include_dirs = [pathlib.Path(p).resolve() for p in sys.argv[3:]] +macros = {} +result = [] +include_stack = [] +token_re = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b") + +def expand_macros(text): + for _ in range(16): + changed = False + def repl(match): + nonlocal changed + name = match.group(0) + if name in macros: + changed = True + return macros[name] + return name + expanded = token_re.sub(repl, text) + text = expanded + if not changed: + break + return text + +def eval_expr(expr): + expr = expand_macros(expr) + expr = token_re.sub("0", expr) + expr = expr.replace("&&", " and ").replace("||", " or ") + if not re.match(r"^[0-9\s<>=!&|()+*/%.\-andor]+$", expr): + return False + try: + return bool(eval(expr, {"__builtins__": {}}, {})) + except Exception: + return False + +def find_include(name, current): + candidates = [current.parent] + include_dirs + for directory in candidates: + path = directory / name + if path.exists(): + return path.resolve() + raise SystemExit(f"could not resolve SQL include {name} from {current}") + +def in_block_comment_after_line(line, in_block_comment): + offset = 0 + while True: + if in_block_comment: + end = line.find("*/", offset) + if end == -1: + return True + in_block_comment = False + offset = end + 2 + continue + start = line.find("/*", offset) + if start == -1: + return False + end = line.find("*/", start + 2) + if end == -1: + return True + offset = end + 2 + +def process(path): + path = path.resolve() + if path in include_stack: + cycle = include_stack[include_stack.index(path):] + [path] + raise SystemExit("recursive SQL include: " + " -> ".join(str(item) for item in cycle)) + include_stack.append(path) + active = True + stack = [] + in_block_comment = False + try: + for raw in path.read_text(encoding="utf-8").splitlines(True): + stripped = raw.lstrip() + directive = None if in_block_comment or not stripped.startswith("#") else stripped[1:].strip() + if directive is None: + if active: + result.append(expand_macros(raw)) + in_block_comment = in_block_comment_after_line(raw, in_block_comment) + continue + + if directive.startswith("include"): + if active: + match = re.match(r'include\s+"([^"]+)"', directive) + if not match: + raise SystemExit(f"unsupported include directive in {path}: {raw.rstrip()}") + process(find_include(match.group(1), path)) + continue + if directive.startswith("define"): + if active: + match = re.match(r"define\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+(.*?))?\s*$", directive) + if match: + macros[match.group(1)] = match.group(2) if match.group(2) is not None else "1" + continue + if directive.startswith("undef"): + if active: + parts = directive.split() + if len(parts) > 1: + macros.pop(parts[1], None) + continue + if directive.startswith("ifdef"): + name = directive.split(None, 1)[1].strip() + cond = name in macros + stack.append([active, cond]) + active = active and cond + continue + if directive.startswith("ifndef"): + name = directive.split(None, 1)[1].strip() + cond = name not in macros + stack.append([active, cond]) + active = active and cond + continue + if directive.startswith("if"): + cond = eval_expr(directive[2:].strip()) if active else False + stack.append([active, cond]) + active = active and cond + continue + if directive.startswith("elif"): + if not stack: + raise SystemExit(f"orphan #elif in {path}") + parent, taken = stack[-1] + cond = (not taken) and eval_expr(directive[4:].strip()) if parent else False + stack[-1][1] = taken or cond + active = parent and cond + continue + if directive.startswith("else"): + if not stack: + raise SystemExit(f"orphan #else in {path}") + parent, taken = stack[-1] + active = parent and not taken + stack[-1][1] = True + continue + if directive.startswith("endif"): + if not stack: + raise SystemExit(f"orphan #endif in {path}") + parent, _ = stack.pop() + active = parent + continue + + if active: + result.append(raw) + finally: + include_stack.pop() + +process(source) +output.parent.mkdir(parents=True, exist_ok=True) +output.write_text("".join(result), encoding="utf-8") +'@ + $args = @("-c", $script, $InputPath, $OutputPath) + $IncludeDirs + Invoke-Python $args +} + +function New-PostgisSqlFromTemplate( + [string]$InputPath, + [string]$OutputPath, + [string[]]$IncludeDirs, + [string]$ModulePath, + [bool]$StripTransactionBlocks, + [bool]$RemoveExtschemaPrefix +) { + $tmp = "$OutputPath.tmp" + Invoke-PostgisSqlPreprocessor $InputPath $tmp $IncludeDirs + $text = Get-Content -Raw -Path $tmp + Remove-Item -Force $tmp + $text = $text.Replace("MODULE_PATHNAME", $ModulePath) + if ($StripTransactionBlocks) { + $text = $text.Replace("BEGIN;", "").Replace("COMMIT;", "") + } + if ($RemoveExtschemaPrefix) { + $text = $text.Replace("@extschema@.", "") + } + Set-Content -Path $OutputPath -Encoding UTF8 -Value $text +} + +function Invoke-PerlToFile([string[]]$Arguments, [string]$OutputPath) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputPath) | Out-Null + $global:LASTEXITCODE = 0 + & perl @Arguments > $OutputPath + if ($LASTEXITCODE -ne 0) { + Fail "perl command failed: $($Arguments -join ' ')" + } +} + +function Invoke-PerlFromInputFile([string]$InputPath, [string[]]$Arguments, [string]$OutputPath) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputPath) | Out-Null + $global:LASTEXITCODE = 0 + Get-Content -Raw -Path $InputPath | & perl @Arguments > $OutputPath + if ($LASTEXITCODE -ne 0) { + Fail "perl command failed: $($Arguments -join ' ')" + } +} + +function Join-TextFiles([string]$OutputPath, [string[]]$InputPaths, [string]$Prefix = "", [string]$Suffix = "") { + $builder = [System.Text.StringBuilder]::new() + if ($Prefix) { + [void]$builder.Append($Prefix) + if (-not $Prefix.EndsWith("`n")) { + [void]$builder.Append("`n") + } + } + foreach ($path in $InputPaths) { + [void]$builder.Append((Get-Content -Raw -Path $path)) + if (-not $builder.ToString().EndsWith("`n")) { + [void]$builder.Append("`n") + } + } + if ($Suffix) { + [void]$builder.Append($Suffix) + if (-not $Suffix.EndsWith("`n")) { + [void]$builder.Append("`n") + } + } + Set-Content -Path $OutputPath -Encoding UTF8 -Value $builder.ToString() +} + +function New-PostgisRasterUnpackageSql([string]$PostgisDir, [string]$SqlDir, [string[]]$RasterDropSqlFiles) { + $template = Join-Path $PostgisDir "extensions/postgis/unpackage_raster_if_needed.sql" + $prefix = [System.Text.StringBuilder]::new() + $suffix = [System.Text.StringBuilder]::new() + $pastMarker = $false + foreach ($line in Get-Content $template) { + if (-not $pastMarker) { + [void]$prefix.AppendLine($line) + if ($line.Contains("UNPACKAGE_CODE")) { + $pastMarker = $true + } + } else { + [void]$suffix.AppendLine($line) + } + } + $dropSql = Join-Path $SqlDir "raster_drop_all.sql" + Join-TextFiles $dropSql $RasterDropSqlFiles + $unpackageBody = Join-Path $SqlDir "raster_unpackage_body.sql" + Invoke-PerlFromInputFile $dropSql @((Join-Path $PostgisDir "utils/create_extension_unpackage.pl"), "postgis") $unpackageBody + $body = Get-Content -Raw -Path $unpackageBody + Set-Content -Path (Join-Path $SqlDir "raster_unpackage.sql") -Encoding UTF8 -Value ($prefix.ToString() + $body + $suffix.ToString()) +} + +function Convert-PostgisExtensionDropGuards([string]$InputPath, [string]$OutputPath) { + $text = (Get-Content -Raw -Path $InputPath).Replace("BEGIN;", "").Replace("COMMIT;", "") + $lines = New-Object System.Collections.Generic.List[string] + foreach ($line in ($text -split "`r?`n")) { + if ($line -match "^(DROP .*)\;") { + $drop = $Matches[1] + $lines.Add("SELECT @extschema@.postgis_extension_drop_if_exists('postgis', '$drop');") + } + $lines.Add($line) + } + Set-Content -Path $OutputPath -Encoding UTF8 -Value ($lines -join "`n") +} + +function Build-WindowsPostgisSql([string]$PostgisDir, [pscustomobject]$Version) { + $postgisSqlDir = Join-Path $PostgisDir "postgis" + $extensionDir = Join-Path $PostgisDir "extensions/postgis" + $extensionSqlDir = Join-Path $extensionDir "sql" + New-Item -ItemType Directory -Force -Path $extensionSqlDir | Out-Null + $includeDirs = @($postgisSqlDir) + $modulePath = '$libdir/postgis-3' + + New-PostgisSqlFromTemplate (Join-Path $PostgisDir "extensions/postgis_extension_helper.sql.in") (Join-Path $PostgisDir "extensions/postgis_extension_helper.sql") @($PostgisDir, $postgisSqlDir) "" $false $false + New-PostgisSqlFromTemplate (Join-Path $postgisSqlDir "postgis.sql.in") (Join-Path $postgisSqlDir "postgis.sql") $includeDirs $modulePath $false $true + New-PostgisSqlFromTemplate (Join-Path $postgisSqlDir "legacy_minimal.sql.in") (Join-Path $postgisSqlDir "legacy_minimal.sql") $includeDirs $modulePath $false $true + New-PostgisSqlFromTemplate (Join-Path $postgisSqlDir "legacy.sql.in") (Join-Path $postgisSqlDir "legacy.sql") $includeDirs $modulePath $false $true + New-PostgisSqlFromTemplate (Join-Path $postgisSqlDir "legacy_gist.sql.in") (Join-Path $postgisSqlDir "legacy_gist.sql") $includeDirs $modulePath $false $true + + Invoke-PerlToFile @((Join-Path $PostgisDir "utils/create_upgrade.pl"), (Join-Path $postgisSqlDir "postgis.sql")) (Join-Path $postgisSqlDir "postgis_upgrade.sql.in") + Join-TextFiles (Join-Path $postgisSqlDir "postgis_upgrade.sql") @( + (Join-Path $postgisSqlDir "common_before_upgrade.sql"), + (Join-Path $postgisSqlDir "postgis_before_upgrade.sql"), + (Join-Path $postgisSqlDir "postgis_upgrade.sql.in"), + (Join-Path $postgisSqlDir "postgis_after_upgrade.sql"), + (Join-Path $postgisSqlDir "common_after_upgrade.sql") + ) "BEGIN;" "COMMIT;" + Invoke-PerlToFile @((Join-Path $PostgisDir "utils/create_uninstall.pl"), (Join-Path $postgisSqlDir "postgis.sql"), "180") (Join-Path $postgisSqlDir "uninstall_postgis.sql") + Invoke-PerlToFile @((Join-Path $PostgisDir "utils/create_uninstall.pl"), (Join-Path $postgisSqlDir "legacy.sql"), "180") (Join-Path $postgisSqlDir "uninstall_legacy.sql") + + New-PostgisSqlFromTemplate (Join-Path $postgisSqlDir "postgis.sql.in") (Join-Path $extensionSqlDir "postgis_for_extension.sql") $includeDirs $modulePath $true $false + $spatialRefExtension = Join-Path $extensionSqlDir "spatial_ref_sys.sql" + $spatialRefText = (Get-Content -Raw -Path (Join-Path $PostgisDir "spatial_ref_sys.sql")).Replace("BEGIN;", "").Replace("COMMIT;", "") + Set-Content -Path $spatialRefExtension -Encoding UTF8 -Value $spatialRefText + Invoke-PerlToFile @((Join-Path $PostgisDir "utils/create_spatial_ref_sys_config_dump.pl"), (Join-Path $PostgisDir "spatial_ref_sys.sql")) (Join-Path $extensionSqlDir "spatial_ref_sys_config_dump.sql") + Invoke-PerlToFile @((Join-Path $PostgisDir "utils/create_upgrade.pl"), (Join-Path $extensionSqlDir "postgis_for_extension.sql")) (Join-Path $extensionSqlDir "postgis_upgrade_for_extension.sql.in") + Join-TextFiles (Join-Path $extensionSqlDir "postgis_upgrade_for_extension.sql") @( + (Join-Path $postgisSqlDir "common_before_upgrade.sql"), + (Join-Path $postgisSqlDir "postgis_before_upgrade.sql"), + (Join-Path $extensionSqlDir "postgis_upgrade_for_extension.sql.in"), + (Join-Path $postgisSqlDir "postgis_after_upgrade.sql"), + (Join-Path $postgisSqlDir "common_after_upgrade.sql") + ) + $upgradeForExtensionText = (Get-Content -Raw -Path (Join-Path $extensionSqlDir "postgis_upgrade_for_extension.sql")).Replace("BEGIN;", "").Replace("COMMIT;", "") + Set-Content -Path (Join-Path $extensionSqlDir "postgis_upgrade_for_extension.sql") -Encoding UTF8 -Value $upgradeForExtensionText + Convert-PostgisExtensionDropGuards (Join-Path $extensionSqlDir "postgis_upgrade_for_extension.sql") (Join-Path $extensionSqlDir "postgis_upgrade.sql") + + $rasterDir = Join-Path $PostgisDir "raster/rt_pg" + $rasterIncludeDirs = @($postgisSqlDir, $rasterDir) + $rasterBaseSql = Join-Path $rasterDir "rtpostgis.sql" + New-PostgisSqlFromTemplate (Join-Path $rasterDir "rtpostgis.sql.in") $rasterBaseSql $rasterIncludeDirs '$libdir/rtpostgis-3' $false $true + $rasterDropSql = @() + foreach ($name in @("rtpostgis_upgrade_cleanup", "rtpostgis_drop")) { + $output = Join-Path $rasterDir "$name.sql" + New-PostgisSqlFromTemplate (Join-Path $rasterDir "$name.sql.in") $output $rasterIncludeDirs '$libdir/rtpostgis-3' $false $true + $rasterDropSql += $output + } + $rasterUninstallSql = Join-Path $rasterDir "uninstall_rtpostgis.sql" + Invoke-PerlToFile @((Join-Path $PostgisDir "utils/create_uninstall.pl"), $rasterBaseSql, "180") $rasterUninstallSql + $rasterDropSql += $rasterUninstallSql + New-PostgisRasterUnpackageSql $PostgisDir $extensionSqlDir $rasterDropSql + + $installSql = Join-Path $extensionSqlDir "postgis--$($Version.Version).sql" + Join-TextFiles $installSql @( + (Join-Path $extensionSqlDir "postgis_for_extension.sql"), + (Join-Path $extensionSqlDir "spatial_ref_sys_config_dump.sql"), + (Join-Path $extensionSqlDir "spatial_ref_sys.sql") + ) '\echo Use "CREATE EXTENSION postgis" to load this file. \quit' + + $anyUpgradeSql = Join-Path $extensionSqlDir "postgis--ANY--$($Version.Version).sql" + Join-TextFiles $anyUpgradeSql @( + (Join-Path $PostgisDir "extensions/postgis_extension_helper.sql"), + (Join-Path $extensionSqlDir "raster_unpackage.sql"), + (Join-Path $extensionSqlDir "postgis_upgrade.sql"), + (Join-Path $extensionSqlDir "spatial_ref_sys.sql"), + (Join-Path $extensionSqlDir "spatial_ref_sys_config_dump.sql"), + (Join-Path $PostgisDir "extensions/postgis_extension_helper_uninstall.sql") + ) '\echo Use "CREATE EXTENSION postgis" to load this file. \quit' + + $templatedSql = Join-Path $extensionSqlDir "postgis--TEMPLATED--TO--ANY.sql" + Set-Content -Path $templatedSql -Encoding UTF8 -Value @" +-- Just tag extension postgis version as "ANY" +-- Installed by postgis $($Version.Version) +-- Built on $((Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss")) +"@ + Copy-Item -Force $templatedSql (Join-Path $extensionSqlDir "postgis--$($Version.Version)--ANY.sql") + Set-Content -Path (Join-Path $extensionSqlDir "postgis--unpackaged.sql") -Encoding UTF8 -Value "-- Nothing to do here" + $unpackagedVersionSql = Join-Path $extensionSqlDir "postgis--unpackaged--$($Version.Version).sql" + Invoke-PerlFromInputFile $installSql @((Join-Path $PostgisDir "utils/create_unpackaged.pl"), "postgis") $unpackagedVersionSql + Add-Content -Path $unpackagedVersionSql -Encoding UTF8 -Value (Get-Content -Raw -Path $anyUpgradeSql) +} + +function Patch-WindowsPostgisFlatgeobufSource([string]$SourceDir) { + $geometryReader = Join-Path $SourceDir "geometryreader.cpp" + $text = Get-Content -Raw -Path $geometryReader + $pointLiteral = "pt = (POINT4D) { x, y, z, m };" + $pointAssignments = "pt.x = x;`n`tpt.y = y;`n`tpt.z = z;`n`tpt.m = m;" + $arrayLiteral = "pt = (POINT4D) { xv, yv, zv, mv };" + $arrayAssignments = "pt.x = xv;`n`t`tpt.y = yv;`n`t`tpt.z = zv;`n`t`tpt.m = mv;" + foreach ($expected in @($pointLiteral, $arrayLiteral)) { + if (-not $text.Contains($expected)) { + Fail "PostGIS FlatGeobuf geometryreader.cpp is missing expected MSVC patch anchor: $expected" + } + } + $text = $text.Replace($pointLiteral, $pointAssignments) + $text = $text.Replace($arrayLiteral, $arrayAssignments) + Set-Content -Path $geometryReader -Encoding UTF8 -Value $text +} + +function Build-WindowsPostgisFlatgeobufLibrary([string]$PostgisDir) { + $prefix = Join-Path $PostgisDependencyPrefix "flatgeobuf" + $archive = Join-Path $prefix "lib/flatgeobuf.lib" + if (Test-Path $archive) { + return $archive + } + $sourceDir = Join-Path $PostgisDir "deps/flatgeobuf" + Patch-WindowsPostgisFlatgeobufSource $sourceDir + $buildRoot = Join-Path $WorkRoot "postgis-flatgeobuf-windows-build" + Remove-Item -Recurse -Force $buildRoot, $prefix -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $buildRoot, (Split-Path -Parent $archive) | Out-Null + $compatHeader = Join-Path $buildRoot "oliphaunt_flatgeobuf_windows_compat.h" + Set-Content -Path $compatHeader -Encoding UTF8 -Value @" +#ifdef _MSC_VER +#ifndef __attribute__ +#define __attribute__(x) +#endif +#ifndef PROJ_DLL +#define PROJ_DLL +#endif +#endif +"@ + $includeArgs = @( + "/I$(Join-Path $PostgisDir "liblwgeom")", + "/I$sourceDir", + "/I$(Join-Path $sourceDir "include")", + "/I$(Join-Path $PostgisDependencyPrefix "proj/include")" + ) + $objects = New-Object System.Collections.Generic.List[string] + foreach ($source in @("flatgeobuf_c.cpp", "geometrywriter.cpp", "geometryreader.cpp", "packedrtree.cpp")) { + $sourcePath = Join-Path $sourceDir $source + $object = Join-Path $buildRoot ([System.IO.Path]::GetFileNameWithoutExtension($source) + ".obj") + Invoke-Logged "postgis-flatgeobuf-$([System.IO.Path]::GetFileNameWithoutExtension($source)).log" { + cl.exe /nologo /O2 /MD /EHsc /D_CRT_SECURE_NO_WARNINGS /Dflatbuffers=postgis_flatbuffers ` + "/FI$compatHeader" ` + @includeArgs ` + /c $sourcePath "/Fo$object" + } + $objects.Add($object) + } + Invoke-Logged "postgis-flatgeobuf-lib.log" { lib.exe /nologo "/OUT:$archive" @objects } + if (-not (Test-Path $archive)) { + Fail "PostGIS FlatGeobuf Windows build did not produce $archive" + } + $archive +} + +function Copy-WindowsPostgisRuntimeData([string]$PostgisDir) { + $projDb = Join-Path $PostgisDependencyPrefix "proj/share/proj/proj.db" + if (-not (Test-Path $projDb)) { + Fail "PostGIS PROJ dependency did not produce $projDb" + } + $destination = Join-Path $PostgisDir "share/proj" + New-Item -ItemType Directory -Force -Path $destination | Out-Null + Copy-Item -Force $projDb (Join-Path $destination "proj.db") +} + +function Ensure-WindowsPostgisCommentsSql([string]$PostgisDir) { + $comments = Join-Path $PostgisDir "doc/postgis_comments.sql" + if (Test-Path $comments) { + return + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $comments) | Out-Null + Set-Content -Path $comments -Encoding UTF8 -Value "-- PostGIS SQL comments are optional and are not generated by the Windows native producer." +} + +function Patch-WindowsPostgisSource([string]$PostgisDir) { + $compat = Join-Path $PostgisDir "oliphaunt_postgis_windows_compat.h" + Set-Content -Path $compat -Encoding UTF8 -Value @" +#ifndef OLIPHAUNT_POSTGIS_WINDOWS_COMPAT_H +#define OLIPHAUNT_POSTGIS_WINDOWS_COMPAT_H + +#ifdef _MSC_VER +#ifndef __attribute__ +#define __attribute__(x) +#endif +#ifndef __attribute +#define __attribute(x) +#endif +#ifndef FALLTHROUGH +#define FALLTHROUGH ((void)0) +#endif +#ifndef PROJ_DLL +#define PROJ_DLL +#endif +#ifndef strcasecmp +#define strcasecmp _stricmp +#endif +#ifndef strncasecmp +#define strncasecmp _strnicmp +#endif +#endif + +#endif +"@ + + $declarationPattern = "(?m)^\s*(?!(?:extern\s+)?PGDLLEXPORT\s+)(?:extern\s+)?Datum\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(PG_FUNCTION_ARGS\);\r?$" + $patchedDeclarationCount = 0 + foreach ($subdir in @("postgis", "libpgcommon", "liblwgeom")) { + $root = Join-Path $PostgisDir $subdir + foreach ($file in Get-ChildItem -Path $root -Recurse -File | Where-Object { $_.Extension -in @(".c", ".h") }) { + $text = Get-Content -Raw -Path $file.FullName + $patchedDeclarationCount += [regex]::Matches($text, $declarationPattern).Count + $patched = [regex]::Replace( + $text, + $declarationPattern, + 'extern PGDLLEXPORT Datum $1(PG_FUNCTION_ARGS);' + ) + if ($patched -ne $text) { + Set-Content -Path $file.FullName -Encoding UTF8 -Value $patched + } + } + } + + if ($patchedDeclarationCount -lt 50) { + Fail "PostGIS Windows source patch normalized only $patchedDeclarationCount SQL-callable declarations" + } + + $legacySource = Join-Path $PostgisDir "postgis/postgis_legacy.c" + $legacyText = Get-Content -Raw -Path $legacySource + $legacyDeclarationPattern = "(?m)^([ \t]*)Datum[ \t]+funcname[ \t]*\(PG_FUNCTION_ARGS\);[ \t]*\\\r?$" + $legacyPatched = [regex]::Replace( + $legacyText, + $legacyDeclarationPattern, + '$1extern PGDLLEXPORT Datum funcname(PG_FUNCTION_ARGS); \' + ) + if ($legacyPatched -eq $legacyText) { + Fail "PostGIS Windows source patch did not export POSTGIS_DEPRECATE declarations" + } + Set-Content -Path $legacySource -Encoding UTF8 -Value $legacyPatched + + $requiredDeclarations = @( + @{ + Path = "postgis/lwgeom_accum.c" + Functions = @( + "pgis_geometry_accum_transfn", + "pgis_geometry_collect_finalfn", + "pgis_geometry_polygonize_finalfn", + "pgis_geometry_makeline_finalfn", + "pgis_geometry_clusterintersecting_finalfn", + "pgis_geometry_clusterwithin_finalfn" + ) + }, + @{ + Path = "postgis/lwgeom_union.c" + Functions = @( + "pgis_geometry_union_parallel_transfn", + "pgis_geometry_union_parallel_combinefn", + "pgis_geometry_union_parallel_serialfn", + "pgis_geometry_union_parallel_deserialfn", + "pgis_geometry_union_parallel_finalfn" + ) + }, + @{ + Path = "postgis/lwgeom_spheroid.c" + Functions = @( + "ellipsoid_in", + "ellipsoid_out", + "LWGEOM_length2d_ellipsoid", + "LWGEOM_length_ellipsoid_linestring", + "LWGEOM_distance_ellipsoid", + "LWGEOM_distance_sphere", + "geometry_distance_spheroid" + ) + } + ) + foreach ($required in $requiredDeclarations) { + $text = Get-Content -Raw -Path (Join-Path $PostgisDir $required.Path) + foreach ($functionName in $required.Functions) { + $expected = "extern PGDLLEXPORT Datum $functionName(PG_FUNCTION_ARGS);" + if (-not $text.Contains($expected)) { + Fail "PostGIS Windows source patch did not export $functionName in $($required.Path)" + } + } + } +} + +function Write-PostgisMesonModule([string]$PostgisDir, [pscustomobject]$Version, [string]$FlatgeobufLib) { + $jsonLib = First-File (Join-Path $PostgisDependencyPrefix "json-c") @("json-c.lib", "json-c-static.lib") + $sqliteLib = First-File (Join-Path $PostgisDependencyPrefix "sqlite") @("sqlite3.lib", "libsqlite3.lib") + $geosCLib = First-File (Join-Path $PostgisDependencyPrefix "geos") @("geos_c.lib") + $geosLib = First-File (Join-Path $PostgisDependencyPrefix "geos") @("geos.lib") + $libxml2Lib = First-File (Join-Path $PostgisDependencyPrefix "libxml2") @("libxml2s.lib", "libxml2.lib", "xml2.lib") + $projLib = First-File (Join-Path $PostgisDependencyPrefix "proj") @("proj.lib", "libproj.lib") + + $sources = @( + "postgis/postgis_module.c", + "postgis/lwgeom_accum.c", + "postgis/lwgeom_union.c", + "postgis/lwgeom_spheroid.c", + "postgis/lwgeom_ogc.c", + "postgis/lwgeom_functions_analytic.c", + "postgis/lwgeom_functions_basic.c", + "postgis/lwgeom_inout.c", + "postgis/lwgeom_btree.c", + "postgis/lwgeom_box.c", + "postgis/lwgeom_box3d.c", + "postgis/lwgeom_geos.c", + "postgis/lwgeom_geos_predicates.c", + "postgis/lwgeom_geos_prepared.c", + "postgis/lwgeom_geos_clean.c", + "postgis/lwgeom_geos_relatematch.c", + "postgis/lwgeom_generate_grid.c", + "postgis/lwgeom_export.c", + "postgis/lwgeom_in_gml.c", + "postgis/lwgeom_in_kml.c", + "postgis/lwgeom_in_marc21.c", + "postgis/lwgeom_out_marc21.c", + "postgis/lwgeom_in_geohash.c", + "postgis/lwgeom_in_geojson.c", + "postgis/lwgeom_in_encoded_polyline.c", + "postgis/lwgeom_triggers.c", + "postgis/lwgeom_dump.c", + "postgis/lwgeom_dumppoints.c", + "postgis/lwgeom_functions_lrs.c", + "postgis/lwgeom_functions_temporal.c", + "postgis/lwgeom_rectree.c", + "postgis/lwgeom_itree.c", + "postgis/lwgeom_sqlmm.c", + "postgis/lwgeom_transform.c", + "postgis/lwgeom_window.c", + "postgis/gserialized_typmod.c", + "postgis/gserialized_gist_2d.c", + "postgis/gserialized_gist_nd.c", + "postgis/gserialized_supportfn.c", + "postgis/gserialized_spgist_2d.c", + "postgis/gserialized_spgist_3d.c", + "postgis/gserialized_spgist_nd.c", + "postgis/brin_2d.c", + "postgis/brin_nd.c", + "postgis/brin_common.c", + "postgis/gserialized_estimate.c", + "postgis/geography_inout.c", + "postgis/geography_btree.c", + "postgis/geography_centroid.c", + "postgis/geography_measurement.c", + "postgis/geography_measurement_trees.c", + "postgis/geometry_inout.c", + "postgis/postgis_libprotobuf.c", + "postgis/mvt.c", + "postgis/lwgeom_out_mvt.c", + "postgis/geobuf.c", + "postgis/lwgeom_out_geobuf.c", + "postgis/lwgeom_out_geojson.c", + "postgis/flatgeobuf.c", + "postgis/lwgeom_in_flatgeobuf.c", + "postgis/lwgeom_out_flatgeobuf.c", + "postgis/lwgeom_remove_irrelevant_points_for_view.c", + "postgis/lwgeom_remove_small_parts.c", + "postgis/postgis_legacy.c", + "libpgcommon/gserialized_gist.c", + "libpgcommon/lwgeom_transform.c", + "libpgcommon/lwgeom_cache.c", + "libpgcommon/lwgeom_pg.c", + "libpgcommon/shared_gserialized.c", + "liblwgeom/stringbuffer.c", + "liblwgeom/optionlist.c", + "liblwgeom/stringlist.c", + "liblwgeom/bytebuffer.c", + "liblwgeom/measures.c", + "liblwgeom/measures3d.c", + "liblwgeom/ptarray.c", + "liblwgeom/lookup3.c", + "liblwgeom/lwgeom_api.c", + "liblwgeom/lwgeom.c", + "liblwgeom/lwpoint.c", + "liblwgeom/lwline.c", + "liblwgeom/lwpoly.c", + "liblwgeom/lwtriangle.c", + "liblwgeom/lwmpoint.c", + "liblwgeom/lwmline.c", + "liblwgeom/lwmpoly.c", + "liblwgeom/lwboundingcircle.c", + "liblwgeom/lwcollection.c", + "liblwgeom/lwcircstring.c", + "liblwgeom/lwcompound.c", + "liblwgeom/lwcurvepoly.c", + "liblwgeom/lwmcurve.c", + "liblwgeom/lwmsurface.c", + "liblwgeom/lwpsurface.c", + "liblwgeom/lwtin.c", + "liblwgeom/lwout_wkb.c", + "liblwgeom/lwin_geojson.c", + "liblwgeom/lwin_wkb.c", + "liblwgeom/lwin_twkb.c", + "liblwgeom/lwiterator.c", + "liblwgeom/lwgeom_median.c", + "liblwgeom/lwout_wkt.c", + "liblwgeom/lwout_twkb.c", + "liblwgeom/lwin_wkt_parse.c", + "liblwgeom/lwin_wkt_lex.c", + "liblwgeom/lwin_wkt.c", + "liblwgeom/lwin_encoded_polyline.c", + "liblwgeom/lwutil.c", + "liblwgeom/lwhomogenize.c", + "liblwgeom/intervaltree.c", + "liblwgeom/lwalgorithm.c", + "liblwgeom/lwstroke.c", + "liblwgeom/lwlinearreferencing.c", + "liblwgeom/lwprint.c", + "liblwgeom/gbox.c", + "liblwgeom/gserialized.c", + "liblwgeom/gserialized1.c", + "liblwgeom/gserialized2.c", + "liblwgeom/lwgeodetic.c", + "liblwgeom/lwgeodetic_measures.c", + "liblwgeom/lwgeodetic_tree.c", + "liblwgeom/lwrandom.c", + "liblwgeom/lwtree.c", + "liblwgeom/lwout_gml.c", + "liblwgeom/lwout_kml.c", + "liblwgeom/lwout_geojson.c", + "liblwgeom/lwout_svg.c", + "liblwgeom/lwout_x3d.c", + "liblwgeom/lwout_encoded_polyline.c", + "liblwgeom/lwgeom_debug.c", + "liblwgeom/lwgeom_geos.c", + "liblwgeom/lwgeom_geos_clean.c", + "liblwgeom/lwgeom_geos_cluster.c", + "liblwgeom/lwgeom_geos_node.c", + "liblwgeom/lwgeom_geos_split.c", + "liblwgeom/topo/lwgeom_topo.c", + "liblwgeom/topo/lwgeom_topo_polygonizer.c", + "liblwgeom/topo/lwt_edgeend.c", + "liblwgeom/topo/lwt_edgeend_star.c", + "liblwgeom/topo/lwt_node_edges.c", + "liblwgeom/lwgeom_transform.c", + "liblwgeom/lwgeom_wrapx.c", + "liblwgeom/lwunionfind.c", + "liblwgeom/effectivearea.c", + "liblwgeom/lwchaikins.c", + "liblwgeom/lwmval.c", + "liblwgeom/lwkmeans.c", + "liblwgeom/varint.c", + "liblwgeom/lwgeom_remove_irrelevant_points_for_view.c", + "liblwgeom/lwspheroid.c", + "deps/ryu/d2s.c" + ) + $extensionSqlFiles = @( + "extensions/postgis/postgis.control", + "extensions/postgis/sql/postgis--$($Version.Version).sql", + "extensions/postgis/sql/postgis--ANY--$($Version.Version).sql", + "extensions/postgis/sql/postgis--$($Version.Version)--ANY.sql", + "extensions/postgis/sql/postgis--TEMPLATED--TO--ANY.sql", + "extensions/postgis/sql/postgis--unpackaged.sql", + "extensions/postgis/sql/postgis--unpackaged--$($Version.Version).sql" + ) + $contribDataFiles = @( + "postgis/legacy.sql", + "postgis/legacy_gist.sql", + "postgis/legacy_minimal.sql", + "postgis/postgis.sql", + "postgis/postgis_upgrade.sql", + "spatial_ref_sys.sql", + "postgis/uninstall_legacy.sql", + "postgis/uninstall_postgis.sql", + "doc/postgis_comments.sql" + ) + $includeArgs = @( + "/I$(Meson-Path $PostgisDir)", + # liblwgeom has headers with the same basename as the PostgreSQL module. + # Source-local includes still win for postgis/*.c; this order keeps + # liblwgeom/topo/*.c from accidentally including server-side headers. + "/I$(Meson-Path (Join-Path $PostgisDir "liblwgeom"))", + "/I$(Meson-Path (Join-Path $PostgisDir "postgis"))", + "/I$(Meson-Path (Join-Path $PostgisDir "libpgcommon"))", + "/I$(Meson-Path (Join-Path $PostgisDir "deps"))", + "/I$(Meson-Path (Join-Path $PostgisDir "deps/flatgeobuf"))", + "/I$(Meson-Path (Join-Path $PostgisDir "deps/flatgeobuf/include"))", + "/I$(Meson-Path (Join-Path $PostgisDir "deps/ryu"))", + "/I$(Meson-Path (Join-Path $PostgisDependencyPrefix "geos/include"))", + "/I$(Meson-Path (Join-Path $PostgisDependencyPrefix "proj/include"))", + "/I$(Meson-Path (Join-Path $PostgisDependencyPrefix "json-c/include"))", + "/I$(Meson-Path (Join-Path $PostgisDependencyPrefix "json-c/include/json-c"))", + "/I$(Meson-Path (Join-Path $PostgisDependencyPrefix "libxml2/include/libxml2"))" + ) + $cArgs = @( + "/D_CRT_SECURE_NO_WARNINGS", + "/D_USE_MATH_DEFINES", + "/DLIBXML_STATIC", + "/DRYU_NO_TRAILING_ZEROS", + "/FI$(Meson-Path (Join-Path $PostgisDir "oliphaunt_postgis_windows_compat.h"))" + ) + $includeArgs + $linkArgs = @( + (Meson-Path $FlatgeobufLib), + (Meson-Path $geosCLib), + (Meson-Path $geosLib), + (Meson-Path $projLib), + (Meson-Path $sqliteLib), + (Meson-Path $jsonLib), + (Meson-Path $libxml2Lib), + "ws2_32.lib", + "bcrypt.lib", + "advapi32.lib", + "shell32.lib", + "user32.lib" + ) + + $sourceList = Meson-List $sources + $cArgList = Meson-List $cArgs " " + $linkArgList = Meson-List $linkArgs " " + $extensionDataList = Meson-List $extensionSqlFiles + $contribDataList = Meson-List $contribDataFiles + $meson = @" +postgis = shared_module( + 'postgis-3', + files( +$sourceList, + ), + c_pch: pch_postgres_h, + kwargs: contrib_mod_args + { + 'c_args': [ +$cArgList + ], + 'link_args': [ +$linkArgList + ], + }, +) +contrib_targets += postgis + +install_data( +$extensionDataList, + kwargs: contrib_data_args, +) + +install_data( +$contribDataList, + install_dir: dir_data / 'contrib' / 'postgis-$($Version.MajorMinor)', +) + +install_data( + 'share/proj/proj.db', + install_dir: dir_data / 'proj', +) +"@ + Set-Content -Path (Join-Path $PostgisDir "meson.build") -Encoding UTF8 -Value $meson + Append-OliphauntContribSubdir "postgis" +} + +function Add-PostgisMesonProducer { + if (-not (NativeExtension-Selected "postgis")) { + return + } + Build-WindowsPostgisDependencies + $sourceDir = External-Checkout "postgis" + if (-not (Test-Path (Join-Path $sourceDir "Version.config"))) { + Fail "missing PostGIS checkout for Windows extension artifacts: $sourceDir" + } + $destination = Join-Path $OliphauntContribDir "postgis" + Copy-SourceTree $sourceDir $destination + $version = Initialize-WindowsPostgisGeneratedSource $destination $sourceDir + Build-WindowsPostgisSql $destination $version + Ensure-WindowsPostgisCommentsSql $destination + Patch-WindowsPostgisSource $destination + Copy-WindowsPostgisRuntimeData $destination + $flatgeobufLib = Build-WindowsPostgisFlatgeobufLibrary $destination + Write-PostgisMesonModule $destination $version $flatgeobufLib +} + +function Add-PgcryptoMesonProducer { + if (-not (NativeExtension-Selected "pgcrypto")) { + return + } + Build-WindowsOpenSslDependency + $opensslInclude = Meson-Path (Join-Path $OpenSslDependencyPrefix "include") + $libCrypto = Meson-Path (Join-Path $OpenSslDependencyPrefix "lib/libcrypto.lib") + Write-OliphauntMesonModule ` + "pgcrypto" ` + "pgcrypto" ` + @( + "../../pgcrypto/crypt-blowfish.c", + "../../pgcrypto/crypt-des.c", + "../../pgcrypto/crypt-gensalt.c", + "../../pgcrypto/crypt-md5.c", + "../../pgcrypto/crypt-sha.c", + "../../pgcrypto/mbuf.c", + "../../pgcrypto/openssl.c", + "../../pgcrypto/pgcrypto.c", + "../../pgcrypto/pgp-armor.c", + "../../pgcrypto/pgp-cfb.c", + "../../pgcrypto/pgp-compress.c", + "../../pgcrypto/pgp-decrypt.c", + "../../pgcrypto/pgp-encrypt.c", + "../../pgcrypto/pgp-info.c", + "../../pgcrypto/pgp-mpi.c", + "../../pgcrypto/pgp-mpi-openssl.c", + "../../pgcrypto/pgp-pgsql.c", + "../../pgcrypto/pgp-pubdec.c", + "../../pgcrypto/pgp-pubenc.c", + "../../pgcrypto/pgp-pubkey.c", + "../../pgcrypto/pgp-s2k.c", + "../../pgcrypto/pgp.c", + "../../pgcrypto/px-crypt.c", + "../../pgcrypto/px-hmac.c", + "../../pgcrypto/px.c" + ) ` + @( + "../../pgcrypto/pgcrypto--1.0--1.1.sql", + "../../pgcrypto/pgcrypto--1.1--1.2.sql", + "../../pgcrypto/pgcrypto--1.2--1.3.sql", + "../../pgcrypto/pgcrypto--1.3.sql", + "../../pgcrypto/pgcrypto--1.3--1.4.sql", + "../../pgcrypto/pgcrypto.control" + ) ` + @("/I$opensslInclude") ` + @($libCrypto, "crypt32.lib", "advapi32.lib", "bcrypt.lib", "ws2_32.lib", "user32.lib") +} + +function Add-UuidOsspMesonProducer { + if (-not (NativeExtension-Selected "uuid-ossp")) { + return + } + $destination = Join-Path $OliphauntContribDir "uuid_ossp" + New-Item -ItemType Directory -Force -Path $destination | Out-Null + Copy-Item -Force (Join-Path $PortableUuidDir "portable_uuid.c") (Join-Path $destination "portable_uuid.c") + $portableUuidInclude = Meson-Path $PortableUuidIncludeDir + Write-OliphauntMesonModule ` + "uuid_ossp" ` + "uuid-ossp" ` + @("../../uuid-ossp/uuid-ossp.c", "portable_uuid.c") ` + @( + "../../uuid-ossp/uuid-ossp--1.0--1.1.sql", + "../../uuid-ossp/uuid-ossp--1.1.sql", + "../../uuid-ossp/uuid-ossp.control" + ) ` + @("/I$portableUuidInclude", "/DHAVE_UUID_E2FS=1", "/DHAVE_UUID_UUID_H=1") +} + +function Patch-PgTextsearchWindowsSource([string]$ExtensionDir) { + $compat = Join-Path $ExtensionDir "src/oliphaunt_windows_compat.h" + Set-Content -Path $compat -Encoding UTF8 -Value @" +#ifdef _MSC_VER +#ifndef __attribute__ +#define __attribute__(x) +#endif +#endif +"@ + Set-Content -Path (Join-Path $ExtensionDir "src/unistd.h") -Encoding UTF8 -Value @" +#ifndef OLIPHAUNT_PG_TEXTSEARCH_WINDOWS_UNISTD_H +#define OLIPHAUNT_PG_TEXTSEARCH_WINDOWS_UNISTD_H +#endif +"@ + $segmentHeader = Join-Path $ExtensionDir "src/segment/segment.h" + $text = Get-Content -Raw -Path $segmentHeader + $text = $text.Replace("} __attribute__((aligned(4))) TpDictEntry;", "} TpDictEntry;") + $text = $text.Replace( + "typedef struct TpSegmentPosting", + "#ifdef _MSC_VER`n#pragma pack(push, 1)`n#endif`ntypedef struct TpSegmentPosting" + ) + $text = $text.Replace( + "} __attribute__((packed)) TpSegmentPosting;", + "} TpSegmentPosting;`n#ifdef _MSC_VER`n#pragma pack(pop)`n#endif" + ) + $text = $text.Replace( + "typedef struct TpSkipEntry", + "#ifdef _MSC_VER`n#pragma pack(push, 1)`n#endif`ntypedef struct TpSkipEntry" + ) + $text = $text.Replace( + "} __attribute__((packed)) TpSkipEntry;", + "} TpSkipEntry;`n#ifdef _MSC_VER`n#pragma pack(pop)`n#endif" + ) + $text = $text.Replace( + "typedef struct TpCtidMapEntry", + "#ifdef _MSC_VER`n#pragma pack(push, 1)`n#endif`ntypedef struct TpCtidMapEntry" + ) + $text = $text.Replace( + "} __attribute__((packed)) TpCtidMapEntry;", + "} TpCtidMapEntry;`n#ifdef _MSC_VER`n#pragma pack(pop)`n#endif" + ) + Set-Content -Path $segmentHeader -Encoding UTF8 -Value $text + + $amHeader = Join-Path $ExtensionDir "src/am/am.h" + $text = Get-Content -Raw -Path $amHeader + $original = "Datum tp_handler(PG_FUNCTION_ARGS);" + $replacement = "extern PGDLLEXPORT Datum tp_handler(PG_FUNCTION_ARGS);" + if (-not $text.Contains($original)) { + Fail "pg_textsearch am.h is missing expected tp_handler declaration" + } + $text = $text.Replace($original, $replacement) + Set-Content -Path $amHeader -Encoding UTF8 -Value $text + + $vectorHeader = Join-Path $ExtensionDir "src/types/vector.h" + $text = Get-Content -Raw -Path $vectorHeader + foreach ($functionName in @("tpvector_in", "tpvector_out", "tpvector_recv", "tpvector_send", "to_tpvector", "tpvector_eq")) { + $original = "Datum $($functionName)(PG_FUNCTION_ARGS);" + $replacement = "extern PGDLLEXPORT Datum $($functionName)(PG_FUNCTION_ARGS);" + if (-not $text.Contains($original)) { + Fail "pg_textsearch vector.h is missing expected $functionName declaration" + } + $text = $text.Replace($original, $replacement) + } + Set-Content -Path $vectorHeader -Encoding UTF8 -Value $text + + $queryHeader = Join-Path $ExtensionDir "src/types/query.h" + $text = Get-Content -Raw -Path $queryHeader + foreach ($functionName in @( + "tpquery_in", + "tpquery_out", + "tpquery_recv", + "tpquery_send", + "to_tpquery_text", + "to_tpquery_text_index", + "bm25_text_bm25query_score", + "bm25_text_text_score", + "tpquery_eq" + )) { + $original = "Datum $($functionName)(PG_FUNCTION_ARGS);" + $replacement = "extern PGDLLEXPORT Datum $($functionName)(PG_FUNCTION_ARGS);" + if (-not $text.Contains($original)) { + Fail "pg_textsearch query.h is missing expected $functionName declaration" + } + $text = $text.Replace($original, $replacement) + } + Set-Content -Path $queryHeader -Encoding UTF8 -Value $text +} + +function Patch-PgUuidv7WindowsSource([string]$ExtensionDir) { + $source = Join-Path $ExtensionDir "pg_uuidv7.c" + $text = Get-Content -Raw -Path $source + $epochDefine = "#define EPOCH_DIFF_USECS ((POSTGRES_EPOCH_JDATE - UNIX_EPOCH_JDATE) * USECS_PER_DAY)" + $compat = @" +#define EPOCH_DIFF_USECS ((POSTGRES_EPOCH_JDATE - UNIX_EPOCH_JDATE) * USECS_PER_DAY) + +#ifdef _WIN32 +#ifndef CLOCK_REALTIME +#define CLOCK_REALTIME 0 +#endif +static int +oliphaunt_pg_uuidv7_clock_gettime(int clock_id, struct timespec *ts) +{ + TimestampTz unix_usecs; + + if (clock_id != CLOCK_REALTIME || ts == NULL) + return -1; + + unix_usecs = GetCurrentTimestamp() + EPOCH_DIFF_USECS; + ts->tv_sec = (time_t) (unix_usecs / USECS_PER_SEC); + ts->tv_nsec = (long) ((unix_usecs % USECS_PER_SEC) * 1000); + return 0; +} +#define clock_gettime oliphaunt_pg_uuidv7_clock_gettime +#endif +"@ + if (-not $text.Contains($epochDefine)) { + Fail "pg_uuidv7.c is missing expected epoch define" + } + $text = $text.Replace($epochDefine, $compat) + Set-Content -Path $source -Encoding UTF8 -Value $text +} + +function Add-ExternalPgxsMesonProducer( + [string]$SqlName, + [string]$CheckoutName, + [string]$Subdir, + [string]$ModuleName, + [string[]]$Sources, + [string[]]$DataFiles, + [string[]]$CArgs = @(), + [string[]]$LocalIncludeDirs = @() +) { + if (-not (NativeExtension-Selected $SqlName)) { + return + } + $destination = Join-Path $OliphauntContribDir $Subdir + Copy-SourceTree (External-Checkout $CheckoutName) $destination + if ($SqlName -eq "pg_uuidv7") { + Patch-PgUuidv7WindowsSource $destination + } + if ($SqlName -eq "pg_textsearch") { + Patch-PgTextsearchWindowsSource $destination + $compatHeader = Meson-Path (Join-Path $destination "src/oliphaunt_windows_compat.h") + $CArgs = @($CArgs) + @("/FI$compatHeader") + } + if ($SqlName -eq "vector") { + Copy-Item -Force (Join-Path $destination "sql/vector.sql") (Join-Path $destination "sql/vector--0.8.2.sql") + } + Write-OliphauntMesonModule $Subdir $ModuleName $Sources $DataFiles $CArgs @() $LocalIncludeDirs +} + +function Add-ExternalPgxsMesonProducers { + Add-ExternalPgxsMesonProducer ` + "pg_hashids" "pg_hashids" "pg_hashids" "pg_hashids" ` + @("pg_hashids.c", "hashids.c") ` + @( + "pg_hashids--1.3.sql", + "pg_hashids--1.2.1--1.3.sql", + "pg_hashids--1.2--1.3.sql", + "pg_hashids--1.1--1.2.sql", + "pg_hashids--1.0--1.1.sql", + "pg_hashids.control" + ) + Add-ExternalPgxsMesonProducer ` + "pg_ivm" "pg_ivm" "pg_ivm" "pg_ivm" ` + @("createas.c", "matview.c", "pg_ivm.c", "ruleutils.c", "subselect.c") ` + @( + "pg_ivm--1.0.sql", + "pg_ivm--1.0--1.1.sql", + "pg_ivm--1.1--1.2.sql", + "pg_ivm--1.2--1.3.sql", + "pg_ivm--1.3--1.4.sql", + "pg_ivm--1.4--1.5.sql", + "pg_ivm--1.5--1.6.sql", + "pg_ivm--1.6--1.7.sql", + "pg_ivm--1.7--1.8.sql", + "pg_ivm--1.8--1.9.sql", + "pg_ivm--1.9--1.10.sql", + "pg_ivm--1.10.sql", + "pg_ivm--1.10--1.11.sql", + "pg_ivm--1.11--1.12.sql", + "pg_ivm--1.12--1.13.sql", + "pg_ivm.control" + ) + Add-ExternalPgxsMesonProducer ` + "pg_uuidv7" "pg_uuidv7" "pg_uuidv7" "pg_uuidv7" ` + @("pg_uuidv7.c") ` + @( + "sql/pg_uuidv7--1.7.sql", + "pg_uuidv7.control" + ) + Add-ExternalPgxsMesonProducer ` + "pg_textsearch" "pg_textsearch" "pg_textsearch" "pg_textsearch" ` + @( + "src/mod.c", + "src/source.c", + "src/am/handler.c", + "src/am/build.c", + "src/am/build_parallel.c", + "src/am/scan.c", + "src/am/vacuum.c", + "src/memtable/memtable.c", + "src/memtable/posting.c", + "src/memtable/stringtable.c", + "src/memtable/local_memtable.c", + "src/memtable/scan.c", + "src/memtable/source.c", + "src/segment/segment.c", + "src/segment/dictionary.c", + "src/segment/scan.c", + "src/segment/merge.c", + "src/segment/docmap.c", + "src/segment/compression.c", + "src/query/bmw.c", + "src/query/score.c", + "src/types/vector.c", + "src/types/query.c", + "src/state/state.c", + "src/state/registry.c", + "src/state/metapage.c", + "src/state/limit.c", + "src/planner/hooks.c", + "src/planner/cost.c", + "src/debug/dump.c" + ) ` + @( + "sql/pg_textsearch--0.5.1.sql", + "sql/pg_textsearch--0.0.1--0.0.2.sql", + "sql/pg_textsearch--0.0.2--0.0.3.sql", + "sql/pg_textsearch--0.0.3--0.0.4.sql", + "sql/pg_textsearch--0.0.4--0.0.5.sql", + "sql/pg_textsearch--0.0.5--0.1.0.sql", + "sql/pg_textsearch--0.1.0--0.2.0.sql", + "sql/pg_textsearch--0.2.0--0.3.0.sql", + "sql/pg_textsearch--0.3.0--0.4.0.sql", + "sql/pg_textsearch--0.4.0--0.4.1.sql", + "sql/pg_textsearch--0.4.1--0.4.2.sql", + "sql/pg_textsearch--0.4.2--0.5.0.sql", + "sql/pg_textsearch--0.5.0--0.5.1.sql", + "pg_textsearch.control" + ) ` + @("/D_CRT_SECURE_NO_WARNINGS") ` + @("src") + Add-ExternalPgxsMesonProducer ` + "vector" "pgvector" "vector" "vector" ` + @( + "src/bitutils.c", + "src/bitvec.c", + "src/halfutils.c", + "src/halfvec.c", + "src/hnsw.c", + "src/hnswbuild.c", + "src/hnswinsert.c", + "src/hnswscan.c", + "src/hnswutils.c", + "src/hnswvacuum.c", + "src/ivfbuild.c", + "src/ivfflat.c", + "src/ivfinsert.c", + "src/ivfkmeans.c", + "src/ivfscan.c", + "src/ivfutils.c", + "src/ivfvacuum.c", + "src/sparsevec.c", + "src/vector.c" + ) ` + @( + "sql/vector--0.1.0--0.1.1.sql", + "sql/vector--0.1.1--0.1.3.sql", + "sql/vector--0.1.3--0.1.4.sql", + "sql/vector--0.1.4--0.1.5.sql", + "sql/vector--0.1.5--0.1.6.sql", + "sql/vector--0.1.6--0.1.7.sql", + "sql/vector--0.1.7--0.1.8.sql", + "sql/vector--0.1.8--0.2.0.sql", + "sql/vector--0.2.0--0.2.1.sql", + "sql/vector--0.2.1--0.2.2.sql", + "sql/vector--0.2.2--0.2.3.sql", + "sql/vector--0.2.3--0.2.4.sql", + "sql/vector--0.2.4--0.2.5.sql", + "sql/vector--0.2.5--0.2.6.sql", + "sql/vector--0.2.6--0.2.7.sql", + "sql/vector--0.2.7--0.3.0.sql", + "sql/vector--0.3.0--0.3.1.sql", + "sql/vector--0.3.1--0.3.2.sql", + "sql/vector--0.3.2--0.4.0.sql", + "sql/vector--0.4.0--0.4.1.sql", + "sql/vector--0.4.1--0.4.2.sql", + "sql/vector--0.4.2--0.4.3.sql", + "sql/vector--0.4.3--0.4.4.sql", + "sql/vector--0.4.4--0.5.0.sql", + "sql/vector--0.5.0--0.5.1.sql", + "sql/vector--0.5.1--0.6.0.sql", + "sql/vector--0.6.0--0.6.1.sql", + "sql/vector--0.6.1--0.6.2.sql", + "sql/vector--0.6.2--0.7.0.sql", + "sql/vector--0.7.0--0.7.1.sql", + "sql/vector--0.7.1--0.7.2.sql", + "sql/vector--0.7.2--0.7.3.sql", + "sql/vector--0.7.3--0.7.4.sql", + "sql/vector--0.7.4--0.8.0.sql", + "sql/vector--0.8.0--0.8.1.sql", + "sql/vector--0.8.1--0.8.2.sql", + "sql/vector--0.8.2.sql", + "vector.control" + ) ` + @("/fp:fast") +} + +function Prepare-WindowsExtensionInputs { + if ($BuildExtensions -eq "0") { + return + } + Assert-WindowsNativeExtensionSelectionSupported + Add-PgcryptoMesonProducer + Add-UuidOsspMesonProducer + Add-ExternalPgxsMesonProducers + Add-PostgisMesonProducer +} + +function Expand-PgtapSqlTemplate([string]$InputPath, [string]$OutputPath, [string]$ModulePath) { + $text = Get-Content -Raw -Path $InputPath + $text = $text.Replace("MODULE_PATHNAME", $ModulePath) + $text = $text.Replace("__OS__", "MSWin32") + $text = $text.Replace("__VERSION__", "1.3") + Set-Content -Path $OutputPath -Encoding UTF8 -Value $text +} + +function Install-WindowsPgtapExtension { + if (-not (NativeExtension-Selected "pgtap")) { + return + } + $sourceDir = External-Checkout "pgtap" + if (-not (Test-Path (Join-Path $sourceDir "pgtap.control"))) { + Fail "missing pgTAP checkout for Windows extension artifact staging: $sourceDir" + } + $buildDir = Join-Path $WorkRoot "pgtap-windows" + Copy-SourceTree $sourceDir $buildDir + $sqlDir = Join-Path $buildDir "sql" + Expand-PgtapSqlTemplate (Join-Path $sqlDir "pgtap.sql.in") (Join-Path $sqlDir "pgtap.sql") "pgtap" + foreach ($input in Get-ChildItem -Path $sqlDir -Filter "*.sql.in" -File) { + $output = Join-Path $sqlDir ($input.Name.Substring(0, $input.Name.Length - 3)) + if (-not (Test-Path $output)) { + Copy-Item -Force $input.FullName $output + } + } + Expand-PgtapSqlTemplate (Join-Path $sqlDir "pgtap.sql.in") (Join-Path $sqlDir "pgtap-static.sql") '$libdir/pgtap' + $coreSql = Join-Path $sqlDir "pgtap-core.sql" + $schemaSql = Join-Path $sqlDir "pgtap-schema.sql" + & perl (Join-Path $buildDir "compat/gencore") 0 (Join-Path $sqlDir "pgtap-static.sql") > $coreSql + if ($LASTEXITCODE -ne 0) { + Fail "pgTAP core SQL generation failed" + } + & perl (Join-Path $buildDir "compat/gencore") 1 (Join-Path $sqlDir "pgtap-static.sql") > $schemaSql + if ($LASTEXITCODE -ne 0) { + Fail "pgTAP schema SQL generation failed" + } + $uninstallSql = Join-Path $sqlDir "uninstall_pgtap.sql" + & perl -e 'for (grep { /^CREATE /} reverse <>) { chomp; s/CREATE (OR REPLACE )?/DROP /; s/DROP (FUNCTION|VIEW|TYPE) /DROP $1 IF EXISTS /; s/ (DEFAULT|=)[ ]+[a-zA-Z0-9]+//g; print "$_;\n" }' (Join-Path $sqlDir "pgtap.sql") > $uninstallSql + if ($LASTEXITCODE -ne 0) { + Fail "pgTAP uninstall SQL generation failed" + } + Copy-Item -Force (Join-Path $sqlDir "pgtap.sql") (Join-Path $sqlDir "pgtap--1.3.5.sql") + Copy-Item -Force $coreSql (Join-Path $sqlDir "pgtap-core--1.3.5.sql") + Copy-Item -Force $schemaSql (Join-Path $sqlDir "pgtap-schema--1.3.5.sql") + + $extensionDir = Join-Path $InstallDir "share/postgresql/extension" + New-Item -ItemType Directory -Force -Path $extensionDir | Out-Null + Copy-Item -Force (Join-Path $buildDir "pgtap.control") (Join-Path $extensionDir "pgtap.control") + Copy-Item -Force (Join-Path $sqlDir "pgtap*.sql") $extensionDir + Copy-Item -Force $uninstallSql $extensionDir + if (-not (Test-Path (Join-Path $extensionDir "pgtap--1.3.5.sql"))) { + Fail "pgTAP Windows staging did not produce pgtap--1.3.5.sql" + } +} + +function Get-ExactExtensionCatalogRows([string]$Purpose) { + Push-Location $RepoRoot + try { + $catalogText = cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions + $exitCode = $LASTEXITCODE + } finally { + Pop-Location + } + if ($exitCode -ne 0 -or -not $catalogText) { + Fail "failed to read exact extension catalog for $Purpose" + } + $catalogText | Select-Object -Skip 1 +} + +function Runtime-Installed([string]$DesiredHash) { + return (Test-Path (Join-Path $InstallDir "bin/initdb.exe")) -and + (Test-Path (Join-Path $InstallDir "bin/postgres.exe")) -and + (Test-Path (Join-Path $InstallDir "bin/pg_config.exe")) -and + (Test-Path (Join-Path $InstallDir "share/postgresql/postgresql.conf.sample")) -and + (Test-Path (Join-Path $InstallDir "share/postgresql/timezone/UTC")) -and + (Test-Path (Join-Path $InstallDir ".oliphaunt-postgres-runtime.sha256")) -and + ((Get-Content (Join-Path $InstallDir ".oliphaunt-postgres-runtime.sha256") -Raw).Trim() -eq $DesiredHash) -and + (($BuildExtensions -ne "0") -or (BaseRuntimeOptionalExtensionsAbsent)) +} + +function Build-Runtime([string]$DesiredHash) { + if (Runtime-Installed $DesiredHash) { + return + } + Write-MesonNativeFile $RuntimeNativeFile $false + $options = @( + "--native-file", $RuntimeNativeFile, + "--prefix", $InstallDir, + "--buildtype=release", + "-Db_pch=false", + "-Dreadline=disabled", + "-Dicu=disabled", + "-Dldap=disabled", + "-Dllvm=disabled", + "-Dzlib=disabled", + "-Dzstd=disabled", + "-Dlz4=disabled", + "-Dnls=disabled", + "-Dssl=none", + "-Ddocs=disabled", + "-Dtap_tests=disabled", + "-Dplperl=disabled", + "-Dplpython=disabled", + "-Dpltcl=disabled" + ) + if (-not (Test-Path $RuntimeBuildDir)) { + Invoke-Logged "meson-runtime-setup.log" { meson setup $RuntimeBuildDir $BuildDir @options } + } + Invoke-Logged "meson-runtime-compile.log" { meson compile -C $RuntimeBuildDir } + Invoke-Logged "meson-runtime-install.log" { meson install -C $RuntimeBuildDir } + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Install-WindowsPgtapExtension + Prune-BaseRuntimeOptionalExtensions + Set-Content -Path (Join-Path $InstallDir ".oliphaunt-postgres-runtime.sha256") -Value $DesiredHash -NoNewline + if (-not (Runtime-Installed $DesiredHash)) { + Fail "PostgreSQL Windows runtime install is incomplete" + } +} + +function Prune-BaseRuntimeOptionalExtensions { + if ($BuildExtensions -ne "0") { + return + } + + $extensionDir = Join-Path $InstallDir "share/postgresql/extension" + $moduleDir = Join-Path $InstallDir "lib/postgresql" + $shareDir = Join-Path $InstallDir "share/postgresql" + foreach ($row in (Get-ExactExtensionCatalogRows "base Windows runtime pruning")) { + if (-not $row) { + continue + } + $columns = $row -split "`t", 12 + if ($columns.Count -lt 12) { + Fail "malformed extension catalog row while pruning Windows base runtime: $row" + } + + $sqlName = $columns[0] + $stem = $columns[3] + $dataFiles = $columns[10] + if (Test-Path $extensionDir) { + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "$sqlName.control") + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "$sqlName--*.sql") + } + if ((Test-Path $moduleDir) -and $stem -and $stem -ne "-") { + foreach ($suffix in @("dll", "so", "dylib")) { + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $moduleDir "$stem.$suffix") + } + } + if ($dataFiles -and $dataFiles -ne "-") { + foreach ($dataFile in $dataFiles.Split(",")) { + if ($dataFile) { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue (Join-Path $shareDir $dataFile) + } + } + } + } + + if (Test-Path $extensionDir) { + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "postgis*.sql") + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "rtpostgis*.sql") + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "uninstall_postgis.sql") + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "uninstall_legacy.sql") + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "pgtap-*.sql") + Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $extensionDir "uninstall_pgtap.sql") + } + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue (Join-Path $shareDir "contrib") + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue (Join-Path $shareDir "proj") +} + +function BaseRuntimeOptionalExtensionsAbsent { + if (-not (Test-Path $InstallDir)) { + return $false + } + + $extensionDir = Join-Path $InstallDir "share/postgresql/extension" + $moduleDir = Join-Path $InstallDir "lib/postgresql" + $shareDir = Join-Path $InstallDir "share/postgresql" + foreach ($row in (Get-ExactExtensionCatalogRows "base Windows runtime validation")) { + if (-not $row) { + continue + } + $columns = $row -split "`t", 12 + if ($columns.Count -lt 12) { + Fail "malformed extension catalog row while validating Windows base runtime: $row" + } + + $sqlName = $columns[0] + $stem = $columns[3] + $dataFiles = $columns[10] + if ((Test-Path $extensionDir) -and (Test-Path (Join-Path $extensionDir "$sqlName.control"))) { + return $false + } + if ((Test-Path $extensionDir) -and (Get-ChildItem -Path $extensionDir -Filter "$sqlName--*.sql" -File -ErrorAction SilentlyContinue | Select-Object -First 1)) { + return $false + } + if ((Test-Path $moduleDir) -and $stem -and $stem -ne "-") { + foreach ($suffix in @("dll", "so", "dylib")) { + if (Test-Path (Join-Path $moduleDir "$stem.$suffix")) { + return $false + } + } + } + if ($dataFiles -and $dataFiles -ne "-") { + foreach ($dataFile in $dataFiles.Split(",")) { + if ($dataFile -and (Test-Path (Join-Path $shareDir $dataFile))) { + return $false + } + } + } + } + + if ((Test-Path (Join-Path $shareDir "contrib")) -or (Test-Path (Join-Path $shareDir "proj"))) { + return $false + } + + return $true +} + +function Write-MesonNativeFile([string]$Path, [bool]$UseCrtSecureNoWarnings) { + $content = @( + "[binaries]", + "c = 'cl.exe'", + "cpp = 'cl.exe'", + "ar = 'lib.exe'" + ) + if ($UseCrtSecureNoWarnings) { + $content += @( + "", + "[built-in options]", + "c_args = ['/D_CRT_SECURE_NO_WARNINGS']" + ) + } + Set-Content -Path $Path -Value ($content -join "`n") -Encoding UTF8 +} + +function Build-EmbeddedBackend { + Write-MesonNativeFile $EmbeddedNativeFile $true + $options = @( + "--native-file", $EmbeddedNativeFile, + "--prefix", $InstallDir, + "--buildtype=release", + "-Doliphaunt_embedded=true", + "-Db_pch=false", + "-Dreadline=disabled", + "-Dicu=disabled", + "-Dldap=disabled", + "-Dllvm=disabled", + "-Dzlib=disabled", + "-Dzstd=disabled", + "-Dlz4=disabled", + "-Dnls=disabled", + "-Dssl=none", + "-Ddocs=disabled", + "-Dtap_tests=disabled", + "-Dplperl=disabled", + "-Dplpython=disabled", + "-Dpltcl=disabled" + ) + $previousCflags = $env:CFLAGS + $env:CFLAGS = "" + try { + if (-not (Test-Path $EmbeddedBuildDir)) { + Invoke-Logged "meson-embedded-setup.log" { meson setup $EmbeddedBuildDir $BuildDir @options } + } + Invoke-Logged "meson-embedded-postgres-lib.log" { meson compile -C $EmbeddedBuildDir postgres_lib } + Invoke-Logged "meson-embedded-postgres-def.log" { meson compile -C $EmbeddedBuildDir postgres.def } + } finally { + $env:CFLAGS = $previousCflags + } +} + +function Assert-SymbolPresent([string]$Binary, [string]$Symbol) { + $stem = [System.IO.Path]::GetFileNameWithoutExtension($Binary) + $log = Join-Path $WorkRoot "dumpbin-symbols-$stem.log" + dumpbin.exe /symbols $Binary *> $log + if ($LASTEXITCODE -ne 0) { + Get-Content $log -Tail 160 | Write-Error + Fail "dumpbin failed while inspecting $Binary" + } + $symbols = Get-Content $log -Raw + if ($symbols -notmatch "(^|[^A-Za-z0-9_])_?$([regex]::Escape($Symbol))([^A-Za-z0-9_]|$)") { + Get-Content $log -Tail 160 | Write-Error + Fail "$Binary does not define required embedded PostgreSQL symbol $Symbol" + } +} + +function First-File([string]$Root, [string[]]$Filters) { + foreach ($filter in $Filters) { + $item = Get-ChildItem -Path $Root -Recurse -Filter $filter -File | Select-Object -First 1 + if ($item) { + return $item.FullName + } + } + Fail "could not find any of $($Filters -join ', ') under $Root" +} + +function First-PostgresArchive([string]$Root, [string]$BaseName) { + $file = First-File $Root @("$BaseName.lib", "$BaseName.a") + if (-not $file) { + Fail "could not find PostgreSQL archive $BaseName under $Root" + } + $file +} + +function Compile-LiboliphauntSources { + Remove-Item -Recurse -Force $ObjDir -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $ObjDir | Out-Null + $objects = New-Object System.Collections.Generic.List[string] + foreach ($source in $LiboliphauntSources) { + $object = Join-Path $ObjDir ([System.IO.Path]::GetFileNameWithoutExtension($source) + ".obj") + $sourceName = [System.IO.Path]::GetFileNameWithoutExtension($source) + Invoke-Logged "compile-liboliphaunt-$sourceName.log" { + cl.exe /nologo /O2 /Zi /MD /DOLIPHAUNT_EMBEDDED /DOLIPHAUNT_BUILDING_DLL /D_CRT_SECURE_NO_WARNINGS ` + "/I$(Join-Path $RepoRoot "src/runtimes/liboliphaunt/native/include")" ` + "/I$(Join-Path $RepoRoot "src/runtimes/liboliphaunt/native/src")" ` + /c $source "/Fo$object" + } + $objects.Add($object) + } + $objects +} + +function Link-LiboliphauntDll([System.Collections.Generic.List[string]]$Objects) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $DllOut), (Split-Path -Parent $ImportLibOut) | Out-Null + $postgresLib = First-PostgresArchive $EmbeddedBuildDir "postgres_lib" + $postgresDef = First-File $EmbeddedBuildDir "postgres.def" + Assert-SymbolPresent $postgresLib "oliphaunt_embedded_main" + $exports = @( + "oliphaunt_init", + "oliphaunt_exec_protocol", + "oliphaunt_exec_simple_query", + "oliphaunt_exec_protocol_stream", + "oliphaunt_backup", + "oliphaunt_backup_ex", + "oliphaunt_restore", + "oliphaunt_cancel", + "oliphaunt_detach", + "oliphaunt_close", + "oliphaunt_register_static_extensions", + "oliphaunt_last_error", + "oliphaunt_version", + "oliphaunt_capabilities", + "oliphaunt_free_response" + ) + $response = Join-Path $OutDir "link-oliphaunt.rsp" + $lines = @( + "/nologo", + "/DLL", + "/INCREMENTAL:NO", + "/OUT:$DllOut", + "/IMPLIB:$ImportLibOut", + "/PDB:$(Join-Path $OutDir "bin/oliphaunt.pdb")", + "/DEF:$postgresDef", + "/WHOLEARCHIVE:$postgresLib" + ) + foreach ($export in $exports) { + $lines += "/EXPORT:$export" + } + foreach ($object in $Objects) { + $lines += $object + } + $lines += @( + "ws2_32.lib", + "secur32.lib", + "advapi32.lib", + "shell32.lib", + "user32.lib", + "bcrypt.lib" + ) + Set-Content -Path $response -Value ($lines -join "`r`n") + link.exe "@$response" + if ($LASTEXITCODE -ne 0) { + Fail "failed to link $DllOut" + } +} + +function Artifact-Ready { + if (-not (Test-Path $DllOut) -or -not (Test-Path $ImportLibOut)) { + return $false + } + $exports = dumpbin.exe /exports $DllOut 2>$null | Out-String + foreach ($symbol in @( + "oliphaunt_init", + "oliphaunt_exec_protocol", + "oliphaunt_exec_protocol_stream", + "oliphaunt_backup", + "oliphaunt_restore", + "oliphaunt_close", + "oliphaunt_version", + "oliphaunt_capabilities", + "oliphaunt_free_response" + )) { + if ($exports -notmatch "\b$symbol\b") { + return $false + } + } + $true +} + +if (-not $IsWindows) { + Fail "Windows liboliphaunt build must run on Windows" +} + +Require-Command git +Require-Command curl.exe +if ($BuildExtensions -eq "0") { + Require-Command cargo +} +Import-MsvcEnvironment +Prefer-NativePerl +$env:CCACHE_DISABLE = "1" +Ensure-MesonTools +Configure-MsvcToolchainPath + +$desiredHash = Get-DesiredHash +Prepare-Source $desiredHash +Prepare-WindowsExtensionInputs + +if ($CheckCurrent) { + if ((Runtime-Installed $desiredHash) -and (Artifact-Ready) -and (Test-Path $Stamp) -and ((Get-Content $Stamp -Raw).Trim() -eq $desiredHash)) { + Write-Output "Windows $TargetId liboliphaunt DLL is current" + exit 0 + } + Write-Error "Windows $TargetId liboliphaunt DLL is missing or stale" + exit 1 +} + +Build-Runtime $desiredHash +Build-EmbeddedBackend +$objects = Compile-LiboliphauntSources +Link-LiboliphauntDll $objects +if (-not (Artifact-Ready)) { + Fail "Windows liboliphaunt DLL did not pass export checks" +} +Set-Content -Path $Stamp -Value $desiredHash -NoNewline +Write-Output $DllOut diff --git a/src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh b/src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh new file mode 100755 index 00000000..f12e0c2f --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/check-c-abi-conformance.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$script_dir/common.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +cd "$repo_root" + +node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs --abi-only diff --git a/src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh b/src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh new file mode 100755 index 00000000..a38bb398 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(cd "$(dirname "$0")" && pwd)" +. "$script_dir/common.sh" +root="$(oliphaunt_resolve_repo_root "$script_dir")" +manifest="$root/src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml" +online=0 + +case "${1:-}" in + "") + ;; + --online) + online=1 + ;; + *) + echo "usage: src/runtimes/liboliphaunt/native/bin/check-external-extension-pins.sh [--online]" >&2 + exit 2 + ;; +esac + +require_line() { + line="$1" + if ! grep -Fxq "$line" "$manifest"; then + echo "external extension source manifest is missing: $line" >&2 + exit 1 + fi +} + +check_checkout_if_present() { + name="$1" + relative_checkout="$2" + expected_commit="$3" + checkout="$root/$relative_checkout" + + if [ ! -d "$checkout/.git" ]; then + echo "external extension checkout not present for $name: $relative_checkout" + return 0 + fi + + actual_commit="$(git -C "$checkout" rev-parse HEAD)" + if [ "$actual_commit" != "$expected_commit" ]; then + cat >&2 <&2 <} +MSG + exit 1 + fi + echo "external extension remote pin verified for $name: $ref -> $expected_commit" +} + +[ -f "$manifest" ] || { + echo "missing external extension source manifest: $manifest" >&2 + exit 1 +} + +require_line 'schema = "liboliphaunt-external-extensions-v2"' +require_line 'pg_major = 18' + +if grep -Eq '^[[:space:]]*pack[[:space:]]*=' "$manifest"; then + echo "external extension manifest must not declare extension selection aliases; select exact extensions only" >&2 + exit 1 +fi + +require_line 'id = "pggraph"' +require_line 'sql_name = "graph"' +require_line 'module_stem = "graph"' +require_line 'upstream = "https://github.com/evokoa/pggraph.git"' +require_line 'source_ref = "main"' +require_line 'commit = "4ea3c3206811deda03de136b4f465a2cf9bc8e72"' +require_line 'checkout = "target/oliphaunt-sources/checkouts/pggraph"' +require_line 'source_subdir = "graph"' +require_line 'license = "Apache-2.0"' +require_line 'redistribution = "allowed"' +require_line 'pgrx_version = "0.18.0"' +require_line 'pg_feature = "pg18"' + +require_line 'id = "paradedb-pg-search"' +require_line 'sql_name = "pg_search"' +require_line 'module_stem = "pg_search"' +require_line 'upstream = "https://github.com/paradedb/paradedb.git"' +require_line 'source_ref = "v0.23.4"' +require_line 'commit = "c07921a78f3d24cbb0251b31a1150a7db600af5a"' +require_line 'checkout = "target/oliphaunt-sources/checkouts/paradedb"' +require_line 'source_subdir = "pg_search"' +require_line 'license = "AGPL-3.0"' +require_line 'redistribution = "requires-commercial-license"' +require_line 'pgrx_version = "0.18.0"' +require_line 'pg_feature = "pg18"' +require_line 'requires_shared_preload = true' + +check_checkout_if_present \ + pggraph \ + target/oliphaunt-sources/checkouts/pggraph \ + 4ea3c3206811deda03de136b4f465a2cf9bc8e72 + +check_checkout_if_present \ + paradedb-pg-search \ + target/oliphaunt-sources/checkouts/paradedb \ + c07921a78f3d24cbb0251b31a1150a7db600af5a + +check_remote_if_requested \ + pggraph \ + https://github.com/evokoa/pggraph.git \ + HEAD \ + 4ea3c3206811deda03de136b4f465a2cf9bc8e72 + +check_remote_if_requested \ + paradedb-pg-search \ + https://github.com/paradedb/paradedb.git \ + refs/tags/v0.23.4 \ + c07921a78f3d24cbb0251b31a1150a7db600af5a + +echo "external extension source pins passed" diff --git a/src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh b/src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh new file mode 100755 index 00000000..ba0e06a0 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +. "$script_dir/icu.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +pg_version="18.4" +pg_sha256="81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" +pg_url="https://ftp.postgresql.org/pub/source/v${pg_version}/postgresql-${pg_version}.tar.bz2" +source_manifest="$repo_root/src/runtimes/liboliphaunt/native/postgres18/source.toml" +patch_dir="$repo_root/src/runtimes/liboliphaunt/native/patches/postgresql-${pg_version}" +work_root="${OLIPHAUNT_IOS_SIMULATOR_CHECK_ROOT:-$repo_root/target/liboliphaunt-ios-simulator-check}" +source_cache="$work_root/source" +tarball="$source_cache/postgresql-${pg_version}.tar.bz2" +build_dir="$work_root/postgresql-${pg_version}" +install_dir="$work_root/install" +stamp="$build_dir/.liboliphaunt-ios-simulator-check.sha256" +make_log="$work_root/make.log" +configure_log="$work_root/configure.log" +icu_source_dir="$(oliphaunt_icu_source_dir "$repo_root")" +icu_native_build_dir="$work_root/icu-native" +icu_build_dir="$work_root/icu-ios-simulator-build" +icu_prefix="$work_root/icu-ios-simulator" +icu_cflags="$(oliphaunt_icu_cflags "$icu_prefix")" +icu_static_libs="$(oliphaunt_icu_static_libs "$icu_prefix")" +icu_cpp_libs="-lc++" +icu_libs="$icu_static_libs $icu_cpp_libs" + +if [ "$(uname -s)" != "Darwin" ]; then + echo "PostgreSQL iOS simulator probe requires Darwin" >&2 + exit 2 +fi + +if ! command -v xcrun >/dev/null 2>&1; then + echo "missing required command: xcrun" >&2 + exit 1 +fi + +if ! command -v rg >/dev/null 2>&1; then + echo "missing required command: rg" >&2 + exit 1 +fi + +sdk_path="$(xcrun --sdk iphonesimulator --show-sdk-path 2>/dev/null || true)" +clang_path="$(xcrun --find --sdk iphonesimulator clang 2>/dev/null || true)" +clangxx_path="$(xcrun --find --sdk iphonesimulator clang++ 2>/dev/null || true)" +ar_path="$(xcrun --find --sdk iphonesimulator ar 2>/dev/null || true)" +ranlib_path="$(xcrun --find --sdk iphonesimulator ranlib 2>/dev/null || true)" +if [ -z "$sdk_path" ] || [ -z "$clang_path" ] || [ -z "$clangxx_path" ] || [ -z "$ar_path" ] || [ -z "$ranlib_path" ]; then + echo "iPhoneSimulator SDK is unavailable" >&2 + exit 1 +fi +oliphaunt_icu_require_source "$icu_source_dir" + +min_ios="${OLIPHAUNT_IOS_SIMULATOR_MIN_VERSION:-17.0}" +cc=("$clang_path" -target "arm64-apple-ios${min_ios}-simulator" "-mios-simulator-version-min=${min_ios}" -isysroot "$sdk_path") +cxx=("$clangxx_path" -target "arm64-apple-ios${min_ios}-simulator" "-mios-simulator-version-min=${min_ios}" -isysroot "$sdk_path") +ccache_mode="${OLIPHAUNT_CCACHE:-auto}" +if [ "$ccache_mode" != "0" ] && [ "$ccache_mode" != "off" ]; then + ccache_bin="" + if [ "$ccache_mode" = "auto" ]; then + ccache_bin="$(command -v ccache || true)" + else + ccache_bin="$ccache_mode" + fi + if [ -n "$ccache_bin" ]; then + cc=("$ccache_bin" "${cc[@]}") + cxx=("$ccache_bin" "${cxx[@]}") + fi +fi +cc_string="${cc[*]}" +cxx_string="${cxx[*]}" + +patch_series() { + sed -n '/series = \[/,/\]/p' "$source_manifest" | + sed -n 's/.*"\([^"]*\.patch\)".*/\1/p' +} + +patch_series_hash() { + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + shasum -a 256 "$patch_dir/$patch_name" + done < <(patch_series) | shasum -a 256 | awk '{print $1}' +} + +desired_hash() { + { + printf 'pg_version=%s\n' "$pg_version" + printf 'pg_sha256=%s\n' "$pg_sha256" + printf 'sdk_path=%s\n' "$sdk_path" + printf 'clang_path=%s\n' "$clang_path" + printf 'clangxx_path=%s\n' "$clangxx_path" + printf 'min_ios=%s\n' "$min_ios" + printf 'cc=%s\n' "$cc_string" + printf 'cxx=%s\n' "$cxx_string" + printf 'ar=%s\n' "$ar_path" + printf 'ranlib=%s\n' "$ranlib_path" + printf 'icu_source=%s\n' "$(oliphaunt_icu_source_commit "$icu_source_dir")" + printf 'icu_script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'patch_series_hash=%s\n' "$(patch_series_hash)" + shasum -a 256 "$0" + shasum -a 256 "$source_manifest" + } | shasum -a 256 | awk '{print $1}' +} + +apply_patch_series() { + local patch_name + while IFS= read -r patch_name; do + [ -n "$patch_name" ] || continue + GIT_CEILING_DIRECTORIES="$work_root" git apply --recount --whitespace=nowarn "$patch_dir/$patch_name" >/dev/null + done < <(patch_series) +} + +prepare_source() { + mkdir -p "$source_cache" "$work_root" + + if [ ! -f "$tarball" ]; then + curl -L --fail --silent --show-error "$pg_url" -o "$tarball" + fi + ( + cd "$source_cache" + printf '%s %s\n' "$pg_sha256" "postgresql-${pg_version}.tar.bz2" | shasum -a 256 -c - + ) + + local wanted + wanted="$(desired_hash)" + if [ -d "$build_dir" ] && { [ ! -f "$stamp" ] || [ "$(cat "$stamp")" != "$wanted" ]; }; then + rm -rf "$build_dir" + fi + if [ ! -d "$build_dir" ]; then + tar -xjf "$tarball" -C "$work_root" + ( + cd "$build_dir" + apply_patch_series + ) + fi + printf '%s\n' "$wanted" > "$stamp" +} + +configure_source() { + export CC="$cc_string" + export CXX="$cxx_string" + export CFLAGS="-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED" + export CPPFLAGS="-isysroot $sdk_path $icu_cflags" + export LDFLAGS="-isysroot $sdk_path -L$icu_prefix/lib" + export ICU_CFLAGS="$icu_cflags" + export ICU_LIBS="$icu_libs" + + if [ ! -f "$build_dir/config.status" ]; then + ( + cd "$build_dir" + ./configure \ + --host=aarch64-apple-darwin \ + --prefix="$install_dir" \ + --without-readline \ + --with-icu \ + --without-llvm \ + --without-pam \ + --with-openssl=no \ + --without-zlib \ + --disable-nls \ + ac_cv_file__dev_urandom=yes + ) > "$configure_log" 2>&1 + fi +} + +build_icu() { + oliphaunt_icu_build_target \ + "$icu_source_dir" \ + "$script_dir" \ + "$icu_native_build_dir" \ + "$icu_build_dir" \ + "$icu_prefix" \ + "${OLIPHAUNT_JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || echo 4)}" \ + "ios-simulator-check" \ + "aarch64-apple-darwin" \ + "$cc_string" \ + "$cxx_string" \ + "$ar_path" \ + "$ranlib_path" \ + "-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED" \ + "-O2 -g -fPIC -DOLIPHAUNT_EMBEDDED" \ + "-isysroot $sdk_path" +} + +compile_probe_objects() { + : > "$make_log" + ( + cd "$build_dir" + make -C src/backend generated-headers + make -C src/backend/archive shell_archive.o V=1 + make -C src/backend/access/transam xlogarchive.o V=1 + make -C src/backend/libpq be-secure.o V=1 + make -C src/backend/libpq pqcomm.o V=1 + make -C src/backend/tcop postgres.o V=1 + make -C src/backend/storage/ipc ipc.o V=1 + make -C src/backend/utils/fmgr dfmgr.o V=1 + ) > "$make_log" 2>&1 + + if rg -n "warning:|error:" "$make_log" >/dev/null; then + rg -n "warning:|error:" "$make_log" >&2 + echo "PostgreSQL iOS simulator probe must be warning-clean" >&2 + exit 1 + fi +} + +verify_probe_symbols() { + local source_root="$build_dir/src" + rg -q --fixed-strings "OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS" "$source_root/backend/archive/shell_archive.c" + rg -q --fixed-strings "OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS" "$source_root/backend/access/transam/xlogarchive.c" + rg -q --fixed-strings "oliphaunt_embedded_main" "$source_root/include/tcop/tcopprot.h" + rg -q --fixed-strings "oliphaunt_static_extension_magic(file_scanner->static_extension)" "$source_root/backend/utils/fmgr/dfmgr.c" +} + +prepare_source +build_icu +configure_source +compile_probe_objects +verify_probe_symbols + +echo "PostgreSQL 18 iOS simulator embedded probe passed: $build_dir" diff --git a/src/runtimes/liboliphaunt/native/bin/common.sh b/src/runtimes/liboliphaunt/native/bin/common.sh new file mode 100755 index 00000000..e139c132 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/common.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +oliphaunt_resolve_repo_root() { + script_dir="${1:?oliphaunt_resolve_repo_root requires a script directory}" + if repo_root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null)"; then + printf '%s\n' "$repo_root" + return 0 + fi + cd "$script_dir/../../../../.." && pwd +} diff --git a/src/runtimes/liboliphaunt/native/bin/icu.sh b/src/runtimes/liboliphaunt/native/bin/icu.sh new file mode 100755 index 00000000..25048901 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/icu.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +oliphaunt_icu_source_dir() { + local repo_root="${1:?repo root is required}" + printf '%s\n' "${OLIPHAUNT_ICU_SOURCE_DIR:-$repo_root/target/oliphaunt-sources/checkouts/icu/icu4c/source}" +} + +oliphaunt_icu_source_commit() { + local source_dir="${1:?ICU source dir is required}" + git -C "$source_dir/../../" rev-parse HEAD +} + +oliphaunt_icu_script_sha256() { + local script_dir="${1:?script dir is required}" + shasum -a 256 "$script_dir/icu.sh" | awk '{print $1}' +} + +oliphaunt_icu_native_tools_stamp() { + local source_dir="$1" + local script_dir="$2" + { + printf 'schema=oliphaunt-icu-native-tools-v2\n' + printf 'source=%s\n' "$(oliphaunt_icu_source_commit "$source_dir")" + printf 'script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'configure=static-no-tests-no-samples-no-extras-no-icuio-no-layoutex-tools-only\n' + } | shasum -a 256 | awk '{print $1}' +} + +oliphaunt_icu_target_stamp() { + local source_dir="$1" + local script_dir="$2" + local target_label="$3" + local host="$4" + local cc="$5" + local cxx="$6" + local ar="$7" + local ranlib="$8" + local cflags="$9" + local cxxflags="${10}" + local ldflags="${11}" + { + printf 'schema=oliphaunt-icu-target-v4\n' + printf 'source=%s\n' "$(oliphaunt_icu_source_commit "$source_dir")" + printf 'script=%s\n' "$(oliphaunt_icu_script_sha256 "$script_dir")" + printf 'target=%s\n' "$target_label" + printf 'host=%s\n' "$host" + printf 'cc=%s\n' "$cc" + printf 'cxx=%s\n' "$cxx" + printf 'ar=%s\n' "$ar" + printf 'ranlib=%s\n' "$ranlib" + printf 'cflags=%s\n' "$cflags" + printf 'cxxflags=%s\n' "$cxxflags" + printf 'ldflags=%s\n' "$ldflags" + printf 'configure=static-data-static-libs-static-consumer-no-extra-target-tools-real-data-archive\n' + } | shasum -a 256 | awk '{print $1}' +} + +oliphaunt_icu_require_source() { + local source_dir="${1:?ICU source dir is required}" + if [ ! -x "$source_dir/configure" ]; then + echo "missing ICU source checkout at $source_dir; run \`cargo run -p xtask -- assets fetch\` first" >&2 + return 1 + fi +} + +oliphaunt_icu_native_tool_names() { + printf '%s\n' \ + makeconv \ + gencnval \ + gencfu \ + genbrk \ + gendict \ + genrb \ + gensprep \ + icupkg \ + pkgdata \ + genccode \ + gencmn +} + +oliphaunt_icu_native_tools_ready() { + local native_build_dir="${1:?native build dir is required}" + [ -f "$native_build_dir/icudefs.mk" ] || return 1 + [ -f "$native_build_dir/config/icucross.mk" ] || return 1 + [ -f "$native_build_dir/config/icucross.inc" ] || return 1 + [ -f "$native_build_dir/lib/libicui18n.a" ] || return 1 + [ -f "$native_build_dir/lib/libicuuc.a" ] || return 1 + [ -f "$native_build_dir/stubdata/libicudata.a" ] || return 1 + [ -f "$native_build_dir/lib/libicutu.a" ] || return 1 + local tool + while IFS= read -r tool; do + [ -x "$native_build_dir/bin/$tool" ] || return 1 + done < <(oliphaunt_icu_native_tool_names) +} + +oliphaunt_icu_static_data_ready() { + local data_archive="${1:?ICU data archive is required}" + [ -f "$data_archive" ] || return 1 + local members + members="$(ar -t "$data_archive")" || return 1 + if grep -Eq '^stubdata\.ao/?$' <<< "$members"; then + return 1 + fi + grep -Eq '^icudt[0-9]+[a-z]*_dat\.o/?$' <<< "$members" +} + +oliphaunt_icu_artifacts_ready() { + local prefix="${1:?ICU prefix is required}" + [ -f "$prefix/.oliphaunt-icu-build" ] || return 1 + [ -f "$prefix/include/unicode/ucol.h" ] || return 1 + [ -f "$prefix/lib/libicui18n.a" ] || return 1 + [ -f "$prefix/lib/libicuuc.a" ] || return 1 + oliphaunt_icu_static_data_ready "$prefix/lib/libicudata.a" +} + +oliphaunt_icu_linked_symbols_ready() { + local symbols="${1-}" + [ -n "$symbols" ] || return 1 + grep -Eq '(^|[[:space:]])_?pg_register_static_icu_data($|[[:space:]])' <<< "$symbols" || return 1 + grep -Eq '(^|[[:space:]])_?udata_setCommonData(_[0-9]+)?($|[[:space:]])' <<< "$symbols" || return 1 + grep -Eq '(^|[[:space:]])_?ucol_open(_[0-9]+)?($|[[:space:]])' <<< "$symbols" || return 1 + grep -Eq '(^|[[:space:]])_?icudt[0-9]+[a-z]*_dat($|[[:space:]])' <<< "$symbols" +} + +oliphaunt_icu_build_native_tools() { + local source_dir="${1:?ICU source dir is required}" + local script_dir="${2:?script dir is required}" + local native_build_dir="${3:?native build dir is required}" + local jobs="${4:?jobs is required}" + + oliphaunt_icu_require_source "$source_dir" + + local stamp_file="$native_build_dir/.oliphaunt-icu-native-tools" + local stamp + stamp="$(oliphaunt_icu_native_tools_stamp "$source_dir" "$script_dir")" + if [ -f "$stamp_file" ] && + [ "$(cat "$stamp_file")" = "$stamp" ] && + oliphaunt_icu_native_tools_ready "$native_build_dir"; then + return 0 + fi + + rm -rf "$native_build_dir" + mkdir -p "$native_build_dir" + ( + cd "$native_build_dir" + "$source_dir/configure" \ + --disable-shared \ + --enable-static \ + --disable-tests \ + --disable-samples \ + --disable-extras \ + --disable-icuio \ + --disable-layoutex + make all-local + mkdir -p lib bin + make -j"$jobs" -C stubdata + make -j"$jobs" -C common + make -j"$jobs" -C i18n + make -j"$jobs" -C tools/toolutil + local tool + while IFS= read -r tool; do + make -j"$jobs" -C "tools/$tool" + done < <(oliphaunt_icu_native_tool_names) + ) + oliphaunt_icu_native_tools_ready "$native_build_dir" + printf '%s\n' "$stamp" > "$stamp_file" +} + +oliphaunt_icu_build_target() { + local source_dir="${1:?ICU source dir is required}" + local script_dir="${2:?script dir is required}" + local native_build_dir="${3:?native build dir is required}" + local target_build_dir="${4:?target build dir is required}" + local prefix="${5:?prefix is required}" + local jobs="${6:?jobs is required}" + local target_label="${7:?target label is required}" + local host="${8:?host is required}" + local cc="${9:?cc is required}" + local cxx="${10:?cxx is required}" + local ar="${11:?ar is required}" + local ranlib="${12:?ranlib is required}" + local cflags="${13:-}" + local cxxflags="${14:-}" + local ldflags="${15:-}" + + oliphaunt_icu_build_native_tools "$source_dir" "$script_dir" "$native_build_dir" "$jobs" + + local stamp_file="$prefix/.oliphaunt-icu-build" + local stamp + stamp="$(oliphaunt_icu_target_stamp "$source_dir" "$script_dir" "$target_label" "$host" "$cc" "$cxx" "$ar" "$ranlib" "$cflags" "$cxxflags" "$ldflags")" + if [ -f "$stamp_file" ] && + [ "$(cat "$stamp_file")" = "$stamp" ] && + [ -f "$prefix/include/unicode/ucol.h" ] && + [ -f "$prefix/lib/libicui18n.a" ] && + [ -f "$prefix/lib/libicuuc.a" ] && + oliphaunt_icu_static_data_ready "$prefix/lib/libicudata.a"; then + return 0 + fi + + rm -rf "$target_build_dir" "$prefix" + mkdir -p "$target_build_dir" "$(dirname "$prefix")" + ( + cd "$target_build_dir" + CC="$cc" \ + CXX="$cxx" \ + AR="$ar" \ + RANLIB="$ranlib" \ + CFLAGS="$cflags" \ + CXXFLAGS="$cxxflags" \ + LDFLAGS="$ldflags" \ + "$source_dir/configure" \ + --host="$host" \ + --with-cross-build="$native_build_dir" \ + --with-data-packaging=static \ + --disable-shared \ + --enable-static \ + --disable-tests \ + --disable-samples \ + --disable-tools \ + --disable-extras \ + --disable-icuio \ + --disable-layoutex \ + --prefix="$prefix" + make -j"$jobs" + make install + make -j"$jobs" -C data packagedata + make -C data install-local DESTDIR= + ) + + test -f "$prefix/include/unicode/ucol.h" + test -f "$prefix/lib/libicui18n.a" + test -f "$prefix/lib/libicuuc.a" + oliphaunt_icu_static_data_ready "$prefix/lib/libicudata.a" + printf '%s\n' "$stamp" > "$stamp_file" +} + +oliphaunt_icu_cflags() { + local prefix="${1:?prefix is required}" + printf '%s\n' "-DU_STATIC_IMPLEMENTATION -I$prefix/include" +} + +oliphaunt_icu_static_libs() { + local prefix="${1:?prefix is required}" + printf '%s\n' "$prefix/lib/libicui18n.a $prefix/lib/libicuuc.a $prefix/lib/libicudata.a" +} diff --git a/src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh b/src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh new file mode 100644 index 00000000..715c625c --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh @@ -0,0 +1,659 @@ +#!/usr/bin/env bash + +oliphaunt_postgis_fail() { + echo "PostGIS mobile static build: $*" >&2 + exit 1 +} + +oliphaunt_postgis_selected() { + mobile_static_extensions_include postgis +} + +oliphaunt_postgis_require_tools() { + oliphaunt_postgis_selected || return 0 + local cmd + for cmd in cmake rsync tar; do + command -v "$cmd" >/dev/null 2>&1 || oliphaunt_postgis_fail "missing required command: $cmd" + done +} + +oliphaunt_postgis_dependency_archive() { + local name="$1" + local archive="$2" + [ -f "$archive" ] || oliphaunt_postgis_fail "missing dependency archive for $name: $archive" + mobile_static_dependency_archives+=("$archive") +} + +oliphaunt_postgis_cmake_platform_args() { + case "${oliphaunt_mobile_target:?missing oliphaunt mobile target}" in + ios-simulator | ios-device) + printf '%s\n' \ + -DCMAKE_SYSTEM_NAME=iOS \ + "-DCMAKE_OSX_SYSROOT=$sdk_path" \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + "-DCMAKE_OSX_DEPLOYMENT_TARGET=$min_ios" \ + "-DCMAKE_C_COMPILER=$clang_path" \ + "-DCMAKE_CXX_COMPILER=${clangxx_path:-$clang_path}" + ;; + android-arm64 | android-x86_64) + printf '%s\n' \ + "-DCMAKE_TOOLCHAIN_FILE=$ndk_root/build/cmake/android.toolchain.cmake" \ + "-DANDROID_ABI=$android_abi" \ + "-DANDROID_PLATFORM=android-$android_api" \ + -DANDROID_STL=c++_static + ;; + *) + oliphaunt_postgis_fail "unsupported mobile target: $oliphaunt_mobile_target" + ;; + esac +} + +oliphaunt_postgis_cmake_install() { + local source_dir="$1" + local build_root="$2" + local dependency_dir="$3" + shift 3 + local -a platform_args + local platform_arg + while IFS= read -r platform_arg; do + [ -n "$platform_arg" ] && platform_args+=("$platform_arg") + done < <(oliphaunt_postgis_cmake_platform_args) + cmake -S "$source_dir" -B "$build_root" \ + "${platform_args[@]}" \ + -DCMAKE_INSTALL_PREFIX="$dependency_dir" \ + "$@" >> "$make_log" 2>&1 + cmake --build "$build_root" --target install -- -j"$jobs" >> "$make_log" 2>&1 +} + +build_postgis_jsonc_dependency() { + oliphaunt_postgis_selected || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/json-c" + local dependency_dir="$mobile_static_dependency_root/json-c" + local build_root="$work_root/json-c-$oliphaunt_mobile_target-build" + local archive="$dependency_dir/lib/libjson-c.a" + if [ -f "$archive" ] && [ -d "$dependency_dir/include/json-c" ]; then + oliphaunt_postgis_dependency_archive json-c "$archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || oliphaunt_postgis_fail "missing JSON-C checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + oliphaunt_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_STATIC_LIBS=ON \ + -DBUILD_APPS=OFF \ + -DBUILD_TESTING=OFF \ + -DDISABLE_WERROR=ON + [ -f "$archive" ] || oliphaunt_postgis_fail "JSON-C build did not produce $archive" + oliphaunt_postgis_dependency_archive json-c "$archive" +} + +build_postgis_sqlite_dependency() { + oliphaunt_postgis_selected || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/sqlite" + local dependency_dir="$mobile_static_dependency_root/sqlite" + local build_root="$work_root/sqlite-$oliphaunt_mobile_target-build" + local archive="$dependency_dir/lib/libsqlite3.a" + if [ -f "$archive" ] && [ -f "$dependency_dir/include/sqlite3.h" ]; then + oliphaunt_postgis_dependency_archive sqlite "$archive" + return 0 + fi + [ -x "$source_dir/configure" ] || oliphaunt_postgis_fail "missing SQLite checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + mkdir -p "$build_root" "$dependency_dir/include" "$dependency_dir/lib" + rsync -a --delete --exclude .git "$source_dir/" "$build_root/" + ( + cd "$build_root" + case "$oliphaunt_mobile_target" in + ios-simulator | ios-device) + CC="$cc_string" CFLAGS="-O2 -g -fPIC" ./configure \ + --host=aarch64-apple-darwin \ + --disable-shared \ + --enable-static \ + --prefix="$dependency_dir" >> "$make_log" 2>&1 + make -j"$jobs" sqlite3.c >> "$make_log" 2>&1 + "${cc[@]}" -O2 -g -fPIC \ + -DSQLITE_THREADSAFE=0 \ + -DSQLITE_OMIT_LOAD_EXTENSION \ + -c sqlite3.c \ + -o sqlite3.o >> "$make_log" 2>&1 + "$libtool_path" -static -o "$archive" sqlite3.o >> "$make_log" 2>&1 + ;; + android-arm64 | android-x86_64) + CC="$clang_path" CFLAGS="-O2 -g -fPIC" ./configure \ + --host="$android_host" \ + --disable-shared \ + --enable-static \ + --prefix="$dependency_dir" >> "$make_log" 2>&1 + make -j"$jobs" sqlite3.c >> "$make_log" 2>&1 + "$clang_path" -O2 -g -fPIC \ + -DSQLITE_THREADSAFE=0 \ + -DSQLITE_OMIT_LOAD_EXTENSION \ + -c sqlite3.c \ + -o sqlite3.o >> "$make_log" 2>&1 + "$llvm_ar" crs "$archive" sqlite3.o >> "$make_log" 2>&1 + "$llvm_ranlib" "$archive" >> "$make_log" 2>&1 + ;; + esac + cp -p sqlite3.h sqlite3ext.h "$dependency_dir/include/" + ) + [ -f "$archive" ] || oliphaunt_postgis_fail "SQLite build did not produce $archive" + oliphaunt_postgis_dependency_archive sqlite "$archive" +} + +build_postgis_geos_dependency() { + oliphaunt_postgis_selected || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/geos" + local dependency_dir="$mobile_static_dependency_root/geos" + local build_root="$work_root/geos-$oliphaunt_mobile_target-build" + local geos_c_archive="$dependency_dir/lib/libgeos_c.a" + local geos_archive="$dependency_dir/lib/libgeos.a" + if [ -f "$geos_c_archive" ] && [ -f "$geos_archive" ] && [ -f "$dependency_dir/include/geos_c.h" ]; then + oliphaunt_postgis_dependency_archive geos-c "$geos_c_archive" + oliphaunt_postgis_dependency_archive geos "$geos_archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || oliphaunt_postgis_fail "missing GEOS checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + oliphaunt_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_TESTING=OFF \ + -DBUILD_BENCHMARKS=OFF \ + -DBUILD_GEOSOP=OFF \ + -DGEOS_BUILD_DEVELOPER=OFF + [ -f "$geos_c_archive" ] || oliphaunt_postgis_fail "GEOS build did not produce $geos_c_archive" + [ -f "$geos_archive" ] || oliphaunt_postgis_fail "GEOS build did not produce $geos_archive" + oliphaunt_postgis_dependency_archive geos-c "$geos_c_archive" + oliphaunt_postgis_dependency_archive geos "$geos_archive" +} + +build_postgis_libxml2_dependency() { + oliphaunt_postgis_selected || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/libxml2" + local dependency_dir="$mobile_static_dependency_root/libxml2" + local build_root="$work_root/libxml2-$oliphaunt_mobile_target-build" + local archive="$dependency_dir/lib/libxml2.a" + if [ -f "$archive" ] && [ -x "$dependency_dir/bin/xml2-config" ]; then + oliphaunt_postgis_dependency_archive libxml2 "$archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || oliphaunt_postgis_fail "missing libxml2 checkout: $source_dir" + rm -rf "$build_root" "$dependency_dir" + oliphaunt_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + -DLIBXML2_WITH_PROGRAMS=OFF \ + -DLIBXML2_WITH_TESTS=OFF \ + -DLIBXML2_WITH_PYTHON=OFF \ + -DLIBXML2_WITH_THREADS=OFF \ + -DLIBXML2_WITH_MODULES=OFF \ + -DLIBXML2_WITH_ICONV=OFF \ + -DLIBXML2_WITH_ZLIB=OFF \ + -DLIBXML2_WITH_LZMA=OFF \ + -DLIBXML2_WITH_HTTP=OFF + [ -f "$archive" ] || oliphaunt_postgis_fail "libxml2 build did not produce $archive" + oliphaunt_postgis_dependency_archive libxml2 "$archive" +} + +build_postgis_proj_dependency() { + oliphaunt_postgis_selected || return 0 + local source_dir="$repo_root/target/oliphaunt-sources/checkouts/proj" + local dependency_dir="$mobile_static_dependency_root/proj" + local sqlite_dir="$mobile_static_dependency_root/sqlite" + local build_root="$work_root/proj-$oliphaunt_mobile_target-build" + local archive="$dependency_dir/lib/libproj.a" + if [ -f "$archive" ] && [ -f "$dependency_dir/share/proj/proj.db" ]; then + oliphaunt_postgis_dependency_archive proj "$archive" + return 0 + fi + [ -f "$source_dir/CMakeLists.txt" ] || oliphaunt_postgis_fail "missing PROJ checkout: $source_dir" + [ -f "$sqlite_dir/lib/libsqlite3.a" ] || oliphaunt_postgis_fail "PROJ dependency requires SQLite archive first" + rm -rf "$build_root" "$dependency_dir" + oliphaunt_postgis_cmake_install "$source_dir" "$build_root" "$dependency_dir" \ + -DBUILD_SHARED_LIBS=OFF \ + "-DSQLite3_INCLUDE_DIR=$sqlite_dir/include" \ + "-DSQLite3_LIBRARY=$sqlite_dir/lib/libsqlite3.a" \ + "-DEXE_SQLITE3=$(command -v sqlite3)" \ + -DENABLE_TIFF=OFF \ + -DENABLE_CURL=OFF \ + -DENABLE_EMSCRIPTEN_FETCH=OFF \ + -DHAVE_LIBDL=OFF \ + -DBUILD_APPS=OFF \ + -DBUILD_TESTING=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DEMBED_RESOURCE_FILES=ON \ + -DUSE_ONLY_EMBEDDED_RESOURCE_FILES=ON + mkdir -p "$dependency_dir/share/proj" + if [ -f "$build_root/data/proj.db" ]; then + cp -p "$build_root/data/proj.db" "$dependency_dir/share/proj/proj.db" + fi + [ -f "$archive" ] || oliphaunt_postgis_fail "PROJ build did not produce $archive" + [ -f "$dependency_dir/share/proj/proj.db" ] || oliphaunt_postgis_fail "PROJ build did not produce proj.db" + oliphaunt_postgis_dependency_archive proj "$archive" +} + +build_postgis_libiconv_dependency() { + oliphaunt_postgis_selected || return 0 + case "$oliphaunt_mobile_target" in + android-arm64 | android-x86_64) ;; + *) return 0 ;; + esac + local dependency_dir="$mobile_static_dependency_root/libiconv" + local build_root="$work_root/libiconv-$oliphaunt_mobile_target-build" + local source_tar="$work_root/source/libiconv-1.19.tar.gz" + local archive="$dependency_dir/lib/libiconv.a" + local charset_archive="$dependency_dir/lib/libcharset.a" + if [ -f "$archive" ] && [ -f "$charset_archive" ] && [ -f "$dependency_dir/include/iconv.h" ]; then + oliphaunt_postgis_dependency_archive libiconv "$archive" + oliphaunt_postgis_dependency_archive libcharset "$charset_archive" + return 0 + fi + mkdir -p "$(dirname "$source_tar")" + if [ ! -f "$source_tar" ]; then + curl -L --fail --silent --show-error \ + --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ + https://ftp.gnu.org/gnu/libiconv/libiconv-1.19.tar.gz \ + -o "$source_tar" + fi + printf '%s %s\n' \ + "88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6" \ + "$source_tar" | shasum -a 256 -c - >> "$make_log" 2>&1 + rm -rf "$build_root" "$dependency_dir" + mkdir -p "$build_root" "$dependency_dir" + tar -xzf "$source_tar" -C "$build_root" --strip-components=1 + ( + cd "$build_root" + CC="$clang_path" AR="$llvm_ar" RANLIB="$llvm_ranlib" \ + ./configure \ + --host="$android_host" \ + --disable-shared \ + --enable-static \ + --prefix="$dependency_dir" >> "$make_log" 2>&1 + make -j"$jobs" >> "$make_log" 2>&1 + make install >> "$make_log" 2>&1 + ) + [ -f "$archive" ] || oliphaunt_postgis_fail "libiconv build did not produce $archive" + [ -f "$charset_archive" ] || oliphaunt_postgis_fail "libiconv build did not produce $charset_archive" + oliphaunt_postgis_dependency_archive libiconv "$archive" + oliphaunt_postgis_dependency_archive libcharset "$charset_archive" +} + +build_postgis_mobile_static_dependencies() { + oliphaunt_postgis_selected || return 0 + oliphaunt_postgis_require_tools + build_postgis_jsonc_dependency + build_postgis_sqlite_dependency + build_postgis_geos_dependency + build_postgis_libxml2_dependency + build_postgis_proj_dependency + build_postgis_libiconv_dependency +} + +oliphaunt_postgis_host_alias() { + case "$oliphaunt_mobile_target" in + ios-simulator | ios-device) printf '%s\n' aarch64-apple-darwin ;; + android-arm64 | android-x86_64) printf '%s\n' "$android_host" ;; + *) oliphaunt_postgis_fail "unsupported mobile target: $oliphaunt_mobile_target" ;; + esac +} + +oliphaunt_postgis_extra_ldflags() { + case "$oliphaunt_mobile_target" in + ios-simulator | ios-device) + printf '%s\n' "-isysroot $sdk_path -L$mobile_static_dependency_root/geos/lib -L$mobile_static_dependency_root/proj/lib -L$mobile_static_dependency_root/sqlite/lib -L$mobile_static_dependency_root/json-c/lib -L$mobile_static_dependency_root/libxml2/lib -lc++" + ;; + android-arm64 | android-x86_64) + printf '%s\n' "-L$mobile_static_dependency_root/geos/lib -L$mobile_static_dependency_root/proj/lib -L$mobile_static_dependency_root/sqlite/lib -L$mobile_static_dependency_root/json-c/lib -L$mobile_static_dependency_root/libxml2/lib -L$mobile_static_dependency_root/libiconv/lib -L$toolchain_dir/sysroot/usr/lib/$android_host -lc++_static -lc++abi" + ;; + esac +} + +oliphaunt_postgis_geos_config_libs() { + case "$oliphaunt_mobile_target" in + ios-simulator | ios-device) printf '%s\n' "-L$mobile_static_dependency_root/geos/lib -lgeos_c -lgeos -lc++" ;; + android-arm64 | android-x86_64) printf '%s\n' "-L$mobile_static_dependency_root/geos/lib -lgeos_c -lgeos -lc++_static -lc++abi" ;; + esac +} + +oliphaunt_postgis_pkg_config_script() { + local path="$1" + local proj_cxx_libs + case "$oliphaunt_mobile_target" in + ios-simulator | ios-device) proj_cxx_libs="-lc++" ;; + android-arm64 | android-x86_64) proj_cxx_libs="-lc++_static -lc++abi" ;; + esac + cat > "$path" < "$path" <&2 + exit 1 + ;; +esac +EOF + chmod +x "$path" +} + +oliphaunt_postgis_geos_config_script() { + local path="$1" + local geos_libs + geos_libs="$(oliphaunt_postgis_geos_config_libs)" + cat > "$path" <> "$objects_file" + mobile_static_objects+=("$object") + done < <(find "$extract_dir" -type f -name '*.o' -print | LC_ALL=C sort) +} + +oliphaunt_postgis_stage_object() { + local source="$1" + local prefix_dir="$2" + local object_dir="$3" + local objects_file="$4" + local target="$object_dir/$prefix_dir/$(basename "$source")" + mkdir -p "$(dirname "$target")" + cp -p "$source" "$target" + printf '%s\n' "$target" >> "$objects_file" + mobile_static_objects+=("$target") +} + +oliphaunt_postgis_stage_runtime_sql() { + local postgis_build="$1" + mkdir -p "$install_dir/share/postgresql/extension" "$install_dir/share/postgresql/proj" + cp -p "$postgis_build/extensions/postgis/postgis.control" \ + "$install_dir/share/postgresql/extension/postgis.control" + cp -p "$postgis_build/extensions/postgis/sql/"*.sql \ + "$install_dir/share/postgresql/extension/" + cp -p "$mobile_static_dependency_root/proj/share/proj/proj.db" \ + "$install_dir/share/postgresql/proj/proj.db" +} + +oliphaunt_postgis_patch_extension_makefile() { + local postgis_build="$1" + local prefix="$2" + local makefile="$postgis_build/postgis/Makefile" + [ -f "$makefile" ] || oliphaunt_postgis_fail "PostGIS extension Makefile is missing: $makefile" + OLIPHAUNT_POSTGIS_PG_MAGIC_SYMBOL="${prefix}_Pg_magic_func" \ + OLIPHAUNT_POSTGIS_PG_INIT_SYMBOL="${prefix}__PG_init" \ + OLIPHAUNT_POSTGIS_DIFFERENCE_SYMBOL="${prefix}_difference" \ + OLIPHAUNT_POSTGIS_PG_FINFO_DIFFERENCE_SYMBOL="${prefix}_pg_finfo_difference" \ + perl -0pi -e ' + my $defs = + " -DPg_magic_func=$ENV{OLIPHAUNT_POSTGIS_PG_MAGIC_SYMBOL}" . + " -D_PG_init=$ENV{OLIPHAUNT_POSTGIS_PG_INIT_SYMBOL}" . + " -Ddifference=$ENV{OLIPHAUNT_POSTGIS_DIFFERENCE_SYMBOL}" . + " -Dpg_finfo_difference=$ENV{OLIPHAUNT_POSTGIS_PG_FINFO_DIFFERENCE_SYMBOL}"; + my $updated = s|^(PG_CPPFLAGS \+= .*)$|$1$defs|m; + die "could not patch PostGIS PG_CPPFLAGS\n" unless $updated; + ' "$makefile" +} + +oliphaunt_postgis_write_static_symbol_aliases() { + local path="$1" + local prefix="$2" + { + printf 'difference\t%s_difference\n' "$prefix" + printf 'pg_finfo_difference\tpg_finfo_%s_difference\n' "$prefix" + } > "$path" +} + +oliphaunt_postgis_verify_prefixed_module_symbol() { + local stem="$1" + local prefix="$2" + if ! module_has_c_symbol "$stem" "${prefix}_Pg_magic_func"; then + oliphaunt_postgis_fail "PostGIS did not export prefixed Pg_magic_func symbol for $stem" + fi +} + +oliphaunt_postgis_verify_prefixed_legacy_symbols() { + local stem="$1" + local prefix="$2" + if ! module_has_c_symbol "$stem" "${prefix}_difference"; then + oliphaunt_postgis_fail "PostGIS did not export prefixed legacy difference symbol for $stem" + fi + if ! module_has_c_symbol "$stem" "pg_finfo_${prefix}_difference"; then + oliphaunt_postgis_fail "PostGIS did not export prefixed legacy pg_finfo_difference symbol for $stem" + fi +} + +build_postgis_mobile_static_extension_objects() { + local extension="$1" + [ "$extension" = "postgis" ] || return 1 + + local stem prefix source_dir postgis_build object_dir objects_file alias_file archive scripts_dir + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + prefix="$(oliphaunt_static_symbol_prefix "$stem")" + source_dir="$repo_root/target/oliphaunt-sources/checkouts/postgis" + postgis_build="$work_root/postgis-$oliphaunt_mobile_target" + object_dir="$out_dir/extensions/$stem" + objects_file="$object_dir/objects.list" + alias_file="$object_dir/symbol-aliases.list" + archive="$object_dir/liboliphaunt_extension_$stem.a" + scripts_dir="$work_root/postgis-$oliphaunt_mobile_target-scripts" + + if [ -f "$archive" ] && [ -s "$objects_file" ] && [ -s "$alias_file" ] && [ -f "$install_dir/share/postgresql/extension/postgis.control" ]; then + local object + while IFS= read -r object; do + [ -f "$object" ] || oliphaunt_postgis_fail "missing staged PostGIS object listed in $objects_file: $object" + mobile_static_objects+=("$object") + done < "$objects_file" + return 0 + fi + + [ -f "$source_dir/configure.ac" ] || oliphaunt_postgis_fail "missing PostGIS checkout: $source_dir" + rm -rf "$postgis_build" "$object_dir" "$scripts_dir" + mkdir -p "$postgis_build" "$object_dir" "$scripts_dir" \ + "$work_root/postgis-fake-postgres-bin" "$work_root/postgis-fake-postgres-share" + : > "$work_root/postgis-fake-postgres-bin/postgres" + chmod +x "$work_root/postgis-fake-postgres-bin/postgres" + rsync -a --delete --exclude .git "$source_dir/" "$postgis_build/" + + local pg_config geos_config pkg_config host_alias ldflags + pg_config="$scripts_dir/pg_config" + geos_config="$scripts_dir/geos-config" + pkg_config="$scripts_dir/pkg-config" + host_alias="$(oliphaunt_postgis_host_alias)" + ldflags="$(oliphaunt_postgis_extra_ldflags)" + oliphaunt_postgis_pg_config_script "$pg_config" + oliphaunt_postgis_geos_config_script "$geos_config" + oliphaunt_postgis_pkg_config_script "$pkg_config" + + local postgis_cflags postgis_cppflags + postgis_cflags="$native_cflags" + postgis_cppflags="-I$build_dir/src/include -I$build_dir/src/include/port -I$build_dir/src/interfaces/libpq -I$mobile_static_dependency_root/libxml2/include/libxml2 -I$mobile_static_dependency_root/proj/include -I$mobile_static_dependency_root/json-c/include -I$mobile_static_dependency_root/json-c/include/json-c" + case "$oliphaunt_mobile_target" in + android-arm64 | android-x86_64) + postgis_cflags="$postgis_cflags -D_GNU_SOURCE" + postgis_cppflags="-D_GNU_SOURCE -I$mobile_static_dependency_root/libiconv/include $postgis_cppflags" + ;; + esac + + local -a configure_args + configure_args=( + --host="$host_alias" + --with-pgconfig="$pg_config" + --with-geosconfig="$geos_config" + --with-xml2config="$mobile_static_dependency_root/libxml2/bin/xml2-config" + --without-protobuf + --without-raster + --without-topology + --without-sfcgal + --without-address-standardizer + --without-tiger + --disable-nls + ) + case "$oliphaunt_mobile_target" in + android-arm64 | android-x86_64) + configure_args+=(--with-libiconv="$mobile_static_dependency_root/libiconv") + ;; + esac + + ( + cd "$postgis_build" + export PATH="$scripts_dir:$PATH" + export PKG_CONFIG="$pkg_config" + export PKG_CONFIG_ALLOW_CROSS=1 + export PKG_CONFIG_LIBDIR="$mobile_static_dependency_root/json-c/lib/pkgconfig:$mobile_static_dependency_root/proj/lib/pkgconfig:$mobile_static_dependency_root/sqlite/lib/pkgconfig" + export PKG_CONFIG_PATH="$PKG_CONFIG_LIBDIR" + export JSONC_CFLAGS="-I$mobile_static_dependency_root/json-c/include -I$mobile_static_dependency_root/json-c/include/json-c" + export JSONC_LIBS="-L$mobile_static_dependency_root/json-c/lib -ljson-c" + export CC="$cc_string" + export CXX="$cc_string" + export CFLAGS="$postgis_cflags" + export CXXFLAGS="$postgis_cflags" + export CPPFLAGS="$postgis_cppflags" + export LDFLAGS="$ldflags" + export ac_cv_lib_pq_PQserverVersion=yes + ./autogen.sh >> "$make_log" 2>&1 + local build_alias + build_alias="$(build-aux/config.guess)" + ./configure --build="$build_alias" "${configure_args[@]}" >> "$make_log" 2>&1 + oliphaunt_postgis_patch_extension_makefile "$postgis_build" "$prefix" + make -j"$jobs" -C liblwgeom liblwgeom.la >> "$make_log" 2>&1 + make -j"$jobs" -C libpgcommon libpgcommon.a >> "$make_log" 2>&1 + make -j"$jobs" -C deps/flatgeobuf all >> "$make_log" 2>&1 + make -j"$jobs" -C postgis all >> "$make_log" 2>&1 || true + make -j1 raster-sql >> "$make_log" 2>&1 || true + make -j1 -C raster/rt_pg sql_objs >> "$make_log" 2>&1 + make -j1 -C extensions postgis_extension_helper.sql >> "$make_log" 2>&1 + make -j1 -C extensions/postgis postgis.control all >> "$make_log" 2>&1 + ) + + : > "$objects_file" + oliphaunt_postgis_write_static_symbol_aliases "$alias_file" "$prefix" + local object + while IFS= read -r object; do + [ -n "$object" ] || continue + oliphaunt_postgis_stage_object "$object" postgis "$object_dir" "$objects_file" + done < <(find "$postgis_build/postgis" -maxdepth 1 -type f -name '*.o' -print | LC_ALL=C sort) + for object in \ + "$postgis_build/deps/flatgeobuf/flatgeobuf_c.o" \ + "$postgis_build/deps/flatgeobuf/geometrywriter.o" \ + "$postgis_build/deps/flatgeobuf/geometryreader.o" \ + "$postgis_build/deps/flatgeobuf/packedrtree.o" + do + [ -f "$object" ] || oliphaunt_postgis_fail "PostGIS FlatGeobuf object is missing: $object" + oliphaunt_postgis_stage_object "$object" flatgeobuf "$object_dir" "$objects_file" + done + oliphaunt_postgis_copy_archive_objects "$postgis_build/liblwgeom/.libs/liblwgeom.a" liblwgeom "$object_dir" "$objects_file" + oliphaunt_postgis_copy_archive_objects "$postgis_build/libpgcommon/libpgcommon.a" libpgcommon "$object_dir" "$objects_file" + [ -s "$objects_file" ] || oliphaunt_postgis_fail "PostGIS did not produce object inputs" + oliphaunt_postgis_verify_prefixed_module_symbol "$stem" "$prefix" + oliphaunt_postgis_verify_prefixed_legacy_symbols "$stem" "$prefix" + oliphaunt_postgis_stage_runtime_sql "$postgis_build" + archive_mobile_static_extension_objects "$extension" "$object_dir" "$objects_file" +} + +oliphaunt_postgis_extra_link_args() { + oliphaunt_postgis_selected || return 0 + case "$oliphaunt_mobile_target" in + ios-simulator | ios-device) + printf '%s\n' -lc++ + ;; + android-arm64 | android-x86_64) + printf '%s\n' \ + "$toolchain_dir/sysroot/usr/lib/$android_host/libc++_static.a" \ + "$toolchain_dir/sysroot/usr/lib/$android_host/libc++abi.a" + ;; + esac +} diff --git a/src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh b/src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh new file mode 100644 index 00000000..0e1e5f20 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash + +oliphaunt_mobile_static_specs_tsv() { + local script_dir + script_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + printf '%s\n' "$script_dir/../../../../../src/extensions/generated/mobile/static-extensions.tsv" +} + +oliphaunt_mobile_static_extension_spec() { + local extension="${1:?missing mobile static extension}" + local spec_path + spec_path="$(oliphaunt_mobile_static_specs_tsv)" + [ -f "$spec_path" ] || return 1 + awk -F '\t' -v extension="$extension" ' + $1 == extension { + printf "%s", $1 + for (field = 2; field <= 16; field++) { + printf "|%s", $field + } + printf "\n" + found = 1 + exit + } + END { exit found ? 0 : 1 } + ' "$spec_path" +} + +oliphaunt_mobile_static_supported_extensions() { + local spec_path + spec_path="$(oliphaunt_mobile_static_specs_tsv)" + [ -f "$spec_path" ] || return 1 + awk -F '\t' 'NR > 2 && $1 != "" { print $1 }' "$spec_path" +} + +oliphaunt_mobile_static_spec_field() { + local spec="${1:?missing mobile static extension spec}" + local field="${2:?missing mobile static extension spec field}" + printf '%s\n' "$spec" | awk -F '|' -v field="$field" '{ print $field }' +} + +oliphaunt_mobile_static_extension_sql_name() { + oliphaunt_mobile_static_spec_field "$(oliphaunt_mobile_static_extension_spec "$1")" 1 +} + +oliphaunt_mobile_static_extension_module_stem() { + oliphaunt_mobile_static_spec_field "$(oliphaunt_mobile_static_extension_spec "$1")" 2 +} + +oliphaunt_mobile_static_extension_kind() { + oliphaunt_mobile_static_spec_field "$(oliphaunt_mobile_static_extension_spec "$1")" 3 +} + +oliphaunt_mobile_static_extension_dependencies() { + local extension="${1:?missing mobile static extension}" + if [ -n "${oliphaunt_mobile_target:-}" ]; then + oliphaunt_mobile_static_extension_dependencies_for_target "$extension" "$oliphaunt_mobile_target" + return 0 + fi + oliphaunt_mobile_static_extension_dependency_field "$extension" 5 +} + +oliphaunt_mobile_static_extension_dependencies_for_target() { + local extension="${1:?missing mobile static extension}" + local target="${2:?missing mobile static target}" + case "$target" in + ios | ios-simulator | ios-device) + oliphaunt_mobile_static_extension_dependency_field "$extension" 6 5 + ;; + android | android-arm64 | android-x86_64 | arm64-v8a | x86_64) + oliphaunt_mobile_static_extension_dependency_field "$extension" 7 5 + ;; + *) + oliphaunt_mobile_static_extension_dependency_field "$extension" 5 + ;; + esac +} + +oliphaunt_mobile_static_extension_dependency_field() { + oliphaunt_mobile_static_extension_list_field "$@" +} + +oliphaunt_mobile_static_extension_list_field() { + local extension="${1:?missing mobile static extension}" + local primary_field="${2:?missing list field}" + local fallback_field="${3:-}" + local spec values + spec="$(oliphaunt_mobile_static_extension_spec "$extension")" + values="$(oliphaunt_mobile_static_spec_field "$spec" "$primary_field")" + if [ -z "$values" ] && [ -n "$fallback_field" ]; then + values="$(oliphaunt_mobile_static_spec_field "$spec" "$fallback_field")" + fi + printf '%s\n' "$values" | tr ',' '\n' | sed '/^$/d' +} + +oliphaunt_mobile_static_dependency_archive_candidates() { + local dependency_root="${1:?missing mobile static dependency root}" + local dependency="${2:?missing mobile static dependency name}" + case "$dependency" in + geos-c) printf '%s\n' "$dependency_root/geos/lib/libgeos_c.a" ;; + geos) printf '%s\n' "$dependency_root/geos/lib/libgeos.a" ;; + json-c) printf '%s\n' "$dependency_root/json-c/lib/libjson-c.a" ;; + libcharset) printf '%s\n' "$dependency_root/libiconv/lib/libcharset.a" ;; + libiconv) printf '%s\n' "$dependency_root/libiconv/lib/libiconv.a" ;; + libxml2) printf '%s\n' "$dependency_root/libxml2/lib/libxml2.a" ;; + openssl) + printf '%s\n' \ + "$dependency_root/openssl/libcrypto.a" \ + "$dependency_root/openssl/lib/libcrypto.a" + ;; + proj) printf '%s\n' "$dependency_root/proj/lib/libproj.a" ;; + sqlite) printf '%s\n' "$dependency_root/sqlite/lib/libsqlite3.a" ;; + uuid) printf '%s\n' "$dependency_root/uuid/lib/libuuid.a" ;; + *) + printf '%s\n' "$dependency_root/$dependency/lib$dependency.a" + ;; + esac +} + +oliphaunt_mobile_static_dependency_archive_for_root() { + local dependency_root="${1:?missing mobile static dependency root}" + local dependency="${2:?missing mobile static dependency name}" + local candidate + while IFS= read -r candidate; do + [ -n "$candidate" ] || continue + if [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done < <(oliphaunt_mobile_static_dependency_archive_candidates "$dependency_root" "$dependency") + return 1 +} + +oliphaunt_mobile_static_extension_source_rel() { + oliphaunt_mobile_static_spec_field "$(oliphaunt_mobile_static_extension_spec "$1")" 4 +} + +oliphaunt_static_symbol_prefix() { + local stem="${1:?missing mobile static module stem}" + printf 'oliphaunt_static_' + printf '%s' "$stem" | tr -c 'A-Za-z0-9_' '_' + printf '\n' +} + +oliphaunt_mobile_static_extension_source_dir() { + local repo_root="${1:?missing repo root}" + local build_dir="${2:?missing PostgreSQL build dir}" + local extension="${3:?missing mobile static extension}" + local rel + rel="$(oliphaunt_mobile_static_extension_source_rel "$extension")" + case "$(oliphaunt_mobile_static_extension_kind "$extension")" in + contrib) printf '%s/%s\n' "$build_dir" "$rel" ;; + external) printf '%s/%s\n' "$repo_root" "$rel" ;; + *) return 1 ;; + esac +} + +oliphaunt_mobile_static_extension_source_files() { + local repo_root="${1:?missing repo root}" + local build_dir="${2:?missing PostgreSQL build dir}" + local extension="${3:?missing mobile static extension}" + local source_dir + source_dir="$(oliphaunt_mobile_static_extension_source_dir "$repo_root" "$build_dir" "$extension")" + local configured_source source_subdir used_configured_source + used_configured_source=0 + while IFS= read -r configured_source; do + [ -n "$configured_source" ] || continue + printf '%s\n' "$source_dir/$configured_source" + used_configured_source=1 + done < <(oliphaunt_mobile_static_extension_list_field "$extension" 15) + while IFS= read -r source_subdir; do + [ -n "$source_subdir" ] || continue + if [ -d "$source_dir/$source_subdir" ]; then + find "$source_dir/$source_subdir" -type f -name '*.c' -print | LC_ALL=C sort + used_configured_source=1 + fi + done < <(oliphaunt_mobile_static_extension_list_field "$extension" 16) + [ "$used_configured_source" -eq 0 ] || return 0 + if find "$source_dir" -maxdepth 1 -type f -name '*.c' -print -quit | grep -q .; then + find "$source_dir" -maxdepth 1 -type f -name '*.c' -print | LC_ALL=C sort + return 0 + fi + if [ -d "$source_dir/src" ]; then + find "$source_dir/src" -maxdepth 1 -type f -name '*.c' -print | LC_ALL=C sort + fi +} + +oliphaunt_mobile_static_extension_include_dirs() { + local repo_root="${1:?missing repo root}" + local build_dir="${2:?missing PostgreSQL build dir}" + local extension="${3:?missing mobile static extension}" + local dependency include_dir source_dir + source_dir="$(oliphaunt_mobile_static_extension_source_dir "$repo_root" "$build_dir" "$extension")" + printf '%s\n' "$source_dir" + if [ -n "${OLIPHAUNT_MOBILE_STATIC_DEPENDENCY_ROOT:-}" ]; then + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + printf '%s/%s/include\n' "$OLIPHAUNT_MOBILE_STATIC_DEPENDENCY_ROOT" "$dependency" + done < <(oliphaunt_mobile_static_extension_list_field "$extension" 8) + fi + while IFS= read -r include_dir; do + [ -n "$include_dir" ] || continue + oliphaunt_mobile_static_expand_path "$repo_root" "$build_dir" "$source_dir" "$include_dir" + done < <(oliphaunt_mobile_static_extension_list_field "$extension" 9) +} + +oliphaunt_mobile_static_extension_cflags() { + oliphaunt_mobile_static_extension_list_field "$1" 10 +} + +oliphaunt_mobile_static_extension_hash_inputs() { + local repo_root="${1:?missing repo root}" + local build_dir="${2:?missing PostgreSQL build dir}" + local extension="${3:?missing mobile static extension}" + local source_dir + source_dir="$(oliphaunt_mobile_static_extension_source_dir "$repo_root" "$build_dir" "$extension")" + if [ ! -d "$source_dir" ]; then + return 0 + fi + find "$source_dir" -maxdepth 3 -type f \( \ + -name '*.c' -o \ + -name '*.h' -o \ + -name '*.control' -o \ + -path '*/sql/*.sql' -o \ + -name '*.sql' -o \ + -name 'Makefile' \ + \) -print | LC_ALL=C sort + local dependency_dir hash_dir hash_source_dependency + while IFS= read -r hash_source_dependency; do + [ -n "$hash_source_dependency" ] || continue + dependency_dir="$repo_root/target/oliphaunt-sources/checkouts/$hash_source_dependency" + oliphaunt_mobile_static_hash_tree "$dependency_dir" + done < <(oliphaunt_mobile_static_extension_hash_source_dependencies "$extension") + while IFS= read -r hash_dir; do + [ -n "$hash_dir" ] || continue + oliphaunt_mobile_static_hash_tree \ + "$(oliphaunt_mobile_static_expand_path "$repo_root" "$build_dir" "$source_dir" "$hash_dir")" + done < <(oliphaunt_mobile_static_extension_list_field "$extension" 14) +} + +oliphaunt_mobile_static_extension_hash_source_dependencies() { + local extension="${1:?missing mobile static extension}" + if [ -n "${oliphaunt_mobile_target:-}" ]; then + case "$oliphaunt_mobile_target" in + ios | ios-simulator | ios-device) + oliphaunt_mobile_static_extension_list_field "$extension" 12 11 + return 0 + ;; + android | android-arm64 | android-x86_64 | arm64-v8a | x86_64) + oliphaunt_mobile_static_extension_list_field "$extension" 13 11 + return 0 + ;; + esac + fi + oliphaunt_mobile_static_extension_list_field "$extension" 11 +} + +oliphaunt_mobile_static_expand_path() { + local repo_root="${1:?missing repo root}" + local build_dir="${2:?missing PostgreSQL build dir}" + local source_dir="${3:?missing source dir}" + local path="${4:?missing path}" + case "$path" in + repo:*) printf '%s/%s\n' "$repo_root" "${path#repo:}" ;; + build:*) printf '%s/%s\n' "$build_dir" "${path#build:}" ;; + source:*) printf '%s/%s\n' "$source_dir" "${path#source:}" ;; + /*) printf '%s\n' "$path" ;; + *) printf '%s/%s\n' "$repo_root" "$path" ;; + esac +} + +oliphaunt_mobile_static_hash_tree() { + local dir="$1" + [ -d "$dir" ] || return 0 + find "$dir" -maxdepth 3 -type f \( \ + -name '*.c' -o \ + -name '*.cc' -o \ + -name '*.cpp' -o \ + -name '*.h' -o \ + -name '*.hpp' -o \ + -name '*.in' -o \ + -name '*.conf' -o \ + -name 'CMakeLists.txt' -o \ + -name 'Configure' -o \ + -name 'VERSION.dat' -o \ + -name 'configure' -o \ + -name 'configure.ac' -o \ + -name 'Makefile' -o \ + -name 'Makefile.in' \ + \) -print | LC_ALL=C sort +} diff --git a/src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh b/src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh new file mode 100755 index 00000000..ec862928 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/run-native-postgres-regression-sql.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$script_dir/common.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +work_root="${OLIPHAUNT_WORK_ROOT:-$repo_root/target/liboliphaunt-pg18}" +liboliphaunt="${LIBOLIPHAUNT_PATH:-$work_root/out/liboliphaunt.dylib}" +initdb="${OLIPHAUNT_INITDB:-$work_root/install/bin/initdb}" +postgres="${OLIPHAUNT_POSTGRES:-$work_root/install/bin/postgres}" +test_bin="${OLIPHAUNT_POSTGRES_REGRESSION_BIN:-}" + +cases=( + datatypes_cover_oliphaunt_basic_surface + ddl_schema_view_trigger_and_rollback_behave_like_postgres + transactions_savepoints_and_error_recovery_match_postgres + expected_sql_error_recovery_stays_inside_protocol_loop + pg17_uuidv4_alias_error_is_recoverable + planner_uses_indexes_for_selective_queries_and_updates + direct_blob_copy_round_trips_csv_with_oliphaunt_dev_blob_surface +) + +if [ ! -f "$liboliphaunt" ] || [ ! -x "$initdb" ] || [ ! -x "$postgres" ]; then + echo "native liboliphaunt artifacts are missing; run src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh first" >&2 + exit 1 +fi + +if [ -z "$test_bin" ] || [ ! -x "$test_bin" ]; then + ( + cd "$repo_root" + cargo test --test postgres_regression --no-run + ) || exit $? + test_bin="$( + find "$repo_root/target/debug/deps" \ + -maxdepth 1 \ + -type f \ + -name 'postgres_regression-*' \ + -perm -111 \ + -print | + sort | + tail -n 1 + )" +fi + +if [ -z "$test_bin" ] || [ ! -x "$test_bin" ]; then + echo "could not locate compiled postgres_regression test binary" >&2 + exit 1 +fi + +export OLIPHAUNT_INITDB="$initdb" +export OLIPHAUNT_POSTGRES="$postgres" +export LIBOLIPHAUNT_PATH="$liboliphaunt" +export OLIPHAUNT_INITDB="$initdb" +export OLIPHAUNT_POSTGRES="$postgres" +export LIBOLIPHAUNT_PATH="$liboliphaunt" +export OLIPHAUNT_INITDB="$initdb" +export OLIPHAUNT_POSTGRES="$postgres" +export OLIPHAUNT_WASM_POSTGRES_REGRESSION_NATIVE=1 + +failed=() +for case in "${cases[@]}"; do + printf '\n===== native SQL regression: %s =====\n' "$case" + if ! "$test_bin" "$case" --exact --nocapture; then + failed+=("$case") + fi +done + +if [ "${#failed[@]}" -ne 0 ]; then + printf '\nFAILED native SQL regression cases:\n' >&2 + printf ' %s\n' "${failed[@]}" >&2 + exit 1 +fi + +printf '\nAll native SQL regression cases passed.\n' diff --git a/src/runtimes/liboliphaunt/native/bin/smoke-host-happy-path.sh b/src/runtimes/liboliphaunt/native/bin/smoke-host-happy-path.sh new file mode 100755 index 00000000..50505605 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/smoke-host-happy-path.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$script_dir/common.sh" +repo_root="$(oliphaunt_resolve_repo_root "$script_dir")" +cd "$repo_root" + +if [ "${1:-}" != "" ]; then + node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs --smoke-only --root "$1" +else + node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs --smoke-only +fi diff --git a/src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh b/src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh new file mode 100755 index 00000000..7691ef5c --- /dev/null +++ b/src/runtimes/liboliphaunt/native/bin/smoke-macos-happy-path.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +exec "$script_dir/smoke-host-happy-path.sh" "$@" diff --git a/src/runtimes/liboliphaunt/native/include/oliphaunt.h b/src/runtimes/liboliphaunt/native/include/oliphaunt.h new file mode 100644 index 00000000..262d46d5 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/include/oliphaunt.h @@ -0,0 +1,172 @@ +#ifndef OLIPHAUNT_H +#define OLIPHAUNT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define OLIPHAUNT_ABI_VERSION 6u +#define OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION 1u + +#define OLIPHAUNT_CAP_PROTOCOL_RAW (1ull << 0) +#define OLIPHAUNT_CAP_PROTOCOL_STREAM (1ull << 1) +#define OLIPHAUNT_CAP_MULTI_INSTANCE (1ull << 2) +#define OLIPHAUNT_CAP_SERVER_MODE (1ull << 3) +#define OLIPHAUNT_CAP_EXTENSIONS (1ull << 4) +#define OLIPHAUNT_CAP_QUERY_CANCEL (1ull << 5) +#define OLIPHAUNT_CAP_BACKUP_RESTORE (1ull << 6) +#define OLIPHAUNT_CAP_SIMPLE_QUERY (1ull << 7) +#define OLIPHAUNT_CAP_STATIC_EXTENSIONS (1ull << 8) +#define OLIPHAUNT_CAP_LOGICAL_REOPEN (1ull << 9) + +#define OLIPHAUNT_BACKUP_FORMAT_SQL 1u +#define OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE 2u +#define OLIPHAUNT_BACKUP_FORMAT_OLIPHAUNT_ARCHIVE 3u + +#if defined(_WIN32) && defined(OLIPHAUNT_BUILDING_DLL) +#define OLIPHAUNT_API __declspec(dllexport) +#elif defined(_WIN32) +#define OLIPHAUNT_API __declspec(dllimport) +#else +#define OLIPHAUNT_API +#endif + +/* + * The caller already owns an equivalent root lock for this PGDATA path. + * + * Leave this flag unset for plain C, Swift, Kotlin, and other direct C ABI + * callers; oliphaunt_init will then take a non-blocking stable filesystem lease + * for and create /.oliphaunt.lock as the + * visible root marker. The Rust SDK sets this flag because it owns a stronger + * process-plus-filesystem root coordinator across direct, broker, server, + * backup, and restore paths. + */ +#define OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK (1ull << 0) + +#define OLIPHAUNT_RESTORE_REPLACE_EXISTING (1ull << 0) + +typedef struct OliphauntHandle OliphauntHandle; + +typedef struct OliphauntStaticExtensionSymbol { + const char *name; + void *address; +} OliphauntStaticExtensionSymbol; + +typedef struct OliphauntStaticExtension { + uint32_t abi_version; + const char *name; + const void *(*magic)(void); + void (*init)(void); + const OliphauntStaticExtensionSymbol *symbols; + size_t symbol_count; + uint64_t reserved_flags; +} OliphauntStaticExtension; + +/* + * Registers statically linked PostgreSQL extension modules for the embedded + * backend's normal LOAD path. + * + * Call this before oliphaunt_init in processes that link extension code directly + * into the application or SDK library. The registry is process-wide and becomes + * immutable once backend startup begins. Each extension name is the module stem + * used by SQL, for example AS 'vector', and each symbol row exposes the C + * symbols PostgreSQL would otherwise resolve with dlsym(). + */ + +/* + * Direct-mode extension compatibility contract: + * + * oliphaunt_init sets the process PGDATA environment variable to this config's + * pgdata path while the embedded backend is active, because PostgreSQL + * extensions may read PGDATA through standard process APIs. oliphaunt_detach + * releases a logical direct-mode lease but keeps the resident backend alive; + * oliphaunt_close is terminal for the process lifetime and restores the caller's + * previous PGDATA value, or unsets it if it was unset. + * + * Callers that require process environment isolation should use broker/server + * mode through the Rust SDK instead of keeping multiple direct-mode backends in + * one process. + */ +typedef struct OliphauntConfig { + uint32_t abi_version; + const char *pgdata; + const char *runtime_dir; + const char *username; + const char *database; + uint64_t reserved_flags; + const char *const *startup_args; + size_t startup_arg_count; +} OliphauntConfig; + +typedef struct OliphauntResponse { + uint8_t *data; + size_t len; +} OliphauntResponse; + +typedef struct OliphauntArchiveFile { + const char *path; + const uint8_t *data; + size_t len; + uint32_t mode; + uint64_t reserved_flags; +} OliphauntArchiveFile; + +typedef struct OliphauntBackupOptions { + uint32_t abi_version; + uint32_t format; + const OliphauntArchiveFile *generated_files; + size_t generated_file_count; + uint64_t reserved_flags; +} OliphauntBackupOptions; + +typedef struct OliphauntRestoreOptions { + uint32_t abi_version; + const char *root; + uint32_t format; + const uint8_t *data; + size_t len; + uint64_t flags; +} OliphauntRestoreOptions; + +typedef int32_t (*OliphauntStreamCallback)(void *context, const uint8_t *data, size_t len); + +OLIPHAUNT_API int32_t oliphaunt_init(const OliphauntConfig *config, OliphauntHandle **out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_simple_query( + OliphauntHandle *handle, + const char *sql, + size_t sql_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol_stream( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +OLIPHAUNT_API int32_t oliphaunt_backup(OliphauntHandle *handle, uint32_t format, OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_backup_ex( + OliphauntHandle *handle, + const OliphauntBackupOptions *options, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_restore(const OliphauntRestoreOptions *options); +OLIPHAUNT_API int32_t oliphaunt_cancel(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_detach(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_close(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_register_static_extensions(const OliphauntStaticExtension *extensions, size_t count); +OLIPHAUNT_API const char *oliphaunt_last_error(OliphauntHandle *handle); +OLIPHAUNT_API const char *oliphaunt_version(void); +OLIPHAUNT_API uint64_t oliphaunt_capabilities(void); +OLIPHAUNT_API void oliphaunt_free_response(OliphauntResponse *response); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/runtimes/liboliphaunt/native/moon.yml b/src/runtimes/liboliphaunt/native/moon.yml new file mode 100644 index 00000000..7082a085 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/moon.yml @@ -0,0 +1,222 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "liboliphaunt-native" +language: "c" +layer: "library" +stack: "systems" +tags: ["native", "postgres", "c-abi", "pg18", "release-product"] +dependsOn: + - "postgres18" + - "third-party-shared" + - "third-party-native" + - "extension-runtime-contract" + +project: + title: "liboliphaunt Native" + description: "C ABI and PostgreSQL 18 patch stack for native embedded Oliphaunt." + owner: "oliphaunt" + release: + component: "liboliphaunt-native" + packagePath: "src/runtimes/liboliphaunt/native" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "node src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs" + deps: + - "postgres18:check" + - "third-party-shared:check" + - "third-party-native:check" + - "extension-runtime-contract:check" + inputs: + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/policy/check-native-boundaries.sh" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "runtime"] + command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh host-smoke" + env: + OLIPHAUNT_TRACK_BUILD: "never" + deps: + - "liboliphaunt-native:release-runtime" + inputs: + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/runtime/**/*" + options: + cache: false + runFromWorkspaceRoot: true + smoke: + tags: ["runtime", "smoke"] + command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh quick" + inputs: + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + release-runtime: + tags: ["runtime", "release"] + deps: + - "extension-runtime-contract:check" + - "source-inputs:source-fetch-native-runtime" + command: "node src/runtimes/liboliphaunt/native/tools/build-release-runtime.mjs" + inputs: + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + options: + cache: false + runFromWorkspaceRoot: true + release-runtime-desktop: + tags: ["runtime", "release", "ci-liboliphaunt-native-desktop"] + deps: + - "extension-runtime-contract:check" + - "source-inputs:source-fetch-native-runtime" + command: "node src/runtimes/liboliphaunt/native/tools/build-release-runtime.mjs" + inputs: + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + options: + cache: false + runFromWorkspaceRoot: true + release-runtime-mobile-target: + tags: + [ + "runtime", + "release", + "ci-liboliphaunt-native-android", + "ci-liboliphaunt-native-ios", + ] + deps: + - "extension-runtime-contract:check" + - "source-inputs:source-fetch-native-runtime" + command: "bash -c 'src/runtimes/liboliphaunt/native/tools/build-ci-target.sh \"$OLIPHAUNT_CI_TARGET\"'" + inputs: + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + options: + cache: false + runFromWorkspaceRoot: true + release-assets: + tags: ["runtime", "release", "artifact-package", "ci-liboliphaunt-native-release-assets"] + command: "bash tools/release/package-liboliphaunt-aggregate-assets.sh" + inputs: + - "/.release-please-manifest.json" + - "/release-please-config.json" + - "/src/extensions/generated/sdk/rust.json" + - "/src/runtimes/liboliphaunt/native/targets/**/*" + - "/tools/release/artifact_targets.py" + - "/tools/release/check_liboliphaunt_release_assets.py" + - "/tools/release/package-liboliphaunt-aggregate-assets.sh" + - "/tools/release/product_metadata.py" + - "/target/liboliphaunt/release-assets/**/*" + outputs: + - "/target/liboliphaunt/release-assets/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true + build-ios-xcframework: + tags: ["runtime", "artifact"] + command: "bash src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh" + inputs: + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + release-check: + tags: ["release", "package"] + command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh host-smoke" + env: + OLIPHAUNT_TRACK_BUILD: "never" + deps: + - "liboliphaunt-native:release-runtime" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/tools/xtask/**/*" + - "/rust-toolchain.toml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + bench: + tags: ["bench", "plan"] + command: "bash tools/perf/check-native-perf-harness.sh" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + - "/rust-toolchain.toml" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0001-liboliphaunt-add-backend-host-io.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0001-liboliphaunt-add-backend-host-io.patch new file mode 100644 index 00000000..3041086b --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0001-liboliphaunt-add-backend-host-io.patch @@ -0,0 +1,93 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: add backend host I/O callbacks + +Add an explicit backend libpq I/O hook for embedded hosts. This keeps the +normal socket path untouched while allowing liboliphaunt to route protocol bytes +through host-owned buffers without preprocessor remapping of send()/recv(). +--- + src/backend/libpq/be-secure.c | 10 ++++++++++ + src/backend/libpq/pqcomm.c | 8 ++++++++ + src/include/libpq/libpq-be.h | 11 +++++++++++ + 3 files changed, 29 insertions(+) + +diff --git a/src/backend/libpq/be-secure.c b/src/backend/libpq/be-secure.c +index 2c9dfc28f0..8c3681e470 100644 +--- a/src/backend/libpq/be-secure.c ++++ b/src/backend/libpq/be-secure.c +@@ -288,6 +288,11 @@ secure_raw_read(Port *port, void *ptr, size_t len) + return len; + } + ++#ifdef OLIPHAUNT_EMBEDDED ++ if (port->oliphaunt_io != NULL && port->oliphaunt_io->read != NULL) ++ return port->oliphaunt_io->read(port->oliphaunt_io->context, ptr, len); ++#endif ++ + /* + * Try to read from the socket without blocking. If it succeeds we're + * done, otherwise we'll wait for the socket using the latch mechanism. +@@ -381,6 +386,11 @@ secure_raw_write(Port *port, const void *ptr, size_t len) + { + ssize_t n; + ++#ifdef OLIPHAUNT_EMBEDDED ++ if (port->oliphaunt_io != NULL && port->oliphaunt_io->write != NULL) ++ return port->oliphaunt_io->write(port->oliphaunt_io->context, ptr, len); ++#endif ++ + #ifdef WIN32 + pgwin32_noblock = true; + #endif +diff --git a/src/backend/libpq/pqcomm.c b/src/backend/libpq/pqcomm.c +index 6f7b4f62b6..ecb1a62db8 100644 +--- a/src/backend/libpq/pqcomm.c ++++ b/src/backend/libpq/pqcomm.c +@@ -309,6 +309,14 @@ pq_init(ClientSocket *client_sock) + port->sock, NULL, NULL); + latch_pos = AddWaitEventToSet(FeBeWaitSet, WL_LATCH_SET, PGINVALID_SOCKET, + MyLatch, NULL); ++#ifdef OLIPHAUNT_EMBEDDED ++ /* ++ * Embedded liboliphaunt initializes PostgreSQL as a standalone backend and ++ * therefore has no postmaster death watch handle. Normal postmaster ++ * children keep the upstream wait event unchanged. ++ */ ++ if (IsUnderPostmaster) ++#endif + AddWaitEventToSet(FeBeWaitSet, WL_POSTMASTER_DEATH, PGINVALID_SOCKET, + NULL, NULL); + +diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h +index 6aa52a64ff..112afe80ac 100644 +--- a/src/include/libpq/libpq-be.h ++++ b/src/include/libpq/libpq-be.h +@@ -99,6 +99,15 @@ typedef struct ClientConnectionInfo + UserAuth auth_method; + } ClientConnectionInfo; + ++#ifdef OLIPHAUNT_EMBEDDED ++typedef struct OliphauntEmbeddedIO ++{ ++ void *context; ++ ssize_t (*read) (void *context, void *ptr, size_t len); ++ ssize_t (*write) (void *context, const void *ptr, size_t len); ++} OliphauntEmbeddedIO; ++#endif ++ + /* + * The Port structure holds state information about a client connection in a + * backend process. It is available in the global variable MyProcPort. The +@@ -244,6 +253,9 @@ typedef struct Port + char *raw_buf; + ssize_t raw_buf_consumed, + raw_buf_remaining; ++#ifdef OLIPHAUNT_EMBEDDED ++ OliphauntEmbeddedIO *oliphaunt_io; ++#endif + } Port; + + /* +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0002-liboliphaunt-add-embedded-entrypoint.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0002-liboliphaunt-add-embedded-entrypoint.patch new file mode 100644 index 00000000..c34f0e3b --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0002-liboliphaunt-add-embedded-entrypoint.patch @@ -0,0 +1,152 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: add embedded backend entrypoint + +Add a dedicated embedded entrypoint for liboliphaunt. The entrypoint performs +standalone backend initialization directly, installs a host-backed Port, emits +the normal AuthenticationOk message, and then lets PostgreSQL's ordinary +PostgresMain loop own protocol execution on the backend thread. + +This deliberately avoids driving the backend through main("--single", ...). +The standalone initialization path is reused, but the protocol lifecycle is a +real FE/BE remote connection backed by host I/O callbacks. +--- + src/backend/tcop/postgres.c | 118 +++++++++++++++++++++++++++++++++++- + 1 file changed, 117 insertions(+), 1 deletion(-) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index 39e4e3216d..a56f321579 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -24,6 +24,9 @@ + #include + #include + #include ++#ifdef OLIPHAUNT_EMBEDDED ++#include ++#endif + #include + + #ifdef USE_VALGRIND +@@ -43,6 +46,10 @@ + #include "libpq/libpq.h" + #include "libpq/pqformat.h" + #include "libpq/pqsignal.h" ++#ifdef OLIPHAUNT_EMBEDDED ++#include "libpq/libpq-be.h" ++#include "libpq/protocol.h" ++#endif + #include "mb/pg_wchar.h" + #include "mb/stringinfo_mb.h" + #include "miscadmin.h" +@@ -4161,6 +4168,107 @@ PostgresSingleUserMain(int argc, char *argv[], + PostgresMain(dbname, username); + } + ++#ifdef OLIPHAUNT_EMBEDDED ++/* ++ * oliphaunt_embedded_main ++ * ++ * Dedicated liboliphaunt backend entrypoint. This mirrors the standalone ++ * bootstrap used by PostgresSingleUserMain(), but installs a normal remote ++ * FE/BE Port backed by host I/O callbacks before entering PostgresMain(). ++ * ++ * The caller owns the thread. This function returns when the frontend sends ++ * Terminate. ++ */ ++int ++oliphaunt_embedded_main(int argc, char *argv[], ++ const char *dbname, const char *username, ++ OliphauntEmbeddedIO *io) ++{ ++ const char *switch_dbname = NULL; ++ int socket_pair[2] = {-1, -1}; ++ ClientSocket client_sock; ++ struct sockaddr_in *addr; ++ ++ if (argv == NULL || argv[0] == NULL || dbname == NULL || ++ username == NULL || io == NULL) ++ return -1; ++ ++ Assert(!IsUnderPostmaster); ++ ++ if (progname == NULL) ++ progname = get_progname(argv[0]); ++ MyProcPid = getpid(); ++ if (TopMemoryContext == NULL) ++ MemoryContextInit(); ++ (void) set_stack_base(); ++ set_pglocale_pgservice(argv[0], PG_TEXTDOMAIN("postgres")); ++ ++ InitStandaloneProcess(argv[0]); ++ InitializeGUCOptions(); ++ process_postgres_switches(argc, argv, PGC_POSTMASTER, &switch_dbname); ++ ++ if (switch_dbname != NULL && strcmp(switch_dbname, dbname) != 0) ++ ereport(FATAL, ++ (errcode(ERRCODE_INVALID_PARAMETER_VALUE), ++ errmsg("embedded liboliphaunt database name was provided twice"))); ++ ++ if (!SelectConfigFiles(userDoption, progname)) ++ return -1; ++ ++ checkDataDir(); ++ ChangeToDataDir(); ++ CreateDataDirLockFile(false); ++ LocalProcessControlFile(false); ++ process_shared_preload_libraries(); ++ InitializeMaxBackends(); ++ InitPostmasterChildSlots(); ++ InitializeFastPathLocks(); ++ process_shmem_requests(); ++ InitializeShmemGUCs(); ++ InitializeWalConsistencyChecking(); ++ CreateSharedMemoryAndSemaphores(); ++ set_max_safe_fds(); ++ PgStartTime = GetCurrentTimestamp(); ++ InitProcess(); ++ ++ if (socketpair(AF_UNIX, SOCK_STREAM, 0, socket_pair) != 0) ++ ereport(FATAL, ++ (errmsg("could not create embedded liboliphaunt socketpair: %m"))); ++ ++ memset(&client_sock, 0, sizeof(client_sock)); ++ client_sock.sock = socket_pair[0]; ++ addr = (struct sockaddr_in *) &client_sock.raddr.addr; ++ addr->sin_family = AF_INET; ++ addr->sin_port = htons(5432); ++ addr->sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ client_sock.raddr.salen = sizeof(struct sockaddr_in); ++ ++ whereToSendOutput = DestRemote; ++ MyBackendType = B_BACKEND; ++ MyProcPort = pq_init(&client_sock); ++ MyProcPort->oliphaunt_io = io; ++ ++ /* ++ * Authentication is host-owned for embedded mode. Emit the standard ++ * AuthenticationOk message before PostgresMain reports ParameterStatus, ++ * BackendKeyData, and ReadyForQuery. ++ */ ++ { ++ StringInfoData buf; ++ ++ pq_beginmessage(&buf, PqMsg_AuthenticationRequest); ++ pq_sendint32(&buf, (int32) AUTH_REQ_OK); ++ pq_endmessage(&buf); ++ } ++ ++ PostgresMain(dbname, username); ++ ++ if (socket_pair[1] >= 0) ++ close(socket_pair[1]); ++ ++ return 0; ++} ++#endif + + /* ---------------------------------------------------------------- + * PostgresMain +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0003-liboliphaunt-return-from-embedded-frontend-terminate.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0003-liboliphaunt-return-from-embedded-frontend-terminate.patch new file mode 100644 index 00000000..d0852352 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0003-liboliphaunt-return-from-embedded-frontend-terminate.patch @@ -0,0 +1,60 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: return from embedded frontend terminate + +Let embedded liboliphaunt backend threads return to their host owner when the +frontend sends Terminate. PostgreSQL's normal backend process path still uses +proc_exit(0); only OLIPHAUNT_EMBEDDED changes PostgresMain into a returning +function. +--- + src/backend/tcop/postgres.c | 9 ++++++++- + src/include/tcop/tcopprot.h | 7 +++++++ + 2 files changed, 15 insertions(+), 1 deletion(-) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index a56f321579..ca3dc3fa4 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -5097,10 +5097,16 @@ PostgresMain(const char *dbname, const char *username) + * Whatever you had in mind to do should be set up as an + * on_proc_exit or on_shmem_exit callback, instead. Otherwise + * it will fail to be called during other backend-shutdown +- * scenarios. ++ * scenarios. Embedded liboliphaunt owns the backend thread and ++ * returns here so the host can join it without terminating the ++ * process. + */ ++#ifdef OLIPHAUNT_EMBEDDED ++ return; ++#else + proc_exit(0); ++#endif + + case PqMsg_CopyData: + case PqMsg_CopyDone: + case PqMsg_CopyFail: +diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h +index 65f9ea20f4..f9c03c87d9 100644 +--- a/src/include/tcop/tcopprot.h ++++ b/src/include/tcop/tcopprot.h +@@ -78,10 +78,17 @@ extern void ProcessClientWriteInterrupt(bool blocked); + + extern void process_postgres_switches(int argc, char *argv[], + GucContext ctx, const char **dbname); ++#ifdef OLIPHAUNT_EMBEDDED ++extern void PostgresSingleUserMain(int argc, char *argv[], ++ const char *username); ++extern void PostgresMain(const char *dbname, ++ const char *username); ++#else + pg_noreturn extern void PostgresSingleUserMain(int argc, char *argv[], + const char *username); + pg_noreturn extern void PostgresMain(const char *dbname, + const char *username); ++#endif + extern void ResetUsage(void); + extern void ShowUsage(const char *title); + extern int check_log_duration(char *msec_str, bool was_logged); +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0004-liboliphaunt-run-embedded-exit-cleanup.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0004-liboliphaunt-run-embedded-exit-cleanup.patch new file mode 100644 index 00000000..94bcacc8 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0004-liboliphaunt-run-embedded-exit-cleanup.patch @@ -0,0 +1,73 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: run embedded exit cleanup without exiting + +Embedded liboliphaunt owns a backend thread inside a host process. When the +frontend sends Terminate, the thread must return to the host rather than call +exit(3), but PostgreSQL's normal proc_exit callback chain still needs to run +so shared memory, semaphores, lock files, sockets, and backend-local state are +released in the upstream order. + +Expose an embedded-only cleanup wrapper around proc_exit_prepare() and call it +after the embedded PostgresMain loop returns. +--- + src/backend/storage/ipc/ipc.c | 15 +++++++++++++++ + src/backend/tcop/postgres.c | 1 + + src/include/storage/ipc.h | 3 +++ + 3 files changed, 19 insertions(+) + +diff --git a/src/backend/storage/ipc/ipc.c b/src/backend/storage/ipc/ipc.c +index 59d3b90b6d..e5f4b93207 100644 +--- a/src/backend/storage/ipc/ipc.c ++++ b/src/backend/storage/ipc/ipc.c +@@ -217,6 +217,21 @@ proc_exit_prepare(int code) + + on_proc_exit_index = 0; + } ++ ++#ifdef OLIPHAUNT_EMBEDDED ++/* ++ * Run PostgreSQL's normal backend exit callbacks without terminating the host ++ * process. This is only valid for the embedded thread owner that already ++ * arranged for PostgresMain() to return on frontend Terminate. ++ */ ++void ++oliphaunt_embedded_proc_exit(int code) ++{ ++ proc_exit_prepare(code); ++ ++ proc_exit_inprogress = false; ++} ++#endif + + /* ------------------ + * Run all of the on_shmem_exit routines --- but don't actually exit. +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index a1d6b4b3e2..ad8c551e88 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -4269,6 +4269,7 @@ oliphaunt_embedded_main(int argc, char *argv[], + } + + PostgresMain(dbname, username); ++ oliphaunt_embedded_proc_exit(0); + + if (socket_pair[1] >= 0) + close(socket_pair[1]); +diff --git a/src/include/storage/ipc.h b/src/include/storage/ipc.h +index d27f8a5398..25b8697bf 100644 +--- a/src/include/storage/ipc.h ++++ b/src/include/storage/ipc.h +@@ -68,6 +68,9 @@ extern PGDLLIMPORT bool shmem_exit_inprogress; + + pg_noreturn extern void proc_exit(int code); + extern void shmem_exit(int code); ++#ifdef OLIPHAUNT_EMBEDDED ++extern void oliphaunt_embedded_proc_exit(int code); ++#endif + extern void on_proc_exit(pg_on_exit_callback function, Datum arg); + extern void on_shmem_exit(pg_on_exit_callback function, Datum arg); + extern void before_shmem_exit(pg_on_exit_callback function, Datum arg); +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0005-liboliphaunt-restore-host-cwd.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0005-liboliphaunt-restore-host-cwd.patch new file mode 100644 index 00000000..511ccfd8 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0005-liboliphaunt-restore-host-cwd.patch @@ -0,0 +1,50 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: restore host cwd after embedded shutdown + +PostgreSQL's standalone startup changes the process working directory to +PGDATA. Embedded liboliphaunt must not leave the host process in that directory +after close, so preserve the caller cwd around the embedded backend lifetime +and restore it after PostgreSQL's normal exit cleanup callbacks run. +--- + src/backend/tcop/postgres.c | 7 +++++++ + 1 file changed, 7 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index ad8c551e88..34a0a16750 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -4196,6 +4196,8 @@ oliphaunt_embedded_main(int argc, char *argv[], + int socket_pair[2] = {-1, -1}; + ClientSocket client_sock; + struct sockaddr_in *addr; ++ char original_cwd[MAXPGPATH]; ++ bool have_original_cwd; + + if (argv == NULL || argv[0] == NULL || dbname == NULL || + username == NULL || io == NULL) +@@ -4222,6 +4224,8 @@ oliphaunt_embedded_main(int argc, char *argv[], + if (!SelectConfigFiles(userDoption, progname)) + return -1; + ++ have_original_cwd = (getcwd(original_cwd, sizeof(original_cwd)) != NULL); ++ + checkDataDir(); + ChangeToDataDir(); + CreateDataDirLockFile(false); +@@ -4272,8 +4276,11 @@ oliphaunt_embedded_main(int argc, char *argv[], + PostgresMain(dbname, username); +- oliphaunt_embedded_proc_exit(0); ++ oliphaunt_embedded_proc_exit(0); + ++ if (have_original_cwd && chdir(original_cwd) != 0) ++ return -1; ++ + if (socket_pair[1] >= 0) + close(socket_pair[1]); + + return 0; + } +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0006-liboliphaunt-add-static-extension-loader.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0006-liboliphaunt-add-static-extension-loader.patch new file mode 100644 index 00000000..854d1297 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0006-liboliphaunt-add-static-extension-loader.patch @@ -0,0 +1,223 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: add static extension loader + +Allow embedded PostgreSQL hosts to provide statically linked extension modules +through weak lookup callbacks. Normal dynamic loading remains unchanged when no +static extension is registered for a requested module. +--- + src/backend/utils/fmgr/dfmgr.c | 100 ++++++++++++++++++++++++++++++++- + 1 file changed, 99 insertions(+), 1 deletion(-) + +diff --git a/src/backend/utils/fmgr/dfmgr.c b/src/backend/utils/fmgr/dfmgr.c +--- a/src/backend/utils/fmgr/dfmgr.c ++++ b/src/backend/utils/fmgr/dfmgr.c +@@ -31,6 +31,16 @@ + /* signature for PostgreSQL-specific library init function */ + typedef void (*PG_init_t) (void); + ++#ifdef OLIPHAUNT_EMBEDDED ++typedef struct OliphauntStaticExtension OliphauntStaticExtension; ++ ++#ifdef _MSC_VER ++#define OLIPHAUNT_OPTIONAL_HOOK ++#elif defined(__APPLE__) ++#define OLIPHAUNT_OPTIONAL_HOOK __attribute__((weak_import)) ++#else ++#define OLIPHAUNT_OPTIONAL_HOOK __attribute__((weak)) ++#endif ++ ++extern const OliphauntStaticExtension *oliphaunt_static_extension_lookup(const char *filename) OLIPHAUNT_OPTIONAL_HOOK; ++extern const Pg_magic_struct *oliphaunt_static_extension_magic(const OliphauntStaticExtension *extension) OLIPHAUNT_OPTIONAL_HOOK; ++extern void *oliphaunt_static_extension_symbol(const OliphauntStaticExtension *extension, ++ const char *symbol) OLIPHAUNT_OPTIONAL_HOOK; ++extern void oliphaunt_static_extension_init(const OliphauntStaticExtension *extension) OLIPHAUNT_OPTIONAL_HOOK; ++#endif ++ + /* hashtable entry for rendezvous variables */ + typedef struct + { +@@ -52,6 +62,10 @@ + ino_t inode; /* Inode number of file */ + #endif + void *handle; /* a handle for pg_dl* functions */ ++#ifdef OLIPHAUNT_EMBEDDED ++ bool is_static; /* handle is a liboliphaunt static extension */ ++ const OliphauntStaticExtension *static_extension; ++#endif + const Pg_magic_struct *magic; /* Location of module's magic block */ + char filename[FLEXIBLE_ARRAY_MEMBER]; /* Full pathname of file */ + }; +@@ -69,6 +83,7 @@ + char *Dynamic_library_path; + + static void *internal_load_library(const char *libname); ++static void *lookup_library_symbol(void *filehandle, const char *funcname); + pg_noreturn static void incompatible_module_error(const char *libname, + const Pg_abi_values *module_magic_data); + static char *expand_dynamic_library_name(const char *name); +@@ -125,7 +140,7 @@ + *filehandle = lib_handle; + + /* Look up the function within the library. */ +- retval = dlsym(lib_handle, funcname); ++ retval = lookup_library_symbol(lib_handle, funcname); + + if (retval == NULL && signalNotFound) + ereport(ERROR, +@@ -170,6 +185,26 @@ + void * + lookup_external_function(void *filehandle, const char *funcname) + { ++ return lookup_library_symbol(filehandle, funcname); ++} ++ ++ ++static void * ++lookup_library_symbol(void *filehandle, const char *funcname) ++{ ++#ifdef OLIPHAUNT_EMBEDDED ++ DynamicFileList *file_scanner; ++ ++ for (file_scanner = file_list; file_scanner != NULL; file_scanner = file_scanner->next) ++ { ++ if (file_scanner->handle == filehandle && file_scanner->is_static) ++ { ++ if (oliphaunt_static_extension_symbol == NULL) ++ return NULL; ++ return oliphaunt_static_extension_symbol(file_scanner->static_extension, funcname); ++ } ++ } ++#endif + return dlsym(filehandle, funcname); + } + +@@ -193,6 +228,9 @@ + char *load_error; + struct stat stat_buf; + PG_init_t PG_init; ++#ifdef OLIPHAUNT_EMBEDDED ++ const OliphauntStaticExtension *static_extension = NULL; ++#endif + + /* + * Scan the list of loaded FILES to see if the file has been loaded. +@@ -205,6 +243,13 @@ + + if (file_scanner == NULL) + { ++#ifdef OLIPHAUNT_EMBEDDED ++ if (oliphaunt_static_extension_lookup != NULL) ++ static_extension = oliphaunt_static_extension_lookup(libname); ++ ++ if (static_extension == NULL) ++ { ++#endif + /* + * Check for same files - different paths (ie, symlink or link) + */ +@@ -219,6 +264,9 @@ + !SAME_INODE(stat_buf, *file_scanner); + file_scanner = file_scanner->next) + ; ++#ifdef OLIPHAUNT_EMBEDDED ++ } ++#endif + } + + if (file_scanner == NULL) +@@ -235,6 +283,16 @@ + + MemSet(file_scanner, 0, offsetof(DynamicFileList, filename)); + strcpy(file_scanner->filename, libname); ++#ifdef OLIPHAUNT_EMBEDDED ++ if (static_extension != NULL) ++ { ++ file_scanner->is_static = true; ++ file_scanner->static_extension = static_extension; ++ file_scanner->handle = (void *) static_extension; ++ } ++ else ++ { ++#endif + file_scanner->device = stat_buf.st_dev; + #ifndef WIN32 + file_scanner->inode = stat_buf.st_ino; +@@ -252,13 +310,33 @@ + errmsg("could not load library \"%s\": %s", + libname, load_error))); + } ++#ifdef OLIPHAUNT_EMBEDDED ++ } ++#endif + + /* Check the magic function to determine compatibility */ ++#ifdef OLIPHAUNT_EMBEDDED ++ if (file_scanner->is_static) ++ { ++ if (oliphaunt_static_extension_magic != NULL) ++ magic_func = (PGModuleMagicFunction) oliphaunt_static_extension_magic; ++ else ++ magic_func = NULL; ++ } ++ else ++#endif + magic_func = (PGModuleMagicFunction) + dlsym(file_scanner->handle, PG_MAGIC_FUNCTION_NAME_STRING); + if (magic_func) + { +- const Pg_magic_struct *magic_data_ptr = (*magic_func) (); ++ const Pg_magic_struct *magic_data_ptr; ++ ++#ifdef OLIPHAUNT_EMBEDDED ++ if (file_scanner->is_static) ++ magic_data_ptr = oliphaunt_static_extension_magic(file_scanner->static_extension); ++ else ++#endif ++ magic_data_ptr = (*magic_func) (); + + /* Check ABI compatibility fields */ + if (magic_data_ptr->len != sizeof(Pg_magic_struct) || +@@ -269,6 +347,9 @@ + Pg_magic_struct module_magic_data = *magic_data_ptr; + + /* try to close library */ ++#ifdef OLIPHAUNT_EMBEDDED ++ if (!file_scanner->is_static) ++#endif + dlclose(file_scanner->handle); + free(file_scanner); + +@@ -282,6 +363,9 @@ + else + { + /* try to close library */ ++#ifdef OLIPHAUNT_EMBEDDED ++ if (!file_scanner->is_static) ++#endif + dlclose(file_scanner->handle); + free(file_scanner); + /* complain */ +@@ -294,9 +378,21 @@ + /* + * If the library has a _PG_init() function, call it. + */ ++#ifdef OLIPHAUNT_EMBEDDED ++ if (file_scanner->is_static) ++ { ++ if (oliphaunt_static_extension_init != NULL) ++ oliphaunt_static_extension_init(file_scanner->static_extension); ++ } ++ else ++ { ++#endif + PG_init = (PG_init_t) dlsym(file_scanner->handle, "_PG_init"); + if (PG_init) + (*PG_init) (); ++#ifdef OLIPHAUNT_EMBEDDED ++ } ++#endif + + /* OK to link it into list */ + if (file_list == NULL) diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch new file mode 100644 index 00000000..d88214b1 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch @@ -0,0 +1,187 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Thu, 21 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: disable shell commands on Apple mobile targets + +Apple mobile SDKs mark system(3) unavailable. Embedded liboliphaunt must not +depend on shell archive, restore, cleanup, or recovery-end commands on mobile +anyway: the app owns packaged runtime/template assets, and direct mode has no +postmaster-owned process environment for shell command execution. + +Keep desktop/server PostgreSQL behavior unchanged, but compile the system(3) +call sites out for OLIPHAUNT_EMBEDDED Apple mobile builds and fail those optional +commands explicitly. +--- + src/backend/access/transam/xlogarchive.c | 39 ++++++++++++++++++++++++ + src/backend/archive/shell_archive.c | 48 ++++++++++++++++++++++++++++ + 2 files changed, 87 insertions(+) + +diff --git a/src/backend/access/transam/xlogarchive.c b/src/backend/access/transam/xlogarchive.c +index 1ef1713..b26dd7b 100644 +--- a/src/backend/access/transam/xlogarchive.c ++++ b/src/backend/access/transam/xlogarchive.c +@@ -19,6 +19,29 @@ + #include + #include + ++#if defined(OLIPHAUNT_EMBEDDED) && defined(__APPLE__) ++#ifdef __has_include ++#if __has_include() ++#include ++#endif ++#else ++#include ++#endif ++#endif ++ ++#ifndef TARGET_OS_IPHONE ++#define TARGET_OS_IPHONE 0 ++#endif ++#ifndef TARGET_OS_TV ++#define TARGET_OS_TV 0 ++#endif ++#ifndef TARGET_OS_WATCH ++#define TARGET_OS_WATCH 0 ++#endif ++#ifndef TARGET_OS_VISION ++#define TARGET_OS_VISION 0 ++#endif ++ + #include "access/xlog.h" + #include "access/xlog_internal.h" + #include "access/xlogarchive.h" +@@ -32,6 +55,12 @@ + #include "storage/fd.h" + #include "storage/ipc.h" + ++#if defined(OLIPHAUNT_EMBEDDED) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_WATCH || TARGET_OS_VISION) ++#define OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS 1 ++#else ++#define OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS 0 ++#endif ++ + /* + * Attempt to retrieve the specified file from off-line archival storage. + * If successful, fill "path" with its complete path (note that this will be +@@ -174,7 +203,13 @@ RestoreArchivedFile(char *path, const char *xlogfname, + /* + * Copy xlog from archival storage to XLOGDIR + */ ++#if OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS ++ ereport(DEBUG2, ++ (errmsg("restore_command cannot be executed by embedded liboliphaunt on this platform"))); ++ rc = -1; ++#else + rc = system(xlogRestoreCmd); ++#endif + + PostRestoreCommand(); + +@@ -327,7 +362,11 @@ ExecuteRecoveryCommand(const char *command, const char *commandName, + */ + fflush(NULL); + pgstat_report_wait_start(wait_event_info); ++#if OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS ++ rc = -1; ++#else + rc = system(xlogRecoveryCmd); ++#endif + pgstat_report_wait_end(); + + pfree(xlogRecoveryCmd); +diff --git a/src/backend/archive/shell_archive.c b/src/backend/archive/shell_archive.c +index 828723a..26d38d7 100644 +--- a/src/backend/archive/shell_archive.c ++++ b/src/backend/archive/shell_archive.c +@@ -17,12 +17,41 @@ + + #include + ++#if defined(OLIPHAUNT_EMBEDDED) && defined(__APPLE__) ++#ifdef __has_include ++#if __has_include() ++#include ++#endif ++#else ++#include ++#endif ++#endif ++ ++#ifndef TARGET_OS_IPHONE ++#define TARGET_OS_IPHONE 0 ++#endif ++#ifndef TARGET_OS_TV ++#define TARGET_OS_TV 0 ++#endif ++#ifndef TARGET_OS_WATCH ++#define TARGET_OS_WATCH 0 ++#endif ++#ifndef TARGET_OS_VISION ++#define TARGET_OS_VISION 0 ++#endif ++ + #include "access/xlog.h" + #include "archive/archive_module.h" + #include "archive/shell_archive.h" + #include "common/percentrepl.h" + #include "pgstat.h" + ++#if defined(OLIPHAUNT_EMBEDDED) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_WATCH || TARGET_OS_VISION) ++#define OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS 1 ++#else ++#define OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS 0 ++#endif ++ + static bool shell_archive_configured(ArchiveModuleState *state); + static bool shell_archive_file(ArchiveModuleState *state, + const char *file, +@@ -46,7 +75,14 @@ static bool + shell_archive_configured(ArchiveModuleState *state) + { + if (XLogArchiveCommand[0] != '\0') ++#if OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS ++ { ++ arch_module_check_errdetail("\"archive_command\" cannot be executed by embedded liboliphaunt on this platform."); ++ return false; ++ } ++#else + return true; ++#endif + + arch_module_check_errdetail("\"%s\" is not set.", + "archive_command"); +@@ -60,7 +96,9 @@ shell_archive_file(ArchiveModuleState *state, const char *file, + { + char *xlogarchcmd; + char *nativePath = NULL; ++#if !OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS + int rc; ++#endif + + if (path) + { +@@ -74,6 +112,14 @@ shell_archive_file(ArchiveModuleState *state, const char *file, + ereport(DEBUG3, + (errmsg_internal("executing archive command \"%s\"", + xlogarchcmd))); ++#if OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS ++ ereport(LOG, ++ (errmsg("archive_command cannot be executed by embedded liboliphaunt on this platform"), ++ errdetail("The skipped archive command was: %s", xlogarchcmd))); ++ pfree(xlogarchcmd); ++ return false; ++#else ++ + + fflush(NULL); + pgstat_report_wait_start(WAIT_EVENT_ARCHIVE_COMMAND); +@@ -134,6 +180,7 @@ shell_archive_file(ArchiveModuleState *state, const char *file, + + elog(DEBUG1, "archived write-ahead log file \"%s\"", file); + return true; ++#endif + } + + static void +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0008-liboliphaunt-clean-embedded-symbols.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0008-liboliphaunt-clean-embedded-symbols.patch new file mode 100644 index 00000000..a020a600 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0008-liboliphaunt-clean-embedded-symbols.patch @@ -0,0 +1,144 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Thu, 21 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: clean embedded symbols + +Expose the embedded backend entrypoint through the PostgreSQL tcop header so +the translation unit has a normal prototype under OLIPHAUNT_EMBEDDED builds. + +Also avoid treating the host-provided static extension magic callback as a +PostgreSQL PG_MODULE_MAGIC function. The host callback needs extension +context, so call it directly and keep the dynamic-loader path unchanged for +normal modules. +--- + src/backend/utils/fmgr/dfmgr.c | 64 ++++++++++++++++----------------- + src/include/tcop/tcopprot.h | 4 ++ + 2 files changed, 36 insertions(+), 32 deletions(-) + +diff --git a/src/backend/utils/fmgr/dfmgr.c b/src/backend/utils/fmgr/dfmgr.c +index a72cbe06af..a5da23c29a 100644 +--- a/src/backend/utils/fmgr/dfmgr.c ++++ b/src/backend/utils/fmgr/dfmgr.c +@@ -315,64 +315,62 @@ internal_load_library(const char *libname) + #endif + + /* Check the magic function to determine compatibility */ +-#ifdef OLIPHAUNT_EMBEDDED +- if (file_scanner->is_static) + { +- if (oliphaunt_static_extension_magic != NULL) +- magic_func = (PGModuleMagicFunction) oliphaunt_static_extension_magic; +- else +- magic_func = NULL; +- } +- else +-#endif +- magic_func = (PGModuleMagicFunction) +- dlsym(file_scanner->handle, PG_MAGIC_FUNCTION_NAME_STRING); +- if (magic_func) +- { +- const Pg_magic_struct *magic_data_ptr; ++ const Pg_magic_struct *magic_data_ptr = NULL; + + #ifdef OLIPHAUNT_EMBEDDED + if (file_scanner->is_static) +- magic_data_ptr = oliphaunt_static_extension_magic(file_scanner->static_extension); ++ { ++ if (oliphaunt_static_extension_magic != NULL) ++ magic_data_ptr = oliphaunt_static_extension_magic(file_scanner->static_extension); ++ } + else + #endif +- magic_data_ptr = (*magic_func) (); ++ { ++ magic_func = (PGModuleMagicFunction) ++ dlsym(file_scanner->handle, PG_MAGIC_FUNCTION_NAME_STRING); ++ if (magic_func) ++ magic_data_ptr = (*magic_func) (); ++ } + +- /* Check ABI compatibility fields */ +- if (magic_data_ptr->len != sizeof(Pg_magic_struct) || +- memcmp(&magic_data_ptr->abi_fields, &magic_data, +- sizeof(Pg_abi_values)) != 0) ++ if (magic_data_ptr) + { +- /* copy data block before unlinking library */ +- Pg_magic_struct module_magic_data = *magic_data_ptr; ++ /* Check ABI compatibility fields */ ++ if (magic_data_ptr->len != sizeof(Pg_magic_struct) || ++ memcmp(&magic_data_ptr->abi_fields, &magic_data, ++ sizeof(Pg_abi_values)) != 0) ++ { ++ /* copy data block before unlinking library */ ++ Pg_magic_struct module_magic_data = *magic_data_ptr; ++ ++ /* try to close library */ ++#ifdef OLIPHAUNT_EMBEDDED ++ if (!file_scanner->is_static) ++#endif ++ dlclose(file_scanner->handle); ++ free(file_scanner); + ++ /* issue suitable complaint */ ++ incompatible_module_error(libname, &module_magic_data.abi_fields); ++ } ++ ++ /* Remember the magic block's location for future use */ ++ file_scanner->magic = magic_data_ptr; ++ } ++ else ++ { + /* try to close library */ + #ifdef OLIPHAUNT_EMBEDDED + if (!file_scanner->is_static) + #endif +- dlclose(file_scanner->handle); ++ dlclose(file_scanner->handle); + free(file_scanner); +- +- /* issue suitable complaint */ +- incompatible_module_error(libname, &module_magic_data.abi_fields); ++ /* complain */ ++ ereport(ERROR, ++ (errmsg("incompatible library \"%s\": missing magic block", ++ libname), ++ errhint("Extension libraries are required to use the PG_MODULE_MAGIC macro."))); + } +- +- /* Remember the magic block's location for future use */ +- file_scanner->magic = magic_data_ptr; +- } +- else +- { +- /* try to close library */ +-#ifdef OLIPHAUNT_EMBEDDED +- if (!file_scanner->is_static) +-#endif +- dlclose(file_scanner->handle); +- free(file_scanner); +- /* complain */ +- ereport(ERROR, +- (errmsg("incompatible library \"%s\": missing magic block", +- libname), +- errhint("Extension libraries are required to use the PG_MODULE_MAGIC macro."))); + } + + /* +diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h +index f9c03c87d9..4b53201b3a 100644 +--- a/src/include/tcop/tcopprot.h ++++ b/src/include/tcop/tcopprot.h +@@ -79,6 +79,10 @@ extern void ProcessClientWriteInterrupt(bool blocked); + extern void process_postgres_switches(int argc, char *argv[], + GucContext ctx, const char **dbname); + #ifdef OLIPHAUNT_EMBEDDED ++typedef struct OliphauntEmbeddedIO OliphauntEmbeddedIO; ++extern int oliphaunt_embedded_main(int argc, char *argv[], ++ const char *dbname, const char *username, ++ OliphauntEmbeddedIO *io); + extern void PostgresSingleUserMain(int argc, char *argv[], + const char *username); + extern void PostgresMain(const char *dbname, +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0009-liboliphaunt-guard-embedded-proc-exit.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0009-liboliphaunt-guard-embedded-proc-exit.patch new file mode 100644 index 00000000..5d461008 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0009-liboliphaunt-guard-embedded-proc-exit.patch @@ -0,0 +1,172 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: guard embedded proc_exit failures + +PostgreSQL startup failures normally reach proc_exit(), which terminates the +process. That is correct for backend processes, but embedded liboliphaunt runs a +backend thread inside a host app. A FATAL during embedded startup must unwind +to the liboliphaunt owner so the host can surface an error instead of exiting. + +Install a thread-local embedded proc_exit handler around oliphaunt_embedded_main(). +The normal proc_exit_prepare() callback chain still runs, then control jumps +back to the embedded entrypoint with the PostgreSQL exit code. +--- + src/backend/storage/ipc/ipc.c | 21 +++++++++++++++++++++ + src/backend/tcop/postgres.c | 33 +++++++++++++++++++++++++++++++-- + src/include/storage/ipc.h | 3 +++ + 3 files changed, 55 insertions(+), 2 deletions(-) + +diff --git a/src/backend/storage/ipc/ipc.c b/src/backend/storage/ipc/ipc.c +index 22e16d9..401542f 100644 +--- a/src/backend/storage/ipc/ipc.c ++++ b/src/backend/storage/ipc/ipc.c +@@ -85,6 +85,11 @@ static int on_proc_exit_index, + on_shmem_exit_index, + before_shmem_exit_index; + ++#ifdef OLIPHAUNT_EMBEDDED ++#ifdef _MSC_VER ++#define OLIPHAUNT_THREAD_LOCAL __declspec(thread) ++#else ++#define OLIPHAUNT_THREAD_LOCAL __thread ++#endif ++ ++static OLIPHAUNT_THREAD_LOCAL oliphaunt_embedded_proc_exit_handler oliphaunt_proc_exit_handler = NULL; ++static OLIPHAUNT_THREAD_LOCAL void *oliphaunt_proc_exit_context = NULL; ++#endif ++ + + /* ---------------------------------------------------------------- + * proc_exit +@@ -111,6 +116,14 @@ proc_exit(int code) + /* Clean up everything that must be cleaned up */ + proc_exit_prepare(code); + ++#ifdef OLIPHAUNT_EMBEDDED ++ if (oliphaunt_proc_exit_handler != NULL) ++ { ++ oliphaunt_proc_exit_handler(code, oliphaunt_proc_exit_context); ++ abort(); ++ } ++#endif ++ + #ifdef PROFILE_PID_DIR + { + /* +@@ -218,6 +231,14 @@ proc_exit_prepare(int code) + } + + #ifdef OLIPHAUNT_EMBEDDED ++void ++oliphaunt_embedded_set_proc_exit_handler(oliphaunt_embedded_proc_exit_handler handler, ++ void *context) ++{ ++ oliphaunt_proc_exit_handler = handler; ++ oliphaunt_proc_exit_context = context; ++} ++ + /* + * Run PostgreSQL's normal backend exit callbacks without terminating the host + * process. This is only valid for the embedded thread owner that already +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index bc83077..d6be482 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -27,6 +27,7 @@ + #include + #ifdef OLIPHAUNT_EMBEDDED + #include ++#include + #endif + #include + +@@ -4176,6 +4177,20 @@ PostgresSingleUserMain(int argc, char *argv[], + } + + #ifdef OLIPHAUNT_EMBEDDED ++typedef struct OliphauntEmbeddedProcExitGuard ++{ ++ sigjmp_buf env; ++ int code; ++} OliphauntEmbeddedProcExitGuard; ++ ++static void ++oliphaunt_embedded_throw_proc_exit(int code, void *context) ++{ ++ OliphauntEmbeddedProcExitGuard *guard = (OliphauntEmbeddedProcExitGuard *) context; ++ guard->code = code; ++ siglongjmp(guard->env, 1); ++} ++ + /* + * oliphaunt_embedded_main + * +@@ -4195,6 +4210,8 @@ oliphaunt_embedded_main(int argc, char *argv[], + int socket_pair[2] = {-1, -1}; + ClientSocket client_sock; + struct sockaddr_in *addr; ++ OliphauntEmbeddedProcExitGuard exit_guard; ++ int rc = 0; + char original_cwd[MAXPGPATH]; + bool have_original_cwd; + +@@ -4204,6 +4221,15 @@ oliphaunt_embedded_main(int argc, char *argv[], + + Assert(!IsUnderPostmaster); + ++ memset(&exit_guard, 0, sizeof(exit_guard)); ++ oliphaunt_embedded_set_proc_exit_handler(oliphaunt_embedded_throw_proc_exit, ++ &exit_guard); ++ if (sigsetjmp(exit_guard.env, 1) != 0) ++ { ++ rc = exit_guard.code; ++ goto embedded_cleanup; ++ } ++ + if (progname == NULL) + progname = get_progname(argv[0]); + MyProcPid = getpid(); +@@ -4274,14 +4300,22 @@ oliphaunt_embedded_main(int argc, char *argv[], + + PostgresMain(dbname, username); +- oliphaunt_embedded_proc_exit(0); ++ oliphaunt_embedded_proc_exit(0); ++ rc = 0; + ++embedded_cleanup: ++ oliphaunt_embedded_set_proc_exit_handler(NULL, NULL); + if (have_original_cwd && chdir(original_cwd) != 0) +- return -1; ++ rc = rc == 0 ? -1 : rc; + + if (socket_pair[1] >= 0) + close(socket_pair[1]); ++ if (socket_pair[0] >= 0 && MyProcPort == NULL) ++ close(socket_pair[0]); + +- return 0; ++ if (rc == 0 && exit_guard.code != 0) ++ rc = exit_guard.code; ++ ++ return rc; + } + #endif + +diff --git a/src/include/storage/ipc.h b/src/include/storage/ipc.h +index 3e798c8..ddbeae3 100644 +--- a/src/include/storage/ipc.h ++++ b/src/include/storage/ipc.h +@@ -68,7 +68,10 @@ extern PGDLLIMPORT bool shmem_exit_inprogress; + pg_noreturn extern void proc_exit(int code); + extern void shmem_exit(int code); + #ifdef OLIPHAUNT_EMBEDDED ++typedef void (*oliphaunt_embedded_proc_exit_handler) (int code, void *context); + extern void oliphaunt_embedded_proc_exit(int code); ++extern void oliphaunt_embedded_set_proc_exit_handler(oliphaunt_embedded_proc_exit_handler handler, ++ void *context); + #endif + extern void on_proc_exit(pg_on_exit_callback function, Datum arg); + extern void on_shmem_exit(pg_on_exit_callback function, Datum arg); +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0010-liboliphaunt-use-host-runtime-paths.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0010-liboliphaunt-use-host-runtime-paths.patch new file mode 100644 index 00000000..3ac0fd88 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0010-liboliphaunt-use-host-runtime-paths.patch @@ -0,0 +1,95 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: use host-provided embedded runtime paths + +Embedded liboliphaunt hosts package PostgreSQL runtime files as application +resources. On mobile platforms those resources may be copied into app storage +without executable permissions, and the embedded backend never executes the +postgres binary anyway. + +Use argv[0] as a host-provided install-root anchor for embedded mode when it is +already absolute. This initializes my_exec_path, pkglib_path, and pgservice +paths before InitStandaloneProcess(), avoiding find_my_exec()'s executable-bit +requirement while preserving PostgreSQL's existing path derivation for +share/lib/etc resources. +--- + src/backend/tcop/postgres.c | 51 ++++++++++++++++++++++++++++++++++++++++++-- + 1 file changed, 49 insertions(+), 2 deletions(-) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index d6be482..3d9c968 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -4191,6 +4191,50 @@ oliphaunt_embedded_throw_proc_exit(int code, void *context) + siglongjmp(guard->env, 1); + } + ++static bool ++oliphaunt_embedded_set_runtime_paths(const char *argv0) ++{ ++ char path[MAXPGPATH]; ++ ++ if (argv0 == NULL || argv0[0] == '\0' || !is_absolute_path(argv0)) ++ return false; ++ ++ /* ++ * Embedded hosts may package the postgres binary as a non-executable ++ * resource. The backend only needs an install-root anchor for deriving ++ * share, pkglib, and pgservice paths; it does not exec argv[0]. ++ */ ++ if (my_exec_path[0] == '\0') ++ { ++ strlcpy(my_exec_path, argv0, MAXPGPATH); ++ canonicalize_path(my_exec_path); ++ } ++ ++ if (pkglib_path[0] == '\0') ++ get_pkglib_path(my_exec_path, pkglib_path); ++ ++ /* ++ * set_pglocale_pgservice() reaches find_my_exec(), which intentionally ++ * rejects non-executable files. Reproduce its path-derived environment ++ * setup here for the embedded backend after accepting the host-provided ++ * absolute runtime anchor. ++ */ ++#ifdef ENABLE_NLS ++ get_locale_path(my_exec_path, path); ++ bindtextdomain(PG_TEXTDOMAIN("postgres"), path); ++ textdomain(PG_TEXTDOMAIN("postgres")); ++ setenv("PGLOCALEDIR", path, 0); ++#endif ++ ++ if (getenv("PGSYSCONFDIR") == NULL) ++ { ++ get_etc_path(my_exec_path, path); ++ setenv("PGSYSCONFDIR", path, 0); ++ } ++ ++ return true; ++} ++ + /* + * oliphaunt_embedded_main + * +@@ -4216,8 +4260,8 @@ oliphaunt_embedded_main(int argc, char *argv[], +- OliphauntEmbeddedProcExitGuard exit_guard; ++ OliphauntEmbeddedProcExitGuard exit_guard; + int rc = 0; + char original_cwd[MAXPGPATH]; +- bool have_original_cwd; ++ bool have_original_cwd = false; + + if (argv == NULL || argv[0] == NULL || dbname == NULL || + username == NULL || io == NULL) +@@ -4236,6 +4280,7 @@ oliphaunt_embedded_main(int argc, char *argv[], + if (TopMemoryContext == NULL) + MemoryContextInit(); + (void) set_stack_base(); +- set_pglocale_pgservice(argv[0], PG_TEXTDOMAIN("postgres")); ++ if (!oliphaunt_embedded_set_runtime_paths(argv[0])) ++ set_pglocale_pgservice(argv[0], PG_TEXTDOMAIN("postgres")); + + InitStandaloneProcess(argv[0]); +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0011-liboliphaunt-add-android-embedded-shared-memory.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0011-liboliphaunt-add-android-embedded-shared-memory.patch new file mode 100644 index 00000000..243f9bae --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0011-liboliphaunt-add-android-embedded-shared-memory.patch @@ -0,0 +1,483 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Wed, 13 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: add embedded mobile shared memory and semaphores + +Android and Apple mobile app processes cannot rely on SysV shared memory or +SysV semaphores. Embedded liboliphaunt runs a single PostgreSQL backend in the +owning app process, so use process-local mmap-backed main shared memory, +process-local pthread-backed semaphores, and keep DSM on the existing mmap +implementation. +--- + src/backend/port/Makefile | 18 ++- + src/backend/port/meson.build | 15 ++- + src/backend/port/oliphaunt_embedded_sema.c | 177 ++++++++++++++++++++++++++ + src/backend/port/oliphaunt_embedded_shmem.c | 98 ++++++++++++++ + src/include/port.h | 2 + + src/include/storage/dsm_impl.h | 8 +- + src/port/chklocale.c | 12 ++ + 7 files changed, 323 insertions(+), 7 deletions(-) + create mode 100644 src/backend/port/oliphaunt_embedded_sema.c + create mode 100644 src/backend/port/oliphaunt_embedded_shmem.c + +diff --git a/src/backend/port/Makefile b/src/backend/port/Makefile +index 511c04410d..6b2a73ba8b 100644 +--- a/src/backend/port/Makefile ++++ b/src/backend/port/Makefile +@@ -18,12 +18,24 @@ subdir = src/backend/port + top_builddir = ../../.. + include $(top_builddir)/src/Makefile.global + ++ifeq ($(host_os), linux-android) ++SEMA_OBJ = oliphaunt_embedded_sema.o ++SHMEM_OBJ = oliphaunt_embedded_shmem.o ++else ifeq ($(OLIPHAUNT_EMBEDDED_MOBILE_SHMEM), 1) ++SEMA_OBJ = oliphaunt_embedded_sema.o ++SHMEM_OBJ = oliphaunt_embedded_shmem.o ++else ++SEMA_OBJ = pg_sema.o ++SHMEM_OBJ = pg_shmem.o ++endif ++ + OBJS = \ + $(TAS) \ + atomics.o \ +- pg_sema.o \ +- pg_shmem.o ++ $(SEMA_OBJ) \ ++ $(SHMEM_OBJ) + + ifeq ($(PORTNAME), win32) + SUBDIRS += win32 + endif +diff --git a/src/backend/port/meson.build b/src/backend/port/meson.build +index 64db968e74..e246052e30 100644 +--- a/src/backend/port/meson.build ++++ b/src/backend/port/meson.build +@@ -5,19 +5,23 @@ backend_sources += files( + ) + + +-if cdata.has('USE_UNNAMED_POSIX_SEMAPHORES') or cdata.has('USE_NAMED_POSIX_SEMAPHORES') ++if host_system == 'android' ++ backend_sources += files('oliphaunt_embedded_sema.c') ++elif cdata.has('USE_UNNAMED_POSIX_SEMAPHORES') or cdata.has('USE_NAMED_POSIX_SEMAPHORES') + backend_sources += files('posix_sema.c') + endif + +-if cdata.has('USE_SYSV_SEMAPHORES') ++if host_system != 'android' and cdata.has('USE_SYSV_SEMAPHORES') + backend_sources += files('sysv_sema.c') + endif + + if cdata.has('USE_WIN32_SEMAPHORES') + backend_sources += files('win32_sema.c') + endif + +-if cdata.has('USE_SYSV_SHARED_MEMORY') +- backend_sources += files('sysv_shmem.c') ++if host_system == 'android' ++ backend_sources += files('oliphaunt_embedded_shmem.c') ++elif cdata.has('USE_SYSV_SHARED_MEMORY') ++ backend_sources += files('sysv_shmem.c') + endif + + if cdata.has('USE_WIN32_SHARED_MEMORY') +diff --git a/src/backend/port/oliphaunt_embedded_sema.c b/src/backend/port/oliphaunt_embedded_sema.c +new file mode 100644 +index 0000000000..f70d3e90d7 +--- /dev/null ++++ b/src/backend/port/oliphaunt_embedded_sema.c +@@ -0,0 +1,177 @@ ++/*------------------------------------------------------------------------- ++ * ++ * oliphaunt_embedded_sema.c ++ * Process-local semaphores for embedded mobile liboliphaunt. ++ * ++ * PostgreSQL's Darwin template defaults to SysV semaphores, which are not ++ * available to iOS apps. Android builds also avoid process-global semaphore ++ * primitives here so the embedded mobile runtime depends only on the owning ++ * app process. This implementation is intentionally only for liboliphaunt's ++ * one-backend embedded mode; server and broker modes must use normal ++ * PostgreSQL process boundaries. ++ * ++ *------------------------------------------------------------------------- ++ */ ++#include "postgres.h" ++ ++#include ++#include ++ ++#include "miscadmin.h" ++#include "storage/ipc.h" ++#include "storage/pg_sema.h" ++#include "storage/shmem.h" ++ ++typedef struct PGSemaphoreData ++{ ++ pthread_mutex_t mutex; ++ pthread_cond_t cond; ++ int count; ++ bool initialized; ++} PGSemaphoreData; ++ ++static PGSemaphore sharedSemas; ++static int numSems; ++static int maxSems; ++ ++static void ReleaseSemaphores(int status, Datum arg); ++ ++static void ++OliphauntPthreadFatal(int status, const char *operation) ++{ ++ if (status != 0) ++ { ++ errno = status; ++ elog(FATAL, "%s failed: %m", operation); ++ } ++} ++ ++static void ++OliphauntPthreadLog(int status, const char *operation) ++{ ++ if (status != 0) ++ { ++ errno = status; ++ elog(LOG, "%s failed: %m", operation); ++ } ++} ++ ++Size ++PGSemaphoreShmemSize(int maxSemas) ++{ ++ return mul_size(maxSemas, sizeof(PGSemaphoreData)); ++} ++ ++void ++PGReserveSemaphores(int maxSemas) ++{ ++ sharedSemas = (PGSemaphore) ++ ShmemAllocUnlocked(PGSemaphoreShmemSize(maxSemas)); ++ if (sharedSemas == NULL) ++ elog(PANIC, "out of memory"); ++ ++ MemSet(sharedSemas, 0, PGSemaphoreShmemSize(maxSemas)); ++ numSems = 0; ++ maxSems = maxSemas; ++ ++ on_shmem_exit(ReleaseSemaphores, 0); ++} ++ ++static void ++ReleaseSemaphores(int status, Datum arg) ++{ ++ int i; ++ ++ for (i = 0; i < numSems; i++) ++ { ++ PGSemaphore sema = sharedSemas + i; ++ ++ if (!sema->initialized) ++ continue; ++ ++ OliphauntPthreadLog(pthread_cond_destroy(&sema->cond), ++ "pthread_cond_destroy"); ++ OliphauntPthreadLog(pthread_mutex_destroy(&sema->mutex), ++ "pthread_mutex_destroy"); ++ sema->initialized = false; ++ } ++} ++ ++PGSemaphore ++PGSemaphoreCreate(void) ++{ ++ PGSemaphore sema; ++ int status; ++ ++ Assert(!IsUnderPostmaster); ++ ++ if (numSems >= maxSems) ++ elog(PANIC, "too many semaphores created"); ++ ++ sema = sharedSemas + numSems; ++ status = pthread_mutex_init(&sema->mutex, NULL); ++ OliphauntPthreadFatal(status, "pthread_mutex_init"); ++ ++ status = pthread_cond_init(&sema->cond, NULL); ++ if (status != 0) ++ { ++ OliphauntPthreadLog(pthread_mutex_destroy(&sema->mutex), ++ "pthread_mutex_destroy"); ++ OliphauntPthreadFatal(status, "pthread_cond_init"); ++ } ++ ++ sema->count = 1; ++ sema->initialized = true; ++ numSems++; ++ ++ return sema; ++} ++ ++void ++PGSemaphoreReset(PGSemaphore sema) ++{ ++ int status; ++ ++ status = pthread_mutex_lock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_lock"); ++ sema->count = 0; ++ status = pthread_mutex_unlock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_unlock"); ++} ++ ++void ++PGSemaphoreLock(PGSemaphore sema) ++{ ++ int status; ++ ++ status = pthread_mutex_lock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_lock"); ++ ++ while (sema->count <= 0) ++ { ++ status = pthread_cond_wait(&sema->cond, &sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_cond_wait"); ++ } ++ sema->count--; ++ ++ status = pthread_mutex_unlock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_unlock"); ++} ++ ++void ++PGSemaphoreUnlock(PGSemaphore sema) ++{ ++ int status; ++ ++ status = pthread_mutex_lock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_lock"); ++ ++ if (sema->count == INT_MAX) ++ elog(FATAL, "embedded semaphore count overflow"); ++ sema->count++; ++ ++ status = pthread_cond_signal(&sema->cond); ++ OliphauntPthreadFatal(status, "pthread_cond_signal"); ++ status = pthread_mutex_unlock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_unlock"); ++} ++ ++bool ++PGSemaphoreTryLock(PGSemaphore sema) ++{ ++ bool acquired = false; ++ int status; ++ ++ status = pthread_mutex_lock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_lock"); ++ if (sema->count > 0) ++ { ++ sema->count--; ++ acquired = true; ++ } ++ status = pthread_mutex_unlock(&sema->mutex); ++ OliphauntPthreadFatal(status, "pthread_mutex_unlock"); ++ ++ return acquired; ++} +diff --git a/src/backend/port/oliphaunt_embedded_shmem.c b/src/backend/port/oliphaunt_embedded_shmem.c +new file mode 100644 +index 0000000000..7002ba6e7c +--- /dev/null ++++ b/src/backend/port/oliphaunt_embedded_shmem.c +@@ -0,0 +1,119 @@ ++/*------------------------------------------------------------------------- ++ * ++ * oliphaunt_embedded_shmem.c ++ * mmap-backed shared memory for embedded mobile liboliphaunt. ++ * ++ * Android and Apple mobile app processes cannot rely on SysV shared memory. ++ * Embedded liboliphaunt owns one backend inside the host process, so the root ++ * lock and process-wide instance guard provide the cross-process safety ++ * boundary. ++ * ++ *------------------------------------------------------------------------- ++ */ ++#include "postgres.h" ++ ++#include ++#include ++#include ++ ++#include "miscadmin.h" ++#include "storage/ipc.h" ++#include "storage/pg_shmem.h" ++#include "utils/guc.h" ++#include "utils/guc_hooks.h" ++ ++unsigned long UsedShmemSegID = 0; ++void *UsedShmemSegAddr = NULL; ++ ++static Size UsedShmemSegSize = 0; ++ ++static void ++OliphauntEmbeddedShmemDetach(int status, Datum arg) ++{ ++ PGSharedMemoryDetach(); ++} ++ ++PGShmemHeader * ++PGSharedMemoryCreate(Size size, PGShmemHeader **shim) ++{ ++ PGShmemHeader *hdr; ++ struct stat statbuf; ++ void *memAddress; ++ ++ if (stat(DataDir, &statbuf) < 0) ++ ereport(FATAL, ++ (errcode_for_file_access(), ++ errmsg("could not stat data directory \"%s\": %m", DataDir))); ++ ++ if (huge_pages == HUGE_PAGES_ON) ++ ereport(ERROR, ++ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), ++ errmsg("huge pages not supported on embedded mobile liboliphaunt"))); ++ ++ SetConfigOption("huge_pages_status", "off", ++ PGC_INTERNAL, PGC_S_DYNAMIC_DEFAULT); ++ ++ memAddress = mmap(NULL, size, PROT_READ | PROT_WRITE, ++ MAP_ANONYMOUS | MAP_SHARED, -1, 0); ++ if (memAddress == MAP_FAILED) ++ ereport(FATAL, ++ (errmsg("could not map embedded mobile shared memory: %m"))); ++ ++ hdr = (PGShmemHeader *) memAddress; ++ hdr->creatorPID = getpid(); ++ hdr->magic = PGShmemMagic; ++ hdr->dsm_control = 0; ++ hdr->device = statbuf.st_dev; ++ hdr->inode = statbuf.st_ino; ++ hdr->totalsize = size; ++ hdr->freeoffset = MAXALIGN(sizeof(PGShmemHeader)); ++ ++ UsedShmemSegAddr = memAddress; ++ UsedShmemSegID = 1; ++ UsedShmemSegSize = size; ++ *shim = hdr; ++ ++ on_shmem_exit(OliphauntEmbeddedShmemDetach, PointerGetDatum(memAddress)); ++ ++ return hdr; ++} ++ ++bool ++PGSharedMemoryIsInUse(unsigned long id1, unsigned long id2) ++{ ++ return false; ++} ++ ++void ++PGSharedMemoryDetach(void) ++{ ++ if (UsedShmemSegAddr != NULL) ++ { ++ if (munmap(UsedShmemSegAddr, UsedShmemSegSize) < 0) ++ elog(LOG, "munmap(%p, %zu) failed: %m", ++ UsedShmemSegAddr, UsedShmemSegSize); ++ UsedShmemSegAddr = NULL; ++ UsedShmemSegID = 0; ++ UsedShmemSegSize = 0; ++ } ++} ++ ++bool ++check_huge_page_size(int *newval, void **extra, GucSource source) ++{ ++ if (*newval != 0) ++ { ++ GUC_check_errdetail("\"huge_page_size\" must be 0 on embedded mobile liboliphaunt."); ++ return false; ++ } ++ ++ return true; ++} ++ ++void ++GetHugePageSize(Size *hugepagesize, int *mmap_flags) ++{ ++ if (hugepagesize) ++ *hugepagesize = 0; ++ if (mmap_flags) ++ *mmap_flags = 0; ++} +diff --git a/src/include/port.h b/src/include/port.h +index a58e7d55f4..9ce9cfad1d 100644 +--- a/src/include/port.h ++++ b/src/include/port.h +@@ -557,7 +557,9 @@ extern int wait_result_to_exit_code(int exit_status); + #define HAVE_POLL_H 1 + #define HAVE_READLINK 1 + #define HAVE_SETSID 1 ++#if !defined(__ANDROID__) && !defined(OLIPHAUNT_EMBEDDED_MOBILE_SHMEM) + #define HAVE_SHM_OPEN 1 ++#endif + #define HAVE_SYMLINK 1 + #endif + +diff --git a/src/include/storage/dsm_impl.h b/src/include/storage/dsm_impl.h +index 6a400df687..f859d3ff41 100644 +--- a/src/include/storage/dsm_impl.h ++++ b/src/include/storage/dsm_impl.h +@@ -27,13 +27,19 @@ + #define USE_DSM_WINDOWS + #define DEFAULT_DYNAMIC_SHARED_MEMORY_TYPE DSM_IMPL_WINDOWS + #else +-#ifdef HAVE_SHM_OPEN ++#if defined(HAVE_SHM_OPEN) && !defined(OLIPHAUNT_EMBEDDED_MOBILE_SHMEM) + #define USE_DSM_POSIX + #define DEFAULT_DYNAMIC_SHARED_MEMORY_TYPE DSM_IMPL_POSIX + #endif ++#if !defined(__ANDROID__) && !defined(OLIPHAUNT_EMBEDDED_MOBILE_SHMEM) + #define USE_DSM_SYSV ++#endif + #ifndef DEFAULT_DYNAMIC_SHARED_MEMORY_TYPE ++#if defined(__ANDROID__) || defined(OLIPHAUNT_EMBEDDED_MOBILE_SHMEM) ++#define DEFAULT_DYNAMIC_SHARED_MEMORY_TYPE DSM_IMPL_MMAP ++#else + #define DEFAULT_DYNAMIC_SHARED_MEMORY_TYPE DSM_IMPL_SYSV ++#endif + #endif + #define USE_DSM_MMAP + #endif +diff --git a/src/port/chklocale.c b/src/port/chklocale.c +index 2efc779d6b..f15894a4f1 100644 +--- a/src/port/chklocale.c ++++ b/src/port/chklocale.c +@@ -315,15 +315,24 @@ pg_get_encoding_from_locale(const char *ctype, bool write_message) + + + #ifndef WIN32 ++#ifdef __ANDROID__ ++ /* ++ * Android's bionic libc does not provide nl_langinfo_l(). Android app ++ * locales are UTF-8 in practice, and liboliphaunt initializes template ++ * clusters with UTF-8, so report the portable server encoding directly. ++ */ ++ sys = strdup("UTF-8"); ++#else + loc = newlocale(LC_CTYPE_MASK, ctype, (locale_t) 0); + if (loc == (locale_t) 0) + return -1; /* bogus ctype passed in? */ + + sys = nl_langinfo_l(CODESET, loc); + if (sys) + sys = strdup(sys); + + freelocale(loc); ++#endif + #else + sys = win32_get_codeset(ctype); + #endif +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch new file mode 100644 index 00000000..c3fcb37c --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch @@ -0,0 +1,84 @@ +From 0000000000000000000000000000000000000012 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Fri, 22 May 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: enable event triggers in embedded backend sessions + +PostgreSQL disables event triggers when !IsUnderPostmaster so standalone +single-user recovery has an escape hatch. liboliphaunt direct mode is not a +postmaster child, but it is also not a standalone recovery shell: it runs a +normal embedded backend session behind the frontend/backend protocol. + +Keep upstream standalone behavior unchanged, but allow OLIPHAUNT_EMBEDDED builds +to fire event triggers when the event_triggers GUC is enabled. +--- + src/backend/commands/event_trigger.c | 20 +++++++++++++++----- + 1 file changed, 15 insertions(+), 5 deletions(-) + +diff --git a/src/backend/commands/event_trigger.c b/src/backend/commands/event_trigger.c +index 074c476543..af3c678406 100644 +--- a/src/backend/commands/event_trigger.c ++++ b/src/backend/commands/event_trigger.c +@@ -84,6 +84,16 @@ static EventTriggerQueryState *currentEventTriggerState = NULL; + /* GUC parameter */ + bool event_triggers = true; + ++static bool ++EventTriggersHaveRunnableBackend(void) ++{ ++#ifdef OLIPHAUNT_EMBEDDED ++ return true; ++#else ++ return IsUnderPostmaster; ++#endif ++} ++ + /* Support for dropped objects */ + typedef struct SQLDropObject + { +@@ -746,7 +755,7 @@ EventTriggerDDLCommandStart(Node *parsetree) + * Additionally, event triggers can be disabled with a superuser-only GUC + * to make fixing database easier as per 1 above. + */ +- if (!IsUnderPostmaster || !event_triggers) ++ if (!EventTriggersHaveRunnableBackend() || !event_triggers) + return; + + runlist = EventTriggerCommonSetup(parsetree, +@@ -782,7 +791,7 @@ EventTriggerDDLCommandEnd(Node *parsetree) + * See EventTriggerDDLCommandStart for a discussion about why event + * triggers are disabled in single user mode or via GUC. + */ +- if (!IsUnderPostmaster || !event_triggers) ++ if (!EventTriggersHaveRunnableBackend() || !event_triggers) + return; + + /* +@@ -830,7 +839,7 @@ EventTriggerSQLDrop(Node *parsetree) + * See EventTriggerDDLCommandStart for a discussion about why event + * triggers are disabled in single user mode or via a GUC. + */ +- if (!IsUnderPostmaster || !event_triggers) ++ if (!EventTriggersHaveRunnableBackend() || !event_triggers) + return; + + /* +@@ -904,7 +913,7 @@ EventTriggerOnLogin(void) + * triggers are disabled in single user mode or via a GUC. We also need a + * database connection (some background workers don't have it). + */ +- if (!IsUnderPostmaster || !event_triggers || ++ if (!EventTriggersHaveRunnableBackend() || !event_triggers || + !OidIsValid(MyDatabaseId) || !MyDatabaseHasLoginEventTriggers) + return; + +@@ -1009,7 +1018,7 @@ EventTriggerTableRewrite(Node *parsetree, Oid tableOid, int reason) + * See EventTriggerDDLCommandStart for a discussion about why event + * triggers are disabled in single user mode or via a GUC. + */ +- if (!IsUnderPostmaster || !event_triggers) ++ if (!EventTriggersHaveRunnableBackend() || !event_triggers) + return; + + /* +-- +2.39.0 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0013-liboliphaunt-register-static-icu-data.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0013-liboliphaunt-register-static-icu-data.patch new file mode 100644 index 00000000..ec03b3b9 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0013-liboliphaunt-register-static-icu-data.patch @@ -0,0 +1,132 @@ +From 0000000000000000000000000000000000000013 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Tue, 2 Jun 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: register static ICU data + +PostgreSQL normally relies on the platform ICU data loader. liboliphaunt links +ICU statically for embedded/mobile artifacts, including initdb's bootstrap +postgres binary and the embedded backend dylib. Register the linked ICU common +data once before PostgreSQL calls ICU APIs when the build is compiled as a +static ICU consumer. + +This keeps normal dynamic ICU builds unchanged and avoids requiring host apps to +ship loose ICU data files or set ICU_DATA. +--- + src/backend/commands/collationcmds.c | 3 +++ + src/backend/utils/adt/pg_locale.c | 4 ++++ + src/backend/utils/adt/pg_locale_icu.c | 29 ++++++++++++++++++++++++++ + src/include/utils/pg_locale.h | 1 + + 4 files changed, 37 insertions(+) + +diff --git a/src/backend/commands/collationcmds.c b/src/backend/commands/collationcmds.c +--- a/src/backend/commands/collationcmds.c ++++ b/src/backend/commands/collationcmds.c +@@ -651,6 +651,8 @@ get_icu_locale_comment(const char *localename) + char *result; + + status = U_ZERO_ERROR; ++ pg_register_static_icu_data(); ++ + len_uchar = uloc_getDisplayName(localename, "en", + displayname, lengthof(displayname), + &status); +@@ -980,6 +982,8 @@ pg_import_system_collations(PG_FUNCTION_ARGS) + { + int i; + ++ pg_register_static_icu_data(); ++ + /* + * Start the loop at -1 to sneak in the root locale without too much + * code duplication. +diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c +--- a/src/backend/utils/adt/pg_locale.c ++++ b/src/backend/utils/adt/pg_locale.c +@@ -1561,6 +1561,8 @@ icu_language_tag(const char *loc_str, int elevel) + * first call, necessitating a loop. + */ + langtag = palloc(buflen); ++ pg_register_static_icu_data(); ++ + while (true) + { + status = U_ZERO_ERROR; +@@ -1622,6 +1624,8 @@ icu_validate_locale(const char *loc_str) + if (IsBinaryUpgrade && elevel > WARNING) + elevel = WARNING; + ++ pg_register_static_icu_data(); ++ + /* validate that we can extract the language */ + status = U_ZERO_ERROR; + uloc_getLanguage(loc_str, lang, ULOC_LANG_CAPACITY, &status); +diff --git a/src/backend/utils/adt/pg_locale_icu.c b/src/backend/utils/adt/pg_locale_icu.c +--- a/src/backend/utils/adt/pg_locale_icu.c ++++ b/src/backend/utils/adt/pg_locale_icu.c +@@ -13,6 +13,9 @@ + #ifdef USE_ICU + #include + #include ++#ifdef U_STATIC_IMPLEMENTATION ++#include ++#endif + + /* + * ucol_strcollUTF8() was introduced in ICU 50, but it is buggy before ICU 53. +@@ -137,6 +140,27 @@ static const struct collate_methods collate_methods_icu_utf8 = { + .strxfrm_is_safe = true, + }; + ++void ++pg_register_static_icu_data(void) ++{ ++#ifdef U_STATIC_IMPLEMENTATION ++ static bool registered = false; ++ extern const char U_IMPORT U_ICUDATA_ENTRY_POINT[]; ++ UErrorCode status = U_ZERO_ERROR; ++ ++ if (registered) ++ return; ++ ++ udata_setCommonData(&U_ICUDATA_ENTRY_POINT, &status); ++ if (U_FAILURE(status)) ++ ereport(ERROR, ++ (errmsg("could not register static ICU data: %s", ++ u_errorName(status)))); ++ ++ registered = true; ++#endif ++} ++ + #endif + + pg_locale_t +@@ -278,6 +303,8 @@ pg_ucol_open(const char *loc_str) + } + } + ++ pg_register_static_icu_data(); ++ + status = U_ZERO_ERROR; + collator = ucol_open(loc_str, &status); + if (U_FAILURE(status)) +@@ -844,6 +871,8 @@ init_icu_converter(void) + errmsg("encoding \"%s\" not supported by ICU", + pg_encoding_to_char(GetDatabaseEncoding())))); + ++ pg_register_static_icu_data(); ++ + status = U_ZERO_ERROR; + conv = ucnv_open(icu_encoding_name, &status); + if (U_FAILURE(status)) +diff --git a/src/include/utils/pg_locale.h b/src/include/utils/pg_locale.h +--- a/src/include/utils/pg_locale.h ++++ b/src/include/utils/pg_locale.h +@@ -150,6 +150,7 @@ extern int builtin_locale_encoding(const char *locale); + extern const char *builtin_validate_locale(int encoding, const char *locale); + extern void icu_validate_locale(const char *loc_str); + extern char *icu_language_tag(const char *loc_str, int elevel); ++extern void pg_register_static_icu_data(void); + extern void report_newlocale_failure(const char *localename); + + /* These functions convert from/to libc's wchar_t, *not* pg_wchar_t */ diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0014-liboliphaunt-use-portable-embedded-socketpair.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0014-liboliphaunt-use-portable-embedded-socketpair.patch new file mode 100644 index 00000000..9f315dd2 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0014-liboliphaunt-use-portable-embedded-socketpair.patch @@ -0,0 +1,136 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Thu, 4 Jun 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: use portable embedded socketpair + +The embedded backend routes protocol bytes through host-owned I/O callbacks, +but PostgreSQL's backend libpq initialization still requires a real socket for +address discovery, socket option setup, connection polling, and fixed wait-set +positions. Unix builds can satisfy that with socketpair(); Windows has no +socketpair(), so create an equivalent loopback pair with bind/listen/connect/ +accept. + +This keeps the regular PostgreSQL socket path intact and keeps embedded +transport host-owned without adding Windows-specific logic outside PostgreSQL. +--- + src/backend/tcop/postgres.c | 61 ++++++++++++++++++++++++++++++++++++++------- + 1 file changed, 52 insertions(+), 9 deletions(-) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index 5e4710cb8e..9cbb420d91 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -25,7 +25,9 @@ + #include + #include + #ifdef OLIPHAUNT_EMBEDDED ++#ifndef WIN32 + #include ++#endif + #include + #endif + #include +@@ -4238,6 +4240,51 @@ oliphaunt_embedded_set_runtime_paths(const char *argv0) + return true; + } + ++static int ++oliphaunt_embedded_create_socketpair(pgsocket sockets[2]) ++{ ++#ifdef WIN32 ++ static bool wsa_started = false; ++ WSADATA wsaData; ++ pgsocket listener = PGINVALID_SOCKET; ++ struct sockaddr_in addr; ++ socklen_t addrlen = sizeof(addr); ++ int rc = -1; ++ ++ if (!wsa_started) ++ { ++ if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) ++ return -1; ++ wsa_started = true; ++ } ++ ++ listener = socket(AF_INET, SOCK_STREAM, 0); ++ if (listener == PGINVALID_SOCKET) ++ goto cleanup; ++ ++ memset(&addr, 0, sizeof(addr)); ++ addr.sin_family = AF_INET; ++ addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ addr.sin_port = 0; ++ if (bind(listener, (struct sockaddr *) &addr, sizeof(addr)) == SOCKET_ERROR) ++ goto cleanup; ++ if (listen(listener, 1) == SOCKET_ERROR) ++ goto cleanup; ++ if (getsockname(listener, (struct sockaddr *) &addr, &addrlen) == SOCKET_ERROR) ++ goto cleanup; ++ ++ sockets[0] = socket(AF_INET, SOCK_STREAM, 0); ++ if (sockets[0] == PGINVALID_SOCKET) ++ goto cleanup; ++ if (connect(sockets[0], (struct sockaddr *) &addr, addrlen) == SOCKET_ERROR) ++ goto cleanup; ++ sockets[1] = accept(listener, NULL, NULL); ++ if (sockets[1] == PGINVALID_SOCKET) ++ goto cleanup; ++ rc = 0; ++ ++cleanup: ++ if (listener != PGINVALID_SOCKET) ++ closesocket(listener); ++ if (rc != 0) ++ { ++ if (sockets[0] != PGINVALID_SOCKET) ++ closesocket(sockets[0]); ++ if (sockets[1] != PGINVALID_SOCKET) ++ closesocket(sockets[1]); ++ sockets[0] = sockets[1] = PGINVALID_SOCKET; ++ } ++ return rc; ++#else ++ return socketpair(AF_UNIX, SOCK_STREAM, 0, sockets); ++#endif ++} ++ + /* + * oliphaunt_embedded_main + * +@@ -4259,7 +4306,7 @@ oliphaunt_embedded_main(int argc, char *argv[], + { + const char *switch_dbname = NULL; +- int socket_pair[2] = {-1, -1}; ++ pgsocket socket_pair[2] = {PGINVALID_SOCKET, PGINVALID_SOCKET}; + ClientSocket client_sock; + struct sockaddr_in *addr; + OliphauntEmbeddedProcExitGuard exit_guard; +@@ -4329,9 +4376,9 @@ oliphaunt_embedded_main(int argc, char *argv[], + PgStartTime = GetCurrentTimestamp(); + InitProcess(); + +- if (socketpair(AF_UNIX, SOCK_STREAM, 0, socket_pair) != 0) ++ if (oliphaunt_embedded_create_socketpair(socket_pair) != 0) + ereport(FATAL, +- (errmsg("could not create embedded liboliphaunt socketpair: %m"))); ++ (errmsg("could not create embedded liboliphaunt socket pair: %m"))); + + memset(&client_sock, 0, sizeof(client_sock)); + client_sock.sock = socket_pair[0]; +@@ -4365,10 +4412,10 @@ embedded_cleanup: + if (have_original_cwd && chdir(original_cwd) != 0) + rc = rc == 0 ? -1 : rc; + +- if (socket_pair[1] >= 0) +- close(socket_pair[1]); +- if (socket_pair[0] >= 0 && MyProcPort == NULL) +- close(socket_pair[0]); ++ if (socket_pair[1] != PGINVALID_SOCKET) ++ closesocket(socket_pair[1]); ++ if (socket_pair[0] != PGINVALID_SOCKET && MyProcPort == NULL) ++ closesocket(socket_pair[0]); + + if (rc == 0 && exit_guard.code != 0) + rc = exit_guard.code; +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0015-liboliphaunt-add-embedded-meson-option.patch b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0015-liboliphaunt-add-embedded-meson-option.patch new file mode 100644 index 00000000..1516a675 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0015-liboliphaunt-add-embedded-meson-option.patch @@ -0,0 +1,46 @@ +From 0000000000000000000000000000000000000015 Mon Sep 17 00:00:00 2001 +From: liboliphaunt +Date: Thu, 4 Jun 2026 00:00:00 +0000 +Subject: [PATCH] liboliphaunt: add embedded meson option + +Route the embedded backend compile define through PostgreSQL's Meson project +instead of requiring each host script to thread OLIPHAUNT_EMBEDDED through +compiler-specific native files. Normal PostgreSQL builds keep the default +off value and remain unchanged. + +--- + meson.build | 4 ++++ + meson_options.txt | 3 +++ + 2 files changed, 7 insertions(+) + +diff --git a/meson.build b/meson.build +index 2c94236..4b6503d 100644 +--- a/meson.build ++++ b/meson.build +@@ -41,6 +41,10 @@ host_cpu = host_machine.cpu_family() + + cc = meson.get_compiler('c') + ++if get_option('oliphaunt_embedded') ++ add_project_arguments('-DOLIPHAUNT_EMBEDDED', language: 'c') ++endif ++ + not_found_dep = dependency('', required: false) + thread_dep = dependency('threads') + auto_features = get_option('auto_features') +diff --git a/meson_options.txt b/meson_options.txt +index 06bf562..6abb1c8 100644 +--- a/meson_options.txt ++++ b/meson_options.txt +@@ -52,6 +52,9 @@ option('PG_TEST_EXTRA', type: 'string', value: '', + option('PG_GIT_REVISION', type: 'string', value: 'HEAD', + description: 'git revision to be packaged by pgdist target') + ++option('oliphaunt_embedded', type: 'boolean', value: false, ++ description: 'Compile host-owned embedded backend entrypoints') ++ + + # Compilation options + +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/native/portable-uuid/include/uuid/uuid.h b/src/runtimes/liboliphaunt/native/portable-uuid/include/uuid/uuid.h new file mode 100644 index 00000000..f85bd54c --- /dev/null +++ b/src/runtimes/liboliphaunt/native/portable-uuid/include/uuid/uuid.h @@ -0,0 +1,10 @@ +#ifndef OLIPHAUNT_PORTABLE_UUID_UUID_H +#define OLIPHAUNT_PORTABLE_UUID_UUID_H + +typedef unsigned char uuid_t[16]; + +void uuid_generate_random(uuid_t out); +void uuid_generate_time(uuid_t out); +void uuid_unparse(const uuid_t uu, char *out); + +#endif diff --git a/src/runtimes/liboliphaunt/native/portable-uuid/portable_uuid.c b/src/runtimes/liboliphaunt/native/portable-uuid/portable_uuid.c new file mode 100644 index 00000000..0eef53e0 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/portable-uuid/portable_uuid.c @@ -0,0 +1,77 @@ +#include "postgres.h" +#include "port.h" + +#include +#include +#include + +#include + +static void +oliphaunt_uuid_random_bytes(unsigned char *out, size_t len) +{ + if (!pg_strong_random(out, len)) + elog(ERROR, "could not generate UUID randomness"); +} + +void +uuid_generate_random(uuid_t out) +{ + oliphaunt_uuid_random_bytes(out, 16); + out[6] = (unsigned char) ((out[6] & 0x0f) | 0x40); + out[8] = (unsigned char) ((out[8] & 0x3f) | 0x80); +} + +void +uuid_generate_time(uuid_t out) +{ + struct timeval tv; + uint64_t timestamp; + uint16_t clock_seq; + unsigned char random_tail[8]; + + if (gettimeofday(&tv, NULL) != 0) + elog(ERROR, "could not read system time for UUID generation"); + + timestamp = ((uint64_t) tv.tv_sec * UINT64CONST(10000000)) + + ((uint64_t) tv.tv_usec * UINT64CONST(10)) + + UINT64CONST(0x01B21DD213814000); + oliphaunt_uuid_random_bytes(random_tail, sizeof(random_tail)); + clock_seq = ((uint16_t) random_tail[0] << 8) | random_tail[1]; + clock_seq &= 0x3fff; + + out[0] = (unsigned char) (timestamp >> 24); + out[1] = (unsigned char) (timestamp >> 16); + out[2] = (unsigned char) (timestamp >> 8); + out[3] = (unsigned char) timestamp; + out[4] = (unsigned char) (timestamp >> 40); + out[5] = (unsigned char) (timestamp >> 32); + out[6] = (unsigned char) (((timestamp >> 56) & 0x0f) | 0x10); + out[7] = (unsigned char) (timestamp >> 48); + out[8] = (unsigned char) ((clock_seq >> 8) | 0x80); + out[9] = (unsigned char) clock_seq; + memcpy(out + 10, random_tail + 2, 6); + out[10] |= 0x01; +} + +void +uuid_unparse(const uuid_t uu, char *out) +{ + static const char hex[] = "0123456789abcdef"; + static const int positions[16] = { + 0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34 + }; + static const int hyphens[4] = {8, 13, 18, 23}; + + memset(out, '0', 36); + for (int i = 0; i < 4; i++) + out[hyphens[i]] = '-'; + for (int i = 0; i < 16; i++) + { + int pos = positions[i]; + + out[pos] = hex[uu[i] >> 4]; + out[pos + 1] = hex[uu[i] & 0x0f]; + } + out[36] = '\0'; +} diff --git a/src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml b/src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml new file mode 100644 index 00000000..37532354 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml @@ -0,0 +1,36 @@ +schema = "liboliphaunt-external-extensions-v2" +pg_major = 18 + +[[extensions]] +id = "pggraph" +sql_name = "graph" +module_stem = "graph" +source_kind = "pgrx" +upstream = "https://github.com/evokoa/pggraph.git" +source_ref = "main" +commit = "4ea3c3206811deda03de136b4f465a2cf9bc8e72" +checkout = "target/oliphaunt-sources/checkouts/pggraph" +source_subdir = "graph" +license = "Apache-2.0" +redistribution = "allowed" +pgrx_version = "0.18.0" +pg_feature = "pg18" +requires_shared_preload = false +release_state = "candidate" + +[[extensions]] +id = "paradedb-pg-search" +sql_name = "pg_search" +module_stem = "pg_search" +source_kind = "pgrx" +upstream = "https://github.com/paradedb/paradedb.git" +source_ref = "v0.23.4" +commit = "c07921a78f3d24cbb0251b31a1150a7db600af5a" +checkout = "target/oliphaunt-sources/checkouts/paradedb" +source_subdir = "pg_search" +license = "AGPL-3.0" +redistribution = "requires-commercial-license" +pgrx_version = "0.18.0" +pg_feature = "pg18" +requires_shared_preload = true +release_state = "candidate" diff --git a/src/runtimes/liboliphaunt/native/postgres18/source.toml b/src/runtimes/liboliphaunt/native/postgres18/source.toml new file mode 100644 index 00000000..4c17e9d3 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/postgres18/source.toml @@ -0,0 +1,24 @@ +[postgresql] +version = "18.4" +url = "https://ftp.postgresql.org/pub/source/v18.4/postgresql-18.4.tar.bz2" +sha256 = "81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094" + +[patches] +directory = "../patches/postgresql-18.4" +series = [ + "0001-liboliphaunt-add-backend-host-io.patch", + "0002-liboliphaunt-add-embedded-entrypoint.patch", + "0003-liboliphaunt-return-from-embedded-frontend-terminate.patch", + "0004-liboliphaunt-run-embedded-exit-cleanup.patch", + "0005-liboliphaunt-restore-host-cwd.patch", + "0006-liboliphaunt-add-static-extension-loader.patch", + "0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch", + "0008-liboliphaunt-clean-embedded-symbols.patch", + "0009-liboliphaunt-guard-embedded-proc-exit.patch", + "0010-liboliphaunt-use-host-runtime-paths.patch", + "0011-liboliphaunt-add-android-embedded-shared-memory.patch", + "0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch", + "0013-liboliphaunt-register-static-icu-data.patch", + "0014-liboliphaunt-use-portable-embedded-socketpair.patch", + "0015-liboliphaunt-add-embedded-meson-option.patch", +] diff --git a/src/runtimes/liboliphaunt/native/release.toml b/src/runtimes/liboliphaunt/native/release.toml new file mode 100644 index 00000000..294b6f9c --- /dev/null +++ b/src/runtimes/liboliphaunt/native/release.toml @@ -0,0 +1,14 @@ +id = "liboliphaunt-native" +owner = "@oliphaunt/core" +kind = "native-core" +publish_targets = ["github-release-assets"] +release_artifacts = [ + "c-headers", + "macos-dylib", + "linux-shared-library", + "windows-dll", + "ios-xcframework", + "android-shared-library", + "runtime-resources", + "package-size-report", +] diff --git a/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_abi_conformance.c b/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_abi_conformance.c new file mode 100644 index 00000000..959b3801 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_abi_conformance.c @@ -0,0 +1,193 @@ +#include "oliphaunt.h" + +#include +#include +#include +#include + +#define CHECK(condition, message) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "liboliphaunt C ABI conformance failed: %s\n", message); \ + return 1; \ + } \ + } while (0) + +#define CHECK_ONE_BIT(value) \ + _Static_assert(((value) != 0) && (((value) & ((value) - 1ull)) == 0), #value " must be a single capability bit") + +CHECK_ONE_BIT(OLIPHAUNT_CAP_PROTOCOL_RAW); +CHECK_ONE_BIT(OLIPHAUNT_CAP_PROTOCOL_STREAM); +CHECK_ONE_BIT(OLIPHAUNT_CAP_MULTI_INSTANCE); +CHECK_ONE_BIT(OLIPHAUNT_CAP_SERVER_MODE); +CHECK_ONE_BIT(OLIPHAUNT_CAP_EXTENSIONS); +CHECK_ONE_BIT(OLIPHAUNT_CAP_QUERY_CANCEL); +CHECK_ONE_BIT(OLIPHAUNT_CAP_BACKUP_RESTORE); +CHECK_ONE_BIT(OLIPHAUNT_CAP_SIMPLE_QUERY); +CHECK_ONE_BIT(OLIPHAUNT_CAP_STATIC_EXTENSIONS); +CHECK_ONE_BIT(OLIPHAUNT_CAP_LOGICAL_REOPEN); + +_Static_assert(OLIPHAUNT_ABI_VERSION == 6u, "unexpected liboliphaunt ABI version"); +_Static_assert(OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION == 1u, "unexpected static extension ABI version"); +_Static_assert(OLIPHAUNT_BACKUP_FORMAT_SQL == 1u, "unexpected SQL backup format tag"); +_Static_assert(OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE == 2u, "unexpected physical archive backup format tag"); +_Static_assert(OLIPHAUNT_BACKUP_FORMAT_OLIPHAUNT_ARCHIVE == 3u, "unexpected oliphaunt archive backup format tag"); +_Static_assert(offsetof(OliphauntConfig, abi_version) == 0, "OliphauntConfig must start with abi_version"); +_Static_assert(offsetof(OliphauntBackupOptions, abi_version) == 0, "OliphauntBackupOptions must start with abi_version"); +_Static_assert(offsetof(OliphauntRestoreOptions, abi_version) == 0, "OliphauntRestoreOptions must start with abi_version"); +_Static_assert(sizeof(((OliphauntConfig *)0)->reserved_flags) == sizeof(uint64_t), "config flags must be 64-bit"); +_Static_assert(sizeof(((OliphauntArchiveFile *)0)->len) == sizeof(size_t), "archive file length must be size_t"); +_Static_assert(sizeof(((OliphauntArchiveFile *)0)->reserved_flags) == sizeof(uint64_t), "archive file flags must be 64-bit"); +_Static_assert(sizeof(((OliphauntBackupOptions *)0)->generated_file_count) == sizeof(size_t), "generated file count must be size_t"); +_Static_assert(sizeof(((OliphauntBackupOptions *)0)->reserved_flags) == sizeof(uint64_t), "backup flags must be 64-bit"); +_Static_assert(sizeof(((OliphauntRestoreOptions *)0)->flags) == sizeof(uint64_t), "restore flags must be 64-bit"); +_Static_assert(sizeof(((OliphauntResponse *)0)->len) == sizeof(size_t), "response length must be size_t"); +_Static_assert(sizeof(((OliphauntStaticExtension *)0)->symbol_count) == sizeof(size_t), "symbol count must be size_t"); + +static int32_t stream_callback(void *context, const uint8_t *data, size_t len) { + size_t *total = (size_t *)context; + if (total != NULL) { + *total += len; + } + return data != NULL || len == 0 ? 0 : -1; +} + +static uint8_t static_extension_symbol_storage; + +int main(void) { + int32_t (*init_fn)(const OliphauntConfig *, OliphauntHandle **) = oliphaunt_init; + int32_t (*exec_protocol_fn)(OliphauntHandle *, const uint8_t *, size_t, OliphauntResponse *) = + oliphaunt_exec_protocol; + int32_t (*exec_simple_query_fn)(OliphauntHandle *, const char *, size_t, OliphauntResponse *) = + oliphaunt_exec_simple_query; + int32_t (*exec_protocol_stream_fn)( + OliphauntHandle *, + const uint8_t *, + size_t, + OliphauntStreamCallback, + void *) = oliphaunt_exec_protocol_stream; + int32_t (*backup_fn)(OliphauntHandle *, uint32_t, OliphauntResponse *) = oliphaunt_backup; + int32_t (*backup_ex_fn)(OliphauntHandle *, const OliphauntBackupOptions *, OliphauntResponse *) = + oliphaunt_backup_ex; + int32_t (*restore_fn)(const OliphauntRestoreOptions *) = oliphaunt_restore; + int32_t (*cancel_fn)(OliphauntHandle *) = oliphaunt_cancel; + int32_t (*detach_fn)(OliphauntHandle *) = oliphaunt_detach; + int32_t (*close_fn)(OliphauntHandle *) = oliphaunt_close; + int32_t (*register_static_extensions_fn)(const OliphauntStaticExtension *, size_t) = + oliphaunt_register_static_extensions; + const char *(*last_error_fn)(OliphauntHandle *) = oliphaunt_last_error; + const char *(*version_fn)(void) = oliphaunt_version; + uint64_t (*capabilities_fn)(void) = oliphaunt_capabilities; + void (*free_response_fn)(OliphauntResponse *) = oliphaunt_free_response; + OliphauntStreamCallback stream_callback_fn = stream_callback; + + CHECK(init_fn != NULL, "oliphaunt_init must link"); + CHECK(exec_protocol_fn != NULL, "oliphaunt_exec_protocol must link"); + CHECK(exec_simple_query_fn != NULL, "oliphaunt_exec_simple_query must link"); + CHECK(exec_protocol_stream_fn != NULL, "oliphaunt_exec_protocol_stream must link"); + CHECK(backup_fn != NULL, "oliphaunt_backup must link"); + CHECK(backup_ex_fn != NULL, "oliphaunt_backup_ex must link"); + CHECK(restore_fn != NULL, "oliphaunt_restore must link"); + CHECK(cancel_fn != NULL, "oliphaunt_cancel must link"); + CHECK(detach_fn != NULL, "oliphaunt_detach must link"); + CHECK(close_fn != NULL, "oliphaunt_close must link"); + CHECK(register_static_extensions_fn != NULL, "oliphaunt_register_static_extensions must link"); + CHECK(last_error_fn != NULL, "oliphaunt_last_error must link"); + CHECK(version_fn != NULL, "oliphaunt_version must link"); + CHECK(capabilities_fn != NULL, "oliphaunt_capabilities must link"); + CHECK(free_response_fn != NULL, "oliphaunt_free_response must link"); + CHECK(stream_callback_fn != NULL, "OliphauntStreamCallback must accept stream callbacks"); + + OliphauntConfig config = {0}; + config.abi_version = OLIPHAUNT_ABI_VERSION; + config.pgdata = "/tmp/oliphaunt-abi-conformance-pgdata"; + config.runtime_dir = "/tmp/oliphaunt-abi-conformance-runtime"; + config.username = "liboliphaunt"; + config.database = "postgres"; + config.reserved_flags = OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK; + config.startup_args = NULL; + config.startup_arg_count = 0; + + OliphauntResponse response = {0}; + response.data = NULL; + response.len = 0; + free_response_fn(&response); + CHECK(response.data == NULL && response.len == 0, "oliphaunt_free_response must clear empty responses"); + free_response_fn(NULL); + + const uint8_t manifest_bytes[] = "layout=abi\n"; + OliphauntArchiveFile generated_file = { + .path = "manifest.properties", + .data = manifest_bytes, + .len = sizeof(manifest_bytes) - 1, + .mode = 0600, + .reserved_flags = 0, + }; + OliphauntBackupOptions backup_options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .generated_files = &generated_file, + .generated_file_count = 1, + .reserved_flags = 0, + }; + CHECK(backup_options.generated_files[0].len == sizeof(manifest_bytes) - 1, "backup options layout mismatch"); + + OliphauntRestoreOptions restore = {0}; + restore.abi_version = OLIPHAUNT_ABI_VERSION; + restore.root = "/tmp/oliphaunt-abi-conformance-restore"; + restore.format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE; + restore.data = (const uint8_t *)"x"; + restore.len = 1; + restore.flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING; + + OliphauntStaticExtensionSymbol symbol = { + .name = "liboliphaunt_abi_conformance_symbol", + .address = &static_extension_symbol_storage, + }; + OliphauntStaticExtension extension = { + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION, + .name = "liboliphaunt_abi_conformance", + .magic = NULL, + .init = NULL, + .symbols = &symbol, + .symbol_count = 1, + .reserved_flags = 0, + }; + CHECK(extension.symbols[0].address == &static_extension_symbol_storage, "static extension symbol layout mismatch"); + + const char *version = version_fn(); + CHECK(version != NULL && strstr(version, "postgresql-18.4") != NULL, "unexpected version string"); + + uint64_t capabilities = capabilities_fn(); + uint64_t required = + OLIPHAUNT_CAP_PROTOCOL_RAW | + OLIPHAUNT_CAP_PROTOCOL_STREAM | + OLIPHAUNT_CAP_EXTENSIONS | + OLIPHAUNT_CAP_QUERY_CANCEL | + OLIPHAUNT_CAP_BACKUP_RESTORE | + OLIPHAUNT_CAP_SIMPLE_QUERY | + OLIPHAUNT_CAP_STATIC_EXTENSIONS | + OLIPHAUNT_CAP_LOGICAL_REOPEN; + CHECK((capabilities & required) == required, "missing required capability bits"); + CHECK((capabilities & OLIPHAUNT_CAP_MULTI_INSTANCE) == 0, "direct C ABI must not advertise multi-instance"); + CHECK((capabilities & OLIPHAUNT_CAP_SERVER_MODE) == 0, "direct C ABI must not advertise server mode"); + + CHECK(close_fn(NULL) == 0, "oliphaunt_close(NULL) must be a no-op"); + CHECK(detach_fn(NULL) == 0, "oliphaunt_detach(NULL) must be a no-op"); + CHECK(cancel_fn(NULL) != 0, "oliphaunt_cancel(NULL) must fail"); + const char *error = last_error_fn(NULL); + CHECK(error != NULL && strstr(error, "invalid oliphaunt_cancel arguments") != NULL, + "oliphaunt_cancel(NULL) must set a global error"); + + (void)init_fn; + (void)exec_protocol_fn; + (void)exec_simple_query_fn; + (void)exec_protocol_stream_fn; + (void)backup_fn; + (void)restore_fn; + (void)register_static_extensions_fn; + (void)config; + (void)restore; + + return 0; +} diff --git a/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c b/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c new file mode 100644 index 00000000..4179d610 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c @@ -0,0 +1,1878 @@ +#ifndef _WIN32 +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L +#endif +#endif + +#include "postgres.h" +#include "fmgr.h" + +#include "../include/oliphaunt.h" +#ifdef _WIN32 +#define OLIPHAUNT_PLATFORM_EXTERNAL_POSIX_SHIMS 1 +#endif +#include "liboliphaunt_platform.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#endif + +#undef PG_MAGIC_FUNCTION_NAME +#define PG_MAGIC_FUNCTION_NAME liboliphaunt_smoke_static_magic +PG_MODULE_MAGIC_EXT( + .name = "liboliphaunt_smoke_static", + .version = "1"); + +PG_FUNCTION_INFO_V1(liboliphaunt_smoke_static_answer); + +static int liboliphaunt_smoke_static_init_calls = 0; + +Datum liboliphaunt_smoke_static_answer(PG_FUNCTION_ARGS) { + (void)fcinfo; + PG_RETURN_INT32(2718); +} + +static void liboliphaunt_smoke_static_init(void) { + liboliphaunt_smoke_static_init_calls++; +} + +static void push_query(unsigned char **buf, size_t *len, const char *sql) { + size_t sql_len = strlen(sql) + 1; + size_t frame_len = sql_len + 4; + *len = frame_len + 1; + *buf = (unsigned char *)calloc(*len, 1); + (*buf)[0] = 'Q'; + (*buf)[1] = (unsigned char)((frame_len >> 24) & 0xff); + (*buf)[2] = (unsigned char)((frame_len >> 16) & 0xff); + (*buf)[3] = (unsigned char)((frame_len >> 8) & 0xff); + (*buf)[4] = (unsigned char)(frame_len & 0xff); + memcpy(*buf + 5, sql, sql_len); +} + +static int expect_error_contains(OliphauntHandle *db, const char *context, const char *needle) { + const char *message = oliphaunt_last_error(db); + if (message == NULL || strstr(message, needle) == NULL) { + fprintf(stderr, "%s did not set expected error containing '%s': %s\n", + context, + needle, + message ? message : "(null)"); + return 1; + } + return 0; +} + +static int verify_global_contract(void) { + const char *version = oliphaunt_version(); + if (version == NULL || strstr(version, "postgresql-18.4") == NULL) { + fprintf(stderr, "unexpected liboliphaunt version: %s\n", version ? version : "(null)"); + return 1; + } + + uint64_t capabilities = oliphaunt_capabilities(); + uint64_t required = + OLIPHAUNT_CAP_PROTOCOL_RAW | + OLIPHAUNT_CAP_PROTOCOL_STREAM | + OLIPHAUNT_CAP_EXTENSIONS | + OLIPHAUNT_CAP_QUERY_CANCEL | + OLIPHAUNT_CAP_BACKUP_RESTORE | + OLIPHAUNT_CAP_SIMPLE_QUERY | + OLIPHAUNT_CAP_STATIC_EXTENSIONS | + OLIPHAUNT_CAP_LOGICAL_REOPEN; + if ((capabilities & required) != required) { + fprintf(stderr, "missing required liboliphaunt capabilities: 0x%llx\n", + (unsigned long long)capabilities); + return 1; + } + if ((capabilities & OLIPHAUNT_CAP_MULTI_INSTANCE) != 0 || + (capabilities & OLIPHAUNT_CAP_SERVER_MODE) != 0) { + fprintf(stderr, "liboliphaunt advertised unsupported v1 capabilities: 0x%llx\n", + (unsigned long long)capabilities); + return 1; + } + + if (oliphaunt_close(NULL) != 0) { + fprintf(stderr, "oliphaunt_close(NULL) should be a successful no-op\n"); + return 1; + } + if (oliphaunt_detach(NULL) != 0) { + fprintf(stderr, "oliphaunt_detach(NULL) should be a successful no-op\n"); + return 1; + } + if (oliphaunt_cancel(NULL) == 0) { + fprintf(stderr, "oliphaunt_cancel(NULL) should fail\n"); + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_cancel null handle", "invalid oliphaunt_cancel arguments") != 0) { + return 1; + } + + OliphauntConfig invalid_config = { + .abi_version = OLIPHAUNT_ABI_VERSION + 1, + .pgdata = "/tmp/oliphaunt-invalid-pgdata", + }; + OliphauntHandle *invalid = NULL; + if (oliphaunt_init(&invalid_config, &invalid) == 0 || invalid != NULL) { + fprintf(stderr, "oliphaunt_init accepted an invalid ABI version\n"); + if (invalid != NULL) { + oliphaunt_close(invalid); + } + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_init invalid ABI", "invalid oliphaunt_init config") != 0) { + return 1; + } + if (oliphaunt_init(NULL, &invalid) == 0 || invalid != NULL) { + fprintf(stderr, "oliphaunt_init accepted a null config\n"); + if (invalid != NULL) { + oliphaunt_close(invalid); + } + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_init null config", "invalid oliphaunt_init config") != 0) { + return 1; + } + if (oliphaunt_init(&invalid_config, NULL) == 0) { + fprintf(stderr, "oliphaunt_init accepted a null out parameter\n"); + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_init null out", "out parameter is null") != 0) { + return 1; + } + OliphauntConfig invalid_flags = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .pgdata = "/tmp/oliphaunt-invalid-flags-pgdata", + .reserved_flags = 1ull << 63, + }; + if (oliphaunt_init(&invalid_flags, &invalid) == 0 || invalid != NULL) { + fprintf(stderr, "oliphaunt_init accepted unknown config flags\n"); + if (invalid != NULL) { + oliphaunt_close(invalid); + } + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_init invalid flags", "invalid oliphaunt_init config flags") != 0) { + return 1; + } + return 0; +} + +static int expect_pgdata_env(const char *context, const char *expected) { + const char *actual = getenv("PGDATA"); + if ((expected == NULL && actual != NULL) || + (expected != NULL && (actual == NULL || strcmp(actual, expected) != 0))) { + fprintf(stderr, + "%s left unexpected PGDATA environment: expected %s, got %s\n", + context, + expected ? expected : "(unset)", + actual ? actual : "(unset)"); + return 1; + } + return 0; +} + +static int set_pgdata_env_for_smoke(const char *value) { + if (setenv("PGDATA", value, 1) != 0) { + perror("set smoke PGDATA environment"); + return 1; + } + return 0; +} + +static int expect_static_extension_registration_fails( + const OliphauntStaticExtension *extensions, + size_t count, + const char *context, + const char *needle) { + if (oliphaunt_register_static_extensions(extensions, count) == 0) { + fprintf(stderr, "%s unexpectedly succeeded\n", context); + return 1; + } + return expect_error_contains(NULL, context, needle); +} + +static int verify_static_extension_registry_rejects_invalid_entries(void) { + static const OliphauntStaticExtensionSymbol valid_symbols[] = { + { + .name = "liboliphaunt_smoke_static_answer", + .address = (void *)liboliphaunt_smoke_static_answer, + }, + }; + static const OliphauntStaticExtensionSymbol duplicate_symbols[] = { + { + .name = "liboliphaunt_smoke_static_answer", + .address = (void *)liboliphaunt_smoke_static_answer, + }, + { + .name = "liboliphaunt_smoke_static_answer", + .address = (void *)liboliphaunt_smoke_static_answer, + }, + }; + static const OliphauntStaticExtension invalid_abi[] = { + { + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION + 1, + .name = "liboliphaunt_smoke_invalid_abi", + .magic = (const void *(*)(void))liboliphaunt_smoke_static_magic, + .symbols = valid_symbols, + .symbol_count = sizeof(valid_symbols) / sizeof(valid_symbols[0]), + }, + }; + static const OliphauntStaticExtension duplicate_extensions[] = { + { + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION, + .name = "liboliphaunt_smoke_duplicate", + .magic = (const void *(*)(void))liboliphaunt_smoke_static_magic, + .symbols = valid_symbols, + .symbol_count = sizeof(valid_symbols) / sizeof(valid_symbols[0]), + }, + { + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION, + .name = "liboliphaunt_smoke_duplicate", + .magic = (const void *(*)(void))liboliphaunt_smoke_static_magic, + .symbols = valid_symbols, + .symbol_count = sizeof(valid_symbols) / sizeof(valid_symbols[0]), + }, + }; + static const OliphauntStaticExtension duplicate_symbol_entry[] = { + { + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION, + .name = "liboliphaunt_smoke_duplicate_symbol", + .magic = (const void *(*)(void))liboliphaunt_smoke_static_magic, + .symbols = duplicate_symbols, + .symbol_count = sizeof(duplicate_symbols) / sizeof(duplicate_symbols[0]), + }, + }; + + if (expect_static_extension_registration_fails( + invalid_abi, + sizeof(invalid_abi) / sizeof(invalid_abi[0]), + "static extension invalid ABI", + "invalid static extension registration entry") != 0) { + return 1; + } + if (expect_static_extension_registration_fails( + duplicate_extensions, + sizeof(duplicate_extensions) / sizeof(duplicate_extensions[0]), + "static extension duplicate module", + "duplicate static extension registration entry") != 0) { + return 1; + } + if (expect_static_extension_registration_fails( + duplicate_symbol_entry, + sizeof(duplicate_symbol_entry) / sizeof(duplicate_symbol_entry[0]), + "static extension duplicate symbol", + "duplicate static extension symbol registration entry") != 0) { + return 1; + } + return 0; +} + +static int register_static_extension_fixture(void) { + static const OliphauntStaticExtensionSymbol symbols[] = { + { + .name = "liboliphaunt_smoke_static_answer", + .address = (void *)liboliphaunt_smoke_static_answer, + }, + { + .name = "pg_finfo_liboliphaunt_smoke_static_answer", + .address = (void *)pg_finfo_liboliphaunt_smoke_static_answer, + }, + }; + static const OliphauntStaticExtension extensions[] = { + { + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION, + .name = "liboliphaunt_smoke_static", + .magic = (const void *(*)(void))liboliphaunt_smoke_static_magic, + .init = liboliphaunt_smoke_static_init, + .symbols = symbols, + .symbol_count = sizeof(symbols) / sizeof(symbols[0]), + .reserved_flags = 0, + }, + }; + if (oliphaunt_register_static_extensions(extensions, sizeof(extensions) / sizeof(extensions[0])) != 0) { + fprintf(stderr, "oliphaunt_register_static_extensions failed: %s\n", oliphaunt_last_error(NULL)); + return 1; + } + return 0; +} + +static int contains_tag(const OliphauntResponse *response, unsigned char tag) { + size_t off = 0; + while (off + 5 <= response->len) { + unsigned char current = response->data[off]; + uint32_t len = ((uint32_t)response->data[off + 1] << 24) | + ((uint32_t)response->data[off + 2] << 16) | + ((uint32_t)response->data[off + 3] << 8) | + (uint32_t)response->data[off + 4]; + if (len < 4 || off + 1 + len > response->len) { + return 0; + } + if (current == tag) { + return 1; + } + off += 1 + len; + } + return 0; +} + +static int contains_bytes(const OliphauntResponse *response, const char *needle) { + size_t needle_len = strlen(needle); + if (needle_len == 0) { + return 1; + } + if (response->data == NULL || response->len < needle_len) { + return 0; + } + for (size_t i = 0; i + needle_len <= response->len; i++) { + if (memcmp(response->data + i, needle, needle_len) == 0) { + return 1; + } + } + return 0; +} + +static int32_t append_stream_chunk(void *context, const uint8_t *data, size_t len); + +typedef struct StreamAccumulator { + unsigned char *data; + size_t len; + size_t cap; + size_t chunks; +} StreamAccumulator; + +typedef struct CancelQueryThread { + OliphauntHandle *db; + int status; +} CancelQueryThread; + +typedef struct TestTarArchive { + unsigned char data[8192]; + size_t len; +} TestTarArchive; + +static void smoke_sleep_millis(unsigned milliseconds) { +#ifdef _WIN32 + Sleep((DWORD)milliseconds); +#else + struct timespec remaining = { + .tv_sec = milliseconds / 1000, + .tv_nsec = (long)(milliseconds % 1000) * 1000000L, + }; + while (nanosleep(&remaining, &remaining) != 0 && errno == EINTR) { + } +#endif +} + +static int verify_free_response_contract(void) { + OliphauntResponse empty = {0}; + oliphaunt_free_response(NULL); + oliphaunt_free_response(&empty); + if (empty.data != NULL || empty.len != 0) { + fprintf(stderr, "oliphaunt_free_response mutated empty response incorrectly\n"); + return 1; + } + + empty.data = (uint8_t *)malloc(4); + if (empty.data == NULL) { + fprintf(stderr, "failed to allocate response fixture\n"); + return 1; + } + empty.len = 4; + oliphaunt_free_response(&empty); + if (empty.data != NULL || empty.len != 0) { + fprintf(stderr, "oliphaunt_free_response did not clear freed response\n"); + return 1; + } + oliphaunt_free_response(&empty); + return 0; +} + +static int exec_query_expect_tags( + OliphauntHandle *db, + const char *sql, + const unsigned char *tags, + size_t tag_count) { + unsigned char *query = NULL; + size_t query_len = 0; + push_query(&query, &query_len, sql); + + OliphauntResponse response = {0}; + fprintf(stderr, "executing raw protocol: %s\n", sql); + /* OLIPHAUNT_DOCS_SNIPPET liboliphaunt-quickstart */ + int rc = oliphaunt_exec_protocol(db, query, query_len, &response); + free(query); + if (rc != 0) { + fprintf(stderr, "oliphaunt_exec_protocol failed: %s\n", oliphaunt_last_error(db)); + return 1; + } + for (size_t i = 0; i < tag_count; i++) { + if (!contains_tag(&response, tags[i])) { + fprintf(stderr, "response for %s did not contain protocol tag %c\n", sql, tags[i]); + oliphaunt_free_response(&response); + return 1; + } + } + oliphaunt_free_response(&response); + return 0; +} + +static int exec_query_expect_bytes(OliphauntHandle *db, const char *sql, const char *needle) { + unsigned char *query = NULL; + size_t query_len = 0; + push_query(&query, &query_len, sql); + + OliphauntResponse response = {0}; + fprintf(stderr, "executing raw protocol: %s\n", sql); + int rc = oliphaunt_exec_protocol(db, query, query_len, &response); + free(query); + if (rc != 0) { + fprintf(stderr, "oliphaunt_exec_protocol failed: %s\n", oliphaunt_last_error(db)); + return 1; + } + if (!contains_bytes(&response, needle)) { + fprintf(stderr, "response for %s did not contain expected bytes %s\n", sql, needle); + oliphaunt_free_response(&response); + return 1; + } + oliphaunt_free_response(&response); + return 0; +} + +static int exec_simple_query_expect_bytes(OliphauntHandle *db, const char *sql, const char *needle) { + OliphauntResponse response = {0}; + fprintf(stderr, "executing simple query ABI: %s\n", sql); + int rc = oliphaunt_exec_simple_query(db, sql, strlen(sql), &response); + if (rc != 0) { + fprintf(stderr, "oliphaunt_exec_simple_query failed: %s\n", oliphaunt_last_error(db)); + return 1; + } + if (!contains_bytes(&response, needle)) { + fprintf(stderr, "simple-query response for %s did not contain expected bytes %s\n", sql, needle); + oliphaunt_free_response(&response); + return 1; + } + oliphaunt_free_response(&response); + return 0; +} + +static int exec_query_ignores_legacy_wait_timeout_env(OliphauntHandle *db) { + const char *previous_timeout = getenv("OLIPHAUNT_TIMEOUT_MS"); + char *saved_timeout = previous_timeout != NULL ? strdup(previous_timeout) : NULL; + if (previous_timeout != NULL && saved_timeout == NULL) { + fprintf(stderr, "failed to save OLIPHAUNT_TIMEOUT_MS\n"); + return 1; + } + if (setenv("OLIPHAUNT_TIMEOUT_MS", "1", 1) != 0) { + fprintf(stderr, "failed to set OLIPHAUNT_TIMEOUT_MS\n"); + free(saved_timeout); + return 1; + } + + const unsigned char select_tags[] = {'T', 'D', 'C', 'Z'}; + int status = exec_query_expect_tags( + db, + "SELECT pg_sleep(0.02) AS no_synthetic_query_timeout", + select_tags, + sizeof(select_tags)); + + if (saved_timeout != NULL) { + if (setenv("OLIPHAUNT_TIMEOUT_MS", saved_timeout, 1) != 0) { + fprintf(stderr, "failed to restore OLIPHAUNT_TIMEOUT_MS\n"); + status = 1; + } + } else if (unsetenv("OLIPHAUNT_TIMEOUT_MS") != 0) { + fprintf(stderr, "failed to unset OLIPHAUNT_TIMEOUT_MS\n"); + status = 1; + } + free(saved_timeout); + return status; +} + +static int exec_invalid_argument_checks(OliphauntHandle *db) { + OliphauntResponse response = {0}; + unsigned char byte = 0; + if (oliphaunt_exec_protocol(db, NULL, 1, &response) == 0) { + fprintf(stderr, "oliphaunt_exec_protocol accepted null request with non-zero length\n"); + oliphaunt_free_response(&response); + return 1; + } + if (expect_error_contains(db, "oliphaunt_exec_protocol null request", "invalid oliphaunt_exec_protocol arguments") != 0) { + return 1; + } + if (response.data != NULL || response.len != 0) { + fprintf(stderr, "invalid exec arguments unexpectedly produced a response\n"); + oliphaunt_free_response(&response); + return 1; + } + if (oliphaunt_exec_protocol(db, &byte, sizeof(byte), NULL) == 0) { + fprintf(stderr, "oliphaunt_exec_protocol accepted null response out parameter\n"); + return 1; + } + if (expect_error_contains(db, "oliphaunt_exec_protocol null out", "invalid oliphaunt_exec_protocol arguments") != 0) { + return 1; + } + if (oliphaunt_exec_protocol_stream(db, &byte, sizeof(byte), NULL, NULL) == 0) { + fprintf(stderr, "oliphaunt_exec_protocol_stream accepted null callback\n"); + return 1; + } + if (expect_error_contains(db, "oliphaunt_exec_protocol_stream null callback", "invalid oliphaunt_exec_protocol_stream arguments") != 0) { + return 1; + } + if (oliphaunt_exec_protocol_stream(db, NULL, 1, append_stream_chunk, NULL) == 0) { + fprintf(stderr, "oliphaunt_exec_protocol_stream accepted null request with non-zero length\n"); + return 1; + } + if (expect_error_contains(db, "oliphaunt_exec_protocol_stream null request", "invalid oliphaunt_exec_protocol_stream arguments") != 0) { + return 1; + } + if (oliphaunt_exec_simple_query(db, NULL, 0, &response) == 0) { + fprintf(stderr, "oliphaunt_exec_simple_query accepted null SQL\n"); + oliphaunt_free_response(&response); + return 1; + } + if (expect_error_contains(db, "oliphaunt_exec_simple_query null SQL", "invalid oliphaunt_exec_simple_query arguments") != 0) { + return 1; + } + if (oliphaunt_exec_simple_query(db, "SELECT 1", strlen("SELECT 1"), NULL) == 0) { + fprintf(stderr, "oliphaunt_exec_simple_query accepted null response out parameter\n"); + return 1; + } + if (expect_error_contains(db, "oliphaunt_exec_simple_query null out", "invalid oliphaunt_exec_simple_query arguments") != 0) { + return 1; + } + static const char interior_nul_query[] = {'S', 'E', 'L', 'E', 'C', 'T', ' ', '1', '\0', '2'}; + if (oliphaunt_exec_simple_query(db, interior_nul_query, sizeof(interior_nul_query), &response) == 0) { + fprintf(stderr, "oliphaunt_exec_simple_query accepted interior NUL SQL\n"); + oliphaunt_free_response(&response); + return 1; + } + if (expect_error_contains(db, "oliphaunt_exec_simple_query interior NUL", "interior NUL") != 0) { + return 1; + } + return 0; +} + +static int expect_malformed_exec_rejected( + OliphauntHandle *db, + const char *context, + const unsigned char *request, + size_t request_len, + const char *needle) { + OliphauntResponse response = {0}; + if (oliphaunt_exec_protocol(db, request, request_len, &response) == 0) { + fprintf(stderr, "%s was accepted as a raw protocol request\n", context); + oliphaunt_free_response(&response); + return 1; + } + if (response.data != NULL || response.len != 0) { + fprintf(stderr, "%s unexpectedly produced a response\n", context); + oliphaunt_free_response(&response); + return 1; + } + return expect_error_contains(db, context, needle); +} + +static int expect_malformed_stream_rejected( + OliphauntHandle *db, + const char *context, + const unsigned char *request, + size_t request_len, + const char *needle) { + StreamAccumulator acc = {0}; + if (oliphaunt_exec_protocol_stream(db, request, request_len, append_stream_chunk, &acc) == 0) { + fprintf(stderr, "%s was accepted as a streaming raw protocol request\n", context); + free(acc.data); + return 1; + } + if (acc.chunks != 0 || acc.data != NULL || acc.len != 0) { + fprintf(stderr, "%s unexpectedly invoked the stream callback\n", context); + free(acc.data); + return 1; + } + return expect_error_contains(db, context, needle); +} + +static int exec_malformed_frame_checks(OliphauntHandle *db) { + static const unsigned char too_short_header[] = {'Q', 0, 0}; + static const unsigned char short_length[] = {'Q', 0, 0, 0, 3}; + static const unsigned char truncated_body[] = {'Q', 0, 0, 0, 64, 'S', 'E', 'L'}; + + if (expect_malformed_exec_rejected(db, "empty raw protocol request", NULL, 0, "empty request") != 0) { + return 1; + } + if (expect_malformed_exec_rejected( + db, + "truncated raw protocol header", + too_short_header, + sizeof(too_short_header), + "truncated message header") != 0) { + return 1; + } + if (expect_malformed_exec_rejected( + db, + "short raw protocol message length", + short_length, + sizeof(short_length), + "message length is smaller") != 0) { + return 1; + } + if (expect_malformed_exec_rejected( + db, + "truncated raw protocol body", + truncated_body, + sizeof(truncated_body), + "truncated message body") != 0) { + return 1; + } + if (expect_malformed_stream_rejected( + db, + "truncated streaming protocol body", + truncated_body, + sizeof(truncated_body), + "truncated message body") != 0) { + return 1; + } + return 0; +} + +static int32_t append_stream_chunk(void *context, const uint8_t *data, size_t len) { + StreamAccumulator *acc = (StreamAccumulator *)context; + acc->chunks++; + if (len == 0) { + return 0; + } + if (acc->len + len > acc->cap) { + size_t next = acc->cap ? acc->cap : 8192; + while (next < acc->len + len) { + next *= 2; + } + unsigned char *grown = (unsigned char *)realloc(acc->data, next); + if (grown == NULL) { + return -1; + } + acc->data = grown; + acc->cap = next; + } + memcpy(acc->data + acc->len, data, len); + acc->len += len; + return 0; +} + +static int32_t fail_stream_chunk(void *context, const uint8_t *data, size_t len) { + (void)data; + (void)len; + StreamAccumulator *acc = (StreamAccumulator *)context; + acc->chunks++; + return -1; +} + +static int exec_stream_expect_tags( + OliphauntHandle *db, + const char *sql, + const unsigned char *tags, + size_t tag_count) { + unsigned char *query = NULL; + size_t query_len = 0; + push_query(&query, &query_len, sql); + + StreamAccumulator acc = {0}; + fprintf(stderr, "streaming raw protocol: %s\n", sql); + int rc = oliphaunt_exec_protocol_stream(db, query, query_len, append_stream_chunk, &acc); + free(query); + if (rc != 0) { + fprintf(stderr, "oliphaunt_exec_protocol_stream failed: %s\n", oliphaunt_last_error(db)); + free(acc.data); + return 1; + } + OliphauntResponse response = { + .data = acc.data, + .len = acc.len, + }; + for (size_t i = 0; i < tag_count; i++) { + if (!contains_tag(&response, tags[i])) { + fprintf(stderr, "stream response for %s did not contain protocol tag %c\n", sql, tags[i]); + free(acc.data); + return 1; + } + } + if (acc.chunks == 0) { + fprintf(stderr, "stream response for %s did not invoke callback\n", sql); + free(acc.data); + return 1; + } + free(acc.data); + return 0; +} + +static int exec_stream_callback_failure_recovers(OliphauntHandle *db) { + unsigned char *query = NULL; + size_t query_len = 0; + push_query(&query, &query_len, "SELECT repeat('z', 4096) AS callback_failure"); + + StreamAccumulator acc = {0}; + fprintf(stderr, "streaming raw protocol with failing callback\n"); + int rc = oliphaunt_exec_protocol_stream(db, query, query_len, fail_stream_chunk, &acc); + free(query); + free(acc.data); + if (rc == 0) { + fprintf(stderr, "oliphaunt_exec_protocol_stream succeeded despite callback failure\n"); + return 1; + } + if (acc.chunks == 0) { + fprintf(stderr, "failing stream callback was not invoked\n"); + return 1; + } + if (expect_error_contains(db, "stream callback failure", "protocol stream callback failed") != 0) { + return 1; + } + const unsigned char select_tags[] = {'T', 'D', 'C', 'Z'}; + return exec_query_expect_tags(db, "SELECT 4 AS recovered_after_callback_failure", select_tags, sizeof(select_tags)); +} + +static void *cancel_query_thread_main(void *context) { + CancelQueryThread *state = (CancelQueryThread *)context; + unsigned char *query = NULL; + size_t query_len = 0; + push_query(&query, &query_len, "SELECT pg_sleep(5) AS should_cancel"); + + OliphauntResponse response = {0}; + fprintf(stderr, "executing cancellable raw protocol query\n"); + int rc = oliphaunt_exec_protocol(state->db, query, query_len, &response); + free(query); + if (rc != 0) { + fprintf(stderr, "cancellable query failed at ABI level: %s\n", oliphaunt_last_error(state->db)); + state->status = 1; + return NULL; + } + + if (!contains_tag(&response, 'E') || !contains_tag(&response, 'Z')) { + fprintf(stderr, "cancellable query response did not contain ErrorResponse and ReadyForQuery\n"); + oliphaunt_free_response(&response); + state->status = 1; + return NULL; + } + if (!contains_bytes(&response, "canceling statement due to user request")) { + fprintf(stderr, "cancellable query response did not contain PostgreSQL cancel message\n"); + oliphaunt_free_response(&response); + state->status = 1; + return NULL; + } + + oliphaunt_free_response(&response); + state->status = 0; + return NULL; +} + +static int exec_cancel_recovers(OliphauntHandle *db) { + CancelQueryThread state = { + .db = db, + .status = 1, + }; + pthread_t thread; + if (pthread_create(&thread, NULL, cancel_query_thread_main, &state) != 0) { + fprintf(stderr, "failed to create cancellation smoke thread\n"); + return 1; + } + + smoke_sleep_millis(100); + fprintf(stderr, "cancelling active raw protocol query\n"); + if (oliphaunt_cancel(db) != 0) { + fprintf(stderr, "oliphaunt_cancel failed: %s\n", oliphaunt_last_error(db)); + pthread_join(thread, NULL); + return 1; + } + pthread_join(thread, NULL); + if (state.status != 0) { + return 1; + } + + const unsigned char select_tags[] = {'T', 'D', 'C', 'Z'}; + return exec_query_expect_tags(db, "SELECT 5 AS recovered_after_cancel", select_tags, sizeof(select_tags)); +} + +static int exec_static_extension_registry_smoke(OliphauntHandle *db) { + int before = liboliphaunt_smoke_static_init_calls; + if (exec_simple_query_expect_bytes( + db, + "CREATE OR REPLACE FUNCTION liboliphaunt_static_answer() " + "RETURNS integer AS 'liboliphaunt_smoke_static', 'liboliphaunt_smoke_static_answer' " + "LANGUAGE C STRICT; " + "SELECT liboliphaunt_static_answer()", + "2718") != 0) { + return 1; + } + if (liboliphaunt_smoke_static_init_calls != before + 1) { + fprintf(stderr, + "static extension init was not called exactly once: before=%d after=%d\n", + before, + liboliphaunt_smoke_static_init_calls); + return 1; + } + if (expect_static_extension_registration_fails( + NULL, + 0, + "static extension registry freeze", + "static extension registry cannot be changed after backend startup") != 0) { + return 1; + } + return 0; +} + +static int file_exists(const char *path) { + struct stat st; + return stat(path, &st) == 0 && S_ISREG(st.st_mode); +} + +static int parent_path(const char *path, char *out, size_t out_len) { + if (path == NULL || out == NULL || out_len == 0) { + return -1; + } + if (snprintf(out, out_len, "%s", path) >= (int)out_len) { + return -1; + } + char *slash = strrchr(out, '/'); + if (slash == NULL) { + return snprintf(out, out_len, ".") >= (int)out_len ? -1 : 0; + } + if (slash == out) { + slash[1] = '\0'; + return 0; + } + *slash = '\0'; + return 0; +} + +static int verify_root_lock_marker(const char *pgdata) { + char lock_path[4096]; + if (parent_path(pgdata, lock_path, sizeof(lock_path)) != 0) { + fprintf(stderr, "failed to resolve native root lock marker parent\n"); + return 1; + } + size_t len = strlen(lock_path); + if (snprintf(lock_path + len, sizeof(lock_path) - len, "%s.oliphaunt.lock", len > 0 && lock_path[len - 1] == '/' ? "" : "/") >= + (int)(sizeof(lock_path) - len)) { + fprintf(stderr, "native root lock marker path is too long\n"); + return 1; + } + if (!file_exists(lock_path)) { + fprintf(stderr, "native root lock marker was not created at %s\n", lock_path); + return 1; + } + return 0; +} + +static int is_stable_root_lock_name(const char *name) { + const char *prefix = ".oliphaunt-root-"; + const char *suffix = ".lock"; + const size_t prefix_len = strlen(prefix); + const size_t digest_len = 32; + const size_t suffix_len = strlen(suffix); + size_t len = strlen(name); + if (len != prefix_len + digest_len + suffix_len || + strncmp(name, prefix, prefix_len) != 0 || + strcmp(name + prefix_len + digest_len, suffix) != 0) { + return 0; + } + for (size_t i = prefix_len; i < prefix_len + digest_len; i++) { + if (!((name[i] >= '0' && name[i] <= '9') || (name[i] >= 'a' && name[i] <= 'f'))) { + return 0; + } + } + return 1; +} + +static int count_stable_root_lock_files(const char *lock_dir, int *found) { + *found = 0; +#ifdef _WIN32 + char pattern[4096]; + if (snprintf(pattern, sizeof(pattern), "%s/.oliphaunt-root-*.lock", lock_dir) >= (int)sizeof(pattern)) { + fprintf(stderr, "stable native root lock glob path is too long\n"); + return 1; + } + WIN32_FIND_DATAA data; + HANDLE handle = FindFirstFileA(pattern, &data); + if (handle == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) { + return 0; + } + fprintf(stderr, "open native root lock directory failed: %lu\n", (unsigned long)error); + return 1; + } + do { + if (is_stable_root_lock_name(data.cFileName)) { + (*found)++; + } + } while (FindNextFileA(handle, &data)); + DWORD error = GetLastError(); + FindClose(handle); + if (error != ERROR_NO_MORE_FILES) { + fprintf(stderr, "read native root lock directory failed: %lu\n", (unsigned long)error); + return 1; + } + return 0; +#else + DIR *dir = opendir(lock_dir); + if (dir == NULL) { + perror("open native root lock directory"); + return 1; + } + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (is_stable_root_lock_name(entry->d_name)) { + (*found)++; + } + } + closedir(dir); + return 0; +#endif +} + +static int verify_stable_root_lock_file(const char *pgdata) { + char root[4096]; + if (parent_path(pgdata, root, sizeof(root)) != 0) { + fprintf(stderr, "failed to resolve stable native root\n"); + return 1; + } + char lock_dir[4096]; + if (parent_path(root, lock_dir, sizeof(lock_dir)) != 0) { + fprintf(stderr, "failed to resolve stable native root lock directory\n"); + return 1; + } + int found = 0; + if (count_stable_root_lock_files(lock_dir, &found) != 0) { + return 1; + } + if (found == 0) { + fprintf(stderr, "stable native root lock file was not created under %s\n", lock_dir); + return 1; + } + return 0; +} + +static void test_tar_write_octal(unsigned char *field, size_t width, unsigned long long value) { + memset(field, '0', width); + char scratch[32]; + snprintf(scratch, sizeof(scratch), "%0*llo", (int)width - 1, value); + size_t len = strlen(scratch); + if (len >= width) { + memset(field, '7', width - 1); + field[width - 1] = '\0'; + return; + } + memcpy(field + (width - 1 - len), scratch, len); + field[width - 1] = '\0'; +} + +static void test_tar_rewrite_checksum(unsigned char *header) { + memset(header + 148, ' ', 8); + unsigned int checksum = 0; + for (size_t i = 0; i < 512; i++) { + checksum += header[i]; + } + snprintf((char *)header + 148, 8, "%06o", checksum); + header[154] = '\0'; + header[155] = ' '; +} + +static int test_tar_append_header(TestTarArchive *archive, const char *name, char typeflag, size_t size, const char *link_name) { + if (archive->len + 512 > sizeof(archive->data) || strlen(name) > 100) { + return -1; + } + unsigned char *header = archive->data + archive->len; + memset(header, 0, 512); + memcpy(header, name, strlen(name)); + test_tar_write_octal(header + 100, 8, 0600); + test_tar_write_octal(header + 108, 8, 0); + test_tar_write_octal(header + 116, 8, 0); + test_tar_write_octal(header + 124, 12, (unsigned long long)size); + test_tar_write_octal(header + 136, 12, 0); + memset(header + 148, ' ', 8); + header[156] = (unsigned char)typeflag; + if (link_name != NULL) { + size_t link_len = strlen(link_name); + if (link_len > 100) { + return -1; + } + memcpy(header + 157, link_name, link_len); + } + memcpy(header + 257, "ustar", 5); + memcpy(header + 263, "00", 2); + test_tar_rewrite_checksum(header); + archive->len += 512; + return 0; +} + +static int test_tar_append_file(TestTarArchive *archive, const char *name, const char *contents) { + size_t size = strlen(contents); + size_t padded = (size + 511) & ~(size_t)511; + if (archive->len + 512 + padded > sizeof(archive->data)) { + return -1; + } + if (test_tar_append_header(archive, name, '0', size, NULL) != 0) { + return -1; + } + memcpy(archive->data + archive->len, contents, size); + memset(archive->data + archive->len + size, 0, padded - size); + archive->len += padded; + return 0; +} + +static int test_tar_append_special(TestTarArchive *archive, const char *name, char typeflag, const char *link_name) { + return test_tar_append_header(archive, name, typeflag, 0, link_name); +} + +static int test_tar_finish(TestTarArchive *archive) { + if (archive->len + 1024 > sizeof(archive->data)) { + return -1; + } + memset(archive->data + archive->len, 0, 1024); + archive->len += 1024; + return 0; +} + +static int test_tar_append_zero_block(TestTarArchive *archive) { + if (archive->len + 512 > sizeof(archive->data)) { + return -1; + } + memset(archive->data + archive->len, 0, 512); + archive->len += 512; + return 0; +} + +static int test_tar_append_nonzero_block(TestTarArchive *archive) { + if (archive->len + 512 > sizeof(archive->data)) { + return -1; + } + memset(archive->data + archive->len, 'x', 512); + archive->len += 512; + return 0; +} + +static int append_required_restore_entries(TestTarArchive *archive) { + if (test_tar_append_file(archive, "pgdata/PG_VERSION", "18\n") != 0 || + test_tar_append_file(archive, "pgdata/global/pg_control", "control") != 0 || + test_tar_append_file(archive, "pgdata/backup_label", "label") != 0) { + return -1; + } + return 0; +} + +static int build_archive_with_special_entry(TestTarArchive *archive, char typeflag) { + memset(archive, 0, sizeof(*archive)); + if (append_required_restore_entries(archive) != 0) { + return -1; + } + const char *link_name = typeflag == '2' || typeflag == '1' ? "pgdata/PG_VERSION" : NULL; + if (test_tar_append_special(archive, "pgdata/base/special-entry", typeflag, link_name) != 0) { + return -1; + } + return test_tar_finish(archive); +} + +static int verify_restore_rejects_special_archive_entry(const char *pgdata, char typeflag) { + TestTarArchive archive; + if (build_archive_with_special_entry(&archive, typeflag) != 0) { + fprintf(stderr, "failed to build malicious physical archive fixture\n"); + return 1; + } + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-%c.%ld", pgdata, typeflag, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted unsupported tar entry type '%c'\n", typeflag); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore unsupported archive entry", "unsupported tar entry type"); +} + +static int verify_restore_rejects_directory_entry_with_payload(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_append_header(&archive, "pgdata/base/nonzero-dir", '5', 1, NULL) != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build directory-payload physical archive fixture\n"); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-nonzero-dir.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted a directory archive entry with payload bytes\n"); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore directory entry with payload", "directory entry pgdata/base/nonzero-dir has non-zero size"); +} + +static int verify_restore_rejects_bad_tar_checksum(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build checksum physical archive fixture\n"); + return 1; + } + archive.data[148] = archive.data[148] == '0' ? '1' : '0'; + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-checksum.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted an archive with an invalid tar checksum\n"); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore invalid tar checksum", "invalid tar checksum"); +} + +static int verify_restore_rejects_bad_tar_checksum_field(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build checksum-field physical archive fixture\n"); + return 1; + } + archive.data[148] = 'x'; + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-checksum-field.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted an archive with an invalid tar checksum field\n"); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore invalid tar checksum field", "invalid tar checksum field"); +} + +static int verify_restore_rejects_bad_tar_magic(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build tar-format physical archive fixture\n"); + return 1; + } + archive.data[257] = archive.data[257] == 'u' ? 'x' : 'u'; + test_tar_rewrite_checksum(archive.data); + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-magic.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted an archive with an unsupported tar header format\n"); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore unsupported tar header format", "unsupported tar header format"); +} + +static int verify_restore_rejects_bad_tar_numeric_field(const char *pgdata, size_t field_offset, const char *label) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build invalid-%s physical archive fixture\n", label); + return 1; + } + archive.data[field_offset] = 'x'; + test_tar_rewrite_checksum(archive.data); + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-%s-field.%ld", pgdata, label, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted an archive with an invalid tar %s field\n", label); + return 1; + } + char expected[128]; + snprintf(expected, sizeof(expected), "invalid tar %s field", label); + return expect_error_contains(NULL, "oliphaunt_restore invalid tar numeric field", expected); +} + +static int verify_restore_rejects_bad_tar_string_field(const char *pgdata, size_t field_offset, const char *label) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build invalid-%s physical archive fixture\n", label); + return 1; + } + archive.data[field_offset] = 'x'; + test_tar_rewrite_checksum(archive.data); + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-%s-field.%ld", pgdata, label, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted an archive with an invalid tar %s field\n", label); + return 1; + } + char expected[128]; + snprintf(expected, sizeof(expected), "invalid tar %s field", label); + return expect_error_contains(NULL, "oliphaunt_restore invalid tar string field", expected); +} + +static int verify_restore_rejects_truncated_tar_terminator(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_append_zero_block(&archive) != 0) { + fprintf(stderr, "failed to build truncated-terminator physical archive fixture\n"); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-short-terminator.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted an archive with a truncated tar terminator\n"); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore truncated tar terminator", "final tar zero block"); +} + +static int verify_restore_rejects_trailing_tar_data(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_append_zero_block(&archive) != 0 || + test_tar_append_nonzero_block(&archive) != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build trailing-data physical archive fixture\n"); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-trailing.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted an archive with trailing data after the tar terminator\n"); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore trailing tar data", "trailing data after tar terminator"); +} + +static int verify_restore_rejects_duplicate_tar_entry(const char *pgdata, const char *duplicate_path) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_append_file(&archive, duplicate_path, "duplicate") != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build duplicate-entry physical archive fixture\n"); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-duplicate.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted a duplicate archive entry %s\n", duplicate_path); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore duplicate archive entry", "duplicate entry pgdata/PG_VERSION"); +} + +static int verify_restore_rejects_file_tree_collision(const char *pgdata, int parent_first) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + int rc = append_required_restore_entries(&archive); + if (rc == 0 && parent_first) { + rc = test_tar_append_file(&archive, "pgdata/base", "parent-file"); + if (rc == 0) { + rc = test_tar_append_file(&archive, "pgdata/base/child", "child-file"); + } + } else if (rc == 0) { + rc = test_tar_append_file(&archive, "pgdata/base/child", "child-file"); + if (rc == 0) { + rc = test_tar_append_file(&archive, "pgdata/base", "parent-file"); + } + } + if (rc != 0 || test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build file-tree-collision physical archive fixture\n"); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-file-tree.%ld.%d", pgdata, (long)getpid(), parent_first); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted a file/tree archive collision\n"); + return 1; + } + const char *expected = parent_first + ? "entry pgdata/base/child is nested under file entry pgdata/base" + : "file entry pgdata/base conflicts with existing child entries"; + return expect_error_contains(NULL, "oliphaunt_restore file/tree archive collision", expected); +} + +static int verify_restore_rejects_regular_tar_link_metadata(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (append_required_restore_entries(&archive) != 0 || + test_tar_append_header( + &archive, + "pgdata/base/regular-link-metadata", + '0', + 0, + "pgdata/PG_VERSION") != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build regular-file-link-metadata physical archive fixture\n"); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.reject-link-metadata.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted regular file link metadata\n"); + return 1; + } + return expect_error_contains(NULL, "oliphaunt_restore regular file link metadata", "unexpected link target"); +} + +static int verify_restore_accepts_canonicalized_tar_paths(const char *pgdata) { + TestTarArchive archive; + memset(&archive, 0, sizeof(archive)); + if (test_tar_append_file(&archive, "pgdata/PG_VERSION", "18\n") != 0 || + test_tar_append_file(&archive, "pgdata/./global/pg_control", "control") != 0 || + test_tar_append_file(&archive, "pgdata/backup_label", "label") != 0 || + test_tar_finish(&archive) != 0) { + fprintf(stderr, "failed to build canonicalized-path physical archive fixture\n"); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.restore-canonical.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&options) != 0) { + fprintf(stderr, "oliphaunt_restore rejected a canonicalizable archive path: %s\n", oliphaunt_last_error(NULL)); + return 1; + } + + char pg_control[4096]; + snprintf(pg_control, sizeof(pg_control), "%s/pgdata/global/pg_control", restore_root); + if (!file_exists(pg_control)) { + fprintf(stderr, "oliphaunt_restore did not materialize canonical pg_control path\n"); + return 1; + } + return 0; +} + +static int verify_backup_rejects_symlinked_pgdata_entry(OliphauntHandle *db, const char *pgdata) { +#ifdef _WIN32 + (void)db; + (void)pgdata; + fprintf(stderr, "skipping POSIX symlinked PGDATA backup rejection smoke on Windows\n"); + return 0; +#else + char link_path[4096]; + snprintf(link_path, sizeof(link_path), "%s/liboliphaunt-smoke-symlink-%ld", pgdata, (long)getpid()); + (void)unlink(link_path); + if (symlink("/tmp", link_path) != 0) { + perror("create symlinked PGDATA smoke entry"); + return 1; + } + OliphauntResponse archive = {0}; + int rc = oliphaunt_backup(db, OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, &archive); + (void)unlink(link_path); + if (rc == 0) { + fprintf(stderr, "oliphaunt_backup accepted symlinked PGDATA entry\n"); + oliphaunt_free_response(&archive); + return 1; + } + return expect_error_contains(db, "oliphaunt_backup symlinked PGDATA entry", "symlinked PGDATA entry"); +#endif +} + +static int verify_backup_restore_contract(OliphauntHandle *db, const char *pgdata) { + OliphauntResponse invalid = {0}; + if (oliphaunt_backup(NULL, OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, &invalid) == 0) { + fprintf(stderr, "oliphaunt_backup accepted a null handle\n"); + oliphaunt_free_response(&invalid); + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_backup null handle", "invalid oliphaunt_backup arguments") != 0) { + return 1; + } + if (oliphaunt_backup(db, OLIPHAUNT_BACKUP_FORMAT_SQL, &invalid) == 0) { + fprintf(stderr, "oliphaunt_backup accepted SQL format in direct mode\n"); + oliphaunt_free_response(&invalid); + return 1; + } + if (expect_error_contains(db, "oliphaunt_backup sql format", "physicalArchive") != 0) { + return 1; + } + if (verify_restore_rejects_special_archive_entry(pgdata, '2') != 0 || + verify_restore_rejects_special_archive_entry(pgdata, '6') != 0 || + verify_restore_rejects_directory_entry_with_payload(pgdata) != 0 || + verify_restore_rejects_bad_tar_checksum(pgdata) != 0 || + verify_restore_rejects_bad_tar_checksum_field(pgdata) != 0 || + verify_restore_rejects_bad_tar_magic(pgdata) != 0 || + verify_restore_rejects_bad_tar_numeric_field(pgdata, 124, "size") != 0 || + verify_restore_rejects_bad_tar_numeric_field(pgdata, 100, "mode") != 0 || + verify_restore_rejects_bad_tar_numeric_field(pgdata, 108, "uid") != 0 || + verify_restore_rejects_bad_tar_numeric_field(pgdata, 116, "gid") != 0 || + verify_restore_rejects_bad_tar_numeric_field(pgdata, 136, "mtime") != 0 || + verify_restore_rejects_bad_tar_string_field(pgdata, strlen("pgdata/PG_VERSION") + 1, "name") != 0 || + verify_restore_rejects_bad_tar_string_field(pgdata, 158, "linkname") != 0 || + verify_restore_rejects_bad_tar_string_field(pgdata, 346, "prefix") != 0 || + verify_restore_rejects_truncated_tar_terminator(pgdata) != 0 || + verify_restore_rejects_trailing_tar_data(pgdata) != 0 || + verify_restore_rejects_duplicate_tar_entry(pgdata, "pgdata/PG_VERSION") != 0 || + verify_restore_rejects_duplicate_tar_entry(pgdata, "pgdata/./PG_VERSION") != 0 || + verify_restore_rejects_file_tree_collision(pgdata, 1) != 0 || + verify_restore_rejects_file_tree_collision(pgdata, 0) != 0 || + verify_restore_rejects_regular_tar_link_metadata(pgdata) != 0 || + verify_restore_accepts_canonicalized_tar_paths(pgdata) != 0 || + verify_backup_rejects_symlinked_pgdata_entry(db, pgdata) != 0) { + return 1; + } + + if (exec_query_expect_tags( + db, + "CREATE TABLE IF NOT EXISTS liboliphaunt_backup_smoke(value integer); " + "TRUNCATE liboliphaunt_backup_smoke; " + "INSERT INTO liboliphaunt_backup_smoke VALUES (42)", + (const unsigned char[]){'C', 'Z'}, + 2) != 0) { + return 1; + } + if (exec_query_expect_tags( + db, + "SELECT value FROM liboliphaunt_backup_smoke", + (const unsigned char[]){'T', 'D', 'C', 'Z'}, + 4) != 0) { + return 1; + } + + OliphauntResponse archive = {0}; + fprintf(stderr, "creating physical backup through C ABI\n"); + if (oliphaunt_backup(db, OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, &archive) != 0) { + fprintf(stderr, "oliphaunt_backup failed: %s\n", oliphaunt_last_error(db)); + return 1; + } + if (archive.data == NULL || archive.len < 1024 || !contains_bytes(&archive, "backup_label")) { + fprintf(stderr, "physical backup archive did not contain expected tar payload\n"); + oliphaunt_free_response(&archive); + return 1; + } + if (contains_bytes(&archive, ".oliphaunt.lock")) { + fprintf(stderr, "physical backup archive included the native root lock marker\n"); + oliphaunt_free_response(&archive); + return 1; + } + + char restore_root[4096]; + snprintf(restore_root, sizeof(restore_root), "%s.restore.%ld", pgdata, (long)getpid()); + OliphauntRestoreOptions invalid_options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = 1ull << 63, + }; + if (oliphaunt_restore(&invalid_options) == 0) { + fprintf(stderr, "oliphaunt_restore accepted invalid flags\n"); + oliphaunt_free_response(&archive); + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_restore invalid flags", "invalid oliphaunt_restore flags") != 0) { + oliphaunt_free_response(&archive); + return 1; + } + + char live_root[4096]; + if (parent_path(pgdata, live_root, sizeof(live_root)) != 0) { + fprintf(stderr, "failed to resolve live native root from PGDATA\n"); + oliphaunt_free_response(&archive); + return 1; + } + OliphauntRestoreOptions live_root_options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = live_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + if (oliphaunt_restore(&live_root_options) == 0) { + fprintf(stderr, "oliphaunt_restore replaced a live locked native root\n"); + oliphaunt_free_response(&archive); + return 1; + } + if (expect_error_contains(NULL, "oliphaunt_restore live locked root", "already locked") != 0) { + oliphaunt_free_response(&archive); + return 1; + } + + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = restore_root, + .format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + .data = archive.data, + .len = archive.len, + .flags = OLIPHAUNT_RESTORE_REPLACE_EXISTING, + }; + fprintf(stderr, "restoring physical backup through C ABI: %s\n", restore_root); + if (oliphaunt_restore(&options) != 0) { + fprintf(stderr, "oliphaunt_restore failed: %s\n", oliphaunt_last_error(NULL)); + oliphaunt_free_response(&archive); + return 1; + } + + char pg_version[4096]; + char backup_label[4096]; + snprintf(pg_version, sizeof(pg_version), "%s/pgdata/PG_VERSION", restore_root); + snprintf(backup_label, sizeof(backup_label), "%s/pgdata/backup_label", restore_root); + if (!file_exists(pg_version) || !file_exists(backup_label)) { + fprintf(stderr, "restored physical archive is missing required files\n"); + oliphaunt_free_response(&archive); + return 1; + } + oliphaunt_free_response(&archive); + return 0; +} + +static int run_cycle(const char *pgdata, const char *runtime_dir) { + static const char *const startup_args[] = { + "-c", + "application_name=liboliphaunt_smoke", + }; + OliphauntConfig config = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .pgdata = pgdata, + .runtime_dir = runtime_dir, + .username = "postgres", + .database = "postgres", + .reserved_flags = 0, + .startup_args = startup_args, + .startup_arg_count = sizeof(startup_args) / sizeof(startup_args[0]), + }; + OliphauntHandle *db = NULL; + fprintf(stderr, "opening pgdata: %s\n", pgdata); + int rc = oliphaunt_init(&config, &db); + if (rc != 0 || db == NULL) { + fprintf(stderr, "oliphaunt_init failed: %s\n", oliphaunt_last_error(db)); + return 1; + } + if (expect_pgdata_env("oliphaunt_init active backend", pgdata) != 0) { + oliphaunt_close(db); + return 1; + } + + const unsigned char select_tags[] = {'T', 'D', 'C', 'Z'}; + if (verify_root_lock_marker(pgdata) != 0 || verify_stable_root_lock_file(pgdata) != 0) { + oliphaunt_close(db); + return 1; + } + if (exec_query_expect_tags(db, "SELECT 1 AS value", select_tags, sizeof(select_tags)) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_simple_query_expect_bytes(db, "SELECT 11 AS simple_query_value", "11") != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_query_expect_bytes(db, "SELECT current_setting('application_name')", "liboliphaunt_smoke") != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_invalid_argument_checks(db) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_malformed_frame_checks(db) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_query_expect_tags(db, "SELECT 3 AS recovered_after_invalid_args", select_tags, sizeof(select_tags)) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_query_ignores_legacy_wait_timeout_env(db) != 0) { + oliphaunt_close(db); + return 1; + } + + const unsigned char error_tags[] = {'E', 'Z'}; + if (exec_query_expect_tags(db, "SELECT * FROM liboliphaunt_missing_table", error_tags, sizeof(error_tags)) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_query_expect_tags(db, "SELECT 2 AS recovered", select_tags, sizeof(select_tags)) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_query_expect_tags(db, "SELECT repeat('y', 131072) AS large_owned_response", select_tags, sizeof(select_tags)) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_stream_expect_tags(db, "SELECT repeat('x', 65536) AS streamed", select_tags, sizeof(select_tags)) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_stream_callback_failure_recovers(db) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_cancel_recovers(db) != 0) { + oliphaunt_close(db); + return 1; + } + + if (exec_static_extension_registry_smoke(db) != 0) { + oliphaunt_close(db); + return 1; + } + + if (verify_backup_restore_contract(db, pgdata) != 0) { + oliphaunt_close(db); + return 1; + } + + OliphauntHandle *duplicate = NULL; + if (oliphaunt_init(&config, &duplicate) == 0 || duplicate != NULL) { + fprintf(stderr, "oliphaunt_init unexpectedly allowed two active logical direct handles\n"); + if (duplicate != NULL) { + oliphaunt_close(duplicate); + } else { + oliphaunt_close(db); + } + return 1; + } + if (expect_error_contains(NULL, "duplicate oliphaunt_init", "active logical direct handle") != 0) { + oliphaunt_close(db); + return 1; + } + + fprintf(stderr, "detaching logical database handle\n"); + rc = oliphaunt_detach(db); + if (rc != 0) { + fprintf(stderr, "oliphaunt_detach failed: %s\n", oliphaunt_last_error(db)); + oliphaunt_close(db); + return 1; + } + OliphauntResponse detached_response = {0}; + if (oliphaunt_exec_simple_query(db, "SELECT 1", strlen("SELECT 1"), &detached_response) == 0) { + fprintf(stderr, "detached logical handle unexpectedly accepted a query\n"); + oliphaunt_free_response(&detached_response); + oliphaunt_close(db); + return 1; + } + oliphaunt_free_response(&detached_response); + if (expect_error_contains(db, "detached logical query", "logical handle is closed") != 0) { + oliphaunt_close(db); + return 1; + } + if (expect_pgdata_env("oliphaunt_detach resident backend", pgdata) != 0) { + oliphaunt_close(db); + return 1; + } + + OliphauntConfig mismatched_config = config; + mismatched_config.database = "template1"; + if (oliphaunt_init(&mismatched_config, &duplicate) == 0 || duplicate != NULL) { + fprintf(stderr, "oliphaunt_init unexpectedly attached to a different resident database identity\n"); + oliphaunt_close(duplicate != NULL ? duplicate : db); + return 1; + } + if (expect_error_contains(NULL, "mismatched resident oliphaunt_init", "different root") != 0) { + oliphaunt_close(db); + return 1; + } + + fprintf(stderr, "reattaching logical database handle\n"); + OliphauntHandle *reopened = NULL; + rc = oliphaunt_init(&config, &reopened); + if (rc != 0 || reopened == NULL) { + fprintf(stderr, "same-process logical reopen failed: %s\n", oliphaunt_last_error(NULL)); + oliphaunt_close(db); + return 1; + } + if (exec_query_expect_tags(reopened, "SELECT 42 AS reopened", select_tags, sizeof(select_tags)) != 0) { + oliphaunt_close(reopened); + return 1; + } + + fprintf(stderr, "terminal shutdown of resident database runtime\n"); + rc = oliphaunt_close(reopened); + if (rc != 0) { + fprintf(stderr, "oliphaunt_close failed\n"); + return 1; + } + return 0; +} + +static int expect_terminal_shutdown_reopen_rejected(const char *pgdata, const char *runtime_dir) { + OliphauntConfig config = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .pgdata = pgdata, + .runtime_dir = runtime_dir, + .username = "postgres", + .database = "postgres", + .reserved_flags = 0, + }; + OliphauntHandle *db = NULL; + fprintf(stderr, "verifying terminal direct shutdown remains process-final\n"); + int rc = oliphaunt_init(&config, &db); + if (rc == 0 || db != NULL) { + fprintf(stderr, "terminal shutdown reopen unexpectedly succeeded\n"); + if (db != NULL) { + oliphaunt_close(db); + } + return 1; + } + return expect_error_contains(NULL, "terminal shutdown reopen", "process lifetime has already been used"); +} + +int main(int argc, char **argv) { + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + fprintf(stderr, "liboliphaunt version: %s\n", oliphaunt_version()); + fprintf(stderr, "liboliphaunt capabilities: 0x%llx\n", (unsigned long long)oliphaunt_capabilities()); + if (verify_global_contract() != 0 || + verify_free_response_contract() != 0 || + verify_static_extension_registry_rejects_invalid_entries() != 0) { + return 1; + } + if (register_static_extension_fixture() != 0) { + return 1; + } + + const char *host_pgdata = "/tmp/oliphaunt-host-pgdata-sentinel"; + if (set_pgdata_env_for_smoke(host_pgdata) != 0) { + return 1; + } + if (run_cycle(argv[1], argv[2]) != 0) { + return 1; + } + if (expect_pgdata_env("oliphaunt_close", host_pgdata) != 0) { + return 1; + } + if (expect_terminal_shutdown_reopen_rejected(argv[1], argv[2]) != 0) { + return 1; + } + if (expect_pgdata_env("rejected oliphaunt_init", host_pgdata) != 0) { + return 1; + } + + fprintf(stderr, "native liboliphaunt smoke passed\n"); + return 0; +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c new file mode 100644 index 00000000..cfde4d01 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive.c @@ -0,0 +1,720 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "../include/oliphaunt.h" +#include "liboliphaunt_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#include +#include +#include +#endif +#include + +static uint32_t read_be32(const unsigned char *ptr) { + return ((uint32_t)ptr[0] << 24) | + ((uint32_t)ptr[1] << 16) | + ((uint32_t)ptr[2] << 8) | + (uint32_t)ptr[3]; +} + +typedef struct OliphauntBackupStopFiles { + char *backup_label; + char *tablespace_map; +} OliphauntBackupStopFiles; + +static int build_simple_query(OliphauntHandle *handle, const char *sql, uint8_t **out, size_t *out_len) { + size_t sql_len = strlen(sql); + if (sql_len > UINT32_MAX - 5) { + set_error(handle, "SQL query is too large for PostgreSQL simple-query protocol"); + return -1; + } + size_t len = 1 + 4 + sql_len + 1; + uint32_t msg_len = (uint32_t)(4 + sql_len + 1); + uint8_t *bytes = (uint8_t *)malloc(len); + if (bytes == NULL) { + set_error(handle, "out of memory building simple-query protocol message"); + return -1; + } + bytes[0] = 'Q'; + bytes[1] = (uint8_t)(msg_len >> 24); + bytes[2] = (uint8_t)(msg_len >> 16); + bytes[3] = (uint8_t)(msg_len >> 8); + bytes[4] = (uint8_t)msg_len; + memcpy(bytes + 5, sql, sql_len); + bytes[5 + sql_len] = 0; + *out = bytes; + *out_len = len; + return 0; +} + +static int exec_simple_query(OliphauntHandle *handle, const char *sql, OliphauntResponse *response) { + uint8_t *request = NULL; + size_t request_len = 0; + if (build_simple_query(handle, sql, &request, &request_len) != 0) { + return -1; + } + int32_t rc = oliphaunt_exec_protocol(handle, request, request_len, response); + free(request); + return rc; +} + +static void postgres_error_message(const uint8_t *body, size_t len, char *out, size_t out_len) { + const char *severity = NULL; + size_t severity_len = 0; + const char *message = NULL; + size_t message_len = 0; + size_t off = 0; + while (off < len && body[off] != 0) { + uint8_t field = body[off++]; + size_t start = off; + while (off < len && body[off] != 0) { + off++; + } + if (off >= len) { + break; + } + if ((field == 'S' || field == 'V') && severity == NULL) { + severity = (const char *)body + start; + severity_len = off - start; + } else if (field == 'M') { + message = (const char *)body + start; + message_len = off - start; + } + off++; + } + if (severity != NULL && message != NULL) { + snprintf(out, out_len, "%.*s: %.*s", (int)severity_len, severity, (int)message_len, message); + } else if (message != NULL) { + snprintf(out, out_len, "%.*s", (int)message_len, message); + } else { + snprintf(out, out_len, "PostgreSQL ErrorResponse"); + } +} + +static int scan_response_for_error(OliphauntHandle *handle, const OliphauntResponse *response, const char *context) { + size_t off = 0; + while (off < response->len) { + if (response->len - off < 5) { + set_error(handle, "truncated PostgreSQL backend message header"); + return -1; + } + uint8_t tag = response->data[off]; + uint32_t len = read_be32(response->data + off + 1); + if (len < 4 || (size_t)len + 1 > response->len - off) { + set_error(handle, "truncated PostgreSQL backend message body"); + return -1; + } + const uint8_t *body = response->data + off + 5; + size_t body_len = (size_t)len - 4; + if (tag == 'E') { + char pg_error[512]; + char message[1024]; + postgres_error_message(body, body_len, pg_error, sizeof(pg_error)); + snprintf(message, sizeof(message), "%s failed: %s", context, pg_error); + set_error(handle, message); + return -1; + } + off += (size_t)len + 1; + } + return 0; +} + +static int ensure_simple_query_ok(OliphauntHandle *handle, const char *sql, const char *context) { + OliphauntResponse response = {NULL, 0}; + if (exec_simple_query(handle, sql, &response) != 0) { + return -1; + } + int rc = scan_response_for_error(handle, &response, context); + oliphaunt_free_response(&response); + return rc; +} + +static int parse_stop_backup_response(OliphauntHandle *handle, const OliphauntResponse *response, OliphauntBackupStopFiles *out) { + memset(out, 0, sizeof(*out)); + size_t off = 0; + while (off < response->len) { + if (response->len - off < 5) { + set_error(handle, "truncated PostgreSQL backend message header"); + return -1; + } + uint8_t tag = response->data[off]; + uint32_t len = read_be32(response->data + off + 1); + if (len < 4 || (size_t)len + 1 > response->len - off) { + set_error(handle, "truncated PostgreSQL backend message body"); + return -1; + } + const uint8_t *body = response->data + off + 5; + size_t body_len = (size_t)len - 4; + if (tag == 'E') { + char pg_error[512]; + char message[1024]; + postgres_error_message(body, body_len, pg_error, sizeof(pg_error)); + snprintf(message, sizeof(message), "stop physical backup failed: %s", pg_error); + set_error(handle, message); + return -1; + } + if (tag == 'D') { + if (body_len < 2) { + set_error(handle, "truncated PostgreSQL DataRow column count"); + return -1; + } + uint16_t columns = ((uint16_t)body[0] << 8) | (uint16_t)body[1]; + const uint8_t *p = body + 2; + size_t remaining = body_len - 2; + if (columns != 2) { + set_error(handle, "pg_backup_stop returned an unexpected column count"); + return -1; + } + char *values[2] = {NULL, NULL}; + for (uint16_t column = 0; column < columns; column++) { + if (remaining < 4) { + set_error(handle, "truncated PostgreSQL DataRow column length"); + goto fail; + } + uint32_t raw_value_len = ((uint32_t)p[0] << 24) | + ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | + (uint32_t)p[3]; + int32_t value_len = (int32_t)raw_value_len; + p += 4; + remaining -= 4; + if (value_len == -1) { + continue; + } + if (value_len < 0 || (size_t)value_len > remaining) { + set_error(handle, "truncated PostgreSQL DataRow column value"); + goto fail; + } + values[column] = (char *)malloc((size_t)value_len + 1); + if (values[column] == NULL) { + set_error(handle, "out of memory copying pg_backup_stop result"); + goto fail; + } + memcpy(values[column], p, (size_t)value_len); + values[column][value_len] = '\0'; + p += value_len; + remaining -= (size_t)value_len; + } + if (values[0] == NULL || values[0][0] == '\0') { + set_error(handle, "pg_backup_stop returned an empty backup_label"); + goto fail; + } + out->backup_label = values[0]; + out->tablespace_map = values[1]; + return 0; +fail: + free(values[0]); + free(values[1]); + return -1; + } + off += (size_t)len + 1; + } + set_error(handle, "pg_backup_stop returned no DataRow"); + return -1; +} + +static int stop_physical_backup(OliphauntHandle *handle, OliphauntBackupStopFiles *out) { + OliphauntResponse response = {NULL, 0}; + int rc = exec_simple_query( + handle, + "SELECT labelfile, spcmapfile FROM pg_backup_stop(wait_for_archive => false)", + &response); + if (rc == 0) { + rc = parse_stop_backup_response(handle, &response, out); + } + oliphaunt_free_response(&response); + return rc; +} + +static void free_backup_stop_files(OliphauntBackupStopFiles *files) { + free(files->backup_label); + free(files->tablespace_map); + files->backup_label = NULL; + files->tablespace_map = NULL; +} + +static bool path_has_component(const char *path, const char *component) { + size_t component_len = strlen(component); + const char *p = path; + while (*p != '\0') { + while (*p == '/' +#ifdef _WIN32 + || *p == '\\' +#endif + ) { + p++; + } + const char *start = p; + while (*p != '\0' && *p != '/' +#ifdef _WIN32 + && *p != '\\' +#endif + ) { + p++; + } + if ((size_t)(p - start) == component_len && strncmp(start, component, component_len) == 0) { + return true; + } + } + return false; +} + +static bool is_external_generated_archive_path(const char *path) { + return strcmp(path, "manifest.properties") == 0 || strncmp(path, ".oliphaunt/", 11) == 0; +} + +static int validate_generated_backup_files(OliphauntHandle *handle, const OliphauntArchiveFile *files, size_t count) { + if (count == 0) { + return 0; + } + if (files == NULL) { + set_error(handle, "backup options provided generated file count without generated files"); + return -1; + } + for (size_t i = 0; i < count; i++) { + const OliphauntArchiveFile *file = &files[i]; + if (file->reserved_flags != 0) { + set_error(handle, "generated backup file reserved_flags must be zero"); + return -1; + } + if (file->path == NULL || file->path[0] == '\0' || file->path[0] == '/' || + path_has_component(file->path, "..") || !is_external_generated_archive_path(file->path)) { + set_error(handle, "generated backup files must target manifest.properties or .oliphaunt/ metadata paths"); + return -1; + } + if (file->len > 0 && file->data == NULL) { + set_error(handle, "generated backup file has bytes but no data pointer"); + return -1; + } + if (file->mode != 0 && (file->mode & ~0777u) != 0) { + set_error(handle, "generated backup file mode must contain only permission bits"); + return -1; + } + for (size_t j = 0; j < i; j++) { + if (strcmp(files[j].path, file->path) == 0) { + set_error(handle, "generated backup file paths must be unique"); + return -1; + } + } + } + return 0; +} + +static int append_generated_backup_files( + OliphauntByteBuffer *archive, + OliphauntHandle *handle, + const OliphauntArchiveFile *files, + size_t count) { + for (size_t i = 0; i < count; i++) { + if (oliphaunt_archive_append_generated_bytes( + archive, + handle, + files[i].path, + files[i].data, + files[i].len, + files[i].mode == 0 ? 0600u : files[i].mode) != 0) { + return -1; + } + } + return 0; +} + +static bool backup_trace_enabled(void) { + const char *value = getenv("OLIPHAUNT_TRACE_BACKUP"); + return value != NULL && value[0] != '\0' && strcmp(value, "0") != 0 && strcmp(value, "false") != 0; +} + +static void print_backup_trace_phase( + bool trace, + const char *phase, + uint64_t started_ns, + const OliphauntByteBuffer *archive) { + if (!trace) { + return; + } + fprintf( + stderr, + "oliphaunt_backup_trace phase=%s elapsed_us=%llu archive_bytes=%llu\n", + phase, + (unsigned long long)(oliphaunt_elapsed_ns(started_ns) / 1000), + (unsigned long long)(archive == NULL ? 0 : archive->len)); +} + +static int32_t oliphaunt_backup_impl( + OliphauntHandle *handle, + uint32_t format, + const OliphauntArchiveFile *generated_files, + size_t generated_file_count, + OliphauntResponse *out) { + if (handle == NULL || out == NULL) { + set_error(handle, "invalid oliphaunt_backup arguments"); + return -1; + } + out->data = NULL; + out->len = 0; + if (format != OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE) { + set_error(handle, "native direct backup currently supports only physicalArchive format"); + return -1; + } + if (validate_generated_backup_files(handle, generated_files, generated_file_count) != 0) { + return -1; + } + + bool trace = backup_trace_enabled(); + uint64_t total_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + uint64_t phase_started_ns = total_started_ns; + if (ensure_simple_query_ok( + handle, + "SELECT pg_backup_start(label => 'liboliphaunt physical archive', fast => true)", + "start physical backup") != 0) { + return -1; + } + print_backup_trace_phase(trace, "pg_backup_start", phase_started_ns, NULL); + + OliphauntByteBuffer archive = {0}; + OliphauntBackupStopFiles stop_files = {0}; + phase_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + int rc = oliphaunt_archive_append_pgdata_tree(&archive, handle, oliphaunt_handle_pgdata(handle)); + print_backup_trace_phase(trace, "append_pgdata", phase_started_ns, &archive); + if (rc == 0) { + phase_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + rc = stop_physical_backup(handle, &stop_files); + print_backup_trace_phase(trace, "pg_backup_stop", phase_started_ns, &archive); + } else { + OliphauntBackupStopFiles ignored = {0}; + (void)stop_physical_backup(handle, &ignored); + free_backup_stop_files(&ignored); + } + if (rc == 0) { + phase_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + rc = oliphaunt_archive_append_pg_wal_tree(&archive, handle, oliphaunt_handle_pgdata(handle)); + print_backup_trace_phase(trace, "append_pg_wal", phase_started_ns, &archive); + } + if (rc == 0) { + phase_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + rc = oliphaunt_archive_append_generated_file(&archive, handle, "pgdata/backup_label", stop_files.backup_label); + print_backup_trace_phase(trace, "append_backup_label", phase_started_ns, &archive); + } + if (rc == 0 && stop_files.tablespace_map != NULL && stop_files.tablespace_map[0] != '\0') { + phase_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + rc = oliphaunt_archive_append_generated_file(&archive, handle, "pgdata/tablespace_map", stop_files.tablespace_map); + print_backup_trace_phase(trace, "append_tablespace_map", phase_started_ns, &archive); + } + if (rc == 0) { + phase_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + rc = append_generated_backup_files(&archive, handle, generated_files, generated_file_count); + print_backup_trace_phase(trace, "append_generated_files", phase_started_ns, &archive); + } + if (rc == 0) { + phase_started_ns = trace ? oliphaunt_monotonic_ns() : 0; + rc = oliphaunt_archive_finish(&archive, handle); + print_backup_trace_phase(trace, "finish", phase_started_ns, &archive); + } + free_backup_stop_files(&stop_files); + if (rc != 0) { + free(archive.data); + return -1; + } + out->data = archive.data; + out->len = archive.len; + print_backup_trace_phase(trace, "total", total_started_ns, &archive); + return 0; +} + +int32_t oliphaunt_backup(OliphauntHandle *handle, uint32_t format, OliphauntResponse *out) { + return oliphaunt_backup_impl(handle, format, NULL, 0, out); +} + +int32_t oliphaunt_backup_ex( + OliphauntHandle *handle, + const OliphauntBackupOptions *options, + OliphauntResponse *out) { + if (options == NULL || options->abi_version != OLIPHAUNT_ABI_VERSION || options->reserved_flags != 0) { + set_error(handle, "invalid oliphaunt_backup_ex options"); + return -1; + } + return oliphaunt_backup_impl( + handle, + options->format, + options->generated_files, + options->generated_file_count, + out); +} + +static int validate_restored_pgdata(OliphauntHandle *handle, const char *staging_root) { + const char *required[] = { + "pgdata/PG_VERSION", + "pgdata/global/pg_control", + "pgdata/backup_label", + }; + for (size_t i = 0; i < sizeof(required) / sizeof(required[0]); i++) { + char *path = oliphaunt_join_path(staging_root, required[i]); + if (path == NULL) { + set_error(handle, "out of memory validating restored PGDATA"); + return -1; + } + struct stat st; + int ok = stat(path, &st) == 0 && S_ISREG(st.st_mode); + free(path); + if (!ok) { + char message[1024]; + snprintf(message, sizeof(message), "physical archive is missing required file %s", required[i]); + set_error(handle, message); + return -1; + } + } + return 0; +} + +static char *unique_sibling_path_c(const char *target_root, const char *suffix) { + char *parent = oliphaunt_path_parent_dup(target_root); + char *name = oliphaunt_path_file_name_dup(target_root); + if (parent == NULL || name == NULL) { + free(parent); + free(name); + return NULL; + } + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + for (int attempt = 0; attempt < 100; attempt++) { + char leaf[512]; + snprintf( + leaf, + sizeof(leaf), + ".%s-%s-%ld-%lld-%d", + name, + suffix, + (long)getpid(), + (long long)ts.tv_nsec, + attempt); + char *candidate = oliphaunt_join_path(parent, leaf); + if (candidate == NULL) { + free(parent); + free(name); + return NULL; + } + if (!oliphaunt_path_exists(candidate)) { + free(parent); + free(name); + return candidate; + } + free(candidate); + } + free(parent); + free(name); + return NULL; +} + +static int publish_restore_without_replacement(OliphauntHandle *handle, const char *staging_root, const char *target_root) { + struct stat st; + if (lstat(target_root, &st) == 0) { + if (!S_ISDIR(st.st_mode)) { + char message[1024]; + snprintf(message, sizeof(message), "refusing to restore over non-directory target %s", target_root); + set_error(handle, message); + return -1; + } + int empty = oliphaunt_directory_is_empty(target_root); + if (empty < 0) { + char message[1024]; + snprintf(message, sizeof(message), "read restore target %s: %s", target_root, strerror(errno)); + set_error(handle, message); + return -1; + } + if (!empty) { + char message[1024]; + snprintf(message, sizeof(message), "refusing to restore into non-empty target %s; use replaceExisting to replace it", target_root); + set_error(handle, message); + return -1; + } + if (rmdir(target_root) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "remove empty restore target %s: %s", target_root, strerror(errno)); + set_error(handle, message); + return -1; + } + } else if (errno != ENOENT) { + char message[1024]; + snprintf(message, sizeof(message), "stat restore target %s: %s", target_root, strerror(errno)); + set_error(handle, message); + return -1; + } + if (rename(staging_root, target_root) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "publish restored root %s: %s", target_root, strerror(errno)); + set_error(handle, message); + return -1; + } + return 0; +} + +static int acquire_restore_lock(OliphauntHandle *handle, const char *target_root) { + char *lock_path = oliphaunt_join_path(target_root, ".oliphaunt.lock"); + if (lock_path == NULL) { + set_error(handle, "out of memory resolving restore lock path"); + return -1; + } + int fd = open(lock_path, O_RDWR | O_CREAT, 0600); + if (fd < 0) { + char message[1024]; + snprintf(message, sizeof(message), "open restore lock %s: %s", lock_path, strerror(errno)); + set_error(handle, message); + free(lock_path); + return -1; + } + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "restore target %s is already locked: %s", target_root, strerror(errno)); + set_error(handle, message); + close(fd); + free(lock_path); + return -1; + } + free(lock_path); + return fd; +} + +static int publish_restore_with_replacement(OliphauntHandle *handle, const char *staging_root, const char *target_root) { + struct stat st; + if (lstat(target_root, &st) != 0) { + if (errno == ENOENT) { + return publish_restore_without_replacement(handle, staging_root, target_root); + } + char message[1024]; + snprintf(message, sizeof(message), "stat restore target %s: %s", target_root, strerror(errno)); + set_error(handle, message); + return -1; + } + if (!S_ISDIR(st.st_mode)) { + char message[1024]; + snprintf(message, sizeof(message), "refusing to replace non-directory restore target %s", target_root); + set_error(handle, message); + return -1; + } + int lock_fd = acquire_restore_lock(handle, target_root); + if (lock_fd < 0) { + return -1; + } + char *displaced = unique_sibling_path_c(target_root, "restore-replaced"); + if (displaced == NULL) { + close(lock_fd); + set_error(handle, "out of memory resolving displaced restore target path"); + return -1; + } + if (rename(target_root, displaced) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "move existing root aside for restore: %s", strerror(errno)); + set_error(handle, message); + free(displaced); + close(lock_fd); + return -1; + } + if (rename(staging_root, target_root) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "publish restored root %s: %s", target_root, strerror(errno)); + set_error(handle, message); + (void)rename(displaced, target_root); + free(displaced); + close(lock_fd); + return -1; + } + close(lock_fd); + int rc = oliphaunt_remove_tree(displaced); + free(displaced); + if (rc != 0) { + char message[1024]; + snprintf(message, sizeof(message), "remove replaced restore target: %s", strerror(errno)); + set_error(handle, message); + return -1; + } + return 0; +} + +int32_t oliphaunt_restore(const OliphauntRestoreOptions *options) { + if (options == NULL || + options->abi_version != OLIPHAUNT_ABI_VERSION || + options->root == NULL || + options->root[0] == '\0' || + options->data == NULL || + options->len == 0) { + set_error(NULL, "invalid oliphaunt_restore options"); + return -1; + } + if (strcmp(options->root, "/") == 0) { + set_error(NULL, "refusing to restore over filesystem root"); + return -1; + } + if (options->format != OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE) { + set_error(NULL, "restore currently supports only physicalArchive format"); + return -1; + } + if ((options->flags & ~OLIPHAUNT_RESTORE_REPLACE_EXISTING) != 0) { + set_error(NULL, "invalid oliphaunt_restore flags"); + return -1; + } + + int stable_lock_fd = -1; + char *stable_lock_path = NULL; + if (oliphaunt_acquire_stable_root_lock(NULL, options->root, &stable_lock_fd, &stable_lock_path) != 0) { + return -1; + } + + char *parent = oliphaunt_path_parent_dup(options->root); + if (parent == NULL) { + set_error(NULL, "out of memory resolving restore parent"); + oliphaunt_release_file_lock(&stable_lock_fd, &stable_lock_path); + return -1; + } + if (oliphaunt_mkdir_p(parent, 0700) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "create restore parent directory %s: %s", parent, strerror(errno)); + set_error(NULL, message); + free(parent); + oliphaunt_release_file_lock(&stable_lock_fd, &stable_lock_path); + return -1; + } + free(parent); + + char *staging_root = unique_sibling_path_c(options->root, "restore-staging"); + if (staging_root == NULL) { + set_error(NULL, "out of memory resolving restore staging path"); + oliphaunt_release_file_lock(&stable_lock_fd, &stable_lock_path); + return -1; + } + if (mkdir(staging_root, 0700) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "create restore staging directory %s: %s", staging_root, strerror(errno)); + set_error(NULL, message); + free(staging_root); + oliphaunt_release_file_lock(&stable_lock_fd, &stable_lock_path); + return -1; + } + + int rc = oliphaunt_unpack_physical_archive(NULL, options->data, options->len, staging_root); + if (rc == 0) { + rc = validate_restored_pgdata(NULL, staging_root); + } + if (rc == 0) { + if ((options->flags & OLIPHAUNT_RESTORE_REPLACE_EXISTING) != 0) { + rc = publish_restore_with_replacement(NULL, staging_root, options->root); + } else { + rc = publish_restore_without_replacement(NULL, staging_root, options->root); + } + } + if (rc != 0) { + (void)oliphaunt_remove_tree(staging_root); + } + oliphaunt_release_file_lock(&stable_lock_fd, &stable_lock_path); + free(staging_root); + return rc; +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c new file mode 100644 index 00000000..2bcc9213 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_archive_tar.c @@ -0,0 +1,1243 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "liboliphaunt_internal.h" + +#ifndef _WIN32 +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#include +#endif +#include + +#ifndef O_BINARY +#define O_BINARY 0 +#endif + +static int buffer_reserve(OliphauntByteBuffer *buffer, size_t additional) { + if (additional > SIZE_MAX - buffer->len) { + return -1; + } + size_t required = buffer->len + additional; + if (required <= buffer->cap) { + return 0; + } + size_t next = buffer->cap == 0 ? 4096 : buffer->cap; + while (next < required) { + if (next > SIZE_MAX / 2) { + next = required; + break; + } + next *= 2; + } + uint8_t *data = (uint8_t *)realloc(buffer->data, next); + if (data == NULL) { + return -1; + } + buffer->data = data; + buffer->cap = next; + return 0; +} + +static int buffer_append(OliphauntByteBuffer *buffer, const void *data, size_t len) { + if (len == 0) { + return 0; + } + if (buffer_reserve(buffer, len) != 0) { + return -1; + } + memcpy(buffer->data + buffer->len, data, len); + buffer->len += len; + return 0; +} + +static int buffer_append_zeros(OliphauntByteBuffer *buffer, size_t len) { + static const uint8_t zeros[512] = {0}; + while (len > 0) { + size_t take = len < sizeof(zeros) ? len : sizeof(zeros); + if (buffer_append(buffer, zeros, take) != 0) { + return -1; + } + len -= take; + } + return 0; +} + +static int reserve_tar_entry(OliphauntByteBuffer *archive, OliphauntHandle *handle, size_t payload_size) { + size_t padding = (512 - (payload_size % 512)) % 512; + if (payload_size > SIZE_MAX - 512 || payload_size + 512 > SIZE_MAX - padding) { + set_error(handle, "physical backup tar entry size overflows"); + return -1; + } + if (buffer_reserve(archive, 512 + payload_size + padding) != 0) { + set_error(handle, "out of memory reserving physical backup tar entry"); + return -1; + } + return 0; +} + +static int compare_string_ptrs(const void *left, const void *right) { + const char *const *a = (const char *const *)left; + const char *const *b = (const char *const *)right; + return strcmp(*a, *b); +} + +static bool is_platform_separator(char value) { + return value == '/' +#ifdef _WIN32 + || value == '\\' +#endif + ; +} + +static int sorted_dir_names(OliphauntHandle *handle, const char *path, char ***out, size_t *out_count) { + *out = NULL; + *out_count = 0; +#ifdef _WIN32 + char *pattern = oliphaunt_join_path(path, "*"); + if (pattern == NULL) { + set_error(handle, "out of memory preparing backup directory scan"); + return -1; + } + WIN32_FIND_DATAA data; + HANDLE find = FindFirstFileA(pattern, &data); + free(pattern); + if (find == INVALID_HANDLE_VALUE) { + char message[1024]; + snprintf(message, sizeof(message), "read directory %s for backup: Windows error %lu", path, GetLastError()); + set_error(handle, message); + return -1; + } + char **items = NULL; + size_t len = 0; + size_t cap = 0; + do { + if (strcmp(data.cFileName, ".") == 0 || strcmp(data.cFileName, "..") == 0) { + continue; + } + if (len == cap) { + size_t next = cap == 0 ? 16 : cap * 2; + char **grown = (char **)realloc(items, next * sizeof(char *)); + if (grown == NULL) { + FindClose(find); + for (size_t i = 0; i < len; i++) { + free(items[i]); + } + free(items); + set_error(handle, "out of memory sorting backup directory entries"); + return -1; + } + items = grown; + cap = next; + } + items[len] = strdup(data.cFileName); + if (items[len] == NULL) { + FindClose(find); + for (size_t i = 0; i < len; i++) { + free(items[i]); + } + free(items); + set_error(handle, "out of memory copying backup directory entry"); + return -1; + } + len++; + } while (FindNextFileA(find, &data)); + DWORD error = GetLastError(); + FindClose(find); + if (error != ERROR_NO_MORE_FILES) { + for (size_t i = 0; i < len; i++) { + free(items[i]); + } + free(items); + char message[1024]; + snprintf(message, sizeof(message), "read directory %s for backup: Windows error %lu", path, error); + set_error(handle, message); + return -1; + } + qsort(items, len, sizeof(char *), compare_string_ptrs); + *out = items; + *out_count = len; + return 0; +#else + DIR *dir = opendir(path); + if (dir == NULL) { + char message[1024]; + snprintf(message, sizeof(message), "read directory %s for backup: %s", path, strerror(errno)); + set_error(handle, message); + return -1; + } + char **items = NULL; + size_t len = 0; + size_t cap = 0; + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + if (len == cap) { + size_t next = cap == 0 ? 16 : cap * 2; + char **grown = (char **)realloc(items, next * sizeof(char *)); + if (grown == NULL) { + closedir(dir); + for (size_t i = 0; i < len; i++) { + free(items[i]); + } + free(items); + set_error(handle, "out of memory sorting backup directory entries"); + return -1; + } + items = grown; + cap = next; + } + items[len] = strdup(entry->d_name); + if (items[len] == NULL) { + closedir(dir); + for (size_t i = 0; i < len; i++) { + free(items[i]); + } + free(items); + set_error(handle, "out of memory copying backup directory entry"); + return -1; + } + len++; + } + closedir(dir); + qsort(items, len, sizeof(char *), compare_string_ptrs); + *out = items; + *out_count = len; + return 0; +#endif +} + +static void free_string_list(char **items, size_t len) { + for (size_t i = 0; i < len; i++) { + free(items[i]); + } + free(items); +} + +static int string_list_contains(char **items, size_t len, const char *value) { + for (size_t i = 0; i < len; i++) { + if (strcmp(items[i], value) == 0) { + return 1; + } + } + return 0; +} + +static int has_component(const char *path, const char *component) { + size_t component_len = strlen(component); + const char *p = path; + while (*p != '\0') { + while (is_platform_separator(*p)) { + p++; + } + const char *start = p; + while (*p != '\0' && !is_platform_separator(*p)) { + p++; + } + if ((size_t)(p - start) == component_len && strncmp(start, component, component_len) == 0) { + return 1; + } + } + return 0; +} + +static int validate_relative_archive_path(const char *path, bool require_pgdata_prefix) { + if (path == NULL || path[0] == '\0' || is_platform_separator(path[0]) || has_component(path, "..")) { + return -1; + } + if (!require_pgdata_prefix) { + return 0; + } + if (strcmp(path, "pgdata") == 0 || strncmp(path, "pgdata/", 7) == 0) { + return 0; + } + if (strcmp(path, "manifest.properties") == 0 || + strcmp(path, ".oliphaunt/backup-manifest.properties") == 0) { + return 0; + } + return -1; +} + +static char *canonical_relative_archive_path(OliphauntHandle *handle, const char *path, bool require_pgdata_prefix) { + if (path == NULL || path[0] == '\0' || is_platform_separator(path[0])) { + set_error(handle, "physical archive entry is unsafe or outside pgdata"); + return NULL; + } + + size_t input_len = strlen(path); + char *out = (char *)malloc(input_len + 1); + if (out == NULL) { + set_error(handle, "out of memory canonicalizing archive path"); + return NULL; + } + size_t out_len = 0; + size_t component_count = 0; + const char *p = path; + while (*p != '\0') { + while (is_platform_separator(*p)) { + p++; + } + const char *start = p; + while (*p != '\0' && !is_platform_separator(*p)) { + p++; + } + size_t len = (size_t)(p - start); + if (len == 0 || (len == 1 && start[0] == '.')) { + continue; + } + if (len == 2 && start[0] == '.' && start[1] == '.') { + free(out); + set_error(handle, "physical archive entry is unsafe or outside pgdata"); + return NULL; + } + if (component_count == 0 && require_pgdata_prefix && + !(len == 6 && memcmp(start, "pgdata", 6) == 0) && + !(len == 19 && memcmp(start, "manifest.properties", 19) == 0) && + !(len == 10 && memcmp(start, ".oliphaunt", 10) == 0)) { + free(out); + set_error(handle, "physical archive entry is unsafe or outside pgdata"); + return NULL; + } + if (out_len > 0) { + out[out_len++] = '/'; + } + memcpy(out + out_len, start, len); + out_len += len; + component_count++; + } + if (component_count == 0) { + free(out); + set_error(handle, "physical archive entry is unsafe or outside pgdata"); + return NULL; + } + out[out_len] = '\0'; + if (require_pgdata_prefix && validate_relative_archive_path(out, true) != 0) { + free(out); + set_error(handle, "physical archive entry is unsafe or outside pgdata"); + return NULL; + } + return out; +} + +static int remember_archive_path(OliphauntHandle *handle, char ***paths, size_t *count, size_t *cap, const char *path) { + for (size_t i = 0; i < *count; i++) { + if (strcmp((*paths)[i], path) == 0) { + char message[1024]; + snprintf(message, sizeof(message), "physical archive contains duplicate entry %s", path); + set_error(handle, message); + return -1; + } + } + if (*count == *cap) { + size_t next = *cap == 0 ? 32 : *cap * 2; + char **grown = (char **)realloc(*paths, next * sizeof(char *)); + if (grown == NULL) { + set_error(handle, "out of memory tracking physical archive paths"); + return -1; + } + *paths = grown; + *cap = next; + } + (*paths)[*count] = strdup(path); + if ((*paths)[*count] == NULL) { + set_error(handle, "out of memory tracking physical archive path"); + return -1; + } + (*count)++; + return 0; +} + +static int remember_archive_string_if_absent( + OliphauntHandle *handle, + char ***paths, + size_t *count, + size_t *cap, + const char *path, + size_t path_len, + const char *oom_message) { + for (size_t i = 0; i < *count; i++) { + if (strlen((*paths)[i]) == path_len && strncmp((*paths)[i], path, path_len) == 0) { + return 0; + } + } + if (*count == *cap) { + size_t next = *cap == 0 ? 32 : *cap * 2; + char **grown = (char **)realloc(*paths, next * sizeof(char *)); + if (grown == NULL) { + set_error(handle, oom_message); + return -1; + } + *paths = grown; + *cap = next; + } + char *copy = (char *)malloc(path_len + 1); + if (copy == NULL) { + set_error(handle, oom_message); + return -1; + } + memcpy(copy, path, path_len); + copy[path_len] = '\0'; + (*paths)[*count] = copy; + (*count)++; + return 0; +} + +static const char *archive_file_ancestor(char **file_paths, size_t file_count, const char *path) { + for (size_t i = 0; i < file_count; i++) { + size_t len = strlen(file_paths[i]); + if (strncmp(path, file_paths[i], len) == 0 && path[len] == '/') { + return file_paths[i]; + } + } + return NULL; +} + +static int remember_archive_ancestors( + OliphauntHandle *handle, + char ***ancestors, + size_t *count, + size_t *cap, + const char *path) { + for (const char *slash = strchr(path, '/'); slash != NULL; slash = strchr(slash + 1, '/')) { + if (remember_archive_string_if_absent( + handle, + ancestors, + count, + cap, + path, + (size_t)(slash - path), + "out of memory tracking physical archive ancestors") != 0) { + return -1; + } + } + return 0; +} + +static int tar_set_name(uint8_t *header, const char *path) { + size_t len = strlen(path); + if (len <= 100) { + memcpy(header, path, len); + return 0; + } + const char *split = path + len; + while (split > path) { + split--; + if (*split != '/') { + continue; + } + size_t prefix_len = (size_t)(split - path); + size_t name_len = len - prefix_len - 1; + if (prefix_len <= 155 && name_len <= 100) { + memcpy(header, split + 1, name_len); + memcpy(header + 345, path, prefix_len); + return 0; + } + } + return -1; +} + +static void tar_write_octal(uint8_t *field, size_t width, unsigned long long value) { + memset(field, '0', width); + char scratch[32]; + snprintf(scratch, sizeof(scratch), "%0*llo", (int)width - 1, value); + size_t len = strlen(scratch); + if (len >= width) { + memset(field, '7', width - 1); + field[width - 1] = '\0'; + return; + } + memcpy(field + (width - 1 - len), scratch, len); + field[width - 1] = '\0'; +} + +static int tar_append_header( + OliphauntByteBuffer *archive, + OliphauntHandle *handle, + const char *archive_path, + char typeflag, + size_t size, + mode_t mode, + uid_t uid, + gid_t gid, + time_t mtime, + const char *link_name) { + if (validate_relative_archive_path(archive_path, false) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "refusing to archive unsafe path %s", archive_path); + set_error(handle, message); + return -1; + } + + char path_with_slash[512]; + const char *path_for_header = archive_path; + if (typeflag == '5') { + size_t len = strlen(archive_path); + if (len + 1 >= sizeof(path_with_slash)) { + set_error(handle, "backup directory path is too long for ustar header"); + return -1; + } + memcpy(path_with_slash, archive_path, len); + if (len == 0 || archive_path[len - 1] != '/') { + path_with_slash[len++] = '/'; + } + path_with_slash[len] = '\0'; + path_for_header = path_with_slash; + } + + uint8_t header[512]; + memset(header, 0, sizeof(header)); + if (tar_set_name(header, path_for_header) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "backup path %s is too long for ustar header", path_for_header); + set_error(handle, message); + return -1; + } + tar_write_octal(header + 100, 8, (unsigned long long)(mode & 07777)); + tar_write_octal(header + 108, 8, (unsigned long long)uid); + tar_write_octal(header + 116, 8, (unsigned long long)gid); + tar_write_octal(header + 124, 12, (unsigned long long)size); + tar_write_octal(header + 136, 12, (unsigned long long)(mtime < 0 ? 0 : mtime)); + memset(header + 148, ' ', 8); + header[156] = (uint8_t)typeflag; + if (link_name != NULL && link_name[0] != '\0') { + size_t link_len = strlen(link_name); + if (link_len > 100 || validate_relative_archive_path(link_name, false) != 0) { + set_error(handle, "backup symlink target is unsafe or too long for ustar header"); + return -1; + } + memcpy(header + 157, link_name, link_len); + } + memcpy(header + 257, "ustar", 5); + memcpy(header + 263, "00", 2); + + unsigned int checksum = 0; + for (size_t i = 0; i < sizeof(header); i++) { + checksum += header[i]; + } + snprintf((char *)header + 148, 8, "%06o", checksum); + header[154] = '\0'; + header[155] = ' '; + if (buffer_append(archive, header, sizeof(header)) != 0) { + set_error(handle, "out of memory appending tar header"); + return -1; + } + return 0; +} + +static int tar_append_file_contents(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *source, size_t size) { + int fd = open(source, O_RDONLY | O_CLOEXEC | O_BINARY); + if (fd < 0) { + char message[1024]; + snprintf(message, sizeof(message), "open %s for physical backup: %s", source, strerror(errno)); + set_error(handle, message); + return -1; + } + uint8_t chunk[64 * 1024]; + size_t remaining = size; + while (remaining > 0) { + size_t take = remaining < sizeof(chunk) ? remaining : sizeof(chunk); + ssize_t read_count; + do { + read_count = read(fd, chunk, take); + } while (read_count < 0 && errno == EINTR); + if (read_count < 0) { + char message[1024]; + snprintf(message, sizeof(message), "read %s for physical backup: %s", source, strerror(errno)); + close(fd); + set_error(handle, message); + return -1; + } + if (read_count == 0) { + char message[1024]; + snprintf(message, sizeof(message), "short read %s for physical backup", source); + close(fd); + set_error(handle, message); + return -1; + } + if (buffer_append(archive, chunk, (size_t)read_count) != 0) { + close(fd); + set_error(handle, "out of memory appending tar file contents"); + return -1; + } + remaining -= (size_t)read_count; + } + close(fd); + size_t padding = (512 - (size % 512)) % 512; + if (buffer_append_zeros(archive, padding) != 0) { + set_error(handle, "out of memory appending tar file padding"); + return -1; + } + return 0; +} + +static int tar_append_file(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *source, const char *archive_path, const struct stat *st) { + if (reserve_tar_entry(archive, handle, (size_t)st->st_size) != 0) { + return -1; + } + if (tar_append_header( + archive, + handle, + archive_path, + '0', + (size_t)st->st_size, + st->st_mode, + st->st_uid, + st->st_gid, + st->st_mtime, + NULL) != 0) { + return -1; + } + return tar_append_file_contents(archive, handle, source, (size_t)st->st_size); +} + +static int tar_append_directory(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *archive_path, const struct stat *st) { + if (reserve_tar_entry(archive, handle, 0) != 0) { + return -1; + } + return tar_append_header( + archive, + handle, + archive_path, + '5', + 0, + st->st_mode, + st->st_uid, + st->st_gid, + st->st_mtime, + NULL); +} + +static int should_skip_pgdata_entry(const char *relative, bool include_wal_contents) { + if (strcmp(relative, "postmaster.pid") == 0 || strcmp(relative, "postmaster.opts") == 0) { + return 1; + } + if (strcmp(relative, ".oliphaunt.lock") == 0) { + return 1; + } + const char *name = strrchr(relative, '/'); + name = name == NULL ? relative : name + 1; + if (strcmp(name, "pg_internal.init") == 0 || strncmp(name, "pgsql_tmp", 9) == 0) { + return 1; + } + const char *slash = strchr(relative, '/'); + if (slash == NULL) { + return 0; + } + size_t first_len = (size_t)(slash - relative); + static const char *transient[] = { + "pg_dynshmem", + "pg_notify", + "pg_serial", + "pg_snapshots", + "pg_stat_tmp", + "pg_subtrans", + }; + for (size_t i = 0; i < sizeof(transient) / sizeof(transient[0]); i++) { + if (strlen(transient[i]) == first_len && strncmp(relative, transient[i], first_len) == 0) { + return 1; + } + } + return first_len == 6 && strncmp(relative, "pg_wal", 6) == 0 && !include_wal_contents; +} + +static int append_pgdata_entry(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *pgdata, const char *relative, bool include_wal_contents); + +static int append_children(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *pgdata, const char *relative_dir, bool include_wal_contents) { + char *source_dir = relative_dir[0] == '\0' ? strdup(pgdata) : oliphaunt_join_path(pgdata, relative_dir); + if (source_dir == NULL) { + set_error(handle, "out of memory building backup source path"); + return -1; + } + char **names = NULL; + size_t count = 0; + if (sorted_dir_names(handle, source_dir, &names, &count) != 0) { + free(source_dir); + return -1; + } + for (size_t i = 0; i < count; i++) { + char *child_relative = relative_dir[0] == '\0' ? strdup(names[i]) : oliphaunt_join_path(relative_dir, names[i]); + if (child_relative == NULL) { + free_string_list(names, count); + free(source_dir); + set_error(handle, "out of memory building backup relative path"); + return -1; + } + int rc = append_pgdata_entry(archive, handle, pgdata, child_relative, include_wal_contents); + free(child_relative); + if (rc != 0) { + free_string_list(names, count); + free(source_dir); + return -1; + } + } + free_string_list(names, count); + free(source_dir); + return 0; +} + +static int append_pgdata_entry(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *pgdata, const char *relative, bool include_wal_contents) { + if (should_skip_pgdata_entry(relative, include_wal_contents)) { + return 0; + } + char *source = oliphaunt_join_path(pgdata, relative); + char *archive_path = oliphaunt_join_path("pgdata", relative); + if (source == NULL || archive_path == NULL) { + free(source); + free(archive_path); + set_error(handle, "out of memory building backup archive path"); + return -1; + } + struct stat st; + if (lstat(source, &st) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "stat %s for physical backup: %s", source, strerror(errno)); + free(source); + free(archive_path); + set_error(handle, message); + return -1; + } + int rc = 0; + if (S_ISDIR(st.st_mode)) { + rc = tar_append_directory(archive, handle, archive_path, &st); + if (rc == 0) { + rc = append_children(archive, handle, pgdata, relative, include_wal_contents); + } + } else if (S_ISREG(st.st_mode)) { + rc = tar_append_file(archive, handle, source, archive_path, &st); + } else if (S_ISLNK(st.st_mode)) { + char message[1024]; + snprintf(message, sizeof(message), + "physical archive does not support symlinked PGDATA entry %s; external tablespaces and linked WAL directories are not portable in liboliphaunt archives", + archive_path); + set_error(handle, message); + rc = -1; + } else { + char message[1024]; + snprintf(message, sizeof(message), + "physical archive does not support non-regular PGDATA entry %s; liboliphaunt archives only support regular files and directories", + archive_path); + set_error(handle, message); + rc = -1; + } + free(source); + free(archive_path); + return rc; +} + +int oliphaunt_archive_append_pgdata_tree(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *pgdata) { + struct stat st; + if (lstat(pgdata, &st) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "stat PGDATA %s for physical backup: %s", pgdata, strerror(errno)); + set_error(handle, message); + return -1; + } + if (!S_ISDIR(st.st_mode)) { + char message[1024]; + snprintf(message, sizeof(message), "physical backup PGDATA %s is not a directory", pgdata); + set_error(handle, message); + return -1; + } + if (tar_append_directory(archive, handle, "pgdata", &st) != 0) { + return -1; + } + return append_children(archive, handle, pgdata, "", false); +} + +int oliphaunt_archive_append_pg_wal_tree(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *pgdata) { + char *pg_wal = oliphaunt_join_path(pgdata, "pg_wal"); + if (pg_wal == NULL) { + set_error(handle, "out of memory building pg_wal path"); + return -1; + } + struct stat st; + if (lstat(pg_wal, &st) != 0 || !S_ISDIR(st.st_mode)) { + free(pg_wal); + return 0; + } + free(pg_wal); + return append_children(archive, handle, pgdata, "pg_wal", true); +} + +int oliphaunt_archive_append_generated_file(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *archive_path, const char *contents) { + size_t len = contents == NULL ? 0 : strlen(contents); + return oliphaunt_archive_append_generated_bytes( + archive, + handle, + archive_path, + (const uint8_t *)contents, + len, + 0600); +} + +int oliphaunt_archive_append_generated_bytes( + OliphauntByteBuffer *archive, + OliphauntHandle *handle, + const char *archive_path, + const uint8_t *contents, + size_t len, + uint32_t mode) { + if (len > 0 && contents == NULL) { + set_error(handle, "generated backup file has bytes but no data pointer"); + return -1; + } + mode_t file_mode = mode == 0 ? 0600 : (mode_t)(mode & 0777u); + if (reserve_tar_entry(archive, handle, len) != 0) { + return -1; + } + if (tar_append_header(archive, handle, archive_path, '0', len, file_mode, 0, 0, time(NULL), NULL) != 0) { + return -1; + } + if (buffer_append(archive, contents, len) != 0 || + buffer_append_zeros(archive, (512 - (len % 512)) % 512) != 0) { + set_error(handle, "out of memory appending generated backup file"); + return -1; + } + return 0; +} + +int oliphaunt_archive_finish(OliphauntByteBuffer *archive, OliphauntHandle *handle) { + if (buffer_append_zeros(archive, 1024) != 0) { + set_error(handle, "out of memory finishing physical backup archive"); + return -1; + } + return 0; +} + +static int parse_tar_octal(const uint8_t *field, size_t len, unsigned long long *out) { + unsigned long long value = 0; + int saw_digit = 0; + size_t index = 0; + while (index < len && (field[index] == ' ' || field[index] == '\0')) { + index++; + } + for (; index < len; index++) { + if (field[index] >= '0' && field[index] <= '7') { + unsigned long long digit = (unsigned long long)(field[index] - '0'); + if (value > (ULLONG_MAX - digit) / 8) { + return -1; + } + saw_digit = 1; + value = (value << 3) + digit; + continue; + } + if (field[index] == ' ' || field[index] == '\0') { + for (index++; index < len; index++) { + if (field[index] != ' ' && field[index] != '\0') { + return -1; + } + } + break; + } + return -1; + } + if (!saw_digit) { + return -1; + } + *out = value; + return 0; +} + +static unsigned int tar_header_checksum(const uint8_t *header) { + unsigned int checksum = 0; + for (size_t i = 0; i < 512; i++) { + checksum += (i >= 148 && i < 156) ? (unsigned int)' ' : (unsigned int)header[i]; + } + return checksum; +} + +static int parse_tar_octal_field( + OliphauntHandle *handle, + const uint8_t *field, + size_t len, + const char *label, + int allow_empty, + unsigned long long *out) { + if (parse_tar_octal(field, len, out) == 0) { + return 0; + } + int empty = 1; + for (size_t i = 0; i < len; i++) { + if (field[i] != ' ' && field[i] != '\0') { + empty = 0; + break; + } + } + if (empty && allow_empty) { + *out = 0; + return 0; + } + char message[128]; + snprintf(message, sizeof(message), "physical archive entry has invalid tar %s field", label); + set_error(handle, message); + return -1; +} + +static int validate_tar_header_checksum(OliphauntHandle *handle, const uint8_t *header) { + unsigned long long stored = 0; + if (parse_tar_octal_field(handle, header + 148, 8, "checksum", 0, &stored) != 0) { + return -1; + } + if (stored != (unsigned long long)tar_header_checksum(header)) { + set_error(handle, "physical archive entry has invalid tar checksum"); + return -1; + } + return 0; +} + +static int validate_tar_numeric_metadata(OliphauntHandle *handle, const uint8_t *header, unsigned long long *size, unsigned long long *mode) { + unsigned long long ignored = 0; + if (parse_tar_octal_field(handle, header + 100, 8, "mode", 0, mode) != 0 || + parse_tar_octal_field(handle, header + 108, 8, "uid", 1, &ignored) != 0 || + parse_tar_octal_field(handle, header + 116, 8, "gid", 1, &ignored) != 0 || + parse_tar_octal_field(handle, header + 124, 12, "size", 0, size) != 0 || + parse_tar_octal_field(handle, header + 136, 12, "mtime", 1, &ignored) != 0) { + return -1; + } + return 0; +} + +static int validate_tar_string_field(OliphauntHandle *handle, const uint8_t *field, size_t len, const char *label, int allow_empty) { + size_t index = 0; + while (index < len && field[index] != '\0') { + index++; + } + if (index == 0 && !allow_empty) { + char message[128]; + snprintf(message, sizeof(message), "physical archive entry has invalid tar %s field", label); + set_error(handle, message); + return -1; + } + if (index == len) { + return 0; + } + for (index++; index < len; index++) { + if (field[index] != '\0') { + char message[128]; + snprintf(message, sizeof(message), "physical archive entry has invalid tar %s field", label); + set_error(handle, message); + return -1; + } + } + return 0; +} + +static int validate_tar_string_metadata(OliphauntHandle *handle, const uint8_t *header) { + if (validate_tar_string_field(handle, header, 100, "name", 0) != 0 || + validate_tar_string_field(handle, header + 157, 100, "linkname", 1) != 0 || + validate_tar_string_field(handle, header + 345, 155, "prefix", 1) != 0) { + return -1; + } + return 0; +} + +static int tar_header_format_is_supported(const uint8_t *header) { + if (memcmp(header + 257, "ustar\0", 6) == 0 && + memcmp(header + 263, "00", 2) == 0) { + return 1; + } + if (memcmp(header + 257, "ustar ", 6) == 0 && + memcmp(header + 263, " \0", 2) == 0) { + return 1; + } + return 0; +} + +static int tar_block_is_zero(const uint8_t *block) { + for (size_t i = 0; i < 512; i++) { + if (block[i] != 0) { + return 0; + } + } + return 1; +} + +static char *tar_entry_name(const uint8_t *header) { + char name[101]; + char prefix[156]; + memcpy(name, header, 100); + name[100] = '\0'; + memcpy(prefix, header + 345, 155); + prefix[155] = '\0'; + size_t name_len = strnlen(name, sizeof(name)); + size_t prefix_len = strnlen(prefix, sizeof(prefix)); + if (name_len == 0) { + return NULL; + } + if (prefix_len == 0) { + return strdup(name); + } + char *out = (char *)malloc(prefix_len + 1 + name_len + 1); + if (out == NULL) { + return NULL; + } + memcpy(out, prefix, prefix_len); + out[prefix_len] = '/'; + memcpy(out + prefix_len + 1, name, name_len); + out[prefix_len + 1 + name_len] = '\0'; + return out; +} + +static char *tar_link_name(const uint8_t *header) { + char link[101]; + memcpy(link, header + 157, 100); + link[100] = '\0'; + size_t len = strnlen(link, sizeof(link)); + if (len == 0) { + return NULL; + } + return strdup(link); +} + +static int ensure_parent_dir_for_path(OliphauntHandle *handle, const char *path) { + char *parent = oliphaunt_path_parent_dup(path); + if (parent == NULL) { + set_error(handle, "out of memory resolving restore parent directory"); + return -1; + } + int rc = oliphaunt_mkdir_p(parent, 0700); + if (rc != 0) { + char message[1024]; + snprintf(message, sizeof(message), "create restore parent directory %s: %s", parent, strerror(errno)); + set_error(handle, message); + } + free(parent); + return rc; +} + +static int unpack_tar_file(OliphauntHandle *handle, const char *path, const uint8_t *data, size_t len, mode_t mode) { + if (ensure_parent_dir_for_path(handle, path) != 0) { + return -1; + } + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, mode & 07777); + if (fd < 0) { + char message[1024]; + snprintf(message, sizeof(message), "create restored file %s: %s", path, strerror(errno)); + set_error(handle, message); + return -1; + } + size_t off = 0; + while (off < len) { + ssize_t written = write(fd, data + off, len - off); + if (written < 0) { + char message[1024]; + snprintf(message, sizeof(message), "write restored file %s: %s", path, strerror(errno)); + close(fd); + set_error(handle, message); + return -1; + } + off += (size_t)written; + } + (void)fchmod(fd, mode & 07777); + close(fd); + return 0; +} + +static int process_physical_archive(OliphauntHandle *handle, const uint8_t *data, size_t len, const char *staging_root, bool write_entries) { + if (len < 1024 || (len % 512) != 0) { + set_error(handle, "physical archive has invalid tar block framing"); + return -1; + } + if (!tar_block_is_zero(data + len - 1024) || !tar_block_is_zero(data + len - 512)) { + set_error(handle, "physical archive ended before final tar zero block"); + return -1; + } + + char **seen_paths = NULL; + size_t seen_count = 0; + size_t seen_cap = 0; + char **file_paths = NULL; + size_t file_count = 0; + size_t file_cap = 0; + char **entry_ancestors = NULL; + size_t ancestor_count = 0; + size_t ancestor_cap = 0; + char *name = NULL; + char *canonical_name = NULL; + char *dest = NULL; + char *link = NULL; + int result = -1; + size_t off = 0; + while (off + 512 <= len) { + const uint8_t *header = data + off; + off += 512; + if (tar_block_is_zero(header)) { + if (off + 512 > len) { + set_error(handle, "physical archive ended before final tar zero block"); + goto cleanup; + } + if (!tar_block_is_zero(data + off)) { + set_error(handle, "physical archive has trailing data after tar terminator"); + goto cleanup; + } + off += 512; + while (off < len) { + if (!tar_block_is_zero(data + off)) { + set_error(handle, "physical archive has trailing data after tar terminator"); + goto cleanup; + } + off += 512; + } + result = 0; + goto cleanup; + } + if (validate_tar_header_checksum(handle, header) != 0) { + goto cleanup; + } + if (!tar_header_format_is_supported(header)) { + set_error(handle, "physical archive entry has unsupported tar header format"); + goto cleanup; + } + if (validate_tar_string_metadata(handle, header) != 0) { + goto cleanup; + } + unsigned long long size = 0; + unsigned long long mode = 0; + if (validate_tar_numeric_metadata(handle, header, &size, &mode) != 0) { + goto cleanup; + } + name = tar_entry_name(header); + if (name == NULL) { + set_error(handle, "physical archive contains an empty path"); + goto cleanup; + } + canonical_name = canonical_relative_archive_path(handle, name, true); + if (canonical_name == NULL) { + goto cleanup; + } + if (remember_archive_path(handle, &seen_paths, &seen_count, &seen_cap, canonical_name) != 0) { + goto cleanup; + } + if (size > SIZE_MAX || (size_t)size > len - off) { + set_error(handle, "physical archive entry is truncated"); + goto cleanup; + } + if ((size_t)size > SIZE_MAX - 511) { + set_error(handle, "physical archive entry size overflows"); + goto cleanup; + } + size_t padded = ((size_t)size + 511) & ~(size_t)511; + if (padded > len - off) { + set_error(handle, "physical archive entry padding is truncated"); + goto cleanup; + } + char type = header[156] == '\0' ? '0' : (char)header[156]; + if (type != '0' && type != '5') { + char message[1024]; + snprintf(message, sizeof(message), + "physical archive entry %s has unsupported tar entry type '%c'; liboliphaunt physical archives only support regular files and directories", + canonical_name, + type >= 32 && type <= 126 ? type : '?'); + set_error(handle, message); + goto cleanup; + } + link = tar_link_name(header); + if (link != NULL) { + char message[1024]; + snprintf(message, sizeof(message), + "physical archive entry %s has an unexpected link target; liboliphaunt physical archives must contain concrete root files", + canonical_name); + set_error(handle, message); + goto cleanup; + } + if (type == '5') { + if (size != 0) { + char message[1024]; + snprintf(message, sizeof(message), "physical archive directory entry %s has non-zero size", canonical_name); + set_error(handle, message); + goto cleanup; + } + } + const char *ancestor = archive_file_ancestor(file_paths, file_count, canonical_name); + if (ancestor != NULL) { + char message[1024]; + snprintf(message, sizeof(message), + "physical archive entry %s is nested under file entry %s", + canonical_name, + ancestor); + set_error(handle, message); + goto cleanup; + } + if (type != '5') { + if (string_list_contains(entry_ancestors, ancestor_count, canonical_name)) { + char message[1024]; + snprintf(message, sizeof(message), + "physical archive file entry %s conflicts with existing child entries", + canonical_name); + set_error(handle, message); + goto cleanup; + } + if (remember_archive_string_if_absent( + handle, + &file_paths, + &file_count, + &file_cap, + canonical_name, + strlen(canonical_name), + "out of memory tracking physical archive file paths") != 0) { + goto cleanup; + } + } + if (remember_archive_ancestors(handle, &entry_ancestors, &ancestor_count, &ancestor_cap, canonical_name) != 0) { + goto cleanup; + } + if (write_entries) { + dest = oliphaunt_join_path(staging_root, canonical_name); + if (dest == NULL) { + set_error(handle, "out of memory resolving restore destination"); + goto cleanup; + } + int rc = 0; + if (type == '5') { + rc = oliphaunt_mkdir_p(dest, (mode_t)(mode == 0 ? 0700 : mode)); + if (rc != 0) { + char message[1024]; + snprintf(message, sizeof(message), "create restored directory %s: %s", dest, strerror(errno)); + set_error(handle, message); + } + } else { + rc = unpack_tar_file(handle, dest, data + off, (size_t)size, (mode_t)(mode == 0 ? 0600 : mode)); + } + free(dest); + dest = NULL; + if (rc != 0) { + goto cleanup; + } + } + free(canonical_name); + canonical_name = NULL; + free(name); + name = NULL; + off += padded; + } + set_error(handle, "physical archive ended before final tar zero block"); +cleanup: + free(link); + free(dest); + free(canonical_name); + free(name); + free_string_list(seen_paths, seen_count); + free_string_list(file_paths, file_count); + free_string_list(entry_ancestors, ancestor_count); + return result; +} + +int oliphaunt_unpack_physical_archive(OliphauntHandle *handle, const uint8_t *data, size_t len, const char *staging_root) { + if (process_physical_archive(handle, data, len, NULL, false) != 0) { + return -1; + } + return process_physical_archive(handle, data, len, staging_root, true); +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c new file mode 100644 index 00000000..86a7f405 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c @@ -0,0 +1,356 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "liboliphaunt_internal.h" + +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#if defined(__APPLE__) && defined(__has_include) +#if __has_include() +#include +#endif +#endif + +#ifndef TARGET_OS_IPHONE +#define TARGET_OS_IPHONE 0 +#endif +#ifndef TARGET_OS_TV +#define TARGET_OS_TV 0 +#endif +#ifndef TARGET_OS_WATCH +#define TARGET_OS_WATCH 0 +#endif +#ifndef TARGET_OS_VISION +#define TARGET_OS_VISION 0 +#endif + +#if TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_WATCH || TARGET_OS_VISION +#define OLIPHAUNT_CAN_EXEC_INITDB 0 +#else +#define OLIPHAUNT_CAN_EXEC_INITDB 1 +#endif + +#if OLIPHAUNT_CAN_EXEC_INITDB +#include +#endif +#include +#endif + +static int path_exists(const char *path) { + struct stat st; + return path != NULL && stat(path, &st) == 0; +} + +char *oliphaunt_dup_config_string(const char *value, const char *fallback) { + const char *source = value && value[0] ? value : fallback; + return strdup(source); +} + +static char *sibling_postgres_path(const char *initdb_path) { + if (initdb_path == NULL || initdb_path[0] == '\0') { + return NULL; + } + const char *slash = strrchr(initdb_path, '/'); +#ifdef _WIN32 + const char *backslash = strrchr(initdb_path, '\\'); + if (backslash != NULL && (slash == NULL || backslash > slash)) { + slash = backslash; + } +#endif + if (slash == NULL) { + return NULL; + } + size_t dir_len = (size_t)(slash - initdb_path); + const char *leaf = +#ifdef _WIN32 + "\\postgres.exe"; +#else + "/postgres"; +#endif + size_t leaf_len = strlen(leaf); + char *path = (char *)malloc(dir_len + leaf_len + 1); + if (path == NULL) { + return NULL; + } + memcpy(path, initdb_path, dir_len); + memcpy(path + dir_len, leaf, leaf_len + 1); + return path; +} + +static char *runtime_tool_path(const char *runtime_dir, const char *tool_name) { + if (runtime_dir == NULL || runtime_dir[0] == '\0' || + tool_name == NULL || tool_name[0] == '\0') { + return NULL; + } + const char *bin_sep = +#ifdef _WIN32 + "\\bin\\"; +#else + "/bin/"; +#endif + size_t dir_len = strlen(runtime_dir); + size_t sep_len = strlen(bin_sep); + size_t tool_len = strlen(tool_name); + char *path = (char *)malloc(dir_len + sep_len + tool_len + 1); + if (path == NULL) { + return NULL; + } + memcpy(path, runtime_dir, dir_len); + memcpy(path + dir_len, bin_sep, sep_len); + memcpy(path + dir_len + sep_len, tool_name, tool_len + 1); + if (access(path, X_OK) == 0 || path_exists(path)) { + return path; + } +#ifdef _WIN32 + static const char exe_suffix[] = ".exe"; + if (tool_len < sizeof(exe_suffix) - 1 || + strcmp(tool_name + tool_len - (sizeof(exe_suffix) - 1), exe_suffix) != 0) { + char *exe_path = (char *)malloc(dir_len + sep_len + tool_len + sizeof(exe_suffix)); + if (exe_path == NULL) { + free(path); + return NULL; + } + memcpy(exe_path, path, dir_len + sep_len + tool_len); + memcpy(exe_path + dir_len + sep_len + tool_len, exe_suffix, sizeof(exe_suffix)); + if (access(exe_path, X_OK) == 0 || path_exists(exe_path)) { + free(path); + return exe_path; + } + free(exe_path); + } +#endif + free(path); + return NULL; +} + +char *oliphaunt_resolve_postgres_argv0(const char *runtime_dir) { + char *from_runtime = runtime_tool_path(runtime_dir, "postgres"); + if (from_runtime != NULL) { + return from_runtime; + } + + const char *postgres = getenv("OLIPHAUNT_POSTGRES"); + if (postgres != NULL && postgres[0] != '\0') { + return strdup(postgres); + } + + const char *initdb = getenv("OLIPHAUNT_INITDB"); + char *from_initdb = sibling_postgres_path(initdb); + if (from_initdb != NULL) { + return from_initdb; + } + + return strdup("postgres"); +} + +int oliphaunt_dup_startup_args(OliphauntHandle *handle, const OliphauntConfig *config) { + if (config->startup_arg_count == 0) { + return 0; + } + if (config->startup_args == NULL) { + set_error(handle, "startup_arg_count is non-zero but startup_args is null"); + return -1; + } + handle->startup_args = (char **)calloc(config->startup_arg_count, sizeof(char *)); + if (handle->startup_args == NULL) { + set_error(handle, "out of memory allocating startup arguments"); + return -1; + } + handle->startup_arg_count = config->startup_arg_count; + for (size_t i = 0; i < config->startup_arg_count; i++) { + if (config->startup_args[i] == NULL) { + set_error(handle, "startup argument must not be null"); + return -1; + } + handle->startup_args[i] = strdup(config->startup_args[i]); + if (handle->startup_args[i] == NULL) { + set_error(handle, "out of memory copying startup argument"); + return -1; + } + } + return 0; +} + +#ifdef _WIN32 +static int run_initdb_command(OliphauntHandle *handle, const char *initdb) { + const char *argv[] = { + initdb, + "-D", + handle->pgdata, + "-U", + handle->username, + "--auth=trust", + "--no-sync", + NULL, + }; + intptr_t status = _spawnvp(_P_WAIT, initdb, argv); + if (status == -1) { + snprintf(handle->last_error, sizeof(handle->last_error), "spawn initdb %s failed: %s", initdb, strerror(errno)); + return -1; + } + if (status == 0) { + return 0; + } + snprintf( + handle->last_error, + sizeof(handle->last_error), + "initdb %s failed with exit status %ld for PGDATA %s", + initdb, + (long)status, + handle->pgdata); + return -1; +} +#elif OLIPHAUNT_CAN_EXEC_INITDB +static int run_initdb_command(OliphauntHandle *handle, const char *initdb) { + int exec_error_pipe[2]; + if (pipe(exec_error_pipe) != 0) { + snprintf(handle->last_error, sizeof(handle->last_error), "create initdb exec pipe failed: %s", strerror(errno)); + return -1; + } + + (void)fcntl(exec_error_pipe[1], F_SETFD, FD_CLOEXEC); + + pid_t pid = fork(); + if (pid < 0) { + int fork_errno = errno; + close(exec_error_pipe[0]); + close(exec_error_pipe[1]); + snprintf(handle->last_error, sizeof(handle->last_error), "fork initdb failed: %s", strerror(fork_errno)); + return -1; + } + + if (pid == 0) { + close(exec_error_pipe[0]); + int devnull = open("/dev/null", O_WRONLY); + if (devnull >= 0) { + (void)dup2(devnull, STDOUT_FILENO); + close(devnull); + } + + execlp( + initdb, + initdb, + "-D", + handle->pgdata, + "-U", + handle->username, + "--auth=trust", + "--no-sync", + (char *)NULL); + + int exec_errno = errno; + (void)write(exec_error_pipe[1], &exec_errno, sizeof(exec_errno)); + _exit(127); + } + + close(exec_error_pipe[1]); + int exec_errno = 0; + ssize_t read_len; + do { + read_len = read(exec_error_pipe[0], &exec_errno, sizeof(exec_errno)); + } while (read_len < 0 && errno == EINTR); + close(exec_error_pipe[0]); + + int status = 0; + pid_t waited; + do { + waited = waitpid(pid, &status, 0); + } while (waited < 0 && errno == EINTR); + if (waited < 0) { + snprintf(handle->last_error, sizeof(handle->last_error), "wait for initdb failed: %s", strerror(errno)); + return -1; + } + + if (read_len > 0 && exec_errno != 0) { + snprintf( + handle->last_error, + sizeof(handle->last_error), + "exec initdb %s failed: %s", + initdb, + strerror(exec_errno)); + return -1; + } + if (WIFEXITED(status) && WEXITSTATUS(status) == 0) { + return 0; + } + if (WIFEXITED(status)) { + snprintf( + handle->last_error, + sizeof(handle->last_error), + "initdb %s failed with exit status %d for PGDATA %s", + initdb, + WEXITSTATUS(status), + handle->pgdata); + return -1; + } + if (WIFSIGNALED(status)) { + snprintf( + handle->last_error, + sizeof(handle->last_error), + "initdb %s terminated by signal %d for PGDATA %s", + initdb, + WTERMSIG(status), + handle->pgdata); + return -1; + } + snprintf(handle->last_error, sizeof(handle->last_error), "initdb %s failed for PGDATA %s", initdb, handle->pgdata); + return -1; +} +#else +static int run_initdb_command(OliphauntHandle *handle, const char *initdb) { + (void)initdb; + snprintf( + handle->last_error, + sizeof(handle->last_error), + "PGDATA %s is not initialized and this platform cannot execute initdb; hydrate the root from packaged template PGDATA before oliphaunt_init", + handle->pgdata ? handle->pgdata : "(null)"); + return -1; +} +#endif + +int oliphaunt_run_initdb_if_needed(OliphauntHandle *handle) { + char version_path[4096]; + snprintf(version_path, sizeof(version_path), "%s/PG_VERSION", handle->pgdata); + if (path_exists(version_path)) { + return 0; + } + + char *pgdata_parent = oliphaunt_path_parent_dup(handle->pgdata); + if (pgdata_parent == NULL) { + snprintf(handle->last_error, sizeof(handle->last_error), "out of memory resolving PGDATA parent for %s", handle->pgdata); + return -1; + } + if (oliphaunt_mkdir_p(pgdata_parent, 0700) != 0) { + int mkdir_errno = errno; + snprintf( + handle->last_error, + sizeof(handle->last_error), + "create PGDATA parent directory %s: %s", + pgdata_parent, + strerror(mkdir_errno)); + free(pgdata_parent); + return -1; + } + free(pgdata_parent); + + const char *initdb = getenv("OLIPHAUNT_INITDB"); + char *runtime_initdb = NULL; + if (initdb == NULL || initdb[0] == '\0') { + runtime_initdb = runtime_tool_path(handle->runtime_dir, "initdb"); + initdb = runtime_initdb; + if (initdb == NULL) { + initdb = "initdb"; + } + } + + int rc = run_initdb_command(handle, initdb); + free(runtime_initdb); + return rc; +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c new file mode 100644 index 00000000..fd9c4577 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_builtin_extensions.c @@ -0,0 +1,47 @@ +#include "liboliphaunt_internal.h" + +#ifdef OLIPHAUNT_BUILTIN_PLPGSQL +extern const void *Pg_magic_func(void); +extern void _PG_init(void); +extern void plpgsql_call_handler(void); +extern void pg_finfo_plpgsql_call_handler(void); +extern void plpgsql_inline_handler(void); +extern void pg_finfo_plpgsql_inline_handler(void); +extern void plpgsql_validator(void); +extern void pg_finfo_plpgsql_validator(void); + +static const OliphauntStaticExtensionSymbol plpgsql_symbols[] = { + {.name = "plpgsql_call_handler", .address = (void *)plpgsql_call_handler}, + {.name = "pg_finfo_plpgsql_call_handler", .address = (void *)pg_finfo_plpgsql_call_handler}, + {.name = "plpgsql_inline_handler", .address = (void *)plpgsql_inline_handler}, + {.name = "pg_finfo_plpgsql_inline_handler", .address = (void *)pg_finfo_plpgsql_inline_handler}, + {.name = "plpgsql_validator", .address = (void *)plpgsql_validator}, + {.name = "pg_finfo_plpgsql_validator", .address = (void *)pg_finfo_plpgsql_validator}, +}; + +static const OliphauntStaticExtension builtin_static_extensions[] = { + { + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION, + .name = "plpgsql", + .magic = Pg_magic_func, + .init = _PG_init, + .symbols = plpgsql_symbols, + .symbol_count = sizeof(plpgsql_symbols) / sizeof(plpgsql_symbols[0]), + .reserved_flags = 0, + }, +}; +#endif + +const OliphauntStaticExtension *liboliphaunt_builtin_static_extensions(size_t *count) { +#ifdef OLIPHAUNT_BUILTIN_PLPGSQL + if (count != NULL) { + *count = sizeof(builtin_static_extensions) / sizeof(builtin_static_extensions[0]); + } + return builtin_static_extensions; +#else + if (count != NULL) { + *count = 0; + } + return NULL; +#endif +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c new file mode 100644 index 00000000..d5c780ea --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_fs.c @@ -0,0 +1,533 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "liboliphaunt_internal.h" + +#ifndef _WIN32 +#include +#endif +#include +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#include +#include +#endif + +typedef struct OliphauntSha256Ctx { + uint32_t state[8]; + uint64_t bitcount; + uint8_t buffer[64]; +} OliphauntSha256Ctx; + +extern void pg_sha256_init(OliphauntSha256Ctx *ctx); +extern void pg_sha256_update(OliphauntSha256Ctx *ctx, const uint8_t *data, size_t len); +extern void pg_sha256_final(OliphauntSha256Ctx *ctx, uint8_t *dest); + +static bool oliphaunt_is_path_separator(char value) { + return value == '/' +#ifdef _WIN32 + || value == '\\' +#endif + ; +} + +int oliphaunt_path_exists(const char *path) { + struct stat st; + return path != NULL && stat(path, &st) == 0; +} + +char *oliphaunt_join_path(const char *left, const char *right) { + if (left == NULL || right == NULL) { + return NULL; + } + size_t left_len = strlen(left); + size_t right_len = strlen(right); + bool needs_slash = left_len > 0 && !oliphaunt_is_path_separator(left[left_len - 1]); + char *out = (char *)malloc(left_len + (needs_slash ? 1 : 0) + right_len + 1); + if (out == NULL) { + return NULL; + } + memcpy(out, left, left_len); + size_t off = left_len; + if (needs_slash) { + out[off++] = '/'; + } + memcpy(out + off, right, right_len + 1); + return out; +} + +char *oliphaunt_path_parent_dup(const char *path) { + const char *slash = NULL; + for (const char *cursor = path; cursor != NULL && *cursor != '\0'; cursor++) { + if (oliphaunt_is_path_separator(*cursor)) { + slash = cursor; + } + } + if (slash == NULL) { + return strdup("."); + } + if (slash == path) { + return strdup("/"); + } + size_t len = (size_t)(slash - path); + char *out = (char *)malloc(len + 1); + if (out == NULL) { + return NULL; + } + memcpy(out, path, len); + out[len] = '\0'; + return out; +} + +char *oliphaunt_path_file_name_dup(const char *path) { + const char *slash = NULL; + for (const char *cursor = path; cursor != NULL && *cursor != '\0'; cursor++) { + if (oliphaunt_is_path_separator(*cursor)) { + slash = cursor; + } + } + const char *name = slash == NULL ? path : slash + 1; + return strdup(name[0] == '\0' ? "root" : name); +} + +static char *oliphaunt_trim_trailing_slashes_dup(const char *path) { + if (path == NULL) { + return NULL; + } + size_t len = strlen(path); + while (len > 1 && oliphaunt_is_path_separator(path[len - 1])) { + len--; + } + if (len == 0) { + return strdup("."); + } + char *out = (char *)malloc(len + 1); + if (out == NULL) { + return NULL; + } + memcpy(out, path, len); + out[len] = '\0'; + return out; +} + +static char *oliphaunt_canonicalish_path_dup(const char *path) { + char *trimmed = oliphaunt_trim_trailing_slashes_dup(path); + if (trimmed == NULL) { + return NULL; + } + + char *resolved = realpath(trimmed, NULL); + if (resolved != NULL) { + free(trimmed); + return resolved; + } + + char *parent = oliphaunt_path_parent_dup(trimmed); + char *name = oliphaunt_path_file_name_dup(trimmed); + free(trimmed); + if (parent == NULL || name == NULL) { + free(parent); + free(name); + return NULL; + } + + char *canonical_parent = realpath(parent, NULL); + if (canonical_parent == NULL && strcmp(parent, ".") != 0 && strcmp(parent, "/") != 0) { + canonical_parent = oliphaunt_canonicalish_path_dup(parent); + } + if (canonical_parent == NULL) { + char cwd[4096]; + if (getcwd(cwd, sizeof(cwd)) == NULL) { + free(parent); + free(name); + return NULL; + } + canonical_parent = parent[0] == '/' ? strdup("/") : strdup(cwd); + } + free(parent); + if (canonical_parent == NULL) { + free(name); + return NULL; + } + + char *joined = oliphaunt_join_path(canonical_parent, name); + free(canonical_parent); + free(name); + return joined; +} + +static char *oliphaunt_mkdir_p_scan_start(char *path) { +#ifdef _WIN32 + if (path[0] != '\0' && path[1] == ':') { + path += 2; + if (oliphaunt_is_path_separator(*path)) { + path++; + } + return path; + } + if (oliphaunt_is_path_separator(path[0]) && oliphaunt_is_path_separator(path[1])) { + path += 2; + while (*path != '\0' && !oliphaunt_is_path_separator(*path)) { + path++; + } + if (oliphaunt_is_path_separator(*path)) { + path++; + } + while (*path != '\0' && !oliphaunt_is_path_separator(*path)) { + path++; + } + if (oliphaunt_is_path_separator(*path)) { + path++; + } + return path; + } +#endif + return path + 1; +} + +int oliphaunt_mkdir_p(const char *path, mode_t mode) { + if (path == NULL || path[0] == '\0') { + return -1; + } + char *copy = strdup(path); + if (copy == NULL) { + return -1; + } + size_t len = strlen(copy); + while (len > 1 && oliphaunt_is_path_separator(copy[len - 1])) { + copy[--len] = '\0'; + } + + struct stat existing; + if (stat(copy, &existing) == 0) { + if (S_ISDIR(existing.st_mode)) { + free(copy); + return 0; + } + free(copy); + errno = ENOTDIR; + return -1; + } + + for (char *p = oliphaunt_mkdir_p_scan_start(copy); *p != '\0'; p++) { + if (oliphaunt_is_path_separator(*p)) { + char separator = *p; + *p = '\0'; + if (mkdir(copy, mode) != 0 && errno != EEXIST) { + free(copy); + return -1; + } + *p = separator; + } + } + int rc = mkdir(copy, mode); + if (rc != 0 && errno == EEXIST) { + rc = 0; + } + free(copy); + return rc; +} + +#ifdef _WIN32 +static int oliphaunt_remove_tree_windows(const char *path) { + struct stat st; + if (lstat(path, &st) != 0) { + return errno == ENOENT ? 0 : -1; + } + if (S_ISDIR(st.st_mode)) { + char *pattern = oliphaunt_join_path(path, "*"); + if (pattern == NULL) { + errno = ENOMEM; + return -1; + } + WIN32_FIND_DATAA data; + HANDLE find = FindFirstFileA(pattern, &data); + free(pattern); + if (find == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error != ERROR_FILE_NOT_FOUND && error != ERROR_PATH_NOT_FOUND) { + errno = EACCES; + return -1; + } + } else { + do { + if (strcmp(data.cFileName, ".") == 0 || strcmp(data.cFileName, "..") == 0) { + continue; + } + char *child = oliphaunt_join_path(path, data.cFileName); + if (child == NULL) { + FindClose(find); + errno = ENOMEM; + return -1; + } + int rc = oliphaunt_remove_tree_windows(child); + free(child); + if (rc != 0) { + FindClose(find); + return -1; + } + } while (FindNextFileA(find, &data)); + DWORD error = GetLastError(); + FindClose(find); + if (error != ERROR_NO_MORE_FILES) { + errno = EACCES; + return -1; + } + } + return rmdir(path); + } + return unlink(path); +} + +static int oliphaunt_directory_is_empty_windows(const char *path) { + char *pattern = oliphaunt_join_path(path, "*"); + if (pattern == NULL) { + errno = ENOMEM; + return -1; + } + WIN32_FIND_DATAA data; + HANDLE find = FindFirstFileA(pattern, &data); + free(pattern); + if (find == INVALID_HANDLE_VALUE) { + return -1; + } + do { + if (strcmp(data.cFileName, ".") != 0 && strcmp(data.cFileName, "..") != 0) { + FindClose(find); + return 0; + } + } while (FindNextFileA(find, &data)); + FindClose(find); + return 1; +} +#endif + +int oliphaunt_remove_tree(const char *path) { +#ifdef _WIN32 + return oliphaunt_remove_tree_windows(path); +#else + struct stat st; + if (lstat(path, &st) != 0) { + return errno == ENOENT ? 0 : -1; + } + if (S_ISDIR(st.st_mode)) { + DIR *dir = opendir(path); + if (dir == NULL) { + return -1; + } + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + char *child = oliphaunt_join_path(path, entry->d_name); + if (child == NULL) { + closedir(dir); + return -1; + } + int rc = oliphaunt_remove_tree(child); + free(child); + if (rc != 0) { + closedir(dir); + return -1; + } + } + closedir(dir); + return rmdir(path); + } + return unlink(path); +#endif +} + +int oliphaunt_directory_is_empty(const char *path) { +#ifdef _WIN32 + return oliphaunt_directory_is_empty_windows(path); +#else + DIR *dir = opendir(path); + if (dir == NULL) { + return -1; + } + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") != 0 && strcmp(entry->d_name, "..") != 0) { + closedir(dir); + return 0; + } + } + closedir(dir); + return 1; +#endif +} + +static char *oliphaunt_root_from_pgdata(const char *pgdata) { + return oliphaunt_path_parent_dup(pgdata); +} + +static void oliphaunt_stable_root_lock_suffix(const char *root_key, char out[33]) { + uint8_t digest[32]; + static const char hex[] = "0123456789abcdef"; + OliphauntSha256Ctx ctx; + pg_sha256_init(&ctx); + pg_sha256_update(&ctx, (const uint8_t *)root_key, strlen(root_key)); + pg_sha256_final(&ctx, digest); + for (size_t i = 0; i < 16; i++) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 0x0f]; + } + out[32] = '\0'; +} + +static char *oliphaunt_existing_lock_dir_dup(const char *root_key) { + char *cursor = oliphaunt_path_parent_dup(root_key); + while (cursor != NULL) { + struct stat st; + if (stat(cursor, &st) == 0 && S_ISDIR(st.st_mode)) { + return cursor; + } + if (strcmp(cursor, "/") == 0 || strcmp(cursor, ".") == 0) { + free(cursor); + return NULL; + } + char *parent = oliphaunt_path_parent_dup(cursor); + free(cursor); + cursor = parent; + } + return NULL; +} + +int oliphaunt_acquire_stable_root_lock(OliphauntHandle *handle, const char *root, int *out_fd, char **out_path) { + if (root == NULL || root[0] == '\0' || out_fd == NULL || out_path == NULL) { + set_error(handle, "invalid stable root lock arguments"); + return -1; + } + *out_fd = -1; + *out_path = NULL; + + char *root_key = oliphaunt_canonicalish_path_dup(root); + if (root_key == NULL) { + set_error(handle, "out of memory resolving stable native root lock key"); + return -1; + } + char *lock_dir = oliphaunt_existing_lock_dir_dup(root_key); + if (lock_dir == NULL) { + char message[1024]; + snprintf(message, sizeof(message), "native root %s has no parent directory for stable lock", root_key); + set_error(handle, message); + free(root_key); + return -1; + } + + char suffix[33]; + char leaf[128]; + oliphaunt_stable_root_lock_suffix(root_key, suffix); + snprintf(leaf, sizeof(leaf), ".oliphaunt-root-%s.lock", suffix); + char *lock_path = oliphaunt_join_path(lock_dir, leaf); + free(lock_dir); + if (lock_path == NULL) { + set_error(handle, "out of memory resolving stable native root lock path"); + free(root_key); + return -1; + } + + int fd = open(lock_path, O_RDWR | O_CREAT, 0600); + if (fd < 0) { + char message[1024]; + snprintf(message, sizeof(message), "open stable native root lock %s: %s", lock_path, strerror(errno)); + set_error(handle, message); + free(root_key); + free(lock_path); + return -1; + } + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "native root %s is already locked: %s", root_key, strerror(errno)); + set_error(handle, message); + free(root_key); + close(fd); + free(lock_path); + return -1; + } + + free(root_key); + *out_fd = fd; + *out_path = lock_path; + return 0; +} + +void oliphaunt_release_file_lock(int *fd, char **path) { + if (fd != NULL && *fd >= 0) { + (void)flock(*fd, LOCK_UN); + close(*fd); + *fd = -1; + } + if (path != NULL) { + free(*path); + *path = NULL; + } +} + +int oliphaunt_acquire_root_marker_lock(OliphauntHandle *handle, const char *pgdata) { + if (handle == NULL || pgdata == NULL || pgdata[0] == '\0') { + set_error(handle, "invalid root lock arguments"); + return -1; + } + char *root = oliphaunt_root_from_pgdata(pgdata); + if (root == NULL) { + set_error(handle, "out of memory resolving native root lock directory"); + return -1; + } + if (oliphaunt_acquire_stable_root_lock(handle, root, &handle->stable_root_lock_fd, &handle->stable_root_lock_path) != 0) { + free(root); + return -1; + } + if (oliphaunt_mkdir_p(root, 0700) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "create native root lock directory %s: %s", root, strerror(errno)); + set_error(handle, message); + oliphaunt_release_file_lock(&handle->stable_root_lock_fd, &handle->stable_root_lock_path); + free(root); + return -1; + } + char *lock_path = oliphaunt_join_path(root, ".oliphaunt.lock"); + free(root); + if (lock_path == NULL) { + set_error(handle, "out of memory resolving native root lock path"); + oliphaunt_release_file_lock(&handle->stable_root_lock_fd, &handle->stable_root_lock_path); + return -1; + } + int fd = open(lock_path, O_RDWR | O_CREAT, 0600); + if (fd < 0) { + char message[1024]; + snprintf(message, sizeof(message), "open native root lock %s: %s", lock_path, strerror(errno)); + set_error(handle, message); + free(lock_path); + oliphaunt_release_file_lock(&handle->stable_root_lock_fd, &handle->stable_root_lock_path); + return -1; + } + if (flock(fd, LOCK_EX | LOCK_NB) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "native root %s is already locked: %s", pgdata, strerror(errno)); + set_error(handle, message); + close(fd); + free(lock_path); + oliphaunt_release_file_lock(&handle->stable_root_lock_fd, &handle->stable_root_lock_path); + return -1; + } + handle->root_marker_lock_fd = fd; + handle->root_marker_lock_path = lock_path; + return 0; +} + +void oliphaunt_release_root_marker_lock(OliphauntHandle *handle) { + if (handle == NULL) { + return; + } + oliphaunt_release_file_lock(&handle->root_marker_lock_fd, &handle->root_marker_lock_path); + oliphaunt_release_file_lock(&handle->stable_root_lock_fd, &handle->stable_root_lock_path); +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h b/src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h new file mode 100644 index 00000000..8aa8f86e --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_internal.h @@ -0,0 +1,174 @@ +#ifndef OLIPHAUNT_INTERNAL_H +#define OLIPHAUNT_INTERNAL_H + +#include "../include/oliphaunt.h" +#include "liboliphaunt_platform.h" + +#include +#include +#include + +typedef struct OliphauntEmbeddedIO { + void *context; + ssize_t (*read)(void *context, void *ptr, size_t len); + ssize_t (*write)(void *context, const void *ptr, size_t len); +} OliphauntEmbeddedIO; + +typedef struct OliphauntOutputChunk { + unsigned char *data; + size_t len; + struct OliphauntOutputChunk *next; +} OliphauntOutputChunk; + +typedef struct OliphauntProtocolScanner { + unsigned char header[5]; + size_t header_len; + unsigned char tag; + size_t payload_remaining; + unsigned char ready_status; + bool ready_status_set; +} OliphauntProtocolScanner; + +struct OliphauntHandle { + char *pgdata; + char *runtime_dir; + char *username; + char *database; + char *postgres_path; + char *previous_pgdata_env; + char *previous_proj_data_env; + char **startup_args; + size_t startup_arg_count; + bool had_previous_pgdata_env; + bool pgdata_env_overridden; + bool had_previous_proj_data_env; + bool proj_data_env_overridden; + + pthread_t backend_thread; + bool thread_started; + bool backend_exited; + int backend_status; + + pthread_mutex_t mutex; + pthread_cond_t input_cond; + pthread_cond_t output_cond; + bool sync_initialized; + bool closing; + bool logical_active; + bool external_root_lock; + + unsigned char *input; + size_t input_len; + size_t input_off; + size_t input_cap; + + unsigned char *output; + size_t output_len; + size_t output_cap; + size_t output_scan_off; + bool output_ready; + unsigned char transaction_status; + + bool streaming; + bool stream_failed; + OliphauntOutputChunk *stream_head; + OliphauntOutputChunk *stream_tail; + size_t stream_bytes_queued; + size_t stream_queue_max_bytes; + OliphauntProtocolScanner stream_scanner; + + uint64_t trace_seq; + uint64_t trace_request_bytes; + uint64_t trace_response_bytes; + uint64_t trace_lock_ns; + uint64_t trace_input_copy_ns; + uint64_t trace_wait_ns; + uint64_t trace_response_copy_ns; + uint64_t trace_read_calls; + uint64_t trace_read_bytes; + uint64_t trace_read_copy_ns; + uint64_t trace_write_calls; + uint64_t trace_write_bytes; + uint64_t trace_write_append_ns; + uint64_t trace_ready_scan_calls; + uint64_t trace_ready_scan_ns; + uint64_t trace_output_grows; + bool trace_protocol; + + OliphauntEmbeddedIO io; + bool owns_global_guard; + int stable_root_lock_fd; + int root_marker_lock_fd; + char *stable_root_lock_path; + char *root_marker_lock_path; + char last_error[1024]; +}; + +typedef struct OliphauntByteBuffer { + uint8_t *data; + size_t len; + size_t cap; +} OliphauntByteBuffer; + +typedef struct OliphauntBackendArgv { + int argc; + char **argv; +} OliphauntBackendArgv; + +const char *oliphaunt_handle_pgdata(OliphauntHandle *handle); +void oliphaunt_set_error(OliphauntHandle *handle, const char *message); +const OliphauntStaticExtension *oliphaunt_static_extension_lookup(const char *filename); +const void *oliphaunt_static_extension_magic(const OliphauntStaticExtension *extension); +void *oliphaunt_static_extension_symbol(const OliphauntStaticExtension *extension, const char *symbol); +void oliphaunt_static_extension_init(const OliphauntStaticExtension *extension); + +char *oliphaunt_dup_config_string(const char *value, const char *fallback); +int oliphaunt_dup_startup_args(OliphauntHandle *handle, const OliphauntConfig *config); +int oliphaunt_run_initdb_if_needed(OliphauntHandle *handle); +char *oliphaunt_resolve_postgres_argv0(const char *runtime_dir); + +int oliphaunt_build_backend_argv(OliphauntHandle *handle, OliphauntBackendArgv *out); +void oliphaunt_free_backend_argv(OliphauntBackendArgv *argv); +size_t oliphaunt_backend_stack_size_bytes(void); + +int oliphaunt_acquire_global_instance(OliphauntHandle **existing); +void oliphaunt_publish_global_instance(OliphauntHandle *handle); +void oliphaunt_release_global_instance(bool spent); +void oliphaunt_clear_global_instance(OliphauntHandle *handle, bool spent); +void oliphaunt_register_process_exit_shutdown(void); + +bool oliphaunt_trace_enabled(void); +uint64_t oliphaunt_monotonic_ns(void); +uint64_t oliphaunt_elapsed_ns(uint64_t started_ns); +void oliphaunt_reset_trace_locked(OliphauntHandle *handle, size_t request_len); +void oliphaunt_print_trace_locked(OliphauntHandle *handle, uint64_t total_ns); + +ssize_t oliphaunt_embedded_read(void *context, void *ptr, size_t len); +ssize_t oliphaunt_embedded_write(void *context, const void *ptr, size_t len); +int oliphaunt_set_input_locked(OliphauntHandle *handle, const void *buf, size_t len); +int oliphaunt_startup_timeout_ms(void); +int oliphaunt_wait_for_ready_locked(OliphauntHandle *handle, int timeout_ms); +void oliphaunt_clear_stream_chunks_locked(OliphauntHandle *handle); + +int oliphaunt_path_exists(const char *path); +char *oliphaunt_join_path(const char *left, const char *right); +char *oliphaunt_path_parent_dup(const char *path); +char *oliphaunt_path_file_name_dup(const char *path); +int oliphaunt_mkdir_p(const char *path, mode_t mode); +int oliphaunt_remove_tree(const char *path); +int oliphaunt_directory_is_empty(const char *path); +int oliphaunt_acquire_stable_root_lock(OliphauntHandle *handle, const char *root, int *out_fd, char **out_path); +void oliphaunt_release_file_lock(int *fd, char **path); +int oliphaunt_acquire_root_marker_lock(OliphauntHandle *handle, const char *pgdata); +void oliphaunt_release_root_marker_lock(OliphauntHandle *handle); + +int oliphaunt_archive_append_pgdata_tree(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *pgdata); +int oliphaunt_archive_append_pg_wal_tree(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *pgdata); +int oliphaunt_archive_append_generated_file(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *archive_path, const char *contents); +int oliphaunt_archive_append_generated_bytes(OliphauntByteBuffer *archive, OliphauntHandle *handle, const char *archive_path, const uint8_t *contents, size_t len, uint32_t mode); +int oliphaunt_archive_finish(OliphauntByteBuffer *archive, OliphauntHandle *handle); +int oliphaunt_unpack_physical_archive(OliphauntHandle *handle, const uint8_t *data, size_t len, const char *staging_root); + +#define set_error oliphaunt_set_error + +#endif diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c new file mode 100644 index 00000000..b6bb38ae --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_native.c @@ -0,0 +1,564 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "../include/oliphaunt.h" +#include "liboliphaunt_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#endif + +extern int oliphaunt_embedded_main( + int argc, + char **argv, + const char *dbname, + const char *username, + OliphauntEmbeddedIO *io); + +typedef struct Latch Latch; + +extern volatile sig_atomic_t InterruptPending; +extern volatile sig_atomic_t QueryCancelPending; +extern Latch *MyLatch; +extern void SetLatch(Latch *latch); + +static char global_last_error[1024]; + +void oliphaunt_set_error(OliphauntHandle *handle, const char *message) { + char *target = handle ? handle->last_error : global_last_error; + snprintf(target, 1024, "%s", message ? message : "unknown native liboliphaunt error"); +} + +const char *oliphaunt_handle_pgdata(OliphauntHandle *handle) { + return handle != NULL ? handle->pgdata : NULL; +} + +static bool config_string_matches(const char *actual, const char *requested, const char *fallback) { + const char *expected = requested != NULL ? requested : fallback; + return strcmp(actual != NULL ? actual : "", expected != NULL ? expected : "") == 0; +} + +static bool startup_args_match(OliphauntHandle *handle, const OliphauntConfig *config) { + if (handle->startup_arg_count != config->startup_arg_count) { + return false; + } + for (size_t i = 0; i < handle->startup_arg_count; i++) { + const char *expected = config->startup_args != NULL ? config->startup_args[i] : NULL; + if (expected == NULL || strcmp(handle->startup_args[i], expected) != 0) { + return false; + } + } + return true; +} + +static bool config_matches_resident_runtime(OliphauntHandle *handle, const OliphauntConfig *config) { + bool external_root_lock = (config->reserved_flags & OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK) != 0; + return handle != NULL && + config_string_matches(handle->pgdata, config->pgdata, "") && + config_string_matches(handle->runtime_dir, config->runtime_dir, "") && + config_string_matches(handle->username, config->username, "postgres") && + config_string_matches(handle->database, config->database, "postgres") && + handle->external_root_lock == external_root_lock && + startup_args_match(handle, config); +} + +static int reopen_resident_runtime(OliphauntHandle *handle, const OliphauntConfig *config, OliphauntHandle **out) { + if (handle == NULL) { + set_error(NULL, "native liboliphaunt process-wide runtime is unavailable"); + return -1; + } + pthread_mutex_lock(&handle->mutex); + if (handle->logical_active) { + pthread_mutex_unlock(&handle->mutex); + set_error(NULL, "native liboliphaunt already has an active logical direct handle"); + return -1; + } + if (handle->backend_exited || handle->closing) { + pthread_mutex_unlock(&handle->mutex); + set_error(NULL, "native liboliphaunt resident runtime has already shut down"); + return -1; + } + if (!config_matches_resident_runtime(handle, config)) { + pthread_mutex_unlock(&handle->mutex); + set_error(NULL, "native liboliphaunt resident runtime is bound to a different root, identity, runtime, or extension startup configuration"); + return -1; + } + handle->logical_active = true; + handle->last_error[0] = '\0'; + *out = handle; + pthread_mutex_unlock(&handle->mutex); + return 0; +} + +static int set_backend_env_var( + OliphauntHandle *handle, + const char *name, + const char *value, + char **previous, + bool *had_previous, + bool *overridden, + const char *label) { + const char *current = getenv(name); + *had_previous = current != NULL; + if (current != NULL) { + *previous = strdup(current); + if (*previous == NULL) { + char message[1024]; + snprintf(message, sizeof(message), "out of memory saving %s environment", name); + set_error(handle, message); + return -1; + } + } + if (setenv(name, value, 1) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "set %s environment for embedded backend %s: %s", name, label, strerror(errno)); + set_error(handle, message); + free(*previous); + *previous = NULL; + *had_previous = false; + return -1; + } + *overridden = true; + return 0; +} + +static void restore_backend_env_var( + const char *name, + char **previous, + bool *had_previous, + bool *overridden) { + if (!*overridden) { + return; + } + if (*had_previous) { + (void)setenv(name, *previous != NULL ? *previous : "", 1); + } else { + (void)unsetenv(name); + } + free(*previous); + *previous = NULL; + *had_previous = false; + *overridden = false; +} + +static int set_backend_pgdata_env(OliphauntHandle *handle) { + return set_backend_env_var( + handle, + "PGDATA", + handle->pgdata, + &handle->previous_pgdata_env, + &handle->had_previous_pgdata_env, + &handle->pgdata_env_overridden, + "data directory"); +} + +static int set_backend_proj_data_env(OliphauntHandle *handle) { + if (handle->runtime_dir == NULL || handle->runtime_dir[0] == '\0') { + return 0; + } + char *proj_dir = oliphaunt_join_path(handle->runtime_dir, "share/postgresql/proj"); + if (proj_dir == NULL) { + set_error(handle, "out of memory resolving PostGIS PROJ data directory"); + return -1; + } + char *proj_db = oliphaunt_join_path(proj_dir, "proj.db"); + if (proj_db == NULL) { + free(proj_dir); + set_error(handle, "out of memory resolving PostGIS proj.db path"); + return -1; + } + int has_proj_db = oliphaunt_path_exists(proj_db); + free(proj_db); + if (!has_proj_db) { + free(proj_dir); + return 0; + } + + int rc = set_backend_env_var( + handle, + "PROJ_DATA", + proj_dir, + &handle->previous_proj_data_env, + &handle->had_previous_proj_data_env, + &handle->proj_data_env_overridden, + "PostGIS PROJ data"); + free(proj_dir); + return rc; +} + +static int set_backend_runtime_env(OliphauntHandle *handle) { + if (set_backend_pgdata_env(handle) != 0) { + return -1; + } + if (set_backend_proj_data_env(handle) != 0) { + return -1; + } + return 0; +} + +static void restore_backend_runtime_env(OliphauntHandle *handle) { + if (handle == NULL) { + return; + } + restore_backend_env_var( + "PROJ_DATA", + &handle->previous_proj_data_env, + &handle->had_previous_proj_data_env, + &handle->proj_data_env_overridden); + restore_backend_env_var( + "PGDATA", + &handle->previous_pgdata_env, + &handle->had_previous_pgdata_env, + &handle->pgdata_env_overridden); +} + +static void mark_backend_failed(OliphauntHandle *handle) { + pthread_mutex_lock(&handle->mutex); + if (handle->last_error[0] == '\0') { + set_error(handle, "embedded backend failed before startup"); + } + handle->backend_status = -1; + handle->backend_exited = true; + handle->closing = true; + pthread_cond_broadcast(&handle->input_cond); + pthread_cond_broadcast(&handle->output_cond); + pthread_mutex_unlock(&handle->mutex); +} + +static void *backend_thread_main(void *arg) { + OliphauntHandle *handle = (OliphauntHandle *)arg; + OliphauntBackendArgv backend_argv = {0}; + if (oliphaunt_build_backend_argv(handle, &backend_argv) != 0) { + if (handle->last_error[0] == '\0') { + set_error(handle, "failed to build embedded backend argv"); + } + mark_backend_failed(handle); + return NULL; + } + if (set_backend_runtime_env(handle) != 0) { + restore_backend_runtime_env(handle); + oliphaunt_free_backend_argv(&backend_argv); + mark_backend_failed(handle); + return NULL; + } + + int rc = oliphaunt_embedded_main( + backend_argv.argc, + backend_argv.argv, + handle->database, + handle->username, + &handle->io); + restore_backend_runtime_env(handle); + oliphaunt_free_backend_argv(&backend_argv); + + pthread_mutex_lock(&handle->mutex); + handle->backend_status = rc; + handle->backend_exited = true; + handle->closing = true; + pthread_cond_broadcast(&handle->input_cond); + pthread_cond_broadcast(&handle->output_cond); + pthread_mutex_unlock(&handle->mutex); + return NULL; +} + +static int start_backend(OliphauntHandle *handle) { + if (oliphaunt_run_initdb_if_needed(handle) != 0) { + return -1; + } + + handle->postgres_path = oliphaunt_resolve_postgres_argv0(handle->runtime_dir); + if (handle->postgres_path == NULL) { + set_error(handle, "out of memory while resolving postgres path"); + return -1; + } + + handle->io.context = handle; + handle->io.read = oliphaunt_embedded_read; + handle->io.write = oliphaunt_embedded_write; + + pthread_attr_t attr; + int rc = pthread_attr_init(&attr); + if (rc != 0) { + snprintf(handle->last_error, sizeof(handle->last_error), "pthread_attr_init failed: %d", rc); + return -1; + } + size_t stack_size = oliphaunt_backend_stack_size_bytes(); + rc = pthread_attr_setstacksize(&attr, stack_size); + if (rc != 0) { + pthread_attr_destroy(&attr); + snprintf( + handle->last_error, + sizeof(handle->last_error), + "pthread_attr_setstacksize(%zu) failed: %d", + stack_size, + rc); + return -1; + } + rc = pthread_create(&handle->backend_thread, &attr, backend_thread_main, handle); + pthread_attr_destroy(&attr); + if (rc != 0) { + snprintf(handle->last_error, sizeof(handle->last_error), "pthread_create failed: %d", rc); + return -1; + } + handle->thread_started = true; + + pthread_mutex_lock(&handle->mutex); + rc = oliphaunt_wait_for_ready_locked(handle, oliphaunt_startup_timeout_ms()); + if (rc == 0) { + handle->output_len = 0; + handle->output_scan_off = 0; + handle->output_ready = false; + } + pthread_mutex_unlock(&handle->mutex); + return rc; +} + +int32_t oliphaunt_init(const OliphauntConfig *config, OliphauntHandle **out) { + if (out == NULL) { + set_error(NULL, "oliphaunt_init out parameter is null"); + return -1; + } + *out = NULL; + if (config == NULL || config->abi_version != OLIPHAUNT_ABI_VERSION || config->pgdata == NULL) { + set_error(NULL, "invalid oliphaunt_init config"); + return -1; + } + if ((config->reserved_flags & ~OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK) != 0) { + set_error(NULL, "invalid oliphaunt_init config flags"); + return -1; + } + OliphauntHandle *existing = NULL; + int acquire_rc = oliphaunt_acquire_global_instance(&existing); + if (acquire_rc < 0) { + return -1; + } + if (acquire_rc > 0) { + return reopen_resident_runtime(existing, config, out); + } + + OliphauntHandle *handle = (OliphauntHandle *)calloc(1, sizeof(OliphauntHandle)); + if (handle == NULL) { + oliphaunt_release_global_instance(false); + set_error(NULL, "out of memory allocating OliphauntHandle"); + return -1; + } + handle->owns_global_guard = true; + handle->stable_root_lock_fd = -1; + handle->root_marker_lock_fd = -1; + handle->trace_protocol = oliphaunt_trace_enabled(); + handle->external_root_lock = (config->reserved_flags & OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK) != 0; + handle->transaction_status = 'I'; + + handle->pgdata = oliphaunt_dup_config_string(config->pgdata, ""); + handle->runtime_dir = oliphaunt_dup_config_string(config->runtime_dir, ""); + handle->username = oliphaunt_dup_config_string(config->username, "postgres"); + handle->database = oliphaunt_dup_config_string(config->database, "postgres"); + if (handle->pgdata == NULL || handle->runtime_dir == NULL || + handle->username == NULL || handle->database == NULL) { + oliphaunt_close(handle); + set_error(NULL, "out of memory copying oliphaunt config"); + return -1; + } + if (oliphaunt_dup_startup_args(handle, config) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "%s", handle->last_error); + oliphaunt_close(handle); + set_error(NULL, message); + return -1; + } + if ((config->reserved_flags & OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK) == 0 && + oliphaunt_acquire_root_marker_lock(handle, handle->pgdata) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "%s", handle->last_error); + oliphaunt_close(handle); + set_error(NULL, message); + return -1; + } + + if (pthread_mutex_init(&handle->mutex, NULL) != 0 || + pthread_cond_init(&handle->input_cond, NULL) != 0 || + pthread_cond_init(&handle->output_cond, NULL) != 0) { + oliphaunt_close(handle); + set_error(NULL, "failed to initialize native liboliphaunt synchronization"); + return -1; + } + handle->sync_initialized = true; + + if (start_backend(handle) != 0) { + char message[1024]; + snprintf(message, sizeof(message), "%s", handle->last_error); + oliphaunt_close(handle); + set_error(NULL, message); + return -1; + } + + handle->logical_active = true; + oliphaunt_register_process_exit_shutdown(); + oliphaunt_publish_global_instance(handle); + *out = handle; + return 0; +} + +int32_t oliphaunt_detach(OliphauntHandle *handle) { + if (handle == NULL) { + return 0; + } + pthread_mutex_lock(&handle->mutex); + if (!handle->logical_active) { + pthread_mutex_unlock(&handle->mutex); + return 0; + } + bool can_reset = handle->sync_initialized && handle->thread_started && !handle->backend_exited && !handle->closing; + bool in_transaction = handle->transaction_status != 'I'; + pthread_mutex_unlock(&handle->mutex); + + if (can_reset) { + if (in_transaction) { + OliphauntResponse response = {0}; + static const char rollback_sql[] = "ROLLBACK"; + int32_t rc = oliphaunt_exec_simple_query(handle, rollback_sql, sizeof(rollback_sql) - 1, &response); + oliphaunt_free_response(&response); + if (rc != 0) { + return rc; + } + } + + OliphauntResponse response = {0}; + static const char discard_sql[] = "DISCARD ALL"; + int32_t rc = oliphaunt_exec_simple_query(handle, discard_sql, sizeof(discard_sql) - 1, &response); + oliphaunt_free_response(&response); + if (rc != 0) { + return rc; + } + } + + pthread_mutex_lock(&handle->mutex); + handle->logical_active = false; + handle->output_len = 0; + handle->output_scan_off = 0; + handle->output_ready = false; + oliphaunt_clear_stream_chunks_locked(handle); + pthread_mutex_unlock(&handle->mutex); + return 0; +} + +int32_t oliphaunt_close(OliphauntHandle *handle) { + if (handle == NULL) { + return 0; + } + + if (handle->sync_initialized) { + pthread_mutex_lock(&handle->mutex); + handle->logical_active = false; + if (handle->thread_started && !handle->backend_exited) { + static const unsigned char terminate[] = {'X', 0, 0, 0, 4}; + handle->closing = true; + if (handle->input_len == 0) { + (void)oliphaunt_set_input_locked(handle, terminate, sizeof(terminate)); + } else { + pthread_cond_broadcast(&handle->input_cond); + } + } else { + handle->closing = true; + pthread_cond_broadcast(&handle->input_cond); + } + pthread_mutex_unlock(&handle->mutex); + } + + if (handle->thread_started) { + pthread_join(handle->backend_thread, NULL); + } + + if (handle->sync_initialized) { + pthread_cond_destroy(&handle->input_cond); + pthread_cond_destroy(&handle->output_cond); + pthread_mutex_destroy(&handle->mutex); + } + + free(handle->pgdata); + free(handle->runtime_dir); + free(handle->username); + free(handle->database); + free(handle->postgres_path); + free(handle->previous_pgdata_env); + free(handle->previous_proj_data_env); + oliphaunt_release_root_marker_lock(handle); + for (size_t i = 0; i < handle->startup_arg_count; i++) { + free(handle->startup_args[i]); + } + free(handle->startup_args); + free(handle->input); + free(handle->output); + oliphaunt_clear_stream_chunks_locked(handle); + if (handle->owns_global_guard) { + oliphaunt_clear_global_instance(handle, handle->thread_started); + } + free(handle); + return 0; +} + +int32_t oliphaunt_cancel(OliphauntHandle *handle) { + if (handle == NULL) { + set_error(NULL, "invalid oliphaunt_cancel arguments"); + return -1; + } + + pthread_mutex_lock(&handle->mutex); + if (!handle->logical_active) { + set_error(handle, "native liboliphaunt logical handle is closed"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (!handle->thread_started || handle->backend_exited || handle->closing) { + set_error(handle, "native backend is not running"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + + InterruptPending = true; + QueryCancelPending = true; + if (MyLatch != NULL) { + SetLatch(MyLatch); + } + pthread_cond_broadcast(&handle->input_cond); + pthread_cond_broadcast(&handle->output_cond); + pthread_mutex_unlock(&handle->mutex); + return 0; +} + +const char *oliphaunt_last_error(OliphauntHandle *handle) { + return handle ? handle->last_error : global_last_error; +} + +const char *oliphaunt_version(void) { + return "native-liboliphaunt-postgresql-18.4-spike-0"; +} + +uint64_t oliphaunt_capabilities(void) { + return OLIPHAUNT_CAP_PROTOCOL_RAW | + OLIPHAUNT_CAP_PROTOCOL_STREAM | + OLIPHAUNT_CAP_EXTENSIONS | + OLIPHAUNT_CAP_QUERY_CANCEL | + OLIPHAUNT_CAP_BACKUP_RESTORE | + OLIPHAUNT_CAP_SIMPLE_QUERY | + OLIPHAUNT_CAP_STATIC_EXTENSIONS | + OLIPHAUNT_CAP_LOGICAL_REOPEN; +} + +void oliphaunt_free_response(OliphauntResponse *response) { + if (response == NULL) { + return; + } + free(response->data); + response->data = NULL; + response->len = 0; +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_platform.h b/src/runtimes/liboliphaunt/native/src/liboliphaunt_platform.h new file mode 100644 index 00000000..0437e561 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_platform.h @@ -0,0 +1,371 @@ +#ifndef OLIPHAUNT_PLATFORM_H +#define OLIPHAUNT_PLATFORM_H + +#include +#include +#include +#include +#include + +#ifdef _WIN32 + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef _CRT_SECURE_NO_WARNINGS +#define _CRT_SECURE_NO_WARNINGS +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef OLIPHAUNT_PLATFORM_EXTERNAL_POSIX_SHIMS +typedef SSIZE_T ssize_t; +typedef int mode_t; +typedef int uid_t; +typedef int gid_t; + +#ifndef PATH_MAX +#define PATH_MAX MAX_PATH +#endif + +#ifndef S_ISDIR +#define S_ISDIR(mode) (((mode) & _S_IFDIR) != 0) +#endif + +#ifndef S_ISREG +#define S_ISREG(mode) (((mode) & _S_IFREG) != 0) +#endif + +#ifndef S_ISLNK +#define S_ISLNK(mode) 0 +#endif +#endif + +#ifndef CLOCK_REALTIME +#define CLOCK_REALTIME 0 +#endif + +#ifndef CLOCK_MONOTONIC +#define CLOCK_MONOTONIC 1 +#endif + +#ifndef OLIPHAUNT_PLATFORM_EXTERNAL_POSIX_SHIMS +#ifndef PTHREAD_STACK_MIN +#define PTHREAD_STACK_MIN (64 * 1024) +#endif + +#define strdup _strdup +#define open _open +#define read _read +#define write _write +#define close _close +#define access _access +#define unlink _unlink +#define rmdir _rmdir +#define getcwd _getcwd +#define getpid _getpid +#define mkdir(path, mode) _mkdir(path) +#define stat _stat64 +#define lstat _stat64 +#ifndef X_OK +#define X_OK 0 +#endif +#ifndef O_CLOEXEC +#define O_CLOEXEC _O_NOINHERIT +#endif + +static inline int oliphaunt_fchmod(int fd, mode_t mode) { + (void)fd; + (void)mode; + return 0; +} + +#define fchmod oliphaunt_fchmod +#endif + +typedef SRWLOCK pthread_mutex_t; +typedef CONDITION_VARIABLE pthread_cond_t; +typedef INIT_ONCE pthread_once_t; +typedef HANDLE pthread_t; + +typedef struct pthread_attr_t { + size_t stack_size; +} pthread_attr_t; + +#define PTHREAD_MUTEX_INITIALIZER SRWLOCK_INIT +#define PTHREAD_COND_INITIALIZER CONDITION_VARIABLE_INIT +#define PTHREAD_ONCE_INIT INIT_ONCE_STATIC_INIT + +static inline int pthread_mutex_init(pthread_mutex_t *mutex, void *attr) { + (void)attr; + InitializeSRWLock(mutex); + return 0; +} + +static inline int pthread_mutex_destroy(pthread_mutex_t *mutex) { + (void)mutex; + return 0; +} + +static inline int pthread_mutex_lock(pthread_mutex_t *mutex) { + AcquireSRWLockExclusive(mutex); + return 0; +} + +static inline int pthread_mutex_unlock(pthread_mutex_t *mutex) { + ReleaseSRWLockExclusive(mutex); + return 0; +} + +static inline int pthread_cond_init(pthread_cond_t *cond, void *attr) { + (void)attr; + InitializeConditionVariable(cond); + return 0; +} + +static inline int pthread_cond_destroy(pthread_cond_t *cond) { + (void)cond; + return 0; +} + +static inline int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) { + return SleepConditionVariableSRW(cond, mutex, INFINITE, 0) ? 0 : (int)GetLastError(); +} + +static BOOL CALLBACK oliphaunt_win_init_qpc_frequency(PINIT_ONCE once, PVOID parameter, PVOID *context) { + (void)once; + (void)context; + return QueryPerformanceFrequency((LARGE_INTEGER *)parameter); +} + +static inline int oliphaunt_clock_gettime(int clock_id, struct timespec *ts) { + if (clock_id == CLOCK_MONOTONIC) { + static LARGE_INTEGER frequency; + static INIT_ONCE frequency_once = INIT_ONCE_STATIC_INIT; + LARGE_INTEGER counter; + if (!InitOnceExecuteOnce(&frequency_once, oliphaunt_win_init_qpc_frequency, &frequency, NULL) || + !QueryPerformanceCounter(&counter)) { + return -1; + } + ts->tv_sec = (time_t)(counter.QuadPart / frequency.QuadPart); + ts->tv_nsec = (long)(((counter.QuadPart % frequency.QuadPart) * 1000000000LL) / frequency.QuadPart); + return 0; + } + return timespec_get(ts, TIME_UTC) == TIME_UTC ? 0 : -1; +} + +static inline DWORD oliphaunt_deadline_to_timeout_ms(const struct timespec *deadline) { + struct timespec now; + if (deadline == NULL || oliphaunt_clock_gettime(CLOCK_REALTIME, &now) != 0) { + return INFINITE; + } + int64_t seconds = (int64_t)deadline->tv_sec - (int64_t)now.tv_sec; + int64_t nanoseconds = (int64_t)deadline->tv_nsec - (int64_t)now.tv_nsec; + int64_t milliseconds = seconds * 1000 + nanoseconds / 1000000; + if (nanoseconds > 0 && milliseconds == 0) { + milliseconds = 1; + } + if (milliseconds <= 0) { + return 0; + } + if (milliseconds > (int64_t)0x7fffffff) { + return 0x7fffffff; + } + return (DWORD)milliseconds; +} + +static inline int pthread_cond_timedwait( + pthread_cond_t *cond, + pthread_mutex_t *mutex, + const struct timespec *deadline) { + DWORD timeout_ms = oliphaunt_deadline_to_timeout_ms(deadline); + if (SleepConditionVariableSRW(cond, mutex, timeout_ms, 0)) { + return 0; + } + DWORD error = GetLastError(); + return error == ERROR_TIMEOUT ? ETIMEDOUT : (int)error; +} + +static inline int pthread_cond_broadcast(pthread_cond_t *cond) { + WakeAllConditionVariable(cond); + return 0; +} + +static inline int pthread_attr_init(pthread_attr_t *attr) { + attr->stack_size = 0; + return 0; +} + +static inline int pthread_attr_destroy(pthread_attr_t *attr) { + (void)attr; + return 0; +} + +static inline int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stack_size) { + attr->stack_size = stack_size; + return 0; +} + +typedef struct OliphauntWinThreadStart { + void *(*start)(void *); + void *arg; +} OliphauntWinThreadStart; + +static unsigned __stdcall oliphaunt_win_thread_main(void *arg) { + OliphauntWinThreadStart *state = (OliphauntWinThreadStart *)arg; + void *(*start)(void *) = state->start; + void *start_arg = state->arg; + free(state); + (void)start(start_arg); + return 0; +} + +static inline int pthread_create( + pthread_t *thread, + const pthread_attr_t *attr, + void *(*start)(void *), + void *arg) { + OliphauntWinThreadStart *state = (OliphauntWinThreadStart *)calloc(1, sizeof(*state)); + if (state == NULL) { + return ENOMEM; + } + state->start = start; + state->arg = arg; + uintptr_t handle = _beginthreadex( + NULL, + attr != NULL ? (unsigned)attr->stack_size : 0, + oliphaunt_win_thread_main, + state, + 0, + NULL); + if (handle == 0) { + int rc = errno != 0 ? errno : EINVAL; + free(state); + return rc; + } + *thread = (HANDLE)handle; + return 0; +} + +static inline int pthread_join(pthread_t thread, void **value_ptr) { + (void)value_ptr; + if (thread == NULL) { + return EINVAL; + } + DWORD rc = WaitForSingleObject(thread, INFINITE); + CloseHandle(thread); + return rc == WAIT_OBJECT_0 ? 0 : (int)GetLastError(); +} + +static BOOL CALLBACK oliphaunt_win_once_callback( + PINIT_ONCE once, + PVOID parameter, + PVOID *context) { + (void)once; + (void)context; + void (*callback)(void) = (void (*)(void))parameter; + callback(); + return TRUE; +} + +static inline int pthread_once(pthread_once_t *once, void (*callback)(void)) { + return InitOnceExecuteOnce(once, oliphaunt_win_once_callback, (PVOID)callback, NULL) + ? 0 + : (int)GetLastError(); +} + +static inline int oliphaunt_setenv(const char *name, const char *value, int overwrite) { + if (!overwrite) { + size_t required = 0; + getenv_s(&required, NULL, 0, name); + if (required > 0) { + return 0; + } + } + return _putenv_s(name, value != NULL ? value : ""); +} + +static inline int oliphaunt_unsetenv(const char *name) { + return _putenv_s(name, ""); +} + +static inline char *oliphaunt_realpath(const char *path, char *resolved) { + DWORD required = GetFullPathNameA(path, 0, NULL, NULL); + if (required == 0) { + return NULL; + } + char *buffer = resolved != NULL ? resolved : (char *)malloc(required); + if (buffer == NULL) { + errno = ENOMEM; + return NULL; + } + DWORD written = GetFullPathNameA(path, required, buffer, NULL); + if (written == 0 || written >= required) { + if (resolved == NULL) { + free(buffer); + } + return NULL; + } + return buffer; +} + +#ifndef OLIPHAUNT_PLATFORM_EXTERNAL_POSIX_SHIMS +#define setenv oliphaunt_setenv +#define unsetenv oliphaunt_unsetenv +#define realpath oliphaunt_realpath +#define clock_gettime oliphaunt_clock_gettime +#endif + +#define LOCK_EX 1 +#define LOCK_NB 2 +#define LOCK_UN 4 + +static inline int flock(int fd, int operation) { + intptr_t os_handle = _get_osfhandle(fd); + if (os_handle == -1) { + errno = EBADF; + return -1; + } + HANDLE handle = (HANDLE)os_handle; + OVERLAPPED overlapped; + memset(&overlapped, 0, sizeof(overlapped)); + if ((operation & LOCK_UN) != 0) { + if (UnlockFileEx(handle, 0, MAXDWORD, MAXDWORD, &overlapped)) { + return 0; + } + } else { + DWORD flags = 0; + if ((operation & LOCK_EX) != 0) { + flags |= LOCKFILE_EXCLUSIVE_LOCK; + } + if ((operation & LOCK_NB) != 0) { + flags |= LOCKFILE_FAIL_IMMEDIATELY; + } + if (LockFileEx(handle, flags, 0, MAXDWORD, MAXDWORD, &overlapped)) { + return 0; + } + } + DWORD error = GetLastError(); + errno = error == ERROR_LOCK_VIOLATION ? EWOULDBLOCK : EACCES; + return -1; +} + +#else + +#include +#include +#include + +#endif + +#endif diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c new file mode 100644 index 00000000..cad0c541 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_process.c @@ -0,0 +1,86 @@ +#include "liboliphaunt_internal.h" + +#include + +static pthread_mutex_t global_instance_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_once_t process_exit_shutdown_once = PTHREAD_ONCE_INIT; +static enum { + OLIPHAUNT_GLOBAL_UNUSED = 0, + OLIPHAUNT_GLOBAL_ACTIVE, + OLIPHAUNT_GLOBAL_SPENT, +} global_instance_state = OLIPHAUNT_GLOBAL_UNUSED; +static OliphauntHandle *global_instance = NULL; + +static void oliphaunt_shutdown_global_instance_at_exit(void) { + OliphauntHandle *handle = NULL; + pthread_mutex_lock(&global_instance_mutex); + if (global_instance_state == OLIPHAUNT_GLOBAL_ACTIVE && global_instance != NULL) { + handle = global_instance; + global_instance = NULL; + global_instance_state = OLIPHAUNT_GLOBAL_SPENT; + } + pthread_mutex_unlock(&global_instance_mutex); + + if (handle != NULL) { + (void)oliphaunt_close(handle); + } +} + +static void oliphaunt_register_process_exit_shutdown_once(void) { + (void)atexit(oliphaunt_shutdown_global_instance_at_exit); +} + +void oliphaunt_register_process_exit_shutdown(void) { + (void)pthread_once( + &process_exit_shutdown_once, + oliphaunt_register_process_exit_shutdown_once); +} + +int oliphaunt_acquire_global_instance(OliphauntHandle **existing) { + if (existing != NULL) { + *existing = NULL; + } + pthread_mutex_lock(&global_instance_mutex); + if (global_instance_state == OLIPHAUNT_GLOBAL_ACTIVE) { + if (existing != NULL && global_instance != NULL) { + *existing = global_instance; + pthread_mutex_unlock(&global_instance_mutex); + return 1; + } + pthread_mutex_unlock(&global_instance_mutex); + set_error(NULL, "native liboliphaunt already has an active process-wide instance"); + return -1; + } + if (global_instance_state == OLIPHAUNT_GLOBAL_SPENT) { + pthread_mutex_unlock(&global_instance_mutex); + set_error(NULL, "native liboliphaunt process lifetime has already been used"); + return -1; + } + global_instance_state = OLIPHAUNT_GLOBAL_ACTIVE; + pthread_mutex_unlock(&global_instance_mutex); + return 0; +} + +void oliphaunt_publish_global_instance(OliphauntHandle *handle) { + pthread_mutex_lock(&global_instance_mutex); + if (global_instance_state == OLIPHAUNT_GLOBAL_ACTIVE) { + global_instance = handle; + } + pthread_mutex_unlock(&global_instance_mutex); +} + +void oliphaunt_release_global_instance(bool spent) { + pthread_mutex_lock(&global_instance_mutex); + global_instance_state = spent ? OLIPHAUNT_GLOBAL_SPENT : OLIPHAUNT_GLOBAL_UNUSED; + global_instance = NULL; + pthread_mutex_unlock(&global_instance_mutex); +} + +void oliphaunt_clear_global_instance(OliphauntHandle *handle, bool spent) { + pthread_mutex_lock(&global_instance_mutex); + if (global_instance == handle || global_instance == NULL) { + global_instance = NULL; + global_instance_state = spent ? OLIPHAUNT_GLOBAL_SPENT : OLIPHAUNT_GLOBAL_UNUSED; + } + pthread_mutex_unlock(&global_instance_mutex); +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c new file mode 100644 index 00000000..a4b99e06 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_protocol.c @@ -0,0 +1,766 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "../include/oliphaunt.h" +#include "liboliphaunt_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define DEFAULT_WAIT_TIMEOUT_MS 60000 +#define DEFAULT_STREAM_QUEUE_MAX_BYTES (4 * 1024 * 1024) + +static int validate_frontend_protocol_frames(OliphauntHandle *handle, const uint8_t *request, size_t request_len) { + if (request_len == 0) { + set_error(handle, "malformed frontend protocol request: empty request"); + return -1; + } + size_t off = 0; + while (off < request_len) { + if (request_len - off < 5) { + set_error(handle, "malformed frontend protocol request: truncated message header"); + return -1; + } + uint32_t msg_len = ((uint32_t)request[off + 1] << 24) | + ((uint32_t)request[off + 2] << 16) | + ((uint32_t)request[off + 3] << 8) | + (uint32_t)request[off + 4]; + if (msg_len < 4) { + set_error(handle, "malformed frontend protocol request: message length is smaller than protocol header"); + return -1; + } + size_t total = (size_t)msg_len + 1; + if (total > request_len - off) { + set_error(handle, "malformed frontend protocol request: truncated message body"); + return -1; + } + off += total; + } + return 0; +} + +static int append_output_locked(OliphauntHandle *handle, const void *buf, size_t len) { + if (len == 0) { + return 0; + } + size_t required = handle->output_len + len; + if (required > handle->output_cap) { + size_t next = handle->output_cap ? handle->output_cap : 8192; + while (next < required) { + next *= 2; + } + unsigned char *grown = (unsigned char *)realloc(handle->output, next); + if (grown == NULL) { + errno = ENOMEM; + return -1; + } + handle->output = grown; + handle->output_cap = next; + if (handle->trace_protocol) { + handle->trace_output_grows++; + } + } + memcpy(handle->output + handle->output_len, buf, len); + handle->output_len += len; + return 0; +} + +static uint32_t read_be32(const unsigned char *ptr) { + return ((uint32_t)ptr[0] << 24) | + ((uint32_t)ptr[1] << 16) | + ((uint32_t)ptr[2] << 8) | + (uint32_t)ptr[3]; +} + +static void reset_protocol_scanner(OliphauntProtocolScanner *scanner) { + memset(scanner, 0, sizeof(*scanner)); +} + +static bool scan_stream_ready_locked(OliphauntHandle *handle, const unsigned char *buf, size_t len) { + OliphauntProtocolScanner *scanner = &handle->stream_scanner; + while (len > 0) { + if (scanner->header_len < sizeof(scanner->header)) { + size_t need = sizeof(scanner->header) - scanner->header_len; + size_t take = len < need ? len : need; + memcpy(scanner->header + scanner->header_len, buf, take); + scanner->header_len += take; + buf += take; + len -= take; + if (scanner->header_len < sizeof(scanner->header)) { + continue; + } + + scanner->tag = scanner->header[0]; + uint32_t msg_len = read_be32(scanner->header + 1); + if (msg_len < 4) { + handle->stream_failed = true; + set_error(handle, "invalid backend protocol frame while streaming"); + return true; + } + scanner->payload_remaining = (size_t)msg_len - 4; + if (scanner->payload_remaining == 0) { + bool ready = scanner->tag == 'Z'; + if (ready) { + handle->transaction_status = 'I'; + } + reset_protocol_scanner(scanner); + if (ready) { + return true; + } + } + } + + if (scanner->payload_remaining > 0) { + size_t take = len < scanner->payload_remaining ? len : scanner->payload_remaining; + if (scanner->tag == 'Z' && !scanner->ready_status_set && take > 0) { + scanner->ready_status = buf[0]; + scanner->ready_status_set = true; + } + buf += take; + len -= take; + scanner->payload_remaining -= take; + if (scanner->payload_remaining == 0) { + bool ready = scanner->tag == 'Z'; + if (ready) { + handle->transaction_status = scanner->ready_status_set ? scanner->ready_status : 'I'; + } + reset_protocol_scanner(scanner); + if (ready) { + return true; + } + } + } + } + return false; +} + +static size_t stream_queue_max_bytes(void) { + const char *value = getenv("OLIPHAUNT_STREAM_QUEUE_MAX_BYTES"); + if (value == NULL || value[0] == '\0') { + return DEFAULT_STREAM_QUEUE_MAX_BYTES; + } + + char *end = NULL; + unsigned long long parsed = strtoull(value, &end, 10); + if (end == value || parsed == 0) { + return DEFAULT_STREAM_QUEUE_MAX_BYTES; + } + if (parsed > (unsigned long long)SIZE_MAX) { + return SIZE_MAX; + } + return (size_t)parsed; +} + +static bool stream_queue_has_room_locked(OliphauntHandle *handle, size_t len, size_t max_bytes) { + if (len == 0) { + return true; + } + if (len > max_bytes) { + return handle->stream_bytes_queued == 0; + } + return handle->stream_bytes_queued <= max_bytes - len; +} + +static int wait_for_stream_queue_room_locked(OliphauntHandle *handle, size_t len) { + size_t max_bytes = handle->stream_queue_max_bytes > 0 + ? handle->stream_queue_max_bytes + : DEFAULT_STREAM_QUEUE_MAX_BYTES; + while (!stream_queue_has_room_locked(handle, len, max_bytes)) { + if (!handle->streaming || handle->stream_failed || handle->backend_exited || handle->closing) { + set_error(handle, "native liboliphaunt stream queue closed"); + errno = EPIPE; + return -1; + } + int rc = pthread_cond_wait(&handle->output_cond, &handle->mutex); + if (rc != 0) { + snprintf(handle->last_error, sizeof(handle->last_error), "stream queue wait failed: %d", rc); + errno = rc; + return -1; + } + } + return 0; +} + +static int enqueue_stream_chunk_locked(OliphauntHandle *handle, const void *buf, size_t len) { + if (len == 0) { + return 0; + } + if (wait_for_stream_queue_room_locked(handle, len) != 0) { + return -1; + } + OliphauntOutputChunk *chunk = (OliphauntOutputChunk *)calloc(1, sizeof(OliphauntOutputChunk)); + if (chunk == NULL) { + set_error(handle, "out of memory enqueuing protocol stream response"); + errno = ENOMEM; + return -1; + } + chunk->data = (unsigned char *)malloc(len); + if (chunk->data == NULL) { + free(chunk); + set_error(handle, "out of memory enqueuing protocol stream response"); + errno = ENOMEM; + return -1; + } + memcpy(chunk->data, buf, len); + chunk->len = len; + if (handle->stream_tail == NULL) { + handle->stream_head = chunk; + handle->stream_tail = chunk; + } else { + handle->stream_tail->next = chunk; + handle->stream_tail = chunk; + } + handle->stream_bytes_queued += len; + return 0; +} + +static OliphauntOutputChunk *pop_stream_chunk_locked(OliphauntHandle *handle) { + OliphauntOutputChunk *chunk = handle->stream_head; + if (chunk == NULL) { + return NULL; + } + handle->stream_head = chunk->next; + if (handle->stream_head == NULL) { + handle->stream_tail = NULL; + } + chunk->next = NULL; + if (handle->stream_bytes_queued >= chunk->len) { + handle->stream_bytes_queued -= chunk->len; + } else { + handle->stream_bytes_queued = 0; + } + pthread_cond_broadcast(&handle->output_cond); + return chunk; +} + +static void free_stream_chunk(OliphauntOutputChunk *chunk) { + if (chunk == NULL) { + return; + } + free(chunk->data); + free(chunk); +} + +void oliphaunt_clear_stream_chunks_locked(OliphauntHandle *handle) { + OliphauntOutputChunk *chunk = handle->stream_head; + handle->stream_head = NULL; + handle->stream_tail = NULL; + handle->stream_bytes_queued = 0; + while (chunk != NULL) { + OliphauntOutputChunk *next = chunk->next; + free_stream_chunk(chunk); + chunk = next; + } +} + +static bool scan_ready_for_query_locked(OliphauntHandle *handle) { + bool trace = handle->trace_protocol; + uint64_t scan_started = trace ? oliphaunt_monotonic_ns() : 0; + size_t off = handle->output_scan_off; + while (off + 5 <= handle->output_len) { + unsigned char tag = handle->output[off]; + uint32_t msg_len = read_be32(handle->output + off + 1); + if (msg_len < 4) { + if (trace) { + handle->trace_ready_scan_calls++; + handle->trace_ready_scan_ns += oliphaunt_elapsed_ns(scan_started); + } + return false; + } + size_t frame_len = 1 + (size_t)msg_len; + if (frame_len > handle->output_len - off) { + if (trace) { + handle->trace_ready_scan_calls++; + handle->trace_ready_scan_ns += oliphaunt_elapsed_ns(scan_started); + } + return false; + } + if (tag == 'Z') { + handle->transaction_status = msg_len >= 5 ? handle->output[off + 5] : 'I'; + handle->output_ready = true; + off += frame_len; + handle->output_scan_off = off; + if (trace) { + handle->trace_ready_scan_calls++; + handle->trace_ready_scan_ns += oliphaunt_elapsed_ns(scan_started); + } + return true; + } + off += frame_len; + } + handle->output_scan_off = off; + if (trace) { + handle->trace_ready_scan_calls++; + handle->trace_ready_scan_ns += oliphaunt_elapsed_ns(scan_started); + } + return handle->output_ready; +} + +int oliphaunt_startup_timeout_ms(void) { + const char *value = getenv("OLIPHAUNT_STARTUP_TIMEOUT_MS"); + if (value == NULL || value[0] == '\0') { + value = getenv("OLIPHAUNT_TIMEOUT_MS"); + } + if (value == NULL || value[0] == '\0') { + return DEFAULT_WAIT_TIMEOUT_MS; + } + int parsed = atoi(value); + return parsed > 0 ? parsed : DEFAULT_WAIT_TIMEOUT_MS; +} + +static void add_ms_to_timespec(struct timespec *ts, int ms) { + ts->tv_sec += ms / 1000; + ts->tv_nsec += (long)(ms % 1000) * 1000000L; + if (ts->tv_nsec >= 1000000000L) { + ts->tv_sec++; + ts->tv_nsec -= 1000000000L; + } +} + +int oliphaunt_wait_for_ready_locked(OliphauntHandle *handle, int timeout_ms) { + bool has_timeout = timeout_ms > 0; + struct timespec deadline; + if (has_timeout) { + clock_gettime(CLOCK_REALTIME, &deadline); + add_ms_to_timespec(&deadline, timeout_ms); + } + + while (true) { + bool ready = scan_ready_for_query_locked(handle); + if (ready) { + break; + } + if (handle->backend_exited) { + snprintf( + handle->last_error, + sizeof(handle->last_error), + "embedded backend exited with status %d before ReadyForQuery", + handle->backend_status); + return -1; + } + if (handle->closing) { + set_error(handle, "native backend is closing before ReadyForQuery"); + return -1; + } + int rc = has_timeout + ? pthread_cond_timedwait(&handle->output_cond, &handle->mutex, &deadline) + : pthread_cond_wait(&handle->output_cond, &handle->mutex); + if (has_timeout && rc == ETIMEDOUT) { + snprintf( + handle->last_error, + sizeof(handle->last_error), + "timed out after %dms waiting for embedded backend ReadyForQuery", + timeout_ms); + return -1; + } + if (rc != 0) { + snprintf(handle->last_error, sizeof(handle->last_error), "pthread wait failed: %d", rc); + return -1; + } + } + return 0; +} + +ssize_t oliphaunt_embedded_read(void *context, void *ptr, size_t len) { + OliphauntHandle *handle = (OliphauntHandle *)context; + pthread_mutex_lock(&handle->mutex); + while (handle->input_off >= handle->input_len && !handle->closing) { + pthread_cond_wait(&handle->input_cond, &handle->mutex); + } + + if (handle->input_off >= handle->input_len && handle->closing) { + pthread_mutex_unlock(&handle->mutex); + return 0; + } + + size_t available = handle->input_len - handle->input_off; + size_t take = available < len ? available : len; + bool trace = handle->trace_protocol; + uint64_t copy_started = trace ? oliphaunt_monotonic_ns() : 0; + memcpy(ptr, handle->input + handle->input_off, take); + if (trace) { + handle->trace_read_calls++; + handle->trace_read_bytes += take; + handle->trace_read_copy_ns += oliphaunt_elapsed_ns(copy_started); + } + handle->input_off += take; + + if (handle->input_off >= handle->input_len) { + handle->input_len = 0; + handle->input_off = 0; + } + + pthread_mutex_unlock(&handle->mutex); + return (ssize_t)take; +} + +ssize_t oliphaunt_embedded_write(void *context, const void *ptr, size_t len) { + OliphauntHandle *handle = (OliphauntHandle *)context; + pthread_mutex_lock(&handle->mutex); + bool trace = handle->trace_protocol; + uint64_t append_started = trace ? oliphaunt_monotonic_ns() : 0; + int rc; + bool ready = false; + if (handle->streaming) { + rc = enqueue_stream_chunk_locked(handle, ptr, len); + if (rc == 0) { + ready = scan_stream_ready_locked(handle, (const unsigned char *)ptr, len); + if (ready) { + handle->output_ready = true; + } + } + } else { + rc = append_output_locked(handle, ptr, len); + if (rc == 0) { + ready = scan_ready_for_query_locked(handle); + } + } + if (trace && rc == 0) { + handle->trace_write_calls++; + handle->trace_write_bytes += len; + handle->trace_write_append_ns += oliphaunt_elapsed_ns(append_started); + } + if (handle->streaming || ready) { + pthread_cond_broadcast(&handle->output_cond); + } + pthread_mutex_unlock(&handle->mutex); + return rc == 0 ? (ssize_t)len : -1; +} + +int oliphaunt_set_input_locked(OliphauntHandle *handle, const void *buf, size_t len) { + if (handle->input_len != 0) { + set_error(handle, "native liboliphaunt input queue is busy"); + return -1; + } + bool trace = handle->trace_protocol; + uint64_t copy_started = trace ? oliphaunt_monotonic_ns() : 0; + if (len > handle->input_cap) { + unsigned char *grown = (unsigned char *)realloc(handle->input, len); + if (grown == NULL) { + set_error(handle, "out of memory while copying protocol input"); + return -1; + } + handle->input = grown; + handle->input_cap = len; + } + if (len > 0) { + memcpy(handle->input, buf, len); + } + handle->input_len = len; + handle->input_off = 0; + if (trace) { + handle->trace_input_copy_ns += oliphaunt_elapsed_ns(copy_started); + } + pthread_cond_broadcast(&handle->input_cond); + return 0; +} + +static int oliphaunt_set_simple_query_input_locked(OliphauntHandle *handle, const char *sql, size_t sql_len) { + if (handle->input_len != 0) { + set_error(handle, "native liboliphaunt input queue is busy"); + return -1; + } + if (sql_len > (size_t)UINT32_MAX - 5) { + set_error(handle, "simple query is too large for the PostgreSQL protocol"); + return -1; + } + + size_t total_len = sql_len + 6; + bool trace = handle->trace_protocol; + uint64_t copy_started = trace ? oliphaunt_monotonic_ns() : 0; + if (total_len > handle->input_cap) { + unsigned char *grown = (unsigned char *)realloc(handle->input, total_len); + if (grown == NULL) { + set_error(handle, "out of memory while copying simple-query input"); + return -1; + } + handle->input = grown; + handle->input_cap = total_len; + } + + uint32_t message_len = (uint32_t)(sql_len + 5); + handle->input[0] = 'Q'; + handle->input[1] = (unsigned char)((message_len >> 24) & 0xff); + handle->input[2] = (unsigned char)((message_len >> 16) & 0xff); + handle->input[3] = (unsigned char)((message_len >> 8) & 0xff); + handle->input[4] = (unsigned char)(message_len & 0xff); + if (sql_len > 0) { + memcpy(handle->input + 5, sql, sql_len); + } + handle->input[5 + sql_len] = '\0'; + handle->input_len = total_len; + handle->input_off = 0; + if (trace) { + handle->trace_input_copy_ns += oliphaunt_elapsed_ns(copy_started); + } + pthread_cond_broadcast(&handle->input_cond); + return 0; +} + +static int oliphaunt_copy_response_locked(OliphauntHandle *handle, OliphauntResponse *out, bool trace) { + if (handle->output_len == 0) { + return 0; + } + + uint64_t response_copy_started = trace ? oliphaunt_monotonic_ns() : 0; + out->data = (uint8_t *)malloc(handle->output_len); + if (out->data == NULL) { + set_error(handle, "out of memory copying protocol response"); + return -1; + } + memcpy(out->data, handle->output, handle->output_len); + out->len = handle->output_len; + if (trace) { + handle->trace_response_bytes = out->len; + handle->trace_response_copy_ns = oliphaunt_elapsed_ns(response_copy_started); + } + handle->output_len = 0; + return 0; +} + +static int oliphaunt_wait_and_copy_response_locked(OliphauntHandle *handle, OliphauntResponse *out, bool trace) { + uint64_t wait_started = trace ? oliphaunt_monotonic_ns() : 0; + if (oliphaunt_wait_for_ready_locked(handle, -1) != 0) { + return -1; + } + if (trace) { + handle->trace_wait_ns = oliphaunt_elapsed_ns(wait_started); + } + return oliphaunt_copy_response_locked(handle, out, trace); +} + +int32_t oliphaunt_exec_protocol( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out) { + if (handle == NULL || out == NULL || (request_len > 0 && request == NULL)) { + set_error(handle, "invalid oliphaunt_exec_protocol arguments"); + return -1; + } + out->data = NULL; + out->len = 0; + if (validate_frontend_protocol_frames(handle, request, request_len) != 0) { + return -1; + } + + bool trace = handle->trace_protocol; + uint64_t total_started = trace ? oliphaunt_monotonic_ns() : 0; + uint64_t lock_started = trace ? oliphaunt_monotonic_ns() : 0; + pthread_mutex_lock(&handle->mutex); + if (trace) { + oliphaunt_reset_trace_locked(handle, request_len); + handle->trace_lock_ns = oliphaunt_elapsed_ns(lock_started); + } + if (!handle->logical_active) { + set_error(handle, "native liboliphaunt logical handle is closed"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (handle->backend_exited) { + set_error(handle, "native backend is not running"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + handle->output_len = 0; + handle->output_scan_off = 0; + handle->output_ready = false; + if (oliphaunt_set_input_locked(handle, request, request_len) != 0) { + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (oliphaunt_wait_and_copy_response_locked(handle, out, trace) != 0) { + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (trace) { + oliphaunt_print_trace_locked(handle, oliphaunt_elapsed_ns(total_started)); + } + pthread_mutex_unlock(&handle->mutex); + return 0; +} + +int32_t oliphaunt_exec_simple_query( + OliphauntHandle *handle, + const char *sql, + size_t sql_len, + OliphauntResponse *out) { + if (handle == NULL || out == NULL || sql == NULL) { + set_error(handle, "invalid oliphaunt_exec_simple_query arguments"); + return -1; + } + out->data = NULL; + out->len = 0; + if (sql_len > 0 && memchr(sql, '\0', sql_len) != NULL) { + set_error(handle, "simple query contains an interior NUL byte"); + return -1; + } + if (sql_len > (size_t)UINT32_MAX - 5) { + set_error(handle, "simple query is too large for the PostgreSQL protocol"); + return -1; + } + + size_t request_len = sql_len + 6; + bool trace = handle->trace_protocol; + uint64_t total_started = trace ? oliphaunt_monotonic_ns() : 0; + uint64_t lock_started = trace ? oliphaunt_monotonic_ns() : 0; + pthread_mutex_lock(&handle->mutex); + if (trace) { + oliphaunt_reset_trace_locked(handle, request_len); + handle->trace_lock_ns = oliphaunt_elapsed_ns(lock_started); + } + if (!handle->logical_active) { + set_error(handle, "native liboliphaunt logical handle is closed"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (handle->backend_exited) { + set_error(handle, "native backend is not running"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + handle->output_len = 0; + handle->output_scan_off = 0; + handle->output_ready = false; + if (oliphaunt_set_simple_query_input_locked(handle, sql, sql_len) != 0) { + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (oliphaunt_wait_and_copy_response_locked(handle, out, trace) != 0) { + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (trace) { + oliphaunt_print_trace_locked(handle, oliphaunt_elapsed_ns(total_started)); + } + pthread_mutex_unlock(&handle->mutex); + return 0; +} + +int32_t oliphaunt_exec_protocol_stream( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context) { + if (handle == NULL || callback == NULL || (request_len > 0 && request == NULL)) { + set_error(handle, "invalid oliphaunt_exec_protocol_stream arguments"); + return -1; + } + if (validate_frontend_protocol_frames(handle, request, request_len) != 0) { + return -1; + } + + pthread_mutex_lock(&handle->mutex); + if (!handle->logical_active) { + set_error(handle, "native liboliphaunt logical handle is closed"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (handle->backend_exited) { + set_error(handle, "native backend is not running"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + if (handle->streaming) { + set_error(handle, "native liboliphaunt stream queue is busy"); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + + handle->output_len = 0; + handle->output_scan_off = 0; + handle->output_ready = false; + handle->streaming = true; + handle->stream_failed = false; + handle->stream_queue_max_bytes = stream_queue_max_bytes(); + reset_protocol_scanner(&handle->stream_scanner); + oliphaunt_clear_stream_chunks_locked(handle); + + if (oliphaunt_set_input_locked(handle, request, request_len) != 0) { + handle->streaming = false; + handle->stream_queue_max_bytes = 0; + oliphaunt_clear_stream_chunks_locked(handle); + pthread_cond_broadcast(&handle->output_cond); + pthread_mutex_unlock(&handle->mutex); + return -1; + } + + int status = 0; + bool callback_failed = false; + while (status == 0) { + OliphauntOutputChunk *chunk = NULL; + while ((chunk = pop_stream_chunk_locked(handle)) != NULL) { + pthread_mutex_unlock(&handle->mutex); + if (!callback_failed) { + int32_t callback_rc = callback(callback_context, chunk->data, chunk->len); + if (callback_rc != 0) { + callback_failed = true; + } + } + free_stream_chunk(chunk); + pthread_mutex_lock(&handle->mutex); + } + + if (handle->stream_failed) { + status = -1; + break; + } + if (handle->output_ready) { + break; + } + if (handle->backend_exited) { + snprintf( + handle->last_error, + sizeof(handle->last_error), + "embedded backend exited with status %d before ReadyForQuery", + handle->backend_status); + status = -1; + break; + } + if (handle->closing) { + set_error(handle, "native backend is closing before ReadyForQuery"); + status = -1; + break; + } + + int rc = pthread_cond_wait(&handle->output_cond, &handle->mutex); + if (rc != 0) { + snprintf(handle->last_error, sizeof(handle->last_error), "pthread wait failed: %d", rc); + status = -1; + break; + } + } + + OliphauntOutputChunk *chunk = NULL; + while ((chunk = pop_stream_chunk_locked(handle)) != NULL) { + pthread_mutex_unlock(&handle->mutex); + if (!callback_failed) { + int32_t callback_rc = callback(callback_context, chunk->data, chunk->len); + if (callback_rc != 0) { + callback_failed = true; + } + } + free_stream_chunk(chunk); + pthread_mutex_lock(&handle->mutex); + } + + handle->streaming = false; + handle->stream_queue_max_bytes = 0; + reset_protocol_scanner(&handle->stream_scanner); + oliphaunt_clear_stream_chunks_locked(handle); + pthread_cond_broadcast(&handle->output_cond); + if (status == 0 && callback_failed) { + set_error(handle, "protocol stream callback failed"); + status = -1; + } + pthread_mutex_unlock(&handle->mutex); + return status; +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c new file mode 100644 index 00000000..8b1a0f26 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_runtime.c @@ -0,0 +1,131 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "liboliphaunt_internal.h" + +#include +#include +#include + +#define DEFAULT_BACKEND_STACK_BYTES (8 * 1024 * 1024) + +static const char *const DEFAULT_BACKEND_ARGS[] = { + "-F", + "-c", + "search_path=public", + "-c", + "exit_on_error=false", + "-c", + "max_worker_processes=0", + "-c", + "max_parallel_workers=0", + "-c", + "max_parallel_workers_per_gather=0", + "-c", + "autovacuum=off", + "-c", + "wal_buffers=4MB", + "-c", + "min_wal_size=80MB", + "-c", + "shared_buffers=128MB", + "-c", + "log_checkpoints=off", + "-c", + "log_timezone=UTC", + "-c", + "TimeZone=UTC", +}; + +static int append_backend_arg(OliphauntHandle *handle, OliphauntBackendArgv *out, size_t capacity, const char *arg) { + if (out->argc < 0 || (size_t)out->argc + 1 >= capacity) { + set_error(handle, "embedded backend argv capacity exceeded"); + return -1; + } + out->argv[out->argc] = strdup(arg != NULL ? arg : ""); + if (out->argv[out->argc] == NULL) { + set_error(handle, "out of memory copying embedded backend argv"); + return -1; + } + out->argc++; + out->argv[out->argc] = NULL; + return 0; +} + +int oliphaunt_build_backend_argv(OliphauntHandle *handle, OliphauntBackendArgv *out) { + if (handle == NULL || out == NULL) { + set_error(handle, "invalid embedded backend argv builder arguments"); + return -1; + } + if (handle->postgres_path == NULL || handle->postgres_path[0] == '\0' || + handle->pgdata == NULL || handle->pgdata[0] == '\0') { + set_error(handle, "embedded backend argv requires postgres path and PGDATA"); + return -1; + } + + memset(out, 0, sizeof(*out)); + size_t default_arg_count = sizeof(DEFAULT_BACKEND_ARGS) / sizeof(DEFAULT_BACKEND_ARGS[0]); + size_t capacity = 1 + default_arg_count + handle->startup_arg_count + 2 + 1; + if (capacity > (size_t)INT_MAX) { + set_error(handle, "embedded backend argv is too large"); + return -1; + } + + out->argv = (char **)calloc(capacity, sizeof(char *)); + if (out->argv == NULL) { + set_error(handle, "out of memory allocating embedded backend argv"); + return -1; + } + + if (append_backend_arg(handle, out, capacity, handle->postgres_path) != 0) { + goto fail; + } + for (size_t i = 0; i < default_arg_count; i++) { + if (append_backend_arg(handle, out, capacity, DEFAULT_BACKEND_ARGS[i]) != 0) { + goto fail; + } + } + for (size_t i = 0; i < handle->startup_arg_count; i++) { + if (append_backend_arg(handle, out, capacity, handle->startup_args[i]) != 0) { + goto fail; + } + } + if (append_backend_arg(handle, out, capacity, "-D") != 0 || + append_backend_arg(handle, out, capacity, handle->pgdata) != 0) { + goto fail; + } + + return 0; + +fail: + oliphaunt_free_backend_argv(out); + return -1; +} + +void oliphaunt_free_backend_argv(OliphauntBackendArgv *argv) { + if (argv == NULL) { + return; + } + if (argv->argv != NULL) { + for (int i = 0; i < argv->argc; i++) { + free(argv->argv[i]); + } + free(argv->argv); + } + argv->argc = 0; + argv->argv = NULL; +} + +size_t oliphaunt_backend_stack_size_bytes(void) { + const char *value = getenv("OLIPHAUNT_STACK_BYTES"); + if (value == NULL || value[0] == '\0') { + return DEFAULT_BACKEND_STACK_BYTES; + } + char *end = NULL; + unsigned long long parsed = strtoull(value, &end, 10); + if (end == value || parsed < (unsigned long long)PTHREAD_STACK_MIN) { + return DEFAULT_BACKEND_STACK_BYTES; + } + return (size_t)parsed; +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c new file mode 100644 index 00000000..e807d6eb --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_static_extensions.c @@ -0,0 +1,317 @@ +#include "liboliphaunt_internal.h" + +#include +#include +#include + +typedef struct OliphauntRegisteredStaticExtension { + OliphauntStaticExtension extension; + OliphauntStaticExtensionSymbol *symbols; + char *name; + char **symbol_names; +} OliphauntRegisteredStaticExtension; + +static pthread_mutex_t static_registry_mutex = PTHREAD_MUTEX_INITIALIZER; +static OliphauntRegisteredStaticExtension *static_registry = NULL; +static size_t static_registry_count = 0; +static bool static_registry_frozen = false; + +#ifdef _MSC_VER +extern const OliphauntStaticExtension *liboliphaunt_builtin_static_extensions(size_t *count); +#else +extern const OliphauntStaticExtension *liboliphaunt_builtin_static_extensions(size_t *count) __attribute__((weak)); +#endif + +static const OliphauntStaticExtension *builtin_static_extensions(size_t *count) { + if (liboliphaunt_builtin_static_extensions == NULL) { + if (count != NULL) { + *count = 0; + } + return NULL; + } + return liboliphaunt_builtin_static_extensions(count); +} + +static const OliphauntStaticExtension *lookup_static_extension( + const OliphauntStaticExtension *extensions, + size_t count, + const char *name) { + if (extensions == NULL || name == NULL) { + return NULL; + } + for (size_t i = 0; i < count; i++) { + if (strcmp(extensions[i].name, name) == 0) { + return &extensions[i]; + } + } + return NULL; +} + +static const OliphauntStaticExtension *lookup_registered_static_extension(const char *name) { + if (name == NULL) { + return NULL; + } + for (size_t i = 0; i < static_registry_count; i++) { + if (strcmp(static_registry[i].extension.name, name) == 0) { + return &static_registry[i].extension; + } + } + return NULL; +} + +static bool is_portable_static_name(const char *value) { + if (value == NULL || value[0] == '\0') { + return false; + } + size_t len = strlen(value); + if (len > 128) { + return false; + } + for (size_t i = 0; i < len; i++) { + unsigned char ch = (unsigned char)value[i]; + if ((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '_' || ch == '-' || ch == '.') { + continue; + } + return false; + } + return true; +} + +static const char *file_stem(const char *filename, char *buffer, size_t buffer_len) { + if (filename == NULL || filename[0] == '\0' || buffer == NULL || buffer_len == 0) { + return ""; + } + const char *base = strrchr(filename, '/'); + base = base != NULL ? base + 1 : filename; + size_t len = strlen(base); + const char *suffixes[] = {".dylib", ".so", ".bundle", ".dll"}; + for (size_t i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { + size_t suffix_len = strlen(suffixes[i]); + if (len > suffix_len && strcmp(base + len - suffix_len, suffixes[i]) == 0) { + len -= suffix_len; + break; + } + } + if (len >= buffer_len) { + len = buffer_len - 1; + } + memcpy(buffer, base, len); + buffer[len] = '\0'; + return buffer; +} + +static void free_static_registry_entries(OliphauntRegisteredStaticExtension *entries, size_t count) { + if (entries == NULL) { + return; + } + for (size_t i = 0; i < count; i++) { + free(entries[i].name); + if (entries[i].symbol_names != NULL) { + for (size_t j = 0; j < entries[i].extension.symbol_count; j++) { + free(entries[i].symbol_names[j]); + } + free(entries[i].symbol_names); + } + free(entries[i].symbols); + } + free(entries); +} + +static int validate_static_extensions(const OliphauntStaticExtension *extensions, size_t count) { + if (count == 0) { + return 0; + } + if (extensions == NULL) { + set_error(NULL, "static extension registration requires extensions when count is non-zero"); + return -1; + } + for (size_t i = 0; i < count; i++) { + const OliphauntStaticExtension *extension = &extensions[i]; + if (extension->abi_version != OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION || + extension->reserved_flags != 0 || + !is_portable_static_name(extension->name) || + extension->magic == NULL || + (extension->symbol_count > 0 && extension->symbols == NULL)) { + set_error(NULL, "invalid static extension registration entry"); + return -1; + } + size_t builtin_count = 0; + const OliphauntStaticExtension *builtins = builtin_static_extensions(&builtin_count); + if (lookup_static_extension(builtins, builtin_count, extension->name) != NULL) { + set_error(NULL, "static extension registration conflicts with built-in extension"); + return -1; + } + for (size_t existing = 0; existing < i; existing++) { + if (strcmp(extensions[existing].name, extension->name) == 0) { + set_error(NULL, "duplicate static extension registration entry"); + return -1; + } + } + for (size_t j = 0; j < extension->symbol_count; j++) { + if (!is_portable_static_name(extension->symbols[j].name) || + extension->symbols[j].address == NULL) { + set_error(NULL, "invalid static extension symbol registration entry"); + return -1; + } + for (size_t existing = 0; existing < j; existing++) { + if (strcmp(extension->symbols[existing].name, extension->symbols[j].name) == 0) { + set_error(NULL, "duplicate static extension symbol registration entry"); + return -1; + } + } + } + } + return 0; +} + +static int copy_static_extensions( + const OliphauntStaticExtension *extensions, + size_t count, + OliphauntRegisteredStaticExtension **out_entries) { + *out_entries = NULL; + if (count == 0) { + return 0; + } + OliphauntRegisteredStaticExtension *entries = + (OliphauntRegisteredStaticExtension *)calloc(count, sizeof(OliphauntRegisteredStaticExtension)); + if (entries == NULL) { + set_error(NULL, "out of memory allocating static extension registry"); + return -1; + } + for (size_t i = 0; i < count; i++) { + const OliphauntStaticExtension *source = &extensions[i]; + OliphauntRegisteredStaticExtension *target = &entries[i]; + target->name = strdup(source->name); + if (target->name == NULL) { + set_error(NULL, "out of memory copying static extension name"); + free_static_registry_entries(entries, count); + return -1; + } + target->extension = *source; + target->extension.name = target->name; + if (source->symbol_count > 0) { + target->symbols = (OliphauntStaticExtensionSymbol *)calloc( + source->symbol_count, + sizeof(OliphauntStaticExtensionSymbol)); + target->symbol_names = (char **)calloc(source->symbol_count, sizeof(char *)); + if (target->symbols == NULL || target->symbol_names == NULL) { + set_error(NULL, "out of memory copying static extension symbols"); + free_static_registry_entries(entries, count); + return -1; + } + for (size_t j = 0; j < source->symbol_count; j++) { + target->symbol_names[j] = strdup(source->symbols[j].name); + if (target->symbol_names[j] == NULL) { + set_error(NULL, "out of memory copying static extension symbol name"); + free_static_registry_entries(entries, count); + return -1; + } + target->symbols[j].name = target->symbol_names[j]; + target->symbols[j].address = source->symbols[j].address; + } + target->extension.symbols = target->symbols; + } + } + *out_entries = entries; + return 0; +} + +static bool static_registry_matches(const OliphauntStaticExtension *extensions, size_t count) { + if (static_registry_count != count) { + return false; + } + for (size_t i = 0; i < count; i++) { + const OliphauntStaticExtension *existing = &static_registry[i].extension; + const OliphauntStaticExtension *incoming = &extensions[i]; + if (strcmp(existing->name, incoming->name) != 0 || + existing->magic != incoming->magic || + existing->init != incoming->init || + existing->symbol_count != incoming->symbol_count) { + return false; + } + for (size_t j = 0; j < existing->symbol_count; j++) { + if (strcmp(existing->symbols[j].name, incoming->symbols[j].name) != 0 || + existing->symbols[j].address != incoming->symbols[j].address) { + return false; + } + } + } + return true; +} + +int32_t oliphaunt_register_static_extensions(const OliphauntStaticExtension *extensions, size_t count) { + if (validate_static_extensions(extensions, count) != 0) { + return -1; + } + pthread_mutex_lock(&static_registry_mutex); + if (static_registry_frozen && static_registry_matches(extensions, count)) { + pthread_mutex_unlock(&static_registry_mutex); + return 0; + } + pthread_mutex_unlock(&static_registry_mutex); + + OliphauntRegisteredStaticExtension *new_entries = NULL; + if (copy_static_extensions(extensions, count, &new_entries) != 0) { + return -1; + } + + pthread_mutex_lock(&static_registry_mutex); + if (static_registry_frozen) { + pthread_mutex_unlock(&static_registry_mutex); + free_static_registry_entries(new_entries, count); + set_error(NULL, "static extension registry cannot be changed after backend startup"); + return -1; + } + OliphauntRegisteredStaticExtension *old_entries = static_registry; + size_t old_count = static_registry_count; + static_registry = new_entries; + static_registry_count = count; + pthread_mutex_unlock(&static_registry_mutex); + + free_static_registry_entries(old_entries, old_count); + return 0; +} + +const OliphauntStaticExtension *oliphaunt_static_extension_lookup(const char *filename) { + char stem[129]; + const char *name = file_stem(filename, stem, sizeof(stem)); + size_t builtin_count = 0; + const OliphauntStaticExtension *builtins = builtin_static_extensions(&builtin_count); + const OliphauntStaticExtension *builtin = lookup_static_extension(builtins, builtin_count, name); + pthread_mutex_lock(&static_registry_mutex); + static_registry_frozen = true; + const OliphauntStaticExtension *registered = lookup_registered_static_extension(name); + pthread_mutex_unlock(&static_registry_mutex); + if (builtin != NULL) { + return builtin; + } + return registered; +} + +const void *oliphaunt_static_extension_magic(const OliphauntStaticExtension *extension) { + if (extension == NULL || extension->magic == NULL) { + return NULL; + } + return extension->magic(); +} + +void *oliphaunt_static_extension_symbol(const OliphauntStaticExtension *extension, const char *symbol) { + if (extension == NULL || symbol == NULL) { + return NULL; + } + for (size_t i = 0; i < extension->symbol_count; i++) { + if (strcmp(extension->symbols[i].name, symbol) == 0) { + return extension->symbols[i].address; + } + } + return NULL; +} + +void oliphaunt_static_extension_init(const OliphauntStaticExtension *extension) { + if (extension != NULL && extension->init != NULL) { + extension->init(); + } +} diff --git a/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c b/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c new file mode 100644 index 00000000..5408d042 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/src/liboliphaunt_trace.c @@ -0,0 +1,92 @@ +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif + +#include "liboliphaunt_internal.h" + +#include +#include +#include +#include + +static pthread_once_t trace_once = PTHREAD_ONCE_INIT; +static bool trace_protocol_enabled = false; + +static void init_trace_protocol_flag(void) { + const char *value = getenv("OLIPHAUNT_TRACE_PROTOCOL"); + if (value == NULL || value[0] == '\0') { + value = getenv("OLIPHAUNT_TRACE"); + } + trace_protocol_enabled = + value != NULL && + value[0] != '\0' && + strcmp(value, "0") != 0 && + strcmp(value, "false") != 0 && + strcmp(value, "FALSE") != 0; +} + +bool oliphaunt_trace_enabled(void) { + pthread_once(&trace_once, init_trace_protocol_flag); + return trace_protocol_enabled; +} + +uint64_t oliphaunt_monotonic_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec; +} + +uint64_t oliphaunt_elapsed_ns(uint64_t started_ns) { + return oliphaunt_monotonic_ns() - started_ns; +} + +static uint64_t ns_to_us(uint64_t value) { + return value / 1000ULL; +} + +void oliphaunt_reset_trace_locked(OliphauntHandle *handle, size_t request_len) { + handle->trace_request_bytes = request_len; + handle->trace_response_bytes = 0; + handle->trace_lock_ns = 0; + handle->trace_input_copy_ns = 0; + handle->trace_wait_ns = 0; + handle->trace_response_copy_ns = 0; + handle->trace_read_calls = 0; + handle->trace_read_bytes = 0; + handle->trace_read_copy_ns = 0; + handle->trace_write_calls = 0; + handle->trace_write_bytes = 0; + handle->trace_write_append_ns = 0; + handle->trace_ready_scan_calls = 0; + handle->trace_ready_scan_ns = 0; + handle->trace_output_grows = 0; +} + +void oliphaunt_print_trace_locked(OliphauntHandle *handle, uint64_t total_ns) { + uint64_t seq = ++handle->trace_seq; + fprintf( + stderr, + "oliphaunt_native_trace seq=%llu request_bytes=%llu response_bytes=%llu " + "total_us=%llu lock_us=%llu input_copy_us=%llu wait_us=%llu " + "ready_scan_calls=%llu ready_scan_us=%llu read_calls=%llu read_bytes=%llu " + "read_copy_us=%llu write_calls=%llu write_bytes=%llu write_append_us=%llu " + "output_grows=%llu output_cap=%llu response_copy_us=%llu\n", + (unsigned long long)seq, + (unsigned long long)handle->trace_request_bytes, + (unsigned long long)handle->trace_response_bytes, + (unsigned long long)ns_to_us(total_ns), + (unsigned long long)ns_to_us(handle->trace_lock_ns), + (unsigned long long)ns_to_us(handle->trace_input_copy_ns), + (unsigned long long)ns_to_us(handle->trace_wait_ns), + (unsigned long long)handle->trace_ready_scan_calls, + (unsigned long long)ns_to_us(handle->trace_ready_scan_ns), + (unsigned long long)handle->trace_read_calls, + (unsigned long long)handle->trace_read_bytes, + (unsigned long long)ns_to_us(handle->trace_read_copy_ns), + (unsigned long long)handle->trace_write_calls, + (unsigned long long)handle->trace_write_bytes, + (unsigned long long)ns_to_us(handle->trace_write_append_ns), + (unsigned long long)handle->trace_output_grows, + (unsigned long long)handle->output_cap, + (unsigned long long)ns_to_us(handle->trace_response_copy_ns)); +} diff --git a/src/runtimes/liboliphaunt/native/targets/android-arm64-v8a.toml b/src/runtimes/liboliphaunt/native/targets/android-arm64-v8a.toml new file mode 100644 index 00000000..b39154c5 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/android-arm64-v8a.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-native.android-arm64-v8a" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "android-arm64-v8a" +triple = "aarch64-linux-android" +runner = "ubuntu-latest" +asset = "liboliphaunt-{version}-android-arm64-v8a.tar.gz" +library_relative_path = "jni/arm64-v8a/liboliphaunt.so" +surfaces = ["github-release", "maven", "react-native-android"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/android-x86_64.toml b/src/runtimes/liboliphaunt/native/targets/android-x86_64.toml new file mode 100644 index 00000000..443af510 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/android-x86_64.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-native.android-x86_64" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "android-x86_64" +triple = "x86_64-linux-android" +runner = "ubuntu-latest" +asset = "liboliphaunt-{version}-android-x86_64.tar.gz" +library_relative_path = "jni/x86_64/liboliphaunt.so" +surfaces = ["github-release", "maven", "react-native-android"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/apple-spm-xcframework.toml b/src/runtimes/liboliphaunt/native/targets/apple-spm-xcframework.toml new file mode 100644 index 00000000..366dd101 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/apple-spm-xcframework.toml @@ -0,0 +1,9 @@ +id = "liboliphaunt-native.apple-spm-xcframework" +product = "liboliphaunt-native" +kind = "apple-swiftpm-binary" +target = "apple-spm-xcframework" +triple = "apple-xcframework" +runner = "macos-latest" +asset = "liboliphaunt-{version}-apple-spm-xcframework.zip" +surfaces = ["github-release", "swiftpm"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/checksums.toml b/src/runtimes/liboliphaunt/native/targets/checksums.toml new file mode 100644 index 00000000..729ab397 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/checksums.toml @@ -0,0 +1,7 @@ +id = "liboliphaunt-native.checksums" +product = "liboliphaunt-native" +kind = "checksums" +target = "portable" +asset = "liboliphaunt-{version}-release-assets.sha256" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/ios-xcframework.toml b/src/runtimes/liboliphaunt/native/targets/ios-xcframework.toml new file mode 100644 index 00000000..666c0f37 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/ios-xcframework.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-native.ios-xcframework" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "ios-xcframework" +triple = "ios-xcframework" +runner = "macos-26" +asset = "liboliphaunt-{version}-ios-xcframework.tar.gz" +library_relative_path = "liboliphaunt.xcframework" +surfaces = ["github-release", "swiftpm", "react-native-ios"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/linux-arm64-gnu.toml b/src/runtimes/liboliphaunt/native/targets/linux-arm64-gnu.toml new file mode 100644 index 00000000..78a0870b --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/linux-arm64-gnu.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-native.linux-arm64-gnu" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "linux-arm64-gnu" +triple = "aarch64-unknown-linux-gnu" +runner = "ubuntu-24.04-arm" +asset = "liboliphaunt-{version}-linux-arm64-gnu.tar.gz" +library_relative_path = "lib/liboliphaunt.so" +surfaces = ["github-release", "rust-native-direct", "typescript-native-direct"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/linux-x64-gnu.toml b/src/runtimes/liboliphaunt/native/targets/linux-x64-gnu.toml new file mode 100644 index 00000000..d1a4394d --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/linux-x64-gnu.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-native.linux-x64-gnu" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "linux-x64-gnu" +triple = "x86_64-unknown-linux-gnu" +runner = "ubuntu-latest" +asset = "liboliphaunt-{version}-linux-x64-gnu.tar.gz" +library_relative_path = "lib/liboliphaunt.so" +surfaces = ["github-release", "rust-native-direct", "typescript-native-direct"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/macos-arm64.toml b/src/runtimes/liboliphaunt/native/targets/macos-arm64.toml new file mode 100644 index 00000000..0edc59fa --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/macos-arm64.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-native.macos-arm64" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "macos-arm64" +triple = "aarch64-apple-darwin" +runner = "macos-latest" +asset = "liboliphaunt-{version}-macos-arm64.tar.gz" +library_relative_path = "lib/liboliphaunt.dylib" +surfaces = ["github-release", "rust-native-direct", "typescript-native-direct"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/macos-x64.toml b/src/runtimes/liboliphaunt/native/targets/macos-x64.toml new file mode 100644 index 00000000..2ec95ea7 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/macos-x64.toml @@ -0,0 +1,12 @@ +id = "liboliphaunt-native.macos-x64" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "macos-x64" +triple = "x86_64-apple-darwin" +runner = "macos-latest" +asset = "liboliphaunt-{version}-macos-x64.tar.gz" +library_relative_path = "lib/liboliphaunt.dylib" +surfaces = ["github-release", "rust-native-direct", "typescript-native-direct"] +published = false +tier = "planned" +unsupported_reason = "macOS x64 native runtime is planned but not published until CI, release asset packaging, and SDK resolver smoke coverage exist." diff --git a/src/runtimes/liboliphaunt/native/targets/package-size.toml b/src/runtimes/liboliphaunt/native/targets/package-size.toml new file mode 100644 index 00000000..600ed56e --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/package-size.toml @@ -0,0 +1,7 @@ +id = "liboliphaunt-native.package-size" +product = "liboliphaunt-native" +kind = "package-footprint" +target = "portable" +asset = "liboliphaunt-{version}-package-size.tsv" +surfaces = ["github-release", "swiftpm", "maven", "react-native-ios", "react-native-android", "rust-native-direct", "typescript-native-direct"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/runtime-resources.toml b/src/runtimes/liboliphaunt/native/targets/runtime-resources.toml new file mode 100644 index 00000000..8154c207 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/runtime-resources.toml @@ -0,0 +1,7 @@ +id = "liboliphaunt-native.runtime-resources" +product = "liboliphaunt-native" +kind = "runtime-resources" +target = "portable" +asset = "liboliphaunt-{version}-runtime-resources.tar.gz" +surfaces = ["github-release", "rust-native-direct", "typescript-native-direct", "swiftpm", "maven"] +published = true diff --git a/src/runtimes/liboliphaunt/native/targets/windows-x64-msvc.toml b/src/runtimes/liboliphaunt/native/targets/windows-x64-msvc.toml new file mode 100644 index 00000000..8b4a97f8 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/targets/windows-x64-msvc.toml @@ -0,0 +1,11 @@ +id = "liboliphaunt-native.windows-x64-msvc" +product = "liboliphaunt-native" +kind = "native-runtime" +target = "windows-x64-msvc" +triple = "x86_64-pc-windows-msvc" +runner = "windows-latest" +asset = "liboliphaunt-{version}-windows-x64-msvc.zip" +library_relative_path = "bin/oliphaunt.dll" +surfaces = ["github-release", "rust-native-direct", "typescript-native-direct"] +published = true +extension_artifacts = true diff --git a/src/runtimes/liboliphaunt/native/tools/build-ci-target.sh b/src/runtimes/liboliphaunt/native/tools/build-ci-target.sh new file mode 100755 index 00000000..a98a345a --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools/build-ci-target.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +target="${1:-}" +case "$target" in + android-arm64-v8a|android-x86_64|ios-xcframework) + ;; + *) + echo "usage: src/runtimes/liboliphaunt/native/tools/build-ci-target.sh [android-arm64-v8a|android-x86_64|ios-xcframework]" >&2 + exit 2 + ;; +esac + +stage_root="$root/target/liboliphaunt-native-ci/$target" +mobile_extensions="${OLIPHAUNT_CI_MOBILE_EXTENSIONS:-${OLIPHAUNT_MOBILE_STATIC_EXTENSIONS:-}}" +if [ -n "$mobile_extensions" ]; then + echo "base liboliphaunt CI target builds do not accept selected extensions; publish exact extension artifacts through the extension artifact lane" >&2 + exit 2 +fi + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +stage_path() { + local source="$1" + local relative="${source#$root/}" + [ "$relative" != "$source" ] || { + echo "refusing to stage path outside repository: $source" >&2 + exit 1 + } + [ -e "$source" ] || { + echo "missing CI target artifact input: $source" >&2 + exit 1 + } + mkdir -p "$stage_root/$(dirname "$relative")" + rsync -a --delete "$source/" "$stage_root/$relative/" +} + +build_linux_runtime_assets() { + run src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh --runtime-only +} + +build_macos_runtime_assets() { + run env \ + OLIPHAUNT_BUILD_EXTENSIONS="${OLIPHAUNT_BUILD_EXTENSIONS:-0}" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --runtime-only +} + +rm -rf "$stage_root" +mkdir -p "$stage_root" + +run bun tools/policy/fetch-sources.mjs native-runtime + +case "$target" in + android-arm64-v8a) + run env \ + OLIPHAUNT_ANDROID_ABI=arm64-v8a \ + OLIPHAUNT_ANDROID_ARM64_ROOT="$root/target/liboliphaunt-pg18-android-arm64" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh + build_linux_runtime_assets + stage_path "$root/target/liboliphaunt-pg18-android-arm64/out" + stage_path "$root/target/liboliphaunt-pg18-linux-x64-gnu/install" + ;; + android-x86_64) + run env \ + OLIPHAUNT_ANDROID_ABI=x86_64 \ + OLIPHAUNT_ANDROID_X86_64_ROOT="$root/target/liboliphaunt-pg18-android-x86_64" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh + build_linux_runtime_assets + stage_path "$root/target/liboliphaunt-pg18-android-x86_64/out" + stage_path "$root/target/liboliphaunt-pg18-linux-x64-gnu/install" + ;; + ios-xcframework) + run src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh + build_macos_runtime_assets + stage_path "$root/target/liboliphaunt-ios-xcframework/out" + stage_path "$root/target/liboliphaunt-ios-simulator/out" + stage_path "$root/target/liboliphaunt-ios-device/out" + stage_path "$root/target/liboliphaunt-pg18/install" + ;; +esac + +printf '\nStaged liboliphaunt CI target artifact: %s\n' "$stage_root" diff --git a/src/runtimes/liboliphaunt/native/tools/build-release-runtime.mjs b/src/runtimes/liboliphaunt/native/tools/build-release-runtime.mjs new file mode 100755 index 00000000..7f996da0 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools/build-release-runtime.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; + +const env = { ...process.env }; +let command; +let args; + +if (process.platform === 'darwin') { + env.OLIPHAUNT_BUILD_EXTENSIONS ??= '0'; + command = 'bash'; + args = ['src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh']; +} else if (process.platform === 'linux') { + command = 'bash'; + args = ['src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh']; +} else if (process.platform === 'win32') { + command = 'pwsh'; + args = [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1', + ]; +} else { + console.error(`unsupported liboliphaunt release runtime host: ${process.platform}`); + process.exit(2); +} + +const result = spawnSync(command, args, { stdio: 'inherit', env }); +if (result.error !== undefined) { + console.error(result.error.message); + process.exit(1); +} +process.exit(result.status ?? 1); diff --git a/src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs b/src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs new file mode 100755 index 00000000..f8781b61 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs @@ -0,0 +1,425 @@ +#!/usr/bin/env node +import {execFileSync} from 'node:child_process'; +import {existsSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; + +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', +}).trim(); +const mode = process.argv[2] ?? '--check'; +const outputPath = path.join(root, 'docs/internal/OLIPHAUNT_PATCH_STACK.md'); +const sourceManifestPath = path.join(root, 'src/runtimes/liboliphaunt/native/postgres18/source.toml'); + +const REQUIRED_AUDIT_CHECKS = [ + { + id: 'host-io-vtable', + requirement: 'Host-owned protocol I/O vtable', + patches: ['0001-liboliphaunt-add-backend-host-io.patch'], + evidence: ['OliphauntEmbeddedIO', 'secure_raw_read', 'secure_raw_write'], + posture: 'Generic libpq backend hook; normal socket I/O remains untouched.', + }, + { + id: 'postmaster-waitset-guard', + requirement: 'Standalone backend waitset guard', + patches: ['0001-liboliphaunt-add-backend-host-io.patch'], + evidence: ['WL_POSTMASTER_DEATH', 'if (IsUnderPostmaster)'], + posture: 'Embedded standalone sessions avoid a postmaster-death wait handle that cannot exist.', + }, + { + id: 'embedded-entrypoint', + requirement: 'Explicit embedded backend entrypoint', + patches: ['0002-liboliphaunt-add-embedded-entrypoint.patch'], + evidence: ['oliphaunt_embedded_main', 'pq_init(&client_sock)', 'PostgresMain(dbname, username)'], + posture: 'Uses PostgreSQL backend initialization and FE/BE protocol instead of single-user query transport.', + }, + { + id: 'frontend-terminate-return', + requirement: 'Frontend Terminate returns to host owner', + patches: ['0003-liboliphaunt-return-from-embedded-frontend-terminate.patch'], + evidence: ['frontend sends Terminate', 'return;', 'proc_exit(0)'], + posture: 'Only OLIPHAUNT_EMBEDDED changes backend termination into a returning thread lifecycle.', + }, + { + id: 'embedded-exit-cleanup', + requirement: 'PostgreSQL exit callbacks still run', + patches: ['0004-liboliphaunt-run-embedded-exit-cleanup.patch'], + evidence: ['oliphaunt_embedded_proc_exit', 'proc_exit_prepare(code)'], + posture: 'Keeps upstream cleanup ordering for shmem, locks, callbacks, and backend-local state.', + }, + { + id: 'fatal-startup-guard', + requirement: 'Startup FATAL does not exit the host process', + patches: ['0009-liboliphaunt-guard-embedded-proc-exit.patch'], + evidence: ['oliphaunt_embedded_set_proc_exit_handler', 'siglongjmp', 'proc_exit_handler'], + posture: 'Embedded startup failures unwind to liboliphaunt after PostgreSQL cleanup callbacks run.', + }, + { + id: 'fatal-startup-cleanup-label', + requirement: 'Embedded proc_exit guard is cleared before returning to host', + patches: ['0009-liboliphaunt-guard-embedded-proc-exit.patch'], + evidence: ['embedded_cleanup:', 'oliphaunt_embedded_set_proc_exit_handler(NULL, NULL)', 'chdir(original_cwd)'], + posture: 'Normal and FATAL startup paths share one cleanup label so thread-local exit guards and host cwd are restored before returning.', + }, + { + id: 'cwd-restore', + requirement: 'Host working directory is restored', + patches: ['0005-liboliphaunt-restore-host-cwd.patch'], + evidence: ['original_cwd', 'getcwd(original_cwd', 'chdir(original_cwd)'], + posture: 'Contains PostgreSQL standalone ChangeToDataDir side effects inside the backend lifetime.', + }, + { + id: 'static-extension-loader', + requirement: 'Static extension registry uses PostgreSQL dfmgr path', + patches: ['0006-liboliphaunt-add-static-extension-loader.patch'], + evidence: ['oliphaunt_static_extension_lookup', 'lookup_library_symbol', 'oliphaunt_static_extension_symbol'], + posture: 'CREATE EXTENSION/LOAD semantics stay in PostgreSQL; hosts only provide module symbols.', + }, + { + id: 'static-extension-magic', + requirement: 'Static extension ABI magic is validated', + patches: [ + '0006-liboliphaunt-add-static-extension-loader.patch', + '0008-liboliphaunt-clean-embedded-symbols.patch', + ], + evidence: ['oliphaunt_static_extension_magic', 'Pg_magic_struct', 'memcmp(&magic_data_ptr->abi_fields'], + posture: 'Static modules still pass PostgreSQL ABI checks before symbols are used.', + }, + { + id: 'host-runtime-paths', + requirement: 'Runtime paths come from host-packaged resources', + patches: ['0010-liboliphaunt-use-host-runtime-paths.patch'], + evidence: ['oliphaunt_embedded_set_runtime_paths', 'my_exec_path', 'PGSYSCONFDIR'], + posture: 'Avoids executable-bit assumptions for mobile resources while preserving PostgreSQL path derivation.', + }, + { + id: 'apple-mobile-shell-exclusion', + requirement: 'Apple mobile builds do not call system(3)', + patches: ['0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch'], + evidence: ['OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS', 'TARGET_OS_IPHONE', 'archive_command cannot be executed'], + posture: 'Mobile direct mode fails optional shell archive/restore hooks explicitly instead of compiling unavailable APIs.', + }, + { + id: 'embedded-mobile-shared-memory', + requirement: 'Embedded mobile shared memory and semaphores are process-local', + patches: ['0011-liboliphaunt-add-android-embedded-shared-memory.patch'], + evidence: ['oliphaunt_embedded_shmem.c', 'oliphaunt_embedded_sema.c', 'OLIPHAUNT_EMBEDDED_MOBILE_SHMEM'], + posture: 'Android and Apple mobile builds avoid unavailable SysV shared memory and semaphores while direct mode remains one backend per process.', + }, + { + id: 'event-trigger-policy', + requirement: 'Event triggers run in embedded protocol sessions', + patches: ['0012-liboliphaunt-enable-event-triggers-in-embedded-backend.patch'], + evidence: ['EventTriggersHaveRunnableBackend', 'OLIPHAUNT_EMBEDDED', 'event_triggers'], + posture: 'Keeps upstream single-user escape hatch outside OLIPHAUNT_EMBEDDED but treats embedded protocol sessions as runnable backends.', + }, + { + id: 'static-icu-data', + requirement: 'Static ICU data is registered before PostgreSQL calls ICU APIs', + patches: ['0013-liboliphaunt-register-static-icu-data.patch'], + evidence: ['pg_register_static_icu_data', 'udata_setCommonData', 'init_icu_converter'], + posture: 'Static ICU consumers can initialize PostgreSQL without loose ICU data files while dynamic ICU builds remain unchanged.', + }, + { + id: 'embedded-meson-option', + requirement: 'Meson builds expose an explicit embedded backend option', + patches: ['0015-liboliphaunt-add-embedded-meson-option.patch'], + evidence: ['oliphaunt_embedded', 'add_project_arguments', '-DOLIPHAUNT_EMBEDDED'], + posture: 'Windows and other Meson-hosted embedded builds enable the backend entrypoint through PostgreSQL build configuration while default server builds remain unchanged.', + }, +]; + +const EXPECTED_UPSTREAM_TOUCHPOINTS = new Map([ + ['meson.build', 'Meson-hosted embedded builds enable OLIPHAUNT_EMBEDDED through an explicit opt-in build option.'], + ['meson_options.txt', 'Meson-hosted embedded builds declare the opt-in embedded backend option without changing default PostgreSQL builds.'], + ['src/backend/access/transam/xlogarchive.c', 'Apple mobile embedded builds compile out optional archive shell commands.'], + ['src/backend/archive/shell_archive.c', 'Apple mobile embedded builds compile out optional archive shell commands.'], + ['src/backend/commands/collationcmds.c', 'Static ICU consumers register linked common data before collation commands call ICU locale APIs.'], + ['src/backend/commands/event_trigger.c', 'Embedded FE/BE protocol sessions can run event triggers without changing standalone recovery behavior.'], + ['src/backend/libpq/be-secure.c', 'Backend secure read/write path delegates to a host I/O vtable only when OLIPHAUNT_EMBEDDED is set.'], + ['src/backend/libpq/pqcomm.c', 'Standalone embedded sessions avoid waiting on a non-existent postmaster death latch.'], + ['src/backend/port/Makefile', 'Embedded mobile builds swap unavailable SysV shared memory and semaphores for process-local implementations.'], + ['src/backend/port/meson.build', 'Android embedded builds swap unavailable SysV shared memory and semaphores for process-local implementations.'], + ['src/backend/port/oliphaunt_embedded_sema.c', 'Embedded mobile semaphore implementation for one backend in one process.'], + ['src/backend/port/oliphaunt_embedded_shmem.c', 'Embedded mobile shared memory implementation for one backend in one process.'], + ['src/backend/storage/ipc/ipc.c', 'Embedded backend cleanup and proc_exit unwinding stay at PostgreSQL lifecycle boundaries.'], + ['src/backend/tcop/postgres.c', 'Embedded backend entrypoint, protocol lifecycle, cwd restoration, and host runtime paths.'], + ['src/backend/utils/adt/pg_locale.c', 'Static ICU consumers register linked common data before PostgreSQL validates or canonicalizes ICU locales.'], + ['src/backend/utils/adt/pg_locale_icu.c', 'Static ICU consumers register linked common data before PostgreSQL opens ICU collators or converters.'], + ['src/backend/utils/fmgr/dfmgr.c', 'Static extension lookup reuses PostgreSQL dynamic function manager semantics.'], + ['src/include/libpq/libpq-be.h', 'Host I/O vtable is attached to PostgreSQL Port state under OLIPHAUNT_EMBEDDED.'], + ['src/include/port.h', 'Embedded mobile builds avoid POSIX shared memory declarations in the portable path.'], + ['src/include/storage/dsm_impl.h', 'Embedded mobile builds keep DSM on mmap instead of POSIX or SysV shared memory.'], + ['src/include/storage/ipc.h', 'Embedded cleanup and proc_exit guard declarations.'], + ['src/include/tcop/tcopprot.h', 'Embedded entrypoint and returning PostgresMain declarations.'], + ['src/include/utils/pg_locale.h', 'Declares the generic static ICU data registration helper for PostgreSQL ICU call sites.'], + ['src/port/chklocale.c', 'Android embedded builds avoid unsupported locale-environment mutation.'], +]); + +if (!['--check', '--write'].includes(mode)) { + console.error('usage: src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs [--check|--write]'); + process.exit(2); +} + +function read(relativePath) { + return readFileSync(path.join(root, relativePath), 'utf8'); +} + +function parseSourceManifest() { + const text = readFileSync(sourceManifestPath, 'utf8'); + const version = matchRequired(text, /version\s*=\s*"([^"]+)"/u, 'postgresql.version'); + const url = matchRequired(text, /url\s*=\s*"([^"]+)"/u, 'postgresql.url'); + const sha256 = matchRequired(text, /sha256\s*=\s*"([^"]+)"/u, 'postgresql.sha256'); + const directory = matchRequired(text, /directory\s*=\s*"([^"]+)"/u, 'patches.directory'); + const seriesBlock = matchRequired(text, /series\s*=\s*\[([\s\S]*?)\]/u, 'patches.series'); + const series = Array.from(seriesBlock.matchAll(/"([^"]+\.patch)"/gu), match => match[1]); + if (series.length === 0) { + throw new Error('src/runtimes/liboliphaunt/native/postgres18/source.toml patch series is empty'); + } + const patchDir = path.resolve(path.dirname(sourceManifestPath), directory); + return {version, url, sha256, directory, patchDir, series}; +} + +function matchRequired(text, pattern, label) { + const match = text.match(pattern); + if (!match) { + throw new Error(`missing ${label} in src/runtimes/liboliphaunt/native/postgres18/source.toml`); + } + return match[1]; +} + +function patchFiles(patchDir) { + return readdirSync(patchDir) + .filter(name => name.endsWith('.patch')) + .sort((a, b) => a.localeCompare(b)); +} + +function parsePatch(fileName, patchDir) { + const relativePath = `src/runtimes/liboliphaunt/native/patches/${path.basename(patchDir)}/${fileName}`; + const text = read(relativePath); + const author = text.match(/^From:\s+(.+)$/mu)?.[1]; + if (!author) { + throw new Error(`${relativePath} must have a deterministic From: header`); + } + if (author !== 'liboliphaunt ') { + throw new Error( + `${relativePath} From: header must be "liboliphaunt ", got ${author}`, + ); + } + const subject = text.match(/^Subject:\s+\[PATCH\]\s+(.+)$/mu)?.[1]; + if (!subject) { + throw new Error(`${relativePath} must have a deterministic Subject: [PATCH] header`); + } + if (!subject.startsWith('liboliphaunt: ')) { + throw new Error(`${relativePath} subject must start with "liboliphaunt: "`); + } + + const changedFiles = Array.from( + text.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gmu), + match => match[2], + ); + if (changedFiles.length === 0) { + throw new Error(`${relativePath} does not contain any diff --git file entries`); + } + + const forbidden = []; + const whitespaceProblems = []; + const symbols = new Set(); + for (const [index, line] of text.split('\n').entries()) { + if (!line.startsWith('+') || line.startsWith('+++')) { + continue; + } + if (line !== '+' && /[ \t]$/u.test(line)) { + whitespaceProblems.push(`${index + 1}: ${line}`); + } + if (/^\+ \t/u.test(line)) { + whitespaceProblems.push(`${index + 1}: ${line}`); + } + if (/\b(Swift|Kotlin|React|JavaScript|TypeScript|wasix|wasmer|wasm|oliphaunt-wasix)\b/iu.test(line)) { + forbidden.push(line); + } + if (/\b(extern|PGDLLIMPORT|oliphaunt_embedded_main|oliphaunt_embedded_proc_exit)\b/u.test(line)) { + for (const symbol of line.matchAll(/\b(oliphaunt_[A-Za-z0-9_]+)\b/gu)) { + symbols.add(symbol[1]); + } + } + } + if (forbidden.length > 0) { + throw new Error( + `${relativePath} contains product-specific terms in added PostgreSQL code:\n${forbidden.join('\n')}`, + ); + } + if (whitespaceProblems.length > 0) { + throw new Error( + `${relativePath} contains whitespace problems in added PostgreSQL code:\n${whitespaceProblems.join('\n')}`, + ); + } + + return { + fileName, + relativePath, + author, + subject, + changedFiles, + symbols: Array.from(symbols).sort((a, b) => a.localeCompare(b)), + }; +} + +function validateSeries(manifest, actualFiles) { + const expected = manifest.series; + if (JSON.stringify(expected) !== JSON.stringify(actualFiles)) { + const expectedText = expected.map(name => ` ${name}`).join('\n'); + const actualText = actualFiles.map(name => ` ${name}`).join('\n'); + throw new Error( + `source.toml patch series must exactly match patch directory files\nexpected:\n${expectedText}\nactual:\n${actualText}`, + ); + } +} + +function render() { + const manifest = parseSourceManifest(); + const actualFiles = patchFiles(manifest.patchDir); + validateSeries(manifest, actualFiles); + const patches = actualFiles.map(fileName => parsePatch(fileName, manifest.patchDir)); + const patchesByName = new Map(patches.map(patch => [patch.fileName, patch])); + + const changedFiles = new Map(); + const symbols = new Map(); + for (const patch of patches) { + for (const file of patch.changedFiles) { + if (!changedFiles.has(file)) { + changedFiles.set(file, []); + } + changedFiles.get(file).push(patch.fileName); + } + for (const symbol of patch.symbols) { + if (!symbols.has(symbol)) { + symbols.set(symbol, []); + } + symbols.get(symbol).push(patch.fileName); + } + } + + for (const file of changedFiles.keys()) { + if (!EXPECTED_UPSTREAM_TOUCHPOINTS.has(file)) { + throw new Error( + `patch-stack audit found unexpected upstream touchpoint ${file}; add an explicit rationale before changing it`, + ); + } + } + for (const file of EXPECTED_UPSTREAM_TOUCHPOINTS.keys()) { + if (!changedFiles.has(file)) { + throw new Error(`patch-stack audit expected upstream touchpoint ${file} is no longer changed`); + } + } + + for (const check of REQUIRED_AUDIT_CHECKS) { + for (const patchName of check.patches) { + if (!patchesByName.has(patchName)) { + throw new Error(`patch-stack audit check ${check.id} references missing patch ${patchName}`); + } + } + const checkText = check.patches + .map(patchName => read(patchesByName.get(patchName).relativePath)) + .join('\n'); + const missing = check.evidence.filter(fragment => !checkText.includes(fragment)); + if (missing.length > 0) { + throw new Error( + `patch-stack audit check ${check.id} is missing evidence in ${check.patches.join(', ')}: ${missing.join(', ')}`, + ); + } + } + + const lines = []; + lines.push(''); + lines.push('# liboliphaunt PostgreSQL 18 Patch Stack Review'); + lines.push(''); + lines.push('This source-only review artifact keeps the native PostgreSQL patch stack deterministic and reviewable without rebuilding PostgreSQL.'); + lines.push(''); + lines.push('Regenerate with:'); + lines.push(''); + lines.push('```sh'); + lines.push('src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --write'); + lines.push('```'); + lines.push(''); + lines.push('## Source Pin'); + lines.push(''); + lines.push(`- PostgreSQL: \`${manifest.version}\``); + lines.push(`- URL: \`${manifest.url}\``); + lines.push(`- SHA-256: \`${manifest.sha256}\``); + lines.push(`- Patch directory: \`${manifest.directory}\``); + lines.push(''); + lines.push('## Patch Series'); + lines.push(''); + lines.push('| Order | Patch | Author | Subject |'); + lines.push('| --- | --- | --- | --- |'); + patches.forEach((patch, index) => { + lines.push(`| ${index + 1} | \`${patch.fileName}\` | ${patch.author} | ${patch.subject} |`); + }); + lines.push(''); + lines.push('## Changed Upstream Files'); + lines.push(''); + for (const [file, owners] of Array.from(changedFiles.entries()).sort()) { + lines.push(`- \`${file}\` (${owners.map(owner => `\`${owner}\``).join(', ')})`); + } + lines.push(''); + lines.push('## Expected Upstream Touchpoints'); + lines.push(''); + lines.push('| File | Rationale |'); + lines.push('| --- | --- |'); + for (const [file, rationale] of Array.from(EXPECTED_UPSTREAM_TOUCHPOINTS.entries()).sort()) { + lines.push(`| \`${file}\` | ${rationale} |`); + } + lines.push(''); + lines.push('## PostgreSQL Patch Symbols'); + lines.push(''); + if (symbols.size === 0) { + lines.push('- none'); + } else { + for (const [symbol, owners] of Array.from(symbols.entries()).sort()) { + lines.push(`- \`${symbol}\` (${owners.map(owner => `\`${owner}\``).join(', ')})`); + } + } + lines.push(''); + lines.push('## Audit Checklist'); + lines.push(''); + lines.push('| Requirement | Owning Patch | Required Evidence | Review Posture |'); + lines.push('| --- | --- | --- | --- |'); + for (const check of REQUIRED_AUDIT_CHECKS) { + lines.push( + `| ${check.requirement} | ${check.patches.map(patch => `\`${patch}\``).join(', ')} | ${check.evidence.map(fragment => `\`${fragment}\``).join(', ')} | ${check.posture} |`, + ); + } + lines.push(''); + lines.push('## Guardrails'); + lines.push(''); + lines.push('- `source.toml` patch series exactly matches the patch directory.'); + lines.push('- Every patch has a deterministic `From: liboliphaunt ` header.'); + lines.push('- Every patch has a deterministic `Subject: [PATCH] liboliphaunt: ...` header.'); + lines.push('- Added PostgreSQL lines are checked for trailing whitespace, space-before-tab indentation, and SDK/runtime/product-specific terms that belong above PostgreSQL.'); + lines.push('- Changed upstream files must exactly match the expected touchpoint table above; new upstream touchpoints need an explicit rationale before landing.'); + lines.push('- Required audit checks prove their evidence in the named owning patch or patches, keeping host I/O, embedded lifecycle, cleanup, cwd restore, runtime paths, static extensions, mobile shell exclusion, embedded mobile shared memory, and event triggers reviewable independently.'); + lines.push('- Changed upstream files and patch-introduced `oliphaunt_*` symbols are listed here for release review.'); + lines.push(''); + + return `${lines.join('\n')}`; +} + +function normalizeGeneratedMarkdown(text) { + return text.replace(/\r\n/gu, '\n').replace(/\r/gu, '\n').trimEnd(); +} + +try { + const generated = render(); + if (mode === '--write') { + writeFileSync(outputPath, generated, 'utf8'); + } else { + const current = existsSync(outputPath) ? readFileSync(outputPath, 'utf8') : ''; + if (normalizeGeneratedMarkdown(current) !== normalizeGeneratedMarkdown(generated)) { + console.error('docs/internal/OLIPHAUNT_PATCH_STACK.md is stale; run src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --write'); + process.exit(1); + } + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/src/runtimes/liboliphaunt/native/tools/check-track.sh b/src/runtimes/liboliphaunt/native/tools/check-track.sh new file mode 100755 index 00000000..5cf56f86 --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools/check-track.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env sh +set -eu + +mode="${1:-quick}" + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +. "$root/tools/runtime/preflight.sh" + +work_root="$(oliphaunt_runtime_native_host_work_root)" +default_liboliphaunt="$(oliphaunt_runtime_native_host_default_lib)" +default_initdb="$(oliphaunt_runtime_native_host_default_initdb)" +default_postgres="$(oliphaunt_runtime_native_host_default_postgres)" +default_install_dir="$(oliphaunt_runtime_native_host_default_install_dir)" +build_policy="${OLIPHAUNT_TRACK_BUILD:-missing}" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +native_runtime_lock() { + run tools/runtime/with-native-runtime-lock.py "$@" +} + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +run_native_backlog_guard() { + run src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --check + if ! grep -Fq "Native Product Backlog" docs/internal/TODO.md; then + echo "docs/internal/TODO.md must track the native product backlog" >&2 + exit 1 + fi + if ! grep -Fq "Benchmarks release claims" docs/internal/TODO.md && + ! grep -Fq "Make Benchmarks Release-Grade" docs/internal/TODO.md; then + echo "docs/internal/TODO.md must keep native benchmark release work visible" >&2 + exit 1 + fi + if grep -Eiq -- 'route native product work back to WASIX|WASIX fallback|--skip-wasix|Wasmer' docs/internal/TODO.md; then + echo "docs/internal/TODO.md must not route native product work back to the legacy runtime lane" >&2 + exit 1 + fi +} + +runtime_ready() { + [ -f "${LIBOLIPHAUNT_PATH:-$default_liboliphaunt}" ] && + [ -x "${OLIPHAUNT_INITDB:-$default_initdb}" ] && + [ -x "${OLIPHAUNT_POSTGRES:-$default_postgres}" ] && + [ -d "${OLIPHAUNT_INSTALL_DIR:-$default_install_dir}" ] +} + +liboliphaunt_current() { + if [ "${OLIPHAUNT_TRACK_SKIP_CURRENT_GUARD:-0}" = "1" ]; then + return 0 + fi + case "$(uname -s)" in + Darwin) + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --check-oliphaunt-current >/dev/null + ;; + Linux) + src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh --check-current >/dev/null + ;; + MINGW* | MSYS* | CYGWIN*) + pwsh -NoProfile -ExecutionPolicy Bypass -File src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 -CheckCurrent >/dev/null + ;; + *) + return 1 + ;; + esac +} + +host_supports_extension_artifacts() { + [ "$(uname -s)" = "Darwin" ] +} + +host_build_runtime() { + case "$(uname -s)" in + Darwin) + env OLIPHAUNT_BUILD_EXTENSIONS="$OLIPHAUNT_BUILD_EXTENSIONS" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh + ;; + Linux) + src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh + ;; + MINGW* | MSYS* | CYGWIN*) + pwsh -NoProfile -ExecutionPolicy Bypass -File src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 + ;; + *) + echo "native liboliphaunt validation is unsupported on $(uname -s)" >&2 + return 2 + ;; + esac +} + +requires_extension_artifacts() { + case "$mode" in + extensions|full) + return 0 + ;; + *) + return 1 + ;; + esac +} + +extension_sql_ready() { + extension="$1" + extension_dir="${OLIPHAUNT_INSTALL_DIR:-$default_install_dir}/share/postgresql/extension" + [ -f "$extension_dir/$extension.control" ] || return 1 + ls "$extension_dir/$extension"--*.sql >/dev/null 2>&1 +} + +extension_artifacts_ready() { + install_dir="${OLIPHAUNT_INSTALL_DIR:-$default_install_dir}" + out_dir="$(dirname "${LIBOLIPHAUNT_PATH:-$default_liboliphaunt}")" + [ -f "$out_dir/native-extension-artifacts.sha256" ] || return 1 + + required_artifacts="$(src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --print-required-extension-artifacts)" + for artifact in $required_artifacts; do + kind="${artifact%%:*}" + name="${artifact#*:}" + case "$kind" in + control) + extension_sql_ready "$name" || return 1 + ;; + module) + [ -f "$install_dir/lib/postgresql/$name.dylib" ] || return 1 + [ -f "$out_dir/modules/$name.dylib" ] || return 1 + ;; + *) + echo "unknown native extension artifact kind from build script: $artifact" >&2 + return 1 + ;; + esac + done +} + +export_default_runtime() { + export LIBOLIPHAUNT_PATH="${LIBOLIPHAUNT_PATH:-$default_liboliphaunt}" + export OLIPHAUNT_INITDB="${OLIPHAUNT_INITDB:-$default_initdb}" + export OLIPHAUNT_POSTGRES="${OLIPHAUNT_POSTGRES:-$default_postgres}" + export OLIPHAUNT_INSTALL_DIR="${OLIPHAUNT_INSTALL_DIR:-$default_install_dir}" +} + +ensure_native_runtime() { + case "$(uname -s)" in + Darwin | Linux | MINGW* | MSYS* | CYGWIN*) + ;; + *) + if [ -n "${OLIPHAUNT_REQUIRE_NATIVE:-}" ]; then + echo "native liboliphaunt validation is unsupported on $(uname -s)" >&2 + exit 1 + fi + echo "warning: skipping native runtime checks on unsupported host $(uname -s)" >&2 + return 1 + ;; + esac + + export_default_runtime + + if requires_extension_artifacts; then + if ! host_supports_extension_artifacts; then + echo "native extension artifact validation currently requires Darwin host tooling" >&2 + return 1 + fi + export OLIPHAUNT_BUILD_EXTENSIONS="${OLIPHAUNT_BUILD_EXTENSIONS:-1}" + else + export OLIPHAUNT_BUILD_EXTENSIONS="${OLIPHAUNT_BUILD_EXTENSIONS:-0}" + fi + + case "$build_policy" in + always) + run host_build_runtime + ;; + missing) + if ! runtime_ready; then + run host_build_runtime + elif ! liboliphaunt_current; then + echo "refreshing stale native liboliphaunt runtime through fingerprinted build script" + run host_build_runtime + elif requires_extension_artifacts; then + if src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --check-extension-artifacts-current >/dev/null; then + echo "reusing current native extension artifacts at $work_root" + else + echo "refreshing native extension artifacts through fingerprinted build script" + run host_build_runtime + fi + else + echo "reusing native Oliphaunt runtime at $work_root" + fi + ;; + never) + if ! liboliphaunt_current; then + cat >&2 <&2 + exit 2 + ;; + esac + + export_default_runtime + if ! runtime_ready; then + cat >&2 <&2 <&2 <<'MSG' +usage: src/runtimes/liboliphaunt/native/tools/check-track.sh [host-smoke|quick|rust|extensions|sdks|external-pgrx|full] + +Modes: + host-smoke reuse existing native runtime and run the host C ABI smoke only + quick reuse/build missing native runtime, run C smoke and Rust native SDK tests + rust run Rust oliphaunt tests without forcing native runtime smoke + extensions quick plus the native extension matrix with extension artifacts enabled + sdks Rust, Swift, Kotlin, and React Native SDK package checks + external-pgrx + prove opt-in pgrx extension artifacts are current + full extensions plus SDK checks + +Set OLIPHAUNT_TRACK_BUILD=never to fail fast if native artifacts are missing, +missing to build only when absent, or always for a deliberate rebuild. +MSG + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs b/src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs new file mode 100755 index 00000000..b2e9aefb --- /dev/null +++ b/src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs @@ -0,0 +1,469 @@ +#!/usr/bin/env node +import childProcess from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const PG_VERSION = '18.4'; + +function usage() { + console.error(`usage: src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs [--abi-only|--smoke-only] [--root ] + +Compiles and runs the host liboliphaunt C ABI smoke against the current native +runtime artifacts for macOS, Linux, or Windows. + +Set LIBOLIPHAUNT_PATH and OLIPHAUNT_INSTALL_DIR to smoke a staged release +layout. Set OLIPHAUNT_SMOKE_BIN_DIR to keep compiled smoke binaries out of that +layout. Set OLIPHAUNT_SMOKE_ROOT to run database scratch roots outside the +build work root.`); +} + +function parseArgs(argv) { + const args = { + abiOnly: false, + smokeOnly: false, + root: '', + }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--abi-only') { + args.abiOnly = true; + } else if (arg === '--smoke-only') { + args.smokeOnly = true; + } else if (arg === '--root') { + index++; + if (index >= argv.length) { + throw new Error('--root requires a directory'); + } + args.root = argv[index]; + } else if (arg === '--help' || arg === '-h') { + usage(); + process.exit(0); + } else if (!arg.startsWith('-') && !args.root) { + args.root = arg; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + if (args.abiOnly && args.smokeOnly) { + throw new Error('--abi-only and --smoke-only are mutually exclusive'); + } + return args; +} + +function run(command, args, options = {}) { + const rendered = [command, ...args].join(' '); + console.error(`\n==> ${rendered}`); + const result = childProcess.spawnSync(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + encoding: 'utf8', + shell: false, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + windowsVerbatimArguments: options.windowsVerbatimArguments ?? false, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + if (options.capture) { + process.stderr.write(result.stderr ?? ''); + process.stderr.write(result.stdout ?? ''); + } + throw new Error(`${rendered} exited with status ${result.status}`); + } + return result.stdout ?? ''; +} + +function commandExists(command, env = process.env) { + const result = process.platform === 'win32' + ? childProcess.spawnSync('where.exe', [command], { env, stdio: 'ignore' }) + : childProcess.spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command], { + env, + stdio: 'ignore', + }); + return result.status === 0; +} + +function executableExists(file) { + if (!fs.existsSync(file)) { + return false; + } + if (process.platform === 'win32') { + return true; + } + try { + fs.accessSync(file, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function requireExecutable(file, label) { + if (!executableExists(file)) { + throw new Error(`missing ${label}: ${file}`); + } +} + +function repoRoot() { + const output = childProcess.spawnSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (output.status === 0 && output.stdout.trim()) { + return output.stdout.trim(); + } + return path.resolve(new URL('../../..', import.meta.url).pathname); +} + +function hostTarget() { + if (process.platform === 'darwin') { + return process.arch === 'x64' ? 'macos-x64' : 'macos-arm64'; + } + if (process.platform === 'linux') { + if (process.arch === 'x64') { + return 'linux-x64-gnu'; + } + if (process.arch === 'arm64') { + return 'linux-arm64-gnu'; + } + } + if (process.platform === 'win32' && process.arch === 'x64') { + return 'windows-x64-msvc'; + } + throw new Error(`unsupported liboliphaunt host target: ${process.platform}/${process.arch}`); +} + +function defaultWorkRoot(root, target) { + if (process.env.OLIPHAUNT_WORK_ROOT) { + return path.resolve(process.env.OLIPHAUNT_WORK_ROOT); + } + if (process.platform === 'darwin') { + return path.join(root, 'target/liboliphaunt-pg18'); + } + return path.join(root, `target/liboliphaunt-pg18-${target}`); +} + +function executableName(name) { + return process.platform === 'win32' ? `${name}.exe` : name; +} + +function artifactPaths(root) { + const target = hostTarget(); + const workRoot = defaultWorkRoot(root, target); + const installDir = path.resolve(process.env.OLIPHAUNT_INSTALL_DIR ?? path.join(workRoot, 'install')); + const libPath = path.resolve( + process.env.LIBOLIPHAUNT_PATH ?? + (process.platform === 'win32' + ? path.join(workRoot, 'out/bin/oliphaunt.dll') + : path.join(workRoot, `out/${process.platform === 'darwin' ? 'liboliphaunt.dylib' : 'liboliphaunt.so'}`)), + ); + const outDir = process.platform === 'win32' + ? path.dirname(path.dirname(libPath)) + : path.dirname(libPath); + const binDir = path.resolve( + process.env.OLIPHAUNT_SMOKE_BIN_DIR ?? + (process.platform === 'win32' ? path.dirname(libPath) : outDir), + ); + return { + root, + target, + workRoot, + outDir, + binDir, + installDir, + buildDir: path.join(workRoot, `postgresql-${PG_VERSION}`), + embeddedBuildDir: path.join(workRoot, 'meson-embedded'), + libPath, + importLib: path.join(outDir, 'lib/oliphaunt.lib'), + initdb: path.resolve(process.env.OLIPHAUNT_INITDB ?? path.join(installDir, 'bin', executableName('initdb'))), + postgres: path.resolve(process.env.OLIPHAUNT_POSTGRES ?? path.join(installDir, 'bin', executableName('postgres'))), + }; +} + +function requireFile(file, label) { + if (!fs.existsSync(file)) { + throw new Error(`missing ${label}: ${file}`); + } +} + +function normalizeForC(value) { + return process.platform === 'win32' ? value.replaceAll('\\', '/') : value; +} + +function splitCommand(value, fallback) { + return (value ?? fallback).trim().split(/\s+/).filter(Boolean); +} + +function ccachePrefix() { + const mode = process.env.OLIPHAUNT_CCACHE ?? 'auto'; + if (mode === '0' || mode === 'off' || process.platform === 'win32') { + return []; + } + if (mode !== 'auto') { + return [mode]; + } + return commandExists('ccache') ? ['ccache'] : []; +} + +function compileUnix(paths, kind, source, output, extraArgs) { + const envName = kind === 'abi' ? 'OLIPHAUNT_ABI_CC' : 'OLIPHAUNT_SMOKE_CC'; + const compiler = splitCommand(process.env[envName], 'cc'); + const command = [...ccachePrefix(), ...compiler]; + const exe = command[0]; + const args = [ + ...command.slice(1), + '-std=c11', + '-Wall', + '-Wextra', + '-Werror', + '-O0', + '-g', + '-I', + path.join(paths.root, 'src/runtimes/liboliphaunt/native/include'), + ...extraArgs, + source, + '-L', + path.dirname(paths.libPath), + `-Wl,-rpath,${path.dirname(paths.libPath)}`, + '-pthread', + '-loliphaunt', + '-o', + output, + ]; + run(exe, args, { cwd: paths.root }); +} + +function msvcEnvironment() { + if (commandExists('cl.exe')) { + return process.env; + } + const programFilesX86 = process.env['ProgramFiles(x86)']; + if (!programFilesX86) { + throw new Error('ProgramFiles(x86) is not set; cannot locate Visual Studio Build Tools'); + } + const vswhere = path.join(programFilesX86, 'Microsoft Visual Studio/Installer/vswhere.exe'); + requireFile(vswhere, 'vswhere.exe'); + const vsRoot = run( + vswhere, + [ + '-latest', + '-products', + '*', + '-requires', + 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', + 'installationPath', + ], + { capture: true }, + ).trim(); + if (!vsRoot) { + throw new Error('Visual Studio Build Tools with MSVC x64 tools were not found'); + } + const vsDevCmd = path.join(vsRoot, 'Common7/Tools/VsDevCmd.bat'); + requireFile(vsDevCmd, 'VsDevCmd.bat'); + const envOutput = run( + 'cmd.exe', + ['/d', '/s', '/c', `call "${vsDevCmd}" -arch=x64 -host_arch=x64 >nul && set`], + { capture: true, windowsVerbatimArguments: true }, + ); + const env = { ...process.env }; + for (const line of envOutput.split(/\r?\n/)) { + const match = /^(.*?)=(.*)$/.exec(line); + if (match) { + env[match[1]] = match[2]; + } + } + return env; +} + +function compileWindows(paths, source, output, extraIncludes) { + requireFile(paths.importLib, 'Windows import library'); + const env = msvcEnvironment(); + fs.mkdirSync(path.dirname(output), { recursive: true }); + run( + 'cl.exe', + [ + '/nologo', + '/std:c11', + '/Zi', + '/MD', + '/D_CRT_SECURE_NO_WARNINGS', + '/DWIN32_LEAN_AND_MEAN', + `/I${path.join(paths.root, 'src/runtimes/liboliphaunt/native/include')}`, + ...extraIncludes.map((include) => `/I${include}`), + source, + '/link', + `/LIBPATH:${path.dirname(paths.importLib)}`, + 'oliphaunt.lib', + `/OUT:${output}`, + ], + { cwd: paths.root, env }, + ); +} + +function compileAbi(paths) { + requireFile(paths.libPath, 'liboliphaunt library'); + const source = path.join(paths.root, 'src/runtimes/liboliphaunt/native/smoke/liboliphaunt_abi_conformance.c'); + const output = path.join(paths.binDir, executableName('liboliphaunt_abi_conformance')); + fs.mkdirSync(path.dirname(output), { recursive: true }); + if (process.platform === 'win32') { + compileWindows(paths, source, output, []); + } else { + compileUnix(paths, 'abi', source, output, ['-pedantic']); + } + run(output, [], { env: smokeEnv(paths) }); +} + +function smokeIncludes(paths) { + const includes = [ + path.join(paths.root, 'src/runtimes/liboliphaunt/native/src'), + path.join(paths.buildDir, 'src/include'), + path.join(paths.embeddedBuildDir, 'src/include'), + path.join(paths.installDir, 'include'), + ]; + if (process.platform === 'win32') { + includes.push(path.join(paths.buildDir, 'src/include/port/win32')); + } + return includes; +} + +function compileSmoke(paths) { + requireFile(paths.libPath, 'liboliphaunt library'); + requireExecutable(paths.initdb, 'initdb'); + requireExecutable(paths.postgres, 'postgres'); + const source = path.join(paths.root, 'src/runtimes/liboliphaunt/native/smoke/liboliphaunt_smoke.c'); + const output = path.join(paths.binDir, executableName('liboliphaunt_smoke')); + fs.mkdirSync(path.dirname(output), { recursive: true }); + if (process.platform === 'win32') { + compileWindows(paths, source, output, smokeIncludes(paths)); + } else { + const includeArgs = smokeIncludes(paths).flatMap((include) => ['-I', include]); + compileUnix(paths, 'smoke', source, output, includeArgs); + } + return output; +} + +function smokeEnv(paths) { + const sharedPathEnv = process.platform === 'win32' + ? { PATH: [path.dirname(paths.libPath), path.join(paths.installDir, 'bin'), process.env.PATH ?? ''].join(path.delimiter) } + : process.platform === 'darwin' + ? { DYLD_LIBRARY_PATH: [path.dirname(paths.libPath), process.env.DYLD_LIBRARY_PATH ?? ''].join(path.delimiter) } + : { LD_LIBRARY_PATH: [path.dirname(paths.libPath), process.env.LD_LIBRARY_PATH ?? ''].join(path.delimiter) }; + return { + ...process.env, + ...sharedPathEnv, + LIBOLIPHAUNT_PATH: paths.libPath, + OLIPHAUNT_INITDB: paths.initdb, + OLIPHAUNT_POSTGRES: paths.postgres, + OLIPHAUNT_INSTALL_DIR: paths.installDir, + OLIPHAUNT_STREAM_QUEUE_MAX_BYTES: process.env.OLIPHAUNT_STREAM_QUEUE_MAX_BYTES ?? '4096', + }; +} + +function runSmoke(paths, smokeBin, rootArg) { + const smokeRoot = process.env.OLIPHAUNT_SMOKE_ROOT + ? path.resolve(process.env.OLIPHAUNT_SMOKE_ROOT) + : paths.workRoot; + if (!rootArg) { + fs.mkdirSync(smokeRoot, { recursive: true }); + } + const root = rootArg + ? path.resolve(rootArg) + : fs.mkdtempSync(path.join(smokeRoot, 'smoke.')); + const keepRoot = Boolean(rootArg); + const pgdata = path.join(root, '.oliphaunt-pgdata'); + const runtime = path.join(root, 'runtime'); + fs.mkdirSync(runtime, { recursive: true }); + const args = [normalizeForC(pgdata), normalizeForC(runtime)]; + const env = smokeEnv(paths); + try { + run(smokeBin, args, { env }); + run(smokeBin, args, { env }); + if (!keepRoot) { + fs.rmSync(root, { recursive: true, force: true }); + } + } catch (error) { + console.error(`native smoke root: ${root}`); + throw error; + } +} + +function checkIosCSourceSyntax(paths) { + if (process.platform !== 'darwin') { + return; + } + const sdkPath = childProcess.spawnSync('xcrun', ['--sdk', 'iphonesimulator', '--show-sdk-path'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).stdout?.trim(); + const clang = childProcess.spawnSync('xcrun', ['--sdk', 'iphonesimulator', '--find', 'clang'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).stdout?.trim(); + if (!sdkPath || !clang) { + console.error('skipping iOS C source syntax check: iPhoneSimulator SDK is unavailable'); + return; + } + + const sources = [ + 'liboliphaunt_native.c', + 'liboliphaunt_runtime.c', + 'liboliphaunt_protocol.c', + 'liboliphaunt_bootstrap.c', + 'liboliphaunt_process.c', + 'liboliphaunt_trace.c', + 'liboliphaunt_fs.c', + 'liboliphaunt_archive.c', + 'liboliphaunt_archive_tar.c', + 'liboliphaunt_static_extensions.c', + 'liboliphaunt_builtin_extensions.c', + ]; + for (const source of sources) { + run(clang, [ + '-std=c11', + '-Wall', + '-Wextra', + '-Werror', + '-Wpedantic', + '-Werror=unguarded-availability-new', + '-fsyntax-only', + '-target', + 'arm64-apple-ios17.0-simulator', + '-mios-simulator-version-min=17.0', + '-isysroot', + sdkPath, + '-I', + path.join(paths.root, 'src/runtimes/liboliphaunt/native/include'), + '-I', + path.join(paths.root, 'src/runtimes/liboliphaunt/native/src'), + path.join(paths.root, 'src/runtimes/liboliphaunt/native/src', source), + ]); + } + console.error('iOS liboliphaunt C source syntax check passed'); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const root = repoRoot(); + const paths = artifactPaths(root); + console.error(`liboliphaunt host target: ${paths.target}`); + console.error(`liboliphaunt work root: ${paths.workRoot}`); + + if (!args.smokeOnly) { + compileAbi(paths); + checkIosCSourceSyntax(paths); + } + if (!args.abiOnly) { + const smokeBin = compileSmoke(paths); + runSmoke(paths, smokeBin, args.root); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/CHANGELOG.md b/src/runtimes/liboliphaunt/wasix/CHANGELOG.md similarity index 76% rename from CHANGELOG.md rename to src/runtimes/liboliphaunt/wasix/CHANGELOG.md index 2d807225..786651ec 100644 --- a/CHANGELOG.md +++ b/src/runtimes/liboliphaunt/wasix/CHANGELOG.md @@ -7,14 +7,35 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -## [0.5.1](https://github.com/f0rr0/oliphaunt/compare/0.5.0...0.5.1) - 2026-06-04 +## [0.6.0] - 2026-06-12 -### Fixed +### Added + +- Split WASIX runtime artifacts into the product-scoped `liboliphaunt-wasix` + release with portable assets, AOT crates, and GitHub release assets. +- Add exact-extension WASIX artifact build metadata for the product release + graph. + +### Changed + +- Harden WASIX builder setup, source fetches, and release packaging for + same-SHA artifact consumption. +- Rename internal WASIX runtime payload crates to `oliphaunt-wasix-assets` and + `oliphaunt-wasix-aot-*`. -- webc dependency conflict ([#39](https://github.com/f0rr0/oliphaunt/pull/39)) +## [0.5.1] - 2026-06-01 + +### Changed + +- Rename project to Oliphaunt +- Organize polyglot release tooling ## [0.5.0](https://github.com/f0rr0/oliphaunt/compare/0.4.1...0.5.0) - 2026-05-08 +### Added + +- [**breaking**] add WASIX asset pipeline and protocol recovery ([#13](https://github.com/f0rr0/oliphaunt/pull/13)) + ### Fixed - publish runtime assets on GitHub releases ([#27](https://github.com/f0rr0/oliphaunt/pull/27)) @@ -26,7 +47,7 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - Publish release crates from the validated staged workspace so the - `pglite-oxide-assets` and target-specific AOT crates include their generated + `oliphaunt-wasix-assets` and target-specific AOT crates include their generated payloads ([#25](https://github.com/f0rr0/oliphaunt/pull/25), [#24](https://github.com/f0rr0/oliphaunt/issues/24)). - Release all internal asset/AOT crates at `0.4.1` alongside the root crate so @@ -42,7 +63,7 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Breaking -- Pivoted `pglite-oxide` to a new runtime architecture built around +- Pivoted `oliphaunt-wasix` to a new runtime architecture built around reproducible Wasmer WASIX artifacts, generated asset manifests, and target-specific AOT crates instead of checked-in runtime blobs ([#13](https://github.com/f0rr0/oliphaunt/pull/13)). @@ -64,7 +85,7 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- Refreshed example lockfiles automatically after release-plz version bumps so +- Refreshed example lockfiles automatically after release version bumps so release PRs keep examples in sync with package versions. - Isolated downloaded AOT artifacts by target and preserved portable assets after cache restore during asset/release jobs. @@ -79,7 +100,7 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - optimize startup and add Tauri SQLx profiler ([#9](https://github.com/f0rr0/oliphaunt/pull/9)) -- `PgliteRuntimeOptions::default` now selects the optimized embedded-template +- `OliphauntRuntimeOptions::default` now selects the optimized embedded-template startup path. - `ensure_cluster` now requires runtime options. - Runtime packaging now uses a bundled optimized runtime archive. @@ -90,7 +111,8 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). for faster startup. - Embedded prepopulated PGDATA template with manifest validation. - Vanilla Tauri v2 SQLx profiler example with release-mode workload reporting. -- Repo hooks for Conventional Commit validation, formatting, and pre-push checks. +- Repo hooks for Conventional Commit validation and lightweight formatting/file + hygiene checks. ### Changed @@ -107,9 +129,9 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- modernize embedded PGlite API and OSS tooling ([#3](https://github.com/f0rr0/oliphaunt/pull/3)) +- modernize embedded Oliphaunt API and OSS tooling ([#3](https://github.com/f0rr0/oliphaunt/pull/3)) -- Added the high-level `Pglite` and `PgliteServer` APIs for direct embedded use +- Added the high-level `Oliphaunt` and `OliphauntServer` APIs for direct embedded use and PostgreSQL client compatibility. - Added process-local template cluster reuse for fast temporary databases. - Added SQLx and `tokio-postgres` compatibility coverage, runtime/proxy smoke diff --git a/src/runtimes/liboliphaunt/wasix/VERSION b/src/runtimes/liboliphaunt/wasix/VERSION new file mode 100644 index 00000000..a918a2aa --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/VERSION @@ -0,0 +1 @@ +0.6.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/.gitignore b/src/runtimes/liboliphaunt/wasix/assets/build/.gitignore new file mode 100644 index 00000000..b7f14662 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/.gitignore @@ -0,0 +1,2 @@ +# Source-only WASIX build inputs live here. +# Generated build/work trees live under /target/oliphaunt-wasix/wasix-build. diff --git a/assets/wasix-build/analyze_pgl_stubs.sh b/src/runtimes/liboliphaunt/wasix/assets/build/analyze_pgl_stubs.sh similarity index 74% rename from assets/wasix-build/analyze_pgl_stubs.sh rename to src/runtimes/liboliphaunt/wasix/assets/build/analyze_pgl_stubs.sh index f365f019..307a9268 100755 --- a/assets/wasix-build/analyze_pgl_stubs.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/analyze_pgl_stubs.sh @@ -2,13 +2,14 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" -IMAGE="${IMAGE:-pglite-oxide-wasix-build:local}" +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" JOBS="${JOBS:-4}" -CONTAINER_ROOT="${CONTAINER_ROOT:-/work/assets/wasix-build}" -CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_ROOT/work/docker-pglite}" -CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_ROOT/work/postgres-pglite-wasix-src}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_GENERATED_ROOT/work/docker-oliphaunt}" +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_GENERATED_ROOT/work/postgres18-wasix-src}" DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then DOCKER=/usr/local/bin/docker @@ -21,7 +22,7 @@ if [ -z "$DOCKER" ]; then exit 127 fi DOCKER_USER_ARGS=() -if [ "${PGLITE_OXIDE_DOCKER_AS_ROOT:-0}" != "1" ]; then +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) fi @@ -30,17 +31,18 @@ fi --cpus="$JOBS" \ -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ -e PGSRC="$CONTAINER_PGSRC" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ -v "$REPO_ROOT:/work" \ -w /work \ "$IMAGE" \ bash -lc ' set -euo pipefail - . ./assets/wasix-build/docker_wasix_env.sh - test -f "$BUILD_DIR/src/backend/pglite" + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + test -f "$BUILD_DIR/src/backend/oliphaunt" - mkdir -p /work/assets/wasix-build/build/link-analysis - out=/work/assets/wasix-build/build/link-analysis/wasix-host-abi-used.txt + mkdir -p "$CONTAINER_GENERATED_ROOT/build/link-analysis" + out="$CONTAINER_GENERATED_ROOT/build/link-analysis/wasix-host-abi-used.txt" stubs="pgl_system pgl_popen pgl_pclose pgl_geteuid pgl_getuid pgl_getpwuid pgl_exit pgl_atexit pgl_longjmp pgl_siglongjmp pgl_recv pgl_send pgl_shmget pgl_shmat pgl_shmdt pgl_shmctl ProcessStartupPacket" runtime_inputs=( @@ -61,7 +63,7 @@ fi "$BUILD_DIR/src/fe_utils/libpgfeutils.a" ) compiled_sources=( - "$PGSRC/pglite/src/pglitec" + "$PGSRC/oliphaunt/src/oliphauntc" "$PGSRC/src/bin/initdb/initdb.c" "$PGSRC/src/bin/initdb/findtimezone.c" "$PGSRC/src/fe_utils/option_utils.c" @@ -92,10 +94,10 @@ fi echo "Generated from $BUILD_DIR with wasixnm." echo "Source tree: $PGSRC" echo - echo "## Definitions compiled into final pglite" + echo "## Definitions compiled into final oliphaunt" for sym in $stubs; do printf "%-30s" "$sym" - if symbol_defined_in "$BUILD_DIR/src/backend/pglite" "$sym"; then + if symbol_defined_in "$BUILD_DIR/src/backend/oliphaunt" "$sym"; then printf " final" fi printf "\n" @@ -110,7 +112,7 @@ fi print_undefined_refs "${frontend_tool_inputs[@]}" echo echo "## Runtime compiled-source call sites" - echo "This includes upstream pglitec plus initdb/frontend source files when present." + echo "This includes upstream oliphauntc plus initdb/frontend source files when present." for sym in $stubs; do matches=$(grep -R --line-number -E "\\b${sym}\\s*\\(" "${compiled_sources[@]}" 2>/dev/null || true) if [ -n "$matches" ]; then diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_geos.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_geos.sh new file mode 100755 index 00000000..5e83cc7a --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_geos.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +GEOS_SOURCE_DIR="${GEOS_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/geos}" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +GEOS_PREFIX="${GEOS_PREFIX:-$GENERATED_ROOT/work/geos-wasix}" +GEOS_BUILD_DIR="${GEOS_BUILD_DIR:-$GENERATED_ROOT/work/geos-wasix-build}" +JOBS="${JOBS:-4}" + +if [ ! -f "$GEOS_SOURCE_DIR/CMakeLists.txt" ]; then + echo "missing GEOS source checkout at $GEOS_SOURCE_DIR; run assets fetch/source-spine first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(oliphaunt_wasix_source_commit "$GEOS_SOURCE_DIR")" +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="source=$source_commit +script=$script_sha256 +helper=$helper_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +cmake=static-libs-only-no-tests" + +if [ -f "$GEOS_PREFIX/.oliphaunt-wasix-geos-build" ] && + [ -f "$GEOS_PREFIX/include/geos_c.h" ] && + [ -f "$GEOS_PREFIX/lib/libgeos_c.a" ] && + [ -f "$GEOS_PREFIX/lib/libgeos.a" ] && + [ "$(cat "$GEOS_PREFIX/.oliphaunt-wasix-geos-build")" = "$stamp" ]; then + echo "$GEOS_PREFIX" + exit 0 +fi + +{ + rm -rf "$GEOS_BUILD_DIR" "$GEOS_PREFIX" + mkdir -p "$GEOS_BUILD_DIR" "$(dirname "$GEOS_PREFIX")" + oliphaunt_wasix_static_cmake_build \ + "$GEOS_SOURCE_DIR" \ + "$GEOS_BUILD_DIR" \ + "$GEOS_PREFIX" \ + -DBUILD_TESTING=OFF \ + -DBUILD_BENCHMARKS=OFF \ + -DBUILD_GEOSOP=OFF \ + -DGEOS_BUILD_DEVELOPER=OFF +} >&2 + +test -f "$GEOS_PREFIX/include/geos_c.h" +test -f "$GEOS_PREFIX/lib/libgeos_c.a" +test -f "$GEOS_PREFIX/lib/libgeos.a" +printf '%s\n' "$stamp" > "$GEOS_PREFIX/.oliphaunt-wasix-geos-build" +echo "$GEOS_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh new file mode 100755 index 00000000..1dc4de1d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +ICU_SOURCE_DIR="${ICU_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/icu/icu4c/source}" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +ICU_NATIVE_BUILD_DIR="${ICU_NATIVE_BUILD_DIR:-$GENERATED_ROOT/work/icu-native}" +ICU_PREFIX="${ICU_PREFIX:-$GENERATED_ROOT/work/icu-wasix}" +ICU_BUILD_DIR="${ICU_BUILD_DIR:-$GENERATED_ROOT/work/icu-wasix-build}" +JOBS="${JOBS:-4}" + +if [ ! -x "$ICU_SOURCE_DIR/configure" ]; then + echo "missing ICU source checkout at $ICU_SOURCE_DIR; run \`cargo run -p xtask -- assets fetch\` first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(oliphaunt_wasix_source_commit "$ICU_SOURCE_DIR/../../")" +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="schema=oliphaunt-wasix-icu-v4 +source=$source_commit +script=$script_sha256 +helper=$helper_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +configure=static-data-static-libs-static-consumer-no-extra-target-tools-real-data-archive +wasix-platform-fragment=mh-linux +wasix-timezone-cache=no-tzname +wasix-data-packaging=without-assembly" + +icu_native_tool_names() { + printf '%s\n' \ + makeconv \ + gencnval \ + gencfu \ + genbrk \ + gendict \ + genrb \ + gensprep \ + icupkg \ + pkgdata \ + genccode \ + gencmn +} + +icu_native_tools_ready() { + [ -f "$ICU_NATIVE_BUILD_DIR/icudefs.mk" ] || return 1 + [ -f "$ICU_NATIVE_BUILD_DIR/config/icucross.mk" ] || return 1 + [ -f "$ICU_NATIVE_BUILD_DIR/config/icucross.inc" ] || return 1 + [ -f "$ICU_NATIVE_BUILD_DIR/lib/libicui18n.a" ] || return 1 + [ -f "$ICU_NATIVE_BUILD_DIR/lib/libicuuc.a" ] || return 1 + [ -f "$ICU_NATIVE_BUILD_DIR/stubdata/libicudata.a" ] || return 1 + [ -f "$ICU_NATIVE_BUILD_DIR/lib/libicutu.a" ] || return 1 + local tool + while IFS= read -r tool; do + [ -x "$ICU_NATIVE_BUILD_DIR/bin/$tool" ] || return 1 + done < <(icu_native_tool_names) +} + +icu_static_data_ready() { + local data_archive="$1" + [ -f "$data_archive" ] || return 1 + local members + members="$(ar -t "$data_archive")" || return 1 + if grep -q '^stubdata\.ao$' <<< "$members"; then + return 1 + fi + grep -Eq '^icudt[0-9]+[a-z]*_dat\.o$' <<< "$members" +} + +icu_wasix_config_ready() { + local makefile_inc="$ICU_BUILD_DIR/config/Makefile.inc" + [ -f "$makefile_inc" ] || return 1 + grep -q '^include .*/config/mh-linux$' "$makefile_inc" +} + +icu_install_static_data_archive() { + local built_archive="$ICU_BUILD_DIR/lib/libicudata.a" + local installed_archive="$ICU_PREFIX/lib/libicudata.a" + local tmp_archive="$installed_archive.tmp" + + icu_static_data_ready "$built_archive" + mkdir -p "$ICU_PREFIX/lib" + rm -f "$tmp_archive" + cp "$built_archive" "$tmp_archive" + chmod 0644 "$tmp_archive" + mv "$tmp_archive" "$installed_archive" +} + +if [ -f "$ICU_PREFIX/.oliphaunt-wasix-icu-build" ] && + [ -f "$ICU_PREFIX/include/unicode/ucol.h" ] && + [ -f "$ICU_PREFIX/lib/libicui18n.a" ] && + [ -f "$ICU_PREFIX/lib/libicuuc.a" ] && + icu_static_data_ready "$ICU_PREFIX/lib/libicudata.a" && + [ "$(cat "$ICU_PREFIX/.oliphaunt-wasix-icu-build")" = "$stamp" ]; then + echo "$ICU_PREFIX" + exit 0 +fi + +{ + rm -rf "$ICU_NATIVE_BUILD_DIR" "$ICU_BUILD_DIR" "$ICU_PREFIX" + mkdir -p "$ICU_NATIVE_BUILD_DIR" "$ICU_BUILD_DIR" "$(dirname "$ICU_PREFIX")" + + ( + cd "$ICU_NATIVE_BUILD_DIR" + "$ICU_SOURCE_DIR/configure" \ + --disable-shared \ + --enable-static \ + --disable-tests \ + --disable-samples \ + --disable-extras \ + --disable-icuio \ + --disable-layoutex + make all-local + mkdir -p lib bin + make -j"$JOBS" -C stubdata + make -j"$JOBS" -C common + make -j"$JOBS" -C i18n + make -j"$JOBS" -C tools/toolutil + while IFS= read -r tool; do + make -j"$JOBS" -C "tools/$tool" + done < <(icu_native_tool_names) + ) + icu_native_tools_ready + + ( + cd "$ICU_BUILD_DIR" + CC=wasixcc \ + CXX=wasixcc++ \ + AR=wasixar \ + RANLIB=wasixranlib \ + icu_cv_host_frag=mh-linux \ + ac_cv_var_tzname=no \ + ac_cv_var__tzname=no \ + CFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC -fvisibility=hidden -Wno-unused-command-line-argument" \ + CXXFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -std=c++17 -fPIC -fvisibility=hidden -fvisibility-inlines-hidden -Wno-unused-command-line-argument" \ + LDFLAGS="$OLIPHAUNT_WASM_PROFILE_LDFLAGS" \ + "$ICU_SOURCE_DIR/configure" \ + --host=wasm32-wasi \ + --with-cross-build="$ICU_NATIVE_BUILD_DIR" \ + --with-data-packaging=static \ + --disable-shared \ + --enable-static \ + --disable-tests \ + --disable-samples \ + --disable-tools \ + --disable-extras \ + --disable-icuio \ + --disable-layoutex \ + --prefix="$ICU_PREFIX" + icu_wasix_config_ready + icu_pkgdata_opts="-O $ICU_BUILD_DIR/data/icupkg.inc -w" + make -j"$JOBS" PKGDATA_OPTS="$icu_pkgdata_opts" + if ! make install PKGDATA_OPTS="$icu_pkgdata_opts"; then + echo "ICU make install failed after artifacts were built; installing static data archive directly" >&2 + fi + make -j"$JOBS" -C data packagedata PKGDATA_OPTS="$icu_pkgdata_opts" + icu_install_static_data_archive + ) +} >&2 + +test -f "$ICU_PREFIX/include/unicode/ucol.h" +test -f "$ICU_PREFIX/lib/libicui18n.a" +test -f "$ICU_PREFIX/lib/libicuuc.a" +icu_static_data_ready "$ICU_PREFIX/lib/libicudata.a" +printf '%s\n' "$stamp" > "$ICU_PREFIX/.oliphaunt-wasix-icu-build" +echo "$ICU_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_jsonc.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_jsonc.sh new file mode 100755 index 00000000..28444b57 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_jsonc.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +JSONC_SOURCE_DIR="${JSONC_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/json-c}" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +JSONC_PREFIX="${JSONC_PREFIX:-$GENERATED_ROOT/work/json-c-wasix}" +JSONC_BUILD_DIR="${JSONC_BUILD_DIR:-$GENERATED_ROOT/work/json-c-wasix-build}" +JOBS="${JOBS:-4}" + +if [ ! -f "$JSONC_SOURCE_DIR/CMakeLists.txt" ]; then + echo "missing JSON-C source checkout at $JSONC_SOURCE_DIR; run assets fetch/source-spine first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(oliphaunt_wasix_source_commit "$JSONC_SOURCE_DIR")" +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="source=$source_commit +script=$script_sha256 +helper=$helper_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +cmake=static-libs-only-no-tests" + +if [ -f "$JSONC_PREFIX/.oliphaunt-wasix-json-c-build" ] && + [ -f "$JSONC_PREFIX/include/json-c/json.h" ] && + [ -f "$JSONC_PREFIX/lib/libjson-c.a" ] && + [ "$(cat "$JSONC_PREFIX/.oliphaunt-wasix-json-c-build")" = "$stamp" ]; then + echo "$JSONC_PREFIX" + exit 0 +fi + +{ + rm -rf "$JSONC_BUILD_DIR" "$JSONC_PREFIX" + mkdir -p "$JSONC_BUILD_DIR" "$(dirname "$JSONC_PREFIX")" + oliphaunt_wasix_static_cmake_build \ + "$JSONC_SOURCE_DIR" \ + "$JSONC_BUILD_DIR" \ + "$JSONC_PREFIX" \ + -DDISABLE_WERROR=ON \ + -DBUILD_APPS=OFF \ + -DHAVE_SNPRINTF=ON \ + -DBUILD_TESTING=OFF +} >&2 + +test -f "$JSONC_PREFIX/include/json-c/json.h" +test -f "$JSONC_PREFIX/lib/libjson-c.a" +printf '%s\n' "$stamp" > "$JSONC_PREFIX/.oliphaunt-wasix-json-c-build" +echo "$JSONC_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh new file mode 100755 index 00000000..82cdce61 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +LIBICONV_VERSION="${LIBICONV_VERSION:-1.19}" +LIBICONV_URL="${LIBICONV_URL:-https://ftp.gnu.org/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz}" +LIBICONV_SHA256="${LIBICONV_SHA256:-88dd96a8c0464eca144fc791ae60cd31cd8ee78321e67397e25fc095c4a19aa6}" +LIBICONV_ARCHIVE="${LIBICONV_ARCHIVE:-$GENERATED_ROOT/source-cache/libiconv-$LIBICONV_VERSION.tar.gz}" +LIBICONV_BUILD_DIR="${LIBICONV_BUILD_DIR:-$GENERATED_ROOT/work/libiconv-wasix-build}" +LIBICONV_PREFIX="${LIBICONV_PREFIX:-$GENERATED_ROOT/work/libiconv-wasix}" +JOBS="${JOBS:-4}" + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile configure + +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="url=$LIBICONV_URL +sha256=$LIBICONV_SHA256 +script=$script_sha256 +helper=$helper_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +configure=static-no-shared-no-nls" + +if [ -f "$LIBICONV_PREFIX/.oliphaunt-wasix-libiconv-build" ] && + [ -f "$LIBICONV_PREFIX/include/iconv.h" ] && + [ -f "$LIBICONV_PREFIX/lib/libiconv.a" ] && + [ "$(cat "$LIBICONV_PREFIX/.oliphaunt-wasix-libiconv-build")" = "$stamp" ]; then + echo "$LIBICONV_PREFIX" + exit 0 +fi + +{ + mkdir -p "$(dirname "$LIBICONV_ARCHIVE")" + if [ ! -f "$LIBICONV_ARCHIVE" ] || + [ "$(sha256sum "$LIBICONV_ARCHIVE" | awk '{print $1}')" != "$LIBICONV_SHA256" ]; then + tmp_archive="$LIBICONV_ARCHIVE.tmp" + rm -f "$tmp_archive" + curl -fsSL --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ + "$LIBICONV_URL" -o "$tmp_archive" + actual_sha="$(sha256sum "$tmp_archive" | awk '{print $1}')" + if [ "$actual_sha" != "$LIBICONV_SHA256" ]; then + echo "libiconv archive sha256 mismatch: expected $LIBICONV_SHA256 got $actual_sha" >&2 + exit 1 + fi + mv "$tmp_archive" "$LIBICONV_ARCHIVE" + fi + + rm -rf "$LIBICONV_BUILD_DIR" "$LIBICONV_PREFIX" + mkdir -p "$LIBICONV_BUILD_DIR" "$(dirname "$LIBICONV_PREFIX")" + tar -xzf "$LIBICONV_ARCHIVE" -C "$LIBICONV_BUILD_DIR" --strip-components=1 + cd "$LIBICONV_BUILD_DIR" + ./configure \ + --build="$(build-aux/config.guess)" \ + --host=wasm32-wasi \ + --prefix="$LIBICONV_PREFIX" \ + --disable-shared \ + --enable-static \ + --disable-nls \ + CC=wasixcc \ + AR=wasixar \ + RANLIB=wasixranlib \ + CFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC -Wno-unused-command-line-argument" + oliphaunt_wasix_apply_wasix_profile build + make -s -j"$JOBS" + make -s install +} >&2 + +test -f "$LIBICONV_PREFIX/include/iconv.h" +test -f "$LIBICONV_PREFIX/lib/libiconv.a" +printf '%s\n' "$stamp" > "$LIBICONV_PREFIX/.oliphaunt-wasix-libiconv-build" +echo "$LIBICONV_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libxml2.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libxml2.sh new file mode 100755 index 00000000..a8a89ea5 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libxml2.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +LIBXML2_SOURCE_DIR="${LIBXML2_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/libxml2}" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +LIBXML2_PREFIX="${LIBXML2_PREFIX:-$GENERATED_ROOT/work/libxml2-wasix}" +LIBXML2_BUILD_DIR="${LIBXML2_BUILD_DIR:-$GENERATED_ROOT/work/libxml2-wasix-build}" +JOBS="${JOBS:-4}" + +if [ ! -f "$LIBXML2_SOURCE_DIR/CMakeLists.txt" ]; then + echo "missing libxml2 source checkout at $LIBXML2_SOURCE_DIR; run assets fetch/source-spine first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(oliphaunt_wasix_source_commit "$LIBXML2_SOURCE_DIR")" +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="source=$source_commit +script=$script_sha256 +helper=$helper_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +cmake=static-no-programs-no-threads-no-iconv" + +if [ -f "$LIBXML2_PREFIX/.oliphaunt-wasix-libxml2-build" ] && + [ -f "$LIBXML2_PREFIX/include/libxml2/libxml/parser.h" ] && + [ -f "$LIBXML2_PREFIX/lib/libxml2.a" ] && + [ -x "$LIBXML2_PREFIX/bin/xml2-config" ] && + [ "$(cat "$LIBXML2_PREFIX/.oliphaunt-wasix-libxml2-build")" = "$stamp" ]; then + echo "$LIBXML2_PREFIX" + exit 0 +fi + +{ + rm -rf "$LIBXML2_BUILD_DIR" "$LIBXML2_PREFIX" + mkdir -p "$LIBXML2_BUILD_DIR" "$(dirname "$LIBXML2_PREFIX")" + oliphaunt_wasix_static_cmake_build \ + "$LIBXML2_SOURCE_DIR" \ + "$LIBXML2_BUILD_DIR" \ + "$LIBXML2_PREFIX" \ + -DLIBXML2_WITH_PROGRAMS=OFF \ + -DLIBXML2_WITH_TESTS=OFF \ + -DLIBXML2_WITH_PYTHON=OFF \ + -DLIBXML2_WITH_THREADS=OFF \ + -DLIBXML2_WITH_THREAD_ALLOC=OFF \ + -DLIBXML2_WITH_MODULES=OFF \ + -DLIBXML2_WITH_ICONV=OFF \ + -DLIBXML2_WITH_ZLIB=OFF \ + -DLIBXML2_WITH_LZMA=OFF \ + -DLIBXML2_WITH_HTTP=OFF +} >&2 + +test -f "$LIBXML2_PREFIX/include/libxml2/libxml/parser.h" +test -f "$LIBXML2_PREFIX/lib/libxml2.a" +test -x "$LIBXML2_PREFIX/bin/xml2-config" +printf '%s\n' "$stamp" > "$LIBXML2_PREFIX/.oliphaunt-wasix-libxml2-build" +echo "$LIBXML2_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_openssl.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_openssl.sh new file mode 100755 index 00000000..982d6155 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_openssl.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" + +OPENSSL_SOURCE_DIR="${OPENSSL_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/openssl}" +GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-${OLIPHAUNT_WASM_GENERATED_ROOT:-$REPO_ROOT/target/oliphaunt-wasix/wasix-build}}" +OPENSSL_PREFIX="${OPENSSL_PREFIX:-$GENERATED_ROOT/work/openssl-wasix}" +OPENSSL_BUILD_DIR="${OPENSSL_BUILD_DIR:-$GENERATED_ROOT/work/openssl-wasix-build}" +JOBS="${JOBS:-4}" + +if [ ! -f "$OPENSSL_SOURCE_DIR/Configure" ]; then + echo "missing OpenSSL source checkout at $OPENSSL_SOURCE_DIR; run assets fetch/source-spine first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(git -C "$OPENSSL_SOURCE_DIR" rev-parse HEAD)" +script_sha256="$(sha256sum "$0" | awk '{print $1}')" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="source=$source_commit +script=$script_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +configure=no-asm no-shared no-tests no-apps no-docs no-module no-engine no-dso no-zlib no-pinshared no-dgram no-sock no-threads no-secure-memory" + +if [ -f "$OPENSSL_PREFIX/.oliphaunt-wasix-openssl-build" ] && + [ -f "$OPENSSL_PREFIX/include/openssl/evp.h" ] && + [ -f "$OPENSSL_PREFIX/lib/libcrypto.a" ] && + [ "$(cat "$OPENSSL_PREFIX/.oliphaunt-wasix-openssl-build")" = "$stamp" ]; then + echo "$OPENSSL_PREFIX" + exit 0 +fi + +{ + rm -rf "$OPENSSL_BUILD_DIR" "$OPENSSL_PREFIX" + mkdir -p "$OPENSSL_BUILD_DIR" "$(dirname "$OPENSSL_PREFIX")" + cp -a "$OPENSSL_SOURCE_DIR/." "$OPENSSL_BUILD_DIR/" + rm -rf "$OPENSSL_BUILD_DIR/.git" + + ( + cd "$OPENSSL_BUILD_DIR" + CC=wasixcc \ + AR=wasixar \ + RANLIB=wasixranlib \ + ./Configure gcc \ + no-asm \ + no-shared \ + no-tests \ + no-apps \ + no-docs \ + no-module \ + no-engine \ + no-dso \ + no-zlib \ + no-pinshared \ + no-dgram \ + no-sock \ + no-threads \ + no-secure-memory \ + --prefix="$OPENSSL_PREFIX" \ + --openssldir="$OPENSSL_PREFIX/ssl" \ + CFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC -Wno-unused-command-line-argument" + make -s -j"$JOBS" build_libs + make -s install_dev >/dev/null + ) +} >&2 + +test -f "$OPENSSL_PREFIX/include/openssl/evp.h" +test -f "$OPENSSL_PREFIX/lib/libcrypto.a" +printf '%s\n' "$stamp" > "$OPENSSL_PREFIX/.oliphaunt-wasix-openssl-build" +echo "$OPENSSL_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_proj.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_proj.sh new file mode 100755 index 00000000..684bf8f9 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_proj.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +PROJ_SOURCE_DIR="${PROJ_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/proj}" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +PROJ_PREFIX="${PROJ_PREFIX:-$GENERATED_ROOT/work/proj-wasix}" +PROJ_BUILD_DIR="${PROJ_BUILD_DIR:-$GENERATED_ROOT/work/proj-wasix-build}" +SQLITE_PREFIX="${SQLITE_PREFIX:-$("$ROOT/build_wasix_sqlite.sh")}" +JOBS="${JOBS:-4}" + +if [ ! -f "$PROJ_SOURCE_DIR/CMakeLists.txt" ]; then + echo "missing PROJ source checkout at $PROJ_SOURCE_DIR; run assets fetch/source-spine first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(oliphaunt_wasix_source_commit "$PROJ_SOURCE_DIR")" +sqlite_stamp="$(cat "$SQLITE_PREFIX/.oliphaunt-wasix-sqlite-build")" +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +sqlite_script_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/build_wasix_sqlite.sh")" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="source=$source_commit +sqlite=$sqlite_stamp +script=$script_sha256 +sqlite_script=$sqlite_script_sha256 +helper=$helper_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +cmake=static-libs-only-no-tiff-no-curl-no-libdl-embedded-projdb-install-projdb" + +if [ -f "$PROJ_PREFIX/.oliphaunt-wasix-proj-build" ] && + [ -f "$PROJ_PREFIX/include/proj.h" ] && + [ -f "$PROJ_PREFIX/lib/libproj.a" ] && + [ -f "$PROJ_PREFIX/share/proj/proj.db" ] && + [ "$(cat "$PROJ_PREFIX/.oliphaunt-wasix-proj-build")" = "$stamp" ]; then + echo "$PROJ_PREFIX" + exit 0 +fi + +{ + rm -rf "$PROJ_BUILD_DIR" "$PROJ_PREFIX" + mkdir -p "$PROJ_BUILD_DIR" "$(dirname "$PROJ_PREFIX")" + oliphaunt_wasix_static_cmake_build \ + "$PROJ_SOURCE_DIR" \ + "$PROJ_BUILD_DIR" \ + "$PROJ_PREFIX" \ + -DSQLite3_INCLUDE_DIR="$SQLITE_PREFIX/include" \ + -DSQLite3_LIBRARY="$SQLITE_PREFIX/lib/libsqlite3.a" \ + -DEXE_SQLITE3="$(command -v sqlite3)" \ + -DENABLE_TIFF=OFF \ + -DENABLE_CURL=OFF \ + -DENABLE_EMSCRIPTEN_FETCH=OFF \ + -DHAVE_LIBDL=OFF \ + -DBUILD_APPS=OFF \ + -DBUILD_TESTING=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DEMBED_RESOURCE_FILES=ON \ + -DUSE_ONLY_EMBEDDED_RESOURCE_FILES=ON + mkdir -p "$PROJ_PREFIX/share/proj" + cp "$PROJ_BUILD_DIR/data/proj.db" "$PROJ_PREFIX/share/proj/proj.db" +} >&2 + +test -f "$PROJ_PREFIX/include/proj.h" +test -f "$PROJ_PREFIX/lib/libproj.a" +test -f "$PROJ_PREFIX/share/proj/proj.db" +printf '%s\n' "$stamp" > "$PROJ_PREFIX/.oliphaunt-wasix-proj-build" +echo "$PROJ_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_sqlite.sh b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_sqlite.sh new file mode 100755 index 00000000..16c62f47 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_sqlite.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/wasix_third_party.sh" + +REPO_ROOT="$(oliphaunt_wasix_repo_root "$ROOT")" +SQLITE_SOURCE_DIR="${SQLITE_SOURCE_DIR:-$REPO_ROOT/target/oliphaunt-sources/checkouts/sqlite}" +GENERATED_ROOT="$(oliphaunt_wasix_generated_root "$REPO_ROOT")" +SQLITE_PREFIX="${SQLITE_PREFIX:-$GENERATED_ROOT/work/sqlite-wasix}" +SQLITE_BUILD_DIR="${SQLITE_BUILD_DIR:-$GENERATED_ROOT/work/sqlite-wasix-build}" +JOBS="${JOBS:-4}" + +if [ ! -x "$SQLITE_SOURCE_DIR/configure" ]; then + echo "missing SQLite source checkout at $SQLITE_SOURCE_DIR; run assets fetch/source-spine first" >&2 + exit 1 +fi + +. "$ROOT/docker_wasix_env.sh" +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile build + +source_commit="$(oliphaunt_wasix_source_commit "$SQLITE_SOURCE_DIR")" +script_sha256="$(oliphaunt_wasix_script_sha256 "$0")" +helper_sha256="$(oliphaunt_wasix_script_sha256 "$ROOT/wasix_third_party.sh")" +wasixcc_version="$(wasixcc --version 2>/dev/null)" +wasixcc_version="${wasixcc_version%%$'\n'*}" +stamp="source=$source_commit +script=$script_sha256 +helper=$helper_sha256 +profile=$(oliphaunt_wasix_wasix_profile_signature) +wasixcc=$wasixcc_version +configure=amalgamation-static-threadsafe0-hidden-symbols" + +if [ -f "$SQLITE_PREFIX/.oliphaunt-wasix-sqlite-build" ] && + [ -f "$SQLITE_PREFIX/include/sqlite3.h" ] && + [ -f "$SQLITE_PREFIX/lib/libsqlite3.a" ] && + [ "$(cat "$SQLITE_PREFIX/.oliphaunt-wasix-sqlite-build")" = "$stamp" ]; then + echo "$SQLITE_PREFIX" + exit 0 +fi + +{ + rm -rf "$SQLITE_BUILD_DIR" "$SQLITE_PREFIX" + oliphaunt_wasix_copy_source_clean "$SQLITE_SOURCE_DIR" "$SQLITE_BUILD_DIR" + + ( + cd "$SQLITE_BUILD_DIR" + ./configure --disable-shared --enable-static --disable-readline + make -s -j"$JOBS" sqlite3.c sqlite3.h + mkdir -p "$SQLITE_PREFIX/include" "$SQLITE_PREFIX/lib/pkgconfig" + wasixcc \ + $OLIPHAUNT_WASM_PROFILE_CFLAGS \ + -fPIC \ + -fvisibility=hidden \ + -DSQLITE_THREADSAFE=0 \ + -DSQLITE_OMIT_LOAD_EXTENSION \ + -Wno-unused-command-line-argument \ + -c sqlite3.c \ + -o sqlite3.o + wasixar crs "$SQLITE_PREFIX/lib/libsqlite3.a" sqlite3.o + wasixranlib "$SQLITE_PREFIX/lib/libsqlite3.a" + cp sqlite3.h sqlite3ext.h "$SQLITE_PREFIX/include/" + cat >"$SQLITE_PREFIX/lib/pkgconfig/sqlite3.pc" <&2 + +test -f "$SQLITE_PREFIX/include/sqlite3.h" +test -f "$SQLITE_PREFIX/lib/libsqlite3.a" +printf '%s\n' "$stamp" > "$SQLITE_PREFIX/.oliphaunt-wasix-sqlite-build" +echo "$SQLITE_PREFIX" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh b/src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh new file mode 100755 index 00000000..db4f6b77 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" +GENERATED_ROOT="${OLIPHAUNT_WASM_GENERATED_ROOT:-$REPO_ROOT/target/oliphaunt-wasix/wasix-build}" +. "$ROOT/wasix_icu_link.sh" + +if [ -z "${PGSRC:-}" ]; then + PGSRC="$(SOURCE_CACHE="${SOURCE_CACHE:-$REPO_ROOT/target/liboliphaunt-pg18/source}" "$ROOT/prepare_postgres_source.sh")" +fi +BUILD="${BUILD_DIR:-$GENERATED_ROOT/work/configure-smoke}" + +WASIX_HOME="${WASIX_HOME:-/tmp/wasixcc-home/.wasixcc}" +export HOME="${WASIX_HOME%/.wasixcc}" +export PATH="$WASIX_HOME/bin:$PATH" + +mkdir -p "$BUILD" + +. "$ROOT/profile_flags.sh" +oliphaunt_wasix_apply_wasix_profile configure + +ICU_PREFIX="${ICU_PREFIX:-$("$ROOT/build_wasix_icu.sh")}" +ICU_CFLAGS="$(oliphaunt_wasix_icu_cflags "$ICU_PREFIX")" +ICU_LIBS="$(oliphaunt_wasix_icu_libs "$ICU_PREFIX")" + +COMMON_CPPFLAGS="-I$PGSRC/src/include/port/wasix-dl" +if [ "${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" = "1" ]; then + COMMON_CPPFLAGS="$COMMON_CPPFLAGS -DOLIPHAUNT_WASIX_BACKEND_TIMING" +fi +COMMON_CPPFLAGS="$COMMON_CPPFLAGS $ICU_CFLAGS" +COMMON_CFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -Wno-unused-command-line-argument" +COMMON_LDFLAGS="$OLIPHAUNT_WASM_PROFILE_LDFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -L$ICU_PREFIX/lib" +MAIN_LDFLAGS="-sMODULE_KIND=dynamic-main -sSTACK_SIZE=8MB -sINITIAL_MEMORY=128MB" +SIDE_MODULE_LDFLAGS="-Wl,-shared" +CONFIGURE_EXTRA_ARGS=() +if [ "${OLIPHAUNT_WASM_PG18_DISABLE_SPINLOCKS:-0}" = "1" ]; then + # Experimental only. Keep the + # production default on WASIX atomics unless perf data justifies changing it. + CONFIGURE_EXTRA_ARGS+=("--disable""-spinlocks") +fi + +mkdir -p "$GENERATED_ROOT/build/wasix-oliphaunt" +OLIPHAUNT_SHIM="$GENERATED_ROOT/build/wasix-oliphaunt/oliphaunt_wasix_bridge.o" + +wasixcc $COMMON_CFLAGS $COMMON_CPPFLAGS \ + -include stdbool.h \ + -include stdlib.h \ + -I"$PGSRC/src/include/port/wasix-dl" \ + -c "$ROOT/wasix_shim/oliphaunt_wasix_bridge.c" \ + -o "$OLIPHAUNT_SHIM" + +OLIPHAUNT_CFLAGS="\ + -Dsystem=oliphaunt_wasix_system -Dpopen=oliphaunt_wasix_popen -Dpclose=oliphaunt_wasix_pclose\ + -Dexit=oliphaunt_wasix_exit\ + -Dmunmap=oliphaunt_wasix_munmap\ + -Dfcntl=oliphaunt_wasix_fcntl\ + -Datexit=oliphaunt_wasix_atexit\ + -Dsetsockopt=oliphaunt_wasix_setsockopt -Dgetsockopt=oliphaunt_wasix_getsockopt -Dgetsockname=oliphaunt_wasix_getsockname\ + -Dconnect=oliphaunt_wasix_connect\ + -Dpoll=oliphaunt_wasix_poll\ + -Dlongjmp=oliphaunt_wasix_longjmp -Dsiglongjmp=oliphaunt_wasix_siglongjmp\ + -Wno-declaration-after-statement\ + -Wno-macro-redefined\ + -Wno-unused-function\ + -Wno-missing-prototypes\ + -Wno-incompatible-pointer-types" + +cd "$BUILD" + +CC=wasixcc \ +CXX=wasixcc++ \ +AR=wasixar \ +RANLIB=wasixranlib \ +NM=wasixnm \ +CPPFLAGS="$COMMON_CPPFLAGS" \ +CFLAGS="$COMMON_CFLAGS$OLIPHAUNT_CFLAGS" \ +LDFLAGS="$COMMON_LDFLAGS" \ +ICU_CFLAGS="$ICU_CFLAGS" \ +ICU_LIBS="$ICU_LIBS" \ +LDFLAGS_EX="$MAIN_LDFLAGS $OLIPHAUNT_SHIM" \ +LDFLAGS_SL="$SIDE_MODULE_LDFLAGS" \ +"$PGSRC/configure" \ + --prefix=/ \ + --libdir=/lib \ + --datadir=/share/postgresql \ + --bindir=/bin \ + --host=wasm32-wasix \ + --with-template=wasix-dl \ + --without-readline \ + --with-icu \ + --without-zlib \ + --without-llvm \ + --disable-largefile \ + --without-pam \ + --with-openssl=no \ + "${CONFIGURE_EXTRA_ARGS[@]}" diff --git a/assets/wasix-build/docker/Dockerfile b/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile similarity index 60% rename from assets/wasix-build/docker/Dockerfile rename to src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile index 1e471ed3..10744615 100644 --- a/assets/wasix-build/docker/Dockerfile +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile @@ -10,12 +10,16 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ bison \ build-essential \ ca-certificates \ + cmake \ curl \ file \ flex \ gawk \ git \ + autoconf \ + automake \ jq \ + libtool \ lz4 \ make \ patch \ @@ -23,7 +27,9 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ pkg-config \ python3 \ rsync \ + sqlite3 \ tar \ + tcl \ wget \ xz-utils \ zstd @@ -32,9 +38,23 @@ ENV HOME=/opt/wasixcc-home ENV WASIX_HOME=/opt/wasixcc-home/.wasixcc ENV PATH=/opt/wasixcc-home/.wasixcc/bin:$PATH -RUN mkdir -p /opt/wasixcc-home \ - && curl -fsSL https://wasix.cc -o /tmp/wasixcc-install.sh \ +RUN bash -euxo pipefail <<'EOF' +mkdir -p /opt/wasixcc-home +for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do + rm -rf /opt/wasixcc-home/.wasixcc /tmp/wasixcc-install.sh + if curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 https://wasix.cc -o /tmp/wasixcc-install.sh \ && HOME=/opt/wasixcc-home sh /tmp/wasixcc-install.sh \ - && wasixcc --version + && wasixcc --version; then + exit 0 + fi + if [ "$attempt" -lt 6 ]; then + sleep_seconds="$((attempt * 10))" + else + sleep_seconds=60 + fi + sleep "$sleep_seconds" +done +exit 1 +EOF WORKDIR /work diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh new file mode 100755 index 00000000..cf1db77a --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" +. "$ROOT/source_lane.sh" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" + +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" +JOBS="${JOBS:-4}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$(oliphaunt_wasix_prepare_source_for_docker "$SOURCE_LANE")}" +CONTAINER_PLAN="${CONTAINER_PLAN:-/work/src/extensions/generated/contrib-build.tsv}" +DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" +if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then + DOCKER=/usr/local/bin/docker +fi +if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then + DOCKER=/opt/homebrew/bin/docker +fi +if [ -z "$DOCKER" ]; then + echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 + exit 127 +fi +export PATH="$(dirname "$DOCKER"):$PATH" +DOCKER_USER_ARGS=() +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then + DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) +fi + +if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $IMAGE" >&2 + exit 1 + } + echo "reusing Docker image $IMAGE" +elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + "$DOCKER" build \ + -t "$IMAGE" \ + -f "$ROOT/docker/Dockerfile" \ + "$ROOT/docker" +else + echo "reusing Docker image $IMAGE" +fi + +"$DOCKER" run --rm \ + "${DOCKER_USER_ARGS[@]}" \ + --cpus="$JOBS" \ + -e CONTAINER_ROOT="$CONTAINER_ROOT" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ + -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ + -e PGSRC="$CONTAINER_PGSRC" \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$SOURCE_LANE" \ + -e PLAN="$CONTAINER_PLAN" \ + -e JOBS="$JOBS" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" \ + -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ + -v "$REPO_ROOT:/work" \ + -w /work \ + "$IMAGE" \ + bash -lc ' + set -euo pipefail + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh + oliphaunt_wasix_apply_wasix_profile build + + test -f "$BUILD_DIR/config.status" + test -f "$BUILD_DIR/src/backend/oliphaunt" + oliphaunt_wasix_check_source_markers + sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" >/dev/null + test "$(oliphaunt_wasix_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.oliphaunt-wasix-build-profile")" + + if [ ! -f "$PLAN" ]; then + echo "generated contrib build plan missing: $PLAN" >&2 + exit 1 + fi + + OPENSSL_PREFIX= + UUID_PREFIX= + build_portable_uuid() { + local prefix="$CONTAINER_GENERATED_ROOT/dependencies/uuid" + local source_dir="/work/src/runtimes/liboliphaunt/native/portable-uuid" + local object="$prefix/portable_uuid.o" + local archive="$prefix/lib/libuuid.a" + if [ -f "$archive" ] && [ -d "$prefix/include/uuid" ]; then + printf "%s\n" "$prefix" + return 0 + fi + test -f "$source_dir/portable_uuid.c" + rm -rf "$prefix" + mkdir -p "$prefix/include" "$prefix/lib" + cp -R "$source_dir/include/uuid" "$prefix/include/" + wasixcc $OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC \ + -I"$source_dir/include" \ + -I"$BUILD_DIR/src/include" \ + -I"$BUILD_DIR/src/include/port" \ + -I"$PGSRC/src/include" \ + -I"$PGSRC/src/include/port" \ + -c "$source_dir/portable_uuid.c" \ + -o "$object" + wasixar crs "$archive" "$object" + wasixranlib "$archive" + test -s "$archive" + printf "%s\n" "$prefix" + } + while IFS=$'\''\t'\'' read -r id sql_name contrib_dir module_file archive stable; do + case "$id" in ""|"#"*) continue ;; esac + test -n "$sql_name" + test -n "$contrib_dir" + echo "building contrib extension $id from contrib/$contrib_dir" + test -d "$BUILD_DIR/contrib/$contrib_dir" + extra_make_args=() + if [ "$id" = "pgcrypto" ]; then + if [ -z "$OPENSSL_PREFIX" ]; then + OPENSSL_PREFIX="$("$CONTAINER_ROOT/build_wasix_openssl.sh")" + fi + extra_make_args+=("PG_CPPFLAGS=-I$OPENSSL_PREFIX/include") + extra_make_args+=("SHLIB_LINK=$OPENSSL_PREFIX/lib/libcrypto.a") + make -s -C "$BUILD_DIR/contrib/$contrib_dir" "${extra_make_args[@]}" clean >/dev/null 2>&1 || true + fi + if [ "$id" = "uuid_ossp" ]; then + if [ -z "$UUID_PREFIX" ]; then + UUID_PREFIX="$(build_portable_uuid)" + fi + extra_make_args+=("PG_CPPFLAGS=-I$UUID_PREFIX/include -DHAVE_UUID_E2FS=1 -DHAVE_UUID_UUID_H=1") + extra_make_args+=("UUID_LIBS=$UUID_PREFIX/lib/libuuid.a") + make -s -C "$BUILD_DIR/contrib/$contrib_dir" "${extra_make_args[@]}" clean >/dev/null 2>&1 || true + fi + make -s -j"$JOBS" -C "$BUILD_DIR/contrib/$contrib_dir" "${extra_make_args[@]}" all + if [ "$module_file" = "-" ]; then + continue + fi + if [ ! -f "$BUILD_DIR/contrib/$contrib_dir/$module_file" ]; then + echo "expected WASIX side module missing: $BUILD_DIR/contrib/$contrib_dir/$module_file" >&2 + find "$BUILD_DIR/contrib/$contrib_dir" -maxdepth 1 -type f -name "*.so" -print >&2 + exit 1 + fi + done < "$PLAN" + ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh new file mode 100755 index 00000000..9c7513cb --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" +. "$ROOT/source_lane.sh" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" + +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" +JOBS="${JOBS:-4}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$(oliphaunt_wasix_prepare_source_for_docker "$SOURCE_LANE")}" +DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" +if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then + DOCKER=/usr/local/bin/docker +fi +if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then + DOCKER=/opt/homebrew/bin/docker +fi +if [ -z "$DOCKER" ]; then + echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 + exit 127 +fi +export PATH="$(dirname "$DOCKER"):$PATH" +DOCKER_USER_ARGS=() +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then + DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) +fi + +if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $IMAGE" >&2 + exit 1 + } + echo "reusing Docker image $IMAGE" +elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + "$DOCKER" build \ + -t "$IMAGE" \ + -f "$ROOT/docker/Dockerfile" \ + "$ROOT/docker" +else + echo "reusing Docker image $IMAGE" +fi + +"$DOCKER" run --rm \ + "${DOCKER_USER_ARGS[@]}" \ + --cpus="$JOBS" \ + -e CONTAINER_ROOT="$CONTAINER_ROOT" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ + -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ + -e PGSRC="$CONTAINER_PGSRC" \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$SOURCE_LANE" \ + -e JOBS="$JOBS" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" \ + -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ + -v "$REPO_ROOT:/work" \ + -w /work \ + "$IMAGE" \ + bash -lc ' + set -euo pipefail + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh + oliphaunt_wasix_apply_wasix_profile build + export AR=wasixar + export RANLIB=wasixranlib + export NM=wasixnm + export LLVM_NM=wasixnm + + test -f "$BUILD_DIR/config.status" + oliphaunt_wasix_check_source_markers + sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" >/dev/null + test "$(oliphaunt_wasix_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.oliphaunt-wasix-build-profile")" + + ICU_PREFIX="$(./src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh)" + ICU_CFLAGS="$(oliphaunt_wasix_icu_cflags "$ICU_PREFIX")" + ICU_LIBS="$(oliphaunt_wasix_icu_libs "$ICU_PREFIX")" + + COMMON_CPPFLAGS="-I$PGSRC/src/include/port/wasix-dl $ICU_CFLAGS" + COMMON_CFLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -Wno-unused-command-line-argument" + COMMON_LDFLAGS="$OLIPHAUNT_WASM_PROFILE_LDFLAGS -sWASM_EXCEPTIONS=yes -sPIC=yes -L$ICU_PREFIX/lib" + MAIN_LDFLAGS="-sMODULE_KIND=dynamic-main -sSTACK_SIZE=8MB -sINITIAL_MEMORY=128MB -Wl,--wrap=system -Wl,--wrap=popen -Wl,--wrap=pclose" + + INITDB_BUILD_DIR="$(oliphaunt_wasix_scratch_build_dir "${OLIPHAUNT_WASM_SOURCE_LANE:-stable}" wasix-initdb)" + mkdir -p "$INITDB_BUILD_DIR" + GENERIC_SHIM="$INITDB_BUILD_DIR/oliphaunt_wasix_shim.o" + INITDB_SHIM="$INITDB_BUILD_DIR/oliphaunt_wasix_initdb_shim.o" + wasixcc $COMMON_CFLAGS $COMMON_CPPFLAGS \ + -I"$BUILD_DIR/src/include" \ + -I"$PGSRC/src/include/port/wasix-dl" \ + -c "$CONTAINER_ROOT/wasix_shim/oliphaunt_wasix_shim.c" \ + -o "$GENERIC_SHIM" + wasixcc $COMMON_CFLAGS $COMMON_CPPFLAGS \ + -I"$BUILD_DIR/src/include" \ + -I"$PGSRC/src/include/port/wasix-dl" \ + -c "$CONTAINER_ROOT/wasix_shim/oliphaunt_wasix_initdb_shim.c" \ + -o "$INITDB_SHIM" + + make -s -C "$BUILD_DIR/src/bin/initdb" clean + make -s -j"$JOBS" -C "$BUILD_DIR/src/bin/initdb" initdb \ + CFLAGS="$COMMON_CFLAGS -Dsystem=oliphaunt_wasix_initdb_system -Dpopen=oliphaunt_wasix_initdb_popen -Dpclose=oliphaunt_wasix_initdb_pclose -Dgeteuid=oliphaunt_wasix_geteuid -Dgetuid=oliphaunt_wasix_getuid -Dgetegid=oliphaunt_wasix_getegid -Dgetgid=oliphaunt_wasix_getgid -Dgetpwuid=oliphaunt_wasix_getpwuid -Dgetpwuid_r=oliphaunt_wasix_getpwuid_r -Wno-unused-function -Wno-missing-prototypes" \ + LDFLAGS="$COMMON_LDFLAGS -L$BUILD_DIR/src/common -L$BUILD_DIR/src/port" \ + LDFLAGS_EX="$MAIN_LDFLAGS $GENERIC_SHIM $INITDB_SHIM $BUILD_DIR/src/fe_utils/libpgfeutils.a $BUILD_DIR/src/interfaces/libpq/libpq.a $BUILD_DIR/src/common/libpgcommon.a $BUILD_DIR/src/port/libpgport.a $ICU_LIBS" + test -f "$BUILD_DIR/src/bin/initdb/initdb" + ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh new file mode 100755 index 00000000..09b7cf9a --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" +. "$ROOT/source_lane.sh" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" + +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" +JOBS="${JOBS:-4}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" +if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then + DOCKER=/usr/local/bin/docker +fi +if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then + DOCKER=/opt/homebrew/bin/docker +fi +if [ -z "$DOCKER" ]; then + echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 + exit 127 +fi +export PATH="$(dirname "$DOCKER"):$PATH" +DOCKER_USER_ARGS=() +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then + DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) +fi + +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$(oliphaunt_wasix_prepare_source_for_docker "$SOURCE_LANE")}" + +if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $IMAGE" >&2 + exit 1 + } + echo "reusing Docker image $IMAGE" +elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + "$DOCKER" build \ + -t "$IMAGE" \ + -f "$ROOT/docker/Dockerfile" \ + "$ROOT/docker" +else + echo "reusing Docker image $IMAGE" +fi + +"$DOCKER" run --rm \ + "${DOCKER_USER_ARGS[@]}" \ + --cpus="$JOBS" \ + -e CONTAINER_ROOT="$CONTAINER_ROOT" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ + -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ + -e PGSRC="$CONTAINER_PGSRC" \ + -e FORCE_RECONFIGURE="${FORCE_RECONFIGURE:-0}" \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$SOURCE_LANE" \ + -e JOBS="$JOBS" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" \ + -e OLIPHAUNT_WASM_PG18_DISABLE_SPINLOCKS="${OLIPHAUNT_WASM_PG18_DISABLE_SPINLOCKS:-0}" \ + -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ + -v "$REPO_ROOT:/work" \ + -w /work \ + "$IMAGE" \ + bash -lc ' + set -euo pipefail + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh + oliphaunt_wasix_apply_wasix_profile configure + profile_signature="$(oliphaunt_wasix_wasix_profile_signature)" + configure_script=./src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh + icu_prefix="$(./src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh)" + export ICU_PREFIX="$icu_prefix" + icu_stamp="$icu_prefix/.oliphaunt-wasix-icu-build" + + needs_configure=0 + if [ "${FORCE_RECONFIGURE:-0}" = "1" ] || [ ! -f "$BUILD_DIR/config.status" ]; then + needs_configure=1 + elif ! cmp -s "$PGSRC/.oliphaunt-wasix-source-fingerprint" "$BUILD_DIR/.oliphaunt-wasix-source-fingerprint"; then + needs_configure=1 + elif ! cmp -s "$PGSRC/.oliphaunt-wasix-postgres-version" "$BUILD_DIR/.oliphaunt-wasix-postgres-version"; then + needs_configure=1 + elif [ ! -f "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" ]; then + needs_configure=1 + elif ! sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" >/dev/null 2>&1; then + needs_configure=1 + elif [ ! -f "$BUILD_DIR/.oliphaunt-wasix-configure-sha256" ]; then + needs_configure=1 + elif ! sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-configure-sha256" >/dev/null 2>&1; then + needs_configure=1 + elif [ ! -f "$BUILD_DIR/.oliphaunt-wasix-icu-build" ]; then + needs_configure=1 + elif ! cmp -s "$icu_stamp" "$BUILD_DIR/.oliphaunt-wasix-icu-build"; then + needs_configure=1 + elif [ ! -f "$BUILD_DIR/.oliphaunt-wasix-build-profile" ]; then + needs_configure=1 + elif [ "$profile_signature" != "$(cat "$BUILD_DIR/.oliphaunt-wasix-build-profile")" ]; then + needs_configure=1 + elif [ ! -f "$BUILD_DIR/.oliphaunt-wasix-configure-experiment" ]; then + needs_configure=1 + elif [ "${OLIPHAUNT_WASM_PG18_DISABLE_SPINLOCKS:-0}" != "$(cat "$BUILD_DIR/.oliphaunt-wasix-configure-experiment")" ]; then + needs_configure=1 + fi + + if [ "$needs_configure" = "1" ]; then + rm -rf "$BUILD_DIR" + "$configure_script" + cp "$PGSRC/.oliphaunt-wasix-source-fingerprint" "$BUILD_DIR/.oliphaunt-wasix-source-fingerprint" + cp "$PGSRC/.oliphaunt-wasix-postgres-version" "$BUILD_DIR/.oliphaunt-wasix-postgres-version" + sha256sum ./src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c \ + > "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" + sha256sum "$configure_script" > "$BUILD_DIR/.oliphaunt-wasix-configure-sha256" + cp "$icu_stamp" "$BUILD_DIR/.oliphaunt-wasix-icu-build" + printf "%s\n" "$profile_signature" > "$BUILD_DIR/.oliphaunt-wasix-build-profile" + printf "%s\n" "${OLIPHAUNT_WASM_PG18_DISABLE_SPINLOCKS:-0}" > "$BUILD_DIR/.oliphaunt-wasix-configure-experiment" + else + echo "reusing configured PG18 Oliphaunt build at $BUILD_DIR" + fi + oliphaunt_wasix_apply_wasix_profile build + rm -rf "$BUILD_DIR/src/timezone/compiled" + mkdir -p "$BUILD_DIR/src/timezone/compiled" + /usr/sbin/zic \ + -d "$BUILD_DIR/src/timezone/compiled" \ + "$PGSRC/src/timezone/data/tzdata.zi" + test -f "$BUILD_DIR/src/timezone/compiled/UTC" + test -f "$BUILD_DIR/src/timezone/compiled/GMT" + test -f "$BUILD_DIR/src/timezone/compiled/Etc/UTC" + test -f "$BUILD_DIR/src/timezone/compiled/America/New_York" + make -s -C "$BUILD_DIR/src/backend" generated-headers + make -s -C "$BUILD_DIR/src/backend" submake-libpgport + make -s -j"$JOBS" -C "$BUILD_DIR/src/backend" oliphaunt + ' diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh new file mode 100755 index 00000000..a8d0dfa3 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" +. "$ROOT/source_lane.sh" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" + +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" +JOBS="${JOBS:-4}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$(oliphaunt_wasix_prepare_source_for_docker "$SOURCE_LANE")}" +DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" +if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then + DOCKER=/usr/local/bin/docker +fi +if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then + DOCKER=/opt/homebrew/bin/docker +fi +if [ -z "$DOCKER" ]; then + echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 + exit 127 +fi +export PATH="$(dirname "$DOCKER"):$PATH" +DOCKER_USER_ARGS=() +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then + DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) +fi + +if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $IMAGE" >&2 + exit 1 + } + echo "reusing Docker image $IMAGE" +elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + "$DOCKER" build \ + -t "$IMAGE" \ + -f "$ROOT/docker/Dockerfile" \ + "$ROOT/docker" +else + echo "reusing Docker image $IMAGE" +fi + +"$DOCKER" run --rm \ + "${DOCKER_USER_ARGS[@]}" \ + --cpus="$JOBS" \ + -e CONTAINER_ROOT="$CONTAINER_ROOT" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ + -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ + -e PGSRC="$CONTAINER_PGSRC" \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$SOURCE_LANE" \ + -e JOBS="$JOBS" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" \ + -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ + -v "$REPO_ROOT:/work" \ + -w /work \ + "$IMAGE" \ + bash -lc ' + set -euo pipefail + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh + icu_prefix="$(./src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh)" + ICU_CFLAGS="$(oliphaunt_wasix_icu_cflags "$icu_prefix")" + ICU_LIBS="$(oliphaunt_wasix_icu_libs "$icu_prefix")" + oliphaunt_wasix_apply_wasix_profile build + export AR=wasixar + export RANLIB=wasixranlib + export NM=wasixnm + export LLVM_NM=wasixnm + + test -f "$BUILD_DIR/config.status" + oliphaunt_wasix_check_source_markers + sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" >/dev/null + test "$(oliphaunt_wasix_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.oliphaunt-wasix-build-profile")" + make -s -C "$BUILD_DIR/src/bin/pg_dump" clean + make -s -C "$BUILD_DIR/src/bin/pg_dump" pg_dump \ + libpq="$BUILD_DIR/src/interfaces/libpq/libpq.a" \ + LIBS="$BUILD_DIR/src/common/libpgcommon.a $BUILD_DIR/src/port/libpgport.a $ICU_LIBS -lm" + test -f "$BUILD_DIR/src/bin/pg_dump/pg_dump" + if wasixnm -u "$BUILD_DIR/src/bin/pg_dump/pg_dump" | grep -E " PQ[A-Za-z0-9_]+$"; then + echo "pg_dump still imports libpq symbols; expected standalone WASIX pg_dump" >&2 + exit 1 + fi + ' diff --git a/assets/wasix-build/docker_pgxs_extensions.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh similarity index 51% rename from assets/wasix-build/docker_pgxs_extensions.sh rename to src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh index d96f7721..23f2dce3 100755 --- a/assets/wasix-build/docker_pgxs_extensions.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh @@ -2,14 +2,17 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$ROOT/../.." && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" +. "$ROOT/source_lane.sh" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" -IMAGE="${IMAGE:-pglite-oxide-wasix-build:local}" +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" JOBS="${JOBS:-4}" -CONTAINER_ROOT="${CONTAINER_ROOT:-/work/assets/wasix-build}" -CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$CONTAINER_ROOT/work/docker-pglite}" -CONTAINER_PGSRC="${CONTAINER_PGSRC:-$CONTAINER_ROOT/work/postgres-pglite-wasix-src}" -CONTAINER_PLAN="${CONTAINER_PLAN:-/work/assets/generated/pgxs-build.tsv}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$(oliphaunt_wasix_prepare_source_for_docker "$SOURCE_LANE")}" +CONTAINER_PLAN="${CONTAINER_PLAN:-/work/src/extensions/generated/pgxs-build.tsv}" DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then DOCKER=/usr/local/bin/docker @@ -23,13 +26,17 @@ if [ -z "$DOCKER" ]; then fi export PATH="$(dirname "$DOCKER"):$PATH" DOCKER_USER_ARGS=() -if [ "${PGLITE_OXIDE_DOCKER_AS_ROOT:-0}" != "1" ]; then +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) fi -"$ROOT/prepare_patched_source.sh" - -if [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then +if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $IMAGE" >&2 + exit 1 + } + echo "reusing Docker image $IMAGE" +elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then "$DOCKER" build \ -t "$IMAGE" \ -f "$ROOT/docker/Dockerfile" \ @@ -42,37 +49,39 @@ fi "${DOCKER_USER_ARGS[@]}" \ --cpus="$JOBS" \ -e CONTAINER_ROOT="$CONTAINER_ROOT" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ -e PGSRC="$CONTAINER_PGSRC" \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$SOURCE_LANE" \ -e PLAN="$CONTAINER_PLAN" \ -e JOBS="$JOBS" \ - -e PGLITE_OXIDE_BUILD_PROFILE="${PGLITE_OXIDE_BUILD_PROFILE:-release-o3}" \ - -e PGLITE_OXIDE_WASIX_COPT="${PGLITE_OXIDE_WASIX_COPT:-}" \ - -e PGLITE_OXIDE_WASIX_LOPT="${PGLITE_OXIDE_WASIX_LOPT:-}" \ - -e PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT="${PGLITE_OXIDE_WASIX_CONFIGURE_WASM_OPT:-no}" \ - -e PGLITE_OXIDE_WASIX_BUILD_WASM_OPT="${PGLITE_OXIDE_WASIX_BUILD_WASM_OPT:-yes}" \ - -e PGLITE_OXIDE_WASM_OPT_FLAGS="${PGLITE_OXIDE_WASM_OPT_FLAGS-}" \ - -e PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT="${PGLITE_OXIDE_WASM_OPT_SUPPRESS_DEFAULT-}" \ - -e PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED="${PGLITE_OXIDE_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ - -e PGLITE_OXIDE_WASIX_COMPILER_FLAGS="${PGLITE_OXIDE_WASIX_COMPILER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_LINKER_FLAGS="${PGLITE_OXIDE_WASIX_LINKER_FLAGS:-}" \ - -e PGLITE_OXIDE_WASIX_BACKEND_TIMING="${PGLITE_OXIDE_WASIX_BACKEND_TIMING:-0}" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" \ -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ -v "$REPO_ROOT:/work" \ -w /work \ "$IMAGE" \ bash -lc ' set -euo pipefail - . ./assets/wasix-build/docker_wasix_env.sh - . ./assets/wasix-build/profile_flags.sh - pglite_oxide_apply_wasix_profile build + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh + oliphaunt_wasix_apply_wasix_profile build test -f "$BUILD_DIR/config.status" - test -f "$BUILD_DIR/src/backend/pglite" - cmp -s "$PGSRC/.pglite-oxide-source-head" "$BUILD_DIR/.pglite-oxide-source-head" - cmp -s "$PGSRC/.pglite-oxide-patch-sha256" "$BUILD_DIR/.pglite-oxide-patch-sha256" - sha256sum -c "$BUILD_DIR/.pglite-oxide-bridge-sha256" >/dev/null - test "$(pglite_oxide_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.pglite-oxide-build-profile")" + test -f "$BUILD_DIR/src/backend/oliphaunt" + oliphaunt_wasix_check_source_markers + sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" >/dev/null + test "$(oliphaunt_wasix_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.oliphaunt-wasix-build-profile")" if [ ! -f "$PLAN" ]; then echo "generated PGXS build plan missing: $PLAN" >&2 @@ -99,10 +108,10 @@ fi read -r -a extra_make_args <<< "$make_args" fi make -s -C "$extension_dir" \ - PG_CONFIG=/work/assets/wasix-build/pg_config_wasix.sh \ + PG_CONFIG="$CONTAINER_ROOT/pg_config_wasix.sh" \ clean >/dev/null 2>&1 || true make -s -j"$JOBS" -C "$extension_dir" \ - PG_CONFIG=/work/assets/wasix-build/pg_config_wasix.sh \ + PG_CONFIG="$CONTAINER_ROOT/pg_config_wasix.sh" \ CPPFLAGS="-I$BUILD_DIR/src/include -I$PGSRC/src/include -I$PGSRC/src/include/port/wasix-dl" \ OPTFLAGS="" \ "${extra_make_args[@]}" \ diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh new file mode 100755 index 00000000..ef4420f5 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || (cd "$ROOT/../../../../../.." && pwd))" +. "$ROOT/source_lane.sh" +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" + +IMAGE="${IMAGE:-oliphaunt-wasix-wasix-build:local}" +JOBS="${JOBS:-4}" +CONTAINER_ROOT="${CONTAINER_ROOT:-/work/src/runtimes/liboliphaunt/wasix/assets/build}" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +CONTAINER_BUILD_DIR="${CONTAINER_BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +CONTAINER_PGSRC="${CONTAINER_PGSRC:-$(oliphaunt_wasix_prepare_source_for_docker "$SOURCE_LANE")}" +DOCKER="${DOCKER:-$(command -v docker 2>/dev/null || true)}" +if [ -z "$DOCKER" ] && [ -x /usr/local/bin/docker ]; then + DOCKER=/usr/local/bin/docker +fi +if [ -z "$DOCKER" ] && [ -x /opt/homebrew/bin/docker ]; then + DOCKER=/opt/homebrew/bin/docker +fi +if [ -z "$DOCKER" ]; then + echo "docker CLI not found; set DOCKER=/path/to/docker" >&2 + exit 127 +fi +export PATH="$(dirname "$DOCKER"):$PATH" +DOCKER_USER_ARGS=() +if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then + DOCKER_USER_ARGS=(--user "$(id -u):$(id -g)" -e HOME=/tmp) +fi + +if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $IMAGE" >&2 + exit 1 + } + echo "reusing Docker image $IMAGE" +elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + "$DOCKER" build \ + -t "$IMAGE" \ + -f "$ROOT/docker/Dockerfile" \ + "$ROOT/docker" +else + echo "reusing Docker image $IMAGE" +fi + +"$DOCKER" run --rm \ + "${DOCKER_USER_ARGS[@]}" \ + --cpus="$JOBS" \ + -e CONTAINER_ROOT="$CONTAINER_ROOT" \ + -e CONTAINER_GENERATED_ROOT="$CONTAINER_GENERATED_ROOT" \ + -e BUILD_DIR="$CONTAINER_BUILD_DIR" \ + -e PGSRC="$CONTAINER_PGSRC" \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$SOURCE_LANE" \ + -e JOBS="$JOBS" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" \ + -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ + -v "$REPO_ROOT:/work" \ + -w /work \ + "$IMAGE" \ + bash -lc ' + set -euo pipefail + . ./src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh + . ./src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh + oliphaunt_wasix_apply_wasix_profile build + + test -f "$BUILD_DIR/config.status" + test -f "$BUILD_DIR/src/backend/oliphaunt" + oliphaunt_wasix_check_source_markers + sha256sum -c "$BUILD_DIR/.oliphaunt-wasix-bridge-sha256" >/dev/null + test "$(oliphaunt_wasix_wasix_profile_signature)" = "$(cat "$BUILD_DIR/.oliphaunt-wasix-build-profile")" + + make -s -j"$JOBS" -C "$BUILD_DIR/src/pl/plpgsql/src" all + make -s -j"$JOBS" -C "$BUILD_DIR/src/backend/snowball" all + test -f "$BUILD_DIR/src/pl/plpgsql/src/plpgsql.so" + test -f "$BUILD_DIR/src/backend/snowball/dict_snowball.so" + test -f "$BUILD_DIR/src/backend/snowball/snowball_create.sql" + ' diff --git a/assets/wasix-build/docker_wasix_env.sh b/src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh similarity index 58% rename from assets/wasix-build/docker_wasix_env.sh rename to src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh index 2f2df26b..1fcca1eb 100755 --- a/assets/wasix-build/docker_wasix_env.sh +++ b/src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh @@ -3,7 +3,9 @@ set -euo pipefail : "${WASIX_HOME:=/opt/wasixcc-home/.wasixcc}" -if [ "${HOME:-}" != "${WASIX_HOME%/.wasixcc}" ] && [ ! -e "$HOME/.wasixcc" ]; then +if [ "${HOME:-}" != "${WASIX_HOME%/.wasixcc}" ] && + [ ! -e "$HOME/.wasixcc" ] && + [ ! -L "$HOME/.wasixcc" ]; then ln -s "$WASIX_HOME" "$HOME/.wasixcc" fi diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh b/src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh new file mode 100755 index 00000000..ae9151b0 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$ROOT/source_lane.sh" + +SOURCE_LANE="$(oliphaunt_wasix_source_lane)" +CONTAINER_GENERATED_ROOT="${CONTAINER_GENERATED_ROOT:-/work/target/oliphaunt-wasix/wasix-build}" +BUILD_DIR="${BUILD_DIR:-$(oliphaunt_wasix_default_build_dir "$SOURCE_LANE")}" +if [ -z "${PGSRC:-}" ]; then + case "$SOURCE_LANE" in + stable | released | packaged | default) + echo "PGSRC must be set when pg_config_wasix.sh runs" >&2 + exit 2 + ;; + *) + echo "unsupported OLIPHAUNT_WASM_SOURCE_LANE=$SOURCE_LANE" >&2 + exit 2 + ;; + esac +fi +case "$SOURCE_LANE" in + stable | released | packaged | default) + if [ ! -s "$PGSRC/.oliphaunt-wasix-postgres-version" ]; then + echo "PG18 PGSRC is missing .oliphaunt-wasix-postgres-version: $PGSRC" >&2 + exit 2 + fi + if [ ! -s "$PGSRC/.oliphaunt-wasix-source-fingerprint" ]; then + echo "PG18 PGSRC is missing .oliphaunt-wasix-source-fingerprint: $PGSRC" >&2 + exit 2 + fi + ;; + *) + echo "unsupported OLIPHAUNT_WASM_SOURCE_LANE=$SOURCE_LANE" >&2 + exit 2 + ;; +esac +PREFIX="${OLIPHAUNT_WASIX_PREFIX:-$BUILD_DIR/install}" + +postgres_version() { + local version_file version source_toml + for version_file in \ + "$PGSRC/.oliphaunt-wasix-postgres-version" \ + "$BUILD_DIR/.oliphaunt-wasix-postgres-version"; do + if [ -f "$version_file" ]; then + IFS= read -r version < "$version_file" + if [ -n "$version" ]; then + printf '%s-wasix-oliphaunt\n' "$version" + return + fi + fi + done + source_toml="$ROOT/postgres/source.toml" + if [ -f "$source_toml" ]; then + version="$(awk -F'=' '/^[[:space:]]*version[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); gsub(/^"|"$/, "", $2); print $2; exit}' "$source_toml")" + if [ -n "$version" ]; then + printf '%s-wasix-oliphaunt\n' "$version" + return + fi + fi + echo "unable to determine pinned PostgreSQL version" >&2 + return 2 +} + +case "${1:-}" in + --pgxs) + echo "$BUILD_DIR/src/makefiles/pgxs.mk" + ;; + --bindir) + echo "$PREFIX/bin" + ;; + --sharedir) + echo "$PREFIX/share" + ;; + --sysconfdir) + echo "$PREFIX/etc" + ;; + --libdir) + echo "$PREFIX/lib" + ;; + --pkglibdir) + echo "$PREFIX/lib/postgresql" + ;; + --includedir | --pkgincludedir) + echo "$PREFIX/include" + ;; + --includedir-server) + echo "$BUILD_DIR/src/include" + ;; + --mandir) + echo "$PREFIX/share/man" + ;; + --docdir) + echo "$PREFIX/share/doc" + ;; + --localedir) + echo "$PREFIX/share/locale" + ;; + --version) + echo "PostgreSQL $(postgres_version)" + ;; + --configure) + echo "--host=wasm32-wasix --with-template=wasix-dl" + ;; + --cc) + echo "wasixcc" + ;; + --cppflags) + echo "-I$BUILD_DIR/src/include -I$PGSRC/src/include -I$PGSRC/src/include/port/wasix-dl" + ;; + --cflags) + echo "" + ;; + --ldflags | --libs) + echo "" + ;; + *) + echo "unsupported pg_config_wasix.sh option: ${1:-}" >&2 + exit 2 + ;; +esac diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml new file mode 100644 index 00000000..7ec4d301 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml @@ -0,0 +1,78 @@ +# Disposition of PostgreSQL-source patches from: +# f0rr0/wasix-pg18-experiment. +# +# This file is intentionally separate from the applied patch series. It records +# which experiment patches were ported into the PG18 WASIX runtime, which +# were replaced by narrower patches, and which remain deferred or rejected. + +[metadata] +source_repository = "f0rr0/wasix-pg18-experiment" +source_branch = "f0rr0/wasix-pg18-experiment" +source_path = "assets/wasix-build/experiments/fresh-wasix-postgres/patches" +policy = "do-not-port-experiment-patches-without-a-recorded-wasix-runtime-rationale" + +[[patch]] +experiment = "0001-wasix-use-posix-dsm-not-sysv.patch" +status = "not-carried" +wasix_runtime_decision = "replaced where relevant by 0010 and 0011" +rationale = "The experiment patch changes full-concurrent PostgreSQL dynamic shared memory selection. The embedded WASIX runtime does not take postmaster DSM as a product constraint; it instead routes SysV shmem through the wasix-dl port header and explicitly selects POSIX semaphores." + +[[patch]] +experiment = "0003-wasix-libpq-static-encoding-shim.patch" +status = "covered-by-build-spine" +wasix_runtime_decision = "covered by the WASIX bridge aliases and standalone pg_dump build path" +rationale = "The released-lane bridge already provides weak pg_char_to_encoding and pg_encoding_to_char aliases for static pg_dump/libpq linkage. No PG18 source patch is needed unless a future configured build proves a tool-specific gap." + +[[patch]] +experiment = "0004-wasix-core-execbackend-initdb-runtime.patch" +status = "not-carried" +wasix_runtime_decision = "full-concurrent runtime blocker patch, not embedded product shape" +rationale = "The patch addresses EXEC_BACKEND, fork/exec, root checks, locale command probing, and directory fsync behavior for proper concurrent PostgreSQL under WASIX. The embedded WASIX runtime avoids the postmaster/fork lifecycle; any tool blocker found later should become a smaller tool-specific patch." + +[[patch]] +experiment = "0005-pg-dump-avoid-lto-executequery-collision.patch" +status = "ported" +wasix_runtime_decision = "ported as 0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch" +rationale = "This is a narrow pg_dump source hygiene patch that supports standalone WASIX tool builds under thin LTO without changing query behavior." + +[[patch]] +experiment = "0006-like-literal-substring-fast-path.patch" +status = "ported-with-tighter-guards" +wasix_runtime_decision = "ported as 0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch" +rationale = "The PG18 patch narrows the experiment shortcut to deterministic, case-sensitive LIKE matching for the simple %literal% shape and rejects escapes, _, inner %, lower/case-insensitive variants, and nondeterministic collations before using memchr/memcmp." + +[[patch]] +experiment = "0007-top-xid-current-transaction-fast-path.patch" +status = "ported" +wasix_runtime_decision = "ported as 0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch" +rationale = "The final PG18 patch keeps the upstream parallel and subtransaction paths and only short-circuits the ordinary top-level case after all alternate current-XID sources are absent." + +[[patch]] +experiment = "0008-btree-int4-compare-fast-path.patch" +status = "ported-with-tighter-guards" +wasix_runtime_decision = "ported as 0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch" +rationale = "The PG18 patch keeps index_getattr(), requires the built-in integer btree family, int4 input type, int4 or InvalidOid subtype, and InvalidOid collation. It does not assume every int4 opclass has the built-in ordering." + +[[patch]] +experiment = "0009-btree-delete-stack-state.patch" +status = "ported-with-single-user-gate" +wasix_runtime_decision = "ported as 0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch" +rationale = "The PG18 patch is restricted to __wasi__ and OLIPHAUNT_WASM_SINGLE_USER. It keeps deletion policy and tableam behavior unchanged while avoiding page-local allocator churn." + +[[patch]] +experiment = "0010-btree-bottomup-delete-runtime-toggle.patch" +status = "rejected-for-default-lane" +wasix_runtime_decision = "diagnostic toggle was tested with Oliphaunt env names but not kept in the patch stack" +rationale = "Disabling bottom-up deletion changes PostgreSQL's index maintenance behavior. A local PG18 WASIX port of the diagnostic hook made the default release-profile 9/10 run much slower before any override was enabled, so it is not a defensible carried patch." + +[[patch]] +experiment = "0011-btree-first-int4-compare-fast-path.patch" +status = "ported-with-tighter-guards" +wasix_runtime_decision = "ported as 0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch" +rationale = "The PG18 patch keeps the direct tuple-data read only for embedded WASIX, leaf pages, one-key non-null non-posting non-pivot tuples, built-in int4 btree family, int4 input type, InvalidOid collation, and non-DESC scan keys. Equal values still fall through to PostgreSQL's existing heap-TID and truncated-key tie-break logic." + +[[patch]] +experiment = "0012-hash-bytes-unaligned-load-fast-path.patch" +status = "ported" +wasix_runtime_decision = "ported as 0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch" +rationale = "The PG18 patch uses memcpy-based little-endian 32-bit loads under __wasi__ only, preserving defined C behavior while giving LLVM a wasm-load lowering opportunity." diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch new file mode 100644 index 00000000..fc6a63ee --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch @@ -0,0 +1,134 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add wasix-dl build spine + +Add the minimum PostgreSQL-side build plumbing for the Oliphaunt WASIX +embedded WASIX runtime. + +This patch intentionally does not add lifecycle, host I/O, identity, +shared-memory, or error-recovery behavior. It only teaches the upstream +makefiles about the wasm side-module shape we need: + +* a `wasix-dl` template, +* unversioned shared-library side modules, +* GNU ld-style export lists for those side modules, and +* a backend `oliphaunt` target that links a dynamic-main wasm artifact + from the normal backend object graph. + +The template intentionally does not disable spinlocks. The released WASIX +lane now relies on the toolchain atomics path, and this PG18 lane should keep +that performance-critical invariant unless a later patch proves a narrower +replacement. + +Keeping this as the first small patch makes later behavioral changes easy +to review on their own merits. +--- + src/Makefile.shlib | 9 +++++++++ + src/backend/Makefile | 22 +++++++++++++++++++--- + src/include/port/wasix-dl.h | 8 ++++++++ + src/template/wasix-dl | 14 ++++++++++++++ + 4 files changed, 51 insertions(+), 3 deletions(-) + create mode 100644 src/include/port/wasix-dl.h + create mode 100644 src/template/wasix-dl + +diff --git a/src/Makefile.shlib b/src/Makefile.shlib +index 1b5ddf2825..55129150fa 100644 +--- a/src/Makefile.shlib ++++ b/src/Makefile.shlib +@@ -196,6 +196,16 @@ ifeq ($(PORTNAME), linux) + endif + endif + ++ifeq ($(PORTNAME), wasix-dl) ++ LINK.shared = $(COMPILER) -shared ++ ifdef SO_MAJOR_VERSION ++ shlib = $(shlib_bare) ++ soname = $(shlib_bare) ++ endif ++ BUILD.exports = ( echo '{ global:'; $(AWK) '/^[^\#]/ {printf "%s;\n",$$1}' $<; echo ' local: *; };' ) >$@ ++ exports_file = $(SHLIB_EXPORTS:%.txt=%.list) ++endif ++ + ifeq ($(PORTNAME), solaris) + LINK.shared = $(COMPILER) -shared + ifdef soname +diff --git a/src/backend/Makefile b/src/backend/Makefile +index 4827ee361d..9292f77031 100644 +--- a/src/backend/Makefile ++++ b/src/backend/Makefile +@@ -44,13 +44,13 @@ endif + + all: submake-libpgport submake-catalog-headers submake-utils-headers postgres $(POSTGRES_IMP) + +-ifneq ($(PORTNAME), cygwin) ++ifeq (,$(filter cygwin wasix-dl,$(PORTNAME))) + ifneq ($(PORTNAME), win32) + + postgres: $(OBJS) + $(CC) $(CFLAGS) $(call expand_subsys,$^) $(LDFLAGS) $(LIBS) -o $@ + +-endif ++endif # win32 + endif + + ifeq ($(PORTNAME), cygwin) +@@ -82,6 +82,22 @@ libpostgres.a: postgres + + endif # win32 + ++ifeq ($(PORTNAME), wasix-dl) ++oliphaunt: $(OBJS) ++ $(AR) rcs libpgmain.a main/main.o ++ $(AR) rcs libpostgres.a $(filter-out main/main.o,$(call expand_subsys,$^)) ++ $(LD) -r -o libpgcore.o $(filter-out main/main.o,$(call expand_subsys,$^)) ++ $(AR) rcs libpgcore.a libpgcore.o ++ $(CC) $(CFLAGS) main/main.o libpgcore.a $(LDFLAGS) $(LIBS) -Wl,--no-entry -Wl,--export-dynamic -Wl,--export=_start -nostartfiles -o $@ ++ ++postgres: oliphaunt ++ ++libpostgres.a: oliphaunt ++ touch $@ ++ ++.PHONY: oliphaunt ++endif # wasix-dl ++ + $(top_builddir)/src/port/libpgport_srv.a: | submake-libpgport + + +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +new file mode 100644 +index 0000000000..630687ec15 +--- /dev/null ++++ b/src/include/port/wasix-dl.h +@@ -0,0 +1,8 @@ ++/*------------------------------------------------------------------------- ++ * src/include/port/wasix-dl.h ++ * Port-specific declarations for the Oliphaunt WASIX side-module build. ++ *------------------------------------------------------------------------- ++ */ ++#ifndef PG_PORT_WASIX_DL_H ++#define PG_PORT_WASIX_DL_H ++#endif +diff --git a/src/template/wasix-dl b/src/template/wasix-dl +new file mode 100644 +index 0000000000..f3792d3e83 +--- /dev/null ++++ b/src/template/wasix-dl +@@ -0,0 +1,14 @@ ++#------------------------------------------------------------------------- ++# ++# src/template/wasix-dl ++# Template for the Oliphaunt WASIX dynamic-main PostgreSQL build. ++# ++#------------------------------------------------------------------------- ++ ++CPPFLAGS="$CPPFLAGS -D_GNU_SOURCE -DOLIPHAUNT_WASM_SINGLE_USER" ++CFLAGS_SL="-fPIC" ++DLSUFFIX=".so" ++THREAD_CFLAGS="-pthread" ++THREAD_LIBS="-pthread" ++ ++# The linked wasm artifact is a standalone side module loaded by the Rust host. +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch new file mode 100644 index 00000000..fb2ae9b5 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch @@ -0,0 +1,99 @@ +From 0000000000000000000000000000000000000002 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add backend host I/O hooks + +Add an explicit backend libpq I/O hook for the embedded WASIX host. + +The released PG17.5 lane routes protocol bytes through broad recv()/send() +remapping in the WASIX shim. For the PG18 lane, keep the normal socket path +unchanged and add a Port-owned callback table that can be installed by the +single-user startup patch later in the series. + +This patch only provides the hook surface. It does not choose startup +lifecycle, authentication, or protocol-pump behavior. +--- + src/backend/libpq/be-secure.c | 10 ++++++++++ + src/backend/libpq/pqcomm.c | 8 ++++++++ + src/include/libpq/libpq-be.h | 11 +++++++++++ + 3 files changed, 29 insertions(+) + +diff --git a/src/backend/libpq/be-secure.c b/src/backend/libpq/be-secure.c +index 2c9dfc28f0..d069849c9d 100644 +--- a/src/backend/libpq/be-secure.c ++++ b/src/backend/libpq/be-secure.c +@@ -288,6 +288,11 @@ secure_raw_read(Port *port, void *ptr, size_t len) + return len; + } + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ if (port->oliphaunt_wasix_io != NULL && port->oliphaunt_wasix_io->read != NULL) ++ return port->oliphaunt_wasix_io->read(port->oliphaunt_wasix_io->context, ptr, len); ++#endif ++ + /* + * Try to read from the socket without blocking. If it succeeds we're + * done, otherwise we'll wait for the socket using the latch mechanism. +@@ -381,6 +386,11 @@ secure_raw_write(Port *port, const void *ptr, size_t len) + { + ssize_t n; + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ if (port->oliphaunt_wasix_io != NULL && port->oliphaunt_wasix_io->write != NULL) ++ return port->oliphaunt_wasix_io->write(port->oliphaunt_wasix_io->context, ptr, len); ++#endif ++ + #ifdef WIN32 + pgwin32_noblock = true; + #endif +diff --git a/src/backend/libpq/pqcomm.c b/src/backend/libpq/pqcomm.c +index 6f7b4f62b6..742d1856e5 100644 +--- a/src/backend/libpq/pqcomm.c ++++ b/src/backend/libpq/pqcomm.c +@@ -309,6 +309,14 @@ pq_init(ClientSocket *client_sock) + port->sock, NULL, NULL); + latch_pos = AddWaitEventToSet(FeBeWaitSet, WL_LATCH_SET, PGINVALID_SOCKET, + MyLatch, NULL); ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ /* ++ * The embedded WASIX backend is not a postmaster child, so there is no ++ * postmaster-death event to watch. Normal postmaster children keep the ++ * upstream wait event unchanged. ++ */ ++ if (IsUnderPostmaster) ++#endif + AddWaitEventToSet(FeBeWaitSet, WL_POSTMASTER_DEATH, PGINVALID_SOCKET, + NULL, NULL); + +diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h +index 6aa52a64ff..6068a0cb8f 100644 +--- a/src/include/libpq/libpq-be.h ++++ b/src/include/libpq/libpq-be.h +@@ -99,6 +99,15 @@ typedef struct ClientConnectionInfo + UserAuth auth_method; + } ClientConnectionInfo; + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++typedef struct OliphauntWasmHostIO ++{ ++ void *context; ++ ssize_t (*read) (void *context, void *ptr, size_t len); ++ ssize_t (*write) (void *context, const void *ptr, size_t len); ++} OliphauntWasmHostIO; ++#endif ++ + /* + * The Port structure holds state information about a client connection in a + * backend process. It is available in the global variable MyProcPort. The +@@ -244,6 +253,9 @@ typedef struct Port + char *raw_buf; + ssize_t raw_buf_consumed, + raw_buf_remaining; ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ OliphauntWasmHostIO *oliphaunt_wasix_io; ++#endif + } Port; + + /* +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0003-oliphaunt-wasix-export-startup-packet-parser.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0003-oliphaunt-wasix-export-startup-packet-parser.patch new file mode 100644 index 00000000..d4d502df --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0003-oliphaunt-wasix-export-startup-packet-parser.patch @@ -0,0 +1,58 @@ +From 0000000000000000000000000000000000000003 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: export startup packet parser + +Allow the embedded WASIX host to feed PostgreSQL's normal startup packet +parser directly. + +The released lane already relies on this shape: the Rust host writes the +client startup packet into the guest protocol buffer, then calls +ProcessStartupPacket() before asking the backend to send AuthenticationOk, +ParameterStatus, BackendKeyData, and ReadyForQuery. + +Keep PostgreSQL's normal symbol scope unchanged. Only +OLIPHAUNT_WASM_SINGLE_USER builds make the parser externally visible and export +it with the ABI name expected by the current host. +--- + src/backend/tcop/backend_startup.c | 18 ++++++++++++++++++ + 1 file changed, 18 insertions(+) + +diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c +index dd3307cb76..f7b053e3b8 100644 +--- a/src/backend/tcop/backend_startup.c ++++ b/src/backend/tcop/backend_startup.c +@@ -44,9 +44,19 @@ char *log_connections_string = NULL; + */ + ConnectionTiming conn_timing = {.ready_for_use = TIMESTAMP_MINUS_INFINITY}; + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#define OLIPHAUNT_WASM_HOST_EXPORT(name) __attribute__((export_name(name))) ++#else ++#define OLIPHAUNT_WASM_HOST_EXPORT(name) ++#endif ++ + static void BackendInitialize(ClientSocket *client_sock, CAC_state cac); + static int ProcessSSLStartup(Port *port); ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++int ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done); ++#else + static int ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done); ++#endif + static void ProcessCancelRequestPacket(Port *port, void *pkt, int pktlen); + static void SendNegotiateProtocolVersion(List *unrecognized_protocol_options); + static void process_startup_packet_die(SIGNAL_ARGS); +@@ -489,7 +499,11 @@ reject: + * should make no assumption here about the order in which the client may make + * requests. + */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++OLIPHAUNT_WASM_HOST_EXPORT("ProcessStartupPacket") int ++#else + static int ++#endif + ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done) + { + int32 len; +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0004-oliphaunt-wasix-add-host-lifecycle-exports.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0004-oliphaunt-wasix-add-host-lifecycle-exports.patch new file mode 100644 index 00000000..60a992f8 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0004-oliphaunt-wasix-add-host-lifecycle-exports.patch @@ -0,0 +1,226 @@ +From 0000000000000000000000000000000000000004 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add host lifecycle exports + +Expose the minimal compatibility ABI that the current Oliphaunt WASIX host +needs after `_start` has initialized the standalone backend. + +The important invariant is that PostgreSQL still performs normal standalone +backend initialization through `PostgresSingleUserMain()` and `PostgresMain()`. +The host ABI added here only attaches a direct FE/BE Port after that backend +state exists, then sends the normal AuthenticationOk, ParameterStatus, +BackendKeyData, and ReadyForQuery messages through libpq. + +The exported names intentionally keep the current `oliphaunt_wasix_*` spelling so the Rust +release lane can be brought up before a host-side ABI rename. The code path is +guarded by `OLIPHAUNT_WASM_SINGLE_USER`. +--- + src/backend/tcop/postgres.c | 120 ++++++++++++++++++++++++++++++++++++++ + src/backend/utils/init/postinit.c | 9 +++ + src/include/port/wasix-dl.h | 10 ++++ + 3 files changed, 139 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index 39e4e3216d..674dd23641 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -24,6 +24,9 @@ + #include + #include + #include ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include ++#endif + #include + + #ifdef USE_VALGRIND +@@ -40,13 +43,20 @@ + #include "common/pg_prng.h" + #include "jit/jit.h" + #include "libpq/libpq.h" ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include "libpq/libpq-be.h" ++#include "libpq/protocol.h" ++#endif + #include "libpq/pqformat.h" + #include "libpq/pqsignal.h" + #include "mb/pg_wchar.h" + #include "mb/stringinfo_mb.h" + #include "miscadmin.h" + #include "nodes/print.h" + #include "optimizer/optimizer.h" ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include "port/wasix-dl.h" ++#endif + #include "parser/analyze.h" + #include "parser/parser.h" + #include "pg_getopt.h" +@@ -158,6 +168,111 @@ static volatile sig_atomic_t RecoveryConflictPendingReasons[NUM_PROCSIGNALS]; + static MemoryContext row_description_context = NULL; + static StringInfoData row_description_buf; + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#define OLIPHAUNT_WASM_EXIT_ALIVE 99 ++#define OLIPHAUNT_WASM_HOST_EXPORT(name) __attribute__((export_name(name))) ++ ++extern volatile int is_oliphaunt_active; ++ ++static OliphauntWasmHostIO oliphaunt_wasix_protocol_io = ++{ ++ NULL, ++ oliphaunt_wasix_host_read, ++ oliphaunt_wasix_host_write ++}; ++ ++static void ++oliphaunt_wasix_init_protocol_port(void) ++{ ++ ClientSocket client_sock; ++ struct sockaddr_in *addr; ++ MemoryContext oldcontext; ++ ++ if (MyProcPort != NULL) ++ { ++ MyProcPort->oliphaunt_wasix_io = &oliphaunt_wasix_protocol_io; ++ return; ++ } ++ ++ memset(&client_sock, 0, sizeof(client_sock)); ++ client_sock.sock = 1; ++ addr = (struct sockaddr_in *) &client_sock.raddr.addr; ++ addr->sin_family = AF_INET; ++ addr->sin_port = htons(5432); ++ addr->sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ client_sock.raddr.salen = sizeof(struct sockaddr_in); ++ ++ oldcontext = MemoryContextSwitchTo(TopMemoryContext); ++ MyProcPort = pq_init(&client_sock); ++ MyProcPort->oliphaunt_wasix_io = &oliphaunt_wasix_protocol_io; ++ MyProcPort->remote_host = MemoryContextStrdup(TopMemoryContext, "127.0.0.1"); ++ MyProcPort->remote_port = MemoryContextStrdup(TopMemoryContext, "5432"); ++ MemoryContextSwitchTo(oldcontext); ++} ++ ++OLIPHAUNT_WASM_HOST_EXPORT("oliphaunt_wasix_start") void ++oliphaunt_wasix_start(void) ++{ ++ oliphaunt_wasix_init_protocol_port(); ++ whereToSendOutput = DestRemote; ++ ExitOnAnyError = false; ++ MyBackendType = B_BACKEND; ++} ++ ++OLIPHAUNT_WASM_HOST_EXPORT("oliphaunt_wasix_pq_flush") void ++oliphaunt_wasix_pq_flush(void) ++{ ++ pq_flush(); ++} ++ ++OLIPHAUNT_WASM_HOST_EXPORT("oliphaunt_wasix_get_proc_port") struct Port * ++oliphaunt_wasix_get_proc_port(void) ++{ ++ return MyProcPort; ++} ++ ++OLIPHAUNT_WASM_HOST_EXPORT("oliphaunt_wasix_send_conn_data") void ++oliphaunt_wasix_send_conn_data(void) ++{ ++ StringInfoData buf; ++ ++ if (MyProcPort == NULL) ++ oliphaunt_wasix_init_protocol_port(); ++ ++ oliphaunt_wasix_process_startup_options(MyProcPort); ++ ClientAuthInProgress = false; ++ whereToSendOutput = DestRemote; ++ ++ if (MyCancelKeyLength == 0) ++ { ++ int len; ++ ++ len = (MyProcPort == NULL || MyProcPort->proto >= PG_PROTOCOL(3, 2)) ++ ? MAX_CANCEL_KEY_LENGTH : 4; ++ if (!pg_strong_random(&MyCancelKey, len)) ++ ereport(ERROR, ++ (errcode(ERRCODE_INTERNAL_ERROR), ++ errmsg("could not generate random cancel key"))); ++ MyCancelKeyLength = len; ++ } ++ ++ pq_beginmessage(&buf, PqMsg_AuthenticationRequest); ++ pq_sendint32(&buf, (int32) AUTH_REQ_OK); ++ pq_endmessage(&buf); ++ ++ BeginReportingGUCOptions(); ++ ++ pq_beginmessage(&buf, PqMsg_BackendKeyData); ++ pq_sendint32(&buf, (int32) MyProcPid); ++ pq_sendbytes(&buf, MyCancelKey, MyCancelKeyLength); ++ pq_endmessage(&buf); ++ ++ ReadyForQuery(DestRemote); ++} ++#else ++#define OLIPHAUNT_WASM_HOST_EXPORT(name) ++#endif ++ + /* ---------------------------------------------------------------- + * decls for routines only used in this file + * ---------------------------------------------------------------- +@@ -5109,6 +5220,11 @@ PostgresMain(const char *dbname, const char *username) + * it will fail to be called during other backend-shutdown + * scenarios. + */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ if (is_oliphaunt_active != 0) ++ exit(OLIPHAUNT_WASM_EXIT_ALIVE); ++#endif ++ + proc_exit(0); + + case PqMsg_CopyData: +diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c +index 47925d6848..bcce4363fe 100644 +--- a/src/backend/utils/init/postinit.c ++++ b/src/backend/utils/init/postinit.c +@@ -1290,6 +1290,15 @@ process_startup_options(Port *port, bool am_superuser) + } + } + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++void ++oliphaunt_wasix_process_startup_options(Port *port) ++{ ++ if (port != NULL) ++ process_startup_options(port, true); ++} ++#endif ++ + /* + * Load GUC settings from pg_db_role_setting. + * +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index 630687ec15..dfae28b4cc 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -3,6 +3,17 @@ + * Port-specific declarations for the Oliphaunt WASIX side-module build. + *------------------------------------------------------------------------- + */ + #ifndef PG_PORT_WASIX_DL_H + #define PG_PORT_WASIX_DL_H ++ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include ++ ++struct Port; ++ ++extern ssize_t oliphaunt_wasix_host_read(void *context, void *ptr, size_t len); ++extern ssize_t oliphaunt_wasix_host_write(void *context, const void *ptr, size_t len); ++extern void oliphaunt_wasix_process_startup_options(struct Port *port); ++#endif ++ + #endif +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch new file mode 100644 index 00000000..cadbadc9 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch @@ -0,0 +1,1653 @@ +From 0000000000000000000000000000000000000005 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add loop-pumped protocol exports + +Expose PostgreSQL main-loop control points for the embedded WASIX host. + +PostgreSQL still owns the normal backend initialization path and the normal +per-message FE/BE protocol logic. This patch factors the existing loop body +and top-level error recovery into helper functions, exporting them only for +OLIPHAUNT_WASM_SINGLE_USER builds so the Rust host can pump one frontend +message at a time and recover a backend ERROR without starting another +backend lifecycle. + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index b2e2753..da82a52 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -153,6 +153,16 @@ static bool DoingCommandRead = false; + static bool doing_extended_query_message = false; + static bool ignore_till_sync = false; + ++/* ++ * These are local to PostgresMain() upstream. Oliphaunt WASIX exposes the ++ * main loop to the host, so the loop and recovery exports need the state at ++ * file scope. Each backend instance still has one copy because a WASIX module ++ * instance owns a single PostgreSQL backend. ++ */ ++static volatile bool send_ready_for_query = true; ++static volatile bool idle_in_transaction_timeout_enabled = false; ++static volatile bool idle_session_timeout_enabled = false; ++ + /* + * If an unnamed prepared statement exists, it's stored here. + * We keep it separate from the hashtable kept by commands/prepare.c +@@ -4284,861 +4294,899 @@ PostgresSingleUserMain(int argc, char *argv[], + } + + +-/* ---------------------------------------------------------------- +- * PostgresMain +- * postgres main loop -- all backends, interactive or otherwise loop here +- * +- * dbname is the name of the database to connect to, username is the +- * PostgreSQL user name to be used for the session. +- * +- * NB: Single user mode specific setup should go to PostgresSingleUserMain() +- * if reasonably possible. +- * ---------------------------------------------------------------- +- */ +-void +-PostgresMain(const char *dbname, const char *username) ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++OLIPHAUNT_WASM_HOST_EXPORT("PostgresSendReadyForQueryIfNecessary") void ++#else ++static void ++#endif ++PostgresSendReadyForQueryIfNecessary(void) + { +- sigjmp_buf local_sigjmp_buf; +- +- /* these must be volatile to ensure state is preserved across longjmp: */ +- volatile bool send_ready_for_query = true; +- volatile bool idle_in_transaction_timeout_enabled = false; +- volatile bool idle_session_timeout_enabled = false; +- +- Assert(dbname != NULL); +- Assert(username != NULL); +- +- Assert(GetProcessingMode() == InitProcessing); +- + /* +- * Set up signal handlers. (InitPostmasterChild or InitStandaloneProcess +- * has already set up BlockSig and made that the active signal mask.) ++ * (1) If we've reached idle state, tell the frontend we're ready for ++ * a new query. + * +- * Note that postmaster blocked all signals before forking child process, +- * so there is no race condition whereby we might receive a signal before +- * we have set up the handler. ++ * Note: this includes fflush()'ing the last of the prior output. + * +- * Also note: it's best not to use any signals that are SIG_IGNored in the +- * postmaster. If such a signal arrives before we are able to change the +- * handler to non-SIG_IGN, it'll get dropped. Instead, make a dummy +- * handler in the postmaster to reserve the signal. (Of course, this isn't +- * an issue for signals that are locally generated, such as SIGALRM and +- * SIGPIPE.) ++ * This is also a good time to flush out collected statistics to the ++ * cumulative stats system, and to update the PS stats display. We ++ * avoid doing those every time through the message loop because it'd ++ * slow down processing of batched messages, and because we don't want ++ * to report uncommitted updates (that confuses autovacuum). The ++ * notification processor wants a call too, if we are not in a ++ * transaction block. ++ * ++ * Also, if an idle timeout is enabled, start the timer for that. + */ +- if (am_walsender) +- WalSndSignals(); +- else ++ if (send_ready_for_query) + { +- pqsignal(SIGHUP, SignalHandlerForConfigReload); +- pqsignal(SIGINT, StatementCancelHandler); /* cancel current query */ +- pqsignal(SIGTERM, die); /* cancel current query and exit */ ++ if (IsAbortedTransactionBlockState()) ++ { ++ set_ps_display("idle in transaction (aborted)"); ++ pgstat_report_activity(STATE_IDLEINTRANSACTION_ABORTED, NULL); + +- /* +- * In a postmaster child backend, replace SignalHandlerForCrashExit +- * with quickdie, so we can tell the client we're dying. +- * +- * In a standalone backend, SIGQUIT can be generated from the keyboard +- * easily, while SIGTERM cannot, so we make both signals do die() +- * rather than quickdie(). +- */ +- if (IsUnderPostmaster) +- pqsignal(SIGQUIT, quickdie); /* hard crash time */ ++ /* Start the idle-in-transaction timer */ ++ if (IdleInTransactionSessionTimeout > 0 ++ && (IdleInTransactionSessionTimeout < TransactionTimeout || TransactionTimeout == 0)) ++ { ++ idle_in_transaction_timeout_enabled = true; ++ enable_timeout_after(IDLE_IN_TRANSACTION_SESSION_TIMEOUT, ++ IdleInTransactionSessionTimeout); ++ } ++ } ++ else if (IsTransactionOrTransactionBlock()) ++ { ++ set_ps_display("idle in transaction"); ++ pgstat_report_activity(STATE_IDLEINTRANSACTION, NULL); ++ ++ /* Start the idle-in-transaction timer */ ++ if (IdleInTransactionSessionTimeout > 0 ++ && (IdleInTransactionSessionTimeout < TransactionTimeout || TransactionTimeout == 0)) ++ { ++ idle_in_transaction_timeout_enabled = true; ++ enable_timeout_after(IDLE_IN_TRANSACTION_SESSION_TIMEOUT, ++ IdleInTransactionSessionTimeout); ++ } ++ } + else +- pqsignal(SIGQUIT, die); /* cancel current query and exit */ +- InitializeTimeouts(); /* establishes SIGALRM handler */ ++ { ++ long stats_timeout; + +- /* +- * Ignore failure to write to frontend. Note: if frontend closes +- * connection, we will notice it and exit cleanly when control next +- * returns to outer loop. This seems safer than forcing exit in the +- * midst of output during who-knows-what operation... +- */ +- pqsignal(SIGPIPE, SIG_IGN); +- pqsignal(SIGUSR1, procsignal_sigusr1_handler); +- pqsignal(SIGUSR2, SIG_IGN); +- pqsignal(SIGFPE, FloatExceptionHandler); ++ /* ++ * Process incoming notifies (including self-notifies), if ++ * any, and send relevant messages to the client. Doing it ++ * here helps ensure stable behavior in tests: if any notifies ++ * were received during the just-finished transaction, they'll ++ * be seen by the client before ReadyForQuery is. ++ */ ++ if (notifyInterruptPending) ++ ProcessNotifyInterrupt(false); + +- /* +- * Reset some signals that are accepted by postmaster but not by +- * backend +- */ +- pqsignal(SIGCHLD, SIG_DFL); /* system() requires this on some +- * platforms */ +- } ++ /* ++ * Check if we need to report stats. If pgstat_report_stat() ++ * decides it's too soon to flush out pending stats / lock ++ * contention prevented reporting, it'll tell us when we ++ * should try to report stats again (so that stats updates ++ * aren't unduly delayed if the connection goes idle for a ++ * long time). We only enable the timeout if we don't already ++ * have a timeout in progress, because we don't disable the ++ * timeout below. enable_timeout_after() needs to determine ++ * the current timestamp, which can have a negative ++ * performance impact. That's OK because pgstat_report_stat() ++ * won't have us wake up sooner than a prior call. ++ */ ++ stats_timeout = pgstat_report_stat(false); ++ if (stats_timeout > 0) ++ { ++ if (!get_timeout_active(IDLE_STATS_UPDATE_TIMEOUT)) ++ enable_timeout_after(IDLE_STATS_UPDATE_TIMEOUT, ++ stats_timeout); ++ } ++ else ++ { ++ /* all stats flushed, no need for the timeout */ ++ if (get_timeout_active(IDLE_STATS_UPDATE_TIMEOUT)) ++ disable_timeout(IDLE_STATS_UPDATE_TIMEOUT, false); ++ } + +- /* Early initialization */ +- BaseInit(); ++ set_ps_display("idle"); ++ pgstat_report_activity(STATE_IDLE, NULL); + +- /* We need to allow SIGINT, etc during the initial transaction */ +- sigprocmask(SIG_SETMASK, &UnBlockSig, NULL); ++ /* Start the idle-session timer */ ++ if (IdleSessionTimeout > 0) ++ { ++ idle_session_timeout_enabled = true; ++ enable_timeout_after(IDLE_SESSION_TIMEOUT, ++ IdleSessionTimeout); ++ } ++ } + +- /* +- * Generate a random cancel key, if this is a backend serving a +- * connection. InitPostgres() will advertise it in shared memory. +- */ +- Assert(MyCancelKeyLength == 0); +- if (whereToSendOutput == DestRemote) +- { +- int len; ++ /* Report any recently-changed GUC options */ ++ ReportChangedGUCOptions(); + +- len = (MyProcPort == NULL || MyProcPort->proto >= PG_PROTOCOL(3, 2)) +- ? MAX_CANCEL_KEY_LENGTH : 4; +- if (!pg_strong_random(&MyCancelKey, len)) ++ /* ++ * The first time this backend is ready for query, log the ++ * durations of the different components of connection ++ * establishment and setup. ++ */ ++ if (conn_timing.ready_for_use == TIMESTAMP_MINUS_INFINITY && ++ (log_connections & LOG_CONNECTION_SETUP_DURATIONS) && ++ IsExternalConnectionBackend(MyBackendType)) + { +- ereport(ERROR, +- (errcode(ERRCODE_INTERNAL_ERROR), +- errmsg("could not generate random cancel key"))); ++ uint64 total_duration, ++ fork_duration, ++ auth_duration; ++ ++ conn_timing.ready_for_use = GetCurrentTimestamp(); ++ ++ total_duration = ++ TimestampDifferenceMicroseconds(conn_timing.socket_create, ++ conn_timing.ready_for_use); ++ fork_duration = ++ TimestampDifferenceMicroseconds(conn_timing.fork_start, ++ conn_timing.fork_end); ++ auth_duration = ++ TimestampDifferenceMicroseconds(conn_timing.auth_start, ++ conn_timing.auth_end); ++ ++ ereport(LOG, ++ errmsg("connection ready: setup total=%.3f ms, fork=%.3f ms, authentication=%.3f ms", ++ (double) total_duration / NS_PER_US, ++ (double) fork_duration / NS_PER_US, ++ (double) auth_duration / NS_PER_US)); + } +- MyCancelKeyLength = len; ++ ++ ReadyForQuery(whereToSendOutput); ++ send_ready_for_query = false; + } ++} + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++OLIPHAUNT_WASM_HOST_EXPORT("PostgresMainLongJmp") void ++#else ++static void ++#endif ++PostgresMainLongJmp(void) ++{ + /* +- * General initialization. +- * +- * NOTE: if you are tempted to add code in this vicinity, consider putting +- * it inside InitPostgres() instead. In particular, anything that +- * involves database access should be there, not here. +- * +- * Honor session_preload_libraries if not dealing with a WAL sender. ++ * NOTE: if you are tempted to add more code in this if-block, ++ * consider the high probability that it should be in ++ * AbortTransaction() instead. The only stuff done directly here ++ * should be stuff that is guaranteed to apply *only* for outer-level ++ * error recovery, such as adjusting the FE/BE protocol status. + */ +- InitPostgres(dbname, InvalidOid, /* database to connect to */ +- username, InvalidOid, /* role to connect as */ +- (!am_walsender) ? INIT_PG_LOAD_SESSION_LIBS : 0, +- NULL); /* no out_dbname */ ++ ++ /* Since not using PG_TRY, must reset error stack by hand */ ++ error_context_stack = NULL; ++ ++ /* Prevent interrupts while cleaning up */ ++ HOLD_INTERRUPTS(); + + /* +- * If the PostmasterContext is still around, recycle the space; we don't +- * need it anymore after InitPostgres completes. ++ * Forget any pending QueryCancel request, since we're returning to ++ * the idle loop anyway, and cancel any active timeout requests. (In ++ * future we might want to allow some timeout requests to survive, but ++ * at minimum it'd be necessary to do reschedule_timeouts(), in case ++ * we got here because of a query cancel interrupting the SIGALRM ++ * interrupt handler.) Note in particular that we must clear the ++ * statement and lock timeout indicators, to prevent any future plain ++ * query cancels from being misreported as timeouts in case we're ++ * forgetting a timeout cancel. + */ +- if (PostmasterContext) +- { +- MemoryContextDelete(PostmasterContext); +- PostmasterContext = NULL; +- } ++ disable_all_timeouts(false); /* do first to avoid race condition */ ++ QueryCancelPending = false; ++ idle_in_transaction_timeout_enabled = false; ++ idle_session_timeout_enabled = false; + +- SetProcessingMode(NormalProcessing); ++ /* Not reading from the client anymore. */ ++ DoingCommandRead = false; ++ ++ /* Make sure libpq is in a good state */ ++ pq_comm_reset(); ++ ++ /* Report the error to the client and/or server log */ ++ EmitErrorReport(); + + /* +- * Now all GUC states are fully set up. Report them to client if +- * appropriate. ++ * If Valgrind noticed something during the erroneous query, print the ++ * query string, assuming we have one. + */ +- BeginReportingGUCOptions(); ++ valgrind_report_error_query(debug_query_string); + + /* +- * Also set up handler to log session end; we have to wait till now to be +- * sure Log_disconnections has its final value. ++ * Make sure debug_query_string gets reset before we possibly clobber ++ * the storage it points at. + */ +- if (IsUnderPostmaster && Log_disconnections) +- on_proc_exit(log_disconnections, 0); ++ debug_query_string = NULL; + +- pgstat_report_connect(MyDatabaseId); ++ /* ++ * Abort the current transaction in order to recover. ++ */ ++ AbortCurrentTransaction(); + +- /* Perform initialization specific to a WAL sender process. */ + if (am_walsender) +- InitWalSender(); ++ WalSndErrorCleanup(); ++ ++ PortalErrorCleanup(); + + /* +- * Send this backend's cancellation info to the frontend. ++ * We can't release replication slots inside AbortTransaction() as we ++ * need to be able to start and abort transactions while having a slot ++ * acquired. But we never need to hold them across top level errors, ++ * so releasing here is fine. There also is a before_shmem_exit() ++ * callback ensuring correct cleanup on FATAL errors. + */ +- if (whereToSendOutput == DestRemote) +- { +- StringInfoData buf; +- +- Assert(MyCancelKeyLength > 0); +- pq_beginmessage(&buf, PqMsg_BackendKeyData); +- pq_sendint32(&buf, (int32) MyProcPid); ++ if (MyReplicationSlot != NULL) ++ ReplicationSlotRelease(); + +- pq_sendbytes(&buf, MyCancelKey, MyCancelKeyLength); +- pq_endmessage(&buf); +- /* Need not flush since ReadyForQuery will do it. */ +- } ++ /* We also want to cleanup temporary slots on error. */ ++ ReplicationSlotCleanup(false); + +- /* Welcome banner for standalone case */ +- if (whereToSendOutput == DestDebug) +- printf("\nPostgreSQL stand-alone backend %s\n", PG_VERSION); ++ jit_reset_after_error(); + + /* +- * Create the memory context we will use in the main loop. +- * +- * MessageContext is reset once per iteration of the main loop, ie, upon +- * completion of processing of each command message from the client. ++ * Now return to normal top-level context and clear ErrorContext for ++ * next time. + */ +- MessageContext = AllocSetContextCreate(TopMemoryContext, +- "MessageContext", +- ALLOCSET_DEFAULT_SIZES); ++ MemoryContextSwitchTo(MessageContext); ++ FlushErrorState(); + + /* +- * Create memory context and buffer used for RowDescription messages. As +- * SendRowDescriptionMessage(), via exec_describe_statement_message(), is +- * frequently executed for ever single statement, we don't want to +- * allocate a separate buffer every time. ++ * If we were handling an extended-query-protocol message, initiate ++ * skip till next Sync. This also causes us not to issue ++ * ReadyForQuery (until we get Sync). + */ +- row_description_context = AllocSetContextCreate(TopMemoryContext, +- "RowDescriptionContext", +- ALLOCSET_DEFAULT_SIZES); +- MemoryContextSwitchTo(row_description_context); +- initStringInfo(&row_description_buf); +- MemoryContextSwitchTo(TopMemoryContext); ++ if (doing_extended_query_message) ++ ignore_till_sync = true; + +- /* Fire any defined login event triggers, if appropriate */ +- EventTriggerOnLogin(); ++ /* We don't have a transaction command open anymore */ ++ xact_started = false; + + /* +- * POSTGRES main processing loop begins here +- * +- * If an exception is encountered, processing resumes here so we abort the +- * current transaction and start a new one. +- * +- * You might wonder why this isn't coded as an infinite loop around a +- * PG_TRY construct. The reason is that this is the bottom of the +- * exception stack, and so with PG_TRY there would be no exception handler +- * in force at all during the CATCH part. By leaving the outermost setjmp +- * always active, we have at least some chance of recovering from an error +- * during error recovery. (If we get into an infinite loop thereby, it +- * will soon be stopped by overflow of elog.c's internal state stack.) +- * +- * Note that we use sigsetjmp(..., 1), so that this function's signal mask +- * (to wit, UnBlockSig) will be restored when longjmp'ing to here. This +- * is essential in case we longjmp'd out of a signal handler on a platform +- * where that leaves the signal blocked. It's not redundant with the +- * unblock in AbortTransaction() because the latter is only called if we +- * were inside a transaction. ++ * If an error occurred while we were reading a message from the ++ * client, we have potentially lost track of where the previous ++ * message ends and the next one begins. Even though we have ++ * otherwise recovered from the error, we cannot safely read any more ++ * messages from the client, so there isn't much we can do with the ++ * connection anymore. + */ ++ if (pq_is_reading_msg()) ++ ereport(FATAL, ++ (errcode(ERRCODE_PROTOCOL_VIOLATION), ++ errmsg("terminating connection because protocol synchronization was lost"))); + +- if (sigsetjmp(local_sigjmp_buf, 1) != 0) +- { +- /* +- * NOTE: if you are tempted to add more code in this if-block, +- * consider the high probability that it should be in +- * AbortTransaction() instead. The only stuff done directly here +- * should be stuff that is guaranteed to apply *only* for outer-level +- * error recovery, such as adjusting the FE/BE protocol status. +- */ ++ /* Now we can allow interrupts again */ ++ RESUME_INTERRUPTS(); ++} + +- /* Since not using PG_TRY, must reset error stack by hand */ +- error_context_stack = NULL; ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++OLIPHAUNT_WASM_HOST_EXPORT("PostgresMainLoopOnce") void ++#else ++static void ++#endif ++PostgresMainLoopOnce(void) ++{ ++ int firstchar; ++ StringInfoData input_message; + +- /* Prevent interrupts while cleaning up */ +- HOLD_INTERRUPTS(); ++ /* ++ * At top of loop, reset extended-query-message flag, so that any ++ * errors encountered in "idle" state don't provoke skip. ++ */ ++ doing_extended_query_message = false; + +- /* +- * Forget any pending QueryCancel request, since we're returning to +- * the idle loop anyway, and cancel any active timeout requests. (In +- * future we might want to allow some timeout requests to survive, but +- * at minimum it'd be necessary to do reschedule_timeouts(), in case +- * we got here because of a query cancel interrupting the SIGALRM +- * interrupt handler.) Note in particular that we must clear the +- * statement and lock timeout indicators, to prevent any future plain +- * query cancels from being misreported as timeouts in case we're +- * forgetting a timeout cancel. +- */ +- disable_all_timeouts(false); /* do first to avoid race condition */ +- QueryCancelPending = false; +- idle_in_transaction_timeout_enabled = false; +- idle_session_timeout_enabled = false; ++ /* ++ * For valgrind reporting purposes, the "current query" begins here. ++ */ ++#ifdef USE_VALGRIND ++ old_valgrind_error_count = VALGRIND_COUNT_ERRORS; ++#endif + +- /* Not reading from the client anymore. */ +- DoingCommandRead = false; ++ /* ++ * Release storage left over from prior query cycle, and create a new ++ * query input buffer in the cleared MessageContext. ++ */ ++ MemoryContextSwitchTo(MessageContext); ++ MemoryContextReset(MessageContext); + +- /* Make sure libpq is in a good state */ +- pq_comm_reset(); ++ initStringInfo(&input_message); + +- /* Report the error to the client and/or server log */ +- EmitErrorReport(); ++ /* ++ * Also consider releasing our catalog snapshot if any, so that it's ++ * not preventing advance of global xmin while we wait for the client. ++ */ ++ InvalidateCatalogSnapshotConditionally(); + +- /* +- * If Valgrind noticed something during the erroneous query, print the +- * query string, assuming we have one. +- */ +- valgrind_report_error_query(debug_query_string); ++ PostgresSendReadyForQueryIfNecessary(); + +- /* +- * Make sure debug_query_string gets reset before we possibly clobber +- * the storage it points at. +- */ +- debug_query_string = NULL; ++ /* ++ * (2) Allow asynchronous signals to be executed immediately if they ++ * come in while we are waiting for client input. (This must be ++ * conditional since we don't want, say, reads on behalf of COPY FROM ++ * STDIN doing the same thing.) ++ */ ++ DoingCommandRead = true; + +- /* +- * Abort the current transaction in order to recover. +- */ +- AbortCurrentTransaction(); +- +- if (am_walsender) +- WalSndErrorCleanup(); +- +- PortalErrorCleanup(); +- +- /* +- * We can't release replication slots inside AbortTransaction() as we +- * need to be able to start and abort transactions while having a slot +- * acquired. But we never need to hold them across top level errors, +- * so releasing here is fine. There also is a before_shmem_exit() +- * callback ensuring correct cleanup on FATAL errors. +- */ +- if (MyReplicationSlot != NULL) +- ReplicationSlotRelease(); +- +- /* We also want to cleanup temporary slots on error. */ +- ReplicationSlotCleanup(false); +- +- jit_reset_after_error(); +- +- /* +- * Now return to normal top-level context and clear ErrorContext for +- * next time. +- */ +- MemoryContextSwitchTo(MessageContext); +- FlushErrorState(); +- +- /* +- * If we were handling an extended-query-protocol message, initiate +- * skip till next Sync. This also causes us not to issue +- * ReadyForQuery (until we get Sync). +- */ +- if (doing_extended_query_message) +- ignore_till_sync = true; +- +- /* We don't have a transaction command open anymore */ +- xact_started = false; +- +- /* +- * If an error occurred while we were reading a message from the +- * client, we have potentially lost track of where the previous +- * message ends and the next one begins. Even though we have +- * otherwise recovered from the error, we cannot safely read any more +- * messages from the client, so there isn't much we can do with the +- * connection anymore. +- */ +- if (pq_is_reading_msg()) +- ereport(FATAL, +- (errcode(ERRCODE_PROTOCOL_VIOLATION), +- errmsg("terminating connection because protocol synchronization was lost"))); ++ /* ++ * (3) read a command (loop blocks here) ++ */ ++ firstchar = ReadCommand(&input_message); + +- /* Now we can allow interrupts again */ +- RESUME_INTERRUPTS(); ++ /* ++ * (4) turn off the idle-in-transaction and idle-session timeouts if ++ * active. We do this before step (5) so that any last-moment timeout ++ * is certain to be detected in step (5). ++ * ++ * At most one of these timeouts will be active, so there's no need to ++ * worry about combining the timeout.c calls into one. ++ */ ++ if (idle_in_transaction_timeout_enabled) ++ { ++ disable_timeout(IDLE_IN_TRANSACTION_SESSION_TIMEOUT, false); ++ idle_in_transaction_timeout_enabled = false; ++ } ++ if (idle_session_timeout_enabled) ++ { ++ disable_timeout(IDLE_SESSION_TIMEOUT, false); ++ idle_session_timeout_enabled = false; + } +- +- /* We can now handle ereport(ERROR) */ +- PG_exception_stack = &local_sigjmp_buf; +- +- if (!ignore_till_sync) +- send_ready_for_query = true; /* initially, or after error */ + + /* +- * Non-error queries loop here. ++ * (5) disable async signal conditions again. ++ * ++ * Query cancel is supposed to be a no-op when there is no query in ++ * progress, so if a query cancel arrived while we were idle, just ++ * reset QueryCancelPending. ProcessInterrupts() has that effect when ++ * it's called when DoingCommandRead is set, so check for interrupts ++ * before resetting DoingCommandRead. + */ ++ CHECK_FOR_INTERRUPTS(); ++ DoingCommandRead = false; + +- for (;;) ++ /* ++ * (6) check for any other interesting events that happened while we ++ * slept. ++ */ ++ if (ConfigReloadPending) + { +- int firstchar; +- StringInfoData input_message; +- +- /* +- * At top of loop, reset extended-query-message flag, so that any +- * errors encountered in "idle" state don't provoke skip. +- */ +- doing_extended_query_message = false; +- +- /* +- * For valgrind reporting purposes, the "current query" begins here. +- */ +-#ifdef USE_VALGRIND +- old_valgrind_error_count = VALGRIND_COUNT_ERRORS; +-#endif ++ ConfigReloadPending = false; ++ ProcessConfigFile(PGC_SIGHUP); ++ } + +- /* +- * Release storage left over from prior query cycle, and create a new +- * query input buffer in the cleared MessageContext. +- */ +- MemoryContextSwitchTo(MessageContext); +- MemoryContextReset(MessageContext); ++ /* ++ * (7) process the command. But ignore it if we're skipping till ++ * Sync. ++ */ ++ if (ignore_till_sync && firstchar != EOF) ++ return; + +- initStringInfo(&input_message); ++ switch (firstchar) ++ { ++ case PqMsg_Query: ++ { ++ const char *query_string; + +- /* +- * Also consider releasing our catalog snapshot if any, so that it's +- * not preventing advance of global xmin while we wait for the client. +- */ +- InvalidateCatalogSnapshotConditionally(); ++ /* Set statement_timestamp() */ ++ SetCurrentStatementStartTimestamp(); + +- /* +- * (1) If we've reached idle state, tell the frontend we're ready for +- * a new query. +- * +- * Note: this includes fflush()'ing the last of the prior output. +- * +- * This is also a good time to flush out collected statistics to the +- * cumulative stats system, and to update the PS stats display. We +- * avoid doing those every time through the message loop because it'd +- * slow down processing of batched messages, and because we don't want +- * to report uncommitted updates (that confuses autovacuum). The +- * notification processor wants a call too, if we are not in a +- * transaction block. +- * +- * Also, if an idle timeout is enabled, start the timer for that. +- */ +- if (send_ready_for_query) +- { +- if (IsAbortedTransactionBlockState()) +- { +- set_ps_display("idle in transaction (aborted)"); +- pgstat_report_activity(STATE_IDLEINTRANSACTION_ABORTED, NULL); ++ query_string = pq_getmsgstring(&input_message); ++ pq_getmsgend(&input_message); + +- /* Start the idle-in-transaction timer */ +- if (IdleInTransactionSessionTimeout > 0 +- && (IdleInTransactionSessionTimeout < TransactionTimeout || TransactionTimeout == 0)) ++ if (am_walsender) + { +- idle_in_transaction_timeout_enabled = true; +- enable_timeout_after(IDLE_IN_TRANSACTION_SESSION_TIMEOUT, +- IdleInTransactionSessionTimeout); ++ if (!exec_replication_command(query_string)) ++ exec_simple_query(query_string); + } +- } +- else if (IsTransactionOrTransactionBlock()) +- { +- set_ps_display("idle in transaction"); +- pgstat_report_activity(STATE_IDLEINTRANSACTION, NULL); ++ else ++ exec_simple_query(query_string); + +- /* Start the idle-in-transaction timer */ +- if (IdleInTransactionSessionTimeout > 0 +- && (IdleInTransactionSessionTimeout < TransactionTimeout || TransactionTimeout == 0)) +- { +- idle_in_transaction_timeout_enabled = true; +- enable_timeout_after(IDLE_IN_TRANSACTION_SESSION_TIMEOUT, +- IdleInTransactionSessionTimeout); +- } ++ valgrind_report_error_query(query_string); ++ ++ send_ready_for_query = true; + } +- else ++ break; ++ ++ case PqMsg_Parse: + { +- long stats_timeout; ++ const char *stmt_name; ++ const char *query_string; ++ int numParams; ++ Oid *paramTypes = NULL; + +- /* +- * Process incoming notifies (including self-notifies), if +- * any, and send relevant messages to the client. Doing it +- * here helps ensure stable behavior in tests: if any notifies +- * were received during the just-finished transaction, they'll +- * be seen by the client before ReadyForQuery is. +- */ +- if (notifyInterruptPending) +- ProcessNotifyInterrupt(false); ++ forbidden_in_wal_sender(firstchar); + +- /* +- * Check if we need to report stats. If pgstat_report_stat() +- * decides it's too soon to flush out pending stats / lock +- * contention prevented reporting, it'll tell us when we +- * should try to report stats again (so that stats updates +- * aren't unduly delayed if the connection goes idle for a +- * long time). We only enable the timeout if we don't already +- * have a timeout in progress, because we don't disable the +- * timeout below. enable_timeout_after() needs to determine +- * the current timestamp, which can have a negative +- * performance impact. That's OK because pgstat_report_stat() +- * won't have us wake up sooner than a prior call. +- */ +- stats_timeout = pgstat_report_stat(false); +- if (stats_timeout > 0) +- { +- if (!get_timeout_active(IDLE_STATS_UPDATE_TIMEOUT)) +- enable_timeout_after(IDLE_STATS_UPDATE_TIMEOUT, +- stats_timeout); +- } +- else ++ /* Set statement_timestamp() */ ++ SetCurrentStatementStartTimestamp(); ++ ++ stmt_name = pq_getmsgstring(&input_message); ++ query_string = pq_getmsgstring(&input_message); ++ numParams = pq_getmsgint(&input_message, 2); ++ if (numParams > 0) + { +- /* all stats flushed, no need for the timeout */ +- if (get_timeout_active(IDLE_STATS_UPDATE_TIMEOUT)) +- disable_timeout(IDLE_STATS_UPDATE_TIMEOUT, false); ++ paramTypes = palloc_array(Oid, numParams); ++ for (int i = 0; i < numParams; i++) ++ paramTypes[i] = pq_getmsgint(&input_message, 4); + } ++ pq_getmsgend(&input_message); + +- set_ps_display("idle"); +- pgstat_report_activity(STATE_IDLE, NULL); ++ exec_parse_message(query_string, stmt_name, ++ paramTypes, numParams); + +- /* Start the idle-session timer */ +- if (IdleSessionTimeout > 0) +- { +- idle_session_timeout_enabled = true; +- enable_timeout_after(IDLE_SESSION_TIMEOUT, +- IdleSessionTimeout); +- } ++ valgrind_report_error_query(query_string); + } ++ break; + +- /* Report any recently-changed GUC options */ +- ReportChangedGUCOptions(); ++ case PqMsg_Bind: ++ forbidden_in_wal_sender(firstchar); ++ ++ /* Set statement_timestamp() */ ++ SetCurrentStatementStartTimestamp(); + + /* +- * The first time this backend is ready for query, log the +- * durations of the different components of connection +- * establishment and setup. ++ * this message is complex enough that it seems best to put ++ * the field extraction out-of-line + */ +- if (conn_timing.ready_for_use == TIMESTAMP_MINUS_INFINITY && +- (log_connections & LOG_CONNECTION_SETUP_DURATIONS) && +- IsExternalConnectionBackend(MyBackendType)) ++ exec_bind_message(&input_message); ++ ++ /* exec_bind_message does valgrind_report_error_query */ ++ break; ++ ++ case PqMsg_Execute: + { +- uint64 total_duration, +- fork_duration, +- auth_duration; +- +- conn_timing.ready_for_use = GetCurrentTimestamp(); +- +- total_duration = +- TimestampDifferenceMicroseconds(conn_timing.socket_create, +- conn_timing.ready_for_use); +- fork_duration = +- TimestampDifferenceMicroseconds(conn_timing.fork_start, +- conn_timing.fork_end); +- auth_duration = +- TimestampDifferenceMicroseconds(conn_timing.auth_start, +- conn_timing.auth_end); +- +- ereport(LOG, +- errmsg("connection ready: setup total=%.3f ms, fork=%.3f ms, authentication=%.3f ms", +- (double) total_duration / NS_PER_US, +- (double) fork_duration / NS_PER_US, +- (double) auth_duration / NS_PER_US)); +- } ++ const char *portal_name; ++ int max_rows; + +- ReadyForQuery(whereToSendOutput); +- send_ready_for_query = false; +- } ++ forbidden_in_wal_sender(firstchar); + +- /* +- * (2) Allow asynchronous signals to be executed immediately if they +- * come in while we are waiting for client input. (This must be +- * conditional since we don't want, say, reads on behalf of COPY FROM +- * STDIN doing the same thing.) +- */ +- DoingCommandRead = true; ++ /* Set statement_timestamp() */ ++ SetCurrentStatementStartTimestamp(); + +- /* +- * (3) read a command (loop blocks here) +- */ +- firstchar = ReadCommand(&input_message); ++ portal_name = pq_getmsgstring(&input_message); ++ max_rows = pq_getmsgint(&input_message, 4); ++ pq_getmsgend(&input_message); + +- /* +- * (4) turn off the idle-in-transaction and idle-session timeouts if +- * active. We do this before step (5) so that any last-moment timeout +- * is certain to be detected in step (5). +- * +- * At most one of these timeouts will be active, so there's no need to +- * worry about combining the timeout.c calls into one. +- */ +- if (idle_in_transaction_timeout_enabled) +- { +- disable_timeout(IDLE_IN_TRANSACTION_SESSION_TIMEOUT, false); +- idle_in_transaction_timeout_enabled = false; +- } +- if (idle_session_timeout_enabled) +- { +- disable_timeout(IDLE_SESSION_TIMEOUT, false); +- idle_session_timeout_enabled = false; +- } ++ exec_execute_message(portal_name, max_rows); + +- /* +- * (5) disable async signal conditions again. +- * +- * Query cancel is supposed to be a no-op when there is no query in +- * progress, so if a query cancel arrived while we were idle, just +- * reset QueryCancelPending. ProcessInterrupts() has that effect when +- * it's called when DoingCommandRead is set, so check for interrupts +- * before resetting DoingCommandRead. +- */ +- CHECK_FOR_INTERRUPTS(); +- DoingCommandRead = false; ++ /* exec_execute_message does valgrind_report_error_query */ ++ } ++ break; + +- /* +- * (6) check for any other interesting events that happened while we +- * slept. +- */ +- if (ConfigReloadPending) +- { +- ConfigReloadPending = false; +- ProcessConfigFile(PGC_SIGHUP); +- } ++ case PqMsg_FunctionCall: ++ forbidden_in_wal_sender(firstchar); + +- /* +- * (7) process the command. But ignore it if we're skipping till +- * Sync. +- */ +- if (ignore_till_sync && firstchar != EOF) +- continue; ++ /* Set statement_timestamp() */ ++ SetCurrentStatementStartTimestamp(); + +- switch (firstchar) +- { +- case PqMsg_Query: +- { +- const char *query_string; ++ /* Report query to various monitoring facilities. */ ++ pgstat_report_activity(STATE_FASTPATH, NULL); ++ set_ps_display(""); + +- /* Set statement_timestamp() */ +- SetCurrentStatementStartTimestamp(); ++ /* start an xact for this function invocation */ ++ start_xact_command(); + +- query_string = pq_getmsgstring(&input_message); +- pq_getmsgend(&input_message); ++ /* ++ * Note: we may at this point be inside an aborted ++ * transaction. We can't throw error for that until we've ++ * finished reading the function-call message, so ++ * HandleFunctionRequest() must check for it after doing so. ++ * Be careful not to do anything that assumes we're inside a ++ * valid transaction here. ++ */ + +- if (am_walsender) +- { +- if (!exec_replication_command(query_string)) +- exec_simple_query(query_string); +- } +- else +- exec_simple_query(query_string); ++ /* switch back to message context */ ++ MemoryContextSwitchTo(MessageContext); + +- valgrind_report_error_query(query_string); ++ HandleFunctionRequest(&input_message); + +- send_ready_for_query = true; +- } +- break; ++ /* commit the function-invocation transaction */ ++ finish_xact_command(); + +- case PqMsg_Parse: +- { +- const char *stmt_name; +- const char *query_string; +- int numParams; +- Oid *paramTypes = NULL; ++ valgrind_report_error_query("fastpath function call"); + +- forbidden_in_wal_sender(firstchar); ++ send_ready_for_query = true; ++ break; + +- /* Set statement_timestamp() */ +- SetCurrentStatementStartTimestamp(); ++ case PqMsg_Close: ++ { ++ int close_type; ++ const char *close_target; + +- stmt_name = pq_getmsgstring(&input_message); +- query_string = pq_getmsgstring(&input_message); +- numParams = pq_getmsgint(&input_message, 2); +- if (numParams > 0) +- { +- paramTypes = palloc_array(Oid, numParams); +- for (int i = 0; i < numParams; i++) +- paramTypes[i] = pq_getmsgint(&input_message, 4); +- } +- pq_getmsgend(&input_message); ++ forbidden_in_wal_sender(firstchar); + +- exec_parse_message(query_string, stmt_name, +- paramTypes, numParams); ++ close_type = pq_getmsgbyte(&input_message); ++ close_target = pq_getmsgstring(&input_message); ++ pq_getmsgend(&input_message); ++ ++ switch (close_type) ++ { ++ case 'S': ++ if (close_target[0] != '\0') ++ DropPreparedStatement(close_target, false); ++ else ++ { ++ /* special-case the unnamed statement */ ++ drop_unnamed_stmt(); ++ } ++ break; ++ case 'P': ++ { ++ Portal portal; + +- valgrind_report_error_query(query_string); ++ portal = GetPortalByName(close_target); ++ if (PortalIsValid(portal)) ++ PortalDrop(portal, false); ++ } ++ break; ++ default: ++ ereport(ERROR, ++ (errcode(ERRCODE_PROTOCOL_VIOLATION), ++ errmsg("invalid CLOSE message subtype %d", ++ close_type))); ++ break; + } +- break; + +- case PqMsg_Bind: ++ if (whereToSendOutput == DestRemote) ++ pq_putemptymessage(PqMsg_CloseComplete); ++ ++ valgrind_report_error_query("CLOSE message"); ++ } ++ break; ++ ++ case PqMsg_Describe: ++ { ++ int describe_type; ++ const char *describe_target; ++ + forbidden_in_wal_sender(firstchar); + +- /* Set statement_timestamp() */ ++ /* Set statement_timestamp() (needed for xact) */ + SetCurrentStatementStartTimestamp(); + +- /* +- * this message is complex enough that it seems best to put +- * the field extraction out-of-line +- */ +- exec_bind_message(&input_message); +- +- /* exec_bind_message does valgrind_report_error_query */ +- break; ++ describe_type = pq_getmsgbyte(&input_message); ++ describe_target = pq_getmsgstring(&input_message); ++ pq_getmsgend(&input_message); + +- case PqMsg_Execute: ++ switch (describe_type) + { +- const char *portal_name; +- int max_rows; ++ case 'S': ++ exec_describe_statement_message(describe_target); ++ break; ++ case 'P': ++ exec_describe_portal_message(describe_target); ++ break; ++ default: ++ ereport(ERROR, ++ (errcode(ERRCODE_PROTOCOL_VIOLATION), ++ errmsg("invalid DESCRIBE message subtype %d", ++ describe_type))); ++ break; ++ } + +- forbidden_in_wal_sender(firstchar); ++ valgrind_report_error_query("DESCRIBE message"); ++ } ++ break; + +- /* Set statement_timestamp() */ +- SetCurrentStatementStartTimestamp(); ++ case PqMsg_Flush: ++ pq_getmsgend(&input_message); ++ if (whereToSendOutput == DestRemote) ++ pq_flush(); ++ break; + +- portal_name = pq_getmsgstring(&input_message); +- max_rows = pq_getmsgint(&input_message, 4); +- pq_getmsgend(&input_message); ++ case PqMsg_Sync: ++ pq_getmsgend(&input_message); + +- exec_execute_message(portal_name, max_rows); ++ /* ++ * If pipelining was used, we may be in an implicit ++ * transaction block. Close it before calling ++ * finish_xact_command. ++ */ ++ EndImplicitTransactionBlock(); ++ finish_xact_command(); ++ valgrind_report_error_query("SYNC message"); ++ send_ready_for_query = true; ++ break; + +- /* exec_execute_message does valgrind_report_error_query */ +- } +- break; ++ /* ++ * PqMsg_Terminate means that the frontend is closing down the ++ * socket. EOF means unexpected loss of frontend connection. ++ * Either way, perform normal shutdown. ++ */ ++ case EOF: + +- case PqMsg_FunctionCall: +- forbidden_in_wal_sender(firstchar); ++ /* for the cumulative statistics system */ ++ pgStatSessionEndCause = DISCONNECT_CLIENT_EOF; + +- /* Set statement_timestamp() */ +- SetCurrentStatementStartTimestamp(); ++ /* FALLTHROUGH */ + +- /* Report query to various monitoring facilities. */ +- pgstat_report_activity(STATE_FASTPATH, NULL); +- set_ps_display(""); ++ case PqMsg_Terminate: + +- /* start an xact for this function invocation */ +- start_xact_command(); ++ /* ++ * Reset whereToSendOutput to prevent ereport from attempting ++ * to send any more messages to client. ++ */ ++ if (whereToSendOutput == DestRemote) ++ whereToSendOutput = DestNone; + +- /* +- * Note: we may at this point be inside an aborted +- * transaction. We can't throw error for that until we've +- * finished reading the function-call message, so +- * HandleFunctionRequest() must check for it after doing so. +- * Be careful not to do anything that assumes we're inside a +- * valid transaction here. +- */ ++ /* ++ * NOTE: if you are tempted to add more code here, DON'T! ++ * Whatever you had in mind to do should be set up as an ++ * on_proc_exit or on_shmem_exit callback, instead. Otherwise ++ * it will fail to be called during other backend-shutdown ++ * scenarios. ++ */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ if (is_oliphaunt_active != 0) ++ exit(OLIPHAUNT_WASM_EXIT_ALIVE); ++#endif + +- /* switch back to message context */ +- MemoryContextSwitchTo(MessageContext); ++ proc_exit(0); + +- HandleFunctionRequest(&input_message); ++ case PqMsg_CopyData: ++ case PqMsg_CopyDone: ++ case PqMsg_CopyFail: + +- /* commit the function-invocation transaction */ +- finish_xact_command(); ++ /* ++ * Accept but ignore these messages, per protocol spec; we ++ * probably got here because a COPY failed, and the frontend ++ * is still sending data. ++ */ ++ break; + +- valgrind_report_error_query("fastpath function call"); ++ default: ++ ereport(FATAL, ++ (errcode(ERRCODE_PROTOCOL_VIOLATION), ++ errmsg("invalid frontend message type %d", ++ firstchar))); ++ } ++} + +- send_ready_for_query = true; +- break; + +- case PqMsg_Close: +- { +- int close_type; +- const char *close_target; ++/* ---------------------------------------------------------------- ++ * PostgresMain ++ * postgres main loop -- all backends, interactive or otherwise loop here ++ * ++ * dbname is the name of the database to connect to, username is the ++ * PostgreSQL user name to be used for the session. ++ * ++ * NB: Single user mode specific setup should go to PostgresSingleUserMain() ++ * if reasonably possible. ++ * ---------------------------------------------------------------- ++ */ ++void ++PostgresMain(const char *dbname, const char *username) ++{ ++#ifndef OLIPHAUNT_WASM_SINGLE_USER ++ sigjmp_buf local_sigjmp_buf; ++#endif + +- forbidden_in_wal_sender(firstchar); ++ Assert(dbname != NULL); ++ Assert(username != NULL); + +- close_type = pq_getmsgbyte(&input_message); +- close_target = pq_getmsgstring(&input_message); +- pq_getmsgend(&input_message); ++ send_ready_for_query = true; ++ idle_in_transaction_timeout_enabled = false; ++ idle_session_timeout_enabled = false; + +- switch (close_type) +- { +- case 'S': +- if (close_target[0] != '\0') +- DropPreparedStatement(close_target, false); +- else +- { +- /* special-case the unnamed statement */ +- drop_unnamed_stmt(); +- } +- break; +- case 'P': +- { +- Portal portal; +- +- portal = GetPortalByName(close_target); +- if (PortalIsValid(portal)) +- PortalDrop(portal, false); +- } +- break; +- default: +- ereport(ERROR, +- (errcode(ERRCODE_PROTOCOL_VIOLATION), +- errmsg("invalid CLOSE message subtype %d", +- close_type))); +- break; +- } ++ Assert(GetProcessingMode() == InitProcessing); + +- if (whereToSendOutput == DestRemote) +- pq_putemptymessage(PqMsg_CloseComplete); ++ /* ++ * Set up signal handlers. (InitPostmasterChild or InitStandaloneProcess ++ * has already set up BlockSig and made that the active signal mask.) ++ * ++ * Note that postmaster blocked all signals before forking child process, ++ * so there is no race condition whereby we might receive a signal before ++ * we have set up the handler. ++ * ++ * Also note: it's best not to use any signals that are SIG_IGNored in the ++ * postmaster. If such a signal arrives before we are able to change the ++ * handler to non-SIG_IGN, it'll get dropped. Instead, make a dummy ++ * handler in the postmaster to reserve the signal. (Of course, this isn't ++ * an issue for signals that are locally generated, such as SIGALRM and ++ * SIGPIPE.) ++ */ ++ if (am_walsender) ++ WalSndSignals(); ++ else ++ { ++ pqsignal(SIGHUP, SignalHandlerForConfigReload); ++ pqsignal(SIGINT, StatementCancelHandler); /* cancel current query */ ++ pqsignal(SIGTERM, die); /* cancel current query and exit */ + +- valgrind_report_error_query("CLOSE message"); +- } +- break; ++ /* ++ * In a postmaster child backend, replace SignalHandlerForCrashExit ++ * with quickdie, so we can tell the client we're dying. ++ * ++ * In a standalone backend, SIGQUIT can be generated from the keyboard ++ * easily, while SIGTERM cannot, so we make both signals do die() ++ * rather than quickdie(). ++ */ ++ if (IsUnderPostmaster) ++ pqsignal(SIGQUIT, quickdie); /* hard crash time */ ++ else ++ pqsignal(SIGQUIT, die); /* cancel current query and exit */ ++ InitializeTimeouts(); /* establishes SIGALRM handler */ + +- case PqMsg_Describe: +- { +- int describe_type; +- const char *describe_target; ++ /* ++ * Ignore failure to write to frontend. Note: if frontend closes ++ * connection, we will notice it and exit cleanly when control next ++ * returns to outer loop. This seems safer than forcing exit in the ++ * midst of output during who-knows-what operation... ++ */ ++ pqsignal(SIGPIPE, SIG_IGN); ++ pqsignal(SIGUSR1, procsignal_sigusr1_handler); ++ pqsignal(SIGUSR2, SIG_IGN); ++ pqsignal(SIGFPE, FloatExceptionHandler); + +- forbidden_in_wal_sender(firstchar); ++ /* ++ * Reset some signals that are accepted by postmaster but not by ++ * backend ++ */ ++ pqsignal(SIGCHLD, SIG_DFL); /* system() requires this on some ++ * platforms */ ++ } + +- /* Set statement_timestamp() (needed for xact) */ +- SetCurrentStatementStartTimestamp(); ++ /* Early initialization */ ++ BaseInit(); + +- describe_type = pq_getmsgbyte(&input_message); +- describe_target = pq_getmsgstring(&input_message); +- pq_getmsgend(&input_message); ++ /* We need to allow SIGINT, etc during the initial transaction */ ++ sigprocmask(SIG_SETMASK, &UnBlockSig, NULL); + +- switch (describe_type) +- { +- case 'S': +- exec_describe_statement_message(describe_target); +- break; +- case 'P': +- exec_describe_portal_message(describe_target); +- break; +- default: +- ereport(ERROR, +- (errcode(ERRCODE_PROTOCOL_VIOLATION), +- errmsg("invalid DESCRIBE message subtype %d", +- describe_type))); +- break; +- } ++ /* ++ * Generate a random cancel key, if this is a backend serving a ++ * connection. InitPostgres() will advertise it in shared memory. ++ */ ++ Assert(MyCancelKeyLength == 0); ++ if (whereToSendOutput == DestRemote) ++ { ++ int len; + +- valgrind_report_error_query("DESCRIBE message"); +- } +- break; ++ len = (MyProcPort == NULL || MyProcPort->proto >= PG_PROTOCOL(3, 2)) ++ ? MAX_CANCEL_KEY_LENGTH : 4; ++ if (!pg_strong_random(&MyCancelKey, len)) ++ { ++ ereport(ERROR, ++ (errcode(ERRCODE_INTERNAL_ERROR), ++ errmsg("could not generate random cancel key"))); ++ } ++ MyCancelKeyLength = len; ++ } + +- case PqMsg_Flush: +- pq_getmsgend(&input_message); +- if (whereToSendOutput == DestRemote) +- pq_flush(); +- break; ++ /* ++ * General initialization. ++ * ++ * NOTE: if you are tempted to add code in this vicinity, consider putting ++ * it inside InitPostgres() instead. In particular, anything that ++ * involves database access should be there, not here. ++ * ++ * Honor session_preload_libraries if not dealing with a WAL sender. ++ */ ++ InitPostgres(dbname, InvalidOid, /* database to connect to */ ++ username, InvalidOid, /* role to connect as */ ++ (!am_walsender) ? INIT_PG_LOAD_SESSION_LIBS : 0, ++ NULL); /* no out_dbname */ + +- case PqMsg_Sync: +- pq_getmsgend(&input_message); ++ /* ++ * If the PostmasterContext is still around, recycle the space; we don't ++ * need it anymore after InitPostgres completes. ++ */ ++ if (PostmasterContext) ++ { ++ MemoryContextDelete(PostmasterContext); ++ PostmasterContext = NULL; ++ } + +- /* +- * If pipelining was used, we may be in an implicit +- * transaction block. Close it before calling +- * finish_xact_command. +- */ +- EndImplicitTransactionBlock(); +- finish_xact_command(); +- valgrind_report_error_query("SYNC message"); +- send_ready_for_query = true; +- break; ++ SetProcessingMode(NormalProcessing); + +- /* +- * PqMsg_Terminate means that the frontend is closing down the +- * socket. EOF means unexpected loss of frontend connection. +- * Either way, perform normal shutdown. +- */ +- case EOF: ++ /* ++ * Now all GUC states are fully set up. Report them to client if ++ * appropriate. ++ */ ++ BeginReportingGUCOptions(); + +- /* for the cumulative statistics system */ +- pgStatSessionEndCause = DISCONNECT_CLIENT_EOF; ++ /* ++ * Also set up handler to log session end; we have to wait till now to be ++ * sure Log_disconnections has its final value. ++ */ ++ if (IsUnderPostmaster && Log_disconnections) ++ on_proc_exit(log_disconnections, 0); + +- /* FALLTHROUGH */ ++ pgstat_report_connect(MyDatabaseId); + +- case PqMsg_Terminate: ++ /* Perform initialization specific to a WAL sender process. */ ++ if (am_walsender) ++ InitWalSender(); + +- /* +- * Reset whereToSendOutput to prevent ereport from attempting +- * to send any more messages to client. +- */ +- if (whereToSendOutput == DestRemote) +- whereToSendOutput = DestNone; ++ /* ++ * Send this backend's cancellation info to the frontend. ++ */ ++ if (whereToSendOutput == DestRemote) ++ { ++ StringInfoData buf; ++ ++ Assert(MyCancelKeyLength > 0); ++ pq_beginmessage(&buf, PqMsg_BackendKeyData); ++ pq_sendint32(&buf, (int32) MyProcPid); ++ ++ pq_sendbytes(&buf, MyCancelKey, MyCancelKeyLength); ++ pq_endmessage(&buf); ++ /* Need not flush since ReadyForQuery will do it. */ ++ } ++ ++ /* Welcome banner for standalone case */ ++ if (whereToSendOutput == DestDebug) ++ printf("\nPostgreSQL stand-alone backend %s\n", PG_VERSION); ++ ++ /* ++ * Create the memory context we will use in the main loop. ++ * ++ * MessageContext is reset once per iteration of the main loop, ie, upon ++ * completion of processing of each command message from the client. ++ */ ++ MessageContext = AllocSetContextCreate(TopMemoryContext, ++ "MessageContext", ++ ALLOCSET_DEFAULT_SIZES); ++ ++ /* ++ * Create memory context and buffer used for RowDescription messages. As ++ * SendRowDescriptionMessage(), via exec_describe_statement_message(), is ++ * frequently executed for ever single statement, we don't want to ++ * allocate a separate buffer every time. ++ */ ++ row_description_context = AllocSetContextCreate(TopMemoryContext, ++ "RowDescriptionContext", ++ ALLOCSET_DEFAULT_SIZES); ++ MemoryContextSwitchTo(row_description_context); ++ initStringInfo(&row_description_buf); ++ MemoryContextSwitchTo(TopMemoryContext); ++ ++ /* Fire any defined login event triggers, if appropriate */ ++ EventTriggerOnLogin(); ++ ++ /* ++ * POSTGRES main processing loop begins here ++ * ++ * If an exception is encountered, processing resumes here so we abort the ++ * current transaction and start a new one. ++ * ++ * You might wonder why this isn't coded as an infinite loop around a ++ * PG_TRY construct. The reason is that this is the bottom of the ++ * exception stack, and so with PG_TRY there would be no exception handler ++ * in force at all during the CATCH part. By leaving the outermost setjmp ++ * always active, we have at least some chance of recovering from an error ++ * during error recovery. (If we get into an infinite loop thereby, it ++ * will soon be stopped by overflow of elog.c's internal state stack.) ++ * ++ * Note that we use sigsetjmp(..., 1), so that this function's signal mask ++ * (to wit, UnBlockSig) will be restored when longjmp'ing to here. This ++ * is essential in case we longjmp'd out of a signal handler on a platform ++ * where that leaves the signal blocked. It's not redundant with the ++ * unblock in AbortTransaction() because the latter is only called if we ++ * were inside a transaction. ++ */ + +- /* +- * NOTE: if you are tempted to add more code here, DON'T! +- * Whatever you had in mind to do should be set up as an +- * on_proc_exit or on_shmem_exit callback, instead. Otherwise +- * it will fail to be called during other backend-shutdown +- * scenarios. +- */ + #ifdef OLIPHAUNT_WASM_SINGLE_USER +- if (is_oliphaunt_active != 0) +- exit(OLIPHAUNT_WASM_EXIT_ALIVE); ++ if (sigsetjmp(postgresmain_sigjmp_buf, 1) != 0) ++#else ++ if (sigsetjmp(local_sigjmp_buf, 1) != 0) + #endif ++ { ++ PostgresMainLongJmp(); ++ } + +- proc_exit(0); ++ /* We can now handle ereport(ERROR) */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ PG_exception_stack = &postgresmain_sigjmp_buf; ++#else ++ PG_exception_stack = &local_sigjmp_buf; ++#endif + +- case PqMsg_CopyData: +- case PqMsg_CopyDone: +- case PqMsg_CopyFail: ++ if (!ignore_till_sync) ++ send_ready_for_query = true; /* initially, or after error */ + +- /* +- * Accept but ignore these messages, per protocol spec; we +- * probably got here because a COPY failed, and the frontend +- * is still sending data. +- */ +- break; ++ /* ++ * Non-error queries loop here. ++ */ + +- default: +- ereport(FATAL, +- (errcode(ERRCODE_PROTOCOL_VIOLATION), +- errmsg("invalid frontend message type %d", +- firstchar))); +- } +- } /* end of input-reading loop */ ++ for (;;) ++ PostgresMainLoopOnce(); /* end of input-reading loop */ + } + + /* +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index 569a589..b8d22ca 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -7,10 +7,12 @@ + #define PG_PORT_WASIX_DL_H + + #ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include + #include + + struct Port; + ++extern sigjmp_buf postgresmain_sigjmp_buf; + extern ssize_t oliphaunt_wasix_host_read(void *context, void *ptr, size_t len); + extern ssize_t oliphaunt_wasix_host_write(void *context, const void *ptr, size_t len); + extern void oliphaunt_wasix_process_startup_options(struct Port *port); +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0006-oliphaunt-wasix-report-copy-protocol-state.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0006-oliphaunt-wasix-report-copy-protocol-state.patch new file mode 100644 index 00000000..ef805760 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0006-oliphaunt-wasix-report-copy-protocol-state.patch @@ -0,0 +1,138 @@ +From 0000000000000000000000000000000000000006 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: report COPY protocol state + +Report PostgreSQL COPY response transitions to the Oliphaunt WASIX host. + +The Rust proxy can batch ordinary FE/BE protocol messages through the buffered +single-user pump, but COPY is a subprotocol whose next bytes are owned by +PostgreSQL. The host therefore needs to know exactly when PostgreSQL has +emitted CopyInResponse, CopyOutResponse, or CopyBothResponse so it can switch +the direct transport to streaming without guessing from frontend SQL text. + +This patch does not change COPY execution. It adds guarded notifications next +to the existing protocol response messages and keeps the ABI declaration in the +wasix-dl port header. +--- + src/backend/commands/copyfromparse.c | 6 ++++++ + src/backend/commands/copyto.c | 6 ++++++ + src/backend/replication/walsender.c | 12 ++++++++++++ + src/include/port/wasix-dl.h | 6 ++++++ + 4 files changed, 30 insertions(+) + +diff --git a/src/backend/commands/copyfromparse.c b/src/backend/commands/copyfromparse.c +index 97a4c387a3..0f0eb63c8c 100644 +--- a/src/backend/commands/copyfromparse.c ++++ b/src/backend/commands/copyfromparse.c +@@ -66,6 +66,9 @@ + #include "libpq/libpq.h" + #include "libpq/pqformat.h" + #include "mb/pg_wchar.h" ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include "port/wasix-dl.h" ++#endif + #include "miscadmin.h" + #include "pgstat.h" + #include "port/pg_bswap.h" +@@ -174,6 +177,9 @@ ReceiveCopyBegin(CopyFromState cstate) + int16 format = (cstate->opts.binary ? 1 : 0); + int i; + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_IN); ++#endif + pq_beginmessage(&buf, PqMsg_CopyInResponse); + pq_sendbyte(&buf, format); /* overall format */ + pq_sendint16(&buf, natts); +diff --git a/src/backend/commands/copyto.c b/src/backend/commands/copyto.c +index 84dc465cba..705f42916f 100644 +--- a/src/backend/commands/copyto.c ++++ b/src/backend/commands/copyto.c +@@ -24,6 +24,9 @@ + #include "libpq/libpq.h" + #include "libpq/pqformat.h" + #include "mb/pg_wchar.h" ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include "port/wasix-dl.h" ++#endif + #include "miscadmin.h" + #include "pgstat.h" + #include "storage/fd.h" +@@ -394,6 +397,9 @@ SendCopyBegin(CopyToState cstate) + int16 format = (cstate->opts.binary ? 1 : 0); + int i; + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT); ++#endif + pq_beginmessage(&buf, PqMsg_CopyOutResponse); + pq_sendbyte(&buf, format); /* overall format */ + pq_sendint16(&buf, natts); +diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c +index ff1c357870..a4608ae247 100644 +--- a/src/backend/replication/walsender.c ++++ b/src/backend/replication/walsender.c +@@ -61,6 +61,9 @@ + #include "libpq/pqformat.h" + #include "miscadmin.h" + #include "nodes/replnodes.h" ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include "port/wasix-dl.h" ++#endif + #include "pgstat.h" + #include "postmaster/interrupt.h" + #include "replication/decode.h" +@@ -686,6 +689,9 @@ UploadManifest(void) + ib = CreateIncrementalBackupInfo(mcxt); + + /* Send a CopyInResponse message */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_IN); ++#endif + pq_beginmessage(&buf, PqMsg_CopyInResponse); + pq_sendbyte(&buf, 0); + pq_sendint16(&buf, 0); +@@ -940,6 +946,9 @@ StartReplication(StartReplicationCmd *cmd) + WalSndSetState(WALSNDSTATE_CATCHUP); + + /* Send a CopyBothResponse message, and start streaming */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH); ++#endif + pq_beginmessage(&buf, PqMsg_CopyBothResponse); + pq_sendbyte(&buf, 0); + pq_sendint16(&buf, 0); +@@ -1487,6 +1496,9 @@ StartLogicalReplication(StartReplicationCmd *cmd) + WalSndSetState(WALSNDSTATE_CATCHUP); + + /* Send a CopyBothResponse message, and start streaming */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH); ++#endif + pq_beginmessage(&buf, PqMsg_CopyBothResponse); + pq_sendbyte(&buf, 0); + pq_sendint16(&buf, 0); +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index 70e2debc5c..7754c1eef9 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -10,10 +10,16 @@ + + struct Port; + ++#define OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE 0 ++#define OLIPHAUNT_WASIX_PROTOCOL_COPY_IN 1 ++#define OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT 2 ++#define OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH 3 ++ + extern sigjmp_buf postgresmain_sigjmp_buf; + extern ssize_t oliphaunt_wasix_host_read(void *context, void *ptr, size_t len); + extern ssize_t oliphaunt_wasix_host_write(void *context, const void *ptr, size_t len); + extern void oliphaunt_wasix_process_startup_options(struct Port *port); ++extern void oliphaunt_wasix_protocol_report_copy_response(int state); + #endif + + #endif +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch new file mode 100644 index 00000000..9d93beb7 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch @@ -0,0 +1,114 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add wasix PGXS side-module support + +Add the platform makefile that configure expects for the `wasix-dl` +template and teach PGXS installs how to emit extension import lists for +WASIX side modules. + +This is deliberately separated from the backend build spine. The backend +patch only links the embedded PostgreSQL module. This patch owns the +extension/tooling contract: + +* `src/Makefile.port` can be linked from `src/makefiles/Makefile.wasix-dl`, +* extension side modules build as unversioned `.so` files, and +* `make install` writes per-extension import lists into the Oliphaunt WASIX + include layout using `DESTDIR` like the rest of PGXS installation. + +The import-list generation uses a WASIX-named `WASM_DL_NM` tool variable +rather than inheriting Emscripten variable names. +--- + src/makefiles/Makefile.wasix-dl | 20 ++++++++++++++++++++ + src/makefiles/pgxs.mk | 21 +++++++++++++++++++++ + 2 files changed, 41 insertions(+) + create mode 100644 src/makefiles/Makefile.wasix-dl + +diff --git a/src/makefiles/Makefile.wasix-dl b/src/makefiles/Makefile.wasix-dl +new file mode 100644 +index 0000000000..cb23b1ff25 +--- /dev/null ++++ b/src/makefiles/Makefile.wasix-dl +@@ -0,0 +1,20 @@ ++#------------------------------------------------------------------------- ++# ++# src/makefiles/Makefile.wasix-dl ++# Platform makefile for Oliphaunt WASIX dynamic modules. ++# ++#------------------------------------------------------------------------- ++ ++# WebAssembly side modules are loaded by name, not by ELF-style soname. ++rpath = ++AROPT = crs ++ ++WASM_DL_NM ?= wasixnm ++ ++# Rule for building a shared library from a single .o file. ++%.so: %.o ++ $(CC) $(CFLAGS) $< $(LDFLAGS) $(LDFLAGS_SL) -shared -o $@ ++ ++wasm_dl_include_dir := $(pkgincludedir)/wasix-dl ++wasm_dl_extension_dir := $(wasm_dl_include_dir)/extension ++wasm_dl_extension_imports_dir := $(wasm_dl_extension_dir)/imports +diff --git a/src/makefiles/pgxs.mk b/src/makefiles/pgxs.mk +index b9cf0a3c3c..123f938adb 100644 +--- a/src/makefiles/pgxs.mk ++++ b/src/makefiles/pgxs.mk +@@ -251,6 +251,12 @@ ifeq ($(with_llvm), yes) + $(foreach mod, $(MODULES), $(call install_llvm_module,$(mod),$(mod).bc)) + endif # with_llvm + endif # MODULES ++ifeq ($(PORTNAME), wasix-dl) ++ifneq (,$(MODULES)) ++ $(MKDIR_P) '$(DESTDIR)$(wasm_dl_extension_imports_dir)' ++ for mod in $(MODULES); do $(WASM_DL_NM) --undefined-only "$$mod.o" | awk '{print $$2}' | sed '/^$$/d' | sort -u > "$$mod.undef.txt"; $(WASM_DL_NM) --defined-only "$$mod.o" "$$mod$(DLSUFFIX)" | awk '$$2 ~ /^[TDB]$$/ {print $$3}' | sed '/^$$/d' | sort -u > "$$mod.defs.txt"; comm -23 "$$mod.undef.txt" "$$mod.defs.txt" > '$(DESTDIR)$(wasm_dl_extension_imports_dir)'/"$$mod.imports"; done ++endif # MODULES ++endif # PORTNAME=wasix-dl + ifdef DOCS + ifdef docdir + $(INSTALL_DATA) $(addprefix $(srcdir)/, $(DOCS)) '$(DESTDIR)$(docdir)/$(docmoduledir)/' +@@ -276,6 +282,12 @@ ifdef MODULE_big + ifeq ($(with_llvm), yes) + $(call install_llvm_module,$(MODULE_big),$(OBJS)) + endif # with_llvm ++ifeq ($(PORTNAME), wasix-dl) ++ifneq (,$(MODULE_big)) ++ $(MKDIR_P) '$(DESTDIR)$(wasm_dl_extension_imports_dir)' ++ find . -name "*.o" -exec $(WASM_DL_NM) --undefined-only {} \; | awk '{print $$2}' | sed '/^$$/d' | sort -u > '$(MODULE_big).undef.txt'; find . -type f \( -name "*.o" -o -name "*$(DLSUFFIX)" \) -exec $(WASM_DL_NM) --defined-only {} \; | awk '$$2 ~ /^[TDB]$$/ {print $$3}' | sed '/^$$/d' | sort -u > '$(MODULE_big).defs.txt'; comm -23 '$(MODULE_big).undef.txt' '$(MODULE_big).defs.txt' > '$(DESTDIR)$(wasm_dl_extension_imports_dir)/$(MODULE_big).imports' ++endif # MODULE_big ++endif # PORTNAME=wasix-dl + + install: install-lib + endif # MODULE_big +@@ -306,6 +318,9 @@ endif # DOCS + ifneq (,$(PROGRAM)$(SCRIPTS)$(SCRIPTS_built)) + $(MKDIR_P) '$(DESTDIR)$(bindir)' + endif ++ifeq ($(PORTNAME), wasix-dl) ++ $(MKDIR_P) '$(DESTDIR)$(wasm_dl_extension_imports_dir)' ++endif # PORTNAME=wasix-dl + + ifdef MODULE_big + installdirs: installdirs-lib +@@ -330,6 +345,9 @@ ifdef MODULES + ifeq ($(with_llvm), yes) + $(foreach mod, $(MODULES), $(call uninstall_llvm_module,$(mod))) + endif # with_llvm ++ifeq ($(PORTNAME), wasix-dl) ++ for mod in $(MODULES); do rm -f '$(DESTDIR)$(wasm_dl_extension_imports_dir)'/"$$mod.imports"; done ++endif # PORTNAME=wasix-dl + endif # MODULES + ifdef DOCS + rm -f $(addprefix '$(DESTDIR)$(docdir)/$(docmoduledir)'/, $(DOCS)) +@@ -355,6 +374,9 @@ ifdef MODULE_big + ifeq ($(with_llvm), yes) + $(call uninstall_llvm_module,$(MODULE_big)) + endif # with_llvm ++ifeq ($(PORTNAME), wasix-dl) ++ rm -f '$(DESTDIR)$(wasm_dl_extension_imports_dir)/$(MODULE_big).imports' ++endif # PORTNAME=wasix-dl + + uninstall: uninstall-lib + endif # MODULE_big +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch new file mode 100644 index 00000000..05ffef76 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch @@ -0,0 +1,38 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: reset copy state on error recovery + +Top-level PostgreSQL error recovery aborts any active COPY subprotocol. +Publish that boundary to the WASIX host before emitting the ErrorResponse so +the host-side transport state does not treat the recovered backend as still +being inside a PostgreSQL-owned COPY exchange. + +This does not switch the active transport back to buffered mode. The host +still owns transport restoration after a streaming handoff; PostgreSQL only +reports that its COPY state is no longer active. +--- + src/backend/tcop/postgres.c | 8 ++++++++ + 1 file changed, 8 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index 8017e5e97e..bb7f33f100 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -4478,6 +4478,14 @@ PostgresMainLongJmp(void) + /* Make sure libpq is in a good state */ + pq_comm_reset(); + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ /* ++ * A top-level ERROR aborts COPY. Keep the host-side protocol handoff ++ * state aligned before the ErrorResponse is written. ++ */ ++ oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE); ++#endif ++ + /* Report the error to the client and/or server log */ + EmitErrorReport(); + +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0009-oliphaunt-wasix-route-process-identity-through-port.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0009-oliphaunt-wasix-route-process-identity-through-port.patch new file mode 100644 index 00000000..339d7e24 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0009-oliphaunt-wasix-route-process-identity-through-port.patch @@ -0,0 +1,53 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: route process identity through port + +The embedded WASIX runtime has one synthetic PostgreSQL process identity. +Make that port behavior explicit in `wasix-dl.h` instead of relying on +configure-script-wide `-Dgeteuid=...` flags. + +The remap includes `getpwuid_r` because PostgreSQL frontend support code and +libpq use it for home-directory lookup. Keeping these mappings in the port +header makes the runtime identity contract visible in the PostgreSQL patch +stack while still preserving normal PostgreSQL call sites. +--- + src/include/port/wasix-dl.h | 15 +++++++++++++++ + 1 file changed, 15 insertions(+) + +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index 6252eecbfd..6c1c7b895f 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -8,6 +8,7 @@ + + #ifdef OLIPHAUNT_WASM_SINGLE_USER + #include ++#include + #include + + struct Port; +@@ -21,7 +22,21 @@ extern sigjmp_buf postgresmain_sigjmp_buf; + extern ssize_t oliphaunt_wasix_host_read(void *context, void *ptr, size_t len); + extern ssize_t oliphaunt_wasix_host_write(void *context, const void *ptr, size_t len); + extern void oliphaunt_wasix_process_startup_options(struct Port *port); ++extern uid_t oliphaunt_wasix_geteuid(void); ++extern uid_t oliphaunt_wasix_getuid(void); ++extern gid_t oliphaunt_wasix_getegid(void); ++extern gid_t oliphaunt_wasix_getgid(void); ++extern struct passwd *oliphaunt_wasix_getpwuid(uid_t uid); ++extern int oliphaunt_wasix_getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, ++ struct passwd **result); + extern void oliphaunt_wasix_protocol_report_copy_response(int state); ++ ++#define geteuid oliphaunt_wasix_geteuid ++#define getuid oliphaunt_wasix_getuid ++#define getegid oliphaunt_wasix_getegid ++#define getgid oliphaunt_wasix_getgid ++#define getpwuid oliphaunt_wasix_getpwuid ++#define getpwuid_r oliphaunt_wasix_getpwuid_r + #endif + + #endif +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch new file mode 100644 index 00000000..20b0c339 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch @@ -0,0 +1,137 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: route sysv shmem through port + +The embedded WASIX runtime emulates PostgreSQL's fixed shared-memory +segment in the host bridge. Make that PostgreSQL port behavior explicit in +`wasix-dl.h` and provide local SysV shared-memory header declarations for +toolchains that do not ship those interfaces. + +This removes another PG18 configure-script-wide syscall remap. PostgreSQL +source still calls `shmget`, `shmat`, `shmdt`, and `shmctl`; the `wasix-dl` +port owns the mapping to the embedded runtime implementation. +--- + src/include/port/wasix-dl.h | 12 ++++++++++++ + src/include/port/wasix-dl/sys/ipc.h | 37 +++++++++++++++++++++++++++++++++++++ + src/include/port/wasix-dl/sys/shm.h | 30 ++++++++++++++++++++++++++++++ + 3 files changed, 79 insertions(+) + create mode 100644 src/include/port/wasix-dl/sys/ipc.h + create mode 100644 src/include/port/wasix-dl/sys/shm.h + +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index c37ccbb546..5cfa325b50 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -10,6 +10,8 @@ + #include + #include + #include ++#include ++#include + + struct Port; + +@@ -29,6 +31,10 @@ extern gid_t oliphaunt_wasix_getgid(void); + extern struct passwd *oliphaunt_wasix_getpwuid(uid_t uid); + extern int oliphaunt_wasix_getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, + struct passwd **result); ++extern int oliphaunt_wasix_shmget(key_t key, size_t size, int shmflg); ++extern void *oliphaunt_wasix_shmat(int shmid, const void *shmaddr, int shmflg); ++extern int oliphaunt_wasix_shmdt(const void *shmaddr); ++extern int oliphaunt_wasix_shmctl(int shmid, int cmd, struct shmid_ds *buf); + extern void oliphaunt_wasix_protocol_report_copy_response(int state); + + #define geteuid oliphaunt_wasix_geteuid +@@ -37,6 +43,10 @@ extern void oliphaunt_wasix_protocol_report_copy_response(int state); + #define getgid oliphaunt_wasix_getgid + #define getpwuid oliphaunt_wasix_getpwuid + #define getpwuid_r oliphaunt_wasix_getpwuid_r ++#define shmget oliphaunt_wasix_shmget ++#define shmat oliphaunt_wasix_shmat ++#define shmdt oliphaunt_wasix_shmdt ++#define shmctl oliphaunt_wasix_shmctl + #endif + + #endif +diff --git a/src/include/port/wasix-dl/sys/ipc.h b/src/include/port/wasix-dl/sys/ipc.h +new file mode 100644 +index 0000000000..0872fdb47f +--- /dev/null ++++ b/src/include/port/wasix-dl/sys/ipc.h +@@ -0,0 +1,37 @@ ++#pragma once ++ ++#include ++ ++#ifndef IPC_PRIVATE ++#define IPC_PRIVATE ((key_t) 0) ++#endif ++#ifndef IPC_CREAT ++#define IPC_CREAT 01000 ++#endif ++#ifndef IPC_EXCL ++#define IPC_EXCL 02000 ++#endif ++#ifndef IPC_NOWAIT ++#define IPC_NOWAIT 04000 ++#endif ++ ++#ifndef IPC_RMID ++#define IPC_RMID 0 ++#endif ++#ifndef IPC_SET ++#define IPC_SET 1 ++#endif ++#ifndef IPC_STAT ++#define IPC_STAT 2 ++#endif ++ ++struct ipc_perm ++{ ++ key_t __key; ++ uid_t uid; ++ gid_t gid; ++ uid_t cuid; ++ gid_t cgid; ++ mode_t mode; ++ unsigned short __seq; ++}; +diff --git a/src/include/port/wasix-dl/sys/shm.h b/src/include/port/wasix-dl/sys/shm.h +new file mode 100644 +index 0000000000..91f59d9e39 +--- /dev/null ++++ b/src/include/port/wasix-dl/sys/shm.h +@@ -0,0 +1,30 @@ ++#pragma once ++ ++#include ++#include ++#include ++ ++#ifndef SHM_RDONLY ++#define SHM_RDONLY 010000 ++#endif ++#ifndef SHM_RND ++#define SHM_RND 020000 ++#endif ++#ifndef SHMLBA ++#define SHMLBA 4096 ++#endif ++ ++struct shmid_ds ++{ ++ struct ipc_perm shm_perm; ++ size_t shm_segsz; ++ time_t shm_atime; ++ time_t shm_dtime; ++ time_t shm_ctime; ++ unsigned long shm_nattch; ++}; ++ ++int shmget(key_t key, size_t size, int shmflg); ++void *shmat(int shmid, const void *shmaddr, int shmflg); ++int shmdt(const void *shmaddr); ++int shmctl(int shmid, int cmd, struct shmid_ds *buf); +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0011-oliphaunt-wasix-prefer-posix-semaphores.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0011-oliphaunt-wasix-prefer-posix-semaphores.patch new file mode 100644 index 00000000..104eb71d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0011-oliphaunt-wasix-prefer-posix-semaphores.patch @@ -0,0 +1,29 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: prefer POSIX semaphores + +The WASIX runtime should not accidentally select PostgreSQL's SysV +semaphore implementation while shared memory is being emulated in-process. +Prefer unnamed POSIX semaphores the same way PostgreSQL's Linux, FreeBSD, and +Cygwin templates do. +--- + src/template/wasix-dl | 5 +++++ + 1 file changed, 5 insertions(+) + +diff --git a/src/template/wasix-dl b/src/template/wasix-dl +index f3792d3e83..10477bdb8a 100644 +--- a/src/template/wasix-dl ++++ b/src/template/wasix-dl +@@ -11,4 +11,9 @@ DLSUFFIX=".so" + THREAD_CFLAGS="-pthread" + THREAD_LIBS="-pthread" + ++# Prefer the semaphore path already used by PostgreSQL's POSIX templates. ++if test x"$PREFERRED_SEMAPHORES" = x"" ; then ++ PREFERRED_SEMAPHORES=UNNAMED_POSIX ++fi ++ + # The linked wasm artifact is a standalone side module loaded by the Rust host. +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0012-oliphaunt-wasix-capture-startup-errors.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0012-oliphaunt-wasix-capture-startup-errors.patch new file mode 100644 index 00000000..5ab8b87c --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0012-oliphaunt-wasix-capture-startup-errors.patch @@ -0,0 +1,86 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: capture startup errors + +PostgreSQL can emit protocol ErrorResponse bytes from InitPostgres() before +the exported main-loop recovery buffer is installed. In the embedded WASIX +runtime that path still needs to be PostgreSQL-owned: database, role, and +startup-option failures should reach the host as PostgreSQL ErrorResponse +frames instead of collapsing into a generic process exit. + +Attach the host-backed protocol Port before InitPostgres() only while startup +error capture is active, route startup errors to DestRemote, and ask the bridge +to trap nonzero proc_exit() so the host can flush the captured protocol bytes. +Normal successful startup restores the previous output destination and leaves +the later oliphaunt_wasix_start()/oliphaunt_wasix_send_conn_data() lifecycle unchanged. +--- + src/backend/tcop/postgres.c | 35 +++++++++++++++++++++++++++++++++++ + src/include/port/wasix-dl.h | 1 + + 2 files changed, 36 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index b2e2753f7c..a835d2c6dd 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -281,3 +281,23 @@ oliphaunt_wasix_send_conn_data(void) + ReadyForQuery(DestRemote); + } ++static CommandDest oliphaunt_wasix_startup_error_saved_dest = DestDebug; ++ ++static void ++oliphaunt_wasix_begin_startup_error_capture(void) ++{ ++ if (MyProcPort == NULL) ++ oliphaunt_wasix_init_protocol_port(); ++ oliphaunt_wasix_startup_error_saved_dest = whereToSendOutput; ++ oliphaunt_wasix_startup_error_capture_active = 1; ++ whereToSendOutput = DestRemote; ++} ++ ++static void ++oliphaunt_wasix_end_startup_error_capture(void) ++{ ++ oliphaunt_wasix_startup_error_capture_active = 0; ++ if (whereToSendOutput == DestRemote) ++ whereToSendOutput = oliphaunt_wasix_startup_error_saved_dest; ++} ++ + #else +@@ -5051,10 +5071,22 @@ PostgresMain(const char *dbname, const char *username) + * + * Honor session_preload_libraries if not dealing with a WAL sender. + */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ /* ++ * If InitPostgres() fails before the exported top-level recovery buffer is ++ * active, keep the failure on PostgreSQL's wire-protocol path. The bridge ++ * traps nonzero proc_exit() while this flag is set so the host can collect ++ * the ErrorResponse bytes already written to the host-backed Port. ++ */ ++ oliphaunt_wasix_begin_startup_error_capture(); ++#endif + InitPostgres(dbname, InvalidOid, /* database to connect to */ + username, InvalidOid, /* role to connect as */ + (!am_walsender) ? INIT_PG_LOAD_SESSION_LIBS : 0, + NULL); /* no out_dbname */ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ oliphaunt_wasix_end_startup_error_capture(); ++#endif + + /* + * If the PostmasterContext is still around, recycle the space; we don't +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index e0e6dbab31..3b934a7d0f 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -23,6 +23,7 @@ extern sigjmp_buf postgresmain_sigjmp_buf; + extern ssize_t oliphaunt_wasix_host_read(void *context, void *ptr, size_t len); + extern ssize_t oliphaunt_wasix_host_write(void *context, const void *ptr, size_t len); + extern void oliphaunt_wasix_process_startup_options(struct Port *port); ++extern volatile int oliphaunt_wasix_startup_error_capture_active; + extern uid_t oliphaunt_wasix_geteuid(void); + extern uid_t oliphaunt_wasix_getuid(void); + extern gid_t oliphaunt_wasix_getegid(void); +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch new file mode 100644 index 00000000..939b602f --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch @@ -0,0 +1,60 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: fail active portals on host recovery + +Some WASIX hosts cannot preserve nested WebAssembly exception unwinds for every +PostgreSQL PG_TRY/PG_CATCH boundary. On those hosts, the bridge routes ERROR +longjmp through the exported top-level recovery path, which means an active +portal can reach transaction abort before its local PG_CATCH marks it failed. + +Keep that cleanup inside PostgreSQL: when the embedded WASIX backend is +active, treat active portals like the existing FATAL/shmem-exit path during +AtAbort_Portals(). Marking them failed prevents executor shutdown from running +against transaction-aborted state while preserving the normal portal abort and +drop lifecycle. +--- + src/backend/utils/mmgr/portalmem.c | 10 ++++++++++ + src/include/port/wasix-dl.h | 1 + + 2 files changed, 11 insertions(+) + +diff --git a/src/backend/utils/mmgr/portalmem.c b/src/backend/utils/mmgr/portalmem.c +index 6479012910..18e6155689 100644 +--- a/src/backend/utils/mmgr/portalmem.c ++++ b/src/backend/utils/mmgr/portalmem.c +@@ -25,6 +25,9 @@ + #include "utils/memutils.h" + #include "utils/snapmgr.h" + #include "utils/timestamp.h" ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++#include "port/wasix-dl.h" ++#endif + + /* + * Estimate of the maximum number of open portals a user would have, +@@ -795,6 +798,11 @@ AtAbort_Portals(void) + */ + if (portal->status == PORTAL_ACTIVE && shmem_exit_inprogress) + MarkPortalFailed(portal); ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ else if (portal->status == PORTAL_ACTIVE && ++ is_oliphaunt_active != 0) ++ MarkPortalFailed(portal); ++#endif + + /* + * Do nothing else to cursors held over from a previous transaction. +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index 3b934a7d0f..4e8bfb98b7 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -21,6 +21,7 @@ struct Port; + #define OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH 3 + + extern sigjmp_buf postgresmain_sigjmp_buf; ++extern volatile int is_oliphaunt_active; + extern ssize_t oliphaunt_wasix_host_read(void *context, void *ptr, size_t len); + extern ssize_t oliphaunt_wasix_host_write(void *context, const void *ptr, size_t len); + extern void oliphaunt_wasix_process_startup_options(struct Port *port); +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch new file mode 100644 index 00000000..dd304486 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch @@ -0,0 +1,183 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: speed up hash_bytes unaligned loads + +PostgreSQL's unaligned little-endian hash_bytes() path builds each uint32 from +four byte loads and shifts. On WASIX this is visible in guest CPU profiles for +hash-table-heavy query paths, and WebAssembly supports unaligned 32-bit loads. + +Use a tiny memcpy-based load helper for WASIX only. This keeps the C semantics +defined for unaligned input while giving clang/LLVM the pattern it can lower to +a single little-endian wasm load. Big-endian and non-WASIX builds keep the +upstream byte-by-byte path. +--- + src/common/hashfn.c | 114 ++++++++++++++++++++++++++++++++++++++++++++-------- + 1 file changed, 97 insertions(+), 17 deletions(-) + +diff --git a/src/common/hashfn.c b/src/common/hashfn.c +index 8a6bd816ff..31c8ac9ee8 100644 +--- a/src/common/hashfn.c ++++ b/src/common/hashfn.c +@@ -47,6 +47,17 @@ + + #define rot(x,k) pg_rotate_left32(x, k) + ++#if defined(__wasi__) && !defined(WORDS_BIGENDIAN) ++static inline uint32 ++oliphaunt_wasix_hash_load32(const unsigned char *ptr) ++{ ++ uint32 value; ++ ++ memcpy(&value, ptr, sizeof(value)); ++ return value; ++} ++#endif ++ + /*---------- + * mix -- mix 3 32-bit values reversibly. + * +@@ -265,9 +276,15 @@ hash_bytes(const unsigned char *k, int keylen) + b += (k[7] + ((uint32) k[6] << 8) + ((uint32) k[5] << 16) + ((uint32) k[4] << 24)); + c += (k[11] + ((uint32) k[10] << 8) + ((uint32) k[9] << 16) + ((uint32) k[8] << 24)); + #else /* !WORDS_BIGENDIAN */ ++#ifdef __wasi__ ++ a += oliphaunt_wasix_hash_load32(k); ++ b += oliphaunt_wasix_hash_load32(k + 4); ++ c += oliphaunt_wasix_hash_load32(k + 8); ++#else + a += (k[0] + ((uint32) k[1] << 8) + ((uint32) k[2] << 16) + ((uint32) k[3] << 24)); + b += (k[4] + ((uint32) k[5] << 8) + ((uint32) k[6] << 16) + ((uint32) k[7] << 24)); + c += (k[8] + ((uint32) k[9] << 8) + ((uint32) k[10] << 16) + ((uint32) k[11] << 24)); ++#endif + #endif /* WORDS_BIGENDIAN */ + mix(a, b, c); + k += 12; +@@ -314,6 +331,46 @@ hash_bytes(const unsigned char *k, int keylen) + /* case 0: nothing left to add */ + } + #else /* !WORDS_BIGENDIAN */ ++#ifdef __wasi__ ++ switch (len) ++ { ++ case 11: ++ c += ((uint32) k[10] << 24); ++ /* fall through */ ++ case 10: ++ c += ((uint32) k[9] << 16); ++ /* fall through */ ++ case 9: ++ c += ((uint32) k[8] << 8); ++ /* fall through */ ++ case 8: ++ /* the lowest byte of c is reserved for the length */ ++ b += oliphaunt_wasix_hash_load32(k + 4); ++ a += oliphaunt_wasix_hash_load32(k); ++ break; ++ case 7: ++ b += ((uint32) k[6] << 16); ++ /* fall through */ ++ case 6: ++ b += ((uint32) k[5] << 8); ++ /* fall through */ ++ case 5: ++ b += k[4]; ++ /* fall through */ ++ case 4: ++ a += oliphaunt_wasix_hash_load32(k); ++ break; ++ case 3: ++ a += ((uint32) k[2] << 16); ++ /* fall through */ ++ case 2: ++ a += ((uint32) k[1] << 8); ++ /* fall through */ ++ case 1: ++ a += k[0]; ++ /* case 0: nothing left to add */ ++ } ++#else + switch (len) + { + case 11: +@@ -351,6 +408,7 @@ hash_bytes(const unsigned char *k, int keylen) + a += k[0]; + /* case 0: nothing left to add */ + } ++#endif + #endif /* WORDS_BIGENDIAN */ + } + +@@ -504,9 +562,15 @@ hash_bytes_extended(const unsigned char *k, int keylen, uint64 seed) + b += (k[7] + ((uint32) k[6] << 8) + ((uint32) k[5] << 16) + ((uint32) k[4] << 24)); + c += (k[11] + ((uint32) k[10] << 8) + ((uint32) k[9] << 16) + ((uint32) k[8] << 24)); + #else /* !WORDS_BIGENDIAN */ ++#ifdef __wasi__ ++ a += oliphaunt_wasix_hash_load32(k); ++ b += oliphaunt_wasix_hash_load32(k + 4); ++ c += oliphaunt_wasix_hash_load32(k + 8); ++#else + a += (k[0] + ((uint32) k[1] << 8) + ((uint32) k[2] << 16) + ((uint32) k[3] << 24)); + b += (k[4] + ((uint32) k[5] << 8) + ((uint32) k[6] << 16) + ((uint32) k[7] << 24)); + c += (k[8] + ((uint32) k[9] << 8) + ((uint32) k[10] << 16) + ((uint32) k[11] << 24)); ++#endif + #endif /* WORDS_BIGENDIAN */ + mix(a, b, c); + k += 12; +@@ -553,6 +617,46 @@ hash_bytes_extended(const unsigned char *k, int keylen, uint64 seed) + /* case 0: nothing left to add */ + } + #else /* !WORDS_BIGENDIAN */ ++#ifdef __wasi__ ++ switch (len) ++ { ++ case 11: ++ c += ((uint32) k[10] << 24); ++ /* fall through */ ++ case 10: ++ c += ((uint32) k[9] << 16); ++ /* fall through */ ++ case 9: ++ c += ((uint32) k[8] << 8); ++ /* fall through */ ++ case 8: ++ /* the lowest byte of c is reserved for the length */ ++ b += oliphaunt_wasix_hash_load32(k + 4); ++ a += oliphaunt_wasix_hash_load32(k); ++ break; ++ case 7: ++ b += ((uint32) k[6] << 16); ++ /* fall through */ ++ case 6: ++ b += ((uint32) k[5] << 8); ++ /* fall through */ ++ case 5: ++ b += k[4]; ++ /* fall through */ ++ case 4: ++ a += oliphaunt_wasix_hash_load32(k); ++ break; ++ case 3: ++ a += ((uint32) k[2] << 16); ++ /* fall through */ ++ case 2: ++ a += ((uint32) k[1] << 8); ++ /* fall through */ ++ case 1: ++ a += k[0]; ++ /* case 0: nothing left to add */ ++ } ++#else + switch (len) + { + case 11: +@@ -590,6 +694,7 @@ hash_bytes_extended(const unsigned char *k, int keylen, uint64 seed) + a += k[0]; + /* case 0: nothing left to add */ + } ++#endif + #endif /* WORDS_BIGENDIAN */ + } + +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch new file mode 100644 index 00000000..3337c218 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch @@ -0,0 +1,43 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add top xid current transaction fast path + +TransactionIdIsCurrentTransactionId() is a hot visibility helper. In the +common single-backend top-level transaction case, once the top XID comparison +has failed there are no other current XIDs to discover unless parallel-worker +state, a parent subtransaction stack, or subcommitted child XIDs exist. + +Short-circuit that ordinary case before entering the generic stack walk. The +existing parallel and subtransaction paths are preserved for every case that +can still have another current XID. +--- + src/backend/access/transam/xact.c | 13 +++++++++++++ + 1 file changed, 13 insertions(+) + +diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c +index b885513e3e..7026efae20 100644 +--- a/src/backend/access/transam/xact.c ++++ b/src/backend/access/transam/xact.c +@@ -961,6 +961,19 @@ TransactionIdIsCurrentTransactionId(TransactionId xid) + if (TransactionIdEquals(xid, GetTopTransactionIdIfAny())) + return true; + ++ /* ++ * Fast path for the common top-level transaction case. Once the top XID ++ * comparison failed, there are no other current XIDs to find unless we are ++ * in a parallel worker, a subtransaction stack, or have subcommitted ++ * children. ++ */ ++ s = CurrentTransactionState; ++ if (nParallelCurrentXids == 0 && ++ s->parent == NULL && ++ s->state != TRANS_ABORT && ++ s->nChildXids == 0) ++ return false; ++ + /* + * In parallel workers, the XIDs we must consider as current are stored in + * ParallelCurrentXids rather than the transaction-state stack. Note that +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch new file mode 100644 index 00000000..af2d60f6 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch @@ -0,0 +1,78 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add btree int4 compare fast path + +WASIX makes PostgreSQL's fmgr indirect-call trampoline comparatively visible in +btree-heavy point lookup and update paths. The built-in int4 btree order proc +is a pure three-way integer comparison, so the embedded WASIX runtime can avoid +that trampoline when the relation metadata proves that the comparison is the +standard integer btree family with int4 input on both sides. + +Keep the optimization narrow: it is WASIX plus OLIPHAUNT_WASM_SINGLE_USER only, +still obtains tuple values through index_getattr(), requires the built-in +integer btree family and int4 opclass input type, requires int4/InvalidOid scan +subtype and InvalidOid collation, and falls back to PostgreSQL's upstream +FunctionCall2Coll path for every other case. +--- + src/backend/access/nbtree/nbtsearch.c | 35 +++++++++++++++++++++++++++++++---- + 1 file changed, 31 insertions(+), 4 deletions(-) + +diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c +index 3ad6f1f3b4..49c18b831e 100644 +--- a/src/backend/access/nbtree/nbtsearch.c ++++ b/src/backend/access/nbtree/nbtsearch.c +@@ -16,7 +16,9 @@ + #include "postgres.h" + + #include "access/nbtree.h" + #include "access/relscan.h" ++#include "catalog/pg_opfamily_d.h" ++#include "catalog/pg_type_d.h" + #include "access/xact.h" + #include "miscadmin.h" + #include "pgstat.h" +@@ -767,10 +769,37 @@ _bt_compare(Relation rel, + * to flip the sign of the comparison result. (Unless it's a DESC + * column, in which case we *don't* flip the sign.) + */ +- result = DatumGetInt32(FunctionCall2Coll(&scankey->sk_func, +- scankey->sk_collation, +- datum, +- scankey->sk_argument)); ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) ++ if (rel->rd_opfamily[i - 1] == INTEGER_BTREE_FAM_OID && ++ rel->rd_opcintype[i - 1] == INT4OID && ++ (scankey->sk_subtype == INT4OID || ++ scankey->sk_subtype == InvalidOid) && ++ scankey->sk_collation == InvalidOid) ++ { ++ int32 index_value = DatumGetInt32(datum); ++ int32 scan_value = DatumGetInt32(scankey->sk_argument); ++ ++ /* ++ * Avoid the fmgr trampoline only for PostgreSQL's built-in ++ * int4 btree comparison. The result is still "index datum ++ * compared with scan argument"; the existing DESC handling ++ * below preserves upstream _bt_compare() polarity. ++ */ ++ if (index_value > scan_value) ++ result = 1; ++ else if (index_value == scan_value) ++ result = 0; ++ else ++ result = -1; ++ } ++ else ++#endif ++ { ++ result = DatumGetInt32(FunctionCall2Coll(&scankey->sk_func, ++ scankey->sk_collation, ++ datum, ++ scankey->sk_argument)); ++ } + + if (!(scankey->sk_flags & SK_BT_DESC)) + INVERT_COMPARE_RESULT(result); +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch new file mode 100644 index 00000000..c4714746 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch @@ -0,0 +1,111 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: keep btree delete scratch on stack + +PostgreSQL btree simple deletion and bottom-up deletion use two page-local +scratch arrays sized by MaxTIDsPerBTreePage. The arrays are bounded by BLCKSZ +and only live for a single deletion pass, but upstream allocates and frees both +arrays for each pass. + +Keep upstream allocation behavior everywhere except the embedded WASIX +embedded WASIX runtime. In that lane, place the scratch arrays on the C stack to +avoid allocator traffic during indexed-update churn. This does not change +which tuples are considered, which tableam callback runs, or when physical +deletion is attempted. +--- + src/backend/access/nbtree/nbtdedup.c | 20 ++++++++++++++++++++ + src/backend/access/nbtree/nbtinsert.c | 19 +++++++++++++++++++ + 2 files changed, 39 insertions(+) + +diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c +index 53907b54a6..7b016cdb6e 100644 +--- a/src/backend/access/nbtree/nbtdedup.c ++++ b/src/backend/access/nbtree/nbtdedup.c +@@ -314,6 +314,10 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, + BTPageOpaque opaque = BTPageGetOpaque(page); + BTDedupState state; + TM_IndexDeleteOp delstate; ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) ++ TM_IndexDelete deltids[MaxTIDsPerBTreePage]; ++ TM_IndexStatus status[MaxTIDsPerBTreePage]; ++#endif + bool neverdedup; + int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel); + +@@ -355,8 +359,18 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, + delstate.bottomup = true; + delstate.bottomupfreespace = Max(BLCKSZ / 16, newitemsz); + delstate.ndeltids = 0; ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) ++ /* ++ * The delete state is page-local and bounded by BLCKSZ. Keep it off the ++ * allocator in the embedded WASIX backend, where these palloc/pfree pairs ++ * are visible during indexed-update churn. ++ */ ++ delstate.deltids = deltids; ++ delstate.status = status; ++#else + delstate.deltids = palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexDelete)); + delstate.status = palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexStatus)); ++#endif + + minoff = P_FIRSTDATAKEY(opaque); + maxoff = PageGetMaxOffsetNumber(page); +@@ -409,8 +423,10 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, + /* Ask tableam which TIDs are deletable, then physically delete them */ + _bt_delitems_delete_check(rel, buf, heapRel, &delstate); + ++#if !defined(__wasi__) || !defined(OLIPHAUNT_WASM_SINGLE_USER) + pfree(delstate.deltids); + pfree(delstate.status); ++#endif + + /* Report "success" to caller unconditionally to avoid deduplication */ + if (neverdedup) +diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c +index 0da44e1779..c006987ca9 100644 +--- a/src/backend/access/nbtree/nbtinsert.c ++++ b/src/backend/access/nbtree/nbtinsert.c +@@ -2817,6 +2817,10 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel, + BlockNumber *deadblocks; + int ndeadblocks; + TM_IndexDeleteOp delstate; ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) ++ TM_IndexDelete deltids[MaxTIDsPerBTreePage]; ++ TM_IndexStatus status[MaxTIDsPerBTreePage]; ++#endif + OffsetNumber offnum; + + /* Get array of table blocks pointed to by LP_DEAD-set tuples */ +@@ -2829,8 +2833,17 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel, + delstate.bottomup = false; + delstate.bottomupfreespace = 0; + delstate.ndeltids = 0; ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) ++ /* ++ * The scratch arrays are page-size bounded and used for this deletion ++ * pass only. Avoid allocator traffic in the embedded WASIX backend. ++ */ ++ delstate.deltids = deltids; ++ delstate.status = status; ++#else + delstate.deltids = palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexDelete)); + delstate.status = palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexStatus)); ++#endif + + for (offnum = minoff; + offnum <= maxoff; +@@ -2911,8 +2924,10 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel, + /* Physically delete LP_DEAD tuples (plus any delete-safe extra TIDs) */ + _bt_delitems_delete_check(rel, buffer, heapRel, &delstate); + ++#if !defined(__wasi__) || !defined(OLIPHAUNT_WASM_SINGLE_USER) + pfree(delstate.deltids); + pfree(delstate.status); ++#endif + } + + /* +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch new file mode 100644 index 00000000..e5a6f254 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch @@ -0,0 +1,161 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: avoid pg_dump executeQuery LTO collision + +The WASIX release profile builds standalone tools with thin LTO. pg_dump's +shared connect helper exposes a generic external symbol named executeQuery(), +which is unnecessarily collision-prone once LTO flattens objects and archives. + +Rename the helper to executeDumpQuery(). This is a tool-only source hygiene +patch: call sites, behavior, result handling, and failure paths stay unchanged. +--- + src/bin/pg_dump/connectdb.c | 6 +++--- + src/bin/pg_dump/connectdb.h | 2 +- + src/bin/pg_dump/pg_dumpall.c | 22 +++++++++++----------- + 3 files changed, 15 insertions(+), 15 deletions(-) + +diff --git a/src/bin/pg_dump/connectdb.c b/src/bin/pg_dump/connectdb.c +index d55d53da52..08033ecff6 100644 +--- a/src/bin/pg_dump/connectdb.c ++++ b/src/bin/pg_dump/connectdb.c +@@ -225,7 +225,7 @@ ConnectDatabase(const char *dbname, const char *connection_string, + exit_nicely(1); + } + +- PQclear(executeQuery(conn, ALWAYS_SECURE_SEARCH_PATH_SQL)); ++ PQclear(executeDumpQuery(conn, ALWAYS_SECURE_SEARCH_PATH_SQL)); + + return conn; + } +@@ -270,12 +270,12 @@ constructConnStr(const char **keywords, const char **values) + } + + /* +- * executeQuery ++ * executeDumpQuery + * + * Run a query, return the results, exit program on failure. + */ + PGresult * +-executeQuery(PGconn *conn, const char *query) ++executeDumpQuery(PGconn *conn, const char *query) + { + PGresult *res; + +diff --git a/src/bin/pg_dump/connectdb.h b/src/bin/pg_dump/connectdb.h +index 6c1e1958e6..6ccda90723 100644 +--- a/src/bin/pg_dump/connectdb.h ++++ b/src/bin/pg_dump/connectdb.h +@@ -22,5 +22,5 @@ extern PGconn *ConnectDatabase(const char *dbname, const char *connection_string + trivalue prompt_password, bool fail_on_error, + const char *progname, const char **connstr, int *server_version, + char *password, char *override_dbname); +-extern PGresult *executeQuery(PGconn *conn, const char *query); ++extern PGresult *executeDumpQuery(PGconn *conn, const char *query); + #endif /* CONNECTDB_H */ +diff --git a/src/bin/pg_dump/pg_dumpall.c b/src/bin/pg_dump/pg_dumpall.c +index cb6e5f49f8..0a0638d8fe 100644 +--- a/src/bin/pg_dump/pg_dumpall.c ++++ b/src/bin/pg_dump/pg_dumpall.c +@@ -792,7 +792,7 @@ dropRoles(PGconn *conn) + "FROM %s " + "ORDER BY 1", role_catalog); + +- res = executeQuery(conn, buf->data); ++ res = executeDumpQuery(conn, buf->data); + + i_rolname = PQfnumber(res, "rolname"); + +@@ -877,7 +877,7 @@ dumpRoles(PGconn *conn) + "FROM %s " + "ORDER BY 2", role_catalog); + +- res = executeQuery(conn, buf->data); ++ res = executeDumpQuery(conn, buf->data); + + i_oid = PQfnumber(res, "oid"); + i_rolname = PQfnumber(res, "rolname"); +@@ -1082,7 +1082,7 @@ dumpRoleMembership(PGconn *conn) + "LEFT JOIN %s ug on ug.oid = a.grantor " + "WHERE NOT (ur.rolname ~ '^pg_' AND um.rolname ~ '^pg_')" + "ORDER BY 1,2,3", role_catalog, role_catalog, role_catalog); +- res = executeQuery(conn, buf->data); ++ res = executeDumpQuery(conn, buf->data); + i_role = PQfnumber(res, "role"); + i_member = PQfnumber(res, "member"); + i_grantor = PQfnumber(res, "grantor"); +@@ -1297,7 +1297,7 @@ dumpRoleGUCPrivs(PGconn *conn) + /* + * Get all parameters that have non-default acls defined. + */ +- res = executeQuery(conn, "SELECT parname, " ++ res = executeDumpQuery(conn, "SELECT parname, " + "pg_catalog.pg_get_userbyid(" CppAsString2(BOOTSTRAP_SUPERUSERID) ") AS parowner, " + "paracl, " + "pg_catalog.acldefault('p', " CppAsString2(BOOTSTRAP_SUPERUSERID) ") AS acldefault " +@@ -1353,7 +1353,7 @@ dropTablespaces(PGconn *conn) + * Get all tablespaces except built-in ones (which we assume are named + * pg_xxx) + */ +- res = executeQuery(conn, "SELECT spcname " ++ res = executeDumpQuery(conn, "SELECT spcname " + "FROM pg_catalog.pg_tablespace " + "WHERE spcname !~ '^pg_' " + "ORDER BY 1"); +@@ -1388,7 +1388,7 @@ dumpTablespaces(PGconn *conn) + * Get all tablespaces except built-in ones (which we assume are named + * pg_xxx) + */ +- res = executeQuery(conn, "SELECT oid, spcname, " ++ res = executeDumpQuery(conn, "SELECT oid, spcname, " + "pg_catalog.pg_get_userbyid(spcowner) AS spcowner, " + "pg_catalog.pg_tablespace_location(oid), " + "spcacl, acldefault('t', spcowner) AS acldefault, " +@@ -1492,7 +1492,7 @@ dropDBs(PGconn *conn) + * Skip databases marked not datallowconn, since we'd be unable to connect + * to them anyway. This must agree with dumpDatabases(). + */ +- res = executeQuery(conn, ++ res = executeDumpQuery(conn, + "SELECT datname " + "FROM pg_database d " + "WHERE datallowconn AND datconnlimit != -2 " +@@ -1542,7 +1542,7 @@ dumpUserConfig(PGconn *conn, const char *username) + appendStringLiteralConn(buf, username, conn); + appendPQExpBufferChar(buf, ')'); + +- res = executeQuery(conn, buf->data); ++ res = executeDumpQuery(conn, buf->data); + + if (PQntuples(res) > 0) + { +@@ -1608,7 +1608,7 @@ expand_dbname_patterns(PGconn *conn, + exit_nicely(1); + } + +- res = executeQuery(conn, query->data); ++ res = executeDumpQuery(conn, query->data); + for (int i = 0; i < PQntuples(res); i++) + { + simple_string_list_append(names, PQgetvalue(res, i, 0)); +@@ -1641,7 +1641,7 @@ dumpDatabases(PGconn *conn) + * connected to "template1" a bad idea, but there's no fixed order that + * doesn't have some failure mode with --clean. + */ +- res = executeQuery(conn, ++ res = executeDumpQuery(conn, + "SELECT datname " + "FROM pg_database d " + "WHERE datallowconn AND datconnlimit != -2 " +@@ -1783,7 +1783,7 @@ buildShSecLabels(PGconn *conn, const char *catalog_name, Oid objectId, + PGresult *res; + + buildShSecLabelQuery(catalog_name, objectId, sql); +- res = executeQuery(conn, sql->data); ++ res = executeDumpQuery(conn, sql->data); + emitShSecLabels(conn, res, buffer, objtype, objname); + + PQclear(res); +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch new file mode 100644 index 00000000..4e9cfdce --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch @@ -0,0 +1,42 @@ +From 0000000000000000000000000000000000000019 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: schedule ready after host recovery + +PostgreSQL's normal top-level ERROR recovery returns through the +PostgresMain() sigsetjmp site. After PostgresMainLongJmp() performs cleanup, +PostgresMain() re-arms the exception stack and sets send_ready_for_query when +the backend is not in extended-protocol skip-till-Sync mode. + +The embedded WASIX host can instead force ERROR recovery across the process +exit boundary and then invoke PostgresMainLongJmp() directly. Preserve the +same ReadyForQuery scheduling in that host-callable path, while still +withholding ReadyForQuery when extended-query recovery has set ignore_till_sync. +--- + src/backend/tcop/postgres.c | 11 +++++++++++ + 1 file changed, 11 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index 0dc4c9d1b3..b33221f794 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -4585,6 +4585,17 @@ PostgresMainLongJmp(void) + + /* Now we can allow interrupts again */ + RESUME_INTERRUPTS(); ++ ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ /* ++ * Host-forced ERROR recovery calls this export directly instead of ++ * returning through PostgresMain()'s sigsetjmp site. Preserve upstream's ++ * post-recovery ReadyForQuery scheduling for simple-query errors without ++ * breaking extended-protocol skip-till-Sync behavior. ++ */ ++ if (!ignore_till_sync) ++ send_ready_for_query = true; ++#endif + } + + #ifdef OLIPHAUNT_WASM_SINGLE_USER +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch new file mode 100644 index 00000000..7515533e --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch @@ -0,0 +1,43 @@ +From 0000000000000000000000000000000000000020 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: rearm exception stack after host recovery + +PostgresMain() normally reassigns PG_exception_stack after top-level ERROR +cleanup returns through the sigsetjmp site. The WASIX host-forced recovery +path invokes PostgresMainLongJmp() directly, so it must restore the same +top-level exception boundary before the host pumps another frontend message. + +Keep this paired with the direct-call ReadyForQuery scheduling and only under +OLIPHAUNT_WASM_SINGLE_USER. Non-WASIX builds and the normal sigsetjmp return +path keep PostgreSQL's upstream assignment. +--- + src/backend/tcop/postgres.c | 10 ++++++++++ + 1 file changed, 10 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +index b33221f794..d87ef8ba37 100644 +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -4588,11 +4588,19 @@ PostgresMainLongJmp(void) + + #ifdef OLIPHAUNT_WASM_SINGLE_USER + /* ++ * The normal path re-arms the top-level exception stack immediately after ++ * returning through PostgresMain()'s sigsetjmp site. Host-forced recovery ++ * calls this function directly, so it has to restore the same boundary ++ * before the next frontend message can report another ERROR. ++ */ ++ PG_exception_stack = &postgresmain_sigjmp_buf; ++ ++ /* + * Host-forced ERROR recovery calls this export directly instead of + * returning through PostgresMain()'s sigsetjmp site. Preserve upstream's + * post-recovery ReadyForQuery scheduling for simple-query errors without + * breaking extended-protocol skip-till-Sync behavior. + */ + if (!ignore_till_sync) + send_ready_for_query = true; + #endif +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0021-oliphaunt-wasix-declare-wasix-fork.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0021-oliphaunt-wasix-declare-wasix-fork.patch new file mode 100644 index 00000000..ceb7886c --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0021-oliphaunt-wasix-declare-wasix-fork.patch @@ -0,0 +1,48 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: stub fork_process in embedded WASIX runtime + +The embedded Oliphaunt entrypoint is not a postmaster and must not fork +backend children. PostgreSQL 18 still builds postmaster/fork_process.c into +the backend object graph, so provide a local ENOSYS implementation for this +lane instead of requiring a WASIX fork import at final link. +--- + src/backend/postmaster/fork_process.c | 11 +++++++++++ + 1 file changed, 11 insertions(+) + +diff --git a/src/backend/postmaster/fork_process.c b/src/backend/postmaster/fork_process.c +index b6ff0ce761..d77fe53b9a 100644 +--- a/src/backend/postmaster/fork_process.c ++++ b/src/backend/postmaster/fork_process.c +@@ -11,6 +11,7 @@ + */ + #include "postgres.h" + ++#include + #include + #include + #include +@@ -22,6 +23,15 @@ + #include "miscadmin.h" + #include "postmaster/fork_process.h" + ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++pid_t ++fork_process(void) ++{ ++ errno = ENOSYS; ++ return -1; ++} ++#else ++ + #ifndef WIN32 + /* + * Wrapper for fork(). Return values are the same as those for fork(): +@@ -126,3 +136,4 @@ fork_process(void) + } + + #endif /* ! WIN32 */ ++#endif /* OLIPHAUNT_WASM_SINGLE_USER */ +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch new file mode 100644 index 00000000..bce38e5d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch @@ -0,0 +1,43 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: use wasm-ld for backend core + +Use the same relocatable wasm linker shape as the released WASIX lane when +assembling the backend core archive. The default LD can resolve to the host +linker in the cross build and cannot read wasm/LTO backend objects. +--- + src/backend/Makefile | 12 +++++++++--- + 1 file changed, 9 insertions(+), 3 deletions(-) + +diff --git a/src/backend/Makefile b/src/backend/Makefile +index 9292f77031..89c356d090 100644 +--- a/src/backend/Makefile ++++ b/src/backend/Makefile +@@ -83,12 +83,19 @@ libpostgres.a: postgres + endif # win32 + + ifeq ($(PORTNAME), wasix-dl) ++WASM_LD ?= $(shell $(CC) -print-prog-name=wasm-ld) ++LIBPGCORE ?= $(top_builddir)/libpgcore.a ++LIBPG = $(top_builddir)/libpostgres.a ++PGCORE = $(top_builddir)/src/common/libpgcommon_srv.a $(top_builddir)/src/port/libpgport_srv.a $(LIBPG) ++PGMAIN = main/main.o ++PGBACKEND = $(filter-out $(PGMAIN) $(top_builddir)/src/common/libpgcommon_srv.a $(top_builddir)/src/port/libpgport_srv.a,$(call expand_subsys,$(OBJS))) ++ + oliphaunt: $(OBJS) +- $(AR) rcs libpgmain.a main/main.o +- $(AR) rcs libpostgres.a $(filter-out main/main.o,$(call expand_subsys,$^)) +- $(LD) -r -o libpgcore.o $(filter-out main/main.o,$(call expand_subsys,$^)) +- $(AR) rcs libpgcore.a libpgcore.o +- $(CC) $(CFLAGS) main/main.o libpgcore.a $(LDFLAGS) $(LIBS) -Wl,--no-entry -Wl,--export-dynamic -Wl,--export=_start -nostartfiles -o $@ ++ $(AR) rcs $(top_builddir)/libpgmain.a $(PGMAIN) ++ $(AR) rcs $(LIBPG) $(PGBACKEND) ++ $(WASM_LD) --relocatable -o $(top_builddir)/libpgcore.o --whole-archive $(PGCORE) --no-whole-archive ++ $(AR) rcs $(LIBPGCORE) $(top_builddir)/libpgcore.o ++ $(CC) $(CFLAGS) $(top_builddir)/libpgcore.a $(PGMAIN) $(LDFLAGS) $(LIBS) -Wl,--no-entry -Wl,--export-dynamic -Wl,--export=_start -nostartfiles -o $@ + + postgres: oliphaunt + +-- +2.49.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch new file mode 100644 index 00000000..73a975eb --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch @@ -0,0 +1,29 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0000 +Subject: [PATCH] oliphaunt-wasix: skip data-dir ownership check under embedded WASIX + +WASIX host filesystem metadata can report the embedding host uid while the +Postgres process identity shim exposes the synthetic oliphaunt uid. Keep the +normal upstream ownership interlock everywhere else, but bypass it for the +embedded WASIX runtime so initdb bootstrap can start the backend against the +mounted template data directory. +--- + src/backend/utils/init/miscinit.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/backend/utils/init/miscinit.c b/src/backend/utils/init/miscinit.c +index df182577a1..6941770da1 100644 +--- a/src/backend/utils/init/miscinit.c ++++ b/src/backend/utils/init/miscinit.c +@@ -380,7 +380,7 @@ checkDataDir(void) + * + * XXX can we safely enable this check on Windows? + */ +-#if !defined(WIN32) && !defined(__CYGWIN__) ++#if !defined(WIN32) && !defined(__CYGWIN__) && !defined(OLIPHAUNT_WASM_SINGLE_USER) + if (stat_buf.st_uid != geteuid()) + ereport(FATAL, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), +-- +2.50.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch new file mode 100644 index 00000000..d11b0e3a --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch @@ -0,0 +1,96 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add like literal substring fast path + +The benchmark suite has simple `%literal%` LIKE predicates where the pattern +contains no wildcard other than the leading and trailing `%`. PostgreSQL's +generic LIKE matcher still enters the recursive wildcard engine for those +cases. Add a narrow single-byte fast path for deterministic locales that +reduces the check to `memchr` plus `memcmp`, while preserving upstream behavior +for lower-case/case-insensitive match variants, non-deterministic collations, +escapes, `_`, inner `%`, and every other pattern shape. +--- + src/backend/utils/adt/like.c | 45 ++++++++++++++++++++++++++++++++++++++ + src/backend/utils/adt/like_match.c | 10 +++++++++ + 2 files changed, 55 insertions(+) + +diff --git a/src/backend/utils/adt/like.c b/src/backend/utils/adt/like.c +index 36d2797000..84be33c000 100644 +--- a/src/backend/utils/adt/like.c ++++ b/src/backend/utils/adt/like.c +@@ -101,6 +101,51 @@ SB_lower_char(unsigned char c, pg_locale_t locale) + return tolower_l(c, locale->info.lt); + } + ++static inline int ++MatchTextLiteralSubstring(const char *t, int tlen, const char *p, int plen, ++ pg_locale_t locale) ++{ ++ const char *needle; ++ int needle_len; ++ const char *pos; ++ const char *end; ++ unsigned char first; ++ ++ if (locale && !locale->deterministic) ++ return LIKE_ABORT; ++ if (plen < 2 || p[0] != '%' || p[plen - 1] != '%') ++ return LIKE_ABORT; ++ ++ needle = p + 1; ++ needle_len = plen - 2; ++ for (int i = 0; i < needle_len; i++) ++ { ++ if (needle[i] == '%' || needle[i] == '_' || needle[i] == '\\') ++ return LIKE_ABORT; ++ } ++ ++ if (needle_len == 0) ++ return LIKE_TRUE; ++ if (tlen < needle_len) ++ return LIKE_FALSE; ++ ++ first = (unsigned char) needle[0]; ++ pos = t; ++ end = t + tlen - needle_len + 1; ++ while (pos < end) ++ { ++ const char *hit = memchr(pos, first, end - pos); ++ ++ if (hit == NULL) ++ return LIKE_FALSE; ++ if (memcmp(hit, needle, needle_len) == 0) ++ return LIKE_TRUE; ++ pos = hit + 1; ++ } ++ ++ return LIKE_FALSE; ++} ++ + + #define NextByte(p, plen) ((p)++, (plen)--) + +diff --git a/src/backend/utils/adt/like_match.c b/src/backend/utils/adt/like_match.c +index 0e3b2bc000..7b93dd4000 100644 +--- a/src/backend/utils/adt/like_match.c ++++ b/src/backend/utils/adt/like_match.c +@@ -86,6 +86,16 @@ MatchText(const char *t, int tlen, const char *p, int plen, pg_locale_t locale) + /* Since this function recurses, it could be driven to stack overflow */ + check_stack_depth(); + ++#ifndef MATCH_LOWER ++ { ++ int fast_match; ++ ++ fast_match = MatchTextLiteralSubstring(t, tlen, p, plen, locale); ++ if (fast_match != LIKE_ABORT) ++ return fast_match; ++ } ++#endif ++ + /* + * In this loop, we advance by char when matching wildcards (and thus on + * recursive entry to this function we are properly char-synced). On other +-- +2.50.0 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch new file mode 100644 index 00000000..290d7862 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch @@ -0,0 +1,229 @@ +From 0000000000000000000000000000000000000025 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add simple-query backend timing probes + +The PG18 WASIX runtime needs phase evidence for the speed-suite +regression against the released PG17.5 lane. The Rust host already supports +optional `oliphaunt_wasix_backend_timing_*` exports, and the WASIX bridge only +compiles them when `OLIPHAUNT_WASIX_BACKEND_TIMING` is enabled. Add a narrow +PG18 source probe for simple-query total time, simple-query subphases, portal +execution, and transaction finish time so diagnostic builds can distinguish +guest execution cost from host/protocol/runtime overhead. + +These probes are compiled out of normal release artifacts because +`OLIPHAUNT_WASIX_BACKEND_TIMING` is off by default. +--- + src/backend/tcop/postgres.c | 34 ++++++++++++++++++++++++++++++++++ + src/include/port/wasix-dl.h | 23 +++++++++++++++++++++++ + 2 files changed, 57 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -1161,6 +1161,8 @@ + bool use_implicit_block; + char msec_str[32]; + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_SIMPLE_QUERY); ++ + /* + * Report query to various monitoring facilities. + */ +@@ -1184,7 +1186,9 @@ + * one of those, else bad things will happen in xact.c. (Note that this + * will normally change current memory context.) + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_START_XACT); + start_xact_command(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_START_XACT); + + /* + * Zap any pre-existing unnamed statement. (While not strictly necessary, +@@ -1192,7 +1196,9 @@ + * statement and portal; this ensures we recover any storage used by prior + * unnamed operations.) + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_DROP_UNNAMED); + drop_unnamed_stmt(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_DROP_UNNAMED); + + /* + * Switch to appropriate context for constructing parsetrees. +@@ -1203,7 +1209,9 @@ + * Do basic parsing of the query or queries (this should be safe even if + * we are in aborted transaction state!) + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_PARSE); + parsetree_list = pg_parse_query(query_string); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_PARSE); + + /* Log immediately if dictated by log_statement */ + if (check_log_statement(parsetree_list)) +@@ -1281,7 +1289,9 @@ + errdetail_abort())); + + /* Make sure we are in a transaction command */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_START_XACT); + start_xact_command(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_START_XACT); + + /* + * If using an implicit transaction block, and we're not already in a +@@ -1301,7 +1311,9 @@ + */ + if (analyze_requires_snapshot(parsetree)) + { ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_SNAPSHOT); + PushActiveSnapshot(GetTransactionSnapshot()); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_SNAPSHOT); + snapshot_set = true; + } + +@@ -1328,11 +1340,15 @@ + else + oldcontext = MemoryContextSwitchTo(MessageContext); + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_ANALYZE_REWRITE); + querytree_list = pg_analyze_and_rewrite_fixedparams(parsetree, query_string, + NULL, 0, NULL); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_ANALYZE_REWRITE); + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_PLAN); + plantree_list = pg_plan_queries(querytree_list, query_string, + CURSOR_OPT_PARALLEL_OK, NULL); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_PLAN); + + /* + * Done with the snapshot used for parsing/planning. +@@ -1373,7 +1389,9 @@ + /* + * Start the portal. No parameters here. + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_PORTAL_START); + PortalStart(portal, NULL, 0, InvalidSnapshot); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_PORTAL_START); + + /* + * Select the appropriate output format: text unless we are doing a +@@ -1400,9 +1418,11 @@ + /* + * Now we can create the destination receiver object. + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_DEST_RECEIVER); + receiver = CreateDestReceiver(dest); + if (dest == DestRemote) + SetRemoteDestReceiverParams(receiver, portal); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_DEST_RECEIVER); + + /* + * Switch back to transaction context for execution. +@@ -1412,12 +1432,14 @@ + /* + * Run the portal to completion, and then drop it (and the receiver). + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_PORTAL_RUN); + (void) PortalRun(portal, + FETCH_ALL, + true, /* always top level */ + receiver, + receiver, + &qc); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_PORTAL_RUN); + + receiver->rDestroy(receiver); + +@@ -1436,7 +1458,9 @@ + */ + if (use_implicit_block) + EndImplicitTransactionBlock(); ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT); + finish_xact_command(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT); + } + else if (IsA(parsetree->stmt, TransactionStmt)) + { +@@ -1444,7 +1468,9 @@ + * If this was a transaction control statement, commit it. We will + * start a new xact command for the next command. + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT); + finish_xact_command(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT); + } + else + { +@@ -1459,7 +1485,9 @@ + * We need a CommandCounterIncrement after every query, except + * those that start or end a transaction block. + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_COMMAND_COUNTER); + CommandCounterIncrement(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_COMMAND_COUNTER); + + /* + * Disable statement timeout between queries of a multi-query +@@ -1475,7 +1503,9 @@ + * command the client sent, regardless of rewriting. (But a command + * aborted by error will not send an EndCommand report at all.) + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_END_COMMAND); + EndCommand(&qc, dest, false); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_END_COMMAND); + + /* Now we may drop the per-parsetree context, if one was created. */ + if (per_parsetree_context) +@@ -1487,7 +1517,9 @@ + * something if the parsetree list was empty; otherwise the last loop + * iteration already did it.) + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT); + finish_xact_command(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT); + + /* + * If there were no parsetrees, return EmptyQueryResponse message. +@@ -1518,6 +1550,8 @@ + ShowUsage("QUERY STATISTICS"); + + TRACE_POSTGRESQL_QUERY_DONE(query_string); ++ ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_EXEC_SIMPLE_QUERY); + + debug_query_string = NULL; + } +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -39,6 +39,29 @@ + extern int oliphaunt_wasix_shmctl(int shmid, int cmd, struct shmid_ds *buf); + extern void oliphaunt_wasix_protocol_report_copy_response(int state); + ++#ifdef OLIPHAUNT_WASIX_BACKEND_TIMING ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_SIMPLE_QUERY 36 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_START_XACT 37 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_DROP_UNNAMED 38 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_PARSE 39 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_SNAPSHOT 40 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_ANALYZE_REWRITE 41 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_PLAN 42 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_PORTAL_START 43 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_DEST_RECEIVER 44 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_PORTAL_RUN 45 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT 46 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_COMMAND_COUNTER 47 ++#define OLIPHAUNT_BACKEND_TIMING_EXEC_END_COMMAND 48 ++extern void oliphaunt_wasix_backend_timing_start(int id); ++extern void oliphaunt_wasix_backend_timing_end(int id); ++#define OLIPHAUNT_BACKEND_TIMING_START(id) oliphaunt_wasix_backend_timing_start(id) ++#define OLIPHAUNT_BACKEND_TIMING_END(id) oliphaunt_wasix_backend_timing_end(id) ++#else ++#define OLIPHAUNT_BACKEND_TIMING_START(id) ++#define OLIPHAUNT_BACKEND_TIMING_END(id) ++#endif ++ + #define geteuid oliphaunt_wasix_geteuid + #define getuid oliphaunt_wasix_getuid + #define getegid oliphaunt_wasix_getegid +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch new file mode 100644 index 00000000..823be395 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch @@ -0,0 +1,99 @@ +From 0000000000000000000000000000000000000026 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add executor storage backend timing probes + +The PG18 WASIX indexed-update diagnostics show that simple-query planning is a +large cost, but not enough to explain the remaining gap versus the released +PG17.5 lane. Add diagnostic-only timing probes around coarse executor/storage +hot paths so the next run can separate heap update, btree insert, and WAL +record insertion time from the rest of executor work. + +These probes are compiled out of normal release artifacts because +`OLIPHAUNT_WASIX_BACKEND_TIMING` is off by default. +--- + src/backend/access/heap/heapam_handler.c | 2 ++ + src/backend/access/nbtree/nbtinsert.c | 4 ++++ + src/backend/access/transam/xlog.c | 5 +++++ + src/include/port/wasix-dl.h | 3 +++ + 4 files changed, 14 insertions(+) + +diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c +--- a/src/backend/access/heap/heapam_handler.c ++++ b/src/backend/access/heap/heapam_handler.c +@@ -327,8 +327,10 @@ heapam_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, + slot->tts_tableOid = RelationGetRelid(relation); + tuple->t_tableOid = slot->tts_tableOid; + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_HEAPAM_TUPLE_UPDATE); + result = heap_update(relation, otid, tuple, cid, crosscheck, wait, + tmfd, lockmode, update_indexes); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_HEAPAM_TUPLE_UPDATE); + ItemPointerCopy(&tuple->t_self, &slot->tts_tid); + + /* +diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c +--- a/src/backend/access/nbtree/nbtinsert.c ++++ b/src/backend/access/nbtree/nbtinsert.c +@@ -109,6 +109,8 @@ _bt_doinsert(Relation rel, IndexTuple itup, + BTStack stack; + bool checkingunique = (checkUnique != UNIQUE_CHECK_NO); + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_DOINSERT); ++ + /* we need an insertion scan key to do our search, so build one */ + itup_key = _bt_mkscankey(rel, itup); + +@@ -271,6 +273,8 @@ _bt_doinsert(Relation rel, IndexTuple itup, + if (stack) + _bt_freestack(stack); + pfree(itup_key); ++ ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_DOINSERT); + + return is_unique; + } +diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c +--- a/src/backend/access/transam/xlog.c ++++ b/src/backend/access/transam/xlog.c +@@ -762,6 +762,8 @@ XLogInsertRecord(XLogRecData *rdata, + bool prevDoPageWrites = doPageWrites; + TimeLineID insertTLI; + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_XLOG_INSERT_RECORD); ++ + /* Does this record type require special handling? */ + if (unlikely(rechdr->xl_rmid == RM_XLOG_ID)) + { +@@ -855,6 +857,7 @@ XLogInsertRecord(XLogRecData *rdata, + */ + WALInsertLockRelease(); + END_CRIT_SECTION(); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_XLOG_INSERT_RECORD); + return InvalidXLogRecPtr; + } + +@@ -1085,6 +1088,8 @@ XLogInsertRecord(XLogRecData *rdata, + /* Required for the flush of pending stats WAL data */ + pgstat_report_fixed = true; + } ++ ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_XLOG_INSERT_RECORD); + + return EndPos; + } +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -53,6 +53,9 @@ extern void oliphaunt_wasix_protocol_report_copy_response(int state); + #define OLIPHAUNT_BACKEND_TIMING_EXEC_FINISH_XACT 46 + #define OLIPHAUNT_BACKEND_TIMING_EXEC_COMMAND_COUNTER 47 + #define OLIPHAUNT_BACKEND_TIMING_EXEC_END_COMMAND 48 ++#define OLIPHAUNT_BACKEND_TIMING_HEAPAM_TUPLE_UPDATE 49 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_DOINSERT 50 ++#define OLIPHAUNT_BACKEND_TIMING_XLOG_INSERT_RECORD 51 + extern void oliphaunt_wasix_backend_timing_start(int id); + extern void oliphaunt_wasix_backend_timing_end(int id); + #define OLIPHAUNT_BACKEND_TIMING_START(id) oliphaunt_wasix_backend_timing_start(id) +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch new file mode 100644 index 00000000..b13aa720 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch @@ -0,0 +1,98 @@ +From 0000000000000000000000000000000000000027 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add btree insert backend timing probes + +The coarse storage diagnostic shows `_bt_doinsert` as the largest named +execution component for indexed updates, but that single timer does not say +whether the time is in scan-key construction, tree search, uniqueness checks, +insert-location selection, page mutation, or page splits. Add a second layer +of diagnostic-only btree timers so follow-up work can target the right part of +the btree path before adding another speculative shortcut. + +These probes are compiled out of normal release artifacts because +`OLIPHAUNT_WASIX_BACKEND_TIMING` is off by default. +--- + src/backend/access/nbtree/nbtinsert.c | 16 ++++++++++++++++ + src/include/port/wasix-dl.h | 6 ++++++ + 2 files changed, 22 insertions(+) + +diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c +--- a/src/backend/access/nbtree/nbtinsert.c ++++ b/src/backend/access/nbtree/nbtinsert.c +@@ -112,7 +112,9 @@ _bt_doinsert(Relation rel, IndexTuple itup, + OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_DOINSERT); + + /* we need an insertion scan key to do our search, so build one */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_MKSCANKEY); + itup_key = _bt_mkscankey(rel, itup); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_MKSCANKEY); + + if (checkingunique) + { +@@ -166,7 +168,9 @@ search: + * searching from the root page. insertstate.buf will hold a buffer that + * is locked in exclusive mode afterwards. + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_SEARCH_INSERT); + stack = _bt_search_insert(rel, heapRel, &insertstate); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_SEARCH_INSERT); + + /* + * checkingunique inserts are not allowed to go ahead when two tuples with +@@ -209,8 +213,10 @@ search: + TransactionId xwait; + uint32 speculativeToken; + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_CHECK_UNIQUE); + xwait = _bt_check_unique(rel, &insertstate, heapRel, checkUnique, + &is_unique, &speculativeToken); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_CHECK_UNIQUE); + + if (unlikely(TransactionIdIsValid(xwait))) + { +@@ -257,11 +263,15 @@ search: + * search bounds established within _bt_check_unique when insertion is + * checkingunique. + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_FIND_INSERTLOC); + newitemoff = _bt_findinsertloc(rel, &insertstate, checkingunique, + indexUnchanged, stack, heapRel); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_FIND_INSERTLOC); ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_INSERTONPG); + _bt_insertonpg(rel, heapRel, itup_key, insertstate.buf, InvalidBuffer, + stack, itup, insertstate.itemsz, newitemoff, + insertstate.postingoff, false); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_INSERTONPG); + } + else + { +@@ -1218,8 +1228,10 @@ _bt_insertonpg(Relation rel, + Assert(!split_only_page); + + /* split the buffer into left and right halves */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_SPLIT); + rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz, + itup, origitup, nposting, postingoff); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_SPLIT); + PredicateLockPageSplit(rel, + BufferGetBlockNumber(buf), + BufferGetBlockNumber(rbuf)); +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -56,6 +56,12 @@ extern void oliphaunt_wasix_protocol_report_copy_response(int state); + #define OLIPHAUNT_BACKEND_TIMING_HEAPAM_TUPLE_UPDATE 49 + #define OLIPHAUNT_BACKEND_TIMING_BTREE_DOINSERT 50 + #define OLIPHAUNT_BACKEND_TIMING_XLOG_INSERT_RECORD 51 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_MKSCANKEY 52 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_SEARCH_INSERT 53 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_CHECK_UNIQUE 54 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_FIND_INSERTLOC 55 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_INSERTONPG 56 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_SPLIT 57 + extern void oliphaunt_wasix_backend_timing_start(int id); + extern void oliphaunt_wasix_backend_timing_end(int id); + #define OLIPHAUNT_BACKEND_TIMING_START(id) oliphaunt_wasix_backend_timing_start(id) +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch new file mode 100644 index 00000000..5c9fff22 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch @@ -0,0 +1,140 @@ +From 0000000000000000000000000000000000000028 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add btree search compare timing probes + +The first btree insert diagnostic split shows that page splits are tiny and +that a meaningful residual remains inside `_bt_doinsert` after scan-key build, +root search, insert-location search, page insertion, and WAL are separated. +Add a third diagnostic layer around the leaf-page insert binary search and the +btree tuple comparison routine. These probes are intentionally diagnostic: +`_bt_compare` can run many times per statement, so the measurement perturbs +absolute elapsed time, but it can still tell whether the residual is mostly +comparison/search work before adding a narrower optimization. + +Normal release builds are unaffected because `OLIPHAUNT_WASIX_BACKEND_TIMING` +is off by default. +--- + src/backend/access/nbtree/nbtsearch.c | 27 +++++++++++++++++++++------ + src/include/port/wasix-dl.h | 2 ++ + 2 files changed, 23 insertions(+), 6 deletions(-) + +diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c +--- a/src/backend/access/nbtree/nbtsearch.c ++++ b/src/backend/access/nbtree/nbtsearch.c +@@ -489,6 +489,7 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate) + int32 result, + cmpval; + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_BINSRCH_INSERT); + page = BufferGetPage(insertstate->buf); + opaque = BTPageGetOpaque(page); + +@@ -516,6 +517,7 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate) + insertstate->low = InvalidOffsetNumber; + insertstate->stricthigh = InvalidOffsetNumber; + insertstate->bounds_valid = false; ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_BINSRCH_INSERT); + return low; + } + +@@ -594,6 +596,7 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate) + insertstate->stricthigh = stricthigh; + insertstate->bounds_valid = true; + ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_BINSRCH_INSERT); + return low; + } + +@@ -718,6 +721,7 @@ _bt_compare(Relation rel, + return 1; + + itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)); ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + ntupatts = BTreeTupleGetNAtts(itup, rel); + + /* +@@ -807,7 +811,10 @@ _bt_compare(Relation rel, + + /* if the keys are unequal, return the difference */ + if (result != 0) ++ { ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return result; ++ } + + scankey++; + } +@@ -822,7 +829,10 @@ _bt_compare(Relation rel, + * necessary. + */ + if (key->keysz > ntupatts) ++ { ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return 1; ++ } + + /* + * Use the heap TID attribute and scantid to try to break the tie. The +@@ -857,9 +867,13 @@ _bt_compare(Relation rel, + */ + if (!key->backward && key->keysz == ntupatts && heapTid == NULL && + key->heapkeyspace) ++ { ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return 1; ++ } + + /* All provided scankey arguments found to be equal */ ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return 0; + } + +@@ -869,7 +883,10 @@ _bt_compare(Relation rel, + */ + Assert(key->keysz == IndexRelationGetNumberOfKeyAttributes(rel)); + if (heapTid == NULL) ++ { ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return 1; ++ } + + /* + * Scankey must be treated as equal to a posting list tuple if its scantid +@@ -880,15 +897,22 @@ _bt_compare(Relation rel, + Assert(ntupatts >= IndexRelationGetNumberOfKeyAttributes(rel)); + result = ItemPointerCompare(key->scantid, heapTid); + if (result <= 0 || !BTreeTupleIsPosting(itup)) ++ { ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return result; ++ } + else + { + result = ItemPointerCompare(key->scantid, + BTreeTupleGetMaxHeapTID(itup)); + if (result > 0) ++ { ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return 1; ++ } + } + ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); + return 0; + } + +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -62,6 +62,8 @@ extern void oliphaunt_wasix_protocol_report_copy_response(int state); + #define OLIPHAUNT_BACKEND_TIMING_BTREE_FIND_INSERTLOC 55 + #define OLIPHAUNT_BACKEND_TIMING_BTREE_INSERTONPG 56 + #define OLIPHAUNT_BACKEND_TIMING_BTREE_SPLIT 57 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_BINSRCH_INSERT 58 ++#define OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE 59 + extern void oliphaunt_wasix_backend_timing_start(int id); + extern void oliphaunt_wasix_backend_timing_end(int id); + #define OLIPHAUNT_BACKEND_TIMING_START(id) oliphaunt_wasix_backend_timing_start(id) +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch new file mode 100644 index 00000000..518590bf --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch @@ -0,0 +1,37 @@ +From 0000000000000000000000000000000000000029 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: stub pg_dump parallel fork + +The packaged standalone WASIX `pg_dump` is a single-process tool. PostgreSQL's +parallel dump support still references `fork()` on non-Windows builds, while +the embedded WASIX sysroot used here does not declare or provide that symbol. +Provide a local `ENOSYS` stub for this lane so `pg_dump` links without a fork +runtime import and any attempted parallel worker creation fails through the +existing pg_dump error path. +--- + src/bin/pg_dump/parallel.c | 10 ++++++++++ + 1 file changed, 10 insertions(+) + +diff --git a/src/bin/pg_dump/parallel.c b/src/bin/pg_dump/parallel.c +--- a/src/bin/pg_dump/parallel.c ++++ b/src/bin/pg_dump/parallel.c +@@ -61,6 +61,16 @@ + #include + #endif + ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) && !defined(WIN32) ++static pid_t ++oliphaunt_wasix_pgdump_fork(void) ++{ ++ errno = ENOSYS; ++ return -1; ++} ++#define fork oliphaunt_wasix_pgdump_fork ++#endif ++ + #include "fe_utils/string_utils.h" + #include "parallel.h" + #include "pg_backup_utils.h" +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch new file mode 100644 index 00000000..b8943cb8 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch @@ -0,0 +1,114 @@ +From 0000000000000000000000000000000000000030 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Thu, 28 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add first int4 leaf compare fast path + +The btree compare diagnostic shows that `_bt_compare` dominates the indexed +update miss after the broad executor/storage split. The existing WASIX int4 +fast path avoids the fmgr comparator trampoline, but it still obtains the first +attribute through `index_getattr()`. + +Add a narrower leaf-page shortcut for the normal benchmark shape: single-column +non-null int4 btree tuples using the built-in integer opfamily. The first int4 +datum is read directly from the index tuple data area. Unequal comparisons +return immediately; equal comparisons skip the attribute loop but still fall +through to PostgreSQL's existing heap-TID/truncated-key tie-break logic. Pivot +tuples, posting tuples, null bitmaps, DESC keys, non-int4 opclasses, and +non-WASIX builds continue through the upstream path. +--- + src/backend/access/nbtree/nbtsearch.c | 65 ++++++++++++++++++++++++++++++++--- + 1 file changed, 61 insertions(+), 4 deletions(-) + +diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c +--- a/src/backend/access/nbtree/nbtsearch.c ++++ b/src/backend/access/nbtree/nbtsearch.c +@@ -667,6 +667,56 @@ _bt_binsrch_posting(BTScanInsert key, Page page, OffsetNumber offnum) + return low; + } + ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) ++/* ++ * Fast path for the dominant single-column int4 leaf-page compare shape. ++ * ++ * The existing int4 shortcut still pays index_getattr() to reach the first ++ * fixed-width datum. For a normal non-null leaf tuple, the first int4 datum ++ * starts directly at the index tuple data offset. Equal keys deliberately ++ * return true as result zero: the caller skips the attribute loop but still ++ * executes PostgreSQL's existing heap-TID and truncated-key tie-break logic. ++ */ ++static inline bool ++_bt_oliphaunt_fast_first_int4_leaf_compare(Relation rel, BTScanInsert key, ++ IndexTuple itup, int ntupatts, ++ int32 *result) ++{ ++ ScanKey scankey; ++ int32 index_value; ++ int32 scan_value; ++ ++ if (key->keysz != 1 || ntupatts < 1) ++ return false; ++ if (BTreeTupleIsPivot(itup) || BTreeTupleIsPosting(itup) || ++ IndexTupleHasNulls(itup)) ++ return false; ++ ++ scankey = key->scankeys; ++ if (scankey->sk_attno != 1 || ++ (scankey->sk_flags & (SK_ISNULL | SK_BT_DESC)) != 0 || ++ rel->rd_opfamily[0] != INTEGER_BTREE_FAM_OID || ++ rel->rd_opcintype[0] != INT4OID || ++ (scankey->sk_subtype != INT4OID && ++ scankey->sk_subtype != InvalidOid) || ++ scankey->sk_collation != InvalidOid) ++ return false; ++ ++ memcpy(&index_value, ++ (char *) itup + IndexInfoFindDataOffset(itup->t_info), ++ sizeof(index_value)); ++ scan_value = DatumGetInt32(scankey->sk_argument); ++ ++ if (index_value > scan_value) ++ *result = -1; ++ else if (index_value < scan_value) ++ *result = 1; ++ else ++ *result = 0; ++ return true; ++} ++#endif ++ + /*---------- + * _bt_compare() -- Compare insertion-type scankey to tuple on a page. + * +@@ -708,6 +758,7 @@ _bt_compare(Relation rel, + int ncmpkey; + int ntupatts; + int32 result; ++ bool key_equal_fast = false; + + Assert(_bt_check_natts(rel, key->heapkeyspace, page, offnum)); + Assert(key->keysz <= IndexRelationGetNumberOfKeyAttributes(rel)); +@@ -740,7 +791,21 @@ _bt_compare(Relation rel, + Assert(key->heapkeyspace || ncmpkey == key->keysz); + Assert(!BTreeTupleIsPosting(itup) || key->allequalimage); + scankey = key->scankeys; +- for (int i = 1; i <= ncmpkey; i++) ++#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER) ++ if (P_ISLEAF(opaque) && ++ _bt_oliphaunt_fast_first_int4_leaf_compare(rel, key, itup, ntupatts, ++ &result)) ++ { ++ if (result != 0) ++ { ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE); ++ return result; ++ } ++ key_equal_fast = true; ++ scankey++; ++ } ++#endif ++ for (int i = key_equal_fast ? 2 : 1; i <= ncmpkey; i++) + { + Datum datum; + bool isNull; +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch new file mode 100644 index 00000000..b5dad2ec --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch @@ -0,0 +1,101 @@ +From 0000000000000000000000000000000000000031 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Fri, 29 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: add heap update backend timing probes + +The btree compare diagnostics showed real index-search cost, but the benchmark +text update case updates an unindexed text column while using the integer index +only for lookup. That makes heap update shape, page placement, toast, and heap +WAL logging the next likely explanation for the remaining gap. + +Add finer diagnostic-only timers inside heap_update(). They split the coarse +heapam_tuple_update timer into modified-column detection, toast update work, +new-page buffer selection, heap tuple insertion, and heap update WAL logging. +These probes are compiled out of normal release artifacts because +OLIPHAUNT_WASIX_BACKEND_TIMING is off by default. +--- + src/backend/access/heap/heapam.c | 10 ++++++++++ + src/include/port/wasix-dl.h | 5 +++++ + 2 files changed, 15 insertions(+) + +diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c +index 6203e3d..0b51048 100644 +--- a/src/backend/access/heap/heapam.c ++++ b/src/backend/access/heap/heapam.c +@@ -3418,9 +3418,11 @@ heap_update(Relation relation, ItemPointer otid, HeapTuple newtup, + * new tuple so we must include it as part of the old_key_tuple. See + * ExtractReplicaIdentity. + */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_HEAP_DETERMINE_COLUMNS); + modified_attrs = HeapDetermineColumnsInfo(relation, interesting_attrs, + id_attrs, &oldtup, + newtup, &id_has_external); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_HEAP_DETERMINE_COLUMNS); + + /* + * If we're not updating any "key" column, we can grab a weaker lock type. +@@ -3910,7 +3912,9 @@ l2: + if (need_toast) + { + /* Note we always use WAL and FSM during updates */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_HEAP_TOAST_UPDATE); + heaptup = heap_toast_insert_or_update(relation, newtup, &oldtup, 0); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_HEAP_TOAST_UPDATE); + newtupsize = MAXALIGN(heaptup->t_len); + } + else +@@ -3944,10 +3948,12 @@ l2: + if (newtupsize > pagefree) + { + /* It doesn't fit, must use RelationGetBufferForTuple. */ ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_HEAP_GET_BUFFER_FOR_TUPLE); + newbuf = RelationGetBufferForTuple(relation, heaptup->t_len, + buffer, 0, NULL, + &vmbuffer_new, &vmbuffer, + 0); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_HEAP_GET_BUFFER_FOR_TUPLE); + /* We're all done. */ + break; + } +@@ -4080,7 +4086,9 @@ l2: + HeapTupleClearHeapOnly(newtup); + } + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_HEAP_PUT_TUPLE); + RelationPutHeapTuple(relation, newbuf, heaptup, false); /* insert new tuple */ ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_HEAP_PUT_TUPLE); + + + /* Clear obsolete visibility flags, possibly set by ourselves above... */ +@@ -4131,11 +4139,13 @@ l2: + log_heap_new_cid(relation, heaptup); + } + ++ OLIPHAUNT_BACKEND_TIMING_START(OLIPHAUNT_BACKEND_TIMING_HEAP_LOG_UPDATE); + recptr = log_heap_update(relation, buffer, + newbuf, &oldtup, heaptup, + old_key_tuple, + all_visible_cleared, + all_visible_cleared_new); ++ OLIPHAUNT_BACKEND_TIMING_END(OLIPHAUNT_BACKEND_TIMING_HEAP_LOG_UPDATE); + if (newbuf != buffer) + { + PageSetLSN(BufferGetPage(newbuf), recptr); +diff --git a/src/include/port/wasix-dl.h b/src/include/port/wasix-dl.h +index 401decc..99ecf81 100644 +--- a/src/include/port/wasix-dl.h ++++ b/src/include/port/wasix-dl.h +@@ -64,6 +64,11 @@ extern void oliphaunt_wasix_protocol_report_copy_response(int state); + #define OLIPHAUNT_BACKEND_TIMING_BTREE_SPLIT 57 + #define OLIPHAUNT_BACKEND_TIMING_BTREE_BINSRCH_INSERT 58 + #define OLIPHAUNT_BACKEND_TIMING_BTREE_COMPARE 59 ++#define OLIPHAUNT_BACKEND_TIMING_HEAP_DETERMINE_COLUMNS 60 ++#define OLIPHAUNT_BACKEND_TIMING_HEAP_TOAST_UPDATE 61 ++#define OLIPHAUNT_BACKEND_TIMING_HEAP_GET_BUFFER_FOR_TUPLE 62 ++#define OLIPHAUNT_BACKEND_TIMING_HEAP_PUT_TUPLE 63 ++#define OLIPHAUNT_BACKEND_TIMING_HEAP_LOG_UPDATE 64 + extern void oliphaunt_wasix_backend_timing_start(int id); + extern void oliphaunt_wasix_backend_timing_end(int id); + #define OLIPHAUNT_BACKEND_TIMING_START(id) oliphaunt_wasix_backend_timing_start(id) +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch new file mode 100644 index 00000000..be31a9c2 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch @@ -0,0 +1,46 @@ +From 0000000000000000000000000000000000000032 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Fri, 29 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: avoid XLog-size checkpoint requests + +Oliphaunt WASIX runs one long-lived embedded backend and cannot route checkpoint +requests to a separate checkpointer process. Keep checkpoint decisions local to +the embedded backend under the Oliphaunt WASIX marker. +--- + src/backend/access/transam/xlog.c | 2 ++ + src/backend/postmaster/checkpointer.c | 2 ++ + 2 files changed, 4 insertions(+) + +diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c +--- a/src/backend/access/transam/xlog.c ++++ b/src/backend/access/transam/xlog.c +@@ -2501,12 +2501,14 @@ XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible) + * like a checkpoint is needed, forcibly update RedoRecPtr and + * recheck. + */ ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + if (IsUnderPostmaster && XLogCheckpointNeeded(openLogSegNo)) + { + (void) GetRedoRecPtr(); + if (XLogCheckpointNeeded(openLogSegNo)) + RequestCheckpoint(CHECKPOINT_CAUSE_XLOG); + } ++#endif + } + } + +diff --git a/src/backend/postmaster/checkpointer.c b/src/backend/postmaster/checkpointer.c +--- a/src/backend/postmaster/checkpointer.c ++++ b/src/backend/postmaster/checkpointer.c +@@ -1009,7 +1009,9 @@ RequestCheckpoint(int flags) + /* + * If in a standalone backend, just do it ourselves. + */ ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + if (!IsPostmasterEnvironment) ++#endif + { + /* + * There's no point in doing slow checkpoints in a standalone backend, +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch new file mode 100644 index 00000000..328c0850 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch @@ -0,0 +1,63 @@ +From 0000000000000000000000000000000000000033 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Fri, 29 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: use lightweight embedded runtime paths + +Oliphaunt WASIX has one embedded backend and reports startup parameters +directly to the host. Use the cheaper local semaphore reset path and avoid +retaining duplicate GUC report strings after ParameterStatus is emitted. +--- + src/backend/port/posix_sema.c | 5 +++++ + src/backend/utils/misc/guc.c | 2 ++ + 2 files changed, 7 insertions(+) + +diff --git a/src/backend/port/posix_sema.c b/src/backend/port/posix_sema.c +--- a/src/backend/port/posix_sema.c ++++ b/src/backend/port/posix_sema.c +@@ -293,20 +293,25 @@ void + PGSemaphoreReset(PGSemaphore sema) + { ++#ifdef OLIPHAUNT_WASM_SINGLE_USER ++ sem_trywait(PG_SEM_REF(sema)); ++ return; ++#else + /* + * There's no direct API for this in POSIX, so we have to ratchet the + * semaphore down to 0 with repeated trywait's. + */ + for (;;) + { + if (sem_trywait(PG_SEM_REF(sema)) < 0) + { + if (errno == EAGAIN || errno == EDEADLK) + break; /* got it down to 0 */ + if (errno == EINTR) + continue; /* can this happen? */ + elog(FATAL, "sem_trywait failed: %m"); + } + } ++#endif + } + + /* +diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c +--- a/src/backend/utils/misc/guc.c ++++ b/src/backend/utils/misc/guc.c +@@ -2645,13 +2645,15 @@ ReportGUCOption(struct config_generic *record) + pq_sendstring(&msgbuf, val); + pq_endmessage(&msgbuf); + ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + /* + * We need a long-lifespan copy. If guc_strdup() fails due to OOM, + * we'll set last_reported to NULL and thereby possibly make a + * duplicate report later. + */ + guc_free(record->last_reported); + record->last_reported = guc_strdup(LOG, val); ++#endif + } + + pfree(val); +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch new file mode 100644 index 00000000..eb1a691a --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch @@ -0,0 +1,31 @@ +From 0000000000000000000000000000000000000034 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Fri, 29 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: set embedded postmaster environment + +The long-lived embedded backend should run through PostgreSQL's normal backend +conditionals where possible. Mark the WASIX backend as a postmaster environment +after startup while keeping checkpoint requests local under +OLIPHAUNT_WASM_SINGLE_USER. + +This is paired with the prior checkpoint patch: that patch keeps single-user +checkpoint behavior explicit, while this one restores the embedded backend's +postmaster-style runtime identity for the rest of PostgreSQL. +--- + src/backend/tcop/postgres.c | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -232,6 +232,8 @@ oliphaunt_wasix_start(void) + whereToSendOutput = DestRemote; + ExitOnAnyError = false; + MyBackendType = B_BACKEND; ++ IsPostmasterEnvironment = true; ++ IsUnderPostmaster = true; + } + + OLIPHAUNT_WASM_HOST_EXPORT("oliphaunt_wasix_pq_flush") void +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch new file mode 100644 index 00000000..d66cfd1a --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch @@ -0,0 +1,74 @@ +From 0000000000000000000000000000000000000035 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Fri, 29 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: avoid xlogwrite prevseg division + +The PG18 embedded WASIX Test 11 COMMIT gap is dominated by the +per-WAL-page scan in XLogWrite(), not raw pwrite, fsync, pgstat accounting, or +WALWriteLock waiting. XLogWrite() calls XLByteInPrevSeg() for every WAL page +it scans, and that macro computes segment membership with a dynamic 64-bit +division by wal_segment_size. + +PostgreSQL validates WAL segment sizes as powers of two before normal +operation. In the XLogWrite() hot loop, keep the current open segment's byte +bounds and compare against those bounds instead of repeatedly dividing the LSN. +The segment bounds are refreshed whenever XLogWrite() changes openLogSegNo, so +the semantics match XLByteInPrevSeg() while avoiding the expensive per-page +divide in the WASIX codegen path. +--- + src/backend/access/transam/xlog.c | 15 +++++++++++++-- + 1 file changed, 13 insertions(+), 2 deletions(-) + +diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c +--- a/src/backend/access/transam/xlog.c ++++ b/src/backend/access/transam/xlog.c +@@ -2315,6 +2315,8 @@ XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible) + int npages; + int startidx; + uint32 startoffset; ++ XLogRecPtr openLogSegStart; ++ XLogRecPtr openLogSegEnd; + + /* We should always be inside a critical section here */ + Assert(CritSectionCount > 0); +@@ -2339,6 +2341,9 @@ XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible) + startidx = 0; + startoffset = 0; + ++ openLogSegStart = openLogSegNo * (uint64) wal_segment_size; ++ openLogSegEnd = openLogSegStart + (uint64) wal_segment_size; ++ + /* + * Within the loop, curridx is the cache block index of the page to + * consider writing. Begin at the buffer containing the next unwritten +@@ -2367,8 +2372,8 @@ XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible) + LogwrtResult.Write = EndPtr; + ispartialpage = WriteRqst.Write < LogwrtResult.Write; + +- if (!XLByteInPrevSeg(LogwrtResult.Write, openLogSegNo, +- wal_segment_size)) ++ if (!((LogwrtResult.Write - 1) >= openLogSegStart && ++ (LogwrtResult.Write - 1) < openLogSegEnd)) + { + /* + * Switch to new logfile segment. We cannot have any pending +@@ -2381,6 +2387,8 @@ XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible) + XLByteToPrevSeg(LogwrtResult.Write, openLogSegNo, + wal_segment_size); + openLogTLI = tli; ++ openLogSegStart = openLogSegNo * (uint64) wal_segment_size; ++ openLogSegEnd = openLogSegStart + (uint64) wal_segment_size; + + /* create/use new log file */ + openLogFile = XLogFileInit(openLogSegNo, tli); +@@ -2393,6 +2401,8 @@ XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible) + XLByteToPrevSeg(LogwrtResult.Write, openLogSegNo, + wal_segment_size); + openLogTLI = tli; ++ openLogSegStart = openLogSegNo * (uint64) wal_segment_size; ++ openLogSegEnd = openLogSegStart + (uint64) wal_segment_size; + openLogFile = XLogFileOpen(openLogSegNo, tli); + ReserveExternalFD(); + } +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0036-oliphaunt-wasix-skip-activity-id-reporting.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0036-oliphaunt-wasix-skip-activity-id-reporting.patch new file mode 100644 index 00000000..4a7607af --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0036-oliphaunt-wasix-skip-activity-id-reporting.patch @@ -0,0 +1,125 @@ +From 0000000000000000000000000000000000000036 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Fri, 29 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: skip activity id reporting + +PostgreSQL 18 reports plan IDs to activity statistics from the simple-query, +extended-query, and planner paths. The embedded embedded WASIX runtime does +not expose query or plan IDs as an observability surface, and the activity +tracking GUC is disabled for this lane. + +Guard query-id and plan-id activity reporting behind the existing single-user +build define. This keeps SQL behavior unchanged while avoiding extra reporting +calls and portal/query statement scans on the hot embedded path. +--- + src/backend/optimizer/plan/planner.c | 2 ++ + src/backend/tcop/postgres.c | 20 ++++++++++++++++++++ + 2 files changed, 22 insertions(+) + +diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c +--- a/src/backend/optimizer/plan/planner.c ++++ b/src/backend/optimizer/plan/planner.c +@@ -309,7 +309,9 @@ planner(Query *parse, const char *query_string, int cursorOptions, + else + result = standard_planner(parse, query_string, cursorOptions, boundParams); + ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + pgstat_report_plan_id(result->planId, false); ++#endif + + return result; + } +diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c +--- a/src/backend/tcop/postgres.c ++++ b/src/backend/tcop/postgres.c +@@ -1259,8 +1259,10 @@ exec_simple_query(const char *query_string) + const char *cmdtagname; + size_t cmdtaglen; + ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + pgstat_report_query_id(0, true); + pgstat_report_plan_id(0, true); ++#endif + + /* + * Get the command name for use in status display (it also becomes the +@@ -1822,7 +1824,9 @@ exec_bind_message(StringInfo input_message) + char msec_str[32]; + ParamsErrorCbData params_data; + ErrorContextCallback params_errcxt; ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + ListCell *lc; ++#endif + + /* Get the fixed part of the message */ + portal_name = pq_getmsgstring(input_message); +@@ -1858,6 +1860,7 @@ exec_bind_message(StringInfo input_message) + + pgstat_report_activity(STATE_RUNNING, psrc->query_string); + ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + foreach(lc, psrc->query_list) + { + Query *query = lfirst_node(Query, lc); +@@ -1870,6 +1873,7 @@ exec_bind_message(StringInfo input_message) + break; + } + } ++#endif + + set_ps_display("BIND"); + +@@ -2211,6 +2215,7 @@ exec_bind_message(StringInfo input_message) + cplan->stmt_list, + cplan); + ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + /* Portal is defined, set the plan ID based on its contents. */ + foreach(lc, portal->stmts) + { +@@ -2223,6 +2228,7 @@ exec_bind_message(StringInfo input_message) + break; + } + } ++#endif + + /* Done with the snapshot used for parameter I/O and parsing/planning */ + if (snapshot_set) +@@ -2307,7 +2316,9 @@ exec_execute_message(const char *portal_name, long max_rows) + ErrorContextCallback params_errcxt; + const char *cmdtagname; + size_t cmdtaglen; ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + ListCell *lc; ++#endif + + /* Adjust destination to tell printtup.c what to do */ + dest = whereToSendOutput; +@@ -2347,6 +2353,7 @@ PortalRun(QueryCompletion *qc, DestReceiver *dest) + + pgstat_report_activity(STATE_RUNNING, sourceText); + ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + foreach(lc, portal->stmts) + { + PlannedStmt *stmt = lfirst_node(PlannedStmt, lc); +@@ -2359,7 +2366,9 @@ PortalRun(QueryCompletion *qc, DestReceiver *dest) + break; + } + } ++#endif + ++#ifndef OLIPHAUNT_WASM_SINGLE_USER + foreach(lc, portal->stmts) + { + PlannedStmt *stmt = lfirst_node(PlannedStmt, lc); +@@ -2372,6 +2381,7 @@ PortalRun(QueryCompletion *qc, DestReceiver *dest) + break; + } + } ++#endif + + cmdtagname = GetCommandTagNameAndLen(portal->commandTag, &cmdtaglen); + +-- +2.39.5 diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch new file mode 100644 index 00000000..fe1ebc01 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch @@ -0,0 +1,29 @@ +From 0000000000000000000000000000000000000037 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Sat, 30 May 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: treat directory fsync EISDIR as unsupported + +PostgreSQL already treats several directory fsync failures as a platform +signal that directory fsync is unsupported or unnecessary. WASIX can open a +directory for the sync path, then report EISDIR from fsync itself. + +Treat EISDIR from directory fsync the same way as the existing unsupported +directory fsync errors. This keeps initdb's final sync phase working while +preserving fatal error handling for regular files and non-directory errors. +--- + src/backend/storage/file/fd.c | 2 +- + src/common/file_utils.c | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + +diff --git a/src/backend/storage/file/fd.c b/src/backend/storage/file/fd.c +--- a/src/backend/storage/file/fd.c ++++ b/src/backend/storage/file/fd.c +@@ -3845,1 +3845,1 @@ fsync_fname_ext(const char *fname, bool isdir, bool ignore_perm, int elevel) +- if (returncode != 0 && !(isdir && (errno == EBADF || errno == EINVAL))) ++ if (returncode != 0 && !(isdir && (errno == EBADF || errno == EINVAL || errno == EISDIR))) +diff --git a/src/common/file_utils.c b/src/common/file_utils.c +--- a/src/common/file_utils.c ++++ b/src/common/file_utils.c +@@ -416,1 +416,1 @@ fsync_fname(const char *fname, bool isdir) +- if (returncode != 0 && !(isdir && (errno == EBADF || errno == EINVAL))) ++ if (returncode != 0 && !(isdir && (errno == EBADF || errno == EINVAL || errno == EISDIR))) diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0038-oliphaunt-wasix-register-static-icu-data.patch b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0038-oliphaunt-wasix-register-static-icu-data.patch new file mode 100644 index 00000000..751bf0ad --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0038-oliphaunt-wasix-register-static-icu-data.patch @@ -0,0 +1,131 @@ +From 0000000000000000000000000000000000000038 Mon Sep 17 00:00:00 2001 +From: Oliphaunt Maintainers +Date: Tue, 2 Jun 2026 00:00:00 +0530 +Subject: [PATCH] oliphaunt-wasix: register static ICU data + +The WASIX PostgreSQL artifact links ICU statically. PostgreSQL normally relies +on the platform ICU data loader, so register the linked ICU common data once +before PostgreSQL calls ICU APIs when the build is compiled as a static ICU +consumer. + +This keeps dynamic ICU builds unchanged and avoids requiring a loose ICU data +file inside the WASIX runtime filesystem. +--- + src/backend/commands/collationcmds.c | 3 +++ + src/backend/utils/adt/pg_locale.c | 4 ++++ + src/backend/utils/adt/pg_locale_icu.c | 29 ++++++++++++++++++++++++++ + src/include/utils/pg_locale.h | 1 + + 4 files changed, 37 insertions(+) + +diff --git a/src/backend/commands/collationcmds.c b/src/backend/commands/collationcmds.c +--- a/src/backend/commands/collationcmds.c ++++ b/src/backend/commands/collationcmds.c +@@ -651,6 +651,8 @@ get_icu_locale_comment(const char *localename) + char *result; + + status = U_ZERO_ERROR; ++ pg_register_static_icu_data(); ++ + len_uchar = uloc_getDisplayName(localename, "en", + displayname, lengthof(displayname), + &status); +@@ -980,6 +982,8 @@ pg_import_system_collations(PG_FUNCTION_ARGS) + { + int i; + ++ pg_register_static_icu_data(); ++ + /* + * Start the loop at -1 to sneak in the root locale without too much + * code duplication. +diff --git a/src/backend/utils/adt/pg_locale.c b/src/backend/utils/adt/pg_locale.c +--- a/src/backend/utils/adt/pg_locale.c ++++ b/src/backend/utils/adt/pg_locale.c +@@ -1561,6 +1561,8 @@ icu_language_tag(const char *loc_str, int elevel) + * first call, necessitating a loop. + */ + langtag = palloc(buflen); ++ pg_register_static_icu_data(); ++ + while (true) + { + status = U_ZERO_ERROR; +@@ -1622,6 +1624,8 @@ icu_validate_locale(const char *loc_str) + if (IsBinaryUpgrade && elevel > WARNING) + elevel = WARNING; + ++ pg_register_static_icu_data(); ++ + /* validate that we can extract the language */ + status = U_ZERO_ERROR; + uloc_getLanguage(loc_str, lang, ULOC_LANG_CAPACITY, &status); +diff --git a/src/backend/utils/adt/pg_locale_icu.c b/src/backend/utils/adt/pg_locale_icu.c +--- a/src/backend/utils/adt/pg_locale_icu.c ++++ b/src/backend/utils/adt/pg_locale_icu.c +@@ -13,6 +13,9 @@ + #ifdef USE_ICU + #include + #include ++#ifdef U_STATIC_IMPLEMENTATION ++#include ++#endif + + /* + * ucol_strcollUTF8() was introduced in ICU 50, but it is buggy before ICU 53. +@@ -137,6 +140,27 @@ static const struct collate_methods collate_methods_icu_utf8 = { + .strxfrm_is_safe = true, + }; + ++void ++pg_register_static_icu_data(void) ++{ ++#ifdef U_STATIC_IMPLEMENTATION ++ static bool registered = false; ++ extern const char U_IMPORT U_ICUDATA_ENTRY_POINT[]; ++ UErrorCode status = U_ZERO_ERROR; ++ ++ if (registered) ++ return; ++ ++ udata_setCommonData(&U_ICUDATA_ENTRY_POINT, &status); ++ if (U_FAILURE(status)) ++ ereport(ERROR, ++ (errmsg("could not register static ICU data: %s", ++ u_errorName(status)))); ++ ++ registered = true; ++#endif ++} ++ + #endif + + pg_locale_t +@@ -278,6 +303,8 @@ pg_ucol_open(const char *loc_str) + } + } + ++ pg_register_static_icu_data(); ++ + status = U_ZERO_ERROR; + collator = ucol_open(loc_str, &status); + if (U_FAILURE(status)) +@@ -844,6 +871,8 @@ init_icu_converter(void) + errmsg("encoding \"%s\" not supported by ICU", + pg_encoding_to_char(GetDatabaseEncoding())))); + ++ pg_register_static_icu_data(); ++ + status = U_ZERO_ERROR; + conv = ucnv_open(icu_encoding_name, &status); + if (U_FAILURE(status)) +diff --git a/src/include/utils/pg_locale.h b/src/include/utils/pg_locale.h +--- a/src/include/utils/pg_locale.h ++++ b/src/include/utils/pg_locale.h +@@ -150,6 +150,7 @@ extern int builtin_locale_encoding(const char *locale); + extern const char *builtin_validate_locale(int encoding, const char *locale); + extern void icu_validate_locale(const char *loc_str); + extern char *icu_language_tag(const char *loc_str, int elevel); ++extern void pg_register_static_icu_data(void); + extern void report_newlocale_failure(const char *localename); + + /* These functions convert from/to libc's wchar_t, *not* pg_wchar_t */ diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series new file mode 100644 index 00000000..7ad96772 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series @@ -0,0 +1,38 @@ +0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch +0002-oliphaunt-wasix-add-backend-host-io-hooks.patch +0003-oliphaunt-wasix-export-startup-packet-parser.patch +0004-oliphaunt-wasix-add-host-lifecycle-exports.patch +0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch +0006-oliphaunt-wasix-report-copy-protocol-state.patch +0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch +0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch +0009-oliphaunt-wasix-route-process-identity-through-port.patch +0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch +0011-oliphaunt-wasix-prefer-posix-semaphores.patch +0012-oliphaunt-wasix-capture-startup-errors.patch +0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch +0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch +0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch +0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch +0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch +0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch +0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch +0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch +0021-oliphaunt-wasix-declare-wasix-fork.patch +0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch +0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch +0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch +0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch +0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch +0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch +0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch +0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch +0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch +0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch +0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch +0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch +0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch +0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch +0036-oliphaunt-wasix-skip-activity-id-reporting.patch +0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch +0038-oliphaunt-wasix-register-static-icu-data.patch diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml new file mode 100644 index 00000000..99156484 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml @@ -0,0 +1,41 @@ +[patches] +series = [ + "0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch", + "0002-oliphaunt-wasix-add-backend-host-io-hooks.patch", + "0003-oliphaunt-wasix-export-startup-packet-parser.patch", + "0004-oliphaunt-wasix-add-host-lifecycle-exports.patch", + "0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch", + "0006-oliphaunt-wasix-report-copy-protocol-state.patch", + "0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch", + "0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch", + "0009-oliphaunt-wasix-route-process-identity-through-port.patch", + "0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch", + "0011-oliphaunt-wasix-prefer-posix-semaphores.patch", + "0012-oliphaunt-wasix-capture-startup-errors.patch", + "0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch", + "0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch", + "0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch", + "0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch", + "0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch", + "0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch", + "0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch", + "0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch", + "0021-oliphaunt-wasix-declare-wasix-fork.patch", + "0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch", + "0023-oliphaunt-wasix-skip-data-dir-ownership-check-under-embedded-wasix.patch", + "0024-oliphaunt-wasix-add-like-literal-substring-fast-path.patch", + "0025-oliphaunt-wasix-add-simple-query-backend-timing-probes.patch", + "0026-oliphaunt-wasix-add-executor-storage-backend-timing-probes.patch", + "0027-oliphaunt-wasix-add-btree-insert-backend-timing-probes.patch", + "0028-oliphaunt-wasix-add-btree-search-compare-timing-probes.patch", + "0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch", + "0030-oliphaunt-wasix-add-first-int4-leaf-compare-fast-path.patch", + "0031-oliphaunt-wasix-add-heap-update-backend-timing-probes.patch", + "0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch", + "0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch", + "0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch", + "0035-oliphaunt-wasix-avoid-xlogwrite-prevseg-division.patch", + "0036-oliphaunt-wasix-skip-activity-id-reporting.patch", + "0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch", + "0038-oliphaunt-wasix-register-static-icu-data.patch", +] diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh b/src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh new file mode 100755 index 00000000..75c9ed87 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || (cd "$SCRIPT_DIR/../../../../../.." && pwd))" +SOURCE_ROOT="$SCRIPT_DIR/postgres" +SOURCE_TOML="$REPO_ROOT/src/postgres/versions/18/source.toml" +PATCH_DIR="$SOURCE_ROOT/patches" + +read_toml_value() { + local key="$1" + awk -F'=' -v key="$key" ' + $1 ~ "^[[:space:]]*" key "[[:space:]]*$" { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2) + gsub(/^"|"$/, "", $2) + print $2 + exit + } + ' "$SOURCE_TOML" +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +sha256_stream() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + else + sha256sum | awk '{print $1}' + fi +} + +sha256_text_lf() { + sed 's/\r$//' "$1" | sha256_stream +} + +source_has_patch_artifacts() { + [[ -d "$1" ]] && find "$1" \( -name "*.orig" -o -name "*.rej" \) -print -quit | grep -q . +} + +PG_VERSION="$(read_toml_value version)" +PG_URL="$(read_toml_value url)" +PG_SHA256="$(read_toml_value sha256)" + +if [[ -z "$PG_VERSION" || -z "$PG_URL" || -z "$PG_SHA256" ]]; then + echo "prepare_postgres_source: failed to read PostgreSQL source metadata from $SOURCE_TOML" >&2 + exit 1 +fi + +GENERATED_ROOT="${OLIPHAUNT_WASM_GENERATED_ROOT:-$REPO_ROOT/target/oliphaunt-wasix/wasix-build}" +WORK_ROOT="${OLIPHAUNT_WASM_POSTGRES_WORK_ROOT:-$GENERATED_ROOT}" +SOURCE_CACHE="${SOURCE_CACHE:-$REPO_ROOT/target/liboliphaunt-pg18/source}" +TARBALL="$SOURCE_CACHE/postgresql-$PG_VERSION.tar.bz2" +PATCHED_PGSRC="$WORK_ROOT/work/postgresql-$PG_VERSION-oliphaunt-wasix-src" +FINGERPRINT="$WORK_ROOT/.source-fingerprint" +SOURCE_FINGERPRINT_FILE="$PATCHED_PGSRC/.oliphaunt-wasix-source-fingerprint" +SOURCE_VERSION_FILE="$PATCHED_PGSRC/.oliphaunt-wasix-postgres-version" + +mkdir -p "$SOURCE_CACHE" "$WORK_ROOT/work" + +if [[ ! -f "$TARBALL" ]]; then + echo "prepare_postgres_source: downloading PostgreSQL $PG_VERSION" >&2 + curl -L "$PG_URL" -o "$TARBALL" +fi + +actual_sha="$(sha256_file "$TARBALL")" +if [[ "$actual_sha" != "$PG_SHA256" ]]; then + echo "prepare_postgres_source: checksum mismatch for $TARBALL" >&2 + echo " expected: $PG_SHA256" >&2 + echo " actual: $actual_sha" >&2 + exit 1 +fi + +series_hash="$( + { + sha256_text_lf "$PATCH_DIR/series" + for patch_file in "$PATCH_DIR"/*.patch; do + sha256_text_lf "$patch_file" + done + } | sha256_stream +)" +new_fingerprint="$PG_VERSION:$PG_SHA256:$series_hash" + +if [[ -d "$PATCHED_PGSRC" && -f "$FINGERPRINT" && "$(cat "$FINGERPRINT")" == "$new_fingerprint" ]] && ! source_has_patch_artifacts "$PATCHED_PGSRC"; then + if [[ ! -f "$SOURCE_FINGERPRINT_FILE" || "$(cat "$SOURCE_FINGERPRINT_FILE")" != "$new_fingerprint" ]]; then + printf '%s\n' "$new_fingerprint" > "$SOURCE_FINGERPRINT_FILE" + fi + if [[ ! -f "$SOURCE_VERSION_FILE" || "$(cat "$SOURCE_VERSION_FILE")" != "$PG_VERSION" ]]; then + printf '%s\n' "$PG_VERSION" > "$SOURCE_VERSION_FILE" + fi + echo "$PATCHED_PGSRC" + exit 0 +fi + +rm -rf "$PATCHED_PGSRC" +tar -xjf "$TARBALL" -C "$WORK_ROOT/work" +mv "$WORK_ROOT/work/postgresql-$PG_VERSION" "$PATCHED_PGSRC" + +while IFS= read -r patch_name; do + [[ -z "$patch_name" || "$patch_name" =~ ^# ]] && continue + echo "prepare_postgres_source: applying $patch_name" >&2 + (cd "$PATCHED_PGSRC" && patch --no-backup-if-mismatch -p1 < "$PATCH_DIR/$patch_name") >&2 +done < "$PATCH_DIR/series" + +if source_has_patch_artifacts "$PATCHED_PGSRC"; then + echo "prepare_postgres_source: patch backup/reject files were left in $PATCHED_PGSRC" >&2 + find "$PATCHED_PGSRC" \( -name "*.orig" -o -name "*.rej" \) -print >&2 + exit 1 +fi + +printf '%s\n' "$new_fingerprint" > "$FINGERPRINT" +printf '%s\n' "$new_fingerprint" > "$SOURCE_FINGERPRINT_FILE" +printf '%s\n' "$PG_VERSION" > "$SOURCE_VERSION_FILE" +echo "$PATCHED_PGSRC" diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh b/src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh new file mode 100644 index 00000000..87c9e81e --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +oliphaunt_wasix_wasix_profile="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" + +case "$oliphaunt_wasix_wasix_profile" in + debug) + OLIPHAUNT_WASM_PROFILE_CFLAGS="${OLIPHAUNT_WASM_WASIX_COPT:--O0 -g3}" + OLIPHAUNT_WASM_PROFILE_LDFLAGS="${OLIPHAUNT_WASM_WASIX_LOPT:-}" + ;; + release) + OLIPHAUNT_WASM_PROFILE_CFLAGS="${OLIPHAUNT_WASM_WASIX_COPT:--O2 -g0}" + OLIPHAUNT_WASM_PROFILE_LDFLAGS="${OLIPHAUNT_WASM_WASIX_LOPT:-}" + ;; + release-o3) + OLIPHAUNT_WASM_PROFILE_CFLAGS="${OLIPHAUNT_WASM_WASIX_COPT:--O3 -g0 -flto=thin}" + OLIPHAUNT_WASM_PROFILE_LDFLAGS="${OLIPHAUNT_WASM_WASIX_LOPT:--flto=thin}" + ;; + release-os) + OLIPHAUNT_WASM_PROFILE_CFLAGS="${OLIPHAUNT_WASM_WASIX_COPT:--Os -g0}" + OLIPHAUNT_WASM_PROFILE_LDFLAGS="${OLIPHAUNT_WASM_WASIX_LOPT:-}" + ;; + release-oz) + OLIPHAUNT_WASM_PROFILE_CFLAGS="${OLIPHAUNT_WASM_WASIX_COPT:--Oz -g0}" + OLIPHAUNT_WASM_PROFILE_LDFLAGS="${OLIPHAUNT_WASM_WASIX_LOPT:-}" + ;; + *) + echo "unknown OLIPHAUNT_WASM_BUILD_PROFILE=$oliphaunt_wasix_wasix_profile" >&2 + exit 2 + ;; +esac + +OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" +OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" +OLIPHAUNT_WASM_WASIX_BACKEND_TIMING="${OLIPHAUNT_WASM_WASIX_BACKEND_TIMING:-0}" +if [ -z "${OLIPHAUNT_WASM_WASM_OPT_FLAGS:-}" ]; then + case "$oliphaunt_wasix_wasix_profile" in + release*) + OLIPHAUNT_WASM_WASM_OPT_FLAGS="--converge:--strip-debug:--strip-producers" + ;; + *) + OLIPHAUNT_WASM_WASM_OPT_FLAGS="" + ;; + esac +elif [ "$OLIPHAUNT_WASM_WASM_OPT_FLAGS" = "none" ]; then + OLIPHAUNT_WASM_WASM_OPT_FLAGS="" +fi + +oliphaunt_wasix_reject_asyncify_flag() { + local name="$1" + local value="${!name:-}" + + if [ -z "$value" ] || [ -n "${OLIPHAUNT_WASM_ALLOW_ASYNCIFY_EXPERIMENT:-}" ]; then + return + fi + + case "$value" in + *ASYNCIFY*|*asyncify*) + echo "$name contains Asyncify flags; production WASIX artifacts require WebAssembly exceptions. Set OLIPHAUNT_WASM_ALLOW_ASYNCIFY_EXPERIMENT=1 only for isolated experiments." >&2 + exit 2 + ;; + esac +} + +for oliphaunt_wasix_flag_var in \ + OLIPHAUNT_WASM_PROFILE_CFLAGS \ + OLIPHAUNT_WASM_PROFILE_LDFLAGS \ + OLIPHAUNT_WASM_WASM_OPT_FLAGS \ + OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS \ + OLIPHAUNT_WASM_WASIX_LINKER_FLAGS +do + oliphaunt_wasix_reject_asyncify_flag "$oliphaunt_wasix_flag_var" +done + +oliphaunt_wasix_apply_wasix_profile() { + local phase="${1:-build}" + + export OLIPHAUNT_WASM_PROFILE_CFLAGS + export OLIPHAUNT_WASM_PROFILE_LDFLAGS + export WASIXCC_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" + export WASIXCC_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" + export WASIXCC_WASM_OPT_FLAGS="$OLIPHAUNT_WASM_WASM_OPT_FLAGS" + if [ -n "${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT:-}" ]; then + export WASIXCC_WASM_OPT_SUPPRESS_DEFAULT="$OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT" + fi + if [ -n "${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED:-}" ]; then + export WASIXCC_WASM_OPT_PRESERVE_UNOPTIMIZED="$OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED" + fi + + if [ "$phase" = "configure" ]; then + export WASIXCC_RUN_WASM_OPT="$OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT" + else + export WASIXCC_RUN_WASM_OPT="$OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT" + fi +} + +oliphaunt_wasix_wasix_profile_signature() { + printf 'profile=%s\n' "$oliphaunt_wasix_wasix_profile" + printf 'cflags=%s\n' "$OLIPHAUNT_WASM_PROFILE_CFLAGS" + printf 'ldflags=%s\n' "$OLIPHAUNT_WASM_PROFILE_LDFLAGS" + printf 'configure_wasm_opt=%s\n' "$OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT" + printf 'build_wasm_opt=%s\n' "$OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT" + printf 'wasm_opt_flags=%s\n' "$OLIPHAUNT_WASM_WASM_OPT_FLAGS" + printf 'wasm_opt_suppress_default=%s\n' "${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT:-}" + printf 'wasm_opt_preserve_unoptimized=%s\n' "${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED:-}" + printf 'compiler_flags=%s\n' "${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" + printf 'linker_flags=%s\n' "${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" + printf 'backend_timing=%s\n' "$OLIPHAUNT_WASM_WASIX_BACKEND_TIMING" + if [ -f ./src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh ]; then + printf 'configure_postgres_wasix_dl_sha256=%s\n' "$(sha256sum ./src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh | awk '{print $1}')" + fi + if [ -f ./src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh ]; then + printf 'build_wasix_icu_sha256=%s\n' "$(sha256sum ./src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh | awk '{print $1}')" + fi +} diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh b/src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh new file mode 100644 index 00000000..fced9218 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +oliphaunt_wasix_source_lane() { + printf '%s\n' "${OLIPHAUNT_WASM_SOURCE_LANE:-stable}" +} + +oliphaunt_wasix_default_build_dir() { + local lane="$1" + case "$lane" in + stable | released | packaged | default) + printf '%s\n' "$CONTAINER_GENERATED_ROOT/work/docker-oliphaunt" + ;; + *) + echo "unsupported OLIPHAUNT_WASM_SOURCE_LANE=$lane" >&2 + return 1 + ;; + esac +} + +oliphaunt_wasix_generated_build_dir() { + local lane="$1" + case "$lane" in + stable | released | packaged | default) + printf '%s\n' "$CONTAINER_GENERATED_ROOT/build" + ;; + *) + echo "unsupported OLIPHAUNT_WASM_SOURCE_LANE=$lane" >&2 + return 1 + ;; + esac +} + +oliphaunt_wasix_scratch_build_dir() { + local lane="$1" + local name="$2" + printf '%s/%s\n' "$(oliphaunt_wasix_generated_build_dir "$lane")" "$name" +} + +oliphaunt_wasix_prepare_source_for_docker() { + local lane="$1" + case "$lane" in + stable | released | packaged | default) + local host_pgsrc + host_pgsrc="$(SOURCE_CACHE="${SOURCE_CACHE:-$REPO_ROOT/target/liboliphaunt-pg18/source}" "$ROOT/prepare_postgres_source.sh")" + case "$host_pgsrc" in + "$REPO_ROOT"/*) + printf '%s\n' "/work${host_pgsrc#"$REPO_ROOT"}" + ;; + *) + echo "prepared PG18 source is outside repo mount: $host_pgsrc" >&2 + return 1 + ;; + esac + ;; + *) + echo "unsupported OLIPHAUNT_WASM_SOURCE_LANE=$lane" >&2 + return 1 + ;; + esac +} + +oliphaunt_wasix_check_source_markers() { + case "${OLIPHAUNT_WASM_SOURCE_LANE:-stable}" in + stable | released | packaged | default) + if ! cmp -s "$PGSRC/.oliphaunt-wasix-source-fingerprint" "$BUILD_DIR/.oliphaunt-wasix-source-fingerprint"; then + echo "PG18 build source fingerprint mismatch for $BUILD_DIR" >&2 + return 1 + fi + if ! cmp -s "$PGSRC/.oliphaunt-wasix-postgres-version" "$BUILD_DIR/.oliphaunt-wasix-postgres-version"; then + echo "PG18 build PostgreSQL version marker mismatch for $BUILD_DIR" >&2 + return 1 + fi + ;; + *) + echo "unsupported OLIPHAUNT_WASM_SOURCE_LANE=${OLIPHAUNT_WASM_SOURCE_LANE:-}" >&2 + return 1 + ;; + esac +} diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh new file mode 100644 index 00000000..db6c0d52 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +oliphaunt_wasix_cxx_runtime_lib_dir() { + printf '%s\n' "${WASIX_CXX_RUNTIME_LIB_DIR:-$HOME/.wasixcc/sysroot/sysroot-exnref-ehpic/lib/wasm32-wasi}" +} + +oliphaunt_wasix_cxx_runtime_libs() { + local lib_dir + lib_dir="$(oliphaunt_wasix_cxx_runtime_lib_dir)" + local libs=( + "$lib_dir/libc++.a" + "$lib_dir/libc++abi.a" + "$lib_dir/libunwind.a" + ) + local lib + for lib in "${libs[@]}"; do + if [ ! -f "$lib" ]; then + echo "missing WASIX C++ runtime archive: $lib" >&2 + return 1 + fi + done + printf '%s\n' "${libs[*]}" +} + +oliphaunt_wasix_icu_cflags() { + local prefix="${1:?ICU prefix is required}" + printf '%s\n' "-DU_STATIC_IMPLEMENTATION -I$prefix/include" +} + +oliphaunt_wasix_icu_libs() { + local prefix="${1:?ICU prefix is required}" + printf '%s\n' "$prefix/lib/libicui18n.a $prefix/lib/libicuuc.a $prefix/lib/libicudata.a $(oliphaunt_wasix_cxx_runtime_libs)" +} diff --git a/assets/wasix-build/wasix_shim/pglite_wasix_bridge.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c similarity index 53% rename from assets/wasix-build/wasix_shim/pglite_wasix_bridge.c rename to src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c index 0dd0114e..caeae37a 100644 --- a/assets/wasix-build/wasix_shim/pglite_wasix_bridge.c +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c @@ -37,18 +37,18 @@ #define EMSCRIPTEN_KEEPALIVE __attribute__((used)) #endif -#define PGLITE_UID 123 -#define PGLITE_PROTOCOL_FD 1 +#define OLIPHAUNT_UID 123 +#define OLIPHAUNT_PROTOCOL_FD 1 #define POSTGRES_MAIN_LONGJMP 100 #define MAX_ATEXIT_FUNCS 32 -#ifdef PGLITE_WASIX_BACKEND_TIMING -#define PGL_BACKEND_TIMING_MAX 64 +#ifdef OLIPHAUNT_WASIX_BACKEND_TIMING +#define OLIPHAUNT_BACKEND_TIMING_MAX 104 #endif -volatile int is_pglite_active = 0; +volatile int is_oliphaunt_active = 0; volatile int force_host_error_recovery = 0; -volatile int pglite_wasix_startup_error_capture_active = 0; -volatile sigjmp_buf postgresmain_sigjmp_buf; +volatile int oliphaunt_wasix_startup_error_capture_active = 0; +sigjmp_buf postgresmain_sigjmp_buf; volatile bool ignore_till_sync = false; volatile bool send_ready_for_query = false; @@ -56,7 +56,7 @@ extern int pg_char_to_encoding_private(const char *name); extern const char *pg_encoding_to_char_private(int encoding); /* - * PGlite's libpq sources intentionally use private encoding symbols in the + * Oliphaunt's libpq sources intentionally use private encoding symbols in the * embedded backend build so libpq does not leak a second copy of the encoding * table into the main module. A standalone WASIX pg_dump links the same static * libpq archive, whose connection path still expects libpq's public aliases. @@ -75,92 +75,94 @@ pg_encoding_to_char(int encoding) return pg_encoding_to_char_private(encoding); } -static unsigned char *pgl_wasix_input_buf; -static size_t pgl_wasix_input_len; -static size_t pgl_wasix_input_off; +static unsigned char *oliphaunt_wasix_input_buf; +static size_t oliphaunt_wasix_input_len; +static size_t oliphaunt_wasix_input_off; -static unsigned char *pgl_wasix_output_buf; -static size_t pgl_wasix_output_len_value; -static size_t pgl_wasix_output_cap; +static unsigned char *oliphaunt_wasix_output_buf; +static size_t oliphaunt_wasix_output_len_value; +static size_t oliphaunt_wasix_output_cap; enum { - PGL_WASIX_PROTOCOL_BUFFERED = 0, - PGL_WASIX_PROTOCOL_STREAM = 1, - PGL_WASIX_PROTOCOL_HYBRID = 2, + OLIPHAUNT_WASIX_PROTOCOL_BUFFERED = 0, + OLIPHAUNT_WASIX_PROTOCOL_STREAM = 1, + OLIPHAUNT_WASIX_PROTOCOL_HYBRID = 2, }; enum { - PGL_WASIX_PROTOCOL_COPY_NONE = 0, - PGL_WASIX_PROTOCOL_COPY_IN = 1, - PGL_WASIX_PROTOCOL_COPY_OUT = 2, - PGL_WASIX_PROTOCOL_COPY_BOTH = 3, + OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE = 0, + OLIPHAUNT_WASIX_PROTOCOL_COPY_IN = 1, + OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT = 2, + OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH = 3, }; -static int pgl_wasix_protocol_transport; -static int pgl_wasix_protocol_copy_state; -static bool pgl_wasix_protocol_stream_requested; -static bool pgl_wasix_protocol_stream_active; +static int oliphaunt_wasix_protocol_transport; +static int oliphaunt_wasix_protocol_copy_state_value; +static bool oliphaunt_wasix_protocol_stream_requested; +static bool oliphaunt_wasix_protocol_stream_active_value; static void (*atexit_funcs[MAX_ATEXIT_FUNCS])(void); static int atexit_func_count; -int pgl_set_protocol_transport(int mode); +int oliphaunt_wasix_set_protocol_transport(int mode); +ssize_t oliphaunt_wasix_recv(int fd, void *buf, size_t n, int flags); +ssize_t oliphaunt_wasix_send(int fd, const void *buf, size_t n, int flags); int EMSCRIPTEN_KEEPALIVE -pgl_set_protocol_stdio(int enabled) +oliphaunt_wasix_set_protocol_stdio(int enabled) { - return pgl_set_protocol_transport(enabled ? PGL_WASIX_PROTOCOL_STREAM - : PGL_WASIX_PROTOCOL_BUFFERED); + return oliphaunt_wasix_set_protocol_transport(enabled ? OLIPHAUNT_WASIX_PROTOCOL_STREAM + : OLIPHAUNT_WASIX_PROTOCOL_BUFFERED); } int EMSCRIPTEN_KEEPALIVE -pgl_set_protocol_transport(int mode) +oliphaunt_wasix_set_protocol_transport(int mode) { - if (mode < PGL_WASIX_PROTOCOL_BUFFERED || mode > PGL_WASIX_PROTOCOL_HYBRID) + if (mode < OLIPHAUNT_WASIX_PROTOCOL_BUFFERED || mode > OLIPHAUNT_WASIX_PROTOCOL_HYBRID) { errno = EINVAL; return -1; } - int previous = pgl_wasix_protocol_transport; - pgl_wasix_protocol_transport = mode; - pgl_wasix_protocol_stream_active = mode == PGL_WASIX_PROTOCOL_STREAM; - if (mode != PGL_WASIX_PROTOCOL_HYBRID) + int previous = oliphaunt_wasix_protocol_transport; + oliphaunt_wasix_protocol_transport = mode; + oliphaunt_wasix_protocol_stream_active_value = mode == OLIPHAUNT_WASIX_PROTOCOL_STREAM; + if (mode != OLIPHAUNT_WASIX_PROTOCOL_HYBRID) { - pgl_wasix_protocol_copy_state = PGL_WASIX_PROTOCOL_COPY_NONE; - pgl_wasix_protocol_stream_requested = false; + oliphaunt_wasix_protocol_copy_state_value = OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE; + oliphaunt_wasix_protocol_stream_requested = false; } return previous; } int EMSCRIPTEN_KEEPALIVE -pgl_protocol_stream_active(void) +oliphaunt_wasix_protocol_stream_active(void) { - return pgl_wasix_protocol_stream_active ? 1 : 0; + return oliphaunt_wasix_protocol_stream_active_value ? 1 : 0; } void EMSCRIPTEN_KEEPALIVE -pgl_protocol_report_copy_response(int state) +oliphaunt_wasix_protocol_report_copy_response(int state) { - if (state < PGL_WASIX_PROTOCOL_COPY_NONE || - state > PGL_WASIX_PROTOCOL_COPY_BOTH) + if (state < OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE || + state > OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH) { errno = EINVAL; return; } - pgl_wasix_protocol_copy_state = state; - pgl_wasix_protocol_stream_requested = - pgl_wasix_protocol_transport == PGL_WASIX_PROTOCOL_HYBRID && - state != PGL_WASIX_PROTOCOL_COPY_NONE; + oliphaunt_wasix_protocol_copy_state_value = state; + oliphaunt_wasix_protocol_stream_requested = + oliphaunt_wasix_protocol_transport == OLIPHAUNT_WASIX_PROTOCOL_HYBRID && + state != OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE; } int EMSCRIPTEN_KEEPALIVE -pgl_protocol_copy_state(void) +oliphaunt_wasix_protocol_copy_state(void) { - return pgl_wasix_protocol_copy_state; + return oliphaunt_wasix_protocol_copy_state_value; } int EMSCRIPTEN_KEEPALIVE -pgl_set_force_host_error_recovery(int new_value) +oliphaunt_wasix_set_force_host_error_recovery(int new_value) { int current = force_host_error_recovery; force_host_error_recovery = new_value != 0; @@ -168,10 +170,10 @@ pgl_set_force_host_error_recovery(int new_value) } int EMSCRIPTEN_KEEPALIVE -pgl_setPGliteActive(int new_value) +oliphaunt_wasix_set_active(int new_value) { - int current = is_pglite_active; - is_pglite_active = new_value; + int current = is_oliphaunt_active; + is_oliphaunt_active = new_value; if (new_value == 0) { struct itimerval zero = {{0, 0}, {0, 0}}; @@ -181,7 +183,7 @@ pgl_setPGliteActive(int new_value) } void EMSCRIPTEN_KEEPALIVE -pgl_longjmp(jmp_buf env, int val) +oliphaunt_wasix_longjmp(jmp_buf env, int val) { /* * Some hosts can run nested WebAssembly exception unwinds and can preserve @@ -190,7 +192,7 @@ pgl_longjmp(jmp_buf env, int val) * single-user process-exit boundary; Rust then invokes PostgresMainLongJmp() * to perform the same top-level cleanup and emit the backend ErrorResponse. */ - if (is_pglite_active && + if (is_oliphaunt_active && (force_host_error_recovery || memcmp(env, (void *) postgresmain_sigjmp_buf, sizeof(jmp_buf)) == 0)) { @@ -200,18 +202,18 @@ pgl_longjmp(jmp_buf env, int val) } void EMSCRIPTEN_KEEPALIVE -pgl_siglongjmp(sigjmp_buf env, int val) +oliphaunt_wasix_siglongjmp(sigjmp_buf env, int val) { - pgl_longjmp(env, val); + oliphaunt_wasix_longjmp(env, val); } -#ifdef PGLITE_WASIX_BACKEND_TIMING -static uint64_t pgl_backend_timing_started_us[PGL_BACKEND_TIMING_MAX]; -static uint64_t pgl_backend_timing_elapsed_us_value[PGL_BACKEND_TIMING_MAX]; -static bool pgl_backend_timing_seen[PGL_BACKEND_TIMING_MAX]; +#ifdef OLIPHAUNT_WASIX_BACKEND_TIMING +static uint64_t oliphaunt_wasix_backend_timing_started_us[OLIPHAUNT_BACKEND_TIMING_MAX]; +static uint64_t oliphaunt_wasix_backend_timing_elapsed_us_value[OLIPHAUNT_BACKEND_TIMING_MAX]; +static bool oliphaunt_wasix_backend_timing_seen[OLIPHAUNT_BACKEND_TIMING_MAX]; static uint64_t -pgl_monotonic_us(void) +oliphaunt_wasix_monotonic_us(void) { struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) @@ -220,56 +222,66 @@ pgl_monotonic_us(void) } void EMSCRIPTEN_KEEPALIVE -pgl_backend_timing_reset(void) +oliphaunt_wasix_backend_timing_reset(void) { - memset(pgl_backend_timing_started_us, 0, sizeof(pgl_backend_timing_started_us)); - memset(pgl_backend_timing_elapsed_us_value, 0, sizeof(pgl_backend_timing_elapsed_us_value)); - memset(pgl_backend_timing_seen, 0, sizeof(pgl_backend_timing_seen)); + memset(oliphaunt_wasix_backend_timing_started_us, 0, sizeof(oliphaunt_wasix_backend_timing_started_us)); + memset(oliphaunt_wasix_backend_timing_elapsed_us_value, 0, sizeof(oliphaunt_wasix_backend_timing_elapsed_us_value)); + memset(oliphaunt_wasix_backend_timing_seen, 0, sizeof(oliphaunt_wasix_backend_timing_seen)); } void EMSCRIPTEN_KEEPALIVE -pgl_backend_timing_start(int id) +oliphaunt_wasix_backend_timing_start(int id) { - if (id <= 0 || id >= PGL_BACKEND_TIMING_MAX) + if (id <= 0 || id >= OLIPHAUNT_BACKEND_TIMING_MAX) return; - pgl_backend_timing_started_us[id] = pgl_monotonic_us(); + oliphaunt_wasix_backend_timing_started_us[id] = oliphaunt_wasix_monotonic_us(); } void EMSCRIPTEN_KEEPALIVE -pgl_backend_timing_end(int id) +oliphaunt_wasix_backend_timing_end(int id) { - if (id <= 0 || id >= PGL_BACKEND_TIMING_MAX) + if (id <= 0 || id >= OLIPHAUNT_BACKEND_TIMING_MAX) return; - uint64_t started = pgl_backend_timing_started_us[id]; - uint64_t ended = pgl_monotonic_us(); + uint64_t started = oliphaunt_wasix_backend_timing_started_us[id]; + uint64_t ended = oliphaunt_wasix_monotonic_us(); if (started == 0 || ended < started) return; - pgl_backend_timing_elapsed_us_value[id] += ended - started; - pgl_backend_timing_seen[id] = true; - pgl_backend_timing_started_us[id] = 0; + oliphaunt_wasix_backend_timing_elapsed_us_value[id] += ended - started; + oliphaunt_wasix_backend_timing_seen[id] = true; + oliphaunt_wasix_backend_timing_started_us[id] = 0; +} + +void EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_backend_timing_add(int id, uint64_t value) +{ + if (id <= 0 || id >= OLIPHAUNT_BACKEND_TIMING_MAX) + return; + + oliphaunt_wasix_backend_timing_elapsed_us_value[id] += value; + oliphaunt_wasix_backend_timing_seen[id] = true; } int64_t EMSCRIPTEN_KEEPALIVE -pgl_backend_timing_elapsed_us(int id) +oliphaunt_wasix_backend_timing_elapsed_us(int id) { - if (id <= 0 || id >= PGL_BACKEND_TIMING_MAX || !pgl_backend_timing_seen[id]) + if (id <= 0 || id >= OLIPHAUNT_BACKEND_TIMING_MAX || !oliphaunt_wasix_backend_timing_seen[id]) return -1; - return (int64_t) pgl_backend_timing_elapsed_us_value[id]; + return (int64_t) oliphaunt_wasix_backend_timing_elapsed_us_value[id]; } #endif int EMSCRIPTEN_KEEPALIVE -pgl_wasix_input_reset(void) +oliphaunt_wasix_input_reset(void) { - pgl_wasix_input_len = 0; - pgl_wasix_input_off = 0; + oliphaunt_wasix_input_len = 0; + oliphaunt_wasix_input_off = 0; return 0; } int EMSCRIPTEN_KEEPALIVE -pgl_wasix_input_write(const void *buffer, size_t length) +oliphaunt_wasix_input_write(const void *buffer, size_t length) { if (length == 0) return 0; @@ -279,66 +291,66 @@ pgl_wasix_input_write(const void *buffer, size_t length) return -1; } - if (pgl_wasix_input_off == pgl_wasix_input_len) + if (oliphaunt_wasix_input_off == oliphaunt_wasix_input_len) { - pgl_wasix_input_len = 0; - pgl_wasix_input_off = 0; + oliphaunt_wasix_input_len = 0; + oliphaunt_wasix_input_off = 0; } - size_t new_len = pgl_wasix_input_len + length; - unsigned char *new_buf = realloc(pgl_wasix_input_buf, new_len); + size_t new_len = oliphaunt_wasix_input_len + length; + unsigned char *new_buf = realloc(oliphaunt_wasix_input_buf, new_len); if (new_buf == NULL) { errno = ENOMEM; return -1; } - pgl_wasix_input_buf = new_buf; - memcpy(pgl_wasix_input_buf + pgl_wasix_input_len, buffer, length); - pgl_wasix_input_len = new_len; + oliphaunt_wasix_input_buf = new_buf; + memcpy(oliphaunt_wasix_input_buf + oliphaunt_wasix_input_len, buffer, length); + oliphaunt_wasix_input_len = new_len; return (int) length; } size_t EMSCRIPTEN_KEEPALIVE -pgl_wasix_input_available(void) +oliphaunt_wasix_input_available(void) { - if (pgl_wasix_input_off >= pgl_wasix_input_len) + if (oliphaunt_wasix_input_off >= oliphaunt_wasix_input_len) return 0; - return pgl_wasix_input_len - pgl_wasix_input_off; + return oliphaunt_wasix_input_len - oliphaunt_wasix_input_off; } int EMSCRIPTEN_KEEPALIVE -pgl_wasix_input_peek(void) +oliphaunt_wasix_input_peek(void) { - if (pgl_wasix_input_off >= pgl_wasix_input_len) + if (oliphaunt_wasix_input_off >= oliphaunt_wasix_input_len) return -1; - return (int) pgl_wasix_input_buf[pgl_wasix_input_off]; + return (int) oliphaunt_wasix_input_buf[oliphaunt_wasix_input_off]; } static ssize_t -pgl_wasix_buffer_read(void *buffer, size_t max_length) +oliphaunt_wasix_buffer_read(void *buffer, size_t max_length) { if (buffer == NULL || max_length == 0) return 0; - if (pgl_wasix_input_off >= pgl_wasix_input_len) + if (oliphaunt_wasix_input_off >= oliphaunt_wasix_input_len) return 0; - size_t available = pgl_wasix_input_len - pgl_wasix_input_off; + size_t available = oliphaunt_wasix_input_len - oliphaunt_wasix_input_off; size_t to_copy = available < max_length ? available : max_length; - memcpy(buffer, pgl_wasix_input_buf + pgl_wasix_input_off, to_copy); - pgl_wasix_input_off += to_copy; + memcpy(buffer, oliphaunt_wasix_input_buf + oliphaunt_wasix_input_off, to_copy); + oliphaunt_wasix_input_off += to_copy; return (ssize_t) to_copy; } static int -pgl_wasix_flush_output_to_stdio(void) +oliphaunt_wasix_flush_output_to_stdio(void) { size_t off = 0; - while (off < pgl_wasix_output_len_value) + while (off < oliphaunt_wasix_output_len_value) { ssize_t written = write(STDOUT_FILENO, - pgl_wasix_output_buf + off, - pgl_wasix_output_len_value - off); + oliphaunt_wasix_output_buf + off, + oliphaunt_wasix_output_len_value - off); if (written < 0) return -1; if (written == 0) @@ -348,40 +360,40 @@ pgl_wasix_flush_output_to_stdio(void) } off += (size_t) written; } - pgl_wasix_output_len_value = 0; + oliphaunt_wasix_output_len_value = 0; return 0; } int EMSCRIPTEN_KEEPALIVE -pgl_wasix_output_reset(void) +oliphaunt_wasix_output_reset(void) { - pgl_wasix_output_len_value = 0; - pgl_wasix_protocol_copy_state = PGL_WASIX_PROTOCOL_COPY_NONE; - pgl_wasix_protocol_stream_requested = false; + oliphaunt_wasix_output_len_value = 0; + oliphaunt_wasix_protocol_copy_state_value = OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE; + oliphaunt_wasix_protocol_stream_requested = false; return 0; } size_t EMSCRIPTEN_KEEPALIVE -pgl_wasix_output_len(void) +oliphaunt_wasix_output_len(void) { - return pgl_wasix_output_len_value; + return oliphaunt_wasix_output_len_value; } size_t EMSCRIPTEN_KEEPALIVE -pgl_wasix_output_read(void *buffer, size_t max_length) +oliphaunt_wasix_output_read(void *buffer, size_t max_length) { - if (buffer == NULL || max_length == 0 || pgl_wasix_output_len_value == 0) + if (buffer == NULL || max_length == 0 || oliphaunt_wasix_output_len_value == 0) return 0; - size_t to_copy = pgl_wasix_output_len_value < max_length - ? pgl_wasix_output_len_value + size_t to_copy = oliphaunt_wasix_output_len_value < max_length + ? oliphaunt_wasix_output_len_value : max_length; - memcpy(buffer, pgl_wasix_output_buf, to_copy); + memcpy(buffer, oliphaunt_wasix_output_buf, to_copy); return to_copy; } static ssize_t -pgl_wasix_buffer_write(const void *buffer, size_t length) +oliphaunt_wasix_buffer_write(const void *buffer, size_t length) { if (length == 0) return 0; @@ -391,37 +403,51 @@ pgl_wasix_buffer_write(const void *buffer, size_t length) return -1; } - size_t required = pgl_wasix_output_len_value + length; - if (required > pgl_wasix_output_cap) + size_t required = oliphaunt_wasix_output_len_value + length; + if (required > oliphaunt_wasix_output_cap) { - size_t next_cap = pgl_wasix_output_cap ? pgl_wasix_output_cap : 8192; + size_t next_cap = oliphaunt_wasix_output_cap ? oliphaunt_wasix_output_cap : 8192; while (next_cap < required) next_cap *= 2; - unsigned char *new_buf = realloc(pgl_wasix_output_buf, next_cap); + unsigned char *new_buf = realloc(oliphaunt_wasix_output_buf, next_cap); if (new_buf == NULL) { errno = ENOMEM; return -1; } - pgl_wasix_output_buf = new_buf; - pgl_wasix_output_cap = next_cap; + oliphaunt_wasix_output_buf = new_buf; + oliphaunt_wasix_output_cap = next_cap; } - memcpy(pgl_wasix_output_buf + pgl_wasix_output_len_value, buffer, length); - pgl_wasix_output_len_value += length; - if (pgl_wasix_protocol_transport == PGL_WASIX_PROTOCOL_HYBRID && - pgl_wasix_protocol_stream_requested) + memcpy(oliphaunt_wasix_output_buf + oliphaunt_wasix_output_len_value, buffer, length); + oliphaunt_wasix_output_len_value += length; + if (oliphaunt_wasix_protocol_transport == OLIPHAUNT_WASIX_PROTOCOL_HYBRID && + oliphaunt_wasix_protocol_stream_requested) { - if (pgl_wasix_flush_output_to_stdio() != 0) + if (oliphaunt_wasix_flush_output_to_stdio() != 0) return -1; - pgl_wasix_protocol_stream_active = true; - pgl_wasix_protocol_stream_requested = false; + oliphaunt_wasix_protocol_stream_active_value = true; + oliphaunt_wasix_protocol_stream_requested = false; } return (ssize_t) length; } +ssize_t +oliphaunt_wasix_host_read(void *context, void *buffer, size_t max_length) +{ + (void) context; + return oliphaunt_wasix_recv(OLIPHAUNT_PROTOCOL_FD, buffer, max_length, 0); +} + +ssize_t +oliphaunt_wasix_host_write(void *context, const void *buffer, size_t length) +{ + (void) context; + return oliphaunt_wasix_send(OLIPHAUNT_PROTOCOL_FD, buffer, length, 0); +} + int EMSCRIPTEN_KEEPALIVE -pgl_system(const char *command) +oliphaunt_wasix_system(const char *command) { (void) command; errno = ENOSYS; @@ -435,7 +461,7 @@ pg_free(void *ptr) } static char * -pgl_locale_file_path(void) +oliphaunt_wasix_locale_file_path(void) { const char *sysconfdir = getenv("PGSYSCONFDIR"); if (sysconfdir == NULL || sysconfdir[0] == '\0') @@ -457,7 +483,7 @@ pgl_locale_file_path(void) } static FILE * -pgl_open_locale_pipe(const char *command, const char *mode) +oliphaunt_wasix_open_locale_pipe(const char *command, const char *mode) { if (command == NULL || mode == NULL || strcmp(command, "locale -a") != 0 || strcmp(mode, "r") != 0) @@ -466,7 +492,7 @@ pgl_open_locale_pipe(const char *command, const char *mode) return NULL; } - char *path = pgl_locale_file_path(); + char *path = oliphaunt_wasix_locale_file_path(); if (path == NULL) { if (errno == 0) @@ -493,13 +519,13 @@ pgl_open_locale_pipe(const char *command, const char *mode) } __attribute__((weak)) FILE *EMSCRIPTEN_KEEPALIVE -pgl_popen(const char *command, const char *mode) +oliphaunt_wasix_popen(const char *command, const char *mode) { - return pgl_open_locale_pipe(command, mode); + return oliphaunt_wasix_open_locale_pipe(command, mode); } __attribute__((weak)) int EMSCRIPTEN_KEEPALIVE -pgl_pclose(FILE *file) +oliphaunt_wasix_pclose(FILE *file) { if (file == NULL) { @@ -510,21 +536,33 @@ pgl_pclose(FILE *file) } uid_t EMSCRIPTEN_KEEPALIVE -pgl_geteuid(void) +oliphaunt_wasix_geteuid(void) { - return PGLITE_UID; + return OLIPHAUNT_UID; } uid_t EMSCRIPTEN_KEEPALIVE -pgl_getuid(void) +oliphaunt_wasix_getuid(void) +{ + return OLIPHAUNT_UID; +} + +gid_t EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_getegid(void) { - return PGLITE_UID; + return OLIPHAUNT_UID; +} + +gid_t EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_getgid(void) +{ + return OLIPHAUNT_UID; } struct passwd *EMSCRIPTEN_KEEPALIVE -pgl_getpwuid(uid_t uid) +oliphaunt_wasix_getpwuid(uid_t uid) { - if (uid != PGLITE_UID) + if (uid != OLIPHAUNT_UID) { errno = ENOENT; return NULL; @@ -549,7 +587,57 @@ pgl_getpwuid(uid_t uid) } int EMSCRIPTEN_KEEPALIVE -pgl_atexit(void (*function)(void)) +oliphaunt_wasix_getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, + struct passwd **result) +{ + const char *name = "postgres"; + const char *passwd = "x"; + const char *gecos = "Static User"; + const char *dir = "/home/postgres"; + const char *shell = "/bin/sh"; + char *cursor = buf; + size_t remaining = buflen; + + if (pwd == NULL || buf == NULL || result == NULL) + { + errno = EINVAL; + return EINVAL; + } + + *result = NULL; + if (uid != OLIPHAUNT_UID) + return 0; + +#define COPY_PASSWD_FIELD(field, value) \ + do { \ + size_t needed = strlen(value) + 1; \ + if (needed > remaining) \ + { \ + errno = ERANGE; \ + return ERANGE; \ + } \ + memcpy(cursor, value, needed); \ + pwd->field = cursor; \ + cursor += needed; \ + remaining -= needed; \ + } while (0) + + COPY_PASSWD_FIELD(pw_name, name); + COPY_PASSWD_FIELD(pw_passwd, passwd); + COPY_PASSWD_FIELD(pw_gecos, gecos); + COPY_PASSWD_FIELD(pw_dir, dir); + COPY_PASSWD_FIELD(pw_shell, shell); + +#undef COPY_PASSWD_FIELD + + pwd->pw_uid = uid; + pwd->pw_gid = uid; + *result = pwd; + return 0; +} + +int EMSCRIPTEN_KEEPALIVE +oliphaunt_wasix_atexit(void (*function)(void)) { if (atexit_func_count >= MAX_ATEXIT_FUNCS) return -1; @@ -558,7 +646,7 @@ pgl_atexit(void (*function)(void)) } void EMSCRIPTEN_KEEPALIVE -pgl_run_atexit_funcs(void) +oliphaunt_wasix_run_atexit_funcs(void) { for (int i = atexit_func_count - 1; i >= 0; i--) { @@ -569,27 +657,27 @@ pgl_run_atexit_funcs(void) } static void -pgl_clear_interval_timer(void) +oliphaunt_wasix_clear_interval_timer(void) { struct itimerval zero = {{0, 0}, {0, 0}}; (void) setitimer(ITIMER_REAL, &zero, NULL); } void EMSCRIPTEN_KEEPALIVE -pgl_exit(int status) +oliphaunt_wasix_exit(int status) { - pgl_clear_interval_timer(); + oliphaunt_wasix_clear_interval_timer(); optind = 1; - if (pglite_wasix_startup_error_capture_active && status != 0) + if (oliphaunt_wasix_startup_error_capture_active && status != 0) { - pglite_wasix_startup_error_capture_active = 0; + oliphaunt_wasix_startup_error_capture_active = 0; __builtin_trap(); } exit(status); } int EMSCRIPTEN_KEEPALIVE -pgl_munmap(void *addr, size_t length) +oliphaunt_wasix_munmap(void *addr, size_t length) { if (addr == NULL || length == 0) { @@ -600,7 +688,7 @@ pgl_munmap(void *addr, size_t length) } int EMSCRIPTEN_KEEPALIVE -pgl_fcntl(int fd, int cmd, ...) +oliphaunt_wasix_fcntl(int fd, int cmd, ...) { va_list args; long arg = 0; @@ -609,13 +697,13 @@ pgl_fcntl(int fd, int cmd, ...) { #ifdef F_GETFL case F_GETFL: - if (fd == PGLITE_PROTOCOL_FD) + if (fd == OLIPHAUNT_PROTOCOL_FD) return 0; return fcntl(fd, cmd); #endif #ifdef F_GETFD case F_GETFD: - if (fd == PGLITE_PROTOCOL_FD) + if (fd == OLIPHAUNT_PROTOCOL_FD) return 0; return fcntl(fd, cmd); #endif @@ -624,7 +712,7 @@ pgl_fcntl(int fd, int cmd, ...) va_start(args, cmd); arg = va_arg(args, long); va_end(args); - if (fd == PGLITE_PROTOCOL_FD) + if (fd == OLIPHAUNT_PROTOCOL_FD) { #ifdef O_NONBLOCK if ((arg & ~((long) O_NONBLOCK)) == 0) @@ -643,7 +731,7 @@ pgl_fcntl(int fd, int cmd, ...) va_start(args, cmd); arg = va_arg(args, long); va_end(args); - if (fd == PGLITE_PROTOCOL_FD) + if (fd == OLIPHAUNT_PROTOCOL_FD) { #ifdef FD_CLOEXEC if ((arg & ~((long) FD_CLOEXEC)) == 0) @@ -664,7 +752,7 @@ pgl_fcntl(int fd, int cmd, ...) } static int -pgl_write_int_sockopt(void *optval, socklen_t *optlen, int value) +oliphaunt_wasix_write_int_sockopt(void *optval, socklen_t *optlen, int value) { if (optval == NULL || optlen == NULL || *optlen < (socklen_t) sizeof(int)) { @@ -677,9 +765,9 @@ pgl_write_int_sockopt(void *optval, socklen_t *optlen, int value) } int EMSCRIPTEN_KEEPALIVE -pgl_setsockopt(int fd, int level, int optname, const void *optval, socklen_t optlen) +oliphaunt_wasix_setsockopt(int fd, int level, int optname, const void *optval, socklen_t optlen) { - if (fd != PGLITE_PROTOCOL_FD) + if (fd != OLIPHAUNT_PROTOCOL_FD) return setsockopt(fd, level, optname, optval, optlen); if (optval == NULL && optlen != 0) @@ -743,9 +831,9 @@ pgl_setsockopt(int fd, int level, int optname, const void *optval, socklen_t opt } int EMSCRIPTEN_KEEPALIVE -pgl_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen) +oliphaunt_wasix_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen) { - if (fd != PGLITE_PROTOCOL_FD) + if (fd != OLIPHAUNT_PROTOCOL_FD) return getsockopt(fd, level, optname, optval, optlen); if (level == SOL_SOCKET) @@ -754,19 +842,19 @@ pgl_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen) { #ifdef SO_ERROR case SO_ERROR: - return pgl_write_int_sockopt(optval, optlen, 0); + return oliphaunt_wasix_write_int_sockopt(optval, optlen, 0); #endif #ifdef SO_TYPE case SO_TYPE: - return pgl_write_int_sockopt(optval, optlen, SOCK_STREAM); + return oliphaunt_wasix_write_int_sockopt(optval, optlen, SOCK_STREAM); #endif #ifdef SO_SNDBUF case SO_SNDBUF: - return pgl_write_int_sockopt(optval, optlen, 32768); + return oliphaunt_wasix_write_int_sockopt(optval, optlen, 32768); #endif #ifdef SO_RCVBUF case SO_RCVBUF: - return pgl_write_int_sockopt(optval, optlen, 32768); + return oliphaunt_wasix_write_int_sockopt(optval, optlen, 32768); #endif default: break; @@ -789,7 +877,7 @@ pgl_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen) #ifdef TCP_USER_TIMEOUT case TCP_USER_TIMEOUT: #endif - return pgl_write_int_sockopt(optval, optlen, 0); + return oliphaunt_wasix_write_int_sockopt(optval, optlen, 0); default: break; } @@ -800,9 +888,9 @@ pgl_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen) } int EMSCRIPTEN_KEEPALIVE -pgl_getsockname(int fd, struct sockaddr *addr, socklen_t *len) +oliphaunt_wasix_getsockname(int fd, struct sockaddr *addr, socklen_t *len) { - if (fd != PGLITE_PROTOCOL_FD) + if (fd != OLIPHAUNT_PROTOCOL_FD) return getsockname(fd, addr, len); if (addr == NULL || len == NULL || *len < (socklen_t) sizeof(sa_family_t)) @@ -818,51 +906,51 @@ pgl_getsockname(int fd, struct sockaddr *addr, socklen_t *len) } ssize_t EMSCRIPTEN_KEEPALIVE -pgl_recv(int fd, void *buf, size_t n, int flags) +oliphaunt_wasix_recv(int fd, void *buf, size_t n, int flags) { - if (fd != PGLITE_PROTOCOL_FD) + if (fd != OLIPHAUNT_PROTOCOL_FD) return recv(fd, buf, n, flags); - if (pgl_wasix_protocol_transport == PGL_WASIX_PROTOCOL_STREAM || - pgl_wasix_protocol_stream_active) + if (oliphaunt_wasix_protocol_transport == OLIPHAUNT_WASIX_PROTOCOL_STREAM || + oliphaunt_wasix_protocol_stream_active_value) { (void) flags; return read(STDIN_FILENO, buf, n); } - return pgl_wasix_buffer_read(buf, n); + return oliphaunt_wasix_buffer_read(buf, n); } ssize_t EMSCRIPTEN_KEEPALIVE -pgl_send(int fd, const void *buf, size_t n, int flags) +oliphaunt_wasix_send(int fd, const void *buf, size_t n, int flags) { - if (fd != PGLITE_PROTOCOL_FD) + if (fd != OLIPHAUNT_PROTOCOL_FD) return send(fd, buf, n, flags); - if (pgl_wasix_protocol_transport == PGL_WASIX_PROTOCOL_STREAM || - pgl_wasix_protocol_stream_active) + if (oliphaunt_wasix_protocol_transport == OLIPHAUNT_WASIX_PROTOCOL_STREAM || + oliphaunt_wasix_protocol_stream_active_value) { (void) flags; return write(STDOUT_FILENO, buf, n); } - return pgl_wasix_buffer_write(buf, n); + return oliphaunt_wasix_buffer_write(buf, n); } int EMSCRIPTEN_KEEPALIVE -pgl_connect(int socket, const struct sockaddr *address, socklen_t address_len) +oliphaunt_wasix_connect(int socket, const struct sockaddr *address, socklen_t address_len) { - if (socket != PGLITE_PROTOCOL_FD) + if (socket != OLIPHAUNT_PROTOCOL_FD) return connect(socket, address, address_len); errno = ENOSYS; return -1; } int EMSCRIPTEN_KEEPALIVE -pgl_poll(struct pollfd fds[], nfds_t nfds, int timeout) +oliphaunt_wasix_poll(struct pollfd fds[], nfds_t nfds, int timeout) { bool has_protocol_fd = false; int ready = 0; for (nfds_t i = 0; i < nfds; i++) { - if (fds[i].fd == PGLITE_PROTOCOL_FD) + if (fds[i].fd == OLIPHAUNT_PROTOCOL_FD) { has_protocol_fd = true; break; @@ -875,7 +963,7 @@ pgl_poll(struct pollfd fds[], nfds_t nfds, int timeout) for (nfds_t i = 0; i < nfds; i++) { fds[i].revents = 0; - if (fds[i].fd != PGLITE_PROTOCOL_FD) + if (fds[i].fd != OLIPHAUNT_PROTOCOL_FD) { struct pollfd one = fds[i]; int rc = poll(&one, 1, 0); @@ -886,8 +974,8 @@ pgl_poll(struct pollfd fds[], nfds_t nfds, int timeout) ready++; continue; } - if (pgl_wasix_protocol_transport == PGL_WASIX_PROTOCOL_STREAM || - pgl_wasix_protocol_stream_active) + if (oliphaunt_wasix_protocol_transport == OLIPHAUNT_WASIX_PROTOCOL_STREAM || + oliphaunt_wasix_protocol_stream_active_value) { struct pollfd one; int rc; @@ -905,7 +993,7 @@ pgl_poll(struct pollfd fds[], nfds_t nfds, int timeout) } #ifdef POLLIN if ((fds[i].events & POLLIN) && - pgl_wasix_input_available() > 0) + oliphaunt_wasix_input_available() > 0) fds[i].revents |= POLLIN; #endif #ifdef POLLOUT @@ -954,7 +1042,7 @@ find_by_id(int shmid) } int EMSCRIPTEN_KEEPALIVE -pgl_shmget(key_t key, size_t size, int shmflg) +oliphaunt_wasix_shmget(key_t key, size_t size, int shmflg) { WasixShmSegment *existing = find_by_key(key); @@ -1008,7 +1096,7 @@ pgl_shmget(key_t key, size_t size, int shmflg) } void *EMSCRIPTEN_KEEPALIVE -pgl_shmat(int shmid, const void *shmaddr, int shmflg) +oliphaunt_wasix_shmat(int shmid, const void *shmaddr, int shmflg) { (void) shmaddr; (void) shmflg; @@ -1025,7 +1113,7 @@ pgl_shmat(int shmid, const void *shmaddr, int shmflg) } int EMSCRIPTEN_KEEPALIVE -pgl_shmdt(const void *shmaddr) +oliphaunt_wasix_shmdt(const void *shmaddr) { for (WasixShmSegment *seg = wasix_shm_list; seg; seg = seg->next) { @@ -1042,7 +1130,7 @@ pgl_shmdt(const void *shmaddr) } int EMSCRIPTEN_KEEPALIVE -pgl_shmctl(int shmid, int cmd, struct shmid_ds *buf) +oliphaunt_wasix_shmctl(int shmid, int cmd, struct shmid_ds *buf) { WasixShmSegment *prev = NULL; WasixShmSegment *seg = wasix_shm_list; diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c new file mode 100644 index 00000000..2554c540 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c @@ -0,0 +1,353 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#define CHECK(condition) \ + do \ + { \ + if (!(condition)) \ + { \ + fprintf(stderr, "bridge ABI check failed at %s:%d: %s\n", __FILE__, __LINE__, \ + #condition); \ + return 1; \ + } \ + } while (0) + +FILE *oliphaunt_wasix_popen(const char *command, const char *mode); +int oliphaunt_wasix_system(const char *command); +int oliphaunt_wasix_set_force_host_error_recovery(int new_value); +int oliphaunt_wasix_set_active(int new_value); +int oliphaunt_wasix_atexit(void (*function)(void)); +void oliphaunt_wasix_run_atexit_funcs(void); +uid_t oliphaunt_wasix_geteuid(void); +uid_t oliphaunt_wasix_getuid(void); +gid_t oliphaunt_wasix_getegid(void); +gid_t oliphaunt_wasix_getgid(void); +struct passwd *oliphaunt_wasix_getpwuid(uid_t uid); +int oliphaunt_wasix_getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, + struct passwd **result); +int oliphaunt_wasix_input_reset(void); +int oliphaunt_wasix_input_write(const void *buffer, size_t length); +size_t oliphaunt_wasix_input_available(void); +int oliphaunt_wasix_output_reset(void); +size_t oliphaunt_wasix_output_len(void); +size_t oliphaunt_wasix_output_read(void *buffer, size_t max_length); +int oliphaunt_wasix_fcntl(int fd, int cmd, ...); +int oliphaunt_wasix_setsockopt(int fd, int level, int optname, const void *optval, socklen_t optlen); +int oliphaunt_wasix_getsockopt(int fd, int level, int optname, void *optval, socklen_t *optlen); +int oliphaunt_wasix_getsockname(int fd, struct sockaddr *addr, socklen_t *len); +int oliphaunt_wasix_set_protocol_stdio(int enabled); +int oliphaunt_wasix_set_protocol_transport(int mode); +int oliphaunt_wasix_protocol_stream_active(void); +void oliphaunt_wasix_protocol_report_copy_response(int state); +int oliphaunt_wasix_protocol_copy_state(void); +ssize_t oliphaunt_wasix_recv(int fd, void *buf, size_t n, int flags); +ssize_t oliphaunt_wasix_send(int fd, const void *buf, size_t n, int flags); +int oliphaunt_wasix_connect(int socket, const struct sockaddr *address, socklen_t address_len); +int oliphaunt_wasix_poll(struct pollfd fds[], nfds_t nfds, int timeout); +int oliphaunt_wasix_munmap(void *addr, size_t length); +int oliphaunt_wasix_shmget(key_t key, size_t size, int shmflg); +void *oliphaunt_wasix_shmat(int shmid, const void *shmaddr, int shmflg); +int oliphaunt_wasix_shmdt(const void *shmaddr); +int oliphaunt_wasix_shmctl(int shmid, int cmd, struct shmid_ds *buf); + +int +pg_char_to_encoding_private(const char *name) +{ + return strcmp(name, "UTF8") == 0 ? 6 : -1; +} + +const char * +pg_encoding_to_char_private(int encoding) +{ + return encoding == 6 ? "UTF8" : ""; +} + +static int atexit_counter; + +static void +increment_atexit_counter(void) +{ + atexit_counter++; +} + +static int +check_locale_pipe(void) +{ + char temp_template[] = "/tmp/oliphaunt-bridge-abi-XXXXXX"; + char *dir = mkdtemp(temp_template); + CHECK(dir != NULL); + CHECK(setenv("PGSYSCONFDIR", dir, 1) == 0); + CHECK(setenv("PGCLIENTENCODING", "UTF8", 1) == 0); + + errno = 0; + CHECK(oliphaunt_wasix_popen("uname -a", "r") == NULL); + CHECK(errno == ENOSYS); + errno = 0; + CHECK(oliphaunt_wasix_popen("locale -a", "w") == NULL); + CHECK(errno == ENOSYS); + + FILE *file = oliphaunt_wasix_popen("locale -a", "r"); + CHECK(file != NULL); + char contents[128] = {0}; + size_t read_len = fread(contents, 1, sizeof(contents) - 1, file); + CHECK(fclose(file) == 0); + CHECK(read_len > 0); + CHECK(strstr(contents, "C\n") != NULL); + CHECK(strstr(contents, "C.UTF8\n") != NULL); + CHECK(strstr(contents, "POSIX\n") != NULL); + CHECK(unsetenv("PGSYSCONFDIR") == 0); + errno = 0; + CHECK(oliphaunt_wasix_popen("locale -a", "r") == NULL); + CHECK(errno == ENOENT); + return 0; +} + +static int +check_identity_and_fail_closed_calls(void) +{ + CHECK(oliphaunt_wasix_geteuid() == 123); + CHECK(oliphaunt_wasix_getuid() == 123); + CHECK(oliphaunt_wasix_getegid() == 123); + CHECK(oliphaunt_wasix_getgid() == 123); + struct passwd *pw = oliphaunt_wasix_getpwuid(123); + CHECK(pw != NULL); + CHECK(strcmp(pw->pw_name, "postgres") == 0); + CHECK(pw->pw_uid == 123); + CHECK(pw->pw_gid == 123); + + struct passwd pwbuf; + struct passwd *result = NULL; + char buf[128]; + CHECK(oliphaunt_wasix_getpwuid_r(123, &pwbuf, buf, sizeof(buf), &result) == 0); + CHECK(result == &pwbuf); + CHECK(strcmp(result->pw_name, "postgres") == 0); + CHECK(result->pw_uid == 123); + CHECK(result->pw_gid == 123); + result = &pwbuf; + CHECK(oliphaunt_wasix_getpwuid_r(999, &pwbuf, buf, sizeof(buf), &result) == 0); + CHECK(result == NULL); + errno = 0; + CHECK(oliphaunt_wasix_getpwuid_r(123, &pwbuf, buf, 4, &result) == ERANGE); + CHECK(errno == ERANGE); + + errno = 0; + CHECK(oliphaunt_wasix_getpwuid(999) == NULL); + CHECK(errno == ENOENT); + + errno = 0; + CHECK(oliphaunt_wasix_system("echo unsafe") == -1); + CHECK(errno == ENOSYS); + + CHECK(oliphaunt_wasix_set_force_host_error_recovery(1) == 0); + CHECK(oliphaunt_wasix_set_force_host_error_recovery(0) == 1); + CHECK(oliphaunt_wasix_set_active(1) == 0); + CHECK(oliphaunt_wasix_set_active(0) == 1); + CHECK(oliphaunt_wasix_atexit(increment_atexit_counter) == 0); + CHECK(oliphaunt_wasix_atexit(increment_atexit_counter) == 0); + oliphaunt_wasix_run_atexit_funcs(); + CHECK(atexit_counter == 2); + oliphaunt_wasix_run_atexit_funcs(); + CHECK(atexit_counter == 2); + + errno = 0; + CHECK(oliphaunt_wasix_connect(1, NULL, 0) == -1); + CHECK(errno == ENOSYS); + errno = 0; + CHECK(oliphaunt_wasix_connect(-1, NULL, 0) == -1); + CHECK(errno == EBADF); + return 0; +} + +static int +check_protocol_socket(void) +{ + char buf[8] = {0}; + const char input[] = "abc"; + const char output[] = "xyz"; + + CHECK(oliphaunt_wasix_input_reset() == 0); + CHECK(oliphaunt_wasix_output_reset() == 0); + CHECK(oliphaunt_wasix_recv(1, buf, sizeof(buf), 0) == 0); + CHECK(oliphaunt_wasix_input_write(input, sizeof(input) - 1) == (int) (sizeof(input) - 1)); + CHECK(oliphaunt_wasix_input_available() == sizeof(input) - 1); + CHECK(oliphaunt_wasix_recv(1, buf, 2, 0) == 2); + CHECK(memcmp(buf, "ab", 2) == 0); + CHECK(oliphaunt_wasix_input_available() == 1); + + CHECK(oliphaunt_wasix_send(1, output, sizeof(output) - 1, 0) == (ssize_t) (sizeof(output) - 1)); + CHECK(oliphaunt_wasix_output_len() == sizeof(output) - 1); + memset(buf, 0, sizeof(buf)); + CHECK(oliphaunt_wasix_output_read(buf, sizeof(buf)) == sizeof(output) - 1); + CHECK(memcmp(buf, output, sizeof(output) - 1) == 0); + + CHECK(oliphaunt_wasix_set_protocol_stdio(0) == 0); + CHECK(oliphaunt_wasix_protocol_stream_active() == 0); + CHECK(oliphaunt_wasix_set_protocol_stdio(1) == 0); + CHECK(oliphaunt_wasix_protocol_stream_active() == 1); + CHECK(oliphaunt_wasix_set_protocol_stdio(0) == 1); + CHECK(oliphaunt_wasix_protocol_stream_active() == 0); + CHECK(oliphaunt_wasix_set_protocol_transport(2) == 0); + CHECK(oliphaunt_wasix_protocol_stream_active() == 0); + CHECK(oliphaunt_wasix_protocol_copy_state() == 0); + oliphaunt_wasix_protocol_report_copy_response(1); + CHECK(oliphaunt_wasix_protocol_copy_state() == 1); + CHECK(oliphaunt_wasix_send(1, output, sizeof(output) - 1, 0) == (ssize_t) (sizeof(output) - 1)); + CHECK(oliphaunt_wasix_protocol_stream_active() == 1); + CHECK(oliphaunt_wasix_set_protocol_transport(0) == 2); + CHECK(oliphaunt_wasix_protocol_stream_active() == 0); + CHECK(oliphaunt_wasix_protocol_copy_state() == 0); + CHECK(oliphaunt_wasix_set_protocol_transport(2) == 0); + oliphaunt_wasix_protocol_report_copy_response(0); + CHECK(oliphaunt_wasix_protocol_copy_state() == 0); + CHECK(oliphaunt_wasix_set_protocol_transport(0) == 2); + errno = 0; + CHECK(oliphaunt_wasix_set_protocol_transport(99) == -1); + CHECK(errno == EINVAL); + +#ifdef ENOTSOCK + errno = 0; + CHECK(oliphaunt_wasix_recv(2, buf, sizeof(buf), 0) == -1); + CHECK(errno == ENOTSOCK); + errno = 0; + CHECK(oliphaunt_wasix_send(2, output, sizeof(output) - 1, 0) == -1); + CHECK(errno == ENOTSOCK); +#endif + + CHECK(oliphaunt_wasix_fcntl(1, F_GETFL) == 0); + CHECK(oliphaunt_wasix_fcntl(1, F_SETFL, O_NONBLOCK) == 0); +#ifdef O_APPEND + errno = 0; + CHECK(oliphaunt_wasix_fcntl(1, F_SETFL, O_APPEND) == -1); + CHECK(errno == EINVAL); +#endif + + int opt = 1; + CHECK(oliphaunt_wasix_setsockopt(1, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt)) == 0); +#ifdef TCP_NODELAY + CHECK(oliphaunt_wasix_setsockopt(1, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)) == 0); +#endif + errno = 0; + CHECK(oliphaunt_wasix_setsockopt(1, SOL_SOCKET, 0x7ffffffe, &opt, sizeof(opt)) == -1); + CHECK(errno == ENOPROTOOPT); + + opt = 0; + socklen_t optlen = sizeof(opt); + CHECK(oliphaunt_wasix_getsockopt(1, SOL_SOCKET, SO_TYPE, &opt, &optlen) == 0); + CHECK(opt == SOCK_STREAM); + CHECK(optlen == (socklen_t) sizeof(opt)); + errno = 0; + optlen = sizeof(opt); + CHECK(oliphaunt_wasix_getsockopt(1, SOL_SOCKET, 0x7ffffffd, &opt, &optlen) == -1); + CHECK(errno == ENOPROTOOPT); + + struct sockaddr_storage addr; + socklen_t addrlen = sizeof(addr); + CHECK(oliphaunt_wasix_getsockname(1, (struct sockaddr *) &addr, &addrlen) == 0); + CHECK(addr.ss_family == AF_UNIX); + + CHECK(oliphaunt_wasix_input_reset() == 0); + struct pollfd fds[1] = {{.fd = 1, .events = POLLIN, .revents = 0}}; + CHECK(oliphaunt_wasix_poll(fds, 1, 0) == 0); + CHECK(fds[0].revents == 0); + CHECK(oliphaunt_wasix_input_write("q", 1) == 1); + CHECK(oliphaunt_wasix_poll(fds, 1, 0) == 1); + CHECK((fds[0].revents & POLLIN) != 0); + + struct pollfd ignored[1] = {{.fd = -1, .events = POLLIN, .revents = 0}}; + CHECK(oliphaunt_wasix_poll(ignored, 1, 0) == 0); + struct pollfd mixed[2] = { + {.fd = 1, .events = POLLOUT, .revents = 0}, + {.fd = 99, .events = POLLIN, .revents = 0}, + }; + CHECK(oliphaunt_wasix_poll(mixed, 2, 0) == 2); + CHECK((mixed[0].revents & POLLOUT) != 0); +#ifdef POLLNVAL + CHECK((mixed[1].revents & POLLNVAL) != 0); +#endif + return 0; +} + +static int +check_memory_and_shared_memory(void) +{ + errno = 0; + CHECK(oliphaunt_wasix_munmap(NULL, 0) == -1); + CHECK(errno == EINVAL); + +#if defined(MAP_ANON) + int anon_flag = MAP_ANON; +#elif defined(MAP_ANONYMOUS) + int anon_flag = MAP_ANONYMOUS; +#else + int anon_flag = 0; +#endif + if (anon_flag != 0) + { + void *mapping = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | anon_flag, -1, 0); + CHECK(mapping != MAP_FAILED); + CHECK(oliphaunt_wasix_munmap(mapping, 4096) == 0); + } + + key_t key = 4242; + int shmid = oliphaunt_wasix_shmget(key, 64, IPC_CREAT | IPC_EXCL); + CHECK(shmid > 0); + errno = 0; + CHECK(oliphaunt_wasix_shmget(key, 64, IPC_CREAT | IPC_EXCL) == -1); + CHECK(errno == EEXIST); + errno = 0; + CHECK(oliphaunt_wasix_shmget(key + 1, 64, 0) == -1); + CHECK(errno == ENOENT); + + void *addr = oliphaunt_wasix_shmat(shmid, NULL, 0); + CHECK(addr != (void *) -1); + memset(addr, 0x7b, 64); + + struct shmid_ds statbuf; + CHECK(oliphaunt_wasix_shmctl(shmid, IPC_STAT, &statbuf) == 0); + CHECK(statbuf.shm_segsz == 64); + CHECK(statbuf.shm_nattch == 1); + CHECK(oliphaunt_wasix_shmdt(addr) == 0); + CHECK(oliphaunt_wasix_shmctl(shmid, IPC_RMID, NULL) == 0); + errno = 0; + CHECK(oliphaunt_wasix_shmat(shmid, NULL, 0) == (void *) -1); + CHECK(errno == EINVAL); + return 0; +} + +int +main(void) +{ + CHECK(check_locale_pipe() == 0); + CHECK(check_identity_and_fail_closed_calls() == 0); + CHECK(check_protocol_socket() == 0); + CHECK(check_memory_and_shared_memory() == 0); + return 0; +} diff --git a/assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c similarity index 84% rename from assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim.c rename to src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c index 84bbf059..ae272cd0 100644 --- a/assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim.c +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c @@ -30,7 +30,7 @@ #define PG_VERSION "unknown" #endif -#define PGLITE_UID 123 +#define OLIPHAUNT_UID 123 extern char **environ; extern int pg_char_to_encoding_private(const char *name); @@ -219,7 +219,7 @@ is_postgres_command(const CommandSpec *spec) if (spec->argc == 0 || spec->argv == NULL || spec->argv[0] == NULL) return false; const char *name = base_name(spec->argv[0]); - return strcmp(name, "postgres") == 0 || strcmp(name, "pglite") == 0; + return strcmp(name, "postgres") == 0 || strcmp(name, "oliphaunt") == 0; } static int @@ -471,7 +471,7 @@ open_postgres_read_pipe(const char *command, const char *mode) } int -pgl_initdb_system(const char *command) +oliphaunt_wasix_initdb_system(const char *command) { CommandSpec spec; if (command == NULL || !parse_command(command, &spec)) @@ -492,13 +492,13 @@ pgl_initdb_system(const char *command) } int -pgl_system(const char *command) +oliphaunt_wasix_system(const char *command) { - return pgl_initdb_system(command); + return oliphaunt_wasix_initdb_system(command); } FILE * -pgl_initdb_popen(const char *command, const char *mode) +oliphaunt_wasix_initdb_popen(const char *command, const char *mode) { FILE *locale = open_locale_pipe(command, mode); if (locale != NULL) @@ -568,13 +568,13 @@ pgl_initdb_popen(const char *command, const char *mode) } FILE * -pgl_popen(const char *command, const char *mode) +oliphaunt_wasix_popen(const char *command, const char *mode) { - return pgl_initdb_popen(command, mode); + return oliphaunt_wasix_initdb_popen(command, mode); } int -pgl_initdb_pclose(FILE *file) +oliphaunt_wasix_initdb_pclose(FILE *file) { if (file == NULL) { @@ -598,27 +598,27 @@ pgl_initdb_pclose(FILE *file) } int -pgl_pclose(FILE *file) +oliphaunt_wasix_pclose(FILE *file) { - return pgl_initdb_pclose(file); + return oliphaunt_wasix_initdb_pclose(file); } int __wrap_system(const char *command) { - return pgl_initdb_system(command); + return oliphaunt_wasix_initdb_system(command); } FILE * __wrap_popen(const char *command, const char *mode) { - return pgl_initdb_popen(command, mode); + return oliphaunt_wasix_initdb_popen(command, mode); } int __wrap_pclose(FILE *file) { - return pgl_initdb_pclose(file); + return oliphaunt_wasix_initdb_pclose(file); } int @@ -634,21 +634,33 @@ pg_encoding_to_char(int encoding) } uid_t -pgl_geteuid(void) +oliphaunt_wasix_geteuid(void) { - return PGLITE_UID; + return OLIPHAUNT_UID; } uid_t -pgl_getuid(void) +oliphaunt_wasix_getuid(void) { - return PGLITE_UID; + return OLIPHAUNT_UID; +} + +gid_t +oliphaunt_wasix_getegid(void) +{ + return OLIPHAUNT_UID; +} + +gid_t +oliphaunt_wasix_getgid(void) +{ + return OLIPHAUNT_UID; } struct passwd * -pgl_getpwuid(uid_t uid) +oliphaunt_wasix_getpwuid(uid_t uid) { - if (uid != PGLITE_UID) + if (uid != OLIPHAUNT_UID) { errno = ENOENT; return NULL; @@ -672,8 +684,58 @@ pgl_getpwuid(uid_t uid) return &pw; } +int +oliphaunt_wasix_getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, + struct passwd **result) +{ + const char *name = "postgres"; + const char *passwd = "x"; + const char *gecos = "Static User"; + const char *dir = "/home/postgres"; + const char *shell = "/bin/sh"; + char *cursor = buf; + size_t remaining = buflen; + + if (pwd == NULL || buf == NULL || result == NULL) + { + errno = EINVAL; + return EINVAL; + } + + *result = NULL; + if (uid != OLIPHAUNT_UID) + return 0; + +#define COPY_PASSWD_FIELD(field, value) \ + do { \ + size_t needed = strlen(value) + 1; \ + if (needed > remaining) \ + { \ + errno = ERANGE; \ + return ERANGE; \ + } \ + memcpy(cursor, value, needed); \ + pwd->field = cursor; \ + cursor += needed; \ + remaining -= needed; \ + } while (0) + + COPY_PASSWD_FIELD(pw_name, name); + COPY_PASSWD_FIELD(pw_passwd, passwd); + COPY_PASSWD_FIELD(pw_gecos, gecos); + COPY_PASSWD_FIELD(pw_dir, dir); + COPY_PASSWD_FIELD(pw_shell, shell); + +#undef COPY_PASSWD_FIELD + + pwd->pw_uid = uid; + pwd->pw_gid = uid; + *result = pwd; + return 0; +} + void -pgl_exit(int status) +oliphaunt_wasix_exit(int status) { exit(status); } diff --git a/assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim_abi_test.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim_abi_test.c similarity index 66% rename from assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim_abi_test.c rename to src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim_abi_test.c index c3b8fd4c..9f5b02aa 100644 --- a/assets/wasix-build/wasix_shim/pglite_wasix_initdb_shim_abi_test.c +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim_abi_test.c @@ -30,11 +30,15 @@ } \ } while (0) -FILE *pgl_initdb_popen(const char *command, const char *mode); -int pgl_initdb_pclose(FILE *file); -uid_t pgl_geteuid(void); -uid_t pgl_getuid(void); -struct passwd *pgl_getpwuid(uid_t uid); +FILE *oliphaunt_wasix_initdb_popen(const char *command, const char *mode); +int oliphaunt_wasix_initdb_pclose(FILE *file); +uid_t oliphaunt_wasix_geteuid(void); +uid_t oliphaunt_wasix_getuid(void); +gid_t oliphaunt_wasix_getegid(void); +gid_t oliphaunt_wasix_getgid(void); +struct passwd *oliphaunt_wasix_getpwuid(uid_t uid); +int oliphaunt_wasix_getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen, + struct passwd **result); int pg_char_to_encoding_private(const char *name) @@ -73,7 +77,7 @@ install_fake_postgres(char *dir) " echo 'postgres (PostgreSQL) fake-from-child'\n" " exit 0\n" "fi\n" - "cat > \"$PGLITE_INITDB_STDIN_CAPTURE\"\n"; + "cat > \"$OLIPHAUNT_INITDB_STDIN_CAPTURE\"\n"; CHECK(write_file(path, script) == 0); CHECK(chmod(path, 0700) == 0); return 0; @@ -99,17 +103,17 @@ check_locale_and_fail_closed(void) { CHECK(setenv("PGCLIENTENCODING", "UTF8", 1) == 0); errno = 0; - CHECK(pgl_initdb_popen("uname -a", "r") == NULL); + CHECK(oliphaunt_wasix_initdb_popen("uname -a", "r") == NULL); CHECK(errno == ENOSYS); errno = 0; - CHECK(pgl_initdb_popen("locale -a", "w") == NULL); + CHECK(oliphaunt_wasix_initdb_popen("locale -a", "w") == NULL); CHECK(errno == ENOSYS); - FILE *file = pgl_initdb_popen("locale -a", "r"); + FILE *file = oliphaunt_wasix_initdb_popen("locale -a", "r"); CHECK(file != NULL); char contents[128] = {0}; size_t read_len = fread(contents, 1, sizeof(contents) - 1, file); - CHECK(pgl_initdb_pclose(file) == 0); + CHECK(oliphaunt_wasix_initdb_pclose(file) == 0); CHECK(read_len > 0); CHECK(strstr(contents, "C\n") != NULL); CHECK(strstr(contents, "C.UTF8\n") != NULL); @@ -123,20 +127,20 @@ check_postgres_read_and_write_pipes(char *dir) CHECK(install_fake_postgres(dir) == 0); CHECK(prepend_path(dir) == 0); - FILE *read_pipe = pgl_initdb_popen("postgres -V 2>&1", "r"); + FILE *read_pipe = oliphaunt_wasix_initdb_popen("postgres -V 2>&1", "r"); CHECK(read_pipe != NULL); char version[128] = {0}; CHECK(fread(version, 1, sizeof(version) - 1, read_pipe) > 0); - CHECK(pgl_initdb_pclose(read_pipe) == 0); + CHECK(oliphaunt_wasix_initdb_pclose(read_pipe) == 0); CHECK(strstr(version, "fake-from-child") != NULL); char capture[512]; snprintf(capture, sizeof(capture), "%s/stdin.txt", dir); - CHECK(setenv("PGLITE_INITDB_STDIN_CAPTURE", capture, 1) == 0); - FILE *write_pipe = pgl_initdb_popen("postgres --boot \"quoted arg\"", "w"); + CHECK(setenv("OLIPHAUNT_INITDB_STDIN_CAPTURE", capture, 1) == 0); + FILE *write_pipe = oliphaunt_wasix_initdb_popen("postgres --boot \"quoted arg\"", "w"); CHECK(write_pipe != NULL); CHECK(fputs("bootstrap input\n", write_pipe) >= 0); - CHECK(pgl_initdb_pclose(write_pipe) == 0); + CHECK(oliphaunt_wasix_initdb_pclose(write_pipe) == 0); FILE *captured = fopen(capture, "r"); CHECK(captured != NULL); @@ -150,13 +154,33 @@ check_postgres_read_and_write_pipes(char *dir) static int check_identity(void) { - CHECK(pgl_geteuid() == 123); - CHECK(pgl_getuid() == 123); - struct passwd *pw = pgl_getpwuid(123); + CHECK(oliphaunt_wasix_geteuid() == 123); + CHECK(oliphaunt_wasix_getuid() == 123); + CHECK(oliphaunt_wasix_getegid() == 123); + CHECK(oliphaunt_wasix_getgid() == 123); + struct passwd *pw = oliphaunt_wasix_getpwuid(123); CHECK(pw != NULL); CHECK(strcmp(pw->pw_name, "postgres") == 0); + CHECK(pw->pw_uid == 123); + CHECK(pw->pw_gid == 123); + + struct passwd pwbuf; + struct passwd *result = NULL; + char buf[128]; + CHECK(oliphaunt_wasix_getpwuid_r(123, &pwbuf, buf, sizeof(buf), &result) == 0); + CHECK(result == &pwbuf); + CHECK(strcmp(result->pw_name, "postgres") == 0); + CHECK(result->pw_uid == 123); + CHECK(result->pw_gid == 123); + result = &pwbuf; + CHECK(oliphaunt_wasix_getpwuid_r(999, &pwbuf, buf, sizeof(buf), &result) == 0); + CHECK(result == NULL); errno = 0; - CHECK(pgl_getpwuid(999) == NULL); + CHECK(oliphaunt_wasix_getpwuid_r(123, &pwbuf, buf, 4, &result) == ERANGE); + CHECK(errno == ERANGE); + + errno = 0; + CHECK(oliphaunt_wasix_getpwuid(999) == NULL); CHECK(errno == ENOENT); return 0; } @@ -164,7 +188,7 @@ check_identity(void) int main(void) { - char temp_template[] = "/tmp/pglite-initdb-shim-XXXXXX"; + char temp_template[] = "/tmp/oliphaunt-initdb-shim-XXXXXX"; char *dir = mkdtemp(temp_template); CHECK(dir != NULL); CHECK(check_locale_and_fail_closed() == 0); diff --git a/assets/wasix-build/wasix_shim/pglite_wasix_shim.c b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_shim.c similarity index 100% rename from assets/wasix-build/wasix_shim/pglite_wasix_shim.c rename to src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_shim.c diff --git a/src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh new file mode 100755 index 00000000..e2510eef --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +set -euo pipefail + +oliphaunt_wasix_script_root() { + cd "$(dirname "${BASH_SOURCE[1]}")" && pwd +} + +oliphaunt_wasix_repo_root() { + local root="$1" + git -C "$root" rev-parse --show-toplevel 2>/dev/null || (cd "$root/../../../.." && pwd) +} + +oliphaunt_wasix_generated_root() { + local repo_root="$1" + printf '%s\n' "${CONTAINER_GENERATED_ROOT:-${OLIPHAUNT_WASM_GENERATED_ROOT:-$repo_root/target/oliphaunt-wasix/wasix-build}}" +} + +oliphaunt_wasix_run_extension_build_in_docker_if_needed() { + local root="$1" + local repo_root="$2" + local source_lane="$3" + local repo_relative_script="$4" + local jobs="${JOBS:-4}" + if [ "${OLIPHAUNT_WASM_EXTENSION_BUILD_IN_DOCKER:-0}" = "1" ] || + command -v wasixcc >/dev/null 2>&1; then + return 0 + fi + + local image="${IMAGE:-oliphaunt-wasix-wasix-build:local}" + local docker="${DOCKER:-$(command -v docker 2>/dev/null || true)}" + if [ -z "$docker" ] && [ -x /usr/local/bin/docker ]; then + docker=/usr/local/bin/docker + fi + if [ -z "$docker" ] && [ -x /opt/homebrew/bin/docker ]; then + docker=/opt/homebrew/bin/docker + fi + if [ -z "$docker" ]; then + echo "wasixcc and docker CLI not found; set DOCKER=/path/to/docker or install wasixcc" >&2 + exit 127 + fi + export PATH="$(dirname "$docker"):$PATH" + + local docker_user_args=() + if [ "${OLIPHAUNT_WASM_DOCKER_AS_ROOT:-0}" != "1" ]; then + docker_user_args=(--user "$(id -u):$(id -g)" -e HOME=/tmp) + fi + if [ "${OLIPHAUNT_WASM_SKIP_IMAGE_BUILD:-0}" = "1" ]; then + "$docker" image inspect "$image" >/dev/null 2>&1 || { + echo "WASIX build image is missing: $image" >&2 + exit 1 + } + echo "reusing Docker image $image" + elif [ "${FORCE_IMAGE_BUILD:-0}" = "1" ] || ! "$docker" image inspect "$image" >/dev/null 2>&1; then + "$docker" build \ + -t "$image" \ + -f "$root/docker/Dockerfile" \ + "$root/docker" + else + echo "reusing Docker image $image" + fi + + "$docker" run --rm \ + "${docker_user_args[@]}" \ + --cpus="$jobs" \ + -e OLIPHAUNT_WASM_EXTENSION_BUILD_IN_DOCKER=1 \ + -e OLIPHAUNT_WASM_SOURCE_LANE="$source_lane" \ + -e JOBS="$jobs" \ + -e OLIPHAUNT_WASM_BUILD_PROFILE="${OLIPHAUNT_WASM_BUILD_PROFILE:-release}" \ + -e OLIPHAUNT_WASM_WASIX_COPT="${OLIPHAUNT_WASM_WASIX_COPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_LOPT="${OLIPHAUNT_WASM_WASIX_LOPT:-}" \ + -e OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT="${OLIPHAUNT_WASM_WASIX_CONFIGURE_WASM_OPT:-no}" \ + -e OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT="${OLIPHAUNT_WASM_WASIX_BUILD_WASM_OPT:-yes}" \ + -e OLIPHAUNT_WASM_WASM_OPT_FLAGS="${OLIPHAUNT_WASM_WASM_OPT_FLAGS-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT="${OLIPHAUNT_WASM_WASM_OPT_SUPPRESS_DEFAULT-}" \ + -e OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED="${OLIPHAUNT_WASM_WASM_OPT_PRESERVE_UNOPTIMIZED-}" \ + -e OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS="${OLIPHAUNT_WASM_WASIX_COMPILER_FLAGS:-}" \ + -e OLIPHAUNT_WASM_WASIX_LINKER_FLAGS="${OLIPHAUNT_WASM_WASIX_LINKER_FLAGS:-}" \ + -e WASIX_HOME=/opt/wasixcc-home/.wasixcc \ + -v "$repo_root:/work" \ + -w /work \ + "$image" \ + bash -lc "./$repo_relative_script" + exit 0 +} + +oliphaunt_wasix_source_commit() { + git -C "$1" rev-parse HEAD +} + +oliphaunt_wasix_script_sha256() { + sha256sum "$1" | awk '{print $1}' +} + +oliphaunt_wasix_extension_wasix_target_values() { + local repo_root="$1" + local extension="$2" + local key="$3" + local target="$repo_root/src/extensions/external/$extension/targets/wasix.toml" + python3 - "$target" "$key" <<'PY' +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + +target = Path(sys.argv[1]) +key = sys.argv[2] +with target.open("rb") as handle: + data = tomllib.load(handle) +values = data.get(key, []) +if not isinstance(values, list) or not all(isinstance(value, str) for value in values): + raise SystemExit(f"{target} field {key} must be an array of strings") +for value in values: + print(value) +PY +} + +oliphaunt_wasix_extension_recipe_value() { + local repo_root="$1" + local extension="$2" + local key="$3" + local recipe="$repo_root/src/extensions/external/$extension/recipe.toml" + python3 - "$recipe" "$key" <<'PY' +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + +recipe = Path(sys.argv[1]) +key = sys.argv[2] +with recipe.open("rb") as handle: + data = tomllib.load(handle) +value = data.get(key) +if not isinstance(value, str) or not value: + raise SystemExit(f"{recipe} field {key} must be a non-empty string") +print(value) +PY +} + +oliphaunt_wasix_extension_source_dir() { + local repo_root="$1" + local extension="$2" + local source + source="$(oliphaunt_wasix_extension_recipe_value "$repo_root" "$extension" source)" + printf '%s\n' "$repo_root/target/oliphaunt-sources/checkouts/$source" +} + +oliphaunt_wasix_extension_build_dir() { + local build_root="$1" + local extension="$2" + printf '%s\n' "$build_root/$extension" +} + +oliphaunt_wasix_extension_wasix_dependencies() { + oliphaunt_wasix_extension_wasix_target_values "$1" "$2" dependencies +} + +oliphaunt_wasix_extension_wasix_configure_flags() { + oliphaunt_wasix_extension_wasix_target_values "$1" "$2" configure_flags +} + +oliphaunt_wasix_extension_wasix_required_build_files() { + oliphaunt_wasix_extension_wasix_target_values "$1" "$2" required_build_files +} + +oliphaunt_wasix_extension_wasix_required_build_globs() { + oliphaunt_wasix_extension_wasix_target_values "$1" "$2" required_build_globs +} + +oliphaunt_wasix_extension_build_outputs_exist() { + local repo_root="$1" + local extension="$2" + local build_dir="$3" + local quiet=0 + if [ "${4:-}" = "--quiet" ]; then + quiet=1 + fi + + local missing=0 + local required_file + while IFS= read -r required_file; do + [ -n "$required_file" ] || continue + if [ ! -f "$build_dir/$required_file" ]; then + if [ "$quiet" -ne 1 ]; then + echo "missing WASIX $extension build output: $build_dir/$required_file" >&2 + fi + missing=1 + fi + done < <(oliphaunt_wasix_extension_wasix_required_build_files "$repo_root" "$extension") + + local required_glob + while IFS= read -r required_glob; do + [ -n "$required_glob" ] || continue + if ! compgen -G "$build_dir/$required_glob" >/dev/null; then + if [ "$quiet" -ne 1 ]; then + echo "missing WASIX $extension build output matching: $build_dir/$required_glob" >&2 + fi + missing=1 + fi + done < <(oliphaunt_wasix_extension_wasix_required_build_globs "$repo_root" "$extension") + + [ "$missing" -eq 0 ] +} + +oliphaunt_wasix_dependency_script_stem() { + case "$1" in + json-c) echo "jsonc" ;; + *) echo "${1//-/_}" ;; + esac +} + +oliphaunt_wasix_dependency_env_prefix() { + case "$1" in + json-c) echo "JSONC" ;; + *) echo "${1//-/_}" | tr '[:lower:]' '[:upper:]' ;; + esac +} + +oliphaunt_wasix_dependency_stamp_file() { + case "$1" in + *) echo ".oliphaunt-wasix-$1-build" ;; + esac +} + +oliphaunt_wasix_export_extension_dependency_prefixes() { + local root="$1" + local repo_root="$2" + local extension="$3" + local dependency script prefix env_prefix env_name + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + script="$root/build_wasix_$(oliphaunt_wasix_dependency_script_stem "$dependency").sh" + if [ ! -f "$script" ]; then + echo "missing WASIX dependency build script for $extension dependency $dependency: $script" >&2 + exit 1 + fi + env_prefix="$(oliphaunt_wasix_dependency_env_prefix "$dependency")" + env_name="${env_prefix}_PREFIX" + prefix="${!env_name:-}" + if [ -z "$prefix" ]; then + prefix="$("$script")" + fi + printf -v "$env_name" '%s' "$prefix" + export "$env_name" + done < <(oliphaunt_wasix_extension_wasix_dependencies "$repo_root" "$extension") +} + +oliphaunt_wasix_extension_dependency_stamp_block() { + local repo_root="$1" + local extension="$2" + local dependency env_prefix env_name prefix stamp_file + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + env_prefix="$(oliphaunt_wasix_dependency_env_prefix "$dependency")" + env_name="${env_prefix}_PREFIX" + prefix="${!env_name:-}" + if [ -z "$prefix" ]; then + echo "missing exported dependency prefix $env_name for $extension" >&2 + exit 1 + fi + stamp_file="$(oliphaunt_wasix_dependency_stamp_file "$dependency")" + if [ ! -f "$prefix/$stamp_file" ]; then + echo "missing WASIX dependency stamp for $extension dependency $dependency: $prefix/$stamp_file" >&2 + exit 1 + fi + printf '%s=%s\n' "$dependency" "$(cat "$prefix/$stamp_file")" + done < <(oliphaunt_wasix_extension_wasix_dependencies "$repo_root" "$extension") +} + +oliphaunt_wasix_extension_configure_signature() { + local repo_root="$1" + local extension="$2" + local flags=() + local flag + while IFS= read -r flag; do + [ -n "$flag" ] && flags+=("$flag") + done < <(oliphaunt_wasix_extension_wasix_configure_flags "$repo_root" "$extension") + local joined + joined="$(printf '%s\n' "${flags[@]}" | paste -sd ',' -)" + printf '%s\n' "${joined:-none}" +} + +oliphaunt_wasix_copy_source_clean() { + local source_dir="$1" + local build_dir="$2" + rm -rf "$build_dir" + mkdir -p "$build_dir" + cp -a "$source_dir/." "$build_dir/" + rm -rf "$build_dir/.git" +} + +oliphaunt_wasix_write_cmake_toolchain() { + local toolchain_file="$1" + mkdir -p "$(dirname "$toolchain_file")" + cat >"$toolchain_file" <<'EOF' +set(CMAKE_SYSTEM_NAME Generic) +set(CMAKE_C_COMPILER wasixcc) +set(CMAKE_CXX_COMPILER wasixcc++) +set(CMAKE_AR wasixar) +set(CMAKE_RANLIB wasixranlib) +set(CMAKE_C_COMPILER_WORKS TRUE) +set(CMAKE_CXX_COMPILER_WORKS TRUE) +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) +EOF +} + +oliphaunt_wasix_static_cmake_build() { + local source_dir="$1" + local build_dir="$2" + local prefix="$3" + shift 3 + local toolchain_file="$build_dir/oliphaunt-wasix-toolchain.cmake" + oliphaunt_wasix_write_cmake_toolchain "$toolchain_file" + cmake -S "$source_dir" -B "$build_dir" \ + -DCMAKE_TOOLCHAIN_FILE="$toolchain_file" \ + -DCMAKE_INSTALL_PREFIX="$prefix" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_C_VISIBILITY_PRESET=hidden \ + -DCMAKE_CXX_VISIBILITY_PRESET=hidden \ + -DCMAKE_VISIBILITY_INLINES_HIDDEN=ON \ + -DCMAKE_C_FLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC -fvisibility=hidden -Wno-unused-command-line-argument" \ + -DCMAKE_CXX_FLAGS="$OLIPHAUNT_WASM_PROFILE_CFLAGS -fPIC -fvisibility=hidden -fvisibility-inlines-hidden -Wno-unused-command-line-argument" \ + "$@" + cmake --build "$build_dir" --parallel "$JOBS" + cmake --install "$build_dir" +} diff --git a/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 b/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 new file mode 100644 index 00000000..c2f86862 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 @@ -0,0 +1 @@ +05f759c0722a5bb4ddf51bef79a1dbe8eafa12892abc1a9220f16eceb9390dbd diff --git a/assets/generated/wasix-dl.exports b/src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports similarity index 80% rename from assets/generated/wasix-dl.exports rename to src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports index 2f11f960..99a066ff 100644 --- a/assets/generated/wasix-dl.exports +++ b/src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports @@ -1,8 +1,6 @@ -AcceptInvalidationMessages AcquireRewriteLocks +AggCheckCallContext AllocSetContextCreateInternal -AlterSequence -AlterTable ArrayGetIntegerTypmods ArrayGetNItems Async_Notify @@ -19,23 +17,18 @@ BufferGetBlockNumber BuildIndexInfo BuildTupleFromCStrings CacheMemoryContext -CacheRegisterRelcacheCallback CacheRegisterSyscacheCallback CachedPlanAllowsSimpleValidityCheck CachedPlanIsSimplyValid +CallerFInfoFunctionCall1 CallerFInfoFunctionCall2 -CatalogCloseIndexes -CatalogOpenIndexes CatalogTupleDelete CatalogTupleInsert -CatalogTupleInsertWithInfo CatalogTupleUpdate -ChangeVarNodes CheckFunctionValidatorAccess CheckIndexCompatible CheckTableNotInUse CheckXidAlive -ChooseRelationName CommandCounterIncrement ConditionVariableCancelSleep ConditionVariableInit @@ -45,22 +38,23 @@ ConditionalLockBuffer ConditionalLockRelationOid CopyErrorData CopyFromErrorCallback -CreateCacheMemoryContext +CopyIndexTuple CreateDestReceiver CreateExecutorState CreateExprContext CreateParallelContext CreateQueryDesc -CreateSchemaCommand CreateTableAsRelExists CreateTemplateTupleDesc CreateTransientRelDestReceiver CreateTrigger CreateTupleDescCopy +CreateTupleDescTruncatedCopy CritSectionCount CurrentMemoryContext CurrentResourceOwner DatumGetEOHP +DecodeDateTime DecrTupleDescRefCount DefineCustomBoolVariable DefineCustomEnumVariable @@ -69,7 +63,6 @@ DefineCustomRealVariable DefineCustomStringVariable DefineIndex DefineRelation -DefineSequence DeleteExpandedObject DestroyParallelContext DirectFunctionCall1Coll @@ -87,33 +80,13 @@ EncodeTimeOnly EndCopyFrom EnsurePortalSnapshotExists EnterParallelMode +EvictAllUnpinnedBuffers +EvictRelUnpinnedBuffers EvictUnpinnedBuffer -ExecAssignExprContext -ExecAssignProjectionInfo -ExecCloseIndices -ExecCloseRangeTableRelations -ExecCloseResultRelations -ExecConstraints ExecDropSingleTupleTableSlot -ExecEndNode -ExecFetchSlotHeapTuple -ExecGetResultType ExecInitExpr ExecInitExprWithParams -ExecInitExtraTupleSlot -ExecInitNode -ExecInitQual -ExecInitRangeTable -ExecInitResultRelation -ExecInitScanTupleSlot -ExecInsertIndexTuples -ExecOpenIndices -ExecPrepareQual -ExecReScan -ExecStoreHeapTuple ExecStoreVirtualTuple -ExecUpdateLockMode -ExecWithCheckOptions ExecuteTruncateGuts ExecutorEnd ExecutorEnd_hook @@ -135,13 +108,11 @@ ExplainQueryParameters ExplainQueryText ExprEvalPushStep ExtendBufferedRel -FigureColname Float8GetDatum FlushErrorState FlushOneBuffer FlushRelationBuffers FreeAccessStrategy -FreeBulkInsertState FreeCachedExpression FreeExecutorState FreeExprContext @@ -158,14 +129,14 @@ GenericXLogRegisterBuffer GenericXLogStart GetAccessStrategy GetActiveSnapshot -GetBulkInsertState +GetAttributeByNum GetCachedExpression GetCommandTagName -GetCurrentCommandId GetCurrentSubTransactionId GetCurrentTimestamp GetDatabaseEncoding GetDatabaseEncodingName +GetDefaultCharSignedness GetDefaultOpClass GetErrorContextStack GetFlushRecPtr @@ -174,6 +145,7 @@ GetForeignDataWrapper GetForeignServer GetForeignTable GetFreeIndexPage +GetMemoryChunkContext GetOldestNonRemovableTransactionId GetRecordedFreeSpace GetRunningTransactionData @@ -184,6 +156,7 @@ GetTransactionSnapshot GetUserId GetUserIdAndSecContext GetXLogReplayRecPtr +GinDataLeafPageGetItems HeapTupleGetUpdateXid HeapTupleHeaderGetDatum HeapTupleSatisfiesVacuum @@ -191,7 +164,6 @@ IncrementVarSublevelsUp IndexFreeSpaceMapVacuum IndexGetRelation InitMaterializedSRF -InitResultRelInfo InitializeParallelDSM InputFunctionCall InstrAlloc @@ -222,7 +194,6 @@ MainLWLockArray MakeExpandedObjectReadOnlyInternal MakePerTupleExprContext MakeSingleTupleTableSlot -MakeTupleTableSlot MarkBufferDirty MarkGUCPrefixReserved MemoryContextAlloc @@ -233,6 +204,7 @@ MemoryContextDelete MemoryContextDeleteChildren MemoryContextGetParent MemoryContextMemAllocated +MemoryContextRegisterResetCallback MemoryContextReset MemoryContextSetIdentifier MemoryContextSetParent @@ -246,8 +218,6 @@ NewExplainState NewGUCNestLevel NewRelationCreateToastTable NextCopyFrom -NextCopyFromRawFields -None_Receiver OidFunctionCall1Coll OidOutputFunctionCall OpernameGetOprid @@ -260,19 +230,20 @@ PageIndexMultiDelete PageIndexTupleOverwrite PageInit ParallelWorkerNumber +ParseDateTime ParseFuncOrColumn PinPortal PopActiveSnapshot PostgresMainLongJmp PostgresMainLoopOnce PostgresSendReadyForQueryIfNecessary +ProcDiePending ProcessCopyOptions ProcessInterrupts ProcessStartupPacket -ProcessUtility -ProcessUtility_hook PushActiveSnapshot PushCopiedSnapshot +QueryCancelPending QueryRewrite RangeVarCallbackMaintainsTable RangeVarGetRelidExtended @@ -284,13 +255,14 @@ ReadNextMultiXactId RecentXmin RecordFreeIndexPage RecoveryInProgress -RegisterExtensibleNodeMethods RegisterSnapshot RegisterSubXactCallback RegisterXactCallback +RelationClose RelationGetIndexList RelationGetIndexScan RelationGetNumberOfBlocksInFork +RelationIdGetRelation RelationIsVisible ReleaseAllPlanCacheRefsInOwner ReleaseBuffer @@ -299,12 +271,12 @@ ReleaseCatCacheList ReleaseCurrentSubTransaction ReleaseSysCache RelnameGetRelid -RemoveObjects -RemoveRelations -RenameSchema RequestAddinShmemSpace ResourceOwnerCreate ResourceOwnerDelete +ResourceOwnerEnlarge +ResourceOwnerForget +ResourceOwnerRemember RestoreBlockImage RestrictSearchPath RmgrNotFound @@ -327,13 +299,16 @@ SPI_execute_extended SPI_execute_plan SPI_execute_plan_extended SPI_execute_plan_with_paramlist +SPI_execute_with_args SPI_finish SPI_fnumber SPI_freeplan SPI_freetuptable SPI_getbinval +SPI_gettype SPI_getvalue SPI_keepplan +SPI_modifytuple SPI_palloc SPI_plan_get_cached_plan SPI_plan_get_plan_sources @@ -351,11 +326,12 @@ SPI_rollback_and_chain SPI_scroll_cursor_fetch SPI_scroll_cursor_move SPI_tuptable -SS_process_sublinks ScanKeyInit ScanKeywordLookup SearchPathMatchesCurrentEnvironment SearchSysCache1 +SearchSysCache2 +SearchSysCache3 SearchSysCacheAttName SearchSysCacheList SetConfigOption @@ -366,8 +342,6 @@ SnapshotAnyData SplitIdentifierString SysCacheGetAttrNotNull SystemFuncName -SystemTypeName -TTSOpsHeapTuple TTSOpsMinimalTuple TTSOpsVirtual TopMemoryContext @@ -391,6 +365,11 @@ UnregisterSnapshot UpdateActiveSnapshotCommandId WaitForParallelWorkersToAttach WaitForParallelWorkersToFinish +WinGetCurrentPosition +WinGetFuncArgCurrent +WinGetFuncArgInPartition +WinGetPartitionLocalMemory +WinGetPartitionRowCount XLogBeginInsert XLogFindNextRecord XLogFlush @@ -420,17 +399,13 @@ _hash_getbuf _hash_ovflblkno_to_bitno _hash_relbuf _start +abort accumArrayResult aclcheck_error acos -addNSItemToQuery -addRangeTableEntry +acosl addRangeTableEntryForENR -addRangeTableEntryForFunction -addRangeTableEntryForJoin -addRangeTableEntryForRelation addRangeTableEntryForSubquery -addTargetToSortList add_exact_object_address add_int_reloption add_local_int_reloption @@ -449,14 +424,14 @@ appendStringInfoStringQuoted appendStringInfoVA array_contains_nulls array_create_iterator +array_free_iterator array_iterate arraycontjoinsel arraycontsel asin -assignSortGroupRef assign_expr_collations -assign_list_collations -assign_query_collations +atan2l +atexit atof atoi be_lo_unlink @@ -467,6 +442,7 @@ bitge bitgt bitle bitlt +block_range_read_stream_cb bloom_add_element bloom_create bloom_free @@ -485,7 +461,6 @@ bms_make_singleton bms_next_member bms_num_members bms_union -bool_int4 boolin boolout bpcharcmp @@ -508,40 +483,32 @@ btint4cmp btint8cmp btnamecmp btoidcmp -btrim1 bttextcmp -build_column_default build_reloptions -buildoidvector byteacmp byteaeq byteage byteagt byteale bytealt -can_coerce_type -cancel_parser_errposition_callback +cached_function_compile +calloc cash_cmp +cfunc_resolve_polymorphic_argtypes checkExprHasSubLink -checkNameSpaceConflicts check_amop_signature check_amoptsproc_signature check_amproc_signature -check_enable_rls check_function_bodies -check_functional_grouping -check_object_ownership check_stack_depth clamp_row_est clauselist_selectivity clearerr clock_gettime -coerce_to_boolean -coerce_to_common_type -coerce_to_specific_type +close +closedir coerce_to_target_type coerce_type -colNameToVar construct_array construct_array_builtin construct_empty_array @@ -549,22 +516,17 @@ construct_md_array contain_aggs_of_level contain_mutable_functions contain_nonstrict_functions -contain_vars_of_level convert_network_to_scalar convert_tuples_by_position copyObjectImpl core_yylex cos -count_nonjunk_tlist_entries +cosl cpu_tuple_cost create_foreignscan_path create_queryEnv cstring_to_text cstring_to_text_with_len -dacos -dasin -datan -datan2 date_cmp date_eq date_ge @@ -575,24 +537,16 @@ date_mi datumCopy datumIsEqual datumTransfer -datum_image_eq -datum_image_hash -dcos -dcot -debackslash debug_query_string deconstruct_array deconstruct_array_builtin deconstruct_expanded_record defGetBoolean defGetString -degrees +destroyStringInfo detoast_external_attr -dexp domain_check downcase_truncate_identifier -dpi -drandom dsa_allocate_extended dsa_attach dsa_create_ext @@ -620,11 +574,6 @@ dshash_release_lock dshash_seq_init dshash_seq_next dshash_seq_term -dsin -dtan -dtoi2 -dtoi4 -dtoi8 end_MultiFuncCall enlargeStringInfo enum_cmp @@ -654,12 +603,10 @@ errsave_start errstart errstart_cold escape_json +escape_json_with_len estimate_expression_value eval_const_expressions execute_attr_map_tuple -expandNSItemAttrs -expandNSItemVars -expandRTE expand_array expanded_record_fetch_field expanded_record_fetch_tupdesc @@ -675,29 +622,25 @@ exprTypmod expression_tree_mutator_impl expression_tree_walker_impl extract_actual_clauses -extract_variadic_args fclose +feof ferror -fetch_search_path fflush +fgets +fileno find_coercion_pathway find_inheritance_children find_nonnullable_rels +find_option find_rendezvous_variable finish_heap_swap fix_opfuncids flatten_join_alias_vars -float4_numeric +float4_cmp_internal float4in_internal -float8_accum -float8_numeric -float8_stddev_pop -float8_stddev_samp -float8in +float8_cmp_internal float8in_internal -float8out float8out_internal -float8pl float_overflow_error float_to_shortest_decimal_buf float_to_shortest_decimal_bufn @@ -705,7 +648,6 @@ float_underflow_error fmgr_info fmgr_info_copy fmgr_info_cxt -fmod fopen forkname_to_number format_elog_string @@ -715,47 +657,53 @@ format_type_be format_type_be_qualified format_type_extended format_type_with_typemod +fprintf +fputc +fputs fread free free_attstatsslot free_object_addresses -free_parsestate +fseek +fstat +ftell function_parse_error_transpose +fwrite +gen_random_uuid generate_operator_clause generic_restriction_selectivity genericcostestimate getBaseType getClosestMatch -getExtensionOfObject getTypeInputInfo getTypeOutputInfo get_am_oid get_array_type get_attname +get_attnum get_attstatsslot get_base_element_type -get_call_expr_argtype get_call_result_type get_collation_oid +get_commutator get_element_type -get_extension_name -get_fn_expr_arg_stable +get_extension_oid get_fn_expr_argtype get_fn_expr_rettype -get_fn_expr_variadic get_fn_opclass_options get_func_arg_info get_func_name get_func_namespace +get_func_support +get_function_sibling_type get_mergejoin_opfamilies get_namespace_name -get_namespace_name_or_temp get_namespace_oid -get_object_address get_opcode +get_opfamily_member +get_opfamily_name get_primary_key_attnos get_rel_name -get_rel_namespace get_rel_relkind get_rel_type_id get_relids_in_jointree @@ -763,25 +711,32 @@ get_relkind_objtype get_relname_relid get_restriction_variable get_row_security_policies -get_rte_attribute_name -get_sort_group_operators get_sortgroupclause_tle get_tablespace_name get_tablespace_page_costs +get_toast_snapshot get_ts_config_oid get_tsearch_config_filename +get_typbyval get_typcollation +get_typlen get_typlenbyval get_typlenbyvalalign get_typsubscript get_typtype getc -geterrcode +getentropy +getenv geterrposition getinternalerrposition getmissingattr getpid +gettimeofday +ginCompareAttEntries ginPostingListDecode +gintuple_get_attrnum +gintuple_get_key +gmtime guc_malloc has_fn_opclass_options has_privs_of_role @@ -790,27 +745,16 @@ hash_bytes hash_bytes_extended hash_create hash_destroy -hash_freeze hash_get_num_entries -hash_numeric_extended hash_search hash_seq_init hash_seq_search -hash_seq_term -hashcharextended -hashfloat8extended -hashint8extended heap_deform_tuple -heap_delete heap_fetch heap_form_tuple heap_freetuple -heap_getnext heap_getsysattr -heap_insert -heap_lock_tuple heap_modify_tuple -heap_multi_insert heap_reloptions heap_tuple_needs_eventual_freeze identify_opfamily_groups @@ -823,21 +767,11 @@ index_open inet_in initArrayResult initClosestMatch +initGinState initStringInfo init_MultiFuncCall init_local_reloptions -init_toast_snapshot -int2_numeric -int4_bool -int4_numeric -int4in int64_to_numeric -int82 -int84 -int8_numeric -int8in -int8out -int8pl internalerrposition internalerrquery interval_cmp @@ -848,37 +782,28 @@ interval_le interval_lt interval_mi interval_um -isTempNamespace +is_pseudo_constant_for_index isalnum j2date -json_in -json_validate +jsonb_in lappend lappend_int lappend_oid -lcons -list_append_unique list_concat list_concat_copy list_copy list_delete_cell list_delete_last list_delete_nth_cell -list_delete_ptr list_free list_free_deep list_insert_nth -list_intersection_int list_make1_impl list_make2_impl list_make3_impl -list_make4_impl list_member_int list_sort -list_truncate -list_union_int -locate_agg_of_level -locate_var_of_level +llround log log_newpage_buffer log_newpage_range @@ -886,9 +811,6 @@ lookup_rowtype_tupdesc lookup_rowtype_tupdesc_domain lookup_ts_dictionary_cache lookup_type_cache -lowerstr -lowerstr_with_len -ltrim1 macaddr8_cmp macaddr8_eq macaddr8_ge @@ -902,131 +824,129 @@ macaddr_gt macaddr_le macaddr_lt maintenance_work_mem -makeA_Expr makeAlias makeArrayResult -makeBoolExpr -makeBoolean makeColumnDef makeConst makeDefElem -makeFloat -makeFromExpr makeFuncCall makeFuncExpr -makeInteger -makeNullConst makeObjectName makeParamList makeRangeVar makeRangeVarFromNameList -makeSimpleA_Expr makeString makeStringInfo +makeStringInfoExt makeTargetEntry makeTypeName makeTypeNameFromNameList makeVar -makeWholeRowVar make_expanded_record_from_exprecord make_expanded_record_from_tupdesc make_expanded_record_from_typeid make_foreignscan make_new_heap -make_op make_opclause make_parsestate -make_scalar_array_op malloc -markNullableIfNeeded -markTargetListOrigins -markVarForSelectPriv max_parallel_maintenance_workers memchr memcmp +memset +mul_size namein -namestrcpy +nanosleep network_cmp new_object_addresses -nextval_internal +nextafterf nocache_index_getattr nocachegetattr -nodeRead nodeToString -numeric_abs -numeric_add -numeric_ceil numeric_cmp numeric_div numeric_eq -numeric_exp numeric_float4 -numeric_float8 numeric_float8_no_overflow -numeric_floor numeric_ge numeric_gt numeric_in -numeric_int2 -numeric_int4 -numeric_int8 numeric_is_nan numeric_le -numeric_ln -numeric_log numeric_lt -numeric_mod -numeric_mul -numeric_normalize -numeric_out -numeric_power -numeric_round -numeric_sign -numeric_sqrt numeric_sub -numeric_uminus object_access_hook object_ownercheck oidin oidout +oliphaunt_wasix_get_proc_port +oliphaunt_wasix_input_available +oliphaunt_wasix_input_reset +oliphaunt_wasix_input_write +oliphaunt_wasix_output_len +oliphaunt_wasix_output_read +oliphaunt_wasix_output_reset +oliphaunt_wasix_pq_flush +oliphaunt_wasix_protocol_stream_active +oliphaunt_wasix_send_conn_data +oliphaunt_wasix_set_active +oliphaunt_wasix_set_force_host_error_recovery +oliphaunt_wasix_set_protocol_stdio +oliphaunt_wasix_set_protocol_transport +oliphaunt_wasix_start op_hashjoinable op_mergejoinable -outNode -outToken +open +opendir pairingheap_add pairingheap_allocate pairingheap_first pairingheap_remove_first palloc palloc0 +palloc0_mul palloc_extended -parserOpenTable parser_errposition per_MultiFuncCall -performDeletion performMultipleDeletions pfree pg_any_to_server +pg_ascii_tolower pg_bindtextdomain pg_checksum_page -pg_class_aclcheck pg_crc32_table +pg_cryptohash_create +pg_cryptohash_error +pg_cryptohash_final +pg_cryptohash_free +pg_cryptohash_init +pg_cryptohash_update pg_database_encoding_max_length pg_detoast_datum pg_detoast_datum_copy pg_detoast_datum_packed +pg_detoast_datum_slice +pg_do_encoding_conversion +pg_encoding_max_length pg_fprintf pg_get_indexdef_columns_extended pg_get_querydef +pg_get_shmem_pagesize pg_global_prng_state -pg_lltoa +pg_is_ascii pg_ltoa pg_mb2wchar_with_len -pg_mblen +pg_mblen_cstr +pg_mblen_range +pg_mblen_unbounded pg_mbstrlen_with_len +pg_newlocale_from_collation +pg_numa_init +pg_numa_query_pages pg_number_of_ones pg_parse_query pg_plan_query +pg_popcount64 pg_popcount_optimized pg_printf pg_prng_double @@ -1049,28 +969,13 @@ pg_server_to_any pg_snprintf pg_sprintf pg_strcasecmp -pg_strncasecmp +pg_strfold pg_strong_random -pg_strtoint64 -pg_strtok -pg_tolower -pg_toupper pg_utf_mblen_private +pg_verifymbstr pg_vfprintf +pg_vsnprintf pg_wchar2mb_with_len -pgl_getMyProcPort -pgl_pq_flush -pgl_sendConnData -pgl_setPGliteActive -pgl_set_force_host_error_recovery -pgl_set_protocol_stdio -pgl_startPGlite -pgl_wasix_input_available -pgl_wasix_input_reset -pgl_wasix_input_write -pgl_wasix_output_len -pgl_wasix_output_read -pgl_wasix_output_reset pgstat_assoc_relation pgstat_count_heap_insert pgstat_count_truncate @@ -1079,8 +984,8 @@ pgstat_report_activity plan_create_index_workers planner_hook pnstrdup +posix_memalign post_parse_analyze_hook -pow pq_begintypsend pq_buffer_remaining_data pq_copymsgbytes @@ -1089,7 +994,6 @@ pq_getmsgbyte pq_getmsgfloat4 pq_getmsgfloat8 pq_getmsgint -pq_getmsgint64 pq_getmsgtext pq_sendbytes pq_sendfloat4 @@ -1099,104 +1003,104 @@ pre_format_elog_string process_shared_preload_libraries_in_progress psprintf pstrdup -pthread_mutex_lock -pthread_mutex_unlock pull_varattnos pull_varnos pull_vars_of_level pushJsonbValue +qsort qsort_arg query_tree_walker_impl quote_identifier quote_literal_cstr quote_qualified_identifier -radians -raw_expression_tree_walker_impl raw_parser +read read_local_xlog_page_no_wait +read_stream_begin_relation +read_stream_end +read_stream_next_buffer +readdir readstoplist realloc -realpath recordDependencyOn recordDependencyOnExpr -refnameNamespaceItem +regclassin regconfigout -regexp_split_to_array register_ENR register_reloptions_validator -regtypeout relation_close relation_open relation_openrv remove_nulling_relids repalloc -replace_text +repalloc0 reservoir_get_next_S reservoir_init_selection_state resetStringInfo -resolve_polymorphic_argtypes -row_security_policy_hook_permissive -row_security_policy_hook_restrictive -rtrim1 +roundf s_lock sampler_random_fract sampler_random_init_state -scanNSItemForColumn scanner_finish scanner_init scanner_isspace searchstoplist -select_common_collation -select_common_type seq_page_cost +set_config_option set_errcontext_domain set_rel_pathlist_hook -setup_parser_errposition_callback -setval_oid shm_toc_allocate shm_toc_insert shm_toc_lookup shmem_request_hook shmem_startup_hook +sigaction +signal sigsetjmp sin +sinl slot_getsomeattrs_int smgrexists smgrnblocks smgropen smgrpin smgrtruncate -smgrtruncate2 +sprintf +sqrtl +sscanf standard_ExecutorEnd standard_ExecutorFinish standard_ExecutorRun standard_ExecutorStart -standard_ProcessUtility standard_planner stat +std_typanalyze +stderr stdin stdout str_tolower -strcasecmp strcat strchr strcmp strcpy +strcspn +strdup +strerror_r stringToNode stringToQualifiedNameList -strip_implicit_coercions strlcpy strlen strncat strncmp strncpy +strpbrk +strrchr strspn strstr -strtod strtof strtoint +strtok strtol -strtoll strtoul superuser systable_beginscan @@ -1205,9 +1109,8 @@ systable_endscan systable_endscan_ordered systable_getnext systable_getnext_ordered -t_isalnum -t_isdigit -t_isspace +t_isalnum_cstr +t_isalnum_with_len table_beginscan_parallel table_close table_open @@ -1215,23 +1118,19 @@ table_parallelscan_estimate table_parallelscan_initialize table_slot_callbacks table_slot_create -tag_hash -targetIsInSortList tbm_add_tuples +tcgetattr +tcsetattr textToQualifiedNameList text_ge text_gt text_le text_left text_lt -text_reverse -text_right -text_substr -text_substr_no_len text_to_cstring +text_to_cstring_buffer texteq -textregexeq -time2tm +time time_cmp time_eq time_ge @@ -1247,38 +1146,32 @@ timestamp_gt timestamp_le timestamp_lt timestamp_mi -timetz2tm timetz_cmp +tm2timestamp to_hex32 to_tsvector to_tsvector_byid toast_close_indexes toast_open_indexes +toast_raw_datum_size tolower toupper transformDistinctClause transformExpr transformRelOptions -transformSortClause transformStmt -truncate_identifier tsearch_readline tsearch_readline_begin tsearch_readline_end tuplesort_attach_shared -tuplesort_begin_datum tuplesort_begin_heap tuplesort_end tuplesort_estimate_shared -tuplesort_getdatum tuplesort_gettupleslot tuplesort_initialize_shared tuplesort_performsort -tuplesort_putdatum tuplesort_puttupleslot -tuplesort_rescan tuplesort_reset -tuplesort_skiptuples tuplestore_begin_heap tuplestore_clear tuplestore_end @@ -1300,12 +1193,13 @@ unpack_sql_state untransformRelOptions updateClosestMatch uuid_cmp +uuid_in vacuum_delay_point varbit_in varstr_cmp varstr_levenshtein varstr_levenshtein_less_equal -verify_common_type +vfprintf visibilitymap_clear visibilitymap_get_status visibilitymap_pin diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml new file mode 100644 index 00000000..ee5eb0a5 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oliphaunt-wasix-aot-aarch64-apple-darwin" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on aarch64-apple-darwin" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = true +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md new file mode 100644 index 00000000..c187ecc1 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-aot-aarch64-apple-darwin + +Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. +Do not depend on this crate directly. diff --git a/crates/aot/x86_64-pc-windows-msvc/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/build.rs similarity index 80% rename from crates/aot/x86_64-pc-windows-msvc/build.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/build.rs index 9de0b650..9698c159 100644 --- a/crates/aot/x86_64-pc-windows-msvc/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/build.rs @@ -3,12 +3,12 @@ use std::fs; use std::path::{Path, PathBuf}; fn main() { - println!("cargo:rerun-if-env-changed=PGLITE_OXIDE_GENERATED_AOT_DIR"); + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("pglite-oxide-aot-") - .expect("AOT crate name starts with pglite-oxide-aot-") + .strip_prefix("oliphaunt-wasix-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -23,7 +23,7 @@ fn main() { } fn emit_expected_artifact_inputs(target: &str) { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -36,13 +36,8 @@ fn emit_expected_artifact_inputs(target: &str) { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - emit_manifest_probe(&repo_root.join("target/pglite-oxide/aot").join(target)); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); } emit_manifest_probe(&manifest_dir.join("artifacts")); } @@ -56,7 +51,7 @@ fn emit_manifest_probe(dir: &Path) { } fn find_artifact_dir(target: &str) -> Option { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -71,13 +66,8 @@ fn find_artifact_dir(target: &str) -> Option { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - let target_artifacts = repo_root.join("target/pglite-oxide/aot").join(target); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); if target_artifacts.join("manifest.json").is_file() { return Some(target_artifacts); } @@ -91,6 +81,15 @@ fn find_artifact_dir(target: &str) -> Option { None } +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + fn emit_rerun_directives(artifact_dir: &Path) { println!("cargo:rerun-if-changed={}", artifact_dir.display()); if let Ok(entries) = fs::read_dir(artifact_dir) { @@ -160,11 +159,15 @@ fn write_source_only_aot(out: &Path, target: &str) { fn artifact_name_from_file_stem(stem: &str) -> String { match stem { - "pglite" => "runtime:pglite".to_owned(), + "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } extension => format!("extension:{extension}"), } } diff --git a/crates/aot/aarch64-apple-darwin/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/src/lib.rs similarity index 100% rename from crates/aot/aarch64-apple-darwin/src/lib.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/src/lib.rs diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml new file mode 100644 index 00000000..93a8d606 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on aarch64-unknown-linux-gnu" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = true +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md new file mode 100644 index 00000000..0b7cc227 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-aot-aarch64-unknown-linux-gnu + +Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. +Do not depend on this crate directly. diff --git a/crates/aot/aarch64-apple-darwin/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/build.rs similarity index 80% rename from crates/aot/aarch64-apple-darwin/build.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/build.rs index 9de0b650..9698c159 100644 --- a/crates/aot/aarch64-apple-darwin/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/build.rs @@ -3,12 +3,12 @@ use std::fs; use std::path::{Path, PathBuf}; fn main() { - println!("cargo:rerun-if-env-changed=PGLITE_OXIDE_GENERATED_AOT_DIR"); + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("pglite-oxide-aot-") - .expect("AOT crate name starts with pglite-oxide-aot-") + .strip_prefix("oliphaunt-wasix-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -23,7 +23,7 @@ fn main() { } fn emit_expected_artifact_inputs(target: &str) { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -36,13 +36,8 @@ fn emit_expected_artifact_inputs(target: &str) { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - emit_manifest_probe(&repo_root.join("target/pglite-oxide/aot").join(target)); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); } emit_manifest_probe(&manifest_dir.join("artifacts")); } @@ -56,7 +51,7 @@ fn emit_manifest_probe(dir: &Path) { } fn find_artifact_dir(target: &str) -> Option { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -71,13 +66,8 @@ fn find_artifact_dir(target: &str) -> Option { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - let target_artifacts = repo_root.join("target/pglite-oxide/aot").join(target); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); if target_artifacts.join("manifest.json").is_file() { return Some(target_artifacts); } @@ -91,6 +81,15 @@ fn find_artifact_dir(target: &str) -> Option { None } +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + fn emit_rerun_directives(artifact_dir: &Path) { println!("cargo:rerun-if-changed={}", artifact_dir.display()); if let Ok(entries) = fs::read_dir(artifact_dir) { @@ -160,11 +159,15 @@ fn write_source_only_aot(out: &Path, target: &str) { fn artifact_name_from_file_stem(stem: &str) -> String { match stem { - "pglite" => "runtime:pglite".to_owned(), + "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } extension => format!("extension:{extension}"), } } diff --git a/crates/aot/aarch64-unknown-linux-gnu/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/src/lib.rs similarity index 100% rename from crates/aot/aarch64-unknown-linux-gnu/src/lib.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/src/lib.rs diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml new file mode 100644 index 00000000..0261af6d --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oliphaunt-wasix-aot-x86_64-pc-windows-msvc" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on x86_64-pc-windows-msvc" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = true +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md new file mode 100644 index 00000000..ed2ee60c --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-aot-x86_64-pc-windows-msvc + +Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. +Do not depend on this crate directly. diff --git a/crates/aot/aarch64-unknown-linux-gnu/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/build.rs similarity index 80% rename from crates/aot/aarch64-unknown-linux-gnu/build.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/build.rs index 9de0b650..9698c159 100644 --- a/crates/aot/aarch64-unknown-linux-gnu/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/build.rs @@ -3,12 +3,12 @@ use std::fs; use std::path::{Path, PathBuf}; fn main() { - println!("cargo:rerun-if-env-changed=PGLITE_OXIDE_GENERATED_AOT_DIR"); + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("pglite-oxide-aot-") - .expect("AOT crate name starts with pglite-oxide-aot-") + .strip_prefix("oliphaunt-wasix-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -23,7 +23,7 @@ fn main() { } fn emit_expected_artifact_inputs(target: &str) { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -36,13 +36,8 @@ fn emit_expected_artifact_inputs(target: &str) { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - emit_manifest_probe(&repo_root.join("target/pglite-oxide/aot").join(target)); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); } emit_manifest_probe(&manifest_dir.join("artifacts")); } @@ -56,7 +51,7 @@ fn emit_manifest_probe(dir: &Path) { } fn find_artifact_dir(target: &str) -> Option { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -71,13 +66,8 @@ fn find_artifact_dir(target: &str) -> Option { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - let target_artifacts = repo_root.join("target/pglite-oxide/aot").join(target); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); if target_artifacts.join("manifest.json").is_file() { return Some(target_artifacts); } @@ -91,6 +81,15 @@ fn find_artifact_dir(target: &str) -> Option { None } +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + fn emit_rerun_directives(artifact_dir: &Path) { println!("cargo:rerun-if-changed={}", artifact_dir.display()); if let Ok(entries) = fs::read_dir(artifact_dir) { @@ -160,11 +159,15 @@ fn write_source_only_aot(out: &Path, target: &str) { fn artifact_name_from_file_stem(stem: &str) -> String { match stem { - "pglite" => "runtime:pglite".to_owned(), + "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } extension => format!("extension:{extension}"), } } diff --git a/crates/aot/x86_64-pc-windows-msvc/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/src/lib.rs similarity index 100% rename from crates/aot/x86_64-pc-windows-msvc/src/lib.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/src/lib.rs diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml new file mode 100644 index 00000000..cc2c654e --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +description = "Internal Wasmer AOT artifacts for oliphaunt-wasix on x86_64-unknown-linux-gnu" +repository = "https://github.com/f0rr0/oliphaunt" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = true +include = ["Cargo.toml", "README.md", "build.rs", "src/**", "artifacts/**"] + +[lib] +path = "src/lib.rs" diff --git a/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md new file mode 100644 index 00000000..41e7d548 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/README.md @@ -0,0 +1,4 @@ +# oliphaunt-wasix-aot-x86_64-unknown-linux-gnu + +Internal target-specific Wasmer AOT artifact crate for `oliphaunt-wasix`. +Do not depend on this crate directly. diff --git a/crates/aot/x86_64-unknown-linux-gnu/build.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/build.rs similarity index 80% rename from crates/aot/x86_64-unknown-linux-gnu/build.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/build.rs index 9de0b650..9698c159 100644 --- a/crates/aot/x86_64-unknown-linux-gnu/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/build.rs @@ -3,12 +3,12 @@ use std::fs; use std::path::{Path, PathBuf}; fn main() { - println!("cargo:rerun-if-env-changed=PGLITE_OXIDE_GENERATED_AOT_DIR"); + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_AOT_DIR"); let target = env::var("CARGO_PKG_NAME") .expect("CARGO_PKG_NAME is set by Cargo") - .strip_prefix("pglite-oxide-aot-") - .expect("AOT crate name starts with pglite-oxide-aot-") + .strip_prefix("oliphaunt-wasix-aot-") + .expect("AOT crate name starts with oliphaunt-wasix-aot-") .to_owned(); emit_expected_artifact_inputs(&target); @@ -23,7 +23,7 @@ fn main() { } fn emit_expected_artifact_inputs(target: &str) { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -36,13 +36,8 @@ fn emit_expected_artifact_inputs(target: &str) { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - emit_manifest_probe(&repo_root.join("target/pglite-oxide/aot").join(target)); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/aot").join(target)); } emit_manifest_probe(&manifest_dir.join("artifacts")); } @@ -56,7 +51,7 @@ fn emit_manifest_probe(dir: &Path) { } fn find_artifact_dir(target: &str) -> Option { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_AOT_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_AOT_DIR") { let path = PathBuf::from(path); let candidate = if path.ends_with(target) { path @@ -71,13 +66,8 @@ fn find_artifact_dir(target: &str) -> Option { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - let target_artifacts = repo_root.join("target/pglite-oxide/aot").join(target); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_artifacts = repo_root.join("target/oliphaunt-wasix/aot").join(target); if target_artifacts.join("manifest.json").is_file() { return Some(target_artifacts); } @@ -91,6 +81,15 @@ fn find_artifact_dir(target: &str) -> Option { None } +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .is_file() + }) +} + fn emit_rerun_directives(artifact_dir: &Path) { println!("cargo:rerun-if-changed={}", artifact_dir.display()); if let Ok(entries) = fs::read_dir(artifact_dir) { @@ -160,11 +159,15 @@ fn write_source_only_aot(out: &Path, target: &str) { fn artifact_name_from_file_stem(stem: &str) -> String { match stem { - "pglite" => "runtime:pglite".to_owned(), + "oliphaunt" => "runtime:oliphaunt".to_owned(), "pg_dump" => "tool:pg_dump".to_owned(), "initdb" => "tool:initdb".to_owned(), "plpgsql" => "runtime-support:plpgsql".to_owned(), "dict_snowball" => "runtime-support:dict_snowball".to_owned(), + extension_support if extension_support.ends_with("_deps") => { + let sql_name = extension_support.trim_end_matches("_deps"); + format!("extension:{sql_name}:{extension_support}") + } extension => format!("extension:{extension}"), } } diff --git a/crates/aot/x86_64-unknown-linux-gnu/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/src/lib.rs similarity index 100% rename from crates/aot/x86_64-unknown-linux-gnu/src/lib.rs rename to src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/src/lib.rs diff --git a/src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml b/src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml new file mode 100644 index 00000000..71454e89 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "oliphaunt-wasix-assets" +version = "0.6.0" +edition = "2024" +rust-version = "1.93" +description = "Internal Oliphaunt runtime and extension assets for oliphaunt-wasix" +repository = "https://github.com/f0rr0/oliphaunt" +homepage = "https://oliphaunt.dev" +documentation = "https://docs.rs/oliphaunt-wasix-assets" +license = "MIT AND Apache-2.0 AND PostgreSQL" +publish = true +include = [ + "Cargo.toml", + "build.rs", + "README.md", + "src/**", + "payload/**", +] + +[lib] +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/src/runtimes/liboliphaunt/wasix/crates/assets/README.md b/src/runtimes/liboliphaunt/wasix/crates/assets/README.md new file mode 100644 index 00000000..12deba83 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/README.md @@ -0,0 +1,6 @@ +# oliphaunt-wasix-assets + +Internal runtime assets for `oliphaunt-wasix`. + +Do not depend on this crate directly. It is published so Cargo can resolve the +default `oliphaunt-wasix` feature set from crates.io. diff --git a/crates/assets/build.rs b/src/runtimes/liboliphaunt/wasix/crates/assets/build.rs similarity index 84% rename from crates/assets/build.rs rename to src/runtimes/liboliphaunt/wasix/crates/assets/build.rs index 843bf0e1..78656284 100644 --- a/crates/assets/build.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/build.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; fn main() { - println!("cargo:rerun-if-env-changed=PGLITE_OXIDE_GENERATED_ASSETS_DIR"); + println!("cargo:rerun-if-env-changed=OLIPHAUNT_WASM_GENERATED_ASSETS_DIR"); emit_expected_asset_inputs(); let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")); @@ -18,15 +18,15 @@ fn main() { } fn emit_expected_asset_inputs() { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_ASSETS_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_ASSETS_DIR") { emit_manifest_probe(&PathBuf::from(path)); } let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - if let Some(repo_root) = manifest_dir.parent().and_then(Path::parent) { - emit_manifest_probe(&repo_root.join("target/pglite-oxide/assets")); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + emit_manifest_probe(&repo_root.join("target/oliphaunt-wasix/assets")); } emit_manifest_probe(&manifest_dir.join("payload")); } @@ -40,7 +40,7 @@ fn emit_manifest_probe(dir: &Path) { } fn find_asset_dir() -> Option { - if let Some(path) = env::var_os("PGLITE_OXIDE_GENERATED_ASSETS_DIR") { + if let Some(path) = env::var_os("OLIPHAUNT_WASM_GENERATED_ASSETS_DIR") { let path = PathBuf::from(path); if path.join("manifest.json").is_file() { return Some(path); @@ -50,12 +50,8 @@ fn find_asset_dir() -> Option { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"), ); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .map(Path::to_path_buf); - if let Some(repo_root) = repo_root { - let target_assets = repo_root.join("target/pglite-oxide/assets"); + if let Some(repo_root) = repo_root_from_manifest_dir(&manifest_dir) { + let target_assets = repo_root.join("target/oliphaunt-wasix/assets"); if target_assets.join("manifest.json").is_file() { return Some(target_assets); } @@ -69,6 +65,15 @@ fn find_asset_dir() -> Option { None } +fn repo_root_from_manifest_dir(manifest_dir: &Path) -> Option<&Path> { + manifest_dir.ancestors().find(|candidate| { + candidate.join("Cargo.toml").is_file() + && candidate + .join("src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml") + .is_file() + }) +} + fn emit_rerun_directives(asset_dir: &Path) { println!("cargo:rerun-if-changed={}", asset_dir.display()); visit_files(asset_dir, &mut |path| { @@ -92,13 +97,13 @@ fn visit_files(path: &Path, f: &mut impl FnMut(&Path)) { fn write_generated_assets(out: &Path, asset_dir: &Path) { let manifest = asset_dir.join("manifest.json"); - let runtime = asset_dir.join("pglite.wasix.tar.zst"); + let runtime = asset_dir.join("oliphaunt.wasix.tar.zst"); let pgdata_archive = asset_dir.join("prepopulated/pgdata-template.tar.zst"); let pgdata_manifest = asset_dir.join("prepopulated/pgdata-template.json"); let pg_dump = asset_dir.join("bin/pg_dump.wasix.wasm"); let initdb = asset_dir.join("bin/initdb.wasix.wasm"); - for required in [&manifest, &runtime, &pg_dump, &initdb] { + for required in [&manifest, &runtime, &initdb] { assert!( required.is_file(), "generated asset directory {} is missing required file {}", @@ -116,6 +121,7 @@ fn write_generated_assets(out: &Path, asset_dir: &Path) { let pgdata_archive_body = optional_include_bytes_body(&pgdata_archive); let pgdata_manifest_body = optional_include_bytes_body(&pgdata_manifest); + let pg_dump_body = optional_include_bytes_body(&pg_dump); let mut extension_cases = String::new(); let extension_dir = asset_dir.join("extensions"); @@ -149,7 +155,7 @@ fn write_generated_assets(out: &Path, asset_dir: &Path) { pub fn runtime_archive() -> Option<&'static [u8]> {{ Some(include_bytes!({runtime})) }}\n\ pub fn pgdata_template_archive() -> Option<&'static [u8]> {{ {pgdata_archive_body} }}\n\ pub fn pgdata_template_manifest() -> Option<&'static [u8]> {{ {pgdata_manifest_body} }}\n\ - pub fn pg_dump_wasm() -> Option<&'static [u8]> {{ Some(include_bytes!({pg_dump})) }}\n\ + pub fn pg_dump_wasm() -> Option<&'static [u8]> {{ {pg_dump_body} }}\n\ pub fn initdb_wasm() -> Option<&'static [u8]> {{ Some(include_bytes!({initdb})) }}\n\ #[rustfmt::skip]\n\ pub fn extension_archive(name: &str) -> Option<&'static [u8]> {{\n\ @@ -160,7 +166,7 @@ fn write_generated_assets(out: &Path, asset_dir: &Path) { runtime = rust_string_literal(&runtime), pgdata_archive_body = pgdata_archive_body, pgdata_manifest_body = pgdata_manifest_body, - pg_dump = rust_string_literal(&pg_dump), + pg_dump_body = pg_dump_body, initdb = rust_string_literal(&initdb), ); fs::write(out, text).expect("write generated asset include module"); diff --git a/crates/assets/src/lib.rs b/src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs similarity index 75% rename from crates/assets/src/lib.rs rename to src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs index 48e49e89..e2d6cafc 100644 --- a/crates/assets/src/lib.rs +++ b/src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs @@ -8,6 +8,10 @@ include!(concat!(env!("OUT_DIR"), "/generated_assets.rs")); #[serde(rename_all = "kebab-case")] pub struct AssetManifest { pub format_version: u32, + #[serde(default)] + pub source_lane: Option, + #[serde(default)] + pub source_fingerprint: Option, pub runtime: RuntimeAsset, #[serde(default)] pub runtime_support: Vec, @@ -61,6 +65,10 @@ pub struct PgDataTemplateAsset { pub runtime_module_sha256: String, pub initdb_module_sha256: String, pub source_pins_sha256: String, + #[serde(default)] + pub source_lane: Option, + #[serde(default)] + pub source_fingerprint: Option, pub postgres_version: String, pub catalog_version: String, pub init_profile: String, @@ -78,6 +86,8 @@ pub struct ExtensionAsset { pub sha256: String, #[serde(default)] pub module_sha256: String, + #[serde(default)] + pub native_modules: Vec, pub size: u64, #[serde(default)] pub stable: bool, @@ -245,4 +255,53 @@ mod tests { .any(|extension| extension.sql_name == "hstore" && extension.stable) ); } + + #[test] + fn pg18_manifest_metadata_round_trips() { + let manifest: AssetManifest = serde_json::from_str( + r#"{ + "format-version": 1, + "source-lane": "stable", + "source-fingerprint": "postgresql-18.4:patch-stack", + "runtime": { + "archive": "oliphaunt.wasix.tar.zst", + "sha256": "runtime-archive", + "module-sha256": "runtime-module", + "postgres-version": "18.4", + "runtime-kind": "wasix-dynamic-main" + }, + "runtime-support": [], + "pgdata-template": { + "archive": "prepopulated/pgdata-template.tar.zst", + "manifest": "prepopulated/pgdata-template.json", + "sha256": "template-archive", + "size": 1, + "runtime-module-sha256": "runtime-module", + "initdb-module-sha256": "initdb-module", + "source-pins-sha256": "source-pins", + "source-lane": "stable", + "source-fingerprint": "postgresql-18.4:patch-stack", + "postgres-version": "18", + "catalog-version": "202505281", + "init-profile": "default", + "wasmer-version": "6.0.0" + }, + "extensions": [], + "sources": [] + }"#, + ) + .expect("PG18 asset manifest metadata should parse"); + + assert_eq!(manifest.source_lane.as_deref(), Some("stable")); + assert_eq!( + manifest.source_fingerprint.as_deref(), + Some("postgresql-18.4:patch-stack") + ); + let template = manifest.pgdata_template.expect("PGDATA template asset"); + assert_eq!(template.source_lane.as_deref(), Some("stable")); + assert_eq!( + template.source_fingerprint.as_deref(), + Some("postgresql-18.4:patch-stack") + ); + } } diff --git a/src/runtimes/liboliphaunt/wasix/moon.yml b/src/runtimes/liboliphaunt/wasix/moon.yml new file mode 100644 index 00000000..71adb236 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/moon.yml @@ -0,0 +1,192 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "liboliphaunt-wasix" +language: "rust" +layer: "library" +stack: "systems" +tags: ["runtime", "wasix", "wasm", "postgres", "release-product"] +dependsOn: + - "postgres18" + - "source-toolchains" + - "third-party-shared" + - "third-party-wasix" + - "extension-model" + - "extension-runtime-contract" + - id: "shared-fixtures" + scope: "build" + +project: + title: "liboliphaunt WASIX" + description: "WASIX PostgreSQL runtime, portable assets, and AOT artifact carriers." + owner: "oliphaunt" + release: + component: "liboliphaunt-wasix" + packagePath: "src/runtimes/liboliphaunt/wasix" + +owners: + defaultOwner: "@oliphaunt/wasix" + paths: + "assets/**": ["@oliphaunt/wasix"] + "crates/**": ["@oliphaunt/wasix"] + "tools/**": ["@oliphaunt/wasix"] + +tasks: + check: + tags: ["quality", "static"] + command: "src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs" + deps: + - "postgres18:check" + - "source-toolchains:check" + - "third-party-shared:check" + - "third-party-wasix:check" + - "extension-runtime-contract:check" + - "shared-fixtures:check" + inputs: + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/wasix/**/*" + - "/src/extensions/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/tools/xtask/**/*" + - "/docs/internal/WASIX_PATCH_STACK.md" + options: + cache: true + runFromWorkspaceRoot: true + + release-check: + tags: ["release", "package"] + command: "bash tools/policy/check-wasm-artifacts.sh" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/wasix/**/*" + - "/src/extensions/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml" + - "/tools/policy/check-wasm-artifacts.sh" + - "/tools/xtask/**/*" + options: + cache: false + runFromWorkspaceRoot: true + + runtime-portable: + tags: ["runtime", "artifact", "ci-liboliphaunt-wasix-runtime"] + command: "bash src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh" + inputs: + - "/.github/actions/setup-wasmer-llvm/**/*" + - "/.github/workflows/ci.yml" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/wasix/**/*" + - "/src/extensions/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml" + - "/tools/xtask/**/*" + outputs: + - "/target/oliphaunt-wasix/wasix-build/build/**/*" + - "/target/oliphaunt-wasix/assets/**/*" + - "/src/extensions/generated/**/*" + - "/src/runtimes/liboliphaunt/wasix/assets/generated/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true + + runtime-aot: + tags: ["runtime", "artifact", "ci-liboliphaunt-wasix-aot"] + command: "bash src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh" + inputs: + - "/.github/actions/setup-wasmer-llvm/**/*" + - "/.github/workflows/ci.yml" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/wasix/**/*" + - "/src/extensions/**/*" + - "/src/shared/extension-runtime-contract/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml" + - "/tools/runtime/**/*" + - "/tools/xtask/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true + + release-assets: + tags: ["release", "artifact", "ci-liboliphaunt-wasix-release-assets"] + command: "cargo run -p xtask -- release package-assets" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml" + - "/src/runtimes/liboliphaunt/wasix/crates/assets/**/*" + - "/src/runtimes/liboliphaunt/wasix/crates/aot/**/*" + - "/tools/xtask/**/*" + - "/target/oliphaunt-wasix/wasix-build/build/**/*" + - "/target/oliphaunt-wasix/assets/**/*" + - "/target/oliphaunt-wasix/aot/**/*" + outputs: + - "/target/oliphaunt-wasix/release-assets/**/*" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: true + + smoke: + tags: ["runtime", "smoke"] + command: "bash src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/wasix/**/*" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/src/bindings/wasix-rust/**/*" + - "/tools/runtime/**/*" + - "/tools/xtask/**/*" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + + regression: + tags: ["regression", "runtime"] + command: "bash src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh regression" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/wasix/**/*" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/wasix/**/*" + - "/src/bindings/wasix-rust/**/*" + - "/tools/runtime/**/*" + - "/tools/xtask/**/*" + options: + cache: local + runFromWorkspaceRoot: true diff --git a/src/runtimes/liboliphaunt/wasix/release.toml b/src/runtimes/liboliphaunt/wasix/release.toml new file mode 100644 index 00000000..68c802a6 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/release.toml @@ -0,0 +1,19 @@ +id = "liboliphaunt-wasix" +owner = "@oliphaunt/wasm" +kind = "wasm-runtime" +publish_targets = ["crates-io", "github-release-assets"] +registry_packages = [ + "crates:oliphaunt-wasix-assets", + "crates:oliphaunt-wasix-aot-aarch64-apple-darwin", + "crates:oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "crates:oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "crates:oliphaunt-wasix-aot-x86_64-pc-windows-msvc", +] +release_artifacts = [ + "wasix-assets", + "aot-crates", + "release-assets", +] +legacy_tag_prefixes = [""] +legacy_version_file = "Cargo.toml" +legacy_version_parser = "cargo" diff --git a/src/runtimes/liboliphaunt/wasix/targets/checksums.toml b/src/runtimes/liboliphaunt/wasix/targets/checksums.toml new file mode 100644 index 00000000..bdd5fbf9 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/targets/checksums.toml @@ -0,0 +1,7 @@ +id = "liboliphaunt-wasix.checksums" +product = "liboliphaunt-wasix" +kind = "checksums" +target = "portable" +asset = "liboliphaunt-wasix-{version}-release-assets.sha256" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/liboliphaunt/wasix/targets/linux-arm64-gnu.toml b/src/runtimes/liboliphaunt/wasix/targets/linux-arm64-gnu.toml new file mode 100644 index 00000000..4b0f9d43 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/targets/linux-arm64-gnu.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-wasix.aot-linux-arm64-gnu" +product = "liboliphaunt-wasix" +kind = "wasix-aot-runtime" +target = "linux-arm64-gnu" +triple = "aarch64-unknown-linux-gnu" +runner = "ubuntu-24.04-arm" +llvm_url = "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-aarch64.tar.xz" +asset = "liboliphaunt-wasix-{version}-runtime-aot-linux-arm64-gnu.tar.zst" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/liboliphaunt/wasix/targets/linux-x64-gnu.toml b/src/runtimes/liboliphaunt/wasix/targets/linux-x64-gnu.toml new file mode 100644 index 00000000..df7a3f9e --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/targets/linux-x64-gnu.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-wasix.aot-linux-x64-gnu" +product = "liboliphaunt-wasix" +kind = "wasix-aot-runtime" +target = "linux-x64-gnu" +triple = "x86_64-unknown-linux-gnu" +runner = "ubuntu-latest" +llvm_url = "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-amd64.tar.xz" +asset = "liboliphaunt-wasix-{version}-runtime-aot-linux-x64-gnu.tar.zst" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/liboliphaunt/wasix/targets/macos-arm64.toml b/src/runtimes/liboliphaunt/wasix/targets/macos-arm64.toml new file mode 100644 index 00000000..58fe2e91 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/targets/macos-arm64.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-wasix.aot-macos-arm64" +product = "liboliphaunt-wasix" +kind = "wasix-aot-runtime" +target = "macos-arm64" +triple = "aarch64-apple-darwin" +runner = "macos-latest" +llvm_url = "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-darwin-aarch64.tar.xz" +asset = "liboliphaunt-wasix-{version}-runtime-aot-macos-arm64.tar.zst" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/liboliphaunt/wasix/targets/wasix-runtime.toml b/src/runtimes/liboliphaunt/wasix/targets/wasix-runtime.toml new file mode 100644 index 00000000..ccd332a8 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/targets/wasix-runtime.toml @@ -0,0 +1,7 @@ +id = "liboliphaunt-wasix.runtime-portable" +product = "liboliphaunt-wasix" +kind = "wasix-runtime" +target = "portable" +asset = "liboliphaunt-wasix-{version}-runtime-portable.tar.zst" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/liboliphaunt/wasix/targets/windows-x64-msvc.toml b/src/runtimes/liboliphaunt/wasix/targets/windows-x64-msvc.toml new file mode 100644 index 00000000..18f62721 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/targets/windows-x64-msvc.toml @@ -0,0 +1,10 @@ +id = "liboliphaunt-wasix.aot-windows-x64-msvc" +product = "liboliphaunt-wasix" +kind = "wasix-aot-runtime" +target = "windows-x64-msvc" +triple = "x86_64-pc-windows-msvc" +runner = "windows-latest" +llvm_url = "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-windows-amd64.tar.xz" +asset = "liboliphaunt-wasix-{version}-runtime-aot-windows-x64-msvc.tar.zst" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh b/src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh new file mode 100755 index 00000000..ab442fce --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$root" ]; then + root="$(cd "$script_dir/../../../../.." && pwd -P)" +fi +[ -f "$root/package.json" ] && [ -d "$root/src/runtimes/liboliphaunt/wasix" ] || { + echo "must run inside the Oliphaunt workspace" >&2 + exit 1 +} +cd "$root" + +target="${AOT_TARGET:-${1:-}}" +if [ -z "$target" ]; then + target="$(rustc -vV | awk '/^host:/{print $2}')" +fi +package="${AOT_PACKAGE:-oliphaunt-wasix-aot-${target}}" + +cargo run -p xtask -- assets aot --target-triple "$target" +cargo run -p xtask -- assets package-aot --target-triple "$target" +cargo run -p xtask -- assets check-aot --target-triple "$target" +cargo check -p "$package" --locked +cargo run -p xtask -- assets smoke diff --git a/src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh b/src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh new file mode 100755 index 00000000..a860eae1 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$root" ]; then + root="$(cd "$script_dir/../../../../.." && pwd -P)" +fi +[ -f "$root/package.json" ] && [ -d "$root/src/runtimes/liboliphaunt/wasix" ] || { + echo "must run inside the Oliphaunt workspace" >&2 + exit 1 +} +cd "$root" + +asset_profile="${ASSET_PROFILE:-release}" +image="${IMAGE:-oliphaunt-wasix-wasix-build:ci}" +export IMAGE="$image" +if [ -z "${DOCKER_CONFIG:-}" ]; then + docker_config="$root/target/docker/public-config" + mkdir -p "$docker_config" + [ -f "$docker_config/config.json" ] || printf '{}\n' >"$docker_config/config.json" + if [ -d "$HOME/.docker/cli-plugins" ]; then + mkdir -p "$docker_config/cli-plugins" + for plugin in "$HOME/.docker/cli-plugins/"*; do + [ -e "$plugin" ] || continue + ln -sf "$plugin" "$docker_config/cli-plugins/$(basename "$plugin")" + done + fi + export DOCKER_CONFIG="$docker_config" +fi +export DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}" + +cargo run -p xtask --features template-runner -- assets release-build \ + --profile "$asset_profile" \ + --target-triple x86_64-unknown-linux-gnu \ + --skip-aot \ + --skip-package-size + +cargo run -p xtask -- assets check --strict-generated diff --git a/src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh b/src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh new file mode 100755 index 00000000..e7005abb --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/tools/check-asset-input-fingerprint.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +base_ref="${ASSET_INPUT_BASE_REF:-}" +if [[ -z "$base_ref" ]]; then + if git rev-parse --verify -q '@{upstream}' >/dev/null; then + base_ref='@{upstream}' + else + base_ref='origin/main' + fi +fi + +if ! git rev-parse --verify -q "${base_ref}^{commit}" >/dev/null; then + echo "asset input fingerprint check skipped: ${base_ref} is not available" >&2 + exit 0 +fi + +changed="$( + git diff --name-only "${base_ref}...HEAD" -- \ + src/sources/third-party \ + src/sources/toolchains \ + src/extensions/catalog/extensions.promoted.toml \ + src/extensions/catalog/extensions.smoke.toml \ + src/runtimes/liboliphaunt/wasix/assets/build \ + src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml \ + src/runtimes/liboliphaunt/wasix/crates/assets/build.rs \ + src/runtimes/liboliphaunt/wasix/crates/assets/src \ + src/runtimes/liboliphaunt/wasix/crates/aot \ + tools/xtask/src \ + src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256 +)" + +if [[ -z "$changed" ]]; then + echo "asset input fingerprint check skipped: no asset input changes" + exit 0 +fi + +cargo run -p xtask -- assets verify-committed diff --git a/src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs b/src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs new file mode 100755 index 00000000..4e11b958 --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs @@ -0,0 +1,473 @@ +#!/usr/bin/env node +import {execFileSync} from 'node:child_process'; +import {existsSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; + +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', +}).trim(); +const mode = process.argv[2] ?? '--check'; +const outputPath = path.join(root, 'docs/internal/WASIX_PATCH_STACK.md'); +const postgresSourceManifestPath = path.join(root, 'src/postgres/versions/18/source.toml'); +const patchSeriesManifestPath = path.join( + root, + 'src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml', +); +const patchDir = path.join(root, 'src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches'); +const dispositionPath = path.join( + root, + 'src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml', +); + +const EXPECTED_AUTHOR = 'Oliphaunt Maintainers '; + +const EXPECTED_TOUCHPOINTS = new Map([ + ['src/Makefile.shlib', 'Defines the WASIX dynamic-link shared-library shape.'], + ['src/backend/Makefile', 'Builds the dynamic-main backend module without changing other ports.'], + ['src/backend/access/heap/heapam.c', 'Adds embedded timing probes and heap fast-path scope.'], + ['src/backend/access/heap/heapam_handler.c', 'Keeps embedded heap update timing observable.'], + ['src/backend/access/nbtree/nbtdedup.c', 'Keeps btree delete scratch storage on stack under embedded WASIX.'], + ['src/backend/access/nbtree/nbtinsert.c', 'Adds embedded btree insert timing and int4 fast-path scope.'], + ['src/backend/access/nbtree/nbtsearch.c', 'Adds embedded btree search timing and guarded int4 leaf fast paths.'], + ['src/backend/access/transam/xact.c', 'Adds top-level current-transaction shortcut for embedded WASIX.'], + ['src/backend/access/transam/xlog.c', 'Avoids expensive segment division under embedded WASIX.'], + ['src/backend/commands/collationcmds.c', 'Static ICU consumers register linked common data before collation commands call ICU locale APIs.'], + ['src/backend/commands/copyfromparse.c', 'Reports COPY protocol state to the host.'], + ['src/backend/commands/copyto.c', 'Reports COPY protocol state to the host.'], + ['src/backend/libpq/be-secure.c', 'Routes embedded protocol reads and writes through host-owned callbacks.'], + ['src/backend/libpq/pqcomm.c', 'Skips unavailable postmaster-death wait handles in embedded WASIX.'], + ['src/backend/optimizer/plan/planner.c', 'Suppresses activity identifier reporting in embedded WASIX.'], + ['src/backend/port/posix_sema.c', 'Uses POSIX semaphore behavior selected by the WASIX template.'], + ['src/backend/postmaster/checkpointer.c', 'Keeps checkpoint requests local to embedded WASIX.'], + ['src/backend/postmaster/fork_process.c', 'Declares the WASIX fork boundary without enabling postmaster concurrency.'], + ['src/backend/replication/walsender.c', 'Suppresses activity identifier reporting in embedded WASIX.'], + ['src/backend/storage/file/fd.c', 'Treats data-directory ownership and directory sync as WASIX platform boundaries.'], + ['src/backend/tcop/backend_startup.c', 'Exports the startup packet parser for host-driven startup.'], + ['src/backend/tcop/postgres.c', 'Owns embedded lifecycle, protocol loop, error recovery, and timing hooks.'], + ['src/backend/utils/adt/like.c', 'Adds guarded LIKE literal fast path for embedded WASIX.'], + ['src/backend/utils/adt/like_match.c', 'Adds guarded LIKE literal fast path for embedded WASIX.'], + ['src/backend/utils/adt/pg_locale.c', 'Static ICU consumers register linked common data before PostgreSQL validates or canonicalizes ICU locales.'], + ['src/backend/utils/adt/pg_locale_icu.c', 'Static ICU consumers register linked common data before PostgreSQL opens ICU collators or converters.'], + ['src/backend/utils/init/miscinit.c', 'Routes process identity through the WASIX port layer.'], + ['src/backend/utils/init/postinit.c', 'Skips data-directory ownership checks under embedded WASIX.'], + ['src/backend/utils/misc/guc.c', 'Uses the embedded WASIX postmaster-style environment.'], + ['src/backend/utils/mmgr/portalmem.c', 'Fails active portals on host-forced recovery.'], + ['src/bin/pg_dump/connectdb.c', 'Avoids pg_dump LTO symbol collisions.'], + ['src/bin/pg_dump/connectdb.h', 'Avoids pg_dump LTO symbol collisions.'], + ['src/bin/pg_dump/parallel.c', 'Stubs unavailable pg_dump parallel fork behavior under WASIX.'], + ['src/bin/pg_dump/pg_dumpall.c', 'Avoids pg_dump LTO symbol collisions.'], + ['src/common/file_utils.c', 'Treats EISDIR directory fsync as unsupported on WASIX.'], + ['src/common/hashfn.c', 'Uses defined unaligned load fast path under WASIX.'], + ['src/include/libpq/libpq-be.h', 'Adds the host I/O callback table to Port only for embedded WASIX.'], + ['src/include/port/wasix-dl.h', 'Defines the embedded WASIX port header and ABI redirects.'], + ['src/include/port/wasix-dl/sys/ipc.h', 'Provides the WASIX SysV IPC shim surface.'], + ['src/include/port/wasix-dl/sys/shm.h', 'Provides the WASIX SysV shared-memory shim surface.'], + ['src/include/utils/pg_locale.h', 'Declares the generic static ICU data registration helper for PostgreSQL ICU call sites.'], + ['src/makefiles/Makefile.wasix-dl', 'Builds side modules and PGXS artifacts for WASIX dynamic linking.'], + ['src/makefiles/pgxs.mk', 'Installs PGXS extension artifacts for WASIX packaging.'], + ['src/template/wasix-dl', 'Keeps the WASIX template and atomics invariants source-controlled.'], +]); + +const REQUIRED_AUDIT_CHECKS = [ + { + requirement: 'WASIX dynamic-main build spine is isolated', + patches: ['0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch'], + evidence: ['PORTNAME), wasix-dl', 'oliphaunt: $(OBJS)'], + posture: 'Build plumbing lands before lifecycle behavior, so linker changes are reviewable alone.', + }, + { + requirement: 'Backend protocol I/O is host-owned without touching normal sockets', + patches: ['0002-oliphaunt-wasix-add-backend-host-io-hooks.patch'], + evidence: ['OliphauntWasmHostIO', 'secure_raw_read', 'secure_raw_write'], + posture: 'Only OLIPHAUNT_WASM_SINGLE_USER installs the callback table.', + }, + { + requirement: 'Startup packet parsing remains PostgreSQL-owned', + patches: ['0003-oliphaunt-wasix-export-startup-packet-parser.patch'], + evidence: ['ProcessStartupPacket', 'OLIPHAUNT_WASM_HOST_EXPORT("ProcessStartupPacket")'], + posture: 'The host can call the parser, but PostgreSQL still validates the startup packet.', + }, + { + requirement: 'Host lifecycle exports stay explicit', + patches: ['0004-oliphaunt-wasix-add-host-lifecycle-exports.patch'], + evidence: ['oliphaunt_wasix_start', 'oliphaunt_wasix_pq_flush', 'oliphaunt_wasix_get_proc_port'], + posture: 'Host-visible entry points are named exports instead of broad syscall remaps.', + }, + { + requirement: 'Protocol loop recovery remains at the PostgresMain boundary', + patches: [ + '0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch', + '0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch', + '0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch', + ], + evidence: ['PostgresMainLoopOnce', 'PostgresMainLongJmp', 'send_ready_for_query = true'], + posture: 'The host pumps PostgreSQL one loop at a time and recovery re-enters the upstream exception stack.', + }, + { + requirement: 'COPY protocol state is host-observable', + patches: [ + '0006-oliphaunt-wasix-report-copy-protocol-state.patch', + '0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch', + ], + evidence: ['oliphaunt_wasix_protocol_report_copy_response', 'OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE'], + posture: 'COPY state is reported and cleared around PostgreSQL error recovery.', + }, + { + requirement: 'PGXS side modules use the WASIX dynamic-link contract', + patches: [ + '0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch', + '0022-oliphaunt-wasix-use-wasm-ld-for-backend-core.patch', + ], + evidence: ['PGXS', 'WASM_LD ?= $(shell $(CC) -print-prog-name=wasm-ld)'], + posture: 'Extension and backend side-module behavior is source-reviewed with the linker path.', + }, + { + requirement: 'Process identity and shared memory stay behind the port header', + patches: [ + '0009-oliphaunt-wasix-route-process-identity-through-port.patch', + '0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch', + '0011-oliphaunt-wasix-prefer-posix-semaphores.patch', + ], + evidence: ['oliphaunt_wasix_geteuid', 'oliphaunt_wasix_shmget', 'PREFERRED_SEMAPHORES=UNNAMED_POSIX'], + posture: 'WASIX platform gaps are explicit port-layer dependencies, not scattered runtime guesses.', + }, + { + requirement: 'Tool/runtime platform stubs fail closed', + patches: [ + '0021-oliphaunt-wasix-declare-wasix-fork.patch', + '0029-oliphaunt-wasix-stub-pg-dump-parallel-fork.patch', + '0037-oliphaunt-wasix-treat-directory-fsync-eisdir-as-unsupported.patch', + ], + evidence: ['fork_process', 'oliphaunt_wasix_pgdump_fork', 'errno == EISDIR'], + posture: 'Unavailable WASIX behavior is explicit and narrow instead of silently emulated.', + }, + { + requirement: 'Static ICU data is registered before PostgreSQL calls ICU APIs', + patches: ['0038-oliphaunt-wasix-register-static-icu-data.patch'], + evidence: ['pg_register_static_icu_data', 'udata_setCommonData', 'init_icu_converter'], + posture: 'The static WASIX ICU build can initialize PostgreSQL without mounting loose ICU data files.', + }, +]; + +if (!['--check', '--write'].includes(mode)) { + console.error('usage: src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs [--check|--write]'); + process.exit(2); +} + +function read(relativePath) { + return readFileSync(path.join(root, relativePath), 'utf8'); +} + +function matchRequired(text, pattern, label) { + const match = text.match(pattern); + if (!match) { + throw new Error(`missing ${label}`); + } + return match[1]; +} + +function parseSourceManifest() { + const postgresText = readFileSync(postgresSourceManifestPath, 'utf8'); + const seriesText = readFileSync(patchSeriesManifestPath, 'utf8'); + const version = matchRequired(postgresText, /version\s*=\s*"([^"]+)"/u, 'postgresql.version'); + const url = matchRequired(postgresText, /url\s*=\s*"([^"]+)"/u, 'postgresql.url'); + const sha256 = matchRequired(postgresText, /sha256\s*=\s*"([^"]+)"/u, 'postgresql.sha256'); + const seriesBlock = matchRequired(seriesText, /series\s*=\s*\[([\s\S]*?)\]/u, 'patches.series'); + const series = Array.from(seriesBlock.matchAll(/"([^"]+\.patch)"/gu), match => match[1]); + if (series.length === 0) { + throw new Error('WASIX source.toml patch series is empty'); + } + return {version, url, sha256, series}; +} + +function patchFiles() { + return readdirSync(patchDir) + .filter(name => name.endsWith('.patch')) + .sort((a, b) => a.localeCompare(b)); +} + +function parsePatch(fileName) { + const relativePath = `src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/${fileName}`; + const text = read(relativePath); + const author = text.match(/^From:\s+(.+)$/mu)?.[1]; + if (author !== EXPECTED_AUTHOR) { + throw new Error(`${relativePath} From: header must be "${EXPECTED_AUTHOR}", got ${author ?? ''}`); + } + const subject = text.match(/^Subject:\s+\[PATCH\]\s+(.+)$/mu)?.[1]; + if (!subject?.startsWith('oliphaunt-wasix: ')) { + throw new Error(`${relativePath} subject must start with "oliphaunt-wasix: "`); + } + const changedFiles = Array.from( + text.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gmu), + match => match[2], + ); + if (changedFiles.length === 0) { + throw new Error(`${relativePath} does not contain any diff --git file entries`); + } + const prefix = `${String(Number(fileName.slice(0, 4))).padStart(4, '0')}-oliphaunt-wasix-`; + if (!fileName.startsWith(prefix)) { + throw new Error(`${relativePath} must use sequential prefix ${prefix}`); + } + if (/\b(TODO|FIXME)\b/u.test(text)) { + throw new Error(`${relativePath} must not carry TODO/FIXME placeholders`); + } + + const diffStart = text + .indexOf('\ndiff --git '); + if (diffStart === -1) { + throw new Error(`${relativePath} is missing a diff body`); + } + const rationaleCount = countRationaleLines(text.slice(0, diffStart)); + if (rationaleCount < 2) { + throw new Error(`${relativePath} must include a short rationale before the diff`); + } + + const whitespaceProblems = []; + const symbols = new Set(); + for (const [index, line] of text.split('\n').entries()) { + if (!line.startsWith('+') || line.startsWith('+++')) { + continue; + } + if (line !== '+' && /[ \t]$/u.test(line)) { + whitespaceProblems.push(`${index + 1}: ${line}`); + } + if (/^\+ \t/u.test(line)) { + whitespaceProblems.push(`${index + 1}: ${line}`); + } + for (const symbol of line.matchAll(/\b(oliphaunt_wasix_[A-Za-z0-9_]+|OLIPHAUNT_WASM_[A-Za-z0-9_]+|PostgresMainLoopOnce|PostgresMainLongJmp|ProcessStartupPacket)\b/gu)) { + symbols.add(symbol[1]); + } + } + if (whitespaceProblems.length > 0) { + throw new Error( + `${relativePath} contains whitespace problems in added PostgreSQL code:\n${whitespaceProblems.join('\n')}`, + ); + } + + return { + fileName, + relativePath, + text, + author, + subject, + changedFiles, + symbols: Array.from(symbols).sort((a, b) => a.localeCompare(b)), + }; +} + +function countRationaleLines(headerText) { + return headerText + .split('\n') + .slice(headerText.split('\n').findIndex(line => line.startsWith('Subject: ')) + 1) + .filter(line => { + const trimmed = line.trim(); + return trimmed !== '' && !trimmed.startsWith('---') && !trimmed.startsWith('From:') && !trimmed.startsWith('Date:'); + }) + .length; +} + +function parseDisposition() { + const text = readFileSync(dispositionPath, 'utf8'); + const policy = matchRequired(text, /policy\s*=\s*"([^"]+)"/u, 'experiment disposition policy'); + const entries = text + .split(/\n\[\[patch\]\]\n/u) + .slice(1) + .map(block => ({ + experiment: matchRequired(block, /experiment\s*=\s*"([^"]+)"/u, 'experiment'), + status: matchRequired(block, /status\s*=\s*"([^"]+)"/u, 'status'), + decision: matchRequired(block, /wasix_runtime_decision\s*=\s*"([^"]+)"/u, 'wasix_runtime_decision'), + rationale: matchRequired(block, /rationale\s*=\s*"([^"]+)"/u, 'rationale'), + })); + if (policy !== 'do-not-port-experiment-patches-without-a-recorded-wasix-runtime-rationale') { + throw new Error(`unexpected experiment disposition policy: ${policy}`); + } + if (entries.length === 0) { + throw new Error('experiment disposition must record at least one patch'); + } + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.experiment)) { + throw new Error(`duplicate experiment disposition for ${entry.experiment}`); + } + seen.add(entry.experiment); + for (const field of ['decision', 'rationale']) { + if (entry[field].trim().length < 8) { + throw new Error(`experiment ${entry.experiment} has an under-specified ${field}`); + } + } + } + return {policy, entries}; +} + +function validateSeries(manifest, actualFiles) { + if (JSON.stringify(manifest.series) !== JSON.stringify(actualFiles)) { + throw new Error( + `WASIX source.toml patch series must exactly match patch directory files\nexpected:\n${manifest.series.join('\n')}\nactual:\n${actualFiles.join('\n')}`, + ); + } + manifest.series.forEach((fileName, index) => { + const expectedPrefix = `${String(index + 1).padStart(4, '0')}-oliphaunt-wasix-`; + if (!fileName.startsWith(expectedPrefix)) { + throw new Error(`${fileName} must use sequential prefix ${expectedPrefix}`); + } + }); +} + +function validateTouchpoints(patches) { + const actual = new Set(patches.flatMap(patch => patch.changedFiles)); + const expected = new Set(EXPECTED_TOUCHPOINTS.keys()); + const missing = [...expected].filter(file => !actual.has(file)); + const extra = [...actual].filter(file => !expected.has(file)); + if (missing.length > 0 || extra.length > 0) { + throw new Error( + `WASIX patch touchpoints changed; update ${path.relative(root, outputPath)} and src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs\nmissing:\n${missing.join('\n') || ''}\nextra:\n${extra.join('\n') || ''}`, + ); + } +} + +function validateAuditChecks(patches) { + const byName = new Map(patches.map(patch => [patch.fileName, patch])); + for (const check of REQUIRED_AUDIT_CHECKS) { + const text = check.patches + .map(fileName => { + const patch = byName.get(fileName); + if (!patch) { + throw new Error(`audit check "${check.requirement}" references missing patch ${fileName}`); + } + return patch.text; + }) + .join('\n'); + for (const evidence of check.evidence) { + if (!text.includes(evidence)) { + throw new Error(`audit check "${check.requirement}" is missing evidence ${evidence}`); + } + } + } +} + +function render() { + const manifest = parseSourceManifest(); + const actualFiles = patchFiles(); + validateSeries(manifest, actualFiles); + const patches = actualFiles.map(parsePatch); + validateTouchpoints(patches); + validateAuditChecks(patches); + const disposition = parseDisposition(); + + const changedFiles = new Map(); + for (const patch of patches) { + for (const changed of patch.changedFiles) { + if (!changedFiles.has(changed)) { + changedFiles.set(changed, []); + } + changedFiles.get(changed).push(patch.fileName); + } + } + + const symbols = new Map(); + for (const patch of patches) { + for (const symbol of patch.symbols) { + if (!symbols.has(symbol)) { + symbols.set(symbol, []); + } + symbols.get(symbol).push(patch.fileName); + } + } + + const lines = []; + lines.push(''); + lines.push('# oliphaunt-wasix PostgreSQL 18 WASIX Patch Stack Review'); + lines.push(''); + lines.push('This source-only review artifact keeps the WASIX PostgreSQL patch stack deterministic and reviewable without rebuilding PostgreSQL.'); + lines.push(''); + lines.push('Regenerate with:'); + lines.push(''); + lines.push('```sh'); + lines.push('src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write'); + lines.push('```'); + lines.push(''); + lines.push('## Source Pin'); + lines.push(''); + lines.push(`- PostgreSQL: \`${manifest.version}\``); + lines.push(`- URL: \`${manifest.url}\``); + lines.push(`- SHA-256: \`${manifest.sha256}\``); + lines.push(`- Patch directory: \`src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches\``); + lines.push(`- Experiment disposition policy: \`${disposition.policy}\``); + lines.push(''); + lines.push('## Patch Series'); + lines.push(''); + lines.push('| Order | Patch | Author | Subject |'); + lines.push('| --- | --- | --- | --- |'); + patches.forEach((patch, index) => { + lines.push(`| ${index + 1} | \`${patch.fileName}\` | ${patch.author} | ${patch.subject} |`); + }); + lines.push(''); + lines.push('## Changed Upstream Files'); + lines.push(''); + lines.push('| File | Owning Patch(es) | Rationale |'); + lines.push('| --- | --- | --- |'); + for (const [file, patchNames] of [...changedFiles.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push( + `| \`${file}\` | ${patchNames.map(name => `\`${name}\``).join(', ')} | ${EXPECTED_TOUCHPOINTS.get(file)} |`, + ); + } + lines.push(''); + lines.push('## Audit Checklist'); + lines.push(''); + lines.push('| Requirement | Owning Patch(es) | Required Evidence | Review Posture |'); + lines.push('| --- | --- | --- | --- |'); + for (const check of REQUIRED_AUDIT_CHECKS) { + lines.push( + `| ${check.requirement} | ${check.patches.map(name => `\`${name}\``).join(', ')} | ${check.evidence.map(evidence => `\`${evidence}\``).join(', ')} | ${check.posture} |`, + ); + } + lines.push(''); + lines.push('## PostgreSQL Patch Symbols'); + lines.push(''); + for (const [symbol, patchNames] of [...symbols.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`- \`${symbol}\` (${patchNames.map(name => `\`${name}\``).join(', ')})`); + } + lines.push(''); + lines.push('## Experiment Patch Disposition'); + lines.push(''); + lines.push('| Experiment Patch | Status | WASIX Runtime Decision | Rationale |'); + lines.push('| --- | --- | --- | --- |'); + for (const entry of disposition.entries) { + lines.push(`| \`${entry.experiment}\` | \`${entry.status}\` | ${entry.decision} | ${entry.rationale} |`); + } + lines.push(''); + lines.push('## Guardrails'); + lines.push(''); + lines.push('- `source.toml` patch series exactly matches the patch directory.'); + lines.push('- Every patch has a deterministic `From: Oliphaunt Maintainers ` header.'); + lines.push('- Every patch has a deterministic `Subject: [PATCH] oliphaunt-wasix: ...` header and a rationale before the diff.'); + lines.push('- Added PostgreSQL lines are checked for trailing whitespace and space-before-tab indentation.'); + lines.push('- Changed upstream files must exactly match the expected touchpoint table above; new upstream touchpoints need an explicit rationale before landing.'); + lines.push('- Required audit checks prove their evidence in the named owning patch or patches.'); + lines.push('- Experiment patches can only be ported, rejected, or replaced with a recorded WASIX runtime decision and rationale.'); + lines.push(''); + return lines.join('\n'); +} + +function normalizeGeneratedMarkdown(text) { + return text.replace(/\r\n/gu, '\n').replace(/\r/gu, '\n').trimEnd(); +} + +try { + const rendered = render(); + if (mode === '--write') { + writeFileSync(outputPath, rendered, 'utf8'); + } else { + if (!existsSync(outputPath)) { + throw new Error(`${path.relative(root, outputPath)} is missing; run src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write`); + } + const actual = readFileSync(outputPath, 'utf8'); + if (normalizeGeneratedMarkdown(actual) !== normalizeGeneratedMarkdown(rendered)) { + throw new Error(`${path.relative(root, outputPath)} is stale; run src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs --write`); + } + } + console.log('WASIX patch stack review artifact is current'); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh b/src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh new file mode 100755 index 00000000..7980bfbe --- /dev/null +++ b/src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$root" ]; then + root="$(cd "$script_dir/../../../../.." && pwd -P)" +fi +[ -f "$root/package.json" ] && [ -d "$root/src/runtimes/liboliphaunt/wasix" ] || { + echo "must run inside the Oliphaunt workspace" >&2 + exit 1 +} +cd "$root" + +. "$root/tools/runtime/preflight.sh" + +mode="${1:-smoke}" +case "$mode" in + smoke|regression) + ;; + *) + echo "usage: src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh [smoke|regression]" >&2 + exit 2 + ;; +esac + +host="$(oliphaunt_runtime_wasm_host_triple)" +oliphaunt_runtime_wasm_require "$mode" +asset_mode="$OLIPHAUNT_RUNTIME_WASM_ASSET_MODE" + +cargo run -p xtask -- assets install-local --target-triple "$host" +export OLIPHAUNT_WASM_GENERATED_ASSETS_DIR="$root/target/oliphaunt-wasix/assets" +export OLIPHAUNT_WASM_GENERATED_AOT_DIR="$root/target/oliphaunt-wasix/aot" +export RUST_BACKTRACE="${RUST_BACKTRACE:-full}" + +cargo test -p oliphaunt-wasix --locked \ + --test runtime_smoke \ + --test proxy_smoke \ + --test cli_smoke \ + --test performance_smoke \ + --test postgres_regression \ + -- --nocapture --test-threads=1 +if [ "$asset_mode" = "full" ]; then + if [ "$mode" = "regression" ]; then + cargo test -p oliphaunt-wasix --locked --test client_compat -- --nocapture --test-threads=1 + fi + cargo test -p oliphaunt-wasix --locked --test extensions_smoke -- --nocapture --test-threads=1 + cargo test -p oliphaunt-wasix --locked --lib public_extensions -- --nocapture --test-threads=1 + cargo test -p oliphaunt-wasix --locked --lib pg_dump -- --nocapture +else + echo "core-only WASIX assets detected; skipping extension and pg_dump smoke tests" +fi diff --git a/src/runtimes/node-direct/CHANGELOG.md b/src/runtimes/node-direct/CHANGELOG.md new file mode 100644 index 00000000..e1e8a169 --- /dev/null +++ b/src/runtimes/node-direct/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial Node-API native direct runtime product metadata. diff --git a/src/runtimes/node-direct/README.md b/src/runtimes/node-direct/README.md new file mode 100644 index 00000000..2143b4fb --- /dev/null +++ b/src/runtimes/node-direct/README.md @@ -0,0 +1,15 @@ +# Oliphaunt Node Direct Runtime + +`oliphaunt-node-direct` owns the Node-API adapter that lets the TypeScript SDK +call the native `liboliphaunt` runtime without compiling native code during a +normal application install. + +Published consumer packages are platform-specific optional npm packages: + +- `@oliphaunt/node-direct-darwin-arm64` +- `@oliphaunt/node-direct-linux-x64-gnu` +- `@oliphaunt/node-direct-linux-arm64-gnu` +- `@oliphaunt/node-direct-win32-x64-msvc` + +The TypeScript SDK selects the matching optional package when present and falls +back to verified GitHub release assets for release validation and local tests. diff --git a/src/runtimes/node-direct/moon.yml b/src/runtimes/node-direct/moon.yml new file mode 100644 index 00000000..90f7cc33 --- /dev/null +++ b/src/runtimes/node-direct/moon.yml @@ -0,0 +1,92 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-node-direct" +language: "typescript" +layer: "library" +stack: "systems" +tags: ["runtime", "node", "native", "node-api", "release-product"] +dependsOn: + - "liboliphaunt-native" + +project: + title: "Oliphaunt Node Direct Runtime" + description: "Node-API native direct addon runtime consumed by the TypeScript SDK." + owner: "oliphaunt" + release: + component: "oliphaunt-node-direct" + packagePath: "src/runtimes/node-direct" + +owners: + defaultOwner: "@oliphaunt/node-direct" + paths: + "native/**": ["@oliphaunt/node-direct"] + "packages/**": ["@oliphaunt/node-direct"] + "targets/**": ["@oliphaunt/node-direct"] + "tools/**": ["@oliphaunt/node-direct"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash src/runtimes/node-direct/tools/check-package.sh check-static" + deps: + - "liboliphaunt-native:check" + inputs: + - "/src/runtimes/node-direct/**/*" + - "/tools/release/artifact_targets.py" + - "/tools/release/check_artifact_targets.py" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: true + runFromWorkspaceRoot: true + + test: + tags: ["quality", "unit"] + command: "bash src/runtimes/node-direct/tools/check-package.sh test-unit" + inputs: + - "/src/runtimes/node-direct/**/*" + options: + cache: true + runFromWorkspaceRoot: true + + package: + tags: ["package"] + command: "bash src/runtimes/node-direct/tools/check-package.sh package-shape" + inputs: + - "/src/runtimes/node-direct/**/*" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/package.json" + options: + cache: true + runFromWorkspaceRoot: true + + release-check: + tags: ["release", "package"] + command: "bash src/runtimes/node-direct/tools/check-package.sh package-shape" + inputs: + - "/src/runtimes/node-direct/**/*" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/package.json" + options: + cache: true + runFromWorkspaceRoot: true + + release-assets: + tags: ["release", "artifact", "ci-node-direct"] + command: "bash src/runtimes/node-direct/tools/build-node-addon.sh" + inputs: + - "/src/runtimes/node-direct/**/*" + - "/tools/release/artifact_target_matrix.py" + - "/tools/release/artifact_targets.py" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + outputs: + - "/target/oliphaunt-node-direct/release-assets/**/*" + - "/target/oliphaunt-node-direct/npm-packages/**/*" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc b/src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc new file mode 100644 index 00000000..d1282246 --- /dev/null +++ b/src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc @@ -0,0 +1,602 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +namespace { + +constexpr uint32_t kAbiVersion = 6; +constexpr uint64_t kRestoreReplaceExisting = 1; + +struct OliphauntHandle; + +struct OliphauntConfig { + uint32_t abi_version; + const char *pgdata; + const char *runtime_dir; + const char *username; + const char *database; + uint64_t reserved_flags; + const char *const *startup_args; + size_t startup_arg_count; +}; + +struct OliphauntResponse { + uint8_t *data; + size_t len; +}; + +struct OliphauntRestoreOptions { + uint32_t abi_version; + const char *root; + uint32_t format; + const uint8_t *data; + size_t len; + uint64_t flags; +}; + +using StreamCallback = int32_t (*)(void *, const uint8_t *, size_t); + +using InitFn = int32_t (*)(const OliphauntConfig *, OliphauntHandle **); +using ExecProtocolFn = int32_t (*)(OliphauntHandle *, const uint8_t *, size_t, OliphauntResponse *); +using ExecSimpleQueryFn = int32_t (*)(OliphauntHandle *, const char *, size_t, OliphauntResponse *); +using ExecProtocolStreamFn = int32_t (*)( + OliphauntHandle *, const uint8_t *, size_t, StreamCallback, void *); +using BackupFn = int32_t (*)(OliphauntHandle *, uint32_t, OliphauntResponse *); +using RestoreFn = int32_t (*)(const OliphauntRestoreOptions *); +using CancelFn = int32_t (*)(OliphauntHandle *); +using DetachFn = int32_t (*)(OliphauntHandle *); +using LastErrorFn = const char *(*)(OliphauntHandle *); +using VersionFn = const char *(*)(); +using CapabilitiesFn = uint64_t (*)(); +using FreeResponseFn = void (*)(OliphauntResponse *); + +struct DynamicLibrary { +#if defined(_WIN32) + HMODULE handle = nullptr; +#else + void *handle = nullptr; +#endif +}; + +struct NativeLibrary { + DynamicLibrary library; + InitFn init = nullptr; + ExecProtocolFn exec_protocol = nullptr; + ExecSimpleQueryFn exec_simple_query = nullptr; + ExecProtocolStreamFn exec_protocol_stream = nullptr; + BackupFn backup = nullptr; + RestoreFn restore = nullptr; + CancelFn cancel = nullptr; + DetachFn detach = nullptr; + LastErrorFn last_error = nullptr; + VersionFn version = nullptr; + CapabilitiesFn capabilities = nullptr; + FreeResponseFn free_response = nullptr; +}; + +struct NativeHandleBox { + std::shared_ptr library; + OliphauntHandle *handle = nullptr; + bool detached = false; +}; + +std::mutex g_libraries_mutex; +std::map> g_libraries; + +void Throw(napi_env env, const std::string &message) { napi_throw_error(env, nullptr, message.c_str()); } + +bool Check(napi_env env, napi_status status, const char *message) { + if (status == napi_ok) { + return true; + } + Throw(env, message); + return false; +} + +bool ExceptionPending(napi_env env) { + bool pending = false; + return napi_is_exception_pending(env, &pending) == napi_ok && pending; +} + +std::string LastError(NativeLibrary *library, OliphauntHandle *handle) { + if (library == nullptr || library->last_error == nullptr) { + return "unknown error"; + } + const char *message = library->last_error(handle); + return message == nullptr || message[0] == '\0' ? "unknown error" : message; +} + +void *LoadSymbol(napi_env env, DynamicLibrary library, const char *name) { +#if defined(_WIN32) + void *symbol = reinterpret_cast(GetProcAddress(library.handle, name)); +#else + void *symbol = dlsym(library.handle, name); +#endif + if (symbol == nullptr) { + Throw(env, std::string("liboliphaunt is missing required symbol ") + name); + } + return symbol; +} + +std::shared_ptr LoadNativeLibrary(napi_env env, const std::string &path) { + std::lock_guard guard(g_libraries_mutex); + auto existing = g_libraries.find(path); + if (existing != g_libraries.end()) { + return existing->second; + } + + DynamicLibrary dynamic; +#if defined(_WIN32) + dynamic.handle = LoadLibraryA(path.c_str()); +#else + dynamic.handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); +#endif + if (dynamic.handle == nullptr) { +#if defined(_WIN32) + Throw(env, "load liboliphaunt failed"); +#else + const char *message = dlerror(); + Throw(env, std::string("load liboliphaunt failed: ") + (message == nullptr ? path : message)); +#endif + return nullptr; + } + + auto library = std::make_shared(); + library->library = dynamic; + library->init = reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_init")); + library->exec_protocol = + reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_exec_protocol")); + library->exec_simple_query = + reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_exec_simple_query")); + library->exec_protocol_stream = reinterpret_cast( + LoadSymbol(env, dynamic, "oliphaunt_exec_protocol_stream")); + library->backup = reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_backup")); + library->restore = reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_restore")); + library->cancel = reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_cancel")); + library->detach = reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_detach")); + library->last_error = + reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_last_error")); + library->version = reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_version")); + library->capabilities = + reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_capabilities")); + library->free_response = + reinterpret_cast(LoadSymbol(env, dynamic, "oliphaunt_free_response")); + + if (ExceptionPending(env)) { + return nullptr; + } + g_libraries[path] = library; + return library; +} + +bool HasNamedProperty(napi_env env, napi_value object, const char *name) { + bool has_property = false; + return napi_has_named_property(env, object, name, &has_property) == napi_ok && has_property; +} + +napi_value GetNamed(napi_env env, napi_value object, const char *name) { + napi_value value = nullptr; + if (!Check(env, napi_get_named_property(env, object, name, &value), "read object property")) { + return nullptr; + } + return value; +} + +std::string ValueToString(napi_env env, napi_value value, const char *label) { + size_t length = 0; + if (!Check(env, napi_get_value_string_utf8(env, value, nullptr, 0, &length), label)) { + return {}; + } + std::vector buffer(length + 1); + if (!Check(env, napi_get_value_string_utf8(env, value, buffer.data(), buffer.size(), &length), + label)) { + return {}; + } + return std::string(buffer.data(), length); +} + +std::string GetString(napi_env env, napi_value object, const char *name, bool required = true) { + if (!HasNamedProperty(env, object, name)) { + if (required) { + Throw(env, std::string("missing required string property ") + name); + } + return {}; + } + napi_value value = GetNamed(env, object, name); + napi_valuetype type = napi_undefined; + napi_typeof(env, value, &type); + if (type == napi_null || type == napi_undefined) { + return {}; + } + std::string out = ValueToString(env, value, "read string value"); + if (required && out.empty()) { + Throw(env, std::string("string property must not be empty: ") + name); + } + return out; +} + +uint32_t GetUint32(napi_env env, napi_value object, const char *name) { + napi_value value = GetNamed(env, object, name); + uint32_t out = 0; + Check(env, napi_get_value_uint32(env, value, &out), "read uint32 property"); + return out; +} + +bool GetBool(napi_env env, napi_value object, const char *name) { + if (!HasNamedProperty(env, object, name)) { + return false; + } + napi_value value = GetNamed(env, object, name); + bool out = false; + Check(env, napi_get_value_bool(env, value, &out), "read boolean property"); + return out; +} + +std::vector GetStringArray(napi_env env, napi_value object, const char *name) { + std::vector out; + if (!HasNamedProperty(env, object, name)) { + return out; + } + napi_value value = GetNamed(env, object, name); + bool is_array = false; + if (!Check(env, napi_is_array(env, value, &is_array), "check string array")) { + return out; + } + if (!is_array) { + Throw(env, std::string("property must be a string array: ") + name); + return out; + } + uint32_t length = 0; + Check(env, napi_get_array_length(env, value, &length), "read string array length"); + out.reserve(length); + for (uint32_t index = 0; index < length; ++index) { + napi_value item = nullptr; + Check(env, napi_get_element(env, value, index, &item), "read string array item"); + out.push_back(ValueToString(env, item, "read string item")); + } + return out; +} + +std::vector GetBytes(napi_env env, napi_value value) { + bool is_typed_array = false; + Check(env, napi_is_typedarray(env, value, &is_typed_array), "check typed array"); + if (!is_typed_array) { + Throw(env, "expected Uint8Array"); + return {}; + } + napi_typedarray_type type; + size_t length = 0; + void *data = nullptr; + napi_value array_buffer = nullptr; + size_t byte_offset = 0; + Check(env, + napi_get_typedarray_info(env, value, &type, &length, &data, &array_buffer, &byte_offset), + "read typed array"); + if (type != napi_uint8_array) { + Throw(env, "expected Uint8Array"); + return {}; + } + const auto *bytes = static_cast(data); + return std::vector(bytes, bytes + length); +} + +napi_value MakeBytes(napi_env env, const uint8_t *data, size_t length) { + void *out = nullptr; + napi_value buffer = nullptr; + if (!Check(env, napi_create_buffer_copy(env, length, data, &out, &buffer), "create response buffer")) { + return nullptr; + } + return buffer; +} + +napi_value MakeResponse(napi_env env, NativeLibrary *library, OliphauntResponse *response) { + napi_value value = MakeBytes(env, response->data, response->len); + library->free_response(response); + response->data = nullptr; + response->len = 0; + return value; +} + +NativeHandleBox *GetHandleBox(napi_env env, napi_value value) { + void *data = nullptr; + if (!Check(env, napi_get_value_external(env, value, &data), "read native handle")) { + return nullptr; + } + auto *box = static_cast(data); + if (box == nullptr || box->handle == nullptr || box->detached) { + Throw(env, "Oliphaunt native handle is closed"); + return nullptr; + } + return box; +} + +void FinalizeHandle(napi_env, void *data, void *) { + auto *box = static_cast(data); + if (box != nullptr) { + if (!box->detached && box->handle != nullptr && box->library != nullptr && box->library->detach != nullptr) { + box->library->detach(box->handle); + } + delete box; + } +} + +std::vector Args(napi_env env, napi_callback_info info, size_t expected) { + size_t argc = expected; + std::vector args(expected); + napi_value this_arg = nullptr; + if (!Check(env, napi_get_cb_info(env, info, &argc, args.data(), &this_arg, nullptr), "read arguments")) { + return {}; + } + if (argc < expected) { + Throw(env, "missing required argument"); + } + return args; +} + +napi_value Version(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 1); + if (args.empty()) return nullptr; + std::string library_path = ValueToString(env, args[0], "read library path"); + auto library = LoadNativeLibrary(env, library_path); + if (library == nullptr) return nullptr; + const char *version = library->version(); + napi_value out = nullptr; + Check(env, napi_create_string_utf8(env, version == nullptr ? "unknown" : version, NAPI_AUTO_LENGTH, &out), + "create version string"); + return out; +} + +napi_value Capabilities(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 1); + if (args.empty()) return nullptr; + std::string library_path = ValueToString(env, args[0], "read library path"); + auto library = LoadNativeLibrary(env, library_path); + if (library == nullptr) return nullptr; + napi_value out = nullptr; + Check(env, napi_create_bigint_uint64(env, library->capabilities(), &out), "create capabilities bigint"); + return out; +} + +napi_value Open(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 1); + if (args.empty()) return nullptr; + napi_value config = args[0]; + std::string library_path = GetString(env, config, "libraryPath"); + std::string pgdata = GetString(env, config, "pgdata"); + std::string runtime_dir = GetString(env, config, "runtimeDirectory", false); + std::string username = GetString(env, config, "username"); + std::string database = GetString(env, config, "database"); + std::vector startup_args = GetStringArray(env, config, "startupArgs"); + if (ExceptionPending(env)) return nullptr; + auto library = LoadNativeLibrary(env, library_path); + if (library == nullptr) return nullptr; + + std::vector startup_ptrs; + startup_ptrs.reserve(startup_args.size()); + for (const auto &arg : startup_args) { + startup_ptrs.push_back(arg.c_str()); + } + + OliphauntConfig native_config = {}; + native_config.abi_version = kAbiVersion; + native_config.pgdata = pgdata.c_str(); + native_config.runtime_dir = runtime_dir.empty() ? nullptr : runtime_dir.c_str(); + native_config.username = username.c_str(); + native_config.database = database.c_str(); + native_config.reserved_flags = 0; + native_config.startup_args = startup_ptrs.empty() ? nullptr : startup_ptrs.data(); + native_config.startup_arg_count = startup_ptrs.size(); + + OliphauntHandle *handle = nullptr; + int32_t rc = library->init(&native_config, &handle); + if (rc != 0) { + Throw(env, "native liboliphaunt init failed: " + LastError(library.get(), nullptr)); + return nullptr; + } + if (handle == nullptr) { + Throw(env, "native liboliphaunt init returned a null handle"); + return nullptr; + } + auto *box = new NativeHandleBox(); + box->library = library; + box->handle = handle; + napi_value external = nullptr; + Check(env, napi_create_external(env, box, FinalizeHandle, nullptr, &external), "create native handle"); + return external; +} + +napi_value ExecProtocolRaw(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 2); + if (args.empty()) return nullptr; + NativeHandleBox *box = GetHandleBox(env, args[0]); + if (box == nullptr) return nullptr; + std::vector request = GetBytes(env, args[1]); + OliphauntResponse response = {}; + int32_t rc = box->library->exec_protocol(box->handle, request.data(), request.size(), &response); + if (rc != 0) { + box->library->free_response(&response); + Throw(env, "native liboliphaunt protocol execution failed: " + LastError(box->library.get(), box->handle)); + return nullptr; + } + return MakeResponse(env, box->library.get(), &response); +} + +napi_value ExecSimpleQuery(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 2); + if (args.empty()) return nullptr; + NativeHandleBox *box = GetHandleBox(env, args[0]); + if (box == nullptr) return nullptr; + std::string sql = ValueToString(env, args[1], "read SQL"); + OliphauntResponse response = {}; + int32_t rc = box->library->exec_simple_query(box->handle, sql.data(), sql.size(), &response); + if (rc != 0) { + box->library->free_response(&response); + Throw(env, "native liboliphaunt simple query failed: " + LastError(box->library.get(), box->handle)); + return nullptr; + } + return MakeResponse(env, box->library.get(), &response); +} + +struct StreamContext { + napi_env env; + napi_ref callback; + std::string error; +}; + +int32_t StreamChunk(void *data, const uint8_t *bytes, size_t length) { + auto *context = static_cast(data); + napi_handle_scope scope = nullptr; + if (napi_open_handle_scope(context->env, &scope) != napi_ok) { + context->error = "open stream callback scope failed"; + return 1; + } + napi_value callback = nullptr; + napi_get_reference_value(context->env, context->callback, &callback); + napi_value global = nullptr; + napi_get_global(context->env, &global); + napi_value chunk = MakeBytes(context->env, bytes, length); + napi_value result = nullptr; + napi_status status = napi_call_function(context->env, global, callback, 1, &chunk, &result); + napi_close_handle_scope(context->env, scope); + if (status != napi_ok) { + context->error = "stream callback failed"; + return 1; + } + return 0; +} + +napi_value ExecProtocolStream(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 3); + if (args.empty()) return nullptr; + NativeHandleBox *box = GetHandleBox(env, args[0]); + if (box == nullptr) return nullptr; + std::vector request = GetBytes(env, args[1]); + StreamContext context = {env, nullptr, {}}; + Check(env, napi_create_reference(env, args[2], 1, &context.callback), "create stream callback reference"); + int32_t rc = box->library->exec_protocol_stream( + box->handle, request.data(), request.size(), StreamChunk, &context); + napi_delete_reference(env, context.callback); + if (!context.error.empty()) { + Throw(env, context.error); + return nullptr; + } + if (rc != 0) { + Throw(env, "native liboliphaunt protocol streaming failed: " + LastError(box->library.get(), box->handle)); + return nullptr; + } + napi_value out = nullptr; + Check(env, napi_get_undefined(env, &out), "create undefined"); + return out; +} + +napi_value Backup(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 2); + if (args.empty()) return nullptr; + NativeHandleBox *box = GetHandleBox(env, args[0]); + if (box == nullptr) return nullptr; + uint32_t format = 0; + Check(env, napi_get_value_uint32(env, args[1], &format), "read backup format"); + OliphauntResponse response = {}; + int32_t rc = box->library->backup(box->handle, format, &response); + if (rc != 0) { + box->library->free_response(&response); + Throw(env, "native liboliphaunt backup failed: " + LastError(box->library.get(), box->handle)); + return nullptr; + } + return MakeResponse(env, box->library.get(), &response); +} + +napi_value Restore(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 1); + if (args.empty()) return nullptr; + napi_value options = args[0]; + std::string library_path = GetString(env, options, "libraryPath"); + auto library = LoadNativeLibrary(env, library_path); + if (library == nullptr) return nullptr; + std::string root = GetString(env, options, "root"); + uint32_t format = GetUint32(env, options, "format"); + std::vector bytes = GetBytes(env, GetNamed(env, options, "bytes")); + bool replace = GetBool(env, options, "replaceExisting"); + OliphauntRestoreOptions native_options = {}; + native_options.abi_version = kAbiVersion; + native_options.root = root.c_str(); + native_options.format = format; + native_options.data = bytes.empty() ? nullptr : bytes.data(); + native_options.len = bytes.size(); + native_options.flags = replace ? kRestoreReplaceExisting : 0; + int32_t rc = library->restore(&native_options); + if (rc != 0) { + Throw(env, "native liboliphaunt restore failed: " + LastError(library.get(), nullptr)); + return nullptr; + } + napi_value out = nullptr; + Check(env, napi_get_undefined(env, &out), "create undefined"); + return out; +} + +napi_value Cancel(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 1); + if (args.empty()) return nullptr; + NativeHandleBox *box = GetHandleBox(env, args[0]); + if (box == nullptr) return nullptr; + int32_t rc = box->library->cancel(box->handle); + if (rc != 0) { + Throw(env, "native liboliphaunt cancel failed: " + LastError(box->library.get(), box->handle)); + return nullptr; + } + napi_value out = nullptr; + Check(env, napi_get_undefined(env, &out), "create undefined"); + return out; +} + +napi_value Detach(napi_env env, napi_callback_info info) { + auto args = Args(env, info, 1); + if (args.empty()) return nullptr; + NativeHandleBox *box = GetHandleBox(env, args[0]); + if (box == nullptr) return nullptr; + int32_t rc = box->library->detach(box->handle); + if (rc != 0) { + Throw(env, "native liboliphaunt detach failed: " + LastError(box->library.get(), box->handle)); + return nullptr; + } + box->detached = true; + box->handle = nullptr; + napi_value out = nullptr; + Check(env, napi_get_undefined(env, &out), "create undefined"); + return out; +} + +napi_value Init(napi_env env, napi_value exports) { + const napi_property_descriptor descriptors[] = { + {"version", nullptr, Version, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"capabilities", nullptr, Capabilities, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"open", nullptr, Open, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"execProtocolRaw", nullptr, ExecProtocolRaw, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"execSimpleQuery", nullptr, ExecSimpleQuery, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"execProtocolStream", nullptr, ExecProtocolStream, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"backup", nullptr, Backup, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"restore", nullptr, Restore, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"cancel", nullptr, Cancel, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"detach", nullptr, Detach, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; + Check(env, napi_define_properties(env, exports, sizeof(descriptors) / sizeof(descriptors[0]), descriptors), + "define exports"); + return exports; +} + +} // namespace + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/src/runtimes/node-direct/package.json b/src/runtimes/node-direct/package.json new file mode 100644 index 00000000..39678923 --- /dev/null +++ b/src/runtimes/node-direct/package.json @@ -0,0 +1,34 @@ +{ + "name": "@oliphaunt/node-direct", + "version": "0.1.0", + "description": "Node-API native direct adapter for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "private": true, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/node-direct" + }, + "oliphaunt": { + "liboliphauntVersion": "0.1.0" + }, + "files": [ + "native", + "packages", + "targets", + "tools", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tools/build-node-addon.sh", + "package": "tools/check-package.sh" + }, + "engines": { + "node": ">=22.13 <25" + }, + "devDependencies": { + "node-api-headers": "1.9.0" + } +} diff --git a/src/runtimes/node-direct/packages/darwin-arm64/README.md b/src/runtimes/node-direct/packages/darwin-arm64/README.md new file mode 100644 index 00000000..4be6b696 --- /dev/null +++ b/src/runtimes/node-direct/packages/darwin-arm64/README.md @@ -0,0 +1,3 @@ +# @oliphaunt/node-direct-darwin-arm64 + +Prebuilt macOS arm64 Node-API native direct adapter for Oliphaunt. diff --git a/src/runtimes/node-direct/packages/darwin-arm64/package.json b/src/runtimes/node-direct/packages/darwin-arm64/package.json new file mode 100644 index 00000000..90d332a6 --- /dev/null +++ b/src/runtimes/node-direct/packages/darwin-arm64/package.json @@ -0,0 +1,31 @@ +{ + "name": "@oliphaunt/node-direct-darwin-arm64", + "version": "0.1.0", + "description": "macOS arm64 prebuilt Node-API native direct adapter for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/node-direct/packages/darwin-arm64" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true, + "publishConfig": { + "access": "public", + "provenance": true + }, + "files": [ + "prebuilds", + "README.md" + ], + "exports": { + "./oliphaunt_node.node": "./prebuilds/oliphaunt_node.node", + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/node-direct/packages/linux-arm64-gnu/README.md b/src/runtimes/node-direct/packages/linux-arm64-gnu/README.md new file mode 100644 index 00000000..60e77417 --- /dev/null +++ b/src/runtimes/node-direct/packages/linux-arm64-gnu/README.md @@ -0,0 +1,3 @@ +# @oliphaunt/node-direct-linux-arm64-gnu + +Prebuilt Linux arm64 glibc Node-API native direct adapter for Oliphaunt. diff --git a/src/runtimes/node-direct/packages/linux-arm64-gnu/package.json b/src/runtimes/node-direct/packages/linux-arm64-gnu/package.json new file mode 100644 index 00000000..05f946db --- /dev/null +++ b/src/runtimes/node-direct/packages/linux-arm64-gnu/package.json @@ -0,0 +1,34 @@ +{ + "name": "@oliphaunt/node-direct-linux-arm64-gnu", + "version": "0.1.0", + "description": "Linux arm64 glibc prebuilt Node-API native direct adapter for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/node-direct/packages/linux-arm64-gnu" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "publishConfig": { + "access": "public", + "provenance": true + }, + "files": [ + "prebuilds", + "README.md" + ], + "exports": { + "./oliphaunt_node.node": "./prebuilds/oliphaunt_node.node", + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/node-direct/packages/linux-x64-gnu/README.md b/src/runtimes/node-direct/packages/linux-x64-gnu/README.md new file mode 100644 index 00000000..8376a7fe --- /dev/null +++ b/src/runtimes/node-direct/packages/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# @oliphaunt/node-direct-linux-x64-gnu + +Prebuilt Linux x64 glibc Node-API native direct adapter for Oliphaunt. diff --git a/src/runtimes/node-direct/packages/linux-x64-gnu/package.json b/src/runtimes/node-direct/packages/linux-x64-gnu/package.json new file mode 100644 index 00000000..e64ecf12 --- /dev/null +++ b/src/runtimes/node-direct/packages/linux-x64-gnu/package.json @@ -0,0 +1,34 @@ +{ + "name": "@oliphaunt/node-direct-linux-x64-gnu", + "version": "0.1.0", + "description": "Linux x64 glibc prebuilt Node-API native direct adapter for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/node-direct/packages/linux-x64-gnu" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "optional": true, + "publishConfig": { + "access": "public", + "provenance": true + }, + "files": [ + "prebuilds", + "README.md" + ], + "exports": { + "./oliphaunt_node.node": "./prebuilds/oliphaunt_node.node", + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/node-direct/packages/win32-x64-msvc/README.md b/src/runtimes/node-direct/packages/win32-x64-msvc/README.md new file mode 100644 index 00000000..07cfd4ae --- /dev/null +++ b/src/runtimes/node-direct/packages/win32-x64-msvc/README.md @@ -0,0 +1,3 @@ +# @oliphaunt/node-direct-win32-x64-msvc + +Prebuilt Windows x64 MSVC Node-API native direct adapter for Oliphaunt. diff --git a/src/runtimes/node-direct/packages/win32-x64-msvc/package.json b/src/runtimes/node-direct/packages/win32-x64-msvc/package.json new file mode 100644 index 00000000..676c92c0 --- /dev/null +++ b/src/runtimes/node-direct/packages/win32-x64-msvc/package.json @@ -0,0 +1,31 @@ +{ + "name": "@oliphaunt/node-direct-win32-x64-msvc", + "version": "0.1.0", + "description": "Windows x64 MSVC prebuilt Node-API native direct adapter for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/runtimes/node-direct/packages/win32-x64-msvc" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true, + "publishConfig": { + "access": "public", + "provenance": true + }, + "files": [ + "prebuilds", + "README.md" + ], + "exports": { + "./oliphaunt_node.node": "./prebuilds/oliphaunt_node.node", + "./package.json": "./package.json" + } +} diff --git a/src/runtimes/node-direct/release.toml b/src/runtimes/node-direct/release.toml new file mode 100644 index 00000000..8abad142 --- /dev/null +++ b/src/runtimes/node-direct/release.toml @@ -0,0 +1,11 @@ +id = "oliphaunt-node-direct" +owner = "@oliphaunt/node-direct" +kind = "runtime" +publish_targets = ["npm", "github-release-assets"] +registry_packages = [ + "npm:@oliphaunt/node-direct-darwin-arm64", + "npm:@oliphaunt/node-direct-linux-x64-gnu", + "npm:@oliphaunt/node-direct-linux-arm64-gnu", + "npm:@oliphaunt/node-direct-win32-x64-msvc", +] +release_artifacts = ["node-api-prebuilds", "npm-optional-platform-packages"] diff --git a/src/runtimes/node-direct/targets/checksums.toml b/src/runtimes/node-direct/targets/checksums.toml new file mode 100644 index 00000000..22913bcf --- /dev/null +++ b/src/runtimes/node-direct/targets/checksums.toml @@ -0,0 +1,7 @@ +id = "oliphaunt-node-direct.checksums" +product = "oliphaunt-node-direct" +kind = "checksums" +target = "portable" +asset = "oliphaunt-node-direct-{version}-release-assets.sha256" +surfaces = ["github-release"] +published = true diff --git a/src/runtimes/node-direct/targets/linux-arm64-gnu.toml b/src/runtimes/node-direct/targets/linux-arm64-gnu.toml new file mode 100644 index 00000000..e92dd751 --- /dev/null +++ b/src/runtimes/node-direct/targets/linux-arm64-gnu.toml @@ -0,0 +1,11 @@ +id = "oliphaunt-node-direct.linux-arm64-gnu" +product = "oliphaunt-node-direct" +kind = "node-direct-addon" +target = "linux-arm64-gnu" +triple = "aarch64-unknown-linux-gnu" +runner = "ubuntu-24.04-arm" +asset = "oliphaunt-node-direct-{version}-linux-arm64-gnu.tar.gz" +library_relative_path = "oliphaunt_node.node" +surfaces = ["github-release", "npm-optional"] +npm_package = "@oliphaunt/node-direct-linux-arm64-gnu" +published = true diff --git a/src/runtimes/node-direct/targets/linux-x64-gnu.toml b/src/runtimes/node-direct/targets/linux-x64-gnu.toml new file mode 100644 index 00000000..d38de658 --- /dev/null +++ b/src/runtimes/node-direct/targets/linux-x64-gnu.toml @@ -0,0 +1,11 @@ +id = "oliphaunt-node-direct.linux-x64-gnu" +product = "oliphaunt-node-direct" +kind = "node-direct-addon" +target = "linux-x64-gnu" +triple = "x86_64-unknown-linux-gnu" +runner = "ubuntu-latest" +asset = "oliphaunt-node-direct-{version}-linux-x64-gnu.tar.gz" +library_relative_path = "oliphaunt_node.node" +surfaces = ["github-release", "npm-optional"] +npm_package = "@oliphaunt/node-direct-linux-x64-gnu" +published = true diff --git a/src/runtimes/node-direct/targets/macos-arm64.toml b/src/runtimes/node-direct/targets/macos-arm64.toml new file mode 100644 index 00000000..5c9320c3 --- /dev/null +++ b/src/runtimes/node-direct/targets/macos-arm64.toml @@ -0,0 +1,11 @@ +id = "oliphaunt-node-direct.macos-arm64" +product = "oliphaunt-node-direct" +kind = "node-direct-addon" +target = "macos-arm64" +triple = "aarch64-apple-darwin" +runner = "macos-latest" +asset = "oliphaunt-node-direct-{version}-macos-arm64.tar.gz" +library_relative_path = "oliphaunt_node.node" +surfaces = ["github-release", "npm-optional"] +npm_package = "@oliphaunt/node-direct-darwin-arm64" +published = true diff --git a/src/runtimes/node-direct/targets/windows-x64-msvc.toml b/src/runtimes/node-direct/targets/windows-x64-msvc.toml new file mode 100644 index 00000000..9a922505 --- /dev/null +++ b/src/runtimes/node-direct/targets/windows-x64-msvc.toml @@ -0,0 +1,11 @@ +id = "oliphaunt-node-direct.windows-x64-msvc" +product = "oliphaunt-node-direct" +kind = "node-direct-addon" +target = "windows-x64-msvc" +triple = "x86_64-pc-windows-msvc" +runner = "windows-latest" +asset = "oliphaunt-node-direct-{version}-windows-x64-msvc.zip" +library_relative_path = "oliphaunt_node.node" +surfaces = ["github-release", "npm-optional"] +npm_package = "@oliphaunt/node-direct-win32-x64-msvc" +published = true diff --git a/src/runtimes/node-direct/tools/build-node-addon.sh b/src/runtimes/node-direct/tools/build-node-addon.sh new file mode 100755 index 00000000..72458f93 --- /dev/null +++ b/src/runtimes/node-direct/tools/build-node-addon.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require node +require npm +require python3 +require tar + +case "$(uname -s)" in + Darwin) platform="macos" ;; + Linux) platform="linux" ;; + MINGW*|MSYS*|CYGWIN*) platform="windows" ;; + *) echo "unsupported Node direct adapter platform: $(uname -s)" >&2; exit 2 ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch="arm64" ;; + x86_64|amd64) arch="x64" ;; + *) echo "unsupported Node direct adapter architecture: $(uname -m)" >&2; exit 2 ;; +esac + +case "$platform:$arch" in + macos:arm64) target="macos-arm64" ;; + linux:x64) target="linux-x64-gnu" ;; + linux:arm64) target="linux-arm64-gnu" ;; + windows:x64) target="windows-x64-msvc" ;; + *) echo "unsupported Node direct adapter target: $platform/$arch" >&2; exit 2 ;; +esac + +to_shell_path() { + if [ "$platform" = "windows" ] && command -v cygpath >/dev/null 2>&1; then + normalized="$(node -e 'process.stdout.write(process.argv[1].replace(/\\/g, "/"))' "$1")" + cygpath -u "$normalized" + else + printf '%s\n' "$1" + fi +} + +version="$(node -e "console.log(require('./src/runtimes/node-direct/package.json').version)")" +node_exec="$(to_shell_path "$(node -p "process.execPath")")" +node_bin_dir="$(dirname "$node_exec")" +node_root="$(dirname "$node_bin_dir")" +node_include="${NODE_INCLUDE_DIR:-}" +if [ -n "$node_include" ]; then + node_include="$(to_shell_path "$node_include")" +fi +if [ -z "$node_include" ]; then + for candidate in "$node_root/include/node" "$node_root/include"; do + if [ -f "$candidate/node_api.h" ]; then + node_include="$candidate" + break + fi + done +fi +if [ -z "$node_include" ]; then + node_include="$( + node -e ' +const path = require("node:path"); +try { + process.stdout.write(path.dirname(require.resolve("node-api-headers/include/node_api.h", { + paths: [process.cwd(), path.join(process.cwd(), "src/runtimes/node-direct")] + }))); +} catch { + process.exit(1); +} +' 2>/dev/null || true + )" + if [ -n "$node_include" ]; then + node_include="$(to_shell_path "$node_include")" + fi +fi +if [ -z "$node_include" ]; then + require curl + node_version="$(node -p "process.versions.node")" + node_headers_dir="$root/target/oliphaunt-node-direct/node-headers/v$node_version" + node_include="$node_headers_dir/include/node" + if [ ! -f "$node_include/node_api.h" ]; then + rm -rf "$node_headers_dir" + mkdir -p "$node_headers_dir" + node_headers_archive="$node_headers_dir/node-headers.tar.gz" + node_headers_url="https://nodejs.org/dist/v$node_version/node-v$node_version-headers.tar.gz" + curl --fail --location --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ + --output "$node_headers_archive" "$node_headers_url" + tar --force-local -C "$node_headers_dir" --strip-components=1 -xzf "$node_headers_archive" + fi +fi + +if [ ! -f "$node_include/node_api.h" ]; then + echo "missing node_api.h; set NODE_INCLUDE_DIR or install node-api-headers" >&2 + exit 2 +fi + +out_dir="${OLIPHAUNT_NODE_ADDON_OUT_DIR:-$root/target/oliphaunt-artifacts/node-direct/$target}" +asset_dir="${OLIPHAUNT_NODE_ADDON_ASSET_OUT_DIR:-$root/target/oliphaunt-node-direct/release-assets}" +npm_package_dir="${OLIPHAUNT_NODE_ADDON_NPM_PACKAGE_OUT_DIR:-$root/target/oliphaunt-node-direct/npm-packages}" +npm_package_work_root="${OLIPHAUNT_NODE_ADDON_NPM_PACKAGE_WORK_DIR:-$root/target/oliphaunt-node-direct/npm-package-work/$target}" +src="src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc" +addon="$out_dir/oliphaunt_node.node" +addon_file="$addon" + +mkdir -p "$out_dir" "$asset_dir" "$npm_package_dir" + +cxx="${CXX:-c++}" +common_flags="-std=c++17 -O3 -DNAPI_VERSION=8 -DNODE_GYP_MODULE_NAME=oliphaunt_node -I$node_include" + +case "$platform" in + macos) + "$cxx" $common_flags -fPIC -bundle -undefined dynamic_lookup "$src" -o "$addon" + ;; + linux) + "$cxx" $common_flags -fPIC -shared "$src" -ldl -o "$addon" + ;; + windows) + node_lib="${NODE_LIB:-}" + if [ -n "$node_lib" ]; then + node_lib="$(to_shell_path "$node_lib")" + fi + if [ -z "$node_lib" ]; then + for candidate in "$node_bin_dir/node.lib" "$node_root/x64/node.lib" "$node_root/lib/node.lib" "$node_root/node.lib"; do + if [ -f "$candidate" ]; then + node_lib="$candidate" + break + fi + done + fi + if [ -z "$node_lib" ]; then + require curl + node_version="$(node -p "process.versions.node")" + case "$arch" in + x64) node_dist_arch="x64" ;; + arm64) node_dist_arch="arm64" ;; + *) echo "unsupported Node direct Windows architecture for node.lib: $arch" >&2; exit 2 ;; + esac + node_lib_dir="$root/target/oliphaunt-node-direct/node-lib/v$node_version-win-$node_dist_arch" + node_lib="$node_lib_dir/node.lib" + if [ ! -f "$node_lib" ]; then + mkdir -p "$node_lib_dir" + node_lib_url="https://nodejs.org/dist/v$node_version/win-$node_dist_arch/node.lib" + curl --fail --location --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ + --output "$node_lib" "$node_lib_url" + fi + fi + if [ ! -f "$node_lib" ]; then + echo "missing node.lib; set NODE_LIB" >&2 + exit 2 + fi + cxx="${CXX:-cl}" + if command -v cygpath >/dev/null 2>&1; then + node_include="$(cygpath -w "$node_include")" + node_lib="$(cygpath -w "$node_lib")" + src="$(cygpath -w "$src")" + addon="$(cygpath -w "$addon")" + fi + "$cxx" //nologo //std:c++17 //O2 //EHsc //LD //DNAPI_VERSION=8 //DNODE_GYP_MODULE_NAME=oliphaunt_node "-I$node_include" "$src" //link "$node_lib" //OUT:"$addon" + ;; +esac + +node - "$addon" <<'JS' +const addonPath = process.argv[2]; +const addon = require(addonPath); +const expected = [ + 'version', + 'capabilities', + 'open', + 'execProtocolRaw', + 'execSimpleQuery', + 'execProtocolStream', + 'backup', + 'restore', + 'cancel', + 'detach', +]; +for (const name of expected) { + if (typeof addon[name] !== 'function') { + throw new Error(`compiled Node direct addon is missing export ${name}`); + } +} +JS + +if [ "$platform" = "windows" ]; then + asset="oliphaunt-node-direct-$version-$target.zip" + python3 - "$out_dir" "$asset_dir/$asset" <<'PY' +import pathlib +import sys +import zipfile + +out_dir = pathlib.Path(sys.argv[1]) +asset = pathlib.Path(sys.argv[2]) +with zipfile.ZipFile(asset, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(out_dir / "oliphaunt_node.node", "oliphaunt_node.node") +PY +else + asset="oliphaunt-node-direct-$version-$target.tar.gz" + tar -C "$out_dir" -czf "$asset_dir/$asset" oliphaunt_node.node +fi + +input_dirs="${OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS:-${OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS:-}}" +if [ -n "$input_dirs" ]; then + old_ifs="$IFS" + IFS=':' + for input_dir in $input_dirs; do + IFS="$old_ifs" + [ -n "$input_dir" ] || continue + [ -d "$input_dir" ] || { + echo "release asset input directory does not exist: $input_dir" >&2 + exit 1 + } + find "$input_dir" -maxdepth 1 -type f \( -name 'oliphaunt-node-direct-*.tar.gz' -o -name 'oliphaunt-node-direct-*.zip' \) -print | + sort | + while IFS= read -r input_asset; do + [ -n "$input_asset" ] || continue + cp -p "$input_asset" "$asset_dir/" + done + IFS=':' + done + IFS="$old_ifs" +fi + +tools/release/write_checksum_manifest.py \ + --asset-dir "$asset_dir" \ + --output "oliphaunt-node-direct-$version-release-assets.sha256" \ + --pattern 'oliphaunt-node-direct-*.tar.gz' \ + --pattern 'oliphaunt-node-direct-*.zip' + +printf 'Node direct addon smoke passed: %s\n' "$addon" +python3 tools/release/check_node_direct_release_assets.py --asset-dir "$asset_dir" --allow-partial +case "$target" in + macos-arm64) optional_package="darwin-arm64" ;; + linux-x64-gnu) optional_package="linux-x64-gnu" ;; + linux-arm64-gnu) optional_package="linux-arm64-gnu" ;; + windows-x64-msvc) optional_package="win32-x64-msvc" ;; + *) echo "unsupported Node direct optional npm package target: $target" >&2; exit 2 ;; +esac +package_source="$root/src/runtimes/node-direct/packages/$optional_package" +package_work="$npm_package_work_root/$optional_package" +rm -rf "$package_work" +mkdir -p "$package_work/prebuilds" +cp -R "$package_source/." "$package_work/" +rm -rf "$package_work/prebuilds" +mkdir -p "$package_work/prebuilds" +cp "$addon_file" "$package_work/prebuilds/oliphaunt_node.node" +pack_json="$(npm pack "$package_work" --pack-destination "$npm_package_dir" --json)" +printf '%s\n' "$pack_json" >"$npm_package_dir/$optional_package.npm-pack.json" +tarball="$( + PACK_JSON="$pack_json" PACK_DIR="$npm_package_dir" node <<'JS' +const path = require('node:path'); +const raw = JSON.parse(process.env.PACK_JSON || '[]'); +const entry = Array.isArray(raw) ? raw[0] : raw; +if (!entry || typeof entry.filename !== 'string' || !entry.filename.endsWith('.tgz')) { + throw new Error('npm pack did not report a .tgz filename'); +} +process.stdout.write(path.isAbsolute(entry.filename) ? entry.filename : path.join(process.env.PACK_DIR, entry.filename)); +JS +)" +[ -f "$tarball" ] || { + echo "npm pack did not create $tarball" >&2 + exit 1 +} +python3 - "$tarball" <<'PY' || { +import sys +import tarfile + +expected = "package/prebuilds/oliphaunt_node.node" +with tarfile.open(sys.argv[1], "r:gz") as archive: + if expected not in archive.getnames(): + raise SystemExit(1) +PY + echo "Node direct optional npm package is missing prebuilds/oliphaunt_node.node: $tarball" >&2 + exit 1 +} +printf 'Node direct optional npm package staged: %s\n' "$tarball" +printf '%s\n' "$asset_dir/$asset" diff --git a/src/runtimes/node-direct/tools/check-package.sh b/src/runtimes/node-direct/tools/check-package.sh new file mode 100755 index 00000000..c8058b6e --- /dev/null +++ b/src/runtimes/node-direct/tools/check-package.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-check-static}" +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +package_dir="src/runtimes/node-direct" + +require_file() { + local path="$1" + if [ ! -f "$path" ]; then + echo "missing required Node direct file: $path" >&2 + exit 1 + fi +} + +require_text() { + local path="$1" + local text="$2" + local message="$3" + if ! grep -Fq "$text" "$path"; then + echo "$message" >&2 + echo "missing text: $text in $path" >&2 + exit 1 + fi +} + +reject_text() { + local path="$1" + local text="$2" + local message="$3" + if grep -Fq "$text" "$path"; then + echo "$message" >&2 + echo "forbidden text: $text in $path" >&2 + exit 1 + fi +} + +check_static() { + require_file "$package_dir/package.json" + require_file "$package_dir/native/node-addon/oliphaunt_node.cc" + require_file "$package_dir/tools/build-node-addon.sh" + require_file "$package_dir/targets/checksums.toml" + require_file "$package_dir/targets/macos-arm64.toml" + require_file "$package_dir/targets/linux-x64-gnu.toml" + require_file "$package_dir/targets/linux-arm64-gnu.toml" + require_file "$package_dir/targets/windows-x64-msvc.toml" + require_text "$package_dir/package.json" '"name": "@oliphaunt/node-direct"' \ + "Node direct runtime must have a product-local package identity" + require_text "$package_dir/tools/build-node-addon.sh" "src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc" \ + "Node direct build must compile product-owned addon source" + require_text "$package_dir/tools/build-node-addon.sh" "oliphaunt-node-direct-\$version-\$target.tar.gz" \ + "Node direct build must emit product-scoped release assets" + require_text "$package_dir/tools/build-node-addon.sh" "Node direct addon smoke passed" \ + "Node direct build must load-smoke the compiled addon before publishing an artifact" + reject_text "$package_dir/tools/build-node-addon.sh" "oliphaunt-js-node-direct" \ + "Node direct runtime must not emit TypeScript-owned addon assets" + require_text "$package_dir/native/node-addon/oliphaunt_node.cc" "NAPI_MODULE" \ + "Node direct addon must register a Node-API module" +} + +check_platform_packages() { + local packages=( + "darwin-arm64" + "linux-x64-gnu" + "linux-arm64-gnu" + "win32-x64-msvc" + ) + for platform_package in "${packages[@]}"; do + local path="$package_dir/packages/$platform_package/package.json" + require_file "$path" + require_text "$path" '"optional": true' \ + "Node direct platform package metadata must mark the package optional" + require_text "$path" '"./oliphaunt_node.node": "./prebuilds/oliphaunt_node.node"' \ + "Node direct platform packages must export the prebuilt addon by stable path" + reject_text "$path" '"scripts"' \ + "Node direct platform packages must not run install or build scripts" + reject_text "$path" "node-gyp" \ + "Node direct platform packages must not require node-gyp" + done +} + +case "$mode" in + check-static) + check_static + ;; + test-unit) + check_static + check_platform_packages + ;; + package-shape) + check_static + check_platform_packages + require_text "pnpm-workspace.yaml" '"src/runtimes/node-direct/packages/*"' \ + "pnpm workspace must include Node direct optional platform packages" + require_text "src/sdks/js/package.json" '"@oliphaunt/node-direct-darwin-arm64"' \ + "TypeScript SDK must depend on Node direct optional platform packages" + ;; + *) + echo "unknown Node direct check mode: $mode" >&2 + exit 2 + ;; +esac + +echo "oliphaunt-node-direct $mode passed" diff --git a/src/sdks/js/ARCHITECTURE.md b/src/sdks/js/ARCHITECTURE.md new file mode 100644 index 00000000..19a56083 --- /dev/null +++ b/src/sdks/js/ARCHITECTURE.md @@ -0,0 +1,418 @@ +# Oliphaunt TypeScript Runtime Architecture + +`@oliphaunt/ts` targets Node.js, Bun, and Deno outside React Native. Its runtime +architecture must preserve the Rust SDK mode semantics rather than mapping every +mode onto the current in-process FFI binding. + +The shipped implementation exposes `nativeDirect`, `nativeBroker`, and +`nativeServer` with honest availability. Node.js, Bun, and Deno all default to +`nativeDirect` for predictable cross-runtime semantics. Node.js gets that +default through Oliphaunt's prebuilt Node-API direct adapter release asset; Bun +and Deno use their runtime-owned FFI surfaces. + +## Research Baseline + +Rust is the parity source: + +- `NativeDirect` is in-process and serialized over one physical PostgreSQL + backend session. +- `NativeBroker` supervises one `oliphaunt-broker` helper process per active + root, uses the authenticated `PGOB` frame protocol, supports multiple roots + up to `broker_max_roots`, and restarts a helper only after a failed or exited + request boundary. +- `NativeServer` starts a real local PostgreSQL server process, exposes a + PostgreSQL connection string, owns one SDK connection for SDK calls, and is + the only mode that advertises independent sessions. + +Runtime and platform facts: + +- Node.js exposes asynchronous child process spawning and local IPC/TCP sockets + through stable standard modules. Its docs also warn that unconsumed child + stdout/stderr pipes can block the child, so long-running helpers must either + inherit/drain stderr and reserve stdout for bounded readiness output. +- Bun exposes `Bun.spawn`, implements `node:net` fully in its Node compatibility + table, and can kill/unref subprocesses. The broker/server implementation can + use the same Node-compatible socket path for Bun, while native Bun adapters can + be optimized later. +- Deno exposes `Deno.Command`, `Deno.connect`, and Unix socket listeners, but it + requires explicit permissions for subprocess, network, filesystem, and FFI + access. Deno support must report actionable permission failures instead of + hiding them behind generic runtime-unavailable errors. +- PostgreSQL server mode must use real `postgres`/`pg_ctl` lifecycle semantics: + local listen addresses, optional Unix socket directories, controlled stop, and + native startup/cancel protocol behavior. + +Primary external references used for this architecture: + +- Node.js child processes: +- Node.js IPC sockets: +- Bun child processes: +- Bun Node compatibility: +- Deno subprocesses: +- Deno networking: +- PostgreSQL `postgres`: +- PostgreSQL `pg_ctl`: + +## Goals + +- Reach Rust parity for mode semantics, capabilities, validation, and error + honesty. +- Keep binary protocol bytes as `Uint8Array` end-to-end. +- Keep hot protocol paths free of JSON, text re-encoding, and avoidable copies. +- Make child process ownership explicit and recoverable. +- Keep runtime-specific code behind small process/socket adapters. +- Prefer one implementation shared by Node, Bun, and Deno when the runtime API is + already compatible. + +## Non-Goals + +- Do not emulate broker/server by opening `nativeDirect` inside the JavaScript + process. +- Do not depend on a general PostgreSQL client library for SDK-owned + `execProtocolRaw`; the SDK owns raw protocol bytes and strict response parsing. +- Do not mark a mode available until native smoke and parity tests cover that + mode on at least one supported runtime. +- Do not invent a second broker protocol for TypeScript. +- Do not make normal Node.js consumers approve a native FFI dependency just to + open a database. Node `nativeDirect` is the default, but it must be served by + Oliphaunt-owned prebuilt Node-API adapter artifacts rather than a + consumer-installed third-party FFI package. + +## Public API Target + +`OpenConfig` should grow only the Rust-parity knobs that affect mode semantics: + +```ts +type OpenConfig = { + engine?: 'nativeDirect' | 'nativeBroker' | 'nativeServer'; + root?: string; + temporary?: boolean; + maxClientSessions?: number; + brokerExecutable?: string; + brokerMaxRoots?: number; + brokerTransport?: 'auto' | 'unix' | 'tcp'; + serverExecutable?: string; + serverPort?: number; + serverToolDirectory?: string; + durability?: 'safe' | 'balanced' | 'fastDev'; + runtimeFootprint?: 'throughput' | 'balancedMobile' | 'smallMobile'; + startupGUCs?: readonly PostgresStartupGUC[]; + username?: string; + database?: string; + extensions?: readonly string[]; + libraryPath?: string; + runtimeDirectory?: string; +}; +``` + +Validation must match Rust: + +- `root` is the Oliphaunt root directory; native PGDATA is always + `/pgdata`, including direct, broker, server, backup, and restore paths; +- direct and broker accept only `maxClientSessions === 1`; +- server accepts `maxClientSessions > 0` and defaults to `32`; +- broker requires `brokerMaxRoots > 0` and defaults to `1`; +- server rejects `serverPort === 0`; omitting it means allocate an ephemeral + localhost port; +- executable, tool directory, root, identity, extension, and GUC validation + remain pre-spawn checks. + +When `engine` is omitted, the default is consistent: + +- Node.js: `nativeDirect`; +- Bun: `nativeDirect`; +- Deno: `nativeDirect`. + +`supportedModes()` reports availability per configured runtime: + +- `nativeDirect`: available when `liboliphaunt` loads and the runtime has a + direct adapter. Bun and Deno use built-in FFI. Node resolves the verified + `oliphaunt-node-direct-*` Node-API adapter release asset and loads it + without `postinstall`, node-gyp, Rust, Cargo, or third-party FFI packages; +- `nativeBroker`: available when the broker helper resolves from an explicit + override, package-adjacent executable, or verified Rust SDK release asset, the + matching `liboliphaunt` install resolves, and the current runtime can spawn + and connect to the selected local transport; +- `nativeServer`: available when the server toolchain resolves and the current + runtime can spawn, connect, and stop the server process. + +Broker/server availability remains conditional on executable/toolchain +discovery and smoke coverage. Missing helpers must stay explicit unavailable +entries rather than aliases to direct mode. Node.js physical-archive restore +uses the same Node direct adapter by default; broker restore is used only when +the caller explicitly selects `engine: 'nativeBroker'`. + +## Runtime Adapter Boundary + +Add one internal adapter layer: + +```ts +type RuntimeProcessAdapter = { + runtime: 'node' | 'bun' | 'deno'; + supportsUnixSockets: boolean; + spawn(command: ProcessCommand): Promise; + connect(endpoint: LocalEndpoint): Promise; + createTempDir(prefix: string): Promise; + removeTree(path: string): Promise; + randomBytes(length: number): Uint8Array; +}; +``` + +`ByteStream` is the only transport shape visible to broker/server code: + +```ts +type ByteStream = { + readExactly(length: number): Promise; + writeAll(bytes: Uint8Array): Promise; + close(): Promise; +}; +``` + +Node and Bun can share a Node-compatible adapter using `node:child_process` and +`node:net`; Bun-native spawn can replace the process half later without changing +broker/server code. Deno gets a native adapter using `Deno.Command` and +`Deno.connect`. + +## Native Broker Design + +TypeScript `nativeBroker` should reuse the Rust `oliphaunt-broker` helper and +the existing `PGOB` frame protocol. + +### Open Flow + +1. Normalize and validate config. +2. Materialize `temporary` roots in the host runtime temp directory. +3. Acquire an in-process broker root lease keyed by canonical or normalized + absolute path. This mirrors Rust's duplicate-root and capacity guard. +4. Resolve the broker executable from `brokerExecutable`, `OLIPHAUNT_BROKER`, + package-adjacent executable names, or the checksum-verified Rust SDK + `oliphaunt-broker` release asset pinned by package metadata. +5. Resolve the compatible `liboliphaunt` install exactly as direct mode does. + Broker launch must pass `LIBOLIPHAUNT_PATH` and `OLIPHAUNT_INSTALL_DIR` to the + Rust helper so explicit config and auto-resolved release assets behave the + same as direct mode. +6. Allocate IPC endpoints: + - Unix sockets on Unix runtimes that support them; + - TCP loopback fallback when Unix sockets are unavailable or + `brokerTransport: 'tcp'` is selected. +7. Generate a 32-byte random auth token and pass it only through the child + environment. +8. Spawn `oliphaunt-broker` with the same argument set Rust uses: + `--root`, `--bootstrap`, `--durability`, `--runtime-footprint`, optional + `--initdb`, `--username`, `--database`, endpoint flags, repeated + `--extension`, and repeated `--startup-guc`. +9. Read exactly one bounded stdout readiness line: + `OLIPHAUNT_BROKER_READY cancel=`. +10. Connect to the primary endpoint and authenticate with the token before any + protocol frame. +11. Create a `BrokerSession` with the child, primary stream, cancel endpoint, + root lease, IPC cleanup path, and temporary root cleanup ownership. + +### Frame Protocol + +The broker client must port Rust's frame codec exactly: + +- magic: `PGOB`; +- header length: 13 bytes; +- payload length: unsigned big-endian `u64`; +- maximum payload length: 128 MiB; +- request kinds: authenticate, raw protocol, simple query, stream protocol, + checkpoint, backup, cancel, close; +- response kinds: ok, error, stream chunk. + +Errors stay textual at the broker IPC boundary because that is the Rust helper +contract. PostgreSQL ErrorResponse bytes still flow through successful protocol +responses and are parsed by the existing query parser. + +### Execution Semantics + +- Raw, simple, stream, checkpoint, backup, and close serialize through the same + `OliphauntDatabase` operation gate used by direct mode. +- Cancellation uses the separate cancel endpoint so it is not queued behind a + long result stream. +- If the helper exits between operations, relaunch before the next operation. +- If a request fails mid-flight, return an error and mark the helper failed. Do + not replay the request because commit state is unknown. +- Subsequent operations may relaunch the helper against the same root and rely + on PostgreSQL WAL recovery. +- Close sends a best-effort close frame, waits for bounded exit, kills on + timeout, and then releases root/temp/socket resources. + +### Capabilities + +Broker capabilities must match Rust: + +- process isolated; +- serialized single session; +- `multiRoot` true only when `brokerMaxRoots > 1`; +- crash restartable at request boundaries; +- root switchable; +- no connection string; +- physical archive backup/restore only. + +## Native Server Design + +TypeScript `nativeServer` must start a real local PostgreSQL-compatible server +process. It should not route through broker mode and must not pretend to expose +independent sessions unless external PostgreSQL clients can connect to the +server. + +### Open Flow + +1. Normalize and validate config. +2. Prepare or validate `/pgdata`. Empty roots are initialized with + matching `initdb`; initialized roots are reused after `PG_VERSION` + validation by PostgreSQL startup. +3. Resolve `postgres`, `pg_ctl`, `pg_dump`, and `initdb` from + `serverToolDirectory`, `serverExecutable`, or the prepared runtime root. +4. Allocate a fixed or ephemeral loopback port. Retry ephemeral bind conflicts a + bounded number of times, matching Rust's behavior. +5. On Unix, allocate a private mode `0700` socket directory and prefer it for + the SDK-owned connection. Expose TCP in the public connection string. +6. Spawn `postgres` with: + - `-D `; + - `-h 127.0.0.1`; + - `-p `; + - `-c logging_collector=off`; + - `-c listen_addresses=127.0.0.1`; + - `-c unix_socket_directories=` on Unix; + - durability, footprint, startup GUCs, extension preload libraries, and + `max_connections=`. +7. Poll startup by connecting with the SDK PostgreSQL wire client until ready, + the child exits, or the startup deadline expires. +8. Capture `BackendKeyData` for SDK query cancellation. +9. Return an `OliphauntDatabase` with server capabilities and a + percent-encoded `postgres://user@127.0.0.1:port/database` connection string. + +### PostgreSQL Wire Client + +Server mode needs a small internal PostgreSQL v3 client, not a dependency on a +general client package: + +- startup message with username/database; +- authentication ok, cleartext password failure as an explicit unsupported auth + error, parameter status, backend key data, notice, error, ready-for-query; +- raw frontend protocol write and backend response collection until + `ReadyForQuery`; +- streaming callback on backend frames; +- `Terminate` on close; +- CancelRequest over a fresh connection using captured cancel key data; +- strict backend UTF-8 handling shared with the existing query parser. + +The server SDK connection is one physical PostgreSQL client connection used for +SDK methods. The mode still advertises independent sessions because external +clients can use the connection string concurrently. + +### Backup And Restore + +- `physicalArchive`: use PostgreSQL online backup boundaries + (`pg_backup_start`/`pg_backup_stop`), archive a stable `pgdata/` tree, append + required WAL, and inject the generated `backup_label`/`tablespace_map` files. + It must not copy a live data directory blindly. +- `sql`: run packaged `pg_dump` against the connection string and return SQL + bytes. +- restore remains physical archive only until a stable logical restore flow is + designed. + +### Close Semantics + +Close must: + +1. mark the JS handle closed so new work is rejected; +2. terminate the SDK connection; +3. run `pg_ctl -D -m fast -w stop` when available; +4. wait for bounded process exit; +5. kill only as a fallback after graceful stop fails; +6. clean private socket directories. + +## Robustness Requirements + +- Every subprocess spawn must have a startup timeout and child-exit detection. +- Readiness parsing must be bounded; a helper cannot stream unbounded stdout. +- Stderr must be inherited or drained to avoid pipe backpressure deadlocks. +- Auth tokens are per session and never logged. +- Unix socket directories are private and removed on close/failure. +- TCP fallback binds only to loopback and uses `TCP_NODELAY`. +- Root leases are released on every failure path. +- Deno permission errors are surfaced with the exact missing capability. +- Close and cancel are lifecycle operations, not ordinary queued SQL. +- Broker request replay is forbidden after an in-flight transport failure. + +## Performance Requirements + +- Direct remains the lowest-latency mode. +- Broker hot path is one binary frame write plus one response read per request; + no JSON and no base64. +- Server hot path writes PostgreSQL protocol bytes directly to the socket. +- Stream paths apply backpressure: do not accumulate full large responses before + invoking the callback. +- Keep one SDK connection open for server-mode SDK calls. +- Prefer Unix sockets for SDK-owned local traffic on Unix; use TCP fallback for + portability. +- Benchmarks must cover direct/broker/server protocol RTT, large streaming, + typed query parsing, cancellation latency, cold/warm open, backup/restore, and + child-process RSS. + +## Implementation Plan + +1. Add runtime adapters, `ByteStream`, endpoint parsing, process lifecycle + helpers, and timeout utilities. +2. Port the `PGOB` frame codec and broker ready-line parser with unit tests. +3. Implement broker session open/execute/stream/cancel/backup/close against a + fake helper fixture, then the Rust `oliphaunt-broker` binary. +4. Add config fields and validation for `maxClientSessions`, broker executable, + broker max roots, broker transport, server executable, server port, and + server tool directory. +5. Implement a minimal PostgreSQL wire client for server startup, raw protocol, + streaming, terminate, and cancel. +6. Implement server process lifecycle and connection string exposure. +7. Keep physical archive and SQL backup behavior covered by unit tests and + native smoke. +8. Add native smoke gates per mode and runtime. Only after those pass should + `supportedModes()` report broker/server as available. + +## Test Matrix + +Unit tests: + +- config validation parity with Rust; +- broker spawn args; +- ready-line parser; +- endpoint parser; +- `PGOB` frame codec, max frame rejection, and UTF-8 error frames; +- root lease duplicate/capacity behavior; +- server connection string percent encoding; +- server startup args and port conflict retry classification. + +Fixture integration: + +- fake broker helper that authenticates, echoes protocol bytes, streams chunks, + rejects bad tokens, and simulates mid-flight exit; +- fake server process that exercises startup timeout and close fallback paths. + +Native smoke: + +- Node broker smoke without app-provided native FFI; +- optional Node direct smoke only when a test fixture intentionally provides an + FFI dependency; +- Bun and Deno direct smoke; +- broker open/query/stream/cancel/backup/close with `oliphaunt-broker`; +- server open/query/connection-string external client/cancel/SQL backup/physical + backup/close with packaged PostgreSQL tools. + +The package must keep broker/server conditional until the relevant native smoke +for that mode is green in release CI or explicitly documented as platform-gated. + +## Rejected Designs + +- JavaScript `child_process` IPC: the broker helper is Rust, Bun cannot pass + socket handles in its Node compatibility layer, and Deno uses different IPC + primitives. +- General PostgreSQL client dependency for SDK calls: it hides protocol bytes, + cancellation, streaming boundaries, and error parsing that the SDK owns. +- Broker implemented as a JS worker running `nativeDirect`: it is not process + isolation and cannot survive native backend death. +- Server implemented as broker plus a connection string facade: it would not + provide independent PostgreSQL client sessions. +- Marking server available without SQL backup and connection string tests: that + would violate the Rust server-mode contract. diff --git a/src/sdks/js/CHANGELOG.md b/src/sdks/js/CHANGELOG.md new file mode 100644 index 00000000..468cdeed --- /dev/null +++ b/src/sdks/js/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Initial TypeScript SDK release lane for Node.js, Bun, and Deno. diff --git a/src/sdks/js/README.md b/src/sdks/js/README.md new file mode 100644 index 00000000..9b1a71cf --- /dev/null +++ b/src/sdks/js/README.md @@ -0,0 +1,134 @@ +# Oliphaunt TypeScript SDK + +`@oliphaunt/ts` is the Oliphaunt SDK for JavaScript runtimes outside React +Native: Node.js, Bun, and Deno. It keeps PostgreSQL protocol bytes as +`Uint8Array` and defaults to `nativeDirect` everywhere. Node.js direct mode uses +Oliphaunt's prebuilt Node-API adapter release asset, while Bun and Deno use +their runtime-owned FFI surfaces. Broker mode is available when an app wants +process isolation, crash restart, or multi-root supervision, but it is explicit +rather than a hidden runtime-specific default. Server mode +starts a local PostgreSQL server when +`serverExecutable`, `serverToolDirectory`, or `OLIPHAUNT_POSTGRES` is +configured. +The broker/server architecture and implementation gates are documented in +[`ARCHITECTURE.md`](ARCHITECTURE.md). + +## Install + +```sh +pnpm add @oliphaunt/ts +``` + +For Deno or pnpm projects that prefer JSR: + +```sh +deno add jsr:@oliphaunt/ts +pnpm add jsr:@oliphaunt/ts +``` + +Node.js, Bun, and Deno use `nativeDirect` by default. The Node/Bun registry +artifact is `@oliphaunt/ts`; the Deno-native registry target is JSR at +`jsr:@oliphaunt/ts`. Deno can consume packages from the npm registry too, but +JSR is the preferred Deno install path because it publishes TypeScript source +and validates the Deno-native entrypoint directly. + +On supported desktop targets, the SDK downloads the compatible `liboliphaunt-native-v*` +GitHub release asset and, for Node.js, the compatible prebuilt Node direct +adapter on first native use. It verifies both against release checksum +manifests, extracts them into the Oliphaunt cache, and reuses that install on +later opens. Set `OLIPHAUNT_CACHE_DIR` to choose the cache location. +There is no `postinstall` native compilation step and no package-manager native +addon approval in the normal path: Node, Bun, and Deno consumers do not install +Rust, run Cargo, build PostgreSQL, or copy Oliphaunt native artifacts. The +package resolves prebuilt release assets at runtime. +Deno native use requires the corresponding runtime permissions, including +`--allow-ffi`, `--allow-read`, `--allow-write`, `--allow-net`, and +`--allow-env`. + +## Compatibility + +| Package | Compatible release | +| --- | --- | +| `@oliphaunt/ts` | `0.1.0` | +| `liboliphaunt` | `0.1.0` | +| Rust broker helper | `oliphaunt` `0.1.0` / `oliphaunt-broker` | + +The normal install path resolves the matching liboliphaunt release asset +automatically. Advanced consumers can still pass `libraryPath` and +`runtimeDirectory`, or set `LIBOLIPHAUNT_PATH` and `OLIPHAUNT_RUNTIME_DIR`, when +using a custom local native build. + +The normal Node.js path resolves the matching prebuilt Node direct adapter from +the `@oliphaunt/ts` release and never asks app developers to install Rust, +Cargo, node-gyp, or a third-party FFI package. Advanced consumers can still pass +`libraryPath`, `runtimeDirectory`, or `OLIPHAUNT_NODE_ADDON` for custom local +native builds. + +Broker mode uses the published `oliphaunt-broker` helper and resolves the +matching helper automatically from the `brokerVersion` pinned in +this package. Advanced consumers can still pass `brokerExecutable` or set +`OLIPHAUNT_BROKER` to test a custom local helper. + +## Quickstart + +```ts +import { Oliphaunt } from '@oliphaunt/ts'; + +const db = await Oliphaunt.open({ + root: '/var/lib/my-app/oliphaunt', + extensions: ['pg_search'], +}); + +const result = await db.query('SELECT $1::text AS value', ['hello']); +console.log(result.getText(0, 'value')); + +const backup = await db.backup('physicalArchive'); +await db.close(); + +await Oliphaunt.restore({ + root: '/var/lib/my-app/restored', + artifact: backup, + replaceExisting: true, +}); +``` + +The configured `root` is the Oliphaunt root directory; PostgreSQL files live +under `root/pgdata`, matching the Rust, Swift, Kotlin, and React Native SDKs. +When `root` is omitted, the SDK creates a process temporary root. Native-direct +close is a logical detach, so the temporary root is not deleted while the +resident native backend may still own `root/pgdata`. + +## Runtime Entry Points + +The default entrypoint detects the JavaScript runtime: + +```ts +import { Oliphaunt, createOliphauntClient } from '@oliphaunt/ts'; +``` + +Runtime-specific native bindings are also exported: + +```ts +import { createNodeNativeBinding } from '@oliphaunt/ts/node'; +import { createBunNativeBinding } from '@oliphaunt/ts/bun'; +import { createDenoNativeBinding } from '@oliphaunt/ts/deno'; +``` + +## Capabilities + +`Oliphaunt.supportedModes()` returns the same mode-support shape as the other +SDKs. For this SDK: + +- `nativeDirect` is available when liboliphaunt can be loaded and the runtime + has an FFI surface. Bun and Deno provide one; Node.js direct mode requires an + explicit app-provided FFI dependency. +- `nativeBroker` is available when the matching broker helper and + `liboliphaunt` release assets can be resolved. +- `nativeServer` is available when the PostgreSQL server executable can be + resolved. Server mode initializes empty roots with matching `initdb`, exposes + a connection string, and supports both SQL and physical-archive backup. + +Opened `OliphauntDatabase` instances expose `capabilities()`, +`supportsBackupFormat()`, `supportsRestoreFormat()`, raw protocol execution, +query helpers, cancellation, `checkpoint()`, background preparation, +transactions, `backup()`, and logical `close()`. diff --git a/src/sdks/js/jsr.json b/src/sdks/js/jsr.json new file mode 100644 index 00000000..eb02abdd --- /dev/null +++ b/src/sdks/js/jsr.json @@ -0,0 +1,24 @@ +{ + "name": "@oliphaunt/ts", + "version": "0.1.0", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "exports": { + ".": "./src/index.ts", + "./node": "./src/native/node.ts", + "./bun": "./src/native/bun.ts", + "./deno": "./src/native/deno.ts", + "./protocol": "./src/protocol.ts", + "./query": "./src/query.ts" + }, + "publish": { + "include": [ + "ARCHITECTURE.md", + "CHANGELOG.md", + "README.md", + "jsr.json", + "package.json", + "src/**/*.ts" + ], + "exclude": ["src/__tests__/**"] + } +} diff --git a/src/sdks/js/moon.yml b/src/sdks/js/moon.yml new file mode 100644 index 00000000..e68bbd18 --- /dev/null +++ b/src/sdks/js/moon.yml @@ -0,0 +1,238 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-js" +language: "typescript" +layer: "library" +stack: "backend" +tags: ["sdk", "typescript", "node", "bun", "deno", "release-product"] +dependsOn: + - "liboliphaunt-native" + - "oliphaunt-rust" + - "oliphaunt-broker" + - "oliphaunt-node-direct" + - "shared-js-core" + - id: "shared-fixtures" + scope: "build" + +project: + title: "Oliphaunt TypeScript SDK" + description: "TypeScript SDK with nativeDirect defaults for Node.js, Bun, and Deno." + owner: "oliphaunt" + release: + component: "oliphaunt-js" + packagePath: "src/sdks/js" + +owners: + defaultOwner: "@oliphaunt/sdk-js" + paths: + "**/*.ts": ["@oliphaunt/sdk-js"] + "tools/**": ["@oliphaunt/sdk-js"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash src/sdks/js/tools/check-sdk.sh check-static" + deps: + - "liboliphaunt-native:check" + - "oliphaunt-node-direct:check" + - "shared-contracts:check" + - "shared-js-core:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/release/release.py" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "unit"] + command: "bash src/sdks/js/tools/check-sdk.sh test-unit" + deps: + - "liboliphaunt-native:check" + - "oliphaunt-node-direct:check" + - "shared-fixtures:check" + - "shared-js-core:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/test/**/*" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + smoke: + tags: ["runtime", "smoke"] + command: "bash src/sdks/js/tools/check-sdk.sh smoke-runtime" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-js/smoke" + deps: + - "liboliphaunt-native:smoke" + - "oliphaunt-broker:check" + - "oliphaunt-node-direct:check" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + inputs: + - "/Cargo.lock" + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/pnpm-lock.yaml" + - "/src/extensions/**/*" + - "/src/runtimes/broker/**/*" + - "/src/runtimes/node-direct/**/*" + - "/src/sdks/rust/Cargo.toml" + - "/src/sdks/rust/src/**/*" + - "/src/sdks/js/**/*" + - "/tools/runtime/**/*" + package: + tags: ["package"] + command: "bash src/sdks/js/tools/check-sdk.sh package-shape" + deps: + - "liboliphaunt-native:check" + - "oliphaunt-node-direct:package" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/release.py" + - "/tools/test/**/*" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + package-artifacts: + tags: ["release", "artifact-package", "ci-js-sdk-package"] + command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-js" + deps: + - "liboliphaunt-native:check" + - "oliphaunt-node-direct:package" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/release.py" + - "/tools/test/**/*" + - "/tools/runtime/**/*" + outputs: + - "/target/sdk-artifacts/oliphaunt-js/**/*" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "bash src/sdks/js/tools/check-sdk.sh release-check" + deps: + - "liboliphaunt-native:release-check" + - "oliphaunt-rust:release-check" + - "oliphaunt-node-direct:release-assets" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/release/**/*" + - "/tools/test/**/*" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + bench: + tags: ["bench", "plan"] + command: "bash tools/perf/matrix/run_native_oliphaunt_matrix.sh --plan-only --quick" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false + regression: + tags: ["regression", "runtime"] + command: "bash src/sdks/js/tools/check-sdk.sh regression" + deps: + - "liboliphaunt-native:check" + - "oliphaunt-rust:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/release/release.py" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + coverage: + tags: ["coverage", "quality"] + command: "tools/coverage/run-product oliphaunt-js" + inputs: + - "/coverage/baseline.toml" + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/coverage/**/*" + - "/tools/test/**/*" + outputs: + - "/target/coverage/oliphaunt-js/**/*" + options: + cache: true + runFromWorkspaceRoot: true + bench-run: + tags: ["bench", "measured"] + command: "bash tools/perf/matrix/run_native_oliphaunt_matrix.sh --engines direct,broker,server" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/src/sdks/js/**/*" + - "/src/runtimes/node-direct/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false diff --git a/src/sdks/js/package.json b/src/sdks/js/package.json new file mode 100644 index 00000000..1fb09fdd --- /dev/null +++ b/src/sdks/js/package.json @@ -0,0 +1,93 @@ +{ + "name": "@oliphaunt/ts", + "version": "0.1.0", + "description": "TypeScript SDK for Oliphaunt on Node.js, Bun, and Deno.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/sdks/js" + }, + "bugs": { + "url": "https://github.com/f0rr0/oliphaunt/issues" + }, + "homepage": "https://oliphaunt.dev", + "oliphaunt": { + "liboliphauntVersion": "0.1.0", + "brokerVersion": "0.1.0", + "nodeDirectAddonVersion": "0.1.0", + "nodeDirectAddon": "oliphaunt-node-direct", + "brokerHelper": "oliphaunt-broker" + }, + "optionalDependencies": { + "@oliphaunt/node-direct-darwin-arm64": "workspace:0.1.0", + "@oliphaunt/node-direct-linux-arm64-gnu": "workspace:0.1.0", + "@oliphaunt/node-direct-linux-x64-gnu": "workspace:0.1.0", + "@oliphaunt/node-direct-win32-x64-msvc": "workspace:0.1.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./node": { + "types": "./lib/native/node.d.ts", + "default": "./lib/native/node.js" + }, + "./bun": { + "types": "./lib/native/bun.d.ts", + "default": "./lib/native/bun.js" + }, + "./deno": { + "types": "./lib/native/deno.d.ts", + "default": "./lib/native/deno.js" + }, + "./protocol": { + "types": "./lib/protocol.d.ts", + "default": "./lib/protocol.js" + }, + "./query": { + "types": "./lib/query.d.ts", + "default": "./lib/query.js" + }, + "./package.json": { + "default": "./package.json" + } + }, + "main": "lib/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib", + "src", + "jsr.json", + "README.md", + "ARCHITECTURE.md", + "CHANGELOG.md", + "!src/__tests__" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "docs:api": "typedoc --options typedoc.json", + "test": "node ../../../tools/test/run-js-tests.mjs src/__tests__", + "typecheck": "tsc --noEmit", + "clean": "rm -rf lib" + }, + "engines": { + "node": ">=22.13 <25" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@vitest/coverage-v8": "catalog:", + "jsr": "^0.14.3", + "tsx": "catalog:", + "typedoc": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/src/sdks/js/release.toml b/src/sdks/js/release.toml new file mode 100644 index 00000000..074d2afe --- /dev/null +++ b/src/sdks/js/release.toml @@ -0,0 +1,23 @@ +id = "oliphaunt-js" +owner = "@oliphaunt/sdk-js" +kind = "sdk" +publish_targets = ["npm", "jsr"] +registry_packages = ["npm:@oliphaunt/ts", "jsr:@oliphaunt/ts"] +release_artifacts = [ + "npm-package", + "jsr-package", + "node-bun-deno-direct", + "rust-broker-helper-compatibility", +] + +[compatibility_versions.oliphaunt-js-liboliphaunt] +path = "src/sdks/js/package.json" +parser = "json:oliphaunt.liboliphauntVersion" + +[compatibility_versions.oliphaunt-js-broker] +path = "src/sdks/js/package.json" +parser = "json:oliphaunt.brokerVersion" + +[compatibility_versions.oliphaunt-js-node-direct-runtime] +path = "src/sdks/js/package.json" +parser = "json:oliphaunt.nodeDirectAddonVersion" diff --git a/src/sdks/js/src/__tests__/asset-resolver.test.ts b/src/sdks/js/src/__tests__/asset-resolver.test.ts new file mode 100644 index 00000000..8f6df50e --- /dev/null +++ b/src/sdks/js/src/__tests__/asset-resolver.test.ts @@ -0,0 +1,485 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createHash } from 'node:crypto'; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { arch, platform, tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { deflateRawSync, gzipSync, inflateRawSync } from 'node:zlib'; + +import { + checksumForReleaseAsset, + liboliphauntChecksumAssetName, + liboliphauntReleaseAssetUrl, + liboliphauntReleaseTarget, + parseReleaseChecksumManifest, +} from '../native/common.js'; +import { resolveNodeNativeInstall } from '../native/assets-node.js'; +import { nodeDirectAddonReleaseAssetUrl } from '../native/node-addon.js'; +import { extractTarArchive } from '../native/tar.js'; +import { extractZipArchive } from '../native/zip.js'; +import { brokerModeSupport, oliphauntBrokerReleaseAssetUrl } from '../runtime/broker.js'; + +async function main(): Promise { + releaseMetadataMatchesLiboliphauntAssets(); + checksumManifestsAreStrict(); + await tarExtractionRejectsTraversal(); + await zipExtractionWritesFilesAndRejectsTraversal(); + await nodeResolverInstallsVerifiedReleaseAsset(); + await nodeDirectAddonReleaseMetadataMatchesTypeScriptAssets(); + await brokerSupportInstallsVerifiedHelperAndNativeAssets(); +} + +async function zipExtractionWritesFilesAndRejectsTraversal(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-zip-')); + const host = { + join, + dirname, + async mkdir(path: string) { + await mkdir(path, { recursive: true }); + }, + async writeFile(file: { path: string; bytes: Uint8Array; mode: number }) { + await writeFile(file.path, file.bytes, { mode: file.mode }); + await chmod(file.path, file.mode); + }, + }; + try { + await extractZipArchive( + zipArchive([{ path: 'bin/oliphaunt.dll', mode: 0o755, bytes: utf8('dll') }]), + root, + host, + (bytes) => Uint8Array.from(inflateRawSync(bytes)), + ); + assert.equal(await readFile(join(root, 'bin/oliphaunt.dll'), 'utf8'), 'dll'); + await assert.rejects( + () => + extractZipArchive( + zipArchive([{ path: '../evil', mode: 0o644, bytes: utf8('bad') }]), + root, + host, + (bytes) => Uint8Array.from(inflateRawSync(bytes)), + ), + /unsafe ZIP entry path/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function releaseMetadataMatchesLiboliphauntAssets(): void { + const target = liboliphauntReleaseTarget('0.1.0', 'darwin', 'aarch64'); + assert.equal(target.id, 'macos-arm64'); + assert.equal(target.assetName, 'liboliphaunt-0.1.0-macos-arm64.tar.gz'); + assert.equal(target.libraryRelativePath, 'lib/liboliphaunt.dylib'); + assert.equal(target.runtimeRelativePath, 'runtime'); + assert.equal( + liboliphauntReleaseAssetUrl('0.1.0', target.assetName), + 'https://github.com/f0rr0/oliphaunt/releases/download/liboliphaunt-native-v0.1.0/liboliphaunt-0.1.0-macos-arm64.tar.gz', + ); + const linuxTarget = liboliphauntReleaseTarget('0.1.0', 'linux', 'x64'); + assert.equal(linuxTarget.id, 'linux-x64-gnu'); + assert.equal(linuxTarget.assetName, 'liboliphaunt-0.1.0-linux-x64-gnu.tar.gz'); + assert.equal(linuxTarget.libraryRelativePath, 'lib/liboliphaunt.so'); + const linuxArmTarget = liboliphauntReleaseTarget('0.1.0', 'linux', 'arm64'); + assert.equal(linuxArmTarget.id, 'linux-arm64-gnu'); + assert.equal(linuxArmTarget.assetName, 'liboliphaunt-0.1.0-linux-arm64-gnu.tar.gz'); + assert.equal(linuxArmTarget.libraryRelativePath, 'lib/liboliphaunt.so'); + const windowsTarget = liboliphauntReleaseTarget('0.1.0', 'win32', 'x64'); + assert.equal(windowsTarget.id, 'windows-x64-msvc'); + assert.equal(windowsTarget.assetName, 'liboliphaunt-0.1.0-windows-x64-msvc.zip'); + assert.equal(windowsTarget.libraryRelativePath, 'bin/oliphaunt.dll'); + assert.equal(windowsTarget.runtimeRelativePath, 'runtime'); +} + +function checksumManifestsAreStrict(): void { + const checksums = parseReleaseChecksumManifest( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ./liboliphaunt-0.1.0-macos-arm64.tar.gz\n', + ); + assert.equal( + checksumForReleaseAsset(checksums, 'liboliphaunt-0.1.0-macos-arm64.tar.gz'), + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ); + assert.throws(() => parseReleaseChecksumManifest('not-a-checksum ./asset.tar.gz'), /malformed/); +} + +async function tarExtractionRejectsTraversal(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-tar-')); + try { + await assert.rejects( + () => + extractTarArchive( + tarArchive([{ path: '../evil', mode: 0o644, bytes: utf8('bad') }]), + root, + { + join, + dirname, + async mkdir(path) { + await mkdir(path, { recursive: true }); + }, + async writeFile(file) { + await writeFile(file.path, file.bytes, { mode: file.mode }); + await chmod(file.path, file.mode); + }, + }, + ), + /escapes/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function nodeResolverInstallsVerifiedReleaseAsset(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-assets-')); + const assetDir = join(root, 'assets'); + const cacheDir = join(root, 'cache'); + const version = '0.1.0'; + const target = liboliphauntReleaseTarget(version, platform(), arch()); + const archive = Uint8Array.from( + gzipSync( + tarArchive([ + { path: 'lib', mode: 0o755, directory: true }, + { path: target.libraryRelativePath, mode: 0o755, bytes: utf8('native-lib') }, + { path: 'runtime', mode: 0o755, directory: true }, + { path: 'runtime/bin', mode: 0o755, directory: true }, + { path: 'runtime/bin/initdb', mode: 0o755, bytes: utf8('#!/bin/sh\n') }, + ]), + ), + ); + try { + await mkdir(assetDir, { recursive: true }); + await writeFile(join(assetDir, target.assetName), archive); + await writeFile( + join(assetDir, liboliphauntChecksumAssetName(version)), + `${sha256Hex(archive)} ./${target.assetName}\n`, + ); + + const previousAssetDir = process.env.OLIPHAUNT_LIBOLIPHAUNT_ASSET_DIR; + const previousCacheDir = process.env.OLIPHAUNT_CACHE_DIR; + const previousLibraryPath = process.env.LIBOLIPHAUNT_PATH; + const previousRuntimeDir = process.env.OLIPHAUNT_RUNTIME_DIR; + process.env.OLIPHAUNT_LIBOLIPHAUNT_ASSET_DIR = assetDir; + process.env.OLIPHAUNT_CACHE_DIR = cacheDir; + delete process.env.LIBOLIPHAUNT_PATH; + delete process.env.OLIPHAUNT_RUNTIME_DIR; + try { + const install = await resolveNodeNativeInstall(); + assert.equal(await readFile(install.libraryPath, 'utf8'), 'native-lib'); + assert.ok(install.runtimeDirectory?.endsWith('runtime')); + assert.equal( + await readFile(join(install.runtimeDirectory ?? '', 'bin/initdb'), 'utf8'), + '#!/bin/sh\n', + ); + assert.ok((await stat(install.libraryPath)).mode & 0o100); + assert.deepEqual(await resolveNodeNativeInstall(), install); + } finally { + restoreEnv('OLIPHAUNT_LIBOLIPHAUNT_ASSET_DIR', previousAssetDir); + restoreEnv('OLIPHAUNT_CACHE_DIR', previousCacheDir); + restoreEnv('LIBOLIPHAUNT_PATH', previousLibraryPath); + restoreEnv('OLIPHAUNT_RUNTIME_DIR', previousRuntimeDir); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function nodeDirectAddonReleaseMetadataMatchesTypeScriptAssets(): Promise { + const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ) as { + oliphaunt?: { + nodeDirectAddon?: string; + nodeDirectAddonVersion?: string; + }; + optionalDependencies?: Record; + }; + assert.equal(packageJson.oliphaunt?.nodeDirectAddon, 'oliphaunt-node-direct'); + assert.equal(packageJson.oliphaunt?.nodeDirectAddonVersion, '0.1.0'); + assert.deepEqual(Object.keys(packageJson.optionalDependencies ?? {}).sort(), [ + '@oliphaunt/node-direct-darwin-arm64', + '@oliphaunt/node-direct-linux-arm64-gnu', + '@oliphaunt/node-direct-linux-x64-gnu', + '@oliphaunt/node-direct-win32-x64-msvc', + ]); + assert.equal( + nodeDirectAddonReleaseAssetUrl('0.1.0', 'oliphaunt-node-direct-0.1.0-macos-arm64.tar.gz'), + 'https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-node-direct-v0.1.0/oliphaunt-node-direct-0.1.0-macos-arm64.tar.gz', + ); + assert.equal( + nodeDirectAddonReleaseAssetUrl('0.1.0', 'oliphaunt-node-direct-0.1.0-linux-x64-gnu.tar.gz'), + 'https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-node-direct-v0.1.0/oliphaunt-node-direct-0.1.0-linux-x64-gnu.tar.gz', + ); + assert.equal( + nodeDirectAddonReleaseAssetUrl('0.1.0', 'oliphaunt-node-direct-0.1.0-windows-x64-msvc.zip'), + 'https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-node-direct-v0.1.0/oliphaunt-node-direct-0.1.0-windows-x64-msvc.zip', + ); + assert.throws( + () => nodeDirectAddonReleaseAssetUrl('0.1.0', '../evil.tar.gz'), + /invalid Oliphaunt Node direct release asset name/, + ); +} + +async function brokerSupportInstallsVerifiedHelperAndNativeAssets(): Promise { + const version = '0.1.0'; + const brokerAsset = `oliphaunt-broker-${version}-macos-arm64.tar.gz`; + assert.equal( + oliphauntBrokerReleaseAssetUrl(version, brokerAsset), + 'https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-broker-v0.1.0/oliphaunt-broker-0.1.0-macos-arm64.tar.gz', + ); + assert.equal( + oliphauntBrokerReleaseAssetUrl(version, `oliphaunt-broker-${version}-linux-x64-gnu.tar.gz`), + 'https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-broker-v0.1.0/oliphaunt-broker-0.1.0-linux-x64-gnu.tar.gz', + ); + assert.equal( + oliphauntBrokerReleaseAssetUrl(version, `oliphaunt-broker-${version}-windows-x64-msvc.zip`), + 'https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-broker-v0.1.0/oliphaunt-broker-0.1.0-windows-x64-msvc.zip', + ); + if (platform() !== 'darwin' || arch() !== 'arm64') { + return; + } + + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-broker-assets-')); + const nativeAssetDir = join(root, 'native-assets'); + const brokerAssetDir = join(root, 'broker-assets'); + const cacheDir = join(root, 'cache'); + const nativeTarget = liboliphauntReleaseTarget(version, 'darwin', 'arm64'); + const brokerChecksumAsset = `oliphaunt-broker-${version}-release-assets.sha256`; + const nativeArchive = Uint8Array.from( + gzipSync( + tarArchive([ + { path: 'lib', mode: 0o755, directory: true }, + { path: nativeTarget.libraryRelativePath, mode: 0o755, bytes: utf8('native-lib') }, + { path: 'runtime', mode: 0o755, directory: true }, + { path: 'runtime/bin', mode: 0o755, directory: true }, + { path: 'runtime/bin/postgres', mode: 0o755, bytes: utf8('#!/bin/sh\n') }, + ]), + ), + ); + const brokerArchive = Uint8Array.from( + gzipSync( + tarArchive([ + { path: 'bin', mode: 0o755, directory: true }, + { path: 'bin/oliphaunt-broker', mode: 0o755, bytes: utf8('#!/bin/sh\n') }, + { path: 'manifest.properties', mode: 0o644, bytes: utf8('schema=test\n') }, + ]), + ), + ); + try { + await mkdir(nativeAssetDir, { recursive: true }); + await mkdir(brokerAssetDir, { recursive: true }); + await writeFile(join(nativeAssetDir, nativeTarget.assetName), nativeArchive); + await writeFile( + join(nativeAssetDir, liboliphauntChecksumAssetName(version)), + `${sha256Hex(nativeArchive)} ./${nativeTarget.assetName}\n`, + ); + await writeFile(join(brokerAssetDir, brokerAsset), brokerArchive); + await writeFile( + join(brokerAssetDir, brokerChecksumAsset), + `${sha256Hex(brokerArchive)} ./${brokerAsset}\n`, + ); + + const previousNativeAssetDir = process.env.OLIPHAUNT_LIBOLIPHAUNT_ASSET_DIR; + const previousBrokerAssetDir = process.env.OLIPHAUNT_BROKER_ASSET_DIR; + const previousCacheDir = process.env.OLIPHAUNT_CACHE_DIR; + const previousLibraryPath = process.env.LIBOLIPHAUNT_PATH; + const previousRuntimeDir = process.env.OLIPHAUNT_RUNTIME_DIR; + const previousBroker = process.env.OLIPHAUNT_BROKER; + process.env.OLIPHAUNT_LIBOLIPHAUNT_ASSET_DIR = nativeAssetDir; + process.env.OLIPHAUNT_BROKER_ASSET_DIR = brokerAssetDir; + process.env.OLIPHAUNT_CACHE_DIR = cacheDir; + delete process.env.LIBOLIPHAUNT_PATH; + delete process.env.OLIPHAUNT_RUNTIME_DIR; + delete process.env.OLIPHAUNT_BROKER; + try { + const support = await brokerModeSupport({}); + assert.equal(support.available, true); + assert.equal(support.capabilities.rawProtocolTransport, 'broker-ipc'); + assert.equal( + await readFile( + join(cacheDir, 'oliphaunt-broker', version, 'macos-arm64', 'bin/oliphaunt-broker'), + 'utf8', + ), + '#!/bin/sh\n', + ); + } finally { + restoreEnv('OLIPHAUNT_LIBOLIPHAUNT_ASSET_DIR', previousNativeAssetDir); + restoreEnv('OLIPHAUNT_BROKER_ASSET_DIR', previousBrokerAssetDir); + restoreEnv('OLIPHAUNT_CACHE_DIR', previousCacheDir); + restoreEnv('LIBOLIPHAUNT_PATH', previousLibraryPath); + restoreEnv('OLIPHAUNT_RUNTIME_DIR', previousRuntimeDir); + restoreEnv('OLIPHAUNT_BROKER', previousBroker); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +type TarEntry = { + path: string; + mode: number; + bytes?: Uint8Array; + directory?: boolean; +}; + +type ZipEntry = { + path: string; + mode: number; + bytes: Uint8Array; +}; + +function zipArchive(entries: ZipEntry[]): Uint8Array { + const chunks: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + for (const entry of entries) { + const name = utf8(entry.path); + const compressed = Uint8Array.from(deflateRawSync(entry.bytes)); + const crc = crc32(entry.bytes); + const local = new Uint8Array(30 + name.length); + writeUInt32LE(local, 0, 0x04034b50); + writeUInt16LE(local, 4, 20); + writeUInt16LE(local, 8, 8); + writeUInt32LE(local, 14, crc); + writeUInt32LE(local, 18, compressed.length); + writeUInt32LE(local, 22, entry.bytes.length); + writeUInt16LE(local, 26, name.length); + local.set(name, 30); + chunks.push(local, compressed); + + const header = new Uint8Array(46 + name.length); + writeUInt32LE(header, 0, 0x02014b50); + writeUInt16LE(header, 4, 20); + writeUInt16LE(header, 6, 20); + writeUInt16LE(header, 10, 8); + writeUInt32LE(header, 16, crc); + writeUInt32LE(header, 20, compressed.length); + writeUInt32LE(header, 24, entry.bytes.length); + writeUInt16LE(header, 28, name.length); + writeUInt32LE(header, 38, (entry.mode & 0o777) << 16); + writeUInt32LE(header, 42, offset); + header.set(name, 46); + central.push(header); + offset += local.length + compressed.length; + } + const centralOffset = offset; + const centralSize = central.reduce((total, chunk) => total + chunk.length, 0); + const eocd = new Uint8Array(22); + writeUInt32LE(eocd, 0, 0x06054b50); + writeUInt16LE(eocd, 8, entries.length); + writeUInt16LE(eocd, 10, entries.length); + writeUInt32LE(eocd, 12, centralSize); + writeUInt32LE(eocd, 16, centralOffset); + return concatBytes([...chunks, ...central, eocd]); +} + +function tarArchive(entries: TarEntry[]): Uint8Array { + const blocks: Uint8Array[] = []; + for (const entry of entries) { + const bytes = entry.bytes ?? new Uint8Array(); + blocks.push( + tarHeader(entry.path, entry.directory === true ? '5' : '0', entry.mode, bytes.length), + ); + if (entry.directory !== true) { + blocks.push(bytes); + const padding = (512 - (bytes.length % 512)) % 512; + if (padding > 0) { + blocks.push(new Uint8Array(padding)); + } + } + } + blocks.push(new Uint8Array(1024)); + const length = blocks.reduce((total, block) => total + block.byteLength, 0); + const archive = new Uint8Array(length); + let offset = 0; + for (const block of blocks) { + archive.set(block, offset); + offset += block.byteLength; + } + return archive; +} + +function tarHeader(path: string, type: '0' | '5', mode: number, size: number): Uint8Array { + const header = new Uint8Array(512); + writeAscii(header, 0, 100, path); + writeOctal(header, 100, 8, mode); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, size); + writeOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + writeAscii(header, 156, 1, type); + writeAscii(header, 257, 6, 'ustar'); + writeAscii(header, 263, 2, '00'); + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + const encoded = checksum.toString(8).padStart(6, '0'); + writeAscii(header, 148, 8, `${encoded}\0 `); + return header; +} + +function writeAscii(buffer: Uint8Array, offset: number, length: number, value: string): void { + const encoded = utf8(value); + if (encoded.byteLength > length) { + throw new Error(`tar test value is too long: ${value}`); + } + buffer.set(encoded, offset); +} + +function writeOctal(buffer: Uint8Array, offset: number, length: number, value: number): void { + writeAscii(buffer, offset, length, `${value.toString(8).padStart(length - 1, '0')}\0`); +} + +function writeUInt16LE(buffer: Uint8Array, offset: number, value: number): void { + buffer[offset] = value & 0xff; + buffer[offset + 1] = (value >>> 8) & 0xff; +} + +function writeUInt32LE(buffer: Uint8Array, offset: number, value: number): void { + buffer[offset] = value & 0xff; + buffer[offset + 1] = (value >>> 8) & 0xff; + buffer[offset + 2] = (value >>> 16) & 0xff; + buffer[offset + 3] = (value >>> 24) & 0xff; +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const length = chunks.reduce((total, chunk) => total + chunk.length, 0); + const out = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function utf8(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +test('asset resolver', async () => { + await main(); +}); diff --git a/src/sdks/js/src/__tests__/broker-frames.test.ts b/src/sdks/js/src/__tests__/broker-frames.test.ts new file mode 100644 index 00000000..0ef97657 --- /dev/null +++ b/src/sdks/js/src/__tests__/broker-frames.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { + decodeBrokerRequest, + decodeBrokerResponse, + encodeBrokerRequest, + encodeBrokerResponse, + readBrokerRequest, + readBrokerResponse, + writeBrokerRequest, + writeBrokerResponse, +} from '../runtime/broker-frames.js'; +import { MemoryDuplexStream } from '../runtime/byte-stream.js'; + +async function main(): Promise { + await requestFramesRoundTrip(); + await responseFramesRoundTrip(); + rejectsMalformedFrames(); + await streamHelpersUseBinaryFrames(); +} + +async function requestFramesRoundTrip(): Promise { + assert.deepEqual(decodeBrokerRequest(6, new TextEncoder().encode('secret')), { + kind: 'authenticate', + token: 'secret', + }); + assert.deepEqual(decodeBrokerRequest(1, new Uint8Array([1, 2])), { + kind: 'execProtocol', + bytes: new Uint8Array([1, 2]), + }); + assert.deepEqual(decodeBrokerRequest(8, new TextEncoder().encode('SELECT 1')), { + kind: 'execSimpleQuery', + sql: 'SELECT 1', + }); + assert.deepEqual(decodeBrokerRequest(2, new Uint8Array()), { kind: 'checkpoint' }); + assert.deepEqual(decodeBrokerRequest(3, new Uint8Array()), { kind: 'close' }); + assert.deepEqual(decodeBrokerRequest(4, new Uint8Array([3, 4])), { + kind: 'execProtocolStream', + bytes: new Uint8Array([3, 4]), + }); + assert.deepEqual(decodeBrokerRequest(5, new Uint8Array([2])), { + kind: 'backup', + format: 'physicalArchive', + }); + assert.deepEqual(decodeBrokerRequest(7, new Uint8Array()), { kind: 'cancel' }); +} + +async function responseFramesRoundTrip(): Promise { + const ok = encodeBrokerResponse({ kind: 'ok', bytes: new Uint8Array([9]) }); + assert.deepEqual(await readBrokerResponse(new MemoryDuplexStream([ok])), { + kind: 'ok', + bytes: new Uint8Array([9]), + }); + + const error = encodeBrokerResponse({ kind: 'error', message: 'boom' }); + assert.deepEqual(await readBrokerResponse(new MemoryDuplexStream([error])), { + kind: 'error', + message: 'boom', + }); + + const chunk = encodeBrokerResponse({ kind: 'chunk', bytes: new Uint8Array([7, 8]) }); + assert.deepEqual(await readBrokerResponse(new MemoryDuplexStream([chunk])), { + kind: 'chunk', + bytes: new Uint8Array([7, 8]), + }); +} + +function rejectsMalformedFrames(): void { + assert.throws(() => decodeBrokerRequest(999, new Uint8Array()), /unknown broker request/); + assert.throws(() => decodeBrokerResponse(999, new Uint8Array()), /unknown broker response/); + assert.throws(() => decodeBrokerRequest(5, new Uint8Array()), /missing a format/); + assert.throws(() => decodeBrokerRequest(5, new Uint8Array([99])), /unknown broker backup/); + assert.throws(() => decodeBrokerRequest(2, new Uint8Array([1])), /unexpectedly had a payload/); +} + +async function streamHelpersUseBinaryFrames(): Promise { + const requestStream = new MemoryDuplexStream(); + await writeBrokerRequest(requestStream, { + kind: 'execProtocol', + bytes: new Uint8Array([0x51, 0, 0, 0, 4]), + }); + assert.deepEqual(await readBrokerRequest(new MemoryDuplexStream(requestStream.output)), { + kind: 'execProtocol', + bytes: new Uint8Array([0x51, 0, 0, 0, 4]), + }); + + const responseStream = new MemoryDuplexStream(); + await writeBrokerResponse(responseStream, { kind: 'ok', bytes: new Uint8Array([0x5a]) }); + assert.deepEqual(await readBrokerResponse(new MemoryDuplexStream(responseStream.output)), { + kind: 'ok', + bytes: new Uint8Array([0x5a]), + }); + + const raw = encodeBrokerRequest({ kind: 'backup', format: 'physicalArchive' }); + assert.equal(raw[0], 0x50); + assert.equal(raw[1], 0x47); + assert.equal(raw[2], 0x4f); + assert.equal(raw[3], 0x42); +} + +test('broker frames', async () => { + await main(); +}); diff --git a/src/sdks/js/src/__tests__/client.test.ts b/src/sdks/js/src/__tests__/client.test.ts new file mode 100644 index 00000000..fa2ff753 --- /dev/null +++ b/src/sdks/js/src/__tests__/client.test.ts @@ -0,0 +1,478 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { + CAP_BACKUP_RESTORE, + CAP_EXTENSIONS, + CAP_LOGICAL_REOPEN, + CAP_MULTI_INSTANCE, + CAP_PROTOCOL_RAW, + CAP_PROTOCOL_STREAM, + CAP_QUERY_CANCEL, + CAP_SIMPLE_QUERY, +} from '../native/common.js'; +import type { + NativeBinding, + NativeBindingOptions, + NativeHandle, + NativeOpenConfig, + NativeRestoreOptions, +} from '../native/types.js'; +import { simpleQuery } from '../protocol.js'; +import { + createOliphauntClient, + defaultEngineForRuntime, + supportsBackupFormat, + supportsRestoreFormat, +} from '../client.js'; +import type { BackupFormat, RawProtocolTransport } from '../types.js'; + +async function main(): Promise { + testDefaultEngineKeepsNodeInstallScriptFree(); + await testSupportedModesExposeNativeDirectContract(); + await testSupportedModesReportsNativeLoaderFailure(); + await testOpenNormalizesNativeConfigAndUsesLibraryOverride(); + await testOpenRejectsUnsupportedModesAndInvalidInputs(); + await testExecuteQueryStreamingAndClose(); + await testTransactionCommitsRollsBackAndPinsSession(); + await testBackupAndRestoreUsePhysicalArchiveShape(); + await testBackgroundPreparationCancelsActiveWorkAndSkipsCheckpoint(); + await testExecutionAfterCloseFailsBeforeNativeCall(); +} + +function testDefaultEngineKeepsNodeInstallScriptFree(): void { + assert.equal(defaultEngineForRuntime('node'), 'nativeDirect'); + assert.equal(defaultEngineForRuntime('bun'), 'nativeDirect'); + assert.equal(defaultEngineForRuntime('deno'), 'nativeDirect'); +} + +async function testSupportedModesExposeNativeDirectContract(): Promise { + const binding = new MockNativeBinding({ + flags: + CAP_PROTOCOL_RAW | + CAP_PROTOCOL_STREAM | + CAP_MULTI_INSTANCE | + CAP_EXTENSIONS | + CAP_QUERY_CANCEL | + CAP_BACKUP_RESTORE | + CAP_SIMPLE_QUERY | + CAP_LOGICAL_REOPEN, + protocolStream: true, + }); + const client = createOliphauntClient((options) => { + binding.factoryOptions.push(options ?? {}); + return binding; + }); + + const support = await client.supportedModes({ libraryPath: '/tmp/liboliphaunt.dylib' }); + + assert.deepEqual( + support.map((entry) => entry.engine), + ['nativeDirect', 'nativeBroker', 'nativeServer'], + ); + assert.equal(support[0]?.available, true); + assert.equal(support[0]?.capabilities.rawProtocolTransport, 'node-addon'); + assert.equal(support[0]?.capabilities.maxClientSessions, 1); + assert.equal(support[0]?.capabilities.multiRoot, true); + assert.equal(support[0]?.capabilities.protocolStream, true); + assert.deepEqual(support[0]?.capabilities.backupFormats, ['physicalArchive']); + assert.equal(supportsBackupFormat(support[0]!.capabilities, 'physicalArchive'), true); + assert.equal(supportsBackupFormat(support[0]!.capabilities, 'sql'), false); + assert.equal(supportsRestoreFormat(support[0]!.capabilities, 'physicalArchive'), true); + assert.equal(support[1]?.available, false); + assert.equal(support[1]?.capabilities.processIsolated, true); + assert.equal(support[1]?.capabilities.rootSwitchable, true); + assert.match(support[1]?.unavailableReason ?? '', /broker/); + assert.equal(support[2]?.available, false); + assert.equal(support[2]?.capabilities.independentSessions, true); + assert.deepEqual(support[2]?.capabilities.backupFormats, ['sql', 'physicalArchive']); + assert.match(support[2]?.unavailableReason ?? '', /server/); + assert.deepEqual(binding.factoryOptions, [{ libraryPath: '/tmp/liboliphaunt.dylib' }]); +} + +async function testSupportedModesReportsNativeLoaderFailure(): Promise { + const client = createOliphauntClient(() => { + throw new Error('missing dylib'); + }); + + const support = await client.supportedModes(); + + assert.equal(support.length, 3); + assert.equal(support[0]?.available, false); + assert.match(support[0]?.unavailableReason ?? '', /missing dylib/); + assert.equal(support[0]?.capabilities.reopenable, true); +} + +async function testOpenNormalizesNativeConfigAndUsesLibraryOverride(): Promise { + const binding = new MockNativeBinding(); + const client = createOliphauntClient((options) => { + binding.factoryOptions.push(options ?? {}); + return binding; + }); + + const db = await client.open({ + engine: 'nativeDirect', + root: '/tmp/oliphaunt-js-root', + libraryPath: '/tmp/liboliphaunt.so', + runtimeDirectory: '/tmp/postgres-runtime', + startupGUCs: ['work_mem=4MB', { name: 'app.custom', value: 'enabled' }], + extensions: ['postgis', ' hstore '], + }); + + assert.equal(db.root, '/tmp/oliphaunt-js-root'); + assert.deepEqual(binding.factoryOptions, [{ libraryPath: '/tmp/liboliphaunt.so' }]); + assert.deepEqual(binding.openCalls, [ + { + pgdata: '/tmp/oliphaunt-js-root/pgdata', + runtimeDirectory: '/tmp/postgres-runtime', + username: 'postgres', + database: 'postgres', + startupArgs: [ + '-c', + 'shared_buffers=128MB', + '-c', + 'wal_buffers=4MB', + '-c', + 'min_wal_size=80MB', + '-c', + 'fsync=on', + '-c', + 'full_page_writes=on', + '-c', + 'synchronous_commit=on', + '-c', + 'work_mem=4MB', + '-c', + 'app.custom=enabled', + ], + }, + ]); +} + +async function testOpenRejectsUnsupportedModesAndInvalidInputs(): Promise { + const binding = new MockNativeBinding(); + const client = createOliphauntClient(() => binding); + + await assert.rejects( + async () => client.open({ engine: 'nativeServer', root: '/tmp/oliphaunt-js-root' }), + /serverExecutable|OLIPHAUNT_POSTGRES/, + ); + await assert.rejects( + async () => client.open({ root: '/tmp/root', temporary: true }), + /root and temporary are mutually exclusive/, + ); + await assert.rejects(async () => client.open({ root: ' \n' }), /database root must not be empty/); + await assert.rejects( + async () => client.open({ root: '/tmp/root', username: '\0' }), + /username must not contain NUL bytes/, + ); + await assert.rejects( + async () => client.open({ root: '/tmp/root', startupGUCs: ['bad-name=value'] }), + /startup GUC name/, + ); + await assert.rejects( + async () => client.open({ root: '/tmp/root', extensions: ['bad/value'] }), + /extension id/, + ); + await assert.rejects( + async () => client.open({ temporary: false }), + /database root is not configured/, + ); + assert.deepEqual(binding.openCalls, []); +} + +async function testExecuteQueryStreamingAndClose(): Promise { + const binding = new MockNativeBinding({ protocolStream: false }); + const client = createOliphauntClient(() => binding); + const db = await client.open({ engine: 'nativeDirect', root: '/tmp/oliphaunt-js-root' }); + + // OLIPHAUNT_DOCS_SNIPPET typescript-quickstart + const executeBytes = await db.execute('SELECT 1'); + assert.ok(executeBytes.includes(0x5a)); + assert.deepEqual(binding.simpleQueryCalls, ['SELECT 1']); + + const query = await db.query('SELECT $1::text AS value', ['typed']); + assert.equal(query.rowCount, 1); + assert.equal(query.getText(0, 'value'), 'typed'); + assert.equal(binding.protocolCalls.length, 1); + assert.equal(binding.protocolCalls[0]?.[0], 0x50); + + const chunks: Uint8Array[] = []; + await db.execProtocolStream(simpleQuery('SELECT fallback'), (chunk) => chunks.push(chunk)); + assert.equal(chunks.length, 1); + assert.ok(chunks[0]?.includes(0x5a)); + + await db.close(); + await db.close(); + assert.deepEqual(binding.detachCalls, [1]); +} + +async function testTransactionCommitsRollsBackAndPinsSession(): Promise { + const binding = new MockNativeBinding(); + const client = createOliphauntClient(() => binding); + const db = await client.open({ engine: 'nativeDirect', root: '/tmp/oliphaunt-js-root' }); + + await db.transaction(async (tx) => { + await tx.execute('SELECT 1'); + await assert.rejects(async () => db.execute('SELECT 2'), /physical session is pinned/); + }); + assert.deepEqual(binding.simpleQueryCalls, []); + assert.deepEqual(binding.protocolSqlCalls.slice(0, 3), ['BEGIN', 'SELECT 1', 'COMMIT']); + + await assert.rejects( + async () => + db.transaction(async (tx) => { + await tx.execute('SELECT before fail'); + throw new Error('body failed'); + }), + /body failed/, + ); + assert.equal(binding.protocolSqlCalls.includes('ROLLBACK'), true); +} + +async function testBackupAndRestoreUsePhysicalArchiveShape(): Promise { + const binding = new MockNativeBinding(); + const client = createOliphauntClient((options) => { + binding.factoryOptions.push(options ?? {}); + return binding; + }); + const db = await client.open({ engine: 'nativeDirect', root: '/tmp/oliphaunt-js-root' }); + assert.equal(binding.openCalls[0]?.pgdata, '/tmp/oliphaunt-js-root/pgdata'); + + await assert.rejects(async () => db.backup('sql'), /sql backup is not supported/); + const backup = await db.backup(); + assert.equal(backup.format, 'physicalArchive'); + assert.deepEqual(Array.from(backup.bytes), [0x70, 0x68, 0x79, 0x73]); + assert.deepEqual(binding.backupCalls, [{ handle: 1, format: 'physicalArchive' }]); + + const restored = await client.restore({ + engine: 'nativeDirect', + root: '/tmp/oliphaunt-js-restore', + libraryPath: '/tmp/liboliphaunt.dylib', + artifact: backup, + replaceExisting: true, + }); + + assert.equal(restored, '/tmp/oliphaunt-js-restore'); + assert.deepEqual(binding.restoreCalls, [ + { + root: '/tmp/oliphaunt-js-restore', + format: 'physicalArchive', + bytes: backup.bytes, + replaceExisting: true, + }, + ]); + const restoredDb = await client.open({ + engine: 'nativeDirect', + root: restored, + libraryPath: '/tmp/liboliphaunt.dylib', + }); + assert.equal(restoredDb.root, '/tmp/oliphaunt-js-restore'); + assert.equal( + binding.openCalls[binding.openCalls.length - 1]?.pgdata, + '/tmp/oliphaunt-js-restore/pgdata', + ); + await restoredDb.close(); + await assert.rejects( + async () => + client.restore({ + engine: 'nativeDirect', + root: '/tmp/root', + artifact: { format: 'sql', bytes: new Uint8Array() }, + }), + /physicalArchive/, + ); + await assert.rejects( + async () => + client.restore({ + engine: 'nativeServer', + root: '/tmp/root', + artifact: backup, + }), + /nativeServer restore is not supported/, + ); +} + +async function testBackgroundPreparationCancelsActiveWorkAndSkipsCheckpoint(): Promise { + const binding = new MockNativeBinding(); + const client = createOliphauntClient(() => binding); + const db = await client.open({ engine: 'nativeDirect', root: '/tmp/oliphaunt-js-root' }); + binding.holdNextProtocolCall = true; + + const active = db.execProtocolRaw(simpleQuery('SELECT slow')); + await Promise.resolve(); + const prepared = await db.prepareForBackground(); + binding.releaseHeldProtocolCall(); + await active; + + assert.deepEqual(prepared, { + cancelledActiveWork: true, + checkpointed: false, + skippedCheckpointReason: 'activeWork', + }); + assert.deepEqual(binding.cancelCalls, [1]); +} + +async function testExecutionAfterCloseFailsBeforeNativeCall(): Promise { + const binding = new MockNativeBinding(); + const client = createOliphauntClient(() => binding); + const db = await client.open({ engine: 'nativeDirect', root: '/tmp/oliphaunt-js-root' }); + + await db.close(); + await assert.rejects(async () => db.execute('SELECT 1'), /database is closed/); + assert.deepEqual(binding.simpleQueryCalls, []); +} + +class MockNativeBinding implements NativeBinding { + runtime = 'node' as const; + rawProtocolTransport: RawProtocolTransport = 'node-addon'; + protocolStream: boolean; + flags: bigint; + factoryOptions: NativeBindingOptions[] = []; + openCalls: NativeOpenConfig[] = []; + protocolCalls: Uint8Array[] = []; + protocolSqlCalls: string[] = []; + simpleQueryCalls: string[] = []; + backupCalls: Array<{ handle: NativeHandle; format: BackupFormat }> = []; + restoreCalls: NativeRestoreOptions[] = []; + cancelCalls: NativeHandle[] = []; + detachCalls: NativeHandle[] = []; + holdNextProtocolCall = false; + #nextHandle = 1; + #releaseHeldProtocolCall: (() => void) | undefined; + + constructor( + options: { + flags?: bigint; + protocolStream?: boolean; + } = {}, + ) { + this.flags = + options.flags ?? + CAP_PROTOCOL_RAW | + CAP_EXTENSIONS | + CAP_QUERY_CANCEL | + CAP_BACKUP_RESTORE | + CAP_SIMPLE_QUERY | + CAP_LOGICAL_REOPEN; + this.protocolStream = options.protocolStream ?? false; + } + + version(): string { + return 'test-liboliphaunt'; + } + + capabilities(): bigint { + return this.flags; + } + + open(config: NativeOpenConfig): NativeHandle { + this.openCalls.push(config); + return this.#nextHandle++; + } + + async execProtocolRaw(handle: NativeHandle, request: Uint8Array): Promise { + assert.equal(handle, 1); + this.protocolCalls.push(request); + const sql = decodeSimpleQuerySql(request); + if (sql !== undefined) { + this.protocolSqlCalls.push(sql); + } + if (this.holdNextProtocolCall) { + this.holdNextProtocolCall = false; + await new Promise((resolve) => { + this.#releaseHeldProtocolCall = resolve; + }); + } + return queryResponse(request[0] === 0x50 || sql?.includes('typed') ? 'typed' : '1'); + } + + execProtocolStream( + handle: NativeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): void { + assert.equal(handle, 1); + this.protocolCalls.push(request); + onChunk(queryResponse('stream')); + } + + execSimpleQuery(handle: NativeHandle, sql: string): Uint8Array { + assert.equal(handle, 1); + this.simpleQueryCalls.push(sql); + return queryResponse(sql.includes('typed') ? 'typed' : '1'); + } + + backup(handle: NativeHandle, format: BackupFormat): Uint8Array { + this.backupCalls.push({ handle, format }); + return new TextEncoder().encode('phys'); + } + + restore(options: NativeRestoreOptions): void { + this.restoreCalls.push(options); + } + + cancel(handle: NativeHandle): void { + this.cancelCalls.push(handle); + } + + detach(handle: NativeHandle): void { + this.detachCalls.push(handle); + } + + releaseHeldProtocolCall(): void { + this.#releaseHeldProtocolCall?.(); + this.#releaseHeldProtocolCall = undefined; + } +} + +function queryResponse(value: string): Uint8Array { + const valueBytes = new TextEncoder().encode(value); + return Uint8Array.from([ + ...backendMessage(0x54, [ + ...i16(1), + ...cstring('value'), + ...u32(0), + ...i16(0), + ...u32(25), + ...i16(-1), + ...i32(-1), + ...i16(0), + ]), + ...backendMessage(0x44, [...i16(1), ...i32(valueBytes.length), ...valueBytes]), + ...backendMessage(0x43, cstring('SELECT 1')), + ...backendMessage(0x5a, [0x49]), + ]); +} + +function backendMessage(tag: number, body: number[]): number[] { + return [tag, ...i32(body.length + 4), ...body]; +} + +function cstring(value: string): number[] { + return [...new TextEncoder().encode(value), 0]; +} + +function i16(value: number): number[] { + const bits = value & 0xffff; + return [(bits >>> 8) & 0xff, bits & 0xff]; +} + +function u32(value: number): number[] { + return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; +} + +function i32(value: number): number[] { + return u32(value >>> 0); +} + +function decodeSimpleQuerySql(request: Uint8Array): string | undefined { + if (request[0] !== 0x51) { + return undefined; + } + return new TextDecoder().decode(request.subarray(5, request.length - 1)); +} + +test('client', async () => { + await main(); +}, 15_000); diff --git a/src/sdks/js/src/__tests__/config.test.ts b/src/sdks/js/src/__tests__/config.test.ts new file mode 100644 index 00000000..0af1a82e --- /dev/null +++ b/src/sdks/js/src/__tests__/config.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { + buildStartupArgs, + normalizeDurability, + normalizeOpenConfig, + normalizeRuntimeFootprint, + validateBrokerMaxRoots, + validateBrokerTransport, + validateMaxClientSessions, + validateOptionalPathOverride, + validateRootPath, + validateServerPort, + validateStartupGUCs, + validateStartupIdentity, +} from '../config.js'; +import { + GENERATED_EXTENSION_METADATA, + generatedExtensionBySqlName, + generatedSharedPreloadLibraries, +} from '../generated/extensions.js'; + +function throwsMessage(fn: () => unknown, message: RegExp): void { + assert.throws(fn, message); +} + +test('normalizes explicit config contracts for broker and server modes', () => { + const broker = normalizeOpenConfig( + { + engine: 'nativeBroker', + root: '/app/root', + durability: 'balanced', + runtimeFootprint: 'balancedMobile', + brokerExecutable: '/opt/oliphaunt-broker', + brokerMaxRoots: 8, + brokerTransport: 'tcp', + maxClientSessions: 1, + username: 'app_user', + database: 'app_db', + extensions: [' vector ', '', 'hstore'], + }, + '/app/root', + ); + + assert.equal(broker.pgdata, '/app/root/pgdata'); + assert.equal(broker.durability, 'balanced'); + assert.equal(broker.runtimeFootprint, 'balancedMobile'); + assert.equal(broker.brokerExecutable, '/opt/oliphaunt-broker'); + assert.equal(broker.brokerMaxRoots, 8); + assert.equal(broker.brokerTransport, 'tcp'); + assert.deepEqual(broker.extensions, ['vector', 'hstore']); + assert.ok(broker.startupArgs.includes('max_connections=1')); + assert.ok(broker.startupArgs.includes('synchronous_commit=off')); + + const server = normalizeOpenConfig( + { + engine: 'nativeServer', + root: '/server/root', + runtimeFootprint: 'smallMobile', + durability: 'fastDev', + serverExecutable: '/opt/postgres', + serverToolDirectory: '/opt/postgres/bin', + serverPort: 15432, + }, + '/server/root', + ); + + assert.equal(server.maxClientSessions, 32); + assert.equal(server.serverPort, 15432); + assert.equal(server.serverExecutable, '/opt/postgres'); + assert.equal(server.serverToolDirectory, '/opt/postgres/bin'); + assert.ok(server.startupArgs.includes('shared_buffers=8MB')); + assert.ok(server.startupArgs.includes('fsync=off')); +}); + +test('validates config error surfaces deterministically', () => { + validateRootPath(undefined, 'database root'); + validateStartupIdentity(undefined, 'username'); + assert.equal(validateOptionalPathOverride(undefined, 'libraryPath'), undefined); + assert.equal(validateMaxClientSessions(undefined, 'nativeDirect'), 1); + assert.equal(validateMaxClientSessions(undefined, 'nativeServer'), 32); + assert.equal(validateBrokerMaxRoots(undefined), 1); + assert.equal(validateServerPort(undefined), undefined); + assert.equal(validateBrokerTransport('auto'), 'auto'); + assert.equal(validateBrokerTransport('unix'), 'unix'); + + throwsMessage(() => validateRootPath('', 'restore root'), /restore root must not be empty/); + throwsMessage(() => validateRootPath('\0', 'restore root'), /restore root must not contain NUL/); + throwsMessage(() => validateRootPath('', 'custom path'), /custom path must not be empty/); + throwsMessage(() => validateRootPath('\0', 'custom path'), /custom path must not contain NUL/); + throwsMessage(() => validateStartupIdentity(' \t', 'database'), /database must not be empty/); + throwsMessage( + () => validateStartupIdentity('bad\0db', 'database'), + /database must not contain NUL/, + ); + throwsMessage( + () => validateOptionalPathOverride(' ', 'libraryPath'), + /libraryPath must not be empty/, + ); + throwsMessage( + () => validateOptionalPathOverride('\0', 'runtimeDirectory'), + /runtimeDirectory must not contain NUL/, + ); + throwsMessage( + () => validateOptionalPathOverride('', 'brokerExecutable'), + /brokerExecutable must not be empty/, + ); + throwsMessage( + () => validateOptionalPathOverride('\0', 'serverExecutable'), + /serverExecutable must not contain NUL/, + ); + throwsMessage( + () => validateOptionalPathOverride('', 'serverToolDirectory'), + /serverToolDirectory must not be empty/, + ); + throwsMessage( + () => validateOptionalPathOverride('\0', 'custom executable'), + /custom executable must not contain NUL/, + ); + throwsMessage(() => validateMaxClientSessions(1.5, 'nativeDirect'), /must be an integer/); + throwsMessage(() => validateMaxClientSessions(0, 'nativeServer'), /greater than zero/); + throwsMessage(() => validateMaxClientSessions(2, 'nativeDirect'), /supports exactly 1/); + throwsMessage(() => validateBrokerMaxRoots(1.5), /must be an integer/); + throwsMessage(() => validateBrokerMaxRoots(0), /max_roots must be greater than zero/); + throwsMessage(() => validateServerPort(1.5), /port must be an integer/); + throwsMessage(() => validateServerPort(0), /range 1..65535/); + throwsMessage(() => validateServerPort(65_536), /range 1..65535/); + throwsMessage( + () => validateBrokerTransport('named-pipe' as never), + /unknown native broker transport/, + ); + throwsMessage( + () => normalizeRuntimeFootprint('desktopTiny' as never), + /unknown liboliphaunt runtime footprint/, + ); + throwsMessage(() => normalizeDurability('unsafe' as never), /unknown liboliphaunt durability/); + throwsMessage(() => validateStartupGUCs(['missing_equals']), /must use name=value/); + throwsMessage( + () => validateStartupGUCs([{ name: 'work_mem', value: '' }]), + /value must not be empty/, + ); + throwsMessage(() => validateStartupGUCs([{ name: 'bad-name', value: '1' }]), /must contain only/); + throwsMessage( + () => validateStartupGUCs([{ name: 'ok', value: 'bad\0' }]), + /must not contain NUL/, + ); +}); + +test('uses generated extension metadata for startup requirements', () => { + assert.equal(GENERATED_EXTENSION_METADATA.length, 39); + assert.deepEqual(generatedExtensionBySqlName('earthdistance')?.selectedExtensionDependencies, [ + 'cube', + ]); + assert.deepEqual(generatedExtensionBySqlName('pgtap')?.dependencies, ['plpgsql']); + assert.deepEqual(generatedExtensionBySqlName('pgtap')?.selectedExtensionDependencies, []); + const postgis = generatedExtensionBySqlName('postgis'); + assert.equal(postgis?.nativeModuleStem, 'postgis-3'); + assert.ok( + postgis?.dataFiles.includes( + 'share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql', + ), + 'PostGIS metadata must include its contrib data files', + ); + assert.ok( + postgis?.dataFiles.includes('share/postgresql/proj/proj.db'), + 'PostGIS metadata must include PROJ data', + ); + assert.deepEqual( + postgis?.dataFiles.map((file) => file.replace(/^share\/postgresql\//, '')), + postgis?.runtimeShareDataFiles, + 'PostGIS packaged data files must match runtime share data files', + ); + assert.equal(generatedExtensionBySqlName('pg_search'), undefined); + assert.deepEqual(generatedSharedPreloadLibraries(['hstore', 'pg_search']), []); + + const args = buildStartupArgs({ + durability: 'safe', + runtimeFootprint: 'throughput', + startupGUCs: [{ name: 'app.setting', value: 'enabled' }], + extensions: ['hstore', 'pg_search'], + }); + assert.ok(args.includes('app.setting=enabled')); + assert.equal( + args.some((value) => value.startsWith('shared_preload_libraries=')), + false, + 'candidate-only extensions must not create startup preload rules unless generated metadata marks them public', + ); +}); diff --git a/src/sdks/js/src/__tests__/native-bindings.test.ts b/src/sdks/js/src/__tests__/native-bindings.test.ts new file mode 100644 index 00000000..452ae26a --- /dev/null +++ b/src/sdks/js/src/__tests__/native-bindings.test.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import Oliphaunt, { createNodeNativeBinding, simpleQuery, type OliphauntClient } from '../index.js'; +import { resolveDenoNativeInstall } from '../native/assets-deno.js'; +import { + cString, + OLIPHAUNT_CONFIG_SIZE, + OLIPHAUNT_RESPONSE_SIZE, + packConfigPointers, + packPointerArray, + packRestoreOptionsPointers, + readResponseLength, + readResponsePointer, + responseBuffer, + writePointer, +} from '../native/ffi-layout.js'; + +async function main(): Promise { + testIndexExportsDefaultClient(); + testFfiLayoutPackingAndBounds(); + await testNodeNativeBindingUsesExplicitAssetsAndAddon(); + await testDenoAssetResolverHonorsExplicitPaths(); +} + +function testIndexExportsDefaultClient(): void { + assert.equal(typeof (Oliphaunt as OliphauntClient).open, 'function'); + assert.equal(typeof (Oliphaunt as OliphauntClient).supportedModes, 'function'); + assert.equal(simpleQuery('SELECT 1')[0], 0x51); +} + +function testFfiLayoutPackingAndBounds(): void { + assert.deepEqual([...cString('pgdata')], [112, 103, 100, 97, 116, 97, 0]); + assert.throws(() => cString('bad\0value'), /NUL bytes/); + + const pointers = packPointerArray([1n, 2n, 3n]); + const pointerView = new DataView(pointers.buffer); + assert.equal(pointerView.getBigUint64(0, true), 1n); + assert.equal(pointerView.getBigUint64(8, true), 2n); + assert.equal(pointerView.getBigUint64(16, true), 3n); + assert.equal(packPointerArray([]).byteLength, 8); + + let nextPointer = 16n; + const seenStrings: string[] = []; + const pointerOf = (value: Uint8Array): bigint => { + const decoded = new TextDecoder().decode(value.slice(0, Math.max(0, value.byteLength - 1))); + seenStrings.push(decoded); + nextPointer += 16n; + return nextPointer; + }; + const packed = packConfigPointers( + { + pgdata: '/tmp/pgdata', + runtimeDirectory: '/tmp/runtime', + username: 'postgres', + database: 'app', + startupArgs: ['-c', 'work_mem=8MB'], + }, + pointerOf, + ); + assert.equal(packed.config.byteLength, OLIPHAUNT_CONFIG_SIZE); + assert.ok(seenStrings.includes('/tmp/pgdata')); + assert.ok(seenStrings.includes('/tmp/runtime')); + assert.ok(seenStrings.includes('work_mem=8MB')); + assert.equal(packed.keepAlive.length, 7); + + const restore = packRestoreOptionsPointers( + { + root: '/tmp/root', + format: 'physicalArchive', + bytes: new Uint8Array([1, 2, 3]), + replaceExisting: true, + }, + pointerOf, + ); + assert.equal(restore.options.byteLength, 48); + assert.equal(restore.keepAlive.length, 2); + + const response = responseBuffer(); + assert.equal(response.byteLength, OLIPHAUNT_RESPONSE_SIZE); + const responseView = new DataView(response.buffer); + writePointer(responseView, 0, 0x1234n); + writePointer(responseView, 8, 3n); + assert.equal(readResponsePointer(response), 0x1234n); + assert.equal(readResponseLength(response), 3); + writePointer(responseView, 8, BigInt(Number.MAX_SAFE_INTEGER) + 1n); + assert.throws(() => readResponseLength(response), /safe integer/); +} + +async function testNodeNativeBindingUsesExplicitAssetsAndAddon(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-node-binding-')); + const addonPath = join(root, 'mock-addon.cjs'); + await writeFile( + addonPath, + ` +let nextHandle = 40n; +module.exports = { + default: { + version(libraryPath) { + globalThis.__oliphauntNodeAddonCalls.push(['version', libraryPath]); + return '18.4-test'; + }, + capabilities(libraryPath) { + globalThis.__oliphauntNodeAddonCalls.push(['capabilities', libraryPath]); + return 195n; + }, + open(config) { + globalThis.__oliphauntNodeAddonCalls.push(['open', config]); + nextHandle += 1n; + return nextHandle; + }, + execProtocolRaw(handle, request) { + globalThis.__oliphauntNodeAddonCalls.push(['execProtocolRaw', handle, Array.from(request)]); + return request.buffer.slice(request.byteOffset, request.byteOffset + request.byteLength); + }, + execSimpleQuery(handle, sql) { + globalThis.__oliphauntNodeAddonCalls.push(['execSimpleQuery', handle, sql]); + return new Uint8Array([90, 0, 0, 0, 5, 73]); + }, + execProtocolStream(handle, request, onChunk) { + globalThis.__oliphauntNodeAddonCalls.push(['execProtocolStream', handle, Array.from(request)]); + onChunk(new Uint8Array([1, 2])); + onChunk(new Uint8Array([3]).buffer); + }, + backup(handle, format) { + globalThis.__oliphauntNodeAddonCalls.push(['backup', handle, format]); + return new Uint8Array([4, 5, 6]).buffer; + }, + restore(options) { + globalThis.__oliphauntNodeAddonCalls.push(['restore', options]); + }, + cancel(handle) { + globalThis.__oliphauntNodeAddonCalls.push(['cancel', handle]); + }, + detach(handle) { + globalThis.__oliphauntNodeAddonCalls.push(['detach', handle]); + }, + }, +}; +`, + 'utf8', + ); + const calls: unknown[][] = []; + (globalThis as { __oliphauntNodeAddonCalls?: unknown[][] }).__oliphauntNodeAddonCalls = calls; + const previousRuntime = process.env.OLIPHAUNT_RUNTIME_DIR; + process.env.OLIPHAUNT_RUNTIME_DIR = join(root, 'runtime'); + try { + const binding = await createNodeNativeBinding({ + libraryPath: join(root, 'liboliphaunt.dylib'), + nodeAddonPath: addonPath, + }); + assert.equal(binding.runtime, 'node'); + assert.equal(binding.rawProtocolTransport, 'node-addon'); + assert.equal(binding.protocolStream, true); + assert.equal(binding.defaultRuntimeDirectory, join(root, 'runtime')); + assert.equal(binding.version(), '18.4-test'); + assert.equal(binding.capabilities(), 195n); + + const handle = binding.open({ + pgdata: join(root, 'pgdata'), + username: 'postgres', + database: 'postgres', + startupArgs: [], + }); + assert.equal(handle, 41n); + assert.deepEqual([...(await binding.execProtocolRaw(handle, new Uint8Array([7, 8])))], [7, 8]); + assert.deepEqual( + [...(await binding.execSimpleQuery!(handle, 'SELECT 1'))], + [90, 0, 0, 0, 5, 73], + ); + const chunks: number[][] = []; + binding.execProtocolStream!(handle, new Uint8Array([9]), (chunk) => chunks.push([...chunk])); + assert.deepEqual(chunks, [[1, 2], [3]]); + assert.deepEqual([...(await binding.backup(handle, 'physicalArchive'))], [4, 5, 6]); + assert.throws(() => binding.backup(handle, 'sql'), /not supported by nativeDirect/); + binding.restore({ + root: join(root, 'restore'), + format: 'physicalArchive', + bytes: new Uint8Array([1]), + replaceExisting: false, + }); + assert.throws( + () => + binding.restore({ + root: join(root, 'restore'), + format: 'sql', + bytes: new Uint8Array(), + replaceExisting: false, + }), + /physicalArchive/, + ); + binding.cancel(handle); + binding.detach(handle); + assert.deepEqual( + calls.map((entry) => entry[0]), + [ + 'version', + 'capabilities', + 'open', + 'execProtocolRaw', + 'execSimpleQuery', + 'execProtocolStream', + 'backup', + 'restore', + 'cancel', + 'detach', + ], + ); + } finally { + if (previousRuntime === undefined) { + delete process.env.OLIPHAUNT_RUNTIME_DIR; + } else { + process.env.OLIPHAUNT_RUNTIME_DIR = previousRuntime; + } + delete (globalThis as { __oliphauntNodeAddonCalls?: unknown[][] }).__oliphauntNodeAddonCalls; + await rm(root, { recursive: true, force: true }); + } +} + +async function testDenoAssetResolverHonorsExplicitPaths(): Promise { + const previousRuntime = process.env.OLIPHAUNT_RUNTIME_DIR; + process.env.OLIPHAUNT_RUNTIME_DIR = '/tmp/oliphaunt-deno-runtime'; + try { + assert.deepEqual(await resolveDenoNativeInstall('/tmp/liboliphaunt.dylib'), { + libraryPath: '/tmp/liboliphaunt.dylib', + runtimeDirectory: '/tmp/oliphaunt-deno-runtime', + }); + await assert.rejects(async () => resolveDenoNativeInstall(), /only be used inside Deno/); + } finally { + if (previousRuntime === undefined) { + delete process.env.OLIPHAUNT_RUNTIME_DIR; + } else { + process.env.OLIPHAUNT_RUNTIME_DIR = previousRuntime; + } + } +} + +test('native bindings', async () => { + await main(); +}); diff --git a/src/sdks/js/src/__tests__/native-smoke.ts b/src/sdks/js/src/__tests__/native-smoke.ts new file mode 100644 index 00000000..fa6e487e --- /dev/null +++ b/src/sdks/js/src/__tests__/native-smoke.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { Oliphaunt } from '../index.js'; +import type { BackupArtifact, EngineMode, OpenConfig } from '../types.js'; + +async function main(): Promise { + const libraryPath = process.env.LIBOLIPHAUNT_PATH; + if (libraryPath === undefined || libraryPath.length === 0) { + throw new Error('LIBOLIPHAUNT_PATH is required for the TypeScript SDK native smoke check'); + } + + if (process.env.OLIPHAUNT_TS_SMOKE_NODE_DIRECT === '1') { + await smokeDirect(libraryPath); + } + + if (process.env.OLIPHAUNT_BROKER !== undefined && process.env.OLIPHAUNT_BROKER.length > 0) { + await smokeBroker(libraryPath, process.env.OLIPHAUNT_BROKER); + } + + if (process.env.OLIPHAUNT_POSTGRES !== undefined && process.env.OLIPHAUNT_POSTGRES.length > 0) { + await smokeServer(libraryPath, process.env.OLIPHAUNT_POSTGRES); + } +} + +async function smokeDirect(libraryPath: string): Promise { + await smokeMode('nativeDirect', { + engine: 'nativeDirect', + libraryPath, + }); +} + +async function smokeBroker(libraryPath: string, brokerExecutable: string): Promise { + await requireAvailable('nativeBroker', { libraryPath, brokerExecutable }); + await smokeMode('nativeBroker', { + engine: 'nativeBroker', + libraryPath, + brokerExecutable, + }); +} + +async function smokeServer(libraryPath: string, serverExecutable: string): Promise { + const serverToolDirectory = process.env.OLIPHAUNT_POSTGRES_TOOL_DIR ?? dirname(serverExecutable); + await requireAvailable('nativeServer', { + libraryPath, + serverExecutable, + serverToolDirectory, + }); + await smokeMode('nativeServer', { + engine: 'nativeServer', + libraryPath, + serverExecutable, + serverToolDirectory, + }); +} + +async function requireAvailable( + engine: EngineMode, + options: { + libraryPath: string; + brokerExecutable?: string; + serverExecutable?: string; + serverToolDirectory?: string; + }, +): Promise { + const modes = await Oliphaunt.supportedModes(options); + const support = modes.find((mode) => mode.engine === engine); + if (!support?.available) { + throw new Error(`${engine} smoke support is unavailable: ${support?.unavailableReason}`); + } +} + +async function smokeMode(engine: EngineMode, config: OpenConfig): Promise { + const root = await mkdtemp(join(tmpdir(), `oliphaunt-js-${engine}-`)); + const db = await Oliphaunt.open({ + ...config, + root, + }); + let closed = false; + try { + const result = await db.query(`SELECT '${engine}'::text AS value`); + assert.equal(result.getText(0, 'value'), engine); + + const chunks: Uint8Array[] = []; + await db.execProtocolStream( + new TextEncoder().encode('Q\0\0\0\u0016SELECT 1 AS value\0'), + (chunk) => chunks.push(chunk), + ); + assert.ok(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) > 0); + + const capabilities = await db.capabilities(); + assert.equal(capabilities.engine, engine); + + if (engine === 'nativeServer') { + assert.equal(typeof (await db.connectionString()), 'string'); + const sql = await db.backup('sql'); + assert.equal(sql.format, 'sql'); + assert.ok(new TextDecoder().decode(sql.bytes).includes('PostgreSQL database dump')); + } + + let archive: BackupArtifact | undefined; + if (await db.supportsBackupFormat('physicalArchive')) { + archive = await db.backup('physicalArchive'); + assert.equal(archive.format, 'physicalArchive'); + assert.ok(archive.bytes.byteLength >= 1024); + assert.ok(new TextDecoder('latin1').decode(archive.bytes).includes('pgdata/backup_label')); + } + if (archive !== undefined) { + await db.close(); + closed = true; + await restoreSmokeBackup(engine, config, archive); + } + } finally { + if (!closed) { + await db.close(); + } + if (engine !== 'nativeDirect') { + await rm(root, { recursive: true, force: true }).catch(() => {}); + } + } +} + +async function restoreSmokeBackup( + engine: EngineMode, + config: OpenConfig, + artifact: BackupArtifact, +): Promise { + const restoredRoot = await mkdtemp(join(tmpdir(), `oliphaunt-js-restored-${engine}-`)); + await rm(restoredRoot, { recursive: true, force: true }); + try { + await Oliphaunt.restore({ + engine: engine === 'nativeDirect' ? 'nativeDirect' : 'nativeBroker', + root: restoredRoot, + libraryPath: config.libraryPath, + brokerExecutable: config.brokerExecutable, + artifact, + }); + assert.match(await readFile(join(restoredRoot, 'pgdata', 'PG_VERSION'), 'utf8'), /^\d+\n$/); + if (engine === 'nativeDirect') { + return; + } + const restored = await Oliphaunt.open({ + ...config, + root: restoredRoot, + }); + try { + const result = await restored.query('SELECT 1 AS value'); + assert.equal(result.getText(0, 'value'), '1'); + } finally { + await restored.close(); + } + } finally { + await rm(restoredRoot, { recursive: true, force: true }).catch(() => {}); + } +} + +await main(); diff --git a/src/sdks/js/src/__tests__/physical-archive.test.ts b/src/sdks/js/src/__tests__/physical-archive.test.ts new file mode 100644 index 00000000..d020936a --- /dev/null +++ b/src/sdks/js/src/__tests__/physical-archive.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createPhysicalArchive } from '../runtime/physical-archive.js'; + +async function main(): Promise { + await testPhysicalArchiveUsesOnlineBackupBoundaries(); +} + +async function testPhysicalArchiveUsesOnlineBackupBoundaries(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-archive-')); + try { + await mkdir(join(root, 'global'), { recursive: true }); + await mkdir(join(root, 'pg_wal'), { recursive: true }); + await mkdir(join(root, 'pg_notify'), { recursive: true }); + await writeFile(join(root, 'PG_VERSION'), '18\n'); + await writeFile(join(root, 'global', 'pg_control'), 'control'); + await writeFile(join(root, 'pg_wal', '000000010000000000000001'), 'wal'); + await writeFile(join(root, 'pg_notify', 'transient'), 'skip'); + await writeFile(join(root, 'postmaster.pid'), 'skip'); + + const sqlCalls: string[] = []; + const archive = await createPhysicalArchive({ + pgdata: root, + async execSimpleQuery(sql) { + sqlCalls.push(sql); + if (sql.includes('pg_backup_stop')) { + return queryResponse(['labelfile', 'spcmapfile'], [['backup label contents', null]]); + } + return commandCompleteResponse(); + }, + }); + + assert.equal(archive.byteLength % 512, 0); + assertArchiveContains(archive, 'pgdata/PG_VERSION'); + assertArchiveContains(archive, 'pgdata/global/pg_control'); + assertArchiveContains(archive, 'pgdata/pg_wal/000000010000000000000001'); + assertArchiveContains(archive, 'pgdata/backup_label'); + assertArchiveContains(archive, 'backup label contents'); + assertArchiveDoesNotContain(archive, 'postmaster.pid'); + assertArchiveDoesNotContain(archive, 'pg_notify/transient'); + assert.equal(sqlCalls.length, 2); + assert.match(sqlCalls[0] ?? '', /pg_backup_start/); + assert.match(sqlCalls[1] ?? '', /pg_backup_stop/); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function assertArchiveContains(archive: Uint8Array, text: string): void { + assert.ok(archiveText(archive).includes(text), `expected archive to include ${text}`); +} + +function assertArchiveDoesNotContain(archive: Uint8Array, text: string): void { + assert.equal(archiveText(archive).includes(text), false, `archive unexpectedly included ${text}`); +} + +function archiveText(archive: Uint8Array): string { + return new TextDecoder('latin1').decode(archive); +} + +function commandCompleteResponse(): Uint8Array { + return Uint8Array.from([ + ...backendMessage(0x43, cstring('SELECT 1')), + ...backendMessage(0x5a, [0x49]), + ]); +} + +function queryResponse(fields: string[], rows: Array>): Uint8Array { + return Uint8Array.from([ + ...backendMessage(0x54, rowDescription(fields)), + ...rows.flatMap((row) => backendMessage(0x44, dataRow(row))), + ...backendMessage(0x43, cstring(`SELECT ${rows.length}`)), + ...backendMessage(0x5a, [0x49]), + ]); +} + +function rowDescription(fields: string[]): number[] { + return [ + ...i16(fields.length), + ...fields.flatMap((field) => [ + ...cstring(field), + ...u32(0), + ...i16(0), + ...u32(25), + ...i16(-1), + ...i32(-1), + ...i16(0), + ]), + ]; +} + +function dataRow(values: Array): number[] { + return [ + ...i16(values.length), + ...values.flatMap((value) => { + if (value === null) { + return i32(-1); + } + const bytes = new TextEncoder().encode(value); + return [...i32(bytes.byteLength), ...bytes]; + }), + ]; +} + +function backendMessage(tag: number, body: number[]): number[] { + return [tag, ...i32(body.length + 4), ...body]; +} + +function cstring(value: string): number[] { + return [...new TextEncoder().encode(value), 0]; +} + +function i16(value: number): number[] { + const bits = value & 0xffff; + return [(bits >>> 8) & 0xff, bits & 0xff]; +} + +function u32(value: number): number[] { + return [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; +} + +function i32(value: number): number[] { + return u32(value >>> 0); +} + +test('physical archive', async () => { + await main(); +}); diff --git a/src/sdks/js/src/__tests__/protocol-fixtures.test.ts b/src/sdks/js/src/__tests__/protocol-fixtures.test.ts new file mode 100644 index 00000000..1199c159 --- /dev/null +++ b/src/sdks/js/src/__tests__/protocol-fixtures.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { parseQueryResponse, PostgresError } from '../query.js'; + +function testQueryParserMatchesSharedProtocolFixtures(): void { + const fixturePath = sharedProtocolFixturePath(); + assert.ok(fixturePath, 'shared protocol fixture corpus must exist'); + + const corpus = JSON.parse(readFileSync(fixturePath, 'utf8')) as SharedProtocolFixtureCorpus; + assert.equal(corpus.schemaVersion, 1); + assert.equal(corpus.kind, 'postgres-backend-query-response'); + assert.ok(corpus.cases.length > 0, 'shared protocol corpus is empty'); + + const names = new Set(); + for (const fixture of corpus.cases) { + assert.equal(names.has(fixture.name), false, `duplicate fixture ${fixture.name}`); + names.add(fixture.name); + const expectation = fixture.queryExpectation; + if (expectation === undefined) { + continue; + } + const bytes = hexToBytes(fixture.responseHex); + if (expectation.ok !== undefined) { + assertSharedProtocolOkFixture(fixture, expectation.ok, bytes); + } else if (expectation.postgresError !== undefined) { + assertSharedProtocolPostgresErrorFixture(fixture, expectation.postgresError, bytes); + } else if (expectation.engineErrorContains !== undefined) { + assertSharedProtocolEngineErrorFixture(fixture, expectation.engineErrorContains, bytes); + } else { + assert.fail(`shared protocol fixture ${fixture.name} has no query expectation`); + } + } +} + +function assertSharedProtocolOkFixture( + fixture: SharedProtocolFixtureCase, + expected: SharedProtocolOkExpectation, + bytes: Uint8Array, +): void { + const result = parseQueryResponse(bytes); + assert.equal(result.rowCount, expected.rowCount, `${fixture.name} row count`); + assert.equal(result.commandTag, expected.commandTag, `${fixture.name} command tag`); + assert.equal(result.fields.length, expected.fields.length, `${fixture.name} field count`); + assert.equal(result.rows.length, expected.rows.length, `${fixture.name} rows size`); + + for (const [index, expectedField] of expected.fields.entries()) { + const actual = result.fields[index]; + assert.ok(actual, `${fixture.name} missing field ${index}`); + assert.equal(actual.name, expectedField.name, `${fixture.name} field name`); + assert.equal(actual.typeOid, expectedField.typeOid, `${fixture.name} type OID`); + if (expectedField.format === 'text') { + assert.equal(actual.format, 'text', `${fixture.name} field format`); + } + } + + for (const [rowIndex, expectedRow] of expected.rows.entries()) { + assert.equal(expectedRow.length, expected.fields.length, `${fixture.name} expected row width`); + for (const [columnIndex, expectedValue] of expectedRow.entries()) { + const field = expected.fields[columnIndex]; + assert.ok(field, `${fixture.name} missing expected field ${columnIndex}`); + assert.equal( + result.getText(rowIndex, field.name), + expectedValue, + `${fixture.name} row ${rowIndex} column ${field.name}`, + ); + } + } +} + +function assertSharedProtocolPostgresErrorFixture( + fixture: SharedProtocolFixtureCase, + expected: SharedProtocolPostgresErrorExpectation, + bytes: Uint8Array, +): void { + const thrown = thrownBy(() => parseQueryResponse(bytes)); + assert.ok(thrown instanceof PostgresError, `${fixture.name} should throw PostgresError`); + assert.equal(thrown.severity, expected.severity, `${fixture.name} severity`); + assert.equal(thrown.sqlstate, expected.sqlstate, `${fixture.name} SQLSTATE`); + assert.equal(thrown.postgresMessage, expected.message, `${fixture.name} PostgreSQL message`); +} + +function assertSharedProtocolEngineErrorFixture( + fixture: SharedProtocolFixtureCase, + expected: string, + bytes: Uint8Array, +): void { + const thrown = thrownBy(() => parseQueryResponse(bytes)); + assert.ok(thrown instanceof Error, `${fixture.name} should throw Error`); + assert.ok( + thrown.message.includes(expected), + `${fixture.name} error ${JSON.stringify(thrown.message)} did not contain ${JSON.stringify( + expected, + )}`, + ); +} + +function sharedProtocolFixturePath(): string | undefined { + const candidates = [ + path.resolve( + process.cwd(), + '..', + '..', + '..', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + '..', + '..', + '..', + 'shared', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + '..', + '..', + '..', + 'src', + 'shared', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + 'src', + 'shared', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + ]; + return candidates.find(existsSync); +} + +function hexToBytes(hex: string): Uint8Array { + const compact = hex.replace(/\s+/g, ''); + assert.equal(compact.length % 2, 0, 'hex fixture must have an even digit count'); + const bytes = new Uint8Array(compact.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + const byte = Number.parseInt(compact.slice(index * 2, index * 2 + 2), 16); + assert.ok(Number.isInteger(byte), 'hex fixture contains invalid byte'); + bytes[index] = byte; + } + return bytes; +} + +function thrownBy(callback: () => unknown): unknown { + try { + callback(); + } catch (error) { + return error; + } + assert.fail('expected callback to throw'); +} + +type SharedProtocolFixtureCorpus = { + schemaVersion: number; + kind: string; + cases: SharedProtocolFixtureCase[]; +}; + +type SharedProtocolFixtureCase = { + name: string; + responseHex: string; + queryExpectation?: SharedProtocolQueryExpectation; +}; + +type SharedProtocolQueryExpectation = { + ok?: SharedProtocolOkExpectation; + postgresError?: SharedProtocolPostgresErrorExpectation; + engineErrorContains?: string; +}; + +type SharedProtocolOkExpectation = { + fields: SharedProtocolFieldExpectation[]; + rows: Array>; + commandTag?: string; + rowCount: number; +}; + +type SharedProtocolFieldExpectation = { + name: string; + typeOid: number; + format?: string; +}; + +type SharedProtocolPostgresErrorExpectation = { + severity: string; + sqlstate: string; + message: string; +}; + +test('protocol fixtures', () => { + testQueryParserMatchesSharedProtocolFixtures(); +}); diff --git a/src/sdks/js/src/__tests__/query.test.ts b/src/sdks/js/src/__tests__/query.test.ts new file mode 100644 index 00000000..89e278bb --- /dev/null +++ b/src/sdks/js/src/__tests__/query.test.ts @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { + PostgresError, + assertSuccessfulQueryResponse, + extendedQuery, + parseQueryResponse, + toUint8Array, +} from '../query.js'; + +test('extendedQuery serializes text, binary, and null parameters', () => { + const bytes = extendedQuery('SELECT $1, $2, $3', [ + 'text', + null, + { format: 'binary', value: new Uint8Array([1, 2, 3]) }, + ]); + const messages = splitFrontendMessages(bytes); + + assert.deepEqual( + messages.map((message) => message.tag), + [0x50, 0x42, 0x44, 0x45, 0x53], + ); + assert.equal(new TextDecoder().decode(messages[0]!.body).includes('SELECT $1, $2, $3'), true); + + const bind = messages[1]!.body; + assert.deepEqual([...bind.slice(0, 2)], [0, 0], 'portal and statement names are empty'); + assert.equal(readI16(bind, 2), 3, 'three parameter format codes'); + assert.deepEqual([readI16(bind, 4), readI16(bind, 6), readI16(bind, 8)], [0, 0, 1]); + assert.equal(readI16(bind, 10), 3, 'three parameter values'); + assert.equal(readI32(bind, 12), 4); + assert.equal(new TextDecoder().decode(bind.slice(16, 20)), 'text'); + assert.equal(readI32(bind, 20), -1); + assert.equal(readI32(bind, 24), 3); + assert.deepEqual([...bind.slice(28, 31)], [1, 2, 3]); +}); + +test('extendedQuery rejects invalid frontend inputs', () => { + assert.throws(() => extendedQuery('SELECT \0', []), /SQL must not contain NUL/); + assert.throws( + () => extendedQuery('SELECT 1', new Array(0x8000).fill(null)), + /at most 32767 parameters/, + ); + + const view = new DataView(new Uint8Array([9, 8, 7, 6]).buffer, 1, 2); + assert.deepEqual([...toUint8Array(view)], [8, 7]); + assert.deepEqual([...toUint8Array([4, 5, 6])], [4, 5, 6]); +}); + +test('parseQueryResponse validates result ordering and accessors', () => { + const result = parseQueryResponse( + Uint8Array.from([ + ...backend(0x53, [...cstring('server_version'), ...cstring('18.4')]), + ...backend(0x54, rowDescription([{ name: 'value', format: 0 }])), + ...backend(0x44, dataRow(['hello'])), + ...backend(0x43, cstring('SELECT 1')), + ...backend(0x5a, [0x49]), + ]), + ); + + assert.equal(result.rowCount, 1); + assert.equal(result.commandTag, 'SELECT 1'); + assert.equal(result.fieldIndex('value'), 0); + assert.equal(result.getText(0, 'value'), 'hello'); + assert.throws(() => result.getText(0, 'missing'), /no column/); + assert.throws(() => result.getText(3, 'value'), /no row/); + assert.throws(() => result.rows[0]!.text(99), /no column/); +}); + +test('parseQueryResponse surfaces PostgreSQL errors and malformed backend traffic', () => { + const error = thrownBy( + () => + parseQueryResponse( + Uint8Array.from([ + ...backend(0x45, [ + 0x53, + ...cstring('ERROR'), + 0x43, + ...cstring('42601'), + 0x4d, + ...cstring('syntax error'), + 0, + ]), + ]), + ), + ); + assert.ok(error instanceof PostgresError); + assert.equal(error.severity, 'ERROR'); + assert.equal(error.sqlstate, '42601'); + assert.equal(error.postgresMessage, 'syntax error'); + assert.match(error.message, /ERROR \[42601\]: syntax error/); + + assert.throws(() => parseQueryResponse(Uint8Array.from([0x5a, 0, 0, 0, 3])), /length 3/); + assert.throws( + () => parseQueryResponse(Uint8Array.from([...backend(0x44, dataRow(['orphan']))])), + /before RowDescription/, + ); + assert.throws( + () => + parseQueryResponse( + Uint8Array.from([ + ...backend(0x54, rowDescription([{ name: 'one', format: 0 }])), + ...backend(0x54, rowDescription([{ name: 'two', format: 0 }])), + ...backend(0x5a, [0x49]), + ]), + ), + /multiple result sets/, + ); + assert.throws(() => parseQueryResponse(Uint8Array.from([...backend(0x47, [])])), /COPY/); + assert.throws(() => parseQueryResponse(Uint8Array.from([...backend(0x99, [])])), /0x99/); + assert.throws( + () => parseQueryResponse(Uint8Array.from([...backend(0x5a, [0x00])])), + /invalid transaction status/, + ); + assert.throws( + () => parseQueryResponse(Uint8Array.from([...backend(0x5a, [0x49]), ...backend(0x49, [])])), + /bytes after ReadyForQuery/, + ); + assert.throws(() => parseQueryResponse(Uint8Array.from([...backend(0x49, [])])), /before ReadyForQuery/); +}); + +test('assertSuccessfulQueryResponse and row decoding reject invalid payloads', () => { + assertSuccessfulQueryResponse(Uint8Array.from([...backend(0x43, cstring('CREATE 1')), ...backend(0x5a, [0x49])])); + assert.throws( + () => assertSuccessfulQueryResponse(Uint8Array.from([...backend(0x5a, [0x49, 0])])), + /ReadyForQuery contained 2 bytes/, + ); + assert.throws( + () => + assertSuccessfulQueryResponse( + Uint8Array.from([...backend(0x45, [0x4d, ...cstring('boom'), 0])]), + ), + PostgresError, + ); + + const result = parseQueryResponse( + Uint8Array.from([ + ...backend(0x54, rowDescription([{ name: 'bad', format: 0 }])), + ...backend(0x44, dataRow([new Uint8Array([0xff])])), + ...backend(0x43, cstring('SELECT 1')), + ...backend(0x5a, [0x49]), + ]), + ); + assert.throws(() => result.getText(0, 'bad'), /not valid UTF-8/); +}); + +type FrontendMessage = { + tag: number; + body: Uint8Array; +}; + +function splitFrontendMessages(bytes: Uint8Array): FrontendMessage[] { + const messages: FrontendMessage[] = []; + let offset = 0; + while (offset < bytes.length) { + const tag = bytes[offset]!; + const length = readI32(bytes, offset + 1); + messages.push({ tag, body: bytes.slice(offset + 5, offset + 1 + length) }); + offset += 1 + length; + } + return messages; +} + +function backend(tag: number, body: number[] | Uint8Array): number[] { + return [tag, ...i32(body.length + 4), ...body]; +} + +function rowDescription(fields: Array<{ name: string; format: number }>): number[] { + return [ + ...i16(fields.length), + ...fields.flatMap((field) => [ + ...cstring(field.name), + ...i32(0), + ...i16(0), + ...i32(25), + ...i16(-1), + ...i32(-1), + ...i16(field.format), + ]), + ]; +} + +function dataRow(values: Array): number[] { + return [ + ...i16(values.length), + ...values.flatMap((value) => { + if (value === null) { + return i32(-1); + } + const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value; + return [...i32(bytes.byteLength), ...bytes]; + }), + ]; +} + +function cstring(value: string): number[] { + return [...new TextEncoder().encode(value), 0]; +} + +function i16(value: number): number[] { + const bits = value & 0xffff; + return [(bits >>> 8) & 0xff, bits & 0xff]; +} + +function i32(value: number): number[] { + const bits = value >>> 0; + return [(bits >>> 24) & 0xff, (bits >>> 16) & 0xff, (bits >>> 8) & 0xff, bits & 0xff]; +} + +function readI16(bytes: Uint8Array, offset: number): number { + const value = (bytes[offset]! << 8) | bytes[offset + 1]!; + return value > 0x7fff ? value - 0x10000 : value; +} + +function readI32(bytes: Uint8Array, offset: number): number { + const value = + (bytes[offset]! * 0x1000000 + + (bytes[offset + 1]! << 16) + + (bytes[offset + 2]! << 8) + + bytes[offset + 3]!) >>> + 0; + return value > 0x7fffffff ? value - 0x100000000 : value; +} + +function thrownBy(fn: () => unknown): unknown { + try { + fn(); + } catch (error) { + return error; + } + assert.fail('expected function to throw'); +} diff --git a/src/sdks/js/src/__tests__/runtime-modes.test.ts b/src/sdks/js/src/__tests__/runtime-modes.test.ts new file mode 100644 index 00000000..eb15068b --- /dev/null +++ b/src/sdks/js/src/__tests__/runtime-modes.test.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import type { NormalizedOpenConfig } from '../config.js'; +import { + brokerCapabilities, + brokerModeSupport, + brokerReleaseTarget, + createBrokerRuntimeBinding, + oliphauntBrokerReleaseAssetUrl, + restorePhysicalArchiveWithBroker, +} from '../runtime/broker.js'; +import { + canonicalPath, + createTempDir, + parseReadyEndpoint, + randomHexToken, + removeTree, +} from '../runtime/node-adapter.js'; +import { + encodeCancelRequest, + encodeStartupMessage, + parseBackendKeyData, +} from '../runtime/pgwire.js'; +import { + createServerRuntimeBinding, + serverCapabilities, + serverConnectionString, + serverModeSupport, +} from '../runtime/server.js'; + +async function main(): Promise { + testBrokerCapabilitiesAndReleaseUrl(); + await testBrokerSupportAndRestoreFailureAreActionable(); + await testBrokerStartupTimeoutEnvIsValidatedBeforeNativeInstall(); + testServerCapabilitiesAndConnectionString(); + await testServerSupportReportsMissingExecutable(); + await testServerStartupTimeoutEnvIsValidatedBeforeProcessSetup(); + testPgwireStartupCancelAndBackendKeyFrames(); + await testNodeAdapterUtilities(); +} + +function testBrokerCapabilitiesAndReleaseUrl(): void { + const binding = createBrokerRuntimeBinding({ maxRoots: 4 }); + assert.equal(binding.runtime, 'node'); + assert.equal(binding.rawProtocolTransport, 'broker-ipc'); + assert.equal(binding.protocolStream, true); + assert.deepEqual(brokerCapabilities(4), { + engine: 'nativeBroker', + processIsolated: true, + multiRoot: true, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: true, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + rawProtocolTransport: 'broker-ipc', + }); + assert.equal( + oliphauntBrokerReleaseAssetUrl('0.1.0', 'oliphaunt-broker-0.1.0-macos-arm64.tar.gz'), + 'https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-broker-v0.1.0/oliphaunt-broker-0.1.0-macos-arm64.tar.gz', + ); + const windowsBrokerTarget = brokerReleaseTarget('0.1.0', 'win32', 'x64'); + assert.equal(windowsBrokerTarget.id, 'windows-x64-msvc'); + assert.equal(windowsBrokerTarget.assetName, 'oliphaunt-broker-0.1.0-windows-x64-msvc.zip'); + assert.equal(windowsBrokerTarget.executableRelativePath, 'bin/oliphaunt-broker.exe'); + assert.throws(() => oliphauntBrokerReleaseAssetUrl('../bad', 'asset.tar.gz'), /invalid/); + assert.throws(() => oliphauntBrokerReleaseAssetUrl('0.1.0', '../asset.tar.gz'), /invalid/); +} + +async function testBrokerSupportAndRestoreFailureAreActionable(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-broker-mode-')); + const missing = join(root, 'missing-broker'); + try { + const support = await brokerModeSupport({ + brokerExecutable: missing, + libraryPath: join(root, 'liboliphaunt.dylib'), + runtimeDirectory: join(root, 'runtime'), + brokerMaxRoots: 2, + }); + assert.equal(support.engine, 'nativeBroker'); + assert.equal(support.available, false); + assert.equal(support.capabilities.multiRoot, true); + assert.match(support.unavailableReason ?? '', /brokerExecutable/); + + await assert.rejects( + async () => + restorePhysicalArchiveWithBroker({ + brokerExecutable: missing, + root: join(root, 'db'), + bytes: new Uint8Array([1, 2, 3]), + replaceExisting: true, + }), + /brokerExecutable/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function testBrokerStartupTimeoutEnvIsValidatedBeforeNativeInstall(): Promise { + const root = await mkdtemp(join(tmpdir(), 'oliphaunt-js-broker-timeout-')); + const executable = join(root, process.platform === 'win32' ? 'broker.cmd' : 'broker'); + const previous = process.env.OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS; + try { + await writeFile(executable, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n'); + await chmod(executable, 0o700); + process.env.OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS = 'not-a-number'; + const binding = createBrokerRuntimeBinding({ executable }); + await assert.rejects( + () => + Promise.resolve( + binding.open( + normalizedTestConfig(join(root, 'db'), { + engine: 'nativeBroker', + }), + ), + ), + /OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS/, + ); + } finally { + if (previous === undefined) { + delete process.env.OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS; + } else { + process.env.OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS = previous; + } + await rm(root, { recursive: true, force: true }); + } +} + +function testServerCapabilitiesAndConnectionString(): void { + const binding = createServerRuntimeBinding(); + assert.equal(binding.runtime, 'node'); + assert.equal(binding.rawProtocolTransport, 'server-wire'); + assert.equal(binding.protocolStream, true); + assert.deepEqual(serverCapabilities(32, 'postgres://localhost/db'), { + engine: 'nativeServer', + processIsolated: true, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: false, + independentSessions: true, + maxClientSessions: 32, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['sql', 'physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + connectionString: 'postgres://localhost/db', + rawProtocolTransport: 'server-wire', + }); + assert.equal( + serverConnectionString('post gres', 'app/db', 55432), + 'postgres://post%20gres@127.0.0.1:55432/app%2Fdb', + ); +} + +async function testServerSupportReportsMissingExecutable(): Promise { + const support = await serverModeSupport({ serverExecutable: '/tmp/oliphaunt-missing-postgres' }); + assert.equal(support.engine, 'nativeServer'); + assert.equal(support.available, false); + assert.equal(support.capabilities.independentSessions, true); + assert.match(support.unavailableReason ?? '', /set serverExecutable|OLIPHAUNT_POSTGRES/); +} + +async function testServerStartupTimeoutEnvIsValidatedBeforeProcessSetup(): Promise { + const previous = process.env.OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS; + try { + process.env.OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS = '0'; + const binding = createServerRuntimeBinding(); + await assert.rejects( + () => + Promise.resolve( + binding.open( + normalizedTestConfig('/tmp/oliphaunt-js-server-timeout', { + engine: 'nativeServer', + }), + ), + ), + /OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS/, + ); + } finally { + if (previous === undefined) { + delete process.env.OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS; + } else { + process.env.OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS = previous; + } + } +} + +function normalizedTestConfig( + root: string, + overrides: Partial = {}, +): NormalizedOpenConfig { + return { + engine: 'nativeDirect', + root, + pgdata: join(root, 'pgdata'), + temporary: true, + durability: 'safe', + runtimeFootprint: 'throughput', + startupArgs: [], + username: 'postgres', + database: 'postgres', + extensions: [], + maxClientSessions: 1, + brokerMaxRoots: 1, + brokerTransport: 'auto', + ...overrides, + }; +} + +function testPgwireStartupCancelAndBackendKeyFrames(): void { + const startup = encodeStartupMessage('postgres', 'app'); + assert.equal(startup[4], 0); + assert.equal(startup[5], 3); + assert.equal(new TextDecoder().decode(startup).includes('client_encoding'), true); + assert.throws(() => encodeStartupMessage('bad\0user', 'app'), /NUL bytes/); + + const cancel = encodeCancelRequest({ processId: 123, secretKey: 456 }); + assert.equal(cancel.byteLength, 16); + assert.deepEqual(parseBackendKeyData(new Uint8Array([0, 0, 0, 123, 0, 0, 1, 200])), { + processId: 123, + secretKey: 456, + }); + assert.throws(() => parseBackendKeyData(new Uint8Array([1, 2, 3])), /invalid/); +} + +async function testNodeAdapterUtilities(): Promise { + assert.equal(randomHexToken(4).length, 8); + assert.deepEqual(parseReadyEndpoint('unix:/tmp/oliphaunt.sock'), { + kind: 'unix', + path: '/tmp/oliphaunt.sock', + }); + assert.deepEqual(parseReadyEndpoint('tcp:127.0.0.1:5432'), { + kind: 'tcp', + host: '127.0.0.1', + port: 5432, + }); + assert.deepEqual(parseReadyEndpoint('localhost:15432'), { + kind: 'tcp', + host: 'localhost', + port: 15432, + }); + assert.throws(() => parseReadyEndpoint('localhost:not-a-port'), /invalid TCP endpoint port/); + assert.throws(() => parseReadyEndpoint('localhost'), /invalid TCP endpoint/); + + const dir = await createTempDir('oliphaunt-js-node-adapter-'); + const file = join(dir, 'file'); + await writeFile(file, 'ok'); + await chmod(file, 0o600); + assert.equal((await canonicalPath(file)).endsWith('/file'), true); + assert.equal(await canonicalPath(join(dir, 'missing')), join(dir, 'missing')); + await removeTree(dir); + await removeTree(undefined); +} + +test('runtime modes', async () => { + await main(); +}); diff --git a/src/sdks/js/src/__tests__/server-wire.test.ts b/src/sdks/js/src/__tests__/server-wire.test.ts new file mode 100644 index 00000000..f9600154 --- /dev/null +++ b/src/sdks/js/src/__tests__/server-wire.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { + encodeCancelRequest, + encodeStartupMessage, + parseBackendKeyData, +} from '../runtime/pgwire.js'; +import { serverConnectionString } from '../runtime/server.js'; + +function main(): void { + startupMessageUsesPostgresV3AndUtf8(); + cancelRequestMatchesPostgresWireShape(); + backendKeyValidationMatchesRust(); + connectionStringPercentEncodesIdentity(); +} + +function startupMessageUsesPostgresV3AndUtf8(): void { + const message = encodeStartupMessage('app user', 'app/db'); + assert.equal(readI32(message, 4), 196_608); + const text = new TextDecoder().decode(message); + assert.match(text, /user\0app user\0/); + assert.match(text, /database\0app\/db\0/); + assert.match(text, /client_encoding\0UTF8\0/); +} + +function cancelRequestMatchesPostgresWireShape(): void { + const packet = encodeCancelRequest({ processId: 7, secretKey: 11 }); + assert.equal(packet.length, 16); + assert.equal(readI32(packet, 0), 16); + assert.equal(readI32(packet, 4), 80_877_102); + assert.equal(readI32(packet, 8), 7); + assert.equal(readI32(packet, 12), 11); +} + +function backendKeyValidationMatchesRust(): void { + assert.deepEqual(parseBackendKeyData(new Uint8Array([0, 0, 0, 7, 0, 0, 0, 11])), { + processId: 7, + secretKey: 11, + }); + assert.throws(() => parseBackendKeyData(new Uint8Array([1, 2, 3])), /BackendKeyData/); +} + +function connectionStringPercentEncodesIdentity(): void { + assert.equal( + serverConnectionString('app user', 'app/db', 15432), + 'postgres://app%20user@127.0.0.1:15432/app%2Fdb', + ); +} + +function readI32(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getInt32(0); +} + +test('server wire', () => { + main(); +}); diff --git a/src/sdks/js/src/client.ts b/src/sdks/js/src/client.ts new file mode 100644 index 00000000..37841baa --- /dev/null +++ b/src/sdks/js/src/client.ts @@ -0,0 +1,584 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { normalizeOpenConfig, validateOptionalPathOverride, validateRootPath } from './config.js'; +import { createDefaultNativeBinding } from './native/default.js'; +import type { NativeBinding, NativeBindingOptions } from './native/types.js'; +import { simpleQuery } from './protocol.js'; +import { + assertSuccessfulQueryResponse, + extendedQuery, + parseQueryResponse, + toUint8Array, + type QueryParam, + type QueryResult, +} from './query.js'; +import type { + BackupArtifact, + BackupFormat, + BackgroundPreparationOptions, + BackgroundPreparationResult, + BinaryInput, + EngineCapabilities, + EngineMode, + EngineModeSupport, + JavaScriptRuntime, + OliphauntClient, + OliphauntTransaction, + OpenConfig, + ProtocolChunkCallback, + RestoreOptions, + SupportedModesOptions, +} from './types.js'; +import { + brokerModeSupport, + createBrokerRuntimeBinding, + restorePhysicalArchiveWithBroker, +} from './runtime/broker.js'; +import { directRuntimeBinding, nativeDirectCapabilities } from './runtime/direct.js'; +import { createServerRuntimeBinding, serverModeSupport } from './runtime/server.js'; +import type { RuntimeBinding, RuntimeHandle } from './runtime/types.js'; + +export type NativeBindingFactory = ( + options?: NativeBindingOptions, +) => NativeBinding | Promise; + +export { nativeDirectCapabilities } from './runtime/direct.js'; + +export class OliphauntDatabase { + readonly #binding: RuntimeBinding; + readonly #handle: RuntimeHandle; + #closed = false; + #activeTransaction = false; + #activeOperations = 0; + + constructor( + binding: RuntimeBinding, + handle: RuntimeHandle, + readonly root: string, + ) { + this.#binding = binding; + this.#handle = handle; + } + + get handle(): RuntimeHandle { + return this.#handle; + } + + async capabilities(): Promise { + this.#assertOpen(); + return this.#binding.capabilities(this.#handle); + } + + async connectionString(): Promise { + return (await this.capabilities()).connectionString; + } + + async supportsBackupFormat(format: BackupFormat): Promise { + return supportsBackupFormat(await this.capabilities(), format); + } + + async supportsRestoreFormat(format: BackupFormat): Promise { + return supportsRestoreFormat(await this.capabilities(), format); + } + + async execute(sql: string): Promise { + this.#assertOpen(); + this.#assertNoActiveTransaction(); + const response = await this.#executeSimpleUnlocked(sql); + assertSuccessfulQueryResponse(response); + return response; + } + + async query(sql: string, parameters: ReadonlyArray = []): Promise { + if (parameters.length === 0) { + return parseQueryResponse(await this.execute(sql)); + } + return parseQueryResponse(await this.execProtocolRaw(extendedQuery(sql, parameters))); + } + + async execProtocolRaw(input: BinaryInput): Promise { + this.#assertOpen(); + this.#assertNoActiveTransaction(); + return this.#execProtocolRawUnlocked(input); + } + + async execProtocolStream(input: BinaryInput, onChunk: ProtocolChunkCallback): Promise { + this.#assertOpen(); + this.#assertNoActiveTransaction(); + await this.#execProtocolStreamUnlocked(input, onChunk); + } + + async backup(format: BackupFormat = 'physicalArchive'): Promise { + this.#assertOpen(); + this.#assertNoActiveTransaction(); + const capabilities = await this.capabilities(); + if (!supportsBackupFormat(capabilities, format)) { + throw new Error(`${format} backup is not supported by ${capabilities.engine}`); + } + return { + format, + bytes: await this.#runNativeOperation(() => this.#binding.backup(this.#handle, format)), + }; + } + + async checkpoint(): Promise { + await this.execute('CHECKPOINT'); + } + + async prepareForBackground( + options: BackgroundPreparationOptions = {}, + ): Promise { + this.#assertOpen(); + const hadActiveWork = this.#activeOperations > 0; + const shouldCancel = options.cancelActiveWork !== false; + const shouldCheckpoint = options.checkpointWhenIdle !== false; + let cancelledActiveWork = false; + + if (shouldCancel && hadActiveWork) { + await this.#binding.cancel(this.#handle); + cancelledActiveWork = true; + } + if (!shouldCheckpoint) { + return { cancelledActiveWork, checkpointed: false }; + } + if (this.#activeTransaction) { + return { + cancelledActiveWork, + checkpointed: false, + skippedCheckpointReason: 'transactionActive', + }; + } + if (hadActiveWork || this.#activeOperations > 0) { + return { + cancelledActiveWork, + checkpointed: false, + skippedCheckpointReason: 'activeWork', + }; + } + await this.checkpoint(); + return { cancelledActiveWork, checkpointed: true }; + } + + async resumeFromBackground(): Promise { + await this.execute('SELECT 1'); + } + + async cancel(): Promise { + this.#assertOpen(); + await this.#binding.cancel(this.#handle); + } + + async transaction(body: (transaction: OliphauntTransaction) => Promise | T): Promise { + this.#assertOpen(); + if (this.#activeTransaction) { + throw new Error(transactionPinnedMessage); + } + + this.#activeTransaction = true; + const transaction = new OliphauntTransactionHandle( + (input) => this.#execProtocolRawUnlocked(input), + (input, onChunk) => this.#execProtocolStreamUnlocked(input, onChunk), + ); + try { + await transaction.execute('BEGIN'); + const result = await body(transaction); + await transaction.execute('COMMIT'); + transaction.deactivate(); + return result; + } catch (error) { + try { + await transaction.execute('ROLLBACK'); + } catch { + // Preserve the original transaction failure; rollback is best-effort cleanup. + } + transaction.deactivate(); + throw error; + } finally { + this.#activeTransaction = false; + } + } + + async close(): Promise { + if (this.#closed) { + return; + } + this.#closed = true; + await this.#binding.detach(this.#handle); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + async #executeSimpleUnlocked(sql: string): Promise { + if (this.#binding.execSimpleQuery !== undefined) { + return this.#runNativeOperation(() => this.#binding.execSimpleQuery?.(this.#handle, sql)); + } + return this.#execProtocolRawUnlocked(simpleQuery(sql)); + } + + async #execProtocolRawUnlocked(input: BinaryInput): Promise { + const requestBytes = toUint8Array(input); + return this.#runNativeOperation(() => + this.#binding.execProtocolRaw(this.#handle, requestBytes), + ); + } + + async #execProtocolStreamUnlocked( + input: BinaryInput, + onChunk: ProtocolChunkCallback, + ): Promise { + const requestBytes = toUint8Array(input); + if (this.#binding.protocolStream && this.#binding.execProtocolStream !== undefined) { + await this.#runNativeVoidOperation(() => + this.#binding.execProtocolStream?.(this.#handle, requestBytes, onChunk), + ); + return; + } + onChunk(await this.#execProtocolRawUnlocked(requestBytes)); + } + + #assertOpen(): void { + if (this.#closed) { + throw new Error('Oliphaunt database is closed'); + } + } + + #assertNoActiveTransaction(): void { + if (this.#activeTransaction) { + throw new Error(transactionPinnedMessage); + } + } + + async #runNativeOperation(body: () => T | undefined | Promise): Promise { + this.#activeOperations += 1; + try { + const result = await body(); + if (result === undefined) { + throw new Error('native oliphaunt runtime operation returned no result'); + } + return result; + } finally { + this.#activeOperations -= 1; + } + } + + async #runNativeVoidOperation(body: () => void | Promise): Promise { + this.#activeOperations += 1; + try { + await body(); + } finally { + this.#activeOperations -= 1; + } + } +} + +class OliphauntTransactionHandle implements OliphauntTransaction { + readonly #execRaw: (input: BinaryInput) => Promise; + readonly #execStream: (input: BinaryInput, onChunk: ProtocolChunkCallback) => Promise; + #active = true; + + constructor( + execRaw: (input: BinaryInput) => Promise, + execStream: (input: BinaryInput, onChunk: ProtocolChunkCallback) => Promise, + ) { + this.#execRaw = execRaw; + this.#execStream = execStream; + } + + async execute(sql: string): Promise { + const response = await this.execProtocolRaw(simpleQuery(sql)); + assertSuccessfulQueryResponse(response); + return response; + } + + async query(sql: string, parameters: ReadonlyArray = []): Promise { + if (parameters.length === 0) { + return parseQueryResponse(await this.execute(sql)); + } + return parseQueryResponse(await this.execProtocolRaw(extendedQuery(sql, parameters))); + } + + async execProtocolRaw(input: BinaryInput): Promise { + this.#assertActive(); + return this.#execRaw(input); + } + + async execProtocolStream(input: BinaryInput, onChunk: ProtocolChunkCallback): Promise { + this.#assertActive(); + await this.#execStream(input, onChunk); + } + + deactivate(): void { + this.#active = false; + } + + #assertActive(): void { + if (!this.#active) { + throw new Error('transaction is no longer active'); + } + } +} + +const transactionPinnedMessage = 'physical session is pinned; use the active OliphauntTransaction'; + +export function createOliphauntClient( + bindingFactory: NativeBindingFactory = createDefaultNativeBinding, +): OliphauntClient { + const bindings = new Map>(); + const brokerBindings = new Map(); + const serverBinding = createServerRuntimeBinding(); + + function bindingFor(options: NativeBindingOptions = {}): Promise { + const key = options.libraryPath ?? ''; + const cached = bindings.get(key); + if (cached !== undefined) { + return cached; + } + const created = Promise.resolve() + .then(() => bindingFactory(options)) + .catch((error) => { + bindings.delete(key); + throw error; + }); + bindings.set(key, created); + return created; + } + + function brokerBindingFor(config: { + brokerExecutable?: string; + brokerMaxRoots?: number; + }): RuntimeBinding { + const key = `${config.brokerExecutable ?? ''}:${config.brokerMaxRoots ?? 1}`; + const cached = brokerBindings.get(key); + if (cached !== undefined) { + return cached; + } + const created = createBrokerRuntimeBinding({ + executable: config.brokerExecutable, + maxRoots: config.brokerMaxRoots, + }); + brokerBindings.set(key, created); + return created; + } + + return { + async supportedModes(options: SupportedModesOptions = {}): Promise { + const support: EngineModeSupport[] = []; + const libraryPath = validateOptionalPathOverride(options.libraryPath, 'libraryPath'); + try { + const binding = await bindingFor({ libraryPath }); + const directCapabilities = nativeDirectCapabilities(await binding.capabilities(), binding); + support.push({ + engine: 'nativeDirect', + available: true, + capabilities: directCapabilities, + }); + } catch (error) { + support.push({ + engine: 'nativeDirect', + available: false, + capabilities: baseCapabilitiesForMode('nativeDirect'), + unavailableReason: `native liboliphaunt is unavailable: ${errorString(error)}`, + }); + } + + const brokerExecutable = validateOptionalPathOverride( + options.brokerExecutable, + 'brokerExecutable', + ); + const runtimeDirectory = validateOptionalPathOverride( + options.runtimeDirectory, + 'runtimeDirectory', + ); + support.push(await brokerModeSupport({ brokerExecutable, libraryPath, runtimeDirectory })); + const serverExecutable = validateOptionalPathOverride( + options.serverExecutable, + 'serverExecutable', + ); + const serverToolDirectory = validateOptionalPathOverride( + options.serverToolDirectory, + 'serverToolDirectory', + ); + support.push(await serverModeSupport({ serverExecutable, serverToolDirectory })); + return support; + }, + + async open(config: OpenConfig = {}): Promise { + const root = await resolveOpenRoot(config); + const normalized = normalizeOpenConfig(withDefaultEngine(config), root); + let binding: RuntimeBinding; + if (normalized.engine === 'nativeDirect') { + binding = directRuntimeBinding(await bindingFor({ libraryPath: normalized.libraryPath })); + } else if (normalized.engine === 'nativeBroker') { + binding = brokerBindingFor({ + brokerExecutable: normalized.brokerExecutable, + brokerMaxRoots: normalized.brokerMaxRoots, + }); + } else { + binding = serverBinding; + } + const handle = await binding.open(normalized); + return new OliphauntDatabase(binding, handle, normalized.root); + }, + + async restore(options: RestoreOptions): Promise { + validateRootPath(options.root, 'restore root'); + const artifact = options.artifact; + if (artifact.format !== 'physicalArchive') { + throw new Error( + `restore currently requires a physicalArchive artifact, got ${artifact.format}`, + ); + } + const engine = options.engine ?? defaultEngineForRuntime(); + if (engine === 'nativeDirect') { + const libraryPath = validateOptionalPathOverride(options.libraryPath, 'libraryPath'); + const binding = await bindingFor({ libraryPath }); + await binding.restore({ + root: options.root, + format: artifact.format, + bytes: toUint8Array(artifact.bytes), + replaceExisting: options.replaceExisting === true, + }); + return options.root; + } + if (engine === 'nativeBroker') { + const brokerExecutable = validateOptionalPathOverride( + options.brokerExecutable, + 'brokerExecutable', + ); + return restorePhysicalArchiveWithBroker({ + root: options.root, + bytes: toUint8Array(artifact.bytes), + replaceExisting: options.replaceExisting, + brokerExecutable, + }); + } + throw new Error('nativeServer restore is not supported by the TypeScript SDK'); + }, + }; +} + +export function defaultEngineForRuntime(runtime: JavaScriptRuntime = currentRuntime()): EngineMode { + switch (runtime) { + case 'node': + case 'bun': + case 'deno': + return 'nativeDirect'; + } +} + +function withDefaultEngine(config: OpenConfig): OpenConfig { + if (config.engine !== undefined) { + return config; + } + return { ...config, engine: defaultEngineForRuntime() }; +} + +function currentRuntime(): JavaScriptRuntime { + if ( + typeof (globalThis as { Deno?: { version?: { deno?: string } } }).Deno?.version?.deno === + 'string' + ) { + return 'deno'; + } + if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') { + return 'bun'; + } + return 'node'; +} + +export function supportsBackupFormat( + capabilities: EngineCapabilities, + format: BackupFormat, +): boolean { + return capabilities.backupRestore && capabilities.backupFormats.includes(format); +} + +export function supportsRestoreFormat( + capabilities: EngineCapabilities, + format: BackupFormat, +): boolean { + return capabilities.backupRestore && capabilities.restoreFormats.includes(format); +} + +function baseCapabilitiesForMode(engine: EngineMode): EngineCapabilities { + switch (engine) { + case 'nativeDirect': + return { + engine, + processIsolated: false, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: true, + rootSwitchable: false, + crashRestartable: false, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + }; + case 'nativeBroker': + return { + engine, + processIsolated: true, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: true, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + }; + case 'nativeServer': + return { + engine, + processIsolated: true, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: false, + independentSessions: true, + maxClientSessions: 32, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['sql', 'physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + }; + } +} + +async function resolveOpenRoot(config: OpenConfig): Promise { + if (config.root !== undefined) { + return config.root; + } + if (config.temporary === false) { + throw new Error('database root is not configured; pass root or set temporary true'); + } + return mkdtemp(join(tmpdir(), 'liboliphaunt-js-')); +} + +function errorString(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/sdks/js/src/config.ts b/src/sdks/js/src/config.ts new file mode 100644 index 00000000..cb9f821f --- /dev/null +++ b/src/sdks/js/src/config.ts @@ -0,0 +1,373 @@ +import { join } from 'node:path'; + +import { generatedSharedPreloadLibraries } from './generated/extensions.js'; +import type { + BrokerTransport, + DurabilityProfile, + EngineMode, + OpenConfig, + PostgresStartupGUC, + RuntimeFootprintProfile, +} from './types.js'; + +export const DEFAULT_USERNAME = 'postgres'; +export const DEFAULT_DATABASE = 'postgres'; + +export type NormalizedOpenConfig = { + engine: EngineMode; + root: string; + pgdata: string; + temporary: boolean; + durability: DurabilityProfile; + runtimeFootprint: RuntimeFootprintProfile; + startupArgs: string[]; + username: string; + database: string; + extensions: string[]; + libraryPath?: string; + runtimeDirectory?: string; + maxClientSessions: number; + brokerExecutable?: string; + brokerMaxRoots: number; + brokerTransport: BrokerTransport; + serverExecutable?: string; + serverPort?: number; + serverToolDirectory?: string; +}; + +export function normalizeOpenConfig( + config: OpenConfig, + resolvedRoot: string, +): NormalizedOpenConfig { + if (config.root !== undefined && config.temporary === true) { + throw new Error('root and temporary are mutually exclusive'); + } + validateRootPath(resolvedRoot, 'database root'); + validateStartupIdentity(config.username ?? DEFAULT_USERNAME, 'username'); + validateStartupIdentity(config.database ?? DEFAULT_DATABASE, 'database'); + const runtimeFootprint = normalizeRuntimeFootprint(config.runtimeFootprint ?? 'throughput'); + const durability = normalizeDurability(config.durability ?? 'safe'); + const extensions = config.extensions ? validateExtensionIds(config.extensions) : []; + const startupArgs = buildStartupArgs({ + durability, + runtimeFootprint, + startupGUCs: config.startupGUCs ?? [], + extensions, + }); + const libraryPath = validateOptionalPathOverride(config.libraryPath, 'libraryPath'); + const runtimeDirectory = validateOptionalPathOverride( + config.runtimeDirectory, + 'runtimeDirectory', + ); + const brokerExecutable = validateOptionalPathOverride( + config.brokerExecutable, + 'brokerExecutable', + ); + const serverExecutable = validateOptionalPathOverride( + config.serverExecutable, + 'serverExecutable', + ); + const serverToolDirectory = validateOptionalPathOverride( + config.serverToolDirectory, + 'serverToolDirectory', + ); + const engine = config.engine ?? 'nativeDirect'; + const maxClientSessions = validateMaxClientSessions(config.maxClientSessions, engine); + const brokerMaxRoots = validateBrokerMaxRoots(config.brokerMaxRoots); + const brokerTransport = validateBrokerTransport(config.brokerTransport ?? 'auto'); + const serverPort = validateServerPort(config.serverPort); + + return { + engine, + root: resolvedRoot, + pgdata: join(resolvedRoot, 'pgdata'), + temporary: config.temporary === true, + durability, + runtimeFootprint, + startupArgs, + username: config.username ?? DEFAULT_USERNAME, + database: config.database ?? DEFAULT_DATABASE, + extensions, + libraryPath, + runtimeDirectory, + maxClientSessions, + brokerExecutable, + brokerMaxRoots, + brokerTransport, + serverExecutable, + serverPort, + serverToolDirectory, + }; +} + +export function buildStartupArgs(options: { + durability: DurabilityProfile; + runtimeFootprint: RuntimeFootprintProfile; + startupGUCs?: ReadonlyArray; + extensions?: ReadonlyArray; +}): string[] { + const assignments = [ + ...runtimeFootprintAssignments(options.runtimeFootprint), + ...durabilityAssignments(options.durability), + ...validateStartupGUCs(options.startupGUCs ?? []), + ]; + const preloadLibraries = requiredSharedPreloadLibraries(options.extensions ?? []); + if (preloadLibraries.length > 0) { + assignments.push(`shared_preload_libraries=${preloadLibraries.join(',')}`); + } + + return assignments.flatMap((assignment) => ['-c', assignment]); +} + +export function validateRootPath(value: string | undefined, label: string): void { + if (value === undefined) { + return; + } + if (value.trim().length === 0) { + throw new Error(rootPathMessage(label, 'empty')); + } + if (value.includes('\0')) { + throw new Error(rootPathMessage(label, 'nul')); + } +} + +export function validateStartupIdentity(value: string | undefined, label: string): void { + if (value === undefined) { + return; + } + if (value.trim().length === 0) { + throw new Error(`${label} must not be empty`); + } + if (value.includes('\0')) { + throw new Error(`${label} must not contain NUL bytes`); + } +} + +export function validateOptionalPathOverride( + value: string | undefined, + label: string, +): string | undefined { + if (value === undefined) { + return undefined; + } + if (value.trim().length === 0) { + throw new Error(pathOverrideMessage(label, 'empty')); + } + if (value.includes('\0')) { + throw new Error(pathOverrideMessage(label, 'nul')); + } + return value; +} + +export function validateMaxClientSessions(value: number | undefined, engine: EngineMode): number { + const sessions = value ?? (engine === 'nativeServer' ? 32 : 1); + if (!Number.isInteger(sessions)) { + throw new Error('maxClientSessions must be an integer'); + } + if (sessions <= 0) { + throw new Error( + engine === 'nativeServer' + ? 'native server maxClientSessions must be greater than zero' + : `${engine} maxClientSessions must be exactly 1`, + ); + } + if (engine !== 'nativeServer' && sessions !== 1) { + throw new Error(`${engine} supports exactly 1 client session, got ${sessions}`); + } + return sessions; +} + +export function validateBrokerMaxRoots(value: number | undefined): number { + const roots = value ?? 1; + if (!Number.isInteger(roots)) { + throw new Error('brokerMaxRoots must be an integer'); + } + if (roots <= 0) { + throw new Error('native broker max_roots must be greater than zero'); + } + return roots; +} + +export function validateServerPort(value: number | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + if (!Number.isInteger(value)) { + throw new Error('native server port must be an integer'); + } + if (value <= 0 || value > 0xffff) { + throw new Error('native server port must be in the range 1..65535'); + } + return value; +} + +export function validateBrokerTransport(value: BrokerTransport): BrokerTransport { + if (value === 'auto' || value === 'unix' || value === 'tcp') { + return value; + } + throw new Error(`unknown native broker transport '${value}'`); +} + +export function validateExtensionIds(extensions: ReadonlyArray): string[] { + const normalized: string[] = []; + for (const extension of extensions) { + const trimmed = extension.trim(); + if (trimmed.length === 0) { + continue; + } + if (!/^[A-Za-z0-9._-]{1,128}$/.test(trimmed)) { + throw new Error( + `Oliphaunt extension id '${trimmed}' must contain 1 to 128 ASCII letters, digits, '.', '_' or '-'`, + ); + } + normalized.push(trimmed); + } + return normalized; +} + +export function validateStartupGUCs(gucs: ReadonlyArray): string[] { + return gucs.map((guc) => { + const [name, value] = + typeof guc === 'string' ? splitStartupGUCAssignment(guc) : [guc.name, guc.value]; + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + throw new Error('PostgreSQL startup GUC name must not be empty'); + } + if (trimmedName.includes('\0') || value.includes('\0')) { + throw new Error('PostgreSQL startup GUC must not contain NUL bytes'); + } + if (!/^[A-Za-z0-9_.]+$/.test(trimmedName)) { + throw new Error( + `PostgreSQL startup GUC name '${name}' must contain only ASCII letters, digits, '_' or '.'`, + ); + } + if (value.trim().length === 0) { + throw new Error(`PostgreSQL startup GUC '${name}' value must not be empty`); + } + return `${trimmedName}=${value}`; + }); +} + +export function normalizeRuntimeFootprint( + profile: RuntimeFootprintProfile, +): RuntimeFootprintProfile { + if (profile === 'throughput' || profile === 'balancedMobile' || profile === 'smallMobile') { + return profile; + } + throw new Error(`unknown liboliphaunt runtime footprint profile '${profile}'`); +} + +export function normalizeDurability(profile: DurabilityProfile): DurabilityProfile { + if (profile === 'safe' || profile === 'balanced' || profile === 'fastDev') { + return profile; + } + throw new Error(`unknown liboliphaunt durability profile '${profile}'`); +} + +function runtimeFootprintAssignments(profile: RuntimeFootprintProfile): string[] { + switch (profile) { + case 'throughput': + return ['shared_buffers=128MB', 'wal_buffers=4MB', 'min_wal_size=80MB']; + case 'balancedMobile': + return [ + 'max_connections=1', + 'superuser_reserved_connections=0', + 'reserved_connections=0', + 'autovacuum_worker_slots=1', + 'max_wal_senders=0', + 'max_replication_slots=0', + 'shared_buffers=32MB', + 'wal_buffers=-1', + 'min_wal_size=32MB', + 'max_wal_size=64MB', + 'io_method=sync', + 'io_max_concurrency=1', + ]; + case 'smallMobile': + return [ + 'max_connections=1', + 'superuser_reserved_connections=0', + 'reserved_connections=0', + 'autovacuum_worker_slots=1', + 'max_wal_senders=0', + 'max_replication_slots=0', + 'shared_buffers=8MB', + 'wal_buffers=256kB', + 'min_wal_size=32MB', + 'max_wal_size=64MB', + 'work_mem=1MB', + 'maintenance_work_mem=16MB', + 'io_method=sync', + 'io_max_concurrency=1', + ]; + } +} + +function durabilityAssignments(profile: DurabilityProfile): string[] { + switch (profile) { + case 'safe': + return ['fsync=on', 'full_page_writes=on', 'synchronous_commit=on']; + case 'balanced': + return ['fsync=on', 'full_page_writes=on', 'synchronous_commit=off']; + case 'fastDev': + return ['fsync=off', 'full_page_writes=off', 'synchronous_commit=off']; + } +} + +function requiredSharedPreloadLibraries(extensions: ReadonlyArray): string[] { + return generatedSharedPreloadLibraries(extensions); +} + +function splitStartupGUCAssignment(assignment: string): [string, string] { + const index = assignment.indexOf('='); + if (index < 0) { + throw new Error('PostgreSQL startup GUC string must use name=value'); + } + return [assignment.slice(0, index), assignment.slice(index + 1)]; +} + +function rootPathMessage(label: string, reason: 'empty' | 'nul'): string { + switch (`${label}:${reason}`) { + case 'database root:empty': + return 'database root must not be empty'; + case 'database root:nul': + return 'database root must not contain NUL bytes'; + case 'restore root:empty': + return 'restore root must not be empty'; + case 'restore root:nul': + return 'restore root must not contain NUL bytes'; + default: + return reason === 'empty' + ? `${label} must not be empty` + : `${label} must not contain NUL bytes`; + } +} + +function pathOverrideMessage(label: string, reason: 'empty' | 'nul'): string { + switch (`${label}:${reason}`) { + case 'libraryPath:empty': + return 'libraryPath must not be empty'; + case 'libraryPath:nul': + return 'libraryPath must not contain NUL bytes'; + case 'runtimeDirectory:empty': + return 'runtimeDirectory must not be empty'; + case 'runtimeDirectory:nul': + return 'runtimeDirectory must not contain NUL bytes'; + case 'brokerExecutable:empty': + return 'brokerExecutable must not be empty'; + case 'brokerExecutable:nul': + return 'brokerExecutable must not contain NUL bytes'; + case 'serverExecutable:empty': + return 'serverExecutable must not be empty'; + case 'serverExecutable:nul': + return 'serverExecutable must not contain NUL bytes'; + case 'serverToolDirectory:empty': + return 'serverToolDirectory must not be empty'; + case 'serverToolDirectory:nul': + return 'serverToolDirectory must not contain NUL bytes'; + default: + return reason === 'empty' + ? `${label} must not be empty` + : `${label} must not contain NUL bytes`; + } +} diff --git a/src/sdks/js/src/generated/extensions.ts b/src/sdks/js/src/generated/extensions.ts new file mode 100644 index 00000000..4dc78a3e --- /dev/null +++ b/src/sdks/js/src/generated/extensions.ts @@ -0,0 +1,1132 @@ +// This file is generated by src/extensions/tools/check-extension-model.py. +// Do not edit by hand. + +export type GeneratedExtensionMetadata = { + readonly id: string; + readonly sqlName: string; + readonly displayName: string; + readonly postgresMajor: number; + readonly createsExtension: boolean; + readonly nativeModuleStem: string | null; + readonly dependencies: readonly string[]; + readonly selectedExtensionDependencies: readonly string[]; + readonly nativeDependencies: readonly string[]; + readonly sharedPreloadLibraries: readonly string[]; + readonly dataFiles: readonly string[]; + readonly runtimeShareDataFiles: readonly string[]; + readonly public: boolean; + readonly stable: boolean; + readonly desktopReleaseReady: boolean; + readonly mobileReleaseReady: boolean; + readonly targetStatus: { + readonly native?: string | null; + readonly wasix?: string | null; + readonly mobile?: string | null; + }; + readonly support: Readonly>>>; + readonly sourceKind: string; + readonly archive: string; +}; + +export const GENERATED_EXTENSION_METADATA = [ + { + archive: 'extensions/amcheck.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'amcheck', + id: 'amcheck', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'amcheck', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'amcheck', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/auto_explain.tar.zst', + createsExtension: false, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'auto_explain', + id: 'auto_explain', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'auto_explain', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'auto_explain', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/bloom.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'bloom', + id: 'bloom', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'bloom', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'bloom', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/btree_gin.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'btree_gin', + id: 'btree_gin', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'btree_gin', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'btree_gin', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/btree_gist.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'btree_gist', + id: 'btree_gist', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'btree_gist', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'btree_gist', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/citext.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'citext', + id: 'citext', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'citext', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'citext', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/cube.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'cube', + id: 'cube', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'cube', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'cube', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/dict_int.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'dict_int', + id: 'dict_int', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'dict_int', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'dict_int', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/dict_xsyn.tar.zst', + createsExtension: true, + dataFiles: ['share/postgresql/tsearch_data/xsyn_sample.rules'], + dependencies: [], + desktopReleaseReady: true, + displayName: 'dict_xsyn', + id: 'dict_xsyn', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'dict_xsyn', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: ['tsearch_data/xsyn_sample.rules'], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'dict_xsyn', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/earthdistance.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: ['cube'], + desktopReleaseReady: true, + displayName: 'earthdistance', + id: 'earthdistance', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'earthdistance', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: ['cube'], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'earthdistance', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/file_fdw.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'file_fdw', + id: 'file_fdw', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'file_fdw', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'file_fdw', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/fuzzystrmatch.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'fuzzystrmatch', + id: 'fuzzystrmatch', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'fuzzystrmatch', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'fuzzystrmatch', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/hstore.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'hstore', + id: 'hstore', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'hstore', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'hstore', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/intarray.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'intarray', + id: 'intarray', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: '_int', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'intarray', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/isn.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'isn', + id: 'isn', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'isn', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'isn', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/lo.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'lo', + id: 'lo', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'lo', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'lo', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/ltree.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'ltree', + id: 'ltree', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'ltree', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'ltree', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pageinspect.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pageinspect', + id: 'pageinspect', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pageinspect', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pageinspect', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_buffercache.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_buffercache', + id: 'pg_buffercache', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_buffercache', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_buffercache', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_freespacemap.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_freespacemap', + id: 'pg_freespacemap', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_freespacemap', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_freespacemap', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_hashids.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_hashids', + id: 'pg_hashids', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_hashids', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_hashids', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_ivm.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_ivm', + id: 'pg_ivm', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_ivm', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_ivm', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_surgery.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_surgery', + id: 'pg_surgery', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_surgery', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_surgery', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_textsearch.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_textsearch', + id: 'pg_textsearch', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_textsearch', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_textsearch', + stable: true, + support: { + mobile: { + android: 'supported', + ios: 'supported', + }, + native: { + broker: 'supported', + direct: 'supported', + server: 'supported', + }, + wasix: { + direct: 'supported', + server: 'supported', + }, + }, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_trgm.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_trgm', + id: 'pg_trgm', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_trgm', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_trgm', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_uuidv7.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_uuidv7', + id: 'pg_uuidv7', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_uuidv7', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_uuidv7', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_visibility.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_visibility', + id: 'pg_visibility', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_visibility', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_visibility', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_walinspect.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_walinspect', + id: 'pg_walinspect', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_walinspect', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_walinspect', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pgcrypto.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pgcrypto', + id: 'pgcrypto', + mobileReleaseReady: true, + nativeDependencies: ['openssl:3.5.6-libcrypto-wasix-static'], + nativeModuleStem: 'pgcrypto', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pgcrypto', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pgtap.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: ['plpgsql'], + desktopReleaseReady: true, + displayName: 'pgtap', + id: 'pgtap', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: null, + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pgtap', + stable: true, + support: { + mobile: { + android: 'supported', + ios: 'supported', + }, + native: { + broker: 'supported', + direct: 'supported', + server: 'supported', + }, + wasix: { + direct: 'supported', + server: 'supported', + }, + }, + targetStatus: { + mobile: null, + native: 'supported', + wasix: 'supported', + }, + }, + { + archive: 'extensions/postgis.tar.zst', + createsExtension: true, + dataFiles: [ + 'share/postgresql/contrib/postgis-3.6/legacy.sql', + 'share/postgresql/contrib/postgis-3.6/legacy_gist.sql', + 'share/postgresql/contrib/postgis-3.6/legacy_minimal.sql', + 'share/postgresql/contrib/postgis-3.6/postgis.sql', + 'share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql', + 'share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql', + 'share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql', + 'share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql', + 'share/postgresql/proj/proj.db', + ], + dependencies: [], + desktopReleaseReady: true, + displayName: 'PostGIS', + id: 'postgis', + mobileReleaseReady: true, + nativeDependencies: [ + 'geos:3.14.1-static', + 'proj:9.8.1-static', + 'sqlite:3.53.1-static', + 'libxml2:2.14.6-static', + 'json-c:0.18-static', + 'libiconv:1.19-static', + ], + nativeModuleStem: 'postgis-3', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [ + 'contrib/postgis-3.6/legacy.sql', + 'contrib/postgis-3.6/legacy_gist.sql', + 'contrib/postgis-3.6/legacy_minimal.sql', + 'contrib/postgis-3.6/postgis.sql', + 'contrib/postgis-3.6/postgis_upgrade.sql', + 'contrib/postgis-3.6/spatial_ref_sys.sql', + 'contrib/postgis-3.6/uninstall_legacy.sql', + 'contrib/postgis-3.6/uninstall_postgis.sql', + 'proj/proj.db', + ], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgis', + sqlName: 'postgis', + stable: true, + support: { + mobile: { + android: 'supported', + ios: 'supported', + }, + native: { + broker: 'supported', + direct: 'supported', + server: 'supported', + }, + wasix: { + direct: 'supported', + server: 'supported', + }, + }, + targetStatus: { + mobile: null, + native: 'supported', + wasix: 'supported', + }, + }, + { + archive: 'extensions/seg.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'seg', + id: 'seg', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'seg', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'seg', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tablefunc.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tablefunc', + id: 'tablefunc', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tablefunc', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tablefunc', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tcn.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tcn', + id: 'tcn', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tcn', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tcn', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tsm_system_rows.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tsm_system_rows', + id: 'tsm_system_rows', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tsm_system_rows', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tsm_system_rows', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tsm_system_time.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tsm_system_time', + id: 'tsm_system_time', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tsm_system_time', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tsm_system_time', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/unaccent.tar.zst', + createsExtension: true, + dataFiles: ['share/postgresql/tsearch_data/unaccent.rules'], + dependencies: [], + desktopReleaseReady: true, + displayName: 'unaccent', + id: 'unaccent', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'unaccent', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: ['tsearch_data/unaccent.rules'], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'unaccent', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/uuid-ossp.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'uuid-ossp', + id: 'uuid_ossp', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'uuid-ossp', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'uuid-ossp', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/vector.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pgvector', + id: 'vector', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'vector', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'vector', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, +] as const satisfies readonly GeneratedExtensionMetadata[]; + +export function generatedExtensionBySqlName( + sqlName: string, +): GeneratedExtensionMetadata | undefined { + return GENERATED_EXTENSION_METADATA.find((extension) => extension.sqlName === sqlName); +} + +export function generatedSharedPreloadLibraries(extensionSqlNames: readonly string[]): string[] { + const libraries = new Set(); + for (const sqlName of extensionSqlNames) { + const extension = generatedExtensionBySqlName(sqlName); + for (const library of extension?.sharedPreloadLibraries ?? []) { + libraries.add(library); + } + } + return [...libraries].sort(); +} diff --git a/src/sdks/js/src/index.ts b/src/sdks/js/src/index.ts new file mode 100644 index 00000000..182ad3dd --- /dev/null +++ b/src/sdks/js/src/index.ts @@ -0,0 +1,66 @@ +export { + createOliphauntClient, + nativeDirectCapabilities, + OliphauntDatabase, + supportsBackupFormat, + supportsRestoreFormat, + type NativeBindingFactory, +} from './client.js'; +export { createBunNativeBinding } from './native/bun.js'; +export { createDefaultNativeBinding } from './native/default.js'; +export { createDenoNativeBinding } from './native/deno.js'; +export { createNodeNativeBinding } from './native/node.js'; +export { simpleQuery } from './protocol.js'; +export { + assertSuccessfulQueryResponse, + extendedQuery, + parseQueryResponse, + PostgresError, + toUint8Array, + type PostgresErrorField, + type QueryBinaryInput, + type QueryField, + type QueryFormat, + type QueryParam, + type QueryResult, + type QueryRow, +} from './query.js'; +export type { + BackupArtifact, + BackupFormat, + BackgroundPreparationOptions, + BackgroundPreparationResult, + BinaryInput, + BrokerTransport, + DurabilityProfile, + EngineCapabilities, + EngineMode, + EngineModeSupport, + JavaScriptRuntime, + OliphauntClient, + OliphauntTransaction, + OpenConfig, + PostgresStartupGUC, + ProtocolChunkCallback, + RawProtocolTransport, + RestoreOptions, + RuntimeFootprintProfile, + SupportedModesOptions, +} from './types.js'; +export type { NormalizedOpenConfig } from './config.js'; +export type { + MaybePromise, + NativeBinding, + NativeBindingOptions, + NativeHandle, + NativeOpenConfig, + NativeRestoreOptions, +} from './native/types.js'; +export type { RuntimeBinding, RuntimeHandle } from './runtime/types.js'; + +import { createOliphauntClient } from './client.js'; +import type { OliphauntClient } from './types.js'; + +export const Oliphaunt: OliphauntClient = createOliphauntClient(); + +export default Oliphaunt; diff --git a/src/sdks/js/src/native/assets-deno.ts b/src/sdks/js/src/native/assets-deno.ts new file mode 100644 index 00000000..03b21044 --- /dev/null +++ b/src/sdks/js/src/native/assets-deno.ts @@ -0,0 +1,328 @@ +import { + assertSha256Matches, + checksumForReleaseAsset, + envVar, + LIBOLIPHAUNT_CACHE_DIR_ENV, + LIBOLIPHAUNT_RELEASE_ASSET_DIR_ENV, + liboliphauntChecksumAssetName, + liboliphauntReleaseAssetUrl, + liboliphauntReleaseTarget, + parseReleaseChecksumManifest, + resolveExplicitLibraryPath, + resolveExplicitRuntimeDirectory, +} from './common.js'; +import { extractTarArchive } from './tar.js'; +import { extractZipArchive } from './zip.js'; + +export type ResolvedDenoNativeInstall = { + libraryPath: string; + runtimeDirectory?: string; +}; + +type DenoRuntime = { + build: { os: string; arch: string }; + env: { get(name: string): string | undefined }; + cwd(): string; + mkdir(path: string, options?: { recursive?: boolean }): Promise; + readFile(path: string): Promise; + readTextFile(path: string): Promise; + writeFile(path: string, bytes: Uint8Array, options?: { mode?: number }): Promise; + writeTextFile(path: string, text: string, options?: { createNew?: boolean }): Promise; + chmod(path: string, mode: number): Promise; + remove(path: string, options?: { recursive?: boolean }): Promise; + rename(oldPath: string, newPath: string): Promise; + stat(path: string): Promise; +}; + +type PackageMetadata = { + name: string; + oliphaunt?: { + liboliphauntVersion?: string; + }; +}; + +type InstallMarker = { + version: string; + asset: string; + checksum: string; +}; + +export async function resolveDenoNativeInstall( + libraryPath?: string, +): Promise { + const explicit = resolveExplicitLibraryPath(libraryPath); + if (explicit !== undefined) { + return { + libraryPath: explicit, + runtimeDirectory: resolveExplicitRuntimeDirectory(), + }; + } + + const deno = denoRuntime(); + const version = await packageLiboliphauntVersion(deno); + const target = liboliphauntReleaseTarget(version, deno.build.os, deno.build.arch); + const installRoot = joinPath(cacheRoot(deno), 'liboliphaunt', version, target.id); + const install = { + libraryPath: joinPath(installRoot, target.libraryRelativePath), + runtimeDirectory: joinPath(installRoot, target.runtimeRelativePath), + }; + const release = await acquireInstallLock(deno, `${installRoot}.lock`); + try { + const current = await validateExistingInstall(deno, install, version, target.assetName); + if (current !== undefined) { + return current; + } + const checksumBytes = await readReleaseAssetBytes( + deno, + version, + liboliphauntChecksumAssetName(version), + ); + const checksums = parseReleaseChecksumManifest(new TextDecoder().decode(checksumBytes)); + const expectedChecksum = checksumForReleaseAsset(checksums, target.assetName); + const archive = await readReleaseAssetBytes(deno, version, target.assetName); + assertSha256Matches(target.assetName, expectedChecksum, await sha256Hex(archive)); + await installArchive(deno, target.assetName, archive, installRoot, { + version, + asset: target.assetName, + checksum: expectedChecksum, + }); + return install; + } finally { + await release(); + } +} + +async function packageLiboliphauntVersion(deno: DenoRuntime): Promise { + const packageUrl = new URL('../../package.json', import.meta.url); + const packageJson = JSON.parse(await deno.readTextFile(packageUrl.pathname)) as PackageMetadata; + const version = packageJson.oliphaunt?.liboliphauntVersion; + if (packageJson.name !== '@oliphaunt/ts' || version === undefined || version.length === 0) { + throw new Error('@oliphaunt/ts package metadata does not pin liboliphauntVersion'); + } + return version; +} + +async function validateExistingInstall( + deno: DenoRuntime, + install: ResolvedDenoNativeInstall, + version: string, + asset: string, +): Promise { + const markerPath = joinPath( + dirnamePath(dirnamePath(install.libraryPath)), + '.oliphaunt-native-install.json', + ); + try { + const marker = JSON.parse(await deno.readTextFile(markerPath)) as InstallMarker; + await deno.stat(install.libraryPath); + if (install.runtimeDirectory !== undefined) { + await deno.stat(install.runtimeDirectory); + } + if (marker.version === version && marker.asset === asset && marker.checksum.length === 64) { + return install; + } + } catch { + return undefined; + } + return undefined; +} + +async function readReleaseAssetBytes( + deno: DenoRuntime, + version: string, + asset: string, +): Promise { + const localAssetDir = envVar(LIBOLIPHAUNT_RELEASE_ASSET_DIR_ENV); + if (localAssetDir !== undefined && localAssetDir.trim().length > 0) { + return deno.readFile(joinPath(localAssetDir, asset)); + } + const url = liboliphauntReleaseAssetUrl(version, asset); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`download ${url} failed with HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +async function installArchive( + deno: DenoRuntime, + assetName: string, + archive: Uint8Array, + installRoot: string, + marker: InstallMarker, +): Promise { + const parent = dirnamePath(installRoot); + const scratch = joinPath(parent, `.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`); + await removeIfExists(deno, scratch); + await deno.mkdir(scratch, { recursive: true }); + try { + const host = { + join: joinPath, + dirname: dirnamePath, + mkdir(path: string) { + return deno.mkdir(path, { recursive: true }); + }, + async writeFile(file: { path: string; bytes: Uint8Array; mode: number }) { + await deno.writeFile(file.path, file.bytes, { mode: file.mode }); + await deno.chmod(file.path, file.mode); + }, + }; + if (assetName.endsWith('.zip')) { + await extractZipArchive(archive, scratch, host, inflateRaw); + } else { + await extractTarArchive(await gunzip(archive), scratch, host); + } + await deno.writeTextFile( + joinPath(scratch, '.oliphaunt-native-install.json'), + `${JSON.stringify(marker, null, 2)}\n`, + ); + await removeIfExists(deno, installRoot); + await deno.rename(scratch, installRoot); + } catch (error) { + await removeIfExists(deno, scratch); + throw error; + } +} + +async function gunzip(bytes: Uint8Array): Promise { + return decompress( + bytes, + 'gzip', + 'Deno runtime does not expose DecompressionStream for gzip assets', + ); +} + +async function inflateRaw(bytes: Uint8Array): Promise { + return decompress( + bytes, + 'deflate-raw', + 'Deno runtime does not expose DecompressionStream for deflated ZIP assets', + ); +} + +async function decompress( + bytes: Uint8Array, + format: 'gzip' | 'deflate-raw', + message: string, +): Promise { + type DecompressionStreamConstructor = new ( + format: 'gzip' | 'deflate-raw', + ) => { + readable: ReadableStream; + writable: WritableStream; + }; + const DecompressionStreamCtor = ( + globalThis as { + DecompressionStream?: DecompressionStreamConstructor; + } + ).DecompressionStream; + if (DecompressionStreamCtor === undefined) { + throw new Error(message); + } + const decompression = new DecompressionStreamCtor(format); + const stream = new Blob([toArrayBuffer(bytes)]).stream().pipeThrough(decompression); + return new Uint8Array(await new Response(stream as ReadableStream).arrayBuffer()); +} + +function cacheRoot(deno: DenoRuntime): string { + const override = envVar(LIBOLIPHAUNT_CACHE_DIR_ENV); + if (override !== undefined && override.trim().length > 0) { + return override; + } + const home = deno.env.get('HOME'); + if (deno.build.os === 'darwin' && home !== undefined && home.length > 0) { + return joinPath(home, 'Library', 'Caches', 'oliphaunt'); + } + const xdgCache = deno.env.get('XDG_CACHE_HOME'); + if (xdgCache !== undefined && xdgCache.length > 0) { + return joinPath(xdgCache, 'oliphaunt'); + } + if (home !== undefined && home.length > 0) { + return joinPath(home, '.cache', 'oliphaunt'); + } + return joinPath(deno.cwd(), '.oliphaunt-cache'); +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(bytes)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + if (bytes.buffer instanceof ArrayBuffer) { + if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) { + return bytes.buffer; + } + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +async function acquireInstallLock(deno: DenoRuntime, path: string): Promise<() => Promise> { + await deno.mkdir(dirnamePath(path), { recursive: true }); + for (let attempt = 0; attempt < 600; attempt += 1) { + try { + await deno.writeTextFile(path, `${Date.now()}\n`, { createNew: true }); + return async () => { + await removeIfExists(deno, path); + }; + } catch (error) { + if (isAlreadyExistsError(error)) { + await sleep(100); + continue; + } + throw error; + } + } + throw new Error(`timed out waiting for liboliphaunt install lock ${path}`); +} + +async function removeIfExists(deno: DenoRuntime, path: string): Promise { + try { + await deno.remove(path, { recursive: true }); + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } +} + +function denoRuntime(): DenoRuntime { + const deno = (globalThis as { Deno?: DenoRuntime }).Deno; + if (deno === undefined) { + throw new Error('Deno native binding can only be used inside Deno'); + } + return deno; +} + +function joinPath(...parts: string[]): string { + const absolute = parts[0]?.startsWith('/') ?? false; + const joined = parts + .flatMap((part) => part.split('/')) + .filter((part) => part.length > 0 && part !== '.') + .join('/'); + return absolute ? `/${joined}` : joined; +} + +function dirnamePath(path: string): string { + const normalized = path.replace(/\/+$/, ''); + const index = normalized.lastIndexOf('/'); + if (index <= 0) { + return index === 0 ? '/' : '.'; + } + return normalized.slice(0, index); +} + +function isAlreadyExistsError(error: unknown): boolean { + return error instanceof Error && error.name === 'AlreadyExists'; +} + +function isNotFoundError(error: unknown): boolean { + return error instanceof Error && error.name === 'NotFound'; +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/sdks/js/src/native/assets-node.ts b/src/sdks/js/src/native/assets-node.ts new file mode 100644 index 00000000..cb2a5f04 --- /dev/null +++ b/src/sdks/js/src/native/assets-node.ts @@ -0,0 +1,227 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { arch, homedir, platform, tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { gunzipSync, inflateRawSync } from 'node:zlib'; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; + +import { + checksumForReleaseAsset, + envVar, + LIBOLIPHAUNT_CACHE_DIR_ENV, + LIBOLIPHAUNT_RELEASE_ASSET_DIR_ENV, + liboliphauntChecksumAssetName, + liboliphauntReleaseAssetUrl, + liboliphauntReleaseTarget, + parseReleaseChecksumManifest, + resolveExplicitLibraryPath, + resolveExplicitRuntimeDirectory, + assertSha256Matches, +} from './common.js'; +import { extractTarArchive } from './tar.js'; +import { extractZipArchive } from './zip.js'; + +export type ResolvedNativeInstall = { + libraryPath: string; + runtimeDirectory?: string; +}; + +type PackageMetadata = { + name: string; + oliphaunt?: { + liboliphauntVersion?: string; + }; +}; + +type InstallMarker = { + version: string; + asset: string; + checksum: string; +}; + +const require = createRequire(import.meta.url); + +export async function resolveNodeNativeInstall( + libraryPath?: string, +): Promise { + const explicit = resolveExplicitLibraryPath(libraryPath); + if (explicit !== undefined) { + return { + libraryPath: explicit, + runtimeDirectory: resolveExplicitRuntimeDirectory(), + }; + } + + const version = await packageLiboliphauntVersion(); + const target = liboliphauntReleaseTarget(version, platform(), arch()); + const installRoot = join(cacheRoot(), 'liboliphaunt', version, target.id); + const install = { + libraryPath: join(installRoot, target.libraryRelativePath), + runtimeDirectory: join(installRoot, target.runtimeRelativePath), + }; + const lockPath = `${installRoot}.lock`; + const release = await acquireInstallLock(lockPath); + try { + const current = await validateExistingInstall(install, version, target.assetName); + if (current !== undefined) { + return current; + } + const checksums = parseReleaseChecksumManifest( + new TextDecoder().decode( + await readReleaseAssetBytes(version, liboliphauntChecksumAssetName(version)), + ), + ); + const expectedChecksum = checksumForReleaseAsset(checksums, target.assetName); + const archive = await readReleaseAssetBytes(version, target.assetName); + assertSha256Matches(target.assetName, expectedChecksum, sha256Hex(archive)); + await installArchive(target.assetName, archive, installRoot, { + version, + asset: target.assetName, + checksum: expectedChecksum, + }); + return install; + } finally { + await release(); + } +} + +async function packageLiboliphauntVersion(): Promise { + const packageJson = JSON.parse( + await readFile(require.resolve('@oliphaunt/ts/package.json'), 'utf8'), + ) as PackageMetadata; + const version = packageJson.oliphaunt?.liboliphauntVersion; + if (packageJson.name !== '@oliphaunt/ts' || version === undefined || version.length === 0) { + throw new Error('@oliphaunt/ts package metadata does not pin liboliphauntVersion'); + } + return version; +} + +async function validateExistingInstall( + install: ResolvedNativeInstall, + version: string, + asset: string, +): Promise { + const markerPath = join(dirname(dirname(install.libraryPath)), '.oliphaunt-native-install.json'); + try { + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as InstallMarker; + await stat(install.libraryPath); + if (install.runtimeDirectory !== undefined) { + await stat(install.runtimeDirectory); + } + if (marker.version === version && marker.asset === asset && marker.checksum.length === 64) { + return install; + } + } catch { + return undefined; + } + return undefined; +} + +async function readReleaseAssetBytes(version: string, asset: string): Promise { + const localAssetDir = envVar(LIBOLIPHAUNT_RELEASE_ASSET_DIR_ENV); + if (localAssetDir !== undefined && localAssetDir.trim().length > 0) { + return Uint8Array.from(await readFile(join(localAssetDir, asset))); + } + const url = liboliphauntReleaseAssetUrl(version, asset); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`download ${url} failed with HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +async function installArchive( + assetName: string, + archive: Uint8Array, + installRoot: string, + marker: InstallMarker, +): Promise { + const parent = dirname(installRoot); + const scratch = join( + parent, + `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await rm(scratch, { recursive: true, force: true }); + await mkdir(scratch, { recursive: true }); + try { + const host = { + join, + dirname, + async mkdir(path: string) { + await mkdir(path, { recursive: true }); + }, + async writeFile(file: { path: string; bytes: Uint8Array; mode: number }) { + await writeFile(file.path, file.bytes, { mode: file.mode }); + await chmod(file.path, file.mode); + }, + }; + if (assetName.endsWith('.zip')) { + await extractZipArchive(archive, scratch, host, (compressed) => + Uint8Array.from(inflateRawSync(compressed)), + ); + } else { + await extractTarArchive(Uint8Array.from(gunzipSync(archive)), scratch, host); + } + await writeFile( + join(scratch, '.oliphaunt-native-install.json'), + `${JSON.stringify(marker, null, 2)}\n`, + 'utf8', + ); + await rm(installRoot, { recursive: true, force: true }); + await rename(scratch, installRoot); + } catch (error) { + await rm(scratch, { recursive: true, force: true }); + throw error; + } +} + +function cacheRoot(): string { + const override = envVar(LIBOLIPHAUNT_CACHE_DIR_ENV); + if (override !== undefined && override.trim().length > 0) { + return override; + } + if (platform() === 'darwin') { + return join(homedir(), 'Library', 'Caches', 'oliphaunt'); + } + const xdgCache = envVar('XDG_CACHE_HOME'); + if (xdgCache !== undefined && xdgCache.trim().length > 0) { + return join(xdgCache, 'oliphaunt'); + } + return join(homedir() || tmpdir(), '.cache', 'oliphaunt'); +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function acquireInstallLock(path: string): Promise<() => Promise> { + await mkdir(dirname(path), { recursive: true }); + for (let attempt = 0; attempt < 600; attempt += 1) { + try { + await writeFile(path, `${process.pid}\n`, { flag: 'wx' }); + return async () => { + await rm(path, { force: true }); + }; + } catch (error) { + if (isFileExistsError(error)) { + await sleep(100); + continue; + } + throw error; + } + } + throw new Error(`timed out waiting for liboliphaunt install lock ${path}`); +} + +function isFileExistsError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'EEXIST' + ); +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/sdks/js/src/native/bun.ts b/src/sdks/js/src/native/bun.ts new file mode 100644 index 00000000..cc29d295 --- /dev/null +++ b/src/sdks/js/src/native/bun.ts @@ -0,0 +1,201 @@ +import { assertSupportedDirectBackupFormat, errorMessage, nativeBackupFormat } from './common.js'; +import { resolveNodeNativeInstall } from './assets-node.js'; +import type { BackupFormat } from '../types.js'; +import { + packConfigPointers, + packRestoreOptionsPointers, + readResponseLength, + readResponsePointer, + responseBuffer, +} from './ffi-layout.js'; +import type { + NativeBinding, + NativeBindingOptions, + NativeHandle, + NativeOpenConfig, + NativeRestoreOptions, +} from './types.js'; + +type BunSymbols = { + oliphaunt_init: (...args: unknown[]) => unknown; + oliphaunt_exec_protocol: (...args: unknown[]) => unknown; + oliphaunt_exec_simple_query: (...args: unknown[]) => unknown; + oliphaunt_backup: (...args: unknown[]) => unknown; + oliphaunt_restore: (...args: unknown[]) => unknown; + oliphaunt_cancel: (...args: unknown[]) => unknown; + oliphaunt_detach: (...args: unknown[]) => unknown; + oliphaunt_last_error: (...args: unknown[]) => unknown; + oliphaunt_version: (...args: unknown[]) => unknown; + oliphaunt_capabilities: (...args: unknown[]) => unknown; + oliphaunt_free_response: (...args: unknown[]) => unknown; +}; + +export async function createBunNativeBinding( + options: NativeBindingOptions = {}, +): Promise { + const install = await resolveNodeNativeInstall(options.libraryPath); + const ffi = await import('bun:ffi'); + const symbols = loadSymbols(ffi, install.libraryPath); + + return { + runtime: 'bun', + rawProtocolTransport: 'bun-ffi', + protocolStream: false, + defaultRuntimeDirectory: install.runtimeDirectory, + version(): string { + return String(symbols.oliphaunt_version()); + }, + capabilities(): bigint { + return BigInt(symbols.oliphaunt_capabilities() as number | bigint); + }, + open(config: NativeOpenConfig): NativeHandle { + const packed = packConfigPointers(config, (value) => pointerOf(ffi, value)); + const out = new Uint8Array(8); + const rc = symbols.oliphaunt_init(packed.config, out) as number; + keepAlive(packed.keepAlive); + if (rc !== 0) { + throw errorMessage('native liboliphaunt init failed', rc, lastError(symbols, null)); + } + const handle = readPointer(out); + if (handle === 0n) { + throw new Error('native liboliphaunt init returned a null handle'); + } + return handle; + }, + execProtocolRaw(handle: NativeHandle, request: Uint8Array): Uint8Array { + const response = responseBuffer(); + const rc = symbols.oliphaunt_exec_protocol( + pointerArgument(handle), + request, + request.byteLength, + response, + ) as number; + if (rc !== 0) { + symbols.oliphaunt_free_response(response); + throw errorMessage( + 'native liboliphaunt protocol execution failed', + rc, + lastError(symbols, handle), + ); + } + return copyResponse(ffi, symbols, response); + }, + execSimpleQuery(handle: NativeHandle, sql: string): Uint8Array { + if (sql.includes('\0')) { + throw new Error('simple query SQL must not contain NUL bytes'); + } + const bytes = new TextEncoder().encode(sql); + const response = responseBuffer(); + const rc = symbols.oliphaunt_exec_simple_query( + pointerArgument(handle), + bytes, + bytes.byteLength, + response, + ) as number; + if (rc !== 0) { + symbols.oliphaunt_free_response(response); + throw errorMessage( + 'native liboliphaunt simple query failed', + rc, + lastError(symbols, handle), + ); + } + return copyResponse(ffi, symbols, response); + }, + backup(handle: NativeHandle, format: BackupFormat): Uint8Array { + assertSupportedDirectBackupFormat(format); + const response = responseBuffer(); + const rc = symbols.oliphaunt_backup( + pointerArgument(handle), + nativeBackupFormat(format), + response, + ) as number; + if (rc !== 0) { + symbols.oliphaunt_free_response(response); + throw errorMessage('native liboliphaunt backup failed', rc, lastError(symbols, handle)); + } + return copyResponse(ffi, symbols, response); + }, + restore(options: NativeRestoreOptions): void { + if (options.format !== 'physicalArchive') { + throw new Error( + `restore currently requires a physicalArchive artifact, got ${options.format}`, + ); + } + const packed = packRestoreOptionsPointers(options, (value) => pointerOf(ffi, value)); + const rc = symbols.oliphaunt_restore(packed.options) as number; + keepAlive(packed.keepAlive); + if (rc !== 0) { + throw errorMessage('native liboliphaunt restore failed', rc, lastError(symbols, null)); + } + }, + cancel(handle: NativeHandle): void { + const rc = symbols.oliphaunt_cancel(pointerArgument(handle)) as number; + if (rc !== 0) { + throw errorMessage('native liboliphaunt cancel failed', rc, lastError(symbols, handle)); + } + }, + detach(handle: NativeHandle): void { + const rc = symbols.oliphaunt_detach(pointerArgument(handle)) as number; + if (rc !== 0) { + throw errorMessage('native liboliphaunt detach failed', rc, lastError(symbols, handle)); + } + }, + }; +} + +function loadSymbols(ffi: typeof import('bun:ffi'), libraryPath: string): BunSymbols { + const { dlopen, FFIType } = ffi; + const { i32, u32, u64, ptr, buffer, cstring, void: voidType } = FFIType; + return dlopen(libraryPath, { + oliphaunt_init: { args: [buffer, buffer], returns: i32 }, + oliphaunt_exec_protocol: { args: [ptr, buffer, u64, buffer], returns: i32 }, + oliphaunt_exec_simple_query: { args: [ptr, buffer, u64, buffer], returns: i32 }, + oliphaunt_backup: { args: [ptr, u32, buffer], returns: i32 }, + oliphaunt_restore: { args: [buffer], returns: i32 }, + oliphaunt_cancel: { args: [ptr], returns: i32 }, + oliphaunt_detach: { args: [ptr], returns: i32 }, + oliphaunt_last_error: { args: [ptr], returns: cstring }, + oliphaunt_version: { args: [], returns: cstring }, + oliphaunt_capabilities: { args: [], returns: u64 }, + oliphaunt_free_response: { args: [buffer], returns: voidType }, + }).symbols as BunSymbols; +} + +function pointerOf(ffi: typeof import('bun:ffi'), value: Uint8Array): bigint { + return BigInt(ffi.ptr(value) as number | bigint); +} + +function pointerArgument(value: NativeHandle): number { + return Number(value as bigint); +} + +function readPointer(value: Uint8Array): bigint { + return new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64(0, true); +} + +function copyResponse( + ffi: typeof import('bun:ffi'), + symbols: BunSymbols, + response: Uint8Array, +): Uint8Array { + try { + const data = readResponsePointer(response); + const length = readResponseLength(response); + if (data === 0n || length === 0) { + return new Uint8Array(); + } + return new Uint8Array(ffi.toArrayBuffer(Number(data), 0, length)).slice(); + } finally { + symbols.oliphaunt_free_response(response); + } +} + +function lastError(symbols: BunSymbols, handle: NativeHandle | null): string | null { + const value = symbols.oliphaunt_last_error(handle === null ? null : pointerArgument(handle)); + return value == null ? null : String(value); +} + +function keepAlive(_values: ReadonlyArray): void { + // Values are referenced until the native call returns; liboliphaunt copies config strings. +} diff --git a/src/sdks/js/src/native/common.ts b/src/sdks/js/src/native/common.ts new file mode 100644 index 00000000..c215bb12 --- /dev/null +++ b/src/sdks/js/src/native/common.ts @@ -0,0 +1,245 @@ +import type { BackupFormat } from '../types.js'; + +export const ABI_VERSION = 6; +export const RESTORE_REPLACE_EXISTING = 1n; +export const DEFAULT_LIBOLIPHAUNT_REPOSITORY = 'f0rr0/oliphaunt'; +export const DEFAULT_LIBOLIPHAUNT_RELEASE_TAG_PREFIX = 'liboliphaunt-native-v'; +export const LIBOLIPHAUNT_RELEASE_ASSET_DIR_ENV = 'OLIPHAUNT_LIBOLIPHAUNT_ASSET_DIR'; +export const LIBOLIPHAUNT_CACHE_DIR_ENV = 'OLIPHAUNT_CACHE_DIR'; +export const LIBOLIPHAUNT_RELEASE_BASE_URL_ENV = 'OLIPHAUNT_RELEASE_BASE_URL'; +export const LIBOLIPHAUNT_RUNTIME_DIR_ENV = 'OLIPHAUNT_RUNTIME_DIR'; + +export const CAP_PROTOCOL_RAW = 1n << 0n; +export const CAP_PROTOCOL_STREAM = 1n << 1n; +export const CAP_MULTI_INSTANCE = 1n << 2n; +export const CAP_SERVER_MODE = 1n << 3n; +export const CAP_EXTENSIONS = 1n << 4n; +export const CAP_QUERY_CANCEL = 1n << 5n; +export const CAP_BACKUP_RESTORE = 1n << 6n; +export const CAP_SIMPLE_QUERY = 1n << 7n; +export const CAP_LOGICAL_REOPEN = 1n << 9n; + +export type NativeReleaseTarget = { + id: string; + assetName: string; + libraryRelativePath: string; + runtimeRelativePath: string; +}; + +export function resolveLibraryPath(libraryPath?: string): string { + const resolved = resolveExplicitLibraryPath(libraryPath); + if (resolved === undefined || resolved.trim().length === 0) { + throw new Error( + 'no liboliphaunt native asset is available; pass libraryPath, set LIBOLIPHAUNT_PATH, or allow the SDK to resolve the compatible liboliphaunt release asset', + ); + } + return resolved; +} + +export function resolveExplicitLibraryPath(libraryPath?: string): string | undefined { + const resolved = libraryPath ?? envVar('LIBOLIPHAUNT_PATH'); + if (resolved === undefined || resolved.trim().length === 0) { + return undefined; + } + if (resolved.includes('\0')) { + throw new Error('libraryPath must not contain NUL bytes'); + } + return resolved; +} + +export function resolveExplicitRuntimeDirectory(): string | undefined { + const resolved = envVar(LIBOLIPHAUNT_RUNTIME_DIR_ENV); + if (resolved === undefined || resolved.trim().length === 0) { + return undefined; + } + if (resolved.includes('\0')) { + throw new Error(`${LIBOLIPHAUNT_RUNTIME_DIR_ENV} must not contain NUL bytes`); + } + return resolved; +} + +export function liboliphauntReleaseTag(version: string): string { + validateVersion(version); + return `${DEFAULT_LIBOLIPHAUNT_RELEASE_TAG_PREFIX}${version}`; +} + +export function liboliphauntReleaseAssetBaseUrl(version: string): string { + const override = envVar(LIBOLIPHAUNT_RELEASE_BASE_URL_ENV); + if (override !== undefined && override.trim().length > 0) { + return override.replace(/\/+$/, ''); + } + return `https://github.com/${DEFAULT_LIBOLIPHAUNT_REPOSITORY}/releases/download/${liboliphauntReleaseTag( + version, + )}`; +} + +export function liboliphauntChecksumAssetName(version: string): string { + validateVersion(version); + return `liboliphaunt-${version}-release-assets.sha256`; +} + +export function liboliphauntReleaseAssetUrl(version: string, assetName: string): string { + validateAssetName(assetName); + return `${liboliphauntReleaseAssetBaseUrl(version)}/${assetName}`; +} + +export function liboliphauntReleaseTarget( + version: string, + platform: string, + architecture: string, +): NativeReleaseTarget { + validateVersion(version); + const normalizedPlatform = normalizePlatform(platform); + const normalizedArch = normalizeArchitecture(architecture); + if (normalizedPlatform === 'darwin' && normalizedArch === 'arm64') { + return { + id: 'macos-arm64', + assetName: `liboliphaunt-${version}-macos-arm64.tar.gz`, + libraryRelativePath: 'lib/liboliphaunt.dylib', + runtimeRelativePath: 'runtime', + }; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'x64') { + return { + id: 'linux-x64-gnu', + assetName: `liboliphaunt-${version}-linux-x64-gnu.tar.gz`, + libraryRelativePath: 'lib/liboliphaunt.so', + runtimeRelativePath: 'runtime', + }; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'arm64') { + return { + id: 'linux-arm64-gnu', + assetName: `liboliphaunt-${version}-linux-arm64-gnu.tar.gz`, + libraryRelativePath: 'lib/liboliphaunt.so', + runtimeRelativePath: 'runtime', + }; + } + if (normalizedPlatform === 'windows' && normalizedArch === 'x64') { + return { + id: 'windows-x64-msvc', + assetName: `liboliphaunt-${version}-windows-x64-msvc.zip`, + libraryRelativePath: 'bin/oliphaunt.dll', + runtimeRelativePath: 'runtime', + }; + } + throw new Error( + `no liboliphaunt ${version} release asset is defined for ${platform}/${architecture}; pass libraryPath and runtimeDirectory explicitly for this platform`, + ); +} + +export function parseReleaseChecksumManifest(text: string): Map { + const checksums = new Map(); + for (const [index, rawLine] of text.split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (line.length === 0) { + continue; + } + const match = /^([0-9a-fA-F]{64})\s+\.\/*([^/\0][^\0]*)$/.exec(line); + if (match === null) { + throw new Error(`malformed release checksum line ${index + 1}: ${rawLine}`); + } + const digest = match[1]; + const asset = match[2]; + if (digest === undefined || asset === undefined) { + throw new Error(`malformed release checksum line ${index + 1}: ${rawLine}`); + } + checksums.set(asset, digest.toLowerCase()); + } + return checksums; +} + +export function checksumForReleaseAsset( + checksums: ReadonlyMap, + assetName: string, +): string { + validateAssetName(assetName); + const checksum = checksums.get(assetName); + if (checksum === undefined) { + throw new Error(`release checksum manifest does not cover ${assetName}`); + } + return checksum; +} + +export function assertSha256Matches(assetName: string, expected: string, actual: string): void { + if (expected.toLowerCase() !== actual.toLowerCase()) { + throw new Error( + `checksum mismatch for ${assetName}: expected ${expected.toLowerCase()}, got ${actual.toLowerCase()}`, + ); + } +} + +export function nativeBackupFormat(format: BackupFormat): number { + switch (format) { + case 'physicalArchive': + return 2; + case 'sql': + return 1; + case 'oliphauntArchive': + return 3; + } +} + +export function assertSupportedDirectBackupFormat(format: BackupFormat): void { + if (format !== 'physicalArchive') { + throw new Error(`${format} backup is not supported by nativeDirect`); + } +} + +export function errorMessage(prefix: string, status: number, lastError?: string | null): Error { + const detail = lastError && lastError.length > 0 ? lastError : `status ${status}`; + return new Error(`${prefix}: ${detail}`); +} + +export function envVar(name: string): string | undefined { + const processEnv = globalThis.process?.env?.[name]; + if (processEnv !== undefined) { + return processEnv; + } + const deno = (globalThis as { Deno?: { env?: { get(name: string): string | undefined } } }).Deno; + try { + return deno?.env?.get(name); + } catch { + return undefined; + } +} + +function normalizePlatform(platform: string): string { + switch (platform) { + case 'darwin': + case 'macos': + return 'darwin'; + case 'linux': + return 'linux'; + case 'win32': + case 'windows': + return 'windows'; + default: + return platform; + } +} + +function normalizeArchitecture(architecture: string): string { + switch (architecture) { + case 'arm64': + case 'aarch64': + return 'arm64'; + case 'x64': + case 'x86_64': + return 'x64'; + default: + return architecture; + } +} + +function validateVersion(version: string): void { + if (!/^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(version)) { + throw new Error(`invalid liboliphaunt release version '${version}'`); + } +} + +function validateAssetName(assetName: string): void { + if (!/^[A-Za-z0-9._+-]+$/.test(assetName) || assetName.includes('..')) { + throw new Error(`invalid liboliphaunt release asset name '${assetName}'`); + } +} diff --git a/src/sdks/js/src/native/default.ts b/src/sdks/js/src/native/default.ts new file mode 100644 index 00000000..c8ce1b7c --- /dev/null +++ b/src/sdks/js/src/native/default.ts @@ -0,0 +1,27 @@ +import type { NativeBinding, NativeBindingOptions } from './types.js'; + +export async function createDefaultNativeBinding( + options: NativeBindingOptions = {}, +): Promise { + if (isDeno()) { + const { createDenoNativeBinding } = await import('./deno.js'); + return createDenoNativeBinding(options); + } + if (isBun()) { + const { createBunNativeBinding } = await import('./bun.js'); + return createBunNativeBinding(options); + } + const { createNodeNativeBinding } = await import('./node.js'); + return createNodeNativeBinding(options); +} + +function isDeno(): boolean { + return ( + typeof (globalThis as { Deno?: { version?: { deno?: string } } }).Deno?.version?.deno === + 'string' + ); +} + +function isBun(): boolean { + return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'; +} diff --git a/src/sdks/js/src/native/deno.ts b/src/sdks/js/src/native/deno.ts new file mode 100644 index 00000000..ed4ca1af --- /dev/null +++ b/src/sdks/js/src/native/deno.ts @@ -0,0 +1,231 @@ +import { assertSupportedDirectBackupFormat, errorMessage, nativeBackupFormat } from './common.js'; +import { resolveDenoNativeInstall } from './assets-deno.js'; +import type { BackupFormat } from '../types.js'; +import { + packConfigPointers, + packRestoreOptionsPointers, + readResponseLength, + readResponsePointer, + responseBuffer, +} from './ffi-layout.js'; +import type { + NativeBinding, + NativeBindingOptions, + NativeHandle, + NativeOpenConfig, + NativeRestoreOptions, +} from './types.js'; + +type DenoPointer = object | null; +type DenoSymbols = { + oliphaunt_init: (...args: unknown[]) => unknown; + oliphaunt_exec_protocol: (...args: unknown[]) => unknown; + oliphaunt_exec_simple_query: (...args: unknown[]) => unknown; + oliphaunt_backup: (...args: unknown[]) => unknown; + oliphaunt_restore: (...args: unknown[]) => unknown; + oliphaunt_cancel: (...args: unknown[]) => unknown; + oliphaunt_detach: (...args: unknown[]) => unknown; + oliphaunt_last_error: (...args: unknown[]) => unknown; + oliphaunt_version: (...args: unknown[]) => unknown; + oliphaunt_capabilities: (...args: unknown[]) => unknown; + oliphaunt_free_response: (...args: unknown[]) => unknown; +}; + +export async function createDenoNativeBinding( + options: NativeBindingOptions = {}, +): Promise { + const deno = denoGlobal(); + const install = await resolveDenoNativeInstall(options.libraryPath); + const dylib = deno.dlopen(install.libraryPath, { + oliphaunt_init: { parameters: ['buffer', 'buffer'], result: 'i32' }, + oliphaunt_exec_protocol: { + parameters: ['pointer', 'buffer', 'usize', 'buffer'], + result: 'i32', + }, + oliphaunt_exec_simple_query: { + parameters: ['pointer', 'buffer', 'usize', 'buffer'], + result: 'i32', + }, + oliphaunt_backup: { parameters: ['pointer', 'u32', 'buffer'], result: 'i32' }, + oliphaunt_restore: { parameters: ['buffer'], result: 'i32' }, + oliphaunt_cancel: { parameters: ['pointer'], result: 'i32' }, + oliphaunt_detach: { parameters: ['pointer'], result: 'i32' }, + oliphaunt_last_error: { parameters: ['pointer'], result: 'pointer' }, + oliphaunt_version: { parameters: [], result: 'pointer' }, + oliphaunt_capabilities: { parameters: [], result: 'u64' }, + oliphaunt_free_response: { parameters: ['buffer'], result: 'void' }, + }); + const symbols = dylib.symbols as DenoSymbols; + + return { + runtime: 'deno', + rawProtocolTransport: 'deno-ffi', + protocolStream: false, + defaultRuntimeDirectory: install.runtimeDirectory, + version(): string { + return cString(deno, symbols.oliphaunt_version() as DenoPointer) ?? 'unknown'; + }, + capabilities(): bigint { + return BigInt(symbols.oliphaunt_capabilities() as bigint | number); + }, + open(config: NativeOpenConfig): NativeHandle { + const packed = packConfigPointers(config, (value) => pointerOf(deno, value)); + const out = new Uint8Array(8); + const rc = symbols.oliphaunt_init(packed.config, out) as number; + keepAlive(packed.keepAlive); + if (rc !== 0) { + throw errorMessage('native liboliphaunt init failed', rc, lastError(deno, symbols, null)); + } + const handle = pointerFromAddress(deno, readPointer(out)); + if (handle === null) { + throw new Error('native liboliphaunt init returned a null handle'); + } + return handle; + }, + execProtocolRaw(handle: NativeHandle, request: Uint8Array): Uint8Array { + const response = responseBuffer(); + const rc = symbols.oliphaunt_exec_protocol( + handle, + request, + BigInt(request.byteLength), + response, + ) as number; + if (rc !== 0) { + symbols.oliphaunt_free_response(response); + throw errorMessage( + 'native liboliphaunt protocol execution failed', + rc, + lastError(deno, symbols, handle), + ); + } + return copyResponse(deno, symbols, response); + }, + execSimpleQuery(handle: NativeHandle, sql: string): Uint8Array { + if (sql.includes('\0')) { + throw new Error('simple query SQL must not contain NUL bytes'); + } + const bytes = new TextEncoder().encode(sql); + const response = responseBuffer(); + const rc = symbols.oliphaunt_exec_simple_query( + handle, + bytes, + BigInt(bytes.byteLength), + response, + ) as number; + if (rc !== 0) { + symbols.oliphaunt_free_response(response); + throw errorMessage( + 'native liboliphaunt simple query failed', + rc, + lastError(deno, symbols, handle), + ); + } + return copyResponse(deno, symbols, response); + }, + backup(handle: NativeHandle, format: BackupFormat): Uint8Array { + assertSupportedDirectBackupFormat(format); + const response = responseBuffer(); + const rc = symbols.oliphaunt_backup(handle, nativeBackupFormat(format), response) as number; + if (rc !== 0) { + symbols.oliphaunt_free_response(response); + throw errorMessage( + 'native liboliphaunt backup failed', + rc, + lastError(deno, symbols, handle), + ); + } + return copyResponse(deno, symbols, response); + }, + restore(options: NativeRestoreOptions): void { + if (options.format !== 'physicalArchive') { + throw new Error( + `restore currently requires a physicalArchive artifact, got ${options.format}`, + ); + } + const packed = packRestoreOptionsPointers(options, (value) => pointerOf(deno, value)); + const rc = symbols.oliphaunt_restore(packed.options) as number; + keepAlive(packed.keepAlive); + if (rc !== 0) { + throw errorMessage( + 'native liboliphaunt restore failed', + rc, + lastError(deno, symbols, null), + ); + } + }, + cancel(handle: NativeHandle): void { + const rc = symbols.oliphaunt_cancel(handle) as number; + if (rc !== 0) { + throw errorMessage( + 'native liboliphaunt cancel failed', + rc, + lastError(deno, symbols, handle), + ); + } + }, + detach(handle: NativeHandle): void { + const rc = symbols.oliphaunt_detach(handle) as number; + if (rc !== 0) { + throw errorMessage( + 'native liboliphaunt detach failed', + rc, + lastError(deno, symbols, handle), + ); + } + }, + }; +} + +function denoGlobal(): any { + const deno = (globalThis as { Deno?: unknown }).Deno; + if (deno === undefined) { + throw new Error('Deno native binding can only be used inside Deno'); + } + return deno; +} + +function pointerOf(deno: any, value: Uint8Array): bigint { + const pointer = deno.UnsafePointer.of(value); + return pointer === null ? 0n : BigInt(deno.UnsafePointer.value(pointer)); +} + +function pointerFromAddress(deno: any, address: bigint): DenoPointer { + return address === 0n ? null : deno.UnsafePointer.create(address); +} + +function readPointer(value: Uint8Array): bigint { + return new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64(0, true); +} + +function copyResponse(deno: any, symbols: DenoSymbols, response: Uint8Array): Uint8Array { + try { + const data = readResponsePointer(response); + const length = readResponseLength(response); + if (data === 0n || length === 0) { + return new Uint8Array(); + } + const pointer = pointerFromAddress(deno, data); + if (pointer === null) { + return new Uint8Array(); + } + const view = new deno.UnsafePointerView(pointer); + return new Uint8Array(view.getArrayBuffer(length)).slice(); + } finally { + symbols.oliphaunt_free_response(response); + } +} + +function lastError(deno: any, symbols: DenoSymbols, handle: NativeHandle | null): string | null { + return cString(deno, symbols.oliphaunt_last_error(handle) as DenoPointer); +} + +function cString(deno: any, pointer: DenoPointer): string | null { + if (pointer === null) { + return null; + } + return new deno.UnsafePointerView(pointer).getCString(); +} + +function keepAlive(_values: ReadonlyArray): void { + // Values are referenced until the native call returns; liboliphaunt copies config strings. +} diff --git a/src/sdks/js/src/native/ffi-layout.ts b/src/sdks/js/src/native/ffi-layout.ts new file mode 100644 index 00000000..4e1a3966 --- /dev/null +++ b/src/sdks/js/src/native/ffi-layout.ts @@ -0,0 +1,118 @@ +import { ABI_VERSION, RESTORE_REPLACE_EXISTING, nativeBackupFormat } from './common.js'; +import type { NativeOpenConfig, NativeRestoreOptions } from './types.js'; + +export const POINTER_SIZE = 8; +export const OLIPHAUNT_CONFIG_SIZE = 64; +export const OLIPHAUNT_RESPONSE_SIZE = 16; +export const OLIPHAUNT_RESTORE_OPTIONS_SIZE = 48; + +const textEncoder = new TextEncoder(); + +export type PointerReader = (value: Uint8Array) => bigint; + +export function cString(value: string): Uint8Array { + if (value.includes('\0')) { + throw new Error('native C string must not contain NUL bytes'); + } + const bytes = textEncoder.encode(value); + const out = new Uint8Array(bytes.length + 1); + out.set(bytes); + return out; +} + +export function packPointerArray(pointers: ReadonlyArray): Uint8Array { + const out = new Uint8Array(Math.max(1, pointers.length) * POINTER_SIZE); + const view = new DataView(out.buffer); + for (const [index, pointer] of pointers.entries()) { + writePointer(view, index * POINTER_SIZE, pointer); + } + return out; +} + +export function packConfigPointers( + config: NativeOpenConfig, + pointerOf: PointerReader, +): { config: Uint8Array; keepAlive: Uint8Array[] } { + const pgdata = cString(config.pgdata); + const runtimeDirectory = config.runtimeDirectory ? cString(config.runtimeDirectory) : undefined; + const username = cString(config.username); + const database = cString(config.database); + const startupStrings = config.startupArgs.map(cString); + const startupPointerArray = packPointerArray(startupStrings.map(pointerOf)); + const out = new Uint8Array(OLIPHAUNT_CONFIG_SIZE); + const view = new DataView(out.buffer); + + view.setUint32(0, ABI_VERSION, true); + writePointer(view, 8, pointerOf(pgdata)); + writePointer(view, 16, runtimeDirectory ? pointerOf(runtimeDirectory) : 0n); + writePointer(view, 24, pointerOf(username)); + writePointer(view, 32, pointerOf(database)); + view.setBigUint64(40, 0n, true); + writePointer(view, 48, config.startupArgs.length > 0 ? pointerOf(startupPointerArray) : 0n); + writeSize(view, 56, config.startupArgs.length); + + return { + config: out, + keepAlive: [ + pgdata, + ...(runtimeDirectory ? [runtimeDirectory] : []), + username, + database, + ...startupStrings, + startupPointerArray, + ], + }; +} + +export function packRestoreOptionsPointers( + options: NativeRestoreOptions, + pointerOf: PointerReader, +): { options: Uint8Array; keepAlive: Uint8Array[] } { + const root = cString(options.root); + const out = new Uint8Array(OLIPHAUNT_RESTORE_OPTIONS_SIZE); + const view = new DataView(out.buffer); + + view.setUint32(0, ABI_VERSION, true); + writePointer(view, 8, pointerOf(root)); + view.setUint32(16, nativeBackupFormat(options.format), true); + writePointer(view, 24, options.bytes.byteLength > 0 ? pointerOf(options.bytes) : 0n); + writeSize(view, 32, options.bytes.byteLength); + view.setBigUint64(40, options.replaceExisting ? RESTORE_REPLACE_EXISTING : 0n, true); + + return { options: out, keepAlive: [root, options.bytes] }; +} + +export function responseBuffer(): Uint8Array { + return new Uint8Array(OLIPHAUNT_RESPONSE_SIZE); +} + +export function readResponsePointer(response: Uint8Array): bigint { + return readPointer(new DataView(response.buffer, response.byteOffset, response.byteLength), 0); +} + +export function readResponseLength(response: Uint8Array): number { + return readSize(new DataView(response.buffer, response.byteOffset, response.byteLength), 8); +} + +export function readPointer(view: DataView, offset: number): bigint { + return view.getBigUint64(offset, true); +} + +export function writePointer(view: DataView, offset: number, value: bigint): void { + view.setBigUint64(offset, value, true); +} + +export function readSize(view: DataView, offset: number): number { + const value = view.getBigUint64(offset, true); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`native size_t value ${value} exceeds JavaScript safe integer range`); + } + return Number(value); +} + +function writeSize(view: DataView, offset: number, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`invalid native size_t value ${value}`); + } + view.setBigUint64(offset, BigInt(value), true); +} diff --git a/src/sdks/js/src/native/node-addon.ts b/src/sdks/js/src/native/node-addon.ts new file mode 100644 index 00000000..5d98c53f --- /dev/null +++ b/src/sdks/js/src/native/node-addon.ts @@ -0,0 +1,469 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { arch, homedir, platform, tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync, inflateRawSync } from 'node:zlib'; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; + +import type { NativeHandle, NativeOpenConfig } from './types.js'; +import { + assertSha256Matches, + checksumForReleaseAsset, + envVar, + LIBOLIPHAUNT_CACHE_DIR_ENV, + parseReleaseChecksumManifest, +} from './common.js'; +import { extractTarArchive } from './tar.js'; +import { extractZipArchive } from './zip.js'; + +export type NodeDirectAddon = { + version(libraryPath: string): string; + capabilities(libraryPath: string): bigint | number; + open(config: NodeDirectOpenConfig): NativeHandle; + execProtocolRaw(handle: NativeHandle, request: Uint8Array): Uint8Array | ArrayBuffer; + execSimpleQuery(handle: NativeHandle, sql: string): Uint8Array | ArrayBuffer; + execProtocolStream( + handle: NativeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array | ArrayBuffer) => void, + ): void; + backup(handle: NativeHandle, format: number): Uint8Array | ArrayBuffer; + restore(options: NodeDirectRestoreOptions): void; + cancel(handle: NativeHandle): void; + detach(handle: NativeHandle): void; +}; + +export type NodeDirectOpenConfig = NativeOpenConfig & { + libraryPath: string; +}; + +export type NodeDirectRestoreOptions = { + libraryPath: string; + root: string; + format: number; + bytes: Uint8Array; + replaceExisting: boolean; +}; + +type PackageMetadata = { + name?: string; + version?: string; + oliphaunt?: { + nodeDirectAddon?: string; + nodeDirectAddonVersion?: string; + }; +}; + +type NodeAddonReleaseTarget = { + id: string; + assetName: string; + addonRelativePath: string; + packageName: string; +}; + +type InstallMarker = { + version: string; + asset: string; + checksum: string; +}; + +const require = createRequire(import.meta.url); +const NODE_ADDON_ENV = 'OLIPHAUNT_NODE_ADDON'; +const NODE_ADDON_ASSET_DIR_ENV = 'OLIPHAUNT_NODE_ADDON_ASSET_DIR'; +const NODE_ADDON_RELEASE_BASE_URL_ENV = 'OLIPHAUNT_NODE_DIRECT_RELEASE_BASE_URL'; +const RELEASE_REPOSITORY = 'f0rr0/oliphaunt'; +const RELEASE_TAG_PREFIX = 'oliphaunt-node-direct-v'; +const ADDON_STEM = 'oliphaunt_node'; + +export async function loadNodeDirectAddon(explicitPath?: string): Promise { + const addonPath = await resolveNodeDirectAddonPath(explicitPath); + const loaded = require(addonPath) as { default?: unknown } | unknown; + const addon = normalizeAddon(loaded); + validateAddon(addon, addonPath); + return addon; +} + +async function resolveNodeDirectAddonPath(explicitPath?: string): Promise { + const explicit = explicitPath ?? envVar(NODE_ADDON_ENV); + if (explicit !== undefined && explicit.trim().length > 0) { + if (explicit.includes('\0')) { + throw new Error(`${NODE_ADDON_ENV} must not contain NUL bytes`); + } + const resolved = resolve(explicit); + await requireFile(resolved, NODE_ADDON_ENV); + return resolved; + } + + for (const candidate of packageAdjacentAddons()) { + if (await isFile(candidate)) { + return candidate; + } + } + for (const candidate of optionalDependencyAddons()) { + if (await isFile(candidate)) { + return candidate; + } + } + return resolveNodeDirectAddonInstall(); +} + +async function resolveNodeDirectAddonInstall(): Promise { + const version = await packageNodeDirectAddonVersion(); + const target = nodeAddonReleaseTarget(version, platform(), arch()); + const installRoot = join(cacheRoot(), 'oliphaunt-node-direct', version, target.id); + const addonPath = join(installRoot, target.addonRelativePath); + const release = await acquireInstallLock(`${installRoot}.lock`); + try { + if (await validateExistingInstall(addonPath, version, target.assetName)) { + return addonPath; + } + const checksums = parseReleaseChecksumManifest( + new TextDecoder().decode(await readReleaseAssetBytes(version, checksumAssetName(version))), + ); + const expectedChecksum = checksumForReleaseAsset(checksums, target.assetName); + const archive = await readReleaseAssetBytes(version, target.assetName); + assertSha256Matches(target.assetName, expectedChecksum, sha256Hex(archive)); + await installArchive(archive, installRoot, { + version, + asset: target.assetName, + checksum: expectedChecksum, + }); + return addonPath; + } finally { + await release(); + } +} + +function nodeAddonReleaseTarget( + version: string, + currentPlatform: string, + currentArch: string, +): NodeAddonReleaseTarget { + validateVersion(version); + const normalizedPlatform = normalizePlatform(currentPlatform); + const normalizedArch = normalizeArchitecture(currentArch); + if (normalizedPlatform === 'darwin' && normalizedArch === 'arm64') { + return { + id: 'macos-arm64', + assetName: `oliphaunt-node-direct-${version}-macos-arm64.tar.gz`, + addonRelativePath: `${ADDON_STEM}.node`, + packageName: '@oliphaunt/node-direct-darwin-arm64', + }; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'x64') { + return { + id: 'linux-x64-gnu', + assetName: `oliphaunt-node-direct-${version}-linux-x64-gnu.tar.gz`, + addonRelativePath: `${ADDON_STEM}.node`, + packageName: '@oliphaunt/node-direct-linux-x64-gnu', + }; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'arm64') { + return { + id: 'linux-arm64-gnu', + assetName: `oliphaunt-node-direct-${version}-linux-arm64-gnu.tar.gz`, + addonRelativePath: `${ADDON_STEM}.node`, + packageName: '@oliphaunt/node-direct-linux-arm64-gnu', + }; + } + if (normalizedPlatform === 'windows' && normalizedArch === 'x64') { + return { + id: 'windows-x64-msvc', + assetName: `oliphaunt-node-direct-${version}-windows-x64-msvc.zip`, + addonRelativePath: `${ADDON_STEM}.node`, + packageName: '@oliphaunt/node-direct-win32-x64-msvc', + }; + } + throw new Error( + `no Oliphaunt Node.js native-direct adapter ${version} release asset is defined for ${currentPlatform}/${currentArch}; pass nodeAddonPath or set ${NODE_ADDON_ENV}`, + ); +} + +async function packageNodeDirectAddonVersion(): Promise { + const packageJson = JSON.parse( + await readFile(require.resolve('@oliphaunt/ts/package.json'), 'utf8'), + ) as PackageMetadata; + const version = packageJson.oliphaunt?.nodeDirectAddonVersion; + if ( + packageJson.name !== '@oliphaunt/ts' || + version === undefined || + version.length === 0 || + packageJson.oliphaunt?.nodeDirectAddon !== 'oliphaunt-node-direct' + ) { + throw new Error('@oliphaunt/ts package metadata does not pin nodeDirectAddonVersion'); + } + return version; +} + +async function readReleaseAssetBytes(version: string, assetName: string): Promise { + const localAssetDir = envVar(NODE_ADDON_ASSET_DIR_ENV); + if (localAssetDir !== undefined && localAssetDir.trim().length > 0) { + return Uint8Array.from(await readFile(join(localAssetDir, assetName))); + } + const response = await fetch(nodeDirectAddonReleaseAssetUrl(version, assetName)); + if (!response.ok) { + throw new Error( + `download ${nodeDirectAddonReleaseAssetUrl(version, assetName)} failed with HTTP ${response.status}`, + ); + } + return new Uint8Array(await response.arrayBuffer()); +} + +export function nodeDirectAddonReleaseAssetUrl(version: string, assetName: string): string { + validateVersion(version); + validateAssetName(assetName); + const override = envVar(NODE_ADDON_RELEASE_BASE_URL_ENV); + const base = + override !== undefined && override.trim().length > 0 + ? override.replace(/\/+$/, '') + : `https://github.com/${RELEASE_REPOSITORY}/releases/download/${RELEASE_TAG_PREFIX}${version}`; + return `${base}/${assetName}`; +} + +function checksumAssetName(version: string): string { + validateVersion(version); + return `oliphaunt-node-direct-${version}-release-assets.sha256`; +} + +async function validateExistingInstall( + addonPath: string, + version: string, + assetName: string, +): Promise { + const markerPath = join(dirname(addonPath), '.oliphaunt-node-direct-install.json'); + try { + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as InstallMarker; + const addonStat = await stat(addonPath); + return ( + marker.version === version && + marker.asset === assetName && + marker.checksum.length === 64 && + addonStat.isFile() + ); + } catch { + return false; + } +} + +async function installArchive( + archive: Uint8Array, + installRoot: string, + marker: InstallMarker, +): Promise { + const parent = dirname(installRoot); + const scratch = join( + parent, + `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await rm(scratch, { recursive: true, force: true }); + await mkdir(scratch, { recursive: true }); + try { + if (marker.asset.endsWith('.zip')) { + await extractZipArchive(archive, scratch, archiveExtractionHost(), (bytes) => + Uint8Array.from(inflateRawSync(bytes)), + ); + } else { + await extractTarArchive( + Uint8Array.from(gunzipSync(archive)), + scratch, + archiveExtractionHost(), + ); + } + await writeFile( + join(scratch, '.oliphaunt-node-direct-install.json'), + `${JSON.stringify(marker, null, 2)}\n`, + 'utf8', + ); + await rm(installRoot, { recursive: true, force: true }); + await rename(scratch, installRoot); + } catch (error) { + await rm(scratch, { recursive: true, force: true }); + throw error; + } +} + +function archiveExtractionHost() { + return { + join, + dirname, + async mkdir(path: string) { + await mkdir(path, { recursive: true }); + }, + async writeFile(file: { path: string; bytes: Uint8Array; mode: number }) { + await writeFile(file.path, file.bytes, { mode: file.mode }); + await chmod(file.path, file.mode); + }, + }; +} + +function packageAdjacentAddons(): string[] { + const here = dirname(fileURLToPath(import.meta.url)); + return [ + join(here, `${ADDON_STEM}.node`), + join(here, '..', `${ADDON_STEM}.node`), + resolve(process.cwd(), `${ADDON_STEM}.node`), + ]; +} + +function optionalDependencyAddons(): string[] { + const target = optionalNodeDirectPackage(platform(), arch()); + if (target === undefined) { + return []; + } + try { + return [require.resolve(`${target}/oliphaunt_node.node`)]; + } catch { + return []; + } +} + +function optionalNodeDirectPackage( + currentPlatform: string, + currentArch: string, +): string | undefined { + const normalizedPlatform = normalizePlatform(currentPlatform); + const normalizedArch = normalizeArchitecture(currentArch); + if (normalizedPlatform === 'darwin' && normalizedArch === 'arm64') { + return '@oliphaunt/node-direct-darwin-arm64'; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'x64') { + return '@oliphaunt/node-direct-linux-x64-gnu'; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'arm64') { + return '@oliphaunt/node-direct-linux-arm64-gnu'; + } + if (normalizedPlatform === 'windows' && normalizedArch === 'x64') { + return '@oliphaunt/node-direct-win32-x64-msvc'; + } + return undefined; +} + +async function requireFile(path: string, source: string): Promise { + if (!(await isFile(path))) { + throw new Error(`${source} does not point to an existing file: ${path}`); + } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +function normalizeAddon(loaded: unknown): NodeDirectAddon { + const maybeDefault = loaded as { default?: unknown }; + return (maybeDefault.default ?? loaded) as NodeDirectAddon; +} + +function validateAddon(addon: NodeDirectAddon, addonPath: string): void { + for (const name of [ + 'version', + 'capabilities', + 'open', + 'execProtocolRaw', + 'execSimpleQuery', + 'execProtocolStream', + 'backup', + 'restore', + 'cancel', + 'detach', + ] as const) { + if (typeof addon[name] !== 'function') { + throw new Error(`Oliphaunt Node.js native-direct adapter ${addonPath} is missing ${name}()`); + } + } +} + +function cacheRoot(): string { + const override = envVar(LIBOLIPHAUNT_CACHE_DIR_ENV); + if (override !== undefined && override.trim().length > 0) { + return override; + } + if (platform() === 'darwin') { + return join(homedir(), 'Library', 'Caches', 'oliphaunt'); + } + const xdgCache = envVar('XDG_CACHE_HOME'); + if (xdgCache !== undefined && xdgCache.trim().length > 0) { + return join(xdgCache, 'oliphaunt'); + } + return join(homedir() || tmpdir(), '.cache', 'oliphaunt'); +} + +async function acquireInstallLock(path: string): Promise<() => Promise> { + await mkdir(dirname(path), { recursive: true }); + for (let attempt = 0; attempt < 600; attempt += 1) { + try { + await writeFile(path, `${process.pid}\n`, { flag: 'wx' }); + return async () => { + await rm(path, { force: true }); + }; + } catch (error) { + if (isFileExistsError(error)) { + await sleep(100); + continue; + } + throw error; + } + } + throw new Error(`timed out waiting for Oliphaunt Node.js adapter install lock ${path}`); +} + +function isFileExistsError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'EEXIST' + ); +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function normalizePlatform(value: string): string { + switch (value) { + case 'darwin': + case 'macos': + return 'darwin'; + case 'linux': + return 'linux'; + case 'win32': + case 'windows': + return 'windows'; + default: + return value; + } +} + +function normalizeArchitecture(value: string): string { + switch (value) { + case 'arm64': + case 'aarch64': + return 'arm64'; + case 'x64': + case 'x86_64': + return 'x64'; + default: + return value; + } +} + +function validateVersion(version: string): void { + if (!/^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(version)) { + throw new Error(`invalid Oliphaunt Node direct release version '${version}'`); + } +} + +function validateAssetName(assetName: string): void { + if (!/^[A-Za-z0-9._+-]+$/.test(assetName) || assetName.includes('..')) { + throw new Error(`invalid Oliphaunt Node direct release asset name '${assetName}'`); + } +} diff --git a/src/sdks/js/src/native/node.ts b/src/sdks/js/src/native/node.ts new file mode 100644 index 00000000..12243926 --- /dev/null +++ b/src/sdks/js/src/native/node.ts @@ -0,0 +1,79 @@ +import { assertSupportedDirectBackupFormat, nativeBackupFormat } from './common.js'; +import { loadNodeDirectAddon } from './node-addon.js'; +import { resolveNodeNativeInstall } from './assets-node.js'; +import type { BackupFormat } from '../types.js'; +import type { + NativeBinding, + NativeBindingOptions, + NativeHandle, + NativeOpenConfig, + NativeRestoreOptions, +} from './types.js'; + +export async function createNodeNativeBinding( + options: NativeBindingOptions = {}, +): Promise { + const install = await resolveNodeNativeInstall(options.libraryPath); + const addon = await loadNodeDirectAddon(options.nodeAddonPath); + + return { + runtime: 'node', + rawProtocolTransport: 'node-addon', + protocolStream: true, + defaultRuntimeDirectory: install.runtimeDirectory, + version(): string { + return addon.version(install.libraryPath); + }, + capabilities(): bigint { + return BigInt(addon.capabilities(install.libraryPath)); + }, + open(config: NativeOpenConfig): NativeHandle { + return addon.open({ + ...config, + libraryPath: install.libraryPath, + runtimeDirectory: config.runtimeDirectory ?? install.runtimeDirectory, + }); + }, + execProtocolRaw(handle: NativeHandle, request: Uint8Array): Uint8Array { + return toUint8Array(addon.execProtocolRaw(handle, request)); + }, + execSimpleQuery(handle: NativeHandle, sql: string): Uint8Array { + return toUint8Array(addon.execSimpleQuery(handle, sql)); + }, + execProtocolStream( + handle: NativeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): void { + addon.execProtocolStream(handle, request, (chunk) => onChunk(toUint8Array(chunk))); + }, + backup(handle: NativeHandle, format: BackupFormat): Uint8Array { + assertSupportedDirectBackupFormat(format); + return toUint8Array(addon.backup(handle, nativeBackupFormat(format))); + }, + restore(options: NativeRestoreOptions): void { + if (options.format !== 'physicalArchive') { + throw new Error( + `restore currently requires a physicalArchive artifact, got ${options.format}`, + ); + } + addon.restore({ + libraryPath: install.libraryPath, + root: options.root, + format: nativeBackupFormat(options.format), + bytes: options.bytes, + replaceExisting: options.replaceExisting, + }); + }, + cancel(handle: NativeHandle): void { + addon.cancel(handle); + }, + detach(handle: NativeHandle): void { + addon.detach(handle); + }, + }; +} + +function toUint8Array(value: Uint8Array | ArrayBuffer): Uint8Array { + return value instanceof Uint8Array ? value : new Uint8Array(value); +} diff --git a/src/sdks/js/src/native/runtime-ambient.d.ts b/src/sdks/js/src/native/runtime-ambient.d.ts new file mode 100644 index 00000000..9d7a6224 --- /dev/null +++ b/src/sdks/js/src/native/runtime-ambient.d.ts @@ -0,0 +1,23 @@ +declare module 'bun:ffi' { + export const FFIType: { + readonly buffer: unknown; + readonly cstring: unknown; + readonly i32: unknown; + readonly ptr: unknown; + readonly u32: unknown; + readonly u64: unknown; + readonly void: unknown; + }; + + export function dlopen( + path: string, + symbols: Record, + ): { symbols: Record unknown> }; + + export function ptr(value: Uint8Array): number | bigint; + export function toArrayBuffer( + pointer: number, + byteOffset: number, + byteLength: number, + ): ArrayBuffer; +} diff --git a/src/sdks/js/src/native/tar.ts b/src/sdks/js/src/native/tar.ts new file mode 100644 index 00000000..3ef47653 --- /dev/null +++ b/src/sdks/js/src/native/tar.ts @@ -0,0 +1,158 @@ +type TarWriteFile = { + path: string; + bytes: Uint8Array; + mode: number; +}; + +export type TarExtractHost = { + join(base: string, relative: string): string; + dirname(path: string): string; + mkdir(path: string): Promise; + writeFile(file: TarWriteFile): Promise; +}; + +const BLOCK_SIZE = 512; +const textDecoder = new TextDecoder(); + +export async function extractTarArchive( + archive: Uint8Array, + destination: string, + host: TarExtractHost, +): Promise { + let offset = 0; + let nextPax: Record | undefined; + let globalPax: Record = {}; + let nextLongName: string | undefined; + + while (offset + BLOCK_SIZE <= archive.byteLength) { + const header = archive.subarray(offset, offset + BLOCK_SIZE); + offset += BLOCK_SIZE; + if (isZeroBlock(header)) { + break; + } + + const type = String.fromCharCode(header[156] ?? 0).replace('\0', '') || '0'; + const size = parseOctal(header.subarray(124, 136), 'tar entry size'); + const mode = parseOctal(header.subarray(100, 108), 'tar entry mode') || 0o644; + const payloadStart = offset; + const payloadEnd = payloadStart + size; + if (payloadEnd > archive.byteLength) { + throw new Error('tar archive ended in the middle of an entry payload'); + } + const payload = archive.subarray(payloadStart, payloadEnd); + offset = payloadStart + roundUpToBlock(size); + + if (type === 'x') { + nextPax = parsePaxPayload(payload); + continue; + } + if (type === 'g') { + globalPax = { ...globalPax, ...parsePaxPayload(payload) }; + continue; + } + if (type === 'L') { + nextLongName = decodeTarString(payload).replace(/\0+$/, ''); + continue; + } + + const pax = { ...globalPax, ...(nextPax ?? {}) }; + nextPax = undefined; + const relativePath = sanitizeTarPath(pax.path ?? nextLongName ?? tarHeaderPath(header)); + nextLongName = undefined; + if (relativePath === undefined) { + continue; + } + + const outputPath = host.join(destination, relativePath); + if (type === '5') { + await host.mkdir(outputPath); + continue; + } + if (type !== '0') { + throw new Error(`unsupported tar entry type '${type}' for ${relativePath}`); + } + await host.mkdir(host.dirname(outputPath)); + await host.writeFile({ + path: outputPath, + bytes: payload.slice(), + mode: mode & 0o777, + }); + } +} + +function isZeroBlock(block: Uint8Array): boolean { + for (const byte of block) { + if (byte !== 0) { + return false; + } + } + return true; +} + +function roundUpToBlock(size: number): number { + return Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE; +} + +function parseOctal(bytes: Uint8Array, label: string): number { + const text = decodeTarString(bytes).replace(/\0.*$/, '').trim(); + if (text.length === 0) { + return 0; + } + if (!/^[0-7]+$/.test(text)) { + throw new Error(`${label} is not an octal tar field`); + } + const value = Number.parseInt(text, 8); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} is outside the safe integer range`); + } + return value; +} + +function tarHeaderPath(header: Uint8Array): string { + const name = decodeTarString(header.subarray(0, 100)).replace(/\0.*$/, ''); + const prefix = decodeTarString(header.subarray(345, 500)).replace(/\0.*$/, ''); + return prefix.length > 0 ? `${prefix}/${name}` : name; +} + +function sanitizeTarPath(path: string): string | undefined { + const normalized = path.replace(/\\/g, '/').replace(/^\.\/+/, ''); + if (normalized.length === 0 || normalized === '.') { + return undefined; + } + if (normalized.startsWith('/')) { + throw new Error(`tar entry path must be relative: ${path}`); + } + const segments = normalized.split('/').filter((segment) => segment.length > 0); + if (segments.some((segment) => segment === '.' || segment === '..')) { + throw new Error(`tar entry path escapes the install root: ${path}`); + } + return segments.join('/'); +} + +function parsePaxPayload(payload: Uint8Array): Record { + const text = decodeTarString(payload); + const values: Record = {}; + let offset = 0; + while (offset < text.length) { + const space = text.indexOf(' ', offset); + if (space < 0) { + throw new Error('malformed pax header record'); + } + const length = Number.parseInt(text.slice(offset, space), 10); + if (!Number.isSafeInteger(length) || length <= 0) { + throw new Error('invalid pax header record length'); + } + const record = text.slice(space + 1, offset + length); + const equals = record.indexOf('='); + if (equals <= 0 || !record.endsWith('\n')) { + throw new Error('malformed pax header key/value record'); + } + values[record.slice(0, equals)] = record.slice(equals + 1, -1); + offset += length; + } + return values; +} + +function decodeTarString(bytes: Uint8Array): string { + return textDecoder.decode(bytes); +} diff --git a/src/sdks/js/src/native/types.ts b/src/sdks/js/src/native/types.ts new file mode 100644 index 00000000..76236c12 --- /dev/null +++ b/src/sdks/js/src/native/types.ts @@ -0,0 +1,45 @@ +import type { BackupFormat, JavaScriptRuntime, RawProtocolTransport } from '../types.js'; + +export type NativeBindingOptions = { + libraryPath?: string; + nodeAddonPath?: string; +}; + +export type NativeOpenConfig = { + pgdata: string; + runtimeDirectory?: string; + username: string; + database: string; + startupArgs: string[]; +}; + +export type NativeRestoreOptions = { + root: string; + format: BackupFormat; + bytes: Uint8Array; + replaceExisting: boolean; +}; + +export type NativeHandle = unknown; +export type MaybePromise = T | Promise; + +export type NativeBinding = { + runtime: JavaScriptRuntime; + rawProtocolTransport: RawProtocolTransport; + protocolStream: boolean; + defaultRuntimeDirectory?: string; + version(): MaybePromise; + capabilities(): MaybePromise; + open(config: NativeOpenConfig): MaybePromise; + execProtocolRaw(handle: NativeHandle, request: Uint8Array): MaybePromise; + execSimpleQuery?(handle: NativeHandle, sql: string): MaybePromise; + execProtocolStream?( + handle: NativeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): MaybePromise; + backup(handle: NativeHandle, format: BackupFormat): MaybePromise; + restore(options: NativeRestoreOptions): MaybePromise; + cancel(handle: NativeHandle): MaybePromise; + detach(handle: NativeHandle): MaybePromise; +}; diff --git a/src/sdks/js/src/native/zip.ts b/src/sdks/js/src/native/zip.ts new file mode 100644 index 00000000..73b2da51 --- /dev/null +++ b/src/sdks/js/src/native/zip.ts @@ -0,0 +1,139 @@ +export type ZipFile = { + path: string; + bytes: Uint8Array; + mode: number; +}; + +export type ZipExtractionHost = { + join(root: string, path: string): string; + dirname(path: string): string; + mkdir(path: string): Promise; + writeFile(file: ZipFile): Promise; +}; + +export async function extractZipArchive( + bytes: Uint8Array, + root: string, + host: ZipExtractionHost, + inflateRaw: (bytes: Uint8Array) => Uint8Array | Promise, +): Promise { + const eocdOffset = findEndOfCentralDirectory(bytes); + const entries = readUInt16LE(bytes, eocdOffset + 10); + const centralDirectorySize = readUInt32LE(bytes, eocdOffset + 12); + const centralDirectoryOffset = readUInt32LE(bytes, eocdOffset + 16); + if (centralDirectoryOffset + centralDirectorySize > bytes.length) { + throw new Error('ZIP central directory is outside archive bounds'); + } + + let offset = centralDirectoryOffset; + for (let index = 0; index < entries; index += 1) { + requireSignature(bytes, offset, 0x02014b50, 'central directory header'); + const method = readUInt16LE(bytes, offset + 10); + const compressedSize = readUInt32LE(bytes, offset + 20); + const uncompressedSize = readUInt32LE(bytes, offset + 24); + const nameLength = readUInt16LE(bytes, offset + 28); + const extraLength = readUInt16LE(bytes, offset + 30); + const commentLength = readUInt16LE(bytes, offset + 32); + const externalAttributes = readUInt32LE(bytes, offset + 38); + const localOffset = readUInt32LE(bytes, offset + 42); + const nameStart = offset + 46; + const nameEnd = nameStart + nameLength; + if (nameEnd > bytes.length) { + throw new Error('ZIP entry name is outside archive bounds'); + } + const path = new TextDecoder().decode(bytes.subarray(nameStart, nameEnd)); + const mode = (externalAttributes >>> 16) & 0o777 || 0o644; + offset = nameEnd + extraLength + commentLength; + + const safePath = validateZipPath(path); + if (safePath === undefined || safePath === '.') { + continue; + } + if (safePath.endsWith('/')) { + await host.mkdir(host.join(root, safePath.slice(0, -1))); + continue; + } + + requireSignature(bytes, localOffset, 0x04034b50, 'local file header'); + const localNameLength = readUInt16LE(bytes, localOffset + 26); + const localExtraLength = readUInt16LE(bytes, localOffset + 28); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const dataEnd = dataStart + compressedSize; + if (dataEnd > bytes.length) { + throw new Error(`ZIP entry ${safePath} data is outside archive bounds`); + } + const compressed = bytes.subarray(dataStart, dataEnd); + const content = + method === 0 ? compressed : method === 8 ? await inflateRaw(compressed) : undefined; + if (content === undefined) { + throw new Error(`ZIP entry ${safePath} uses unsupported compression method ${method}`); + } + if (content.length !== uncompressedSize) { + throw new Error(`ZIP entry ${safePath} has invalid uncompressed size`); + } + const output = host.join(root, safePath); + await host.mkdir(host.dirname(output)); + await host.writeFile({ path: output, bytes: content, mode }); + } +} + +function findEndOfCentralDirectory(bytes: Uint8Array): number { + const minimumOffset = Math.max(0, bytes.length - 65_557); + for (let offset = bytes.length - 22; offset >= minimumOffset; offset -= 1) { + if (readUInt32LE(bytes, offset) === 0x06054b50) { + return offset; + } + } + throw new Error('ZIP end of central directory was not found'); +} + +function validateZipPath(path: string): string | undefined { + if (path.length === 0 || path.includes('\0') || path.startsWith('/') || path.includes('\\')) { + throw new Error(`unsafe ZIP entry path: ${path}`); + } + const parts: string[] = []; + for (const rawPart of path.split('/')) { + if (rawPart.length === 0 || rawPart === '.') { + continue; + } + if (rawPart === '..') { + throw new Error(`unsafe ZIP entry path: ${path}`); + } + parts.push(rawPart); + } + if (parts.length === 0) { + return undefined; + } + return `${parts.join('/')}${path.endsWith('/') ? '/' : ''}`; +} + +function requireSignature( + bytes: Uint8Array, + offset: number, + signature: number, + label: string, +): void { + if (offset < 0 || offset + 4 > bytes.length || readUInt32LE(bytes, offset) !== signature) { + throw new Error(`invalid ZIP ${label}`); + } +} + +function readUInt16LE(bytes: Uint8Array, offset: number): number { + if (offset < 0 || offset + 2 > bytes.length) { + throw new Error('truncated ZIP archive'); + } + return bytes[offset]! | (bytes[offset + 1]! << 8); +} + +function readUInt32LE(bytes: Uint8Array, offset: number): number { + if (offset < 0 || offset + 4 > bytes.length) { + throw new Error('truncated ZIP archive'); + } + return ( + (bytes[offset]! | + (bytes[offset + 1]! << 8) | + (bytes[offset + 2]! << 16) | + (bytes[offset + 3]! << 24)) >>> + 0 + ); +} diff --git a/src/sdks/js/src/protocol.ts b/src/sdks/js/src/protocol.ts new file mode 100644 index 00000000..fe1ccd98 --- /dev/null +++ b/src/sdks/js/src/protocol.ts @@ -0,0 +1,20 @@ +export function simpleQuery(sql: string): Uint8Array { + if (sql.includes('\0')) { + throw new Error('simple query SQL must not contain NUL bytes'); + } + const encoder = new TextEncoder(); + const body = encoder.encode(sql); + const packet = new Uint8Array(body.length + 6); + packet[0] = 'Q'.charCodeAt(0); + writeI32(packet, 1, body.length + 5); + packet.set(body, 5); + packet[packet.length - 1] = 0; + return packet; +} + +function writeI32(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = (value >>> 24) & 0xff; + bytes[offset + 1] = (value >>> 16) & 0xff; + bytes[offset + 2] = (value >>> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} diff --git a/src/sdks/js/src/query.ts b/src/sdks/js/src/query.ts new file mode 100644 index 00000000..7849f5d8 --- /dev/null +++ b/src/sdks/js/src/query.ts @@ -0,0 +1,656 @@ +import { simpleQuery } from './protocol.js'; + +export type QueryBinaryInput = ArrayBuffer | ArrayBufferView | Uint8Array | ReadonlyArray; + +export type QueryParam = + | null + | string + | number + | boolean + | QueryBinaryInput + | { format: 'text'; value: string | number | boolean } + | { format: 'binary'; value: QueryBinaryInput }; + +export type QueryFormat = 'text' | 'binary' | { code: number; kind: 'other' }; + +export type QueryField = { + name: string; + tableOid: number; + tableAttribute: number; + typeOid: number; + typeSize: number; + typeModifier: number; + format: QueryFormat; +}; + +export type QueryRow = { + values: Array; + text(column: number): string | null; +}; + +export type QueryResult = { + fields: QueryField[]; + rows: QueryRow[]; + commandTag?: string; + rowCount: number; + fieldIndex(name: string): number | undefined; + getText(row: number, column: string): string | null; +}; + +export { simpleQuery }; + +export type PostgresErrorField = { + code: number; + value: string; +}; + +export class PostgresError extends Error { + readonly severity?: string; + readonly sqlstate?: string; + readonly detail?: string; + readonly hint?: string; + readonly position?: string; + readonly whereText?: string; + readonly schemaName?: string; + readonly tableName?: string; + readonly columnName?: string; + readonly dataTypeName?: string; + readonly constraintName?: string; + readonly fields: PostgresErrorField[]; + readonly postgresMessage: string; + + constructor(fields: PostgresErrorField[]) { + const severity = fieldValue(fields, 0x53) ?? fieldValue(fields, 0x56); + const sqlstate = fieldValue(fields, 0x43); + const postgresMessage = fieldValue(fields, 0x4d) ?? 'PostgreSQL ErrorResponse'; + super(formatPostgresError(severity, sqlstate, postgresMessage)); + this.name = 'PostgresError'; + this.severity = severity; + this.sqlstate = sqlstate; + this.postgresMessage = postgresMessage; + this.detail = fieldValue(fields, 0x44); + this.hint = fieldValue(fields, 0x48); + this.position = fieldValue(fields, 0x50); + this.whereText = fieldValue(fields, 0x57); + this.schemaName = fieldValue(fields, 0x73); + this.tableName = fieldValue(fields, 0x74); + this.columnName = fieldValue(fields, 0x63); + this.dataTypeName = fieldValue(fields, 0x64); + this.constraintName = fieldValue(fields, 0x6e); + this.fields = fields; + } + + static fallback(): PostgresError { + return new PostgresError([{ code: 0x4d, value: 'PostgreSQL ErrorResponse' }]); + } +} + +export function extendedQuery(sql: string, parameters: ReadonlyArray): Uint8Array { + if (parameters.length > 0x7fff) { + throw new Error( + `extended query supports at most ${0x7fff} parameters, got ${parameters.length}`, + ); + } + if (sql.includes('\0')) { + throw new Error('extended query SQL must not contain NUL bytes'); + } + + const packet: number[] = []; + pushParse(packet, sql); + pushBind(packet, parameters.map(normalizeQueryParam)); + pushDescribePortal(packet); + pushExecute(packet); + pushFrontendMessage(packet, 0x53, []); + return Uint8Array.from(packet); +} + +export function parseQueryResponse(bytes: Uint8Array): QueryResult { + const cursor = new ByteCursor(bytes); + let fields: QueryField[] | undefined; + const rows: QueryRow[] = []; + let commandTag: string | undefined; + let sawReady = false; + + while (!cursor.isAtEnd()) { + const tag = cursor.readU8('backend message tag'); + const length = cursor.readI32('backend message length'); + if (length < 4) { + throw new Error(`invalid backend message length ${length}`); + } + const body = new ByteCursor(cursor.readBytes(length - 4, 'backend message body')); + + switch (tag) { + case 0x54: + if (fields !== undefined) { + throw new Error( + 'query() received multiple result sets; use execProtocolRaw for multi-statement row results', + ); + } + fields = parseRowDescription(body); + body.requireEnd('RowDescription'); + break; + case 0x44: + if (fields === undefined) { + throw new Error('DataRow arrived before RowDescription'); + } + rows.push(parseDataRow(body, fields.length)); + body.requireEnd('DataRow'); + break; + case 0x43: + commandTag = body.readCString('CommandComplete tag'); + body.requireEnd('CommandComplete'); + break; + case 0x45: + throw parseErrorResponse(body); + case 0x47: + case 0x48: + case 0x57: + case 0x64: + case 0x63: + throw new Error( + 'query() does not support COPY protocol responses; use execProtocolRaw for COPY traffic', + ); + case 0x5a: + validateReadyForQuery(body); + sawReady = true; + if (!cursor.isAtEnd()) { + throw new Error('backend returned bytes after ReadyForQuery'); + } + break; + case 0x31: + body.requireEnd('ParseComplete'); + break; + case 0x32: + body.requireEnd('BindComplete'); + break; + case 0x33: + body.requireEnd('CloseComplete'); + break; + case 0x49: + body.requireEnd('EmptyQueryResponse'); + break; + case 0x6e: + body.requireEnd('NoData'); + break; + case 0x53: + validateParameterStatus(body); + break; + case 0x4e: + validateFieldResponse(body, 'NoticeResponse'); + break; + case 0x41: + validateNotificationResponse(body); + break; + default: + throw new Error(`query() received unexpected backend message tag ${hexBackendTag(tag)}`); + } + } + + if (!sawReady) { + throw new Error('query response ended before ReadyForQuery'); + } + + const resultFields = fields ?? []; + return { + fields: resultFields, + rows, + commandTag, + rowCount: rows.length, + fieldIndex(name: string): number | undefined { + const index = resultFields.findIndex((field) => field.name === name); + return index >= 0 ? index : undefined; + }, + getText(row: number, column: string): string | null { + const columnIndex = this.fieldIndex(column); + if (columnIndex === undefined) { + throw new Error(`query result has no column named ${JSON.stringify(column)}`); + } + const queryRow = rows[row]; + if (queryRow === undefined) { + throw new Error(`query result has no row at index ${row}`); + } + return queryRow.text(columnIndex); + }, + }; +} + +export function assertSuccessfulQueryResponse(bytes: Uint8Array): void { + const cursor = new ByteCursor(bytes); + let sawReady = false; + + while (!cursor.isAtEnd()) { + const tag = cursor.readU8('backend message tag'); + const length = cursor.readI32('backend message length'); + if (length < 4) { + throw new Error(`invalid backend message length ${length}`); + } + const body = new ByteCursor(cursor.readBytes(length - 4, 'backend message body')); + + switch (tag) { + case 0x45: + throw parseErrorResponse(body); + case 0x5a: + validateReadyForQuery(body); + sawReady = true; + if (!cursor.isAtEnd()) { + throw new Error('backend returned bytes after ReadyForQuery'); + } + break; + default: + break; + } + } + + if (!sawReady) { + throw new Error('query response ended before ReadyForQuery'); + } +} + +type NormalizedParam = + | { kind: 'null' } + | { kind: 'text'; value: Uint8Array } + | { kind: 'binary'; value: Uint8Array }; + +function normalizeQueryParam(parameter: QueryParam): NormalizedParam { + if (parameter === null) { + return { kind: 'null' }; + } + if ( + typeof parameter === 'string' || + typeof parameter === 'number' || + typeof parameter === 'boolean' + ) { + return { kind: 'text', value: new TextEncoder().encode(String(parameter)) }; + } + if (isQueryBinaryInput(parameter)) { + return { kind: 'binary', value: toUint8Array(parameter) }; + } + if (parameter.format === 'text') { + return { kind: 'text', value: new TextEncoder().encode(String(parameter.value)) }; + } + return { kind: 'binary', value: toUint8Array(parameter.value) }; +} + +function isQueryBinaryInput(value: unknown): value is QueryBinaryInput { + return value instanceof ArrayBuffer || ArrayBuffer.isView(value) || Array.isArray(value); +} + +function pushParse(out: number[], sql: string): void { + const body: number[] = []; + pushCString(body, ''); + pushCString(body, sql); + pushI16(body, 0); + pushFrontendMessage(out, 0x50, body); +} + +function pushBind(out: number[], parameters: NormalizedParam[]): void { + const body: number[] = []; + pushCString(body, ''); + pushCString(body, ''); + + pushI16(body, parameters.length); + for (const parameter of parameters) { + pushI16(body, parameter.kind === 'binary' ? 1 : 0); + } + + pushI16(body, parameters.length); + for (const parameter of parameters) { + if (parameter.kind === 'null') { + pushI32(body, -1); + } else { + pushSizedValue(body, parameter.value); + } + } + + pushI16(body, 1); + pushI16(body, 0); + pushFrontendMessage(out, 0x42, body); +} + +function pushDescribePortal(out: number[]): void { + const body: number[] = [0x50]; + pushCString(body, ''); + pushFrontendMessage(out, 0x44, body); +} + +function pushExecute(out: number[]): void { + const body: number[] = []; + pushCString(body, ''); + pushI32(body, 0); + pushFrontendMessage(out, 0x45, body); +} + +function pushFrontendMessage(out: number[], tag: number, body: ReadonlyArray): void { + out.push(tag); + pushI32(out, body.length + 4); + out.push(...body); +} + +function pushCString(out: number[], value: string): void { + if (value.includes('\0')) { + throw new Error('frontend protocol string must not contain NUL bytes'); + } + out.push(...new TextEncoder().encode(value), 0); +} + +function pushSizedValue(out: number[], value: Uint8Array): void { + pushI32(out, value.length); + out.push(...value); +} + +function pushI32(out: number[], value: number): void { + pushU32(out, value >>> 0); +} + +function pushU32(out: number[], value: number): void { + out.push((value >>> 24) & 0xff); + out.push((value >>> 16) & 0xff); + out.push((value >>> 8) & 0xff); + out.push(value & 0xff); +} + +function pushI16(out: number[], value: number): void { + const bits = value & 0xffff; + out.push((bits >>> 8) & 0xff); + out.push(bits & 0xff); +} + +export function toUint8Array(input: QueryBinaryInput): Uint8Array { + if (input instanceof Uint8Array) { + return input; + } + if (ArrayBuffer.isView(input)) { + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + return Uint8Array.from(input); +} + +function parseRowDescription(cursor: ByteCursor): QueryField[] { + const count = cursor.readI16('RowDescription field count'); + if (count < 0) { + throw new Error(`invalid RowDescription field count ${count}`); + } + const fields: QueryField[] = []; + for (let index = 0; index < count; index += 1) { + fields.push({ + name: cursor.readCString('field name'), + tableOid: cursor.readU32('field table oid'), + tableAttribute: cursor.readI16('field table attribute'), + typeOid: cursor.readU32('field type oid'), + typeSize: cursor.readI16('field type size'), + typeModifier: cursor.readI32('field type modifier'), + format: queryFormat(cursor.readI16('field format')), + }); + } + return fields; +} + +function parseDataRow(cursor: ByteCursor, expectedColumns: number): QueryRow { + const count = cursor.readI16('DataRow column count'); + if (count < 0) { + throw new Error(`invalid DataRow column count ${count}`); + } + if (count !== expectedColumns) { + throw new Error( + `DataRow column count ${count} does not match RowDescription count ${expectedColumns}`, + ); + } + const values: Array = []; + for (let index = 0; index < count; index += 1) { + const length = cursor.readI32('DataRow value length'); + if (length === -1) { + values.push(null); + } else if (length < 0) { + throw new Error(`invalid DataRow value length ${length}`); + } else { + values.push(cursor.readBytes(length, 'DataRow value')); + } + } + return { + values, + text(column: number): string | null { + if (column < 0 || column >= values.length) { + throw new Error(`query row has no column at index ${column}`); + } + const value = values[column]!; + return value === null ? null : decodeUtf8Strict(value, 'query value'); + }, + }; +} + +function parseErrorResponse(cursor: ByteCursor): PostgresError { + const fields: PostgresErrorField[] = []; + while (!cursor.isAtEnd()) { + let code: number; + try { + code = cursor.readU8('ErrorResponse field code'); + } catch { + return PostgresError.fallback(); + } + if (code === 0) { + break; + } + let value: string; + try { + value = cursor.readCString('ErrorResponse field'); + } catch { + return PostgresError.fallback(); + } + fields.push({ code, value }); + } + return new PostgresError(fields); +} + +function fieldValue(fields: ReadonlyArray, code: number): string | undefined { + return fields.find((field) => field.code === code)?.value; +} + +function formatPostgresError( + severity: string | undefined, + sqlstate: string | undefined, + message: string, +): string { + if (severity !== undefined && sqlstate !== undefined) { + return `${severity} [${sqlstate}]: ${message}`; + } + if (severity !== undefined) { + return `${severity}: ${message}`; + } + if (sqlstate !== undefined) { + return `[${sqlstate}]: ${message}`; + } + return message; +} + +function queryFormat(code: number): QueryFormat { + if (code === 0) { + return 'text'; + } + if (code === 1) { + return 'binary'; + } + return { code, kind: 'other' }; +} + +function hexBackendTag(tag: number): string { + return `0x${tag.toString(16).padStart(2, '0')}`; +} + +function validateReadyForQuery(body: ByteCursor): void { + const remaining = body.remainingBytes(); + if (remaining !== 1) { + throw new Error(`ReadyForQuery contained ${remaining} bytes, expected 1`); + } + const status = body.readU8('ReadyForQuery transaction status'); + if (status !== 0x49 && status !== 0x54 && status !== 0x45) { + throw new Error(`ReadyForQuery contained invalid transaction status ${hexBackendTag(status)}`); + } +} + +function validateParameterStatus(body: ByteCursor): void { + body.readCString('ParameterStatus name'); + body.readCString('ParameterStatus value'); + body.requireEnd('ParameterStatus'); +} + +function validateNotificationResponse(body: ByteCursor): void { + body.readI32('NotificationResponse process id'); + body.readCString('NotificationResponse channel'); + body.readCString('NotificationResponse payload'); + body.requireEnd('NotificationResponse'); +} + +function validateFieldResponse(body: ByteCursor, label: string): void { + for (;;) { + if (body.isAtEnd()) { + throw new Error(`${label} is missing terminator`); + } + const code = body.readU8(`${label} field code`); + if (code === 0) { + body.requireEnd(label); + return; + } + body.readCString(`${label} field`); + } +} + +class ByteCursor { + readonly #bytes: Uint8Array; + #offset = 0; + + constructor(bytes: Uint8Array) { + this.#bytes = bytes; + } + + isAtEnd(): boolean { + return this.#offset === this.#bytes.length; + } + + remainingBytes(): number { + return this.#bytes.length - this.#offset; + } + + requireEnd(label: string): void { + if (!this.isAtEnd()) { + throw new Error(`${label} contained trailing bytes`); + } + } + + readU8(label: string): number { + return this.readBytes(1, label)[0]!; + } + + readU32(label: string): number { + return ( + (this.readU8(label) * 0x1000000 + + (this.readU8(label) << 16) + + (this.readU8(label) << 8) + + this.readU8(label)) >>> + 0 + ); + } + + readI32(label: string): number { + const value = this.readU32(label); + return value > 0x7fffffff ? value - 0x100000000 : value; + } + + readI16(label: string): number { + const value = (this.readU8(label) << 8) | this.readU8(label); + return value > 0x7fff ? value - 0x10000 : value; + } + + readCString(label: string): string { + const end = this.#bytes.indexOf(0, this.#offset); + if (end < 0) { + throw new Error(`${label} is missing null terminator`); + } + const value = decodeUtf8Strict(this.#bytes.subarray(this.#offset, end), label); + this.#offset = end + 1; + return value; + } + + readBytes(count: number, label: string): Uint8Array { + if (count < 0 || this.#offset + count > this.#bytes.length) { + throw new Error(`truncated ${label}`); + } + const value = this.#bytes.slice(this.#offset, this.#offset + count); + this.#offset += count; + return value; + } +} + +function decodeUtf8Strict(bytes: Uint8Array, label: string): string { + validateUtf8(bytes, label); + return new TextDecoder().decode(bytes); +} + +function validateUtf8(bytes: Uint8Array, label: string): void { + let index = 0; + while (index < bytes.length) { + const first = bytes[index]!; + if (first <= 0x7f) { + index += 1; + } else if (first >= 0xc2 && first <= 0xdf) { + requireContinuation(bytes, index + 1, label); + index += 2; + } else if (first === 0xe0) { + requireRange(bytes, index + 1, 0xa0, 0xbf, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first >= 0xe1 && first <= 0xec) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first === 0xed) { + requireRange(bytes, index + 1, 0x80, 0x9f, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first >= 0xee && first <= 0xef) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first === 0xf0) { + requireRange(bytes, index + 1, 0x90, 0xbf, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else if (first >= 0xf1 && first <= 0xf3) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else if (first === 0xf4) { + requireRange(bytes, index + 1, 0x80, 0x8f, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else { + throw invalidUtf8(label, index); + } + } +} + +function requireContinuation(bytes: Uint8Array, index: number, label: string): void { + requireRange(bytes, index, 0x80, 0xbf, label); +} + +function requireRange( + bytes: Uint8Array, + index: number, + min: number, + max: number, + label: string, +): void { + const byte = bytes[index]; + if (byte === undefined || byte < min || byte > max) { + throw invalidUtf8(label, index); + } +} + +function invalidUtf8(label: string, index: number): Error { + return new Error(`${label} is not valid UTF-8 at byte ${index}`); +} diff --git a/src/sdks/js/src/runtime/broker-frames.ts b/src/sdks/js/src/runtime/broker-frames.ts new file mode 100644 index 00000000..a1890b93 --- /dev/null +++ b/src/sdks/js/src/runtime/broker-frames.ts @@ -0,0 +1,198 @@ +import type { BackupFormat } from '../types.js'; +import type { ByteStream } from './byte-stream.js'; + +const MAGIC = new Uint8Array([0x50, 0x47, 0x4f, 0x42]); +const HEADER_LEN = 13; +const MAX_FRAME_LEN = 128 * 1024 * 1024; + +export type BrokerRequestFrame = + | { kind: 'authenticate'; token: string } + | { kind: 'execProtocol'; bytes: Uint8Array } + | { kind: 'execSimpleQuery'; sql: string } + | { kind: 'checkpoint' } + | { kind: 'close' } + | { kind: 'execProtocolStream'; bytes: Uint8Array } + | { kind: 'backup'; format: BackupFormat } + | { kind: 'cancel' }; + +export type BrokerResponseFrame = + | { kind: 'ok'; bytes: Uint8Array } + | { kind: 'error'; message: string } + | { kind: 'chunk'; bytes: Uint8Array }; + +export async function writeBrokerRequest( + stream: ByteStream, + frame: BrokerRequestFrame, +): Promise { + await stream.writeAll(encodeBrokerRequest(frame)); +} + +export async function readBrokerRequest(stream: ByteStream): Promise { + const { kind, payload } = await readFrame(stream); + return decodeBrokerRequest(kind, payload); +} + +export async function writeBrokerResponse( + stream: ByteStream, + frame: BrokerResponseFrame, +): Promise { + await stream.writeAll(encodeBrokerResponse(frame)); +} + +export async function readBrokerResponse(stream: ByteStream): Promise { + const { kind, payload } = await readFrame(stream); + return decodeBrokerResponse(kind, payload); +} + +export function encodeBrokerRequest(frame: BrokerRequestFrame): Uint8Array { + switch (frame.kind) { + case 'authenticate': + return encodeFrame(6, encodeUtf8(frame.token)); + case 'execProtocol': + return encodeFrame(1, frame.bytes); + case 'execSimpleQuery': + return encodeFrame(8, encodeUtf8(frame.sql)); + case 'checkpoint': + return encodeFrame(2, emptyPayload); + case 'close': + return encodeFrame(3, emptyPayload); + case 'execProtocolStream': + return encodeFrame(4, frame.bytes); + case 'backup': + return encodeFrame(5, new Uint8Array([encodeBackupFormat(frame.format)])); + case 'cancel': + return encodeFrame(7, emptyPayload); + } +} + +export function encodeBrokerResponse(frame: BrokerResponseFrame): Uint8Array { + switch (frame.kind) { + case 'ok': + return encodeFrame(101, frame.bytes); + case 'error': + return encodeFrame(102, encodeUtf8(frame.message)); + case 'chunk': + return encodeFrame(103, frame.bytes); + } +} + +export function decodeBrokerRequest(kind: number, payload: Uint8Array): BrokerRequestFrame { + switch (kind) { + case 6: + return { kind: 'authenticate', token: decodeUtf8(payload, 'broker auth frame') }; + case 1: + return { kind: 'execProtocol', bytes: payload }; + case 8: + return { kind: 'execSimpleQuery', sql: decodeUtf8(payload, 'broker simple-query frame') }; + case 2: + assertEmptyPayload(payload); + return { kind: 'checkpoint' }; + case 3: + assertEmptyPayload(payload); + return { kind: 'close' }; + case 4: + return { kind: 'execProtocolStream', bytes: payload }; + case 5: + return { kind: 'backup', format: decodeBackupFormat(payload) }; + case 7: + assertEmptyPayload(payload); + return { kind: 'cancel' }; + default: + throw new Error(`unknown broker request frame ${kind}`); + } +} + +export function decodeBrokerResponse(kind: number, payload: Uint8Array): BrokerResponseFrame { + switch (kind) { + case 101: + return { kind: 'ok', bytes: payload }; + case 102: + return { kind: 'error', message: decodeUtf8(payload, 'broker error frame') }; + case 103: + return { kind: 'chunk', bytes: payload }; + default: + throw new Error(`unknown broker response frame ${kind}`); + } +} + +async function readFrame(stream: ByteStream): Promise<{ kind: number; payload: Uint8Array }> { + const header = await stream.readExactly(HEADER_LEN); + for (let i = 0; i < MAGIC.length; i += 1) { + if (header[i] !== MAGIC[i]) { + throw new Error('broker frame magic mismatch'); + } + } + const kind = header[4]; + if (kind === undefined) { + throw new Error('broker frame header is missing a kind byte'); + } + const length = Number(new DataView(header.buffer, header.byteOffset + 5, 8).getBigUint64(0)); + if (length > MAX_FRAME_LEN) { + throw new Error(`broker frame payload length ${length} exceeds limit ${MAX_FRAME_LEN}`); + } + return { kind, payload: await stream.readExactly(length) }; +} + +function encodeFrame(kind: number, payload: Uint8Array): Uint8Array { + if (payload.length > MAX_FRAME_LEN) { + throw new Error(`broker frame payload length ${payload.length} exceeds limit ${MAX_FRAME_LEN}`); + } + const out = new Uint8Array(HEADER_LEN + payload.length); + out.set(MAGIC, 0); + out[4] = kind; + new DataView(out.buffer, out.byteOffset + 5, 8).setBigUint64(0, BigInt(payload.length)); + out.set(payload, HEADER_LEN); + return out; +} + +function encodeBackupFormat(format: BackupFormat): number { + switch (format) { + case 'sql': + return 1; + case 'physicalArchive': + return 2; + case 'oliphauntArchive': + return 3; + } +} + +function decodeBackupFormat(payload: Uint8Array): BackupFormat { + if (payload.length === 0) { + throw new Error('broker backup request frame is missing a format'); + } + if (payload.length > 1) { + throw new Error('broker backup request frame unexpectedly had extra payload'); + } + switch (payload[0]) { + case 1: + return 'sql'; + case 2: + return 'physicalArchive'; + case 3: + return 'oliphauntArchive'; + default: + throw new Error(`unknown broker backup format ${payload[0]}`); + } +} + +function assertEmptyPayload(payload: Uint8Array): void { + if (payload.length > 0) { + throw new Error('broker control frame unexpectedly had a payload'); + } +} + +const emptyPayload = new Uint8Array(); +const utf8 = new TextEncoder(); +const strictUtf8 = new TextDecoder('utf-8', { fatal: true }); + +function encodeUtf8(value: string): Uint8Array { + return utf8.encode(value); +} + +function decodeUtf8(bytes: Uint8Array, label: string): string { + try { + return strictUtf8.decode(bytes); + } catch (error) { + throw new Error(`${label} is not UTF-8: ${String(error)}`); + } +} diff --git a/src/sdks/js/src/runtime/broker.ts b/src/sdks/js/src/runtime/broker.ts new file mode 100644 index 00000000..0d1168e5 --- /dev/null +++ b/src/sdks/js/src/runtime/broker.ts @@ -0,0 +1,1008 @@ +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { arch, homedir, platform, tmpdir } from 'node:os'; +import { gunzipSync, inflateRawSync } from 'node:zlib'; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; + +import type { NormalizedOpenConfig } from '../config.js'; +import type { BackupFormat, EngineCapabilities, EngineModeSupport } from '../types.js'; +import { + assertSha256Matches, + envVar, + LIBOLIPHAUNT_CACHE_DIR_ENV, + LIBOLIPHAUNT_RUNTIME_DIR_ENV, + parseReleaseChecksumManifest, + checksumForReleaseAsset, +} from '../native/common.js'; +import { extractTarArchive } from '../native/tar.js'; +import { extractZipArchive } from '../native/zip.js'; +import { + readBrokerResponse, + writeBrokerRequest, + type BrokerResponseFrame, +} from './broker-frames.js'; +import type { ByteStream } from './byte-stream.js'; +import { + canonicalPath, + connectEndpoint, + createTempDir, + parseReadyEndpoint, + randomHexToken, + readReadyLine, + removeTree, + spawnManagedChild, + type ManagedChild, +} from './node-adapter.js'; +import type { RuntimeBinding, RuntimeHandle } from './types.js'; + +const READY_PREFIX = 'OLIPHAUNT_BROKER_READY '; +const ERROR_PREFIX = 'OLIPHAUNT_BROKER_ERROR '; +const LIBOLIPHAUNT_PATH_ENV = 'LIBOLIPHAUNT_PATH'; +const OLIPHAUNT_INSTALL_DIR_ENV = 'OLIPHAUNT_INSTALL_DIR'; +const OLIPHAUNT_BROKER_ENV = 'OLIPHAUNT_BROKER'; +const OLIPHAUNT_BROKER_RELEASE_ASSET_DIR_ENV = 'OLIPHAUNT_BROKER_ASSET_DIR'; +const OLIPHAUNT_BROKER_RELEASE_BASE_URL_ENV = 'OLIPHAUNT_BROKER_RELEASE_BASE_URL'; +const OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS_ENV = 'OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS'; +const OLIPHAUNT_BROKER_RELEASE_TAG_PREFIX = 'oliphaunt-broker-v'; +const OLIPHAUNT_RELEASE_REPOSITORY = 'f0rr0/oliphaunt'; +const DEFAULT_STARTUP_TIMEOUT_MS = 60_000; +const SHUTDOWN_TIMEOUT_MS = 5_000; +const RESTORE_TIMEOUT_MS = 120_000; + +export type BrokerRuntimeBindingOptions = { + executable?: string; + maxRoots?: number; +}; + +export type BrokerRestoreOptions = { + root: string; + bytes: Uint8Array; + replaceExisting?: boolean; + brokerExecutable?: string; +}; + +export function createBrokerRuntimeBinding( + options: BrokerRuntimeBindingOptions = {}, +): RuntimeBinding { + const supervisor = new BrokerRootSupervisor(options.maxRoots ?? 1); + return { + runtime: runtimeName(), + rawProtocolTransport: 'broker-ipc', + protocolStream: true, + capabilities(handle: RuntimeHandle): EngineCapabilities { + return brokerCapabilities(asBrokerHandle(handle).maxRoots); + }, + async open(config: NormalizedOpenConfig): Promise { + const executable = await resolveBrokerExecutable( + config.brokerExecutable ?? options.executable, + ); + const rootLease = await supervisor.acquire(config.root); + let handle: BrokerHandle | undefined; + try { + handle = new BrokerHandle(executable, config, rootLease, supervisor.maxRoots); + await handle.start(); + return handle; + } catch (error) { + await handle?.detach(); + rootLease.release(); + throw error; + } + }, + execProtocolRaw(handle: RuntimeHandle, request: Uint8Array): Promise { + return asBrokerHandle(handle).requestOk({ kind: 'execProtocol', bytes: request }); + }, + execSimpleQuery(handle: RuntimeHandle, sql: string): Promise { + return asBrokerHandle(handle).requestOk({ kind: 'execSimpleQuery', sql }); + }, + execProtocolStream( + handle: RuntimeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): Promise { + return asBrokerHandle(handle).requestStream(request, onChunk); + }, + backup(handle: RuntimeHandle, format: BackupFormat): Promise { + return asBrokerHandle(handle).requestOk({ kind: 'backup', format }); + }, + cancel(handle: RuntimeHandle): Promise { + return asBrokerHandle(handle).cancel(); + }, + detach(handle: RuntimeHandle): Promise { + return asBrokerHandle(handle).detach(); + }, + }; +} + +export async function brokerModeSupport(options: { + libraryPath?: string; + runtimeDirectory?: string; + brokerExecutable?: string; + brokerMaxRoots?: number; +}): Promise { + const capabilities = brokerCapabilities(options.brokerMaxRoots ?? 1); + try { + await resolveBrokerExecutable(options.brokerExecutable); + await resolveBrokerNativeInstall({ + libraryPath: options.libraryPath, + runtimeDirectory: options.runtimeDirectory, + }); + return { engine: 'nativeBroker', available: true, capabilities }; + } catch (error) { + return { + engine: 'nativeBroker', + available: false, + capabilities, + unavailableReason: `native broker helper is unavailable: ${errorString(error)}`, + }; + } +} + +export async function restorePhysicalArchiveWithBroker( + options: BrokerRestoreOptions, +): Promise { + const executable = await resolveBrokerExecutable(options.brokerExecutable); + const tempDir = await createTempDir('lpgr-'); + const artifactPath = join(tempDir, 'physical-archive.tar'); + try { + await writeFile(artifactPath, options.bytes); + const args = ['restore', '--root', options.root, '--artifact', artifactPath]; + if (options.replaceExisting === true) { + args.push('--replace-existing'); + } + await runBrokerTool(executable, args, RESTORE_TIMEOUT_MS, 'native broker restore'); + return options.root; + } finally { + await removeTree(tempDir); + } +} + +export function brokerCapabilities(maxRoots: number): EngineCapabilities { + return { + engine: 'nativeBroker', + processIsolated: true, + multiRoot: maxRoots > 1, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: true, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + rawProtocolTransport: 'broker-ipc', + }; +} + +class BrokerHandle { + #child: ManagedChild | undefined; + #stream: ByteStream | undefined; + #cancelEndpoint: string | undefined; + #ipcDir: string | undefined; + #authToken: string | undefined; + #closed = false; + + constructor( + readonly executable: string, + readonly config: NormalizedOpenConfig, + readonly rootLease: BrokerRootLease, + readonly maxRoots: number, + ) {} + + async start(): Promise { + if (this.#closed) { + throw new Error('native broker session is closed'); + } + const authToken = randomHexToken(); + const launch = await launchBroker(this.executable, this.config, authToken); + this.#child = launch.child; + this.#stream = launch.stream; + this.#cancelEndpoint = launch.cancelEndpoint; + this.#ipcDir = launch.ipcDir; + this.#authToken = authToken; + } + + async requestOk(frame: Parameters[1]): Promise { + const response = await this.request(frame); + switch (response.kind) { + case 'ok': + return response.bytes; + case 'error': + throw new Error(response.message); + case 'chunk': + throw new Error('broker returned a stream chunk for raw request execution'); + } + } + + async requestStream(request: Uint8Array, onChunk: (chunk: Uint8Array) => void): Promise { + const stream = await this.ensureStream(); + try { + await writeBrokerRequest(stream, { kind: 'execProtocolStream', bytes: request }); + for (;;) { + const response = await readBrokerResponse(stream); + switch (response.kind) { + case 'chunk': + onChunk(response.bytes); + break; + case 'ok': + return; + case 'error': + throw new Error(response.message); + } + } + } catch (error) { + await this.markFailed(); + throw error; + } + } + + async cancel(): Promise { + const endpoint = this.#cancelEndpoint; + if (endpoint === undefined) { + throw new Error('native broker cancel endpoint is unavailable'); + } + const authToken = this.#authToken; + if (authToken === undefined) { + throw new Error('native broker auth token is unavailable'); + } + const stream = await connectEndpoint(parseReadyEndpoint(endpoint)); + try { + await authenticateBroker(stream, authToken); + await writeBrokerRequest(stream, { kind: 'cancel' }); + const response = await readBrokerResponse(stream); + if (response.kind === 'error') { + throw new Error(`native broker cancel failed: ${response.message}`); + } + if (response.kind === 'chunk') { + throw new Error('broker returned a stream chunk for cancellation'); + } + } finally { + await stream.close(); + } + } + + async detach(): Promise { + if (this.#closed) { + return; + } + this.#closed = true; + const stream = this.#stream; + if (stream !== undefined) { + try { + await writeBrokerRequest(stream, { kind: 'close' }); + await readBrokerResponse(stream); + } catch {} + await stream.close(); + } + this.#stream = undefined; + const child = this.#child; + this.#child = undefined; + if (child !== undefined) { + const exited = await waitForChild(child, SHUTDOWN_TIMEOUT_MS); + if (!exited) { + child.kill('SIGKILL'); + await child.wait(); + } + } + await removeTree(this.#ipcDir); + this.#ipcDir = undefined; + if (this.config.temporary) { + await removeTree(this.config.root); + } + this.rootLease.release(); + } + + async request(frame: Parameters[1]): Promise { + const stream = await this.ensureStream(); + try { + await writeBrokerRequest(stream, frame); + return await readBrokerResponse(stream); + } catch (error) { + await this.markFailed(); + throw error; + } + } + + async ensureStream(): Promise { + if (this.#closed) { + throw new Error('native broker session is closed'); + } + if (this.#stream === undefined) { + await this.start(); + } + if (this.#stream === undefined) { + throw new Error('native broker stream is unavailable'); + } + return this.#stream; + } + + async markFailed(): Promise { + await this.#stream?.close(); + this.#stream = undefined; + const child = this.#child; + this.#child = undefined; + if (child !== undefined) { + child.kill('SIGKILL'); + await child.wait(); + } + await removeTree(this.#ipcDir); + this.#ipcDir = undefined; + } +} + +async function launchBroker( + executable: string, + config: NormalizedOpenConfig, + authToken: string, +): Promise<{ + child: ManagedChild; + stream: ByteStream; + cancelEndpoint: string; + ipcDir?: string; +}> { + const startupTimeoutMs = brokerStartupTimeoutMs(); + const endpoint = await allocateBrokerEndpoint(config); + const nativeInstall = await resolveBrokerNativeInstall(config); + const child = spawnManagedChild({ + executable, + args: brokerSpawnArgs(config, endpoint), + env: brokerSpawnEnv(authToken, nativeInstall), + }); + try { + const line = await Promise.race([ + readReadyLine(child.stdout, startupTimeoutMs, 'native broker'), + child.exited().then((code) => { + throw new Error(`native broker exited before readiness with code ${code ?? 'signal'}`); + }), + ]); + const ready = parseBrokerReadyLine(line); + const stream = await connectEndpoint(parseReadyEndpoint(ready.primary)); + await authenticateBroker(stream, authToken); + return { child, stream, cancelEndpoint: ready.cancel, ipcDir: endpoint.ipcDir }; + } catch (error) { + child.kill('SIGKILL'); + await child.wait(); + await removeTree(endpoint.ipcDir); + throw error; + } +} + +function brokerStartupTimeoutMs(): number { + return positiveIntegerEnvMs(OLIPHAUNT_BROKER_STARTUP_TIMEOUT_MS_ENV, DEFAULT_STARTUP_TIMEOUT_MS); +} + +function positiveIntegerEnvMs(name: string, fallback: number): number { + const value = envVar(name); + if (value === undefined || value.length === 0) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed.toString() !== value.trim()) { + throw new Error(`${name} must be a positive integer number of milliseconds`); + } + return parsed; +} + +type BrokerNativeInstall = { + libraryPath: string; + runtimeDirectory?: string; +}; + +async function resolveBrokerNativeInstall(config: { + libraryPath?: string; + runtimeDirectory?: string; +}): Promise { + const install = + runtimeName() === 'deno' + ? await import('../native/assets-deno.js').then((module) => + module.resolveDenoNativeInstall(config.libraryPath), + ) + : await import('../native/assets-node.js').then((module) => + module.resolveNodeNativeInstall(config.libraryPath), + ); + return { + libraryPath: install.libraryPath, + runtimeDirectory: config.runtimeDirectory ?? install.runtimeDirectory, + }; +} + +function brokerSpawnEnv( + authToken: string, + nativeInstall: BrokerNativeInstall, +): Record { + const env: Record = { + OLIPHAUNT_BROKER_AUTH_TOKEN: authToken, + [LIBOLIPHAUNT_PATH_ENV]: nativeInstall.libraryPath, + }; + if (nativeInstall.runtimeDirectory !== undefined) { + env[OLIPHAUNT_INSTALL_DIR_ENV] = nativeInstall.runtimeDirectory; + env[LIBOLIPHAUNT_RUNTIME_DIR_ENV] = nativeInstall.runtimeDirectory; + } + return env; +} + +async function authenticateBroker(stream: ByteStream, authToken: string): Promise { + await writeBrokerRequest(stream, { kind: 'authenticate', token: authToken }); + const response = await readBrokerResponse(stream); + if (response.kind === 'error') { + throw new Error(`native broker authentication failed: ${response.message}`); + } + if (response.kind === 'chunk') { + throw new Error('broker returned a stream chunk during authentication'); + } +} + +type BrokerEndpointPlan = + | { kind: 'unix'; socket: string; cancelSocket: string; ipcDir: string } + | { kind: 'tcp'; listen: string; cancelListen: string; ipcDir?: undefined }; + +async function allocateBrokerEndpoint(config: NormalizedOpenConfig): Promise { + const canUseUnix = process.platform !== 'win32'; + if (config.brokerTransport === 'unix' && !canUseUnix) { + throw new Error('native broker Unix sockets are not supported on this platform'); + } + if (config.brokerTransport !== 'tcp' && canUseUnix) { + const ipcDir = await createTempDir('lpgo-'); + return { + kind: 'unix', + socket: join(ipcDir, 's'), + cancelSocket: join(ipcDir, 'c'), + ipcDir, + }; + } + return { kind: 'tcp', listen: '127.0.0.1:0', cancelListen: '127.0.0.1:0' }; +} + +function brokerSpawnArgs(config: NormalizedOpenConfig, endpoint: BrokerEndpointPlan): string[] { + const args = [ + '--root', + config.root, + '--bootstrap', + 'packaged-template', + '--durability', + durabilityArg(config.durability), + '--runtime-footprint', + runtimeFootprintArg(config.runtimeFootprint), + '--username', + config.username, + '--database', + config.database, + ]; + if (endpoint.kind === 'unix') { + args.push('--socket', endpoint.socket, '--cancel-socket', endpoint.cancelSocket); + } else { + args.push('--listen', endpoint.listen, '--cancel-listen', endpoint.cancelListen); + } + for (const extension of config.extensions) { + args.push('--extension', extension); + } + for (const assignment of startupAssignments(config.startupArgs)) { + args.push('--startup-guc', assignment); + } + return args; +} + +function parseBrokerReadyLine(line: string): { primary: string; cancel: string } { + if (line.startsWith(ERROR_PREFIX)) { + throw new Error(`native broker failed to start: ${line.slice(ERROR_PREFIX.length)}`); + } + if (!line.startsWith(READY_PREFIX)) { + throw new Error(`native broker did not print a ready line: ${line}`); + } + const parts = line.slice(READY_PREFIX.length).trim().split(/\s+/); + const primary = parts[0]; + const cancel = parts[1]?.startsWith('cancel=') ? parts[1].slice('cancel='.length) : undefined; + if (primary === undefined || cancel === undefined) { + throw new Error('native broker ready line did not include primary and cancel endpoints'); + } + return { primary, cancel }; +} + +async function resolveBrokerExecutable(explicit: string | undefined): Promise { + if (explicit !== undefined) { + return requireExecutableFile(explicit, 'brokerExecutable'); + } + + const configured = envVar(OLIPHAUNT_BROKER_ENV); + if (configured !== undefined && configured.trim().length > 0) { + if (configured.includes('\0')) { + throw new Error(`${OLIPHAUNT_BROKER_ENV} must not contain NUL bytes`); + } + return requireExecutableFile(configured, OLIPHAUNT_BROKER_ENV); + } + + for (const candidate of packageAdjacentExecutables('oliphaunt-broker')) { + if (await isFile(candidate)) { + return candidate; + } + } + return resolveBrokerHelperInstall(); +} + +async function runBrokerTool( + executable: string, + args: string[], + timeoutMs: number, + label: string, +): Promise { + await new Promise((resolve, reject) => { + const child = spawn(executable, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let settled = false; + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + child.kill('SIGKILL'); + reject(new Error(`${label} did not finish within ${timeoutMs}ms`)); + }, timeoutMs); + + function finish(error?: Error): void { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (error !== undefined) { + reject(error); + } else { + resolve(); + } + } + + child.stdout?.on('data', (chunk: Buffer) => pushBounded(stdout, chunk)); + child.stderr?.on('data', (chunk: Buffer) => pushBounded(stderr, chunk)); + child.once('error', (error) => finish(error)); + child.once('exit', (code, signal) => { + if (code === 0) { + finish(); + return; + } + const output = [boundedText(stderr), boundedText(stdout)] + .filter((value) => value.length > 0) + .join('\n'); + finish( + new Error( + `${label} failed with ${signal ?? `exit code ${code ?? 'unknown'}`}${ + output.length > 0 ? `: ${output}` : '' + }`, + ), + ); + }); + }); +} + +function pushBounded(chunks: Buffer[], chunk: Buffer): void { + const maxBytes = 64 * 1024; + const total = chunks.reduce((sum, current) => sum + current.byteLength, 0); + if (total >= maxBytes) { + return; + } + const remaining = maxBytes - total; + chunks.push(chunk.byteLength <= remaining ? chunk : chunk.subarray(0, remaining)); +} + +function boundedText(chunks: Buffer[]): string { + return Buffer.concat(chunks).toString('utf8').trim(); +} + +async function requireExecutableFile(path: string, source: string): Promise { + if (!(await isFile(path))) { + throw new Error(`${source} does not point to an existing file: ${path}`); + } + return path; +} + +function packageAdjacentExecutables(base: string): string[] { + const here = dirname(fileURLToPath(import.meta.url)); + return [ + join(here, base), + join(here, `${base}.exe`), + join(here, '..', base), + join(here, '..', `${base}.exe`), + resolve(process.cwd(), base), + resolve(process.cwd(), `${base}.exe`), + ]; +} + +type BrokerReleaseTarget = { + id: string; + assetName: string; + executableRelativePath: string; +}; + +type BrokerInstallMarker = { + version: string; + asset: string; + checksum: string; +}; + +async function resolveBrokerHelperInstall(): Promise { + const version = await packageBrokerVersion(); + const target = brokerReleaseTarget(version, platform(), arch()); + const installRoot = join(cacheRoot(), 'oliphaunt-broker', version, target.id); + const executable = join(installRoot, target.executableRelativePath); + const lockPath = `${installRoot}.lock`; + const release = await acquireInstallLock(lockPath, 'oliphaunt-broker'); + try { + if (await validateExistingBrokerInstall(executable, version, target.assetName)) { + return executable; + } + const checksums = parseReleaseChecksumManifest( + new TextDecoder().decode( + await readBrokerReleaseAssetBytes(version, brokerChecksumAssetName(version)), + ), + ); + const expectedChecksum = checksumForReleaseAsset(checksums, target.assetName); + const archive = await readBrokerReleaseAssetBytes(version, target.assetName); + assertSha256Matches(target.assetName, expectedChecksum, sha256Hex(archive)); + await installBrokerArchive(target.assetName, archive, installRoot, { + version, + asset: target.assetName, + checksum: expectedChecksum, + }); + return executable; + } finally { + await release(); + } +} + +async function packageBrokerVersion(): Promise { + type PackageMetadata = { + name?: string; + oliphaunt?: { brokerVersion?: string }; + }; + const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ) as PackageMetadata; + const version = packageJson.oliphaunt?.brokerVersion; + if (packageJson.name !== '@oliphaunt/ts' || version === undefined || version.length === 0) { + throw new Error('@oliphaunt/ts package metadata does not pin brokerVersion'); + } + return version; +} + +export function brokerReleaseTarget( + version: string, + currentPlatform: string, + currentArch: string, +): BrokerReleaseTarget { + validateBrokerReleaseVersion(version); + const normalizedPlatform = normalizeBrokerPlatform(currentPlatform); + const normalizedArch = normalizeBrokerArchitecture(currentArch); + if (normalizedPlatform === 'darwin' && normalizedArch === 'arm64') { + return { + id: 'macos-arm64', + assetName: `oliphaunt-broker-${version}-macos-arm64.tar.gz`, + executableRelativePath: 'bin/oliphaunt-broker', + }; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'x64') { + return { + id: 'linux-x64-gnu', + assetName: `oliphaunt-broker-${version}-linux-x64-gnu.tar.gz`, + executableRelativePath: 'bin/oliphaunt-broker', + }; + } + if (normalizedPlatform === 'linux' && normalizedArch === 'arm64') { + return { + id: 'linux-arm64-gnu', + assetName: `oliphaunt-broker-${version}-linux-arm64-gnu.tar.gz`, + executableRelativePath: 'bin/oliphaunt-broker', + }; + } + if (normalizedPlatform === 'windows' && normalizedArch === 'x64') { + return { + id: 'windows-x64-msvc', + assetName: `oliphaunt-broker-${version}-windows-x64-msvc.zip`, + executableRelativePath: 'bin/oliphaunt-broker.exe', + }; + } + throw new Error( + `no oliphaunt-broker ${version} release asset is defined for ${currentPlatform}/${currentArch}; pass brokerExecutable explicitly for this platform`, + ); +} + +export function oliphauntBrokerReleaseAssetUrl(version: string, assetName: string): string { + validateBrokerReleaseVersion(version); + validateBrokerAssetName(assetName); + const override = envVar(OLIPHAUNT_BROKER_RELEASE_BASE_URL_ENV); + const base = + override !== undefined && override.trim().length > 0 + ? override.replace(/\/+$/, '') + : `https://github.com/${OLIPHAUNT_RELEASE_REPOSITORY}/releases/download/${OLIPHAUNT_BROKER_RELEASE_TAG_PREFIX}${version}`; + return `${base}/${assetName}`; +} + +function brokerChecksumAssetName(version: string): string { + validateBrokerReleaseVersion(version); + return `oliphaunt-broker-${version}-release-assets.sha256`; +} + +async function validateExistingBrokerInstall( + executable: string, + version: string, + assetName: string, +): Promise { + const markerPath = join(dirname(dirname(executable)), '.oliphaunt-broker-install.json'); + try { + const marker = JSON.parse(await readFile(markerPath, 'utf8')) as BrokerInstallMarker; + const executableStat = await stat(executable); + return ( + marker.version === version && + marker.asset === assetName && + marker.checksum.length === 64 && + executableStat.isFile() + ); + } catch { + return false; + } +} + +async function readBrokerReleaseAssetBytes( + version: string, + assetName: string, +): Promise { + const localAssetDir = envVar(OLIPHAUNT_BROKER_RELEASE_ASSET_DIR_ENV); + if (localAssetDir !== undefined && localAssetDir.trim().length > 0) { + return Uint8Array.from(await readFile(join(localAssetDir, assetName))); + } + const url = oliphauntBrokerReleaseAssetUrl(version, assetName); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`download ${url} failed with HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +async function installBrokerArchive( + assetName: string, + archive: Uint8Array, + installRoot: string, + marker: BrokerInstallMarker, +): Promise { + const parent = dirname(installRoot); + const scratch = join( + parent, + `.tmp-broker-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + await rm(scratch, { recursive: true, force: true }); + await mkdir(scratch, { recursive: true }); + try { + const host = { + join, + dirname, + async mkdir(path: string) { + await mkdir(path, { recursive: true }); + }, + async writeFile(file: { path: string; bytes: Uint8Array; mode: number }) { + await writeFile(file.path, file.bytes, { mode: file.mode }); + await chmod(file.path, file.mode); + }, + }; + if (assetName.endsWith('.zip')) { + await extractZipArchive(archive, scratch, host, (compressed) => + Uint8Array.from(inflateRawSync(compressed)), + ); + } else { + await extractTarArchive(Uint8Array.from(gunzipSync(archive)), scratch, host); + } + await writeFile( + join(scratch, '.oliphaunt-broker-install.json'), + `${JSON.stringify(marker, null, 2)}\n`, + 'utf8', + ); + await rm(installRoot, { recursive: true, force: true }); + await rename(scratch, installRoot); + } catch (error) { + await rm(scratch, { recursive: true, force: true }); + throw error; + } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +function startupAssignments(startupArgs: string[]): string[] { + const assignments: string[] = []; + for (let i = 0; i < startupArgs.length; i += 2) { + const assignment = startupArgs[i + 1]; + if (startupArgs[i] === '-c' && assignment !== undefined) { + assignments.push(assignment); + } + } + return assignments; +} + +function durabilityArg(value: NormalizedOpenConfig['durability']): string { + return value === 'fastDev' ? 'fast-dev' : value; +} + +function runtimeFootprintArg(value: NormalizedOpenConfig['runtimeFootprint']): string { + switch (value) { + case 'throughput': + return 'throughput'; + case 'balancedMobile': + return 'balanced-mobile'; + case 'smallMobile': + return 'small-mobile'; + } +} + +async function waitForChild(child: ManagedChild, timeoutMs: number): Promise { + const timeout = new Promise((resolveTimeout) => { + setTimeout(() => resolveTimeout(false), timeoutMs); + }); + const result = await Promise.race([child.wait().then(() => true), timeout]); + return result; +} + +class BrokerRootSupervisor { + readonly #roots = new Set(); + + constructor(readonly maxRoots: number) {} + + async acquire(root: string): Promise { + if (this.maxRoots <= 0) { + throw new Error('native broker max_roots must be greater than zero'); + } + await mkdir(root, { recursive: true }); + const key = await canonicalPath(root); + if (this.#roots.has(key)) { + throw new Error(`native broker root ${key} is already open in this broker runtime`); + } + if (this.#roots.size >= this.maxRoots) { + throw new Error( + `native broker runtime already owns ${this.#roots.size} root(s), at configured capacity ${this.maxRoots}`, + ); + } + this.#roots.add(key); + return new BrokerRootLease(this, key); + } + + release(key: string): void { + this.#roots.delete(key); + } +} + +class BrokerRootLease { + #released = false; + + constructor( + readonly supervisor: BrokerRootSupervisor, + readonly key: string, + ) {} + + release(): void { + if (!this.#released) { + this.#released = true; + this.supervisor.release(this.key); + } + } +} + +function asBrokerHandle(handle: RuntimeHandle): BrokerHandle { + if (handle instanceof BrokerHandle) { + return handle; + } + throw new Error('invalid native broker handle'); +} + +function runtimeName(): 'node' | 'bun' | 'deno' { + if (typeof (globalThis as { Deno?: unknown }).Deno !== 'undefined') { + return 'deno'; + } + if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') { + return 'bun'; + } + return 'node'; +} + +function errorString(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function cacheRoot(): string { + const override = envVar(LIBOLIPHAUNT_CACHE_DIR_ENV); + if (override !== undefined && override.trim().length > 0) { + return override; + } + if (platform() === 'darwin') { + return join(homedir(), 'Library', 'Caches', 'oliphaunt'); + } + const xdgCache = envVar('XDG_CACHE_HOME'); + if (xdgCache !== undefined && xdgCache.trim().length > 0) { + return join(xdgCache, 'oliphaunt'); + } + return join(homedir() || tmpdir(), '.cache', 'oliphaunt'); +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function acquireInstallLock(path: string, description: string): Promise<() => Promise> { + await mkdir(dirname(path), { recursive: true }); + for (let attempt = 0; attempt < 600; attempt += 1) { + try { + await writeFile(path, `${process.pid}\n`, { flag: 'wx' }); + return async () => { + await rm(path, { force: true }); + }; + } catch (error) { + if (isFileExistsError(error)) { + await sleep(100); + continue; + } + throw error; + } + } + throw new Error(`timed out waiting for ${description} install lock ${path}`); +} + +function isFileExistsError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'EEXIST' + ); +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function normalizeBrokerPlatform(value: string): string { + switch (value) { + case 'darwin': + case 'macos': + return 'darwin'; + case 'win32': + case 'windows': + return 'windows'; + default: + return value; + } +} + +function normalizeBrokerArchitecture(value: string): string { + switch (value) { + case 'arm64': + case 'aarch64': + return 'arm64'; + case 'x64': + case 'x86_64': + return 'x64'; + default: + return value; + } +} + +function validateBrokerReleaseVersion(version: string): void { + if (!/^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(version)) { + throw new Error(`invalid oliphaunt-broker release version '${version}'`); + } +} + +function validateBrokerAssetName(assetName: string): void { + if (!/^[A-Za-z0-9._+-]+$/.test(assetName) || assetName.includes('..')) { + throw new Error(`invalid oliphaunt-broker release asset name '${assetName}'`); + } +} diff --git a/src/sdks/js/src/runtime/byte-stream.ts b/src/sdks/js/src/runtime/byte-stream.ts new file mode 100644 index 00000000..e7908914 --- /dev/null +++ b/src/sdks/js/src/runtime/byte-stream.ts @@ -0,0 +1,40 @@ +export type ByteStream = { + readExactly(length: number): Promise; + writeAll(bytes: Uint8Array): Promise; + close(): Promise; +}; + +export class MemoryDuplexStream implements ByteStream { + readonly #input: Uint8Array[]; + readonly output: Uint8Array[] = []; + + constructor(input: ReadonlyArray = []) { + this.#input = [...input]; + } + + async readExactly(length: number): Promise { + const out = new Uint8Array(length); + let offset = 0; + while (offset < length) { + const chunk = this.#input[0]; + if (chunk === undefined) { + throw new Error(`read stream ended before ${length} byte(s) were available`); + } + const take = Math.min(chunk.length, length - offset); + out.set(chunk.subarray(0, take), offset); + offset += take; + if (take === chunk.length) { + this.#input.shift(); + } else { + this.#input[0] = chunk.subarray(take); + } + } + return out; + } + + async writeAll(bytes: Uint8Array): Promise { + this.output.push(bytes.slice()); + } + + async close(): Promise {} +} diff --git a/src/sdks/js/src/runtime/direct.ts b/src/sdks/js/src/runtime/direct.ts new file mode 100644 index 00000000..d0c2e85f --- /dev/null +++ b/src/sdks/js/src/runtime/direct.ts @@ -0,0 +1,104 @@ +import type { NormalizedOpenConfig } from '../config.js'; +import { + CAP_BACKUP_RESTORE, + CAP_EXTENSIONS, + CAP_LOGICAL_REOPEN, + CAP_MULTI_INSTANCE, + CAP_PROTOCOL_RAW, + CAP_PROTOCOL_STREAM, + CAP_QUERY_CANCEL, + CAP_SIMPLE_QUERY, +} from '../native/common.js'; +import type { NativeBinding, NativeHandle } from '../native/types.js'; +import type { BackupFormat, EngineCapabilities } from '../types.js'; +import type { RuntimeBinding, RuntimeHandle } from './types.js'; + +export function directRuntimeBinding(binding: NativeBinding): RuntimeBinding { + const runtimeBinding: RuntimeBinding = { + runtime: binding.runtime, + rawProtocolTransport: binding.rawProtocolTransport, + protocolStream: binding.protocolStream, + capabilities(): Promise { + return Promise.resolve(binding.capabilities()).then((flags) => + nativeDirectCapabilities(flags, binding), + ); + }, + open(config: NormalizedOpenConfig): Promise { + return Promise.resolve( + binding.open({ + pgdata: config.pgdata, + runtimeDirectory: config.runtimeDirectory ?? binding.defaultRuntimeDirectory, + username: config.username, + database: config.database, + startupArgs: config.startupArgs, + }), + ); + }, + execProtocolRaw(handle: RuntimeHandle, request: Uint8Array): Promise { + return Promise.resolve(binding.execProtocolRaw(handle, request)); + }, + backup(handle: RuntimeHandle, format: BackupFormat): Promise { + return Promise.resolve(binding.backup(handle, format)); + }, + cancel(handle: RuntimeHandle): Promise { + return Promise.resolve(binding.cancel(handle)); + }, + detach(handle: RuntimeHandle): Promise { + return Promise.resolve(binding.detach(handle)); + }, + }; + if (binding.execSimpleQuery !== undefined) { + runtimeBinding.execSimpleQuery = (handle: RuntimeHandle, sql: string) => + Promise.resolve(binding.execSimpleQuery?.(handle, sql)).then(assertDefined); + } + if (binding.protocolStream && binding.execProtocolStream !== undefined) { + runtimeBinding.execProtocolStream = ( + handle: RuntimeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ) => Promise.resolve(binding.execProtocolStream?.(handle, request, onChunk)).then(() => {}); + } + return runtimeBinding; +} + +export function nativeDirectCapabilities( + rawFlags: bigint | number, + binding: Pick, +): EngineCapabilities { + const flags = BigInt(rawFlags); + const backupRestore = hasFlag(flags, CAP_BACKUP_RESTORE); + return { + engine: 'nativeDirect', + processIsolated: false, + multiRoot: hasFlag(flags, CAP_MULTI_INSTANCE), + reopenable: hasFlag(flags, CAP_LOGICAL_REOPEN), + sameRootLogicalReopen: hasFlag(flags, CAP_LOGICAL_REOPEN), + rootSwitchable: false, + crashRestartable: false, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: hasFlag(flags, CAP_PROTOCOL_RAW), + protocolStream: + hasFlag(flags, CAP_PROTOCOL_STREAM) && + binding.protocolStream && + binding.execProtocolStream !== undefined, + queryCancel: hasFlag(flags, CAP_QUERY_CANCEL), + backupRestore, + backupFormats: backupRestore ? ['physicalArchive'] : [], + restoreFormats: backupRestore ? ['physicalArchive'] : [], + simpleQuery: hasFlag(flags, CAP_SIMPLE_QUERY), + extensions: hasFlag(flags, CAP_EXTENSIONS), + rawProtocolTransport: binding.rawProtocolTransport, + }; +} + +function hasFlag(flags: bigint, flag: bigint): boolean { + return (flags & flag) !== 0n; +} + +function assertDefined(value: T | undefined): T { + if (value === undefined) { + throw new Error('nativeDirect operation returned no result'); + } + return value; +} diff --git a/src/sdks/js/src/runtime/node-adapter.ts b/src/sdks/js/src/runtime/node-adapter.ts new file mode 100644 index 00000000..bb82c3bb --- /dev/null +++ b/src/sdks/js/src/runtime/node-adapter.ts @@ -0,0 +1,221 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Socket, createConnection } from 'node:net'; +import type { Readable } from 'node:stream'; + +import type { ByteStream } from './byte-stream.js'; + +export type LocalEndpoint = + | { kind: 'unix'; path: string } + | { kind: 'tcp'; host: string; port: number }; + +export type ManagedChild = { + stdout: Readable; + kill(signal?: NodeJS.Signals): void; + wait(): Promise; + exited(): Promise; +}; + +export function randomHexToken(byteLength = 32): string { + return randomBytes(byteLength).toString('hex'); +} + +export async function createTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), prefix)); +} + +export async function removeTree(path: string | undefined): Promise { + if (path !== undefined) { + await rm(path, { force: true, recursive: true }); + } +} + +export async function canonicalPath(path: string): Promise { + try { + return await realpath(path); + } catch { + return path; + } +} + +export function spawnManagedChild(options: { + executable: string; + args: string[]; + env?: Record; +}): ManagedChild { + const child: ChildProcessByStdio = spawn(options.executable, options.args, { + env: { ...process.env, ...options.env }, + stdio: ['ignore', 'pipe', 'inherit'], + }); + const exited = new Promise((resolve) => { + child.once('exit', (code) => resolve(code)); + }); + + return { + stdout: child.stdout, + kill(signal?: NodeJS.Signals): void { + child.kill(signal); + }, + wait(): Promise { + return exited; + }, + exited(): Promise { + return exited; + }, + }; +} + +export async function readReadyLine( + stream: Readable, + timeoutMs: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`${label} did not print a ready line within ${timeoutMs}ms`)); + }, timeoutMs); + + function cleanup(): void { + clearTimeout(timeout); + stream.off('data', onData); + stream.off('error', onError); + stream.off('end', onEnd); + } + + function onData(chunk: Buffer): void { + const next = Buffer.alloc(buffer.byteLength + chunk.byteLength); + next.set(buffer, 0); + next.set(chunk, buffer.byteLength); + buffer = next; + const index = buffer.indexOf(0x0a); + if (index < 0) { + if (buffer.length > 8192) { + cleanup(); + reject(new Error(`${label} ready line exceeded 8192 bytes`)); + } + return; + } + cleanup(); + resolve(buffer.subarray(0, index).toString('utf8').replace(/\r$/, '')); + } + + function onError(error: Error): void { + cleanup(); + reject(error); + } + + function onEnd(): void { + cleanup(); + reject(new Error(`${label} exited before printing a ready line`)); + } + + stream.on('data', onData); + stream.once('error', onError); + stream.once('end', onEnd); + }); +} + +export async function connectEndpoint(endpoint: LocalEndpoint): Promise { + const socket = + endpoint.kind === 'unix' + ? createConnection(endpoint.path) + : createConnection({ host: endpoint.host, port: endpoint.port }); + if (endpoint.kind === 'tcp') { + socket.setNoDelay(true); + } + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + }); + return new NodeSocketByteStream(socket); +} + +export function parseReadyEndpoint(value: string): LocalEndpoint { + if (value.startsWith('unix:')) { + return { kind: 'unix', path: value.slice('unix:'.length) }; + } + const address = value.startsWith('tcp:') ? value.slice('tcp:'.length) : value; + const lastColon = address.lastIndexOf(':'); + if (lastColon <= 0) { + throw new Error(`invalid TCP endpoint '${value}'`); + } + const port = Number(address.slice(lastColon + 1)); + if (!Number.isInteger(port) || port <= 0 || port > 0xffff) { + throw new Error(`invalid TCP endpoint port '${value}'`); + } + return { kind: 'tcp', host: address.slice(0, lastColon), port }; +} + +class NodeSocketByteStream implements ByteStream { + readonly #socket: Socket; + readonly #chunks: Uint8Array[] = []; + #ended = false; + #error: Error | undefined; + #wake: (() => void) | undefined; + + constructor(socket: Socket) { + this.#socket = socket; + socket.on('data', (chunk: Buffer) => { + this.#chunks.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength).slice()); + this.#wake?.(); + this.#wake = undefined; + }); + socket.once('end', () => { + this.#ended = true; + this.#wake?.(); + this.#wake = undefined; + }); + socket.once('error', (error) => { + this.#error = error; + this.#wake?.(); + this.#wake = undefined; + }); + } + + async readExactly(length: number): Promise { + const out = new Uint8Array(length); + let offset = 0; + while (offset < length) { + if (this.#error !== undefined) { + throw this.#error; + } + const chunk = this.#chunks[0]; + if (chunk === undefined) { + if (this.#ended) { + throw new Error(`socket ended before ${length} byte(s) were available`); + } + await new Promise((resolve) => { + this.#wake = resolve; + }); + continue; + } + const take = Math.min(chunk.length, length - offset); + out.set(chunk.subarray(0, take), offset); + offset += take; + if (take === chunk.length) { + this.#chunks.shift(); + } else { + this.#chunks[0] = chunk.subarray(take); + } + } + return out; + } + + async writeAll(bytes: Uint8Array): Promise { + await new Promise((resolve, reject) => { + const done = (error?: Error | null) => (error ? reject(error) : resolve()); + if (!this.#socket.write(bytes, done)) { + this.#socket.once('drain', resolve); + } + }); + } + + async close(): Promise { + this.#socket.destroy(); + } +} diff --git a/src/sdks/js/src/runtime/pgwire.ts b/src/sdks/js/src/runtime/pgwire.ts new file mode 100644 index 00000000..678a9af7 --- /dev/null +++ b/src/sdks/js/src/runtime/pgwire.ts @@ -0,0 +1,209 @@ +import type { ByteStream } from './byte-stream.js'; +import { connectEndpoint, type LocalEndpoint } from './node-adapter.js'; + +const PROTOCOL_VERSION_3 = 196_608; +const CANCEL_REQUEST_CODE = 80_877_102; + +export type BackendKeyData = { + processId: number; + secretKey: number; +}; + +export class PostgresWireClient { + readonly #stream: ByteStream; + readonly #endpoint: LocalEndpoint; + readonly #backendKey: BackendKeyData; + + private constructor(stream: ByteStream, endpoint: LocalEndpoint, backendKey: BackendKeyData) { + this.#stream = stream; + this.#endpoint = endpoint; + this.#backendKey = backendKey; + } + + static async connect( + endpoint: LocalEndpoint, + username: string, + database: string, + ): Promise { + const stream = await connectEndpoint(endpoint); + await stream.writeAll(encodeStartupMessage(username, database)); + const backendKey = { current: undefined as BackendKeyData | undefined }; + await readUntilReady(stream, { includeMessages: false, errorIsFatal: true, backendKey }); + if (backendKey.current === undefined) { + throw new Error('native server did not return BackendKeyData during startup'); + } + return new PostgresWireClient(stream, endpoint, backendKey.current); + } + + async execProtocolRaw(request: Uint8Array): Promise { + await this.#stream.writeAll(request); + return readUntilReady(this.#stream, { includeMessages: true, errorIsFatal: false }); + } + + async execProtocolStream( + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): Promise { + await this.#stream.writeAll(request); + await readUntilReady(this.#stream, { + includeMessages: true, + errorIsFatal: false, + onChunk, + }); + } + + async terminate(): Promise { + await this.#stream.writeAll(new Uint8Array([0x58, 0, 0, 0, 4])); + await this.#stream.close(); + } + + async cancel(): Promise { + const stream = await connectEndpoint(this.#endpoint); + try { + await stream.writeAll(encodeCancelRequest(this.#backendKey)); + } finally { + await stream.close(); + } + } +} + +export function encodeStartupMessage(username: string, database: string): Uint8Array { + const body: number[] = []; + pushI32(body, PROTOCOL_VERSION_3); + pushCString(body, 'user'); + pushCString(body, username); + pushCString(body, 'database'); + pushCString(body, database); + pushCString(body, 'client_encoding'); + pushCString(body, 'UTF8'); + body.push(0); + const out: number[] = []; + pushI32(out, body.length + 4); + out.push(...body); + return Uint8Array.from(out); +} + +export function encodeCancelRequest(key: BackendKeyData): Uint8Array { + const out: number[] = []; + pushI32(out, 16); + pushI32(out, CANCEL_REQUEST_CODE); + pushI32(out, key.processId); + pushI32(out, key.secretKey); + return Uint8Array.from(out); +} + +export function parseBackendKeyData(body: Uint8Array): BackendKeyData { + if (body.length !== 8) { + throw new Error(`native server returned invalid BackendKeyData length ${body.length}`); + } + return { + processId: readI32(body, 0), + secretKey: readI32(body, 4), + }; +} + +async function readUntilReady( + stream: ByteStream, + options: { + includeMessages: boolean; + errorIsFatal: boolean; + backendKey?: { current: BackendKeyData | undefined }; + onChunk?: (chunk: Uint8Array) => void; + }, +): Promise { + const chunks: Uint8Array[] = []; + for (;;) { + const header = await stream.readExactly(5); + const tag = header[0]; + if (tag === undefined) { + throw new Error('native server returned an empty backend frame header'); + } + const length = readI32(header, 1); + if (length < 4) { + throw new Error(`native server returned invalid message length ${length}`); + } + const body = await stream.readExactly(length - 4); + const frame = new Uint8Array(5 + body.length); + frame.set(header, 0); + frame.set(body, 5); + if (options.includeMessages) { + chunks.push(frame); + options.onChunk?.(frame); + } + switch (tag) { + case 0x52: + handleAuthentication(body); + break; + case 0x4b: + if (options.backendKey !== undefined) { + options.backendKey.current = parseBackendKeyData(body); + } + break; + case 0x45: + if (options.errorIsFatal) { + throw new Error(parseErrorResponse(body)); + } + break; + case 0x5a: + return concat(chunks); + default: + break; + } + } +} + +function handleAuthentication(body: Uint8Array): void { + if (body.length < 4) { + throw new Error('native server returned truncated authentication message'); + } + const method = readI32(body, 0); + if (method !== 0) { + throw new Error(`native server requested unsupported authentication method ${method}`); + } +} + +function parseErrorResponse(body: Uint8Array): string { + let offset = 0; + while (offset < body.length && body[offset] !== 0) { + const code = body[offset]; + offset += 1; + const end = body.indexOf(0, offset); + if (end < 0) { + break; + } + if (code === 0x4d) { + return strictUtf8.decode(body.subarray(offset, end)); + } + offset = end + 1; + } + return 'native server returned an error response'; +} + +function concat(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +function pushCString(out: number[], value: string): void { + if (value.includes('\0')) { + throw new Error('PostgreSQL startup string must not contain NUL bytes'); + } + out.push(...new TextEncoder().encode(value), 0); +} + +function pushI32(out: number[], value: number): void { + const bits = value >>> 0; + out.push((bits >>> 24) & 0xff, (bits >>> 16) & 0xff, (bits >>> 8) & 0xff, bits & 0xff); +} + +function readI32(bytes: Uint8Array, offset: number): number { + return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getInt32(0); +} + +const strictUtf8 = new TextDecoder('utf-8', { fatal: true }); diff --git a/src/sdks/js/src/runtime/physical-archive.ts b/src/sdks/js/src/runtime/physical-archive.ts new file mode 100644 index 00000000..06c285ad --- /dev/null +++ b/src/sdks/js/src/runtime/physical-archive.ts @@ -0,0 +1,315 @@ +import { lstat, readdir, readFile } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; + +import { assertSuccessfulQueryResponse, parseQueryResponse, type QueryResult } from '../query.js'; + +const BACKUP_LABEL = 'oliphaunt physical archive'; +const TRANSIENT_CONTENT_DIRS = new Set([ + 'pg_dynshmem', + 'pg_notify', + 'pg_serial', + 'pg_snapshots', + 'pg_stat_tmp', + 'pg_subtrans', +]); +const BLOCK_SIZE = 512; + +export async function createPhysicalArchive(options: { + pgdata: string; + execSimpleQuery(sql: string): Promise; +}): Promise { + await assertQueryOk( + options.execSimpleQuery(`SELECT pg_backup_start(label => '${BACKUP_LABEL}', fast => true)`), + 'start physical backup', + ); + + const archive = new TarArchive(); + let backupStopped = false; + try { + await appendPgdataTree(archive, options.pgdata); + const stopFiles = await stopPhysicalBackup(options.execSimpleQuery); + backupStopped = true; + await appendPgWalTree(archive, options.pgdata); + archive.appendGeneratedFile('pgdata/backup_label', stopFiles.backupLabel); + if (stopFiles.tablespaceMap !== undefined && stopFiles.tablespaceMap.length > 0) { + archive.appendGeneratedFile('pgdata/tablespace_map', stopFiles.tablespaceMap); + } + return archive.finish(); + } catch (error) { + if (!backupStopped) { + await stopPhysicalBackup(options.execSimpleQuery).catch(() => {}); + } + throw error; + } +} + +async function assertQueryOk(response: Promise, context: string): Promise { + try { + assertSuccessfulQueryResponse(await response); + } catch (error) { + throw new Error(`${context} failed: ${errorString(error)}`); + } +} + +async function stopPhysicalBackup( + execSimpleQuery: (sql: string) => Promise, +): Promise<{ backupLabel: string; tablespaceMap?: string }> { + let result: QueryResult; + try { + result = parseQueryResponse( + await execSimpleQuery( + 'SELECT labelfile, spcmapfile FROM pg_backup_stop(wait_for_archive => false)', + ), + ); + } catch (error) { + throw new Error(`stop physical backup failed: ${errorString(error)}`); + } + if (result.rowCount !== 1) { + throw new Error(`stop physical backup returned ${result.rowCount} rows, expected 1`); + } + const backupLabel = result.getText(0, 'labelfile'); + if (backupLabel === null || backupLabel.length === 0) { + throw new Error('pg_backup_stop returned an empty backup label'); + } + const tablespaceMap = result.getText(0, 'spcmapfile') ?? undefined; + return { backupLabel, tablespaceMap }; +} + +async function appendPgdataTree(archive: TarArchive, pgdata: string): Promise { + await archive.appendDirectory('pgdata', pgdata); + for (const entry of await sortedEntries(pgdata)) { + await appendPgdataEntry(archive, pgdata, join(pgdata, entry), false); + } +} + +async function appendPgWalTree(archive: TarArchive, pgdata: string): Promise { + const pgWal = join(pgdata, 'pg_wal'); + if (!(await isDirectory(pgWal))) { + return; + } + for (const entry of await sortedEntries(pgWal)) { + await appendPgdataEntry(archive, pgdata, join(pgWal, entry), true); + } +} + +async function appendPgdataEntry( + archive: TarArchive, + pgdata: string, + source: string, + includeWalContents: boolean, +): Promise { + const relativePath = toPortablePath(relative(pgdata, source)); + if (shouldSkipPgdataEntry(relativePath, includeWalContents)) { + return; + } + + const archivePath = `pgdata/${relativePath}`; + const metadata = await lstat(source); + if (metadata.isDirectory()) { + await archive.appendDirectory(archivePath, source); + for (const entry of await sortedEntries(source)) { + await appendPgdataEntry(archive, pgdata, join(source, entry), includeWalContents); + } + return; + } + if (metadata.isFile()) { + await archive.appendFile(archivePath, source); + return; + } + if (metadata.isSymbolicLink()) { + throw new Error( + `physical archive does not support symlinked PGDATA entry ${archivePath}; external tablespaces and linked WAL directories are not portable in liboliphaunt archives`, + ); + } + throw new Error( + `physical archive does not support non-regular PGDATA entry ${archivePath}; liboliphaunt archives only support regular files and directories`, + ); +} + +function shouldSkipPgdataEntry(relativePath: string, includeWalContents: boolean): boolean { + if (relativePath === 'postmaster.pid' || relativePath === 'postmaster.opts') { + return true; + } + const leaf = relativePath.split('/').pop() ?? ''; + if (leaf === 'pg_internal.init' || leaf.startsWith('pgsql_tmp')) { + return true; + } + const [first, ...rest] = relativePath.split('/'); + if (first === undefined || rest.length === 0) { + return false; + } + return TRANSIENT_CONTENT_DIRS.has(first) || (first === 'pg_wal' && !includeWalContents); +} + +async function sortedEntries(path: string): Promise { + return (await readdir(path)).sort((left, right) => left.localeCompare(right)); +} + +async function isDirectory(path: string): Promise { + try { + return (await lstat(path)).isDirectory(); + } catch { + return false; + } +} + +class TarArchive { + readonly #chunks: Uint8Array[] = []; + #finished = false; + + async appendDirectory(path: string, source: string): Promise { + const metadata = await lstat(source); + this.#appendHeader({ + path, + type: 'directory', + mode: modeOrDefault(metadata.mode, 0o700), + size: 0, + mtime: Math.floor(metadata.mtimeMs / 1000), + }); + } + + async appendFile(path: string, source: string): Promise { + const metadata = await lstat(source); + const bytes = await readFile(source); + this.#appendHeader({ + path, + type: 'file', + mode: modeOrDefault(metadata.mode, 0o600), + size: bytes.byteLength, + mtime: Math.floor(metadata.mtimeMs / 1000), + }); + this.#append(new Uint8Array(bytes)); + this.#pad(bytes.byteLength); + } + + appendGeneratedFile(path: string, contents: string): void { + const bytes = new TextEncoder().encode(contents); + this.#appendHeader({ + path, + type: 'file', + mode: 0o600, + size: bytes.byteLength, + mtime: Math.floor(Date.now() / 1000), + }); + this.#append(bytes); + this.#pad(bytes.byteLength); + } + + finish(): Uint8Array { + if (!this.#finished) { + this.#append(new Uint8Array(BLOCK_SIZE * 2)); + this.#finished = true; + } + const total = this.#chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of this.#chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; + } + + #appendHeader(options: { + path: string; + type: 'file' | 'directory'; + mode: number; + size: number; + mtime: number; + }): void { + if (this.#finished) { + throw new Error('cannot append to a finished tar archive'); + } + const header = new Uint8Array(BLOCK_SIZE); + const nameParts = splitTarPath(options.path); + writeString(header, 0, 100, nameParts.name); + writeOctal(header, 100, 8, options.mode); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, options.size); + writeOctal(header, 136, 12, options.mtime); + header.fill(0x20, 148, 156); + header[156] = options.type === 'directory' ? 0x35 : 0x30; + writeString(header, 257, 6, 'ustar'); + writeString(header, 263, 2, '00'); + writeString(header, 265, 32, 'oliphaunt'); + writeString(header, 297, 32, 'oliphaunt'); + if (nameParts.prefix !== undefined) { + writeString(header, 345, 155, nameParts.prefix); + } + writeChecksum(header); + this.#append(header); + } + + #append(bytes: Uint8Array): void { + this.#chunks.push(bytes); + } + + #pad(size: number): void { + const remainder = size % BLOCK_SIZE; + if (remainder !== 0) { + this.#append(new Uint8Array(BLOCK_SIZE - remainder)); + } + } +} + +function splitTarPath(path: string): { name: string; prefix?: string } { + const normalized = path.replaceAll('\\', '/').replace(/^\/+/, ''); + if (normalized.length === 0 || normalized.includes('/../') || normalized.startsWith('../')) { + throw new Error(`unsafe physical archive path ${JSON.stringify(path)}`); + } + if (byteLength(normalized) <= 100) { + return { name: normalized }; + } + const parts = normalized.split('/'); + for (let index = parts.length - 1; index > 0; index -= 1) { + const prefix = parts.slice(0, index).join('/'); + const name = parts.slice(index).join('/'); + if (byteLength(prefix) <= 155 && byteLength(name) <= 100) { + return { name, prefix }; + } + } + throw new Error(`physical archive path is too long for ustar: ${normalized}`); +} + +function writeString(header: Uint8Array, offset: number, length: number, value: string): void { + const bytes = new TextEncoder().encode(value); + if (bytes.byteLength > length) { + throw new Error(`tar header value is too long: ${value}`); + } + header.set(bytes, offset); +} + +function writeOctal(header: Uint8Array, offset: number, length: number, value: number): void { + const digits = value.toString(8); + if (digits.length > length - 1) { + throw new Error(`tar numeric field overflow: ${value}`); + } + writeString(header, offset, length, `${digits.padStart(length - 1, '0')}\0`); +} + +function writeChecksum(header: Uint8Array): void { + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + const digits = checksum.toString(8).padStart(6, '0'); + writeString(header, 148, 8, `${digits}\0 `); +} + +function modeOrDefault(mode: number, fallback: number): number { + const permissions = mode & 0o777; + return permissions === 0 ? fallback : permissions; +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function toPortablePath(path: string): string { + return sep === '/' ? path : path.split(sep).join('/'); +} + +function errorString(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/sdks/js/src/runtime/server.ts b/src/sdks/js/src/runtime/server.ts new file mode 100644 index 00000000..f318a370 --- /dev/null +++ b/src/sdks/js/src/runtime/server.ts @@ -0,0 +1,465 @@ +import { spawn } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createServer } from 'node:net'; + +import type { NormalizedOpenConfig } from '../config.js'; +import { simpleQuery } from '../protocol.js'; +import type { BackupFormat, EngineCapabilities, EngineModeSupport } from '../types.js'; +import { + connectEndpoint, + removeTree, + spawnManagedChild, + type LocalEndpoint, + type ManagedChild, +} from './node-adapter.js'; +import { createPhysicalArchive } from './physical-archive.js'; +import { PostgresWireClient } from './pgwire.js'; +import type { RuntimeBinding, RuntimeHandle } from './types.js'; + +const SERVER_HOST = '127.0.0.1'; +const SERVER_STARTUP_TIMEOUT_MS_ENV = 'OLIPHAUNT_SERVER_STARTUP_TIMEOUT_MS'; +const DEFAULT_STARTUP_TIMEOUT_MS = 60_000; +const CONNECT_RETRY_MS = 50; +const STOP_TIMEOUT_MS = 5_000; + +export function createServerRuntimeBinding(): RuntimeBinding { + return { + runtime: runtimeName(), + rawProtocolTransport: 'server-wire', + protocolStream: true, + capabilities(handle: RuntimeHandle): EngineCapabilities { + return asServerHandle(handle).capabilities(); + }, + async open(config: NormalizedOpenConfig): Promise { + return openServer(config); + }, + execProtocolRaw(handle: RuntimeHandle, request: Uint8Array): Promise { + return asServerHandle(handle).execProtocolRaw(request); + }, + execSimpleQuery(handle: RuntimeHandle, sql: string): Promise { + return asServerHandle(handle).execProtocolRaw(simpleQuery(sql)); + }, + execProtocolStream( + handle: RuntimeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): Promise { + return asServerHandle(handle).execProtocolStream(request, onChunk); + }, + backup(handle: RuntimeHandle, format: BackupFormat): Promise { + return asServerHandle(handle).backup(format); + }, + cancel(handle: RuntimeHandle): Promise { + return asServerHandle(handle).cancel(); + }, + detach(handle: RuntimeHandle): Promise { + return asServerHandle(handle).detach(); + }, + }; +} + +export async function serverModeSupport(options: { + serverExecutable?: string; + serverToolDirectory?: string; +}): Promise { + const capabilities = serverCapabilities(32); + try { + await resolveServerExecutable(options); + return { engine: 'nativeServer', available: true, capabilities }; + } catch (error) { + return { + engine: 'nativeServer', + available: false, + capabilities, + unavailableReason: `native server executable is unavailable: ${errorString(error)}`, + }; + } +} + +export function serverCapabilities( + maxClientSessions: number, + connectionString?: string, +): EngineCapabilities { + return { + engine: 'nativeServer', + processIsolated: true, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: false, + independentSessions: true, + maxClientSessions, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['sql', 'physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + connectionString, + rawProtocolTransport: 'server-wire', + }; +} + +class ServerHandle { + #closed = false; + + constructor( + readonly child: ManagedChild, + readonly client: PostgresWireClient, + readonly root: string, + readonly pgdata: string, + readonly pgCtl: string | undefined, + readonly pgDump: string | undefined, + readonly socketDir: string | undefined, + readonly connectionString: string, + readonly maxClientSessions: number, + readonly temporary: boolean, + ) {} + + capabilities(): EngineCapabilities { + return serverCapabilities(this.maxClientSessions, this.connectionString); + } + + async execProtocolRaw(request: Uint8Array): Promise { + this.assertOpen(); + return this.client.execProtocolRaw(request); + } + + async execProtocolStream( + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): Promise { + this.assertOpen(); + await this.client.execProtocolStream(request, onChunk); + } + + async backup(format: BackupFormat): Promise { + this.assertOpen(); + if (format === 'sql') { + if (this.pgDump === undefined) { + throw new Error('native server SQL backup requires pg_dump'); + } + return runPgDump(this.pgDump, this.connectionString); + } + if (format === 'physicalArchive') { + return createPhysicalArchive({ + pgdata: this.pgdata, + execSimpleQuery: (sql) => this.execProtocolRaw(simpleQuery(sql)), + }); + } + throw new Error(`${format} backup is not supported by nativeServer`); + } + + async cancel(): Promise { + this.assertOpen(); + await this.client.cancel(); + } + + async detach(): Promise { + if (this.#closed) { + return; + } + this.#closed = true; + await this.client.terminate().catch(() => {}); + if (this.pgCtl !== undefined && (await isFile(this.pgCtl))) { + await runCommand(this.pgCtl, ['-D', this.pgdata, '-m', 'fast', '-w', 'stop']).catch(() => {}); + } + const exited = await waitForChild(this.child, STOP_TIMEOUT_MS); + if (!exited) { + this.child.kill('SIGKILL'); + await this.child.wait(); + } + await removeTree(this.socketDir); + if (this.temporary) { + await removeTree(this.root); + } + } + + assertOpen(): void { + if (this.#closed) { + throw new Error('native server session is closed'); + } + } +} + +async function openServer(config: NormalizedOpenConfig): Promise { + const startupTimeoutMs = serverStartupTimeoutMs(); + const executable = await resolveServerExecutable({ + serverExecutable: config.serverExecutable, + serverToolDirectory: config.serverToolDirectory, + }); + const toolDirectory = config.serverToolDirectory ?? dirname(executable); + let socketDir: string | undefined; + let child: ManagedChild | undefined; + try { + await initializeServerDataDir(config, toolDirectory); + const pgCtl = await optionalTool(toolDirectory, 'pg_ctl'); + const pgDump = await optionalTool(toolDirectory, 'pg_dump'); + const port = config.serverPort ?? (await pickPort()); + socketDir = process.platform === 'win32' ? undefined : await createSocketDir(); + child = spawnManagedChild({ + executable, + args: postgresArgs(config, port, socketDir), + }); + const endpoint = sdkEndpoint(port, socketDir); + const client = await waitForServer( + endpoint, + child, + config.username, + config.database, + startupTimeoutMs, + ); + return new ServerHandle( + child, + client, + config.root, + config.pgdata, + pgCtl, + pgDump, + socketDir, + serverConnectionString(config.username, config.database, port), + config.maxClientSessions, + config.temporary, + ); + } catch (error) { + if (child !== undefined) { + child.kill('SIGKILL'); + await child.wait(); + } + await removeTree(socketDir); + if (config.temporary) { + await removeTree(config.root); + } + throw error; + } +} + +async function initializeServerDataDir( + config: NormalizedOpenConfig, + toolDirectory: string, +): Promise { + if (await isFile(join(config.pgdata, 'PG_VERSION'))) { + return; + } + const initdb = await optionalTool(toolDirectory, 'initdb'); + if (initdb === undefined) { + throw new Error(`native server bootstrap requires initdb in ${toolDirectory}`); + } + await mkdir(config.pgdata, { recursive: true }); + await runCommand(initdb, [ + '-D', + config.pgdata, + '-U', + config.username, + '--auth=trust', + '--no-sync', + ]); +} + +function postgresArgs( + config: NormalizedOpenConfig, + port: number, + socketDir: string | undefined, +): string[] { + const args = [ + '-D', + config.pgdata, + '-h', + SERVER_HOST, + '-p', + String(port), + '-c', + 'logging_collector=off', + '-c', + 'listen_addresses=127.0.0.1', + ]; + args.push( + '-c', + socketDir === undefined ? 'unix_socket_directories=' : `unix_socket_directories=${socketDir}`, + ); + args.push(...config.startupArgs); + args.push('-c', `max_connections=${config.maxClientSessions}`); + return args; +} + +async function waitForServer( + endpoint: LocalEndpoint, + child: ManagedChild, + username: string, + database: string, + startupTimeoutMs: number, +): Promise { + const deadline = Date.now() + startupTimeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + const exited = await Promise.race([ + child.exited().then((code) => ({ exited: true, code })), + sleep(0).then(() => ({ exited: false, code: null })), + ]); + if (exited.exited) { + throw new Error(`native server exited before accepting connections with code ${exited.code}`); + } + try { + return await PostgresWireClient.connect(endpoint, username, database); + } catch (error) { + lastError = error; + await sleep(CONNECT_RETRY_MS); + } + } + throw new Error(`native server did not accept SDK connections: ${errorString(lastError)}`); +} + +function sdkEndpoint(port: number, socketDir: string | undefined): LocalEndpoint { + if (socketDir !== undefined) { + return { kind: 'unix', path: join(socketDir, `.s.PGSQL.${port}`) }; + } + return { kind: 'tcp', host: SERVER_HOST, port }; +} + +export function serverConnectionString(username: string, database: string, port: number): string { + return `postgres://${percentEncode(username)}@${SERVER_HOST}:${port}/${percentEncode(database)}`; +} + +function percentEncode(value: string): string { + return [...new TextEncoder().encode(value)] + .map((byte) => + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a) || + byte === 0x2d || + byte === 0x2e || + byte === 0x5f || + byte === 0x7e + ? String.fromCharCode(byte) + : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`, + ) + .join(''); +} + +function serverStartupTimeoutMs(): number { + const value = process.env[SERVER_STARTUP_TIMEOUT_MS_ENV]; + if (value === undefined || value.length === 0) { + return DEFAULT_STARTUP_TIMEOUT_MS; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed.toString() !== value.trim()) { + throw new Error( + `${SERVER_STARTUP_TIMEOUT_MS_ENV} must be a positive integer number of milliseconds`, + ); + } + return parsed; +} + +async function resolveServerExecutable(options: { + serverExecutable?: string; + serverToolDirectory?: string; +}): Promise { + const candidates = [ + options.serverExecutable, + process.env.OLIPHAUNT_POSTGRES, + options.serverToolDirectory === undefined + ? undefined + : join(options.serverToolDirectory, 'postgres'), + ].filter((value): value is string => value !== undefined && value.length > 0); + for (const candidate of candidates) { + if (await isFile(candidate)) { + return candidate; + } + } + throw new Error('set serverExecutable, serverToolDirectory, or OLIPHAUNT_POSTGRES'); +} + +async function optionalTool( + directory: string | undefined, + name: string, +): Promise { + if (directory === undefined) { + return undefined; + } + const path = join(directory, name); + return (await isFile(path)) ? path : undefined; +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +async function pickPort(): Promise { + const server = createServer(); + await new Promise((resolveOpen, rejectOpen) => { + server.once('error', rejectOpen); + server.listen(0, SERVER_HOST, resolveOpen); + }); + const address = server.address(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + if (address === null || typeof address === 'string') { + throw new Error('failed to allocate a native server TCP port'); + } + return address.port; +} + +async function createSocketDir(): Promise { + const path = await mkdtemp(join(tmpdir(), 'lpo-s-')); + await chmod(path, 0o700); + return path; +} + +async function runPgDump(pgDump: string, connectionString: string): Promise { + return runCommand(pgDump, [connectionString]); +} + +async function runCommand(command: string, args: string[]): Promise { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'] }); + const chunks: Uint8Array[] = []; + child.stdout.on('data', (chunk: Buffer) => chunks.push(new Uint8Array(chunk))); + const code = await new Promise((resolve) => child.once('exit', resolve)); + if (code !== 0) { + throw new Error(`${command} exited with status ${code}`); + } + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +async function waitForChild(child: ManagedChild, timeoutMs: number): Promise { + return Promise.race([child.wait().then(() => true), sleep(timeoutMs).then(() => false)]); +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +function asServerHandle(handle: RuntimeHandle): ServerHandle { + if (handle instanceof ServerHandle) { + return handle; + } + throw new Error('invalid native server handle'); +} + +function runtimeName(): 'node' | 'bun' | 'deno' { + if (typeof (globalThis as { Deno?: unknown }).Deno !== 'undefined') { + return 'deno'; + } + if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') { + return 'bun'; + } + return 'node'; +} + +function errorString(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/sdks/js/src/runtime/types.ts b/src/sdks/js/src/runtime/types.ts new file mode 100644 index 00000000..a9cd2d81 --- /dev/null +++ b/src/sdks/js/src/runtime/types.ts @@ -0,0 +1,28 @@ +import type { NormalizedOpenConfig } from '../config.js'; +import type { + BackupFormat, + EngineCapabilities, + JavaScriptRuntime, + RawProtocolTransport, +} from '../types.js'; +import type { MaybePromise } from '../native/types.js'; + +export type RuntimeHandle = unknown; + +export type RuntimeBinding = { + runtime: JavaScriptRuntime; + rawProtocolTransport: RawProtocolTransport; + protocolStream: boolean; + capabilities(handle: RuntimeHandle): MaybePromise; + open(config: NormalizedOpenConfig): MaybePromise; + execProtocolRaw(handle: RuntimeHandle, request: Uint8Array): MaybePromise; + execSimpleQuery?(handle: RuntimeHandle, sql: string): MaybePromise; + execProtocolStream?( + handle: RuntimeHandle, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, + ): MaybePromise; + backup(handle: RuntimeHandle, format: BackupFormat): MaybePromise; + cancel(handle: RuntimeHandle): MaybePromise; + detach(handle: RuntimeHandle): MaybePromise; +}; diff --git a/src/sdks/js/src/types.ts b/src/sdks/js/src/types.ts new file mode 100644 index 00000000..7c632d16 --- /dev/null +++ b/src/sdks/js/src/types.ts @@ -0,0 +1,123 @@ +export type EngineMode = 'nativeDirect' | 'nativeBroker' | 'nativeServer'; +export type DurabilityProfile = 'safe' | 'balanced' | 'fastDev'; +export type RuntimeFootprintProfile = 'throughput' | 'balancedMobile' | 'smallMobile'; +export type JavaScriptRuntime = 'node' | 'bun' | 'deno'; +export type RawProtocolTransport = + | 'node-addon' + | 'bun-ffi' + | 'deno-ffi' + | 'broker-ipc' + | 'server-wire'; +export type BackupFormat = 'sql' | 'physicalArchive' | 'oliphauntArchive'; +export type BrokerTransport = 'auto' | 'unix' | 'tcp'; + +export type PostgresStartupGUC = + | string + | { + readonly name: string; + readonly value: string; + }; + +export type BinaryInput = ArrayBuffer | ArrayBufferView | Uint8Array | ReadonlyArray; + +export type OpenConfig = { + engine?: EngineMode; + root?: string; + temporary?: boolean; + durability?: DurabilityProfile; + runtimeFootprint?: RuntimeFootprintProfile; + startupGUCs?: ReadonlyArray; + username?: string; + database?: string; + extensions?: ReadonlyArray; + libraryPath?: string; + runtimeDirectory?: string; + maxClientSessions?: number; + brokerExecutable?: string; + brokerMaxRoots?: number; + brokerTransport?: BrokerTransport; + serverExecutable?: string; + serverPort?: number; + serverToolDirectory?: string; +}; + +export type EngineCapabilities = { + engine: EngineMode; + processIsolated: boolean; + multiRoot: boolean; + reopenable: boolean; + sameRootLogicalReopen: boolean; + rootSwitchable: boolean; + crashRestartable: boolean; + independentSessions: boolean; + maxClientSessions: number; + protocolRaw: boolean; + protocolStream: boolean; + queryCancel: boolean; + backupRestore: boolean; + backupFormats: BackupFormat[]; + restoreFormats: BackupFormat[]; + simpleQuery: boolean; + extensions: boolean; + connectionString?: string; + rawProtocolTransport?: RawProtocolTransport; +}; + +export type EngineModeSupport = { + engine: EngineMode; + available: boolean; + capabilities: EngineCapabilities; + unavailableReason?: string; +}; + +export type BackupArtifact = { + format: BackupFormat; + bytes: Uint8Array; +}; + +export type RestoreOptions = { + engine?: EngineMode; + root: string; + artifact: BackupArtifact; + replaceExisting?: boolean; + libraryPath?: string; + brokerExecutable?: string; +}; + +export type BackgroundPreparationOptions = { + cancelActiveWork?: boolean; + checkpointWhenIdle?: boolean; +}; + +export type BackgroundPreparationResult = { + cancelledActiveWork: boolean; + checkpointed: boolean; + skippedCheckpointReason?: 'activeWork' | 'transactionActive'; +}; + +export type ProtocolChunkCallback = (chunk: Uint8Array) => void; + +export type OliphauntTransaction = { + execute(sql: string): Promise; + query( + sql: string, + parameters?: ReadonlyArray, + ): Promise; + execProtocolRaw(input: BinaryInput): Promise; + execProtocolStream(input: BinaryInput, onChunk: ProtocolChunkCallback): Promise; +}; + +export type SupportedModesOptions = { + libraryPath?: string; + runtimeDirectory?: string; + brokerExecutable?: string; + brokerTransport?: BrokerTransport; + serverExecutable?: string; + serverToolDirectory?: string; +}; + +export type OliphauntClient = { + supportedModes(options?: SupportedModesOptions): Promise; + open(config?: OpenConfig): Promise; + restore(options: RestoreOptions): Promise; +}; diff --git a/src/sdks/js/tools/check-sdk.sh b/src/sdks/js/tools/check-sdk.sh new file mode 100755 index 00000000..73c6516e --- /dev/null +++ b/src/sdks/js/tools/check-sdk.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +. "$root/tools/runtime/preflight.sh" + +scratch_root_base="${OLIPHAUNT_SDK_CHECK_SCRATCH:-$root/target/liboliphaunt-sdk-check/oliphaunt-js}" +source_package_dir="src/sdks/js" +mode="${1:-release-check}" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require_source_text() { + file="$1" + expected="$2" + message="$3" + if ! grep -Fq "$expected" "$file"; then + echo "$message" >&2 + echo "expected '$expected' in $file" >&2 + exit 1 + fi +} + +prepare_package_worktree() { + require rsync + rm -rf "$package_dir" + mkdir -p "$package_dir" + cat >"$scratch_root/package.json" <<'JSON' +{ + "name": "oliphaunt-js-sdk-check-workspace", + "private": true, + "packageManager": "pnpm@11.5.0" +} +JSON + cat >"$scratch_root/pnpm-workspace.yaml" <<'YAML' +packages: + - "src/sdks/js" +catalog: + "@vitest/coverage-v8": ^4.1.8 + tsx: ^4.20.6 + typedoc: ^0.28.16 + typescript: ^5.9.3 + vitest: ^4.1.8 +minimumReleaseAge: 1440 +saveWorkspaceProtocol: rolling +updateNotifier: false +verifyDepsBeforeRun: false +confirmModulesPurge: false +autoInstallPeers: false + +allowBuilds: + core-js: false + esbuild: true + msgpackr-extract: true + sharp: true + unrs-resolver: true +YAML + cp pnpm-lock.yaml "$scratch_root/pnpm-lock.yaml" + cp LICENSE "$scratch_root/LICENSE" + mkdir -p "$scratch_root/fixtures" + mkdir -p "$scratch_root/tools/test" + rsync -a --delete src/shared/fixtures/ "$scratch_root/fixtures/" + rsync -a --delete tools/test/ "$scratch_root/tools/test/" + rsync -a --delete \ + --exclude node_modules \ + --exclude lib \ + "$source_package_dir/" "$package_dir/" + rm -rf "$scratch_root/node_modules" "$package_dir/node_modules" + run pnpm --dir "$scratch_root" install --frozen-lockfile + if [ ! -e "$package_dir/node_modules" ]; then + ln -s "$scratch_root/node_modules" "$package_dir/node_modules" + fi +} + +export_default_native_smoke_runtime() { + oliphaunt_runtime_native_host_export_defaults +} + +ensure_broker_smoke_helper() { + if [ -n "${OLIPHAUNT_BROKER:-}" ]; then + return + fi + require cargo + run cargo build -p oliphaunt-broker --locked + export_default_native_smoke_runtime +} + +case "$mode" in + check-static|test-unit|package-shape|smoke-runtime|regression|coverage|release-check) + ;; + --smoke) + mode="smoke-runtime" + ;; + "") + mode="release-check" + ;; + *) + echo "usage: src/sdks/js/tools/check-sdk.sh [check-static|test-unit|package-shape|smoke-runtime|regression|coverage|release-check]" >&2 + exit 2 + ;; +esac + +scratch_root="$scratch_root_base/$mode" +package_dir="$scratch_root/$source_package_dir" + +require node +require pnpm +export CI="${CI:-1}" + +if [ "$mode" = "coverage" ]; then + exec tools/coverage/run-product oliphaunt-js +fi + +prepare_package_worktree +if [ "$mode" = "test-unit" ]; then + run pnpm --dir "$package_dir" test --if-present + exit 0 +fi + +run pnpm --dir "$package_dir" run build +run pnpm --dir "$package_dir" run typecheck +if [ "$mode" = "release-check" ] || [ "$mode" = "regression" ]; then + run pnpm --dir "$package_dir" test --if-present +fi + +if [ "$mode" != "check-static" ]; then + pack_dir="$(mktemp -d "$scratch_root/pack.XXXXXX")" + pack_json="$(pnpm --dir "$package_dir" pack --pack-destination "$pack_dir" --json)" + printf '%s\n' "$pack_json" + pack_file="$( + PACK_JSON="$pack_json" PACK_DIR="$pack_dir" node -e " +const manifest = JSON.parse(process.env.PACK_JSON || '{}'); +if (!manifest.filename || !manifest.filename.endsWith('.tgz')) { + throw new Error('pnpm pack did not report a .tgz filename'); +} +const path = require('node:path'); +console.log(path.isAbsolute(manifest.filename) ? manifest.filename : path.join(process.env.PACK_DIR || '', manifest.filename)); +" + )" + tar -xOf "$pack_file" package/package.json | node -e " +let input = ''; +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + const pkg = JSON.parse(input); + const expected = { + '@oliphaunt/node-direct-darwin-arm64': '0.1.0', + '@oliphaunt/node-direct-linux-arm64-gnu': '0.1.0', + '@oliphaunt/node-direct-linux-x64-gnu': '0.1.0', + '@oliphaunt/node-direct-win32-x64-msvc': '0.1.0', + }; + if (JSON.stringify(pkg.optionalDependencies || {}) !== JSON.stringify(expected)) { + throw new Error('packed TypeScript package must rewrite Node direct optional dependencies to exact published versions'); + } + for (const scriptName of ['preinstall', 'install', 'postinstall', 'prepare']) { + if (pkg.scripts && Object.hasOwn(pkg.scripts, scriptName)) { + throw new Error('packed TypeScript package must not run consumer install lifecycle script ' + scriptName); + } + } +}); +" + if [ "${OLIPHAUNT_JS_SKIP_REGISTRY_DRY_RUN:-0}" != "1" ]; then + run pnpm --dir "$package_dir" exec jsr publish --dry-run --allow-dirty + fi + cat >"$package_dir/.oliphaunt-bun-smoke.ts" <<'TS' +import { Oliphaunt, createBunNativeBinding, simpleQuery } from './lib/index.js'; + +const bytes: Uint8Array = simpleQuery('SELECT 1'); +if (bytes.byteLength === 0) { + throw new Error('empty protocol frame'); +} +if (typeof Oliphaunt.supportedModes !== 'function') { + throw new Error('missing Oliphaunt.supportedModes'); +} +if (typeof createBunNativeBinding !== 'function') { + throw new Error('missing Bun native binding export'); +} +TS + run "$root/tools/dev/bun.sh" "$package_dir/.oliphaunt-bun-smoke.ts" + rm -f "$package_dir/.oliphaunt-bun-smoke.ts" + cat >"$package_dir/.oliphaunt-deno-smoke.ts" <<'TS' +import { Oliphaunt, createDenoNativeBinding, simpleQuery } from './lib/index.js'; + +const bytes: Uint8Array = simpleQuery('SELECT 1'); +if (bytes.byteLength === 0) { + throw new Error('empty protocol frame'); +} +if (typeof Oliphaunt.supportedModes !== 'function') { + throw new Error('missing Oliphaunt.supportedModes'); +} +if (typeof createDenoNativeBinding !== 'function') { + throw new Error('missing Deno native binding export'); +} +if (typeof Deno.version.deno !== 'string') { + throw new Error('Deno runtime metadata missing'); +} +TS + run "$root/tools/dev/deno.sh" run --allow-read --allow-env "$package_dir/.oliphaunt-deno-smoke.ts" + rm -f "$package_dir/.oliphaunt-deno-smoke.ts" +fi + +base64_runtime_hits="$( + if command -v rg >/dev/null 2>&1; then + rg -n -i --glob '!**/README.md' --glob '!**/node_modules/**' \ + --glob '!**/__tests__/**' \ + 'base64|atob|btoa' \ + "$package_dir/src" \ + "$package_dir/package.json" || true + else + grep -RInE 'base64|atob|btoa' "$package_dir/src" "$package_dir/package.json" 2>/dev/null | + grep -Ev '(/README\.md|/node_modules/|/__tests__/)' || true + fi +)" +if [ -n "$base64_runtime_hits" ]; then + echo "TypeScript SDK runtime must keep protocol bytes as Uint8Array, not base64:" >&2 + echo "$base64_runtime_hits" >&2 + exit 1 +fi + +require_source_text "$package_dir/package.json" '"./node"' \ + "TypeScript SDK package exports must include an explicit Node entrypoint" +require_source_text "$package_dir/package.json" '"./bun"' \ + "TypeScript SDK package exports must include an explicit Bun entrypoint" +require_source_text "$package_dir/package.json" '"./deno"' \ + "TypeScript SDK package exports must include an explicit Deno entrypoint" +require_source_text "$package_dir/package.json" '"liboliphauntVersion"' \ + "TypeScript SDK package metadata must pin the compatible liboliphaunt release" +require_source_text "$package_dir/package.json" '"brokerVersion"' \ + "TypeScript SDK package metadata must pin the compatible Rust broker helper release" +require_source_text "$package_dir/package.json" '"nodeDirectAddon"' \ + "TypeScript SDK package metadata must pin the compatible Node.js native-direct adapter release" +node -e " +const pkg = require(process.argv[1]); +const expected = [ + '@oliphaunt/node-direct-darwin-arm64', + '@oliphaunt/node-direct-linux-arm64-gnu', + '@oliphaunt/node-direct-linux-x64-gnu', + '@oliphaunt/node-direct-win32-x64-msvc', +]; +const optional = Object.keys(pkg.optionalDependencies || {}).sort(); +if (pkg.dependencies || JSON.stringify(optional) !== JSON.stringify(expected.sort())) { + throw new Error('TypeScript SDK normal installs may only declare Node direct optional platform packages'); +} +" "$package_dir/package.json" +require_source_text "$package_dir/jsr.json" '"./deno": "./src/native/deno.ts"' \ + "TypeScript SDK must publish a Deno-native JSR entrypoint" +require_source_text "$package_dir/src/native/node.ts" "loadNodeDirectAddon" \ + "TypeScript Node native-direct binding must load the Oliphaunt-owned prebuilt Node-API adapter" +require_source_text "$package_dir/src/client.ts" "defaultEngineForRuntime(runtime: JavaScriptRuntime" \ + "TypeScript SDK must make the default engine explicit" +require_source_text "$package_dir/src/client.ts" "case 'node':" \ + "TypeScript SDK must treat Node.js consistently in default engine selection" +require_source_text "$package_dir/src/client.ts" "return 'nativeDirect'" \ + "TypeScript SDK must default Node.js, Bun, and Deno to nativeDirect" +require_source_text "$package_dir/src/client.ts" "restorePhysicalArchiveWithBroker" \ + "TypeScript SDK must keep explicit broker restore support separate from nativeDirect defaults" +require_source_text "$package_dir/src/native/common.ts" "liboliphaunt-native-v" \ + "TypeScript SDK must know the product-scoped liboliphaunt release tag prefix" +require_source_text "$package_dir/src/native/assets-node.ts" "liboliphauntReleaseAssetUrl" \ + "TypeScript Node/Bun native binding must resolve compatible liboliphaunt release assets" +require_source_text "$package_dir/src/native/node-addon.ts" "oliphaunt-node-direct" \ + "TypeScript Node native-direct binding must resolve compatible prebuilt Node-API adapter release assets" +require_source_text "$package_dir/src/native/node-addon.ts" "OLIPHAUNT_NODE_ADDON_ASSET_DIR" \ + "TypeScript Node native-direct adapter resolver must support local release-asset fixtures for tests and release validation" +require_source_text "$root/src/runtimes/node-direct/tools/build-node-addon.sh" "oliphaunt-node-direct-\$version-\$target.tar.gz" \ + "Node direct runtime must package the prebuilt Node.js native-direct adapter as a release asset" +require_source_text "$root/tools/release/release.py" "ensure_node_direct_release_assets" \ + "Node direct release dry-run must validate staged Node.js native-direct adapter release assets" +require_source_text "$root/tools/release/release.py" "node_direct_optional_npm_tarballs" \ + "Node direct release dry-run must validate staged optional npm tarballs from builder jobs" +require_source_text "$package_dir/src/native/assets-deno.ts" "liboliphauntReleaseAssetUrl" \ + "TypeScript Deno native binding must resolve compatible liboliphaunt release assets" +require_source_text "$package_dir/src/native/tar.ts" "extractTarArchive" \ + "TypeScript SDK must extract verified liboliphaunt release assets without shelling out" +require_source_text "$package_dir/src/client.ts" "supportedModes(options: SupportedModesOptions = {}): Promise" \ + "TypeScript SDK must expose mode support discovery" +require_source_text "$package_dir/src/client.ts" "async transaction" \ + "TypeScript SDK must expose the transaction helper" +require_source_text "$package_dir/src/client.ts" "async checkpoint(): Promise" \ + "TypeScript SDK must expose checkpoint" +require_source_text "$package_dir/src/config.ts" "pgdata: join(resolvedRoot, 'pgdata')" \ + "TypeScript SDK roots must use the shared Oliphaunt root/pgdata layout" +require_source_text "$package_dir/src/types.ts" "backupFormats: BackupFormat[]" \ + "TypeScript SDK capabilities must expose backup formats" +require_source_text "$package_dir/src/types.ts" "restoreFormats: BackupFormat[]" \ + "TypeScript SDK capabilities must expose restore formats" +require_source_text "$package_dir/src/query.ts" "function validateUtf8(bytes: Uint8Array, label: string): void" \ + "TypeScript SDK query parser must reject malformed backend UTF-8" +require_source_text "$package_dir/src/__tests__/protocol-fixtures.test.ts" "query-response-cases.json" \ + "TypeScript SDK tests must consume the shared protocol fixture corpus" +require_source_text "$package_dir/src/__tests__/broker-frames.test.ts" "encodeBrokerRequest" \ + "TypeScript SDK tests must cover the native broker frame codec" +require_source_text "$package_dir/src/__tests__/server-wire.test.ts" "encodeStartupMessage" \ + "TypeScript SDK tests must cover the native server wire client" +require_source_text "$package_dir/src/__tests__/physical-archive.test.ts" "createPhysicalArchive" \ + "TypeScript SDK tests must cover native server physical archive backup assembly" +require_source_text "$package_dir/src/__tests__/asset-resolver.test.ts" "nodeResolverInstallsVerifiedReleaseAsset" \ + "TypeScript SDK tests must cover verified liboliphaunt release asset installation" +require_source_text "$package_dir/src/__tests__/asset-resolver.test.ts" "nodeDirectAddonReleaseMetadataMatchesTypeScriptAssets" \ + "TypeScript SDK tests must cover verified Node.js native-direct adapter release asset metadata" +require_source_text "$package_dir/src/__tests__/native-smoke.ts" "smokeMode('nativeBroker'" \ + "TypeScript SDK smoke must execute native broker mode when OLIPHAUNT_BROKER is set" +require_source_text "$package_dir/src/__tests__/native-smoke.ts" "smokeMode('nativeServer'" \ + "TypeScript SDK smoke must execute native server mode when OLIPHAUNT_POSTGRES is set" +require_source_text "$package_dir/src/__tests__/native-smoke.ts" "restoreSmokeBackup" \ + "TypeScript SDK smoke must restore physical backup artifacts and reopen restored roots" +require_source_text "$package_dir/src/runtime/broker.ts" "resolveBrokerNativeInstall" \ + "TypeScript broker mode must resolve the same liboliphaunt native install that direct mode uses" +require_source_text "$package_dir/src/runtime/broker.ts" "OLIPHAUNT_INSTALL_DIR" \ + "TypeScript broker mode must pass the resolved PostgreSQL runtime tree to the Rust helper" +require_source_text "$package_dir/src/runtime/broker.ts" "LIBOLIPHAUNT_PATH" \ + "TypeScript broker mode must pass the resolved liboliphaunt library to the Rust helper" +require_source_text "$package_dir/src/runtime/broker.ts" "oliphauntBrokerReleaseAssetUrl" \ + "TypeScript broker mode must resolve the published Rust broker helper release asset" +require_source_text "$package_dir/src/runtime/broker.ts" "OLIPHAUNT_BROKER_ASSET_DIR" \ + "TypeScript broker helper resolver must support local release-asset fixtures for tests and release validation" +require_source_text "$package_dir/src/runtime/broker.ts" "restorePhysicalArchiveWithBroker" \ + "TypeScript broker helper must restore physical archives without requiring a Node native FFI dependency" +require_source_text "$package_dir/tools/check-sdk.sh" "export_default_native_smoke_runtime" \ + "TypeScript SDK smoke must discover native artifacts produced by the liboliphaunt smoke dependency" +require_source_text "$package_dir/tools/check-sdk.sh" "cargo build -p oliphaunt-broker --locked" \ + "TypeScript SDK smoke must build the broker helper when the default artifact is missing" + +if [ "$mode" = "check-static" ] || [ "$mode" = "package-shape" ]; then + exit 0 +fi + +if [ "$mode" = "smoke-runtime" ]; then + export_default_native_smoke_runtime + ensure_broker_smoke_helper + oliphaunt_runtime_native_host_require basic + if [ -z "${OLIPHAUNT_BROKER:-}" ]; then + echo "OLIPHAUNT_BROKER is required for the TypeScript SDK native broker smoke check" >&2 + exit 2 + fi + if [ -z "${OLIPHAUNT_POSTGRES:-}" ]; then + echo "OLIPHAUNT_POSTGRES is required for the TypeScript SDK native server smoke check" >&2 + exit 2 + fi + run pnpm --dir "$package_dir" exec tsx src/__tests__/native-smoke.ts +fi diff --git a/src/sdks/js/tsconfig.build.json b/src/sdks/js/tsconfig.build.json new file mode 100644 index 00000000..fcabcfe3 --- /dev/null +++ b/src/sdks/js/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": ["src/__tests__/**", "lib", "node_modules"] +} diff --git a/src/sdks/js/tsconfig.json b/src/sdks/js/tsconfig.json new file mode 100644 index 00000000..d105afcb --- /dev/null +++ b/src/sdks/js/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "lib": ["ES2023", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "outDir": "lib", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["lib", "node_modules"] +} diff --git a/src/sdks/js/typedoc.json b/src/sdks/js/typedoc.json new file mode 100644 index 00000000..94456d32 --- /dev/null +++ b/src/sdks/js/typedoc.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src/index.ts"], + "exclude": ["src/__tests__/**"], + "excludePrivate": true, + "excludeProtected": true, + "gitRevision": "main", + "json": "../../target/docs/generated/api/typescript/typedoc.json", + "name": "Oliphaunt TypeScript SDK", + "out": "../../target/docs/generated/api/typescript/html", + "plugin": [], + "readme": "README.md", + "tsconfig": "tsconfig.build.json" +} diff --git a/src/sdks/kotlin/.gitignore b/src/sdks/kotlin/.gitignore new file mode 100644 index 00000000..c8c0cd07 --- /dev/null +++ b/src/sdks/kotlin/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.kotlin/ +build/ +target/ +**/build/ +**/.cxx/ +oliphaunt-android-gradle-plugin/bin/ diff --git a/src/sdks/kotlin/CHANGELOG.md b/src/sdks/kotlin/CHANGELOG.md new file mode 100644 index 00000000..71d5b821 --- /dev/null +++ b/src/sdks/kotlin/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + + +## [0.1.0] - 2026-06-01 + +### Changed + +- Initial Kotlin and Android SDK release lane. +- Rename project to Oliphaunt +- Organize polyglot release tooling diff --git a/src/sdks/kotlin/README.md b/src/sdks/kotlin/README.md new file mode 100644 index 00000000..f010856d --- /dev/null +++ b/src/sdks/kotlin/README.md @@ -0,0 +1,299 @@ +# Oliphaunt Kotlin SDK + +## Install + +Add the Android SDK and the app-applied Oliphaunt Android plugin from Maven +Central: + +```gradle +plugins { + id("com.android.application") + id("dev.oliphaunt.android") version "0.1.0" +} + +dependencies { + implementation("dev.oliphaunt:oliphaunt:0.1.0") +} + +oliphaunt { + extensions.add("vector") + // Optional: androidAbis.set(listOf("arm64-v8a")) +} +``` + +The plugin downloads and verifies checksum-covered `liboliphaunt-native-v0.1.0` +GitHub release assets, packages `liboliphaunt.so` for selected Android ABIs, +and bundles only the selected exact extension artifacts. If the app selects +`vector`, unrelated extension files are not copied into the APK/AAB. +Normal Android app consumers use Gradle, Maven Central, and the Android +toolchain they already have. They do not install Rust, run Cargo, build +PostgreSQL, or copy Oliphaunt native artifacts by hand. + +## Compatibility + +| SDK | Native core | Android distribution | +| --- | --- | --- | +| `dev.oliphaunt:oliphaunt` `0.1.0` | `liboliphaunt` `0.1.0` | Maven Central SDK and `dev.oliphaunt.android` plugin plus checksum-covered GitHub release assets | + +The Android release lane publishes `arm64-v8a` and `x86_64` artifacts. Apps may +restrict ABI packaging with `-PoliphauntAndroidAbiFilters=arm64-v8a` or another +comma-separated subset. + +## Quickstart + + +```kotlin +val db = OliphauntAndroid.open( + context = applicationContext, + config = OliphauntConfig( + mode = EngineMode.NativeDirect, + runtimeFootprint = RuntimeFootprintProfile.BalancedMobile, + startupGucs = listOf(PostgresStartupGuc("shared_buffers", "32MB")), + username = "postgres", + database = "postgres", + extensions = listOf("vector"), + ), +) +val result = db.query("SELECT 1::text AS value") +val value = result.getText(0, "value") +db.close() +``` + +Kotlin Multiplatform package for the native `liboliphaunt` product line. + +The common API mirrors the Rust SDK shape and uses suspend functions plus a +serialized `OliphauntDatabase` handle. Raw protocol execution, capability reporting, +SQL/physical backup artifacts, transaction closures, explicit PostgreSQL +checkpoints, startup `username`/`database` identity, and same-version physical +restore all use this shared shape. JVM, Android, and Kotlin/Native implementations can share the API +while platform modules provide the actual native runtime. +Use `OliphauntDatabase.supportedModes()` to discover the current platform default, +or pass an injected engine to inspect that engine. On Android, +`OliphauntAndroid.supportedModes()` reports the same Android facade contract. The +support entries still carry canonical direct/broker/server capability semantics +so unavailable modes are explicit and not confused with direct-mode aliases. +Capabilities report the same product contract as Rust: raw and streaming +protocol support, cancellation, backup/restore, simple-query execution, +extensions, session semantics, multi-root support, and the concrete backup/restore formats +the opened mode accepts. Use `supportsBackupFormat(...)` and +`supportsRestoreFormat(...)` on either `EngineCapabilities` or `OliphauntDatabase` +for UI/action gating instead of manually matching lists. `backup(...)` enforces +those capabilities before it calls the platform session, and +`OliphauntDatabase.restore(...)` rejects unsupported restore artifact formats +before it calls the platform engine. Lifecycle capability fields follow the Rust +contract: `sameRootLogicalReopen`, `rootSwitchable`, and `crashRestartable` +distinguish direct's same-root resident reopen from broker/server +process-managed behavior. Native direct is not root-switchable or +crash-restartable. Mobile direct mode has one resident backend per app process +and one physical session. Use server mode only where the SDK reports true +server support; it is not a crash-isolated server and it does not provide +independent concurrent client sessions. + +This package uses the current Kotlin Multiplatform structure: common API in +`commonMain`, Kotlin/Native cinterop metadata under `src/nativeInterop`, Android +JNI/CMake sources under `src/androidMain/cpp`, and platform runtimes layered +behind `OliphauntEngine`. + +Kotlin/Native now includes a native-direct runtime over the `liboliphaunt` C ABI. +It builds a tiny static cinterop bridge that dynamically loads `liboliphaunt`, +opens one embedded PostgreSQL backend on a dedicated owner thread, serializes +handle-bound native work on that queue, and keeps `cancel()` outside that queue +so long-running SQL can be interrupted. `close()` marks the handle closed, waits +for the execution queue, and detaches the logical native session while keeping +the resident backend alive for same-root reopen. + +Android includes a native-direct runtime over JNI with the same owner-thread +session model. +Native Android apps should use the Android entrypoint because it needs a +`Context` for app storage and packaged assets: + +Kotlin defaults to the mobile resident profile: `runtimeFootprint = +RuntimeFootprintProfile.BalancedMobile` and `durability = +DurabilityProfile.Balanced`. Use `Safe` when last-commit survival matters more +than commit latency, `Throughput` for throughput-lane diagnostics, or +`SmallMobile` for memory-pressure experiments. `startupGucs` are validated and +appended after the footprint and durability defaults so profiling builds can +override specific PostgreSQL GUCs without changing the native ABI. + +Use `database.transaction { tx -> ... }` for multi-step work that must stay on +the same physical session. Database calls outside the active `OliphauntTransaction` +are rejected until the transaction commits or rolls back. +Use `database.checkpoint()` to request a PostgreSQL checkpoint through the same +serialized session; it is rejected while a transaction is active. + +Calling the common `OliphauntDatabase.open(...)` or `OliphauntDatabase.restore(...)` +defaults on Android fails with a targeted diagnostic that points to +`OliphauntAndroid.open(context, config)` or +`OliphauntAndroid.restore(context, request)`. This keeps the common API honest +without hiding Android's required `Context`. + +For large responses or COPY-style traffic, use the streaming raw-protocol API so +native-direct runtimes can forward backend bytes without building a single owned +response first: + + +```kotlin +db.execProtocolStream(ProtocolRequest.simpleQuery("SELECT 1")) { chunk -> + consume(chunk.bytes) +} +``` + +For ordinary one-result-set SQL, use the typed simple-query helper: + + +```kotlin +val result = db.query("SELECT 1::text AS value") +val value = result.getText(0, "value") +``` + +`query(sql)` parses normal PostgreSQL backend protocol frames into field +metadata, rows, command tags, nulls, and structured PostgreSQL errors through +`PostgresException(PostgresError)`, preserving SQLSTATE and raw `ErrorResponse` +fields. Multi-result-set and COPY traffic stay on `execProtocolRaw`. +Pass a `List` for PostgreSQL extended-protocol parameters: + + +```kotlin +val result = db.query( + "SELECT $1::text AS value", + listOf(QueryParam.Text("hello")), +) +``` + +JVM keeps the shared API shape but intentionally reports an unavailable runtime; +desktop JVM apps should use the Rust/Tauri SDK path or a future server/broker +JVM binding rather than a fake direct-mode implementation. + +## Local Development + +On Kotlin/Native, `OliphauntDatabase.open(config)` and +`OliphauntDatabase.restore(request)` default to `NativeDirectEngine` for +`EngineMode.NativeDirect`. During local development the bridge resolves the +runtime through: + +- `LIBOLIPHAUNT_PATH`: path to `liboliphaunt.dylib` or equivalent shared + library; +- `OLIPHAUNT_INSTALL_DIR`: PostgreSQL install/runtime directory; +- `OLIPHAUNT_KOTLIN_LIBRARY`: Kotlin-specific override for the shared library. + +```bash +cd src/sdks/kotlin +./gradlew check + +LIBOLIPHAUNT_PATH=/path/to/liboliphaunt.dylib \ +OLIPHAUNT_INSTALL_DIR=/path/to/postgres-install \ + ./gradlew :oliphaunt:macosArm64Test --rerun-tasks +``` + +`src/sdks/kotlin/tools/check-sdk.sh` defaults Android Gradle/CMake work to one +host-appropriate ABI for fast local iteration. Use +`OLIPHAUNT_KOTLIN_ANDROID_ABI_FILTERS=all` for full ABI coverage, or a +comma-separated subset such as `arm64-v8a,x86_64`. React Native forwards its +matching ABI setting through the same `-PoliphauntAndroidAbiFilters=...` Gradle +property so the delegated Android runtime and RN adapter validate the same ABI +matrix. + +Kotlin/Native accepts `OliphauntConfig(extensions = listOf("vector"))` when +`NativeDirectEngine` has a runtime directory, or when +`OLIPHAUNT_INSTALL_DIR`/`OLIPHAUNT_RUNTIME_DIR` points at a runtime built +with those extensions. Extension names are validated before native code is loaded. + +The Maven Central artifact is the Android SDK and JNI adapter. App builds select +the compatible `liboliphaunt` release with Gradle properties; the SDK downloads +and verifies matching checksum-covered GitHub release assets during the normal +Gradle build. Consumer devices receive the base runtime and only the exact SQL +extensions the app selected. + +Android packages runtime/template assets from the published +`liboliphaunt-native-v` release: + +```bash +./gradlew :oliphaunt:assembleDebug \ + -PoliphauntLiboliphauntVersion=0.1.0 \ + -PoliphauntExtensions=vector +``` + +The resolver downloads `liboliphaunt--release-assets.sha256`, +`liboliphaunt--runtime-resources.tar.gz`, and ABI-specific Android JNI +archives from +`https://github.com/f0rr0/oliphaunt/releases/download/liboliphaunt-native-v`. +Selected extensions are resolved from exact extension releases such as +`oliphaunt-extension-vector-v0.1.0`; each extension release provides its own +manifest, checksums, runtime artifact, and Android static archives. External +extensions can move independently from the base runtime: + +```bash +./gradlew :oliphaunt:assembleRelease \ + -PoliphauntLiboliphauntVersion=0.1.0 \ + -PoliphauntExtensions=vector \ + -PoliphauntExtensionVersions=vector=0.1.0 +``` + +Override the base runtime URL with `-PoliphauntAssetBaseUrl=` for +release-candidate validation. The Android SDK requires +`schema=oliphaunt-runtime-resources-v1`, validates the runtime +`layout=postgres-runtime-files-v1` and template +`layout=postgres-template-pgdata-v1`, and preserves `extensions`, +`sharedPreloadLibraries`, and `mobileStaticRegistryState` from +`manifest.properties`. It rejects a selected extension at runtime when the +package does not advertise that exact extension or reports pending mobile +static-registry rows. Split-resource Android builds are development-only for +module-backed extensions: they can record selected extensions and pending native +module stems, but they cannot mark the package mobile-complete because they do +not generate the C static-registry source. Release mobile extension artifacts include +`static-registry/oliphaunt_static_registry.c` when complete. The Android native +bridge first looks for the registry in `liboliphaunt.so`, then in an optional +`liboliphaunt_extensions.so`, and registers those rows through the loaded +`oliphaunt_register_static_extensions` symbol before the first `oliphaunt_init`. +The same runtime-resource output includes `package-size.tsv`; Android apps can call +`OliphauntAndroid.packageSizeReport(context)` for packaged app assets or +`OliphauntAndroid.packageSizeReport(resourceRoot)` for local unpacked resource +smoke tests. Both paths inspect total package bytes, +runtime/template/static-registry bytes, de-duplicated selected extension bytes, +and per-extension footprints without rewalking packaged assets. + +Package the Android native C ABI library with a normal `jniLibs` directory: + +```text +jniLibs/ + arm64-v8a/liboliphaunt.so + x86_64/liboliphaunt.so +``` + +```bash +./gradlew :oliphaunt:assembleDebug \ + -PoliphauntRuntimeResourcesDir=../../../target/oliphaunt-resources \ + -PoliphauntAndroidJniLibsDir=/path/to/jniLibs +``` + +Each ABI directory may include additional `.so` dependencies, but it must +include `liboliphaunt.so`. The Gradle task rejects symlinks, unknown ABI names, and +nested library layouts so the AAR shape remains predictable. + +For exact mobile extension selection without rebuilding extension sources, Gradle +links only the selected archives named by the runtime-resource manifest into a +small `liboliphaunt_extensions.so` support library: + +```bash +./gradlew :oliphaunt:assembleRelease \ + -PoliphauntLiboliphauntVersion=0.1.0 \ + -PoliphauntExtensions=vector +``` + +The archive root may contain either `extensions//liboliphaunt_extension_.a` +for a single ABI or `/extensions//liboliphaunt_extension_.a` +for multi-ABI release artifacts. Missing selected archives fail the native build. + +You can still pass the split directories directly: + +```bash +./gradlew :oliphaunt:assembleDebug \ + -PoliphauntRuntimeDir=/path/to/postgres-install-root \ + -PoliphauntTemplatePgdataDir=/path/to/template-pgdata \ + -PoliphauntExtensions=vector +``` + +The AAR stores them under `assets/oliphaunt/` with content-keyed manifests. At +runtime the SDK materializes the selected runtime once under `noBackupFilesDir` +and hydrates new PGDATA roots from the packaged template. Empty Android roots +require a packaged template PGDATA or an existing root with `PG_VERSION`. diff --git a/src/sdks/kotlin/VERSION b/src/sdks/kotlin/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/sdks/kotlin/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/sdks/kotlin/build.gradle.kts b/src/sdks/kotlin/build.gradle.kts new file mode 100644 index 00000000..c0ee48a8 --- /dev/null +++ b/src/sdks/kotlin/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.android.library) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.dokka) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.maven.publish) apply false + alias(libs.plugins.spotless) apply false +} diff --git a/src/sdks/kotlin/gradle.properties b/src/sdks/kotlin/gradle.properties new file mode 100644 index 00000000..6af6b564 --- /dev/null +++ b/src/sdks/kotlin/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1536m -Dfile.encoding=UTF-8 +kotlin.mpp.enableCInteropCommonization=true +kotlin.mpp.applyDefaultHierarchyTemplate=false +GROUP=dev.oliphaunt +# x-release-please-start-version +VERSION_NAME=0.1.0 +# x-release-please-end diff --git a/src/sdks/kotlin/gradle/libs.versions.toml b/src/sdks/kotlin/gradle/libs.versions.toml new file mode 100644 index 00000000..16e45335 --- /dev/null +++ b/src/sdks/kotlin/gradle/libs.versions.toml @@ -0,0 +1,25 @@ +[versions] +android-gradle-plugin = "8.13.1" +kotlin = "2.1.20" +kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.8.1" +maven-publish = "0.34.0" +spotless = "8.5.1" +detekt = "2.0.0-alpha.3" +dokka = "2.2.0" +kover = "0.9.8" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +[plugins] +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +detekt = { id = "dev.detekt", version.ref = "detekt" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } diff --git a/src/sdks/kotlin/gradle/wrapper/gradle-wrapper.jar b/src/sdks/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/src/sdks/kotlin/gradle/wrapper/gradle-wrapper.properties b/src/sdks/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ff31567b --- /dev/null +++ b/src/sdks/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=30000 +retries=3 +retryBackOffMs=1000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/sdks/kotlin/gradlew b/src/sdks/kotlin/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/src/sdks/kotlin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/src/sdks/kotlin/gradlew.bat b/src/sdks/kotlin/gradlew.bat new file mode 100644 index 00000000..aa5f10b0 --- /dev/null +++ b/src/sdks/kotlin/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/sdks/kotlin/moon.yml b/src/sdks/kotlin/moon.yml new file mode 100644 index 00000000..3201e1b7 --- /dev/null +++ b/src/sdks/kotlin/moon.yml @@ -0,0 +1,177 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-kotlin" +language: "kotlin" +layer: "library" +stack: "systems" +tags: ["sdk", "kotlin", "android", "native", "release-product"] +dependsOn: + - "liboliphaunt-native" + - id: "shared-fixtures" + scope: "build" + +project: + title: "Oliphaunt Kotlin SDK" + description: "Kotlin and Android SDK for native liboliphaunt." + owner: "oliphaunt" + release: + component: "oliphaunt-kotlin" + packagePath: "src/sdks/kotlin" + +owners: + defaultOwner: "@oliphaunt/sdk-android" + paths: + "**/*.kt": ["@oliphaunt/sdk-android"] + "**/*.kts": ["@oliphaunt/sdk-android"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash src/sdks/kotlin/tools/check-sdk.sh check-static" + deps: + - "liboliphaunt-native:check" + - "shared-contracts:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/tools/android-smoke-artifacts.sh" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "unit"] + command: "bash src/sdks/kotlin/tools/check-sdk.sh test-unit" + deps: + - "liboliphaunt-native:check" + - "shared-fixtures:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/tools/android-smoke-artifacts.sh" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + smoke: + tags: ["runtime", "smoke"] + command: "bash src/sdks/kotlin/tools/check-sdk.sh smoke-runtime" + deps: + - "liboliphaunt-native:check" + inputs: + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/kotlin/**/*" + - "/tools/runtime/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + options: + cache: local + runFromWorkspaceRoot: true + package: + tags: ["package"] + command: "bash src/sdks/kotlin/tools/check-sdk.sh package-shape" + deps: + - "liboliphaunt-native:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/tools/android-smoke-artifacts.sh" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + package-artifacts: + tags: ["release", "artifact-package", "ci-kotlin-sdk-package"] + command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-kotlin" + deps: + - "liboliphaunt-native:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/tools/android-smoke-artifacts.sh" + - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/runtime/**/*" + outputs: + - "/target/sdk-artifacts/oliphaunt-kotlin/**/*" + options: + cache: local + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "bash src/sdks/kotlin/tools/check-sdk.sh release-check" + deps: + - "liboliphaunt-native:check" + - "liboliphaunt-native:release-runtime" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/tools/android-smoke-artifacts.sh" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + bench: + tags: ["bench", "plan"] + command: "bash tools/perf/matrix/run_mobile_footprint_matrix.sh --plan-only --quick --platform android" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/sdks/kotlin/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false + regression: + tags: ["regression", "runtime"] + command: "bash src/sdks/kotlin/tools/check-sdk.sh regression" + deps: + - "liboliphaunt-native:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/tools/android-smoke-artifacts.sh" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + coverage: + tags: ["coverage", "quality"] + command: "tools/coverage/run-product oliphaunt-kotlin" + inputs: + - "/coverage/baseline.toml" + - "/src/shared/fixtures/**/*" + - "/src/sdks/kotlin/**/*" + - "/tools/coverage/**/*" + outputs: + - "/target/coverage/oliphaunt-kotlin/**/*" + options: + cache: true + runFromWorkspaceRoot: true + bench-run: + tags: ["bench", "measured"] + command: "bash tools/perf/matrix/run_mobile_footprint_matrix.sh --platform android" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/sdks/kotlin/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false diff --git a/src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts new file mode 100644 index 00000000..37bdb6ed --- /dev/null +++ b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts @@ -0,0 +1,63 @@ +plugins { + `java-gradle-plugin` + `maven-publish` + alias(libs.plugins.maven.publish) +} + +group = providers.gradleProperty("GROUP").orElse("dev.oliphaunt").get() +version = providers.gradleProperty("VERSION_NAME").orElse("0.1.0").get() + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +gradlePlugin { + plugins { + create("oliphauntAndroid") { + id = "dev.oliphaunt.android" + implementationClass = "dev.oliphaunt.android.OliphauntAndroidPlugin" + displayName = "Oliphaunt Android" + description = + "Resolves liboliphaunt Android runtime assets and exact PostgreSQL extensions for Android apps." + } + } +} + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + if ( + gradle.startParameter.taskNames.any { it.contains("MavenCentral", ignoreCase = true) } || + providers.gradleProperty("signAllPublications").map { + it.equals("true", ignoreCase = true) || it.equals("yes", ignoreCase = true) || it == "1" + }.orElse(false).get() + ) { + signAllPublications() + } + pom { + name.set("Oliphaunt Android Gradle Plugin") + description.set("App-applied Gradle plugin for liboliphaunt Android runtime and exact extension packaging.") + inceptionYear.set("2026") + url.set("https://github.com/f0rr0/oliphaunt") + licenses { + license { + name.set("MIT AND Apache-2.0 AND PostgreSQL") + url.set("https://github.com/f0rr0/oliphaunt/blob/main/LICENSE") + distribution.set("repo") + } + } + developers { + developer { + id.set("f0rr0") + name.set("Oliphaunt Maintainers") + url.set("https://github.com/f0rr0") + } + } + scm { + url.set("https://github.com/f0rr0/oliphaunt") + connection.set("scm:git:https://github.com/f0rr0/oliphaunt.git") + developerConnection.set("scm:git:ssh://git@github.com:f0rr0/oliphaunt.git") + } + } +} diff --git a/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidExtension.java b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidExtension.java new file mode 100644 index 00000000..96af94f8 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidExtension.java @@ -0,0 +1,26 @@ +package dev.oliphaunt.android; + +import javax.inject.Inject; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; + +public abstract class OliphauntAndroidExtension { + @Inject + public OliphauntAndroidExtension(ObjectFactory objects) { + getExtensions().convention(objects.listProperty(String.class).empty()); + getExtensionVersions().convention(objects.mapProperty(String.class, String.class).empty()); + getAndroidAbis().convention(objects.listProperty(String.class).value(java.util.List.of("arm64-v8a", "x86_64"))); + } + + public abstract Property getLiboliphauntVersion(); + + public abstract Property getAssetBaseUrl(); + + public abstract ListProperty getExtensions(); + + public abstract MapProperty getExtensionVersions(); + + public abstract ListProperty getAndroidAbis(); +} diff --git a/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidPlugin.java b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidPlugin.java new file mode 100644 index 00000000..b3b25216 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidPlugin.java @@ -0,0 +1,234 @@ +package dev.oliphaunt.android; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.file.Directory; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Sync; +import org.gradle.api.tasks.TaskProvider; + +public final class OliphauntAndroidPlugin implements Plugin { + @Override + public void apply(Project project) { + OliphauntAndroidExtension extension = + project.getExtensions().create("oliphaunt", OliphauntAndroidExtension.class); + String defaultVersion = defaultLiboliphauntVersion(); + extension + .getLiboliphauntVersion() + .convention( + project + .getProviders() + .gradleProperty("oliphauntLiboliphauntVersion") + .orElse(project.getProviders().environmentVariable("OLIPHAUNT_LIBOLIPHAUNT_VERSION")) + .orElse(defaultVersion)); + extension + .getAssetBaseUrl() + .convention( + extension + .getLiboliphauntVersion() + .map( + version -> + "https://github.com/f0rr0/oliphaunt/releases/download/liboliphaunt-native-v" + + version)); + extension + .getExtensions() + .convention( + project + .getProviders() + .gradleProperty("oliphauntExtensions") + .orElse(project.getProviders().environmentVariable("OLIPHAUNT_ANDROID_EXTENSIONS")) + .map(OliphauntAndroidPlugin::parsePortableList) + .orElse(List.of())); + extension + .getExtensionVersions() + .convention( + project + .getProviders() + .gradleProperty("oliphauntExtensionVersions") + .orElse(project.getProviders().environmentVariable("OLIPHAUNT_ANDROID_EXTENSION_VERSIONS")) + .map(OliphauntAndroidPlugin::parseVersionMap) + .orElse(Map.of())); + extension + .getAndroidAbis() + .convention( + project + .getProviders() + .gradleProperty("oliphauntAndroidAbiFilters") + .orElse(project.getProviders().gradleProperty("oliphauntAndroidAbis")) + .orElse(project.getProviders().environmentVariable("OLIPHAUNT_ANDROID_ABI_FILTERS")) + .map(OliphauntAndroidPlugin::parseAndroidAbis) + .orElse(List.of("arm64-v8a", "x86_64"))); + + Provider assetRoot = + project.getLayout().getBuildDirectory().dir("generated/oliphaunt-android-assets"); + Provider jniRoot = + project.getLayout().getBuildDirectory().dir("generated/oliphaunt-android-jniLibs"); + Provider resolvedRoot = + project.getLayout().getBuildDirectory().dir("oliphaunt/release-assets"); + + TaskProvider resolve = + project + .getTasks() + .register( + "resolveOliphauntAndroidAssets", + ResolveOliphauntAndroidAssetsTask.class, + task -> { + task.getVersion().set(extension.getLiboliphauntVersion()); + task.getAssetBaseUrl().set(extension.getAssetBaseUrl()); + task.getSelectedExtensions().set(extension.getExtensions()); + task.getExtensionVersions().set(extension.getExtensionVersions()); + task.getSelectedAbis().set(extension.getAndroidAbis()); + task.getAssetCacheDir().set(resolvedRoot.map(dir -> dir.dir("cache"))); + task.getRuntimeResourcesDir().set(resolvedRoot.map(dir -> dir.dir("runtime-resources"))); + task.getJniLibsDir().set(resolvedRoot.map(dir -> dir.dir("jniLibs"))); + task.getExtensionArchivesDir().set(resolvedRoot.map(dir -> dir.dir("extensionArchives"))); + }); + + TaskProvider prepareAssets = + project + .getTasks() + .register( + "prepareOliphauntAndroidAssets", + Sync.class, + task -> { + task.dependsOn(resolve); + task.from(resolve.flatMap(ResolveOliphauntAndroidAssetsTask::getRuntimeResourcesDir)); + task.into(assetRoot); + }); + TaskProvider prepareJniLibs = + project + .getTasks() + .register( + "prepareOliphauntAndroidJniLibs", + Sync.class, + task -> { + task.dependsOn(resolve); + task.from(resolve.flatMap(ResolveOliphauntAndroidAssetsTask::getJniLibsDir)); + task.into(jniRoot); + }); + + project + .getPluginManager() + .withPlugin( + "com.android.application", + ignored -> configureAndroid(project, assetRoot, jniRoot, prepareAssets, prepareJniLibs)); + project + .getPluginManager() + .withPlugin( + "com.android.library", + ignored -> configureAndroid(project, assetRoot, jniRoot, prepareAssets, prepareJniLibs)); + } + + private static void configureAndroid( + Project project, + Provider assetRoot, + Provider jniRoot, + TaskProvider prepareAssets, + TaskProvider prepareJniLibs) { + Object android = project.getExtensions().findByName("android"); + if (android == null) { + throw new GradleException("dev.oliphaunt.android requires the Android application or library plugin"); + } + Object sourceSets = invoke(android, "getSourceSets"); + Object main = invoke(sourceSets, "getByName", "main"); + invoke(invoke(main, "getAssets"), "srcDir", assetRoot.get().getAsFile()); + invoke(invoke(main, "getJniLibs"), "srcDir", jniRoot.get().getAsFile()); + project + .getTasks() + .matching(task -> task.getName().equals("preBuild")) + .configureEach( + task -> { + task.dependsOn(prepareAssets); + task.dependsOn(prepareJniLibs); + }); + } + + private static Object invoke(Object target, String method, Object... args) { + Method candidate = null; + for (Method methodCandidate : target.getClass().getMethods()) { + if (methodCandidate.getName().equals(method) && methodCandidate.getParameterCount() == args.length) { + candidate = methodCandidate; + break; + } + } + if (candidate == null) { + throw new GradleException("Android Gradle Plugin API no longer exposes " + method + " on " + target.getClass()); + } + try { + return candidate.invoke(target, args); + } catch (ReflectiveOperationException error) { + throw new GradleException("failed to call Android Gradle Plugin API " + method, error); + } + } + + private static List parsePortableList(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + return java.util.Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .distinct() + .sorted() + .peek( + value -> { + if (!value.matches("[A-Za-z0-9._-]{1,128}")) { + throw new GradleException( + "Oliphaunt Android extension or selector '" + + value + + "' must contain only ASCII letters, digits, '.', '_' or '-'"); + } + }) + .toList(); + } + + private static List parseAndroidAbis(String raw) { + if (raw == null || raw.isBlank() || raw.trim().equalsIgnoreCase("all")) { + return List.of("arm64-v8a", "x86_64"); + } + List values = parsePortableList(raw); + for (String value : values) { + String normalized = value.toLowerCase(Locale.ROOT); + if (!normalized.equals("arm64-v8a") && !normalized.equals("x86_64")) { + throw new GradleException("Oliphaunt release assets currently publish Android arm64-v8a and x86_64, got " + value); + } + } + return values; + } + + private static Map parseVersionMap(String raw) { + if (raw == null || raw.isBlank()) { + return Map.of(); + } + java.util.LinkedHashMap values = new java.util.LinkedHashMap<>(); + for (String item : raw.split(",")) { + String trimmed = item.trim(); + if (trimmed.isEmpty()) { + continue; + } + String[] parts = trimmed.split("=", 2); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + throw new GradleException("oliphauntExtensionVersions entries must use extension=version, got " + trimmed); + } + values.put(parts[0].trim(), parts[1].trim()); + } + return values; + } + + private static String defaultLiboliphauntVersion() { + try (java.io.InputStream stream = + OliphauntAndroidPlugin.class.getResourceAsStream("/dev/oliphaunt/android/liboliphaunt.version")) { + if (stream == null) { + throw new GradleException("Oliphaunt Android plugin is missing liboliphaunt.version"); + } + return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim(); + } catch (java.io.IOException error) { + throw new GradleException("failed to read embedded liboliphaunt version", error); + } + } +} diff --git a/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java new file mode 100644 index 00000000..49081eaa --- /dev/null +++ b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java @@ -0,0 +1,1019 @@ +package dev.oliphaunt.android; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; +import javax.inject.Inject; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ArchiveOperations; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.FileSystemOperations; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +@DisableCachingByDefault(because = "Downloads and verifies mutable remote release assets into an explicit local cache") +public abstract class ResolveOliphauntAndroidAssetsTask extends DefaultTask { + private final ArchiveOperations archiveOperations; + private final FileSystemOperations fileSystemOperations; + + @Inject + public ResolveOliphauntAndroidAssetsTask( + ArchiveOperations archiveOperations, FileSystemOperations fileSystemOperations) { + this.archiveOperations = archiveOperations; + this.fileSystemOperations = fileSystemOperations; + } + + @Input + public abstract Property getVersion(); + + @Input + public abstract Property getAssetBaseUrl(); + + @Input + public abstract ListProperty getSelectedAbis(); + + @Input + public abstract ListProperty getSelectedExtensions(); + + @Input + public abstract MapProperty getExtensionVersions(); + + @OutputDirectory + public abstract DirectoryProperty getAssetCacheDir(); + + @OutputDirectory + public abstract DirectoryProperty getRuntimeResourcesDir(); + + @OutputDirectory + public abstract DirectoryProperty getJniLibsDir(); + + @OutputDirectory + public abstract DirectoryProperty getExtensionArchivesDir(); + + @TaskAction + public void resolve() { + String releaseVersion = getVersion().get(); + validateReleaseVersion(releaseVersion); + File cache = getAssetCacheDir().get().getAsFile(); + if (!cache.mkdirs() && !cache.isDirectory()) { + throw new GradleException("could not create Oliphaunt release asset cache " + cache); + } + File checksumFile = downloadAsset("liboliphaunt-" + releaseVersion + "-release-assets.sha256", cache); + Map checksums = parseChecksums(checksumFile); + + LinkedHashSet assets = new LinkedHashSet<>(); + assets.add("liboliphaunt-" + releaseVersion + "-runtime-resources.tar.gz"); + List abis = effectiveAbis(); + for (String abi : abis) { + assets.add(androidBaseAsset(releaseVersion, abi)); + } + + Map downloaded = new LinkedHashMap<>(); + for (String asset : assets) { + downloaded.put(asset, downloadAndVerify(asset, cache, checksums)); + } + Map extensionDownloaded = new LinkedHashMap<>(); + List> selectedRows = selectedExtensionRows(releaseVersion, cache, extensionDownloaded, abis); + + unpackRuntimeResources(downloaded.get("liboliphaunt-" + releaseVersion + "-runtime-resources.tar.gz")); + mergeExtensionRuntimeArtifacts(extensionDownloaded, selectedRows); + unpackAndroidJniLibs(downloaded, releaseVersion, abis); + unpackAndroidExtensionArchives(extensionDownloaded); + } + + private List effectiveAbis() { + List abis = new ArrayList<>(getSelectedAbis().get()); + if (abis.isEmpty()) { + abis.add("arm64-v8a"); + abis.add("x86_64"); + } + for (String abi : abis) { + if (!abi.equals("arm64-v8a") && !abi.equals("x86_64")) { + throw new GradleException("liboliphaunt Android release assets are published for arm64-v8a and x86_64; got " + abi); + } + } + return abis; + } + + private static void validateReleaseVersion(String releaseVersion) { + if (releaseVersion == null || releaseVersion.isBlank() || !releaseVersion.matches("[A-Za-z0-9._-]+")) { + throw new GradleException("invalid liboliphaunt release version: " + releaseVersion); + } + } + + private static String androidBaseAsset(String releaseVersion, String abi) { + return switch (abi) { + case "arm64-v8a" -> "liboliphaunt-" + releaseVersion + "-android-arm64-v8a.tar.gz"; + case "x86_64" -> "liboliphaunt-" + releaseVersion + "-android-x86_64.tar.gz"; + default -> throw new GradleException("unsupported liboliphaunt Android ABI " + abi); + }; + } + + private File downloadAndVerify(String asset, File cache, Map checksums) { + File file = downloadAsset(asset, cache); + String expected = checksums.get(asset); + if (expected == null) { + throw new GradleException("liboliphaunt release checksum manifest does not cover " + asset); + } + String actual = sha256(file); + if (!expected.equals(actual)) { + throw new GradleException( + "liboliphaunt release asset checksum mismatch for " + + asset + + ": expected " + + expected + + ", got " + + actual); + } + return file; + } + + private File downloadAsset(String asset, File cache) { + if (asset.contains("/") || asset.contains("\\")) { + throw new GradleException("release asset name must be a plain file name: " + asset); + } + File output = new File(cache, asset); + if (output.isFile()) { + return output; + } + File tmp = new File(cache, "." + asset + ".tmp"); + String url = trimTrailingSlash(getAssetBaseUrl().get()) + "/" + asset; + try (var input = URI.create(url).toURL().openStream()) { + Files.copy(input, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.move(tmp.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException error) { + throw new GradleException("download liboliphaunt release asset " + url + ": " + error.getMessage(), error); + } + return output; + } + + private File downloadAndVerifyExtension( + String product, String version, String asset, File cache, Map checksums) { + File file = downloadExtensionAsset(product, version, asset, cache); + String expected = checksums.get(asset); + if (expected == null) { + throw new GradleException(product + " " + version + " checksum manifest does not cover " + asset); + } + String actual = sha256(file); + if (!expected.equals(actual)) { + throw new GradleException( + product + " " + version + " asset checksum mismatch for " + asset + ": expected " + expected + ", got " + actual); + } + return file; + } + + private File downloadExtensionAsset(String product, String version, String asset, File cache) { + if (asset.contains("/") || asset.contains("\\")) { + throw new GradleException("extension release asset name must be a plain file name: " + asset); + } + File output = new File(cache, asset); + if (output.isFile()) { + return output; + } + File tmp = new File(cache, "." + asset + ".tmp"); + String url = "https://github.com/f0rr0/oliphaunt/releases/download/" + product + "-v" + version + "/" + asset; + try (var input = URI.create(url).toURL().openStream()) { + Files.copy(input, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.move(tmp.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException error) { + throw new GradleException("download Oliphaunt extension release asset " + url + ": " + error.getMessage(), error); + } + return output; + } + + private static String trimTrailingSlash(String value) { + String out = value; + while (out.endsWith("/")) { + out = out.substring(0, out.length() - 1); + } + return out; + } + + private static Map parseChecksums(File file) { + Map checksums = new LinkedHashMap<>(); + try { + for (String line : Files.readAllLines(file.toPath(), StandardCharsets.UTF_8)) { + if (line.isBlank()) { + continue; + } + String[] parts = line.trim().split("\\s+"); + if (parts.length != 2 || !parts[1].startsWith("./")) { + throw new GradleException("malformed liboliphaunt checksum line in " + file + ": " + line); + } + checksums.put(parts[1].substring(2), parts[0]); + } + } catch (IOException error) { + throw new GradleException("read " + file + ": " + error.getMessage(), error); + } + return checksums; + } + + private static String sha256(File file) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (var input = Files.newInputStream(file.toPath())) { + byte[] buffer = new byte[1024 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + } + StringBuilder out = new StringBuilder(); + for (byte value : digest.digest()) { + out.append(String.format("%02x", value)); + } + return out.toString(); + } catch (Exception error) { + throw new GradleException("hash " + file + ": " + error.getMessage(), error); + } + } + + private List> selectedExtensionRows( + String defaultVersion, File cache, Map downloaded, List abis) { + if (getSelectedExtensions().get().isEmpty()) { + return List.of(); + } + Map> rows = new LinkedHashMap<>(); + for (String extension : getSelectedExtensions().get()) { + selectExtension(defaultVersion, cache, downloaded, rows, extension, abis); + } + return rows.values().stream() + .sorted(java.util.Comparator.comparing(row -> row.get("sql_name"))) + .toList(); + } + + private void selectExtension( + String defaultVersion, + File cache, + Map downloaded, + Map> rows, + String sqlName, + List abis) { + if (rows.containsKey(sqlName)) { + return; + } + String product = extensionProduct(sqlName); + String version = extensionVersion(sqlName, product, defaultVersion); + File extensionCache = new File(cache, product + "-" + version); + if (!extensionCache.mkdirs() && !extensionCache.isDirectory()) { + throw new GradleException("could not create Oliphaunt extension release asset cache " + extensionCache); + } + Map extensionChecksums = + parseChecksums(downloadExtensionAsset(product, version, product + "-" + version + "-release-assets.sha256", extensionCache)); + String manifestAsset = product + "-" + version + "-manifest.properties"; + File manifestFile = downloadAndVerifyExtension(product, version, manifestAsset, extensionCache, extensionChecksums); + Properties manifest = readProperties(manifestFile); + validateExtensionManifest(product, version, sqlName, manifest); + + for (String dependency : splitCsv(manifest.getProperty("dependencies"))) { + selectExtension(defaultVersion, cache, downloaded, rows, dependency, abis); + } + + LinkedHashSet runtimeAssets = new LinkedHashSet<>(); + LinkedHashSet archiveTargets = new LinkedHashSet<>(); + String nativeModuleStem = manifest.getProperty("nativeModuleStem", "").trim(); + for (String abi : abis) { + String target = androidTarget(abi); + String targetRuntimeAsset = requireExtensionAsset(manifest, product, target, "runtime", sqlName); + runtimeAssets.add(targetRuntimeAsset); + downloaded.computeIfAbsent( + targetRuntimeAsset, + asset -> downloadAndVerifyExtension(product, version, asset, extensionCache, extensionChecksums)); + if (!nativeModuleStem.isEmpty()) { + String staticArchiveAsset = requireExtensionAsset(manifest, product, target, "android-static-archive", sqlName); + downloaded.computeIfAbsent( + staticArchiveAsset, + asset -> downloadAndVerifyExtension(product, version, asset, extensionCache, extensionChecksums)); + archiveTargets.add(abi); + } + } + if (runtimeAssets.isEmpty()) { + throw new GradleException("selected extension " + sqlName + " did not resolve an Android runtime artifact"); + } + validateEquivalentAndroidRuntimeAssets(product, version, sqlName, runtimeAssets, extensionChecksums); + Map row = new LinkedHashMap<>(); + row.put("sql_name", sqlName); + row.put("runtime_artifact", runtimeAssets.iterator().next()); + row.put("native_module_stem", emptyToDash(manifest.getProperty("nativeModuleStem"))); + row.put("shared_preload", emptyToDash(manifest.getProperty("sharedPreloadLibraries"))); + row.put("dependencies", emptyToDash(manifest.getProperty("dependencies"))); + row.put("archive_targets", archiveTargets.isEmpty() ? "-" : String.join(",", archiveTargets)); + rows.put(sqlName, row); + } + + private static void validateEquivalentAndroidRuntimeAssets( + String product, + String version, + String sqlName, + LinkedHashSet runtimeAssets, + Map checksums) { + if (runtimeAssets.size() <= 1) { + return; + } + String expectedChecksum = null; + String expectedAsset = null; + for (String asset : runtimeAssets) { + String checksum = checksums.get(asset); + if (checksum == null) { + throw new GradleException(product + " " + version + " checksum manifest does not cover " + asset); + } + if (expectedChecksum == null) { + expectedChecksum = checksum; + expectedAsset = asset; + } else if (!expectedChecksum.equals(checksum)) { + throw new GradleException( + product + + " " + + version + + " publishes different Android runtime artifacts for " + + sqlName + + ": " + + expectedAsset + + " and " + + asset + + ". Android extension runtime payloads must be ABI-independent; put ABI-specific code in static archives."); + } + } + } + + private static String extensionProduct(String sqlName) { + if (!sqlName.matches("[A-Za-z0-9._-]{1,128}")) { + throw new GradleException("invalid Oliphaunt extension SQL name: " + sqlName); + } + return "oliphaunt-extension-" + sqlName.replace('_', '-'); + } + + private String extensionVersion(String sqlName, String product, String defaultVersion) { + Map versions = getExtensionVersions().get(); + String version = versions.get(sqlName); + if (version == null) { + version = versions.get(product); + } + if (version == null || version.isBlank()) { + version = defaultVersion; + } + validateReleaseVersion(version); + return version; + } + + private static String androidTarget(String abi) { + return switch (abi) { + case "arm64-v8a" -> "android-arm64-v8a"; + case "x86_64" -> "android-x86_64"; + default -> throw new GradleException("unsupported liboliphaunt Android ABI " + abi); + }; + } + + private static void validateExtensionManifest(String product, String version, String sqlName, Properties manifest) { + if (!"oliphaunt-extension-release-manifest-v1".equals(manifest.getProperty("schema"))) { + throw new GradleException(product + " " + version + " extension manifest has unsupported schema"); + } + if (!product.equals(manifest.getProperty("product"))) { + throw new GradleException(product + " " + version + " extension manifest declares product " + manifest.getProperty("product")); + } + if (!version.equals(manifest.getProperty("version"))) { + throw new GradleException(product + " " + version + " extension manifest declares version " + manifest.getProperty("version")); + } + if (!sqlName.equals(manifest.getProperty("sqlName"))) { + throw new GradleException(product + " " + version + " extension manifest declares sqlName " + manifest.getProperty("sqlName")); + } + if (!"true".equals(manifest.getProperty("mobileReleaseReady"))) { + throw new GradleException(sqlName + " is not marked mobileReleaseReady in " + product + " " + version); + } + } + + private static String requireExtensionAsset( + Properties manifest, String product, String target, String kind, String sqlName) { + String key = "asset.native." + target + "." + kind; + String value = manifest.getProperty(key); + if (value == null || value.isBlank()) { + throw new GradleException(product + " manifest has no " + kind + " asset for " + sqlName + " target " + target); + } + return value; + } + + private void unpackRuntimeResources(File archive) { + File output = getRuntimeResourcesDir().get().getAsFile(); + fileSystemOperations.delete(spec -> spec.delete(output)); + fileSystemOperations.copy( + spec -> { + spec.from(archiveOperations.tarTree(archiveOperations.gzip(archive))); + spec.into(output); + }); + } + + private void unpackAndroidJniLibs(Map downloaded, String releaseVersion, List abis) { + File output = getJniLibsDir().get().getAsFile(); + fileSystemOperations.delete(spec -> spec.delete(output)); + for (String abi : abis) { + String asset = androidBaseAsset(releaseVersion, abi); + File extractRoot = new File(getTemporaryDir(), "jni-" + abi); + fileSystemOperations.delete(spec -> spec.delete(extractRoot)); + fileSystemOperations.copy( + spec -> { + spec.from(archiveOperations.tarTree(archiveOperations.gzip(downloaded.get(asset)))); + spec.into(extractRoot); + }); + File source = new File(extractRoot, "jni/" + abi); + if (!source.isDirectory()) { + throw new GradleException("liboliphaunt Android asset " + asset + " did not contain jni/" + abi); + } + fileSystemOperations.copy( + spec -> { + spec.from(source); + spec.into(new File(output, abi)); + }); + } + } + + private void unpackAndroidExtensionArchives(Map downloaded) { + File output = getExtensionArchivesDir().get().getAsFile(); + fileSystemOperations.delete(spec -> spec.delete(output)); + for (Map.Entry entry : downloaded.entrySet()) { + String asset = entry.getKey(); + String abi; + if (asset.contains("-native-android-arm64-v8a-static.")) { + abi = "arm64-v8a"; + } else if (asset.contains("-native-android-x86_64-static.")) { + abi = "x86_64"; + } else { + continue; + } + File extractRoot = new File(getTemporaryDir(), "extension-" + abi + "-" + entry.getValue().getName()); + fileSystemOperations.delete(spec -> spec.delete(extractRoot)); + fileSystemOperations.copy( + spec -> { + spec.from(archiveOperations.tarTree(archiveOperations.gzip(entry.getValue()))); + spec.into(extractRoot); + }); + File source = new File(extractRoot, "extensions"); + if (!source.isDirectory()) { + throw new GradleException("liboliphaunt Android extension asset " + asset + " did not contain extensions/"); + } + fileSystemOperations.copy( + spec -> { + spec.from(source); + spec.into(new File(output, abi + "/extensions")); + }); + } + } + + private void mergeExtensionRuntimeArtifacts(Map downloaded, List> selectedRows) { + if (selectedRows.isEmpty()) { + return; + } + File root = runtimeResourcesRoot(getRuntimeResourcesDir().get().getAsFile()); + File runtimePackage = new File(root, "runtime"); + File runtimeFiles = new File(runtimePackage, "files"); + if (!runtimeFiles.isDirectory()) { + throw new GradleException("liboliphaunt runtime resources did not contain oliphaunt/runtime/files"); + } + List artifacts = new ArrayList<>(); + for (Map row : selectedRows) { + String sqlName = row.get("sql_name"); + File artifact = downloaded.get(row.get("runtime_artifact")); + File artifactRoot = extractExtensionRuntimeArtifact(sqlName, artifact); + copyTree(new File(artifactRoot, "files").toPath(), runtimeFiles.toPath()); + artifacts.add( + new ExtensionRuntimeArtifact( + sqlName, + dashToNull(row.get("native_module_stem")), + dashToNull(row.get("shared_preload")), + splitCsv(row.get("archive_targets")))); + } + List nativeArtifacts = + artifacts.stream().filter(artifact -> artifact.nativeModuleStem != null).toList(); + String staticRegistrySource = ""; + if (!nativeArtifacts.isEmpty()) { + File staticRegistryDir = new File(root, "static-registry"); + if (!staticRegistryDir.mkdirs() && !staticRegistryDir.isDirectory()) { + throw new GradleException("could not create " + staticRegistryDir); + } + writeText(new File(staticRegistryDir, "oliphaunt_static_registry.c"), staticRegistrySourceText(runtimeFiles, nativeArtifacts)); + writeStaticRegistryManifest(staticRegistryDir, nativeArtifacts); + staticRegistrySource = "static-registry/oliphaunt_static_registry.c"; + } + updateRuntimeManifest(new File(runtimePackage, "manifest.properties"), artifacts, staticRegistrySource); + } + + private File extractExtensionRuntimeArtifact(String sqlName, File archive) { + if (!archive.getName().endsWith(".tar.gz") && !archive.getName().endsWith(".tgz")) { + throw new GradleException( + "liboliphaunt release runtime artifact for " + + sqlName + + " must be a Gradle-native .tar.gz archive, got " + + archive.getName()); + } + File extractRoot = new File(getTemporaryDir(), "runtime-artifact-" + sqlName + "-" + archive.getName()); + fileSystemOperations.delete(spec -> spec.delete(extractRoot)); + fileSystemOperations.copy( + spec -> { + spec.from(archiveOperations.tarTree(archiveOperations.gzip(archive))); + spec.into(extractRoot); + }); + File artifactRoot = artifactRoot(extractRoot, archive); + Properties manifest = readProperties(new File(artifactRoot, "manifest.properties")); + if (!"oliphaunt-extension-artifact-v1".equals(manifest.getProperty("packageLayout"))) { + throw new GradleException("liboliphaunt extension runtime artifact " + archive.getName() + " has unsupported packageLayout"); + } + if (!sqlName.equals(manifest.getProperty("sqlName"))) { + throw new GradleException( + "liboliphaunt extension runtime artifact " + + archive.getName() + + " is for " + + manifest.getProperty("sqlName") + + ", expected " + + sqlName); + } + if (!new File(artifactRoot, "files").isDirectory()) { + throw new GradleException("liboliphaunt extension runtime artifact " + archive.getName() + " is missing files/"); + } + return artifactRoot; + } + + private static File artifactRoot(File extractRoot, File archive) { + if (new File(extractRoot, "manifest.properties").isFile()) { + return extractRoot; + } + File[] children = + extractRoot.listFiles(file -> file.isDirectory() && new File(file, "manifest.properties").isFile()); + if (children != null && children.length == 1) { + return children[0]; + } + throw new GradleException( + "liboliphaunt extension runtime artifact " + + archive.getName() + + " did not contain one manifest.properties root"); + } + + private static File runtimeResourcesRoot(File root) { + File nested = new File(root, "oliphaunt"); + if (nested.isDirectory()) { + return nested; + } + if (new File(root, "runtime").isDirectory()) { + return root; + } + return nested; + } + + private static void updateRuntimeManifest( + File manifestFile, List artifacts, String staticRegistrySource) { + Properties properties = manifestFile.isFile() ? readProperties(manifestFile) : new Properties(); + List selectedExtensions = sorted(artifacts.stream().map(artifact -> artifact.sqlName).toList()); + List sharedPreload = + sorted( + artifacts.stream() + .flatMap(artifact -> splitCsv(artifact.sharedPreload).stream()) + .toList()); + List nativeStems = + sorted( + artifacts.stream() + .map(artifact -> artifact.nativeModuleStem) + .filter(value -> value != null) + .toList()); + List registered = + sorted( + artifacts.stream() + .filter(artifact -> artifact.nativeModuleStem != null) + .map(artifact -> artifact.sqlName) + .toList()); + properties.setProperty("schema", "oliphaunt-runtime-resources-v1"); + properties.setProperty("extensions", String.join(",", selectedExtensions)); + properties.setProperty("sharedPreloadLibraries", String.join(",", sharedPreload)); + properties.setProperty("mobileStaticRegistryState", nativeStems.isEmpty() ? "not-required" : "complete"); + properties.setProperty("mobileStaticRegistryRegistered", String.join(",", registered)); + properties.setProperty("mobileStaticRegistryPending", ""); + properties.setProperty("nativeModuleStems", String.join(",", nativeStems)); + properties.setProperty("mobileStaticRegistrySource", staticRegistrySource); + writeOrderedProperties(manifestFile, properties); + } + + private static void writeStaticRegistryManifest(File staticRegistryDir, List artifacts) { + List modules = + sorted( + artifacts.stream() + .map(artifact -> artifact.nativeModuleStem) + .filter(value -> value != null) + .toList()); + List archiveTargets = + sorted( + artifacts.stream() + .filter(artifact -> artifact.nativeModuleStem != null) + .flatMap(artifact -> artifact.archiveTargets.stream()) + .toList()); + List lines = new ArrayList<>(); + lines.add("packageLayout=oliphaunt-static-registry-v1"); + lines.add("abiVersion=1"); + lines.add("state=complete"); + lines.add("source=oliphaunt_static_registry.c"); + lines.add("registeredExtensions=" + String.join(",", sorted(artifacts.stream().map(artifact -> artifact.sqlName).toList()))); + lines.add("pendingExtensions="); + lines.add("nativeModuleStems=" + String.join(",", modules)); + lines.add("modules=" + String.join(",", modules)); + lines.add("archiveTargets=" + String.join(",", archiveTargets)); + for (ExtensionRuntimeArtifact artifact : artifacts.stream().filter(value -> value.nativeModuleStem != null).toList()) { + String stem = artifact.nativeModuleStem; + List targets = sorted(artifact.archiveTargets); + lines.add("module." + stem + ".extension=" + artifact.sqlName); + lines.add("module." + stem + ".symbolPrefix=" + staticRegistrySymbolPrefix(stem)); + lines.add("module." + stem + ".sqlSymbols="); + lines.add("module." + stem + ".archiveTargets=" + String.join(",", targets)); + for (String target : targets) { + lines.add( + "module." + + stem + + ".archive." + + target + + "=archives/" + + target + + "/extensions/" + + stem + + "/liboliphaunt_extension_" + + stem + + ".a"); + } + } + writeText(new File(staticRegistryDir, "manifest.properties"), String.join("\n", lines) + "\n"); + } + + private static String staticRegistrySourceText(File runtimeFiles, List artifacts) { + StringBuilder out = new StringBuilder(); + out.append("/* Generated by Oliphaunt Android Gradle plugin. Do not edit by hand. */\n"); + out.append("#include \n#include \n#include \"oliphaunt.h\"\n\n"); + out.append("#if defined(__GNUC__) || defined(__clang__)\n#define OLIPHAUNT_STATIC_OPTIONAL __attribute__((weak))\n#else\n#define OLIPHAUNT_STATIC_OPTIONAL\n#endif\n\n"); + List modules = new ArrayList<>(); + for (ExtensionRuntimeArtifact artifact : artifacts) { + if (artifact.nativeModuleStem == null) { + continue; + } + modules.add( + new StaticRegistryModule( + artifact.nativeModuleStem, + staticRegistrySymbolPrefix(artifact.nativeModuleStem), + collectExtensionSqlSymbols(runtimeFiles, artifact.sqlName))); + } + modules.sort(java.util.Comparator.comparing(module -> module.moduleStem)); + for (StaticRegistryModule module : modules) { + out.append("extern const void *").append(module.symbolPrefix).append("_Pg_magic_func(void);\n"); + out.append("extern void ").append(module.symbolPrefix).append("__PG_init(void) OLIPHAUNT_STATIC_OPTIONAL;\n"); + for (String symbol : module.sqlSymbols) { + out.append("extern void ").append(symbol).append("(void);\n"); + out.append("extern void pg_finfo_").append(symbol).append("(void);\n"); + } + out.append('\n'); + } + for (StaticRegistryModule module : modules) { + out.append("static const OliphauntStaticExtensionSymbol ").append(module.symbolPrefix).append("_symbols[] = {\n"); + for (String symbol : module.sqlSymbols) { + out.append(" { .name = ").append(cStringLiteral(symbol)).append(", .address = (void *)").append(symbol).append(" },\n"); + out.append(" { .name = ").append(cStringLiteral("pg_finfo_" + symbol)).append(", .address = (void *)pg_finfo_").append(symbol).append(" },\n"); + } + out.append("};\n\n"); + } + out.append("static const OliphauntStaticExtension liboliphaunt_static_extensions[] = {\n"); + for (StaticRegistryModule module : modules) { + out.append(" {\n"); + out.append(" .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION,\n"); + out.append(" .name = ").append(cStringLiteral(module.moduleStem)).append(",\n"); + out.append(" .magic = ").append(module.symbolPrefix).append("_Pg_magic_func,\n"); + out.append(" .init = ").append(module.symbolPrefix).append("__PG_init,\n"); + out.append(" .symbols = ").append(module.symbolPrefix).append("_symbols,\n"); + out.append(" .symbol_count = sizeof(").append(module.symbolPrefix).append("_symbols) / sizeof(").append(module.symbolPrefix).append("_symbols[0]),\n"); + out.append(" .reserved_flags = 0,\n"); + out.append(" },\n"); + } + out.append("};\n\n"); + out.append("const OliphauntStaticExtension *liboliphaunt_selected_static_extensions(size_t *count) {\n"); + out.append(" if (count != NULL) {\n"); + out.append(" *count = sizeof(liboliphaunt_static_extensions) / sizeof(liboliphaunt_static_extensions[0]);\n"); + out.append(" }\n"); + out.append(" return liboliphaunt_static_extensions;\n"); + out.append("}\n"); + return out.toString(); + } + + private static List collectExtensionSqlSymbols(File runtimeFiles, String sqlName) { + File extensionDir = new File(runtimeFiles, "share/postgresql/extension"); + File[] sqlFiles = + extensionDir.listFiles( + file -> file.isFile() && file.getName().startsWith(sqlName + "--") && file.getName().endsWith(".sql")); + if (sqlFiles == null || sqlFiles.length == 0) { + throw new GradleException("selected extension " + sqlName + " has no packaged SQL files in " + extensionDir); + } + Arrays.sort(sqlFiles, java.util.Comparator.comparing(File::getName)); + TreeSet symbols = new TreeSet<>(); + for (File file : sqlFiles) { + try { + symbols.addAll(modulePathnameCSymbols(Files.readString(file.toPath(), StandardCharsets.UTF_8))); + } catch (IOException error) { + throw new GradleException("read extension SQL " + file + ": " + error.getMessage(), error); + } + } + return new ArrayList<>(symbols); + } + + private static List modulePathnameCSymbols(String sql) { + TreeSet symbols = new TreeSet<>(); + for (String statement : splitSqlStatements(stripSqlLineComments(sql))) { + if (!containsIgnoreCase(statement, "module_pathname") || !hasLanguageC(statement)) { + continue; + } + String symbol = explicitModulePathnameSymbol(statement); + if (symbol == null) { + symbol = implicitFunctionSymbol(statement); + } + if (symbol != null) { + if (!symbol.matches("[A-Za-z_][A-Za-z0-9_]*")) { + throw new GradleException("extension SQL references non-portable C symbol '" + symbol + "'"); + } + symbols.add(symbol); + } + } + return new ArrayList<>(symbols); + } + + private static String stripSqlLineComments(String sql) { + StringBuilder out = new StringBuilder(sql.length()); + boolean inString = false; + for (int index = 0; index < sql.length(); index++) { + char ch = sql.charAt(index); + if (ch == '\'') { + out.append(ch); + if (inString && index + 1 < sql.length() && sql.charAt(index + 1) == '\'') { + out.append(sql.charAt(++index)); + } else { + inString = !inString; + } + } else if (!inString && ch == '-' && index + 1 < sql.length() && sql.charAt(index + 1) == '-') { + index += 2; + while (index < sql.length() && sql.charAt(index) != '\n') { + index++; + } + if (index < sql.length()) { + out.append('\n'); + } + } else { + out.append(ch); + } + } + return out.toString(); + } + + private static List splitSqlStatements(String sql) { + List statements = new ArrayList<>(); + int start = 0; + boolean inString = false; + for (int index = 0; index < sql.length(); index++) { + char ch = sql.charAt(index); + if (ch == '\'') { + if (inString && index + 1 < sql.length() && sql.charAt(index + 1) == '\'') { + index++; + } else { + inString = !inString; + } + } else if (!inString && ch == ';') { + String statement = sql.substring(start, index).trim(); + if (!statement.isEmpty()) { + statements.add(statement); + } + start = index + 1; + } + } + if (start < sql.length()) { + String statement = sql.substring(start).trim(); + if (!statement.isEmpty()) { + statements.add(statement); + } + } + return statements; + } + + private static String explicitModulePathnameSymbol(String statement) { + int moduleIndex = statement.toLowerCase(java.util.Locale.ROOT).indexOf("module_pathname"); + if (moduleIndex < 0) { + return null; + } + String rest = statement.substring(moduleIndex + "module_pathname".length()).stripLeading(); + if (rest.startsWith("'")) { + rest = rest.substring(1).stripLeading(); + } + if (!rest.startsWith(",")) { + return null; + } + return parseSqlSingleQuotedLiteral(rest.substring(1).stripLeading()); + } + + private static String implicitFunctionSymbol(String statement) { + int functionIndex = statement.toLowerCase(java.util.Locale.ROOT).indexOf("function"); + if (functionIndex < 0) { + return null; + } + String afterFunction = statement.substring(functionIndex + "function".length()); + int nameEnd = afterFunction.indexOf('('); + if (nameEnd < 0) { + return null; + } + return lastSqlIdentifier(afterFunction.substring(0, nameEnd).trim()); + } + + private static String parseSqlSingleQuotedLiteral(String value) { + if (!value.startsWith("'")) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int index = 1; index < value.length(); index++) { + char ch = value.charAt(index); + if (ch == '\'') { + if (index + 1 < value.length() && value.charAt(index + 1) == '\'') { + out.append('\''); + index++; + } else { + return out.toString(); + } + } else { + out.append(ch); + } + } + return null; + } + + private static String lastSqlIdentifier(String rawName) { + List parts = new ArrayList<>(); + int start = 0; + boolean inQuotes = false; + for (int index = 0; index < rawName.length(); index++) { + char ch = rawName.charAt(index); + if (ch == '"') { + if (inQuotes && index + 1 < rawName.length() && rawName.charAt(index + 1) == '"') { + index++; + } else { + inQuotes = !inQuotes; + } + } else if (!inQuotes && ch == '.') { + parts.add(rawName.substring(start, index).trim()); + start = index + 1; + } + } + parts.add(rawName.substring(start).trim()); + String part = parts.get(parts.size() - 1).trim(); + if (part.startsWith("\"") && part.endsWith("\"") && part.length() >= 2) { + return part.substring(1, part.length() - 1).replace("\"\"", "\""); + } + return part; + } + + private static boolean hasLanguageC(String statement) { + List tokens = + Arrays.stream(statement.split("[^A-Za-z0-9_]+")) + .filter(value -> !value.isEmpty()) + .map(value -> value.toLowerCase(java.util.Locale.ROOT)) + .toList(); + for (int index = 0; index + 1 < tokens.size(); index++) { + if (tokens.get(index).equals("language") && tokens.get(index + 1).equals("c")) { + return true; + } + } + return false; + } + + private static boolean containsIgnoreCase(String value, String needle) { + return value.toLowerCase(java.util.Locale.ROOT).contains(needle.toLowerCase(java.util.Locale.ROOT)); + } + + private static String staticRegistrySymbolPrefix(String moduleStem) { + StringBuilder out = new StringBuilder("oliphaunt_static_"); + for (int index = 0; index < moduleStem.length(); index++) { + char ch = moduleStem.charAt(index); + out.append((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' ? ch : '_'); + } + return out.toString(); + } + + private static String cStringLiteral(String value) { + StringBuilder out = new StringBuilder("\""); + for (int index = 0; index < value.length(); index++) { + char ch = value.charAt(index); + switch (ch) { + case '\\' -> out.append("\\\\"); + case '"' -> out.append("\\\""); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> out.append(ch); + } + } + out.append('"'); + return out.toString(); + } + + private static void copyTree(Path source, Path target) { + try (var stream = Files.walk(source)) { + for (Path path : stream.sorted().toList()) { + if (Files.isSymbolicLink(path)) { + throw new GradleException("Oliphaunt Android release assets do not support symlinks: " + path); + } + Path relative = source.relativize(path); + Path destination = target.resolve(relative); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else if (Files.isRegularFile(path)) { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (IOException error) { + throw new GradleException("copy " + source + " to " + target + ": " + error.getMessage(), error); + } + } + + private static Properties readProperties(File file) { + Properties properties = new Properties(); + try (var input = Files.newInputStream(file.toPath())) { + properties.load(input); + return properties; + } catch (IOException error) { + throw new GradleException("read " + file + ": " + error.getMessage(), error); + } + } + + private static void writeOrderedProperties(File file, Properties properties) { + List preferred = + List.of( + "schema", + "cacheKey", + "layout", + "source", + "extensions", + "sharedPreloadLibraries", + "mobileStaticRegistryState", + "mobileStaticRegistryRegistered", + "mobileStaticRegistryPending", + "nativeModuleStems", + "mobileStaticRegistrySource"); + LinkedHashSet keys = new LinkedHashSet<>(preferred); + keys.addAll(new TreeSet<>(properties.stringPropertyNames())); + StringBuilder out = new StringBuilder(); + for (String key : keys) { + String value = properties.getProperty(key); + if (value != null) { + out.append(key).append('=').append(value).append('\n'); + } + } + writeText(file, out.toString()); + } + + private static void writeText(File file, String text) { + try { + Files.createDirectories(file.toPath().getParent()); + Files.writeString(file.toPath(), text, StandardCharsets.UTF_8); + } catch (IOException error) { + throw new GradleException("write " + file + ": " + error.getMessage(), error); + } + } + + private static String dashToNull(String value) { + return value == null || value.equals("-") || value.isBlank() ? null : value; + } + + private static String emptyToDash(String value) { + return value == null || value.isBlank() ? "-" : value; + } + + private static List splitCsv(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + return Arrays.stream(raw.split(",")).map(String::trim).filter(value -> !value.isEmpty()).toList(); + } + + private static List sorted(List values) { + return new ArrayList<>(new TreeSet<>(values)); + } + + private record ExtensionRuntimeArtifact( + String sqlName, String nativeModuleStem, String sharedPreload, List archiveTargets) {} + + private record StaticRegistryModule(String moduleStem, String symbolPrefix, List sqlSymbols) {} +} diff --git a/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/liboliphaunt.version b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/liboliphaunt.version new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/liboliphaunt.version @@ -0,0 +1 @@ +0.1.0 diff --git a/src/sdks/kotlin/oliphaunt/build.gradle.kts b/src/sdks/kotlin/oliphaunt/build.gradle.kts new file mode 100644 index 00000000..6795dc20 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/build.gradle.kts @@ -0,0 +1,1867 @@ +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.Properties +import javax.inject.Inject + +plugins { + id("com.android.library") + alias(libs.plugins.detekt) + alias(libs.plugins.dokka) + id("org.jetbrains.kotlin.multiplatform") + alias(libs.plugins.kover) + alias(libs.plugins.maven.publish) + alias(libs.plugins.spotless) +} + +group = providers.gradleProperty("GROUP").orElse("dev.oliphaunt").get() +version = providers.gradleProperty("VERSION_NAME").orElse("0.1.0").get() + +spotless { + kotlin { + target("src/**/*.kt") + ktlint().editorConfigOverride(mapOf("ktlint_standard_property-naming" to "disabled")) + } + kotlinGradle { + target("*.gradle.kts", "../*.gradle.kts") + ktlint() + } +} + +detekt { + buildUponDefaultConfig = true + allRules = false + basePath = rootProject.layout.projectDirectory.asFile +} + +kover { + reports { + filters { + includes { + classes( + "dev.oliphaunt.AndroidContextRequiredEngine", + "dev.oliphaunt.Backup*", + "dev.oliphaunt.Engine*", + "dev.oliphaunt.Oliphaunt*", + "dev.oliphaunt.Protocol*", + "dev.oliphaunt.Query*", + "dev.oliphaunt.Restore*", + ) + } + excludes { + classes( + "dev.oliphaunt.AndroidDirectTemporaryRoot", + "dev.oliphaunt.AndroidNativeDirectEngine", + "dev.oliphaunt.AndroidNativeDirectEngineKt", + "dev.oliphaunt.AndroidNativeDirectSession", + "dev.oliphaunt.OliphauntAndroid", + "dev.oliphaunt.OliphauntAndroidNativeBridge", + "dev.oliphaunt.OliphauntAndroidProtocolStreamSink", + ) + } + } + verify { + rule { + minBound(80) + } + } + } +} + +dokka { + dokkaPublications.html { + moduleName.set("Oliphaunt Kotlin SDK") + moduleVersion.set(project.version.toString()) + outputDirectory.set(rootProject.layout.projectDirectory.dir("../../target/docs/generated/api/kotlin/html")) + failOnWarning.set(false) + suppressObviousFunctions.set(true) + } + dokkaSourceSets.configureEach { + documentedVisibilities.set(setOf(VisibilityModifier.Public)) + reportUndocumented.set(false) + skipEmptyPackages.set(true) + suppressGeneratedFiles.set(true) + sourceLink { + localDirectory.set(project.layout.projectDirectory.dir("src")) + remoteUrl("https://github.com/f0rr0/oliphaunt/tree/main/src/sdks/kotlin/oliphaunt/src") + remoteLineSuffix.set("#L") + } + } +} + +val mavenCentralPublishRequested = + gradle.startParameter.taskNames.any { + it.contains("MavenCentral", ignoreCase = true) + } +val explicitPublicationSigning = + providers + .gradleProperty("signAllPublications") + .map { it.equals("true", ignoreCase = true) || it.equals("yes", ignoreCase = true) || it == "1" } + .orElse(false) + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + if (mavenCentralPublishRequested || explicitPublicationSigning.get()) { + signAllPublications() + } + pom { + name.set("Oliphaunt Kotlin SDK") + description.set("Kotlin and Android SDK for native embedded PostgreSQL through liboliphaunt.") + inceptionYear.set("2026") + url.set("https://github.com/f0rr0/oliphaunt") + licenses { + license { + name.set("MIT AND Apache-2.0 AND PostgreSQL") + url.set("https://github.com/f0rr0/oliphaunt/blob/main/LICENSE") + distribution.set("repo") + } + } + developers { + developer { + id.set("f0rr0") + name.set("Oliphaunt Maintainers") + url.set("https://github.com/f0rr0") + } + } + scm { + url.set("https://github.com/f0rr0/oliphaunt") + connection.set("scm:git:https://github.com/f0rr0/oliphaunt.git") + developerConnection.set("scm:git:ssh://git@github.com/f0rr0/oliphaunt.git") + } + } +} + +val bridgeSource = layout.projectDirectory.file("src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.c") +val bridgeHeader = layout.projectDirectory.file("src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.h") +val bridgeOutputDir = layout.buildDirectory.dir("nativeBridge") +val bridgeArchive = bridgeOutputDir.map { it.file("liboliphaunt_kotlin_bridge.a") } +val generatedAndroidAssetsDir = layout.buildDirectory.dir("generated/oliphaunt-android-assets") +val generatedAndroidJniLibsDir = layout.buildDirectory.dir("generated/oliphaunt-android-jniLibs") +val resolvedReleaseAssetsDir = layout.buildDirectory.dir("generated/oliphaunt-release-assets") +val resolvedReleaseRuntimeResourcesDir = resolvedReleaseAssetsDir.map { it.dir("runtime-resources") } +val resolvedReleaseAndroidJniLibsDir = resolvedReleaseAssetsDir.map { it.dir("jniLibs") } +val resolvedReleaseAndroidExtensionArchivesDir = resolvedReleaseAssetsDir.map { it.dir("extensionArchives") } +val liboliphauntReleaseVersion = + providers + .gradleProperty("oliphauntLiboliphauntVersion") + .orElse(providers.environmentVariable("OLIPHAUNT_LIBOLIPHAUNT_VERSION")) +val liboliphauntReleaseAssetBaseUrl = + providers + .gradleProperty("oliphauntAssetBaseUrl") + .orElse(providers.environmentVariable("OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSET_BASE_URL")) + .orElse( + liboliphauntReleaseVersion.map { version -> + "https://github.com/f0rr0/oliphaunt/releases/download/liboliphaunt-native-v$version" + }, + ) +val configuredCxxBuildRoot = + ( + project.findProperty("oliphauntCxxBuildRoot") + ?: System.getenv("OLIPHAUNT_CXX_BUILD_ROOT") + )?.toString() + ?.takeIf(String::isNotBlank) + ?.let(::file) +val cxxBuildRoot = + configuredCxxBuildRoot + ?.resolve(if (path == ":") "root" else path.removePrefix(":").replace(':', '/')) + ?: layout.buildDirectory + .dir("cxx") + .get() + .asFile +val packagedRuntimeResourcesDir = + ( + project.findProperty("oliphauntRuntimeResourcesDir") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_RUNTIME_RESOURCES_DIR") + ?: System.getenv("OLIPHAUNT_ANDROID_RUNTIME_RESOURCES_DIR") + )?.toString() + ?: liboliphauntReleaseVersion.orNull?.let { + resolvedReleaseRuntimeResourcesDir.get().asFile.absolutePath + } +val packagedAndroidJniLibsDir = + ( + project.findProperty("oliphauntAndroidJniLibsDir") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_JNI_LIBS_DIR") + )?.toString() + ?: liboliphauntReleaseVersion.orNull?.let { + resolvedReleaseAndroidJniLibsDir.get().asFile.absolutePath + } +val packagedAndroidExtensionArchivesDir = + ( + project.findProperty("oliphauntAndroidExtensionArchivesDir") + ?: project.findProperty("oliphauntExtensionArchivesDir") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_EXTENSION_ARCHIVES_DIR") + ?: System.getenv("OLIPHAUNT_ANDROID_EXTENSION_ARCHIVES_DIR") + )?.toString() + ?: liboliphauntReleaseVersion.orNull?.let { + resolvedReleaseAndroidExtensionArchivesDir.get().asFile.absolutePath + } +val packagedAndroidLinkEvidenceFile = + ( + project.findProperty("oliphauntAndroidLinkEvidenceFile") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_LINK_EVIDENCE_FILE") + ?: System.getenv("OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE") + )?.toString() +val explicitPackagedRuntimeDir = + ( + project.findProperty("oliphauntRuntimeDir") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_RUNTIME_DIR") + )?.toString() +val explicitPackagedTemplatePgdataDir = + ( + project.findProperty("oliphauntTemplatePgdataDir") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_TEMPLATE_PGDATA_DIR") + )?.toString() +val explicitPackagedExtensionsRaw = + ( + project.findProperty("oliphauntExtensions") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_EXTENSIONS") + )?.toString() +val explicitPackagedExtensionVersionsRaw = + ( + project.findProperty("oliphauntExtensionVersions") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_EXTENSION_VERSIONS") + )?.toString() +val explicitMobileStaticModulesRaw = + ( + project.findProperty("oliphauntMobileStaticModules") + ?: project.findProperty("oliphauntMobileStaticModuleStems") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_MOBILE_STATIC_MODULES") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_MOBILE_STATIC_MODULE_STEMS") + )?.toString() +val explicitAndroidAbiFiltersRaw = + ( + project.findProperty("oliphauntAndroidAbiFilters") + ?: project.findProperty("oliphauntAndroidAbis") + ?: System.getenv("OLIPHAUNT_KOTLIN_ANDROID_ABI_FILTERS") + ?: System.getenv("OLIPHAUNT_ANDROID_ABI_FILTERS") + )?.toString() + +fun runtimeResourcesRoot(): File? { + val root = packagedRuntimeResourcesDir?.takeIf(String::isNotBlank)?.let(::file) ?: return null + val nested = root.resolve("oliphaunt") + return when { + nested.isDirectory -> nested + root.resolve("runtime").isDirectory -> root + else -> root.resolve("oliphaunt") + } +} + +fun runtimeResourceFiles(resourceName: String): String? = + runtimeResourcesRoot() + ?.resolve(resourceName) + ?.resolve("files") + ?.takeIf(File::isDirectory) + ?.absolutePath + +fun runtimeResourceManifestValue( + resourceName: String, + key: String, +): String? { + val manifest = + runtimeResourcesRoot() + ?.resolve(resourceName) + ?.resolve("manifest.properties") + ?.takeIf(File::isFile) + ?: return null + val properties = Properties() + manifest.inputStream().use(properties::load) + return properties.getProperty(key) +} + +val packagedRuntimeDir = runtimeResourceFiles("runtime") ?: explicitPackagedRuntimeDir +val packagedTemplatePgdataDir = + runtimeResourceFiles("template-pgdata") ?: explicitPackagedTemplatePgdataDir +val packagedExtensionsRaw = + explicitPackagedExtensionsRaw ?: runtimeResourceManifestValue("runtime", "extensions") +val packagedMobileStaticModulesRaw = + explicitMobileStaticModulesRaw ?: runtimeResourceManifestValue("runtime", "nativeModuleStems") +val packagedStaticRegistrySource = + runtimeResourceManifestValue("runtime", "mobileStaticRegistrySource") + ?.takeIf(String::isNotBlank) + ?.let { relative -> + runtimeResourcesRoot()?.resolve(relative)?.takeIf(File::isFile)?.absolutePath + } +val packagedResourceExtensionArchivesDir = + runtimeResourcesRoot() + ?.resolve("static-registry") + ?.resolve("archives") + ?.takeIf(File::isDirectory) + ?.absolutePath +val effectiveAndroidExtensionArchivesDir = + packagedAndroidExtensionArchivesDir?.takeIf(String::isNotBlank) + ?: packagedResourceExtensionArchivesDir + +abstract class PrepareOliphauntAndroidAssetsTask : DefaultTask() { + @get:Input + abstract val runtimeResourcesDirPath: Property + + @get:Input + abstract val runtimeDirPath: Property + + @get:Input + abstract val templatePgdataDirPath: Property + + @get:Input + abstract val selectedExtensions: ListProperty + + @get:Input + abstract val mobileStaticModuleStems: ListProperty + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sourceDirectories: ConfigurableFileCollection + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val generatedExtensionMetadata: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + private val generatedExtensionMetadataBySqlName: Map> by lazy { + val metadataFile = generatedExtensionMetadata.singleFile + + @Suppress("UNCHECKED_CAST") + val parsed = + JsonSlurper().parse(metadataFile) as? Map + ?: throw GradleException("generated extension metadata must be a JSON object: $metadataFile") + val rows = + parsed["extensions"] as? List<*> + ?: throw GradleException("generated extension metadata must define extensions: $metadataFile") + rows.associate { value -> + val row = + (value as? Map<*, *>) + ?.mapKeys { (key, _) -> key.toString() } + ?: throw GradleException("generated extension metadata rows must be JSON objects: $metadataFile") + val sqlName = + row["sql-name"] as? String + ?: throw GradleException("generated extension metadata rows must define sql-name: $metadataFile") + sqlName to row + } + } + + @TaskAction + fun prepare() { + val output = outputDir.get().asFile + deleteTree(output.toPath()) + output.mkdirs() + + val runtimeResourcesPath = runtimeResourcesDirPath.get().takeIf(String::isNotBlank) + if (runtimeResourcesPath != null) { + val sourceRuntimeResourcesRoot = runtimeResourcesRoot(File(runtimeResourcesPath)) + require(sourceRuntimeResourcesRoot.isDirectory) { + "Oliphaunt Kotlin Android runtime resources are not a Oliphaunt resource root: $runtimeResourcesPath" + } + validateRuntimeResourcesSchema(sourceRuntimeResourcesRoot) + copyTree( + sourceRuntimeResourcesRoot.toPath(), + output.resolve("oliphaunt").toPath(), + excludedPrefixes = setOf("static-registry/archives"), + ) + return + } + + writeAndroidAssetPackage( + name = "runtime", + layout = "postgres-runtime-files-v1", + sourcePath = runtimeDirPath.get().takeIf(String::isNotBlank), + requestedExtensions = selectedExtensions.get(), + mobileStaticModuleStems = mobileStaticModuleStems.get(), + output = output, + ) + writeAndroidAssetPackage( + name = "template-pgdata", + layout = "postgres-template-pgdata-v1", + sourcePath = templatePgdataDirPath.get().takeIf(String::isNotBlank), + requestedExtensions = emptyList(), + mobileStaticModuleStems = emptyList(), + output = output, + ) + } + + private fun validateRuntimeResourcesSchema(root: File) { + for (name in listOf("runtime", "template-pgdata")) { + val manifest = root.resolve("$name/manifest.properties") + require(manifest.isFile) { + "Oliphaunt Kotlin Android runtime resources are missing $name/manifest.properties under ${root.absolutePath}" + } + val properties = Properties() + manifest.inputStream().use(properties::load) + val schema = properties.getProperty("schema")?.trim().orEmpty() + require(schema == "oliphaunt-runtime-resources-v1") { + "Oliphaunt Kotlin Android runtime resources $name manifest has unsupported schema '${schema.ifEmpty { + "" + }}'; expected oliphaunt-runtime-resources-v1" + } + } + } + + private fun runtimeResourcesRoot(root: File): File { + val nested = root.resolve("oliphaunt") + return when { + nested.isDirectory -> nested + root.resolve("runtime").isDirectory -> root + else -> root.resolve("oliphaunt") + } + } + + private fun writeAndroidAssetPackage( + name: String, + layout: String, + sourcePath: String?, + requestedExtensions: List, + mobileStaticModuleStems: List, + output: File, + ) { + if (sourcePath.isNullOrBlank()) { + require(requestedExtensions.isEmpty()) { + "Oliphaunt Kotlin Android extensions require -PoliphauntRuntimeDir=" + } + return + } + val source = File(sourcePath) + require(source.isDirectory) { + "Oliphaunt Kotlin Android $name assets source is not a directory: $source" + } + require(mobileStaticModuleStems.isEmpty()) { + "Oliphaunt Kotlin Android split runtime packaging cannot declare mobile static module stems. " + + "Use -PoliphauntRuntimeResourcesDir= from " + + "`oliphaunt-resources --mobile-static-module ...` so the runtime resources include the generated static-registry source." + } + val packageDir = output.resolve("oliphaunt/$name") + val filesDir = packageDir.resolve("files") + copyTree(source.toPath(), filesDir.toPath()) + val extensions = resolveExtensionSelection(requestedExtensions) + val nativeModuleStems = nativeModuleStems(extensions) + val registeredModuleStems = mobileStaticModuleStems.toSortedSet() + val unknownRegisteredStems = registeredModuleStems - nativeModuleStems.toSet() + require(unknownRegisteredStems.isEmpty()) { + "Oliphaunt Kotlin Android mobile static module stem(s) were not selected by these runtime resources: " + + unknownRegisteredStems.joinToString(",") + } + val registeredMobileExtensions = mobileStaticRegistryRegisteredExtensions(extensions, registeredModuleStems) + val pendingMobileExtensions = mobileStaticRegistryPendingExtensions(extensions, registeredModuleStems) + val mobileStaticRegistryState = + when { + nativeModuleStems.isEmpty() -> "not-required" + pendingMobileExtensions.isEmpty() -> "complete" + else -> "pending" + } + val manifest = packageDir.resolve("manifest.properties") + manifest.parentFile.mkdirs() + manifest.writeText( + listOf( + "schema=oliphaunt-runtime-resources-v1", + "cacheKey=${sha256Directory(source)}", + "layout=$layout", + "source=${source.name}", + "extensions=${extensions.joinToString(",")}", + "sharedPreloadLibraries=${sharedPreloadLibraries(extensions).joinToString(",")}", + "mobileStaticRegistryState=$mobileStaticRegistryState", + "mobileStaticRegistryRegistered=${registeredMobileExtensions.joinToString(",")}", + "mobileStaticRegistryPending=${pendingMobileExtensions.joinToString(",")}", + "nativeModuleStems=${nativeModuleStems.joinToString(",")}", + "mobileStaticRegistrySource=", + "", + ).joinToString("\n"), + ) + } + + private fun resolveExtensionSelection(requestedExtensions: List): List { + val extensions = linkedSetOf() + for (extension in requestedExtensions) { + extensions.addAll(extensionDependencies(extension)) + extensions.add(extension) + } + return extensions.toSortedSet().onEach(::requireMobileReleaseReady).toList() + } + + private fun extensionDependencies(extension: String): List = + generatedExtensionStringList(extension, "selected-extension-dependencies") + + private fun sharedPreloadLibraries(extensions: List): List = + extensions + .flatMap { extension -> generatedExtensionStringList(extension, "shared-preload-libraries") } + .toSortedSet() + .toList() + + private fun mobileStaticRegistryRegisteredExtensions( + extensions: List, + registeredModuleStems: Set, + ): List = + extensions + .filter { extension -> + val stem = nativeModuleStem(extension) + stem != null && stem in registeredModuleStems + }.toSortedSet() + .toList() + + private fun mobileStaticRegistryPendingExtensions( + extensions: List, + registeredModuleStems: Set, + ): List = + extensions + .filter { extension -> + val stem = nativeModuleStem(extension) + stem != null && stem !in registeredModuleStems + }.toSortedSet() + .toList() + + private fun nativeModuleStems(extensions: List): List = + extensions + .mapNotNull(::nativeModuleStem) + .toSortedSet() + .toList() + + private fun nativeModuleStem(extension: String): String? = generatedNativeModuleStem(extension) + + private fun generatedExtensionStringList( + extension: String, + field: String, + ): List = + (generatedExtensionMetadataRow(extension)[field] as? List<*>) + ?.map { value -> value.toString() } + ?: emptyList() + + private fun generatedExtensionMetadataRow(extension: String): Map = + generatedExtensionMetadataBySqlName[extension] + ?: throw GradleException( + "Oliphaunt Kotlin Android split runtime packaging cannot select unknown extension '$extension'. " + + "Use a generated built-in extension name, or pass " + + "-PoliphauntRuntimeResourcesDir= for custom prebuilt extension artifacts.", + ) + + private fun generatedNativeModuleStem(extension: String): String? { + val row = generatedExtensionMetadataRow(extension) + return row["native-module-stem"] as? String + } + + private fun requireMobileReleaseReady(extension: String) { + val row = generatedExtensionMetadataRow(extension) + require(row["mobile-release-ready"] == true) { + "Oliphaunt Kotlin Android split runtime packaging cannot select extension '$extension' because " + + "it does not have release-ready Android/iOS artifacts in the generated exact-extension catalog." + } + } + + private fun sha256Directory(source: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val rootPath = source.toPath() + Files.walk(rootPath).use { stream -> + stream.sorted().forEach { path -> + require(!Files.isSymbolicLink(path)) { + "Oliphaunt Android assets do not support symlinks: $path" + } + if (Files.isRegularFile(path)) { + val relative = rootPath.relativize(path).toString().replace(File.separatorChar, '/') + digest.update(relative.toByteArray(Charsets.UTF_8)) + digest.update(0.toByte()) + digest.update(Files.readAllBytes(path)) + digest.update(0.toByte()) + } + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun copyTree( + source: Path, + target: Path, + excludedPrefixes: Set = emptySet(), + ) { + Files.walk(source).use { stream -> + stream.sorted().forEach { path -> + require(!Files.isSymbolicLink(path)) { + "Oliphaunt Android assets do not support symlinks: $path" + } + val relative = source.relativize(path) + val relativeName = relative.toString().replace(File.separatorChar, '/') + if (excludedPrefixes.any { prefix -> relativeName == prefix || relativeName.startsWith("$prefix/") }) { + return@forEach + } + val destination = target.resolve(relative) + when { + Files.isDirectory(path) -> { + Files.createDirectories(destination) + } + + Files.isRegularFile(path) -> { + Files.createDirectories(destination.parent) + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING) + } + } + } + } + } + + private fun deleteTree(path: Path) { + if (!Files.exists(path)) return + Files.walk(path).use { stream -> + stream.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } + } +} + +abstract class PrepareOliphauntAndroidJniLibsTask : DefaultTask() { + @get:Input + abstract val jniLibsDirPath: Property + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sourceDirectories: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun prepare() { + val output = outputDir.get().asFile + deleteTree(output.toPath()) + val configured = jniLibsDirPath.get().takeIf(String::isNotBlank) ?: return + val configuredRoot = File(configured) + val source = configuredRoot.resolve("jniLibs").takeIf(File::isDirectory) ?: configuredRoot + require(source.isDirectory) { + "Oliphaunt Kotlin Android JNI libs source is not a directory: $source" + } + + val abiDirs = + source + .listFiles() + ?.filter(File::isDirectory) + ?.sortedBy(File::getName) + ?: emptyList() + require(abiDirs.isNotEmpty()) { + "Oliphaunt Kotlin Android JNI libs require ABI directories under $source" + } + + var packagedLiboliphaunt = false + for (abiDir in abiDirs) { + require(abiDir.name in ANDROID_JNI_LIB_ABIS) { + "unsupported Android ABI directory for Oliphaunt Kotlin package: ${abiDir.name}" + } + require(!Files.isSymbolicLink(abiDir.toPath())) { + "Oliphaunt Kotlin Android JNI libs do not support symlink ABI directories: $abiDir" + } + val sharedLibraries = + abiDir + .listFiles() + ?.filter { file -> + require(!Files.isSymbolicLink(file.toPath())) { + "Oliphaunt Kotlin Android JNI libs do not support symlinks: $file" + } + require(file.isFile) { + "Oliphaunt Kotlin Android JNI libs only support flat .so files under ABI directories: $file" + } + file.name.endsWith(".so") + }?.sortedBy(File::getName) + ?: emptyList() + require(sharedLibraries.any { it.name == "liboliphaunt.so" }) { + "Android ABI ${abiDir.name} is missing liboliphaunt.so" + } + packagedLiboliphaunt = true + val destination = output.resolve(abiDir.name) + destination.mkdirs() + for (library in sharedLibraries) { + Files.copy( + library.toPath(), + destination.resolve(library.name).toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } + require(packagedLiboliphaunt) { + "Oliphaunt Kotlin Android JNI libs did not contain liboliphaunt.so for any ABI" + } + } + + private fun deleteTree(path: Path) { + if (!Files.exists(path)) return + Files.walk(path).use { stream -> + stream.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } + } + + companion object { + private val ANDROID_JNI_LIB_ABIS = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + } +} + +abstract class ResolveOliphauntAndroidReleaseAssetsTask + @Inject + constructor( + private val archiveOperations: ArchiveOperations, + private val fileSystemOperations: FileSystemOperations, + ) : DefaultTask() { + @get:Input + abstract val version: Property + + @get:Input + abstract val assetBaseUrl: Property + + @get:Input + abstract val selectedAbis: ListProperty + + @get:Input + abstract val selectedExtensions: ListProperty + + @get:Input + abstract val extensionVersions: MapProperty + + @get:OutputDirectory + abstract val assetCacheDir: DirectoryProperty + + @get:OutputDirectory + abstract val runtimeResourcesDir: DirectoryProperty + + @get:OutputDirectory + abstract val jniLibsDir: DirectoryProperty + + @get:OutputDirectory + abstract val extensionArchivesDir: DirectoryProperty + + @TaskAction + fun resolve() { + val releaseVersion = version.get() + validateReleaseVersion(releaseVersion) + val cache = assetCacheDir.get().asFile + cache.mkdirs() + val checksumAsset = "liboliphaunt-$releaseVersion-release-assets.sha256" + val checksumFile = downloadAsset(checksumAsset, cache) + val checksums = parseChecksums(checksumFile) + + val assets = + linkedSetOf( + "liboliphaunt-$releaseVersion-runtime-resources.tar.gz", + ) + for (abi in selectedAbis.get().ifEmpty { listOf("arm64-v8a", "x86_64") }) { + assets += androidBaseAsset(releaseVersion, abi) + } + + val downloaded = + assets + .associateWith { asset -> + downloadAndVerify(asset, cache, checksums) + }.toMutableMap() + val extensionDownloaded = linkedMapOf() + val selectedExtensionRows = + selectedExtensionRows( + releaseVersion, + cache, + extensionDownloaded, + selectedAbis.get().ifEmpty { listOf("arm64-v8a", "x86_64") }, + ) + + unpackRuntimeResources(downloaded.getValue("liboliphaunt-$releaseVersion-runtime-resources.tar.gz")) + mergeExtensionRuntimeArtifacts(extensionDownloaded, selectedExtensionRows) + unpackAndroidJniLibs(downloaded, releaseVersion) + unpackAndroidExtensionArchives(extensionDownloaded) + } + + private fun validateReleaseVersion(releaseVersion: String) { + require(releaseVersion.matches(Regex("[A-Za-z0-9._-]+"))) { + "invalid liboliphaunt release version: $releaseVersion" + } + } + + private fun androidBaseAsset( + releaseVersion: String, + abi: String, + ): String = + when (abi) { + "arm64-v8a" -> "liboliphaunt-$releaseVersion-android-arm64-v8a.tar.gz" + + "x86_64" -> "liboliphaunt-$releaseVersion-android-x86_64.tar.gz" + + else -> throw GradleException( + "liboliphaunt release assets are published for arm64-v8a and x86_64; got $abi", + ) + } + + private fun selectedExtensionRows( + defaultVersion: String, + cache: File, + downloaded: MutableMap, + abis: List, + ): List> { + if (selectedExtensions.get().isEmpty()) return emptyList() + val rows = linkedMapOf>() + for (extension in selectedExtensions.get()) { + selectExtension(defaultVersion, cache, downloaded, rows, extension, abis) + } + return rows.values.sortedBy { it.getValue("sql_name") } + } + + private fun selectExtension( + defaultVersion: String, + cache: File, + downloaded: MutableMap, + rows: MutableMap>, + sqlName: String, + abis: List, + ) { + if (rows.containsKey(sqlName)) return + val product = extensionProduct(sqlName) + val extensionVersion = extensionVersion(sqlName, product, defaultVersion) + val extensionCache = cache.resolve("$product-$extensionVersion") + extensionCache.mkdirs() + val extensionChecksums = + parseChecksums( + downloadExtensionAsset(product, extensionVersion, "$product-$extensionVersion-release-assets.sha256", extensionCache), + ) + val manifestAsset = "$product-$extensionVersion-manifest.properties" + val manifestFile = downloadAndVerifyExtension(product, extensionVersion, manifestAsset, extensionCache, extensionChecksums) + val manifest = Properties() + manifestFile.inputStream().use(manifest::load) + validateExtensionManifest(product, extensionVersion, sqlName, manifest) + + manifest + .getProperty("dependencies") + ?.split(',') + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.forEach { dependency -> + selectExtension(defaultVersion, cache, downloaded, rows, dependency, abis) + } + + val runtimeAssets = linkedSetOf() + val archiveTargets = sortedSetOf() + val nativeModuleStem = manifest.getProperty("nativeModuleStem")?.trim().orEmpty() + for (abi in abis) { + val target = androidTarget(abi) + val targetRuntimeAsset = requireExtensionAsset(manifest, product, target, "runtime", sqlName) + runtimeAssets.add(targetRuntimeAsset) + downloaded.getOrPut(targetRuntimeAsset) { + downloadAndVerifyExtension(product, extensionVersion, targetRuntimeAsset, extensionCache, extensionChecksums) + } + if (nativeModuleStem.isNotEmpty()) { + val staticArchiveAsset = requireExtensionAsset(manifest, product, target, "android-static-archive", sqlName) + downloaded.getOrPut(staticArchiveAsset) { + downloadAndVerifyExtension(product, extensionVersion, staticArchiveAsset, extensionCache, extensionChecksums) + } + archiveTargets.add(abi) + } + } + require(runtimeAssets.isNotEmpty()) { + "selected extension $sqlName did not resolve an Android runtime artifact" + } + validateEquivalentAndroidRuntimeAssets(product, extensionVersion, sqlName, runtimeAssets, extensionChecksums) + rows[sqlName] = + mapOf( + "sql_name" to sqlName, + "runtime_artifact" to runtimeAssets.first(), + "native_module_stem" to emptyToDash(manifest.getProperty("nativeModuleStem")), + "shared_preload" to emptyToDash(manifest.getProperty("sharedPreloadLibraries")), + "dependencies" to emptyToDash(manifest.getProperty("dependencies")), + "archive_targets" to if (archiveTargets.isEmpty()) "-" else archiveTargets.joinToString(","), + ) + } + + private fun validateEquivalentAndroidRuntimeAssets( + product: String, + version: String, + sqlName: String, + runtimeAssets: Set, + checksums: Map, + ) { + if (runtimeAssets.size <= 1) return + var expectedChecksum: String? = null + var expectedAsset: String? = null + for (asset in runtimeAssets) { + val checksum = + checksums[asset] + ?: throw GradleException("$product $version checksum manifest does not cover $asset") + if (expectedChecksum == null) { + expectedChecksum = checksum + expectedAsset = asset + } else if (expectedChecksum != checksum) { + throw GradleException( + "$product $version publishes different Android runtime artifacts for $sqlName: " + + "$expectedAsset and $asset. Android extension runtime payloads must be ABI-independent; " + + "put ABI-specific code in static archives.", + ) + } + } + } + + private fun extensionProduct(sqlName: String): String { + require(sqlName.matches(Regex("[A-Za-z0-9._-]{1,128}"))) { + "invalid Oliphaunt extension SQL name: $sqlName" + } + return "oliphaunt-extension-${sqlName.replace('_', '-')}" + } + + private fun extensionVersion( + sqlName: String, + product: String, + defaultVersion: String, + ): String { + val value = extensionVersions.get()[sqlName] ?: extensionVersions.get()[product] ?: defaultVersion + validateReleaseVersion(value) + return value + } + + private fun androidTarget(abi: String): String = + when (abi) { + "arm64-v8a" -> "android-arm64-v8a" + "x86_64" -> "android-x86_64" + else -> throw GradleException("unsupported liboliphaunt Android ABI $abi") + } + + private fun validateExtensionManifest( + product: String, + version: String, + sqlName: String, + manifest: Properties, + ) { + require(manifest.getProperty("schema") == "oliphaunt-extension-release-manifest-v1") { + "$product $version extension manifest has unsupported schema" + } + require(manifest.getProperty("product") == product) { + "$product $version extension manifest declares product ${manifest.getProperty("product")}" + } + require(manifest.getProperty("version") == version) { + "$product $version extension manifest declares version ${manifest.getProperty("version")}" + } + require(manifest.getProperty("sqlName") == sqlName) { + "$product $version extension manifest declares sqlName ${manifest.getProperty("sqlName")}" + } + require(manifest.getProperty("mobileReleaseReady") == "true") { + "$sqlName is not marked mobileReleaseReady in $product $version" + } + } + + private fun requireExtensionAsset( + manifest: Properties, + product: String, + target: String, + kind: String, + sqlName: String, + ): String = + manifest.getProperty("asset.native.$target.$kind")?.takeIf(String::isNotBlank) + ?: throw GradleException("$product manifest has no $kind asset for $sqlName target $target") + + private fun mergeExtensionRuntimeArtifacts( + downloaded: Map, + selectedRows: List>, + ) { + if (selectedRows.isEmpty()) return + val root = runtimeResourcesRoot(runtimeResourcesDir.get().asFile) + val runtimePackage = root.resolve("runtime") + val runtimeFiles = runtimePackage.resolve("files") + require(runtimeFiles.isDirectory) { + "liboliphaunt runtime resources did not contain oliphaunt/runtime/files" + } + val extractedArtifacts = + selectedRows.map { row -> + val sqlName = row.getValue("sql_name") + val artifact = downloaded.getValue(row.getValue("runtime_artifact")) + val artifactRoot = extractExtensionRuntimeArtifact(sqlName, artifact) + copyTree(artifactRoot.resolve("files").toPath(), runtimeFiles.toPath()) + ExtensionRuntimeArtifact( + sqlName = sqlName, + nativeModuleStem = row["native_module_stem"]?.takeIf { it != "-" }, + sharedPreload = row["shared_preload"]?.takeIf { it != "-" }, + ) + } + + val nativeArtifacts = extractedArtifacts.filter { it.nativeModuleStem != null } + val nativeModuleStems = nativeArtifacts.mapNotNull { it.nativeModuleStem }.toSortedSet().toList() + val registeredExtensions = nativeArtifacts.map { it.sqlName }.toSortedSet().toList() + val sharedPreloadLibraries = + extractedArtifacts + .mapNotNull { it.sharedPreload } + .flatMap { it.split(',') } + .map(String::trim) + .filter(String::isNotEmpty) + .toSortedSet() + .toList() + + val staticRegistrySource = + if (nativeArtifacts.isEmpty()) { + "" + } else { + val staticRegistryDir = root.resolve("static-registry") + staticRegistryDir.mkdirs() + val source = staticRegistryDir.resolve("oliphaunt_static_registry.c") + source.writeText(staticRegistrySourceText(runtimeFiles, nativeArtifacts), Charsets.UTF_8) + writeStaticRegistryManifest(staticRegistryDir, nativeArtifacts) + "static-registry/oliphaunt_static_registry.c" + } + + updateRuntimeManifest( + runtimePackage.resolve("manifest.properties"), + selectedRows.map { it.getValue("sql_name") }.toSortedSet().toList(), + sharedPreloadLibraries, + nativeModuleStems, + registeredExtensions, + staticRegistrySource, + ) + } + + private data class ExtensionRuntimeArtifact( + val sqlName: String, + val nativeModuleStem: String?, + val sharedPreload: String?, + ) + + private fun extractExtensionRuntimeArtifact( + sqlName: String, + artifact: File, + ): File { + require(artifact.name.endsWith(".tar.gz") || artifact.name.endsWith(".tgz")) { + "liboliphaunt release runtime artifact for $sqlName must be a Gradle-native .tar.gz archive, got ${artifact.name}" + } + val extractRoot = temporaryDir.resolve("runtime-artifact-$sqlName-${artifact.nameWithoutExtension}") + fileSystemOperations.delete { delete(extractRoot) } + fileSystemOperations.copy { + from(archiveOperations.tarTree(archiveOperations.gzip(artifact))) + into(extractRoot) + } + val artifactRoot = + when { + extractRoot.resolve("manifest.properties").isFile -> { + extractRoot + } + + else -> { + extractRoot + .listFiles() + ?.filter { it.isDirectory && it.resolve("manifest.properties").isFile } + ?.singleOrNull() + ?: throw GradleException( + "liboliphaunt extension runtime artifact ${artifact.name} did not contain one manifest.properties root", + ) + } + } + val manifest = Properties() + artifactRoot.resolve("manifest.properties").inputStream().use(manifest::load) + require(manifest.getProperty("packageLayout") == "oliphaunt-extension-artifact-v1") { + "liboliphaunt extension runtime artifact ${artifact.name} has unsupported packageLayout" + } + require(manifest.getProperty("sqlName") == sqlName) { + "liboliphaunt extension runtime artifact ${artifact.name} is for ${manifest.getProperty("sqlName")}, expected $sqlName" + } + require(artifactRoot.resolve("files").isDirectory) { + "liboliphaunt extension runtime artifact ${artifact.name} is missing files/" + } + return artifactRoot + } + + private fun updateRuntimeManifest( + manifestFile: File, + selectedExtensions: List, + sharedPreloadLibraries: List, + nativeModuleStems: List, + registeredExtensions: List, + staticRegistrySource: String, + ) { + val properties = Properties() + if (manifestFile.isFile) { + manifestFile.inputStream().use(properties::load) + } + properties.setProperty("schema", "oliphaunt-runtime-resources-v1") + properties.setProperty("extensions", selectedExtensions.joinToString(",")) + properties.setProperty("sharedPreloadLibraries", sharedPreloadLibraries.joinToString(",")) + properties.setProperty( + "mobileStaticRegistryState", + if (nativeModuleStems.isEmpty()) "not-required" else "complete", + ) + properties.setProperty("mobileStaticRegistryRegistered", registeredExtensions.joinToString(",")) + properties.setProperty("mobileStaticRegistryPending", "") + properties.setProperty("nativeModuleStems", nativeModuleStems.joinToString(",")) + properties.setProperty("mobileStaticRegistrySource", staticRegistrySource) + writeOrderedProperties(manifestFile, properties) + } + + private fun writeStaticRegistryManifest( + staticRegistryDir: File, + artifacts: List, + ) { + val modules = artifacts.mapNotNull { it.nativeModuleStem }.toSortedSet().toList() + val lines = + mutableListOf( + "packageLayout=oliphaunt-static-registry-v1", + "abiVersion=1", + "state=complete", + "source=oliphaunt_static_registry.c", + "registeredExtensions=${artifacts.map { it.sqlName }.toSortedSet().joinToString(",")}", + "pendingExtensions=", + "nativeModuleStems=${modules.joinToString(",")}", + "modules=${modules.joinToString(",")}", + "archiveTargets=arm64-v8a,x86_64", + ) + for (artifact in artifacts.sortedBy { it.nativeModuleStem }) { + val stem = artifact.nativeModuleStem ?: continue + lines += "module.$stem.extension=${artifact.sqlName}" + lines += "module.$stem.symbolPrefix=${staticRegistrySymbolPrefix(stem)}" + lines += "module.$stem.sqlSymbols=" + lines += "module.$stem.archiveTargets=arm64-v8a,x86_64" + lines += "module.$stem.archive.arm64-v8a=archives/arm64-v8a/extensions/$stem/liboliphaunt_extension_$stem.a" + lines += "module.$stem.archive.x86_64=archives/x86_64/extensions/$stem/liboliphaunt_extension_$stem.a" + } + staticRegistryDir.resolve("manifest.properties").writeText(lines.joinToString("\n", postfix = "\n")) + } + + private fun writeOrderedProperties( + file: File, + properties: Properties, + ) { + file.parentFile.mkdirs() + val preferred = + listOf( + "schema", + "cacheKey", + "layout", + "source", + "extensions", + "sharedPreloadLibraries", + "mobileStaticRegistryState", + "mobileStaticRegistryRegistered", + "mobileStaticRegistryPending", + "nativeModuleStems", + "mobileStaticRegistrySource", + ) + val keys = (preferred + properties.stringPropertyNames().sorted()).distinct() + file.writeText( + keys + .filter { properties.getProperty(it) != null } + .joinToString("\n", postfix = "\n") { key -> "$key=${properties.getProperty(key)}" }, + Charsets.UTF_8, + ) + } + + private fun staticRegistrySourceText( + runtimeFiles: File, + artifacts: List, + ): String { + val modules = + artifacts + .mapNotNull { artifact -> + val stem = artifact.nativeModuleStem ?: return@mapNotNull null + StaticRegistryModule( + extensionSqlName = artifact.sqlName, + moduleStem = stem, + symbolPrefix = staticRegistrySymbolPrefix(stem), + sqlSymbols = collectExtensionSqlSymbols(runtimeFiles, artifact.sqlName), + ) + }.sortedBy { it.moduleStem } + return buildString { + append("/* Generated by Oliphaunt Android Gradle plugin. Do not edit by hand. */\n") + append("#include \n#include \n#include \"oliphaunt.h\"\n\n") + append("#if defined(__GNUC__) || defined(__clang__)\n") + append("#define OLIPHAUNT_STATIC_OPTIONAL __attribute__((weak))\n") + append("#else\n#define OLIPHAUNT_STATIC_OPTIONAL\n#endif\n\n") + for (module in modules) { + append("extern const void *${module.symbolPrefix}_Pg_magic_func(void);\n") + append("extern void ${module.symbolPrefix}__PG_init(void) OLIPHAUNT_STATIC_OPTIONAL;\n") + for (symbol in module.sqlSymbols) { + append("extern void $symbol(void);\n") + append("extern void pg_finfo_$symbol(void);\n") + } + append('\n') + } + for (module in modules) { + append("static const OliphauntStaticExtensionSymbol ${module.symbolPrefix}_symbols[] = {\n") + for (symbol in module.sqlSymbols) { + append(" { .name = ${cStringLiteral(symbol)}, .address = (void *)$symbol },\n") + append( + " { .name = ${cStringLiteral("pg_finfo_$symbol")}, .address = (void *)pg_finfo_$symbol },\n", + ) + } + append("};\n\n") + } + append("static const OliphauntStaticExtension liboliphaunt_static_extensions[] = {\n") + for (module in modules) { + append(" {\n") + append(" .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION,\n") + append(" .name = ${cStringLiteral(module.moduleStem)},\n") + append(" .magic = ${module.symbolPrefix}_Pg_magic_func,\n") + append(" .init = ${module.symbolPrefix}__PG_init,\n") + append(" .symbols = ${module.symbolPrefix}_symbols,\n") + append( + " .symbol_count = sizeof(${module.symbolPrefix}_symbols) / sizeof(${module.symbolPrefix}_symbols[0]),\n", + ) + append(" .reserved_flags = 0,\n") + append(" },\n") + } + append("};\n\n") + append("const OliphauntStaticExtension *liboliphaunt_selected_static_extensions(size_t *count) {\n") + append(" if (count != NULL) {\n") + append(" *count = sizeof(liboliphaunt_static_extensions) / sizeof(liboliphaunt_static_extensions[0]);\n") + append(" }\n") + append(" return liboliphaunt_static_extensions;\n") + append("}\n") + } + } + + private data class StaticRegistryModule( + val extensionSqlName: String, + val moduleStem: String, + val symbolPrefix: String, + val sqlSymbols: List, + ) + + private fun collectExtensionSqlSymbols( + runtimeFiles: File, + sqlName: String, + ): List { + val extensionDir = runtimeFiles.resolve("share/postgresql/extension") + val prefix = "$sqlName--" + val sqlFiles = + extensionDir + .listFiles() + ?.filter { it.isFile && it.name.startsWith(prefix) && it.name.endsWith(".sql") } + ?.sortedBy(File::getName) + ?: emptyList() + require(sqlFiles.isNotEmpty()) { + "selected extension $sqlName has no packaged SQL files in ${extensionDir.absolutePath}" + } + return sqlFiles + .flatMap { file -> modulePathnameCSymbols(file.readText(Charsets.UTF_8)) } + .toSortedSet() + .toList() + } + + private fun modulePathnameCSymbols(sql: String): List = + splitSqlStatements(stripSqlLineComments(sql)) + .filter { statement -> + statement.contains("module_pathname", ignoreCase = true) && hasLanguageC(statement) + }.mapNotNull { statement -> + explicitModulePathnameSymbol(statement) ?: implicitFunctionSymbol(statement) + }.onEach { symbol -> + require(symbol.matches(Regex("[A-Za-z_][A-Za-z0-9_]*"))) { + "extension SQL references non-portable C symbol '$symbol'" + } + }.toSortedSet() + .toList() + + private fun stripSqlLineComments(sql: String): String { + val out = StringBuilder(sql.length) + var index = 0 + var inString = false + while (index < sql.length) { + val ch = sql[index] + if (ch == '\'') { + out.append(ch) + if (inString && index + 1 < sql.length && sql[index + 1] == '\'') { + index += 1 + out.append(sql[index]) + } else { + inString = !inString + } + } else if (!inString && ch == '-' && index + 1 < sql.length && sql[index + 1] == '-') { + index += 2 + while (index < sql.length && sql[index] != '\n') { + index += 1 + } + if (index < sql.length) out.append('\n') + } else { + out.append(ch) + } + index += 1 + } + return out.toString() + } + + private fun splitSqlStatements(sql: String): List { + val statements = mutableListOf() + var start = 0 + var index = 0 + var inString = false + while (index < sql.length) { + val ch = sql[index] + if (ch == '\'') { + if (inString && index + 1 < sql.length && sql[index + 1] == '\'') { + index += 1 + } else { + inString = !inString + } + } else if (!inString && ch == ';') { + statements += sql.substring(start, index).trim() + start = index + 1 + } + index += 1 + } + if (start < sql.length) statements += sql.substring(start).trim() + return statements.filter(String::isNotEmpty) + } + + private fun explicitModulePathnameSymbol(statement: String): String? { + val moduleIndex = statement.indexOf("module_pathname", ignoreCase = true) + if (moduleIndex < 0) return null + var rest = statement.substring(moduleIndex + "module_pathname".length).trimStart() + if (rest.startsWith('\'')) { + rest = rest.drop(1).trimStart() + } + if (!rest.startsWith(',')) return null + return parseSqlSingleQuotedLiteral(rest.drop(1).trimStart())?.first + } + + private fun implicitFunctionSymbol(statement: String): String? { + val functionIndex = statement.indexOf("function", ignoreCase = true) + if (functionIndex < 0) return null + val afterFunction = statement.substring(functionIndex + "function".length) + val nameEnd = afterFunction.indexOf('(') + if (nameEnd < 0) return null + return lastSqlIdentifier(afterFunction.substring(0, nameEnd).trim())?.takeIf(String::isNotEmpty) + } + + private fun parseSqlSingleQuotedLiteral(value: String): Pair? { + if (!value.startsWith('\'')) return null + val out = StringBuilder() + var index = 1 + while (index < value.length) { + val ch = value[index] + if (ch == '\'') { + if (index + 1 < value.length && value[index + 1] == '\'') { + out.append('\'') + index += 2 + continue + } + return out.toString() to value.substring(index + 1) + } + out.append(ch) + index += 1 + } + return null + } + + private fun lastSqlIdentifier(rawName: String): String? { + val parts = mutableListOf() + var start = 0 + var index = 0 + var inQuotes = false + while (index < rawName.length) { + val ch = rawName[index] + if (ch == '"') { + if (inQuotes && index + 1 < rawName.length && rawName[index + 1] == '"') { + index += 1 + } else { + inQuotes = !inQuotes + } + } else if (!inQuotes && ch == '.') { + parts += rawName.substring(start, index).trim() + start = index + 1 + } + index += 1 + } + parts += rawName.substring(start).trim() + val part = parts.lastOrNull()?.trim() ?: return null + return if (part.startsWith('"') && part.endsWith('"') && part.length >= 2) { + part.substring(1, part.length - 1).replace("\"\"", "\"") + } else { + part + } + } + + private fun hasLanguageC(statement: String): Boolean { + val tokens = + statement + .split(Regex("[^A-Za-z0-9_]+")) + .filter(String::isNotEmpty) + .map { it.lowercase() } + return tokens.windowed(2).any { it[0] == "language" && it[1] == "c" } + } + + private fun staticRegistrySymbolPrefix(moduleStem: String): String = + buildString { + append("oliphaunt_static_") + for (ch in moduleStem) { + append(if (ch.isLetterOrDigit() || ch == '_') ch else '_') + } + } + + private fun cStringLiteral(value: String): String = + buildString { + append('"') + for (ch in value) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } + append('"') + } + + private fun emptyToDash(value: String?): String = value?.takeIf(String::isNotBlank) ?: "-" + + private fun downloadAndVerify( + asset: String, + cache: File, + checksums: Map, + ): File { + val file = downloadAsset(asset, cache) + val expected = + checksums[asset] + ?: throw GradleException("liboliphaunt release checksum manifest does not cover $asset") + val actual = sha256(file) + if (actual != expected) { + throw GradleException( + "liboliphaunt release asset checksum mismatch for $asset: expected $expected, got $actual", + ) + } + return file + } + + private fun downloadAsset( + asset: String, + cache: File, + ): File { + require(!asset.contains('/') && !asset.contains('\\')) { + "release asset name must be a plain file name: $asset" + } + val output = cache.resolve(asset) + if (output.isFile) return output + val tmp = cache.resolve(".$asset.tmp") + val url = "${assetBaseUrl.get().trimEnd('/')}/$asset" + URI(url).toURL().openStream().use { input -> + tmp.outputStream().use { outputStream -> + input.copyTo(outputStream) + } + } + Files.move(tmp.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) + return output + } + + private fun downloadAndVerifyExtension( + product: String, + version: String, + asset: String, + cache: File, + checksums: Map, + ): File { + val file = downloadExtensionAsset(product, version, asset, cache) + val expected = + checksums[asset] + ?: throw GradleException("$product $version checksum manifest does not cover $asset") + val actual = sha256(file) + if (actual != expected) { + throw GradleException( + "$product $version asset checksum mismatch for $asset: expected $expected, got $actual", + ) + } + return file + } + + private fun downloadExtensionAsset( + product: String, + version: String, + asset: String, + cache: File, + ): File { + require(!asset.contains('/') && !asset.contains('\\')) { + "extension release asset name must be a plain file name: $asset" + } + val output = cache.resolve(asset) + if (output.isFile) return output + val tmp = cache.resolve(".$asset.tmp") + val url = "https://github.com/f0rr0/oliphaunt/releases/download/$product-v$version/$asset" + URI(url).toURL().openStream().use { input -> + tmp.outputStream().use { outputStream -> + input.copyTo(outputStream) + } + } + Files.move(tmp.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) + return output + } + + private fun parseChecksums(file: File): Map = + file + .readLines() + .filter(String::isNotBlank) + .associate { line -> + val parts = line.trim().split(Regex("\\s+")) + require(parts.size == 2 && parts[1].startsWith("./")) { + "malformed liboliphaunt checksum line in ${file.absolutePath}: $line" + } + parts[1].removePrefix("./") to parts[0] + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun unpackRuntimeResources(archive: File) { + val output = runtimeResourcesDir.get().asFile + fileSystemOperations.delete { delete(output) } + fileSystemOperations.copy { + from(archiveOperations.tarTree(archiveOperations.gzip(archive))) + into(output) + } + } + + private fun unpackAndroidJniLibs( + downloaded: Map, + releaseVersion: String, + ) { + val output = jniLibsDir.get().asFile + fileSystemOperations.delete { delete(output) } + for (abi in selectedAbis.get().ifEmpty { listOf("arm64-v8a", "x86_64") }) { + val asset = androidBaseAsset(releaseVersion, abi) + val extractRoot = temporaryDir.resolve("jni-$abi") + fileSystemOperations.delete { delete(extractRoot) } + fileSystemOperations.copy { + from(archiveOperations.tarTree(archiveOperations.gzip(downloaded.getValue(asset)))) + into(extractRoot) + } + val source = extractRoot.resolve("jni/$abi") + require(source.isDirectory) { + "liboliphaunt Android asset $asset did not contain jni/$abi" + } + fileSystemOperations.copy { + from(source) + into(output.resolve(abi)) + } + } + } + + private fun unpackAndroidExtensionArchives(downloaded: Map) { + val output = extensionArchivesDir.get().asFile + fileSystemOperations.delete { delete(output) } + for ((asset, archive) in downloaded) { + val abi = + when { + asset.contains("-native-android-arm64-v8a-static.") -> "arm64-v8a" + asset.contains("-native-android-x86_64-static.") -> "x86_64" + else -> continue + } + val extractRoot = temporaryDir.resolve("extension-$abi-${archive.nameWithoutExtension}") + fileSystemOperations.delete { delete(extractRoot) } + fileSystemOperations.copy { + from(archiveOperations.tarTree(archiveOperations.gzip(archive))) + into(extractRoot) + } + val source = extractRoot.resolve("extensions") + require(source.isDirectory) { + "liboliphaunt Android extension asset $asset did not contain extensions/" + } + fileSystemOperations.copy { + from(source) + into(output.resolve("$abi/extensions")) + } + } + } + + private fun runtimeResourcesRoot(root: File): File { + val nested = root.resolve("oliphaunt") + return when { + nested.isDirectory -> nested + root.resolve("runtime").isDirectory -> root + else -> nested + } + } + + private fun copyTree( + source: Path, + target: Path, + ) { + Files.walk(source).use { stream -> + stream.sorted().forEach { path -> + require(!Files.isSymbolicLink(path)) { + "Oliphaunt Android release assets do not support symlinks: $path" + } + val relative = source.relativize(path) + val destination = target.resolve(relative) + when { + Files.isDirectory(path) -> { + Files.createDirectories(destination) + } + + Files.isRegularFile(path) -> { + Files.createDirectories(destination.parent) + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING) + } + } + } + } + } + } + +fun parseExtensions(raw: String?): List = parsePortableList(raw, "extension name") + +fun parseVersionMap(raw: String?): Map { + if (raw.isNullOrBlank()) return emptyMap() + return raw + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .associate { value -> + val parts = value.split('=', limit = 2) + require(parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) { + "oliphauntExtensionVersions entries must use extension=version, got $value" + } + parts[0].trim() to parts[1].trim() + } +} + +fun parsePortableList( + raw: String?, + label: String, +): List { + if (raw.isNullOrBlank()) return emptyList() + val portableId = Regex("[A-Za-z0-9._-]{1,128}") + return raw + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .toSortedSet() + .onEach { value -> + require(portableId.matches(value)) { + "liboliphaunt $label '$value' must contain only ASCII letters, digits, '.', '_' or '-'" + } + }.toList() +} + +val packagedExtensions = parseExtensions(packagedExtensionsRaw) +val packagedExtensionVersions = parseVersionMap(explicitPackagedExtensionVersionsRaw) +val packagedMobileStaticModules = + parsePortableList( + packagedMobileStaticModulesRaw, + "mobile static module stem", + ) +val androidAbiFilters = parseAndroidAbiFilters(explicitAndroidAbiFiltersRaw) +val releaseAssetAbis = + androidAbiFilters.ifEmpty { + listOf("arm64-v8a", "x86_64") + } + +fun parseAndroidAbiFilters(raw: String?): List { + if (raw.isNullOrBlank() || raw.trim().equals("all", ignoreCase = true)) { + return emptyList() + } + val supported = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + return raw + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .onEach { value -> + require(value in supported) { + "Oliphaunt Android ABI filter '$value' is not supported; expected one of ${ + supported.joinToString(", ") + }" + } + } +} + +val resolveOliphauntAndroidReleaseAssets by tasks.registering(ResolveOliphauntAndroidReleaseAssetsTask::class) { + onlyIf { + liboliphauntReleaseVersion.isPresent + } + version.set(liboliphauntReleaseVersion) + assetBaseUrl.set(liboliphauntReleaseAssetBaseUrl) + selectedAbis.set(releaseAssetAbis) + selectedExtensions.set(packagedExtensions) + extensionVersions.set(packagedExtensionVersions) + assetCacheDir.set(layout.buildDirectory.dir("oliphaunt/release-asset-cache")) + runtimeResourcesDir.set(resolvedReleaseRuntimeResourcesDir) + jniLibsDir.set(resolvedReleaseAndroidJniLibsDir) + extensionArchivesDir.set(resolvedReleaseAndroidExtensionArchivesDir) +} + +val prepareOliphauntAndroidAssets by tasks.registering(PrepareOliphauntAndroidAssetsTask::class) { + if (liboliphauntReleaseVersion.isPresent) { + dependsOn(resolveOliphauntAndroidReleaseAssets) + } + runtimeResourcesDirPath.set(packagedRuntimeResourcesDir ?: "") + runtimeDirPath.set(packagedRuntimeDir ?: "") + templatePgdataDirPath.set(packagedTemplatePgdataDir ?: "") + selectedExtensions.set(packagedExtensions) + mobileStaticModuleStems.set(packagedMobileStaticModules) + generatedExtensionMetadata.from(layout.projectDirectory.file("src/generated/extensions.json")) + listOfNotNull(packagedRuntimeResourcesDir, packagedRuntimeDir, packagedTemplatePgdataDir) + .filter(String::isNotBlank) + .forEach { sourceDirectories.from(file(it)) } + outputDir.set(generatedAndroidAssetsDir) +} + +val prepareOliphauntAndroidJniLibs by tasks.registering(PrepareOliphauntAndroidJniLibsTask::class) { + if (liboliphauntReleaseVersion.isPresent) { + dependsOn(resolveOliphauntAndroidReleaseAssets) + } + jniLibsDirPath.set(packagedAndroidJniLibsDir ?: "") + packagedAndroidJniLibsDir?.takeIf(String::isNotBlank)?.let { sourceDirectories.from(file(it)) } + outputDir.set(generatedAndroidJniLibsDir) +} + +val buildNativeBridge by tasks.registering(Exec::class) { + inputs.files( + bridgeSource, + bridgeHeader, + layout.projectDirectory.file("../../../runtimes/liboliphaunt/native/include/oliphaunt.h"), + ) + outputs.file(bridgeArchive) + commandLine( + "sh", + "-c", + """ + set -eu + mkdir -p "${bridgeOutputDir.get().asFile.absolutePath}" + cc -std=c11 -fPIC -I"${project.layout.projectDirectory.dir( + "src/nativeInterop/cinterop", + ).asFile.absolutePath}" -I"${project.layout.projectDirectory.dir( + "../../../runtimes/liboliphaunt/native/include", + ).asFile.absolutePath}" -c "${bridgeSource.asFile.absolutePath}" -o "${bridgeOutputDir.get().file( + "oliphaunt_kotlin_bridge.o", + ).asFile.absolutePath}" + ar rcs "${bridgeArchive.get().asFile.absolutePath}" "${bridgeOutputDir.get().file("oliphaunt_kotlin_bridge.o").asFile.absolutePath}" + """.trimIndent(), + ) +} + +val oliphauntJvmToolchainVersion = + providers + .gradleProperty("oliphauntJvmToolchain") + .orElse("17") + .map(String::toInt) + +kotlin { + jvmToolchain(oliphauntJvmToolchainVersion.get()) + + androidTarget() + jvm() + when { + System.getProperty("os.name").startsWith("Mac") -> macosArm64() + System.getProperty("os.arch") == "aarch64" -> linuxArm64() + else -> linuxX64() + } + + targets.withType().configureEach { + compilations["main"].cinterops.create("oliphaunt") { + definitionFile.set(project.file("src/nativeInterop/cinterop/oliphaunt.def")) + includeDirs(project.layout.projectDirectory.dir("../../../runtimes/liboliphaunt/native/include")) + includeDirs(project.layout.projectDirectory.dir("src/nativeInterop/cinterop")) + extraOpts( + "-libraryPath", + bridgeOutputDir.get().asFile.absolutePath, + "-staticLibrary", + bridgeArchive.get().asFile.name, + ) + } + } + + sourceSets { + val nativeMain by creating { + dependsOn(commonMain.get()) + } + val nativeTest by creating { + dependsOn(commonTest.get()) + } + targets.withType().configureEach { + compilations["main"].defaultSourceSet.dependsOn(nativeMain) + compilations["test"].defaultSourceSet.dependsOn(nativeTest) + } + + commonMain.dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + implementation(libs.kotlinx.serialization.json) + } + } +} + +tasks.withType().configureEach { + systemProperty( + "oliphaunt.sharedFixturesDir", + rootProject.layout.projectDirectory + .dir("../../shared/fixtures") + .asFile.absolutePath, + ) +} + +tasks + .matching { + it.name.startsWith("cinteropOliphaunt") || it.name.startsWith("cinteropLiboliphaunt") + }.configureEach { + dependsOn(buildNativeBridge) + } + +android { + namespace = "dev.oliphaunt" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + if (androidAbiFilters.isNotEmpty()) { + ndk { + abiFilters.addAll(androidAbiFilters) + } + } + externalNativeBuild { + cmake { + cppFlags += "-std=c++17" + if (packagedMobileStaticModules.isNotEmpty()) { + arguments += "-DOLIPHAUNT_MOBILE_STATIC_MODULES=${packagedMobileStaticModules.joinToString(";")}" + packagedStaticRegistrySource?.let { source -> + arguments += "-DOLIPHAUNT_STATIC_REGISTRY_SOURCE=$source" + } + effectiveAndroidExtensionArchivesDir?.let { archiveRoot -> + arguments += "-DOLIPHAUNT_EXTENSION_ARCHIVES_ROOT=${file(archiveRoot).absolutePath}" + } + packagedAndroidJniLibsDir + ?.takeIf(String::isNotBlank) + ?.let { jniRoot -> + arguments += "-DOLIPHAUNT_ANDROID_JNI_LIBS_ROOT=${file(jniRoot).absolutePath}" + } + packagedAndroidLinkEvidenceFile + ?.takeIf(String::isNotBlank) + ?.let { evidenceFile -> + arguments += "-DOLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE=${file(evidenceFile).absolutePath}" + } + } + } + } + } + + externalNativeBuild { + cmake { + path = file("src/androidMain/cpp/CMakeLists.txt") + buildStagingDirectory = cxxBuildRoot + version = "3.22.1" + } + } + + sourceSets["main"].assets.srcDir(generatedAndroidAssetsDir) + sourceSets["main"].jniLibs.srcDir(generatedAndroidJniLibsDir) +} + +tasks.named("preBuild") { + dependsOn(prepareOliphauntAndroidAssets) + dependsOn(prepareOliphauntAndroidJniLibs) +} diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt b/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt new file mode 100644 index 00000000..5db138c9 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt @@ -0,0 +1,124 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(liboliphaunt_kotlin_android LANGUAGES C CXX) + +add_library(oliphaunt_kotlin_android SHARED + oliphaunt_android_bridge.cpp +) + +target_compile_features(oliphaunt_kotlin_android PRIVATE cxx_std_17) + +target_include_directories(oliphaunt_kotlin_android PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" +) + +target_link_libraries(oliphaunt_kotlin_android PRIVATE + dl + log +) + +target_link_options(oliphaunt_kotlin_android PRIVATE + "-Wl,-z,max-page-size=16384" +) + +function(oliphaunt_find_file out_var) + foreach(candidate IN LISTS ARGN) + if(EXISTS "${candidate}") + set("${out_var}" "${candidate}" PARENT_SCOPE) + return() + endif() + endforeach() + set("${out_var}" "" PARENT_SCOPE) +endfunction() + +if(DEFINED OLIPHAUNT_MOBILE_STATIC_MODULES AND NOT "${OLIPHAUNT_MOBILE_STATIC_MODULES}" STREQUAL "") + if(NOT DEFINED OLIPHAUNT_STATIC_REGISTRY_SOURCE OR NOT EXISTS "${OLIPHAUNT_STATIC_REGISTRY_SOURCE}") + message(FATAL_ERROR "OLIPHAUNT_MOBILE_STATIC_MODULES requires OLIPHAUNT_STATIC_REGISTRY_SOURCE from oliphaunt-resources --mobile-static-module") + endif() + if(NOT DEFINED OLIPHAUNT_EXTENSION_ARCHIVES_ROOT OR "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}" STREQUAL "") + message(FATAL_ERROR "OLIPHAUNT_MOBILE_STATIC_MODULES requires OLIPHAUNT_EXTENSION_ARCHIVES_ROOT from static-registry/archives or -PoliphauntAndroidExtensionArchivesDir") + endif() + if(NOT DEFINED OLIPHAUNT_ANDROID_JNI_LIBS_ROOT OR "${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}" STREQUAL "") + message(FATAL_ERROR "OLIPHAUNT_MOBILE_STATIC_MODULES requires OLIPHAUNT_ANDROID_JNI_LIBS_ROOT with liboliphaunt.so") + endif() + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + get_filename_component(OLIPHAUNT_ANDROID_LINK_EVIDENCE_DIR "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" DIRECTORY) + file(MAKE_DIRECTORY "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_DIR}") + file(WRITE "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "schema\toliphaunt-android-static-extension-link-v1\n") + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "abi\t${ANDROID_ABI}\n") + endif() + + oliphaunt_find_file(OLIPHAUNT_IMPORTED_LIBRARY + "${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}/jniLibs/${ANDROID_ABI}/liboliphaunt.so" + "${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}/${ANDROID_ABI}/liboliphaunt.so" + ) + if("${OLIPHAUNT_IMPORTED_LIBRARY}" STREQUAL "") + message(FATAL_ERROR "Could not find liboliphaunt.so for ${ANDROID_ABI} under OLIPHAUNT_ANDROID_JNI_LIBS_ROOT=${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}") + endif() + + add_library(oliphaunt_imported SHARED IMPORTED) + set_target_properties(oliphaunt_imported PROPERTIES + IMPORTED_LOCATION "${OLIPHAUNT_IMPORTED_LIBRARY}" + ) + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "runtime\tliboliphaunt\t${OLIPHAUNT_IMPORTED_LIBRARY}\n") + endif() + + add_library(oliphaunt_extensions SHARED + "${OLIPHAUNT_STATIC_REGISTRY_SOURCE}" + ) + target_include_directories(oliphaunt_extensions PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + ) + target_link_libraries(oliphaunt_extensions PRIVATE + oliphaunt_imported + ) + foreach(stem IN LISTS OLIPHAUNT_MOBILE_STATIC_MODULES) + oliphaunt_find_file(extension_archive + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/jniLibs/${ANDROID_ABI}/extensions/${stem}/liboliphaunt_extension_${stem}.a" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/android-${ANDROID_ABI}/extensions/${stem}/liboliphaunt_extension_${stem}.a" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/${ANDROID_ABI}/extensions/${stem}/liboliphaunt_extension_${stem}.a" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/extensions/${stem}/liboliphaunt_extension_${stem}.a" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/${stem}/liboliphaunt_extension_${stem}.a" + ) + if("${extension_archive}" STREQUAL "") + message(FATAL_ERROR "Could not find prebuilt liboliphaunt_extension_${stem}.a for ${ANDROID_ABI} under OLIPHAUNT_EXTENSION_ARCHIVES_ROOT=${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}") + endif() + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "extension\t${stem}\t${extension_archive}\n") + endif() + target_link_libraries(oliphaunt_extensions PRIVATE + "-Wl,--whole-archive" + "${extension_archive}" + "-Wl,--no-whole-archive" + ) + endforeach() + set(oliphaunt_dependency_archives) + foreach(dependency_root + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/jniLibs/${ANDROID_ABI}/dependencies" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/android-${ANDROID_ABI}/dependencies" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/${ANDROID_ABI}/dependencies" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/dependencies") + if(IS_DIRECTORY "${dependency_root}") + file(GLOB_RECURSE dependency_archives CONFIGURE_DEPENDS "${dependency_root}/*.a") + list(APPEND oliphaunt_dependency_archives ${dependency_archives}) + endif() + endforeach() + if(oliphaunt_dependency_archives) + list(REMOVE_DUPLICATES oliphaunt_dependency_archives) + list(SORT oliphaunt_dependency_archives) + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + foreach(dependency_archive IN LISTS oliphaunt_dependency_archives) + get_filename_component(dependency_dir "${dependency_archive}" DIRECTORY) + get_filename_component(dependency_name "${dependency_dir}" NAME) + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "dependency\t${dependency_name}\t${dependency_archive}\n") + endforeach() + endif() + target_link_libraries(oliphaunt_extensions PRIVATE + ${oliphaunt_dependency_archives} + ) + endif() + target_link_options(oliphaunt_extensions PRIVATE + "-Wl,-z,max-page-size=16384" + ) +endif() diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include/oliphaunt.h b/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include/oliphaunt.h new file mode 100644 index 00000000..262d46d5 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include/oliphaunt.h @@ -0,0 +1,172 @@ +#ifndef OLIPHAUNT_H +#define OLIPHAUNT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define OLIPHAUNT_ABI_VERSION 6u +#define OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION 1u + +#define OLIPHAUNT_CAP_PROTOCOL_RAW (1ull << 0) +#define OLIPHAUNT_CAP_PROTOCOL_STREAM (1ull << 1) +#define OLIPHAUNT_CAP_MULTI_INSTANCE (1ull << 2) +#define OLIPHAUNT_CAP_SERVER_MODE (1ull << 3) +#define OLIPHAUNT_CAP_EXTENSIONS (1ull << 4) +#define OLIPHAUNT_CAP_QUERY_CANCEL (1ull << 5) +#define OLIPHAUNT_CAP_BACKUP_RESTORE (1ull << 6) +#define OLIPHAUNT_CAP_SIMPLE_QUERY (1ull << 7) +#define OLIPHAUNT_CAP_STATIC_EXTENSIONS (1ull << 8) +#define OLIPHAUNT_CAP_LOGICAL_REOPEN (1ull << 9) + +#define OLIPHAUNT_BACKUP_FORMAT_SQL 1u +#define OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE 2u +#define OLIPHAUNT_BACKUP_FORMAT_OLIPHAUNT_ARCHIVE 3u + +#if defined(_WIN32) && defined(OLIPHAUNT_BUILDING_DLL) +#define OLIPHAUNT_API __declspec(dllexport) +#elif defined(_WIN32) +#define OLIPHAUNT_API __declspec(dllimport) +#else +#define OLIPHAUNT_API +#endif + +/* + * The caller already owns an equivalent root lock for this PGDATA path. + * + * Leave this flag unset for plain C, Swift, Kotlin, and other direct C ABI + * callers; oliphaunt_init will then take a non-blocking stable filesystem lease + * for and create /.oliphaunt.lock as the + * visible root marker. The Rust SDK sets this flag because it owns a stronger + * process-plus-filesystem root coordinator across direct, broker, server, + * backup, and restore paths. + */ +#define OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK (1ull << 0) + +#define OLIPHAUNT_RESTORE_REPLACE_EXISTING (1ull << 0) + +typedef struct OliphauntHandle OliphauntHandle; + +typedef struct OliphauntStaticExtensionSymbol { + const char *name; + void *address; +} OliphauntStaticExtensionSymbol; + +typedef struct OliphauntStaticExtension { + uint32_t abi_version; + const char *name; + const void *(*magic)(void); + void (*init)(void); + const OliphauntStaticExtensionSymbol *symbols; + size_t symbol_count; + uint64_t reserved_flags; +} OliphauntStaticExtension; + +/* + * Registers statically linked PostgreSQL extension modules for the embedded + * backend's normal LOAD path. + * + * Call this before oliphaunt_init in processes that link extension code directly + * into the application or SDK library. The registry is process-wide and becomes + * immutable once backend startup begins. Each extension name is the module stem + * used by SQL, for example AS 'vector', and each symbol row exposes the C + * symbols PostgreSQL would otherwise resolve with dlsym(). + */ + +/* + * Direct-mode extension compatibility contract: + * + * oliphaunt_init sets the process PGDATA environment variable to this config's + * pgdata path while the embedded backend is active, because PostgreSQL + * extensions may read PGDATA through standard process APIs. oliphaunt_detach + * releases a logical direct-mode lease but keeps the resident backend alive; + * oliphaunt_close is terminal for the process lifetime and restores the caller's + * previous PGDATA value, or unsets it if it was unset. + * + * Callers that require process environment isolation should use broker/server + * mode through the Rust SDK instead of keeping multiple direct-mode backends in + * one process. + */ +typedef struct OliphauntConfig { + uint32_t abi_version; + const char *pgdata; + const char *runtime_dir; + const char *username; + const char *database; + uint64_t reserved_flags; + const char *const *startup_args; + size_t startup_arg_count; +} OliphauntConfig; + +typedef struct OliphauntResponse { + uint8_t *data; + size_t len; +} OliphauntResponse; + +typedef struct OliphauntArchiveFile { + const char *path; + const uint8_t *data; + size_t len; + uint32_t mode; + uint64_t reserved_flags; +} OliphauntArchiveFile; + +typedef struct OliphauntBackupOptions { + uint32_t abi_version; + uint32_t format; + const OliphauntArchiveFile *generated_files; + size_t generated_file_count; + uint64_t reserved_flags; +} OliphauntBackupOptions; + +typedef struct OliphauntRestoreOptions { + uint32_t abi_version; + const char *root; + uint32_t format; + const uint8_t *data; + size_t len; + uint64_t flags; +} OliphauntRestoreOptions; + +typedef int32_t (*OliphauntStreamCallback)(void *context, const uint8_t *data, size_t len); + +OLIPHAUNT_API int32_t oliphaunt_init(const OliphauntConfig *config, OliphauntHandle **out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_simple_query( + OliphauntHandle *handle, + const char *sql, + size_t sql_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol_stream( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +OLIPHAUNT_API int32_t oliphaunt_backup(OliphauntHandle *handle, uint32_t format, OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_backup_ex( + OliphauntHandle *handle, + const OliphauntBackupOptions *options, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_restore(const OliphauntRestoreOptions *options); +OLIPHAUNT_API int32_t oliphaunt_cancel(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_detach(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_close(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_register_static_extensions(const OliphauntStaticExtension *extensions, size_t count); +OLIPHAUNT_API const char *oliphaunt_last_error(OliphauntHandle *handle); +OLIPHAUNT_API const char *oliphaunt_version(void); +OLIPHAUNT_API uint64_t oliphaunt_capabilities(void); +OLIPHAUNT_API void oliphaunt_free_response(OliphauntResponse *response); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/oliphaunt_android_bridge.cpp b/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/oliphaunt_android_bridge.cpp new file mode 100644 index 00000000..aef44ae2 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/cpp/oliphaunt_android_bridge.cpp @@ -0,0 +1,646 @@ +#include "oliphaunt.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using OliphauntInitFn = int32_t (*)(const OliphauntConfig *, OliphauntHandle **); +using OliphauntExecProtocolFn = int32_t (*)( + OliphauntHandle *, + const uint8_t *, + size_t, + OliphauntResponse *); +using OliphauntExecProtocolStreamFn = int32_t (*)( + OliphauntHandle *, + const uint8_t *, + size_t, + OliphauntStreamCallback, + void *); +using OliphauntCancelFn = int32_t (*)(OliphauntHandle *); +using OliphauntDetachFn = int32_t (*)(OliphauntHandle *); +using OliphauntCloseFn = int32_t (*)(OliphauntHandle *); +using OliphauntRegisterStaticExtensionsFn = int32_t (*)(const OliphauntStaticExtension *, size_t); +using OliphauntSelectedStaticExtensionsFn = const OliphauntStaticExtension *(*)(size_t *); +using OliphauntLastErrorFn = const char *(*)(OliphauntHandle *); +using OliphauntCapabilitiesFn = uint64_t (*)(void); +using OliphauntFreeResponseFn = void (*)(OliphauntResponse *); +using OliphauntBackupFn = int32_t (*)(OliphauntHandle *, uint32_t, OliphauntResponse *); +using OliphauntRestoreFn = int32_t (*)(const OliphauntRestoreOptions *); + +struct Symbols { + void *library = nullptr; + bool ownsLibrary = false; + OliphauntInitFn init = nullptr; + OliphauntExecProtocolFn execProtocol = nullptr; + OliphauntExecProtocolStreamFn execProtocolStream = nullptr; + OliphauntCancelFn cancel = nullptr; + OliphauntDetachFn detach = nullptr; + OliphauntCloseFn close = nullptr; + OliphauntRegisterStaticExtensionsFn registerStaticExtensions = nullptr; + OliphauntLastErrorFn lastError = nullptr; + OliphauntCapabilitiesFn capabilities = nullptr; + OliphauntFreeResponseFn freeResponse = nullptr; + OliphauntBackupFn backup = nullptr; + OliphauntRestoreFn restore = nullptr; +}; + +struct Session { + Symbols symbols; + OliphauntHandle *handle = nullptr; + char lastError[1024] = {0}; +}; + +struct StreamContext { + JNIEnv *env = nullptr; + jobject sink = nullptr; + jmethodID onChunk = nullptr; + bool failed = false; + std::string error; +}; + +std::string jniString(JNIEnv *env, jstring value) { + if (value == nullptr) { + return {}; + } + const char *chars = env->GetStringUTFChars(value, nullptr); + if (chars == nullptr) { + return {}; + } + std::string out(chars); + env->ReleaseStringUTFChars(value, chars); + return out; +} + +std::vector jniStringArray(JNIEnv *env, jobjectArray values) { + std::vector out; + if (values == nullptr) { + return out; + } + const jsize count = env->GetArrayLength(values); + out.reserve(static_cast(count)); + for (jsize index = 0; index < count; index += 1) { + auto item = static_cast(env->GetObjectArrayElement(values, index)); + out.push_back(jniString(env, item)); + env->DeleteLocalRef(item); + } + return out; +} + +void throwException(JNIEnv *env, const char *className, const std::string &message) { + jclass cls = env->FindClass(className); + if (cls == nullptr) { + return; + } + env->ThrowNew(cls, message.c_str()); + env->DeleteLocalRef(cls); +} + +void throwIllegalState(JNIEnv *env, const std::string &message) { + throwException(env, "java/lang/IllegalStateException", message); +} + +void throwRuntime(JNIEnv *env, const std::string &message) { + throwException(env, "java/lang/RuntimeException", message); +} + +const char *envPath(const char *name) { + const char *value = std::getenv(name); + return value != nullptr && value[0] != '\0' ? value : nullptr; +} + +std::string defaultLibraryPath() { + const char *path = envPath("OLIPHAUNT_KOTLIN_ANDROID_LIBRARY"); + if (path == nullptr) { + path = envPath("LIBOLIPHAUNT_PATH"); + } + if (path == nullptr) { + path = envPath("OLIPHAUNT_LIBRARY"); + } + return path != nullptr ? std::string(path) : std::string("liboliphaunt.so"); +} + +void unloadSymbols(Symbols *symbols) { + // liboliphaunt embeds PostgreSQL, which installs process-global runtime state + // while a backend session is active. Ordinary SDK close calls oliphaunt_detach; + // oliphaunt_close is terminal for the process lifetime. Unloading the code image + // can leave host-process callbacks or handlers pointing at unmapped addresses. + // Keep the native engine resident once it has been loaded. + *symbols = Symbols{}; +} + +bool loadSymbol(Symbols *symbols, const char *name, void **out, std::string *error) { + dlerror(); + void *lookupHandle = symbols->library != nullptr ? symbols->library : RTLD_DEFAULT; + *out = dlsym(lookupHandle, name); + const char *dlError = dlerror(); + if (dlError != nullptr || *out == nullptr) { + *error = "liboliphaunt symbol "; + *error += name; + *error += " is unavailable: "; + *error += dlError != nullptr ? dlError : "symbol not found"; + return false; + } + return true; +} + +bool loadSymbols(const std::string &configuredLibraryPath, Symbols *symbols, std::string *error) { + *symbols = Symbols{}; + std::string libraryPath = configuredLibraryPath.empty() + ? defaultLibraryPath() + : configuredLibraryPath; + + if (!libraryPath.empty()) { + symbols->library = dlopen(libraryPath.c_str(), RTLD_NOW | RTLD_LOCAL); + if (symbols->library == nullptr && configuredLibraryPath.empty()) { + libraryPath.clear(); + } else if (symbols->library == nullptr) { + *error = "failed to load liboliphaunt at "; + *error += configuredLibraryPath; + *error += ": "; + *error += dlerror(); + return false; + } else { + symbols->ownsLibrary = true; + } + } + + if (!loadSymbol(symbols, "oliphaunt_init", reinterpret_cast(&symbols->init), error) || + !loadSymbol(symbols, "oliphaunt_exec_protocol", reinterpret_cast(&symbols->execProtocol), error) || + !loadSymbol(symbols, "oliphaunt_exec_protocol_stream", reinterpret_cast(&symbols->execProtocolStream), error) || + !loadSymbol(symbols, "oliphaunt_cancel", reinterpret_cast(&symbols->cancel), error) || + !loadSymbol(symbols, "oliphaunt_detach", reinterpret_cast(&symbols->detach), error) || + !loadSymbol(symbols, "oliphaunt_close", reinterpret_cast(&symbols->close), error) || + !loadSymbol(symbols, "oliphaunt_register_static_extensions", reinterpret_cast(&symbols->registerStaticExtensions), error) || + !loadSymbol(symbols, "oliphaunt_last_error", reinterpret_cast(&symbols->lastError), error) || + !loadSymbol(symbols, "oliphaunt_capabilities", reinterpret_cast(&symbols->capabilities), error) || + !loadSymbol(symbols, "oliphaunt_free_response", reinterpret_cast(&symbols->freeResponse), error) || + !loadSymbol(symbols, "oliphaunt_backup", reinterpret_cast(&symbols->backup), error) || + !loadSymbol(symbols, "oliphaunt_restore", reinterpret_cast(&symbols->restore), error)) { + unloadSymbols(symbols); + if (libraryPath.empty()) { + *error += "; package liboliphaunt.so with the app or pass libraryPath"; + } + return false; + } + return true; +} + +bool registerSelectedStaticExtensions(Symbols *symbols, std::string *error) { + dlerror(); + OliphauntSelectedStaticExtensionsFn selected = nullptr; + static void *extensionLibrary = nullptr; + if (symbols->library != nullptr) { + selected = reinterpret_cast( + dlsym(symbols->library, "liboliphaunt_selected_static_extensions")); + const char *libraryError = dlerror(); + if (libraryError != nullptr) { + selected = nullptr; + } + dlerror(); + } + if (selected == nullptr && extensionLibrary == nullptr) { + extensionLibrary = dlopen("liboliphaunt_extensions.so", RTLD_NOW | RTLD_GLOBAL); + dlerror(); + } + if (selected == nullptr && extensionLibrary != nullptr) { + selected = reinterpret_cast( + dlsym(extensionLibrary, "liboliphaunt_selected_static_extensions")); + const char *extensionError = dlerror(); + if (extensionError != nullptr) { + selected = nullptr; + } + dlerror(); + } + if (selected == nullptr) { + selected = reinterpret_cast( + dlsym(RTLD_DEFAULT, "liboliphaunt_selected_static_extensions")); + } + const char *dlError = dlerror(); + if (dlError != nullptr || selected == nullptr) { + return true; + } + size_t count = 0; + const OliphauntStaticExtension *extensions = selected(&count); + if (count == 0) { + return true; + } + if (extensions == nullptr) { + *error = "selected liboliphaunt static extension registry returned null extensions"; + return false; + } + if (symbols->registerStaticExtensions(extensions, count) != 0) { + const char *message = symbols->lastError != nullptr ? symbols->lastError(nullptr) : nullptr; + *error = message != nullptr ? message : "liboliphaunt static extension registration failed"; + return false; + } + return true; +} + +Session *sessionFromHandle(jlong handle) { + return reinterpret_cast(static_cast(handle)); +} + +std::string lastError(Session *session) { + if (session == nullptr) { + return "invalid liboliphaunt Android session"; + } + const char *message = session->symbols.lastError != nullptr + ? session->symbols.lastError(session->handle) + : nullptr; + std::snprintf( + session->lastError, + sizeof(session->lastError), + "%s", + message != nullptr ? message : "unknown liboliphaunt Android runtime error"); + return session->lastError; +} + +uint32_t backupFormatId(const std::string &format) { + if (format == "physicalArchive") { + return OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE; + } + if (format == "sql") { + return OLIPHAUNT_BACKUP_FORMAT_SQL; + } + if (format == "oliphauntArchive") { + return OLIPHAUNT_BACKUP_FORMAT_OLIPHAUNT_ARCHIVE; + } + return 0; +} + +int32_t streamCallback(void *context, const uint8_t *data, size_t len) { + auto *stream = static_cast(context); + if (stream == nullptr || stream->env == nullptr || stream->sink == nullptr || stream->onChunk == nullptr) { + return -1; + } + jbyteArray chunk = stream->env->NewByteArray(static_cast(len)); + if (chunk == nullptr) { + stream->failed = true; + stream->error = "failed to allocate protocol stream chunk"; + return -1; + } + if (len > 0 && data != nullptr) { + stream->env->SetByteArrayRegion( + chunk, + 0, + static_cast(len), + reinterpret_cast(data)); + if (stream->env->ExceptionCheck()) { + stream->failed = true; + stream->env->DeleteLocalRef(chunk); + return -1; + } + } + jint rc = stream->env->CallIntMethod(stream->sink, stream->onChunk, chunk); + stream->env->DeleteLocalRef(chunk); + if (stream->env->ExceptionCheck()) { + stream->failed = true; + return -1; + } + if (rc != 0) { + stream->failed = true; + stream->error = "protocol stream callback failed"; + return -1; + } + return 0; +} + +} // namespace + +extern "C" JNIEXPORT jlong JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_openNative( + JNIEnv *env, + jobject, + jstring libraryPath, + jstring pgdata, + jstring runtimeDirectory, + jstring username, + jstring database, + jobjectArray startupArgs) { + auto session = new Session(); + std::string error; + if (!loadSymbols(jniString(env, libraryPath), &session->symbols, &error)) { + delete session; + throwRuntime(env, error); + return 0; + } + if (!registerSelectedStaticExtensions(&session->symbols, &error)) { + unloadSymbols(&session->symbols); + delete session; + throwRuntime(env, error); + return 0; + } + + std::vector args = jniStringArray(env, startupArgs); + std::vector argPointers; + argPointers.reserve(args.size()); + for (const auto &arg : args) { + argPointers.push_back(arg.c_str()); + } + + std::string pgdataPath = jniString(env, pgdata); + std::string runtimePath = jniString(env, runtimeDirectory); + std::string usernameString = jniString(env, username); + std::string databaseString = jniString(env, database); + OliphauntConfig config = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .pgdata = pgdataPath.c_str(), + .runtime_dir = runtimePath.c_str(), + .username = usernameString.c_str(), + .database = databaseString.c_str(), + .reserved_flags = 0, + .startup_args = argPointers.data(), + .startup_arg_count = argPointers.size(), + }; + + int32_t rc = session->symbols.init(&config, &session->handle); + if (rc != 0 || session->handle == nullptr) { + error = lastError(session); + unloadSymbols(&session->symbols); + delete session; + throwRuntime(env, error); + return 0; + } + + return static_cast(reinterpret_cast(session)); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_execProtocolRawNative( + JNIEnv *env, + jobject, + jlong handle, + jbyteArray request) { + Session *session = sessionFromHandle(handle); + if (session == nullptr || session->handle == nullptr) { + throwIllegalState(env, "Oliphaunt database is closed"); + return nullptr; + } + if (request == nullptr) { + throwRuntime(env, "request must not be null"); + return nullptr; + } + + const jsize requestLength = env->GetArrayLength(request); + std::vector requestBytes(static_cast(requestLength)); + if (requestLength > 0) { + env->GetByteArrayRegion( + request, + 0, + requestLength, + reinterpret_cast(requestBytes.data())); + if (env->ExceptionCheck()) { + return nullptr; + } + } + + OliphauntResponse response = {nullptr, 0}; + int32_t rc = session->symbols.execProtocol( + session->handle, + requestBytes.empty() ? nullptr : requestBytes.data(), + requestBytes.size(), + &response); + if (rc != 0) { + std::string error = lastError(session); + if (session->symbols.freeResponse != nullptr) { + session->symbols.freeResponse(&response); + } + throwRuntime(env, error); + return nullptr; + } + + jbyteArray out = env->NewByteArray(static_cast(response.len)); + if (out != nullptr && response.len > 0) { + env->SetByteArrayRegion( + out, + 0, + static_cast(response.len), + reinterpret_cast(response.data)); + } + session->symbols.freeResponse(&response); + return out; +} + +extern "C" JNIEXPORT void JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_execProtocolStreamNative( + JNIEnv *env, + jobject, + jlong handle, + jbyteArray request, + jobject sink) { + Session *session = sessionFromHandle(handle); + if (session == nullptr || session->handle == nullptr) { + throwIllegalState(env, "Oliphaunt database is closed"); + return; + } + if (request == nullptr) { + throwRuntime(env, "request must not be null"); + return; + } + if (sink == nullptr) { + throwRuntime(env, "stream sink must not be null"); + return; + } + + const jsize requestLength = env->GetArrayLength(request); + std::vector requestBytes(static_cast(requestLength)); + if (requestLength > 0) { + env->GetByteArrayRegion( + request, + 0, + requestLength, + reinterpret_cast(requestBytes.data())); + if (env->ExceptionCheck()) { + return; + } + } + + jclass sinkClass = env->GetObjectClass(sink); + if (sinkClass == nullptr) { + return; + } + jmethodID onChunk = env->GetMethodID(sinkClass, "onChunk", "([B)I"); + env->DeleteLocalRef(sinkClass); + if (onChunk == nullptr) { + throwRuntime(env, "stream sink is missing onChunk(byte[])"); + return; + } + + StreamContext stream; + stream.env = env; + stream.sink = sink; + stream.onChunk = onChunk; + int32_t rc = session->symbols.execProtocolStream( + session->handle, + requestBytes.empty() ? nullptr : requestBytes.data(), + requestBytes.size(), + streamCallback, + &stream); + if (rc != 0) { + if (stream.failed && env->ExceptionCheck()) { + return; + } + throwRuntime(env, stream.error.empty() ? lastError(session) : stream.error); + } +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_backupNative( + JNIEnv *env, + jobject, + jlong handle, + jstring format) { + Session *session = sessionFromHandle(handle); + if (session == nullptr || session->handle == nullptr) { + throwIllegalState(env, "Oliphaunt database is closed"); + return nullptr; + } + uint32_t formatId = backupFormatId(jniString(env, format)); + if (formatId != OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE) { + throwRuntime(env, "Kotlin Android native-direct backup currently supports physicalArchive"); + return nullptr; + } + + OliphauntResponse response = {nullptr, 0}; + int32_t rc = session->symbols.backup(session->handle, formatId, &response); + if (rc != 0) { + std::string error = lastError(session); + if (session->symbols.freeResponse != nullptr) { + session->symbols.freeResponse(&response); + } + throwRuntime(env, error); + return nullptr; + } + + jbyteArray out = env->NewByteArray(static_cast(response.len)); + if (out != nullptr && response.len > 0) { + env->SetByteArrayRegion( + out, + 0, + static_cast(response.len), + reinterpret_cast(response.data)); + } + session->symbols.freeResponse(&response); + return out; +} + +extern "C" JNIEXPORT void JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_restoreNative( + JNIEnv *env, + jobject, + jstring root, + jstring format, + jbyteArray artifact, + jboolean replaceExisting, + jstring libraryPath) { + if (artifact == nullptr) { + throwRuntime(env, "backup artifact must not be null"); + return; + } + uint32_t formatId = backupFormatId(jniString(env, format)); + if (formatId != OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE) { + throwRuntime(env, "Kotlin Android restore currently requires physicalArchive"); + return; + } + + Symbols symbols; + std::string error; + if (!loadSymbols(jniString(env, libraryPath), &symbols, &error)) { + throwRuntime(env, error); + return; + } + + std::string rootPath = jniString(env, root); + const jsize artifactLength = env->GetArrayLength(artifact); + std::vector artifactBytes(static_cast(artifactLength)); + if (artifactLength > 0) { + env->GetByteArrayRegion( + artifact, + 0, + artifactLength, + reinterpret_cast(artifactBytes.data())); + if (env->ExceptionCheck()) { + unloadSymbols(&symbols); + return; + } + } + + OliphauntRestoreOptions options = { + .abi_version = OLIPHAUNT_ABI_VERSION, + .root = rootPath.c_str(), + .format = formatId, + .data = artifactBytes.empty() ? nullptr : artifactBytes.data(), + .len = artifactBytes.size(), + .flags = replaceExisting ? OLIPHAUNT_RESTORE_REPLACE_EXISTING : 0, + }; + int32_t rc = symbols.restore(&options); + if (rc != 0) { + const char *message = symbols.lastError != nullptr ? symbols.lastError(nullptr) : nullptr; + error = message != nullptr ? message : "liboliphaunt restore failed"; + unloadSymbols(&symbols); + throwRuntime(env, error); + return; + } + unloadSymbols(&symbols); +} + +extern "C" JNIEXPORT void JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_cancelNative( + JNIEnv *env, + jobject, + jlong handle) { + Session *session = sessionFromHandle(handle); + if (session == nullptr || session->handle == nullptr) { + throwIllegalState(env, "Oliphaunt database is closed"); + return; + } + int32_t rc = session->symbols.cancel(session->handle); + if (rc != 0) { + throwRuntime(env, lastError(session)); + } +} + +extern "C" JNIEXPORT void JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_closeNative( + JNIEnv *env, + jobject, + jlong handle) { + Session *session = sessionFromHandle(handle); + if (session == nullptr) { + return; + } + int32_t rc = 0; + std::string error; + if (session->handle != nullptr) { + rc = session->symbols.detach(session->handle); + if (rc != 0) { + error = lastError(session); + } + session->handle = nullptr; + } + unloadSymbols(&session->symbols); + delete session; + if (rc != 0) { + throwRuntime(env, error.empty() ? "liboliphaunt close failed" : error); + } +} + +extern "C" JNIEXPORT jlong JNICALL +Java_dev_oliphaunt_OliphauntAndroidNativeBridge_capabilitiesNative( + JNIEnv *env, + jobject, + jlong handle) { + Session *session = sessionFromHandle(handle); + if (session == nullptr || session->handle == nullptr) { + throwIllegalState(env, "Oliphaunt database is closed"); + return 0; + } + return static_cast(session->symbols.capabilities()); +} diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt new file mode 100644 index 00000000..37d154d3 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt @@ -0,0 +1,363 @@ +package dev.oliphaunt + +import android.content.Context +import android.os.Build +import android.os.Process +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.locks.ReentrantLock +import java.util.zip.ZipFile + +public class AndroidNativeDirectEngine( + context: Context, + private val libraryPath: String? = null, + private val runtimeDirectory: String? = null, + private val username: String = "postgres", + private val database: String = "postgres", +) : OliphauntEngine { + private val appContext = context.applicationContext + + public fun packageSizeReport(): OliphauntPackageSizeReport? = OliphauntAndroid.packageSizeReport(appContext) + + override fun supportedModes(): List = OliphauntAndroid.supportedModes() + + override suspend fun open(config: OliphauntConfig): OliphauntSession { + if (config.mode != EngineMode.NativeDirect) { + throw OliphauntException("AndroidNativeDirectEngine supports NativeDirect, got ${config.mode}") + } + validateRootPath(config.root, "database root") + validateStartupIdentity(config.username ?: username, "username") + validateStartupIdentity(config.database ?: database, "database") + validateStartupGucs(config.startupGucs) + val runtime = + OliphauntAndroidRuntimeAssets.resolve( + context = appContext, + explicitRuntimeDirectory = + runtimeDirectory + ?: env("OLIPHAUNT_KOTLIN_ANDROID_RUNTIME_DIR") + ?: env("OLIPHAUNT_INSTALL_DIR") + ?: env("OLIPHAUNT_RUNTIME_DIR"), + requestedExtensions = config.extensions, + ) + val root = + config.root?.let(::File) + ?: AndroidDirectTemporaryRoot.resolve(appContext) + if (!root.mkdirs() && !root.isDirectory) { + throw OliphauntException("failed to create database root at ${root.absolutePath}") + } + val pgdata = File(root, "pgdata") + val executionDispatcher = + Executors + .newSingleThreadExecutor { runnable -> + Thread(runnable, "oliphaunt-android-direct").apply { + isDaemon = true + } + }.asCoroutineDispatcher() + try { + OliphauntAndroidRuntimeAssets.preparePgdata( + assetManager = appContext.assets, + pgdata = pgdata, + templatePgdata = runtime.templatePgdata, + ) + val effectiveUsername = config.username ?: username + val effectiveDatabase = config.database ?: database + val effectiveLibraryPath = + resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath = libraryPath, + nativeLibraryDirectory = appContext.applicationInfo.nativeLibraryDir, + sourceArchivePaths = appContext.applicationInfo.liboliphauntSourceArchivePaths(), + supportedAbis = Build.SUPPORTED_ABIS.asList(), + ) + val nativeHandle = + withContext(executionDispatcher) { + OliphauntAndroidNativeBridge.openNative( + effectiveLibraryPath, + pgdata.absolutePath, + runtime.runtimeDirectory, + effectiveUsername, + effectiveDatabase, + config.postgresStartupArgs().toTypedArray(), + ) + } + return AndroidNativeDirectSession( + nativeHandle = nativeHandle, + executionDispatcher = executionDispatcher, + ) + } catch (error: Throwable) { + executionDispatcher.close() + if (config.root == null) { + root.deleteRecursively() + } + throw error + } + } + + override suspend fun restore(request: RestoreRequest): String { + validateRootPath(request.root, "restore root") + if (request.artifact.format != BackupFormat.PhysicalArchive) { + throw OliphauntException("Kotlin Android restore currently requires PhysicalArchive, got ${request.artifact.format}") + } + OliphauntAndroidNativeBridge.restoreNative( + root = request.root, + format = request.artifact.format.wireName(), + artifact = request.artifact.bytes, + replaceExisting = request.targetPolicy == RestoreTargetPolicy.ReplaceExisting, + libraryPath = + resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath = libraryPath, + nativeLibraryDirectory = appContext.applicationInfo.nativeLibraryDir, + sourceArchivePaths = appContext.applicationInfo.liboliphauntSourceArchivePaths(), + supportedAbis = Build.SUPPORTED_ABIS.asList(), + ), + ) + return request.root + } +} + +private object AndroidDirectTemporaryRoot { + @Volatile + private var root: File? = null + + fun resolve(context: Context): File = synchronized(this) { + root ?: File( + context.noBackupFilesDir, + "oliphaunt-direct-${Process.myPid()}-${UUID.randomUUID()}", + ).also { root = it } + } +} + +private class AndroidNativeDirectSession( + private val nativeHandle: Long, + private val executionDispatcher: ExecutorCoroutineDispatcher, +) : OliphauntSession { + private val lock = ReentrantLock() + private val noActiveCalls = lock.newCondition() + private var handle: Long = nativeHandle + private var closed = false + private var activeCalls = 0 + + override suspend fun capabilities(): EngineCapabilities = withContext(executionDispatcher) { + val current = beginCall() + val flags = + try { + OliphauntAndroidNativeBridge.capabilitiesNative(current) + } finally { + endCall() + } + EngineCapabilities( + mode = EngineMode.NativeDirect, + processIsolated = false, + independentSessions = false, + maxClientSessions = 1, + multiRoot = flags and CAP_MULTI_INSTANCE != 0L, + reopenable = flags and CAP_LOGICAL_REOPEN != 0L, + sameRootLogicalReopen = flags and CAP_LOGICAL_REOPEN != 0L, + rootSwitchable = false, + crashRestartable = false, + protocolRaw = flags and CAP_PROTOCOL_RAW != 0L, + protocolStream = flags and CAP_PROTOCOL_STREAM != 0L, + queryCancel = flags and CAP_QUERY_CANCEL != 0L, + backupRestore = flags and CAP_BACKUP_RESTORE != 0L, + backupFormats = listOf(BackupFormat.PhysicalArchive), + restoreFormats = listOf(BackupFormat.PhysicalArchive), + simpleQuery = flags and CAP_SIMPLE_QUERY != 0L, + extensions = flags and CAP_EXTENSIONS != 0L, + ) + } + + override suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse = withContext(executionDispatcher) { + val current = beginCall() + try { + ProtocolResponse( + OliphauntAndroidNativeBridge.execProtocolRawNative(current, request.bytes), + ) + } finally { + endCall() + } + } + + override suspend fun execProtocolStream( + request: ProtocolRequest, + onChunk: (ProtocolResponse) -> Unit, + ) { + withContext(executionDispatcher) { + val current = beginCall() + try { + OliphauntAndroidNativeBridge.execProtocolStreamNative( + current, + request.bytes, + OliphauntAndroidProtocolStreamSink { chunk -> + onChunk(ProtocolResponse(chunk)) + 0 + }, + ) + } finally { + endCall() + } + } + } + + override suspend fun backup(request: BackupRequest): BackupArtifact = withContext(executionDispatcher) { + requireAndroidNativeDirectBackupFormat(request.format) + val current = beginCall() + try { + BackupArtifact( + format = BackupFormat.PhysicalArchive, + bytes = + OliphauntAndroidNativeBridge.backupNative( + current, + request.format.wireName(), + ), + ) + } finally { + endCall() + } + } + + override suspend fun cancel() { + val current = beginCall() + try { + OliphauntAndroidNativeBridge.cancelNative(current) + } finally { + endCall() + } + } + + override suspend fun close() { + val current = prepareClose() ?: return + try { + withContext(executionDispatcher) { + OliphauntAndroidNativeBridge.closeNative(current) + } + } finally { + executionDispatcher.close() + } + } + + private fun beginCall(): Long { + lock.lock() + try { + checkOpen() + activeCalls += 1 + return handle + } finally { + lock.unlock() + } + } + + private fun endCall() { + lock.lock() + try { + activeCalls -= 1 + noActiveCalls.signalAll() + } finally { + lock.unlock() + } + } + + private fun prepareClose(): Long? { + lock.lock() + try { + if (closed) { + return null + } + closed = true + val current = handle.takeIf { it != 0L } + while (activeCalls > 0) { + try { + noActiveCalls.await() + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + throw OliphauntException("interrupted while closing database") + } + } + handle = 0 + return current + } finally { + lock.unlock() + } + } + + private fun checkOpen() { + if (closed || handle == 0L) { + throw OliphauntException("database is closed") + } + } + + private companion object { + const val CAP_PROTOCOL_RAW: Long = 1L shl 0 + const val CAP_PROTOCOL_STREAM: Long = 1L shl 1 + const val CAP_MULTI_INSTANCE: Long = 1L shl 2 + const val CAP_EXTENSIONS: Long = 1L shl 4 + const val CAP_QUERY_CANCEL: Long = 1L shl 5 + const val CAP_BACKUP_RESTORE: Long = 1L shl 6 + const val CAP_SIMPLE_QUERY: Long = 1L shl 7 + const val CAP_LOGICAL_REOPEN: Long = 1L shl 9 + } +} + +internal fun requireAndroidNativeDirectBackupFormat(format: BackupFormat) { + if (format != BackupFormat.PhysicalArchive) { + throw OliphauntException("Kotlin Android native-direct backup currently supports PhysicalArchive, got $format") + } +} + +private fun BackupFormat.wireName(): String = when (this) { + BackupFormat.Sql -> "sql" + BackupFormat.PhysicalArchive -> "physicalArchive" + BackupFormat.OliphauntArchive -> "oliphauntArchive" +} + +internal fun resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath: String?, + nativeLibraryDirectory: String?, + sourceArchivePaths: List = emptyList(), + supportedAbis: List = emptyList(), + envProvider: (String) -> String? = ::env, +): String? = explicitLibraryPath?.takeIf(String::isNotBlank) + ?: envProvider("OLIPHAUNT_KOTLIN_ANDROID_LIBRARY")?.takeIf(String::isNotBlank) + ?: envProvider("LIBOLIPHAUNT_PATH")?.takeIf(String::isNotBlank) + ?: envProvider("OLIPHAUNT_LIBRARY")?.takeIf(String::isNotBlank) + ?: packagedAndroidLiboliphauntPath(nativeLibraryDirectory) + ?: packagedAndroidLiboliphauntZipPath(sourceArchivePaths, supportedAbis) + +private fun packagedAndroidLiboliphauntPath(nativeLibraryDirectory: String?): String? = nativeLibraryDirectory + ?.takeIf(String::isNotBlank) + ?.let { File(it, "liboliphaunt.so") } + ?.takeIf(File::isFile) + ?.absolutePath + +private fun android.content.pm.ApplicationInfo.liboliphauntSourceArchivePaths(): List = buildList { + add(sourceDir) + add(publicSourceDir) + splitSourceDirs?.forEach(::add) +}.filter { path -> path.isNotBlank() }.distinct() + +private fun packagedAndroidLiboliphauntZipPath( + sourceArchivePaths: List, + supportedAbis: List, +): String? { + val archivePaths = sourceArchivePaths.filter(String::isNotBlank).distinct() + val abis = supportedAbis.filter(String::isNotBlank).distinct() + for (archivePath in archivePaths) { + val archive = File(archivePath) + if (!archive.isFile) { + continue + } + ZipFile(archive).use { zip -> + for (abi in abis) { + val entryName = "lib/$abi/liboliphaunt.so" + if (zip.getEntry(entryName) != null) { + return "$archivePath!/$entryName" + } + } + } + } + return null +} + +private fun env(name: String): String? = System.getenv(name)?.takeIf(String::isNotEmpty) diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/DefaultEngine.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/DefaultEngine.kt new file mode 100644 index 00000000..cdc04f97 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/DefaultEngine.kt @@ -0,0 +1,31 @@ +package dev.oliphaunt + +public actual fun defaultOliphauntEngine(mode: EngineMode): OliphauntEngine = AndroidContextRequiredEngine + +private object AndroidContextRequiredEngine : OliphauntEngine { + override fun supportedModes(): List = OliphauntAndroid.supportedModes() + + override suspend fun open(config: OliphauntConfig): OliphauntSession = throw when (config.mode) { + EngineMode.NativeDirect -> { + OliphauntException( + "Android native-direct requires an android.content.Context; use OliphauntAndroid.open(context, config)", + ) + } + + EngineMode.NativeBroker -> { + OliphauntException( + "Android broker mode requires a platform broker adapter; it is not aliased to direct mode", + ) + } + + EngineMode.NativeServer -> { + OliphauntException( + "Android server mode requires a platform server adapter; it is not aliased to direct mode", + ) + } + } + + override suspend fun restore(request: RestoreRequest): String = throw OliphauntException( + "Android restore requires an android.content.Context; use OliphauntAndroid.restore(context, request)", + ) +} diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt new file mode 100644 index 00000000..6c9b0366 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroid.kt @@ -0,0 +1,43 @@ +package dev.oliphaunt + +import android.content.Context +import java.io.File + +public object OliphauntAndroid { + public fun supportedModes(): List = OliphauntRuntimeSupport.nativeDirectOnly( + brokerReason = "Android broker mode requires a platform broker adapter; it is not aliased to direct mode", + serverReason = "Android server mode requires a platform server adapter; it is not aliased to direct mode", + ) + + public fun packageSizeReport(context: Context): OliphauntPackageSizeReport? = OliphauntAndroidRuntimeAssets.packageSizeReport(context.applicationContext.assets) + + public fun packageSizeReport(resourceRoot: File): OliphauntPackageSizeReport? = OliphauntAndroidRuntimeAssets.packageSizeReport(resourceRoot) + + public suspend fun open( + context: Context, + config: OliphauntConfig = OliphauntConfig(), + libraryPath: String? = null, + runtimeDirectory: String? = null, + username: String = "postgres", + database: String = "postgres", + ): OliphauntDatabase = OliphauntDatabase.open( + config = config, + engine = + AndroidNativeDirectEngine( + context = context, + libraryPath = libraryPath, + runtimeDirectory = runtimeDirectory, + username = username, + database = database, + ), + ) + + public suspend fun restore( + context: Context, + request: RestoreRequest, + libraryPath: String? = null, + ): String = AndroidNativeDirectEngine( + context = context, + libraryPath = libraryPath, + ).restore(request) +} diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidNativeBridge.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidNativeBridge.kt new file mode 100644 index 00000000..abee62fe --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidNativeBridge.kt @@ -0,0 +1,50 @@ +package dev.oliphaunt + +internal object OliphauntAndroidNativeBridge { + init { + System.loadLibrary("oliphaunt_kotlin_android") + } + + external fun openNative( + libraryPath: String?, + pgdata: String, + runtimeDirectory: String, + username: String, + database: String, + startupArgs: Array, + ): Long + + external fun execProtocolRawNative( + handle: Long, + request: ByteArray, + ): ByteArray + + external fun execProtocolStreamNative( + handle: Long, + request: ByteArray, + sink: OliphauntAndroidProtocolStreamSink, + ) + + external fun backupNative( + handle: Long, + format: String, + ): ByteArray + + external fun restoreNative( + root: String, + format: String, + artifact: ByteArray, + replaceExisting: Boolean, + libraryPath: String?, + ) + + external fun cancelNative(handle: Long) + + external fun closeNative(handle: Long) + + external fun capabilitiesNative(handle: Long): Long +} + +internal fun interface OliphauntAndroidProtocolStreamSink { + fun onChunk(chunk: ByteArray): Int +} diff --git a/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt new file mode 100644 index 00000000..408d3108 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt @@ -0,0 +1,717 @@ +package dev.oliphaunt + +import android.content.Context +import android.content.res.AssetManager +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.util.Properties + +internal data class OliphauntAndroidAssetPackage( + val assetRoot: String, + val cacheKey: String, + val extensions: Set = emptySet(), + val sharedPreloadLibraries: Set = emptySet(), + val mobileStaticRegistryState: String? = null, + val mobileStaticRegistryRegistered: Set = emptySet(), + val mobileStaticRegistryPending: Set = emptySet(), + val nativeModuleStems: Set = emptySet(), +) + +public data class OliphauntPackageSizeReport( + val packageBytes: Long, + val runtimeBytes: Long, + val templatePgdataBytes: Long, + val staticRegistryBytes: Long, + val selectedExtensionBytes: Long, + val extensions: List, + val mobileStaticRegistryState: String? = null, + val mobileStaticRegistryRegistered: List = emptyList(), + val mobileStaticRegistryPending: List = emptyList(), + val nativeModuleStems: List = emptyList(), +) + +public data class OliphauntExtensionSizeReport( + val name: String, + val fileCount: Int, + val bytes: Long, +) + +internal data class OliphauntAndroidResolvedRuntime( + val runtimeDirectory: String, + val templatePgdata: OliphauntAndroidAssetPackage?, +) + +internal object OliphauntAndroidRuntimeAssets { + private const val RUNTIME_ASSET_ROOT = "oliphaunt/runtime" + private const val TEMPLATE_PGDATA_ASSET_ROOT = "oliphaunt/template-pgdata" + private const val PACKAGE_SIZE_REPORT_ASSET = "oliphaunt/package-size.tsv" + private const val RUNTIME_RESOURCES_SCHEMA = "oliphaunt-runtime-resources-v1" + private const val RUNTIME_PACKAGE_LAYOUT = "postgres-runtime-files-v1" + private const val TEMPLATE_PGDATA_PACKAGE_LAYOUT = "postgres-template-pgdata-v1" + private const val MANIFEST_NAME = "manifest.properties" + private const val FILES_DIR_NAME = "files" + private const val STAMP_NAME = ".liboliphaunt-asset-cache-key" + private val requiredTemplatePgdataDirectories = + listOf( + "pg_commit_ts", + "pg_dynshmem", + "pg_logical/mappings", + "pg_logical/snapshots", + "pg_notify", + "pg_replslot", + "pg_serial", + "pg_snapshots", + "pg_stat_tmp", + "pg_tblspc", + "pg_twophase", + "pg_wal/archive_status", + "pg_wal/summaries", + ) + private val portableId = Regex("[A-Za-z0-9._-]{1,128}") + + fun resolve( + context: Context, + explicitRuntimeDirectory: String?, + requestedExtensions: Collection = emptyList(), + ): OliphauntAndroidResolvedRuntime { + val requestedExtensionSet = validateExtensionIds(requestedExtensions) + val templatePgdata = packageManifestOrNull(context.assets, TEMPLATE_PGDATA_ASSET_ROOT) + val runtimeDirectory = + explicitRuntimeDirectory?.takeIf(String::isNotEmpty) + ?: materializePackagedRuntime(context, requestedExtensionSet) + return OliphauntAndroidResolvedRuntime( + runtimeDirectory = runtimeDirectory, + templatePgdata = templatePgdata, + ) + } + + fun packageSizeReport(assetManager: AssetManager): OliphauntPackageSizeReport? = try { + assetManager.open(PACKAGE_SIZE_REPORT_ASSET).bufferedReader().use { reader -> + parsePackageSizeReport(reader.readText(), PACKAGE_SIZE_REPORT_ASSET) + .withRuntimeManifest(packageManifestOrNull(assetManager, RUNTIME_ASSET_ROOT)) + } + } catch (_: FileNotFoundException) { + null + } catch (error: IOException) { + throw OliphauntException("failed to read Oliphaunt package size report: ${error.message}") + } + + fun packageSizeReport(resourceRoot: File): OliphauntPackageSizeReport? { + val report = File(resourceRoot, "package-size.tsv") + if (!report.isFile) { + return null + } + return try { + parsePackageSizeReport(report.readText(), report.absolutePath) + .withRuntimeManifest(filePackageManifestOrNull(resourceRoot, RUNTIME_ASSET_ROOT)) + } catch (error: IOException) { + throw OliphauntException( + "failed to read Oliphaunt package size report ${report.absolutePath}: ${error.message}", + ) + } + } + + fun preparePgdata( + assetManager: AssetManager, + pgdata: File, + templatePgdata: OliphauntAndroidAssetPackage?, + ) { + if (File(pgdata, "PG_VERSION").isFile) { + return + } + if (templatePgdata == null) { + throw OliphauntException( + "Kotlin Android Oliphaunt requires packaged template PGDATA for new roots. " + + "Package oliphaunt/template-pgdata assets or open an existing root that already contains PG_VERSION.", + ) + } + if (pgdata.exists()) { + if (!pgdata.isDirectory) { + throw OliphauntException("PGDATA path exists but is not a directory: ${pgdata.absolutePath}") + } + val existing = pgdata.list() + if (existing != null && existing.isNotEmpty()) { + throw OliphauntException("PGDATA exists without PG_VERSION and is not empty: ${pgdata.absolutePath}") + } + } + + val parent = + pgdata.parentFile + ?: throw OliphauntException("PGDATA has no parent directory: ${pgdata.absolutePath}") + if (!parent.mkdirs() && !parent.isDirectory) { + throw OliphauntException("failed to create PGDATA parent at ${parent.absolutePath}") + } + + val temp = File(parent, ".pgdata-template-${templatePgdata.cacheKey}-${System.nanoTime()}") + temp.deleteRecursively() + try { + copyAssetTree(assetManager, "${templatePgdata.assetRoot}/$FILES_DIR_NAME", temp) + ensureTemplatePgdataDirectoriesForAndroid(temp) + normalizeTemplatePgdataForAndroid(temp) + if (!File(temp, "PG_VERSION").isFile) { + throw OliphauntException( + "packaged liboliphaunt template PGDATA ${templatePgdata.assetRoot} does not contain PG_VERSION", + ) + } + if (pgdata.exists() && !pgdata.delete()) { + throw OliphauntException("failed to replace empty PGDATA at ${pgdata.absolutePath}") + } + if (!temp.renameTo(pgdata)) { + throw OliphauntException("failed to publish template PGDATA at ${pgdata.absolutePath}") + } + } catch (error: Throwable) { + temp.deleteRecursively() + throw error + } + } + + private fun materializePackagedRuntime( + context: Context, + requestedExtensions: Set, + ): String { + val runtimePackage = + packageManifestOrNull(context.assets, RUNTIME_ASSET_ROOT) + ?: throw OliphauntException( + "Kotlin Android Oliphaunt runtime resources are not present. " + + "Pass runtimeDirectory for local development or configure Gradle with " + + "-PoliphauntRuntimeDir=.", + ) + requirePackagedExtensions(runtimePackage, requestedExtensions) + val runtimeRoot = + File( + context.noBackupFilesDir, + "oliphaunt/runtime/${runtimePackage.cacheKey}", + ) + materializeAssetPackage(context.assets, runtimePackage, runtimeRoot) + return runtimeRoot.absolutePath + } + + private fun packageManifestOrNull( + assetManager: AssetManager, + assetRoot: String, + ): OliphauntAndroidAssetPackage? { + val properties = Properties() + try { + assetManager.open("$assetRoot/$MANIFEST_NAME").use(properties::load) + } catch (_: FileNotFoundException) { + return null + } catch (error: IOException) { + throw OliphauntException("failed to read Oliphaunt asset manifest $assetRoot: ${error.message}") + } + return parseManifestProperties(assetRoot, properties) + } + + internal fun parseManifestProperties( + assetRoot: String, + properties: Properties, + ): OliphauntAndroidAssetPackage { + val schema = properties.getProperty("schema")?.trim().orEmpty() + if (schema != RUNTIME_RESOURCES_SCHEMA) { + throw OliphauntException( + "Oliphaunt asset manifest $assetRoot has unsupported runtime resource schema " + + "'${schema.ifEmpty { "" }}'; expected $RUNTIME_RESOURCES_SCHEMA", + ) + } + val layout = properties.getProperty("layout")?.trim().orEmpty() + val expectedLayout = expectedLayout(assetRoot) + if (layout != expectedLayout) { + throw OliphauntException( + "Oliphaunt asset manifest $assetRoot has unsupported layout " + + "'${layout.ifEmpty { "" }}'; expected $expectedLayout", + ) + } + val cacheKey = properties.getProperty("cacheKey")?.trim().orEmpty() + if (!portableId.matches(cacheKey)) { + throw OliphauntException("Oliphaunt asset manifest $assetRoot has invalid cacheKey '$cacheKey'") + } + val extensions = + validateExtensionIds( + properties.getProperty("extensions").orEmpty().split(','), + ) + val mobileStaticRegistryState = + validateMobileStaticRegistryState( + properties.getProperty("mobileStaticRegistryState")?.trim(), + ) + val mobileStaticRegistryPending = + validatePortableIds( + properties.getProperty("mobileStaticRegistryPending").orEmpty().split(','), + label = "mobile static registry extension", + ) + val mobileStaticRegistryRegistered = + validatePortableIds( + properties.getProperty("mobileStaticRegistryRegistered").orEmpty().split(','), + label = "mobile static registry extension", + ) + val nativeModuleStems = + validatePortableIds( + properties.getProperty("nativeModuleStems").orEmpty().split(','), + label = "native module stem", + ) + val sharedPreloadLibraries = + validatePortableIds( + properties.getProperty("sharedPreloadLibraries").orEmpty().split(','), + label = "shared preload library", + ) + validateMobileStaticRegistryManifest( + state = mobileStaticRegistryState, + registered = mobileStaticRegistryRegistered, + pending = mobileStaticRegistryPending, + nativeModuleStems = nativeModuleStems, + ) + return OliphauntAndroidAssetPackage( + assetRoot = assetRoot, + cacheKey = cacheKey, + extensions = extensions, + sharedPreloadLibraries = sharedPreloadLibraries, + mobileStaticRegistryState = mobileStaticRegistryState, + mobileStaticRegistryRegistered = mobileStaticRegistryRegistered, + mobileStaticRegistryPending = mobileStaticRegistryPending, + nativeModuleStems = nativeModuleStems, + ) + } + + private fun filePackageManifestOrNull( + resourceRoot: File, + assetRoot: String, + ): OliphauntAndroidAssetPackage? { + val manifest = File(resourceRoot, "$assetRoot/$MANIFEST_NAME") + if (!manifest.isFile) { + return null + } + val properties = Properties() + manifest.inputStream().use(properties::load) + return parseManifestProperties(assetRoot, properties) + } + + private fun OliphauntPackageSizeReport.withRuntimeManifest(runtime: OliphauntAndroidAssetPackage?): OliphauntPackageSizeReport = if (runtime == null) { + this + } else { + copy( + mobileStaticRegistryState = runtime.mobileStaticRegistryState, + mobileStaticRegistryRegistered = runtime.mobileStaticRegistryRegistered.sorted(), + mobileStaticRegistryPending = runtime.mobileStaticRegistryPending.sorted(), + nativeModuleStems = runtime.nativeModuleStems.sorted(), + ) + } + + internal fun parsePackageSizeReport( + text: String, + source: String = PACKAGE_SIZE_REPORT_ASSET, + ): OliphauntPackageSizeReport { + val lines = + text + .lineSequence() + .filter(String::isNotEmpty) + .toList() + if (lines.firstOrNull() != "kind\tid\textensions\tfiles\tbytes") { + throw OliphauntException("Oliphaunt package size report $source has unsupported header") + } + + var packageBytes: Long? = null + var runtimeBytes: Long? = null + var templatePgdataBytes: Long? = null + var staticRegistryBytes: Long? = null + var selectedExtensionBytes: Long? = null + val extensionReports = mutableListOf() + val seenExtensionIds = mutableSetOf() + + lines.drop(1).forEachIndexed { index, line -> + val lineNumber = index + 2 + val columns = line.split('\t') + if (columns.size != 5) { + throw OliphauntException( + "Oliphaunt package size report $source line $lineNumber must have 5 tab-separated columns", + ) + } + val bytes = parseSizeReportLong(columns[4], source, lineNumber, "bytes") + when (columns[0] to columns[1]) { + "package" to "total" -> { + packageBytes = + setSizeReportValue( + current = packageBytes, + value = bytes, + row = "package/total", + source = source, + line = lineNumber, + ) + } + + "package" to "runtime" -> { + runtimeBytes = + setSizeReportValue( + current = runtimeBytes, + value = bytes, + row = "package/runtime", + source = source, + line = lineNumber, + ) + } + + "package" to "template-pgdata" -> { + templatePgdataBytes = + setSizeReportValue( + current = templatePgdataBytes, + value = bytes, + row = "package/template-pgdata", + source = source, + line = lineNumber, + ) + } + + "package" to "static-registry" -> { + staticRegistryBytes = + setSizeReportValue( + current = staticRegistryBytes, + value = bytes, + row = "package/static-registry", + source = source, + line = lineNumber, + ) + } + + "extensions" to "selected" -> { + selectedExtensionBytes = + setSizeReportValue( + current = selectedExtensionBytes, + value = bytes, + row = "extensions/selected", + source = source, + line = lineNumber, + ) + } + + else -> { + if (columns[0] != "extension") { + throw OliphauntException( + "Oliphaunt package size report $source line $lineNumber has unknown row ${columns[0]}/${columns[1]}", + ) + } + val name = columns[1] + if (!portableId.matches(name)) { + throw OliphauntException( + "Oliphaunt package size report $source line $lineNumber has invalid extension id '$name'", + ) + } + if (!seenExtensionIds.add(name)) { + throw OliphauntException( + "Oliphaunt package size report $source line $lineNumber repeats extension row '$name'", + ) + } + if (columns[2] != "-") { + throw OliphauntException( + "Oliphaunt package size report $source line $lineNumber extension rows must use '-' in the extensions column", + ) + } + val fileCount = parseSizeReportInt(columns[3], source, lineNumber, "files") + extensionReports += + OliphauntExtensionSizeReport( + name = name, + fileCount = fileCount, + bytes = bytes, + ) + } + } + } + + return OliphauntPackageSizeReport( + packageBytes = requireSizeReportValue(packageBytes, "package/total", source), + runtimeBytes = requireSizeReportValue(runtimeBytes, "package/runtime", source), + templatePgdataBytes = + requireSizeReportValue( + templatePgdataBytes, + "package/template-pgdata", + source, + ), + staticRegistryBytes = + requireSizeReportValue( + staticRegistryBytes, + "package/static-registry", + source, + ), + selectedExtensionBytes = + requireSizeReportValue( + selectedExtensionBytes, + "extensions/selected", + source, + ), + extensions = extensionReports.sortedBy(OliphauntExtensionSizeReport::name), + ) + } + + internal fun normalizePostgresqlConfigForAndroid(text: String): String { + var normalized = setPostgresqlConfig(text, "shared_memory_type", "mmap") + normalized = setPostgresqlConfig(normalized, "dynamic_shared_memory_type", "mmap") + return normalized + } + + internal fun ensureTemplatePgdataDirectoriesForAndroid(pgdata: File) { + requiredTemplatePgdataDirectories.forEach { relative -> + val directory = File(pgdata, relative) + if (!directory.mkdirs() && !directory.isDirectory) { + throw OliphauntException( + "failed to create Android template PGDATA directory ${directory.absolutePath}", + ) + } + } + } + + private fun normalizeTemplatePgdataForAndroid(pgdata: File) { + val config = File(pgdata, "postgresql.conf") + if (!config.isFile) { + return + } + val current = config.readText() + val normalized = normalizePostgresqlConfigForAndroid(current) + if (normalized != current) { + config.writeText(normalized) + } + } + + private fun setPostgresqlConfig( + text: String, + key: String, + value: String, + ): String { + val line = "$key = $value" + val pattern = Regex("(?m)^\\s*$key\\s*=.*$") + if (pattern.containsMatchIn(text)) { + return pattern.replace(text, line) + } + val separator = if (text.endsWith('\n')) "" else "\n" + return "$text$separator$line\n" + } + + private fun setSizeReportValue( + current: Long?, + value: Long, + row: String, + source: String, + line: Int, + ): Long { + if (current != null) { + throw OliphauntException("Oliphaunt package size report $source line $line repeats required row $row") + } + return value + } + + private fun requireSizeReportValue( + value: Long?, + row: String, + source: String, + ): Long = value ?: throw OliphauntException("Oliphaunt package size report $source is missing required row $row") + + private fun parseSizeReportLong( + value: String, + source: String, + line: Int, + field: String, + ): Long = value.toLongOrNull()?.takeIf { it >= 0 } + ?: throw OliphauntException( + "Oliphaunt package size report $source line $line has invalid $field value '$value'", + ) + + private fun parseSizeReportInt( + value: String, + source: String, + line: Int, + field: String, + ): Int = value.toIntOrNull()?.takeIf { it >= 0 } + ?: throw OliphauntException( + "Oliphaunt package size report $source line $line has invalid $field value '$value'", + ) + + private fun expectedLayout(assetRoot: String): String = when (assetRoot) { + RUNTIME_ASSET_ROOT -> RUNTIME_PACKAGE_LAYOUT + TEMPLATE_PGDATA_ASSET_ROOT -> TEMPLATE_PGDATA_PACKAGE_LAYOUT + else -> throw OliphauntException("unsupported Oliphaunt asset root '$assetRoot'") + } + + private fun requirePackagedExtensions( + runtimePackage: OliphauntAndroidAssetPackage, + requestedExtensions: Set, + ) { + val missing = + requestedExtensions + .filterNot(runtimePackage.extensions::contains) + .sorted() + if (missing.isNotEmpty()) { + val available = runtimePackage.extensions.sorted().joinToString(",") + throw OliphauntException( + "Kotlin Android Oliphaunt runtime resources ${runtimePackage.assetRoot} " + + "does not contain requested extension(s) ${missing.joinToString(",")}. " + + "Available extensions: ${available.ifEmpty { "" }}.", + ) + } + if (requestedExtensions.isNotEmpty()) { + val state = + runtimePackage.mobileStaticRegistryState + ?: throw OliphauntException( + "Kotlin Android Oliphaunt runtime resources ${runtimePackage.assetRoot} " + + "does not declare mobileStaticRegistryState; rebuild it with the current oliphaunt runtime-resource generator.", + ) + if (state == "pending" || runtimePackage.mobileStaticRegistryPending.isNotEmpty()) { + val pending = runtimePackage.mobileStaticRegistryPending.sorted().joinToString(",") + throw OliphauntException( + "Kotlin Android Oliphaunt runtime resources ${runtimePackage.assetRoot} " + + "is not mobile static-registry ready for selected extension(s). " + + "Pending extension(s): ${pending.ifEmpty { "" }}.", + ) + } + } + } + + private fun validateExtensionIds(values: Collection): Set = validatePortableIds(values, label = "extension id") + + private fun validatePortableIds( + values: Collection, + label: String, + ): Set = values + .map(String::trim) + .filter(String::isNotEmpty) + .also { ids -> + ids.forEach { value -> + if (!portableId.matches(value)) { + throw OliphauntException( + "liboliphaunt $label '$value' must contain only ASCII letters, digits, '.', '_' or '-'", + ) + } + } + }.toSortedSet() + + private fun validateMobileStaticRegistryState(state: String?): String? { + if (state.isNullOrEmpty()) { + return null + } + if (state !in setOf("not-required", "complete", "pending")) { + throw OliphauntException( + "Oliphaunt mobileStaticRegistryState '$state' must be one of not-required, complete, or pending", + ) + } + return state + } + + private fun validateMobileStaticRegistryManifest( + state: String?, + registered: Set, + pending: Set, + nativeModuleStems: Set, + ) { + if (state == null) { + throw OliphauntException("Oliphaunt mobile static-registry manifest omits mobileStaticRegistryState") + } + if (registered.intersect(pending).isNotEmpty()) { + throw OliphauntException( + "Oliphaunt mobile static-registry manifest lists the same extension as registered and pending", + ) + } + when (state) { + "not-required" -> { + if (registered.isNotEmpty() || pending.isNotEmpty() || nativeModuleStems.isNotEmpty()) { + throw OliphauntException( + "Oliphaunt mobileStaticRegistryState=not-required must not list registered, pending, or native module stems", + ) + } + } + + "pending" -> { + if (pending.isEmpty()) { + throw OliphauntException( + "Oliphaunt mobileStaticRegistryState=pending must list mobileStaticRegistryPending", + ) + } + } + + "complete" -> { + if (pending.isNotEmpty()) { + throw OliphauntException( + "Oliphaunt mobileStaticRegistryState=complete must not list mobileStaticRegistryPending", + ) + } + if (registered.isEmpty() || nativeModuleStems.isEmpty()) { + throw OliphauntException( + "Oliphaunt mobileStaticRegistryState=complete must list mobileStaticRegistryRegistered and nativeModuleStems", + ) + } + } + } + } + + private fun materializeAssetPackage( + assetManager: AssetManager, + assetPackage: OliphauntAndroidAssetPackage, + target: File, + ) { + val stamp = File(target, STAMP_NAME) + if (target.isDirectory && stamp.readTextOrNull() == assetPackage.cacheKey) { + return + } + + val parent = + target.parentFile + ?: throw OliphauntException("runtime target has no parent directory: ${target.absolutePath}") + if (!parent.mkdirs() && !parent.isDirectory) { + throw OliphauntException("failed to create runtime cache directory at ${parent.absolutePath}") + } + + val temp = File(parent, ".${target.name}.tmp-${System.nanoTime()}") + temp.deleteRecursively() + try { + copyAssetTree(assetManager, "${assetPackage.assetRoot}/$FILES_DIR_NAME", temp) + markRuntimeExecutablePlaceholders(temp) + File(temp, STAMP_NAME).writeText(assetPackage.cacheKey) + if (target.exists()) { + target.deleteRecursively() + } + if (!temp.renameTo(target)) { + throw OliphauntException("failed to publish runtime assets at ${target.absolutePath}") + } + } catch (error: Throwable) { + temp.deleteRecursively() + throw error + } + } + + private fun markRuntimeExecutablePlaceholders(root: File) { + val postgres = File(root, "bin/postgres") + if (postgres.isFile) { + postgres.setExecutable(true, false) + } + } + + private fun copyAssetTree( + assetManager: AssetManager, + assetPath: String, + destination: File, + ) { + val children = + assetManager.list(assetPath) + ?: throw OliphauntException("failed to list Android asset path $assetPath") + if (children.isEmpty()) { + destination.parentFile?.mkdirs() + try { + assetManager.open(assetPath).use { input -> + destination.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (error: FileNotFoundException) { + throw OliphauntException("missing Android asset path $assetPath: ${error.message}") + } + return + } + + if (!destination.mkdirs() && !destination.isDirectory) { + throw OliphauntException("failed to create directory ${destination.absolutePath}") + } + children.sorted().forEach { child -> + copyAssetTree(assetManager, "$assetPath/$child", File(destination, child)) + } + } + + private fun File.readTextOrNull(): String? = try { + if (isFile) readText() else null + } catch (_: IOException) { + null + } +} diff --git a/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidDefaultEngineTest.kt b/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidDefaultEngineTest.kt new file mode 100644 index 00000000..027ac196 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidDefaultEngineTest.kt @@ -0,0 +1,206 @@ +package dev.oliphaunt + +import android.content.Context +import kotlinx.coroutines.test.runTest +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OliphauntAndroidDefaultEngineTest { + @Test + fun commonSupportedModesExposeAndroidFacadeContract() { + val support = OliphauntDatabase.supportedModes() + + assertEquals( + listOf(EngineMode.NativeDirect, EngineMode.NativeBroker, EngineMode.NativeServer), + support.map { it.mode }, + ) + assertTrue(support[0].available) + assertEquals(1, support[0].capabilities.maxClientSessions) + assertFalse(support[0].capabilities.independentSessions) + assertFalse(support[0].capabilities.multiRoot) + assertTrue(support[0].capabilities.reopenable) + assertTrue(support[0].capabilities.sameRootLogicalReopen) + assertFalse(support[0].capabilities.rootSwitchable) + assertFalse(support[0].capabilities.crashRestartable) + assertFalse(support[1].available) + assertTrue(support[1].capabilities.multiRoot) + assertTrue(support[1].capabilities.reopenable) + assertFalse(support[1].capabilities.sameRootLogicalReopen) + assertTrue(support[1].capabilities.rootSwitchable) + assertTrue(support[1].capabilities.crashRestartable) + assertTrue(support[1].unavailableReason.orEmpty().contains("broker")) + assertFalse(support[2].available) + assertTrue(support[2].capabilities.independentSessions) + assertFalse(support[2].capabilities.multiRoot) + assertTrue(support[2].capabilities.reopenable) + assertFalse(support[2].capabilities.sameRootLogicalReopen) + assertTrue(support[2].capabilities.rootSwitchable) + assertFalse(support[2].capabilities.crashRestartable) + assertTrue(support[2].unavailableReason.orEmpty().contains("server")) + } + + @Test + fun commonOpenDefaultPointsAndroidAppsToContextFacade() = runTest { + val error = + assertFailsWith { + OliphauntDatabase.open(OliphauntConfig(mode = EngineMode.NativeDirect)) + } + + assertTrue(error.message.orEmpty().contains("use OliphauntAndroid.open(context, config)")) + } + + @Test + fun commonOpenDefaultRejectsUnavailableAndroidModes() = runTest { + val brokerError = + assertFailsWith { + OliphauntDatabase.open(OliphauntConfig(mode = EngineMode.NativeBroker)) + } + assertTrue(brokerError.message.orEmpty().contains("broker mode requires a platform broker adapter")) + + val serverError = + assertFailsWith { + OliphauntDatabase.open(OliphauntConfig(mode = EngineMode.NativeServer)) + } + assertTrue(serverError.message.orEmpty().contains("server mode requires a platform server adapter")) + } + + @Test + fun commonRestoreDefaultPointsAndroidAppsToContextFacade() = runTest { + val error = + assertFailsWith { + OliphauntDatabase.restore( + RestoreRequest( + artifact = BackupArtifact(BackupFormat.PhysicalArchive, ByteArray(0)), + root = "/tmp/oliphaunt-android-restore-default", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("use OliphauntAndroid.restore(context, request)")) + } + + @Test + fun androidNativeDirectRejectsUnsupportedBackupFormatsBeforeJniCall() { + val sqlError = + assertFailsWith { + requireAndroidNativeDirectBackupFormat(BackupFormat.Sql) + } + assertTrue(sqlError.message.orEmpty().contains("supports PhysicalArchive")) + + val archiveError = + assertFailsWith { + requireAndroidNativeDirectBackupFormat(BackupFormat.OliphauntArchive) + } + assertTrue(archiveError.message.orEmpty().contains("supports PhysicalArchive")) + + requireAndroidNativeDirectBackupFormat(BackupFormat.PhysicalArchive) + } + + @Test + fun androidNativeDirectLibraryPathResolutionUsesPackagedLibraryLast() { + val nativeLibDir = createTempDirectory("liboliphaunt-android-native-libs").toFile() + try { + val packaged = File(nativeLibDir, "liboliphaunt.so").apply { writeText("test") } + + assertEquals( + "/explicit/liboliphaunt.so", + resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath = "/explicit/liboliphaunt.so", + nativeLibraryDirectory = nativeLibDir.absolutePath, + envProvider = { null }, + ), + ) + assertEquals( + "/env/liboliphaunt.so", + resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath = null, + nativeLibraryDirectory = nativeLibDir.absolutePath, + envProvider = { name -> + when (name) { + "LIBOLIPHAUNT_PATH" -> "/env/liboliphaunt.so" + else -> null + } + }, + ), + ) + assertEquals( + packaged.absolutePath, + resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath = null, + nativeLibraryDirectory = nativeLibDir.absolutePath, + envProvider = { null }, + ), + ) + } finally { + nativeLibDir.deleteRecursively() + } + } + + @Test + fun androidNativeDirectLibraryPathResolutionReturnsNullWhenNoSourceExists() { + val missingLibDir = createTempDirectory("liboliphaunt-android-missing-libs").toFile() + try { + assertNull( + resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath = null, + nativeLibraryDirectory = missingLibDir.absolutePath, + envProvider = { null }, + ), + ) + } finally { + missingLibDir.deleteRecursively() + } + } + + @Test + fun androidNativeDirectLibraryPathResolutionUsesApkZipPathWhenLibrariesAreNotExtracted() { + val tempDir = createTempDirectory("liboliphaunt-android-apk-libs").toFile() + try { + val apk = File(tempDir, "base.apk") + ZipOutputStream(apk.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("lib/arm64-v8a/liboliphaunt.so")) + zip.write(byteArrayOf(1)) + zip.closeEntry() + } + + assertEquals( + "${apk.absolutePath}!/lib/arm64-v8a/liboliphaunt.so", + resolveAndroidLiboliphauntLibraryPath( + explicitLibraryPath = null, + nativeLibraryDirectory = File(tempDir, "not-extracted").absolutePath, + sourceArchivePaths = listOf(apk.absolutePath), + supportedAbis = listOf("x86_64", "arm64-v8a"), + envProvider = { null }, + ), + ) + } finally { + tempDir.deleteRecursively() + } + } +} + +@Suppress("UNUSED_VARIABLE") +private suspend fun readmeAndroidOpenExample(applicationContext: Context) { + // liboliphaunt-doc-example:kotlin-android-open + val db = + OliphauntAndroid.open( + context = applicationContext, + config = + OliphauntConfig( + mode = EngineMode.NativeDirect, + username = "postgres", + database = "postgres", + extensions = listOf("vector"), + ), + ) + val response = db.execProtocolRaw(ProtocolRequest.simpleQuery("SELECT 1")) + db.close() +} diff --git a/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt b/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt new file mode 100644 index 00000000..a8cb94f5 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt @@ -0,0 +1,606 @@ +package dev.oliphaunt + +import java.nio.file.Files +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class OliphauntAndroidRuntimeAssetsTest { + @Test + fun parsesCurrentRuntimeManifestSchema() { + val parsed = + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "extensions" to "pg_trgm,vector", + "sharedPreloadLibraries" to "auto_explain", + "mobileStaticRegistryState" to "complete", + "mobileStaticRegistryRegistered" to "vector", + "mobileStaticRegistryPending" to "", + "nativeModuleStems" to "vector", + ), + ) + + assertEquals("runtime-smoke", parsed.cacheKey) + assertEquals(setOf("pg_trgm", "vector"), parsed.extensions) + assertEquals(setOf("auto_explain"), parsed.sharedPreloadLibraries) + assertEquals("complete", parsed.mobileStaticRegistryState) + } + + @Test + fun parsesPackageSizeReport() { + val report = + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + """ + kind id extensions files bytes + package total - - 185 + package runtime - - 100 + package template-pgdata - - 40 + package static-registry - - 45 + extensions selected - - 30 + extension hstore - 2 12 + extension vector - 3 30 + """.trimIndent(), + source = "test-package-size.tsv", + ) + + assertEquals(185L, report.packageBytes) + assertEquals(100L, report.runtimeBytes) + assertEquals(40L, report.templatePgdataBytes) + assertEquals(45L, report.staticRegistryBytes) + assertEquals(30L, report.selectedExtensionBytes) + assertEquals( + listOf( + OliphauntExtensionSizeReport( + name = "hstore", + fileCount = 2, + bytes = 12L, + ), + OliphauntExtensionSizeReport( + name = "vector", + fileCount = 3, + bytes = 30L, + ), + ), + report.extensions, + ) + } + + @Test + fun parsesPackageSizeReportFromResourceRoot() { + val resourceRoot = Files.createTempDirectory("liboliphaunt-resource-report").toFile() + try { + resourceRoot.resolve("package-size.tsv").writeText( + """ + kind id extensions files bytes + package total - - 185 + package runtime - - 100 + package template-pgdata - - 40 + package static-registry - - 45 + extensions selected - - 30 + extension vector - 3 30 + """.trimIndent(), + ) + + val report = OliphauntAndroidRuntimeAssets.packageSizeReport(resourceRoot) + + assertEquals(185L, report?.packageBytes) + assertEquals( + listOf( + OliphauntExtensionSizeReport( + name = "vector", + fileCount = 3, + bytes = 30L, + ), + ), + report?.extensions, + ) + } finally { + resourceRoot.deleteRecursively() + } + } + + @Test + fun enrichesPackageSizeReportWithRuntimeManifestFromResourceRoot() { + val resourceRoot = Files.createTempDirectory("liboliphaunt-resource-report-manifest").toFile() + try { + resourceRoot.resolve("package-size.tsv").writeText(validPackageSizeReport()) + val manifest = resourceRoot.resolve("oliphaunt/runtime/manifest.properties") + requireNotNull(manifest.parentFile).mkdirs() + manifest.writeText( + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=runtime-smoke + extensions=hstore,vector + sharedPreloadLibraries= + mobileStaticRegistryState=complete + mobileStaticRegistryRegistered=vector,hstore + mobileStaticRegistryPending= + nativeModuleStems=vector,hstore + """.trimIndent(), + ) + + val report = OliphauntAndroidRuntimeAssets.packageSizeReport(resourceRoot) + val facadeReport = OliphauntAndroid.packageSizeReport(resourceRoot) + + assertEquals("complete", report?.mobileStaticRegistryState) + assertEquals(report, facadeReport) + assertEquals(listOf("hstore", "vector"), report?.mobileStaticRegistryRegistered) + assertEquals(emptyList(), report?.mobileStaticRegistryPending) + assertEquals(listOf("hstore", "vector"), report?.nativeModuleStems) + } finally { + resourceRoot.deleteRecursively() + } + } + + @Test + fun returnsNullWhenPackageSizeReportIsAbsentFromResourceRoot() { + val resourceRoot = Files.createTempDirectory("liboliphaunt-resource-report-absent").toFile() + try { + assertEquals(null, OliphauntAndroidRuntimeAssets.packageSizeReport(resourceRoot)) + } finally { + resourceRoot.deleteRecursively() + } + } + + @Test + fun normalizesAndroidPostgresqlConfigSharedMemory() { + val normalized = + OliphauntAndroidRuntimeAssets.normalizePostgresqlConfigForAndroid( + """ + #shared_memory_type = mmap + dynamic_shared_memory_type = posix # initdb host default + max_connections = 100 + """.trimIndent(), + ) + + assertTrue(normalized.contains("shared_memory_type = mmap")) + assertTrue(normalized.contains("dynamic_shared_memory_type = mmap")) + assertTrue(normalized.contains("max_connections = 100")) + } + + @Test + fun appendsAndroidPostgresqlSharedMemoryConfigWhenMissing() { + val normalized = OliphauntAndroidRuntimeAssets.normalizePostgresqlConfigForAndroid("max_connections = 100") + + assertTrue(normalized.startsWith("max_connections = 100\n")) + assertTrue(normalized.contains("shared_memory_type = mmap")) + assertTrue(normalized.endsWith("dynamic_shared_memory_type = mmap\n")) + } + + @Test + fun restoresAndroidTemplatePgdataEmptyDirectories() { + val pgdata = Files.createTempDirectory("liboliphaunt-android-pgdata").toFile() + try { + OliphauntAndroidRuntimeAssets.ensureTemplatePgdataDirectoriesForAndroid(pgdata) + + assertTrue(pgdata.resolve("pg_notify").isDirectory) + assertTrue(pgdata.resolve("pg_wal/archive_status").isDirectory) + assertTrue(pgdata.resolve("pg_logical/snapshots").isDirectory) + } finally { + pgdata.deleteRecursively() + } + } + + @Test + fun rejectsUnsupportedPackageSizeReportHeader() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + "kind\tid\tbytes", + source = "test-package-size.tsv", + ) + } + + assertTrue(error.message.orEmpty().contains("unsupported header")) + } + + @Test + fun rejectsPackageSizeReportWithWrongColumnCount() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + """ + kind id extensions files bytes + package total - - + """.trimIndent(), + source = "test-package-size.tsv", + ) + } + + assertTrue(error.message.orEmpty().contains("5 tab-separated columns")) + } + + @Test + fun rejectsMalformedPackageSizeReport() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + """ + kind id extensions files bytes + package total - - not-bytes + """.trimIndent(), + source = "test-package-size.tsv", + ) + } + + assertTrue(error.message.orEmpty().contains("invalid bytes value")) + } + + @Test + fun rejectsNegativePackageSizeReportBytes() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + """ + kind id extensions files bytes + package total - - -1 + """.trimIndent(), + source = "test-package-size.tsv", + ) + } + + assertTrue(error.message.orEmpty().contains("invalid bytes value")) + } + + @Test + fun rejectsRepeatedPackageSizeRequiredRows() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + """ + kind id extensions files bytes + package total - - 185 + package total - - 200 + package runtime - - 100 + package template-pgdata - - 40 + package static-registry - - 45 + extensions selected - - 30 + """.trimIndent(), + source = "test-package-size.tsv", + ) + } + + assertTrue(error.message.orEmpty().contains("repeats required row package/total")) + } + + @Test + fun rejectsPackageSizeReportMissingRequiredRows() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + """ + kind id extensions files bytes + package total - - 185 + package template-pgdata - - 40 + package static-registry - - 45 + extensions selected - - 30 + """.trimIndent(), + source = "test-package-size.tsv", + ) + } + + assertTrue(error.message.orEmpty().contains("missing required row package/runtime")) + } + + @Test + fun rejectsUnknownPackageSizeRows() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + """ + kind id extensions files bytes + package total - - 185 + package runtime - - 100 + unknown row - - 1 + """.trimIndent(), + source = "test-package-size.tsv", + ) + } + + assertTrue(error.message.orEmpty().contains("unknown row unknown/row")) + } + + @Test + fun rejectsInvalidPackageSizeExtensionRows() { + val invalidId = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + validPackageSizeReport("extension\tbad extension\t-\t1\t1"), + source = "test-package-size.tsv", + ) + } + assertTrue(invalidId.message.orEmpty().contains("invalid extension id")) + + val duplicate = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + validPackageSizeReport( + "extension\tvector\t-\t1\t1", + "extension\tvector\t-\t1\t1", + ), + source = "test-package-size.tsv", + ) + } + assertTrue(duplicate.message.orEmpty().contains("repeats extension row")) + + val wrongExtensionsColumn = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + validPackageSizeReport("extension\tvector\tvector\t1\t1"), + source = "test-package-size.tsv", + ) + } + assertTrue(wrongExtensionsColumn.message.orEmpty().contains("must use '-' in the extensions column")) + + val invalidFileCount = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parsePackageSizeReport( + validPackageSizeReport("extension\tvector\t-\tnope\t1"), + source = "test-package-size.tsv", + ) + } + assertTrue(invalidFileCount.message.orEmpty().contains("invalid files value")) + } + + @Test + fun rejectsMalformedSharedPreloadLibraryMetadata() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "extensions" to "vector", + "sharedPreloadLibraries" to "pg search", + "mobileStaticRegistryState" to "complete", + "mobileStaticRegistryRegistered" to "vector", + "mobileStaticRegistryPending" to "", + "nativeModuleStems" to "vector", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("shared preload library")) + } + + @Test + fun rejectsInvalidRuntimeManifestCacheKeyAndExtensions() { + val badCacheKey = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime smoke", + "mobileStaticRegistryState" to "not-required", + ), + ) + } + assertTrue(badCacheKey.message.orEmpty().contains("invalid cacheKey")) + + val badExtension = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "extensions" to "bad extension", + "mobileStaticRegistryState" to "not-required", + ), + ) + } + assertTrue(badExtension.message.orEmpty().contains("extension id")) + } + + @Test + fun rejectsUnsupportedRuntimeResourcesSchema() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v0", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("unsupported runtime resource schema")) + } + + @Test + fun rejectsRuntimeManifestWithTemplateLayout() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-template-pgdata-v1", + "cacheKey" to "runtime-smoke", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("unsupported layout")) + } + + @Test + fun rejectsTemplateManifestWithRuntimeLayout() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/template-pgdata", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "template-smoke", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("unsupported layout")) + } + + @Test + fun rejectsUnknownRuntimeAssetRoot() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/unknown", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("unsupported Oliphaunt asset root")) + } + + @Test + fun rejectsInvalidMobileStaticRegistryState() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "mobileStaticRegistryState" to "almost", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("mobileStaticRegistryState")) + } + + @Test + fun rejectsManifestWithoutMobileStaticRegistryState() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("omits mobileStaticRegistryState")) + } + + @Test + fun rejectsCompleteMobileRegistryWithPendingEntries() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "extensions" to "vector", + "mobileStaticRegistryState" to "complete", + "mobileStaticRegistryRegistered" to "vector", + "mobileStaticRegistryPending" to "vector", + "nativeModuleStems" to "vector", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("registered and pending")) + } + + @Test + fun rejectsPendingMobileRegistryWithoutPendingEntries() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "mobileStaticRegistryState" to "pending", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("must list mobileStaticRegistryPending")) + } + + @Test + fun rejectsCompleteMobileRegistryWithoutRegisteredModules() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "mobileStaticRegistryState" to "complete", + "mobileStaticRegistryRegistered" to "vector", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("must list mobileStaticRegistryRegistered and nativeModuleStems")) + } + + @Test + fun rejectsNotRequiredMobileRegistryWithNativeModules() { + val error = + assertFailsWith { + OliphauntAndroidRuntimeAssets.parseManifestProperties( + "oliphaunt/runtime", + manifestProperties( + "schema" to "oliphaunt-runtime-resources-v1", + "layout" to "postgres-runtime-files-v1", + "cacheKey" to "runtime-smoke", + "mobileStaticRegistryState" to "not-required", + "nativeModuleStems" to "vector", + ), + ) + } + + assertTrue(error.message.orEmpty().contains("not-required")) + } +} + +private fun manifestProperties(vararg entries: Pair): Properties = Properties().apply { + for ((key, value) in entries) { + setProperty(key, value) + } +} + +private fun validPackageSizeReport(vararg extensionRows: String): String { + val rows = + listOf( + "kind\tid\textensions\tfiles\tbytes", + "package\ttotal\t-\t-\t185", + "package\truntime\t-\t-\t100", + "package\ttemplate-pgdata\t-\t-\t40", + "package\tstatic-registry\t-\t-\t45", + "extensions\tselected\t-\t-\t30", + ) + extensionRows + return rows.joinToString("\n") +} diff --git a/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt new file mode 100644 index 00000000..c373f278 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt @@ -0,0 +1,672 @@ +package dev.oliphaunt + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +public enum class EngineMode { + NativeDirect, + NativeBroker, + NativeServer, +} + +public enum class DurabilityProfile { + Safe, + Balanced, + FastDev, +} + +public enum class RuntimeFootprintProfile { + Throughput, + BalancedMobile, + SmallMobile, +} + +public data class PostgresStartupGuc( + val name: String, + val value: String, +) + +public data class EngineCapabilities( + val mode: EngineMode, + val processIsolated: Boolean, + val independentSessions: Boolean, + val maxClientSessions: Int, + val multiRoot: Boolean = false, + val reopenable: Boolean = processIsolated, + val sameRootLogicalReopen: Boolean = !processIsolated && reopenable, + val rootSwitchable: Boolean = processIsolated, + val crashRestartable: Boolean = false, + val protocolRaw: Boolean = true, + val protocolStream: Boolean = true, + val queryCancel: Boolean = true, + val backupRestore: Boolean = true, + val backupFormats: List = listOf(BackupFormat.PhysicalArchive), + val restoreFormats: List = listOf(BackupFormat.PhysicalArchive), + val simpleQuery: Boolean = true, + val extensions: Boolean = true, + val connectionString: String? = null, +) { + public fun supportsBackupFormat(format: BackupFormat): Boolean = backupRestore && backupFormats.contains(format) + + public fun supportsRestoreFormat(format: BackupFormat): Boolean = backupRestore && restoreFormats.contains(format) +} + +public data class EngineModeSupport( + val mode: EngineMode, + val available: Boolean, + val capabilities: EngineCapabilities, + val unavailableReason: String? = null, +) + +public object OliphauntRuntimeSupport { + public val allModes: List = listOf( + EngineMode.NativeDirect, + EngineMode.NativeBroker, + EngineMode.NativeServer, + ) + + public fun capabilitiesFor(mode: EngineMode): EngineCapabilities = when (mode) { + EngineMode.NativeDirect -> EngineCapabilities( + mode = mode, + processIsolated = false, + independentSessions = false, + maxClientSessions = 1, + reopenable = true, + sameRootLogicalReopen = true, + rootSwitchable = false, + crashRestartable = false, + ) + + EngineMode.NativeBroker -> EngineCapabilities( + mode = mode, + processIsolated = true, + multiRoot = true, + independentSessions = false, + maxClientSessions = 1, + reopenable = true, + sameRootLogicalReopen = false, + rootSwitchable = true, + crashRestartable = true, + ) + + EngineMode.NativeServer -> EngineCapabilities( + mode = mode, + processIsolated = true, + independentSessions = true, + maxClientSessions = 32, + reopenable = true, + sameRootLogicalReopen = false, + rootSwitchable = true, + crashRestartable = false, + backupFormats = listOf(BackupFormat.Sql, BackupFormat.PhysicalArchive), + ) + } + + public fun nativeDirectOnly( + brokerReason: String, + serverReason: String, + ): List = listOf( + EngineModeSupport( + mode = EngineMode.NativeDirect, + available = true, + capabilities = capabilitiesFor(EngineMode.NativeDirect), + ), + EngineModeSupport( + mode = EngineMode.NativeBroker, + available = false, + capabilities = capabilitiesFor(EngineMode.NativeBroker), + unavailableReason = brokerReason, + ), + EngineModeSupport( + mode = EngineMode.NativeServer, + available = false, + capabilities = capabilitiesFor(EngineMode.NativeServer), + unavailableReason = serverReason, + ), + ) + + public fun unavailable(reason: String): List = allModes.map { mode -> + EngineModeSupport( + mode = mode, + available = false, + capabilities = capabilitiesFor(mode), + unavailableReason = reason, + ) + } +} + +public data class OliphauntConfig( + val mode: EngineMode = EngineMode.NativeDirect, + val root: String? = null, + val durability: DurabilityProfile = DurabilityProfile.Balanced, + val runtimeFootprint: RuntimeFootprintProfile = RuntimeFootprintProfile.BalancedMobile, + val startupGucs: List = emptyList(), + val username: String? = null, + val database: String? = null, + val extensions: List = emptyList(), +) + +internal fun validateStartupIdentity(value: String?, label: String) { + if (value == null) { + return + } + if (value.isBlank()) { + throw OliphauntException("$label must not be empty") + } + if (value.any { it.code == 0 }) { + throw OliphauntException("$label must not contain NUL bytes") + } +} + +internal fun validateStartupGucs(gucs: List) { + gucs.forEach { guc -> + val name = guc.name.trim() + if (name.isEmpty()) { + throw OliphauntException("PostgreSQL startup GUC name must not be empty") + } + if (name.any { it.code == 0 } || guc.value.any { it.code == 0 }) { + throw OliphauntException("PostgreSQL startup GUC must not contain NUL bytes") + } + if (!name.all { it.isLetterOrDigit() || it == '_' || it == '.' } || + !name.all { it.code in 0..127 } + ) { + throw OliphauntException( + "PostgreSQL startup GUC name '${guc.name}' must contain only ASCII letters, digits, '_' or '.'", + ) + } + if (guc.value.isBlank()) { + throw OliphauntException("PostgreSQL startup GUC '${guc.name}' value must not be empty") + } + } +} + +internal fun OliphauntConfig.postgresStartupArgs(): List = runtimeFootprint.postgresStartupArgs() + + durability.postgresStartupArgs() + + startupGucs.flatMap { guc -> listOf("-c", "${guc.name.trim()}=${guc.value}") } + +private fun RuntimeFootprintProfile.postgresStartupArgs(): List = when (this) { + RuntimeFootprintProfile.Throughput -> listOf( + "-c", + "shared_buffers=128MB", + "-c", + "wal_buffers=4MB", + "-c", + "min_wal_size=80MB", + ) + + RuntimeFootprintProfile.BalancedMobile -> listOf( + "-c", "max_connections=1", + "-c", "superuser_reserved_connections=0", + "-c", "reserved_connections=0", + "-c", "autovacuum_worker_slots=1", + "-c", "max_wal_senders=0", + "-c", "max_replication_slots=0", + "-c", "shared_buffers=32MB", + "-c", "wal_buffers=-1", + "-c", "min_wal_size=32MB", + "-c", "max_wal_size=64MB", + "-c", "io_method=sync", + "-c", "io_max_concurrency=1", + ) + + RuntimeFootprintProfile.SmallMobile -> listOf( + "-c", "max_connections=1", + "-c", "superuser_reserved_connections=0", + "-c", "reserved_connections=0", + "-c", "autovacuum_worker_slots=1", + "-c", "max_wal_senders=0", + "-c", "max_replication_slots=0", + "-c", "shared_buffers=8MB", + "-c", "wal_buffers=256kB", + "-c", "min_wal_size=32MB", + "-c", "max_wal_size=64MB", + "-c", "work_mem=1MB", + "-c", "maintenance_work_mem=16MB", + "-c", "io_method=sync", + "-c", "io_max_concurrency=1", + ) +} + +private fun DurabilityProfile.postgresStartupArgs(): List = when (this) { + DurabilityProfile.Safe -> listOf( + "-c", + "fsync=on", + "-c", + "full_page_writes=on", + "-c", + "synchronous_commit=on", + ) + + DurabilityProfile.Balanced -> listOf( + "-c", + "fsync=on", + "-c", + "full_page_writes=on", + "-c", + "synchronous_commit=off", + ) + + DurabilityProfile.FastDev -> listOf( + "-c", + "fsync=off", + "-c", + "full_page_writes=off", + "-c", + "synchronous_commit=off", + ) +} + +internal fun validateRootPath(root: String?, label: String) { + if (root == null) { + return + } + if (root.isBlank()) { + throw OliphauntException("$label must not be empty") + } + if (root.any { it.code == 0 }) { + throw OliphauntException("$label must not contain NUL bytes") + } +} + +public enum class BackupFormat { + Sql, + PhysicalArchive, + OliphauntArchive, +} + +public data class BackupRequest( + val format: BackupFormat = BackupFormat.PhysicalArchive, +) + +public data class BackupArtifact( + val format: BackupFormat, + val bytes: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BackupArtifact) return false + return format == other.format && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * format.hashCode() + bytes.contentHashCode() +} + +public enum class RestoreTargetPolicy { + FailIfExists, + ReplaceExisting, +} + +public data class RestoreRequest( + val artifact: BackupArtifact, + val root: String, + val targetPolicy: RestoreTargetPolicy = RestoreTargetPolicy.FailIfExists, +) { + public fun replaceExisting(): RestoreRequest = copy( + targetPolicy = RestoreTargetPolicy.ReplaceExisting, + ) +} + +public class ProtocolRequest(public val bytes: ByteArray) { + public companion object { + public fun simpleQuery(sql: String): ProtocolRequest { + if (sql.any { it.code == 0 }) { + throw OliphauntException("simple query SQL must not contain NUL bytes") + } + val body = sql.encodeToByteArray() + byteArrayOf(0) + val len = body.size + 4 + val header = byteArrayOf( + 'Q'.code.toByte(), + ((len ushr 24) and 0xff).toByte(), + ((len ushr 16) and 0xff).toByte(), + ((len ushr 8) and 0xff).toByte(), + (len and 0xff).toByte(), + ) + return ProtocolRequest(header + body) + } + } +} + +public class ProtocolResponse(public val bytes: ByteArray) + +public interface OliphauntEngine { + public fun supportedModes(): List = OliphauntRuntimeSupport.unavailable("engine does not publish static mode support") + + public suspend fun open(config: OliphauntConfig): OliphauntSession + public suspend fun restore(request: RestoreRequest): String +} + +public interface OliphauntSession { + public suspend fun capabilities(): EngineCapabilities + public suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse + public suspend fun execProtocolStream( + request: ProtocolRequest, + onChunk: (ProtocolResponse) -> Unit, + ) { + onChunk(execProtocolRaw(request)) + } + public suspend fun backup(request: BackupRequest): BackupArtifact + public suspend fun cancel() + public suspend fun close() +} + +public class RuntimeUnavailableEngine : OliphauntEngine { + override fun supportedModes(): List = OliphauntRuntimeSupport.unavailable("no Kotlin runtime is linked") + + override suspend fun open(config: OliphauntConfig): OliphauntSession = throw OliphauntException("no Kotlin runtime is linked for ${config.mode}") + + override suspend fun restore(request: RestoreRequest): String = throw OliphauntException("no Kotlin restore runtime is linked for ${request.artifact.format}") +} + +public open class OliphauntException(message: String) : RuntimeException(message) + +public class PostgresException( + public val postgresError: PostgresError, +) : OliphauntException(postgresError.toString()) + +public data class BackgroundPreparationOptions( + val cancelActiveWork: Boolean = true, + val checkpointWhenIdle: Boolean = true, +) + +public enum class BackgroundCheckpointSkipReason { + ActiveWork, + TransactionActive, +} + +public data class BackgroundPreparationResult( + val cancelledActiveWork: Boolean, + val checkpointed: Boolean, + val skippedCheckpointReason: BackgroundCheckpointSkipReason? = null, +) + +public expect fun defaultOliphauntEngine(mode: EngineMode): OliphauntEngine + +public class OliphauntDatabase private constructor( + private val session: OliphauntSession, +) { + private val executionMutex = Mutex() + private val stateMutex = Mutex() + private var closed = false + private var activeTransactionToken: Long? = null + private var nextTransactionToken = 1L + private var activeOperationCount = 0 + + public suspend fun capabilities(): EngineCapabilities = executionMutex.withLock { + ensureOpen() + session.capabilities() + } + + public suspend fun connectionString(): String? = capabilities().connectionString + + public suspend fun supportsBackupFormat(format: BackupFormat): Boolean = capabilities().supportsBackupFormat(format) + + public suspend fun supportsRestoreFormat(format: BackupFormat): Boolean = capabilities().supportsRestoreFormat(format) + + public suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse = executionMutex.withLock { + ensureOpen() + ensureTransactionAccess(null) + runSessionOperation { + session.execProtocolRaw(request) + } + } + + public suspend fun execute(sql: String): ProtocolResponse = execProtocolRaw(ProtocolRequest.simpleQuery(sql)) + + public suspend fun execProtocolStream( + request: ProtocolRequest, + onChunk: (ProtocolResponse) -> Unit, + ) { + executionMutex.withLock { + ensureOpen() + ensureTransactionAccess(null) + runSessionOperation { + session.execProtocolStream(request, onChunk) + } + } + } + + public suspend fun backup(request: BackupRequest = BackupRequest()): BackupArtifact = executionMutex.withLock { + ensureOpen() + ensureTransactionAccess(null) + val capabilities = session.capabilities() + if (!capabilities.supportsBackupFormat(request.format)) { + throw OliphauntException("${request.format} backup is not supported by ${capabilities.mode}") + } + runSessionOperation { + session.backup(request) + } + } + + public suspend fun checkpoint() { + execProtocolRaw(ProtocolRequest.simpleQuery("CHECKPOINT")) + } + + public suspend fun prepareForBackground( + options: BackgroundPreparationOptions = BackgroundPreparationOptions(), + ): BackgroundPreparationResult { + val snapshot = stateMutex.withLock { + if (closed) { + throw OliphauntException("database is closed") + } + activeOperationCount to activeTransactionToken + } + val hadActiveWork = snapshot.first > 0 + val cancelledActiveWork = if (options.cancelActiveWork && hadActiveWork) { + session.cancel() + true + } else { + false + } + if (!options.checkpointWhenIdle) { + return BackgroundPreparationResult( + cancelledActiveWork = cancelledActiveWork, + checkpointed = false, + ) + } + if (snapshot.second != null) { + return BackgroundPreparationResult( + cancelledActiveWork = cancelledActiveWork, + checkpointed = false, + skippedCheckpointReason = BackgroundCheckpointSkipReason.TransactionActive, + ) + } + val stillActive = stateMutex.withLock { activeOperationCount > 0 } + if (hadActiveWork || stillActive) { + return BackgroundPreparationResult( + cancelledActiveWork = cancelledActiveWork, + checkpointed = false, + skippedCheckpointReason = BackgroundCheckpointSkipReason.ActiveWork, + ) + } + checkpoint() + return BackgroundPreparationResult( + cancelledActiveWork = cancelledActiveWork, + checkpointed = true, + ) + } + + public suspend fun resumeFromBackground() { + execute("SELECT 1") + } + + public suspend fun transaction(block: suspend (OliphauntTransaction) -> T): T { + val token = stateMutex.withLock { + if (closed) { + throw OliphauntException("database is closed") + } + if (activeTransactionToken != null) { + throw OliphauntException(sessionPinnedMessage) + } + val allocated = nextTransactionToken + nextTransactionToken = if (nextTransactionToken == Long.MAX_VALUE) 1L else nextTransactionToken + 1 + activeTransactionToken = allocated + allocated + } + val transaction = OliphauntTransaction(this, token) + try { + execProtocolRaw(request = ProtocolRequest.simpleQuery("BEGIN"), transactionToken = token) + val result = block(transaction) + execProtocolRaw(request = ProtocolRequest.simpleQuery("COMMIT"), transactionToken = token) + return result + } catch (error: Throwable) { + runCatching { + execProtocolRaw(request = ProtocolRequest.simpleQuery("ROLLBACK"), transactionToken = token) + } + throw error + } finally { + stateMutex.withLock { + if (activeTransactionToken == token) { + activeTransactionToken = null + } + } + } + } + + public suspend fun cancel() { + stateMutex.withLock { + if (closed) { + throw OliphauntException("database is closed") + } + } + session.cancel() + } + + public suspend fun close() { + val shouldClose = stateMutex.withLock { + if (closed) { + false + } else { + closed = true + activeTransactionToken = null + true + } + } + if (!shouldClose) { + return + } + executionMutex.withLock { + session.close() + } + } + + private suspend fun ensureOpen() { + val isClosed = stateMutex.withLock { closed } + if (isClosed) { + throw OliphauntException("database is closed") + } + } + + private suspend fun ensureTransactionAccess(token: Long?) { + stateMutex.withLock { + if (token != null) { + if (activeTransactionToken != token) { + throw OliphauntException("transaction is no longer active") + } + } else if (activeTransactionToken != null) { + throw OliphauntException(sessionPinnedMessage) + } + } + } + + internal suspend fun execProtocolRaw( + request: ProtocolRequest, + transactionToken: Long, + ): ProtocolResponse = executionMutex.withLock { + ensureOpen() + ensureTransactionAccess(transactionToken) + runSessionOperation { + session.execProtocolRaw(request) + } + } + + internal suspend fun execProtocolStream( + request: ProtocolRequest, + transactionToken: Long, + onChunk: (ProtocolResponse) -> Unit, + ) { + executionMutex.withLock { + ensureOpen() + ensureTransactionAccess(transactionToken) + runSessionOperation { + session.execProtocolStream(request, onChunk) + } + } + } + + private suspend fun runSessionOperation(block: suspend () -> T): T { + stateMutex.withLock { + activeOperationCount += 1 + } + try { + return block() + } finally { + stateMutex.withLock { + activeOperationCount -= 1 + } + } + } + + public companion object { + public suspend fun open( + config: OliphauntConfig, + engine: OliphauntEngine = defaultOliphauntEngine(config.mode), + ): OliphauntDatabase { + validateRootPath(config.root, "database root") + validateStartupIdentity(config.username, "username") + validateStartupIdentity(config.database, "database") + validateStartupGucs(config.startupGucs) + val normalizedConfig = config.copy( + extensions = validateExtensionIds(config.extensions), + ) + return OliphauntDatabase(engine.open(normalizedConfig)) + } + + public suspend fun restore( + request: RestoreRequest, + engine: OliphauntEngine = defaultOliphauntEngine(EngineMode.NativeDirect), + ): String { + validateRootPath(request.root, "restore root") + if (request.artifact.format != BackupFormat.PhysicalArchive) { + throw OliphauntException( + "restore currently requires a PhysicalArchive artifact, got ${request.artifact.format}", + ) + } + return engine.restore(request) + } + + public fun supportedModes( + engine: OliphauntEngine = defaultOliphauntEngine(EngineMode.NativeDirect), + ): List = engine.supportedModes() + + private fun validateExtensionIds(extensions: Collection): List = extensions.map(String::trim) + .filter(String::isNotEmpty) + .onEach { extension -> + if (!portableId.matches(extension)) { + throw OliphauntException( + "Kotlin Oliphaunt extension id '$extension' must contain only ASCII letters, digits, '.', '_' or '-'", + ) + } + } + + private val portableId = Regex("[A-Za-z0-9._-]{1,128}") + + private const val sessionPinnedMessage: String = + "physical session is pinned; use the active OliphauntTransaction" + } +} + +public class OliphauntTransaction internal constructor( + private val database: OliphauntDatabase, + private val token: Long, +) { + public suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse = database.execProtocolRaw(request, transactionToken = token) + + public suspend fun execProtocolStream( + request: ProtocolRequest, + onChunk: (ProtocolResponse) -> Unit, + ) { + database.execProtocolStream(request, transactionToken = token, onChunk = onChunk) + } + + public suspend fun execute(sql: String): ProtocolResponse = execProtocolRaw(ProtocolRequest.simpleQuery(sql)) +} diff --git a/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Query.kt b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Query.kt new file mode 100644 index 00000000..cc5d47df --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Query.kt @@ -0,0 +1,569 @@ +package dev.oliphaunt + +public sealed class QueryFormat { + public data object Text : QueryFormat() + + public data object Binary : QueryFormat() + + public data class Other( + val code: Int, + ) : QueryFormat() + + public companion object { + public fun fromCode(code: Int): QueryFormat = when (code) { + 0 -> Text + 1 -> Binary + else -> Other(code) + } + } +} + +public sealed class QueryParam { + public data object Null : QueryParam() + + public data class Text( + val value: String, + ) : QueryParam() + + public class Binary( + public val value: ByteArray, + ) : QueryParam() { + override fun equals(other: Any?): Boolean = this === other || (other is Binary && value.contentEquals(other.value)) + + override fun hashCode(): Int = value.contentHashCode() + } + + public companion object { + public fun text(value: String): QueryParam = Text(value) + + public fun binary(value: ByteArray): QueryParam = Binary(value) + } +} + +public data class QueryField( + val name: String, + val tableOid: UInt, + val tableAttribute: Short, + val typeOid: UInt, + val typeSize: Short, + val typeModifier: Int, + val format: QueryFormat, +) + +public class QueryRow( + public val values: List, +) { + public fun text(column: Int): String? { + if (column !in values.indices) { + throw OliphauntException("query row has no column at index $column") + } + return values[column]?.decodeUtf8Strict("query value") + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is QueryRow) return false + if (values.size != other.values.size) return false + return values.indices.all { index -> + val left = values[index] + val right = other.values[index] + when { + left == null && right == null -> true + left == null || right == null -> false + else -> left.contentEquals(right) + } + } + } + + override fun hashCode(): Int = values.fold(1) { acc, value -> + 31 * acc + (value?.contentHashCode() ?: 0) + } +} + +public data class QueryResult( + val fields: List, + val rows: List, + val commandTag: String?, +) { + public val rowCount: Int get() = rows.size + + public fun fieldIndex(name: String): Int? { + val index = fields.indexOfFirst { it.name == name } + return if (index >= 0) index else null + } + + public fun getText( + row: Int, + column: String, + ): String? { + val columnIndex = + fieldIndex(column) + ?: throw OliphauntException("query result has no column named '$column'") + if (row !in rows.indices) { + throw OliphauntException("query result has no row at index $row") + } + return rows[row].text(columnIndex) + } +} + +public data class PostgresErrorField( + val code: Int, + val value: String, +) + +public data class PostgresError( + val severity: String?, + val sqlstate: String?, + val message: String, + val detail: String?, + val hint: String?, + val position: String?, + val whereText: String?, + val schemaName: String?, + val tableName: String?, + val columnName: String?, + val dataTypeName: String?, + val constraintName: String?, + val fields: List, +) { + override fun toString(): String = when { + severity != null && sqlstate != null -> "$severity [$sqlstate]: $message" + severity != null -> "$severity: $message" + sqlstate != null -> "[$sqlstate]: $message" + else -> message + } + + public companion object { + public fun fromFields(fields: List): PostgresError = PostgresError( + severity = fields.value('S'.code) ?: fields.value('V'.code), + sqlstate = fields.value('C'.code), + message = fields.value('M'.code) ?: "PostgreSQL ErrorResponse", + detail = fields.value('D'.code), + hint = fields.value('H'.code), + position = fields.value('P'.code), + whereText = fields.value('W'.code), + schemaName = fields.value('s'.code), + tableName = fields.value('t'.code), + columnName = fields.value('c'.code), + dataTypeName = fields.value('d'.code), + constraintName = fields.value('n'.code), + fields = fields, + ) + + public fun fallback(): PostgresError = fromFields( + listOf(PostgresErrorField('M'.code, "PostgreSQL ErrorResponse")), + ) + } +} + +public suspend fun OliphauntDatabase.query(sql: String): QueryResult = parseQueryResponse(execProtocolRaw(ProtocolRequest.simpleQuery(sql)).bytes) + +public suspend fun OliphauntDatabase.query( + sql: String, + parameters: List, +): QueryResult = parseQueryResponse(execProtocolRaw(ProtocolRequest.extendedQuery(sql, parameters)).bytes) + +public suspend fun OliphauntTransaction.query(sql: String): QueryResult = parseQueryResponse(execProtocolRaw(ProtocolRequest.simpleQuery(sql)).bytes) + +public suspend fun OliphauntTransaction.query( + sql: String, + parameters: List, +): QueryResult = parseQueryResponse(execProtocolRaw(ProtocolRequest.extendedQuery(sql, parameters)).bytes) + +public fun ProtocolRequest.Companion.extendedQuery( + sql: String, + parameters: List, +): ProtocolRequest { + if (parameters.size > Short.MAX_VALUE.toInt()) { + throw OliphauntException("extended query supports at most ${Short.MAX_VALUE} parameters, got ${parameters.size}") + } + if (sql.any { it.code == 0 }) { + throw OliphauntException("extended query SQL must not contain NUL bytes") + } + + val packet = mutableListOf() + packet.addParse(sql) + packet.addBind(parameters) + packet.addDescribePortal() + packet.addExecute() + packet.addFrontendMessage('S'.code, ByteArray(0)) + return ProtocolRequest(packet.toByteArray()) +} + +public fun parseQueryResponse(bytes: ByteArray): QueryResult { + val cursor = ByteCursor(bytes) + var fields: List? = null + val rows = mutableListOf() + var commandTag: String? = null + var sawReady = false + + while (!cursor.isAtEnd) { + val tag = cursor.readUByte("backend message tag").toInt() + val length = cursor.readInt("backend message length") + if (length < 4) { + throw OliphauntException("invalid backend message length $length") + } + val body = ByteCursor(cursor.readBytes(length - 4, "backend message body")) + + when (tag) { + 0x54 -> { + if (fields != null) { + throw OliphauntException( + "query() received multiple result sets; use execProtocolRaw for multi-statement row results", + ) + } + fields = parseRowDescription(body) + body.requireEnd("RowDescription") + } + + 0x44 -> { + val activeFields = fields ?: throw OliphauntException("DataRow arrived before RowDescription") + rows += parseDataRow(body, activeFields.size) + body.requireEnd("DataRow") + } + + 0x43 -> { + commandTag = body.readCString("CommandComplete tag") + body.requireEnd("CommandComplete") + } + + 0x45 -> { + throw PostgresException(parseErrorResponse(body)) + } + + 0x47, 0x48, 0x57, 0x64, 0x63 -> { + throw OliphauntException( + "query() does not support COPY protocol responses; use execProtocolRaw for COPY traffic", + ) + } + + 0x5a -> { + validateReadyForQuery(body) + sawReady = true + if (!cursor.isAtEnd) { + throw OliphauntException("backend returned bytes after ReadyForQuery") + } + } + + 0x31 -> { + body.requireEnd("ParseComplete") + } + + 0x32 -> { + body.requireEnd("BindComplete") + } + + 0x33 -> { + body.requireEnd("CloseComplete") + } + + 0x49 -> { + body.requireEnd("EmptyQueryResponse") + } + + 0x6e -> { + body.requireEnd("NoData") + } + + 0x53 -> { + validateParameterStatus(body) + } + + 0x4e -> { + validateFieldResponse(body, "NoticeResponse") + } + + 0x41 -> { + validateNotificationResponse(body) + } + + else -> { + throw OliphauntException( + "query() received unexpected backend message tag ${tag.hexBackendTag()}", + ) + } + } + } + + if (!sawReady) { + throw OliphauntException("query response ended before ReadyForQuery") + } + + return QueryResult( + fields = fields.orEmpty(), + rows = rows, + commandTag = commandTag, + ) +} + +private fun parseRowDescription(cursor: ByteCursor): List { + val count = cursor.readShort("RowDescription field count").toInt() + if (count < 0) { + throw OliphauntException("invalid RowDescription field count $count") + } + return List(count) { + QueryField( + name = cursor.readCString("field name"), + tableOid = cursor.readUInt("field table oid"), + tableAttribute = cursor.readShort("field table attribute"), + typeOid = cursor.readUInt("field type oid"), + typeSize = cursor.readShort("field type size"), + typeModifier = cursor.readInt("field type modifier"), + format = QueryFormat.fromCode(cursor.readShort("field format").toInt()), + ) + } +} + +private fun parseDataRow( + cursor: ByteCursor, + expectedColumns: Int, +): QueryRow { + val count = cursor.readShort("DataRow column count").toInt() + if (count < 0) { + throw OliphauntException("invalid DataRow column count $count") + } + if (count != expectedColumns) { + throw OliphauntException( + "DataRow column count $count does not match RowDescription count $expectedColumns", + ) + } + val values = + List(count) { + val length = cursor.readInt("DataRow value length") + when { + length == -1 -> null + length < 0 -> throw OliphauntException("invalid DataRow value length $length") + else -> cursor.readBytes(length, "DataRow value") + } + } + return QueryRow(values) +} + +private fun parseErrorResponse(cursor: ByteCursor): PostgresError { + val fields = mutableListOf() + while (!cursor.isAtEnd) { + val code = + runCatching { cursor.readUByte("ErrorResponse field code").toInt() } + .getOrElse { return PostgresError.fallback() } + if (code == 0) { + break + } + val value = + runCatching { cursor.readCString("ErrorResponse field") } + .getOrElse { return PostgresError.fallback() } + fields += PostgresErrorField(code, value) + } + return PostgresError.fromFields(fields) +} + +private fun List.value(code: Int): String? = firstOrNull { it.code == code }?.value + +private fun Int.hexBackendTag(): String = "0x" + toString(16).padStart(2, '0') + +private fun validateReadyForQuery(cursor: ByteCursor) { + val remaining = cursor.remainingBytes() + if (remaining != 1) { + throw OliphauntException("ReadyForQuery contained $remaining bytes, expected 1") + } + val status = cursor.readUByte("ReadyForQuery transaction status").toInt() + if (status != 'I'.code && status != 'T'.code && status != 'E'.code) { + throw OliphauntException( + "ReadyForQuery contained invalid transaction status ${status.hexBackendTag()}", + ) + } +} + +private fun validateParameterStatus(cursor: ByteCursor) { + cursor.readCString("ParameterStatus name") + cursor.readCString("ParameterStatus value") + cursor.requireEnd("ParameterStatus") +} + +private fun validateNotificationResponse(cursor: ByteCursor) { + cursor.readInt("NotificationResponse process id") + cursor.readCString("NotificationResponse channel") + cursor.readCString("NotificationResponse payload") + cursor.requireEnd("NotificationResponse") +} + +private fun validateFieldResponse( + cursor: ByteCursor, + label: String, +) { + while (true) { + if (cursor.isAtEnd) { + throw OliphauntException("$label is missing terminator") + } + val code = cursor.readUByte("$label field code").toInt() + if (code == 0) { + cursor.requireEnd(label) + return + } + cursor.readCString("$label field") + } +} + +private class ByteCursor( + private val bytes: ByteArray, +) { + private var offset = 0 + + val isAtEnd: Boolean get() = offset == bytes.size + + fun remainingBytes(): Int = bytes.size - offset + + fun requireEnd(label: String) { + if (!isAtEnd) { + throw OliphauntException("$label contained trailing bytes") + } + } + + fun readUByte(label: String): UByte = readBytes(1, label)[0].toUByte() + + fun readUInt(label: String): UInt = ( + (readUByte(label).toUInt() shl 24) or + (readUByte(label).toUInt() shl 16) or + (readUByte(label).toUInt() shl 8) or + readUByte(label).toUInt() + ) + + fun readInt(label: String): Int = readUInt(label).toInt() + + fun readShort(label: String): Short { + val value = ((readUByte(label).toInt() shl 8) or readUByte(label).toInt()) + return value.toShort() + } + + fun readCString(label: String): String { + val end = bytes.indexOf(0, startIndex = offset) + if (end < 0) { + throw OliphauntException("$label is missing null terminator") + } + val raw = bytes.copyOfRange(offset, end) + offset = end + 1 + return raw.decodeUtf8Strict(label) + } + + fun readBytes( + count: Int, + label: String, + ): ByteArray { + if (count < 0 || offset + count > bytes.size) { + throw OliphauntException("truncated $label") + } + val value = bytes.copyOfRange(offset, offset + count) + offset += count + return value + } +} + +private fun ByteArray.indexOf( + byte: Byte, + startIndex: Int, +): Int { + for (index in startIndex until size) { + if (this[index] == byte) { + return index + } + } + return -1 +} + +private fun MutableList.addParse(sql: String) { + val body = mutableListOf() + body.addCString("") + body.addCString(sql) + body.addInt16(0) + addFrontendMessage('P'.code, body.toByteArray()) +} + +private fun MutableList.addBind(parameters: List) { + val body = mutableListOf() + body.addCString("") + body.addCString("") + + body.addInt16(parameters.size) + for (parameter in parameters) { + body.addInt16( + when (parameter) { + is QueryParam.Binary -> 1 + QueryParam.Null, is QueryParam.Text -> 0 + }, + ) + } + + body.addInt16(parameters.size) + for (parameter in parameters) { + when (parameter) { + QueryParam.Null -> body.addInt32(-1) + is QueryParam.Text -> body.addSizedValue(parameter.value.encodeToByteArray()) + is QueryParam.Binary -> body.addSizedValue(parameter.value) + } + } + + body.addInt16(1) + body.addInt16(0) + addFrontendMessage('B'.code, body.toByteArray()) +} + +private fun MutableList.addDescribePortal() { + val body = mutableListOf() + body.add('P'.code.toByte()) + body.addCString("") + addFrontendMessage('D'.code, body.toByteArray()) +} + +private fun MutableList.addExecute() { + val body = mutableListOf() + body.addCString("") + body.addInt32(0) + addFrontendMessage('E'.code, body.toByteArray()) +} + +private fun MutableList.addFrontendMessage( + tag: Int, + body: ByteArray, +) { + add(tag.toByte()) + addInt32(body.size + 4) + addAll(body.asIterable()) +} + +private fun MutableList.addCString(value: String) { + if (value.any { it.code == 0 }) { + throw OliphauntException("frontend protocol string must not contain NUL bytes") + } + addAll(value.encodeToByteArray().asIterable()) + add(0) +} + +private fun MutableList.addSizedValue(value: ByteArray) { + addInt32(value.size) + addAll(value.asIterable()) +} + +private fun MutableList.addUInt32(value: UInt) { + add(((value shr 24) and 0xffu).toByte()) + add(((value shr 16) and 0xffu).toByte()) + add(((value shr 8) and 0xffu).toByte()) + add((value and 0xffu).toByte()) +} + +private fun MutableList.addInt32(value: Int) { + addUInt32(value.toUInt()) +} + +private fun MutableList.addInt16(value: Int) { + val bits = value and 0xffff + add(((bits ushr 8) and 0xff).toByte()) + add((bits and 0xff).toByte()) +} + +private fun ByteArray.decodeUtf8Strict(label: String): String { + try { + return decodeToString(throwOnInvalidSequence = true) + } catch (error: Exception) { + val detail = error.message?.let { ": $it" }.orEmpty() + throw OliphauntException("$label is not valid UTF-8$detail") + } +} diff --git a/src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt b/src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt new file mode 100644 index 00000000..fb1abec7 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt @@ -0,0 +1,1413 @@ +package dev.oliphaunt + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OliphauntDatabaseTest { + @Test + fun opensAndExecutesThroughInjectedEngine() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = MockEngine(EngineMode.NativeDirect), + ) + + val response = database.execProtocolRaw(ProtocolRequest(byteArrayOf(0x51))) + assertEquals(listOf(1, 0x51), response.bytes.map(Byte::toInt)) + } + + @Test + fun queryParsesSimpleQueryResultsThroughInjectedEngine() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = MockEngine(EngineMode.NativeDirect), + ) + + // liboliphaunt-doc-example:kotlin-typed-query + val result = database.query("SELECT 1::text AS value, NULL AS empty") + + assertEquals(listOf("value", "empty"), result.fields.map { it.name }) + assertEquals(25u, result.fields[0].typeOid) + assertEquals(1, result.rowCount) + assertEquals("SELECT 1", result.commandTag) + assertEquals("1", result.getText(0, "value")) + assertEquals(null, result.getText(0, "empty")) + } + + @Test + fun queryParametersUseExtendedProtocolThroughInjectedEngine() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = MockEngine(EngineMode.NativeDirect), + ) + + val request = + ProtocolRequest.extendedQuery( + "SELECT \$1::text AS value, \$2::text AS empty", + listOf(QueryParam.Text("1"), QueryParam.Null), + ) + assertEquals('P'.code.toByte(), request.bytes.first()) + assertTrue(request.bytes.contains('B'.code.toByte())) + assertTrue(request.bytes.contains('E'.code.toByte())) + + // liboliphaunt-doc-example:kotlin-parameterized-query + val result = + database.query( + "SELECT \$1::text AS value, \$2::text AS empty", + listOf(QueryParam.Text("1"), QueryParam.Null), + ) + + assertEquals("1", result.getText(0, "value")) + assertEquals(null, result.getText(0, "empty")) + } + + @Test + fun queryValueTypesExposeStableEqualityAndHelpers() { + assertEquals(QueryFormat.Binary, QueryFormat.fromCode(1)) + assertEquals(QueryFormat.Other(7), QueryFormat.fromCode(7)) + assertEquals(QueryParam.Text("hello"), QueryParam.text("hello")) + assertEquals(QueryParam.Binary(byteArrayOf(1, 2)), QueryParam.binary(byteArrayOf(1, 2))) + assertEquals(QueryParam.Binary(byteArrayOf(1, 2)).hashCode(), QueryParam.Binary(byteArrayOf(1, 2)).hashCode()) + + val row = QueryRow(listOf("hello".encodeToByteArray(), null)) + assertEquals("hello", row.text(0)) + assertEquals(null, row.text(1)) + assertEquals(QueryRow(listOf("hello".encodeToByteArray(), null)), row) + assertEquals(row.hashCode(), QueryRow(listOf("hello".encodeToByteArray(), null)).hashCode()) + assertTrue(row != QueryRow(listOf(null, "hello".encodeToByteArray()))) + + val rowError = + assertFailsWith { + row.text(2) + } + assertTrue(rowError.message.orEmpty().contains("query row has no column at index 2")) + + val result = + QueryResult( + fields = + listOf( + QueryField( + name = "value", + tableOid = 0u, + tableAttribute = 0, + typeOid = 25u, + typeSize = -1, + typeModifier = -1, + format = QueryFormat.Text, + ), + ), + rows = listOf(row), + commandTag = "SELECT 1", + ) + val missingColumn = + assertFailsWith { + result.getText(0, "missing") + } + assertTrue(missingColumn.message.orEmpty().contains("no column named 'missing'")) + val missingRow = + assertFailsWith { + result.getText(3, "value") + } + assertTrue(missingRow.message.orEmpty().contains("query result has no row at index 3")) + } + + @Test + fun simpleQueryRejectsNulSqlBeforeBuildingProtocol() { + val error = + assertFailsWith { + ProtocolRequest.simpleQuery("SELECT 1\u0000SELECT 2") + } + assertEquals("simple query SQL must not contain NUL bytes", error.message) + } + + @Test + fun extendedQueryRejectsInvalidFrontendInputsBeforeBuildingProtocol() { + val nulError = + assertFailsWith { + ProtocolRequest.extendedQuery("SELECT \u0000", listOf(QueryParam.Null)) + } + assertEquals("extended query SQL must not contain NUL bytes", nulError.message) + + val tooMany = List(Short.MAX_VALUE.toInt() + 1) { QueryParam.Null } + val parameterCountError = + assertFailsWith { + ProtocolRequest.extendedQuery("SELECT 1", tooMany) + } + assertEquals( + "extended query supports at most ${Short.MAX_VALUE} parameters, got ${Short.MAX_VALUE.toInt() + 1}", + parameterCountError.message, + ) + + val binary = ProtocolRequest.extendedQuery("SELECT \$1::bytea", listOf(QueryParam.binary(byteArrayOf(1, 2, 3)))) + assertEquals('P'.code.toByte(), binary.bytes.first()) + assertTrue(binary.bytes.contains(1.toByte())) + assertTrue(binary.bytes.contains(3.toByte())) + } + + @Test + fun transactionCommitsAndRejectsUnpinnedInterleaving() = runTest { + val session = MockSession(EngineMode.NativeDirect) + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = FixedSessionEngine(session), + ) + + val value = + database.transaction { transaction -> + val error = + assertFailsWith { + database.execute("SELECT outside_transaction") + } + assertTrue(error.message.orEmpty().contains("active OliphauntTransaction")) + val checkpointError = + assertFailsWith { + database.checkpoint() + } + assertTrue(checkpointError.message.orEmpty().contains("active OliphauntTransaction")) + transaction.execute("INSERT INTO kotlin_tx VALUES (1)") + val chunks = mutableListOf>() + transaction.execProtocolStream(ProtocolRequest(byteArrayOf('R'.code.toByte()))) { + chunks += it.bytes.toList() + } + assertEquals(listOf(listOf(3.toByte(), 'R'.code.toByte())), chunks) + 7 + } + + database.checkpoint() + assertEquals(7, value) + val requests = session.requestTexts() + assertTrue(requests.any { it.contains("BEGIN") }) + assertTrue(requests.any { it.contains("INSERT INTO kotlin_tx") }) + assertTrue(requests.any { it.contains("COMMIT") }) + assertTrue(requests.any { it.contains("CHECKPOINT") }) + assertFalse(requests.any { it.contains("ROLLBACK") }) + + val escaped = database.transaction { transaction -> transaction } + val error = + assertFailsWith { + escaped.execute("SELECT after_commit") + } + assertTrue(error.message.orEmpty().contains("transaction is no longer active")) + } + + @Test + fun transactionRollsBackWhenBodyThrows() = runTest { + val session = MockSession(EngineMode.NativeDirect) + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = FixedSessionEngine(session), + ) + + var captured: OliphauntTransaction? = null + val error = + assertFailsWith { + database.transaction { transaction -> + captured = transaction + transaction.execute("INSERT INTO kotlin_tx VALUES (2)") + throw OliphauntException("boom") + } + } + assertEquals("boom", error.message) + + val requests = session.requestTexts() + assertTrue(requests.any { it.contains("BEGIN") }) + assertTrue(requests.any { it.contains("INSERT INTO kotlin_tx") }) + assertTrue(requests.any { it.contains("ROLLBACK") }) + val inactive = + assertFailsWith { + captured?.execute("SELECT after_rollback") ?: error("transaction was not captured") + } + assertTrue(inactive.message.orEmpty().contains("transaction is no longer active")) + } + + @Test + fun closeDuringTransactionClosesSessionAndRejectsPinnedWork() = runTest { + val session = MockSession(EngineMode.NativeDirect) + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = FixedSessionEngine(session), + ) + + val error = + assertFailsWith { + database.transaction { transaction -> + database.close() + transaction.execute("SELECT after_close") + } + } + assertTrue(error.message.orEmpty().contains("database is closed")) + + val afterClose = + assertFailsWith { + database.execute("SELECT after_closed_database") + } + assertTrue(afterClose.message.orEmpty().contains("database is closed")) + + val requests = session.requestTexts() + assertTrue(requests.any { it.contains("BEGIN") }) + assertFalse(requests.any { it.contains("SELECT after_close") }) + assertFalse(requests.any { it.contains("COMMIT") }) + } + + @Test + fun rawProtocolStreamFallsBackToOwnedResponseThroughInjectedEngine() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = MockEngine(EngineMode.NativeDirect), + ) + + val chunks = mutableListOf() + database.execProtocolStream(ProtocolRequest(byteArrayOf(0x51))) { chunk -> + chunks += chunk + } + + assertEquals(listOf(listOf(1, 0x51)), chunks.map { chunk -> chunk.bytes.map(Byte::toInt) }) + } + + @Test + fun querySurfacesPostgresErrors() { + val error = + assertFailsWith { + parseQueryResponse(backendErrorResponse("ERROR", "42P01", "relation does not exist")) + } + assertEquals("ERROR", error.postgresError.severity) + assertEquals("42P01", error.postgresError.sqlstate) + assertEquals("relation does not exist", error.postgresError.message) + } + + @Test + fun queryNormalizesCancellationPostgresErrors() { + val error = + assertFailsWith { + parseQueryResponse(backendErrorResponse("ERROR", "57014", "canceling statement due to user request")) + } + assertEquals("ERROR", error.postgresError.severity) + assertEquals("57014", error.postgresError.sqlstate) + assertEquals("canceling statement due to user request", error.postgresError.message) + } + + @Test + fun queryParserRejectsInvalidUtf8FieldNames() { + val response = + buildList { + addRawRowDescription(listOf(byteArrayOf(0xff.toByte()) to 25u)) + addReadyForQuery() + }.toByteArray() + + val error = + assertFailsWith { + parseQueryResponse(response) + } + assertTrue(error.message.orEmpty().contains("field name is not valid UTF-8")) + } + + @Test + fun queryTextAccessorsRejectInvalidUtf8Values() { + val response = + buildList { + addRowDescription(listOf("value" to 25u)) + addDataRow(listOf(byteArrayOf(0xff.toByte()))) + addCommandComplete("SELECT 1") + addReadyForQuery() + }.toByteArray() + + val result = parseQueryResponse(response) + val error = + assertFailsWith { + result.getText(0, "value") + } + assertTrue(error.message.orEmpty().contains("query value is not valid UTF-8")) + } + + @Test + fun queryParserAcceptsExtendedQueryControlMessages() { + val response = + buildList { + addBackendMessage('1'.code, byteArrayOf()) + addBackendMessage('2'.code, byteArrayOf()) + addBackendMessage('3'.code, byteArrayOf()) + addBackendMessage('n'.code, byteArrayOf()) + addBackendMessage('I'.code, byteArrayOf()) + addCommandComplete("INSERT 0 0") + addReadyForQuery() + }.toByteArray() + + val result = parseQueryResponse(response) + assertTrue(result.fields.isEmpty()) + assertTrue(result.rows.isEmpty()) + assertEquals("INSERT 0 0", result.commandTag) + } + + @Test + fun queryParserAcceptsAsyncBackendControlMessages() { + val response = + buildList { + addParameterStatus("client_encoding", "UTF8") + addNoticeResponse("NOTICE", "hello") + addNotificationResponse(123, "channel", "payload") + addCommandComplete("SELECT 0") + addReadyForQuery() + }.toByteArray() + + val result = parseQueryResponse(response) + assertEquals("SELECT 0", result.commandTag) + } + + @Test + fun queryParserRejectsMalformedEmptyControlMessages() { + val response = + buildList { + addBackendMessage('1'.code, byteArrayOf(0)) + addReadyForQuery() + }.toByteArray() + + val error = + assertFailsWith { + parseQueryResponse(response) + } + assertTrue(error.message.orEmpty().contains("ParseComplete contained trailing bytes")) + } + + @Test + fun queryParserRejectsMalformedResultSequencing() { + val missingReady = + buildList { + addCommandComplete("SELECT 0") + }.toByteArray() + val missingReadyError = + assertFailsWith { + parseQueryResponse(missingReady) + } + assertTrue(missingReadyError.message.orEmpty().contains("ended before ReadyForQuery")) + + val duplicateResult = + buildList { + addRowDescription(listOf("one" to 25u)) + addRowDescription(listOf("two" to 25u)) + addReadyForQuery() + }.toByteArray() + val duplicateResultError = + assertFailsWith { + parseQueryResponse(duplicateResult) + } + assertTrue(duplicateResultError.message.orEmpty().contains("multiple result sets")) + + val invalidLength = + byteArrayOf('Z'.code.toByte(), 0, 0, 0, 3) + val invalidLengthError = + assertFailsWith { + parseQueryResponse(invalidLength) + } + assertTrue(invalidLengthError.message.orEmpty().contains("invalid backend message length 3")) + } + + @Test + fun queryParserRejectsInvalidRowCounts() { + val invalidRowDescription = + buildList { + addBackendMessage('T'.code, byteArrayOf(0xff.toByte(), 0xff.toByte())) + }.toByteArray() + val rowDescriptionError = + assertFailsWith { + parseQueryResponse(invalidRowDescription) + } + assertTrue(rowDescriptionError.message.orEmpty().contains("invalid RowDescription field count -1")) + + val invalidDataRow = + buildList { + addRowDescription(listOf("value" to 25u)) + addBackendMessage('D'.code, byteArrayOf(0xff.toByte(), 0xff.toByte())) + }.toByteArray() + val dataRowError = + assertFailsWith { + parseQueryResponse(invalidDataRow) + } + assertTrue(dataRowError.message.orEmpty().contains("invalid DataRow column count -1")) + + val mismatchedDataRow = + buildList { + addRowDescription(listOf("value" to 25u)) + addBackendMessage('D'.code, byteArrayOf(0, 0)) + }.toByteArray() + val mismatchError = + assertFailsWith { + parseQueryResponse(mismatchedDataRow) + } + assertTrue(mismatchError.message.orEmpty().contains("does not match RowDescription count 1")) + } + + @Test + fun queryParserRejectsMalformedAsyncBackendControlMessages() { + val malformedParameter = + buildList { + addBackendMessage('S'.code, "client_encoding\u0000".encodeToByteArray()) + addReadyForQuery() + }.toByteArray() + val parameterError = + assertFailsWith { + parseQueryResponse(malformedParameter) + } + assertTrue(parameterError.message.orEmpty().contains("ParameterStatus value is missing null terminator")) + + val malformedNotice = + buildList { + addBackendMessage('N'.code, byteArrayOf('S'.code.toByte()) + "NOTICE\u0000".encodeToByteArray()) + addReadyForQuery() + }.toByteArray() + val noticeError = + assertFailsWith { + parseQueryResponse(malformedNotice) + } + assertTrue(noticeError.message.orEmpty().contains("NoticeResponse is missing terminator")) + + val malformedNotification = + buildList { + val body = + buildList { + addInt32(123) + addAll("channel".encodeToByteArray().asIterable()) + }.toByteArray() + addBackendMessage('A'.code, body) + addReadyForQuery() + }.toByteArray() + val notificationError = + assertFailsWith { + parseQueryResponse(malformedNotification) + } + assertTrue( + notificationError.message + .orEmpty() + .contains("NotificationResponse channel is missing null terminator"), + ) + } + + @Test + fun queryParserRejectsUnexpectedBackendMessageTags() { + val response = + buildList { + addBackendMessage('R'.code, byteArrayOf(0, 0, 0, 0)) + addReadyForQuery() + }.toByteArray() + + val error = + assertFailsWith { + parseQueryResponse(response) + } + assertTrue(error.message.orEmpty().contains("unexpected backend message tag 0x52")) + } + + @Test + fun queryParserAcceptsReadyForQueryTransactionStates() { + for (status in listOf('I'.code.toByte(), 'T'.code.toByte(), 'E'.code.toByte())) { + val response = + buildList { + addCommandComplete("SELECT 0") + addReadyForQuery(status) + }.toByteArray() + + val result = parseQueryResponse(response) + assertEquals("SELECT 0", result.commandTag) + } + } + + @Test + fun queryParserRejectsMalformedReadyForQueryStatus() { + val missing = + buildList { + addBackendMessage('Z'.code, byteArrayOf()) + }.toByteArray() + val missingError = + assertFailsWith { + parseQueryResponse(missing) + } + assertTrue(missingError.message.orEmpty().contains("ReadyForQuery contained 0 bytes, expected 1")) + + val invalid = + buildList { + addReadyForQuery(0.toByte()) + }.toByteArray() + val invalidError = + assertFailsWith { + parseQueryResponse(invalid) + } + assertTrue( + invalidError.message + .orEmpty() + .contains("ReadyForQuery contained invalid transaction status 0x00"), + ) + } + + @Test + fun serverCapabilitiesExposeConnectionString() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeServer), + engine = MockEngine(EngineMode.NativeServer), + ) + + val capabilities = database.capabilities() + assertTrue(capabilities.independentSessions) + assertEquals(false, capabilities.multiRoot) + assertTrue(capabilities.queryCancel) + assertTrue(capabilities.backupRestore) + assertEquals( + listOf(BackupFormat.Sql, BackupFormat.PhysicalArchive), + capabilities.backupFormats, + ) + assertEquals(listOf(BackupFormat.PhysicalArchive), capabilities.restoreFormats) + assertTrue(capabilities.supportsBackupFormat(BackupFormat.Sql)) + assertTrue(capabilities.supportsBackupFormat(BackupFormat.PhysicalArchive)) + assertFalse(capabilities.supportsBackupFormat(BackupFormat.OliphauntArchive)) + assertTrue(capabilities.supportsRestoreFormat(BackupFormat.PhysicalArchive)) + assertFalse(capabilities.supportsRestoreFormat(BackupFormat.Sql)) + assertTrue(database.supportsBackupFormat(BackupFormat.Sql)) + assertTrue(database.supportsRestoreFormat(BackupFormat.PhysicalArchive)) + assertFalse(database.supportsRestoreFormat(BackupFormat.Sql)) + assertTrue(capabilities.simpleQuery) + assertEquals("postgres://postgres@127.0.0.1:55432/template1", capabilities.connectionString) + } + + @Test + fun connectionStringIsOnlyPresentForServerCapabilities() = runTest { + listOf(EngineMode.NativeDirect, EngineMode.NativeBroker).forEach { mode -> + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = mode), + engine = MockEngine(mode), + ) + assertEquals(null, database.connectionString()) + assertFalse(database.capabilities().independentSessions) + } + + val server = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeServer), + engine = MockEngine(EngineMode.NativeServer), + ) + assertEquals("postgres://postgres@127.0.0.1:55432/template1", server.connectionString()) + assertTrue(server.capabilities().independentSessions) + } + + @Test + fun runtimeSupportPublishesExplicitModeContract() { + val support = OliphauntDatabase.supportedModes(SupportingDirectEngine()) + + assertEquals( + listOf(EngineMode.NativeDirect, EngineMode.NativeBroker, EngineMode.NativeServer), + support.map { it.mode }, + ) + assertTrue(support[0].available) + assertEquals(1, support[0].capabilities.maxClientSessions) + assertEquals(listOf(BackupFormat.PhysicalArchive), support[0].capabilities.backupFormats) + assertTrue(support[0].capabilities.supportsBackupFormat(BackupFormat.PhysicalArchive)) + assertFalse(support[0].capabilities.supportsBackupFormat(BackupFormat.Sql)) + assertEquals(false, support[0].capabilities.independentSessions) + assertEquals(false, support[0].capabilities.multiRoot) + assertTrue(support[0].capabilities.reopenable) + assertTrue(support[0].capabilities.sameRootLogicalReopen) + assertFalse(support[0].capabilities.rootSwitchable) + assertFalse(support[0].capabilities.crashRestartable) + assertEquals(false, support[1].available) + assertTrue(support[1].capabilities.processIsolated) + assertTrue(support[1].capabilities.multiRoot) + assertTrue(support[1].capabilities.reopenable) + assertFalse(support[1].capabilities.sameRootLogicalReopen) + assertTrue(support[1].capabilities.rootSwitchable) + assertTrue(support[1].capabilities.crashRestartable) + assertTrue(support[1].unavailableReason.orEmpty().contains("broker")) + assertEquals(false, support[2].available) + assertTrue(support[2].capabilities.independentSessions) + assertEquals(false, support[2].capabilities.multiRoot) + assertTrue(support[2].capabilities.reopenable) + assertFalse(support[2].capabilities.sameRootLogicalReopen) + assertTrue(support[2].capabilities.rootSwitchable) + assertFalse(support[2].capabilities.crashRestartable) + assertEquals( + listOf(BackupFormat.Sql, BackupFormat.PhysicalArchive), + support[2].capabilities.backupFormats, + ) + assertTrue(support[2].capabilities.supportsBackupFormat(BackupFormat.Sql)) + assertTrue(support[2].capabilities.supportsRestoreFormat(BackupFormat.PhysicalArchive)) + assertTrue(support[2].unavailableReason.orEmpty().contains("server")) + } + + @Test + fun defaultRuntimeSupportPublishesConcreteModeList() { + val support = OliphauntDatabase.supportedModes() + + assertEquals( + listOf(EngineMode.NativeDirect, EngineMode.NativeBroker, EngineMode.NativeServer), + support.map { it.mode }, + ) + assertTrue(support.filterNot { it.available }.all { it.unavailableReason.orEmpty().isNotBlank() }) + } + + @Test + fun backupUsesCanonicalFormats() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeServer), + engine = MockEngine(EngineMode.NativeServer), + ) + + val artifact = database.backup(BackupRequest(BackupFormat.Sql)) + assertEquals(BackupFormat.Sql, artifact.format) + assertEquals("sql-backup", artifact.bytes.decodeToString()) + } + + @Test + fun backupRejectsUnsupportedFormatsBeforeEngineCall() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = MockEngine(EngineMode.NativeDirect), + ) + + val error = + assertFailsWith { + database.backup(BackupRequest(BackupFormat.Sql)) + } + assertTrue(error.message.orEmpty().contains("Sql backup is not supported by NativeDirect")) + } + + @Test + fun openRejectsBlankRootBeforeEngineCall() = runTest { + val engine = CountingEngine() + val error = + assertFailsWith { + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect, root = " \t"), + engine = engine, + ) + } + + assertTrue(error.message.orEmpty().contains("database root must not be empty")) + assertEquals(0, engine.openCalls) + } + + @Test + fun openRejectsNulRootBeforeEngineCall() = runTest { + val engine = CountingEngine() + val error = + assertFailsWith { + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect, root = "/tmp/oliphaunt\u0000root"), + engine = engine, + ) + } + + assertTrue(error.message.orEmpty().contains("database root must not contain NUL bytes")) + assertEquals(0, engine.openCalls) + } + + @Test + fun openForwardsConnectionIdentityAndRejectsInvalidIdentityBeforeEngineCall() = runTest { + val engine = CountingEngine() + val blankUser = + assertFailsWith { + OliphauntDatabase.open( + config = OliphauntConfig(username = " \n"), + engine = engine, + ) + } + assertTrue(blankUser.message.orEmpty().contains("username must not be empty")) + assertEquals(0, engine.openCalls) + + val nulDatabase = + assertFailsWith { + OliphauntDatabase.open( + config = OliphauntConfig(database = "app\u0000db"), + engine = engine, + ) + } + assertTrue(nulDatabase.message.orEmpty().contains("database must not contain NUL bytes")) + assertEquals(0, engine.openCalls) + + val database = + OliphauntDatabase.open( + config = + OliphauntConfig( + username = "app_user", + database = "app_db", + ), + engine = engine, + ) + assertEquals("app_user", engine.openedConfigs.single().username) + assertEquals("app_db", engine.openedConfigs.single().database) + database.close() + } + + @Test + fun restoreUsesCanonicalPhysicalArchiveShape() = runTest { + val artifact = + BackupArtifact( + BackupFormat.PhysicalArchive, + "physical-backup".encodeToByteArray(), + ) + val root = + OliphauntDatabase.restore( + RestoreRequest( + artifact = artifact, + root = "/tmp/oliphaunt-restore", + ).replaceExisting(), + engine = MockEngine(EngineMode.NativeDirect), + ) + + assertEquals("/tmp/oliphaunt-restore", root) + } + + @Test + fun restoreRejectsUnsupportedFormatsBeforeEngineCall() = runTest { + val error = + assertFailsWith { + OliphauntDatabase.restore( + RestoreRequest( + artifact = + BackupArtifact( + BackupFormat.Sql, + "sql-backup".encodeToByteArray(), + ), + root = "/tmp/oliphaunt-restore-sql", + ), + engine = MockEngine(EngineMode.NativeDirect), + ) + } + + assertTrue( + error.message + .orEmpty() + .contains("restore currently requires a PhysicalArchive artifact, got Sql"), + ) + } + + @Test + fun restoreRejectsBlankRootBeforeEngineCall() = runTest { + val engine = CountingEngine() + val error = + assertFailsWith { + OliphauntDatabase.restore( + RestoreRequest( + artifact = + BackupArtifact( + BackupFormat.PhysicalArchive, + "physical-backup".encodeToByteArray(), + ), + root = "\n", + ), + engine = engine, + ) + } + + assertTrue(error.message.orEmpty().contains("restore root must not be empty")) + assertEquals(0, engine.restoreCalls) + } + + @Test + fun restoreRejectsNulRootBeforeEngineCall() = runTest { + val engine = CountingEngine() + val error = + assertFailsWith { + OliphauntDatabase.restore( + RestoreRequest( + artifact = + BackupArtifact( + BackupFormat.PhysicalArchive, + "physical-backup".encodeToByteArray(), + ), + root = "/tmp/oliphaunt\u0000restore", + ), + engine = engine, + ) + } + + assertTrue(error.message.orEmpty().contains("restore root must not contain NUL bytes")) + assertEquals(0, engine.restoreCalls) + } + + @Test + fun openValidatesExtensionIdsBeforeEngineCall() = runTest { + val engine = CountingEngine() + val error = + assertFailsWith { + OliphauntDatabase.open( + config = OliphauntConfig(extensions = listOf("mobile/vector")), + engine = engine, + ) + } + assertTrue(error.message.orEmpty().contains("extension id 'mobile/vector'")) + assertEquals(0, engine.openCalls) + + val database = + OliphauntDatabase.open( + config = OliphauntConfig(extensions = listOf(" pg_trgm ", "", "vector", "hstore")), + engine = engine, + ) + assertEquals(1, engine.openCalls) + assertEquals(listOf("pg_trgm", "vector", "hstore"), engine.openedConfigs.single().extensions) + database.close() + } + + @Test + fun openForwardsFootprintAndStartupGucsAndRejectsInvalidGucsBeforeEngineCall() = runTest { + val engine = CountingEngine() + val error = + assertFailsWith { + OliphauntDatabase.open( + config = + OliphauntConfig( + startupGucs = listOf(PostgresStartupGuc("shared-buffers", "16MB")), + ), + engine = engine, + ) + } + assertTrue(error.message.orEmpty().contains("startup GUC name 'shared-buffers'")) + assertEquals(0, engine.openCalls) + + val database = + OliphauntDatabase.open( + config = + OliphauntConfig( + runtimeFootprint = RuntimeFootprintProfile.BalancedMobile, + startupGucs = + listOf( + PostgresStartupGuc("shared_buffers", "16MB"), + PostgresStartupGuc("wal_buffers", "256kB"), + ), + ), + engine = engine, + ) + assertEquals(1, engine.openCalls) + assertEquals(RuntimeFootprintProfile.BalancedMobile, engine.openedConfigs.single().runtimeFootprint) + assertEquals( + listOf( + PostgresStartupGuc("shared_buffers", "16MB"), + PostgresStartupGuc("wal_buffers", "256kB"), + ), + engine.openedConfigs.single().startupGucs, + ) + database.close() + } + + @Test + fun runtimeFootprintProfilesBuildTheMobileStartupGucContract() { + assertEquals( + listOf( + "max_connections=1", + "superuser_reserved_connections=0", + "reserved_connections=0", + "autovacuum_worker_slots=1", + "max_wal_senders=0", + "max_replication_slots=0", + "shared_buffers=32MB", + "wal_buffers=-1", + "min_wal_size=32MB", + "max_wal_size=64MB", + "io_method=sync", + "io_max_concurrency=1", + "fsync=on", + "full_page_writes=on", + "synchronous_commit=off", + "shared_buffers=16MB", + ), + startupAssignments( + OliphauntConfig( + durability = DurabilityProfile.Balanced, + runtimeFootprint = RuntimeFootprintProfile.BalancedMobile, + startupGucs = listOf(PostgresStartupGuc(" shared_buffers ", "16MB")), + ).postgresStartupArgs(), + ), + ) + assertEquals( + listOf( + "max_connections=1", + "superuser_reserved_connections=0", + "reserved_connections=0", + "autovacuum_worker_slots=1", + "max_wal_senders=0", + "max_replication_slots=0", + "shared_buffers=8MB", + "wal_buffers=256kB", + "min_wal_size=32MB", + "max_wal_size=64MB", + "work_mem=1MB", + "maintenance_work_mem=16MB", + "io_method=sync", + "io_max_concurrency=1", + "fsync=on", + "full_page_writes=on", + "synchronous_commit=off", + ), + startupAssignments( + OliphauntConfig(runtimeFootprint = RuntimeFootprintProfile.SmallMobile) + .postgresStartupArgs(), + ), + ) + } + + @Test + fun closeIsIdempotentAndRejectsFurtherExecution() = runTest { + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = MockEngine(EngineMode.NativeDirect), + ) + + database.close() + database.close() + + assertFailsWith { + database.execProtocolRaw(ProtocolRequest(ByteArray(0))) + } + } + + @Test + fun closeDoesNotIssueSpuriousCancelBeforeClosing() = runTest { + val session = BlockingSession() + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = FixedSessionEngine(session), + ) + + database.close() + + assertFalse(session.cancelled.isCompleted) + assertTrue(session.closed.isCompleted) + assertFailsWith { + database.cancel() + } + } + + @Test + fun prepareForBackgroundCheckpointsWhenIdleAndResumeProbesSession() = runTest { + val session = MockSession(EngineMode.NativeDirect) + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = FixedSessionEngine(session), + ) + + val prepared = database.prepareForBackground() + database.resumeFromBackground() + + assertEquals( + BackgroundPreparationResult( + cancelledActiveWork = false, + checkpointed = true, + ), + prepared, + ) + val requests = session.requestTexts() + assertTrue(requests.any { it.contains("CHECKPOINT") }) + assertTrue(requests.any { it.contains("SELECT 1") }) + } + + @Test + fun prepareForBackgroundCancelsActiveWorkAndSkipsCheckpoint() = runTest { + val session = BlockingOperationSession() + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = FixedSessionEngine(session), + ) + val running = + async { + database.execProtocolRaw(ProtocolRequest.simpleQuery("SELECT pg_sleep(5)")) + } + session.started.await() + + val prepared = database.prepareForBackground() + + assertEquals( + BackgroundPreparationResult( + cancelledActiveWork = true, + checkpointed = false, + skippedCheckpointReason = BackgroundCheckpointSkipReason.ActiveWork, + ), + prepared, + ) + assertTrue(session.cancelled.isCompleted) + assertEquals("cancelled", running.await().bytes.decodeToString()) + } + + @Test + fun prepareForBackgroundSkipsCheckpointDuringTransaction() = runTest { + val session = MockSession(EngineMode.NativeDirect) + val database = + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = FixedSessionEngine(session), + ) + + val prepared = + database.transaction { + database.prepareForBackground() + } + + assertEquals( + BackgroundPreparationResult( + cancelledActiveWork = false, + checkpointed = false, + skippedCheckpointReason = BackgroundCheckpointSkipReason.TransactionActive, + ), + prepared, + ) + assertFalse(session.requestTexts().any { it.contains("CHECKPOINT") }) + } +} + +private class MockEngine( + private val mode: EngineMode, +) : OliphauntEngine { + override suspend fun open(config: OliphauntConfig): OliphauntSession { + assertEquals(mode, config.mode) + return MockSession(mode) + } + + override suspend fun restore(request: RestoreRequest): String { + assertEquals(BackupFormat.PhysicalArchive, request.artifact.format) + assertEquals(RestoreTargetPolicy.ReplaceExisting, request.targetPolicy) + return request.root + } +} + +private class SupportingDirectEngine : OliphauntEngine { + override fun supportedModes(): List = OliphauntRuntimeSupport.nativeDirectOnly( + brokerReason = "broker adapter is unavailable", + serverReason = "server adapter is unavailable", + ) + + override suspend fun open(config: OliphauntConfig): OliphauntSession = throw OliphauntException("not used") + + override suspend fun restore(request: RestoreRequest): String = throw OliphauntException("not used") +} + +private class CountingEngine : OliphauntEngine { + var openCalls = 0 + var restoreCalls = 0 + val openedConfigs = mutableListOf() + + override suspend fun open(config: OliphauntConfig): OliphauntSession { + openCalls += 1 + openedConfigs += config + return MockSession(config.mode) + } + + override suspend fun restore(request: RestoreRequest): String { + restoreCalls += 1 + return request.root + } +} + +private class MockSession( + private val mode: EngineMode, +) : OliphauntSession { + private var calls = 0 + private val requests = mutableListOf() + + override suspend fun capabilities(): EngineCapabilities = when (mode) { + EngineMode.NativeDirect -> { + EngineCapabilities( + mode = mode, + processIsolated = false, + independentSessions = false, + maxClientSessions = 1, + ) + } + + EngineMode.NativeBroker -> { + EngineCapabilities( + mode = mode, + processIsolated = true, + independentSessions = false, + maxClientSessions = 1, + ) + } + + EngineMode.NativeServer -> { + EngineCapabilities( + mode = mode, + processIsolated = true, + independentSessions = true, + maxClientSessions = 32, + backupFormats = listOf(BackupFormat.Sql, BackupFormat.PhysicalArchive), + connectionString = "postgres://postgres@127.0.0.1:55432/template1", + ) + } + } + + override suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse { + calls += 1 + requests += request.bytes + if ( + request.bytes.size > 5 && + (request.bytes[0] == 'Q'.code.toByte() || request.bytes[0] == 'P'.code.toByte()) + ) { + return ProtocolResponse(backendSelectResponse()) + } + return ProtocolResponse(byteArrayOf(calls.toByte()) + request.bytes) + } + + fun requestTexts(): List = requests.map { it.decodeToString() } + + override suspend fun backup(request: BackupRequest): BackupArtifact = when (request.format) { + BackupFormat.Sql -> { + BackupArtifact(BackupFormat.Sql, "sql-backup".encodeToByteArray()) + } + + BackupFormat.PhysicalArchive -> { + BackupArtifact( + BackupFormat.PhysicalArchive, + "physical-backup".encodeToByteArray(), + ) + } + + BackupFormat.OliphauntArchive -> { + throw OliphauntException("oliphaunt archive is not available") + } + } + + override suspend fun cancel() = Unit + + override suspend fun close() = Unit +} + +private class FixedSessionEngine( + private val session: OliphauntSession, +) : OliphauntEngine { + override suspend fun open(config: OliphauntConfig): OliphauntSession = session + + override suspend fun restore(request: RestoreRequest): String = request.root +} + +private class BlockingSession : OliphauntSession { + val started = CompletableDeferred() + val cancelled = CompletableDeferred() + val closed = CompletableDeferred() + + override suspend fun capabilities(): EngineCapabilities = EngineCapabilities( + mode = EngineMode.NativeDirect, + processIsolated = false, + independentSessions = false, + maxClientSessions = 1, + ) + + override suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse { + started.complete(Unit) + return ProtocolResponse(request.bytes) + } + + override suspend fun backup(request: BackupRequest): BackupArtifact = throw OliphauntException("backup blocked") + + override suspend fun cancel() { + cancelled.complete(Unit) + } + + override suspend fun close() { + closed.complete(Unit) + } +} + +private class BlockingOperationSession : OliphauntSession { + val started = CompletableDeferred() + val cancelled = CompletableDeferred() + private val response = CompletableDeferred() + + override suspend fun capabilities(): EngineCapabilities = EngineCapabilities( + mode = EngineMode.NativeDirect, + processIsolated = false, + independentSessions = false, + maxClientSessions = 1, + ) + + override suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse { + started.complete(Unit) + return response.await() + } + + override suspend fun backup(request: BackupRequest): BackupArtifact = throw OliphauntException("backup blocked") + + override suspend fun cancel() { + cancelled.complete(Unit) + response.complete(ProtocolResponse("cancelled".encodeToByteArray())) + } + + override suspend fun close() = Unit +} + +private fun backendSelectResponse(): ByteArray = buildList { + addRowDescription(listOf("value" to 25u, "empty" to 25u)) + addDataRow(listOf("1".encodeToByteArray(), null)) + addCommandComplete("SELECT 1") + addReadyForQuery() +}.toByteArray() + +private fun backendErrorResponse( + severity: String, + sqlstate: String, + message: String, +): ByteArray = buildList { + val body = + buildList { + add('S'.code.toByte()) + addAll(severity.encodeToByteArray().asIterable()) + add(0) + add('C'.code.toByte()) + addAll(sqlstate.encodeToByteArray().asIterable()) + add(0) + add('M'.code.toByte()) + addAll(message.encodeToByteArray().asIterable()) + add(0) + add(0) + }.toByteArray() + addBackendMessage('E'.code, body) + addReadyForQuery() +}.toByteArray() + +private fun MutableList.addRowDescription(fields: List>) { + addRawRowDescription(fields.map { (name, typeOid) -> name.encodeToByteArray() to typeOid }) +} + +private fun MutableList.addRawRowDescription(fields: List>) { + val body = + buildList { + addInt16(fields.size) + for ((name, typeOid) in fields) { + addAll(name.asIterable()) + add(0) + addUInt32(0u) + addInt16(0) + addUInt32(typeOid) + addInt16(-1) + addInt32(-1) + addInt16(0) + } + }.toByteArray() + addBackendMessage('T'.code, body) +} + +private fun MutableList.addDataRow(values: List) { + val body = + buildList { + addInt16(values.size) + for (value in values) { + if (value == null) { + addInt32(-1) + } else { + addInt32(value.size) + addAll(value.asIterable()) + } + } + }.toByteArray() + addBackendMessage('D'.code, body) +} + +private fun MutableList.addCommandComplete(tag: String) { + val body = + buildList { + addAll(tag.encodeToByteArray().asIterable()) + add(0) + }.toByteArray() + addBackendMessage('C'.code, body) +} + +private fun MutableList.addNoticeResponse( + severity: String, + message: String, +) { + val body = + buildList { + add('S'.code.toByte()) + addAll(severity.encodeToByteArray().asIterable()) + add(0) + add('M'.code.toByte()) + addAll(message.encodeToByteArray().asIterable()) + add(0) + add(0) + }.toByteArray() + addBackendMessage('N'.code, body) +} + +private fun MutableList.addParameterStatus( + name: String, + value: String, +) { + val body = + buildList { + addAll(name.encodeToByteArray().asIterable()) + add(0) + addAll(value.encodeToByteArray().asIterable()) + add(0) + }.toByteArray() + addBackendMessage('S'.code, body) +} + +private fun MutableList.addNotificationResponse( + pid: Int, + channel: String, + payload: String, +) { + val body = + buildList { + addInt32(pid) + addAll(channel.encodeToByteArray().asIterable()) + add(0) + addAll(payload.encodeToByteArray().asIterable()) + add(0) + }.toByteArray() + addBackendMessage('A'.code, body) +} + +private fun MutableList.addReadyForQuery(status: Byte = 'I'.code.toByte()) { + addBackendMessage('Z'.code, byteArrayOf(status)) +} + +private fun startupAssignments(args: List): List { + val assignments = mutableListOf() + var index = 0 + while (index < args.size) { + require(args[index] == "-c") { "unexpected startup flag ${args[index]}" } + require(index + 1 < args.size) { "missing startup assignment after -c" } + assignments += args[index + 1] + index += 2 + } + return assignments +} + +private fun MutableList.addBackendMessage( + tag: Int, + body: ByteArray, +) { + add(tag.toByte()) + addInt32(body.size + 4) + addAll(body.asIterable()) +} + +private fun MutableList.addUInt32(value: UInt) { + add(((value shr 24) and 0xffu).toByte()) + add(((value shr 16) and 0xffu).toByte()) + add(((value shr 8) and 0xffu).toByte()) + add((value and 0xffu).toByte()) +} + +private fun MutableList.addInt32(value: Int) { + addUInt32(value.toUInt()) +} + +private fun MutableList.addInt16(value: Int) { + val bits = value and 0xffff + add(((bits ushr 8) and 0xff).toByte()) + add((bits and 0xff).toByte()) +} diff --git a/src/sdks/kotlin/oliphaunt/src/generated/extensions.json b/src/sdks/kotlin/oliphaunt/src/generated/extensions.json new file mode 100644 index 00000000..d39eb34a --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/generated/extensions.json @@ -0,0 +1,1249 @@ +{ + "consumer": "kotlin", + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "amcheck", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/auto_explain.tar.zst", + "creates-extension": false, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "auto_explain", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/bloom.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "bloom", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gin.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gin", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gist.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gist", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/citext.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "citext", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/cube.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "cube", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_int.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/xsyn_sample.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/earthdistance.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "earthdistance", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [ + "cube" + ], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/file_fdw.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "file_fdw", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/hstore.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "hstore", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/intarray.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/isn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "isn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/lo.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "lo", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/ltree.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "ltree", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pageinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pageinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgtap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "extension-sql-file-names": [ + "uninstall_pgtap.sql" + ], + "extension-sql-file-prefixes": [ + "pgtap-core", + "pgtap-schema" + ], + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": null, + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/postgis.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "extension-sql-file-names": [ + "uninstall_postgis.sql" + ], + "extension-sql-file-prefixes": [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis" + ], + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "postgres-major": 18, + "public": true, + "runtime-environment": [ + { + "name": "PROJ_DATA", + "path": "share/postgresql/proj", + "required_file": "proj.db" + } + ], + "runtime-share-data-files": [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgis", + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/seg.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "seg", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tablefunc.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tablefunc", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tcn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tcn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/unaccent.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "unaccent", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/unaccent.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/vector.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "vector", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-evidence", + "path": "src/extensions/generated/docs/extension-evidence.json" + } + ] +} diff --git a/src/sdks/kotlin/oliphaunt/src/jvmMain/kotlin/dev/oliphaunt/DefaultEngine.kt b/src/sdks/kotlin/oliphaunt/src/jvmMain/kotlin/dev/oliphaunt/DefaultEngine.kt new file mode 100644 index 00000000..5414c21a --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/jvmMain/kotlin/dev/oliphaunt/DefaultEngine.kt @@ -0,0 +1,3 @@ +package dev.oliphaunt + +public actual fun defaultOliphauntEngine(mode: EngineMode): OliphauntEngine = RuntimeUnavailableEngine() diff --git a/src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt b/src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt new file mode 100644 index 00000000..1eae166b --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt @@ -0,0 +1,222 @@ +package dev.oliphaunt + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SharedProtocolFixtureTest { + @Test + fun queryParserMatchesSharedProtocolFixtures() { + val path = sharedProtocolFixturePath() ?: return + val corpus = Json.parseToJsonElement(Files.readString(path)).jsonObject + assertEquals(1, corpus.requiredInt("schemaVersion")) + assertEquals("postgres-backend-query-response", corpus.requiredString("kind")) + + val names = mutableSetOf() + for (fixture in parseFixtures(corpus.requiredArray("cases"))) { + assertTrue(names.add(fixture.name), "duplicate shared protocol fixture ${fixture.name}") + val expectation = fixture.queryExpectation ?: continue + val bytes = hexToBytes(fixture.responseHex) + when { + expectation.ok != null -> { + assertOkFixture(fixture, expectation.ok, bytes) + } + + expectation.postgresError != null -> { + assertPostgresErrorFixture( + fixture, + expectation.postgresError, + bytes, + ) + } + + expectation.engineErrorContains != null -> { + assertEngineErrorFixture( + fixture, + expectation.engineErrorContains, + bytes, + ) + } + + else -> { + error("shared protocol fixture ${fixture.name} has no query expectation") + } + } + } + } + + private fun assertOkFixture( + fixture: SharedProtocolFixture, + expected: SharedProtocolOkExpectation, + bytes: ByteArray, + ) { + val result = parseQueryResponse(bytes) + assertEquals(expected.rowCount, result.rowCount, "${fixture.name} row count") + assertEquals(expected.commandTag, result.commandTag, "${fixture.name} command tag") + assertEquals(expected.fields.size, result.fields.size, "${fixture.name} field count") + assertEquals(expected.rows.size, result.rows.size, "${fixture.name} rows size") + + expected.fields.forEachIndexed { index, expectedField -> + val actual = result.fields[index] + assertEquals(expectedField.name, actual.name, "${fixture.name} field name") + assertEquals(expectedField.typeOid, actual.typeOid, "${fixture.name} type OID") + if (expectedField.format == "text") { + assertEquals(QueryFormat.Text, actual.format, "${fixture.name} field format") + } + } + + expected.rows.forEachIndexed { rowIndex, row -> + assertEquals(expected.fields.size, row.size, "${fixture.name} expected row width") + row.forEachIndexed { columnIndex, expectedValue -> + val field = expected.fields[columnIndex] + assertEquals( + expectedValue, + result.getText(rowIndex, field.name), + "${fixture.name} row $rowIndex column ${field.name}", + ) + } + } + } + + private fun assertPostgresErrorFixture( + fixture: SharedProtocolFixture, + expected: SharedProtocolPostgresErrorExpectation, + bytes: ByteArray, + ) { + val error = + assertFailsWith("${fixture.name} should fail") { + parseQueryResponse(bytes) + }.postgresError + assertEquals(expected.severity, error.severity, "${fixture.name} severity") + assertEquals(expected.sqlstate, error.sqlstate, "${fixture.name} SQLSTATE") + assertEquals(expected.message, error.message, "${fixture.name} message") + } + + private fun assertEngineErrorFixture( + fixture: SharedProtocolFixture, + expected: String, + bytes: ByteArray, + ) { + val error = + assertFailsWith("${fixture.name} should fail") { + parseQueryResponse(bytes) + } + assertTrue( + error.message.orEmpty().contains(expected), + "${fixture.name} error ${error.message} did not contain $expected", + ) + } +} + +private fun sharedProtocolFixturePath(): Path? { + val configured = + System + .getProperty("oliphaunt.sharedFixturesDir") + ?.takeIf(String::isNotBlank) + ?.let { Path.of(it, "protocol", "query-response-cases.json") } + val cwdCandidate = + Path + .of("") + .toAbsolutePath() + .resolve("../../shared/fixtures/protocol/query-response-cases.json") + .normalize() + return listOfNotNull(configured, cwdCandidate).firstOrNull(Files::isRegularFile) +} + +private fun parseFixtures(cases: JsonArray): List = cases.map { element -> + val obj = element.jsonObject + SharedProtocolFixture( + name = obj.requiredString("name"), + responseHex = obj.requiredString("responseHex"), + queryExpectation = obj["queryExpectation"]?.jsonObject?.let(::parseQueryExpectation), + ) +} + +private fun parseQueryExpectation(obj: JsonObject): SharedProtocolQueryExpectation = SharedProtocolQueryExpectation( + ok = obj["ok"]?.jsonObject?.let(::parseOkExpectation), + postgresError = obj["postgresError"]?.jsonObject?.let(::parsePostgresErrorExpectation), + engineErrorContains = obj["engineErrorContains"]?.jsonPrimitive?.contentOrNull, +) + +private fun parseOkExpectation(obj: JsonObject): SharedProtocolOkExpectation = SharedProtocolOkExpectation( + fields = + obj.requiredArray("fields").map { field -> + val fieldObject = field.jsonObject + SharedProtocolFieldExpectation( + name = fieldObject.requiredString("name"), + typeOid = fieldObject.requiredInt("typeOid").toUInt(), + format = fieldObject["format"]?.jsonPrimitive?.contentOrNull, + ) + }, + rows = + obj.requiredArray("rows").map { row -> + row.jsonArray.map { cell -> + if (cell is JsonNull) null else cell.jsonPrimitive.content + } + }, + commandTag = obj["commandTag"]?.jsonPrimitive?.contentOrNull, + rowCount = obj.requiredInt("rowCount"), +) + +private fun parsePostgresErrorExpectation(obj: JsonObject): SharedProtocolPostgresErrorExpectation = SharedProtocolPostgresErrorExpectation( + severity = obj.requiredString("severity"), + sqlstate = obj.requiredString("sqlstate"), + message = obj.requiredString("message"), +) + +private fun JsonObject.requiredArray(name: String): JsonArray = this[name]?.jsonArray ?: error("missing shared protocol fixture array $name") + +private fun JsonObject.requiredInt(name: String): Int = this[name]?.jsonPrimitive?.int ?: error("missing shared protocol fixture integer $name") + +private fun JsonObject.requiredString(name: String): String = this[name]?.jsonPrimitive?.content ?: error("missing shared protocol fixture string $name") + +private fun hexToBytes(hex: String): ByteArray { + val compact = hex.filterNot(Char::isWhitespace) + require(compact.length % 2 == 0) { "hex fixture must have an even digit count" } + return ByteArray(compact.length / 2) { index -> + compact.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } +} + +private data class SharedProtocolFixture( + val name: String, + val responseHex: String, + val queryExpectation: SharedProtocolQueryExpectation?, +) + +private data class SharedProtocolQueryExpectation( + val ok: SharedProtocolOkExpectation?, + val postgresError: SharedProtocolPostgresErrorExpectation?, + val engineErrorContains: String?, +) + +private data class SharedProtocolOkExpectation( + val fields: List, + val rows: List>, + val commandTag: String?, + val rowCount: Int, +) + +private data class SharedProtocolFieldExpectation( + val name: String, + val typeOid: UInt, + val format: String?, +) + +private data class SharedProtocolPostgresErrorExpectation( + val severity: String, + val sqlstate: String, + val message: String, +) diff --git a/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt.def b/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt.def new file mode 100644 index 00000000..34baa2ab --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt.def @@ -0,0 +1,3 @@ +headers = oliphaunt_kotlin_bridge.h +headerFilter = oliphaunt.h oliphaunt_kotlin_bridge.h +package = dev.oliphaunt.native.c diff --git a/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.c b/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.c new file mode 100644 index 00000000..092091ed --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.c @@ -0,0 +1,301 @@ +#define _XOPEN_SOURCE 700 + +#include "oliphaunt_kotlin_bridge.h" + +#include +#include +#include +#include +#include +#include + +#ifndef RTLD_DEFAULT +#define RTLD_DEFAULT ((void *)-2) +#endif + +typedef int32_t (*OliphauntInitFn)(const OliphauntConfig *config, OliphauntHandle **out); +typedef int32_t (*OliphauntExecProtocolFn)( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +typedef int32_t (*OliphauntExecProtocolStreamFn)( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +typedef int32_t (*OliphauntCancelFn)(OliphauntHandle *handle); +typedef int32_t (*OliphauntDetachFn)(OliphauntHandle *handle); +typedef int32_t (*OliphauntCloseFn)(OliphauntHandle *handle); +typedef const char *(*OliphauntLastErrorFn)(OliphauntHandle *handle); +typedef uint64_t (*OliphauntCapabilitiesFn)(void); +typedef void (*OliphauntFreeResponseFn)(OliphauntResponse *response); +typedef int32_t (*OliphauntBackupFn)(OliphauntHandle *handle, uint32_t format, OliphauntResponse *out); +typedef int32_t (*OliphauntRestoreFn)(const OliphauntRestoreOptions *options); + +typedef struct OliphauntKotlinSymbols { + void *library; + bool owns_library; + OliphauntInitFn init; + OliphauntExecProtocolFn exec_protocol; + OliphauntExecProtocolStreamFn exec_protocol_stream; + OliphauntCancelFn cancel; + OliphauntDetachFn detach; + OliphauntCloseFn close; + OliphauntLastErrorFn last_error; + OliphauntCapabilitiesFn capabilities; + OliphauntFreeResponseFn free_response; + OliphauntBackupFn backup; + OliphauntRestoreFn restore; +} OliphauntKotlinSymbols; + +struct OliphauntKotlinSession { + OliphauntKotlinSymbols symbols; + OliphauntHandle *handle; + char last_error[1024]; +}; + +static char global_last_error[1024]; + +static void set_global_error(const char *message) { + snprintf(global_last_error, sizeof(global_last_error), "%s", message ? message : "unknown liboliphaunt Kotlin bridge error"); +} + +static void set_session_error(OliphauntKotlinSession *session, const char *message) { + if (session == NULL) { + set_global_error(message); + return; + } + snprintf(session->last_error, sizeof(session->last_error), "%s", message ? message : "unknown liboliphaunt Kotlin bridge error"); +} + +static const char *env_library_path(void) { + const char *path = getenv("OLIPHAUNT_KOTLIN_LIBRARY"); + if (path == NULL || path[0] == '\0') { + path = getenv("LIBOLIPHAUNT_PATH"); + } + if (path == NULL || path[0] == '\0') { + path = getenv("OLIPHAUNT_LIBRARY"); + } + return path != NULL && path[0] != '\0' ? path : NULL; +} + +static void *symbol_lookup_handle(OliphauntKotlinSymbols *symbols) { + return symbols->library != NULL ? symbols->library : RTLD_DEFAULT; +} + +static int load_symbol(OliphauntKotlinSymbols *symbols, const char *name, void **out) { + dlerror(); + *out = dlsym(symbol_lookup_handle(symbols), name); + const char *error = dlerror(); + if (error != NULL || *out == NULL) { + char message[1024]; + snprintf(message, sizeof(message), "liboliphaunt symbol %s is unavailable: %s", name, error ? error : "symbol not found"); + set_global_error(message); + return -1; + } + return 0; +} + +static void unload_symbols(OliphauntKotlinSymbols *symbols) { + /* + * liboliphaunt embeds PostgreSQL, which owns process-global runtime state + * while a backend session is active. Ordinary SDK close calls oliphaunt_detach; + * oliphaunt_close is terminal for the process lifetime. Unloading the code + * image can leave host-process callbacks or handlers pointing at unmapped + * addresses. Keep the native engine resident once it has been loaded. + */ + memset(symbols, 0, sizeof(*symbols)); +} + +static int load_symbols(const char *library_path, OliphauntKotlinSymbols *symbols) { + memset(symbols, 0, sizeof(*symbols)); + + const char *path = library_path != NULL && library_path[0] != '\0' + ? library_path + : env_library_path(); + if (path != NULL) { + symbols->library = dlopen(path, RTLD_NOW | RTLD_LOCAL); + if (symbols->library == NULL) { + char message[1024]; + snprintf(message, sizeof(message), "failed to load liboliphaunt at %s: %s", path, dlerror()); + set_global_error(message); + return -1; + } + symbols->owns_library = true; + } + + if (load_symbol(symbols, "oliphaunt_init", (void **)&symbols->init) != 0 || + load_symbol(symbols, "oliphaunt_exec_protocol", (void **)&symbols->exec_protocol) != 0 || + load_symbol(symbols, "oliphaunt_exec_protocol_stream", (void **)&symbols->exec_protocol_stream) != 0 || + load_symbol(symbols, "oliphaunt_cancel", (void **)&symbols->cancel) != 0 || + load_symbol(symbols, "oliphaunt_detach", (void **)&symbols->detach) != 0 || + load_symbol(symbols, "oliphaunt_close", (void **)&symbols->close) != 0 || + load_symbol(symbols, "oliphaunt_last_error", (void **)&symbols->last_error) != 0 || + load_symbol(symbols, "oliphaunt_capabilities", (void **)&symbols->capabilities) != 0 || + load_symbol(symbols, "oliphaunt_free_response", (void **)&symbols->free_response) != 0 || + load_symbol(symbols, "oliphaunt_backup", (void **)&symbols->backup) != 0 || + load_symbol(symbols, "oliphaunt_restore", (void **)&symbols->restore) != 0) { + unload_symbols(symbols); + return -1; + } + + return 0; +} + +OliphauntKotlinSession *oliphaunt_kotlin_open( + const char *library_path, + const OliphauntConfig *config) { + if (config == NULL) { + set_global_error("oliphaunt_kotlin_open config is null"); + return NULL; + } + + OliphauntKotlinSession *session = (OliphauntKotlinSession *)calloc(1, sizeof(OliphauntKotlinSession)); + if (session == NULL) { + set_global_error("out of memory allocating OliphauntKotlinSession"); + return NULL; + } + if (load_symbols(library_path, &session->symbols) != 0) { + free(session); + return NULL; + } + if (session->symbols.init(config, &session->handle) != 0) { + const char *error = session->symbols.last_error != NULL + ? session->symbols.last_error(session->handle) + : NULL; + set_global_error(error); + unload_symbols(&session->symbols); + free(session); + return NULL; + } + + return session; +} + +int32_t oliphaunt_kotlin_exec_protocol( + OliphauntKotlinSession *session, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out) { + if (session == NULL || out == NULL) { + set_session_error(session, "invalid oliphaunt_kotlin_exec_protocol arguments"); + return -1; + } + int32_t rc = session->symbols.exec_protocol(session->handle, request, request_len, out); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_kotlin_exec_protocol_stream( + OliphauntKotlinSession *session, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context) { + if (session == NULL || callback == NULL) { + set_session_error(session, "invalid oliphaunt_kotlin_exec_protocol_stream arguments"); + return -1; + } + int32_t rc = session->symbols.exec_protocol_stream( + session->handle, + request, + request_len, + callback, + callback_context); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_kotlin_backup(OliphauntKotlinSession *session, uint32_t format, OliphauntResponse *out) { + if (session == NULL || out == NULL) { + set_session_error(session, "invalid oliphaunt_kotlin_backup arguments"); + return -1; + } + int32_t rc = session->symbols.backup(session->handle, format, out); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_kotlin_restore(const char *library_path, const OliphauntRestoreOptions *options) { + OliphauntKotlinSymbols symbols; + if (load_symbols(library_path, &symbols) != 0) { + return -1; + } + int32_t rc = symbols.restore(options); + if (rc != 0 && symbols.last_error != NULL) { + set_global_error(symbols.last_error(NULL)); + } + unload_symbols(&symbols); + return rc; +} + +int32_t oliphaunt_kotlin_cancel(OliphauntKotlinSession *session) { + if (session == NULL) { + set_global_error("invalid oliphaunt_kotlin_cancel arguments"); + return -1; + } + int32_t rc = session->symbols.cancel(session->handle); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_kotlin_close(OliphauntKotlinSession *session) { + if (session == NULL) { + return 0; + } + int32_t rc = 0; + if (session->symbols.detach != NULL && session->handle != NULL) { + rc = session->symbols.detach(session->handle); + if (rc != 0 && session->symbols.last_error != NULL) { + const char *message = session->symbols.last_error(session->handle); + set_session_error(session, message); + set_global_error(message); + } + session->handle = NULL; + } + unload_symbols(&session->symbols); + free(session); + return rc; +} + +const char *oliphaunt_kotlin_last_error(OliphauntKotlinSession *session) { + return session != NULL ? session->last_error : global_last_error; +} + +uint64_t oliphaunt_kotlin_capabilities(OliphauntKotlinSession *session) { + if (session == NULL || session->symbols.capabilities == NULL) { + return 0; + } + return session->symbols.capabilities(); +} + +void oliphaunt_kotlin_free_response(OliphauntKotlinSession *session, OliphauntResponse *response) { + if (session == NULL || response == NULL || session->symbols.free_response == NULL) { + return; + } + session->symbols.free_response(response); +} + +static int remove_tree_entry(const char *path, const struct stat *statbuf, int typeflag, struct FTW *ftwbuf) { + (void)statbuf; + (void)typeflag; + (void)ftwbuf; + return remove(path); +} + +int32_t oliphaunt_kotlin_remove_tree(const char *path) { + if (path == NULL || path[0] == '\0') { + return -1; + } + return nftw(path, remove_tree_entry, 64, FTW_DEPTH | FTW_PHYS); +} diff --git a/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.h b/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.h new file mode 100644 index 00000000..977e75ff --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/nativeInterop/cinterop/oliphaunt_kotlin_bridge.h @@ -0,0 +1,31 @@ +#ifndef OLIPHAUNT_KOTLIN_BRIDGE_H +#define OLIPHAUNT_KOTLIN_BRIDGE_H + +#include "oliphaunt.h" + +typedef struct OliphauntKotlinSession OliphauntKotlinSession; + +OliphauntKotlinSession *oliphaunt_kotlin_open( + const char *library_path, + const OliphauntConfig *config); +int32_t oliphaunt_kotlin_exec_protocol( + OliphauntKotlinSession *session, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +int32_t oliphaunt_kotlin_exec_protocol_stream( + OliphauntKotlinSession *session, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +int32_t oliphaunt_kotlin_backup(OliphauntKotlinSession *session, uint32_t format, OliphauntResponse *out); +int32_t oliphaunt_kotlin_restore(const char *library_path, const OliphauntRestoreOptions *options); +int32_t oliphaunt_kotlin_cancel(OliphauntKotlinSession *session); +int32_t oliphaunt_kotlin_close(OliphauntKotlinSession *session); +const char *oliphaunt_kotlin_last_error(OliphauntKotlinSession *session); +uint64_t oliphaunt_kotlin_capabilities(OliphauntKotlinSession *session); +void oliphaunt_kotlin_free_response(OliphauntKotlinSession *session, OliphauntResponse *response); +int32_t oliphaunt_kotlin_remove_tree(const char *path); + +#endif diff --git a/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/DefaultEngine.kt b/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/DefaultEngine.kt new file mode 100644 index 00000000..220eaa9d --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/DefaultEngine.kt @@ -0,0 +1,9 @@ +package dev.oliphaunt + +public actual fun defaultOliphauntEngine(mode: EngineMode): OliphauntEngine = when (mode) { + EngineMode.NativeDirect -> NativeDirectEngine() + + EngineMode.NativeBroker, + EngineMode.NativeServer, + -> RuntimeUnavailableEngine() +} diff --git a/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt b/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt new file mode 100644 index 00000000..5407c276 --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt @@ -0,0 +1,458 @@ +@file:OptIn( + kotlinx.cinterop.ExperimentalForeignApi::class, + kotlinx.coroutines.DelicateCoroutinesApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) + +package dev.oliphaunt + +import cnames.structs.OliphauntKotlinSession +import dev.oliphaunt.native.c.OLIPHAUNT_ABI_VERSION +import dev.oliphaunt.native.c.OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_BACKUP_RESTORE +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_EXTENSIONS +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_LOGICAL_REOPEN +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_MULTI_INSTANCE +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_PROTOCOL_RAW +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_PROTOCOL_STREAM +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_QUERY_CANCEL +import dev.oliphaunt.native.c.OLIPHAUNT_CAP_SIMPLE_QUERY +import dev.oliphaunt.native.c.OLIPHAUNT_RESTORE_REPLACE_EXISTING +import dev.oliphaunt.native.c.OliphauntResponse +import dev.oliphaunt.native.c.OliphauntRestoreOptions +import dev.oliphaunt.native.c.oliphaunt_kotlin_backup +import dev.oliphaunt.native.c.oliphaunt_kotlin_cancel +import dev.oliphaunt.native.c.oliphaunt_kotlin_capabilities +import dev.oliphaunt.native.c.oliphaunt_kotlin_close +import dev.oliphaunt.native.c.oliphaunt_kotlin_exec_protocol +import dev.oliphaunt.native.c.oliphaunt_kotlin_exec_protocol_stream +import dev.oliphaunt.native.c.oliphaunt_kotlin_free_response +import dev.oliphaunt.native.c.oliphaunt_kotlin_last_error +import dev.oliphaunt.native.c.oliphaunt_kotlin_open +import dev.oliphaunt.native.c.oliphaunt_kotlin_remove_tree +import dev.oliphaunt.native.c.oliphaunt_kotlin_restore +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.CPointerVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.StableRef +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.asStableRef +import kotlinx.cinterop.convert +import kotlinx.cinterop.cstr +import kotlinx.cinterop.get +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.set +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.CloseableCoroutineDispatcher +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import platform.posix.getenv +import platform.posix.getpid +import platform.posix.mkdir +import kotlin.random.Random +import dev.oliphaunt.native.c.OliphauntConfig as NativeOliphauntConfig + +public class NativeDirectEngine( + private val libraryPath: String? = null, + private val runtimeDirectory: String? = null, + private val username: String = "postgres", + private val database: String = "postgres", +) : OliphauntEngine { + override fun supportedModes(): List = OliphauntRuntimeSupport.nativeDirectOnly( + brokerReason = "Kotlin/Native broker mode requires a platform broker adapter; it is not aliased to direct mode", + serverReason = "Kotlin/Native server mode requires a platform server adapter; it is not aliased to direct mode", + ) + + override suspend fun open(config: OliphauntConfig): OliphauntSession { + if (config.mode != EngineMode.NativeDirect) { + throw OliphauntException("NativeDirectEngine supports NativeDirect, got ${config.mode}") + } + validateRootPath(config.root, "database root") + validateStartupIdentity(config.username ?: username, "username") + validateStartupIdentity(config.database ?: database, "database") + validateStartupGucs(config.startupGucs) + validateExtensionIds(config.extensions) + val resolvedRuntimeDirectory = + runtimeDirectory + ?: env("OLIPHAUNT_INSTALL_DIR") + ?: env("OLIPHAUNT_RUNTIME_DIR") + ?: "" + if (config.extensions.isNotEmpty() && resolvedRuntimeDirectory.isEmpty()) { + throw OliphauntException( + "Kotlin native-direct extensions require runtimeDirectory pointing at a liboliphaunt runtime built with the selected extensions", + ) + } + + val root = config.root ?: temporaryRoot() + val pgdata = "$root/pgdata" + ensureDirectory(root) + ensureDirectory(pgdata) + val ownerDispatcher = newSingleThreadContext("oliphaunt-native-owner") + val session: CPointer = + try { + withContext(ownerDispatcher) { + memScoped { + val startupArgs = config.postgresStartupArgs() + val effectiveUsername = config.username ?: username + val effectiveDatabase = config.database ?: database + val startupArgPointers = allocArray>(startupArgs.size) + startupArgs.forEachIndexed { index, arg -> + startupArgPointers[index] = arg.cstr.getPointer(this) + } + val nativeConfig = + alloc { + abi_version = OLIPHAUNT_ABI_VERSION + this.pgdata = pgdata.cstr.getPointer(this@memScoped) + runtime_dir = resolvedRuntimeDirectory.cstr.getPointer(this@memScoped) + this.username = effectiveUsername.cstr.getPointer(this@memScoped) + database = effectiveDatabase.cstr.getPointer(this@memScoped) + reserved_flags = 0u + startup_args = startupArgPointers + startup_arg_count = startupArgs.size.convert() + } + val resolvedLibrary = libraryPath ?: env("OLIPHAUNT_KOTLIN_LIBRARY") ?: env("LIBOLIPHAUNT_PATH") + oliphaunt_kotlin_open( + resolvedLibrary, + nativeConfig.ptr, + ) ?: run { + if (config.root == null) { + removeDirectoryBestEffort(root) + } + throw OliphauntException(lastError(null)) + } + } + } + } catch (error: Throwable) { + ownerDispatcher.close() + throw error + } + return NativeDirectSession( + session = session, + ownerDispatcher = ownerDispatcher, + ) + } + + override suspend fun restore(request: RestoreRequest): String { + validateRootPath(request.root, "restore root") + if (request.artifact.format != BackupFormat.PhysicalArchive) { + throw OliphauntException("Kotlin native restore currently requires PhysicalArchive, got ${request.artifact.format}") + } + val resolvedLibrary = libraryPath ?: env("OLIPHAUNT_KOTLIN_LIBRARY") ?: env("LIBOLIPHAUNT_PATH") + val flags = + if (request.targetPolicy == RestoreTargetPolicy.ReplaceExisting) { + OLIPHAUNT_RESTORE_REPLACE_EXISTING + } else { + 0uL + } + val rc = + memScoped { + request.artifact.bytes.usePinned { pinned -> + val options = + alloc { + abi_version = OLIPHAUNT_ABI_VERSION + root = request.root.cstr.getPointer(this@memScoped) + format = OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE + data = + if (request.artifact.bytes.isEmpty()) { + null + } else { + pinned.addressOf(0).reinterpret() + } + len = + request.artifact.bytes.size + .convert() + this.flags = flags + } + oliphaunt_kotlin_restore(resolvedLibrary, options.ptr) + } + } + if (rc != 0) { + throw OliphauntException(lastError(null)) + } + return request.root + } +} + +private class NativeDirectSession( + private var session: CPointer?, + private val ownerDispatcher: CloseableCoroutineDispatcher, +) : OliphauntSession { + private val executionMutex = Mutex() + private val stateMutex = Mutex() + + override suspend fun capabilities(): EngineCapabilities { + val flags = + withContext(ownerDispatcher) { + executionMutex.withLock { + val current = stateMutex.withLock { session ?: throw OliphauntException("database is closed") } + oliphaunt_kotlin_capabilities(current) + } + } + return nativeDirectCapabilities(flags) + } + + override suspend fun execProtocolRaw(request: ProtocolRequest): ProtocolResponse = withContext(ownerDispatcher) { + executionMutex.withLock { + val current = stateMutex.withLock { session ?: throw OliphauntException("database is closed") } + memScoped { + val response = + alloc { + data = null + len = 0u + } + val rc = + request.bytes.usePinned { pinned -> + val requestPtr = + if (request.bytes.isEmpty()) { + null + } else { + pinned.addressOf(0).reinterpret() + } + oliphaunt_kotlin_exec_protocol( + current, + requestPtr, + request.bytes.size.convert(), + response.ptr, + ) + } + if (rc != 0) { + throw OliphauntException(lastError(current)) + } + try { + val responseData = response.data + if (responseData == null || response.len == 0uL) { + ProtocolResponse(ByteArray(0)) + } else { + ProtocolResponse(responseData.readBytes(response.len.toInt())) + } + } finally { + oliphaunt_kotlin_free_response(current, response.ptr) + } + } + } + } + + override suspend fun execProtocolStream( + request: ProtocolRequest, + onChunk: (ProtocolResponse) -> Unit, + ) { + withContext(ownerDispatcher) { + executionMutex.withLock { + val current = stateMutex.withLock { session ?: throw OliphauntException("database is closed") } + val callbackBox = NativeStreamCallbackBox(onChunk) + val stableRef = StableRef.create(callbackBox) + try { + val rc = + request.bytes.usePinned { pinned -> + val requestPtr = + if (request.bytes.isEmpty()) { + null + } else { + pinned.addressOf(0).reinterpret() + } + oliphaunt_kotlin_exec_protocol_stream( + current, + requestPtr, + request.bytes.size.convert(), + nativeStreamCallback, + stableRef.asCPointer(), + ) + } + callbackBox.error?.let { throw it } + if (rc != 0) { + throw OliphauntException(lastError(current)) + } + } finally { + stableRef.dispose() + } + } + } + } + + override suspend fun backup(request: BackupRequest): BackupArtifact { + if (request.format != BackupFormat.PhysicalArchive) { + throw OliphauntException("Kotlin native-direct backup currently supports PhysicalArchive, got ${request.format}") + } + return withContext(ownerDispatcher) { + executionMutex.withLock { + val current = stateMutex.withLock { session ?: throw OliphauntException("database is closed") } + memScoped { + val response = + alloc { + data = null + len = 0u + } + val rc = + oliphaunt_kotlin_backup( + current, + OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE, + response.ptr, + ) + if (rc != 0) { + throw OliphauntException(lastError(current)) + } + try { + val responseData = response.data + val bytes = + if (responseData == null || response.len == 0uL) { + ByteArray(0) + } else { + responseData.readBytes(response.len.toInt()) + } + BackupArtifact(BackupFormat.PhysicalArchive, bytes) + } finally { + oliphaunt_kotlin_free_response(current, response.ptr) + } + } + } + } + } + + override suspend fun cancel() { + val (returnCode, current) = + stateMutex.withLock { + val current = session ?: throw OliphauntException("database is closed") + oliphaunt_kotlin_cancel(current) to current + } + if (returnCode != 0) { + throw OliphauntException(lastError(current)) + } + } + + override suspend fun close() { + val current = + stateMutex.withLock { + val current = session ?: return + session = null + current + } + val rc = + try { + withContext(ownerDispatcher) { + executionMutex.withLock { + oliphaunt_kotlin_close(current) + } + } + } finally { + ownerDispatcher.close() + } + if (rc != 0) { + throw OliphauntException(lastError(null)) + } + } +} + +private class NativeStreamCallbackBox( + val onChunk: (ProtocolResponse) -> Unit, +) { + var error: Throwable? = null +} + +private val nativeStreamCallback = + staticCFunction { + context: COpaquePointer?, + data: CPointer?, + len: ULong, + -> + val callbackBox = context?.asStableRef()?.get() ?: return@staticCFunction -1 + try { + val bytes = + if (data == null || len == 0uL) { + ByteArray(0) + } else { + data.reinterpret().readBytes(len.toInt()) + } + callbackBox.onChunk(ProtocolResponse(bytes)) + 0 + } catch (error: Throwable) { + callbackBox.error = error + -1 + } + } + +private fun nativeDirectCapabilities(flags: ULong): EngineCapabilities = EngineCapabilities( + mode = EngineMode.NativeDirect, + processIsolated = false, + independentSessions = false, + maxClientSessions = 1, + multiRoot = flags and OLIPHAUNT_CAP_MULTI_INSTANCE != 0uL, + reopenable = flags and OLIPHAUNT_CAP_LOGICAL_REOPEN != 0uL, + sameRootLogicalReopen = flags and OLIPHAUNT_CAP_LOGICAL_REOPEN != 0uL, + rootSwitchable = false, + crashRestartable = false, + protocolRaw = flags and OLIPHAUNT_CAP_PROTOCOL_RAW != 0uL, + protocolStream = flags and OLIPHAUNT_CAP_PROTOCOL_STREAM != 0uL, + queryCancel = flags and OLIPHAUNT_CAP_QUERY_CANCEL != 0uL, + backupRestore = flags and OLIPHAUNT_CAP_BACKUP_RESTORE != 0uL, + backupFormats = listOf(BackupFormat.PhysicalArchive), + restoreFormats = listOf(BackupFormat.PhysicalArchive), + simpleQuery = flags and OLIPHAUNT_CAP_SIMPLE_QUERY != 0uL, + extensions = flags and OLIPHAUNT_CAP_EXTENSIONS != 0uL, +) + +private fun lastError(session: CPointer?): String = oliphaunt_kotlin_last_error(session)?.toKString()?.takeIf(String::isNotEmpty) + ?: "unknown liboliphaunt Kotlin runtime error" + +private fun env(name: String): String? = getenv(name)?.toKString()?.takeIf(String::isNotEmpty) + +private fun validateExtensionIds(extensions: List) { + extensions + .map(String::trim) + .filter(String::isNotEmpty) + .forEach { extension -> + val valid = + extension.length <= 128 && + extension.all { char -> + char in 'A'..'Z' || + char in 'a'..'z' || + char in '0'..'9' || + char == '.' || + char == '_' || + char == '-' + } + if (!valid) { + throw OliphauntException( + "Kotlin native-direct extension id '$extension' must contain only ASCII letters, digits, '.', '_' or '-'", + ) + } + } +} + +private fun ensureDirectory(path: String) { + val parts = path.split('/').filter(String::isNotEmpty) + var current = if (path.startsWith('/')) "/" else "" + for (part in parts) { + current = + when { + current.isEmpty() -> part + current == "/" -> "/$part" + else -> "$current/$part" + } + mkdir(current, 0x1C0u) + } +} + +private fun temporaryRoot(): String = ProcessTemporaryRoot.path + +private object ProcessTemporaryRoot { + val path: String by lazy { + val base = env("TMPDIR") ?: "/tmp" + "$base/oliphaunt-direct-${getpid()}-${Random.nextInt()}" + } +} + +private fun removeDirectoryBestEffort(path: String) { + oliphaunt_kotlin_remove_tree(path) +} diff --git a/src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt b/src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt new file mode 100644 index 00000000..b113cd6b --- /dev/null +++ b/src/sdks/kotlin/oliphaunt/src/nativeTest/kotlin/dev/oliphaunt/NativeDirectEngineTest.kt @@ -0,0 +1,298 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.oliphaunt + +import kotlinx.cinterop.toKString +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import platform.posix.F_OK +import platform.posix.access +import platform.posix.getenv +import platform.posix.getpid +import platform.posix.usleep +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class NativeDirectEngineTest { + @Test + fun reportsMissingLiboliphauntLibrary() = runTest { + val engine = + NativeDirectEngine( + libraryPath = "/tmp/oliphaunt-missing.dylib", + ) + + val error = + assertFailsWith { + OliphauntDatabase.open( + config = OliphauntConfig(mode = EngineMode.NativeDirect), + engine = engine, + ) + } + assertTrue(error.message.orEmpty().contains("failed to load liboliphaunt")) + } + + @Test + fun extensionsRequireExplicitRuntimeDirectory() = runTest { + if (env("OLIPHAUNT_INSTALL_DIR") != null || env("OLIPHAUNT_RUNTIME_DIR") != null) { + return@runTest + } + val engine = + NativeDirectEngine( + libraryPath = "/tmp/oliphaunt-missing.dylib", + ) + + val error = + assertFailsWith { + OliphauntDatabase.open( + config = + OliphauntConfig( + mode = EngineMode.NativeDirect, + extensions = listOf("vector"), + ), + engine = engine, + ) + } + assertTrue(error.message.orEmpty().contains("extensions require runtimeDirectory")) + } + + @Test + fun extensionIdsMustBePortable() = runTest { + val engine = + NativeDirectEngine( + libraryPath = "/tmp/oliphaunt-missing.dylib", + runtimeDirectory = "/tmp/oliphaunt-runtime", + ) + + val error = + assertFailsWith { + OliphauntDatabase.open( + config = + OliphauntConfig( + mode = EngineMode.NativeDirect, + extensions = listOf("mobile/vector"), + ), + engine = engine, + ) + } + assertTrue(error.message.orEmpty().contains("must contain only ASCII")) + } + + @Test + fun extensionsUseExplicitRuntimeDirectory() = runTest { + val engine = + NativeDirectEngine( + libraryPath = "/tmp/oliphaunt-missing.dylib", + runtimeDirectory = "/tmp/oliphaunt-runtime", + ) + + val error = + assertFailsWith { + OliphauntDatabase.open( + config = + OliphauntConfig( + mode = EngineMode.NativeDirect, + extensions = listOf("vector"), + ), + engine = engine, + ) + } + assertTrue(error.message.orEmpty().contains("failed to load liboliphaunt")) + } + + @Test + fun commonSupportedModesExposeNativeDirectDefault() { + val support = OliphauntDatabase.supportedModes() + + assertEquals( + listOf(EngineMode.NativeDirect, EngineMode.NativeBroker, EngineMode.NativeServer), + support.map { it.mode }, + ) + assertTrue(support[0].available) + assertEquals(1, support[0].capabilities.maxClientSessions) + assertFalse(support[0].capabilities.multiRoot) + assertTrue(support[0].capabilities.sameRootLogicalReopen) + assertFalse(support[0].capabilities.rootSwitchable) + assertFalse(support[0].capabilities.crashRestartable) + assertTrue(support[0].capabilities.supportsBackupFormat(BackupFormat.PhysicalArchive)) + assertTrue(support[1].capabilities.multiRoot) + assertTrue(support[1].capabilities.rootSwitchable) + assertTrue(support[1].capabilities.crashRestartable) + assertTrue(support[1].unavailableReason.orEmpty().contains("broker")) + assertFalse(support[2].capabilities.multiRoot) + assertTrue(support[2].capabilities.rootSwitchable) + assertFalse(support[2].capabilities.crashRestartable) + assertTrue(support[2].unavailableReason.orEmpty().contains("server")) + } + + @Test + fun executesAgainstLinkedLiboliphauntWhenAvailable() = runBlocking { + val library = env("LIBOLIPHAUNT_PATH") ?: return@runBlocking + val runtime = env("OLIPHAUNT_INSTALL_DIR") ?: return@runBlocking + withTimeout(90.seconds) { + val engine = + NativeDirectEngine( + libraryPath = library, + runtimeDirectory = runtime, + ) + val config = + OliphauntConfig( + mode = EngineMode.NativeDirect, + root = nativeTestRoot("oliphaunt-direct"), + durability = DurabilityProfile.FastDev, + ) + val database = + OliphauntDatabase.open( + config = config, + engine = engine, + ) + + try { + val capabilities = database.capabilities() + assertTrue(capabilities.protocolRaw) + assertTrue(capabilities.protocolStream) + assertTrue(capabilities.queryCancel) + assertTrue(capabilities.backupRestore) + assertTrue(capabilities.simpleQuery) + + val response = database.execProtocolRaw(ProtocolRequest.simpleQuery("SELECT 1 AS value")) + assertTrue(response.bytes.containsTag(0x54), "missing RowDescription") + assertTrue(response.bytes.containsTag(0x44), "missing DataRow") + assertTrue(response.bytes.containsTag(0x5A), "missing ReadyForQuery") + + // liboliphaunt-doc-example:kotlin-streaming + val streamed = mutableListOf() + database.execProtocolStream(ProtocolRequest.simpleQuery("SELECT 1 AS streamed_value")) { chunk -> + streamed += chunk + } + val streamedBytes = streamed.flatMap { chunk -> chunk.bytes.asIterable() }.toByteArray() + assertTrue(streamedBytes.containsTag(0x54), "missing streamed RowDescription") + assertTrue(streamedBytes.containsTag(0x44), "missing streamed DataRow") + assertTrue(streamedBytes.containsTag(0x5A), "missing streamed ReadyForQuery") + + val typed = database.query("SELECT 1::text AS value") + assertEquals("1", typed.getText(0, "value")) + + val parameterized = + database.query( + "SELECT \$1::text AS value", + listOf(QueryParam.Text("1")), + ) + assertEquals("1", parameterized.getText(0, "value")) + + database.execProtocolRaw( + ProtocolRequest.simpleQuery( + "CREATE TABLE IF NOT EXISTS kotlin_backup_smoke(value integer); " + + "TRUNCATE kotlin_backup_smoke; " + + "INSERT INTO kotlin_backup_smoke VALUES (42)", + ), + ) + val archive = database.backup() + assertEquals(BackupFormat.PhysicalArchive, archive.format) + assertTrue(archive.bytes.containsAscii("backup_label"), "missing backup_label in physical archive") + + val restoredRoot = "${env("TMPDIR") ?: "/tmp"}/oliphaunt-restore-${getpid()}" + val restored = + OliphauntDatabase.restore( + RestoreRequest( + artifact = archive, + root = restoredRoot, + ).replaceExisting(), + ) + assertEquals(restoredRoot, restored) + assertTrue(fileExists("$restoredRoot/pgdata/PG_VERSION"), "missing restored PG_VERSION") + assertTrue(fileExists("$restoredRoot/pgdata/backup_label"), "missing restored backup_label") + + database.close() + + val session = engine.open(config) + try { + val started = CompletableDeferred() + val query = + async(Dispatchers.Default) { + started.complete(Unit) + session.execProtocolRaw(ProtocolRequest.simpleQuery("SELECT pg_sleep(0.1) AS should_finish")) + } + started.await() + usleep(25_000u) + session.close() + val response = query.await() + assertFalse(response.bytes.containsTag(0x45), "close must not cancel active protocol work") + assertTrue(response.bytes.containsTag(0x5A), "missing ReadyForQuery after close waits") + } finally { + runCatching { + session.close() + } + } + + val reopened = + OliphauntDatabase.open( + config = config, + engine = engine, + ) + try { + val cancelled = + async(Dispatchers.Default) { + reopened.execProtocolRaw(ProtocolRequest.simpleQuery("SELECT pg_sleep(5) AS should_cancel")) + } + usleep(100_000u) + reopened.cancel() + val cancelledResponse = cancelled.await() + assertTrue(cancelledResponse.bytes.containsTag(0x45), "missing ErrorResponse after cancel") + assertTrue(cancelledResponse.bytes.containsTag(0x5A), "missing ReadyForQuery after cancel") + + val response = reopened.execProtocolRaw(ProtocolRequest.simpleQuery("SELECT 42 AS reopened")) + assertTrue(response.bytes.containsTag(0x44), "missing DataRow after reopen") + assertTrue(response.bytes.containsTag(0x5A), "missing ReadyForQuery after reopen") + } finally { + reopened.close() + } + } finally { + database.close() + } + } + } +} + +private fun ByteArray.containsTag(tag: Int): Boolean { + var offset = 0 + while (offset + 5 <= size) { + val messageTag = this[offset].toInt() and 0xff + val length = + ((this[offset + 1].toInt() and 0xff) shl 24) or + ((this[offset + 2].toInt() and 0xff) shl 16) or + ((this[offset + 3].toInt() and 0xff) shl 8) or + (this[offset + 4].toInt() and 0xff) + if (length < 4 || offset + 1 + length > size) { + return false + } + if (messageTag == tag) { + return true + } + offset += 1 + length + } + return false +} + +private fun ByteArray.containsAscii(needle: String): Boolean { + val bytes = needle.encodeToByteArray() + if (bytes.isEmpty()) { + return true + } + return indices.any { start -> + start + bytes.size <= size && bytes.indices.all { offset -> this[start + offset] == bytes[offset] } + } +} + +private fun fileExists(path: String): Boolean = access(path, F_OK) == 0 + +private fun nativeTestRoot(name: String): String = "${env("TMPDIR") ?: "/tmp"}/$name-${getpid()}" + +private fun env(name: String): String? = getenv(name)?.toKString()?.takeIf(String::isNotEmpty) diff --git a/src/sdks/kotlin/release.toml b/src/sdks/kotlin/release.toml new file mode 100644 index 00000000..88578e98 --- /dev/null +++ b/src/sdks/kotlin/release.toml @@ -0,0 +1,18 @@ +id = "oliphaunt-kotlin" +owner = "@oliphaunt/sdk-android" +kind = "sdk" +publish_targets = ["maven-central"] +registry_packages = [ + "maven:dev.oliphaunt:oliphaunt", + "maven:dev.oliphaunt:oliphaunt-android-gradle-plugin", + "maven:dev.oliphaunt.android:dev.oliphaunt.android.gradle.plugin", +] +release_artifacts = [ + "gradle-module-metadata", + "android-adapter-aar", + "android-consumer-gradle-plugin", + "android-consumer-gradle-plugin-marker", + "maven-publication", + "runtime-assets-external", +] +derived_version_files = ["src/sdks/kotlin/oliphaunt/build.gradle.kts", "src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts"] diff --git a/src/sdks/kotlin/settings.gradle.kts b/src/sdks/kotlin/settings.gradle.kts new file mode 100644 index 00000000..9a13dbc1 --- /dev/null +++ b/src/sdks/kotlin/settings.gradle.kts @@ -0,0 +1,33 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +val oliphauntBuildRoot = + providers.gradleProperty("oliphauntBuildRoot") + .orElse(providers.environmentVariable("OLIPHAUNT_GRADLE_BUILD_ROOT")) + .orNull + ?.takeIf(String::isNotBlank) + +if (oliphauntBuildRoot != null) { + val buildRoot = file(oliphauntBuildRoot) + gradle.beforeProject { + val slug = if (path == ":") "root" else path.removePrefix(":").replace(':', '/') + layout.buildDirectory.set(buildRoot.resolve(slug)) + } +} + +rootProject.name = "oliphaunt-kotlin" +include(":oliphaunt") +include(":oliphaunt-android-gradle-plugin") diff --git a/src/sdks/kotlin/tools/check-sdk.sh b/src/sdks/kotlin/tools/check-sdk.sh new file mode 100755 index 00000000..025c3f90 --- /dev/null +++ b/src/sdks/kotlin/tools/check-sdk.sh @@ -0,0 +1,718 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +. "$root/src/sdks/react-native/tools/android-smoke-artifacts.sh" +. "$root/tools/runtime/preflight.sh" + +project_dir="src/sdks/kotlin" +scratch_root_base="${OLIPHAUNT_SDK_CHECK_SCRATCH:-$root/target/liboliphaunt-sdk-check/oliphaunt-kotlin}" +mode="${1:-release-check}" + +case "$mode" in + check-static|test-unit|package-shape|smoke-runtime|regression|coverage|release-check) + ;; + "") + mode="release-check" + ;; + *) + echo "usage: src/sdks/kotlin/tools/check-sdk.sh [check-static|test-unit|package-shape|smoke-runtime|regression|coverage|release-check]" >&2 + exit 2 + ;; +esac + +scratch_root="$scratch_root_base/$mode" + +if [ -z "${ANDROID_HOME:-}" ] && [ -d "$HOME/Library/Android/sdk" ]; then + export ANDROID_HOME="$HOME/Library/Android/sdk" +fi +if [ -n "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then + export ANDROID_SDK_ROOT="$ANDROID_HOME" +fi + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +if [ "$mode" = "coverage" ]; then + exec tools/coverage/run-product oliphaunt-kotlin +fi + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require jar + +require_manifest_line() { + manifest="$1" + expected="$2" + message="$3" + if ! grep -Fxq "$expected" "$manifest"; then + echo "$message" >&2 + echo "expected '$expected' in $manifest" >&2 + exit 1 + fi +} + +require_jar_entry() { + jar_file="$1" + entry="$2" + message="$3" + if [ ! -f "$jar_file" ]; then + echo "missing Kotlin package artifact: $jar_file" >&2 + exit 1 + fi + if ! jar tf "$jar_file" | grep -Fxq "$entry"; then + echo "$message" >&2 + echo "expected $entry in $jar_file" >&2 + exit 1 + fi +} + +require_jar_entry_pattern() { + jar_file="$1" + pattern="$2" + message="$3" + if [ ! -f "$jar_file" ]; then + echo "missing Kotlin package artifact: $jar_file" >&2 + exit 1 + fi + if ! jar tf "$jar_file" | grep -Eq "$pattern"; then + echo "$message" >&2 + echo "expected pattern $pattern in $jar_file" >&2 + exit 1 + fi +} + +kotlin_package_version() { + version="$(sed -n 's/^VERSION_NAME=//p' "$project_dir/gradle.properties" | tail -n 1)" + if [ -z "$version" ]; then + echo "missing VERSION_NAME in $project_dir/gradle.properties" >&2 + exit 1 + fi + printf '%s\n' "$version" +} + +reject_jar_entry_pattern() { + jar_file="$1" + pattern="$2" + message="$3" + if [ ! -f "$jar_file" ]; then + echo "missing Kotlin package artifact: $jar_file" >&2 + exit 1 + fi + if jar tf "$jar_file" | grep -Eq "$pattern"; then + echo "$message" >&2 + echo "unexpected pattern $pattern in $jar_file" >&2 + exit 1 + fi +} + +prepare_scratch_dir() { + dir="$scratch_root/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s\n' "$dir" +} + +gradle_cmd="gradle" +if [ -x "$project_dir/gradlew" ]; then + gradle_cmd="$root/$project_dir/gradlew" +else + require gradle +fi +case "${OLIPHAUNT_GRADLE_CONFIGURATION_CACHE:-1}" in + 1|true|TRUE|yes|YES) + gradle_cache_args="--configuration-cache" + ;; + 0|false|FALSE|no|NO) + gradle_cache_args="" + ;; + *) + echo "OLIPHAUNT_GRADLE_CONFIGURATION_CACHE must be 0 or 1" >&2 + exit 2 + ;; +esac +case "${OLIPHAUNT_GRADLE_SMOKE_CONFIGURATION_CACHE:-0}" in + 1|true|TRUE|yes|YES) + gradle_smoke_cache_args="--configuration-cache" + ;; + 0|false|FALSE|no|NO) + gradle_smoke_cache_args="--no-configuration-cache" + ;; + *) + echo "OLIPHAUNT_GRADLE_SMOKE_CONFIGURATION_CACHE must be 0 or 1" >&2 + exit 2 + ;; +esac + +default_android_abi_filter() { + machine="$(uname -m 2>/dev/null || true)" + case "$machine" in + arm64|aarch64) + printf '%s\n' arm64-v8a + ;; + *) + printf '%s\n' x86_64 + ;; + esac +} + +normalize_android_abi_filters() { + raw="$1" + case "$raw" in + ""|all|ALL|All) + return 0 + ;; + auto|AUTO|Auto) + default_android_abi_filter + return 0 + ;; + esac + normalized="" + old_ifs="$IFS" + IFS="," + # shellcheck disable=SC2086 + set -- $raw + IFS="$old_ifs" + for abi in "$@"; do + abi="$(printf '%s\n' "$abi" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -n "$abi" ] || continue + case "$abi" in + arm64-v8a|armeabi-v7a|x86|x86_64) + case ",$normalized," in + *",$abi,"*) + ;; + *) + if [ -n "$normalized" ]; then + normalized="$normalized,$abi" + else + normalized="$abi" + fi + ;; + esac + ;; + *) + echo "unsupported OLIPHAUNT_KOTLIN_ANDROID_ABI_FILTERS value: $abi" >&2 + echo "expected comma-separated Android ABIs from: arm64-v8a, armeabi-v7a, x86, x86_64, or all" >&2 + exit 2 + ;; + esac + done + printf '%s\n' "$normalized" +} + +android_abi_filters="$(normalize_android_abi_filters "${OLIPHAUNT_KOTLIN_ANDROID_ABI_FILTERS:-${OLIPHAUNT_ANDROID_ABI_FILTERS:-auto}}")" +android_abi_gradle_args="" +if [ -n "$android_abi_filters" ]; then + android_abi_gradle_args="-PoliphauntAndroidAbiFilters=$android_abi_filters" +fi +android_smoke_abi="${android_abi_filters%%,*}" +if [ -z "$android_smoke_abi" ]; then + android_smoke_abi="$(default_android_abi_filter)" +fi +gradle_build_root="$scratch_root/gradle/oliphaunt-kotlin" +gradle_project_cache="$scratch_root/gradle-cache/oliphaunt-kotlin" +gradle_cxx_root="$scratch_root/cxx/oliphaunt-kotlin" +gradle_project_cache_source_stamp="$scratch_root/gradle-cache/project-source-root" +expected_gradle_project_source="$root/$project_dir" +if [ -d "$gradle_project_cache" ]; then + if [ ! -f "$gradle_project_cache_source_stamp" ] || + [ "$(cat "$gradle_project_cache_source_stamp")" != "$expected_gradle_project_source" ]; then + rm -rf "$gradle_project_cache" + fi +fi +mkdir -p "$(dirname "$gradle_project_cache_source_stamp")" +printf '%s\n' "$expected_gradle_project_source" >"$gradle_project_cache_source_stamp" +gradle_scratch_args="-PoliphauntBuildRoot=$gradle_build_root -PoliphauntCxxBuildRoot=$gradle_cxx_root --project-cache-dir $gradle_project_cache" +gradle_non_coverage_args="-x :oliphaunt:koverVerify" +kotlin_build_dir="$gradle_build_root/oliphaunt" + +host_native_suffix() { + case "$(uname -s):$(uname -m)" in + Darwin:*) + printf '%s\n' MacosArm64 + ;; + Linux:arm64|Linux:aarch64) + printf '%s\n' LinuxArm64 + ;; + *) + printf '%s\n' LinuxX64 + ;; + esac +} + +host_native_compile_task() { + printf ':oliphaunt:compileKotlin%s\n' "$(host_native_suffix)" +} + +host_native_test_task() { + first="$(host_native_suffix | cut -c1 | tr '[:upper:]' '[:lower:]')" + rest="$(host_native_suffix | cut -c2-)" + printf ':oliphaunt:%s%sTest\n' "$first" "$rest" +} + +run_without_linked_native_runtime() { + env \ + -u LIBOLIPHAUNT_PATH \ + -u OLIPHAUNT_INSTALL_DIR \ + -u OLIPHAUNT_RUNTIME_DIR \ + -u OLIPHAUNT_KOTLIN_REQUIRE_NATIVE \ + "$@" +} + +run_android_runtime_smoke() { + if [ -z "${ANDROID_HOME:-}" ]; then + echo "Kotlin Android smoke requires ANDROID_HOME" >&2 + exit 1 + fi + + tmp_assets="$(prepare_scratch_dir kotlin-runtime-resources)" + tmp_static_jni="$(prepare_scratch_dir kotlin-static-jni)" + mkdir -p \ + "$tmp_assets/oliphaunt/runtime/files/share/postgresql/extension" \ + "$tmp_assets/oliphaunt/static-registry" \ + "$tmp_assets/oliphaunt/template-pgdata/files/base" + printf 'runtime smoke\n' >"$tmp_assets/oliphaunt/runtime/files/share/postgresql/README.liboliphaunt-smoke" + printf "comment = 'vector smoke control'\n" >"$tmp_assets/oliphaunt/runtime/files/share/postgresql/extension/vector.control" + printf "select 'vector smoke sql';\n" >"$tmp_assets/oliphaunt/runtime/files/share/postgresql/extension/vector--1.0.sql" + printf '/* static registry smoke */\n' >"$tmp_assets/oliphaunt/static-registry/oliphaunt_static_registry.c" + cat >"$tmp_assets/oliphaunt/static-registry/manifest.properties" <"$tmp_assets/oliphaunt/template-pgdata/files/PG_VERSION" + printf 'template smoke\n' >"$tmp_assets/oliphaunt/template-pgdata/files/base/README.liboliphaunt-smoke" + cat >"$tmp_assets/oliphaunt/runtime/manifest.properties" <<'MANIFEST' +schema=oliphaunt-runtime-resources-v1 +cacheKey=runtime-smoke +layout=postgres-runtime-files-v1 +extensions=vector +sharedPreloadLibraries= +mobileStaticRegistryState=complete +mobileStaticRegistryRegistered=vector +mobileStaticRegistryPending= +nativeModuleStems=vector +mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c +MANIFEST + cat >"$tmp_assets/oliphaunt/template-pgdata/manifest.properties" <<'MANIFEST' +schema=oliphaunt-runtime-resources-v1 +cacheKey=template-smoke +layout=postgres-template-pgdata-v1 +extensions= +sharedPreloadLibraries= +mobileStaticRegistryState=not-required +mobileStaticRegistryRegistered= +mobileStaticRegistryPending= +nativeModuleStems= +mobileStaticRegistrySource= +MANIFEST + cat >"$tmp_assets/oliphaunt/package-size.tsv" <<'REPORT' +kind id extensions files bytes +package total - - 185 +package runtime - - 100 +package template-pgdata - - 40 +package static-registry - - 45 +extensions selected - - 30 +extension vector - 3 30 +REPORT + + run "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeResourcesDir=$tmp_assets" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + generated="$kotlin_build_dir/generated/oliphaunt-android-assets" + if [ ! -f "$generated/oliphaunt/runtime/files/share/postgresql/README.liboliphaunt-smoke" ]; then + echo "Kotlin Android generated assets did not include runtime-resources runtime files" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if [ ! -f "$generated/oliphaunt/runtime/files/share/postgresql/extension/vector.control" ]; then + echo "Kotlin Android generated assets did not include selected vector extension control file" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if [ -e "$generated/oliphaunt/runtime/files/share/postgresql/extension/hstore.control" ]; then + echo "Kotlin Android generated assets included unselected hstore extension control file" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if [ ! -f "$generated/oliphaunt/template-pgdata/files/PG_VERSION" ]; then + echo "Kotlin Android generated assets did not include runtime-resources template PGDATA" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if [ ! -f "$generated/oliphaunt/static-registry/oliphaunt_static_registry.c" ]; then + echo "Kotlin Android generated assets did not include runtime-resources static registry source" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if [ -e "$generated/oliphaunt/static-registry/archives" ]; then + echo "Kotlin Android generated assets included build-only static extension archives" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "extension vector - 3 30" "$generated/oliphaunt/package-size.tsv"; then + echo "Kotlin Android generated assets did not preserve runtime-resources size report" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "extensions=vector" "$generated/oliphaunt/runtime/manifest.properties"; then + echo "Kotlin Android generated runtime manifest did not preserve runtime-resources extensions" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "schema=oliphaunt-runtime-resources-v1" "$generated/oliphaunt/runtime/manifest.properties"; then + echo "Kotlin Android generated runtime manifest did not preserve runtime-resources layout schema" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "layout=postgres-runtime-files-v1" "$generated/oliphaunt/runtime/manifest.properties"; then + echo "Kotlin Android generated runtime manifest did not preserve runtime resources layout" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "mobileStaticRegistryState=complete" "$generated/oliphaunt/runtime/manifest.properties"; then + echo "Kotlin Android generated runtime manifest did not preserve mobile static-registry state" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "sharedPreloadLibraries=" "$generated/oliphaunt/runtime/manifest.properties"; then + echo "Kotlin Android generated runtime manifest did not preserve shared preload metadata" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c" "$generated/oliphaunt/runtime/manifest.properties"; then + echo "Kotlin Android generated runtime manifest did not preserve mobile static-registry source" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + run "$gradle_cmd" -p "$project_dir" :oliphaunt:bundleDebugAar \ + "-PoliphauntRuntimeResourcesDir=$tmp_assets" \ + "-PoliphauntAndroidJniLibsDir=$tmp_static_jni" \ + "-PoliphauntAndroidAbiFilters=$android_smoke_abi" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + static_asset_aar="$kotlin_build_dir/outputs/aar/oliphaunt-debug.aar" + require_jar_entry "$static_asset_aar" "jni/$android_smoke_abi/liboliphaunt.so" \ + "Kotlin Android smoke AAR must include the explicitly supplied liboliphaunt runtime for $android_smoke_abi" + if jar tf "$static_asset_aar" | grep -Fq "assets/oliphaunt/static-registry/archives/"; then + echo "Kotlin Android AAR included build-only static extension archives" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + rm -rf "$tmp_assets" "$tmp_static_jni" +} + +run_android_release_asset_fixture() { + liboliphaunt_version="$(cat src/runtimes/liboliphaunt/native/VERSION)" + fixture_assets="$(prepare_scratch_dir kotlin-liboliphaunt-release-assets)" + run python3 tools/test/create-liboliphaunt-release-fixture.py \ + --asset-dir "$fixture_assets" \ + --version "$liboliphaunt_version" + rm -rf \ + "$kotlin_build_dir/generated/oliphaunt-release-assets" \ + "$kotlin_build_dir/oliphaunt/release-asset-cache" + run "$gradle_cmd" -p "$project_dir" :oliphaunt:resolveOliphauntAndroidReleaseAssets \ + "-PoliphauntLiboliphauntVersion=$liboliphaunt_version" \ + "-PoliphauntAssetBaseUrl=file://$fixture_assets" \ + "-PoliphauntAndroidAbiFilters=arm64-v8a" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + resolved_release_root="$kotlin_build_dir/generated/oliphaunt-release-assets" + if [ ! -f "$resolved_release_root/runtime-resources/oliphaunt/runtime/manifest.properties" ]; then + echo "Kotlin Android release asset resolver did not unpack runtime resources" >&2 + exit 1 + fi + if [ ! -f "$resolved_release_root/jniLibs/arm64-v8a/liboliphaunt.so" ]; then + echo "Kotlin Android release asset resolver did not unpack the selected Android liboliphaunt runtime" >&2 + exit 1 + fi +} + +oliphaunt_runtime_native_host_export_defaults + +if [ -n "${OLIPHAUNT_KOTLIN_REQUIRE_NATIVE:-}" ]; then + if ! oliphaunt_runtime_native_host_ready basic; then + oliphaunt_runtime_native_host_diagnostics basic + exit 1 + fi +fi + +if [ "$mode" = "smoke-runtime" ]; then + run_android_runtime_smoke + exit 0 +fi + +if [ "$mode" = "check-static" ]; then + static_tasks=":oliphaunt:spotlessCheck :oliphaunt:detekt :oliphaunt:compileKotlinJvm :oliphaunt:compileDebugKotlinAndroid :oliphaunt:compileReleaseKotlinAndroid :oliphaunt-android-gradle-plugin:check $(host_native_compile_task)" + if [ -n "${ANDROID_HOME:-}" ]; then + static_tasks="$static_tasks :oliphaunt:lintDebug" + fi + # shellcheck disable=SC2086 + run "$gradle_cmd" -p "$project_dir" \ + $static_tasks \ + $android_abi_gradle_args \ + $gradle_scratch_args \ + $gradle_cache_args + exit 0 +fi + +if [ "$mode" = "test-unit" ]; then + unit_tasks=":oliphaunt:jvmTest :oliphaunt:testDebugUnitTest :oliphaunt:testReleaseUnitTest $(host_native_test_task)" + # shellcheck disable=SC2086 + run run_without_linked_native_runtime "$gradle_cmd" -p "$project_dir" \ + $unit_tasks \ + $gradle_non_coverage_args \ + $android_abi_gradle_args \ + $gradle_scratch_args \ + $gradle_cache_args + exit 0 +fi + +if [ "$mode" = "regression" ] || [ "$mode" = "release-check" ]; then + # Kover verification is owned by tools/coverage/run-product. Static/unit/package + # SDK checks should still compile and run tests, but must not enforce measured + # coverage thresholds as a side effect of Gradle's aggregate `check` task. + # shellcheck disable=SC2086 + run "$gradle_cmd" -p "$project_dir" check $gradle_non_coverage_args $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args + if [ "$mode" = "regression" ]; then + exit 0 + fi +fi + +run cmp src/runtimes/liboliphaunt/native/include/oliphaunt.h "$project_dir/oliphaunt/src/androidMain/cpp/include/oliphaunt.h" +run_android_release_asset_fixture +package_tasks=":oliphaunt:metadataSourcesJar :oliphaunt:allMetadataJar :oliphaunt:jvmJar :oliphaunt:jvmSourcesJar :oliphaunt:androidReleaseSourcesJar :oliphaunt:bundleReleaseAar" +if [ "$(uname -s)" = "Darwin" ]; then + package_tasks="$package_tasks :oliphaunt:macosArm64SourcesJar" +fi +# shellcheck disable=SC2086 +run "$gradle_cmd" -p "$project_dir" \ + $package_tasks \ + $android_abi_gradle_args \ + $gradle_scratch_args \ + $gradle_cache_args + +kotlin_libs="$kotlin_build_dir/libs" +kotlin_outputs="$kotlin_build_dir/outputs" +kotlin_version="$(kotlin_package_version)" +metadata_sources="$kotlin_libs/oliphaunt-metadata-$kotlin_version-sources.jar" +metadata_jar="$kotlin_libs/oliphaunt-metadata-$kotlin_version.jar" +jvm_jar="$kotlin_libs/oliphaunt-jvm-$kotlin_version.jar" +jvm_sources="$kotlin_libs/oliphaunt-jvm-$kotlin_version-sources.jar" +android_sources="$kotlin_libs/oliphaunt-android-$kotlin_version-sources.jar" +macos_sources="$kotlin_libs/oliphaunt-macosarm64-$kotlin_version-sources.jar" +android_release_aar="$kotlin_outputs/aar/oliphaunt-release.aar" + +require_jar_entry "$metadata_sources" "commonMain/dev/oliphaunt/Oliphaunt.kt" \ + "Kotlin metadata sources artifact must include the common SDK API" +require_jar_entry "$metadata_sources" "commonMain/dev/oliphaunt/Query.kt" \ + "Kotlin metadata sources artifact must include the common query helpers" +reject_jar_entry_pattern "$metadata_sources" '(^|/)commonTest/|(^|/)androidUnitTest/|(^|/)nativeTest/' \ + "Kotlin metadata sources artifact must not include test sources" + +require_jar_entry "$metadata_jar" "META-INF/kotlin-project-structure-metadata.json" \ + "Kotlin metadata artifact must include project-structure metadata" +require_jar_entry_pattern "$metadata_jar" '^commonMain/default/linkdata/package_dev\.oliphaunt/[0-9]+_oliphaunt\.knm$' \ + "Kotlin metadata artifact must include common dev.oliphaunt linkdata" + +require_jar_entry "$jvm_jar" "dev/oliphaunt/OliphauntDatabase.class" \ + "Kotlin JVM artifact must include the public SDK database class" +require_jar_entry "$jvm_jar" "dev/oliphaunt/RuntimeUnavailableEngine.class" \ + "Kotlin JVM artifact must preserve the explicit unavailable-runtime implementation" + +require_jar_entry "$jvm_sources" "jvmMain/dev/oliphaunt/DefaultEngine.kt" \ + "Kotlin JVM sources artifact must include the JVM runtime boundary" +require_jar_entry "$jvm_sources" "commonMain/dev/oliphaunt/Oliphaunt.kt" \ + "Kotlin JVM sources artifact must include the common SDK API" + +require_jar_entry "$android_sources" "androidMain/dev/oliphaunt/AndroidNativeDirectEngine.kt" \ + "Kotlin Android sources artifact must include the Android direct engine" +require_jar_entry "$android_sources" "androidMain/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt" \ + "Kotlin Android sources artifact must include Android runtime-resources handling" +require_jar_entry "$android_sources" "commonMain/dev/oliphaunt/Oliphaunt.kt" \ + "Kotlin Android sources artifact must include the common SDK API" +reject_jar_entry_pattern "$android_sources" 'androidMain/cpp/|nativeInterop/|(^|/)liboliphaunt\.so$' \ + "Kotlin Android sources artifact must not include native build outputs or bundled Oliphaunt runtime binaries" + +if [ "$(uname -s)" = "Darwin" ]; then + require_jar_entry "$macos_sources" "nativeMain/dev/oliphaunt/NativeDirectEngine.kt" \ + "Kotlin macOS/native sources artifact must include the native-direct engine" + require_jar_entry "$macos_sources" "commonMain/dev/oliphaunt/Oliphaunt.kt" \ + "Kotlin macOS/native sources artifact must include the common SDK API" +fi + +require_jar_entry "$android_release_aar" "classes.jar" \ + "Kotlin Android release AAR must include compiled classes" +if [ -n "$android_abi_filters" ]; then + old_ifs="$IFS" + IFS="," + # shellcheck disable=SC2086 + set -- $android_abi_filters + IFS="$old_ifs" + for abi in "$@"; do + require_jar_entry "$android_release_aar" "jni/$abi/liboliphaunt_kotlin_android.so" \ + "Kotlin Android release AAR must include the JNI adapter for selected ABI $abi" + done +else + require_jar_entry_pattern "$android_release_aar" '^jni/[^/]+/liboliphaunt_kotlin_android\.so$' \ + "Kotlin Android release AAR must include at least one JNI adapter binary" +fi +reject_jar_entry_pattern "$android_release_aar" '^jni/[^/]+/liboliphaunt\.so$' \ + "Kotlin Android default release AAR must not bundle the PostgreSQL runtime binary without an explicit packaged runtime input" + +if [ -n "${ANDROID_HOME:-}" ]; then + run_android_runtime_smoke + + tmp_split_runtime="$(prepare_scratch_dir kotlin-split-runtime)" + tmp_split_template="$(prepare_scratch_dir kotlin-split-template)" + mkdir -p \ + "$tmp_split_runtime/share/postgresql" \ + "$tmp_split_runtime/lib/postgresql" \ + "$tmp_split_template/base" + printf 'runtime split smoke\n' >"$tmp_split_runtime/share/postgresql/README.liboliphaunt-split-smoke" + printf '18\n' >"$tmp_split_template/PG_VERSION" + printf 'template split smoke\n' >"$tmp_split_template/base/README.liboliphaunt-split-smoke" + run "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=vector" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + generated="$kotlin_build_dir/generated/oliphaunt-android-assets" + split_runtime_manifest="$generated/oliphaunt/runtime/manifest.properties" + split_template_manifest="$generated/oliphaunt/template-pgdata/manifest.properties" + require_manifest_line "$split_runtime_manifest" "schema=oliphaunt-runtime-resources-v1" \ + "Kotlin Android split runtime manifest did not emit the shared runtime-resources schema" + require_manifest_line "$split_runtime_manifest" "layout=postgres-runtime-files-v1" \ + "Kotlin Android split runtime manifest did not emit the runtime resources layout" + require_manifest_line "$split_runtime_manifest" "extensions=vector" \ + "Kotlin Android split runtime manifest did not record selected vector extension" + require_manifest_line "$split_runtime_manifest" "sharedPreloadLibraries=" \ + "Kotlin Android split runtime manifest did not record shared preload libraries" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryState=pending" \ + "Kotlin Android split runtime manifest did not mark mobile static registry as pending" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryRegistered=" \ + "Kotlin Android split runtime manifest should not claim registered mobile static modules" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryPending=vector" \ + "Kotlin Android split runtime manifest did not record pending mobile static registry modules" + require_manifest_line "$split_runtime_manifest" "nativeModuleStems=vector" \ + "Kotlin Android split runtime manifest did not record expected native module stems" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistrySource=" \ + "Kotlin Android split runtime manifest should not claim generated mobile static-registry source" + require_manifest_line "$split_template_manifest" "mobileStaticRegistryState=not-required" \ + "Kotlin Android split template manifest should not require mobile static registry work" + require_manifest_line "$split_template_manifest" "mobileStaticRegistryPending=" \ + "Kotlin Android split template manifest should not list pending mobile static registry modules" + require_manifest_line "$split_template_manifest" "sharedPreloadLibraries=" \ + "Kotlin Android split template manifest should not list shared preload libraries" + require_manifest_line "$split_template_manifest" "nativeModuleStems=" \ + "Kotlin Android split template manifest should not list native module stems" + require_manifest_line "$split_template_manifest" "mobileStaticRegistrySource=" \ + "Kotlin Android split template manifest should not claim generated mobile static-registry source" + + split_static_log="$scratch_root/kotlin-split-static.log" + rm -f "$split_static_log" + printf '\n==> %s\n' "$gradle_cmd -p $project_dir :oliphaunt:prepareOliphauntAndroidAssets -PoliphauntMobileStaticModules=vector" + if "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=vector" \ + "-PoliphauntMobileStaticModules=vector" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args >"$split_static_log" 2>&1; then + echo "Kotlin Android split runtime packaging accepted a mobile static module declaration without generated registry source" >&2 + cat "$split_static_log" >&2 + rm -f "$split_static_log" + exit 1 + fi + if ! grep -Fq "split runtime packaging cannot declare mobile static module stems" "$split_static_log"; then + echo "Kotlin Android split runtime packaging failed without the expected static-registry diagnostic" >&2 + cat "$split_static_log" >&2 + rm -f "$split_static_log" + exit 1 + fi + rm -f "$split_static_log" + + run "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=earthdistance" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + require_manifest_line "$split_runtime_manifest" "extensions=cube,earthdistance" \ + "Kotlin Android split runtime manifest did not include exact extension dependencies" + require_manifest_line "$split_runtime_manifest" "sharedPreloadLibraries=" \ + "Kotlin Android split runtime manifest should not record shared preload libraries for earthdistance" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryPending=cube,earthdistance" \ + "Kotlin Android split runtime manifest did not map earthdistance mobile pending extensions" + require_manifest_line "$split_runtime_manifest" "nativeModuleStems=cube,earthdistance" \ + "Kotlin Android split runtime manifest did not map earthdistance native module stems" + + split_unknown_extension_log="$scratch_root/kotlin-split-unknown-extension.log" + rm -f "$split_unknown_extension_log" + printf '\n==> %s\n' "$gradle_cmd -p $project_dir :oliphaunt:prepareOliphauntAndroidAssets -PoliphauntExtensions=acme_unknown" + if "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=acme_unknown" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args >"$split_unknown_extension_log" 2>&1; then + echo "Kotlin Android split runtime packaging accepted an extension absent from generated metadata" >&2 + cat "$split_unknown_extension_log" >&2 + rm -f "$split_unknown_extension_log" + exit 1 + fi + if ! grep -Fq "cannot select unknown extension 'acme_unknown'" "$split_unknown_extension_log"; then + echo "Kotlin Android split runtime packaging failed without the expected unknown-extension diagnostic" >&2 + cat "$split_unknown_extension_log" >&2 + rm -f "$split_unknown_extension_log" + exit 1 + fi + rm -f "$split_unknown_extension_log" + rm -rf "$tmp_split_runtime" "$tmp_split_template" + + tmp_jni="$(prepare_scratch_dir kotlin-jni)" + mkdir -p "$tmp_jni/jniLibs/arm64-v8a" + printf 'not-a-real-android-elf-for-packaging-smoke\n' >"$tmp_jni/jniLibs/arm64-v8a/liboliphaunt.so" + run "$gradle_cmd" -p "$project_dir" :oliphaunt:prepareOliphauntAndroidJniLibs \ + "-PoliphauntAndroidJniLibsDir=$tmp_jni" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + generated_jni="$kotlin_build_dir/generated/oliphaunt-android-jniLibs" + if [ ! -f "$generated_jni/arm64-v8a/liboliphaunt.so" ]; then + echo "Kotlin Android generated JNI libs did not include packaged liboliphaunt.so" >&2 + rm -rf "$tmp_jni" + exit 1 + fi + rm -rf "$tmp_jni" +fi diff --git a/src/sdks/react-native/.gitignore b/src/sdks/react-native/.gitignore new file mode 100644 index 00000000..306f86bc --- /dev/null +++ b/src/sdks/react-native/.gitignore @@ -0,0 +1,7 @@ +lib/ +node_modules/ +.build/ +android/.gradle/ +android/build/ +android/.cxx/ +ios/vendor/ diff --git a/src/sdks/react-native/CHANGELOG.md b/src/sdks/react-native/CHANGELOG.md new file mode 100644 index 00000000..8d24fd53 --- /dev/null +++ b/src/sdks/react-native/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + + +## [0.1.0] - 2026-06-01 + +### Changed + +- Initial React Native SDK release lane. +- Rename project to Oliphaunt +- Organize polyglot release tooling diff --git a/src/sdks/react-native/OliphauntReactNative.podspec b/src/sdks/react-native/OliphauntReactNative.podspec new file mode 100644 index 00000000..8a200c09 --- /dev/null +++ b/src/sdks/react-native/OliphauntReactNative.podspec @@ -0,0 +1,43 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +native_sdk_version = ENV.fetch("OLIPHAUNT_REACT_NATIVE_SWIFT_SDK_VERSION") do + package.fetch("oliphaunt", {}).fetch("swiftSdkVersion", package["version"]) +end + +Pod::Spec.new do |s| + s.name = "OliphauntReactNative" + s.version = package["version"] + s.summary = package["description"] + s.license = package["license"] + s.homepage = "https://oliphaunt.dev" + s.authors = { "Oliphaunt" => "opensource@oliphaunt.dev" } + s.source = { :git => "https://github.com/f0rr0/oliphaunt.git", :tag => "oliphaunt-react-native-v#{s.version}" } + s.platforms = { :ios => "17.0" } + s.swift_version = "6.0" + static_registry_sources = Dir.glob(File.join(__dir__, "ios/generated/static-registry/*.c")) + s.source_files = "ios/*.{h,m,mm,swift}", "ios/generated/static-registry/*.c" + if static_registry_sources.any? + s.user_target_xcconfig = { + "OTHER_LDFLAGS" => "$(inherited) -u _liboliphaunt_selected_static_extensions" + } + end + resource_bundle = "ios/resources/OliphauntReactNativeResources.bundle" + if Dir.exist?(File.join(__dir__, resource_bundle)) + s.resources = resource_bundle + end + vendored_frameworks = [] + if Dir.glob(File.join(__dir__, "ios/frameworks/**/*.xcframework")).any? + vendored_frameworks << "ios/frameworks/**/*.xcframework" + end + if Dir.glob(File.join(__dir__, "ios/extension-frameworks/**/*.xcframework")).any? + vendored_frameworks << "ios/extension-frameworks/**/*.xcframework" + end + unless vendored_frameworks.empty? + s.vendored_frameworks = vendored_frameworks + end + s.requires_arc = true + s.dependency "Oliphaunt", native_sdk_version + + install_modules_dependencies(s) +end diff --git a/src/sdks/react-native/README.md b/src/sdks/react-native/README.md new file mode 100644 index 00000000..aa220835 --- /dev/null +++ b/src/sdks/react-native/README.md @@ -0,0 +1,488 @@ +# Oliphaunt React Native SDK + +## Install + +Install the New Architecture package from npm: + +```bash +pnpm add @oliphaunt/react-native +``` + +Expo apps select the compatible native core and exact PostgreSQL extensions in +`app.json`: + +```json +{ + "expo": { + "plugins": [ + [ + "@oliphaunt/react-native", + { + "liboliphauntVersion": "0.1.0", + "extensions": ["vector"] + } + ] + ] + } +} +``` + +The config plugin delegates iOS packaging to the Swift SDK and Android packaging +to the Kotlin SDK. It writes normal native project settings so app builds fetch +checksum-covered `liboliphaunt-native-v0.1.0` assets and include only the exact +extensions selected by the app. +Normal React Native and Expo app consumers do not install Rust, run Cargo, build +PostgreSQL, or copy native Oliphaunt artifacts. The package uses standard New +Architecture, Expo config-plugin, CocoaPods project integration, and Gradle +integration to resolve prebuilt release assets. + +## Compatibility + +| Package | Swift SDK | Kotlin SDK | Native core | +| --- | --- | --- | --- | +| `@oliphaunt/react-native` `0.1.0` | `Oliphaunt` `0.6.0` | `dev.oliphaunt:oliphaunt` `0.1.0` | `liboliphaunt` `0.1.0` | + +React Native iOS uses the Swift SDK through npm-shipped podspec shims required +by current React Native iOS integration. The Expo config plugin wires +`COliphaunt` and `Oliphaunt` podspecs that resolve the released Swift SDK source +tag through CocoaPods, so builds do not require CocoaPods trunk publication and +the npm package does not vendor Swift SDK source. React Native Android uses the +Kotlin SDK and Gradle resolver. + +## Quickstart + + +```ts +import {Oliphaunt} from '@oliphaunt/react-native'; + +const db = await Oliphaunt.open({ + engine: 'nativeDirect', + temporary: true, + runtimeFootprint: 'balancedMobile', + startupGUCs: [{name: 'shared_buffers', value: '32MB'}], + username: 'postgres', + database: 'postgres', + extensions: ['vector'], +}); +const response = await db.query('SELECT 1::text AS value'); +const value = response.getText(0, 'value'); +await db.close(); +``` + +Modern React Native package for `liboliphaunt`. + +This package targets the React Native New Architecture. The public TypeScript +API accepts and returns `Uint8Array` for raw PostgreSQL protocol bytes. The +TurboModule Codegen surface is intentionally limited to typed lifecycle and +capability calls. Protocol, backup, and restore bytes require the versioned +`globalThis.__oliphauntReactNativeJsi` transport and use `ArrayBuffer`/typed +arrays directly. The iOS and Android New Architecture adapters install that +JSI transport and delegate the binary handoff to `Oliphaunt` and +`OliphauntAndroid`; there is no base64 binary fallback. + +The native layer should stay deliberately thin: + +- Apple runtime behavior belongs to the Swift SDK. The current package ships an + iOS adapter; any future React Native macOS target must use the same + `Oliphaunt` boundary. +- Android runtime behavior belongs to the Kotlin SDK. +- React Native owns TypeScript handles, the TurboModule Codegen + lifecycle/control surface, the JSI transport installer, and JS ergonomics. +- RN Android delegates to the Kotlin SDK through `OliphauntAndroid`. +- RN iOS delegates to `Oliphaunt` through a small Objective-C-visible Swift + adapter. +- The published package vendors only the Swift SDK source slice needed for RN iOS + local pod resolution at publish time; the canonical Swift SDK source remains + `src/sdks/swift`. + +Capabilities are delegated from the platform SDK and keep the same field names +as the product contract: raw and streaming protocol support, cancellation, +backup/restore, simple-query execution, exact extensions, and session semantics. +They also expose `multiRoot` plus `backupFormats` and `restoreFormats`, so TypeScript callers can +disable unsupported SQL or archive actions before crossing the native boundary. +Use the exported `supportsBackupFormat` and `supportsRestoreFormat` helpers, or +the matching `OliphauntDatabase.supportsBackupFormat` and +`OliphauntDatabase.supportsRestoreFormat` methods, when gating app actions. +`OliphauntDatabase.backup` enforces those capabilities before it crosses the +TurboModule boundary. `Oliphaunt.restore` rejects unsupported restore artifact formats +before it crosses the TurboModule restore boundary. +`OliphauntDatabase.transaction(async tx => ...)` keeps multi-step work on the same +platform SDK session and rejects database calls outside the active transaction +handle until commit or rollback. +`OliphauntDatabase.checkpoint()` requests a PostgreSQL checkpoint through the same +delegated platform SDK session and is rejected while a transaction is active. +Call `Oliphaunt.supportedModes()` before opening to discover the platform adapter's +actual direct/broker/server availability. React Native reports the same +canonical capability shape as Swift/Kotlin and carries explicit reasons for +unavailable modes instead of attempting direct-mode aliases. +Lifecycle capability fields are forwarded from the platform SDK: +`sameRootLogicalReopen`, `rootSwitchable`, and `crashRestartable` distinguish +direct's same-root resident reopen from broker/server process-managed behavior. +Native direct is not root-switchable or crash-restartable. Mobile direct mode +has one resident backend per app process and one physical session. Use server +mode only where the SDK reports true server support; it is not a +crash-isolated server and it does not provide independent concurrent client +sessions. +`Oliphaunt.open({ username, database })` forwards startup identity to the Swift or +Kotlin SDK and rejects empty or NUL-containing values before the TurboModule +call. + +Packaged runtime/template assets use the same `oliphaunt/runtime` and +`oliphaunt/template-pgdata` resource layout as the Swift and Kotlin SDKs. Empty +mobile roots require a packaged template because mobile bootstrap must not +depend on executing `initdb` from app data. + +See [`docs/architecture.md`](docs/architecture.md) for the architecture, +transport, and performance completion criteria. + +React Native defaults to the mobile resident profile: `runtimeFootprint: +'balancedMobile'` and `durability: 'balanced'`. Use `'safe'` when last-commit +survival matters more than commit latency, `'throughput'` for throughput-lane +diagnostics, or `'smallMobile'` for memory-pressure experiments. `startupGUCs` +can be objects or `name=value` strings; the TypeScript layer validates them and +forwards `name=value` assignments to Swift/Kotlin, where they are appended +after footprint and durability defaults. + +`query(sql)` parses normal PostgreSQL backend protocol frames into field +metadata, rows, command tags, nulls, and structured PostgreSQL errors through +`PostgresError`, preserving SQLSTATE and raw `ErrorResponse` fields. +Multi-result-set and COPY traffic stay on `execProtocolRaw`/`execute`. +Pass query parameters as the second argument to use PostgreSQL's extended +protocol instead of interpolating values into SQL: + +For crash-recovery and physical-device harnesses, `root` may be an absolute +native path or an app-sandbox specifier. `app-support://name` resolves under +Application Support on Apple platforms and app-private files storage on +Android; `documents://name` resolves under Documents on Apple platforms and the +same app-private files base on Android. The suffix must be a relative path and +cannot contain `.` or `..`. + + +```ts +const result = await db.query('SELECT $1::text AS value', ['hello']); +``` + +`execProtocolStream(bytes, onChunk)` is part of the public TypeScript shape. +`EngineCapabilities.protocolStream` is `true` only when the installed JSI +transport has a real chunked stream primitive. Current iOS and Android adapters +delegate to the Swift/Kotlin streaming APIs and invoke `onChunk` for each native +chunk. If a custom or stale transport omits that primitive, the public method +falls back to the owned-response raw path, invokes `onChunk` once, and reports +`protocolStream=false`. + +For app/device smoke tests, call the installed-package runner inside a real +React Native New Architecture app after packaging `liboliphaunt` and runtime +resources: + + +```ts +import {runInstalledOliphauntReactNativeSmoke} from '@oliphaunt/react-native'; + +const report = await runInstalledOliphauntReactNativeSmoke({ + open: {engine: 'nativeDirect', temporary: true, extensions: ['vector']}, + requirePackageSizeReport: true, + afterSmoke: async database => { + await database.execute('CREATE TABLE app_smoke (id integer PRIMARY KEY)'); + }, +}); +``` + +The runner opens through the installed TurboModule/JSI transport, verifies +`SELECT 1`, verifies a parameterized query, optionally requires packaged +resource size evidence, reports JS timer progress during the smoke, optionally +runs app-specific validation on the same live session, and closes the database. + +For measured device work, use the benchmark runner instead of reading smoke +latencies. It records warmup-controlled raw protocol RTT, typed query RTT, +parameterized RTT, transaction insert throughput, indexed lookup, indexed +aggregate, indexed update, background checkpoint latency, large raw-result +transfer, package size, event-loop liveness, and a same-device Expo SQLite WAL +baseline for mobile comparison: + + +```ts +import {Oliphaunt, runOliphauntReactNativeBenchmark} from '@oliphaunt/react-native'; + +const report = await runOliphauntReactNativeBenchmark(Oliphaunt, { + requirePackageSizeReport: true, +}); +``` + +The monorepo includes a real Expo development-build app at +`src/sdks/react-native/examples/expo`. Its Android smoke harness packages the +current SDK tarball, runs Expo prebuild when the ignored generated Android +project is absent, packages `liboliphaunt.so` and runtime/template resources, +builds the dev-client APK, launches through Expo dev-client, and waits for +`OLIPHAUNT_EXPO_SMOKE_PASS`: + +```bash +pnpm --dir ../../src/sdks/react-native/examples/expo run smoke +pnpm --dir ../../src/sdks/react-native/examples/expo run smoke:android +moon run oliphaunt-react-native:smoke-mobile +pnpm --dir src/sdks/react-native/examples/expo run smoke:android +``` + +`moon run oliphaunt-react-native:smoke-mobile` is the default installed-app +validation lane. It delegates to the Expo development-client harness and runs +the Android and iOS smokes over the packed SDK; use the platform-specific lanes +when only one simulator/device stack is available. + +The example defaults Metro to the dev-client plus local MCP path: `pnpm start`, +`pnpm run android:start`, and `pnpm run ios:start` all use +`EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client`. +The installed-app smoke, benchmark, and crash scripts own their dev-client +Metro process by default with the same local MCP capabilities enabled so runner +mode, durability, startup GUCs, and persistent crash roots are passed through +`EXPO_PUBLIC_OLIPHAUNT_*` env instead of depending on Expo launcher URL +forwarding. If port 8081 is already in use and no explicit +`OLIPHAUNT_EXPO_*_METRO_PORT` is set, the scripts choose a free port in +8082-8099; set `OLIPHAUNT_EXPO_*_REUSE_METRO=1` only for manual debugging with +an already configured Metro server. +Installed-app smoke and benchmark runs default to the mobile performance profile +(`runtimeFootprint: 'balancedMobile'`, `durability: 'balanced'`). Crash-recovery +runs default to `durability: 'safe'` because `balanced` sets +`synchronous_commit=off`, which is allowed to lose the last acknowledged +transaction after process death. Set +`OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT` and +`OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS` to sweep the device footprint matrix +without changing app code. The matrix wrapper accepts +`--runtime-footprint all` when comparing `throughput`, `balancedMobile`, and +`smallMobile` with the same shared-buffer/WAL startup-GUC axes. + +For benchmark artifacts: + +```bash +pnpm --dir ../../src/sdks/react-native/examples/expo run bench:android +pnpm --dir ../../src/sdks/react-native/examples/expo run bench:ios +``` + +The harnesses emit `OLIPHAUNT_EXPO_BENCH_PASS`, persist parsed JSON reports +under `target/oliphaunt-expo--benchmark/reports/`, and collect +platform memory evidence where available. + +For process-death recovery evidence: + +```bash +pnpm --dir ../../src/sdks/react-native/examples/expo run crash:android +pnpm --dir ../../src/sdks/react-native/examples/expo run crash:ios +``` + +Those lanes run a two-phase installed-app harness. The write phase uses a +persistent app-private root and intentionally leaves the direct-mode database +open; the platform script force-stops or terminates the app, relaunches the +verify phase with the same root, and expects the committed row to survive +PostgreSQL recovery before emitting `OLIPHAUNT_EXPO_CRASH_RECOVERY_PASS`. +For development-client builds, each phase starts a fresh Metro bundle with the +phase-specific runner (`crash-write`, then `crash-verify`) and root in Expo +public env. +The mobile footprint matrix runs this lane for safe-durability cases by default; +balanced cases remain latency/footprint evidence rather than last-commit +survival evidence. + +The same example exposes an iOS smoke/build harness. It packages the RN SDK +with an iOS `oliphaunt/` resource bundle, uses the Swift `Oliphaunt` pods, and +rejects macOS dylibs so Apple validation cannot accidentally use the host +artifact: + +```bash +OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK=/path/to/liboliphaunt.xcframework \ +OLIPHAUNT_EXPO_IOS_RUNTIME_DIR=/path/to/postgres-runtime \ +OLIPHAUNT_EXPO_IOS_TEMPLATE_PGDATA_DIR=/path/to/template-pgdata \ +pnpm --dir ../../src/sdks/react-native/examples/expo run smoke:ios + +../../pnpm --dir src/sdks/react-native/examples/expo run smoke:ios +``` + +Set `OLIPHAUNT_EXPO_IOS_BUILD_ONLY=1` to validate generated spec, local iOS pod, +and Xcode integration without launching a simulator. For an unsigned generic +iPhoneOS compile/package check, also set `OLIPHAUNT_EXPO_IOS_SDK=iphoneos` and +`OLIPHAUNT_EXPO_IOS_CODE_SIGNING_ALLOWED=NO`; physical install/launch +benchmarks still require a runnable paired phone and valid signing. + +For physical iOS runs, set `OLIPHAUNT_EXPO_IOS_SDK=iphoneos` and point +`OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK` at an XCFramework with an iPhoneOS +slice. The harness checks Developer Mode and Developer Disk Image availability +through `devicectl`, then auto-selects the single Xcode development team when +one is configured, or you can set `OLIPHAUNT_EXPO_IOS_DEVELOPMENT_TEAM` +explicitly. Set the team explicitly when Xcode has multiple teams configured. +It fails before expensive project preparation if no local signing identity +exists; set `OLIPHAUNT_EXPO_IOS_ALLOW_PROVISIONING_UPDATES=1` to explicitly +allow automatic provisioning. It also supports +`OLIPHAUNT_EXPO_IOS_CODE_SIGN_IDENTITY`, +`OLIPHAUNT_EXPO_IOS_PROVISIONING_PROFILE_SPECIFIER`, and +`OLIPHAUNT_EXPO_IOS_ALLOW_PROVISIONING_UPDATES` for local or CI signing. + +For Expo-assisted local debugging, the example includes `expo-mcp`: + +```bash +pnpm --dir ../../src/sdks/react-native/examples/expo run mcp:start +``` + +This starts Metro with `EXPO_UNSTABLE_MCP_SERVER=1` for local MCP capabilities +such as app logs, DevTools, screenshots, and simulator/device automation. + +## Local Development + +```bash +pnpm install +pnpm test +pnpm run typecheck +pnpm run codegen:check +ANDROID_HOME="$HOME/Library/Android/sdk" ../../src/sdks/react-native/tools/check-sdk.sh +``` + +Codegen is retained deliberately for the official New Architecture +TurboModule surface: `open`, `close`, `cancel`, `capabilities`, +`supportedModes`, and package-size reporting. It is not a binary transport. +The package check fails if protocol execution, backup, or restore bytes are +added to the Codegen spec, or if runtime source reintroduces base64/Node +`Buffer` conversion. + +The package check defaults Android Gradle/CMake work to one ABI for fast local +iteration. Use `OLIPHAUNT_REACT_NATIVE_ANDROID_ABI_FILTERS=all` for full ABI +coverage, or a comma-separated subset such as `arm64-v8a,x86_64`. + +For local iOS smoke work, the RN module calls the Swift SDK `Oliphaunt` API. +Pass `libraryPath` and `runtimeDirectory`, or set +`OLIPHAUNT_REACT_NATIVE_IOS_*`, `OLIPHAUNT_SWIFT_*`, or +`OLIPHAUNT_*` environment variables in the test process. Pass +`resourceRoot` when testing an unpacked `oliphaunt/` resource layout. Restore +accepts the same `libraryPath` override because it also crosses the native C ABI. +Empty iOS roots require packaged template PGDATA or an existing root with +`PG_VERSION`. + +For local Android smoke work, the RN module calls the Kotlin SDK +`OliphauntAndroid` facade and stores the returned `OliphauntDatabase` handle. Package +or load `liboliphaunt.so` and pass `libraryPath`/`runtimeDirectory`, or set +`OLIPHAUNT_REACT_NATIVE_ANDROID_*` or `OLIPHAUNT_KOTLIN_ANDROID_*` environment +variables in the test process. Restore forwards `libraryPath` to +`OliphauntAndroid.restore(...)` for the same local override path. + +The published React Native artifact does not carry base `liboliphaunt` +binaries, PostgreSQL runtime resources, or optional extension assets. React +Native apps receive those through the delegated SwiftPM-backed local iOS pods +and the app-applied `dev.oliphaunt.android` Gradle plugin on Android, using the +same exact SQL extension selection as native apps. + +For Expo/dev-client apps, add the config plugin and select exact PostgreSQL +extension names: + +```json +{ + "expo": { + "plugins": [ + ["@oliphaunt/react-native", { "extensions": ["vector"] }] + ] + } +} +``` + +The config plugin writes the native extension selection, applies +`id 'dev.oliphaunt.android' version ''` to the +Android app module, and injects the npm-shipped Swift SDK podspec shims for iOS. +The React Native Android module depends on the Kotlin SDK and ships only its JSI +bridge by default, avoiding duplicate ownership of `liboliphaunt.so`, runtime +resources, and extension assets. + +React Native uses the same runtime-resource contract as the platform SDKs. iOS +delegates the mobile static-registry check to `Oliphaunt`; Android preserves +the Rust runtime-resource generator manifest in the Kotlin SDK AAR and delegates +the runtime check to the Kotlin SDK. Extension selection is not reimplemented in TypeScript: +`packageLayout`, per-package `layout`, `extensions`, `sharedPreloadLibraries`, +and mobile static registry state are validated by Swift on Apple platforms and +Kotlin on Android. +`package-size.tsv` is preserved in the same platform resource bundle/AAR. +`Oliphaunt.packageSizeReport(...)` returns the Rust runtime-resource generator's total, runtime, +template PGDATA, static-registry, selected-extension, and per-extension byte +evidence by delegating to `Oliphaunt` on Apple platforms and +`OliphauntAndroid` on Android instead of maintaining a JS-specific resource +walker. + +`Oliphaunt.processMemory()` returns app-reported process memory for benchmark +and diagnostics paths. iOS uses Mach task VM data (`residentBytes` and +`physicalFootprintBytes`) from inside the app process. Android uses +`Debug.MemoryInfo` (`totalPssKb`, dirty pages, native heap, and runtime heap). +Prefer this report over host-side process scraping for physical devices; Core +Device and `adb shell` output varies across OS versions and can omit the fields +needed for reproducible RSS/PSS summaries. + +Package Android native libraries with the same ABI layout Android apps already +use: + +```text +jniLibs/ + arm64-v8a/liboliphaunt.so + x86_64/liboliphaunt.so +``` + +```bash +gradle -p android assembleDebug \ + -PoliphauntRuntimeResourcesDir=../../target/oliphaunt-resources \ + -PoliphauntAndroidJniLibsDir=/path/to/jniLibs +``` + +The Android build rejects unknown ABI names, symlinks, nested layouts, and ABI +directories that do not contain `liboliphaunt.so`. + +For release builds with module-backed extensions, pass the prebuilt mobile +extension archive root to the Kotlin SDK packaging path as well. The Kotlin SDK +AAR links the small `liboliphaunt_extensions.so` from the selected +`liboliphaunt_extension_.a` archives and the generated static-registry +source; React Native does not compile PostgreSQL or extension sources in the app +build. + +```bash +gradle -p android assembleRelease \ + -PoliphauntRuntimeResourcesDir=../../target/oliphaunt-resources \ + -PoliphauntAndroidJniLibsDir=/path/to/jniLibs \ + -PoliphauntAndroidExtensionArchivesDir=/path/to/liboliphaunt-android-arm64/out +``` + +The older split inputs remain available when integrating with a custom build: + +```bash +gradle -p android assembleDebug \ + -PoliphauntRuntimeDir=/path/to/postgres-install-root \ + -PoliphauntTemplatePgdataDir=/path/to/template-pgdata \ + -PoliphauntExtensions=vector +``` + +The build packages those directories under `assets/oliphaunt/` with generated +content manifests. At runtime the Kotlin SDK materializes the runtime directory +once under `noBackupFilesDir` and hydrates new PGDATA roots from the packaged +template. Android records the packaged exact extension names in the runtime +manifest and fails open early if JS requests an extension that was not packaged. +Split-resource Android builds intentionally keep module-backed extensions in +`pending` state because they cannot generate +or verify `static-registry/oliphaunt_static_registry.c`; use the Rust runtime-resource generator +output with `--mobile-static-module ` for a mobile-complete release +package. iOS performs the same manifest check through +`Oliphaunt` when packaged runtime resources are used. The React Native package +does not implement extension loading in TypeScript; its iOS/Android native +layers inherit the Swift/Kotlin bridge behavior. Mobile-ready Rust runtime-resource generator +output includes `static-registry/oliphaunt_static_registry.c`; the native +bridges discover `liboliphaunt_selected_static_extensions` in the linked native +artifact or Android `liboliphaunt_extensions.so` and register the rows through +`oliphaunt_register_static_extensions` before opening the database. +For iOS React Native release builds, selected extension artifacts are resolved +through exact extension package metadata, not by dropping every extension into +the base RN package. Put the generated registry source under +`ios/generated/static-registry/oliphaunt_static_registry.c`; the React Native +podspec compiles that generated source while runtime resources keep only +manifests and selected SQL files. Build those extension XCFrameworks +from prebuilt simulator and device archives with +`src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh --runtime-resources `. +The script derives selected native module stems and dependency archive names +from the Rust runtime-resource manifest, so React Native iOS follows the same +exact selection as the packaged runtime resources instead of maintaining a +second extension list. +The generated registry source strongly references selected extension magic and +SQL symbols, so missing selected prebuilt iOS artifacts are build/link errors +instead of runtime surprises. Android uses `--whole-archive` for the same +reason when building its `liboliphaunt_extensions.so` support library. +Device builds still need real device/emulator smoke tests with packaged native +`liboliphaunt` libraries. + +Backup and restore use the same same-version physical archive model as the +Rust/Swift/Kotlin SDKs. RN Android delegates those calls to the Kotlin Android +SDK; RN iOS delegates those calls to `Oliphaunt`. diff --git a/src/sdks/react-native/android/build.gradle b/src/sdks/react-native/android/build.gradle new file mode 100644 index 00000000..cda1d559 --- /dev/null +++ b/src/sdks/react-native/android/build.gradle @@ -0,0 +1,866 @@ +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.Locale +import java.util.Properties + +plugins { + id "com.android.library" + id "org.jetbrains.kotlin.android" + id "org.jetbrains.kotlin.multiplatform" apply false +} + +def findNodeModulePackageJson(String packageName) { + def current = projectDir + while (current != null) { + def candidate = new File(current, "node_modules/${packageName}/package.json") + if (candidate.exists()) { + return candidate + } + current = current.parentFile + } + return null +} + +def findNodeModuleDir(String packageName) { + def packageJson = findNodeModulePackageJson(packageName) + return packageJson == null ? null : packageJson.parentFile +} + +def resolveReactNativeVersion() { + if (project.hasProperty("reactNativeVersion")) { + return project.property("reactNativeVersion").toString() + } + if (rootProject.ext.has("reactNativeVersion")) { + return rootProject.ext.reactNativeVersion.toString() + } + def packageJson = findNodeModulePackageJson("react-native") + if (packageJson != null) { + return new JsonSlurper().parse(packageJson).version.toString() + } + throw new GradleException( + "Could not resolve React Native version. Install react-native in node_modules " + + "or pass -PreactNativeVersion= when building @oliphaunt/react-native." + ) +} + +def reactNativeVersion = resolveReactNativeVersion() +def reactNativeDir = findNodeModuleDir("react-native") +def reactNativeCodegenDir = findNodeModuleDir("@react-native/codegen") +if (reactNativeDir == null || reactNativeCodegenDir == null) { + throw new GradleException( + "Could not resolve react-native and @react-native/codegen from node_modules. " + + "Run pnpm install for @oliphaunt/react-native before building the Android package." + ) +} +def nodeExecutable = (project.findProperty("nodeExecutable") ?: System.getenv("NODE_BINARY") ?: "node").toString() + +def generatedCodegenDir = file("${buildDir}/generated/source/codegen") +def generatedCodegenSchema = file("${generatedCodegenDir}/schema.json") +def codegenSpec = file("../src/specs/NativeOliphaunt.ts") +def packageJson = file("../package.json") +def packageMetadata = new JsonSlurper().parse(packageJson) +def packageVersion = packageMetadata.version.toString() +def kotlinSdkVersion = ( + packageMetadata.oliphaunt instanceof Map && packageMetadata.oliphaunt.kotlinSdkVersion != null + ? packageMetadata.oliphaunt.kotlinSdkVersion + : packageVersion +).toString() +def generatedOliphauntAssetsDir = file("${buildDir}/generated/liboliphaunt-assets") +def generatedOliphauntJniLibsDir = file("${buildDir}/generated/liboliphaunt-jniLibs") +def configuredCxxBuildRoot = project.findProperty("oliphauntCxxBuildRoot") ?: System.getenv("OLIPHAUNT_CXX_BUILD_ROOT") +def cxxBuildRoot = configuredCxxBuildRoot == null || configuredCxxBuildRoot.toString().isBlank() + ? file("${layout.buildDirectory.get().asFile}/cxx") + : new File(file(configuredCxxBuildRoot), project.path == ":" ? "root" : project.path.substring(1).replace(":", "/")) +def localKotlinSdkProject = findProject(":oliphaunt") +def kotlinSdkDependency = (project.findProperty("liboliphauntKotlinSdkDependency") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_DEPENDENCY"))?.toString() + ?: "dev.oliphaunt:oliphaunt:${kotlinSdkVersion}" +def kotlinSdkMavenRepository = (project.findProperty("oliphauntKotlinSdkMavenRepository") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_MAVEN_REPOSITORY"))?.toString() + ?.trim() +def boolOption = { String propertyName, String environmentName, boolean defaultValue -> + def raw = project.findProperty(propertyName) ?: System.getenv(environmentName) + if (raw == null || raw.toString().isBlank()) { + return defaultValue + } + switch (raw.toString().trim().toLowerCase(Locale.ROOT)) { + case "1": + case "true": + case "yes": + case "on": + return true + case "0": + case "false": + case "no": + case "off": + return false + default: + throw new GradleException("${propertyName} must be a boolean value, got '${raw}'") + } +} +def packagesAndroidRuntimeInReactNative = boolOption( + "oliphauntReactNativePackageRuntime", + "OLIPHAUNT_REACT_NATIVE_ANDROID_PACKAGE_RUNTIME", + false +) +def packagedRuntimeResourcesDir = ( + project.findProperty("oliphauntRuntimeResourcesDir") + ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_RUNTIME_RESOURCES_DIR") + ?: System.getenv("OLIPHAUNT_ANDROID_RUNTIME_RESOURCES_DIR") +)?.toString() +def packagedAndroidJniLibsDir = (project.findProperty("oliphauntAndroidJniLibsDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_JNI_LIBS_DIR"))?.toString() +def packagedAndroidExtensionArchivesDir = ( + project.findProperty("oliphauntAndroidExtensionArchivesDir") + ?: project.findProperty("oliphauntExtensionArchivesDir") + ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_EXTENSION_ARCHIVES_DIR") + ?: System.getenv("OLIPHAUNT_ANDROID_EXTENSION_ARCHIVES_DIR") +)?.toString() +def packagedAndroidLinkEvidenceFile = ( + project.findProperty("oliphauntAndroidLinkEvidenceFile") + ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_LINK_EVIDENCE_FILE") +)?.toString() +def explicitPackagedRuntimeDir = (project.findProperty("oliphauntRuntimeDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_RUNTIME_DIR"))?.toString() +def explicitPackagedTemplatePgdataDir = (project.findProperty("oliphauntTemplatePgdataDir") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_TEMPLATE_PGDATA_DIR"))?.toString() +def explicitPackagedExtensionsRaw = (project.findProperty("oliphauntExtensions") ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_EXTENSIONS"))?.toString() +def explicitMobileStaticModulesRaw = ( + project.findProperty("oliphauntMobileStaticModules") + ?: project.findProperty("oliphauntMobileStaticModuleStems") + ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_MOBILE_STATIC_MODULES") + ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_MOBILE_STATIC_MODULE_STEMS") +)?.toString() + +def runtimeResourcesRoot = { + if (packagedRuntimeResourcesDir == null || packagedRuntimeResourcesDir.isBlank()) { + return null + } + def root = file(packagedRuntimeResourcesDir) + def nested = new File(root, "oliphaunt") + if (nested.isDirectory()) { + return nested + } + if (new File(root, "runtime").isDirectory()) { + return root + } + return nested +} + +def runtimeResourceFiles = { String packageName -> + def root = runtimeResourcesRoot() + if (root == null) { + return null + } + def files = new File(new File(root, packageName), "files") + return files.isDirectory() ? files.absolutePath : null +} + +def runtimeResourceManifestValue = { String packageName, String key -> + def root = runtimeResourcesRoot() + if (root == null) { + return null + } + def manifest = new File(new File(root, packageName), "manifest.properties") + if (!manifest.isFile()) { + return null + } + def properties = new Properties() + manifest.withInputStream { properties.load(it) } + return properties.getProperty(key) +} + +def packagedRuntimeDir = runtimeResourceFiles("runtime") ?: explicitPackagedRuntimeDir +def packagedTemplatePgdataDir = runtimeResourceFiles("template-pgdata") ?: explicitPackagedTemplatePgdataDir +def packagedExtensionsRaw = explicitPackagedExtensionsRaw ?: runtimeResourceManifestValue("runtime", "extensions") +def packagedMobileStaticModulesRaw = explicitMobileStaticModulesRaw ?: runtimeResourceManifestValue("runtime", "nativeModuleStems") +def packagedStaticRegistrySource = { + def relative = runtimeResourceManifestValue("runtime", "mobileStaticRegistrySource") + if (relative == null || relative.isBlank()) { + return null + } + def root = runtimeResourcesRoot() + if (root == null) { + return null + } + def source = new File(root, relative) + return source.isFile() ? source.absolutePath : null +}.call() +def packagedResourceExtensionArchivesDir = { + def root = runtimeResourcesRoot() + if (root == null) { + return null + } + def archives = new File(new File(root, "static-registry"), "archives") + return archives.isDirectory() ? archives.absolutePath : null +}.call() +def effectiveAndroidExtensionArchivesDir = + packagedAndroidExtensionArchivesDir != null && !packagedAndroidExtensionArchivesDir.isBlank() + ? packagedAndroidExtensionArchivesDir + : packagedResourceExtensionArchivesDir + +def parsePortableList = { String raw, String label -> + if (raw == null || raw.isBlank()) { + return [] + } + def portableNamePattern = ~/^[A-Za-z0-9._-]{1,128}$/ + def values = raw.split(",") + .collect { it.trim() } + .findAll { !it.isEmpty() } + .toSet() + .toList() + .sort() + values.each { value -> + if (!(value ==~ portableNamePattern)) { + throw new GradleException( + "liboliphaunt ${label} '${value}' must contain only ASCII letters, digits, '.', '_' or '-'" + ) + } + } + return values +} + +def parseExtensions = { String raw -> + return parsePortableList.call(raw, "extension name") +} + +def packagedExtensions = parseExtensions(packagedExtensionsRaw) +def packagedMobileStaticModules = parsePortableList(packagedMobileStaticModulesRaw, "mobile static module stem") +def explicitAndroidAbiFiltersRaw = ( + project.findProperty("oliphauntAndroidAbiFilters") + ?: project.findProperty("oliphauntAndroidAbis") + ?: System.getenv("OLIPHAUNT_REACT_NATIVE_ANDROID_ABI_FILTERS") + ?: System.getenv("OLIPHAUNT_ANDROID_ABI_FILTERS") +)?.toString() + +def parseAndroidAbiFilters = { String raw -> + if (raw == null || raw.isBlank() || raw.trim().equalsIgnoreCase("all")) { + return [] + } + def supported = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"] as Set + def values = raw.split(",") + .collect { it.trim() } + .findAll { !it.isEmpty() } + .unique(false) + values.each { value -> + if (!supported.contains(value)) { + throw new GradleException( + "Oliphaunt Android ABI filter '${value}' is not supported; expected one of ${supported.join(", ")}" + ) + } + } + return values +} + +def androidAbiFilters = parseAndroidAbiFilters(explicitAndroidAbiFiltersRaw) + +abstract class PrepareOliphauntAndroidAssetsTask extends DefaultTask { + @Input + abstract Property getRuntimeResourcesDirPath() + + @Input + abstract Property getRuntimeDirPath() + + @Input + abstract Property getTemplatePgdataDirPath() + + @Input + abstract ListProperty getSelectedExtensions() + + @Input + abstract ListProperty getMobileStaticModuleStems() + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getSourceDirectories() + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getGeneratedExtensionMetadata() + + @OutputDirectory + abstract DirectoryProperty getOutputDir() + + private Map> generatedExtensionMetadataBySqlName + + private Map> generatedExtensionMetadataBySqlName() { + if (generatedExtensionMetadataBySqlName == null) { + generatedExtensionMetadataBySqlName = loadGeneratedExtensionMetadata(generatedExtensionMetadata.singleFile) + } + return generatedExtensionMetadataBySqlName + } + + @TaskAction + void prepare() { + File output = outputDir.get().asFile + deleteTree(output.toPath()) + output.mkdirs() + + String runtimeResourcesPath = runtimeResourcesDirPath.get() + if (runtimeResourcesPath != null && !runtimeResourcesPath.isBlank()) { + File sourceRuntimeResourcesRoot = runtimeResourcesRoot(new File(runtimeResourcesPath)) + if (!sourceRuntimeResourcesRoot.isDirectory()) { + throw new GradleException("Oliphaunt React Native Android runtime resources are not a Oliphaunt resource root: ${runtimeResourcesPath}") + } + validateRuntimeResourcesSchema(sourceRuntimeResourcesRoot) + copyTree(sourceRuntimeResourcesRoot.toPath(), new File(output, "oliphaunt").toPath(), ["static-registry/archives"] as Set) + return + } + + writeAssetPackage( + "runtime", + "postgres-runtime-files-v1", + blankToNull(runtimeDirPath.get()), + selectedExtensions.get(), + mobileStaticModuleStems.get(), + output + ) + writeAssetPackage( + "template-pgdata", + "postgres-template-pgdata-v1", + blankToNull(templatePgdataDirPath.get()), + [], + [], + output + ) + } + + private static File runtimeResourcesRoot(File root) { + File nested = new File(root, "oliphaunt") + if (nested.isDirectory()) { + return nested + } + if (new File(root, "runtime").isDirectory()) { + return root + } + return nested + } + + private static void validateRuntimeResourcesSchema(File root) { + ["runtime", "template-pgdata"].each { name -> + File manifest = new File(new File(root, name), "manifest.properties") + if (!manifest.isFile()) { + throw new GradleException("Oliphaunt React Native Android runtime resources are missing ${name}/manifest.properties under ${root.absolutePath}") + } + def properties = new Properties() + manifest.withInputStream { properties.load(it) } + String schema = properties.getProperty("schema")?.trim() ?: "" + if (schema != "oliphaunt-runtime-resources-v1") { + throw new GradleException("Oliphaunt React Native Android runtime resources ${name} manifest has unsupported schema '${schema.isEmpty() ? "" : schema}'; expected oliphaunt-runtime-resources-v1") + } + } + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value + } + + private void writeAssetPackage( + String name, + String layout, + String sourcePath, + List requestedExtensions, + List mobileStaticModuleStems, + File output + ) { + if (sourcePath == null || sourcePath.isBlank()) { + if (!requestedExtensions.isEmpty()) { + throw new GradleException("Oliphaunt Android extensions require -PoliphauntRuntimeDir=") + } + return + } + File source = new File(sourcePath) + if (!source.isDirectory()) { + throw new GradleException("Oliphaunt Android ${name} assets source is not a directory: ${source}") + } + if (!mobileStaticModuleStems.isEmpty()) { + throw new GradleException( + "Oliphaunt React Native Android split runtime packaging cannot declare mobile static module stems. " + + "Use -PoliphauntRuntimeResourcesDir= from " + + "`oliphaunt-resources --mobile-static-module ...` so the runtime resources include the generated static-registry source." + ) + } + File packageDir = new File(output, "oliphaunt/${name}") + File filesDir = new File(packageDir, "files") + copyTree(source.toPath(), filesDir.toPath()) + Map> metadataBySqlName = generatedExtensionMetadataBySqlName() + List extensions = resolveExtensionSelection(requestedExtensions, metadataBySqlName) + List nativeModuleStems = nativeModuleStems(extensions, metadataBySqlName) + Set registeredModuleStems = new TreeSet<>(mobileStaticModuleStems) + Set selectedModuleStems = new TreeSet<>(nativeModuleStems) + Set unknownRegisteredStems = new TreeSet<>(registeredModuleStems) + unknownRegisteredStems.removeAll(selectedModuleStems) + if (!unknownRegisteredStems.isEmpty()) { + throw new GradleException( + "Oliphaunt React Native Android mobile static module stem(s) were not selected by these runtime resources: ${unknownRegisteredStems.join(",")}" + ) + } + List registeredMobileExtensions = mobileStaticRegistryRegisteredExtensions( + extensions, + registeredModuleStems, + metadataBySqlName + ) + List pendingMobileExtensions = mobileStaticRegistryPendingExtensions( + extensions, + registeredModuleStems, + metadataBySqlName + ) + String mobileStaticRegistryState = nativeModuleStems.isEmpty() + ? "not-required" + : (pendingMobileExtensions.isEmpty() ? "complete" : "pending") + File manifest = new File(packageDir, "manifest.properties") + manifest.parentFile.mkdirs() + manifest.text = [ + "schema=oliphaunt-runtime-resources-v1", + "cacheKey=${sha256Directory(source)}", + "layout=${layout}", + "source=${source.name}", + "extensions=${extensions.join(",")}", + "sharedPreloadLibraries=${sharedPreloadLibraries(extensions, metadataBySqlName).join(",")}", + "mobileStaticRegistryState=${mobileStaticRegistryState}", + "mobileStaticRegistryRegistered=${registeredMobileExtensions.join(",")}", + "mobileStaticRegistryPending=${pendingMobileExtensions.join(",")}", + "nativeModuleStems=${nativeModuleStems.join(",")}", + "mobileStaticRegistrySource=", + "", + ].join("\n") + } + + private static Map> loadGeneratedExtensionMetadata(File metadataFile) { + def parsed = new JsonSlurper().parse(metadataFile) + if (!(parsed instanceof Map) || !(parsed.extensions instanceof List)) { + throw new GradleException("generated extension metadata must define extensions: ${metadataFile}") + } + Map> rows = [:] + parsed.extensions.each { row -> + if (!(row instanceof Map) || !(row["sql-name"] instanceof String)) { + throw new GradleException("generated extension metadata rows must define sql-name: ${metadataFile}") + } + rows[row["sql-name"].toString()] = row as Map + } + return rows + } + + private static List generatedExtensionStringList( + String extension, + String field, + Map> metadataBySqlName + ) { + def values = generatedExtensionMetadataRow(extension, metadataBySqlName).get(field) + if (!(values instanceof List)) { + return [] + } + return values.collect { it.toString() } + } + + private static Map generatedExtensionMetadataRow( + String extension, + Map> metadataBySqlName + ) { + def row = metadataBySqlName[extension] + if (row == null) { + throw new GradleException( + "Oliphaunt React Native Android split runtime packaging cannot select unknown extension '${extension}'. " + + "Use a generated built-in extension name, or pass " + + "-PoliphauntRuntimeResourcesDir= for custom prebuilt extension artifacts." + ) + } + return row + } + + private static String generatedNativeModuleStem( + String extension, + Map> metadataBySqlName + ) { + def row = generatedExtensionMetadataRow(extension, metadataBySqlName) + def stem = row["native-module-stem"] + return stem == null ? null : stem.toString() + } + + private static List resolveExtensionSelection( + List requestedExtensions, + Map> metadataBySqlName + ) { + Set extensions = new LinkedHashSet<>() + requestedExtensions.each { extension -> + extensions.addAll(extensionDependencies(extension, metadataBySqlName)) + extensions.add(extension) + } + List resolved = extensions.toList().sort() + resolved.each { extension -> + requireMobileReleaseReady(extension, metadataBySqlName) + } + return resolved + } + + private static List extensionDependencies( + String extension, + Map> metadataBySqlName + ) { + return generatedExtensionStringList(extension, "selected-extension-dependencies", metadataBySqlName) + } + + private static void requireMobileReleaseReady( + String extension, + Map> metadataBySqlName + ) { + def row = generatedExtensionMetadataRow(extension, metadataBySqlName) + if (row["mobile-release-ready"] != true) { + throw new GradleException( + "Oliphaunt React Native Android split runtime packaging cannot select extension '${extension}' because " + + "it does not have release-ready Android/iOS artifacts in the generated exact-extension catalog." + ) + } + } + + private static List sharedPreloadLibraries( + List extensions, + Map> metadataBySqlName + ) { + extensions + .collectMany { generatedExtensionStringList(it, "shared-preload-libraries", metadataBySqlName) } + .toSet() + .sort() + } + + private static List mobileStaticRegistryRegisteredExtensions( + List extensions, + Set registeredModuleStems, + Map> metadataBySqlName + ) { + extensions + .findAll { registeredModuleStems.contains(nativeModuleStem(it, metadataBySqlName)) } + .toSet() + .sort() + } + + private static List mobileStaticRegistryPendingExtensions( + List extensions, + Set registeredModuleStems, + Map> metadataBySqlName + ) { + extensions + .findAll { + def stem = nativeModuleStem(it, metadataBySqlName) + stem != null && !registeredModuleStems.contains(stem) + } + .toSet() + .sort() + } + + private static List nativeModuleStems( + List extensions, + Map> metadataBySqlName + ) { + extensions + .collect { nativeModuleStem(it, metadataBySqlName) } + .findAll { it != null } + .toSet() + .sort() + } + + private static String nativeModuleStem(String extension, Map> metadataBySqlName) { + return generatedNativeModuleStem(extension, metadataBySqlName) + } + + private static String sha256Directory(File source) { + MessageDigest digest = MessageDigest.getInstance("SHA-256") + Path rootPath = source.toPath() + Files.walk(rootPath).withCloseable { stream -> + stream.sorted().forEach { path -> + if (Files.isSymbolicLink(path)) { + throw new GradleException("Oliphaunt Android assets do not support symlinks: ${path}") + } + if (Files.isRegularFile(path)) { + String relative = rootPath.relativize(path).toString().replace(File.separatorChar, '/' as char) + digest.update(relative.getBytes("UTF-8")) + digest.update((byte) 0) + digest.update(Files.readAllBytes(path)) + digest.update((byte) 0) + } + } + } + return digest.digest().encodeHex().toString() + } + + private static void copyTree(Path source, Path target) { + copyTree(source, target, [] as Set) + } + + private static void copyTree(Path source, Path target, Set excludedPrefixes) { + Files.walk(source).withCloseable { stream -> + stream.sorted().forEach { path -> + if (Files.isSymbolicLink(path)) { + throw new GradleException("Oliphaunt Android assets do not support symlinks: ${path}") + } + Path relative = source.relativize(path) + String relativeName = relative.toString().replace(File.separatorChar, '/' as char) + if (!excludedPrefixes.any { prefix -> relativeName == prefix || relativeName.startsWith("${prefix}/") }) { + Path destination = target.resolve(relative) + if (Files.isDirectory(path)) { + Files.createDirectories(destination) + } else if (Files.isRegularFile(path)) { + Files.createDirectories(destination.parent) + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING) + } + } + } + } + } + + private static void deleteTree(Path path) { + if (!Files.exists(path)) { + return + } + Files.walk(path).withCloseable { stream -> + stream.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } + +} + +abstract class PrepareOliphauntAndroidJniLibsTask extends DefaultTask { + private static final Set ANDROID_JNI_LIB_ABIS = ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"] as Set + + @Input + abstract Property getJniLibsDirPath() + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getSourceDirectories() + + @OutputDirectory + abstract DirectoryProperty getOutputDir() + + @TaskAction + void prepare() { + File output = outputDir.get().asFile + deleteTree(output.toPath()) + String configured = jniLibsDirPath.get() + if (configured == null || configured.isBlank()) { + return + } + File configuredRoot = new File(configured) + File nested = new File(configuredRoot, "jniLibs") + File source = nested.isDirectory() ? nested : configuredRoot + if (!source.isDirectory()) { + throw new GradleException("Oliphaunt React Native Android JNI libs source is not a directory: ${source}") + } + + List abiDirs = (source.listFiles()?.findAll { it.isDirectory() } ?: []) + .sort { a, b -> a.name <=> b.name } + if (abiDirs.isEmpty()) { + throw new GradleException("Oliphaunt React Native Android JNI libs require ABI directories under ${source}") + } + + boolean packagedLiboliphaunt = false + abiDirs.each { abiDir -> + if (!ANDROID_JNI_LIB_ABIS.contains(abiDir.name)) { + throw new GradleException("unsupported Android ABI directory for Oliphaunt React Native package: ${abiDir.name}") + } + if (Files.isSymbolicLink(abiDir.toPath())) { + throw new GradleException("Oliphaunt React Native Android JNI libs do not support symlink ABI directories: ${abiDir}") + } + List sharedLibraries = (abiDir.listFiles()?.findAll { file -> + if (Files.isSymbolicLink(file.toPath())) { + throw new GradleException("Oliphaunt React Native Android JNI libs do not support symlinks: ${file}") + } + if (!file.isFile()) { + throw new GradleException("Oliphaunt React Native Android JNI libs only support flat .so files under ABI directories: ${file}") + } + file.name.endsWith(".so") + } ?: []).sort { a, b -> a.name <=> b.name } + if (!sharedLibraries.any { it.name == "liboliphaunt.so" }) { + throw new GradleException("Android ABI ${abiDir.name} is missing liboliphaunt.so") + } + packagedLiboliphaunt = true + File destination = new File(output, abiDir.name) + destination.mkdirs() + sharedLibraries.each { library -> + Files.copy(library.toPath(), new File(destination, library.name).toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + if (!packagedLiboliphaunt) { + throw new GradleException("Oliphaunt React Native Android JNI libs did not contain liboliphaunt.so for any ABI") + } + } + + private static void deleteTree(Path path) { + if (!Files.exists(path)) { + return + } + Files.walk(path).withCloseable { stream -> + stream.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} + +tasks.register("generateOliphauntCodegenSchema", Exec) { + inputs.file(codegenSpec) + inputs.file(packageJson) + outputs.file(generatedCodegenSchema) + workingDir projectDir.parentFile + commandLine( + nodeExecutable, + new File(reactNativeCodegenDir, "lib/cli/combine/combine-js-to-schema-cli.js").absolutePath, + generatedCodegenSchema.absolutePath, + codegenSpec.absolutePath + ) +} + +tasks.register("generateOliphauntCodegenArtifacts", Exec) { + dependsOn("generateOliphauntCodegenSchema") + inputs.file(generatedCodegenSchema) + inputs.file(packageJson) + outputs.dir(file("${generatedCodegenDir}/java")) + outputs.dir(file("${generatedCodegenDir}/jni")) + workingDir projectDir.parentFile + commandLine( + nodeExecutable, + new File(reactNativeDir, "scripts/generate-specs-cli.js").absolutePath, + "--platform", + "android", + "--schemaPath", + generatedCodegenSchema.absolutePath, + "--outputDir", + generatedCodegenDir.absolutePath, + "--libraryName", + "OliphauntReactNativeSpec", + "--javaPackageName", + "dev.oliphaunt.reactnative" + ) +} + +tasks.register("prepareOliphauntAndroidAssets", PrepareOliphauntAndroidAssetsTask) { + runtimeResourcesDirPath.set(packagedRuntimeResourcesDir ?: "") + runtimeDirPath.set(packagedRuntimeDir ?: "") + templatePgdataDirPath.set(packagedTemplatePgdataDir ?: "") + selectedExtensions.set(packagedExtensions) + mobileStaticModuleStems.set(packagedMobileStaticModules) + generatedExtensionMetadata.from(file("../src/generated/extensions.json")) + [packagedRuntimeResourcesDir, packagedRuntimeDir, packagedTemplatePgdataDir] + .findAll { it != null && !it.isBlank() } + .each { sourceDirectories.from(file(it)) } + outputDir.set(generatedOliphauntAssetsDir) +} + +tasks.register("prepareOliphauntAndroidJniLibs", PrepareOliphauntAndroidJniLibsTask) { + jniLibsDirPath.set(packagedAndroidJniLibsDir ?: "") + if (packagedAndroidJniLibsDir != null && !packagedAndroidJniLibsDir.isBlank()) { + sourceDirectories.from(file(packagedAndroidJniLibsDir)) + } + outputDir.set(generatedOliphauntJniLibsDir) +} + +android { + namespace "dev.oliphaunt.reactnative" + compileSdk 36 + + sourceSets { + main { + java.srcDirs += file("${generatedCodegenDir}/java") + if (packagesAndroidRuntimeInReactNative) { + assets.srcDirs += generatedOliphauntAssetsDir + jniLibs.srcDirs += generatedOliphauntJniLibsDir + } + } + } + + defaultConfig { + minSdk 24 + if (!androidAbiFilters.isEmpty()) { + ndk { + abiFilters.addAll(androidAbiFilters) + } + } + externalNativeBuild { + cmake { + arguments "-DANDROID_STL=c++_shared" + if (packagesAndroidRuntimeInReactNative && !packagedMobileStaticModules.isEmpty()) { + arguments "-DOLIPHAUNT_MOBILE_STATIC_MODULES=${packagedMobileStaticModules.join(";")}" + if (packagedStaticRegistrySource != null) { + arguments "-DOLIPHAUNT_STATIC_REGISTRY_SOURCE=${packagedStaticRegistrySource}" + } + if (effectiveAndroidExtensionArchivesDir != null && !effectiveAndroidExtensionArchivesDir.isBlank()) { + arguments "-DOLIPHAUNT_EXTENSION_ARCHIVES_ROOT=${file(effectiveAndroidExtensionArchivesDir).absolutePath}" + } + if (packagedAndroidJniLibsDir != null && !packagedAndroidJniLibsDir.isBlank()) { + arguments "-DOLIPHAUNT_ANDROID_JNI_LIBS_ROOT=${file(packagedAndroidJniLibsDir).absolutePath}" + } + if (packagedAndroidLinkEvidenceFile != null && !packagedAndroidLinkEvidenceFile.isBlank()) { + arguments "-DOLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE=${file(packagedAndroidLinkEvidenceFile).absolutePath}" + } + } + } + } + } + + buildFeatures { + prefab true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + + externalNativeBuild { + cmake { + path file("src/main/cpp/CMakeLists.txt") + buildStagingDirectory cxxBuildRoot + version "3.22.1" + } + } + +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +repositories { + if (kotlinSdkMavenRepository != null && !kotlinSdkMavenRepository.isBlank()) { + maven { + url = uri(kotlinSdkMavenRepository) + } + } + google() + mavenCentral() +} + +dependencies { + implementation "com.facebook.react:react-android:${reactNativeVersion}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" + if (localKotlinSdkProject != null) { + implementation localKotlinSdkProject + } else { + implementation kotlinSdkDependency + } + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.16.1" +} + +tasks.named("preBuild") { + dependsOn("generateOliphauntCodegenArtifacts") + if (packagesAndroidRuntimeInReactNative) { + dependsOn("prepareOliphauntAndroidAssets") + dependsOn("prepareOliphauntAndroidJniLibs") + } +} diff --git a/src/sdks/react-native/android/gradle.properties b/src/sdks/react-native/android/gradle.properties new file mode 100644 index 00000000..0054b059 --- /dev/null +++ b/src/sdks/react-native/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1536m -Dfile.encoding=UTF-8 +kotlin.mpp.applyDefaultHierarchyTemplate=false +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/src/sdks/react-native/android/settings.gradle b/src/sdks/react-native/android/settings.gradle new file mode 100644 index 00000000..8a2a3645 --- /dev/null +++ b/src/sdks/react-native/android/settings.gradle @@ -0,0 +1,51 @@ +pluginManagement { + plugins { + id("com.android.library") version "8.13.1" + id("org.jetbrains.kotlin.android") version "2.1.20" + id("org.jetbrains.kotlin.multiplatform") version "2.1.20" + } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +def configuredKotlinSdkDir = settings.startParameter.projectProperties.get("oliphauntKotlinSdkDir") + ?: System.getenv("OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_DIR") +def configuredBuildRoot = settings.startParameter.projectProperties.get("oliphauntBuildRoot") + ?: System.getenv("OLIPHAUNT_GRADLE_BUILD_ROOT") + +if (configuredBuildRoot != null && !configuredBuildRoot.isBlank()) { + gradle.beforeProject { project -> + def slug = project.path == ":" ? "root" : project.path.substring(1).replace(":", "/") + project.layout.buildDirectory.set(new File(configuredBuildRoot, slug)) + } +} + +if (configuredKotlinSdkDir != null && !configuredKotlinSdkDir.isBlank()) { + def localKotlinSdkDir = file(configuredKotlinSdkDir) + def localKotlinSdkBuild = new File(localKotlinSdkDir, "build.gradle.kts") + def localKotlinSdkCatalog = new File(localKotlinSdkDir.parentFile, "gradle/libs.versions.toml") + if (!localKotlinSdkBuild.isFile()) { + throw new GradleException("Configured Oliphaunt Kotlin SDK project is missing build.gradle.kts: ${localKotlinSdkDir}") + } + dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } + if (localKotlinSdkCatalog.isFile()) { + versionCatalogs { + libs { + from(files(localKotlinSdkCatalog)) + } + } + } + } + + include(":oliphaunt") + project(":oliphaunt").projectDir = localKotlinSdkDir +} + +rootProject.name = "oliphaunt-react-native-android" diff --git a/src/sdks/react-native/android/src/main/AndroidManifest.xml b/src/sdks/react-native/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..94cbbcfc --- /dev/null +++ b/src/sdks/react-native/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/src/sdks/react-native/android/src/main/cpp/CMakeLists.txt b/src/sdks/react-native/android/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..cd24e38a --- /dev/null +++ b/src/sdks/react-native/android/src/main/cpp/CMakeLists.txt @@ -0,0 +1,120 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(liboliphaunt_reactnative LANGUAGES C CXX) + +find_package(fbjni REQUIRED CONFIG) +find_package(ReactAndroid REQUIRED CONFIG) + +add_library( + oliphauntreactnative + SHARED + OliphauntJsiBindings.cpp +) + +target_compile_features(oliphauntreactnative PRIVATE cxx_std_20) + +target_link_libraries( + oliphauntreactnative + fbjni::fbjni + ReactAndroid::jsi + ReactAndroid::reactnative +) + +function(oliphaunt_find_file out_var) + foreach(candidate IN LISTS ARGN) + if(EXISTS "${candidate}") + set("${out_var}" "${candidate}" PARENT_SCOPE) + return() + endif() + endforeach() + set("${out_var}" "" PARENT_SCOPE) +endfunction() + +if(DEFINED OLIPHAUNT_MOBILE_STATIC_MODULES AND NOT "${OLIPHAUNT_MOBILE_STATIC_MODULES}" STREQUAL "") + if(NOT DEFINED OLIPHAUNT_STATIC_REGISTRY_SOURCE OR NOT EXISTS "${OLIPHAUNT_STATIC_REGISTRY_SOURCE}") + message(FATAL_ERROR "OLIPHAUNT_MOBILE_STATIC_MODULES requires OLIPHAUNT_STATIC_REGISTRY_SOURCE from oliphaunt-resources --mobile-static-module") + endif() + if(NOT DEFINED OLIPHAUNT_EXTENSION_ARCHIVES_ROOT OR "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}" STREQUAL "") + message(FATAL_ERROR "OLIPHAUNT_MOBILE_STATIC_MODULES requires OLIPHAUNT_EXTENSION_ARCHIVES_ROOT from static-registry/archives or -PoliphauntAndroidExtensionArchivesDir") + endif() + if(NOT DEFINED OLIPHAUNT_ANDROID_JNI_LIBS_ROOT OR "${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}" STREQUAL "") + message(FATAL_ERROR "OLIPHAUNT_MOBILE_STATIC_MODULES requires OLIPHAUNT_ANDROID_JNI_LIBS_ROOT with liboliphaunt.so") + endif() + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + get_filename_component(OLIPHAUNT_ANDROID_LINK_EVIDENCE_DIR "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" DIRECTORY) + file(MAKE_DIRECTORY "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_DIR}") + file(WRITE "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "schema\toliphaunt-android-static-extension-link-v1\n") + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "abi\t${ANDROID_ABI}\n") + endif() + + oliphaunt_find_file(OLIPHAUNT_IMPORTED_LIBRARY + "${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}/jniLibs/${ANDROID_ABI}/liboliphaunt.so" + "${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}/${ANDROID_ABI}/liboliphaunt.so" + ) + if("${OLIPHAUNT_IMPORTED_LIBRARY}" STREQUAL "") + message(FATAL_ERROR "Could not find liboliphaunt.so for ${ANDROID_ABI} under OLIPHAUNT_ANDROID_JNI_LIBS_ROOT=${OLIPHAUNT_ANDROID_JNI_LIBS_ROOT}") + endif() + + add_library(oliphaunt_imported SHARED IMPORTED) + set_target_properties(oliphaunt_imported PROPERTIES + IMPORTED_LOCATION "${OLIPHAUNT_IMPORTED_LIBRARY}" + ) + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "runtime\tliboliphaunt\t${OLIPHAUNT_IMPORTED_LIBRARY}\n") + endif() + + add_library(oliphaunt_extensions SHARED + "${OLIPHAUNT_STATIC_REGISTRY_SOURCE}" + ) + target_include_directories(oliphaunt_extensions PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + ) + target_link_libraries(oliphaunt_extensions PRIVATE + oliphaunt_imported + ) + foreach(stem IN LISTS OLIPHAUNT_MOBILE_STATIC_MODULES) + oliphaunt_find_file(extension_archive + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/jniLibs/${ANDROID_ABI}/extensions/${stem}/liboliphaunt_extension_${stem}.a" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/android-${ANDROID_ABI}/extensions/${stem}/liboliphaunt_extension_${stem}.a" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/${ANDROID_ABI}/extensions/${stem}/liboliphaunt_extension_${stem}.a" + ) + if("${extension_archive}" STREQUAL "") + message(FATAL_ERROR "Could not find prebuilt liboliphaunt_extension_${stem}.a for ${ANDROID_ABI} under OLIPHAUNT_EXTENSION_ARCHIVES_ROOT=${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}") + endif() + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "extension\t${stem}\t${extension_archive}\n") + endif() + target_link_libraries(oliphaunt_extensions PRIVATE + "-Wl,--whole-archive" + "${extension_archive}" + "-Wl,--no-whole-archive" + ) + endforeach() + set(oliphaunt_dependency_archives) + foreach(dependency_root + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/jniLibs/${ANDROID_ABI}/dependencies" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/android-${ANDROID_ABI}/dependencies" + "${OLIPHAUNT_EXTENSION_ARCHIVES_ROOT}/${ANDROID_ABI}/dependencies") + if(IS_DIRECTORY "${dependency_root}") + file(GLOB_RECURSE dependency_archives CONFIGURE_DEPENDS "${dependency_root}/*.a") + list(APPEND oliphaunt_dependency_archives ${dependency_archives}) + endif() + endforeach() + if(oliphaunt_dependency_archives) + list(REMOVE_DUPLICATES oliphaunt_dependency_archives) + list(SORT oliphaunt_dependency_archives) + if(DEFINED OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE AND NOT "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" STREQUAL "") + foreach(dependency_archive IN LISTS oliphaunt_dependency_archives) + get_filename_component(dependency_dir "${dependency_archive}" DIRECTORY) + get_filename_component(dependency_name "${dependency_dir}" NAME) + file(APPEND "${OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE}" "dependency\t${dependency_name}\t${dependency_archive}\n") + endforeach() + endif() + target_link_libraries(oliphaunt_extensions PRIVATE + ${oliphaunt_dependency_archives} + ) + endif() + target_link_options(oliphaunt_extensions PRIVATE + "-Wl,-z,max-page-size=16384" + ) +endif() diff --git a/src/sdks/react-native/android/src/main/cpp/OliphauntJsiBindings.cpp b/src/sdks/react-native/android/src/main/cpp/OliphauntJsiBindings.cpp new file mode 100644 index 00000000..f14793fc --- /dev/null +++ b/src/sdks/react-native/android/src/main/cpp/OliphauntJsiBindings.cpp @@ -0,0 +1,778 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { +namespace { + +class OliphauntMutableBuffer final : public jsi::MutableBuffer { + public: + explicit OliphauntMutableBuffer(std::vector bytes) + : bytes_(std::move(bytes)) {} + + size_t size() const override + { + return bytes_.size(); + } + + uint8_t *data() override + { + return bytes_.data(); + } + + private: + std::vector bytes_; +}; + +struct PendingPromise final { + std::shared_ptr> resolve; + std::shared_ptr> reject; +}; + +struct PendingStream final { + std::shared_ptr> onChunk; + std::shared_ptr> resolve; + std::shared_ptr> reject; +}; + +std::mutex gPendingMutex; +std::unordered_map gPendingPromises; +std::unordered_map> gPendingStreams; +std::atomic gNextToken{1}; + +jsi::ArrayBuffer arrayBufferFromBytes(jsi::Runtime &runtime, std::vector bytes) +{ + return jsi::ArrayBuffer( + runtime, + std::make_shared(std::move(bytes))); +} + +jsi::Value createError(jsi::Runtime &runtime, const std::string &message) +{ + return runtime.global() + .getPropertyAsFunction(runtime, "Error") + .callAsConstructor(runtime, jsi::String::createFromUtf8(runtime, message)); +} + +size_t copySizeArgument(jsi::Runtime &runtime, double value, const char *name) +{ + constexpr double kMaxSafeInteger = 9007199254740991.0; + if (!std::isfinite(value) || + value < 0 || + std::trunc(value) != value || + value > kMaxSafeInteger || + value > static_cast(std::numeric_limits::max())) { + throw jsi::JSError( + runtime, + std::string("liboliphaunt JSI ") + name + " must be a non-negative integer"); + } + return static_cast(value); +} + +int64_t copyHandleArgument(jsi::Runtime &runtime, const jsi::Value &value) +{ + constexpr double kMaxSafeInteger = 9007199254740991.0; + if (!value.isNumber()) { + throw jsi::JSError(runtime, "liboliphaunt JSI handle must be a number"); + } + double handle = value.asNumber(); + if (!std::isfinite(handle) || + handle <= 0 || + std::trunc(handle) != handle || + handle > kMaxSafeInteger || + handle > static_cast(std::numeric_limits::max())) { + throw jsi::JSError(runtime, "liboliphaunt JSI handle must be a positive safe integer"); + } + return static_cast(handle); +} + +std::vector copyBinaryArgument(jsi::Runtime &runtime, const jsi::Value &value) +{ + if (!value.isObject()) { + throw jsi::JSError(runtime, "liboliphaunt JSI request must be an ArrayBuffer or typed array"); + } + + auto object = value.asObject(runtime); + size_t byteOffset = 0; + size_t byteLength = 0; + jsi::ArrayBuffer buffer = [&]() { + if (object.isArrayBuffer(runtime)) { + auto arrayBuffer = object.getArrayBuffer(runtime); + byteLength = arrayBuffer.size(runtime); + return arrayBuffer; + } + + auto bufferValue = object.getProperty(runtime, "buffer"); + if (!bufferValue.isObject() || !bufferValue.asObject(runtime).isArrayBuffer(runtime)) { + throw jsi::JSError(runtime, "liboliphaunt JSI request must be an ArrayBuffer or typed array"); + } + auto offsetValue = object.getProperty(runtime, "byteOffset"); + auto lengthValue = object.getProperty(runtime, "byteLength"); + if (!offsetValue.isNumber() || !lengthValue.isNumber()) { + throw jsi::JSError(runtime, "liboliphaunt JSI typed-array request is missing byteOffset/byteLength"); + } + byteOffset = copySizeArgument(runtime, offsetValue.asNumber(), "typed-array byteOffset"); + byteLength = copySizeArgument(runtime, lengthValue.asNumber(), "typed-array byteLength"); + return bufferValue.asObject(runtime).getArrayBuffer(runtime); + }(); + + if (byteOffset > buffer.size(runtime) || byteLength > buffer.size(runtime) - byteOffset) { + throw jsi::JSError(runtime, "liboliphaunt JSI typed-array request is out of bounds"); + } + + const uint8_t *begin = buffer.data(runtime) + byteOffset; + return std::vector(begin, begin + byteLength); +} + +std::string copyStringArgument(jsi::Runtime &runtime, const jsi::Value &value, const char *name) +{ + if (!value.isString()) { + throw jsi::JSError(runtime, std::string("liboliphaunt JSI ") + name + " must be a string"); + } + return value.asString(runtime).utf8(runtime); +} + +std::optional copyOptionalStringArgument( + jsi::Runtime &runtime, + const jsi::Value &value, + const char *name) +{ + if (value.isNull() || value.isUndefined()) { + return std::nullopt; + } + return copyStringArgument(runtime, value, name); +} + +void storePendingPromise(int64_t token, PendingPromise promise) +{ + std::lock_guard lock(gPendingMutex); + gPendingPromises.emplace(token, std::move(promise)); +} + +std::optional takePendingPromise(int64_t token) +{ + std::lock_guard lock(gPendingMutex); + auto iter = gPendingPromises.find(token); + if (iter == gPendingPromises.end()) { + return std::nullopt; + } + auto promise = std::move(iter->second); + gPendingPromises.erase(iter); + return promise; +} + +void storePendingStream(int64_t token, std::shared_ptr stream) +{ + std::lock_guard lock(gPendingMutex); + gPendingStreams.emplace(token, std::move(stream)); +} + +std::shared_ptr findPendingStream(int64_t token) +{ + std::lock_guard lock(gPendingMutex); + auto iter = gPendingStreams.find(token); + return iter == gPendingStreams.end() ? nullptr : iter->second; +} + +std::shared_ptr takePendingStream(int64_t token) +{ + std::lock_guard lock(gPendingMutex); + auto iter = gPendingStreams.find(token); + if (iter == gPendingStreams.end()) { + return nullptr; + } + auto stream = std::move(iter->second); + gPendingStreams.erase(iter); + return stream; +} + +jni::local_ref makeByteArray(const std::vector &bytes) +{ + if (bytes.size() > static_cast(std::numeric_limits::max())) { + throw std::overflow_error("liboliphaunt JSI request is too large for JNI byte[]"); + } + JNIEnv *env = jni::Environment::current(); + auto array = jni::adopt_local(env->NewByteArray(static_cast(bytes.size()))); + if (array == nullptr) { + throw std::runtime_error("failed to allocate liboliphaunt JNI request byte[]"); + } + if (!bytes.empty()) { + env->SetByteArrayRegion( + array.get(), + 0, + static_cast(bytes.size()), + reinterpret_cast(bytes.data())); + } + return array; +} + +std::vector copyByteArray(jni::alias_ref array) +{ + JNIEnv *env = jni::Environment::current(); + jbyteArray raw = array.get(); + if (raw == nullptr) { + return {}; + } + jsize length = env->GetArrayLength(raw); + std::vector bytes(static_cast(length)); + if (length > 0) { + env->GetByteArrayRegion(raw, 0, length, reinterpret_cast(bytes.data())); + } + return bytes; +} + +class OliphauntJsiPromiseCallback + : public jni::JavaClass { + public: + static constexpr const char *kJavaDescriptor = + "Ldev/oliphaunt/reactnative/OliphauntJsiPromiseCallback;"; + + static void registerNatives() + { + javaClassLocal()->registerNatives({ + makeNativeMethod("nativeResolveBytes", nativeResolveBytes), + makeNativeMethod("nativeResolveString", nativeResolveString), + makeNativeMethod("nativeReject", nativeReject), + }); + } + + private: + static void nativeResolveBytes( + jni::alias_ref, + jlong token, + jni::alias_ref response) + { + auto promise = takePendingPromise(static_cast(token)); + if (!promise) { + return; + } + std::vector bytes = copyByteArray(response); + promise->resolve->call([bytes = std::move(bytes)]( + jsi::Runtime &runtime, + jsi::Function &resolveFunction) mutable { + resolveFunction.call(runtime, arrayBufferFromBytes(runtime, std::move(bytes))); + }); + } + + static void nativeResolveString( + jni::alias_ref, + jlong token, + jni::alias_ref value) + { + auto promise = takePendingPromise(static_cast(token)); + if (!promise) { + return; + } + std::string restored = value != nullptr ? value->toStdString() : ""; + promise->resolve->call([restored]( + jsi::Runtime &runtime, + jsi::Function &resolveFunction) { + resolveFunction.call(runtime, jsi::String::createFromUtf8(runtime, restored)); + }); + } + + static void nativeReject( + jni::alias_ref, + jlong token, + jni::alias_ref message) + { + auto promise = takePendingPromise(static_cast(token)); + if (!promise) { + return; + } + std::string errorMessage = message != nullptr ? message->toStdString() : "liboliphaunt exec failed"; + promise->reject->call([errorMessage]( + jsi::Runtime &runtime, + jsi::Function &rejectFunction) { + rejectFunction.call(runtime, createError(runtime, errorMessage)); + }); + } +}; + +class OliphauntJsiStreamCallback + : public jni::JavaClass { + public: + static constexpr const char *kJavaDescriptor = + "Ldev/oliphaunt/reactnative/OliphauntJsiStreamCallback;"; + + static void registerNatives() + { + javaClassLocal()->registerNatives({ + makeNativeMethod("nativeEmitChunk", nativeEmitChunk), + makeNativeMethod("nativeResolveUnit", nativeResolveUnit), + makeNativeMethod("nativeReject", nativeReject), + }); + } + + private: + static void nativeEmitChunk( + jni::alias_ref, + jlong token, + jni::alias_ref chunk) + { + auto stream = findPendingStream(static_cast(token)); + if (stream == nullptr) { + return; + } + std::vector bytes = copyByteArray(chunk); + stream->onChunk->call([bytes = std::move(bytes)]( + jsi::Runtime &runtime, + jsi::Function &chunkFunction) mutable { + chunkFunction.call(runtime, arrayBufferFromBytes(runtime, std::move(bytes))); + }); + } + + static void nativeResolveUnit( + jni::alias_ref, + jlong token) + { + auto stream = takePendingStream(static_cast(token)); + if (stream == nullptr) { + return; + } + stream->resolve->call([](jsi::Runtime &runtime, jsi::Function &resolveFunction) { + resolveFunction.call(runtime, jsi::Value::undefined()); + }); + } + + static void nativeReject( + jni::alias_ref, + jlong token, + jni::alias_ref message) + { + auto stream = takePendingStream(static_cast(token)); + if (stream == nullptr) { + return; + } + std::string errorMessage = message != nullptr ? message->toStdString() : "liboliphaunt stream failed"; + stream->reject->call([errorMessage]( + jsi::Runtime &runtime, + jsi::Function &rejectFunction) { + rejectFunction.call(runtime, createError(runtime, errorMessage)); + }); + } +}; + +class OliphauntModuleJSIBindings + : public jni::JavaClass { + public: + static constexpr const char *kJavaDescriptor = + "Ldev/oliphaunt/reactnative/OliphauntModule;"; + + static void registerNatives() + { + javaClassLocal()->registerNatives({ + makeNativeMethod("getBindingsInstaller", getBindingsInstaller), + }); + } + + private: + static jni::local_ref getBindingsInstaller( + jni::alias_ref module) + { + auto moduleGlobal = jni::make_global(module); + return BindingsInstallerHolder::newObjectCxxArgs( + [moduleGlobal]( + jsi::Runtime &runtime, + const std::shared_ptr &callInvoker) { + auto transport = jsi::Object(runtime); + transport.setProperty(runtime, "version", 1); + transport.setProperty( + runtime, + "execProtocolRaw", + jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolRaw"), + 2, + [moduleGlobal, callInvoker]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count != 2) { + throw jsi::JSError(runtime, "liboliphaunt JSI execProtocolRaw expects handle and request"); + } + + int64_t handle = copyHandleArgument(runtime, args[0]); + std::vector request = copyBinaryArgument(runtime, args[1]); + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolRawExecutor"), + 2, + [moduleGlobal, callInvoker, handle, request = std::move(request)]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *promiseArgs, + size_t promiseArgCount) mutable -> jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw jsi::JSError( + runtime, + "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + int64_t token = gNextToken.fetch_add(1); + PendingPromise pending{ + std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker), + std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker), + }; + auto reject = pending.reject; + storePendingPromise(token, std::move(pending)); + + try { + auto requestArray = makeByteArray(request); + static const auto callbackConstructor = + OliphauntJsiPromiseCallback::javaClassStatic() + ->getConstructor(); + auto callback = + OliphauntJsiPromiseCallback::javaClassStatic() + ->newObject(callbackConstructor, static_cast(token)); + static const auto execProtocolRawBytes = + OliphauntModuleJSIBindings::javaClassStatic() + ->getMethod( + "execProtocolRawBytes"); + execProtocolRawBytes( + moduleGlobal, + static_cast(handle), + requestArray.get(), + callback.get()); + } catch (const std::exception &error) { + takePendingPromise(token); + std::string message = error.what(); + reject->call([message]( + jsi::Runtime &runtime, + jsi::Function &rejectFunction) { + rejectFunction.call(runtime, createError(runtime, message)); + }); + } + return jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + transport.setProperty( + runtime, + "execProtocolStream", + jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolStream"), + 3, + [moduleGlobal, callInvoker]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count != 3 || + !args[2].isObject() || + !args[2].asObject(runtime).isFunction(runtime)) { + throw jsi::JSError( + runtime, + "liboliphaunt JSI execProtocolStream expects handle, request, and onChunk"); + } + + int64_t handle = copyHandleArgument(runtime, args[0]); + std::vector request = copyBinaryArgument(runtime, args[1]); + auto onChunk = std::make_shared>( + runtime, + args[2].asObject(runtime).getFunction(runtime), + callInvoker); + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolStreamExecutor"), + 2, + [moduleGlobal, + callInvoker, + handle, + request = std::move(request), + onChunk = std::move(onChunk)]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *promiseArgs, + size_t promiseArgCount) mutable -> jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw jsi::JSError( + runtime, + "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + int64_t token = gNextToken.fetch_add(1); + auto stream = std::make_shared(PendingStream{ + onChunk, + std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker), + std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker), + }); + auto reject = stream->reject; + storePendingStream(token, stream); + + try { + auto requestArray = makeByteArray(request); + static const auto callbackConstructor = + OliphauntJsiStreamCallback::javaClassStatic() + ->getConstructor(); + auto callback = + OliphauntJsiStreamCallback::javaClassStatic() + ->newObject(callbackConstructor, static_cast(token)); + static const auto execProtocolStreamBytes = + OliphauntModuleJSIBindings::javaClassStatic() + ->getMethod( + "execProtocolStreamBytes"); + execProtocolStreamBytes( + moduleGlobal, + static_cast(handle), + requestArray.get(), + callback.get()); + } catch (const std::exception &error) { + takePendingStream(token); + std::string message = error.what(); + reject->call([message]( + jsi::Runtime &runtime, + jsi::Function &rejectFunction) { + rejectFunction.call(runtime, createError(runtime, message)); + }); + } + return jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + transport.setProperty( + runtime, + "backup", + jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntBackup"), + 2, + [moduleGlobal, callInvoker]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count != 2) { + throw jsi::JSError(runtime, "liboliphaunt JSI backup expects handle and format"); + } + + int64_t handle = copyHandleArgument(runtime, args[0]); + std::string format = copyStringArgument(runtime, args[1], "backup format"); + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntBackupExecutor"), + 2, + [moduleGlobal, callInvoker, handle, format = std::move(format)]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *promiseArgs, + size_t promiseArgCount) -> jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw jsi::JSError( + runtime, + "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + int64_t token = gNextToken.fetch_add(1); + PendingPromise pending{ + std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker), + std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker), + }; + auto reject = pending.reject; + storePendingPromise(token, std::move(pending)); + + try { + static const auto callbackConstructor = + OliphauntJsiPromiseCallback::javaClassStatic() + ->getConstructor(); + auto callback = + OliphauntJsiPromiseCallback::javaClassStatic() + ->newObject(callbackConstructor, static_cast(token)); + static const auto backupBytes = + OliphauntModuleJSIBindings::javaClassStatic() + ->getMethod( + "backupBytes"); + auto formatString = jni::make_jstring(format); + backupBytes( + moduleGlobal, + static_cast(handle), + formatString.get(), + callback.get()); + } catch (const std::exception &error) { + takePendingPromise(token); + std::string message = error.what(); + reject->call([message]( + jsi::Runtime &runtime, + jsi::Function &rejectFunction) { + rejectFunction.call(runtime, createError(runtime, message)); + }); + } + return jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + transport.setProperty( + runtime, + "restore", + jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntRestore"), + 5, + [moduleGlobal, callInvoker]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count != 5 || !args[3].isBool()) { + throw jsi::JSError( + runtime, + "liboliphaunt JSI restore expects root, format, artifact, replaceExisting, and libraryPath"); + } + + std::string root = copyStringArgument(runtime, args[0], "restore root"); + std::string format = copyStringArgument(runtime, args[1], "restore format"); + std::vector artifact = copyBinaryArgument(runtime, args[2]); + bool replaceExisting = args[3].getBool(); + auto libraryPath = copyOptionalStringArgument(runtime, args[4], "restore libraryPath"); + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "liboliphauntRestoreExecutor"), + 2, + [moduleGlobal, + callInvoker, + root = std::move(root), + format = std::move(format), + artifact = std::move(artifact), + replaceExisting, + libraryPath = std::move(libraryPath)]( + jsi::Runtime &runtime, + const jsi::Value &, + const jsi::Value *promiseArgs, + size_t promiseArgCount) mutable -> jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw jsi::JSError( + runtime, + "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + int64_t token = gNextToken.fetch_add(1); + PendingPromise pending{ + std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker), + std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker), + }; + auto reject = pending.reject; + storePendingPromise(token, std::move(pending)); + + try { + auto rootString = jni::make_jstring(root); + auto formatString = jni::make_jstring(format); + auto artifactArray = makeByteArray(artifact); + jni::local_ref libraryPathString; + jni::JString::javaobject libraryPathObject = nullptr; + if (libraryPath) { + libraryPathString = jni::make_jstring(*libraryPath); + libraryPathObject = libraryPathString.get(); + } + static const auto callbackConstructor = + OliphauntJsiPromiseCallback::javaClassStatic() + ->getConstructor(); + auto callback = + OliphauntJsiPromiseCallback::javaClassStatic() + ->newObject(callbackConstructor, static_cast(token)); + static const auto restoreBytes = + OliphauntModuleJSIBindings::javaClassStatic() + ->getMethod("restoreBytes"); + restoreBytes( + moduleGlobal, + rootString.get(), + formatString.get(), + artifactArray.get(), + static_cast(replaceExisting), + libraryPathObject, + callback.get()); + } catch (const std::exception &error) { + takePendingPromise(token); + std::string message = error.what(); + reject->call([message]( + jsi::Runtime &runtime, + jsi::Function &rejectFunction) { + rejectFunction.call(runtime, createError(runtime, message)); + }); + } + return jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + runtime.global().setProperty(runtime, "__oliphauntReactNativeJsi", std::move(transport)); + }); + } +}; + +} // namespace + +} // namespace facebook::react + +JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *) +{ + return facebook::jni::initialize(vm, [] { + facebook::react::OliphauntModuleJSIBindings::registerNatives(); + facebook::react::OliphauntJsiPromiseCallback::registerNatives(); + facebook::react::OliphauntJsiStreamCallback::registerNatives(); + }); +} diff --git a/src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h b/src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h new file mode 100644 index 00000000..262d46d5 --- /dev/null +++ b/src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h @@ -0,0 +1,172 @@ +#ifndef OLIPHAUNT_H +#define OLIPHAUNT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define OLIPHAUNT_ABI_VERSION 6u +#define OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION 1u + +#define OLIPHAUNT_CAP_PROTOCOL_RAW (1ull << 0) +#define OLIPHAUNT_CAP_PROTOCOL_STREAM (1ull << 1) +#define OLIPHAUNT_CAP_MULTI_INSTANCE (1ull << 2) +#define OLIPHAUNT_CAP_SERVER_MODE (1ull << 3) +#define OLIPHAUNT_CAP_EXTENSIONS (1ull << 4) +#define OLIPHAUNT_CAP_QUERY_CANCEL (1ull << 5) +#define OLIPHAUNT_CAP_BACKUP_RESTORE (1ull << 6) +#define OLIPHAUNT_CAP_SIMPLE_QUERY (1ull << 7) +#define OLIPHAUNT_CAP_STATIC_EXTENSIONS (1ull << 8) +#define OLIPHAUNT_CAP_LOGICAL_REOPEN (1ull << 9) + +#define OLIPHAUNT_BACKUP_FORMAT_SQL 1u +#define OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE 2u +#define OLIPHAUNT_BACKUP_FORMAT_OLIPHAUNT_ARCHIVE 3u + +#if defined(_WIN32) && defined(OLIPHAUNT_BUILDING_DLL) +#define OLIPHAUNT_API __declspec(dllexport) +#elif defined(_WIN32) +#define OLIPHAUNT_API __declspec(dllimport) +#else +#define OLIPHAUNT_API +#endif + +/* + * The caller already owns an equivalent root lock for this PGDATA path. + * + * Leave this flag unset for plain C, Swift, Kotlin, and other direct C ABI + * callers; oliphaunt_init will then take a non-blocking stable filesystem lease + * for and create /.oliphaunt.lock as the + * visible root marker. The Rust SDK sets this flag because it owns a stronger + * process-plus-filesystem root coordinator across direct, broker, server, + * backup, and restore paths. + */ +#define OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK (1ull << 0) + +#define OLIPHAUNT_RESTORE_REPLACE_EXISTING (1ull << 0) + +typedef struct OliphauntHandle OliphauntHandle; + +typedef struct OliphauntStaticExtensionSymbol { + const char *name; + void *address; +} OliphauntStaticExtensionSymbol; + +typedef struct OliphauntStaticExtension { + uint32_t abi_version; + const char *name; + const void *(*magic)(void); + void (*init)(void); + const OliphauntStaticExtensionSymbol *symbols; + size_t symbol_count; + uint64_t reserved_flags; +} OliphauntStaticExtension; + +/* + * Registers statically linked PostgreSQL extension modules for the embedded + * backend's normal LOAD path. + * + * Call this before oliphaunt_init in processes that link extension code directly + * into the application or SDK library. The registry is process-wide and becomes + * immutable once backend startup begins. Each extension name is the module stem + * used by SQL, for example AS 'vector', and each symbol row exposes the C + * symbols PostgreSQL would otherwise resolve with dlsym(). + */ + +/* + * Direct-mode extension compatibility contract: + * + * oliphaunt_init sets the process PGDATA environment variable to this config's + * pgdata path while the embedded backend is active, because PostgreSQL + * extensions may read PGDATA through standard process APIs. oliphaunt_detach + * releases a logical direct-mode lease but keeps the resident backend alive; + * oliphaunt_close is terminal for the process lifetime and restores the caller's + * previous PGDATA value, or unsets it if it was unset. + * + * Callers that require process environment isolation should use broker/server + * mode through the Rust SDK instead of keeping multiple direct-mode backends in + * one process. + */ +typedef struct OliphauntConfig { + uint32_t abi_version; + const char *pgdata; + const char *runtime_dir; + const char *username; + const char *database; + uint64_t reserved_flags; + const char *const *startup_args; + size_t startup_arg_count; +} OliphauntConfig; + +typedef struct OliphauntResponse { + uint8_t *data; + size_t len; +} OliphauntResponse; + +typedef struct OliphauntArchiveFile { + const char *path; + const uint8_t *data; + size_t len; + uint32_t mode; + uint64_t reserved_flags; +} OliphauntArchiveFile; + +typedef struct OliphauntBackupOptions { + uint32_t abi_version; + uint32_t format; + const OliphauntArchiveFile *generated_files; + size_t generated_file_count; + uint64_t reserved_flags; +} OliphauntBackupOptions; + +typedef struct OliphauntRestoreOptions { + uint32_t abi_version; + const char *root; + uint32_t format; + const uint8_t *data; + size_t len; + uint64_t flags; +} OliphauntRestoreOptions; + +typedef int32_t (*OliphauntStreamCallback)(void *context, const uint8_t *data, size_t len); + +OLIPHAUNT_API int32_t oliphaunt_init(const OliphauntConfig *config, OliphauntHandle **out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_simple_query( + OliphauntHandle *handle, + const char *sql, + size_t sql_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol_stream( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +OLIPHAUNT_API int32_t oliphaunt_backup(OliphauntHandle *handle, uint32_t format, OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_backup_ex( + OliphauntHandle *handle, + const OliphauntBackupOptions *options, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_restore(const OliphauntRestoreOptions *options); +OLIPHAUNT_API int32_t oliphaunt_cancel(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_detach(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_close(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_register_static_extensions(const OliphauntStaticExtension *extensions, size_t count); +OLIPHAUNT_API const char *oliphaunt_last_error(OliphauntHandle *handle); +OLIPHAUNT_API const char *oliphaunt_version(void); +OLIPHAUNT_API uint64_t oliphaunt_capabilities(void); +OLIPHAUNT_API void oliphaunt_free_response(OliphauntResponse *response); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiCallback.kt b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiCallback.kt new file mode 100644 index 00000000..7e5c58c9 --- /dev/null +++ b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiCallback.kt @@ -0,0 +1,9 @@ +package dev.oliphaunt.reactnative + +interface OliphauntJsiCallback { + fun resolveBytes(response: ByteArray) + + fun resolveString(value: String) + + fun reject(code: String, message: String?) +} diff --git a/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiPromiseCallback.kt b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiPromiseCallback.kt new file mode 100644 index 00000000..ec974b6e --- /dev/null +++ b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiPromiseCallback.kt @@ -0,0 +1,26 @@ +package dev.oliphaunt.reactnative + +import com.facebook.proguard.annotations.DoNotStrip + +@DoNotStrip +class OliphauntJsiPromiseCallback @DoNotStrip constructor( + private val token: Long, +) : OliphauntJsiCallback { + override fun resolveBytes(response: ByteArray) { + nativeResolveBytes(token, response) + } + + override fun resolveString(value: String) { + nativeResolveString(token, value) + } + + override fun reject(code: String, message: String?) { + nativeReject(token, if (message.isNullOrBlank()) code else "$code: $message") + } + + private external fun nativeResolveBytes(token: Long, response: ByteArray) + + private external fun nativeResolveString(token: Long, value: String) + + private external fun nativeReject(token: Long, message: String) +} diff --git a/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiStreamCallback.kt b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiStreamCallback.kt new file mode 100644 index 00000000..203366f6 --- /dev/null +++ b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiStreamCallback.kt @@ -0,0 +1,26 @@ +package dev.oliphaunt.reactnative + +import com.facebook.proguard.annotations.DoNotStrip + +@DoNotStrip +class OliphauntJsiStreamCallback @DoNotStrip constructor( + private val token: Long, +) { + fun emitChunk(chunk: ByteArray) { + nativeEmitChunk(token, chunk) + } + + fun resolveUnit() { + nativeResolveUnit(token) + } + + fun reject(code: String, message: String?) { + nativeReject(token, if (message.isNullOrBlank()) code else "$code: $message") + } + + private external fun nativeEmitChunk(token: Long, chunk: ByteArray) + + private external fun nativeResolveUnit(token: Long) + + private external fun nativeReject(token: Long, message: String) +} diff --git a/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt new file mode 100644 index 00000000..72b7ed8c --- /dev/null +++ b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt @@ -0,0 +1,648 @@ +package dev.oliphaunt.reactnative + +import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.bridge.WritableNativeArray +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.turbomodule.core.interfaces.BindingsInstallerHolder +import com.facebook.react.turbomodule.core.interfaces.TurboModuleWithJSIBindings +import com.facebook.soloader.SoLoader +import android.os.Debug +import dev.oliphaunt.BackupArtifact +import dev.oliphaunt.BackupFormat +import dev.oliphaunt.BackupRequest +import dev.oliphaunt.DurabilityProfile +import dev.oliphaunt.EngineCapabilities +import dev.oliphaunt.EngineMode +import dev.oliphaunt.EngineModeSupport +import dev.oliphaunt.OliphauntAndroid +import dev.oliphaunt.OliphauntConfig +import dev.oliphaunt.OliphauntDatabase +import dev.oliphaunt.OliphauntExtensionSizeReport +import dev.oliphaunt.OliphauntPackageSizeReport +import dev.oliphaunt.PostgresStartupGuc +import dev.oliphaunt.ProtocolRequest +import dev.oliphaunt.RestoreRequest +import dev.oliphaunt.RestoreTargetPolicy +import dev.oliphaunt.RuntimeFootprintProfile +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class OliphauntModule( + private val reactContext: ReactApplicationContext, +) : NativeOliphauntSpec(reactContext), TurboModuleWithJSIBindings { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val nextHandle = AtomicLong(1) + private val sessions = ConcurrentHashMap() + private val sessionKeys = ConcurrentHashMap() + private val sessionMutex = Mutex() + + override fun getName(): String = NAME + + @DoNotStrip + external override fun getBindingsInstaller(): BindingsInstallerHolder + + override fun supportedModes(promise: Promise) { + promise.resolve( + WritableNativeArray().apply { + OliphauntAndroid.supportedModes().forEach { pushMap(it.toWritableMap()) } + }, + ) + } + + override fun packageSizeReport(config: ReadableMap, promise: Promise) { + scope.launch { + runCatching { + val configuredRoot = config.pathOverride("resourceRoot") + val report = configuredRoot + ?.let { root -> OliphauntAndroid.packageSizeReport(File(root)) } + ?: OliphauntAndroid.packageSizeReport(reactContext) + report?.toWritableMap() + }.fold( + onSuccess = promise::resolve, + onFailure = { error -> promise.reject("liboliphaunt_package_size_failed", error.message, error) }, + ) + } + } + + override fun processMemory(promise: Promise) { + promise.resolve(processMemoryReport()) + } + + override fun open(config: ReadableMap, promise: Promise) { + scope.launch { + runCatching { + val openConfig = parseOpenConfig(config) + sessionMutex.withLock { + existingHandleFor(openConfig)?.let { return@withLock it.toDouble() } + if (sessions.isNotEmpty()) { + throw IllegalStateException( + "React Native nativeDirect already has an active database; close it before opening another root", + ) + } + val session = OliphauntAndroid.open( + context = reactContext, + config = openConfig.config, + libraryPath = openConfig.libraryPath, + runtimeDirectory = openConfig.runtimeDirectory, + username = openConfig.username, + database = openConfig.database, + ) + val handle = nextHandle.getAndIncrement() + sessions[handle] = session + sessionKeys[handle] = openConfig.sessionKey + handle.toDouble() + } + }.fold( + onSuccess = promise::resolve, + onFailure = { error -> promise.reject("liboliphaunt_open_failed", error.message, error) }, + ) + } + } + + @DoNotStrip + fun execProtocolRawBytes( + handle: Long, + request: ByteArray, + callback: OliphauntJsiPromiseCallback, + ) { + val session = sessions[handle] + if (session == null) { + callback.reject("liboliphaunt_unknown_handle", "unknown Oliphaunt handle") + return + } + scope.launch { + runCatching { + session.execProtocolRaw(ProtocolRequest(request)).bytes + }.fold( + onSuccess = callback::resolveBytes, + onFailure = { error -> callback.reject("liboliphaunt_exec_failed", error.message) }, + ) + } + } + + @DoNotStrip + fun execProtocolStreamBytes( + handle: Long, + request: ByteArray, + callback: OliphauntJsiStreamCallback, + ) { + val session = sessions[handle] + if (session == null) { + callback.reject("liboliphaunt_unknown_handle", "unknown Oliphaunt handle") + return + } + scope.launch { + runCatching { + session.execProtocolStream(ProtocolRequest(request)) { chunk -> + callback.emitChunk(chunk.bytes) + } + }.fold( + onSuccess = { callback.resolveUnit() }, + onFailure = { error -> callback.reject("liboliphaunt_stream_failed", error.message) }, + ) + } + } + + @DoNotStrip + fun backupBytes( + handle: Long, + format: String, + callback: OliphauntJsiPromiseCallback, + ) { + val session = sessions[handle] + if (session == null) { + callback.reject("liboliphaunt_unknown_handle", "unknown Oliphaunt handle") + return + } + scope.launch { + runCatching { + session.backup(BackupRequest(parseBackupFormat(format))).bytes + }.fold( + onSuccess = callback::resolveBytes, + onFailure = { error -> callback.reject("liboliphaunt_backup_failed", error.message) }, + ) + } + } + + override fun close(handle: Double, promise: Promise) { + val key = handle.toLong() + scope.launch { + runCatching { + sessionMutex.withLock { + val session = sessions.remove(key) + sessionKeys.remove(key) + session?.close() + } + }.fold( + onSuccess = { promise.resolve(null) }, + onFailure = { error -> promise.reject("liboliphaunt_close_failed", error.message, error) }, + ) + } + } + + override fun invalidate() { + runBlocking(Dispatchers.IO) { + val sessionsToClose = sessionMutex.withLock { + val active = sessions.values.toList() + sessions.clear() + sessionKeys.clear() + active + } + if (sessionsToClose.isNotEmpty()) { + sessionsToClose.forEach { session -> runCatching { session.close() } } + } + } + scope.cancel() + super.invalidate() + } + + @DoNotStrip + fun restoreBytes( + root: String, + format: String, + artifact: ByteArray, + replaceExisting: Boolean, + libraryPath: String?, + callback: OliphauntJsiPromiseCallback, + ) { + scope.launch { + runCatching { + validateRootPath(root, "restore root") + val request = RestoreRequest( + artifact = BackupArtifact(parseBackupFormat(format), artifact), + root = root, + targetPolicy = if (replaceExisting) { + RestoreTargetPolicy.ReplaceExisting + } else { + RestoreTargetPolicy.FailIfExists + }, + ) + OliphauntAndroid.restore( + context = reactContext, + request = request, + libraryPath = reactNativeLibraryPath(validatePathOverride(libraryPath, "libraryPath")), + ) + }.fold( + onSuccess = callback::resolveString, + onFailure = { error -> callback.reject("liboliphaunt_restore_failed", error.message) }, + ) + } + } + + override fun cancel(handle: Double, promise: Promise) { + val session = sessionFor(handle, promise) ?: return + scope.launch { + runCatching { + session.cancel() + }.fold( + onSuccess = { promise.resolve(null) }, + onFailure = { error -> promise.reject("liboliphaunt_cancel_failed", error.message, error) }, + ) + } + } + + override fun capabilities(handle: Double, promise: Promise) { + val session = sessionFor(handle, promise) ?: return + scope.launch { + runCatching { + session.capabilities().toWritableMap() + }.fold( + onSuccess = promise::resolve, + onFailure = { error -> promise.reject("liboliphaunt_capabilities_failed", error.message, error) }, + ) + } + } + + private fun sessionFor(handle: Double, promise: Promise): OliphauntDatabase? { + val session = sessions[handle.toLong()] + if (session == null) { + promise.reject("liboliphaunt_unknown_handle", "unknown Oliphaunt handle") + } + return session + } + + private fun parseOpenConfig(config: ReadableMap): ReactNativeAndroidOpenConfig { + val mode = parseEngineMode(config.string("engine") ?: "nativeDirect") + if (mode != EngineMode.NativeDirect) { + throw IllegalArgumentException("React Native Android currently supports NativeDirect, got $mode") + } + val root = config.string("root")?.let { + resolveRootSpecifier(validateRootPath(it, "database root"), reactContext.filesDir) + } + val runtimeDirectory = reactNativeRuntimeDirectory(config.pathOverride("runtimeDirectory")) + val libraryPath = reactNativeLibraryPath(config.pathOverride("libraryPath")) + val username = config.startupIdentity("username") + val database = config.startupIdentity("database") + + return ReactNativeAndroidOpenConfig( + config = OliphauntConfig( + mode = mode, + root = root, + durability = parseDurability(config.string("durability") ?: "balanced"), + runtimeFootprint = parseRuntimeFootprint(config.string("runtimeFootprint") ?: "balancedMobile"), + startupGucs = config.startupGucs("startupGUCs"), + username = username, + database = database, + extensions = config.stringList("extensions"), + ), + libraryPath = libraryPath, + runtimeDirectory = runtimeDirectory, + username = username ?: "postgres", + database = database ?: "postgres", + ) + } + + private data class ReactNativeAndroidOpenConfig( + val config: OliphauntConfig, + val libraryPath: String?, + val runtimeDirectory: String?, + val username: String, + val database: String, + ) { + val sessionKey: String = + listOf( + config.mode.name, + config.root.orEmpty(), + config.durability.name, + config.runtimeFootprint.name, + config.startupGucs.joinToString(",") { "${it.name}=${it.value}" }, + username, + database, + config.extensions.joinToString(","), + libraryPath.orEmpty(), + runtimeDirectory.orEmpty(), + ).joinToString(separator = "\u001f") + } + + private fun existingHandleFor(openConfig: ReactNativeAndroidOpenConfig): Long? = + sessionKeys.entries.firstOrNull { (handle, sessionKey) -> + sessionKey == openConfig.sessionKey && sessions.containsKey(handle) + }?.key + + companion object { + const val NAME = "Oliphaunt" + + init { + SoLoader.loadLibrary("oliphauntreactnative") + } + + private fun ReadableMap.string(name: String): String? = + when { + !hasKey(name) || isNull(name) -> null + getType(name) == ReadableType.String -> getString(name) + else -> throw IllegalArgumentException("$name must be a string") + } + + private fun ReadableMap.array(name: String): ReadableArray? = + when { + !hasKey(name) || isNull(name) -> null + getType(name) == ReadableType.Array -> getArray(name) + else -> throw IllegalArgumentException(arrayOfStringsMessage(name)) + } + + private fun ReadableMap.stringList(name: String): List { + val array = array(name) ?: return emptyList() + return buildList { + for (index in 0 until array.size()) { + if (array.getType(index) != ReadableType.String) { + throw IllegalArgumentException(arrayOfStringsMessage(name)) + } + add(array.getString(index).orEmpty()) + } + } + } + + private fun ReadableMap.startupIdentity(name: String): String? { + val value = string(name) ?: return null + if (value.isBlank()) { + throw IllegalArgumentException(startupIdentityMessage(name, StartupIdentityError.Empty)) + } + if (value.any { it.code == 0 }) { + throw IllegalArgumentException(startupIdentityMessage(name, StartupIdentityError.Nul)) + } + return value + } + + private fun ReadableMap.startupGucs(name: String): List = + stringList(name).map { assignment -> + val separator = assignment.indexOf('=') + if (separator < 0) { + throw IllegalArgumentException("PostgreSQL startup GUC string must use name=value") + } + PostgresStartupGuc( + name = assignment.substring(0, separator), + value = assignment.substring(separator + 1), + ) + } + + private fun validateRootPath(value: String, name: String): String { + if (value.isBlank()) { + throw IllegalArgumentException("$name must not be empty") + } + if (value.any { it.code == 0 }) { + throw IllegalArgumentException("$name must not contain NUL bytes") + } + return value + } + + private fun resolveRootSpecifier(value: String, filesDir: File): String { + value.removePrefixOrNull("app-support://")?.let { suffix -> + return sandboxRoot(suffix, filesDir).absolutePath + } + value.removePrefixOrNull("documents://")?.let { suffix -> + return sandboxRoot(suffix, filesDir).absolutePath + } + return value + } + + private fun sandboxRoot(suffix: String, filesDir: File): File { + val components = validatedSandboxRootComponents(suffix) + return components.fold(File(filesDir, "Oliphaunt")) { root, component -> + File(root, component) + } + } + + private fun validatedSandboxRootComponents(suffix: String): List { + val trimmed = suffix.trim('/') + if (trimmed.isEmpty()) { + throw IllegalArgumentException("database root sandbox specifier must include a relative path") + } + val components = trimmed.split('/') + if (components.any { it == "." || it == ".." }) { + throw IllegalArgumentException("database root sandbox specifier must not contain '.' or '..'") + } + return components + } + + private fun ReadableMap.pathOverride(name: String): String? = + validatePathOverride(string(name), name) + + private fun validatePathOverride(value: String?, name: String): String? { + if (value == null) { + return null + } + if (value.isBlank()) { + throw IllegalArgumentException(pathOverrideMessage(name, PathOverrideError.Empty)) + } + if (value.any { it.code == 0 }) { + throw IllegalArgumentException(pathOverrideMessage(name, PathOverrideError.Nul)) + } + return value + } + + private enum class PathOverrideError { + Empty, + Nul, + } + + private fun pathOverrideMessage(name: String, error: PathOverrideError): String = + when (name to error) { + "libraryPath" to PathOverrideError.Empty -> "libraryPath must not be empty" + "libraryPath" to PathOverrideError.Nul -> "libraryPath must not contain NUL bytes" + "runtimeDirectory" to PathOverrideError.Empty -> "runtimeDirectory must not be empty" + "runtimeDirectory" to PathOverrideError.Nul -> "runtimeDirectory must not contain NUL bytes" + "resourceRoot" to PathOverrideError.Empty -> "resourceRoot must not be empty" + "resourceRoot" to PathOverrideError.Nul -> "resourceRoot must not contain NUL bytes" + else -> when (error) { + PathOverrideError.Empty -> "$name must not be empty" + PathOverrideError.Nul -> "$name must not contain NUL bytes" + } + } + + private enum class StartupIdentityError { + Empty, + Nul, + } + + private fun startupIdentityMessage(name: String, error: StartupIdentityError): String = + when (name to error) { + "username" to StartupIdentityError.Empty -> "username must not be empty" + "username" to StartupIdentityError.Nul -> "username must not contain NUL bytes" + "database" to StartupIdentityError.Empty -> "database must not be empty" + "database" to StartupIdentityError.Nul -> "database must not contain NUL bytes" + else -> when (error) { + StartupIdentityError.Empty -> "$name must not be empty" + StartupIdentityError.Nul -> "$name must not contain NUL bytes" + } + } + + private fun arrayOfStringsMessage(name: String): String = + when (name) { + "extensions" -> "extensions must be an array of strings" + "startupGUCs" -> "startupGUCs must be an array of strings" + else -> "$name must be an array of strings" + } + + private fun environment(name: String): String? = + System.getenv(name)?.takeIf(String::isNotEmpty) + + private fun String.removePrefixOrNull(prefix: String): String? = + if (startsWith(prefix)) substring(prefix.length) else null + + private fun reactNativeLibraryPath(configured: String?): String? = + configured + ?: environment("OLIPHAUNT_REACT_NATIVE_ANDROID_LIBRARY") + ?: environment("OLIPHAUNT_KOTLIN_ANDROID_LIBRARY") + ?: environment("LIBOLIPHAUNT_PATH") + ?: environment("OLIPHAUNT_LIBRARY") + + private fun reactNativeRuntimeDirectory(configured: String?): String? = + configured + ?: environment("OLIPHAUNT_REACT_NATIVE_ANDROID_RUNTIME_DIR") + ?: environment("OLIPHAUNT_KOTLIN_ANDROID_RUNTIME_DIR") + ?: environment("OLIPHAUNT_INSTALL_DIR") + ?: environment("OLIPHAUNT_RUNTIME_DIR") + + private fun parseEngineMode(engine: String): EngineMode = when (engine) { + "nativeDirect" -> EngineMode.NativeDirect + "nativeBroker" -> EngineMode.NativeBroker + "nativeServer" -> EngineMode.NativeServer + else -> throw IllegalArgumentException("unknown liboliphaunt engine '$engine'") + } + + private fun parseDurability(durability: String): DurabilityProfile = when (durability) { + "safe" -> DurabilityProfile.Safe + "balanced" -> DurabilityProfile.Balanced + "fastDev" -> DurabilityProfile.FastDev + else -> throw IllegalArgumentException("unknown liboliphaunt durability profile '$durability'") + } + + private fun parseRuntimeFootprint(profile: String): RuntimeFootprintProfile = when (profile) { + "throughput" -> RuntimeFootprintProfile.Throughput + "balancedMobile" -> RuntimeFootprintProfile.BalancedMobile + "smallMobile" -> RuntimeFootprintProfile.SmallMobile + else -> throw IllegalArgumentException("unknown liboliphaunt runtime footprint profile '$profile'") + } + + private fun parseBackupFormat(format: String): BackupFormat = when (format) { + "sql" -> BackupFormat.Sql + "physicalArchive" -> BackupFormat.PhysicalArchive + "oliphauntArchive" -> BackupFormat.OliphauntArchive + else -> throw IllegalArgumentException("unknown liboliphaunt backup format '$format'") + } + + private fun EngineMode.wireName(): String = when (this) { + EngineMode.NativeDirect -> "nativeDirect" + EngineMode.NativeBroker -> "nativeBroker" + EngineMode.NativeServer -> "nativeServer" + } + + private fun EngineCapabilities.toWritableMap(): WritableNativeMap = + WritableNativeMap().apply { + putString("engine", mode.wireName()) + putBoolean("processIsolated", processIsolated) + putBoolean("multiRoot", multiRoot) + putBoolean("reopenable", reopenable) + putBoolean("sameRootLogicalReopen", sameRootLogicalReopen) + putBoolean("rootSwitchable", rootSwitchable) + putBoolean("crashRestartable", crashRestartable) + putBoolean("independentSessions", independentSessions) + putInt("maxClientSessions", maxClientSessions) + putBoolean("protocolRaw", protocolRaw) + putBoolean("protocolStream", protocolStream) + putBoolean("queryCancel", queryCancel) + putBoolean("backupRestore", backupRestore) + putArray("backupFormats", backupFormats.toWritableArray()) + putArray("restoreFormats", restoreFormats.toWritableArray()) + putBoolean("simpleQuery", simpleQuery) + putBoolean("extensions", extensions) + if (connectionString != null) { + putString("connectionString", connectionString) + } + putString("rawProtocolTransport", "jsi-array-buffer") + } + + private fun EngineModeSupport.toWritableMap(): WritableNativeMap = + WritableNativeMap().apply { + putString("engine", mode.wireName()) + putBoolean("available", available) + putMap("capabilities", capabilities.toWritableMap()) + if (unavailableReason != null) { + putString("unavailableReason", unavailableReason) + } + } + + private fun OliphauntPackageSizeReport.toWritableMap(): WritableNativeMap = + WritableNativeMap().apply { + putDouble("packageBytes", packageBytes.toDouble()) + putDouble("runtimeBytes", runtimeBytes.toDouble()) + putDouble("templatePgdataBytes", templatePgdataBytes.toDouble()) + putDouble("staticRegistryBytes", staticRegistryBytes.toDouble()) + putDouble("selectedExtensionBytes", selectedExtensionBytes.toDouble()) + mobileStaticRegistryState?.let { putString("mobileStaticRegistryState", it) } + putArray( + "mobileStaticRegistryRegistered", + WritableNativeArray().apply { + mobileStaticRegistryRegistered.forEach(::pushString) + }, + ) + putArray( + "mobileStaticRegistryPending", + WritableNativeArray().apply { + mobileStaticRegistryPending.forEach(::pushString) + }, + ) + putArray( + "nativeModuleStems", + WritableNativeArray().apply { + nativeModuleStems.forEach(::pushString) + }, + ) + putArray( + "extensions", + WritableNativeArray().apply { + extensions.forEach { pushMap(it.toWritableMap()) } + }, + ) + } + + private fun OliphauntExtensionSizeReport.toWritableMap(): WritableNativeMap = + WritableNativeMap().apply { + putString("name", name) + putInt("fileCount", fileCount) + putDouble("bytes", bytes.toDouble()) + } + + private fun processMemoryReport(): WritableNativeMap { + val info = Debug.MemoryInfo() + Debug.getMemoryInfo(info) + val runtime = Runtime.getRuntime() + return WritableNativeMap().apply { + putString("source", "android-debug-memory-info") + putDouble("totalPssKb", info.totalPss.toDouble()) + putDouble("totalPrivateDirtyKb", info.totalPrivateDirty.toDouble()) + putDouble("totalSharedDirtyKb", info.totalSharedDirty.toDouble()) + putDouble("nativeHeapAllocatedBytes", Debug.getNativeHeapAllocatedSize().toDouble()) + putDouble("nativeHeapSizeBytes", Debug.getNativeHeapSize().toDouble()) + putDouble("runtimeTotalBytes", runtime.totalMemory().toDouble()) + putDouble("runtimeFreeBytes", runtime.freeMemory().toDouble()) + } + } + + private fun List.toWritableArray(): WritableNativeArray = + WritableNativeArray().apply { + forEach { pushString(it.wireName()) } + } + + private fun BackupFormat.wireName(): String = when (this) { + BackupFormat.Sql -> "sql" + BackupFormat.PhysicalArchive -> "physicalArchive" + BackupFormat.OliphauntArchive -> "oliphauntArchive" + } + + } +} diff --git a/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntPackage.kt b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntPackage.kt new file mode 100644 index 00000000..68aefdab --- /dev/null +++ b/src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntPackage.kt @@ -0,0 +1,33 @@ +package dev.oliphaunt.reactnative + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class OliphauntPackage : BaseReactPackage() { + override fun getModule( + name: String, + reactContext: ReactApplicationContext, + ): NativeModule? = + if (name == OliphauntModule.NAME) { + OliphauntModule(reactContext) + } else { + null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = + ReactModuleInfoProvider { + mapOf( + OliphauntModule.NAME to ReactModuleInfo( + OliphauntModule.NAME, + OliphauntModule.NAME, + false, + false, + false, + true, + ), + ) + } +} diff --git a/src/sdks/react-native/android/src/test/java/dev/oliphaunt/reactnative/OliphauntAndroidBoundaryTest.kt b/src/sdks/react-native/android/src/test/java/dev/oliphaunt/reactnative/OliphauntAndroidBoundaryTest.kt new file mode 100644 index 00000000..6c455e10 --- /dev/null +++ b/src/sdks/react-native/android/src/test/java/dev/oliphaunt/reactnative/OliphauntAndroidBoundaryTest.kt @@ -0,0 +1,115 @@ +package dev.oliphaunt.reactnative + +import dev.oliphaunt.OliphauntAndroid +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OliphauntAndroidBoundaryTest { + @Test + fun reactNativeAndroidDelegatesRuntimeToKotlinSdk() { + assertEquals("dev.oliphaunt.OliphauntAndroid", OliphauntAndroid::class.java.name) + + val nativeSourceDir = File(System.getProperty("user.dir"), "src/main/cpp") + val nativeSources = nativeSourceDir + .takeIf(File::isDirectory) + ?.walkTopDown() + ?.filter(File::isFile) + ?.toList() + ?: emptyList() + + val nativeSourceNames = nativeSources + .map { it.relativeTo(nativeSourceDir).invariantSeparatorsPath } + .sorted() + assertEquals( + "React Native Android should only carry the JSI installer and must not duplicate the native C++ runtime", + listOf("CMakeLists.txt", "OliphauntJsiBindings.cpp", "include/oliphaunt.h"), + nativeSourceNames, + ) + + val moduleSource = File( + System.getProperty("user.dir"), + "src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt", + ).readText() + assertTrue( + "React Native Android must delegate package-size evidence to OliphauntAndroid", + moduleSource.contains("OliphauntAndroid.packageSizeReport"), + ) + assertTrue( + "React Native Android must reject non-string extension entries before Kotlin SDK open", + moduleSource.contains("extensions must be an array of strings"), + ) + assertFalse( + "React Native Android must not silently drop malformed extension entries", + moduleSource.contains("getString(index)?.let(::add)"), + ) + assertTrue( + "React Native Android must reject invalid startup identity before Kotlin SDK open", + moduleSource.contains("startupIdentity") && + moduleSource.contains("username must not contain NUL bytes"), + ) + assertTrue( + "React Native Android must reject malformed scalar config values before Kotlin SDK open", + moduleSource.contains("getType(name) == ReadableType.String") && + moduleSource.contains("\$name must be a string"), + ) + assertTrue( + "React Native Android must reject blank native override paths before Kotlin SDK open", + moduleSource.contains("pathOverride") && + moduleSource.contains("libraryPath must not be empty"), + ) + assertTrue( + "React Native Android must reject NUL-containing roots before Kotlin SDK open/restore", + moduleSource.contains("validateRootPath") && + moduleSource.contains("must not contain NUL bytes"), + ) + assertTrue( + "React Native Android must expose a byte-array JSI hook that delegates to the Kotlin SDK session", + moduleSource.contains("fun execProtocolRawBytes") && + moduleSource.contains("session.execProtocolRaw(ProtocolRequest(request)).bytes"), + ) + assertTrue( + "React Native Android must expose a true chunked JSI stream hook that delegates to the Kotlin SDK session", + moduleSource.contains("fun execProtocolStreamBytes") && + moduleSource.contains("session.execProtocolStream(ProtocolRequest(request))") && + moduleSource.contains("callback.emitChunk(chunk.bytes)"), + ) + assertTrue( + "React Native Android must expose byte-array JSI backup/restore hooks instead of base64 TurboModule binary APIs", + moduleSource.contains("fun backupBytes") && + moduleSource.contains("fun restoreBytes") && + !moduleSource.contains("Base64"), + ) + assertTrue( + "React Native Android must install a New Architecture JSI transport for ArrayBuffer protocol calls", + moduleSource.contains("TurboModuleWithJSIBindings") && + moduleSource.contains("external override fun getBindingsInstaller()"), + ) + assertFalse( + "React Native Android must use the Kotlin SDK facade instead of constructing AndroidNativeDirectEngine", + moduleSource.contains("AndroidNativeDirectEngine"), + ) + + val jsiSource = File(nativeSourceDir, "OliphauntJsiBindings.cpp").readText() + assertTrue( + "React Native Android JSI must validate handles before native Long casts", + jsiSource.contains("copyHandleArgument") && + jsiSource.contains("positive safe integer") && + jsiSource.contains("std::isfinite"), + ) + assertTrue( + "React Native Android JSI must validate typed-array bounds before native size casts", + jsiSource.contains("copySizeArgument") && + jsiSource.contains("typed-array byteOffset") && + jsiSource.contains("typed-array byteLength"), + ) + assertTrue( + "React Native Android JSI must install a real chunked stream transport before protocolStream can be advertised", + jsiSource.contains("\"execProtocolStream\"") && + jsiSource.contains("OliphauntJsiStreamCallback") && + jsiSource.contains("nativeEmitChunk"), + ) + } +} diff --git a/src/sdks/react-native/app.plugin.js b/src/sdks/react-native/app.plugin.js new file mode 100644 index 00000000..aaf8d88d --- /dev/null +++ b/src/sdks/react-native/app.plugin.js @@ -0,0 +1,234 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const EXTENSION_NAME_RE = /^[A-Za-z0-9._-]{1,128}$/; +const packageMetadata = require('./package.json'); +const extensionMetadata = require('./src/generated/extensions.json'); +const IOS_PODFILE_START = '# @oliphaunt/react-native begin'; +const IOS_PODFILE_END = '# @oliphaunt/react-native end'; +const ANDROID_APP_PLUGIN_RE = /id\s*(?:\(\s*)?['"]dev\.oliphaunt\.android['"]/; +const KNOWN_EXTENSION_SQL_NAMES = new Set( + extensionMetadata.extensions.map((extension) => extension['sql-name']), +); +const MOBILE_RELEASE_READY_EXTENSION_SQL_NAMES = new Set( + extensionMetadata.extensions + .filter((extension) => extension['mobile-release-ready'] === true) + .map((extension) => extension['sql-name']), +); + +function normalizeOptions(options = {}) { + const extensions = Array.isArray(options.extensions) ? options.extensions : []; + const selected = [...new Set(extensions.map((value) => String(value).trim()).filter(Boolean))].sort(); + for (const extension of selected) { + if (!EXTENSION_NAME_RE.test(extension)) { + throw new Error( + `@oliphaunt/react-native extension '${extension}' must be an exact PostgreSQL extension name`, + ); + } + if (!KNOWN_EXTENSION_SQL_NAMES.has(extension)) { + throw new Error( + `@oliphaunt/react-native extension '${extension}' is not in the generated exact-extension catalog`, + ); + } + if (!MOBILE_RELEASE_READY_EXTENSION_SQL_NAMES.has(extension)) { + throw new Error( + `@oliphaunt/react-native extension '${extension}' is known but does not have release-ready iOS/Android artifacts`, + ); + } + } + return { + extensions: selected, + liboliphauntVersion: optionalString(options.liboliphauntVersion), + assetBaseUrl: optionalString(options.assetBaseUrl), + kotlinPluginVersion: optionalString(options.kotlinPluginVersion) ?? packageMetadata.oliphaunt?.kotlinSdkVersion, + }; +} + +function optionalString(value) { + if (value == null) { + return undefined; + } + const stringValue = String(value).trim(); + return stringValue.length > 0 ? stringValue : undefined; +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function writeJson(file, value) { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function mergeProperties(file, entries) { + let lines = []; + if (fs.existsSync(file)) { + lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + } + const keys = new Set(Object.keys(entries)); + const kept = lines.filter((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) { + return true; + } + const key = trimmed.slice(0, trimmed.indexOf('=')).trim(); + return !keys.has(key); + }); + for (const [key, value] of Object.entries(entries)) { + if (value != null && String(value).trim() !== '') { + kept.push(`${key}=${value}`); + } + } + fs.writeFileSync(file, `${kept.join('\n').replace(/\n+$/, '')}\n`); +} + +function iosPodfileBlock() { + return [ + IOS_PODFILE_START, + "oliphaunt_podspecs_path = File.expand_path('../node_modules/@oliphaunt/react-native/ios/podspecs', __dir__)", + "pod 'COliphaunt', :podspec => File.join(oliphaunt_podspecs_path, 'COliphaunt.podspec'), :modular_headers => true", + "pod 'Oliphaunt', :podspec => File.join(oliphaunt_podspecs_path, 'Oliphaunt.podspec')", + IOS_PODFILE_END, + ].join('\n'); +} + +function replaceMarkedBlock(contents, block) { + const start = contents.indexOf(IOS_PODFILE_START); + const end = contents.indexOf(IOS_PODFILE_END); + if (start === -1 && end === -1) { + return undefined; + } + if (start === -1 || end === -1 || end < start) { + throw new Error('ios/Podfile has a partial @oliphaunt/react-native managed block'); + } + const lineStart = contents.lastIndexOf('\n', start) + 1; + const indent = contents.slice(lineStart, start).match(/^[ \t]*/)?.[0] ?? ''; + const indentedBlock = block + .split('\n') + .map((line) => `${indent}${line}`) + .join('\n'); + const afterEnd = end + IOS_PODFILE_END.length; + return `${contents.slice(0, lineStart)}${indentedBlock}${contents.slice(afterEnd)}`; +} + +function insertIosPodfileBlock(contents) { + const block = iosPodfileBlock(); + const replaced = replaceMarkedBlock(contents, block); + if (replaced !== undefined) { + return `${replaced.replace(/\n+$/, '')}\n`; + } + + const lines = contents.split(/\r?\n/); + const anchorIndex = lines.findIndex((line) => /config\s*=\s*use_native_modules!\s*/.test(line)); + const fallbackIndex = lines.findIndex((line) => /^\s*use_expo_modules!\s*$/.test(line)); + const insertAfter = anchorIndex >= 0 ? anchorIndex : fallbackIndex; + if (insertAfter < 0) { + throw new Error('ios/Podfile must call use_native_modules! or use_expo_modules! before Oliphaunt can add Swift SDK podspecs'); + } + const indent = lines[insertAfter].match(/^\s*/)?.[0] ?? ''; + const indentedBlock = block + .split('\n') + .map((line) => `${indent}${line}`) + .join('\n'); + lines.splice(insertAfter + 1, 0, indentedBlock); + return `${lines.join('\n').replace(/\n+$/, '')}\n`; +} + +function patchIosPodfile(file) { + if (!fs.existsSync(file)) { + return false; + } + const before = fs.readFileSync(file, 'utf8'); + const after = insertIosPodfileBlock(before); + if (after !== before) { + fs.writeFileSync(file, after); + } + return true; +} + +function androidPluginVersion(options) { + return optionalString(options.kotlinPluginVersion) ?? packageMetadata.oliphaunt?.kotlinSdkVersion; +} + +function androidAppPluginLine(version) { + return ` id 'dev.oliphaunt.android' version '${version}'`; +} + +function insertAppGradlePlugin(contents, version) { + if (ANDROID_APP_PLUGIN_RE.test(contents)) { + return `${contents.replace(/\n+$/, '')}\n`; + } + const lines = contents.split(/\r?\n/); + const pluginsIndex = lines.findIndex((line) => /^\s*plugins\s*\{\s*$/.test(line)); + if (pluginsIndex < 0) { + return `plugins {\n${androidAppPluginLine(version)}\n}\n\n${contents.replace(/\n+$/, '')}\n`; + } + let insertAt = pluginsIndex + 1; + while (insertAt < lines.length && lines[insertAt].trim().startsWith('//')) { + insertAt += 1; + } + lines.splice(insertAt, 0, androidAppPluginLine(version)); + return `${lines.join('\n').replace(/\n+$/, '')}\n`; +} + +function patchAndroidGradle(androidRoot, normalized) { + const version = androidPluginVersion(normalized); + if (!version) { + throw new Error('@oliphaunt/react-native requires oliphaunt.kotlinSdkVersion metadata or kotlinPluginVersion'); + } + const appBuildGradle = path.join(androidRoot, 'app', 'build.gradle'); + if (fs.existsSync(appBuildGradle)) { + const before = fs.readFileSync(appBuildGradle, 'utf8'); + const after = insertAppGradlePlugin(before, version); + if (after !== before) { + fs.writeFileSync(appBuildGradle, after); + } + } +} + +function withOliphaunt(config, options = {}) { + const plugin = require('expo/config-plugins'); + const normalized = normalizeOptions(options); + + config = plugin.withDangerousMod(config, [ + 'android', + (modConfig) => { + const projectRoot = modConfig.modRequest.projectRoot; + const androidRoot = path.join(projectRoot, 'android'); + writeJson(path.join(androidRoot, 'oliphaunt.json'), normalized); + mergeProperties(path.join(androidRoot, 'gradle.properties'), { + oliphauntExtensions: normalized.extensions.join(','), + oliphauntLiboliphauntVersion: normalized.liboliphauntVersion, + oliphauntAssetBaseUrl: normalized.assetBaseUrl, + }); + patchAndroidGradle(androidRoot, normalized); + return modConfig; + }, + ]); + + config = plugin.withDangerousMod(config, [ + 'ios', + (modConfig) => { + const projectRoot = modConfig.modRequest.projectRoot; + const iosRoot = path.join(projectRoot, 'ios'); + writeJson(path.join(iosRoot, 'oliphaunt.json'), normalized); + writeJson(path.join(iosRoot, 'OliphauntExtensions.json'), { + extensions: normalized.extensions, + liboliphauntVersion: normalized.liboliphauntVersion, + assetBaseUrl: normalized.assetBaseUrl, + }); + patchIosPodfile(path.join(iosRoot, 'Podfile')); + return modConfig; + }, + ]); + + return config; +} + +module.exports = withOliphaunt; +module.exports.withOliphaunt = withOliphaunt; +module.exports.normalizeOptions = normalizeOptions; +module.exports.insertIosPodfileBlock = insertIosPodfileBlock; +module.exports.iosPodfileBlock = iosPodfileBlock; +module.exports.insertAppGradlePlugin = insertAppGradlePlugin; diff --git a/src/sdks/react-native/examples/expo/.gitignore b/src/sdks/react-native/examples/expo/.gitignore new file mode 100644 index 00000000..4b00baf3 --- /dev/null +++ b/src/sdks/react-native/examples/expo/.gitignore @@ -0,0 +1,43 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +example + +# generated native folders +/ios +/android diff --git a/src/sdks/react-native/examples/expo/.vscode/extensions.json b/src/sdks/react-native/examples/expo/.vscode/extensions.json new file mode 100644 index 00000000..b7ed8377 --- /dev/null +++ b/src/sdks/react-native/examples/expo/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/src/sdks/react-native/examples/expo/.vscode/settings.json b/src/sdks/react-native/examples/expo/.vscode/settings.json new file mode 100644 index 00000000..e2798e42 --- /dev/null +++ b/src/sdks/react-native/examples/expo/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/src/sdks/react-native/examples/expo/LICENSE b/src/sdks/react-native/examples/expo/LICENSE new file mode 100644 index 00000000..30b20e3b --- /dev/null +++ b/src/sdks/react-native/examples/expo/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/sdks/react-native/examples/expo/README.md b/src/sdks/react-native/examples/expo/README.md new file mode 100644 index 00000000..abcad67b --- /dev/null +++ b/src/sdks/react-native/examples/expo/README.md @@ -0,0 +1,226 @@ +# React Native Oliphaunt Expo Example + +This is a real Expo development-build app for validating +`@oliphaunt/react-native` against the Kotlin Android SDK and the New +Architecture JSI `ArrayBuffer` transport. + +The first screen is a small field-ops task board rather than a static smoke +screen. On launch it opens one `nativeDirect` database, creates a +project/task/event schema, seeds 240 tasks in a transaction, updates work items +in a second transaction, runs parameterized aggregate/search queries, and logs +latency percentiles plus package-size evidence through +`OLIPHAUNT_EXPO_SMOKE_PASS`. + +When the bundled mobile runtime resources reports `mobileStaticRegistryState = +complete` and registers the `vector` module, the same workload requests that +extension, creates `pgvector`, and runs an HNSW nearest-neighbor query. Builds +without a complete mobile static registry keep running the base Postgres +workload and report the extension selection in the validation list. + +Fast Android smoke: + +```sh +pnpm run smoke +pnpm run smoke:android +``` + +`pnpm run smoke` is the default installed-app harness: it runs the Android and +iOS Expo development-client smokes through the repository validation script. +Use `smoke:android` or `smoke:ios` when only one simulator/device stack is +available. + +The default local dev command is the Expo development-client harness with local +Expo MCP capabilities enabled, not Expo Go: + +```sh +pnpm start +pnpm run android:start +pnpm run ios:start +``` + +The automated smoke, benchmark, and crash scripts start their own +development-client Metro server with local MCP enabled by default so the native +runner receives the same env on every machine. If port 8081 is busy, they choose +a free port in 8082-8099 unless `OLIPHAUNT_EXPO_*_METRO_PORT` is set explicitly. +Set `OLIPHAUNT_EXPO_*_REUSE_METRO=1` only when manually attaching to a Metro +process that already has the desired `EXPO_PUBLIC_OLIPHAUNT_*` env. + +Device benchmark runs use the same native build/package path but launch the app +with the benchmark runner. They emit `OLIPHAUNT_EXPO_BENCH_PASS` and write the +parsed JSON report under `target/oliphaunt-expo--benchmark/reports/`. +The report includes raw/typed/parameterized RTT, bulk insert/update, large +result transfer, package size, JS timer liveness, platform memory evidence, and +background checkpoint latency. It also runs a same-device Expo SQLite WAL +baseline with the same durability label so mobile reports can compare +liboliphaunt against native SQLite without using host-side numbers: + +```sh +pnpm run bench:android +pnpm run bench:ios +``` + +Process-death recovery runs use the same dev-client build but launch a +two-phase crash harness. The write phase opens a persistent app-private root, +writes committed data, and leaves the database open. The platform script then +force-stops/terminates the app process and relaunches the verify phase against +the same root with a fresh phase-specific dev-client bundle, expecting +PostgreSQL recovery to make the committed row visible. Crash runs default to +`durability=safe`; `balanced` keeps `synchronous_commit=off`, so it is a +latency/footprint profile rather than a last-commit survival guarantee: + +```sh +pnpm run crash:android +pnpm run crash:ios +``` + +The smoke script: + +- packs the current React Native SDK when sources changed; +- installs the packed SDK into this Expo app when needed; +- runs Expo prebuild for Android when the ignored generated `android/` project + is missing; +- builds a clean Android `liboliphaunt` runtime resources with runtime files, + template PGDATA, package-size evidence, and `liboliphaunt.so`; +- builds and installs the dev-client APK; +- launches through Expo dev-client and waits for + `OLIPHAUNT_EXPO_SMOKE_PASS` from logcat. + +Useful overrides: + +```sh +OLIPHAUNT_EXPO_MOBILE_DURABILITY=safe pnpm run bench:android +OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT=smallMobile pnpm run bench:android +OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS=shared_buffers=8MB,wal_buffers=-1 pnpm run bench:android +OLIPHAUNT_EXPO_MOBILE_BENCHMARK_PRESET=quick pnpm run bench:android +OLIPHAUNT_EXPO_ANDROID_SKIP_BUILD=1 pnpm run smoke:android +OLIPHAUNT_EXPO_ANDROID_KEEP_METRO=1 pnpm run smoke:android +OLIPHAUNT_EXPO_ANDROID_REPACKAGE_ASSETS=1 pnpm run smoke:android +OLIPHAUNT_EXPO_ANDROID_GRADLE_CONFIGURATION_CACHE=1 pnpm run smoke:android +OLIPHAUNT_EXPO_ANDROID_RUNTIME_DIR=/path/to/runtime pnpm run smoke:android +OLIPHAUNT_EXPO_ANDROID_TEMPLATE_PGDATA_DIR=/path/to/pgdata pnpm run smoke:android +OLIPHAUNT_EXPO_ANDROID_OLIPHAUNT_SO=/path/to/liboliphaunt.so pnpm run smoke:android +``` + +Expo smoke and benchmark runs default to `balancedMobile` because they are +resident mobile app harnesses. Set `OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT` and +`OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS` to sweep the same footprint/GUC matrix used +by the Rust perf harness. `tools/perf/matrix/run_mobile_footprint_matrix.sh` +prints or runs the full Android/iOS device matrix, stores each case in its own +scratch directory, and writes `summary.json` plus `summary.md` under +`target/perf/mobile-footprint-/`. The matrix defaults to +`balancedMobile`; pass `--runtime-footprint all` to compare `throughput`, +`balancedMobile`, and `smallMobile` under the same startup-GUC axes. Matrix cases +run the benchmark lane for Safe/Balanced durability and the process-death +recovery lane for Safe durability so recovery evidence is not falsely attached +to `synchronous_commit=off` runs. +Pass `--quick` to the matrix wrapper, or set +`OLIPHAUNT_EXPO_MOBILE_BENCHMARK_PRESET=quick` directly, when validating harness +changes; leave the default full preset for reportable performance numbers. +Use the matrix axis filters for iterative tuning slices, for example: + +```sh +../../../../../tools/perf/matrix/run_mobile_footprint_matrix.sh --quick --platform android \ + --shared-buffers 8MB,32MB,128MB \ + --wal-buffers -1 \ + --min-wal-size 32MB \ + --max-wal-size 64MB \ + --durability balanced \ + --crash-recovery off +``` + +The harness defaults to `--no-configuration-cache` for the Expo app because the +generated Expo Gradle files currently resolve React Native/Expo paths through +Node during configuration. Keep configuration cache opt-in until that upstream +behavior changes. + +Fast iOS build/smoke harness: + +```sh +OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK=/path/to/liboliphaunt.xcframework \ +OLIPHAUNT_EXPO_IOS_RUNTIME_DIR=/path/to/postgres-runtime \ +OLIPHAUNT_EXPO_IOS_TEMPLATE_PGDATA_DIR=/path/to/template-pgdata \ +pnpm run smoke:ios +``` + +Use `OLIPHAUNT_EXPO_IOS_BUILD_ONLY=1` when you only want the generated Expo iOS +project, CocoaPods integration, bundled resources, and Xcode build checked. The +script rejects macOS `liboliphaunt.dylib` artifacts; iOS validation needs an iOS +simulator/device build of `liboliphaunt`. For an unsigned generic iPhoneOS +compile/package check, set `OLIPHAUNT_EXPO_IOS_SDK=iphoneos`, +`OLIPHAUNT_EXPO_IOS_BUILD_ONLY=1`, and +`OLIPHAUNT_EXPO_IOS_CODE_SIGNING_ALLOWED=NO`; install/launch benchmarks still +require a runnable paired phone and valid signing. + +Physical iOS runs use Xcode's `devicectl` path: + +```sh +OLIPHAUNT_EXPO_IOS_SDK=iphoneos \ +OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK=/path/to/liboliphaunt.xcframework \ +OLIPHAUNT_EXPO_IOS_RUNTIME_DIR=/path/to/postgres-runtime \ +OLIPHAUNT_EXPO_IOS_TEMPLATE_PGDATA_DIR=/path/to/template-pgdata \ +pnpm run bench:ios +``` + +Set `OLIPHAUNT_EXPO_IOS_DEVICE_ID` to pick a specific paired device, and +`OLIPHAUNT_EXPO_IOS_METRO_URL` if the device cannot reach the host address that +the harness auto-detects. Device crash-recovery runs default to +`app-support://oliphaunt-crash-recovery-root`, which resolves inside the app +sandbox and survives process death. + +Physical-device runs require a working Apple Development signing setup. The +harness first checks that the paired phone has Developer Mode and Developer Disk +Image services available through `devicectl`, then uses +`OLIPHAUNT_EXPO_IOS_DEVELOPMENT_TEAM` when set, otherwise it uses the single +team configured in Xcode. If Xcode has multiple teams configured, set +`OLIPHAUNT_EXPO_IOS_DEVELOPMENT_TEAM` explicitly. If no local signing identity +is installed the harness fails before doing the expensive Expo/CocoaPods work; set +`OLIPHAUNT_EXPO_IOS_ALLOW_PROVISIONING_UPDATES=1` to explicitly allow +`xcodebuild -allowProvisioningUpdates` and device registration when the Xcode +account session is valid. Override with `OLIPHAUNT_EXPO_IOS_CODE_SIGN_IDENTITY`, +`OLIPHAUNT_EXPO_IOS_PROVISIONING_PROFILE_SPECIFIER`, or +`OLIPHAUNT_EXPO_IOS_ALLOW_PROVISIONING_UPDATES=0` for locked-down local/CI +signing. + +The iPhone must be unlocked and awake when `devicectl` launches the development +client. If a physical run already built and installed the app but launch failed +because the device was locked, retry without rebuilding: + +```sh +OLIPHAUNT_EXPO_IOS_REUSE_INSTALLED_APP=1 \ +OLIPHAUNT_EXPO_IOS_SDK=iphoneos \ +OLIPHAUNT_EXPO_IOS_DEVICE_ID= \ +pnpm run crash:ios +``` + +The physical iOS smoke harness exercises background/foreground automatically: +after the app reaches `lifecycle:ready`, it opens Safari, waits +`OLIPHAUNT_EXPO_IOS_BACKGROUND_SECONDS` seconds, then foregrounds the same +installed app and verifies SQL still works on the resumed database. + +Expo local MCP capabilities are installed through `expo-mcp`: + +```sh +pnpm run mcp:version +pnpm run mcp:start +``` + +`mcp:start` is an alias for the default `pnpm start` dev-client/MCP harness, +which is the local tool path for screenshots, app logs, DevTools, and automation +from MCP-capable agents. Expo's remote MCP server requires Expo OAuth/EAS +access, so the repo keeps local CLI/dev-client validation as the default +reproducible path. + +EAS CLI is intentionally used through `npx eas-cli@latest` for build-service +operations so the example does not pin a stale global CLI: + +```sh +npx eas-cli@latest --version +``` + +Baseline local checks: + +```sh +pnpm run typecheck +pnpm run lint -- --max-warnings=0 +npx expo-doctor +``` diff --git a/src/sdks/react-native/examples/expo/app.json b/src/sdks/react-native/examples/expo/app.json new file mode 100644 index 00000000..e43ed01e --- /dev/null +++ b/src/sdks/react-native/examples/expo/app.json @@ -0,0 +1,54 @@ +{ + "expo": { + "name": "react-native-oliphaunt-expo", + "slug": "react-native-oliphaunt-expo", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "reactnativeoliphauntexpo", + "userInterfaceStyle": "automatic", + "ios": { + "bundleIdentifier": "dev.oliphaunt.reactnative.example", + "icon": "./assets/expo.icon" + }, + "android": { + "package": "dev.oliphaunt.reactnative.example", + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false + }, + "web": { + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + [ + "expo-splash-screen", + { + "backgroundColor": "#208AEF", + "android": { + "image": "./assets/images/splash-icon.png", + "imageWidth": 76 + } + } + ], + [ + "expo-dev-client", + { + "launchMode": "most-recent" + } + ], + "expo-image", + "expo-router", + "expo-web-browser", + "expo-sqlite" + ], + "experiments": { + "reactCompiler": true + } + } +} diff --git a/src/sdks/react-native/examples/expo/assets/expo.icon/Assets/expo-symbol 2.svg b/src/sdks/react-native/examples/expo/assets/expo.icon/Assets/expo-symbol 2.svg new file mode 100644 index 00000000..51d36767 --- /dev/null +++ b/src/sdks/react-native/examples/expo/assets/expo.icon/Assets/expo-symbol 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/sdks/react-native/examples/expo/assets/expo.icon/Assets/grid.png b/src/sdks/react-native/examples/expo/assets/expo.icon/Assets/grid.png new file mode 100644 index 0000000000000000000000000000000000000000..eefea24246267f9a2446460c389a483577a3adb5 GIT binary patch literal 53681 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 literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/expo.icon/icon.json b/src/sdks/react-native/examples/expo/assets/expo.icon/icon.json new file mode 100644 index 00000000..18ae6876 --- /dev/null +++ b/src/sdks/react-native/examples/expo/assets/expo.icon/icon.json @@ -0,0 +1,40 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "expo-symbol 2.svg", + "name" : "expo-symbol 2", + "position" : { + "scale" : 1, + "translation-in-points" : [ + 1.1008400065293245e-05, + -16.046875 + ] + } + }, + { + "image-name" : "grid.png", + "name" : "grid" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/src/sdks/react-native/examples/expo/assets/images/android-icon-background.png b/src/sdks/react-native/examples/expo/assets/images/android-icon-background.png new file mode 100644 index 0000000000000000000000000000000000000000..5ffefc5bb57a3d7b39ec6ff4e96979226522cc49 GIT binary patch literal 17549 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

f!0B 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 literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/android-icon-monochrome.png b/src/sdks/react-native/examples/expo/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..77484ebdbca253297baea4a7d416233aa47a45c0 GIT binary patch literal 4140 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 literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/expo-badge-white.png b/src/sdks/react-native/examples/expo/assets/images/expo-badge-white.png new file mode 100644 index 0000000000000000000000000000000000000000..28630679fd5be26be3eace15c9e3b53dd413617b GIT binary patch literal 4129 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 fe%`N3ye_{SWhRB?0xdI@wBhGxg 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 UD9cJ(z+Vc8_aY0~Jks(?}v7_xUud z^7&IRydH}ITNsLl({49i1>{P6S;TY(+6r8WMRB%aw0 z;rtJIzFmzc5HNL=9nP`82+ya4@oUf#@Yz^2v_e<5fcycE^D5sGmvQI}3cr41BGHS5 zqZDE=37um+VRnU?J;~S+$dyt83jNjd>z;2;pUk?vq zeO3i60?7N6KB`a@Xqwf576E=dI{Wq;=pe8Fuime*W0heiTZW(n7BKrp?#Y*rF2M3c zzx4i+hFS*>P=l^%%#EN?@GW4Y z$)Z)i=b??9ER}*r!M6aJ=O|?;b?O;SL}2Vnx2Jch;7fqa`;;bgN%_uX<$k~Gwwj{g zXC@mNPJ0%YD-DO?$pM#BSL4dY&-pd{e^mwi|7(`GFw^DZY9WITPR3M8QA1nicpRJhj$ zyC5jC6?({2N=y4_(Q5S+eTj23=C^QVEZpro<|KMQeWwB$gK@sC^5hGVE;SL4Meh13 zTp7;YD+y6uQ-tlGF#nS$Ioc=FEuq}}alo`1@8om-5H5>%D<4B5PGEMAn`h5(I@~?& zUYJ*`Sky=ubjHp(Uxf}s*t|~v+OayU7}V|1cUW`sSVq`L(}gX7q98OaK!-qW*IMuR zU$s!riFH6Kk&VutaYLImSFDQeI8H)4!uWcuCzNsyHOyD^Ks!c)Y^pb&aGh|6&WC11 zPnH_W7MwQpBM{M6C7axCH~p~(wO?67$`xq26Ml;o+cOz)bv6s26-swKqO! zXoQdc{h_1f7t*30qWkEpDxlR&FdqE-zJt7$JQ`Yk8X>|O8;wY~L0w7yRUrCt=SlYR z$iy?8-@#nBAF_7z-&dJg74yLFnIF~De^rRc?0g}5q1*J$r^!L7zWrR!&{5&T0B{RP z`?tTac{uV1;yqL!mLcaw^VKS`s=t^)5~}u>vIw_iMxQag!NZ_hFfAw>=KpLP00fdm22nY;(4rEmO zp95P!Ke+whr!9b`8(2o%&jZL>HzvWBY#*;u=)2VvcWm{)KZMc?!_UJC`=ofOB*;AH zmDr%(VQQLVr%e6@Z%;+asMCH1xqGI{%Wzi=+)Ov5>Ae@pe~iFlW_p2hiSTMx{5Nd)Yi!&=ZU1??ZVadRq>m~`yH zZ*$+|US0H(@$};(l5TP&-pN&QaBGm=A^hLM!t2QodEK0X5|=a70qJh`oe8=Ts*NMi=EDXbA>&^Jc-u8rXfe0PhyMo8CjH! z7S(s#d7dYMe|u!dPI!ph(yy(19}1l(yJOpbp0O}Ro$-SA5l9IZ_c$3@*5%b6i!BN% z3r+?lP5--IClVXZTzt0M9`nUK1*mx?PWbmET2b*pDrCTK&N(QX0h0ihK4iDyxUl(a zoS;&DV>*iuHJI`oHt=wt3U)WYU6!*JNe&Ni= z%$61h?5Jo^9<3-{=)JaSlmqq{b6cxx2~^x&;B))4b^x&@_jOMyRhda-3jSqso`XRV zDd|D^W^U!7z5I5Klt_sdY zY=I`){s-EB=$%|JLm}FO1^|mAJ^Tv#(4SfC#;F+XGYQHkq}F1oW@=qj*!_9=;`kKn zQfQE!7Ah;igMnwWSn0B3i`)LVt`&;;s;7!~BW6>Uw4}*cD?o@toPlxb+gNMLd1`dq ztqbW@stC@XSxdV&pg2`hxhaCfLvUZOuSx4Z>k8&J>EgdxE^xc&4Bv6zQg_4 z%(QXgFDzO0?Tr0T$e`pgHvAcB>Yt^HLSM~nrG|e^ALl4xB$MAEw_I|sV#ZzWJgvPf zrTiLx*D1jFTcBfKA(Ev}eHDMFL+fx0l=AZ*j@^={rY;veCV`WGEi0MoKm*t%rxp*^ z+@YG+aql0tA?Sc;qNy~wZ@lE_KJ|z~FvtsyNy1<1M z7#&L!UfT41!|`I=mvAy3mIuEI4(siHy58LHwzI>H z-}t65cmR{V7HHyUbNRj01#8vhC>9&Cht_I30Tb%|rzTE>no-LA6(3_>X8L%Q*t}r$ z)eYS-!iZqs{X=KxreR-u+62=O`X6c4*3M>tk^uj6ur6{7!q@`)_n;D4un>9<2{S`9 zqe{R4xSfMVxyR`y+m}74Yd^HqqZzATa*OBk{~~J4IEvWi;}qQ&CX`Q#ZD8tZNG{Ct zpWq6&038=1J>H0%B{ zI=l5TQP?Dw8St8<6_QAK(tIHFwxQ@IJn-9I8JXt%l0;h`oE#iD6VNpk6@V^Th|kdf zWlHc(0!i-5Yn4a51SC@m!=H)%9{xWD2Tb85dxet##@4@+ziWX3NpO!X{w?q<>f@Yz zCrqdt76pTxMlL*QBW!WEOn^$1#y4|zIicxizTz}Fd%SWkLpEMf6U|YI_3{~7uasxj z;fTY-pef@P8Ug*;`+7kIgFfshZQ@TQSpEMJ`lY=Lc&9KJ-0~8d(DXJ=6pPb+L9eeI zsTn1%KZ3K)D91Y#pi1D%A}35j+!#fhS4HIIk$Tk{9MJNZ)uLySoJ=8swSPZ6_8`$N z%vbK1Rpzjlz-4l64<3KwRrux+deI`MxcBD>FaM>(K-<5i8bvuCwQjG!GX{J~A3H6q zO>mJV{16jff+3J_^t$e)QSBD_2@YXa4u#mDp`!|U%m2852fCgt{mB4+CNeMa9#~h{ z55{`$_fOfNo8JM+t?#HV`iCPh2{3P)q#foDFWf74@odkE`Cx(N@K{bmp8~&J{CSj$ zs+8s0hSy1vU6qWqizN49OL7>guYBh zvp43&YGuWA4!18+6gE93qocXk_}%1>O_p9Z7L(x0zo_{kZEsK)>&$nt>)r|=RvV>` z9g2Ytc6^mjz`%a2*5@~plglK(uw10@YpEW@DD<(VK4Jm;v#V82r~L2wt#4v|b(6F` zyUyUnu1m8};r_zsd+yX{n{!VZdvLb0{bzj$^87*@4zKLc6t<$KpUY<6{p%Ez6vXa% zY(a{~z?GLFzj|Act*BzL+*lwWhyO{x&U=&~K7v8;t>_T>9F}SrJCUb@eJXymQo_x3 z9!EM1?^rR(j}2DQy=27`Le~VhyApkf@zN!-wVlK#L<7Xn&oM~vQrg%Hr?-{0H$MoG z8?c!|j%T|Q7oFrUW+Xhm^ugu*7n+nliXI7MM2fP+5+x{{;?d%zB<^B)>BpW|`uMP) zb@eBC|8i&Ne!Gp|AWsWsvr>cfttHoev5xh0_F!i*3nC2YlovK$Ce4% z-)s@5`U@taWvx7)|7EsKJ3tEd=+bPHVbaAL5T0Ygz$9}xdfD~LhnUT_)I;zst-SBS zl_AAQ9NIb2N@4SK3w}cVDQDgGYtjYW&C5i7{GX8?aomjRB~OmF1ynK*xRc}^nTEBj zI&10#s|7aXCrigKp`*@)}MSi7drJyt*m#Df>SpRj! zHv%sn9HJigmWoq#Tar z1A+l0W6$jg6jobRX&^pi2(SMf;5fKf7K^Z?4;;*eoF`20w@pUTt)%(Ix1-Ei}-kqjjI-kr45&8$=2@f7n znh}^Pe^Txn(AS#i2}(5cmyYwH6T*lf>H>~#-iI+YA7Ck&$j_|ukmKNS5a66(T7OS6 z4a3@Q-}T$Y+v+P-lp)vX#_`trawUV~%p~Ky`F~%BagN|A7K}uJae{mMT2e z56&A@LX#~3xc@yyIS+E+IaYXE6p{5Z~7n8!bUy_9hUt&E~0IL>7cq^ylDYuv`qq)Gj3Mfx<$ z==ql=tlv@VE8m&LrYeO4@zRXbh*mF13h#+Z1LDZV=8oym0#eerPM-3&BCTaVFiBH8 z>_$%h<5XZ8?(sPbE`#)Get3+SU%gOA2)+7+g)%Fk3=u6F+dYOtUdki9Y0lX-}_s& zH9buTu-=~vbZD0!z~h@EX(G!DgezMF87kT$M7GwHdu9x@3I5_6SQ)JlFi=UlVDJPz z`4}jXL9wB&2_-CU7$y3F>q+!(dVY-N7$+~5#gLJHv>$zCKv9pZB;f_j)&MbPX5q3d z-mSkMqe0fCok5k{i$g`C*!;)5y|mE42dCqoIR%=b>lP8fjRN8<<$({8X8-6G>;MI) zbih!8eY5uuI<=es;<3+UsCrjEu|NOwDLQLf&AT?pQ-BBF;Sjcsl4djh!CJ z9;Puyz>9800ZI`M(2dgV_OTLj)gQa_GEX=X>*w)ed_Hj&oVqRF*7#^P5- zAEC1aaV_;LYR*Wps+eED|5Uao;wx(oj@mMRZ=jA@_w7{;`~Hb0{zR|vEft=HqxPVT zee(ILtnb2EtpyXVk^gL^sG@-a~{*sCFI>Q$j=RvW8}miZPavK zY9Mp#DN8ix3gh%bi|G;TnT5PpJ=y0p$s;{ECf;|JZe zRkvqf*%qltXKO>Ao^#W_Hudoh;)`<41Qpw}dP$v3L>z$%yk1qO@9JZt86GXsHA@f1 zaaIx8@ji@iZ_U8u0+0+t-14H*+bRJ>7nE`3NjF3K%5jw z*ELl%iuvwB_(NL$P;0UHLgG$VnaG?UviM2Lv1EJF>uy_BA@sRDw&Prx-vZCDcv3&N zqkpu*LXctq>xX+y>DJz#k-}%Z@t&+;>7^Vj?Q$a`k zu2LKZequgvP5SfSWRnjd6%e64|e#Wna}K6arWsCcIG(N9~Me~HSM&K{b0 z1C^tQ<)I|&O!$lG8ats)(*KIPh@M9Nv60#M7hf5QK{|97>#x1DZwT=sDMZ~^!k?bi zaBr6$&AI*wYEXPq*&LHv{2aCtDLLq+NW$NPq4BdL^xyZ4qe#x&BqGZQ>Pk{fM#xJL@p_{f5PRRpX!Pf4`(II;A2^~&t!M1 zqkPo7#!>vEH*)pkm-SV<+E{ZP@a*&1Ne|-WSH3E0w8aZ?>(xxTd8Xodhm*K6*8`x$WiA-MCitob##Z_^C{xk2Ga z1n~_4dEs6Z37h~+*E^?SF9_(-VgwV=G>J4GvyyQF-u_+N*$bV0a(LS8D`7Q0oIZu`x4(Gi8BjhA-d`xy`vwv?or}=geqI%k ztI1Kp-Tr8cd|I=m)>iqqe9yL&Q`(EfztGNlX#5nD#fw@S-L3UVy@<@*mA$u8PNd#9 zFxrP8&(o{WyC`F}Y{Gw0z-n^zn{8&BZ9k_JmTKVpUsCP&2q$(Sk?C&#_ve zjM3i=K?4#*f|G#)C`fw;^5MYygb`VbS>QghG%>jgfJ`5lU8Ze=2mVgOL0d_9h24NW z=Na)GJ|ZCHr{N6B7mGm>{yCg~%u{;@cozK_6hs0j+Z?KG1F!%OmJBo_r4sH(U7Wp} z>5@cGx7>97cta8uBBAlGE{iMTYkW)t$Opv|n)|9UWHC=}^@(&dt*S3C&K{O@7=A~{b%ebI?UiWDpVGgQU_La0>+e81EtqQHf>9J) zyP0KPUI)zGr?kyze-pC4;nci*La@7HB$EX2kN?+O|BCa!b?PK=z2IF& z;Eav$;OcKii>?&X8(mM$z0I(J<2@r2`is*sE#1?axmHjQn{TQSnP%&vfnM?A-Hg=g zn}nMLa!LjdP9d*^ruXnERk}UsdV(Q@rb2A!jygxfFj7q$pC%lZ%cjm#u3i_&yKus~ z#WlenW@E%wQ6lfi=D(iA|7HB##e%$q+MHOKG2|8hMJt{LA?}HI*u?OrOVN9Rt1b%x zXZ((dhb)Yrdb;! z!`Tu?{5Bvh-f2viQJkTBhspYqix2nJTA$;~72~{xxWmKvuK+>vNQ+3qsT~^|T_= zwjdA^!1|sJF?eIM*jzLUr23{vCjc%JcVaf?dz8p+PEa23l~PC-=&W@_sr-L{;(?af z);xeG2eMH9APTS>SD~r+W6nwH73QmQ5pYXTh@-kEDdL>9fUwm~_S#CsYplO`{rwjatcroo6_^XsmN#^Pp2r7p8LdVG)E@kqp9aJykR zyI0NgQ4Z@Ec~l;^x%vy4eid*Eobf2p>ce0_-hCNUn>^b1W_!k&x}SR^+Y)<`BnOT_y;8G?SfDXON0T^L!X0@~yLnKqp^5|OZ< zNI}ks<~ONl6TD~eJVOo6W~C=H1lSNX5U}ppj76lFBZMcv=@fwc?u>I~oz8bI`Z?~YEU=j$hR3p!AM=LlkXUBZZX^3O;u z0l>u__yn*F1WG{7BodLf~s3Lu_ zg9@LPVq3V&wDcgYue#njp4UO{74`o zje;>dw0FR#Ry~6yg^i@=>ubZ{L%G6H;NX@Jx*j|oNDZ8m>>MD@1i&V6YNY5POD!PT zzci5AcFv8eAs7m>gwz7=C8GeXNXsFp`oZ89NNqj&0tY_)lZh{|G~-Zuz-1_#R#(3? zX(s$D>(kOfPTP^MHI~&%e0Y+oxK=N0L+Ck_jm7z5(hDhHmZ@`b4D-?n^{{;tQF;<% zZzH7Al7Heljeqnz=<#nVGPY;rYL@=(9f96Na?5`Rzm!lu0VU$J>2$spq2fG4TVUHY z?zwk}@LV!1*v0yuA6g>EAJLD`Ozg|4+dewC^mp6;aU$F9?1UlG;)9Q&5)BE?h4TAO z-(PyK8m!|&US>F3z8p%C)GTP`v>OZjVKG(wgGMP{am`pp)5cCU;)Ibj`2)){Up%92 zjpQUEY=si+!z~Ey6)v=-Sp<|SnGKUc?m-RU9xX%FR+flK5X0M_t#~Nbt2`D1 zov$YXYhGE_sL7^N@Yx@sRHhq%9~cHnr=YwCYWi88yo5|jCIAo{pf(wb*)n{pi?;lWJM`lv81Vw`iRJy%%kN`tDK0banq{X_lH33iM`C zi6wa_&|~toRtx@}S8~Y3T*SjyXQsD9_}(rGT|!s*lH0_d!9*XvXsP`& zx+Q=3_VjzzSqn{V|4?|ESi=6yi$`GLfHm1y7*0|&8H{v~R*;nU=&-lZ>QCr@JZsZF zN8A1U5U-o=s`Y`kHJ#mnqS7@$bllqJgTOW7!&yzppKeG7UsNV_Ix7oJulj&xCrprKZO-_o8lKD8|EkBveK{g)&FWlli1aY2*25U8f&bsnOY zM^CnJ5G<^W;mr+jtVaeCBj+P>Hb4SDd|)hb*OsNoIP+^3y8r42<^v8YI*EYV2aqP9 z2m{~m`QxIsKgIb#DI6RT83!{!SM~1T>$ObRRN|z~eQw9YQx(kPeoQhez*0m>m|D$A z*m?x2eH*i(is_QiO?Z$`r)Ia^Z6)nW%qF(mP0tUTWzu*xccv_(y+!uerQhdSak*_- z`3Hb++^ZtE%9$Co6Gk=^xki)`->{rRH0UQsGm zj*qyJ;2!irMvUT0+Xe>uVwgBr741FcX7eOcAN~F2UJ~m;-IwKcKFQBZmK%|x8?s2I zYN&H(7KXvnr>H7_n$feH+?tSA*}%usFJQyQyiE6>Q%eSr&WOp5!bva~9XYyIV2pCU z)+w0Mc>om9M9xc#h%xhAuD`v0bO}<3{4_bXH;`TixyFs8n%8s@?4a8l=dUpO?SujNA}(YUAzURx4-&v`9HTi z7Uu=+@qM(g+KTT;`IBt^GQl+0H<|EH>XNO1|0vC{(lfM_FsvVotmr2kW>Fi?(Hxe- z3-Q-dXox_Hl+&)6yO=!1r%LOZ@MoiTdkGM1xNSXPKYYG zau+rEt2byN3nBMC?LAhI>$fpgchlifK+Wg;$qm#|d}=v43jDslHf52vswvL9jsE-1 z*W)Uhj#ZIuY5EPtqTpopfd;ob2N&de@tNw@CX)aTM#xb~uv2FMk=fvG-4+D)9A??R zykw&zO%ld8(R-H04~&v*cjl+Tz`{;G?Ui4WaMX)~k@Nd37c9dKZ0I&6`XYN}OjOhA zQilOM1%`2wEPW&rQzln0kVa$r8<2;#;hC-@^9PKzXh1b(1{e0f!Qdw_Y3Le`a%rgb z`zOTuFRHv+01ift;+B9AUn|}X+B==(x2@l-D9M(oo6E>tdYIEN%2k$qtGRut>$Uek z_7klwJjt4*Oj9LgGsJDPc0e0v4T-aa68u%SWPt}@)h9^Ye11Gwm0Bk5+QH-P^~t8a zor%sONTfAI1-qrUGjhR8G@)~GEKcj`9CqZ`bNOIPiPUo!q0}v^K}oHU5w2J$afV+? zT5dqH{C#M+ zV|3sPwUeRL&LJs4cMt;OL_Kvz=nB6N8eHr$5iPabs_d*PvwI@V7^P&dP$=nV)J}Nv zAQkBuINXUQaeET>qQT|qs>_%mSSu9ooVih*pPA4q;;rEwn+r` z>e73;RKxu%b2LqIpYeU|eCY=}n`cG#y%tN325ibsY7t8El_+a}l&FZMM|5LvsUFxJ-;$4t-rcs{PEaUXS zm;Te^H$3R`su2sZ(AzCQ6h29)%;0}PlhfM@7oq||Ay1ytAUVKji?0#2ri8eFIUMr* zMoWP_FG2QtnSgwh8m02P&5*wy=T zq+4}2*u@fD3?Lbiaex%^9urvrEjd4CjZTmu zomJgPkc`aGqv0{ws9=l{x+2|TzRIzg!uY8|^gfQ6T~p_SbF4eq={ zHQ65|t$}S!Wlo>|x6K&CBcgf1x@U@V1`?{(b-~I^{le@^)*YjUP1n0JQh0`j>?+TP z54Sj2(qp^&r{kTK$J{j&#=jWtuyf zZYwWXj+kLA7r_hykjb9?c4;9Zeyh&&uPwyLoPr|-JN}Lld%N*BG`iiN`o;F+%=$cC z4B?pKGoUA4_pKaBrj{C^X+ZK9EU6$dIHVvw*qx0BNaA>|Do_`E)zKt0I2sUCXR zfwoZOr%ZtMeq2}xZFb47>AflU`8VSh4AwzBKv9Wt{ZGg1ef%MSK-R8NKKDF4JVC0y zNxA*#XSiBn-tWw7%h^_faHCCcF)Ibi8VPXJro{p_Aj*V`v|T)dTK3?P&v&mZV2%vl zO$rgaP;>3xd}U~9bryz1Pr zTrNOkH4gyKIdDTBUi7F{b5!e%57Wxx7;I7NDAZM^jfqSC)uz`0iPXiix?>#1uq)Ynd^RMMv9X8khipSvh6E2W;`C-qwH}!m(ES5mGHMsrSviSp}m|hq+;^(inv~* z>TQG?q_X!-P;60rmY-6&Ev^hbMR!XY;;Yc-g(qk!l5Pz-=u%ip9;@|DN4kXRyE_E0XsqhpC zefMW@r`@Nx7g%nE2ia2|uv|hKu6&Um<&(jdIAKzN2n!5y28ji*h5}Gl{Q->pBXK|3 z48EyFNrph|tRov>y1YIKUNYr!GM6dt0;epW)U#aPN8f>b9M}JX6r1xSsrk8CssJPt zlEsHak$gaCP5!NBG(a7*VQB``W3I1vgP(-{cdvc_pScA=Fs=UEhMTHk;2ng&2=@0c zzl<}jGix(B@(X{mgyy6E!GmDjk6CIV>-9X=#>}$RTKSa4pIuffjE7cgQSrPYNgws; z6Y8a#RaBr>wp{pa3pUwn`weCyyz-~aRe!|4{(gO@#j>&y;giks9<=E!Y zieXEG1+A`KHLy3@Oe{4_7w z&3Ltfiv+bI&(3yG*4LD$_HMGv>>42VD;Wo1Z;;E&M*!rUMYZ+;Vve9e&hzX%MxVg; z5eSoYA2n^ClUzpg!cm|w{$ae6T89%#MHtI|FhW|`Fdc2Rm7TJ$NqOL$%F9u3H6=_D z<1=BAV7mG;uMCrlNh%;Km!x5{m@bDm$D?eU5N20m8+EB>_)Zsg&Wr4Ia3yd}$9(sq z0mJJvo0E@U>nmNq>z`qo88viwD>A9MS+_NM;=6HX1_%nVZ@8zSvfZ1zL!(04(3*vdW>wn+!R+Uz~^2->iKuuY9F_dE}n zUW@{lr=}PO-?k7EmBtBs3Ji0|qV{;^2=QPoBxzI8FxUmDYI=2mNHh8lj0DSoDv%7< zN)*;Luag+_R zns1`tzsEC-SmI~1#^q%Xf9~>KOS8ACjUC%HB({f*D0RlYTh`L=A%wHQ>v%XL{vyWG zd8f`L$jysIL|j(~o8-lCF$;OU%w*O(V&~d@QR%4QRNI+!wy(czGFMw!tva1{qsGWw zqVQJ7mh_QkuMO9KpZK*YK8;H)1yU@wzF%OpV>q~&nTAw7`P*lg9y^zyz#ON7L)e3k zdBEd!_*c<5K!HxE)aD~HHb5C;PR8{^Fk?whR6_l8`%tg!mnRaL`bwN5Us##PIc4pl z`30R#Nagj%_@b~c{cmb+8o#0<;G`Gp)o>Pvi}l3I8+L>cg8#+-JkR;MMGSzHLeN}U z-@YROHvbMInqjnn_1W|hXgfgvp?2sxYq<4#9W{MXB1sF)uGdk_`xDs1LgjwMX$&6! z6p!LR=*Gstxw^5AoZ#g1@?`8O+xNj=`;&_GmGq9?l3r|nK@wd=-bM@u&thI`4Jw%N zO)x2T!i1#vBg;Mqx}HMJLAwDhw{YU;&r2$}e08f@kDU0VFR$Ot+P3A2OaPmc1lT?% z{)1@l_{yR}`K9c~l|oeT82X~oh5~#2P0YquUbG~={&;znx$%RA5G=}%UCnk&Ix=9`Qtq7Mc#FQFDwpOF6QinKoUdQxT6~#5Ex)<`|nLkU`O3xinh_Pv1Y6iBc!<(2>Gg^xC3)5S6gNGmz(v%Ol(6 z=jt=pUxH*zRrdP%d`I+(_%?R53_j!Bl;Wd$RSO)z{)~K`j1^YV{z;4~Uvw=}s9|Nb zJH`;V%qKPCkOX7y>XnEj!PISbeMX{*BDR0#@Md+0iyAXg;z#l24@&xM8-(1S3MInS zK^(6FRk87#X-9nP&Z&d(IH0s&tv~$gzxvVKr~Wp>CYZx8M?xt2L}?*~KcIdt(1c>$ zFtGg;uMY|)eRi1j@|le2Z|Ahh{r;h!N_9W|ya8$4pb$d~Wa@{}<6l|I#B^&&{Z)|=1 zfVeGe&p|PakX}&pa9-+k^Uxwtw}`^2CU_Z6z>|y zk|@8;sCz9`C{Z0#-`?g%xuPH>{FsEUWrOlfIH=ISML9sFVrXwFEZ<7M!jK`q<76bm zrtLH@_b{qwIPP}R4M!r{v;=q4C!{6%ato9bi~+z$f)J{t<6Q8Yc0ZFZks*F^W-rf-jlX4vTUNJG&)gUKUqwI>-xl5>T1w1r_vK} z9PvTad?CK`8{UeSb?D^>Z$~E^zDOG!cu%fOT~Yrex1F3URKjNX=G#YwMxkdM?y`Zk z%X^;)^{aeUDsNc-^jzju`S?J!jM@h}ZqB-CLAGGI8S{{eaMhAVG;<-><1Ljp`jNtI zzUD(I$Z}fVVYV738sklKrQFm+OF4paeB%PzEhVWEMg!{zR?N5vx>d!zSJ+%>t(=Vm z={$Ym!X272zJC@2t`1?JTMUb_UP_hJWZ9apXq&x}=C7wIt4l&@C-_6m7bvKezN8B{ zNkNE%$&siZqtGB9;ojt2u3P*--kZXJl{;{wTjMvhFSB4H;KT|w$1hGXtUmrOU{=8_jMiaDgqF#xptXc!y-C#KR5834eT?>KdJSSfdJ%#0*W_!E6Zi!mgwR_@}o9BaipOO~zyJM(&@JmNMYP8AkJ00iV6 z{e*dVF#}rgGt?EXMI7Kh4t7mgx}MwyU7}-ZZ-MQvve!~z?-K4<_U6Dv=T~=@c^Bdr zs7M!z3;#{esElCf!#d+`{?zZ|0Y>yKw?Y?h;TdIy@_if3bfzn2nqwld-{?>a1OGHC zfpm_R2;R25bk>+pTTL%To;&?8%&Amax_d;1W=G~+!$e#4#+;uZIf(Vm>!v`*FqhAX z{0V;-Fc!%itrZf})T2e*en<$tXFoZ_`rdwJQP4!P%qo9^xdwJD_oYZ8vTOO4`DRcl zbdnBVjymFZ$ZLP50SULyS|l$GcnO2yESDB}7$I%6PyQ&s|Mgig!d2m$R<88j$`*LA zw>n0avq8)L5m5cWX-&>UApON(!;JlpbVZC;p=!E8vMF!(=!R7KS`xsr3qrn>nVTt@ z1REfI{$Bo<2n1sz11z-vLa0U_l#nz77^Mw~Dc1#02M0!0bP`sF|G!ZHSg7B&2g_^TOP`WdZDJ(PS?^C_dcZA@{Wz}!_h zp_VJDQcDxNT4_v`lVJ0DT`;Y209Lc#XmpSpzm(u?C}BwWS80wq%%g@%C$40&>`YBg z_73^+w=p8-SM$rH{(IRkxG5n8lVfKJUX_`|5^H=JK}%UR6 z%6jbNr~K`~@y2cY<6qEnp7LBvHt)*4+>qjNMwm#V&DRBGB+%LHFzr>_JqEOk)pI)h z{fe`6P&j0{`BeZI%b>jTs6F4Y9n6F064WYtn^iIacF$KtdnXIz)jooy!7!E`&Mi-{ zJuJ(S%M5T2$XDbt;S3N6`j_mP1qk*P|H&~bScYool77I=G<+F@(>2m6$_Q*igs}cOFW}zQ@=hDY9yh_E8DqdQgB%rP#)xo)K1NFYer7HWeVHS zmo!iY3aC9fZ0Ztu@Ck3{4)__ZL_n2X$TpuK?@BU;nNAG=w~GoNWBJ({<#}a zGZLH*+UtuzbUHrX^?fa5ve89WD;2K>jkw(V=*QYvWk{(?R5BItCe{AZYtoxGO`?>hW^69gq~N#Yw`|%_ zzR;YS*TS!tVrl{|QZo)FrRMXDPbe8Ft8wkg=EXb7UAd5b75ho?@ejX&^) z{A`Qh_f$E$9pCM7tnNtu_Gpj0T;<)jlE#WT4&skby+7?+X80cR5NFBgtgT?8eQ-iA zx2IS%9Ma{Lx-V03&+N}ir(&NHJ-W+Td1FaMqYR^ z74k_*=n;Gg^Fs^eq}=!lvNRjS2!H>{CtqV%?Qw{I!gf-9n(M$2j?%n9{x?m z*Jpfzf?mC6*!N_m5_{%N`n1vpso~U$j|23Ao+nhF+XyrOdxh9b&d*u4YI|C}8?nA} zXD?^c-u|)t_BdHbRdgjcV90n`f_IMgG9AfapUj*sl38fL+u<73$?#|2R#@;Y!?Tug zH|j`-rUPWuv^bCIG7YDF$0(+r7Mv<1UcCR6D6>7V)>*#zig)Wu;rR=Exf})u05#?i0(+RxYUbD zf_dpCmVG0g&v{e`Zy)b##*2y;Cg%USjW=Opd!}77&_Oj_)y))3$fj@HGme4IP% zGIM=G*kDi2TDRNK;$C*ntyNnWVtjd2^4_U}?ur+^*91=J!9P6c?%HoB?)m=X&O3iI zE+!=zX;RGN>vv)z_zSdw#2mCyJtBy&VoW{Ztnqc-ulwfGylAtdN-~8opH1focqsR{ z8Igd=Hup{22Ri5rI*VCEsMwoB28m)y-7JJ$$6k0AV59`(O08-6D%SiTM`s-tRo6vv zI+aFRIs_yXX&5?`6ci*SM5Klei6NvLR6rV(cma`ahVB&U7#gIT85rh%*Y7_b26%=u z=iamT+H3tT4X*oKM_pa}R$9|)R#=Dk&YNvrc}b-eeNlF;F!R_Qn#Us|CBXjKpBexrZgTBl!9eXwP9GqD8omby@}Bf3vww_hB`Cp`q}pc+_s2=R?-q z;i_bER*r1>zo+(G9JTC<(XYSS6|<*3{w-fY>d{-{87V-elWfFPu+?Lt^5u)VC{Y@V zCy}E2f3$G>&RVr^`7PVlu?tpw50M{mB%(A{t@{W=Wb_2Bxn(t_2^#UDcOv_;gKD=d zq>58&1xs7kiSDXg56SA|kA7EesqA`!Q)UssXdoC%CK>QN>_r~)p69mKdZ1Wiwo*}0 zWGE*yG63^5^ytjaXVE#g7pDHRqVZH51F6B_Vmo7A0SuO~>>E}VKCl$MMIw~{-wic@ z4&V=H#Dk-H3sr2^l&mlBB#sv&Bng-h3CO*ddU!;A(q#(7-?w*E`IoipMN*+$@xO2Z zQi^+dL>dX)CwaoVNBhjKQ+{V|6MFzd_)}rVL%Qv-y;aws@HmkI%8l^M*J5y2U5Yl(2w8c7uj-s99 zMU1`gFgm7(i^DFMkJ`%wPF30&Z#GKo&MNa>NQIPAf<*0x_3-JPr>#?z%1s@1>SPxc zdrA?#L)}%F>(qDxXSNd^hsY2Pa^P1N!SK;{HxGe=#6NfS=xd{Om}#O?c)kFyL5;&@>1!(oCN5UtMHg|7*lHIk#&5X=1)fLxVl>n=TIoV zt??H_7aQa7KVRj0P56BpKla4HI`XUOyT${rRJ&BS?U?bKtC5Ro;*lB9e0YP{!7E&g zL`rrrrjUC6RAEzLnDi3ViZJ7+Zy%*;dP?G4#53o7kF#+Rp%9@ycEH+%R=Ap6>4qX*sR65agtRF8BjdZyG)bQ{yT#; z@0-IKhZ#lbCPN|tA@e{`+Au*k$QQ#PAwY^jALcI@rV;=)bXQ@r(LnR5bQ#9u^6Xj= z{}9LoHv3TjE^^w2;!~J%0RJx{JGQ8w3Ovq^ zUyTurCLP3e{z;56N#@ycuZi!8aC;C2g;A4)I`Cc*{MV79L_;MUv{d}Uj|!eLSf@Eb z$Z!y^&{<2g?iG|UHqQCVkzcbA->e+#G?b-$$dA6+xcY@k%4rjMR-CE3(uQn+1kyg^ z1<`nqk8fX##<6#9OBCknc~X4#PmVXyc)bG|@V8FE>{t@d-xgpl2^{AgKgx@&GSXdt zsBAx~3D$Ita;vUC(ZQ6Rq|-due|;XMKIqkPHKrDa?0gUtN;%$_u(8$^?Djav%4wc= zFHY!v{QaA%lvSFlT{o6kLGHt_XM48y7JeVU6VUxU5*NCG4>pbAk?NIy`d7ASh5 ziQch$Skwl`FcqAVVRWm2U8XeiCF(L@vJ#)7Te*dYYHNl1YsNg*D=vl=20eg^{3@3UDYh6 z>?+&M_@RE$^z|nJ7X((3lss0EIB#dzQ}KX~i;c`dqPvp#uYj0MU4?BPKWaG!zMhf$V)t9ebg z2FcQq2VMX71CwW0;p&7ZVi?nc$(12kB`W6)5dXD39%u*wi^_J9Pjg@m&|ujONY-Fp z5KL5oQc_?Xa5{Da2>f5t%mKW4fDoCUzdl}LUjv$775-%Lo&Y?+zSh2xcg>CuxAl0!wNa1FKc21>I|4C&FdnI85e6k#&xJ-|pV#7n?o$xJn4rkXCpf z&*#tmY-~9*Dv5hcZ+v+0-%cCsHP#dLD+&}^eNmd*V1N0#kBM;(=?#SImiebYeZk6)A}t(0e?Nv_DbyTzQctfRHiq-$4vk(Cl+brymTTk zD!I9&W;P-p4-lb;|G*l3d4NOwJ&1R2r5<1fm2nHYYe<|13{XJvG&oa(TVI0YsOmND zJ|^D%MvXTQh_c11PiXf9T#u0u&uSu*5~j+0zvZ`|?& zA>Qni=e5Lne2=Qjp7IFQJZ8yf-cD|H-lQXH_L#zav=SMlm0F?vkY%H|_S9uQ76+TA zWF$ZPkIKm_Iuf5$F~(s>B#~3R)2$+4%SHz;?WGt;baK5-O-Dr5WnWRxkSX$fJ+HiM z=gUa&x0c%||JWc^g1H|jReaxLXvi1{PX@?1tfx=E_$3gV$d`7M7#Eb8t>ZI&u2zGC zaZ9-jAP7wD#u8>ha)Fhlzcun1z`Jg+7zfj}(k$NBY)b`y?>rJkss@WwR z#meH-00{!&)RUQrbEOg~9^icXdd$9iGIw#62mb?V?{hNTTN z3E=kTZc$`qPTg5>m>DsM#cQ?*#KH4s;jDLBO|(*2DNrS&ADZPp;!@sDyL!buoSD;~ zKSg`Ycj+0;ioPQB)V50O!u7~u**-YS*X!abp}iYy-dV|?PV)XkR7CZ9f7QRYmPfG1 zGy29HWqEF|g87K?nDRV@TL}9v&BaN^{|nDUT+|M@Fh=R7pT^3-*wkaUzwgf=ap;Dp z`|x+|7V&@`IPmWIQ+WeH!Oj2qF@WKJ2RcKE4;Xem2M$qvKh+rGN;X9+ z#n{U=;_-`^`xKjN;TvE`fz!EWMVLTpJ4x)YV)HzZb{)dJ%8+ zRAOoG#~LXMxCi${)~XA{i4n`%&lvAX>1e5a#GRNKbtcEyCudjg?PK%2?s2b z{T;jf4`9hPDh24>P44=`=YpmI)-wcnT7z~2n8say)+~l~%HkG`pCe|2oxPrk)X>hU zVaXK9&phD>Vm2z1zFiqhwU_0lUOnk_d&oRU?oD$~EAY|FDDCYtYxUKf+h4sm=z~pd zRjbIsPn3OJpQKw9*7m&rawzE&QBfBRrod{diC=!dUg>y}SS`}_$v3ae)3}>&jCQ!z z%?$FmWCD}WE7EwHocDNc=9E~I@*chf@oSd8>i%verkbt<#@PbS%>gf5cH{Vkqp$g< z>HXDV`&68epVJ>d*(8iXA4HbW`{yzy$+v{D$}4<^zkh4`Iux3%a)*kOnB|RV8}oO_2ND zIM{_57EYm{JJYZeKd|t%0HDK%)13VBmY0?Z@D2>()cUUV3b+D;dp|HwQ(!~@@EHuy z&c%-rP!$FfiTdL8sufI>KFKJ-`D3A(on}hf)C4+lE_F(I5 zPq+f4RJ*^pYU2@AAX05ee$h251v{nWNvDmPUKGUPy&X#v$Pbn6U{o%7+uYFkMT=-K zIc}?gJgX?2y+yvHa=UrV+H6swvw$dJeI`@SJT-K{E1z`HgkMb_DFhePqM`CXvW(e_A z@CtcLP4!%Y4%8Rdbg+053u&L}6Gt$>gw;2%We^*Z$H}tR%A?CFUo;ao16LzL%bi{> zMkos@$n|AD3apKqI}JPcm3G}=Wb&#Q_usYoo~P>F)!O6PBO^V2+EK(|wY2;n&pGeG zbEhS*21yUAn0Z3Sc^}{Pgv&(VfgMOPg0QvGU+bQTIK39;+4Tb9s=C@`C0!p zr5BQN{EM(@-}>o#WB;Ltar(r~l}(*KkByGGv%{hb@Fzgv>1~1%gP>u?D6YM8D;kmVS&11x~veHqNKZy&0DmyW&bK8OEBK;fIy z5)5Is`K|Py?&~we{qWg?{T!Gq|3fL?Ramyt&@ zc#lDl-`zEdPn-MyC*c2_7>Ey`U%UUn5{`iZy5d+`s`C(1R#U%$lbTSZE(+qI|Z+cdII$T z3G8_c^fI@-y#~_{f;)q%za(gQG&+P^lrL?~a;Z&JCuH0g4yPy5S+JuncBFr9xJkhp;MUr%lDx`G92&I)6bwlnqsQ7tT9tQ&x6H| zWsXG=Ui$n&`AlPisrEywNRO46VyAZ^FlIt-avxWBEiWfopQSHol$$`YO=SA3_G2xt z%xs^gI()yS0-mUhb9wHboG%wql={?&!ugj>QSIF1)Wi6Sfe}tjFPhLprj=%cJrmh3*n)eBiLR9DC1tNV$*2jNGIYqEXckw9r4OkTOHMU)4AXf9)nr!pcbO9+PsKf zK>GL=U6~}dU64Zxgp5$wWAV7;yY0A$a>=S=7DE7bj#mx3fuDh(RakJ@z41j91ZnPu zW(N*C5=NlsFpd9M#dFD#mbUJh2C0BW)wfR-6e@`0sMzED4JPHIZ|7F9#nb}WD(tPs z#g1|((1uKWg_c2A7EG4GH~_!qHUc0}((mJ)&y=#Zt3UMan$YK^{hbd~3;aBsf3fiI z+`Rt-B;5$pt1|n#(SPQUOe=2S)MOuXYjNb}Y@L@uLb{NoC@A<2q+<&XRxPrLw13EF z+CM=GUqU_s;HF?5O>g)EIAMQLNqrR$5TbdlV_&p}|5#Fpx_&l3wlx47K4JFZA@>7{PRjb7)wAlymnPYpLKs)lP?r6F%4S|g-!ztpv2 z;ee0f>C>^x89jrId}A+}H-22P2aZ(v8si-P>#THt5q^TW-z<(gMr?qQ zi`1!9;3^ogEI|Na?W#sWry>g_{s_FN)t)6%M{uWgP9dmj(g}Vlm z-G+00Jc9wMXvF2SHTYdHaH@%}bn-*X0NNej(G8$Q4ot9fs*b@{EP?CmS(xp|e5T_n z|Nku5hZs~KC%J#~S-GB!SJJj2%+1@=O#A9>@fwLqyGP!k;U|!s0ibm98r^^kvIi1L z*&N|eNFCOeV7l9>Yt44ad2?$6{Vl#uEJm3`P|Bb zI~Dp{_@e;uE)WDe%wjCFi=BuHD(_%Aqn1JU0igO0Fxh<#1JZyE2>aNm>3y2j47}L% zeyZ}7LBS5&lNy2U@%IBA3tRio-&S^4St;n)HWT1=r~+JbLg#y#RxF9nc4jy>8((SS znxu?)k=YnpZ=;wVF_?&&{!z+499HHs!JDTPqy;LQPDE+5Y*7S(TAgVwve z&Q&Cz@@F_gSzbF<4DA${OPJOldh5zb?0Tt=X5AjfVqIqeWyF@|>-5!5KGj>-I*C)` z+c0mugM8c7m}1QOiZsfRyI@S%twb*T&G-+r*C;sDv4Y4wz}QUT?6g<7-A=&_;REm$ zuZe*{FhvbR3}dw*M_5lFtCrFiy?^27GFV(fJ@mz;UJo96!#=Y=YVQW5|K?Vp z;O)4pB3F%3HC)7M#y*MN1WzJQ5&fdA=M@q%{iL5CxmfXkFTl<^- z8Fx&lVuHTvgp+@Iw_xO9Mf%evHk;I_#CO%y* z=|W>u#(GDx`=|B#-+Z?BweH<^5MSoI;fYPJkv57a^|aWxX@ut7^>_ZNF_II$ZQ;No zcE2|jaK2o-4Y=%_!aU;3SU#M8K*PAz(wIYI}; zxOWAnu1u|GSU+@R0nuRfkthK`1B;f&N&(B6w!^^56E8?HnDq=g-3Ge$H7@~DB~O3d zy-b$de_t4a8w`_r-?m2*@Apvb4b93vlW@> zz3a@=q|jcCjds{F>F55cKS?537WDp{+^0q8TR4R6d9TJU<>L;Nves1sdpVBei#rEB zW~VIgCTdwBQKBSl@3i|Cr{10V2iG_dG-aOPyO=QHi1C`W@`T4OO|PsH9D-aL*ZQXP zQ*ud+l_;-&IV|qIqlux+p-o);LLQ_GFIyc5j8WH=;U}{D4$Eh^mT>9&1f_xmT{tCM zrpO3F9JEjdbCn_9J$JS9ZA?FfW^*S|-z90%JchQXT9CQZC@~BniPFuPH2V4+IX=PG z&wC1!vvectPHP3HT`fp}6SMQ;coo7@<8uy??|`Yeww>`#1DC%^|G@0u&|6B^U`$UT zAi=lKNPY~Ey=4Bkn6o&jp*jF2|9>yJDUkZL0m{^Le4`p@ldoB9z+tbU#P7}{9)mkZ zd#mnDbSnE)?^myW1KCeG4NQn{!x-|Y+k4kAMmH{xN)pfU#uBhPnvUkk*sCMZUFdkW zrjUaeGz&|weaG8rUe~rgx*jufx|UTRr*3vS-_!H*FP>oh5f#VxxTeD40i8!36tx+l zf3Vc=X+3;A^u-@5lOzpQDxgd#84a%vk&o%gn;7YNM#g3Iu`1X7D$ff+;JeFUjxYs+A2XRR}>I*Z;*Eeb`ny*cTzQDIRulb z#1!iuZvd>O0LvpqZxCs+CA0qh2BhRX?K8UT-&g<=!8d3T3dovmjBmo{TlQh@C*JRA zQbd-b| z`!YR6C_-KniM$eKmd3X~k)o!1X3UD9&2($~Ro>6XSLSsMR6P#eFdPAjN ztxzj1-ljG|P?O~0(6t*)XTst)-t7l-buhi#XL;j8;f)=Cp%Le1u(+F8rgy3_&< zhhhwY!7ayagJcP>Vv;twPz#mL8jxw;y{b9i(Gc10bc1yW}CO` zjNnl6O%-mG3H?hxfjqoa7VPq1FWo3{Y3;H8M9l9e9sy&jJDz=opF%^GA>XY#if|Cx zwQMvrv1GPB^PfJi;o0B5S|f^jNt)+l$N!l}-@I6Xd zmwBq_tr}f@fJ)4|C%81_|3CD*qdKUESrV^P;MSf~ofhjV_Suu}Z%CbAl zh6b`M)--;MrNV=B2t=PpA`>^>pFn_;W_DIO0NZK4BdhlxYWViJ8`dy3T)7Eg4H6

S#7-;cNF)R2DH!4lrd@XM~su>mB2Vi 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�#AeXT>->q%h`uRy{ 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 &U<9S#B-+c>y1>Zk-BJLy;7ZO=KIARW@SYej^I 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�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%0D3ugiR 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

U_+Zs`qug>3Hp2_AHE=;s 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

Ql*ZEyIAk?G+XNU+eW0qc+Q=@fzu|)~8M$G+S&x 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


{V!GjrmLn!-}v#!=Q)-sU$mvQ-*( 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%0D4u6&%^wiIX?rp49X}N7p_{$5`JD=zglNm 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

3B)#|M04NOj z`VA;Z0+5c%gRS_CmubH};oO;zf+0dav&DL)Wu8bqeHRaSN$Zs%!uM> zZok;%f_dplKOsK=V&i#yOu2r6IsP)s4)T?P%7gIP{tX^7gw$~nm z?a~^@qMuY4iQjlHEFCQ0nA{1U@H&xP_a_?*-T1sit{q$p^pj;cE>e|>HnAZ4)oKHZ zW}6EL9qhe&l1G#QaO$NLGz`w4R#4#+b@VE4wtTNq8QF&qC1&ZYz zrSg)x$NU7v(C{#7xuX9!g=6gigTyEJui6bnW%zFnl z$KjqmIXe6n<(BMOnd;A*bg=^#tQUz`vCE?NW*1H6E`kZ0W zxq&yLcm|{>nu7iB);`x{ZUO>>bDVHDqHW*=wlUcUSxW{aHUSF+Bnp7xA)2SQArhi4 zU{XYQ-Rce(;#n_TSxZ_T^4D%;Dl#1%Lpv8(VFKs?x0>5Dfd2lHTTOY)=p8GtsLN)| zH>#>g2LmyZwOEy%&Yf#xv0F4B8LjxtIoQFyOPGevEIqGCJed(sx8%r57aVs&(ghUV z0+gE@x&z=uUu*y%M;Jz}uebGJRXy#%Avf6GVS!p~7oGRqmB~6fY&l0E=lKRKMJW{q zT@D9${ZkLC9(368Sn4@z&)JxYzS>NA0kpr<+PEc>u9OC6TL+2MDHbhw3zA-j7Gryr z&7ge6z50``DRBGDBpZGIJ~~h3#v7}iBnHyE$B8 z0Yke$m2Hh)p_}KBCcOFij54$WUSrJvxR>AGdpE5&pp-=Vb(rB}7QRf4ys3kI3b0Tk zcN?1}Mc1Uu&eOxz-pmtIrg~yhzrX|`S;VuKtU%%7|11|#8yHW_qPXs`SILJay z`eXkqjdZO)`04eBi?Cb|{7dyLL^dfjbu~qc*hb5wBXXRjH2j|p&4R7Rf~pR_1zPQ1 zM8c5re>P^vi7upcD~87?(KaX=ydQp37@pU^n$ziRIV8cQUz9+(K+!(M6_pyd=;Hts<<4g}vc+!vUS&F))`)3y~3Dj8pa(BpjQ#@kO z{YbaCzcet&GC!?m`{hLsIXQP$hHV(c>4&x3yUDkKhZ$1eCJ39lJ#+^l^LE{mxAB`G z*FlKBD!h{vxTAqt#`_+p#2qg7Lp)|2Uqu_AX=K|&RpOmGB3ug`L`nW@ptz1pN*!vZ zv#v0WkuCN(m)hXT3D_@LvfgeGet@)fwEs{!zc>KXLJ!%m!P`Uk`&WK&WXCdn?~O>l zzTAj5=w1&2{yTY%sIxL;_}ij7{#kI!1)Fww$t)0QS6kD1V8ksZG*Qzpr^v4R8wW}| ze@)k^lX}c*uLg=dq1K%b*d7`9uIc~x*(&^*AOv3w%@36&kQ{hI1?-xlFM(Fv$rllsoS#RpZrS6k)>mj6eiMeq+@5FsxkZyX{Ts7OAqz))x8PBS z%>Qw8)lpHjU7YSlx?4gT0TC8NLP-S$M7pJDZ+^mRw-j z`R4upm+HIcPr_d8QyMTU|mo3sD(lcJLBK_< z(S-2pP{epEFIznIpr5AUp;!G_A)PdVCynpAL@G*a%a&n6li9{%Xt^}aSDY(zj$>xs zjnJ*F3GwQ5er<*P>TlVh$8jg&QMDFJu~Rl&6vK1Fj?TJuUpE;HjiZQZnIC&~xxoIa zE9zs`RcJ1)@2T$jH9%9`;>ls}UXlHTjwn}zN=XHqHD%$_;<*h13U{ycu-rdvp-ax8 z2^D-dq1JHYQ}iWN-4UJ-F)@_rWD(rSOAxJ)^-LTX?t{@`s);(ExcaF?D;T`AMy{ii zYAHOg9_!fi;-Lgs8`Z5$NJzxwq23`4}= zEAS>AW`Dy6bj`cmAGd++gng02^Np9O*dP&xxmQLh_L#c0HnHBv3!^|VBl8d~lp26GH zgB!h#{_~N)9UY3lLhHO&N(ISWNU<#{-(d4X&-^d?zwo z`tYiGV6`~M>}4g*uvPZf0$HA2qg1#7XT5EkHlST&MG&`XTQlZ9$q@}>omJp_C7e*YhvDh z)Hd8hN~T;)Av)|{uf|FQI7tQ>68r$9zu3UCqompbu#^Y z*U`osjahUUVc$F;muFEz4e!rLn8N)ZA?n4Y6!Z6AprtHWm=zwLq%QQnm20?s#+*T( za-M>%7_?C(Gke<9l_K7!oo5#`OWCq*b}nRF(VDS90RMRO?zyxE@i^HOIGYJP(E6H|F)PA+#WUd87D3A`M%net zb82@m%We|b7VE%f%tM+;HRbY(%hzLXkiAl5Gtcg=i)U#i z{H|ZQfNX`B-@$$DLhl35*?vEQNfxjQHxcUF!C$W1T(JSmaeVamL8S{?_+{tKm4+_Hmt@bhWLqCu3F=kUSV5M7T3`b3>r)dLc>0B^f3r>Tcb(=V z)` z=9J5yk*F1zHNEd?VeI^O=LjLchZXS2;U=!T|LM0gLCZ(E|G57ut-q_EuCv-3LA&&J zGIQLTisWy%FnLenT&9-@+VL!hzAxN~mGhV-p)}Y|bFD48(`dLy{f?i%ja_z@R}tS^ z>%<qt`_AVzHWOjRwM5cP`)N+CxBE;gk7Z1 z!228>KYgDwY`LucI6yb5YmRcI@B9V#-S=_r>c2m~W!A5w$`bXie=f%WIw@tWb<-mu zxf``ei$Y7)Z6*{@??M?hti{uee^azCkAgR0W>Z(TCsM7ix9JYpCD7osbqEQ*M+*Qy zYumjVC?yWyko9aEnHRdO6T$C!FwRattJg8cZw8@vxder0_xWo*3YvE*7M+Z;xmC+f z)t7+H*!m~8SFwx`XpIsA{!XEjMWU^v(gMd-wke4Z_xFhr8i3Hq-rkZ+0T^PI;1pmn z*)`RCv?=8w%GkmQqZ0+xpZ@{g2%CT!ptG~%YnX&-li_!0*d+`z_YaqXTiKr(S))5c>=d;9k8*rE>@ynZw2e8!8J3qzMKd*32}eczF1 zp_qNB=;cVZck1XDY|qAfB=*@6Q6t|OyRt=$yzQm?N<)a9d=0)qQLG=J^Q!lW`n~8XNcu1Mc%0Zj^DOUurA2jLgac&%?NF1 zR32j!l5)|-qB_=aD?O1$K9TzVbi0oexxTg`yYS;6@0Y!ue(Ob_*w@fBfIPX8*KGtF zCX(n1QG`7~2uhDJG|*h%@B{=huDs(KR~B2kz1DInca1fcXS^< zx?u<#-;qaIUgbXXAHJhSQ_3|#>gdq!uvOsAhd4$1Lt61(5DRaiqEYMtpwU2GJkV&M zpXSZ>Y|yP6_<@2eV8&M0O;0Q;gN#pP(Xj0<@Yk7QTH6+@5KIK1^`pIzh=eJ$NNMI# zrbQzS(PvzMM}4>~Htc930T4$N*?b}fKw55wbhBLNd)YmUdp@B~Q_`zRTpLf=Y%?%0P;A2Qm&27t@J7R=S{KEQfQdJP(<-Vya}0CFd=DLo?;yygsM z7odx?ft=f$L0yf?XuW;sm40u5s`AepSQmBnE{{LjtpdHILM{nm&++g)AZ0DC!kxSK zc6Ex;jOgtxDZoN1LW}iwFY``mV2v&y1?EyhEBqLYxQ;_OP5(#o!-9kRHnA}>%c#>5SEq# z{H=7xPv6e@syxy^4A?!ne#LhkNL!q@cz}r>IO3@;+p@3Sc7i^kgcfMl#)`}E62P(HBw7Mn6Q{r#s#X5bYkr=@+V ziH$0R4Jk%p7hRC8)7$0TvcQ-emS4w=n+%B5y%Z41K*#0QjGT*+m-NUJ&OQ7N_PuA1 zFfSM%j0Z;bt|URP-PFFa#=Z9nV#mx{AYjt)edM&)7XozK?lk3%A9Nchv+qs9jTV|A zh6bo|++QLXJKIZwbwNYyq#R5b;$;yqIXr)A_WtZHpw`?>pOqx<<4P~f1eP~FZeSwq zS};`7SFJac{!HL9teucpI^HeDyklzM1Ftp;;+&&*0iNYU>h61C)Cz%XUm%t!f~>)!23thH&z5y*RopkRTsTo z!3nVP-iCzS0VWv0<<7z)S1}w=l}F-$daZpi&$o++y4X_?`@E&YO!#Qe=0Gb9^!2O$ zYB-DqJ&D5mamPPssNqH%I?dfBW*DX~o_pWgD|o~s_ow0>LH((an;7x23!8bAuLOP* zo6n)ved`3FpM(grt449_fZAV~_;2y{H`wGp>XKK{s@`>+c^-F8bK|FLy~ZM|vh-f@ z^1A=B_<+GxZ>{wFB6s^wazriLpukPFo7%98kY|#~n5ZzL(Q;}nhW!`f_MH)T*Mr2L zx&us zRty=Z3wwofvBO16)Dg18Mh9GPp6l_J6+r}}`+~M=qx;J(&BY)k#59XT5sTdrze$5y zh~NMT4as`4dwtQ{a7E>mr!VJaY_!Fai=7NQLRjX*H7_})DS^68*lax1`ycfpA8RG< zqPI%EzjALcKieW5n&lTuH~+|MRty;eBAxr`wYijT#QH{YJZ0L`kvRv^ukc4dg6Bel zDn=SCvU8_LV9=P4zqKtw8_$Odi&D#64I_|b5X?Maavo{1#|wg?qkU~L0ns- z^W*sfeTgxNa~ThZr+%WlK?C3qGsAE|-b@1+KW|hiK$f`r==<6irOSMCx4Lx2;Qo1t z2I=!n)`1Tm4}-o7+w``E@3PaDQtrvh>hwNRJB-a`MzV{?FLHKEPI6d5_E2N5z%V(s zrA2Wpz;trIiNKWp)S@>jO~hs+BFel>r+%zyo6rS5Gi}5%2wcIMC#vQq`cn#2C8O;R z{>?l$mT%fz6Z{oGM!m0OP=uNgY32=;%64JIuwt%`Ukl)~p@7$D0L9sPI2=@a+Yj7L zB@AGgfxBPhz^x(aUpOCT^VqEos2|JuSq_jo5C=q^P%!wmlrBhaTFgBN!CAOuvJ@RB z-?(&L=O`qW;C4=JSBx}lT4WVAR~H|Sq}IiDXn7R(rp%y`=MXYd_}kP`$acrISBS@Iw1j>;=q4szn_7kVz-j?+(!W26)hX>B+^M1&8YFrZRRFx39BZ0MGAu zQhooMhoWX4y^cPB z=7bHnk~Id&7Cm2>m;d!s#+>Ug@WFv&*i0NL#*LTr;^P4!PZPfq%{j%tP%14O7b%jX zGGZYC&RVQSbGfaEUG6x_K=XNoMn-iD*&(Y_hUDJYaX%&)n}@c3xCq-@nTydW^Yx&V z7g)7Vtx`NUU)DYva3E39AK{kW5{@b|W+h>j9H1Vi{uu}u`vnzj3jMV=x(EYwO9#4ABtI73 zyiMNj232^nC86O~iU=ql26TNqK&(0JZ2lo?3Khd^6v9k@wU5;w0h0W2ct#Y{Yy|Xu7d?;KrGlAZvI#T<0f9Msy1tE~4`M(KmR8Yo z_^VS`KPK@idwH*N+m`7A{!ygBKessVeqplcEi|&_+Rwe~WG*;oU9T{VvZ5i?Zv)zv z4hX(!Ony)AnRlhZNl18Lus&6u)5Q;z=R+~zewY$sV5(Iq!uuY)(?|O3%a;0EsnC8= zhGW{lUl&DD@SSa86eXJsM<#qMOIY!2b|>@`DspZk7U#oPBaGg_Vy~;VSE;Vx6+@`r)Es?s;#U zu{UrjPWRr71=4>O8Et%j`rKjsN3u?#km1xL#Xv+J$2be=*G(>&u zT6YWD5A9y(e&W2IjfA}`Mp5SHDB=Ugma$RjHVg>}7 zB^OQG7-X-(b%$k(ZzJfiKKjuvF)HY49$)&J*8erdcUr#3Z}D<0;wAo$X{xYtWOI#a z>GeP}Rb0&j4ZgCBsnJDr$8TIDhL8Pny&f4`Is56l?dj;B9Bz)x{S zr2Ve@SkYcR4bKeTD~f}l-U!LpAlg~Cgw6P*hAZiIG~MbH9P?nF+r~Z%q zAi9rKf{Hku-&6L^Q&}!L(tO&+y4Tpz3aEcHhTE%9*W>xF_}hkOmo zL_zLb@&cd)bJn68LjMVSDVPVa=mu`?&=$wgaOkZ%X0SdP%IXJ3^XvnWHK}VbeDviF z3=>)i+~pym5a3uZ-h4(Y)p(`Iiz;QÉpO>stvPuLrw(j3+TqELo}QfhUqzOu)H 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 C)gYk& literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/logo-glow.png b/src/sdks/react-native/examples/expo/assets/images/logo-glow.png new file mode 100644 index 0000000000000000000000000000000000000000..edc99be1b65ef9ce1bbc04b88d9c2994d74246fc GIT binary patch literal 331624 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

h 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 z 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

(T;14;$?I?&4ux?zQ#4`il1V->EP| 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�HHQl@w3t3}Wv4b_w_zA7J%yE8UU%t8XUZpxe3nz1eg{*)7fIdP4 zR17J`!irYj=UqrgGZpRCBT5RynKZg5v7sLgCt~}#fXnVD$#1RF3kxaoba!Ue2QgdA z18XQMyT#PqC#wy_41!oc5gD!r&mghkDYiD{(@Xkbh9Zby{uw;Kyf?Uz{9v!@?qw=F z)NYA4#Ki^F{i~!dY_GRVKNoC;A;p%{>KLvCEG^?}#j-vMya+t)^m%!avDDAn3RS0E z?>k?5Jg>{v{k0D2f5LAxJa}kI{e<6%3gdBk5n&-dlG+o4!Ok-w5)~BFrlbE%lMMne4AoI^z6W2`9 zr$Ky_I>9?zQp65Ovp%Bfpj?L=mQLn1`v`zG3{)DhHP1s1!uKT}w4mvV`xmuCs_oJX z|2p@fte&&J9w^&V}@$0g$TE4^%bkXoDY+1x6t%h$M9k_Ol?;Lu(FSc(;!Zg zz-TGK9wRPxY#Dy8qnoO@Oax;=^H(Kos70tLgEJKdyoim_+QP;5O!u26rqVTS7pnOOv0a(j-5PP}8)@RSV~8D}6>Le?RHzZrWGM^OjDdsg(PZj#1{HcMqoZJr3Segu z`KlELW#!>{pjxiwM<93Cv;6@-W+9B(Kl_gQBiey9SWUxqK~!Jlc_pmZ??$^4wrQBK zQgEmpfOA+j*hiRziS}K<%0PHC*L3C8v}3m+ZjZrp z9n;<$BU#GI2-U;-iV$0!R@*1ZO%5Hh5>ONtwC$NmDup2(pz?17{mYvExjh7dsGfnF z;>)bJMfCG4B8(^SB)>;w)DAi=A$Zcv%Gtw)($tU3`?FcD&&vR3^%dC?!bhJiTw_R4 zbzHzYt}SV|cC6{r-JO)>I%ymsin;;Z2*+{X{x;@IUN;TlCgzIKD@0uH!u0B0UhaLY zt9aZ173(Uh!Md_sT2~R9)_f+Qg^$P3uv^?o`XfcGe?tvd<1Q~-i)+@`=TxYgALk&T z>#>9qmmP>6T%RDr^(TOjLJh@J6S4{C_Xl}UdkTl|zf52L$I|gT@AJj?rR%o7c~xq9 zeZNYs=z@Fid;d@G)*r6!)VuOUnFm*JnWq;-Wjg3`9!o-q3oZJ(GY>gIu!J${6IeF^ zS`D$K%#Q%8>pfyCCuQk0J_uD?w(Bo2^N%NzQkzX6tq^2srlL5c{4POha z9pKr{zyj3{>Pi#Q0T!~p_C$HbK@kudyp2Frn}9)k*43pJ5b3^= zDXKDFae=_J*#d)*Q4wQOD&tc9rh-9N)*Dv3;i!$DT?-0wv+u$#DX8h@R?CPw()JlJ zkWia4U8|O@ED-C+I*?({xg9YyL}^wN8HVfklLFjEq7bOn<838?D%4n>sVxoE4Obm? z)dMem?m-uMaFt^mS-00C?=X!ggI^tTu!*p+qOHSyxLOt~g#Cm+BtSO{5K6L+ShOcn@=0Xx+$YjLv~r(>Ca>0*C~_k`hk5SGKB z8D=Wdwqm9tsMLwwtCZoQ2;F0!XN}bDlOKg#KN)b@Dib3*V!CR(&2#apczpht`}Eks zmc~M3`{W4d`ZLx#QoOYsoz1r)Gx2x)RDPIf8`G`M^b|Ov7}+hfgkTpgZTz)SSsz^2 z?jli0k>bXF^6K}kU<>UbU{#0{3}|5}(cUUurFQeNEhl8-y%mAiUJLpFXsyF477(xt zrF_6M6}fFf*f20l!TNzfk1W7oZECb%47v_+6W)-fP8?Rm$00CZv`e_|Z7?Gp6+sR) z7d7N$eJ#q;v~d?@uX3H=a0eWk)pG<;rph&EU6^qmt-!&LvNNW;uaoV63^S%)ggjb# z$biH~*U(Tl$B2L??5Tq7V$6m5XsKA&MD;L>Ze@msRm-yCG$*LgfJ_57Gl`Dvu0DDW zxs5^?>Pyhuh^|YYn+poDGm{Ub(Sct(FCJGtu(N|Q5UpF^hR*RY3{f`2YXn`@O9SP> zI^uR3)=%w3?R{Qr#1vG`ywNgZY7CDSo#t>?@ne5zQp?_)1 z0`+0#0Mfh%x~gI!#v<=+Ib!{aWy#sPa~BQ_ZzGOSLA{E{d*f73^Z*#f2*b4Kx<6{8 z>9w(jh#xSR3>)$rp`4AYE>)Pm)Yr=l`$!uH0_!;f_Q^qY6P|6Zg&Nw`osg*lya>Dn z#;c~;b!3zCaM62fwq0Z(hFUJNd$1&|$#lPDOSD-=B`IW0Wq&k$sPz>Ue972%K$2`? z161(@_rto3qdTdsSoZEXkIDb2;X*$y^JogC_egzx=MI6b>0j^G>BhavCIt5tZ^762 z$1)Y!0QIT7`@@HDynW1M&`LNTz}9)U2oGpKjorfbb3%$NZsL|vx(0%GMe|UTI#=+K(tyiyv=Vzrm1oxoTZy}pi#uxJK`tV_B@pR*8 z9P26?vo#7OYTUZRTBPAx2De5!FThLLuPp@cQxeUif7XUhb_HR|P8}%xSBa&9{w_qr zik7yx;nz~p+C}#pDrl|svJkW|>lFqL!2vU76arW_8hL_oNsBZekX@3()z*6G<^#Uh zT%7$QVWU*nrSoDbWX)hKstXe#{oD!a>Cz(h5!0kMddspBX7u#?yw+c7M`rKWY&*|j zW{&m|+U*{6QMsEs1Yc2%UskU1(+Vs!prb<$%68!epTQPC^8hFSk1q1TP9m;5hO=hL zqMDTzlrT`kc$8+D($r(DZC%OGrrr}xkNYHjpuT{EyDC7c_nC@y{p68Fchwx3$TWcf zhQ1zSuOSP|cDzT#4XamUfSuV-uW5(e?^>^y)_i1R^22`<+fSw8i zVSF^aNHdEvE`}C$^IDL*Mqf8}ppQVyVL+B+UXq0tb=~VopS@Pl)dMUIHO3M_zR&tw z5MZU}MaOKZg+vTB;(FqH?R^?CX!Jn@TT1ysJ%#L5i zvS@(1r~V`w^R@u7koE2W^dJLNBrODBX{@Mjcs=HC`6}$wEq{4abX?Qv9cH-E8se>} zIrP+EfuQXYU-_lCUKvS>)Cjw;%1Zq)fJ}Jfkh-8&DGSu;tuzWbX_Q8eD^1=`H47uP0$#FyE!7kC zCX;$5zY{QEtH;AY6xv5j>kC6u8c566#AUSa8Ulh~{3<#)=(Sy{@0xl0 zs8z|}JQ;(ePOz-HrjW_#;U}meNaCQY#1(XaFhd8N;`hmnt;(*HO-o7iN^!mVIyt~= z2f}RAfxP$>*%L3`rIN0V;Y6n888)E9jkW*G_@*ibNTCI@JGss@#S&9KOniqjY zRTh&S>W>c^&cvBntsa|(nk(V8ZJCdU=#*5~H3_>HHC{a^;Vi*y>#YHh)$seFjKsK} zP~Im|+CAdcH)+XaCyvb{=a+mY+=F z;o(2%k}?P^#~YlQ!*xqOpaRwo!a&$90%sHTk+fV>?!uKA#)yTi4|^(PjW0Kttr&#! z)WiT4Nr~%CV2zh?l!f!BUZA( z>bS73;=NNy=sJ&e6{)7;P3_AE{8us+xx~*hp1&3P`Y}I!^2HOL_F0I`J?4id2kKJ# zy4D{LCt~|afJ^gYe1d-#zl}$aSi5x&fqHw4ZmplC)?prZ=o5O)>=ujWn-op;$p{?a z;roT@`W*n*QCvcb3tNbF6lq6p^7FUfeoHo7&rrd2UoB6$ndvDtjRrekS+8H&_S3!P zr+N`Z^ZW`Cy2hPNI*-5$vlus_jL*EQs@+1krHmF?G=vbMF>B~8k9Afsy1@)focBCF-YFUVs!!pa~YB`FmmFl4E zZg~#t?sXQ8O~J(8OGz>TXP#zbQC}LUOUL)@Jq_0L`Q(wfu(4dkND8oLGjQTx5LeVMUikpApxWw&75M(fTeJ)c=bvA`v;NK&y!GJsSmNhFMGY4T=gE)j3&S0jsVEIGOCOz6`zsR5 z%QQj2Kgw?Tyg#8K=<1dDxNr^6Y6)?!oeRq=!)54^+9&jv0T*Wp(9S z-h~B(2Pm9Zzy%Jt(A?~uh2{EcVYtv1;_ok0y7&5ddS*427PP#?dr`qfwh(X^Zl5gR zX!+7e!*%Nxlxx*+-6YUOMXQ&Fg{&{I>;TqM+}>DX*j#WEU31YYqE1+2Fl5MWy7*^q}yN-+B!jK%LujJh`R^KB(qs6ji(9LRvChFE2Wy{FA47!+rU034Gn4=__c70YK?RN~9;dXnZ z1SSwKLEq$L)f!#BsVKW;Ioh&d;0eQtqaxVRF}?3*EB5El9#*Zw9J@@ zFssXGcS`f*nWtSZhx^tt{q;hiA>cw@WIEy3%a~k|F4^VV<>l3LKK@s=ZWhqBS%&cK zCDgB%aM$#TkJL)Q=wt+X1W?@6;;rY)GKTG()JWJGWdBN&WKtqb!(1nY# z1~6aqp{nLTB(Q}p^PB(kgSH1>c}Nl4KTs`a9$cKGs70&qHOYx=d`d3!FM}fQLRESf z1ibVem+@HwiHFmA8b$M0-Sdfyxvr-j_f>2muA7+RwT8LvJ-hZwrsBeOsT~B+CMELhHc~S^Tt4}vK+aImyokn%5rVw8QffcV-^hn3bx4P z)aU^LNLk7NWv>MoLrpr3f{TF-qxB-wg%=?NTawAxr)IOhYqId-YX1dfv9=4W#E+Y$ z28|Ikyq#?&+F8tj86lm9lm<#EFf%bjG8U)C2eD5xF!RPkhOjOy*ZA%9!u=xqR+NS6S}boA zqiX6>5596yK>PfrozrE7`7^DWf+3Le2EfC+@oKMt#4IE-jBO*bt<8K$tA`t-cbD#u zKNrbj4fQaEoGBq>*|1&3dp&g_Ksv0!vn#&NR=Y4m3@`S2uehG5<*Er9R?Ux7I1<7v z(IjQK1X#N+3&VklGZmo)=Dc0H9!hu|QrHCOQKWS>j`PLhkapv6tRZCfdoUrgd<&5* zpY+BT6PVqW6JD+3bV}md`1qB%KCrf}+~ku<_ihK<2@;p~;f73CIds3dAe_gc z7Bdye8bZuZFS1P05 ztj}8B=gCY{6?j2=z>H~{S2ZbvrG@rS0HkL&PIl3E;L_^59Gqxq!uM5~v>cC5?H%|r zR0teilyG5l_YVw2QU_2h6sF~C!X~or6M>O+<|%=${(K1fC({%4fG^rYbkQL_Csf-N z#q>PziAk!27;PLD5WNP8zV8tqCfRnD&Kd{TX{9pwKwai|Y+Qn#Vp!EW&U!hBeGls` zefUcgDfF#^Zd!3^!9@c|3V1eSU=w=}F3^4h#1NyhK?>rUz@dayO-IJRGw(yz-)si6 z0Xfnv)El~g!M z+58mf1m6=N+w+N^L#9YDlTQMUu(&lAvepd0F6_>rOF>-sA$ob~6a;!KB@rzb4utxl z*1^L8N=23Q8T%}4mIXI7K#0gZ02L*T_)g33us~ert)Iq}F_)y}dzjgoTt}q+MEjb- zI2Q(W*lz?QFl|6}rW&lHY!B2q+QkH=-7&VKr-x1nhWxMqF<|d2L2G78DRoa#V zk=TkBE5yA{o%fk#m(IdOS6l?3lwOyc>O*^(8Zfv?cogTKE!1RzL2?@3#%r^{i zUH0PWHTMnHAWP?oVM@v%l`l(KySVw_#icBl@w8z+KCguJr(_e?aZMA4`QvY!z>{gS zd>y6NwNBj?ZssfS6yo@1h{_D}3V5)NYs=#jK&)p?0IU(rQ>LQ3%-2`Qbjjz4!WKpg zbzJ!3l7#a-+G0qtY}f!*o7F$YUS2}N@;;iWXzl*epNnS+-hm_SOR|S}O0PfU$3pHf z!zGC1W1bW1D#j(zyj|PspoCuhW+6or&37wAuyu}N`Nxk3n|g#Piq;(sDWZ<+NtjEs z6r%~*i5Qb{NbzZcCHC61_xUkhyjS13_&Q-Y5g#a;zbADRv8)d=jrWLmya(tsZ_B^n zkfMH(Xn5*XeQr{TpR@gIDp-vVvW0+E$?9Q?$%|JQOBC>Rz1a=zAh2z<+PdhuT9;lf z5z1L40r+%aikgLV{PqO>P?~8Gsp)$GBZoe8NkvO116>U(MmifQ#{&+MT92;ad?nd` z>XT)47vrzCV|z*#|0X4=Gz+q0vE~j;snh%#KtXke%vl({ z&Ei5YYU)mp@Fhb7x|$!IfteAgYr%QZKjESxhv`TgGcQM08*zCl5iZhZG;W$b`j>WY0V`(9&&RX&+3c>R|cVET^-F~VZXeZK@?ZW$x^+z z(J0NJEc3Q#is&z;B?0saCVwww!r3WMg>woyUxkOXgaGS0*h0+LBAUNW z*FDE2V2BwoZzT}ZHP8v_BG`S3>EZ7%Z)8pM~2!kMB2Q_WQs zO_nW8plhnJZe63tk?@^yNdhLUrlN)T9JpyHG7e!yvLsDROjJc%PRPQP7!sBFqJ4z5 z+5xdF8m`$HIM4i3aCUH3&5BM-I5ADe$Z!^8JN(W8ywII(Zy#a(sx3&6^*g|tg3px# zA#ugm%u!>#JiJJ&lEIxh1u-ejBK0j|nri-HyytwJoWIGz210QeeK*2(nN@|_Rm=f& zWq)7PNguauZt!v*=s!IK1}r3rfU2yKbgY~7Jq1&Y$@jmiGre|2e*?j*3r3nckot#-Z6l6d>}%i05#6WrSg27x zEoG&>hmggn&4Z=I-rE+U#w%`9=7gqHk$*n-VNr1KMGWuY*n1+1^M2aag|uxa8MMYE zm9z${vmWorR`o!q_!*@fVCvlLvm|J&5?mm=gS`PhW-e!#TVhs1ltQ%0&HUVCY}Sy# zq_#WLwDFK8fO~aiPMku9tEN3Ix0&O(eOMw%`$PaN3~}ZuDNF-0Tr^Y)>LP|KWGEJK zr!V`Il;A=g7e3GOeDm@}`awRMPnPHvW-8K1uZW9F{7_9raYf%kxu z&hv%gqB@Gepi3Dp)o;D{U!N!V{PS*;4?{>1=m5D_Eb6zuv|5U>p$8W}KVL$M1h>Qw z?fLSLwRNJLry%eT?ZOw(xXcsFwLPmMmtP*vZM}N2fUV7GyZun!2WeC+UX9=iC*!%6 z=55_=L`ZK&rx>nUqiPh)TT$wu;YvqD?bwj%Z7Q^@rYcrW%qSFDW(=$_j^XrtfO@PrSFZJW#1s{G3$y zp4SWwh?Xm`X|Zl1;|Kvp*|l&49E%TLULVn2@e8pW)rxTw=!)A;@%Xe&G)hunIn(!_ zO6Wh2BqXeF*st7%yA*hlsJnO+rf&-18&Q1(9M%S~>!=2(IDW|tnzB6G*q*8b&WJzW z;C$cci6Wa8Ejb44Th7I7D-;06&C)vX_*rOOo?R3^ywI0m8{q)4+HvRe!Rs>CHcOEq zW@Mz|>%Mf39XHQh^!;eVi>q=w7MkqAUxBFcn!|F(%u+{;S6Cs~32M&(L!j04FB}JB zZ}}yDrh)HS3sVnW>xoe}qH=hXP}+=F&oIt(TuYWL)2^XBB2(7202aErkZWqZw9DO0Mr@+!Pc%9%)h)! zmoRQ9oVR}NZ~1rG4d3&tlny68@4c7H>mcEZMXVn%>;h)E9>Tx9)s4Y_db8WSYSlID zN75>;cUXT_@oGV9Ro2ZqF6h{;W#VHDC)OaAm(JmP%233@M9Tv~S1X!dh^GJc(eV!c zMV|i#5_`qJsD5M7{QvrjX|8uJynr<(DHaMTLPKvAs-q~%`R2v{oN3iyZmEe-cc9YuAUXY*4$kr|!wor0L2vZTQQ zia5?|T}1*qWVs+` zYuC`lGmdv1&Vx0EC8tQn#L$ z_EAxN`_+x1pjBCdRYQ~Np=DvrO%d$SN)zzUmRYFhkenfyB!io}0?;nr&Fl7D0dxtA zr0?g>gUi5ybZQ~8NFeRkqAR}As^gR*De%Rz^ANRG%0N;$pR0o|=SoTdbn0Ja=L(?2 z!ioi+PC5>F)Or}oQtD;vj_soMi#Y-&EXKCTWPFwI8#+7!iPL~cZU^rDX9Le=1ey@i z?}Z@++*lt`z>Y%f$|h4ZPEGN;+3VA5eC|PpOfhpY@339oHz||c3PPj*Y0xL;osBa^ zurxkO&C#4Y3yE%wJN@1c(r{c&Oz)s7e7AJlz{tj?RwHtp#T@6oXKXHN`hMRc|ASRb zQFGvQi#J&KDRB!Hi*PpE6~1*0dM+#z&8>tKgKf{yaC5FYuNXD)f{JyHI4Q_7fOVME zvPe!r1zJ*Pn5fTGtPI!rSX1#%FPi62;(8sD+(J>s^?a+GI==Q+;gDjt@Lh5Z*X4Z{ z&c81uo4@dg+Gp;?^~sRDor>w$eR{Na@|L*bl$YPA%>aH-S$%S30~{~akipx#?o=~6C|8grIq z`uZF`Ts&==xtnBY%r%rKkCcw9`fHs9-z~y(Jo1$(o?Yyp#bE0w+$ukE4&q6^2l`z0 z$JeIM8t%N&6}X#$@vUl~sd#B+Y-3p}wokFc ziegE=Q?f#jN?V67_uOdeHTE6bZD0Cg9$?tb(2w*TgsG7XmQ~9yS zPPG4$LWl(Kg!7cG|Lu9|&XR2x_Sa>3h2d}gmXaNeZ`ZhonYLMsbJ&HA%TDuTb^Kze zYP!t}%bm%|!;ESzKxysHMelLd*+(>T#K$?go(8K+(4oM?OP2BV8Ml=U1D`c|uVq=k zOu@i&^22D^Xnw(y2?lTV0h{dDwq9CtD146&wrljY+H1v}=w*K+Yqd2U)e!_{Y>Ht% zUjfH{%OTv(6O0~R@mR)X<94GiO!^rT5cJIe1XC-tt)Si3Pa)Yz54K}3#Vd;xa8l_D zr*keqaxkt5E=dxC6j)cZmQfosF*Rx-ZINA=`b(CP-k$L+!4VHxM`s#_eD#A{)tl=u z<&~eyGI(+EV_o-f5YH_sft}&`by!n5)&397k87&0&6kTM{ArbOm_JwLz}WF*<*Bvg z3-RMpU#^+XGK=J;;2m8N`*Y3dBEz5S>P#P#`Ekwl@x@IkuU}C4<)TOe+?myqh+bSj zT~T?PzN54IZBm3oD&N45OC0Abu3@H& z87{^TWHUe<6?CD{O64=1PkZ=g(&E^If`16 zz}mU7T-+AV*7NtHKt7FGiU1ig4hZemsprBD6EYP^z!EcDr;TQ7fqcnc4J)uc#ES6Z zupQBExH4+<#Y>BsC#$mmW};tB3e1JW&sFD@NZ*XVTqTjEGy`BC3x%s(u;9 zH<{?1TUg&TP zDGHcL!PvLcZ1v}23jO*`h21=)Or6OrZUR(bj}Gi%CTjO!0Hyj{<)D~9+a<7eV9ugI z9$$IB+vn>i0CU(;j9HCin-BJ$cy7&pBcIB_>h>O+a6a4{pN4cVZZ|e<+!4`XQsdVWBcSmrr z_2+)SqvJd`23-QS_(l1@=QN+>*QqpiOZ$&Iekx0!n0V_6|6^!SUVm<3MIs9;k}zD4 zNK-NY#5%5l<&rvze+&#)Ww*Ha(jm2WNVq zqxywAAVwedp=?y3iF0>1pTxk_7aaNmsF94~xc?+h$Le|>YH@AYF4I!Y;ci@+A7+;H zwF<&_l5ORf8~en4Gq~fsewfsQ5mI)ES+2@J$^Kr!cFF#fk-~UzoC2Dx^U3rMYuEq4 zNQE)Db3QLheg85p|NV|G^?IP~d^PW`v43W~r1qNDEKEUJw>i{@wqd4hT$^PB zvm)CiOI;X`$@EMexf0#!W9PO#79)hzu2!N*DI9o6@zE$NuV^IgV_R*B2Anc zlPFo9?%fu&SvifQQe^9rhtB-Xc64QX{?$DmjOxzyTiSlLr?w8NGIW>}+l@fgy4Oo+ zqE7D>+O6!>z<$N|e8`1NtCsC#<3?iY!phn5G^HOYCAIpxQsPHCBM&aby*RHiTKMo;oCufJACi{G2Te)00mY}YX_^>M1Ri!faEr+7KlskQzrvD5{Q^EdU* z^v;Do%CgFIF}szPb9J3xFUx%QB6Ah%>&kS!RvE6V%5brO{`#dx`Hv4uesSVV#pm=! zWx3b{mD#QNZe_Tx6`A~WnaevhtoXxyJ+H^hI^|ceMPGkS=Wo1Q?)=uy_m@QfRavf! zAe?8%c|7kJE^dM4iWdr>#D5Y@-QKJOTq=9@e1?~J+>391{yE~GAJKOohW9IqeE$Ek z`bE2di!%wnbkOw`z4uDM#iDsJny>%7es@aWUuq@shZa&S=M`{WPhxz^rG1#{TIq$} zeBBaKWMK93q=1XVh?mA>T^#PnGI7wA5F$xgo^pLNvEzIfQmkOh!Plz*7jt40ldk&S zaXL$C2eC+*AN}sOkfCfp4PiwZfRw#A^M2Bjpw5yoVoB<^!r@0O@34g}}9@gW5vJ&YG1 zNC#cUY@ieiWru#eJP>(tSQv;Za7w9p6YYfp4IHKj1Xno+BK8m9Nyl{?=;~rfwB1gD zmxpSQB(NzBL|w|cjl2LL0eI2ab|zW(?s1WM82jAs1B+!U8NH>#c3e&f;l(kW7LNr$ z-ZAZJ07Z0M)WVcIAynFw1w!bF5{|~TZkzL$lCxbS_!`#ra~r~o-pwAs8%hg>o@|`~ z80-K~ifl=~O%OhXF^6@4DaK+YeliCH_6osyL`Nuw?eExU;$?Hf`Xlg?W#!d#Rs(~5 z+XG#RLz*QrJWj(8)tM;>m50P z06Tl*oepsQ z8pB0K#hGZ{9Z=Ce-QBI~x9XYr@-Gc+Ef1QO3t$Tkwh%vD+F;FX{teGkwDwH(@?6>H z`OW8tr^4o0D}Z$%&T|T;xm&4k=!UX3Me`0k+U$$dO_w@rKUMluG6A^i3ue z(?_@YV_1NIWDZ3^d7ZkgQo7NrtJgagDtd}U}xa#4DR!FUyFtN9BlDx zuBO9Z#;Fysj&aI0hBJ-~Q2G#~+f@h^Xd-oE#&jhM>#aRs)SeIF(sc|qVd7nVyB5-m zD=mJ$qA)&&vDOgwlkJ85T8DiC_#kyY2HQ(h!twH886 zp4FOLl+pA_`(y6uKd^Mds(BeA3un{bk!r|pW69vx-PAvJRGdDJ;A#(SNw*u({4gRp z(c!ZlJ{x?VFqG}a?BP?pDGqwUyz_pyjXZA*Jb%79u4C)1>-HR~!h;O+*(QS)wmX1> z{hI9o_`LXyxweCNbB*&5-=vpS#q$SbDtfDA#A0TsW~FpR(N(F*c&?5`=30&_!Wu$` zab4%_i~EI(Sig3~^^wtjq~+z0>GeBP`o}*sQ&EKT5>|Xi^6k#eJpDGXz@wt7(9P|x$f<$UE0Z90D3@$zxvTpSvM@HW;n)c#FT|5n~;ge*#2S+wOT(=8YRLa zgD@X1PGT=>AOb2$fJCUc{CMp9fX5b3d}j>0&Rba@Zl0;g2#o6~*2ofTxS}-#+P$Ot zX)@S~Ws+G!#8ECO@$;4xpt1Ii)l<|lyenCXq~(3a_NhK_O~sEtrdwOAtH>?6j_da5 z;wu3cYHvxT1igk!wIRE1v54lM?DU7Ckn;HH_WkHIFCoRymS>gWs$lDP59b!{c}HB=cmGieRH?whdyob;*ig%lH;&y4H0GV)}J+UfZu-Wuz>;2!mB|whz8x3d*Kl@Qy#L z;h5mpHOIZJlUOf+?H~Hu;jwJOc=q3F0`=Q?jd zMcuiIBo2&RX&dJ3UQYpZ;juqAu#JdqYoyu(zuw-$l21y=_8ePMKv^hvp?lz{*~Srp zQy*5vvf!I#zZKY@1H@#%MSd{hJnj6xw`P(F{^1StMt?u8lMWnqTqF;jFoj>^<8dsL zBGwS`wakZHb!E}AKHPZ5bT6(}<%hVuj~bx9RBH$@U0zhNMzgNa{~I3FlA&C^Rlf6A z?^r!WEn+Qta06XzjL>;;{aOFGrlLvD>EkcLZ^Bb$w~(y}{8J<#`0p8?rc%o8-9WKF zL3u^BTd{aG{; zqnpl8%hurfQwSsGWBZY>ESG111mjW`B$^p~-{R{EtTY4&|0!k6Af8`y09LXuD)U}n zJh5}}?12qi1L#^urYyW(G4{P?b2OI(LSHk=&BD~C3d*KlNOiqZfW>M=&2B@JN+Fl(BTfklx;UId%A5j@wf@I>v14O$11|E2`isS$2k$Fx1vfIj`yDWrb85gVfNR$! zO|5{oM}pTlbS(782Bgf9-k3=px9(?}uyyY2DYBIZ^5CFZO*oj$_Nr_%8T$dnaRJ`w z{-R($8mBVY>VceXH|=$7@8cN5GEWW(vGk-RsoF483)|1r*!!dd}WBsV2Q)aC85P597}R@N1h90-e~j{XVF`UMQ!rf zG7;GfwM*IOvCKE==AdPL6mIDRn&Ja24pd zHlanMx1N|3U{Khj_br4E>cgf)PaT@xjWF1VqnCK+2Xe6K<{@WhP-^z-a%sg zo_smx%5DXHSKF5^Pbrl984OEfk22qj@INqJQ(tlilf&$uG**Tt+B5_fNkA8DCX9{p zp95&ET*+Jnc%k%=_8%B3n`DJi0^_yneVt665gdu0lzclOb~)=I9E$_2K7dHbY)sZ) zEbl_G|L*mw;^^4X8qb9`&SE5E-PZBWT_#)`w^>uPkfEP<0YW)wubMiEh`NxbhNSxX zh}b7)U3JRWOf2$!dYDoAL5KsNgZc?&%V3l#P;^?6@o7%@ZC~PPaSLda`%f zxSolo+h?m{jH!PuEDX3|y6_q1jb9gouXKxEuD2D*`(sEg6W5Dp!!Baf(4}T4;|9AU zdJVq;d>C>A81J*@xcbz-Z-e%#XaYvkdzDFkC+5NQl-5BZa+vjZ4%QH{zp1fW-cA~3 zq_xeVJWl~*#n2+_w>W|9oPs<@rqFA7xO-GnacP0&8r&i1`E#DZpVPx=fcjDm&%YPC zWnAJ%!ipbQJw^WK&-70TNG6LPnCvyQval^59lqp%zvs!CzcT0KKqP5w~(TEa52|c|GqK}m$bj`jPTp0 z;refHb1lVs&E5T}zvwE<_4-34xu)~`MKcub1l;iPy+*r@&E_N6;y5CU=ATpY#axh+ zLyFhIbRGsjG?i8ld9K{$UR{TJG;#>w9J^W$PH7nl8E4-~fPok&UmEmLE> zP`*e04tP!e9pP}J6d*M)2H>`WDPt>Cxr14RSz;b}4aKcvJ3zNpx0T48Mj0C0Wno7h z7u|ySBYa!^(U~q2mv`_+=yTLQG_FeS)|PF5xYP6T>x`}L^rVbX4jL>0U9F!q6q1(i ziiNU3hY3qy#j3}NT~fq@Cs;9GEpe<5eM?sXBnGV&G;1ge1W@7=#A+eN)WU7;T$$&Y z3?`hH4w$W}2VKTYIQWXXm#|-%JZ&jhHLw3jjp-`XfUJS15!^T!%oJ)e2JqxenZ2GN z;nB%|m{)Ia4%C(D>VV6zY6Al;td|q@fRGU4Ar9#b=P-D7rP5eU`^%esjYG`_`VEK_ z0~JH2UUR4ymZk%ovik7dOIF1|TGV!Jnte`%IyOo7q#ei?zUUV}UMH>{8=2FN?b7Ce zF}0+V2pSD)cjLlmo-J%QA>(%3Uo3=fgWZ@=-u<^?i1CcH5(|#4f-VML{F(`je7Z^*5u{54S|i%3?Y=Qw&wl24{h`)W zv<6kt;kCE6*Yvx1@*aKt{Jrv*@YesR5w=)l5E3X3&3za{ijVs)je8<$=_R+eUjtm$ z3d#oYH^?4!FD2~siLqQURq}IVxq!FyQF$&7DSl?=r+lE`xsavEwh-@=Vo?^&^Ls3s z?^v$is)p;o)u;daI~B}%`>|=bnBjW%OhtC-hK2JF%=nbS)-P=vD`2fYTtpPbT8y$5 zu5eDO;bOPk!_FyQI7_3lTUw5SKz|4&ehesW zZRW83++_|@)-pcHj7#Gmh3AX#(iU>$0@(EcXFDi;Sd2lsy-Cf*kiod_iNGCl~t0dV+V11qlW>HwkDW6X1BlarMd z92-MU19Mg;Nc!iYO~a3+g$xvE4d5r7qp==iOcvmYZ55^|PG-|^p4+xF=$wrG*-&sC z-8K>28slX8mQ=J*wF~Q0%gAitYc3tzC1ab*IJtW`EKOx+_R;yr3RMq|-`)BsfGzoF zzP}!Lq!3~osLAeG1U}O^f!nG$4`hJINzuFyoYcfsp;KeLXmbbb=Czi=I1kGwY=dLl z3GYT(Qe@Il06UGf_=G<8$@>=IHTk~^r26YDP9gw@T_|+SV%&^*tgWaw5V;f558Di{ zG^qlBEhkT*^twjVu%$yb+Gn?H-5j>v348bBFr^u8M4Ztc_HE{)N=moupf7`^XInL1 z&Rz{|YR89u>hDQkxO;ZiOFa!a9J#9Rn#ad`@t0(z$0`PLf4=sS6bQKva&4tU%%+cx zr?9|q@f3Jf=SD8=)311L^7)GjyzW%sb(!i!B?YVnZ`r&Yr|Q*+lOL{r?)qX3u)g>~ zmPuIgx9r{ZTRk6N)2;1)Rl+BFaIuC9K3t!F-jA2D4mhWHQM@8fChe7g%RD6#xip@L zn)j~ksnc$8@#@EQMP|GnTec$gZV<>+d{qDb6tWcm6vB$maFK=--=k&=@$T>IHS@Cy z=jRt8lo+_fH|0OIh3K;sNksFa<9a?+zob5_n9lCDAM52pyg~SKxT*k)!IsogkV3l~3QnLLd}%6=L&G!mI5vw@g=(lBrv`$d?g z&=!aW5S|CdDi2^SJ7CC-WttkO$V2#ew2zQ60d$e=s)YYSQxKY4xJf~&)@a;>sG*ah zjo9ChVFk(2j@HB0j;}Mh#tXWxuC?%=R}iqwReiUe%aXJxyE812C@QbMQOmgpL(^D? zv-El?bUg*1`uvLZ{Q-1n_pxu#jIJUXhcT|)eBzMpio@%q4;<0l=x}=Ab!)L~Gpmiq zKgEBdZ{1+@s%&fjef9U^&sH%%xbQ=Or-uVz7kT+MfHt|um{p20C!MqSHE#UbCW5D{ zL!Hm~u}+pc4;b5Zes)&BmS=T7Ju8?R*=lL0Br!3u!H*Y*m+9hosrwJXs23MhUOOAk z@sFzEVwUT52`RoUQ83qVy{py`U)kzv4biIFJb$N-^V5DDoT>QXhg6t6k}5h+n}&=0 zbP~02;_XGOUkSKWaj)hv6wM$)SMZ|1B9!&vL4E#lWw!zq*MQd}`8U=q`mBZ&RW$!a zI}=jPTbnRkzo)K=Xx^;*|FpFN!Nbv?b68P5QD9}N zZJ7?Y|EMwj%Pdz8pu~QZIgEHXEChhD;79jB&6`WuuN^>x@!3n!Fc~-lWD}Olf!`3G z#CQbwK;NY-R@#X0D=2g!ywA}E!ul0sv@%5Us6QAXCRYU0ycUwJmtGAVF!8!R$Io~FS!C@i(b%UkWh~-`Q7Q^- z%iAiN_wjb*P}zv`<@j>gCqj%J>u0IQ?K|2ZV?8 z<7Ws1fa@R~R0UwfT(e0vEH)&>7APNKV}o)zoRAldIg$bKYFz%qX>PF$Zi>R$h~XHX@> zl&FlZCm<`V%QV6^`XXs#bXKE1EssAm_(RmN(saC2%IVU_c6D|~Z*H!2Ugjjldsmcx zxf#xJWm(vZ>!*8fmVb~kT!xV+B?p^N8`IavW~&QxTEi^Gaf z==LV!D+{{bmaX_J43})I&lOe$s;jl9hW3PJtAEUFJ+b%jINDk8LGhnxxGLBJ!}WPs zhuJOoaEUF1wZCyl@f~Hjz8(5y^V6U1sA&ETapmBqkn3UR*uelRs}I+6etxfj;VP$> z$+HwUoER6NDEjit2~{@|rdooMGD-!t6R5vK`lOzUG-6?m4r8jK46Uhr8VZ z2Ii0ytgUf>#TYL$8AT>$q<%cy$|kAv-1_IB=A`g5N5MJ3Lkgiw0U<2FpCufr;&(2W z(|&Zbft7655q_@HWag|>GY$=kYTtkA!$3Le#$>z(!lL^=0MFFw;9b3WzjS$4 zkFg8@OZthQ@v8t>0J_->Y01620x+X&0BivNG61t&h)kOfgt~6R32~4GM#(jetrSr+ zZN(Bj11N$nOO4@5G%}O=x&w3anc|x5aZ($zd3$+1#{7x^R#vvs))g|6uR3F@qJ~Vh zSW(Q5gkb$KhwTMqp-}Pod?SZluJWEL#|Kol7lZ)^HW`=+ZA|)~%{%m?V0HDFcAGL>V7C4xhATYoe6(>9Vwz)VpRwp83(;7? zC55a1wC(dld;GG76%SD`|9cYAJckp1$05aUl;Qeqjn76~h;j$a)DOaN#U`bzS;X}- zhZO4{*HZlD7n;u5ZT=TyxL))FGGryT5bigx%f+AV#dW$es4n#s_qb7RtA5FlofzM0 zV)@oiQv1r5Tj-I)h=c`Gi0xC`Jj*#zZtbo3yT7#9WL zrT<}0lm=$QCu18C*fj}jj?AZlRunBXz&fdh#U|gmj7(Xx;xIkj+CHmpE;h!g+vf({ z)9x=SSVa9i0w9f|`mxWbKPyecrjt6}gHDP1S$fE19D(BAFpGKNd2{fE5N@yU+JsE0 zVVVZEc={Pi5QXFm|NAFcuO6Jh8f0L)DE4e%3^pP$%;x@K)3_UInT_O3qwX`O=Aov0 z?u%+`UtIxg>xYRC+SyeMF$!TB89W0!>o81!xsqFnq)litmQS;5VY0A;_cN_-JCQaC zQTk4DUq`s&^UaZ!ev5T?sjqrc>fCg!guyZgISe<`Z{a0dLtHt{rB9sXGN$9A0A!Aw_Rs zw1NZsGj8MUH!P1VIFIY%;co88x8B6@U!KfX4y)>Ak>T=y?j&Av{V7uaR#xUaw zKrjJ(k+V}CVe)QM2?%8G$bcF$|ZMM|Owg)sVbZD!0n0F2TQg=<} zaX;Z4_*;R6YL`xSO%1a<(_SC^UJ8PGgbc@Z%=LaVgdrT+zqzAhq5+@bdlh`wq&2-y z$q{&M#?=G7NG6TW?rAq0khY*}0Nbk9YX+%}?aE_MG{PBgrZsH3#M}=f7QnWHm||At zPAyJEBy#{_vAbbN-BYkka!E&Kc&6?qRC}E%W<9c@FV00L_?K;a0OjGuB_}nR4sr}? zOx6+>a@P0Aa2$mr`54DEikRr>L-lq>}zjcwh$NhV0d>*dyR|k`CZMva; z8@D^)zo$<`NS2L< z3}g(Xq|$4${RgH%EY5dBLiKaM9i z(B&%=xlq2d#u!Rf$$L}N*%5Ubt`eqCFr_otC!x2t;lVsScfFN_zney@41Q-0H~BLQ zl^JBy9p5#)&$Vsm6hi&6aqZs7oKbW-?X%^%mLeU^?ue~Y&P2(OF!7Zx(X1h^vxE9_ zokjE0JDR1)4A&TZg4sNKaWTsUrt8O^XYs~5uCo_<&cCsmiX=V=+8A{GX^6T#LP(JT zOocdO0qZ>;>s4;^2YN+=2+Tu^@jX0wY)_v)_MJSjM_58|1H*;(N&Wj1{V_up5P#1~85_^KZIMM|vTVq1uZ46c8?e?lNUl)t%lKC}XSU~gF*P5+& zepUpPFE=dS(G58-_HH8DjkBH7ja>W3a^ytJ(WXDM}qLEu$%W1{Qw! z{UF7_-GJ3vcXz1kvS$|?rY=@SBK{x+=B)RP0odcQ9}Y4}&QkYFVGOfKbI>ELQHQ0X z{nXcNJ@lW0ts}=B>2w^E75nyjl6mz4v~xUP8_yi_a3DaJ)BdaE`^h(Sozy(l!+9IS z#HM}PipcdG%;C6KEk9I$L_^k?PYxuGwVS>@`_cHqz}AW0%Ssy6abw%!vTy62{G4ds zV(#kCp(Z0W@Fn}T357_K=SYmVWw+>VK+22x022tb$C zWz3>ilF!S+#00IBkG((M+nRok%Jx{G&rf6rSkh4cQK>0}3DXYpY;6Oy4Azwv)^qpx zVjx>jY|`<`3~c9L*)_fv3j|o7L_3s9q$UQV#c+h-y_<;ao{Ee zDHOTsn)SP!O*}8ZM+er)wI4|SwO#plJ*m&4{B+Do4(e2YIL}xsbFgqh&38eHIQcGT!2MI73l}nFFzKRe{%0NeU(~X8z}abS)fLq#$0{Tho2*kiEEA z$2He+&UargZ`}Qn4%Z}Z^9S%;-|9weetP!eo$ef+{|6POc`kme<2NI&Pd?Q+oP-oD zOK~`zsIt>7?{!?;_A3lmLuzh#NL94uLCEVNOVP{w)IZj7{pp?7T{In& zkm6rjmLiMhqlE^*7N5wd;bN9+dii=m@oI2f47ymu#j^QW)CU4Q7t9~)^V7?8S4_C- zM~h5}J1UjG5{n2G#mD-J=c}5x*{iUJ;E2jzT>Si|uXG?G#!APYz+{bjdEUS@Tc_!$ znk;F39*Arfvi#({<;Z3oLa6W1vr%BaRy9LKI8h!~%Z9Y~7VGMjEwV~cByVgNgDtRL z$YMnCYt-yo$1o#~je(cx($tM!ez0gwI+l?eRPCcj8KC?H>0|-z(%DV{TcLZouO$9v!)?Lqfe*O zg$cc!kYs8$%ZbvoU3r>&K{*iY7@{Ga&QfF~qL|&X86o1AWqv@$3?>z2=O~U}$5}D^ z8BDOk*vaCOu_rB>+_5{EHc}orETV0AnMM6E#2B*Ke3>6Z$kMZO;ct}?a?I2)M-!MO#3w%UHddS zORDdzgd*55Om%k2>qds_<<_R_d9nlj+GLi)>9Cs)9PBjo+r>l?N(66YCpIb5h%!a7 z25HM8Ev3&?4A%-6;$hdTL2z7Wa^_+|QZRMFPi>a*C|g$qE5|lzDAILUvhy^Lg1!2^ z=KI*8eun?f^G)VKlCAil)cQf*wt9-otjS!owz5Ab9h@A;@vO~OESRU88{s9io+8JM zuIc(p!Pd0ac@?1~QidxQuy)bUa}6nq+x#QxOvQIT zOP{gPnDKb}FB5yw8p7J|)q@MoJ-B{LU1^^;?^H(1oaW2rPx1kS513Cb7Fwj-U$-t{CetqZ}vCXz}E^J>Ri&2IYBv}V+XFvSP`~(mP@U;=tkD*cF z&RWPxW_F0~199#> zY~$7tDu)==lv4%nuK9sOeTjWrOIp7%oELB|I;nU_HEO!1GTIx+P5fr!)Q}`IT{(mp zr!+T^GJwjM$=LQaxg-7@j(PAY(prf!n`vnv&3wc-Ogn{hIWmXVar_9O?UKnmO+mAU z!N(KO9-!^&&UEn4>g*l%#Pr2DF={{D>XJ^W2UU}@e&e|pc7t|6jKYv9weTsxmKdhO zp?pT~b`Ej_BKrBX^FEDiU^hCpBj9V1?IUt8D4&3Ka`QjC4R}o>Ed?MP^iUXHhd`AF z%Lx__NGP$#_uMK!r3Kc1|tmEQ%wv_mpL_?a+{Jo=6vZqtv zu40)V-sV_N%Sp=o$Xmu5Nf=NJOxci>Ke3bPxOOyK5^BUJx(Gci`;FESYx`!cOetdm z;N`pzJ|EWqn)fte*4L;dn;M{zl0a4mF_r@YBi32LjBJ;s0!KqbfI*@|u5sRdd0xru_)y`G)4r*}dhqpnL> zn&4#>8eHlHk4AkLgH9C<+7JU6k*rQV-xBT%ASl~4Rv^;vFlzP_(D_sC0k3S}35BoA z)LkoxRAOusc`%S1WR{*0((}4!xEg3L8^<0q+VJO0v?ra0tqQfTMW$gQUcDgLnIr^JLp3`{%X{MQ*>saA`3{ z6YaF6_6QtS9zV9XZA26dP^kx6+&;lUz0>L_vJcnAJ0BFzR7CM=ma}zPiqcwOaeggt zFK9j}DPFBXMFzWEy!z+s2Zh6ltl{F%N6Y(c+g%pR3+giHb-ZKi-BD|ZV+CE8S9AwZ zyfR|fE52CDZgH(BsecZSh)SWRC1e%O(e9yMe|SBgM5}dn1SNYTK&ZEkFlYK+v)D}O z+z~i{poKA9V7XvTFDL6zjt2@^iw4jPFaUrtWLSpp=rTTwY>|`}3QQWaWpVrHo=)VV zdK?_gNA26A>na@(V_9cE*v*46uU&n}eY2^JGAOk{N( z17civo^{UogRA}CovMSMn8ldJOwrYWT2~qf>`Jz>L#(B8gUT@D4P{%_)PNxgQ?@EA z6-4e{_YsdlRF8NWJfk8AP|Q5Fu#1?=HWse~c9GqaagwkQ*;uQn_gM#)sV@s;$BPU| z3o}NbIXcacbvVr!Q$uQH@Yyo!fEbl>0eGNYN(fVpPW6+61Pma6t~fiF$Rf2qVt7k7 zPXT<6VaCp;L8tfVv*pAr-yI5z6^?a{hqNR5j%KG5c^2vvJ0QI_;836Tj7r73s~3ic zH-|sy_u4s#Y~WZ`Aok+xmfJ#^xH+-#ElENzVW0Q zmCSNxsOW7kMe%TOikc+HLH^B~RXZi-HxGg%rPW z&IZiZA0eE#RK@%MlM4Uwb78nFG>2#%%-+QctBd}T1Eqh*X&-?>a<%QR+dXj`;eT8%5c4>lW;|(ho9fw9>mCU{l@-; z=W1W`c3l;@k5~D8~ zN!Gu)vn;?(jrw%O@M7r8ie(?tz=WaBqFUygZblg|jEMU^L}m+2*T{Bl%KEe>eq9W@ zQvZVjSNYcW?R`+*nSc%0z$kT%VZ$(SUnSZTs3xGu*t(clls{xCdD6&Iy7?Z3xXmf~6MjdK061jau%VZ3I z5w7uL1As~`)EN5=gBZ4vCmv>`+(gy8KZO&4GD>60I*SZmJ_zf3mTKJ#tnEPdq3xVcXV*02GEH`TGNrEyS2oojU}GT1H|_M9 z9ZYOKl~PROHM^gbTT^%)L>fZKw}%-4R!3cz0-POCh)lupjaQh8deR2QfU-hk_%<~E z)(oK0mq#m*x$#vh0Vo^2nI9J{EM%X0jWTfB$QVO{51*S7G7eLU>{t7KmqfRogGOti zfVKVF9XOn*uTQsc>}IG8x~3WpD$BL+t7ExL(G%-e!)=~hvxVRS*18-%TyN1{d&fDO z%lnkrX9kKTDlG+dh|6|CRv z7X2jV5Zs>G-(%!Q>81Zd6 zs8<*9Z$XXq&yxBpKd`Zs&_TkB?XI{NrW>Hkdy}Sy8TXj12m{?vb=)>C@UlRqL^IRX zy}C9%^tKTaDuH#x(!d56pbmww)1&}P!gs4}n43Ne`{WRy{Dsm$%_x8i-d6uA;vxM zAs}+@-`fRVC`&U|0lJv1vN)#`@g`rZWwK zdK+Kffw3M24A<2(jKf{%?RdHXk%bg>A1Y+e(B^sP!iY-d=sEzc&De9l0s|@0VMWGq z3RmzPuMqNkDs4NNP~$ziwUfPd2;wd+V12Zk`=jf)%CrS;$t;po!&NWML#xihQ zxpE2|W~|-n_7-%S&i9g?p3!{$P?uXs@gLFo^jk19-*j8Od%unCi_wwl)86kw<$bzs9DMbH`73{U<;<2oo`-*9Ya)6nZE)T3tbEgxZszvEis>mk$5ke4ldyQy zZG`3G_&Ky!)|ZUi#Q8^aJy@oJ-Qu_Ty z`YwReY;NV^Vj!7*JIBWvlmWOj;AP_upsU-^^xReO%oKp7#xN%dr|dy83&A$*z4#8VF~ds!e#S`-=e7;^}VPGgJ&;me&Y#!-u; zeMJ+_0J_!y!d7a&T>y9h;natgBh%R!4CsBVS=iBaNwN-=ATF}=(ayVn+lDXb6HhC@}xmi3oU?oCc3t5t#%{e(j34b0(8b$)n5v={pz7uGBD^sk;7zwMNg$K$Te-Ow#?KV>5mf>9+Y)*qT!aZyKB<3V3&DV^vRo>f=k@_z?>Wsg;1Y+}cPan;jVy;f1gs(0 zZT|JUZ~Hdiv+MjD`U%`G&nGs9V8HbZv}9$r*o!NlZLJ~r6P6;@r_1`9YdL|+yi`h!R!o`ZT>g!@6MsYq7&{FR#rX?-XC;Mxqc&C$)#@i@mwu)GicED_x zy*~wZf%}~`IC&7?D{WG?2VY*(D;=`>Z>hjC_-M^~Tol`Q=fm47+m+(&R0-QP@#eU# zILlDqKk6uIXM9XbxkIF^cY|?={#@=pR{;$AW0jHvQS_aS`GOY0$AEC0t>?Ylvyi*Yw#u$@PZqai~?@`s=wcwhPs9)az>M z@0MlfdR`&}#oO|+VNc8=4A(CVph|vG zUo&;WMXbe(>zci|>J9fb>$rZPpDuGbyjGXJR08JsAQ$%GI)6jY^Bn_R|MeeK5K=^@ z;{9)^j11R@U-Yk?<=Sa`OxLdfE;1vp9$bwbJaHc`g?>-SyttnBwG_3q&nJl)E)mQ} zV#~scSKqYtx1b9QR|8z!HlWMuDZYL=B?ep%?@Wo&Z@HQzQ?Xo6!f+K~x@tS0QmFv! zp2dKfxx7P{Qv+Ov$^~@Qup(2OcSurQ1^w4`&g(2H$ed|ZUVhM;QRe_xUdS^wlz(WY z503SH0az7C9P97jN!C3j04_E~Jw!3#be6=si>!o#7|tocQqxmXQ&G!=6sQov5<6H) zGsi8yV{yPmZZe9(rdVD-iLN#xgD!TY=gIbva3`dIrB83O=ee(~c-WzvDeo*h^uP=2 zxh_4o$yYNH1K?T4y?N5u?K2kKz>i7`Y#+5s-cB6A~zLd~+(PQ)A?o9zI4 zv(#~H1L(5xOg_H2eoWyXfLB0j!b(-}P~x!<0LnM*z+i6sG!L!WLL*{qUDQlbrYag> zO}DQ&DK;I-Bz?7zOE-Z|^A>n@{cizAUde2TsAU5VZKW+3iNL~HCFWf^l+m{7OhV&z z|0_TPUB$8S`zvkEp6y7%6#8gjG$~jbffE0NEWrRc#=^%d#&8SZienprB+gT^F-i6z zgLFX(2K{bzs-Gy1C-xm%GT>Vz`6Bl>Cjkh-jI_D82N3VeY$(#<0IJAbTO zRq17R)-v?XAWO{93{=Vc6?7d$gQ(j~hx)OeL^{@t_rrYfj0qHqBG@R z$fD2p02hNTew}%nvwTwlSDmmbxH^+{>VC`hdQ|~eQNVRnpv6p=)KfITHS=QySuZZ? zri%ut_6T1tgc)OGk|vm?S}4m(^mIC{+=TGUvUC||u#h;o zQEC&HtL}Ske@^K`i&jDbY|V&7m?`R|q$E&oVMgf}W~o+_kz2El*lV*Hyv9Z9VFg`R z9>a%Y2y*ZPYhWv!E4PmzD-VPgLT-f6%u3{%x>Y8NHLuO~fI$Jma?*F$SIlArGho8{ z)xuo@t;UerD5~EaHMjb=!{nk1~xuVSRYn1biv4;bmLPNgt^# zrnp|$zVF|6M~L<6XC+zqpBm75_%fxR?rEvN@9Ka5F=)N7e{9ku$x>wfmT9=6Xg-D% zKmXXCE6kx*Ke}Nf-k~WG_itF}R~lF@bY=@v+=)DY^3+~F^^hV9=ARhD#q1WN#g9IC zR6ZdNC8{NagcJwS{D&xDZF!r>RP^@O_7n!FEam#{UM}f1J;{@Ka7lYZ%};;6P-}<> z_Ln$YkxTnjeo{p9Asx?Tilu1ov)j}XgW=@o&Rifn*7pI4xDP5S?}_;F$L z>sVfqWZr0Fj3`TD={Iy{o?P+}>nZYNFXFD_&c?)uAw>~BYA!06($YNw(oPCuTA-|k z3`HMinlA=94l2y0K3c)ct~7g5%~2Qg0y}iTi1*lM1Dco~ngI;p5NbdU00W?FwefZh z00s{AdB`@L?bw**GSCI!C6AaV>+^#UpU8$x$v8b)dr^`!?dYse4;=;{RZ(NuEm z7lM6&l9(xpI7(67Nafbv|UltgN#nK(x9$jwT71zboIbu(BI^^g`N*j z`kSab?npq-(K{`8V)b?eVK?^O0ifj&SL*6uV)sEax93a`I4Wlanmik)0F=3DyLND> zc4Ox>hAfu`{Oz#y8@>9ie@vxNQzQSb=s2b0}>J|dgR-!o90;@Bk6*0m|zbT%+H z>?s@zRtVR;7%)R1n1yAR5G8-UtF9_*+a_9)=-}TPkAy^h7T^3zMCL$Is!#|i_Ip&EFn(FW+Y{xPX3-<(CnjSW~cH+Wgur6jf)whq6284 zxE)=QZ>$Xis!pYE>`M;f@o0=%T81J6FIi(HM?Q=FphH$4O-;Q;(6!iI(Oy}grrW2K z`^y=kdS#E473-RT9WPcb>$g7EU}`BNH9UpkX@+6YAZp_;wLBs{^u} zE`U1UH}(nZjn(S=&F1uO>v8=y10`S^IzR;>a+%5yFeC`-yCxw_>VaJZVHAGkIzjyi zx*({}-MaF6HPE;A;Ljbu%3L~JE1$ci+RyemD2wMznmFELECJYLFiWDHBVU$=YX;#Z z>dsFo(LO@QMjZ%^wVB7zW{O~o2JpLQmKye-DO??P?n-A@W9M%TJLqnzbnB6A%yI14 zZQ7a6K=)-ku3J63wy(WB5I~IquU4wZz!*o;jCzCr7S@>qOVw2jP<=!LpfR}t=LX@$ zso#u4;j`>V^;`X9-LI@(XOK}aAWx|kO84(plN-!^K>pAdnf2---M3AWwr%WgD`YQ@ zT0M~)TRw;+xK1!?_5)R#Ltz#mt~_g5y}QALtNi!?u|Q70B1u4s z|0+Rp?~2kdH+DUQ5Z3o~?*HDNzdd&Uwf|%7OZqQZQCaIs`&b+GTY`a7Af5s_bz6HS z;8MlC_6%^Pkgio{?63T%oy$BdA)W*s*C& zZ!tgBrq&QYTKVu)8Lk@UwJgP-E5lWf?!FRRuibx*;KlXw>~852BIJd#Tj^xL>r#y% zSi^NuFB$>njHra+(spAEmS0ci$7PqBq%%gA3ry<`-x;jz$<^HD`CVY4WUr&sJhwh{ zcV8y}#0qRzrwVp+ zN8H_Zpo>A#8je-G60`ofZG_O?QRhZwWXgQ#Q~%XKCS!-yj&m1wk#XRTaE+NiOB??j zj-hL7dsqPwSM8IRTSnyCv0c=&RL-ug+wQ^GNr-W7W~CG)^|2mfJa(D9;ZU#Ji9B3& zY))gip=O+j+nsoEGgz<3XsE&_OBtK3&wymhlo>Fw$tRB>2b`TR z`R5v;q)zjm%@~A38>7^zg`cv41^^pOqQiV8=e%XcYKCO1JxE@@g@^+47JiTihx)yy zKv+XtvRZB-Pzi&gGkhlOeir+(FkZB!kk!qA)J61NIE*^h&c9>Dj>qgHiXC4W{qj7k zAY3=$G1@pZ@ZGFBvR_MDhY^2%p5)Exm0n$a(~~xur0TH-Z>@}T-QJGCZndysE1-_B z`LsLB>QJW!as$AYC%B#@SwTaq-DNP|jSSkfj_m5z3yBIzZz5Y1W9W9??xy`ZgeC(LBIc4Hrg+3y$*)u-IjuCqo5eH-|4$aA_$=I)f30e%CK&Qopt&{1%Yz>LaYr*TK zfiG71quwI)T#@Mlqh((-0ao#wYU?jyofJWdm&6I<<<)NU>>~T^hW8$|NB7-|wh<%f z8VY>JhI)(%;4MtPa!6sqX`Wq{xh8$j0XPEmtnPmAe=;K@%h8xL0IE%hk=k}0i`BxD zIz9ze-1WhLmoj3jOoZ!BvFeTuS``Q(%qUwH16>Bb-G`OE91+S9+ zzsc5$V+vX!J&zt*qtK*uO453a)6|Z~*)Eyw=Q3hbIH^yKIMK8#WQ`SwoOdQ`c3Ow{ zd)2XKSTvNm371P`c0_=Y{IlDP=T};a=OJAC6eh!|L+PQTr3a9FF+JG-VVDj){+?S6V!||kwZZyAMi&6JE zfUE}epd;(Uh=F~J+A%}7DQdmiP_$Xa0mp@DWcaLwYh=yYGj%;fk;qxZ2Gk}8g_9%` zm0N#1ZEZ=0k;r$Kk-lz{DTb&bNdncfwB?qNbo3eM(8vKPS7)!7KRBFq3^AMp5cgVxIXQD zxHy~`-R4o+$6M64|7*slrqxROF-ZWgC#0fzepJ_a5~hnoiV)66FRn-SN4NQ%Zdfkm zx0YvL-%8kV0c-wK`ViRmO)eTP3>W)wv7}DCxE`uxzMNU?_iW3Oy^vhR7nCa7r8+JZ z@u}PV9ersm*VU;MTpN6;NXY`u&RERct%ekD77a6UyIG8(s^AL@SEnPa7iedXYMxy3 z;v}G;GE$WRoBL1Y)c-?LxqXm#9ZZ#fu%gs#6TxPv{FPYpEuwAE$4BbrVgLq=d zI;u5%StSJ7))5zZcAf{t9gVuWV$g@(nv5Ri<4uh1f{)A!A$}a@9@_=b#o|VVNVtPMNg1C6{F@+FgnjM6ofb#|%$Yy~BEC|2# zvmZ(WF|hM=d5j{jO@NO1=rDI6$k~!;RJyj~@S)hg$C``|D(piOdw^U@?#|_!D1`E) z4>RFS9s(xQbhTp~>oN8K$3mZ}Inl>Z>RydTX?ykjNxW??>SW}kFhD~%D<{(_c{EX)`gBBJ0NF#xXt3@K}60nyynk98^Ad+it} zYTGG5jao-@&5uE#z+#HtlAjN?9DP_(Kj`1NnYt`V-#^Ao>WMe0u|Nqx)FQblg5}Y3 zZNHf#f=8q2T$AYPs0_4MO@@WdI?&5GmVcK} z_?lW6k3)EKaE^EF)1u6fFB61=*IqGXI_ z*k9lcve4aO*fur>U8~=ud5a~I4lxW8fR{|GsR0~$40p250Um$7rv7{pH37G!{Y!{37Qq%IpIY5R zr#ebrF$rr3&2>LAo(PQF^~RsO+eg&%JfR*V^^?DMwJ zo?LIXej38HF3IW<-gZ}xI@NYXJmAW&Gh9=&?jhdt(0^pG^%!>78bS;Q@qRoFH}`IB z)YL6>tn*)9Tc1}pXQExml*<1MX5)*Fy-srNvwMDPIYnYy2!8yB*v-F9Z&rp&YAL>> z?_d9@{k$vs@b~Eh23ucjiJncC;?wrt80m}Q#D6KI$Qwx~We03UU6)uwa5%A9LkR9N z!_(M4F^cXJ`(s-OhFhOi(B;Ob_2|!kQz^bbs@pvM<;&JV4JqcI=QKT7HwX0HFM+8F zZu4RdaiO;VlBrnZip+35w3>>{a4}8!TIlQEy?c~y%xT^cShxr(__{QPOTvn5+y;7d zy-o-sdN8r8t4QiL&%$}=xNexqlJBE3Ui@7RVMTS6Z!A}W3%!ZskLJ+@KqM@#ky5(8 z**-A15rCuL4%Srhmj!hh7h_2dSeGmWxufY?pds67i!o^GOmDUf7Jd|cmjSN@K+6FV z!kGv&&RUq75mA@v=y%-i8o%bobb00^(MGHfO;|VJTf&TByL7{0-_|69ajeNGDb|O^ za`F2n^a0B?n^%^M-7FdAxTC2LF>+YfbzN~CY!g{I120;OQO{2r8O)-(t2GplcJQ?- z5W-l)J$ARAT0t0V6omB@wJdggt7kp}_*p-Zt>I;g7WGEbF6RFg3GNX{!V9Zr>`HPYTLg_Nn8g z&cBvEW`QDkc6qRbbr&f+V4Vinzw@(TD?It ze3(ueU1>5dVY^NTxh|wno)Oa!QoOoCKvCH(=CrPkcS=6IY}&I>OEEHBF5!Bk#kkOZ zs0^2DxZdS|z7}hUuZ7I{l79L7m+6BK8pS1|`A_?h;;7#$fxhgyExi(Hr~RsEK8-LA zFUw|e4JUGmpU3**Y141T;?<8NOR+V6E){-EJm2fM-lzBf_&#k0{cJ4^y1Iqm)|2zv z_qOc&?tvRrT$-u)Oe`T#_>$Q!{@{?O=o&70@`5fotXMzvKCD<@Nm$X=#aQ5)9|TWL$YP`}WCqZ6Lf|&?tF_aT67d#x=7e&W@ zur7n2xuYE>*WYZcDb%f9UmF228G9!asAu`cq+kiXxoH@ZmJi<3FoMc+tifeKuLc_0#2>{+=>i_Fedz z|9Mx!ip+4aXr47(I|P@sj}5?hr0%h5xFo7)Bhw<*C|-SgEyY*H9D&jj*sRb^^o2lO zMekIAS8K7fPZZ97Lbqiqa{GX;zyC5VzX_#%M9K9PEe{SU0a?YUZp*zi&`oY~7Kd$=R9Os$Tl2SjXm+20>&7W~Ucgz6w zjux^G<$fevu|B`#EQ|9CI}0B8|Qgh&zP;_P~rzAf1DC#J=YPH-4FVjICJsH;ls9HcDlEI4hYdrwhbEG=-I_? zwJ#~0?#EEVkb2Hjv)>)X-={pT*VEnN@Xi2ibelf}cl_8-u!zCAh-HDI{7knSN&`)6 zcgzR_a{BZ#5+!zwT-aOKJaYqwQ@wz}Vce^YBQ7gBxWS8^fb~mljI7h@O9SQ@V2E4++v#m3?cf5yK9w?Lr;^rUq zSh5M%v9UyGoANyPd_vR<=R*1|mT8yvwL+rKibT#o;f&vd4DHo38gD5p6#Lrajgm;9 z>K8m)Nv9#4r5jIW*Qq{jwO_F|BV6j+hq4_%l~?G;e2`YsCo-${Aq&H$$XypREYwmE z;)gY-J`2-t*bq`I&HBT(Ey}Qx19wecSczb2JGD|jiHrNxt7ekp6)X5`6&M8sXNCMl z2vNzJE#@L`5SHOqS?aP@+K0i_fgR_wI>}4foj&XZ^JWX-rG3o6{Fn4bJ=}kgmiA!{*SC_Xc=kfi^EW@&<9m~Y;bK2723;2@>%*Cf zpK3(zqi_j`hU;m2%{nezI0lC_`d4T-Q}LAy7a*2wY@iB%jZ4#~TuYJv_P`cDMy3li z-J|-4a3VK=En&Hs-K%He@cmN0`rZmyGuV1o0oY&bz%8WsH_|M{Sj3v^DJs}%nTo9; zk3k8@V%jZ$EjB87VDyExsJANw1_Z&R3*f)J)S$ubtosf$PfOS|I9n9*&&=*0BIt6 z3-|GtNB&Nuo{CkE7A}Ng>S~XOkI5Ihg(I1rIfCtqz(vIIZ5&{{+6-i1s}t0dWXp8n zPGjK^sg!-@Ud+DHb;)+UXq=+=*wVs)9A9@tcmt^iVL( zBy6`5Dm|_~Mxy5MN|r6CVa7Rz>Y6|%+^MemLl3;eDY9Zqv5xoVjd`K9g{2S{Gne|f zjU2PZtMW;nU9K9)mjW8G{FKxR&u2Zk;!Z{SCGhTk?-xkH^yKNM+69q$4 zHG7?>b{;ojGHAR4<3&46Suf}xKj2KxuEm6fTiV{SB!pG*K3HTAk>3Kg$OQ4h)4 zT9t#1ZA8b1`2hMb+>iU1xC2R{Zzm^L#T+_YZ$b0E-r4l`e zt6p53xsWb3Q!#O-Vr94BW1>yt{j3IhT6nQSjSXEjfG&VF+Xu5yo@utWA07B!$-ikWQBW_y%Z2hw zKfrl46Q=;Wq{9Xhe5?RpGHKhm41DEIf3{NR=#HgBC<|f6IUYH*D6o)_``vZ-M<;rH zxjrk1)z!~w`04Nvi*1YJ95Nu|gt^%(LUA(DYO1}=45{g?B%}gEOCtB|(oo&pCSbG< zq{@V;0ByC5#7*c48I#+e(7c8}*`I|#ILt^P+*bN?10i{w_%Qjl5E^Z>Z9~Z(98`4A zUk43SzilywrS!&|JHShNGcUGQz6N4_EjI*hxbJHKGJDn;I7acM+O^}L;==lVG`h7E z)y~_b2yd&q)Vsb5<#ukY+cOZe3B;;atwJ&rJSDK|LvS^6r?+|1>7dJ0czPS`zh}ehNLZCg{?&I}e{-N)G%RXFj+jsR} zH&rJJ60WYiPuHIAEZYHCw;7=RiiS&TDT?DfIvV!FuR*IxT$7C9dTccnKl(_5pbWbZ zR{X^C6iJF$vwjPrd1blu$XPglcylS8(>%A|lV>Xa4ILT7#nuq=sORlAB zDe_a7DT0sqN(AxOcrH4fqFIK65h$9K`jN0A129hwxpB~SJce6}Jy8|~&}CVV+?Fw$ zkp=bj3)}z^DX7VZ{!3^+T~gOk02Ei6K6pJxZ)*cy*bZShgImoUB8ch*bdiBB2_0)r|WcCh7IGA0Fw8KWpJ`TM@J-0FQFCLE_qiQ|iUj>HDeyrsnW{Me#VBlbu zihgw9=1?#GVzV<{otR!?UeI<~|3s~%P^_0|LiY*BB7Ir1fp53f`6x4>kS(JW5wZx) zIwGfm1w@F^1}0s}HD-15tTd1Us0Ot3JWRcvgI{;qyBF}dN$Drr16@+*FzEc^`=USdgIvOJQA&U`pB1QWavBYjzP*-4p6V9GzOY-AndaezdHu`q~0IwVP z(hKVmcfAF4F`JYF`0eo9ati!-XcFpX_xR%SMCKJZrc# zN745G^S9~d=Wlclw&K-y%mLxQ|NYDKzkY0ETAtXJQmMb#QO%q zl4UT;vIwxocG}*7a0v$vH!bEu4{K8$3u~r~>2lFOd#@my4ouRa0jywe5WlpmFJ6kAI7?H=U<9~9p>6lwCE7!C@3>-8!UK(iVJFf%76YL{mTSxF28J-|!Pg6J1 zH@~jXpNQLg&?Wmf7NHrQ0^meesbw{GGbt=57;pPT-m`eVuW%leMU5Gj3>I788s3{U z3vkaN-IylNtjs#zW_Mx02VvFdnvbzbP0kP|jDXyhjUHudWMTvx5xJE`qTLC|^;-5y z1wT9M3h0^)bPXUh))TbjSJtk9AA}jD81?{~tP=yjp)c2>Dch9?OO8#8?B~dP$gow8 zKSASO`udD}T_-E+V+YZ_i_hM}+i7;WKo{1})ad|4w@2*zw$p{yN*i= zSO*OkpWbwL1Qu_Y7Z*v1pNAGw^di>QUJIz}E{o>P096>Skg0fA9xm~+Yu6T9JZG~u z23Br-s$d>r#q)DxxGb=pmQrq(IPMVe4q-)pJmyp4(mzKQ)-%*ut)Ke-F<3|}6<{5Y zLz(OPYpS5kzB1=r$l7)gLV6;WKPlLWZtl!*!7}1-Y+RS_V`Gyy^t&`#+FmP*ReDX= zO0;a)sTQu(48!11@8WmrL5%&7tY94w?aZZ&GA?fOfyVmW7Se#GVQ|Bb<8xm!Jx@N5 zND*u*6l*i$Sf^&#N^qxlCW-p&Ktvyt`gV03*G_|`Uw8F2ZWh{0f1F$J}j2vcU z_9vUROT&z%0WS}AuHm?2*je|95To%>cE=5di+XL2gSwTX-GrWQc5nw^)C0>_$UEa$ zQW>(%5VGm5Bj!Ep2nuYMFoM*DV{@(v6=?JlXkNf?XbEm zLfh6w@nN3^4pReF{#~c;;K|37W1nQe4MSe{PxY-6Jzrz;yGoL!h#OFqVCe6q`w@oC z#0Fy1bPhpC@r>uHjb`gii`$8krqK#1k`Wip-;xM@<+! zyFAROps3_FCb7CGRxc4EW}n#Wvw5X{;zP)GZg;DpK|l|8`ZcgoDBRr`tDuof&AY1^ zpF+6Z*eetV({x~AV>~2oPYofqb&tt zB40zKY;Cm|0mua8l^%4NMrX8+n3I+uaxs0zKEw40D+jz5P&zp!4Cj10c<4;Q#X)q433HFm8lv<;M6P{3dTmInYh~Nj^1KKZj}0~ zB7M9ptrp|jBB&jsW@Faf6dPC9&j4hJI#0ARY1lT#>8F{xaBQXqMD?`>7FF9dZH`Cq zPimm6>|`lsXtgYk8$*aa*jhVa1|8ZO7)kFY4pLSgvPb1Ii|1yhUPGBf;MBTEk>wm? ze)_rR_!fw7*+{Q3{=FR;tT}*b{Y9}34hOM;@c!92`_$kT-n&_Qc4(Qkmat4?XFUDg zki>rK|Hb4sg0;+Oo*m{T`~MUaQBhnim#yUf{76EOC|kF8ZO_m<2+ZQlB{Z8<#WGdFCH5IjSi68wy zelmfbs_dkQbp>8h#F}7#`ipQ|x7Jm>Y#EE`@T$B7ZM`mDIn--2L3Qf&U}E>3G6>R) z#S(yqR1j`;o5h{xZzuvY$rj;%kwqvBQ!_sG#@{Fg5#61O8QQV2s)rK+qKp9(J=cLC zhKJ~#^YNMda=Bq^pygF6~wgz#Aa%PZdQ)D?0 zj^i;R6+>=ra!SrXW*=t&UbqR_2xe@f0J_4Py`F0jq|2GejrG&>nj3a5frX1eDEf9~ zx|O9uEk>=q*z{tmT{5s&!e;@<)wS%u15+5g7Qse1clg8klF+p3yK>l;Ov6Xik`5I2 zDfo|v15v#n=hPy2C1eK5y5$wW_Zd$5kr`{ORF23C05ohHKw0|^bvIucT#kWhi&t8k zsg;;$AKDZ}eDya{+~{TVW|uND{%hI!Ju@R}Nv>j@t%+_qiju5`Z?gFXghT4G@2!UL z%NsEVM*qhbN;8##P#!yeva|K)pMRV*wD^&T z<~5|q?GcedF4l2_1 zkc-&ee>WfD+3;*xBL#nyFd^%`PSNgn4WP?zOZH4HWjzCEX|@m|hZm&}W4Zunob@`m zc#@0E$mtns=7_aKIrRD{rj!bKSs?#2eb-{ycv_C=;QMUh#ev=qz>CLtXsLwcf@Tw^ z@~+1s*s^MLSUyl!kFf_X{e;awE|TJ3$i}|*H5tb{P@9mvEYN6}I^1>U_!`z4!X#m` zoINz)$I<~Wo2^O@l%NNT8H*`uLWKEh!hX?V&D27S10$DqvLt=)n|h2vpVYBl%H%bG zYbZq~s<{Uh{09(;ah0w4WgZ>lWjn5ih~o+qU3Wk`0F_Kn1bTZ0B5$TNp8(F7e^oX>gl=(gJ>)Xdlt- z#;3muxG>NGDs!?uRv&tPm0VLi=YT%wnxH!oSU7|W&1h5i|M zie-OR(^DbwQJ2~KF|-)7AK_QEQyOTUpvMV7Mc?UOOGvOg0L#Rb^GD=k4osbthAAk9 z9J-UTMW%zYvOvgSl(#d+L(~_+Zew<@DEvr6S7qTwW)jvtC;1Qt)JdXey2g?~q^GS5 zF$$Yerdzk;=31em*_ApuuVy)5`nP!||2&EU;H}OH;6SxqVjW?BzQ0&ND#m{G?eV6fe^wKZC%tq1@MJ2IJ=iEgV7e) zLu8~@d(3^PZrfsbFfR6e?XbZ_t?na`)NSAktu!C9Zm~Fk4BLo9Ju`Wf9CSGgnW>4+ z6l6NUEsb3S>l(v_t)vrCXDfm45Ojgz+S2oA1)d_Jll}L=D-J(u;Mz?_Sc$KV=@#7> znB&OAPj-ee+>jxtm)anLUo07C3E@M3Q~!z?IJ1GN{v0;lR$_N~`_9GLqawbvlb1!x zaMhvLaH5A4>z`yNa+YG9PuXR@W4NqwG>So&%-!`L)o_`|rcCp1N|xeVZ>gsH?`G7g zqWO2dpLg$nl>}mZupQr~cy$F^?l%9F{A3pYN3oEG6#s=R#fa33{H2Dq3YKYDQM|aG zP?6B$Qwt|jGdtzRU`w+UxoM{2^LOqi)^I7XjC`KCko}QvZP9Ih{pGs=0pF%%w*Ih& zU^gygD>m~}>ICy##9Ehu<@zP;&5%~0yoA*{>Yp=Qp>?4~q`+F^*mbHbmw0uJK3sTh z6D(`FkdY|Mr+vcmh<`wRiERWaC}SOzWi4(l{<=W)TgM8#ghvPY9zDkKk8HvN!? z@mB3iw^a?Agmn{@zDkpl85?Qzq9GOG?Q_*u??bS0Ey_^6^dtL zf{NLUE<&FIII;;~)(@heNC(z|Hs+T*=YJINa~Nw1rl4!?`pQu#?E}G}m64JxM%$)| z@z(|zV1?ns>@!7GbO)VMnBkK#Ue2&k134jdmXiwUQ|blvgGQV@{Mvzl2AIZB<|u6M zZZjHv)$eHI+h)K&=&&HBFL+Nnw~(Z-FOKH>nPTjAA9Y>1XSo=FmDF1&Y+6jz058ku za#8@x43}nz=5FjJoa(!Z4)a9K871xf&I?N)u2JhRn^~|{#8@Rkq65!*PDEoWAH6fj zV8z-lW-&D#exF>g+Wa5WroJxgfAzOK1rII`E#k(Xr?yn*LR3iM*B3;+Xg;2s<<70Y zf%or|ducMmwVqo=k-KmBHa}gxrOz$=1<|~=uk7(lg&QO2`m}$^U`w3l_r_^|xZ?h$ zT8bndTrq&C;lx5cpY}vqE&qBo0sYf1OR+i4KPsPC`RdPVIFUn&47E6{*w<2|KFG@L z5A}rpyp+Sw?-tHdYfxmMfFjntyj{mFv1&7|2N zC)0K%4=n~QmtEk?95hwIg&vx)6xgq(zfzkA0GuPzP{bS+VK4(wrU8R1{Svbiacm-} zhp3)to0(%G`|bz3@o5YkmhhTF7LBk)Wl3`+mG%r%cj_@h+l7}XhP@2iu1gC;c5Iig za~tR)r}bB8bqWe^w@_tAcKs7$PXdf)-L9pDhacrxv{BCZ&9o8J6Ld;b&t|O##^i3< z`i{dB&}F)=8De1o#{gK86~dO~>)MK08~eobWj1lUgD-rBU~;H2X=@N_>nV@mC;&-! z@5ext;gGjU1@z9mSQyC5u*5Lq#ui~4Mn%!(IBCZDtSf*$WZY$H5mW7dk}SsDWovFf z_n6U`k;OQK)r378!ABIXXPxw?p-hyPnArj+y@&_!6`kh8zTNfS03Je&U?x-4N8Mr_ z5x-v^5+wcnq#Tf2-dOgb?8KO^p%69^x!w)c2-+;zfR^qneu%^K0z=)rAx_tk!75g+ zNDx_Mvh<`Bt4|pd23m&mz#?L?8@TE}Qxw*hU^pCS_^jVy0cHh7)osl9Z75*JaZ_8` zcUl(o5)tIOx2(mZ*b=pnVyI{xTAK%91)&k2tA4dQh|BbHSVo%H))+1V!=+8*T0ao8 zTi!w`n!j4%YKxai6VuZgQyjAtqbMBY*b@l

k!4>R@%qQ0}X;r0aNwI@Cb8FaNpAR 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
+ + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + width: '100%', + zIndex: 1000, + position: 'absolute', + top: 128 / 2 + 138, + }, + imageContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + glow: { + width: 201, + height: 201, + position: 'absolute', + }, + iconContainer: { + justifyContent: 'center', + alignItems: 'center', + width: 128, + height: 128, + }, + image: { + position: 'absolute', + width: 76, + height: 71, + }, + background: { + width: 128, + height: 128, + position: 'absolute', + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/components/app-tabs.tsx b/src/sdks/react-native/examples/expo/src/components/app-tabs.tsx new file mode 100644 index 00000000..80719bc6 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/app-tabs.tsx @@ -0,0 +1,32 @@ +import { NativeTabs } from 'expo-router/unstable-native-tabs'; +import { useColorScheme } from 'react-native'; + +import { Colors } from '@/constants/theme'; + +export default function AppTabs() { + const scheme = useColorScheme(); + const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; + + return ( + + + Home + + + + + Explore + + + + ); +} diff --git a/src/sdks/react-native/examples/expo/src/components/app-tabs.web.tsx b/src/sdks/react-native/examples/expo/src/components/app-tabs.web.tsx new file mode 100644 index 00000000..ca2787df --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/app-tabs.web.tsx @@ -0,0 +1,115 @@ +import { + Tabs, + TabList, + TabTrigger, + TabSlot, + TabTriggerSlotProps, + TabListProps, +} from 'expo-router/ui'; +import { SymbolView } from 'expo-symbols'; +import { Pressable, useColorScheme, View, StyleSheet } from 'react-native'; + +import { ExternalLink } from './external-link'; +import { ThemedText } from './themed-text'; +import { ThemedView } from './themed-view'; + +import { Colors, MaxContentWidth, Spacing } from '@/constants/theme'; + +export default function AppTabs() { + return ( + + + + + + Home + + + Explore + + + + + ); +} + +export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) { + return ( + pressed && styles.pressed}> + + + {children} + + + + ); +} + +export function CustomTabList(props: TabListProps) { + const scheme = useColorScheme(); + const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; + + return ( + + + + Expo Starter + + + {props.children} + + + + Docs + + + + + + ); +} + +const styles = StyleSheet.create({ + tabListContainer: { + position: 'absolute', + width: '100%', + padding: Spacing.three, + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'row', + }, + innerContainer: { + paddingVertical: Spacing.two, + paddingHorizontal: Spacing.five, + borderRadius: Spacing.five, + flexDirection: 'row', + alignItems: 'center', + flexGrow: 1, + gap: Spacing.two, + maxWidth: MaxContentWidth, + }, + brandText: { + marginRight: 'auto', + }, + pressed: { + opacity: 0.7, + }, + tabButtonView: { + paddingVertical: Spacing.one, + paddingHorizontal: Spacing.three, + borderRadius: Spacing.three, + }, + externalPressable: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: Spacing.one, + marginLeft: Spacing.three, + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/components/external-link.tsx b/src/sdks/react-native/examples/expo/src/components/external-link.tsx new file mode 100644 index 00000000..883e515a --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/external-link.tsx @@ -0,0 +1,25 @@ +import { Href, Link } from 'expo-router'; +import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; +import { type ComponentProps } from 'react'; + +type Props = Omit, 'href'> & { href: Href & string }; + +export function ExternalLink({ href, ...rest }: Props) { + return ( + { + if (process.env.EXPO_OS !== 'web') { + // Prevent the default behavior of linking to the default browser on native. + event.preventDefault(); + // Open the link in an in-app browser. + await openBrowserAsync(href, { + presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, + }); + } + }} + /> + ); +} diff --git a/src/sdks/react-native/examples/expo/src/components/hint-row.tsx b/src/sdks/react-native/examples/expo/src/components/hint-row.tsx new file mode 100644 index 00000000..acf4dc5d --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/hint-row.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; +import { View, StyleSheet } from 'react-native'; + +import { ThemedText } from './themed-text'; +import { ThemedView } from './themed-view'; + +import { Spacing } from '@/constants/theme'; + +type HintRowProps = { + title?: string; + hint?: ReactNode; +}; + +export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) { + return ( + + {title} + + {hint} + + + ); +} + +const styles = StyleSheet.create({ + stepRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + codeSnippet: { + borderRadius: Spacing.two, + paddingVertical: Spacing.half, + paddingHorizontal: Spacing.two, + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/components/themed-text.tsx b/src/sdks/react-native/examples/expo/src/components/themed-text.tsx new file mode 100644 index 00000000..799c8b13 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/themed-text.tsx @@ -0,0 +1,73 @@ +import { Platform, StyleSheet, Text, type TextProps } from 'react-native'; + +import { Fonts, ThemeColor } from '@/constants/theme'; +import { useTheme } from '@/hooks/use-theme'; + +export type ThemedTextProps = TextProps & { + type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code'; + themeColor?: ThemeColor; +}; + +export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) { + const theme = useTheme(); + + return ( + + ); +} + +const styles = StyleSheet.create({ + small: { + fontSize: 14, + lineHeight: 20, + fontWeight: 500, + }, + smallBold: { + fontSize: 14, + lineHeight: 20, + fontWeight: 700, + }, + default: { + fontSize: 16, + lineHeight: 24, + fontWeight: 500, + }, + title: { + fontSize: 48, + fontWeight: 600, + lineHeight: 52, + }, + subtitle: { + fontSize: 32, + lineHeight: 44, + fontWeight: 600, + }, + link: { + lineHeight: 30, + fontSize: 14, + }, + linkPrimary: { + lineHeight: 30, + fontSize: 14, + color: '#3c87f7', + }, + code: { + fontFamily: Fonts.mono, + fontWeight: Platform.select({ android: 700 }) ?? 500, + fontSize: 12, + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/components/themed-view.tsx b/src/sdks/react-native/examples/expo/src/components/themed-view.tsx new file mode 100644 index 00000000..c710df9b --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/themed-view.tsx @@ -0,0 +1,16 @@ +import { View, type ViewProps } from 'react-native'; + +import { ThemeColor } from '@/constants/theme'; +import { useTheme } from '@/hooks/use-theme'; + +export type ThemedViewProps = ViewProps & { + lightColor?: string; + darkColor?: string; + type?: ThemeColor; +}; + +export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) { + const theme = useTheme(); + + return ; +} diff --git a/src/sdks/react-native/examples/expo/src/components/ui/collapsible.tsx b/src/sdks/react-native/examples/expo/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..d0d745b4 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/ui/collapsible.tsx @@ -0,0 +1,65 @@ +import { SymbolView } from 'expo-symbols'; +import { PropsWithChildren, useState } from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { Spacing } from '@/constants/theme'; +import { useTheme } from '@/hooks/use-theme'; + +export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { + const [isOpen, setIsOpen] = useState(false); + const theme = useTheme(); + + return ( + + [styles.heading, pressed && styles.pressedHeading]} + onPress={() => setIsOpen((value) => !value)}> + + + + + {title} + + {isOpen && ( + + + {children} + + + )} + + ); +} + +const styles = StyleSheet.create({ + heading: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.two, + }, + pressedHeading: { + opacity: 0.7, + }, + button: { + width: Spacing.four, + height: Spacing.four, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + content: { + marginTop: Spacing.three, + borderRadius: Spacing.three, + marginLeft: Spacing.four, + padding: Spacing.four, + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/components/web-badge.tsx b/src/sdks/react-native/examples/expo/src/components/web-badge.tsx new file mode 100644 index 00000000..6667898d --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/web-badge.tsx @@ -0,0 +1,43 @@ +import { version } from 'expo/package.json'; +import { Image } from 'expo-image'; +import { useColorScheme, StyleSheet } from 'react-native'; + +import { ThemedText } from './themed-text'; +import { ThemedView } from './themed-view'; + +import { Spacing } from '@/constants/theme'; + +export function WebBadge() { + const scheme = useColorScheme(); + + return ( + + + v{version} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: Spacing.five, + alignItems: 'center', + gap: Spacing.two, + }, + versionText: { + textAlign: 'center', + }, + badgeImage: { + width: 123, + aspectRatio: 123 / 24, + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/constants/theme.ts b/src/sdks/react-native/examples/expo/src/constants/theme.ts new file mode 100644 index 00000000..c10ed272 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/constants/theme.ts @@ -0,0 +1,65 @@ +/** + * Below are the colors that are used in the app. The colors are defined in the light and dark mode. + * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. + */ + +import '@/global.css'; + +import { Platform } from 'react-native'; + +export const Colors = { + light: { + text: '#000000', + background: '#ffffff', + backgroundElement: '#F0F0F3', + backgroundSelected: '#E0E1E6', + textSecondary: '#60646C', + }, + dark: { + text: '#ffffff', + background: '#000000', + backgroundElement: '#212225', + backgroundSelected: '#2E3135', + textSecondary: '#B0B4BA', + }, +} as const; + +export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark; + +export const Fonts = Platform.select({ + ios: { + /** iOS `UIFontDescriptorSystemDesignDefault` */ + sans: 'system-ui', + /** iOS `UIFontDescriptorSystemDesignSerif` */ + serif: 'ui-serif', + /** iOS `UIFontDescriptorSystemDesignRounded` */ + rounded: 'ui-rounded', + /** iOS `UIFontDescriptorSystemDesignMonospaced` */ + mono: 'ui-monospace', + }, + default: { + sans: 'normal', + serif: 'serif', + rounded: 'normal', + mono: 'monospace', + }, + web: { + sans: 'var(--font-display)', + serif: 'var(--font-serif)', + rounded: 'var(--font-rounded)', + mono: 'var(--font-mono)', + }, +}); + +export const Spacing = { + half: 2, + one: 4, + two: 8, + three: 16, + four: 24, + five: 32, + six: 64, +} as const; + +export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0; +export const MaxContentWidth = 800; diff --git a/src/sdks/react-native/examples/expo/src/declarations.d.ts b/src/sdks/react-native/examples/expo/src/declarations.d.ts new file mode 100644 index 00000000..e54a1cdf --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/declarations.d.ts @@ -0,0 +1,2 @@ +declare module '*.module.css'; +declare module '@/global.css'; diff --git a/src/sdks/react-native/examples/expo/src/expo-router-template/_layout.tsx b/src/sdks/react-native/examples/expo/src/expo-router-template/_layout.tsx new file mode 100644 index 00000000..f5dc763f --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/expo-router-template/_layout.tsx @@ -0,0 +1,17 @@ +import 'expo-dev-client'; + +import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router'; +import { useColorScheme } from 'react-native'; + +import { AnimatedSplashOverlay } from '@/components/animated-icon'; +import AppTabs from '@/components/app-tabs'; + +export default function TabLayout() { + const colorScheme = useColorScheme(); + return ( + + + + + ); +} diff --git a/src/sdks/react-native/examples/expo/src/expo-router-template/explore.tsx b/src/sdks/react-native/examples/expo/src/expo-router-template/explore.tsx new file mode 100644 index 00000000..29340852 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/expo-router-template/explore.tsx @@ -0,0 +1,180 @@ +import { Image } from 'expo-image'; +import { SymbolView } from 'expo-symbols'; +import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ExternalLink } from '@/components/external-link'; +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { Collapsible } from '@/components/ui/collapsible'; +import { WebBadge } from '@/components/web-badge'; +import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; +import { useTheme } from '@/hooks/use-theme'; + +export default function TabTwoScreen() { + const safeAreaInsets = useSafeAreaInsets(); + const insets = { + ...safeAreaInsets, + bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three, + }; + const theme = useTheme(); + + const contentPlatformStyle = Platform.select({ + android: { + paddingTop: insets.top, + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: insets.bottom, + }, + web: { + paddingTop: Spacing.six, + paddingBottom: Spacing.four, + }, + }); + + return ( + + + + Explore + + This starter app includes example{'\n'}code to help you get started. + + + + pressed && styles.pressed}> + + Expo documentation + + + + + + + + + + This app has two screens: src/app/index.tsx and{' '} + src/app/explore.tsx + + + The layout file in src/app/_layout.tsx sets up + the tab navigator. + + + Learn more + + + + + + + You can open this project on Android, iOS, and the web. To open the web version, + press w in the terminal running this + project. + + + + + + + + For static images, you can use the @2x and{' '} + @3x suffixes to provide files for different + screen densities. + + + + Learn more + + + + + + This template has light and dark mode support. The{' '} + useColorScheme() hook lets you inspect what the + user's current color scheme is, and so you can adjust UI colors accordingly. + + + Learn more + + + + + + This template includes an example of an animated component. The{' '} + src/components/ui/collapsible.tsx component uses + the powerful react-native-reanimated library to + animate opening this hint. + + + + {Platform.OS === 'web' && } + + + ); +} + +const styles = StyleSheet.create({ + scrollView: { + flex: 1, + }, + contentContainer: { + flexDirection: 'row', + justifyContent: 'center', + }, + container: { + maxWidth: MaxContentWidth, + flexGrow: 1, + }, + titleContainer: { + gap: Spacing.three, + alignItems: 'center', + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.six, + }, + centerText: { + textAlign: 'center', + }, + pressed: { + opacity: 0.7, + }, + linkButton: { + flexDirection: 'row', + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.two, + borderRadius: Spacing.five, + justifyContent: 'center', + gap: Spacing.one, + alignItems: 'center', + }, + sectionsWrapper: { + gap: Spacing.five, + paddingHorizontal: Spacing.four, + paddingTop: Spacing.three, + }, + collapsibleContent: { + alignItems: 'center', + }, + imageTutorial: { + width: '100%', + aspectRatio: 296 / 171, + borderRadius: Spacing.three, + marginTop: Spacing.two, + }, + imageReact: { + width: 100, + height: 100, + alignSelf: 'center', + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/global.css b/src/sdks/react-native/examples/expo/src/global.css new file mode 100644 index 00000000..c8fe5031 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/global.css @@ -0,0 +1,9 @@ +:root { + --font-display: + Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, + Segoe UI Symbol, Noto Color Emoji; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; + --font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif; + --font-serif: Georgia, 'Times New Roman', serif; +} diff --git a/src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.ts b/src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.ts new file mode 100644 index 00000000..17e3c63e --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.ts @@ -0,0 +1 @@ +export { useColorScheme } from 'react-native'; diff --git a/src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.web.ts b/src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.web.ts new file mode 100644 index 00000000..642c34b5 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/hooks/use-color-scheme.web.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; +import { useColorScheme as useRNColorScheme } from 'react-native'; + +/** + * To support static rendering, this value needs to be re-calculated on the client side for web + */ +export function useColorScheme() { + const [hasHydrated, setHasHydrated] = useState(false); + + useEffect(() => { + const timeout = setTimeout(() => setHasHydrated(true), 0); + return () => clearTimeout(timeout); + }, []); + + const colorScheme = useRNColorScheme(); + + if (hasHydrated) { + return colorScheme; + } + + return 'light'; +} diff --git a/src/sdks/react-native/examples/expo/src/hooks/use-theme.ts b/src/sdks/react-native/examples/expo/src/hooks/use-theme.ts new file mode 100644 index 00000000..677e0151 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/hooks/use-theme.ts @@ -0,0 +1,14 @@ +/** + * Learn more about light and dark modes: + * https://docs.expo.dev/guides/color-schemes/ + */ + +import { Colors } from '@/constants/theme'; +import { useColorScheme } from '@/hooks/use-color-scheme'; + +export function useTheme() { + const scheme = useColorScheme(); + const theme = scheme === 'unspecified' ? 'light' : scheme; + + return Colors[theme]; +} diff --git a/src/sdks/react-native/examples/expo/src/postgres-workload.ts b/src/sdks/react-native/examples/expo/src/postgres-workload.ts new file mode 100644 index 00000000..0610e11e --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/postgres-workload.ts @@ -0,0 +1,995 @@ +import { + PostgresError, + simpleQuery, + type OliphauntDatabase, + type QueryResult, +} from '@oliphaunt/react-native'; + +export type ProjectRollup = { + name: string; + total: string; + done: string; + blocked: string; + estimate: string; +}; + +export type ActivityItem = { + title: string; + owner: string; + status: string; +}; + +export type OperationCheck = { + name: string; + detail: string; + elapsedMs: number; +}; + +export type PerfReport = { + openMs: number; + schemaMs: number; + seedMs: number; + updateMs: number; + selectP50Ms: number; + selectP90Ms: number; + selectP99Ms: number; + rows: string; + doneRows: string; + blockedRows: string; + checksum: string; + events: string; + checks: string; + backupBytes: string; + streamBytes: string; + rawBytes: string; + cancelSqlstate: string; + constraintSqlstate: string; +}; + +export type PostgresGamutReport = { + perf: PerfReport; + projects: ProjectRollup[]; + activity: ActivityItem[]; + checks: OperationCheck[]; +}; + +export type WorkloadCheckStage = { + name: string; + status: 'start' | 'done'; + detail?: string; + elapsedMs?: number; +}; + +export type PostgresGamutOptions = { + extensions?: readonly string[]; + onCheckStage?: (stage: WorkloadCheckStage) => void; +}; + +type MutablePerf = { + schemaMs: number; + seedMs: number; + updateMs: number; + selectP50Ms: number; + selectP90Ms: number; + selectP99Ms: number; + rows: string; + doneRows: string; + blockedRows: string; + checksum: string; + events: string; + backupBytes: string; + streamBytes: string; + rawBytes: string; + cancelSqlstate: string; + constraintSqlstate: string; +}; + +export async function runPostgresGamutWorkload( + db: OliphauntDatabase, + openMs: number, + options: PostgresGamutOptions = {}, +): Promise { + const checks: OperationCheck[] = []; + const extensions = new Set(options.extensions ?? []); + const perf: MutablePerf = { + schemaMs: 0, + seedMs: 0, + updateMs: 0, + selectP50Ms: 0, + selectP90Ms: 0, + selectP99Ms: 0, + rows: '0', + doneRows: '0', + blockedRows: '0', + checksum: '0', + events: '0', + backupBytes: '0', + streamBytes: '0', + rawBytes: '0', + cancelSqlstate: '', + constraintSqlstate: '', + }; + const recordCheck = ( + name: string, + run: () => Promise, + ) => record(checks, name, run, options.onCheckStage); + + await recordCheck('DDL, enum, indexes, audit rule, view', async () => { + const started = now(); + await resetSchema(db); + const ruleCount = await scalar( + db, + "SELECT count(*)::text AS value FROM pg_rules WHERE schemaname = 'public' AND tablename = 'tasks' AND rulename = 'tasks_audit_rule'", + ); + assertEqual(ruleCount, '1', 'tasks audit rule registration'); + perf.schemaMs = now() - started; + return 'projects, tasks, dependencies, events, metrics, JSONB/array GIN indexes'; + }); + + await recordCheck('DDL event trigger', async () => { + await executeStatements(db, [ + `CREATE TABLE ddl_event_audit ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + command_tag text NOT NULL, + event_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE FUNCTION oliphaunt_mobile_ddl_audit() RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + INSERT INTO ddl_event_audit(command_tag, event_name) + VALUES (TG_TAG, TG_EVENT); + END + $$`, + `CREATE EVENT TRIGGER oliphaunt_mobile_ddl_audit + ON ddl_command_end + EXECUTE FUNCTION oliphaunt_mobile_ddl_audit()`, + 'CREATE TABLE ddl_event_probe (id integer PRIMARY KEY)', + 'ALTER TABLE ddl_event_probe ADD COLUMN label text', + 'DROP TABLE ddl_event_probe', + ]); + + const result = await db.query(` + SELECT + count(*) FILTER (WHERE command_tag = 'CREATE TABLE')::text AS creates, + count(*) FILTER (WHERE command_tag = 'ALTER TABLE')::text AS alters, + count(*) FILTER (WHERE command_tag = 'DROP TABLE')::text AS drops, + string_agg(command_tag, ', ' ORDER BY id)::text AS tags + FROM ddl_event_audit + `); + const creates = requiredText(result, 0, 'creates'); + const alters = requiredText(result, 0, 'alters'); + const drops = requiredText(result, 0, 'drops'); + assertPositiveInteger(creates, 'DDL event trigger CREATE TABLE count'); + assertPositiveInteger(alters, 'DDL event trigger ALTER TABLE count'); + assertPositiveInteger(drops, 'DDL event trigger DROP TABLE count'); + + await executeStatements(db, [ + 'DROP EVENT TRIGGER oliphaunt_mobile_ddl_audit', + 'DROP FUNCTION oliphaunt_mobile_ddl_audit()', + ]); + + return requiredText(result, 0, 'tags'); + }); + + if (extensions.has('vector')) { + await recordCheck('pgvector extension nearest-neighbor search', async () => { + const result = await runPgvectorWorkload(db); + return `${result.nearestTitle} nearest to ${result.query}, distance ${result.distance}, index ${result.indexName}`; + }); + } + + if (!extensions.has('vector')) { + await recordCheck('extension selection', async () => { + const active = [...extensions].sort().join(', ') || 'none'; + return `active extensions: ${active}; pgvector workload runs only when vector is packaged`; + }); + } + + await recordCheck('transaction seed with savepoint recovery', async () => { + const started = now(); + const inserted = await seedDatabase(db); + perf.seedMs = now() - started; + return `${inserted.projects} projects, ${inserted.tasks} tasks, ${inserted.dependencies} dependencies`; + }); + + await recordCheck('explicit rollback keeps data unchanged', async () => { + const before = await scalar(db, 'SELECT count(*)::text AS value FROM projects'); + await expectMessage(async () => { + await db.transaction(async tx => { + await tx.query( + `INSERT INTO projects (id, name, owner, health, metadata, tags, budget) + VALUES (9999, 'Rollback Probe', 'Nia', 'green', '{}'::jsonb, ARRAY['probe'], 1)`, + ); + throw new Error('intentional rollback probe'); + }); + }, 'intentional rollback probe'); + const after = await scalar(db, 'SELECT count(*)::text AS value FROM projects'); + assertEqual(after, before, 'rollback project count'); + return `project count stayed ${after}`; + }); + + await recordCheck('constraints and PostgreSQL error recovery', async () => { + const sqlstate = await expectPostgresError(async () => { + await db.query( + `INSERT INTO tasks + (id, project_id, title, owner, status, priority, estimate, metadata, labels) + VALUES + (9999, 99999, 'missing project', 'Nia', 'open', 1, 1, '{}'::jsonb, ARRAY['probe'])`, + ); + }, '23503'); + const recovered = await scalar(db, "SELECT 'recovered'::text AS value"); + assertEqual(recovered, 'recovered', 'constraint recovery query'); + perf.constraintSqlstate = sqlstate; + return `foreign-key violation ${sqlstate}, then recovered`; + }); + + await recordCheck('bulk update, audit rule, upsert metrics', async () => { + const started = now(); + const update = await updateTasksAndMetrics(db); + perf.updateMs = now() - started; + const events = await scalar(db, 'SELECT count(*)::text AS value FROM task_events'); + const metricRows = await scalar(db, 'SELECT count(*)::text AS value FROM project_metrics'); + assertPositiveInteger(events, 'audit event count'); + return `${update.archivedRows} archived updates, ${update.finalRows} final updates, ${events} audit events, ${metricRows} metric rows`; + }); + + await recordCheck('JSONB and array predicates', async () => { + const result = await db.query(` + SELECT + count(*)::text AS high_mobile, + count(DISTINCT metadata->>'region')::text AS regions + FROM tasks + WHERE metadata @> '{"risk":"high"}'::jsonb + AND labels @> ARRAY['mobile']::text[] + `); + const highMobile = requiredText(result, 0, 'high_mobile'); + const regions = requiredText(result, 0, 'regions'); + assertPositiveInteger(highMobile, 'high mobile JSONB/array count'); + return `${highMobile} high-risk mobile tasks across ${regions} regions`; + }); + + await recordCheck('CTE, recursive CTE, and window functions', async () => { + const recursive = await scalar( + db, + `WITH RECURSIVE chain(n) AS ( + VALUES (1) + UNION ALL + SELECT n + 1 FROM chain WHERE n < 12 + ) + SELECT sum(n)::text AS value FROM chain`, + ); + assertEqual(recursive, '78', 'recursive CTE sum'); + + const ranked = await db.query(` + WITH ranked AS ( + SELECT + id, + dense_rank() OVER ( + PARTITION BY project_id + ORDER BY priority DESC, estimate DESC, id ASC + ) AS rank + FROM tasks + ) + SELECT + count(*) FILTER (WHERE rank = 1)::text AS top_tasks, + max(rank)::text AS max_rank + FROM ranked + `); + const topTasks = requiredText(ranked, 0, 'top_tasks'); + const maxRank = requiredText(ranked, 0, 'max_rank'); + assertPositiveInteger(topTasks, 'window top task count'); + assertPositiveInteger(maxRank, 'window max rank'); + return `recursive sum ${recursive}, ${topTasks} rank-1 tasks, max rank ${maxRank}`; + }); + + await recordCheck('temporary table and DELETE RETURNING', async () => { + await db.execute(` + CREATE TEMP TABLE temp_cleanup_queue ( + id integer PRIMARY KEY, + label text NOT NULL + ) ON COMMIT PRESERVE ROWS; + INSERT INTO temp_cleanup_queue VALUES + (1, 'one'), + (2, 'two'), + (3, 'three'), + (4, 'four'); + `); + const deleted = await db.query(` + WITH deleted AS ( + DELETE FROM temp_cleanup_queue + WHERE id <= $1 + RETURNING id + ) + SELECT count(*)::text AS deleted, coalesce(max(id), 0)::text AS max_id + FROM deleted + `, [3]); + assertEqual(requiredText(deleted, 0, 'deleted'), '3', 'delete returning count'); + assertEqual(requiredText(deleted, 0, 'max_id'), '3', 'delete returning max'); + return 'deleted 3 temp rows through RETURNING'; + }); + + await recordCheck('extended query parameters and nulls', async () => { + const result = await db.query( + `SELECT + ($1::int + $2::int)::text AS sum, + $3::boolean::text AS flag, + ($4::text IS NULL)::text AS is_null`, + [19, 23, true, null], + ); + assertEqual(requiredText(result, 0, 'sum'), '42', 'extended query sum'); + assertEqual(requiredText(result, 0, 'flag'), 'true', 'extended query bool'); + assertEqual(requiredText(result, 0, 'is_null'), 'true', 'extended query null'); + return 'int, boolean, and null parameters round-tripped'; + }); + + await recordCheck('raw protocol and streaming response', async () => { + const raw = await db.execProtocolRaw(simpleQuery('SELECT 1 AS raw_value; SELECT 2 AS raw_value')); + let streamBytes = 0; + let chunks = 0; + await db.execProtocolStream( + simpleQuery("SELECT repeat('x', 65536) AS payload"), + chunk => { + chunks += 1; + streamBytes += chunk.byteLength; + }, + ); + assertPositiveInteger(String(raw.byteLength), 'raw protocol byte length'); + assertPositiveInteger(String(streamBytes), 'streaming byte length'); + perf.rawBytes = String(raw.byteLength); + perf.streamBytes = String(streamBytes); + return `${raw.byteLength} raw bytes, ${streamBytes} streamed bytes in ${chunks} chunk(s)`; + }); + + await recordCheck('query cancellation and recovery', async () => { + const running = db.query("SELECT pg_sleep(5), 'late'::text AS value"); + await sleep(120); + await db.cancel(); + const sqlstate = await expectPostgresPromiseError(running, '57014'); + const recovered = await scalar(db, "SELECT 'after-cancel'::text AS value"); + assertEqual(recovered, 'after-cancel', 'cancel recovery query'); + perf.cancelSqlstate = sqlstate; + return `cancelled with ${sqlstate}, then recovered`; + }); + + await recordCheck('checkpoint and physical backup', async () => { + await db.checkpoint(); + const backup = await db.backup('physicalArchive'); + assertPositiveInteger(String(backup.bytes.byteLength), 'physical backup bytes'); + perf.backupBytes = String(backup.bytes.byteLength); + return `${backup.bytes.byteLength} backup bytes`; + }); + + const selectStats = await measureRollupSelects(db); + perf.selectP50Ms = selectStats.p50; + perf.selectP90Ms = selectStats.p90; + perf.selectP99Ms = selectStats.p99; + perf.rows = selectStats.rows; + perf.doneRows = selectStats.doneRows; + perf.blockedRows = selectStats.blockedRows; + perf.checksum = selectStats.checksum; + perf.events = await scalar(db, 'SELECT count(*)::text AS value FROM task_events'); + + const projects = await loadProjectRollup(db); + const activity = await loadActivity(db); + + return { + perf: { + openMs, + ...perf, + checks: String(checks.length), + }, + projects, + activity, + checks, + }; +} + +export async function runPostgresLifecycleResumeCheck( + db: OliphauntDatabase, +): Promise { + const started = now(); + const select = await scalar(db, 'SELECT 1::text AS value'); + assertEqual(select, '1', 'resume SELECT 1'); + + const eventsBefore = await scalar(db, 'SELECT count(*)::text AS value FROM task_events'); + const updated = await db.query(` + UPDATE tasks + SET status = CASE + WHEN status = 'open'::task_status THEN 'blocked'::task_status + ELSE 'open'::task_status + END, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + RETURNING status::text AS status + `); + assertEqual(String(updated.rowCount), '1', 'resume audit-rule update row count'); + const eventsAfter = await scalar(db, 'SELECT count(*)::text AS value FROM task_events'); + assertGreaterThanBigInt(eventsAfter, eventsBefore, 'resume audit-rule event count'); + + await executeStatements(db, [ + 'DROP EVENT TRIGGER IF EXISTS oliphaunt_mobile_resume_ddl_audit', + 'DROP FUNCTION IF EXISTS oliphaunt_mobile_resume_ddl_audit()', + 'DROP TABLE IF EXISTS ddl_resume_audit CASCADE', + `CREATE TABLE ddl_resume_audit ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + command_tag text NOT NULL, + event_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE FUNCTION oliphaunt_mobile_resume_ddl_audit() RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + INSERT INTO ddl_resume_audit(command_tag, event_name) + VALUES (TG_TAG, TG_EVENT); + END + $$`, + `CREATE EVENT TRIGGER oliphaunt_mobile_resume_ddl_audit + ON ddl_command_end + EXECUTE FUNCTION oliphaunt_mobile_resume_ddl_audit()`, + 'CREATE TABLE ddl_resume_probe (id integer PRIMARY KEY)', + 'ALTER TABLE ddl_resume_probe ADD COLUMN label text', + 'DROP TABLE ddl_resume_probe', + ]); + + const ddl = await db.query(` + SELECT + count(*) FILTER (WHERE command_tag = 'CREATE TABLE')::text AS creates, + count(*) FILTER (WHERE command_tag = 'ALTER TABLE')::text AS alters, + count(*) FILTER (WHERE command_tag = 'DROP TABLE')::text AS drops, + string_agg(command_tag, ', ' ORDER BY id)::text AS tags + FROM ddl_resume_audit + `); + const creates = requiredText(ddl, 0, 'creates'); + const alters = requiredText(ddl, 0, 'alters'); + const drops = requiredText(ddl, 0, 'drops'); + assertPositiveInteger(creates, 'resume DDL trigger CREATE TABLE count'); + assertPositiveInteger(alters, 'resume DDL trigger ALTER TABLE count'); + assertPositiveInteger(drops, 'resume DDL trigger DROP TABLE count'); + + await executeStatements(db, [ + 'DROP EVENT TRIGGER oliphaunt_mobile_resume_ddl_audit', + 'DROP FUNCTION oliphaunt_mobile_resume_ddl_audit()', + 'DROP TABLE ddl_resume_audit', + ]); + + return { + name: 'background/foreground resume SQL', + detail: `SELECT ${select}, audit events ${eventsBefore}->${eventsAfter}, DDL tags ${requiredText(ddl, 0, 'tags')}`, + elapsedMs: now() - started, + }; +} + +async function resetSchema(db: OliphauntDatabase): Promise { + await executeStatements(db, [ + 'DROP EVENT TRIGGER IF EXISTS oliphaunt_mobile_ddl_audit', + 'DROP FUNCTION IF EXISTS oliphaunt_mobile_ddl_audit()', + 'DROP TABLE IF EXISTS ddl_event_audit CASCADE', + 'DROP TABLE IF EXISTS task_events, task_dependencies, project_metrics, tasks, projects CASCADE', + 'DROP TYPE IF EXISTS task_status', + "CREATE TYPE task_status AS ENUM ('open', 'blocked', 'done', 'archived')", + `CREATE TABLE projects ( + id integer PRIMARY KEY, + name text NOT NULL UNIQUE, + owner text NOT NULL, + health text NOT NULL CHECK (health IN ('green', 'yellow', 'red')), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + tags text[] NOT NULL DEFAULT ARRAY[]::text[], + budget numeric(12,2) NOT NULL CHECK (budget >= 0), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE tasks ( + id integer PRIMARY KEY, + project_id integer NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title text NOT NULL, + owner text NOT NULL, + status task_status NOT NULL DEFAULT 'open', + priority integer NOT NULL CHECK (priority BETWEEN 1 AND 4), + estimate integer NOT NULL CHECK (estimate > 0), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + labels text[] NOT NULL DEFAULT ARRAY[]::text[], + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (project_id, title) + )`, + `CREATE TABLE task_dependencies ( + task_id integer NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + depends_on_task_id integer NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, depends_on_task_id), + CHECK (task_id <> depends_on_task_id) + )`, + `CREATE TABLE task_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id integer NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + event_type text NOT NULL, + payload jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE project_metrics ( + project_id integer NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + metric text NOT NULL, + value numeric NOT NULL, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, metric) + )`, + 'CREATE INDEX tasks_project_status_idx ON tasks (project_id, status)', + 'CREATE INDEX tasks_updated_idx ON tasks (updated_at DESC, id DESC)', + 'CREATE INDEX tasks_metadata_gin_idx ON tasks USING gin (metadata)', + 'CREATE INDEX tasks_labels_gin_idx ON tasks USING gin (labels)', + 'CREATE INDEX tasks_lower_title_idx ON tasks ((lower(title)))', + 'CREATE INDEX task_events_payload_gin_idx ON task_events USING gin (payload)', + `CREATE VIEW task_dashboard AS + SELECT + p.id, + p.name, + count(t.id) AS total, + count(t.id) FILTER (WHERE t.status = 'done') AS done, + count(t.id) FILTER (WHERE t.status = 'blocked') AS blocked, + coalesce(sum(t.estimate), 0) AS estimate + FROM projects p + LEFT JOIN tasks t ON t.project_id = p.id + GROUP BY p.id, p.name`, + `CREATE RULE tasks_audit_rule AS + ON UPDATE TO tasks + WHERE OLD.status IS DISTINCT FROM NEW.status + DO ALSO + INSERT INTO task_events (task_id, event_type, payload) + VALUES ( + NEW.id, + 'status_change', + jsonb_build_object( + 'from', OLD.status::text, + 'to', NEW.status::text, + 'owner', NEW.owner, + 'priority', NEW.priority + ) + )`, + ]); +} + +async function runPgvectorWorkload( + db: OliphauntDatabase, +): Promise<{query: string; nearestTitle: string; distance: string; indexName: string}> { + await executeStatements(db, [ + 'CREATE EXTENSION IF NOT EXISTS vector', + 'DROP TABLE IF EXISTS mobile_embedding_docs', + `CREATE TABLE mobile_embedding_docs ( + id integer PRIMARY KEY, + title text NOT NULL, + embedding vector(3) NOT NULL + )`, + `INSERT INTO mobile_embedding_docs (id, title, embedding) VALUES + (1, 'field dispatch routing', '[0.95,0.05,0.10]'), + (2, 'warehouse replenishment', '[0.05,0.92,0.18]'), + (3, 'customer support triage', '[0.22,0.15,0.94]'), + (4, 'mobile incident response', '[0.88,0.10,0.22]')`, + `CREATE INDEX mobile_embedding_docs_hnsw_idx + ON mobile_embedding_docs USING hnsw (embedding vector_l2_ops)`, + ]); + + const query = '[1,0,0]'; + const nearest = await db.query( + `SELECT + title, + round((embedding <-> $1::vector)::numeric, 4)::text AS distance + FROM mobile_embedding_docs + ORDER BY embedding <-> $1::vector + LIMIT 1`, + [query], + ); + const nearestTitle = requiredText(nearest, 0, 'title'); + const distance = requiredText(nearest, 0, 'distance'); + assertEqual(nearestTitle, 'field dispatch routing', 'pgvector nearest row'); + + const index = await db.query(` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'mobile_embedding_docs' + AND indexname = 'mobile_embedding_docs_hnsw_idx' + `); + const indexName = requiredText(index, 0, 'indexname'); + return {query, nearestTitle, distance, indexName}; +} + +async function seedDatabase( + db: OliphauntDatabase, +): Promise<{projects: number; tasks: number; dependencies: number}> { + let dependencies = 0; + await db.transaction(async tx => { + for (const project of projectSeeds) { + await tx.query( + `INSERT INTO projects (id, name, owner, health, metadata, tags, budget) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::text[], $7::numeric)`, + [ + project.id, + project.name, + project.owner, + project.health, + JSON.stringify(project.metadata), + postgresTextArray(project.tags), + project.budget, + ], + ); + } + + await tx.execute('SAVEPOINT duplicate_project_name'); + const duplicateSqlstate = await expectPostgresError(async () => { + await tx.query( + `INSERT INTO projects (id, name, owner, health, metadata, tags, budget) + VALUES (991, $1, 'Probe', 'green', '{}'::jsonb, ARRAY['probe'], 1)`, + [projectSeeds[0]?.name ?? 'Dispatch'], + ); + }, '23505'); + assertEqual(duplicateSqlstate, '23505', 'duplicate project savepoint'); + await tx.execute('ROLLBACK TO SAVEPOINT duplicate_project_name'); + await tx.execute('RELEASE SAVEPOINT duplicate_project_name'); + + for (let index = 1; index <= TASK_COUNT; index += 1) { + const project = projectSeeds[index % projectSeeds.length] ?? projectSeeds[0]; + const status = index % 13 === 0 ? 'blocked' : index % 7 === 0 ? 'done' : 'open'; + const owner = owners[index % owners.length] ?? 'Asha'; + const risk = index % 9 === 0 ? 'high' : index % 4 === 0 ? 'medium' : 'low'; + const labels = [ + index % 2 === 0 ? 'mobile' : 'desktop', + index % 3 === 0 ? 'sync' : 'ui', + index % 5 === 0 ? 'customer' : 'internal', + ]; + const metadata = { + risk, + region: regions[index % regions.length], + sprint: `2026.${(index % 6) + 1}`, + external: index % 17 === 0, + }; + await tx.query( + `INSERT INTO tasks + (id, project_id, title, owner, status, priority, estimate, metadata, labels) + VALUES ($1, $2, $3, $4, $5::task_status, $6, $7, $8::jsonb, $9::text[]) + RETURNING id::text AS id`, + [ + index, + project.id, + `${project.name} field operation ${index}`, + owner, + status, + (index % 4) + 1, + (index % 13) + 1, + JSON.stringify(metadata), + postgresTextArray(labels), + ], + ); + + if (index > 8 && index % 8 === 0) { + await tx.query( + `INSERT INTO task_dependencies (task_id, depends_on_task_id) + VALUES ($1, $2)`, + [index, index - 3], + ); + dependencies += 1; + } + } + }); + return {projects: projectSeeds.length, tasks: TASK_COUNT, dependencies}; +} + +async function updateTasksAndMetrics( + db: OliphauntDatabase, +): Promise<{archivedRows: number; finalRows: number}> { + let archivedRows = 0; + let finalRows = 0; + await db.transaction(async tx => { + for (let id = 1; id <= 96; id += 1) { + const archived = await tx.query( + `UPDATE tasks + SET status = 'archived'::task_status, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING status::text AS status`, + [id], + ); + archivedRows += archived.rowCount; + const final = await tx.query( + `UPDATE tasks + SET status = CASE WHEN id % 2 = 0 THEN 'done'::task_status ELSE 'blocked'::task_status END, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING status::text AS status`, + [id], + ); + finalRows += final.rowCount; + } + }); + assertEqual(String(archivedRows), '96', 'archived update row count'); + assertEqual(String(finalRows), '96', 'final update row count'); + + await executeStatements(db, [ + `INSERT INTO project_metrics (project_id, metric, value) + SELECT project_id, 'done_tasks', count(*) FILTER (WHERE status = 'done') + FROM tasks + GROUP BY project_id + ON CONFLICT (project_id, metric) + DO UPDATE SET + value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP`, + `INSERT INTO project_metrics (project_id, metric, value) + SELECT project_id, 'blocked_tasks', count(*) FILTER (WHERE status = 'blocked') + FROM tasks + GROUP BY project_id + ON CONFLICT (project_id, metric) + DO UPDATE SET + value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP`, + ]); + return {archivedRows, finalRows}; +} + +async function executeStatements( + db: Pick, + statements: readonly string[], +): Promise { + for (const statement of statements) { + await db.execute(statement); + } +} + +async function measureRollupSelects(db: OliphauntDatabase): Promise<{ + p50: number; + p90: number; + p99: number; + rows: string; + doneRows: string; + blockedRows: string; + checksum: string; +}> { + const selectTimes: number[] = []; + let rows = '0'; + let doneRows = '0'; + let blockedRows = '0'; + let checksum = '0'; + for (let index = 0; index < 120; index += 1) { + const started = now(); + const result = await db.query( + `SELECT + count(*)::text AS rows, + count(*) FILTER (WHERE status = 'done')::text AS done_rows, + count(*) FILTER (WHERE status = 'blocked')::text AS blocked_rows, + coalesce(sum(estimate), 0)::text AS checksum + FROM tasks + WHERE priority >= $1 + AND labels && ARRAY[$2]::text[]`, + [(index % 4) + 1, index % 2 === 0 ? 'mobile' : 'desktop'], + ); + selectTimes.push(now() - started); + if (index === 119) { + rows = requiredText(result, 0, 'rows'); + doneRows = requiredText(result, 0, 'done_rows'); + blockedRows = requiredText(result, 0, 'blocked_rows'); + checksum = requiredText(result, 0, 'checksum'); + } + } + + return { + p50: percentile(selectTimes, 0.5), + p90: percentile(selectTimes, 0.9), + p99: percentile(selectTimes, 0.99), + rows, + doneRows, + blockedRows, + checksum, + }; +} + +async function loadProjectRollup(db: OliphauntDatabase): Promise { + const result = await db.query(` + SELECT + name, + total::text AS total, + done::text AS done, + blocked::text AS blocked, + estimate::text AS estimate + FROM task_dashboard + ORDER BY id + `); + return result.rows.map((_, index) => ({ + name: requiredText(result, index, 'name'), + total: requiredText(result, index, 'total'), + done: requiredText(result, index, 'done'), + blocked: requiredText(result, index, 'blocked'), + estimate: requiredText(result, index, 'estimate'), + })); +} + +async function loadActivity(db: OliphauntDatabase): Promise { + const result = await db.query( + `SELECT title, owner, status::text AS status + FROM tasks + WHERE lower(title) LIKE lower($1) + ORDER BY updated_at DESC, id DESC + LIMIT 8`, + ['%field operation%'], + ); + return result.rows.map((_, index) => ({ + title: requiredText(result, index, 'title'), + owner: requiredText(result, index, 'owner'), + status: requiredText(result, index, 'status'), + })); +} + +async function scalar(db: OliphauntDatabase, sql: string): Promise { + const result = await db.query(sql); + return requiredText(result, 0, 'value'); +} + +async function record( + checks: OperationCheck[], + name: string, + run: () => Promise, + onStage?: (stage: WorkloadCheckStage) => void, +): Promise { + const started = now(); + onStage?.({name, status: 'start'}); + const detail = await run(); + const elapsedMs = now() - started; + onStage?.({name, status: 'done', detail, elapsedMs}); + checks.push({ + name, + detail, + elapsedMs, + }); +} + +async function expectPostgresError( + run: () => Promise, + sqlstate: string, +): Promise { + try { + await run(); + } catch (error) { + if (error instanceof PostgresError) { + assertEqual(error.sqlstate ?? '', sqlstate, 'PostgreSQL SQLSTATE'); + return error.sqlstate ?? ''; + } + throw error; + } + throw new Error(`expected PostgreSQL error ${sqlstate}`); +} + +async function expectPostgresPromiseError( + promise: Promise, + sqlstate: string, +): Promise { + return expectPostgresError(() => promise, sqlstate); +} + +async function expectMessage(run: () => Promise, message: string): Promise { + try { + await run(); + } catch (error) { + const actual = error instanceof Error ? error.message : String(error); + if (!actual.includes(message)) { + throw new Error(`expected error containing ${message}, got ${actual}`); + } + return; + } + throw new Error(`expected error containing ${message}`); +} + +function requiredText(result: QueryResult, row: number, column: string): string { + const value = result.getText(row, column); + if (value == null) { + throw new Error(`query result missing ${column} at row ${row}`); + } + return value; +} + +function assertEqual(actual: string, expected: string, label: string): void { + if (actual !== expected) { + throw new Error(`${label}: expected ${expected}, got ${actual}`); + } +} + +function assertPositiveInteger(value: string, label: string): void { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label}: expected positive integer, got ${value}`); + } +} + +function assertGreaterThanBigInt(actual: string, expectedLowerBound: string, label: string): void { + if (!/^[0-9]+$/.test(actual) || !/^[0-9]+$/.test(expectedLowerBound)) { + throw new Error( + `${label}: expected unsigned integer values, got ${actual} and ${expectedLowerBound}`, + ); + } + if (BigInt(actual) <= BigInt(expectedLowerBound)) { + throw new Error(`${label}: expected ${actual} to be greater than ${expectedLowerBound}`); + } +} + +function postgresTextArray(values: readonly string[]): string { + return `{${values.map(postgresArrayElement).join(',')}}`; +} + +function postgresArrayElement(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function percentile(values: number[], p: number): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * p) - 1); + return sorted[index] ?? 0; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function now(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +const TASK_COUNT = 360; + +const projectSeeds = [ + { + id: 1, + name: 'Dispatch', + owner: 'Asha', + health: 'green', + budget: 125000, + tags: ['mobile', 'ops'], + metadata: {region: 'west', tier: 'critical'}, + }, + { + id: 2, + name: 'Inventory', + owner: 'Mika', + health: 'green', + budget: 91000, + tags: ['warehouse', 'sync'], + metadata: {region: 'central', tier: 'core'}, + }, + { + id: 3, + name: 'Routing', + owner: 'Noah', + health: 'yellow', + budget: 76000, + tags: ['maps', 'mobile'], + metadata: {region: 'east', tier: 'core'}, + }, + { + id: 4, + name: 'Billing', + owner: 'Iris', + health: 'green', + budget: 132000, + tags: ['finance', 'batch'], + metadata: {region: 'west', tier: 'critical'}, + }, + { + id: 5, + name: 'Support', + owner: 'Ren', + health: 'yellow', + budget: 68000, + tags: ['customer', 'mobile'], + metadata: {region: 'central', tier: 'growth'}, + }, + { + id: 6, + name: 'Compliance', + owner: 'Leah', + health: 'green', + budget: 84000, + tags: ['audit', 'policy'], + metadata: {region: 'east', tier: 'critical'}, + }, +] as const; + +const owners = ['Asha', 'Mika', 'Noah', 'Iris', 'Ren', 'Leah', 'Omar', 'Vera']; +const regions = ['west', 'central', 'east']; diff --git a/src/sdks/react-native/examples/expo/src/sqlite-benchmark.ts b/src/sdks/react-native/examples/expo/src/sqlite-benchmark.ts new file mode 100644 index 00000000..6ca7d854 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/sqlite-benchmark.ts @@ -0,0 +1,439 @@ +import * as SQLite from 'expo-sqlite'; +import type { + LatencySummary, + ReactNativeBenchmarkWorkload, + ThroughputSummary, +} from '@oliphaunt/react-native'; + +export type SQLiteDurabilityProfile = 'safe' | 'balanced' | 'fastDev'; + +export type ExpoSQLiteBenchmarkOptions = { + readonly durability?: SQLiteDurabilityProfile; + readonly warmupIterations?: number; + readonly simpleRttIterations?: number; + readonly parameterizedRttIterations?: number; + readonly insertRows?: number; + readonly lookupIterations?: number; + readonly aggregateIterations?: number; + readonly updateIterations?: number; + readonly checkpointIterations?: number; + readonly largeResultRows?: number; + readonly metadata?: Record; +}; + +export type ExpoSQLiteBenchmarkReport = { + readonly schemaVersion: 1; + readonly engine: 'expo-sqlite'; + readonly startedAt: string; + readonly elapsedMs: number; + readonly openMs: number; + readonly closeMs: number; + readonly databaseName: string; + readonly durability: SQLiteDurabilityProfile; + readonly options: Required< + Pick< + ExpoSQLiteBenchmarkOptions, + | 'warmupIterations' + | 'simpleRttIterations' + | 'parameterizedRttIterations' + | 'insertRows' + | 'lookupIterations' + | 'aggregateIterations' + | 'updateIterations' + | 'checkpointIterations' + | 'largeResultRows' + > + >; + readonly metadata: Record; + readonly workloads: ReactNativeBenchmarkWorkload[]; +}; + +type SQLiteDatabase = Awaited>; +type ResolvedSQLiteBenchmarkOptions = ExpoSQLiteBenchmarkReport['options']; + +const defaultSQLiteBenchmarkOptions: ResolvedSQLiteBenchmarkOptions = { + warmupIterations: 75, + simpleRttIterations: 750, + parameterizedRttIterations: 750, + insertRows: 1_500, + lookupIterations: 750, + aggregateIterations: 300, + updateIterations: 300, + checkpointIterations: 20, + largeResultRows: 750, +}; + +export async function runExpoSQLiteBenchmark( + options: ExpoSQLiteBenchmarkOptions = {}, +): Promise { + const resolved = resolveOptions(options); + const durability = options.durability ?? 'balanced'; + const startedAt = new Date().toISOString(); + const totalStart = monotonicNow(); + const databaseName = `oliphaunt-sqlite-bench-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}.db`; + await SQLite.deleteDatabaseAsync(databaseName).catch(() => undefined); + + let closeMs = 0; + const openStart = monotonicNow(); + const db = await SQLite.openDatabaseAsync(databaseName); + const openMs = monotonicNow() - openStart; + + try { + await configureDurability(db, durability); + await runWarmup(db, resolved.warmupIterations); + + const workloads: ReactNativeBenchmarkWorkload[] = []; + workloads.push(await runSimpleSelectRtt(db, resolved.simpleRttIterations)); + workloads.push(await runParameterizedSelectRtt(db, resolved.parameterizedRttIterations)); + workloads.push(await prepareDataset(db, resolved.insertRows)); + workloads.push(await runIndexedLookup(db, resolved.lookupIterations, resolved.insertRows)); + workloads.push(await runAggregateScan(db, resolved.aggregateIterations)); + workloads.push(await runIndexedUpdates(db, resolved.updateIterations, resolved.insertRows)); + workloads.push(await runCheckpointLatency(db, resolved.checkpointIterations)); + workloads.push(await runLargeResult(db, resolved.largeResultRows)); + + const closeStart = monotonicNow(); + await db.closeAsync(); + closeMs = monotonicNow() - closeStart; + + return { + schemaVersion: 1, + engine: 'expo-sqlite', + startedAt, + elapsedMs: monotonicNow() - totalStart, + openMs, + closeMs, + databaseName, + durability, + options: resolved, + metadata: options.metadata ?? {}, + workloads, + }; + } finally { + await db.closeAsync().catch(() => undefined); + await SQLite.deleteDatabaseAsync(databaseName).catch(() => undefined); + } +} + +function resolveOptions( + options: ExpoSQLiteBenchmarkOptions, +): ResolvedSQLiteBenchmarkOptions { + return { + warmupIterations: positiveInteger( + options.warmupIterations, + defaultSQLiteBenchmarkOptions.warmupIterations, + 'sqlite warmupIterations', + ), + simpleRttIterations: positiveInteger( + options.simpleRttIterations, + defaultSQLiteBenchmarkOptions.simpleRttIterations, + 'sqlite simpleRttIterations', + ), + parameterizedRttIterations: positiveInteger( + options.parameterizedRttIterations, + defaultSQLiteBenchmarkOptions.parameterizedRttIterations, + 'sqlite parameterizedRttIterations', + ), + insertRows: positiveInteger( + options.insertRows, + defaultSQLiteBenchmarkOptions.insertRows, + 'sqlite insertRows', + ), + lookupIterations: positiveInteger( + options.lookupIterations, + defaultSQLiteBenchmarkOptions.lookupIterations, + 'sqlite lookupIterations', + ), + aggregateIterations: positiveInteger( + options.aggregateIterations, + defaultSQLiteBenchmarkOptions.aggregateIterations, + 'sqlite aggregateIterations', + ), + updateIterations: positiveInteger( + options.updateIterations, + defaultSQLiteBenchmarkOptions.updateIterations, + 'sqlite updateIterations', + ), + checkpointIterations: positiveInteger( + options.checkpointIterations, + defaultSQLiteBenchmarkOptions.checkpointIterations, + 'sqlite checkpointIterations', + ), + largeResultRows: positiveInteger( + options.largeResultRows, + defaultSQLiteBenchmarkOptions.largeResultRows, + 'sqlite largeResultRows', + ), + }; +} + +function positiveInteger(value: number | undefined, fallback: number, name: string): number { + const selected = value ?? fallback; + if (!Number.isInteger(selected) || selected <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return selected; +} + +async function configureDurability( + db: SQLiteDatabase, + durability: SQLiteDurabilityProfile, +): Promise { + const synchronous = (() => { + switch (durability) { + case 'safe': + return 'FULL'; + case 'balanced': + return 'NORMAL'; + case 'fastDev': + return 'OFF'; + } + })(); + await db.execAsync(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = ${synchronous}; + PRAGMA foreign_keys = ON; + `); +} + +async function runWarmup(db: SQLiteDatabase, iterations: number): Promise { + for (let index = 0; index < iterations; index += 1) { + await db.getFirstAsync('SELECT ? AS value', index % 17); + } +} + +async function runSimpleSelectRtt( + db: SQLiteDatabase, + iterations: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async index => { + const row = await db.getFirstAsync<{ value: number }>('SELECT ? AS value', index % 17); + checksum += row?.value ?? 0; + }); + return { + id: 'sqlite_simple_select_rtt', + description: 'SQLite SELECT round trip through expo-sqlite', + latency, + checksum: String(checksum), + }; +} + +async function runParameterizedSelectRtt( + db: SQLiteDatabase, + iterations: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async index => { + const row = await db.getFirstAsync<{ value: string }>( + 'SELECT ? AS value', + `value-${index}`, + ); + checksum += row?.value.length ?? 0; + }); + return { + id: 'sqlite_parameterized_select_rtt', + description: 'SQLite parameter binding round trip through expo-sqlite', + latency, + checksum: String(checksum), + }; +} + +async function prepareDataset( + db: SQLiteDatabase, + rows: number, +): Promise { + await db.execAsync(` + DROP TABLE IF EXISTS rn_bench_events; + CREATE TABLE rn_bench_events ( + id integer PRIMARY KEY, + bucket integer NOT NULL, + label text NOT NULL, + amount integer NOT NULL, + payload text NOT NULL, + updated_at text NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX rn_bench_events_bucket_idx ON rn_bench_events(bucket); + CREATE INDEX rn_bench_events_label_idx ON rn_bench_events(label); + `); + + const started = monotonicNow(); + await db.withExclusiveTransactionAsync(async tx => { + for (let index = 1; index <= rows; index += 1) { + await tx.runAsync( + `INSERT INTO rn_bench_events (id, bucket, label, amount, payload) + VALUES (?, ?, ?, ?, ?)`, + index, + index % 32, + `label-${index % 128}`, + index % 10_000, + `payload-${index}-${'x'.repeat(48)}`, + ); + } + }); + const totalMs = monotonicNow() - started; + + return { + id: 'sqlite_transaction_insert', + description: 'SQLite parameterized INSERT workload inside one transaction', + throughput: throughput(rows, totalMs), + rows, + }; +} + +async function runIndexedLookup( + db: SQLiteDatabase, + iterations: number, + rows: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async index => { + const id = (index % rows) + 1; + const row = await db.getFirstAsync<{ payload: string }>( + 'SELECT payload FROM rn_bench_events WHERE id = ?', + id, + ); + checksum += row?.payload.length ?? 0; + }); + return { + id: 'sqlite_indexed_lookup', + description: 'SQLite primary-key lookup against the benchmark table', + latency, + checksum: String(checksum), + }; +} + +async function runAggregateScan( + db: SQLiteDatabase, + iterations: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async index => { + const row = await db.getFirstAsync<{ rows: number; total: number }>( + `SELECT count(*) AS rows, coalesce(sum(amount), 0) AS total + FROM rn_bench_events + WHERE bucket = ?`, + index % 32, + ); + checksum += row?.rows ?? 0; + checksum += row?.total ?? 0; + }); + return { + id: 'sqlite_indexed_aggregate', + description: 'SQLite indexed aggregate with count and sum over a bucket predicate', + latency, + checksum: String(checksum), + }; +} + +async function runIndexedUpdates( + db: SQLiteDatabase, + iterations: number, + rows: number, +): Promise { + const latency = await measureLatency(iterations, async index => { + const id = ((index * 17) % rows) + 1; + await db.runAsync( + `UPDATE rn_bench_events + SET amount = amount + 1, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + id, + ); + }); + const row = await db.getFirstAsync<{ checksum: number }>( + 'SELECT coalesce(sum(amount), 0) AS checksum FROM rn_bench_events', + ); + return { + id: 'sqlite_indexed_update', + description: 'SQLite single-row parameterized UPDATE by primary key', + latency, + checksum: String(row?.checksum ?? 0), + }; +} + +async function runCheckpointLatency( + db: SQLiteDatabase, + iterations: number, +): Promise { + const latency = await measureLatency(iterations, async () => { + await db.execAsync('PRAGMA wal_checkpoint(TRUNCATE)'); + }); + return { + id: 'sqlite_wal_checkpoint', + description: 'SQLite WAL checkpoint latency through expo-sqlite', + latency, + }; +} + +async function runLargeResult( + db: SQLiteDatabase, + rows: number, +): Promise { + let responseBytes = 0; + const latency = await measureLatency(20, async () => { + const result = await db.getAllAsync<{ id: number; label: string; payload: string }>( + `SELECT id, label, payload + FROM rn_bench_events + ORDER BY id + LIMIT ?`, + rows, + ); + responseBytes = JSON.stringify(result).length; + }); + return { + id: 'sqlite_large_result', + description: 'SQLite large result transfer through expo-sqlite row objects', + latency, + rows, + responseBytes, + checksum: String(responseBytes), + }; +} + +async function measureLatency( + iterations: number, + run: (index: number) => Promise, +): Promise { + const values: number[] = []; + const totalStart = monotonicNow(); + for (let index = 0; index < iterations; index += 1) { + const started = monotonicNow(); + await run(index); + values.push(monotonicNow() - started); + } + const totalMs = monotonicNow() - totalStart; + const sorted = [...values].sort((left, right) => left - right); + return { + iterations, + totalMs, + minMs: sorted[0] ?? 0, + meanMs: values.reduce((sum, value) => sum + value, 0) / values.length, + p50Ms: percentileSorted(sorted, 0.5), + p90Ms: percentileSorted(sorted, 0.9), + p95Ms: percentileSorted(sorted, 0.95), + p99Ms: percentileSorted(sorted, 0.99), + maxMs: sorted[sorted.length - 1] ?? 0, + }; +} + +function percentileSorted(sorted: readonly number[], percentile: number): number { + if (sorted.length === 0) { + return 0; + } + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * percentile) - 1); + return sorted[index] ?? 0; +} + +function throughput(rows: number, totalMs: number): ThroughputSummary { + return { + rows, + totalMs, + rowsPerSecond: rows / (totalMs / 1_000), + }; +} + +function monotonicNow(): number { + return globalThis.performance?.now() ?? Date.now(); +} diff --git a/src/sdks/react-native/examples/expo/tsconfig.json b/src/sdks/react-native/examples/expo/tsconfig.json new file mode 100644 index 00000000..d6da3c90 --- /dev/null +++ b/src/sdks/react-native/examples/expo/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": [ + "./src/*" + ], + "@/assets/*": [ + "./assets/*" + ] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ] +} diff --git a/src/sdks/react-native/ios/Oliphaunt.mm b/src/sdks/react-native/ios/Oliphaunt.mm new file mode 100644 index 00000000..b5d3cecf --- /dev/null +++ b/src/sdks/react-native/ios/Oliphaunt.mm @@ -0,0 +1,918 @@ +#import "OliphauntReactNative.h" +#import "OliphauntAdapter.h" + +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#include +#include +#include +#endif + +#include +#include +#include +#include +#include + +static void OliphauntReject( + RCTPromiseRejectBlock reject, + NSString *code, + NSString *fallback, + NSError *error) +{ + reject(code, error.localizedDescription ?: fallback, error); +} + +#ifdef RCT_NEW_ARCH_ENABLED +static void OliphauntSetIfPresent(NSMutableDictionary *dictionary, NSString *key, id value) +{ + if (value != nil) { + dictionary[key] = value; + } +} + +static NSDictionary *OliphauntNativeResourceConfigToDictionary( + JS::NativeOliphaunt::NativeResourceConfig &config) +{ + NSMutableDictionary *dictionary = [NSMutableDictionary new]; + OliphauntSetIfPresent(dictionary, @"resourceRoot", config.resourceRoot()); + return dictionary; +} + +static NSDictionary *OliphauntNativeOpenConfigToDictionary( + JS::NativeOliphaunt::NativeOpenConfig &config) +{ + NSMutableDictionary *dictionary = [NSMutableDictionary new]; + OliphauntSetIfPresent(dictionary, @"engine", config.engine()); + OliphauntSetIfPresent(dictionary, @"root", config.root()); + if (auto temporary = config.temporary()) { + dictionary[@"temporary"] = @(*temporary); + } + OliphauntSetIfPresent(dictionary, @"durability", config.durability()); + OliphauntSetIfPresent(dictionary, @"runtimeFootprint", config.runtimeFootprint()); + OliphauntSetIfPresent(dictionary, @"startupGUCs", RCTConvertOptionalVecToArray(config.startupGUCs())); + OliphauntSetIfPresent(dictionary, @"username", config.username()); + OliphauntSetIfPresent(dictionary, @"database", config.database()); + OliphauntSetIfPresent(dictionary, @"extensions", RCTConvertOptionalVecToArray(config.extensions())); + OliphauntSetIfPresent(dictionary, @"libraryPath", config.libraryPath()); + OliphauntSetIfPresent(dictionary, @"runtimeDirectory", config.runtimeDirectory()); + OliphauntSetIfPresent(dictionary, @"resourceRoot", config.resourceRoot()); + return dictionary; +} + +class OliphauntMutableBuffer final : public facebook::jsi::MutableBuffer { + public: + explicit OliphauntMutableBuffer(std::vector bytes) + : bytes_(std::move(bytes)) {} + + size_t size() const override + { + return bytes_.size(); + } + + uint8_t *data() override + { + return bytes_.data(); + } + + private: + std::vector bytes_; +}; + +static facebook::jsi::ArrayBuffer OliphauntArrayBufferFromBytes( + facebook::jsi::Runtime &runtime, + std::vector bytes) +{ + return facebook::jsi::ArrayBuffer( + runtime, + std::make_shared(std::move(bytes))); +} + +static std::vector OliphauntBytesFromNSData(NSData *_Nullable data) +{ + std::vector bytes; + if (data != nil && data.length > 0) { + const uint8_t *begin = static_cast(data.bytes); + bytes.assign(begin, begin + data.length); + } + return bytes; +} + +static size_t OliphauntCopySizeArgument( + facebook::jsi::Runtime &runtime, + double value, + const char *name) +{ + constexpr double kMaxSafeInteger = 9007199254740991.0; + if (!std::isfinite(value) || + value < 0 || + std::trunc(value) != value || + value > kMaxSafeInteger || + value > static_cast(std::numeric_limits::max())) { + throw facebook::jsi::JSError( + runtime, + std::string("liboliphaunt JSI ") + name + " must be a non-negative integer"); + } + return static_cast(value); +} + +static double OliphauntCopyHandleArgument( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value) +{ + constexpr double kMaxSafeInteger = 9007199254740991.0; + if (!value.isNumber()) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI handle must be a number"); + } + double handle = value.asNumber(); + if (!std::isfinite(handle) || + handle <= 0 || + std::trunc(handle) != handle || + handle > kMaxSafeInteger) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI handle must be a positive safe integer"); + } + return handle; +} + +static std::vector OliphauntCopyBinaryArgument( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value) +{ + if (!value.isObject()) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI request must be an ArrayBuffer or typed array"); + } + + auto object = value.asObject(runtime); + size_t byteOffset = 0; + size_t byteLength = 0; + facebook::jsi::ArrayBuffer buffer = [&]() { + if (object.isArrayBuffer(runtime)) { + auto arrayBuffer = object.getArrayBuffer(runtime); + byteLength = arrayBuffer.size(runtime); + return arrayBuffer; + } + + auto bufferValue = object.getProperty(runtime, "buffer"); + if (!bufferValue.isObject() || !bufferValue.asObject(runtime).isArrayBuffer(runtime)) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI request must be an ArrayBuffer or typed array"); + } + auto offsetValue = object.getProperty(runtime, "byteOffset"); + auto lengthValue = object.getProperty(runtime, "byteLength"); + if (!offsetValue.isNumber() || !lengthValue.isNumber()) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI typed-array request is missing byteOffset/byteLength"); + } + byteOffset = OliphauntCopySizeArgument( + runtime, + offsetValue.asNumber(), + "typed-array byteOffset"); + byteLength = OliphauntCopySizeArgument( + runtime, + lengthValue.asNumber(), + "typed-array byteLength"); + return bufferValue.asObject(runtime).getArrayBuffer(runtime); + }(); + + if (byteOffset > buffer.size(runtime) || byteLength > buffer.size(runtime) - byteOffset) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI typed-array request is out of bounds"); + } + + const uint8_t *begin = buffer.data(runtime) + byteOffset; + return std::vector(begin, begin + byteLength); +} + +static std::string OliphauntCopyStringArgument( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + const char *name) +{ + if (!value.isString()) { + throw facebook::jsi::JSError(runtime, std::string("liboliphaunt JSI ") + name + " must be a string"); + } + return value.asString(runtime).utf8(runtime); +} + +static NSString *OliphauntNSStringFromString(const std::string &value) +{ + return [NSString stringWithUTF8String:value.c_str()] ?: @""; +} + +static NSString *_Nullable OliphauntCopyOptionalNSStringArgument( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value, + const char *name) +{ + if (value.isNull() || value.isUndefined()) { + return nil; + } + return OliphauntNSStringFromString(OliphauntCopyStringArgument(runtime, value, name)); +} + +static facebook::jsi::Value OliphauntCreateError( + facebook::jsi::Runtime &runtime, + const std::string &message) +{ + return runtime.global() + .getPropertyAsFunction(runtime, "Error") + .callAsConstructor(runtime, facebook::jsi::String::createFromUtf8(runtime, message)); +} +#endif + +static NSString *OliphauntStringConfigValue(id value, NSString *defaultValue) +{ + return [value isKindOfClass:[NSString class]] ? (NSString *)value : defaultValue; +} + +@implementation Oliphaunt { + NSMutableDictionary *_sessions; + NSMutableDictionary *_sessionKeys; + dispatch_queue_t _methodQueue; + NSString *_pendingSessionKey; + uint64_t _nextHandle; +} + +RCT_EXPORT_MODULE(Oliphaunt) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (instancetype)init +{ + if (self = [super init]) { + _sessions = [NSMutableDictionary new]; + _sessionKeys = [NSMutableDictionary new]; + _methodQueue = dispatch_queue_create("dev.oliphaunt.reactnative.ios.module", DISPATCH_QUEUE_SERIAL); + _nextHandle = 1; + } + return self; +} + +- (dispatch_queue_t)methodQueue +{ + return _methodQueue; +} + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)open:(JS::NativeOliphaunt::NativeOpenConfig &)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self openWithConfigDictionary:OliphauntNativeOpenConfigToDictionary(config) resolve:resolve reject:reject]; +} +#else +- (void)open:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self openWithConfigDictionary:config resolve:resolve reject:reject]; +} +#endif + +- (void)openWithConfigDictionary:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSDictionary *configCopy = [config copy] ?: @{}; + NSString *sessionKey = [self sessionKeyForConfigDictionary:configCopy]; + @synchronized (self) { + NSNumber *existingHandle = [self existingHandleForSessionKey:sessionKey]; + if (existingHandle != nil) { + resolve(existingHandle); + return; + } + if (_pendingSessionKey != nil) { + reject( + @"liboliphaunt_open_in_progress", + @"React Native nativeDirect already has an open in progress", + nil); + return; + } + if (_sessions.count > 0) { + reject( + @"liboliphaunt_open_failed", + @"React Native nativeDirect already has an active database; close it before opening another root", + nil); + return; + } + _pendingSessionKey = sessionKey; + } + [OliphauntAdapterDatabase openWithConfig:configCopy completion:^( + OliphauntAdapterDatabase *_Nullable database, + NSError *_Nullable error) { + if (database == nil) { + @synchronized (self) { + self->_pendingSessionKey = nil; + } + OliphauntReject(reject, @"liboliphaunt_open_failed", @"failed to open liboliphaunt", error); + return; + } + + NSNumber *handle = nil; + @synchronized (self) { + handle = @(self->_nextHandle++); + self->_sessions[handle] = database; + self->_sessionKeys[handle] = sessionKey; + self->_pendingSessionKey = nil; + } + resolve(handle); + }]; +} + +- (void)supportedModes:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [OliphauntAdapterDatabase supportedModesWithCompletion:^( + NSArray *_Nullable modes, + NSError *_Nullable error) { + if (error != nil) { + OliphauntReject(reject, @"liboliphaunt_supported_modes_failed", @"liboliphaunt supportedModes failed", error); + return; + } + resolve(modes ?: @[]); + }]; +} + +- (void)packageSizeReportWithConfigDictionary:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSDictionary *configCopy = [config copy] ?: @{}; + [OliphauntAdapterDatabase packageSizeReportWithConfig:configCopy completion:^( + NSDictionary *_Nullable report, + NSError *_Nullable error) { + if (error != nil) { + OliphauntReject(reject, @"liboliphaunt_package_size_failed", @"liboliphaunt packageSizeReport failed", error); + return; + } + resolve(report ?: [NSNull null]); + }]; +} + +- (void)processMemory:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [OliphauntAdapterDatabase processMemoryWithCompletion:^( + NSDictionary *_Nullable report, + NSError *_Nullable error) { + if (error != nil) { + OliphauntReject(reject, @"liboliphaunt_process_memory_failed", @"liboliphaunt processMemory failed", error); + return; + } + resolve(report ?: @{}); + }]; +} + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)packageSizeReport:(JS::NativeOliphaunt::NativeResourceConfig &)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self packageSizeReportWithConfigDictionary:OliphauntNativeResourceConfigToDictionary(config) + resolve:resolve + reject:reject]; +} +#else +- (void)packageSizeReport:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [self packageSizeReportWithConfigDictionary:config resolve:resolve reject:reject]; +} +#endif + +- (void)execProtocolRawDataForJsi:(double)handle + request:(NSData *)request + completion:(OliphauntDataCompletion)completion +{ + OliphauntAdapterDatabase *database = [self sessionForHandle:handle]; + if (database == nil) { + completion(nil, [NSError errorWithDomain:@"dev.oliphaunt.reactnative.ios" + code:404 + userInfo:@{NSLocalizedDescriptionKey: @"unknown Oliphaunt handle"}]); + return; + } + [database execProtocolData:request completion:completion]; +} + +- (void)execProtocolStreamDataForJsi:(double)handle + request:(NSData *)request + onChunk:(OliphauntStreamChunk)onChunk + completion:(OliphauntVoidCompletion)completion +{ + OliphauntAdapterDatabase *database = [self sessionForHandle:handle]; + if (database == nil) { + completion([NSError errorWithDomain:@"dev.oliphaunt.reactnative.ios" + code:404 + userInfo:@{NSLocalizedDescriptionKey: @"unknown Oliphaunt handle"}]); + return; + } + [database execProtocolStreamData:request onChunk:onChunk completion:completion]; +} + +- (void)backupDataForJsi:(double)handle + format:(NSString *)format + completion:(OliphauntDataCompletion)completion +{ + OliphauntAdapterDatabase *database = [self sessionForHandle:handle]; + if (database == nil) { + completion(nil, [NSError errorWithDomain:@"dev.oliphaunt.reactnative.ios" + code:404 + userInfo:@{NSLocalizedDescriptionKey: @"unknown Oliphaunt handle"}]); + return; + } + [database backupDataWithFormat:format completion:completion]; +} + +- (void)restoreDataForJsi:(NSString *)root + format:(NSString *)format + artifactData:(NSData *)artifactData + replaceExisting:(BOOL)replaceExisting + libraryPath:(NSString *_Nullable)libraryPath + completion:(OliphauntStringCompletion)completion +{ + [OliphauntAdapterDatabase restoreWithRoot:root + format:format + artifactData:artifactData + replaceExisting:replaceExisting + libraryPath:libraryPath + completion:completion]; +} + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)runtime + callInvoker:(const std::shared_ptr &)callInvoker +{ + __weak Oliphaunt *weakSelf = self; + auto transport = facebook::jsi::Object(runtime); + transport.setProperty(runtime, "version", 1); + transport.setProperty( + runtime, + "execProtocolRaw", + facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolRaw"), + 2, + [weakSelf, callInvoker]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *args, + size_t count) -> facebook::jsi::Value { + if (count != 2) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI execProtocolRaw expects handle and request"); + } + + double handle = OliphauntCopyHandleArgument(runtime, args[0]); + std::vector request = OliphauntCopyBinaryArgument(runtime, args[1]); + auto requestData = [NSData dataWithBytes:request.data() length:request.size()]; + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolRawExecutor"), + 2, + [weakSelf, callInvoker, handle, requestData]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *promiseArgs, + size_t promiseArgCount) -> facebook::jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + auto resolve = std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker); + auto reject = std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker); + Oliphaunt *strongSelf = weakSelf; + if (strongSelf == nil) { + reject->call([](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, "liboliphaunt native module is unavailable")); + }); + return facebook::jsi::Value::undefined(); + } + + [strongSelf execProtocolRawDataForJsi:handle + request:requestData + completion:^(NSData *_Nullable response, NSError *_Nullable error) { + if (error != nil) { + const char *errorMessage = error.localizedDescription.UTF8String; + std::string message = errorMessage != nullptr ? errorMessage : "liboliphaunt exec failed"; + reject->call([message](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, message)); + }); + return; + } + std::vector bytes = OliphauntBytesFromNSData(response); + resolve->call([bytes = std::move(bytes)]( + facebook::jsi::Runtime &runtime, + facebook::jsi::Function &resolveFunction) mutable { + resolveFunction.call(runtime, OliphauntArrayBufferFromBytes(runtime, std::move(bytes))); + }); + }]; + return facebook::jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + transport.setProperty( + runtime, + "execProtocolStream", + facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolStream"), + 3, + [weakSelf, callInvoker]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *args, + size_t count) -> facebook::jsi::Value { + if (count != 3 || !args[2].isObject() || !args[2].asObject(runtime).isFunction(runtime)) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI execProtocolStream expects handle, request, and onChunk"); + } + + double handle = OliphauntCopyHandleArgument(runtime, args[0]); + std::vector request = OliphauntCopyBinaryArgument(runtime, args[1]); + auto requestData = [NSData dataWithBytes:request.data() length:request.size()]; + auto chunkCallback = std::make_shared>( + runtime, + args[2].asObject(runtime).getFunction(runtime), + callInvoker); + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntExecProtocolStreamExecutor"), + 2, + [weakSelf, callInvoker, handle, requestData, chunkCallback]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *promiseArgs, + size_t promiseArgCount) -> facebook::jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + auto resolve = std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker); + auto reject = std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker); + Oliphaunt *strongSelf = weakSelf; + if (strongSelf == nil) { + reject->call([](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, "liboliphaunt native module is unavailable")); + }); + return facebook::jsi::Value::undefined(); + } + + [strongSelf execProtocolStreamDataForJsi:handle + request:requestData + onChunk:^(NSData *chunk) { + std::vector bytes = OliphauntBytesFromNSData(chunk); + chunkCallback->call([bytes = std::move(bytes)]( + facebook::jsi::Runtime &runtime, + facebook::jsi::Function &chunkFunction) mutable { + chunkFunction.call(runtime, OliphauntArrayBufferFromBytes(runtime, std::move(bytes))); + }); + } + completion:^(NSError *_Nullable error) { + if (error != nil) { + const char *errorMessage = error.localizedDescription.UTF8String; + std::string message = errorMessage != nullptr ? errorMessage : "liboliphaunt stream failed"; + reject->call([message](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, message)); + }); + return; + } + resolve->call([](facebook::jsi::Runtime &runtime, facebook::jsi::Function &resolveFunction) { + resolveFunction.call(runtime, facebook::jsi::Value::undefined()); + }); + }]; + return facebook::jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + transport.setProperty( + runtime, + "backup", + facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntBackup"), + 2, + [weakSelf, callInvoker]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *args, + size_t count) -> facebook::jsi::Value { + if (count != 2) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI backup expects handle and format"); + } + + double handle = OliphauntCopyHandleArgument(runtime, args[0]); + NSString *format = OliphauntNSStringFromString( + OliphauntCopyStringArgument(runtime, args[1], "backup format")); + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntBackupExecutor"), + 2, + [weakSelf, callInvoker, handle, format]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *promiseArgs, + size_t promiseArgCount) -> facebook::jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + auto resolve = std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker); + auto reject = std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker); + Oliphaunt *strongSelf = weakSelf; + if (strongSelf == nil) { + reject->call([](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, "liboliphaunt native module is unavailable")); + }); + return facebook::jsi::Value::undefined(); + } + + [strongSelf backupDataForJsi:handle + format:format + completion:^(NSData *_Nullable response, NSError *_Nullable error) { + if (error != nil) { + const char *errorMessage = error.localizedDescription.UTF8String; + std::string message = errorMessage != nullptr ? errorMessage : "liboliphaunt backup failed"; + reject->call([message](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, message)); + }); + return; + } + std::vector bytes = OliphauntBytesFromNSData(response); + resolve->call([bytes = std::move(bytes)]( + facebook::jsi::Runtime &runtime, + facebook::jsi::Function &resolveFunction) mutable { + resolveFunction.call(runtime, OliphauntArrayBufferFromBytes(runtime, std::move(bytes))); + }); + }]; + return facebook::jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + transport.setProperty( + runtime, + "restore", + facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntRestore"), + 5, + [weakSelf, callInvoker]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *args, + size_t count) -> facebook::jsi::Value { + if (count != 5 || !args[3].isBool()) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI restore expects root, format, artifact, replaceExisting, and libraryPath"); + } + + NSString *root = OliphauntNSStringFromString( + OliphauntCopyStringArgument(runtime, args[0], "restore root")); + NSString *format = OliphauntNSStringFromString( + OliphauntCopyStringArgument(runtime, args[1], "restore format")); + std::vector artifact = OliphauntCopyBinaryArgument(runtime, args[2]); + auto artifactData = [NSData dataWithBytes:artifact.data() length:artifact.size()]; + BOOL replaceExisting = args[3].getBool(); + NSString *libraryPath = OliphauntCopyOptionalNSStringArgument(runtime, args[4], "restore libraryPath"); + auto promiseConstructor = runtime.global().getPropertyAsFunction(runtime, "Promise"); + auto executor = facebook::jsi::Function::createFromHostFunction( + runtime, + facebook::jsi::PropNameID::forAscii(runtime, "liboliphauntRestoreExecutor"), + 2, + [weakSelf, callInvoker, root, format, artifactData, replaceExisting, libraryPath]( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &, + const facebook::jsi::Value *promiseArgs, + size_t promiseArgCount) -> facebook::jsi::Value { + if (promiseArgCount < 2 || + !promiseArgs[0].isObject() || + !promiseArgs[0].asObject(runtime).isFunction(runtime) || + !promiseArgs[1].isObject() || + !promiseArgs[1].asObject(runtime).isFunction(runtime)) { + throw facebook::jsi::JSError(runtime, "liboliphaunt JSI Promise executor received invalid callbacks"); + } + + auto resolve = std::make_shared>( + runtime, + promiseArgs[0].asObject(runtime).getFunction(runtime), + callInvoker); + auto reject = std::make_shared>( + runtime, + promiseArgs[1].asObject(runtime).getFunction(runtime), + callInvoker); + Oliphaunt *strongSelf = weakSelf; + if (strongSelf == nil) { + reject->call([](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, "liboliphaunt native module is unavailable")); + }); + return facebook::jsi::Value::undefined(); + } + + [strongSelf restoreDataForJsi:root + format:format + artifactData:artifactData + replaceExisting:replaceExisting + libraryPath:libraryPath + completion:^(NSString *_Nullable restoredRoot, NSError *_Nullable error) { + if (error != nil) { + const char *errorMessage = error.localizedDescription.UTF8String; + std::string message = errorMessage != nullptr ? errorMessage : "liboliphaunt restore failed"; + reject->call([message](facebook::jsi::Runtime &runtime, facebook::jsi::Function &rejectFunction) { + rejectFunction.call(runtime, OliphauntCreateError(runtime, message)); + }); + return; + } + std::string restored = restoredRoot.UTF8String != nullptr ? restoredRoot.UTF8String : ""; + resolve->call([restored](facebook::jsi::Runtime &runtime, facebook::jsi::Function &resolveFunction) { + resolveFunction.call(runtime, facebook::jsi::String::createFromUtf8(runtime, restored)); + }); + }]; + return facebook::jsi::Value::undefined(); + }); + return promiseConstructor.callAsConstructor(runtime, std::move(executor)); + })); + runtime.global().setProperty(runtime, "__oliphauntReactNativeJsi", std::move(transport)); +} +#endif + +- (void)close:(double)handle + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + OliphauntAdapterDatabase *database = [self removeSessionForHandle:handle]; + if (database == nil) { + resolve(nil); + return; + } + [database closeWithCompletion:^(NSError *_Nullable error) { + if (error != nil) { + OliphauntReject(reject, @"liboliphaunt_close_failed", @"liboliphaunt close failed", error); + return; + } + resolve(nil); + }]; +} + +- (void)cancel:(double)handle + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + OliphauntAdapterDatabase *database = [self sessionForHandle:handle]; + if (database == nil) { + reject(@"liboliphaunt_unknown_handle", @"unknown Oliphaunt handle", nil); + return; + } + [database cancelWithCompletion:^(NSError *_Nullable error) { + if (error != nil) { + OliphauntReject(reject, @"liboliphaunt_cancel_failed", @"liboliphaunt cancel failed", error); + return; + } + resolve(nil); + }]; +} + +- (void)capabilities:(double)handle + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + OliphauntAdapterDatabase *database = [self sessionForHandle:handle]; + if (database == nil) { + reject(@"liboliphaunt_unknown_handle", @"unknown Oliphaunt handle", nil); + return; + } + [database capabilitiesWithCompletion:^(NSDictionary *_Nullable capabilities, NSError *_Nullable error) { + if (error != nil) { + OliphauntReject(reject, @"liboliphaunt_capabilities_failed", @"liboliphaunt capabilities failed", error); + return; + } + resolve(capabilities ?: @{}); + }]; +} + +- (OliphauntAdapterDatabase *)sessionForHandle:(double)handle +{ + NSNumber *key = @((uint64_t)handle); + @synchronized (self) { + return _sessions[key]; + } +} + +- (OliphauntAdapterDatabase *)removeSessionForHandle:(double)handle +{ + NSNumber *key = @((uint64_t)handle); + @synchronized (self) { + OliphauntAdapterDatabase *database = _sessions[key]; + [_sessions removeObjectForKey:key]; + [_sessionKeys removeObjectForKey:key]; + return database; + } +} + +- (NSNumber *)existingHandleForSessionKey:(NSString *)sessionKey +{ + for (NSNumber *handle in _sessionKeys) { + if ([_sessionKeys[handle] isEqualToString:sessionKey] && _sessions[handle] != nil) { + return handle; + } + } + return nil; +} + +- (NSString *)sessionKeyForConfigDictionary:(NSDictionary *)config +{ + NSMutableArray *extensions = [NSMutableArray new]; + NSMutableArray *startupGUCs = [NSMutableArray new]; + id rawExtensions = config[@"extensions"]; + if ([rawExtensions isKindOfClass:[NSArray class]]) { + for (id extension in (NSArray *)rawExtensions) { + if ([extension isKindOfClass:[NSString class]]) { + [extensions addObject:(NSString *)extension]; + } + } + } + id rawStartupGUCs = config[@"startupGUCs"]; + if ([rawStartupGUCs isKindOfClass:[NSArray class]]) { + for (id guc in (NSArray *)rawStartupGUCs) { + if ([guc isKindOfClass:[NSString class]]) { + [startupGUCs addObject:(NSString *)guc]; + } + } + } + NSString *separator = [NSString stringWithFormat:@"%C", (unichar)0x001F]; + return [@[ + OliphauntStringConfigValue(config[@"engine"], @"nativeDirect"), + OliphauntStringConfigValue(config[@"root"], @""), + OliphauntStringConfigValue(config[@"durability"], @"balanced"), + OliphauntStringConfigValue(config[@"runtimeFootprint"], @"balancedMobile"), + [startupGUCs componentsJoinedByString:@","], + OliphauntStringConfigValue(config[@"username"], @"postgres"), + OliphauntStringConfigValue(config[@"database"], @"postgres"), + [extensions componentsJoinedByString:@","], + OliphauntStringConfigValue(config[@"libraryPath"], @""), + OliphauntStringConfigValue(config[@"runtimeDirectory"], @""), + OliphauntStringConfigValue(config[@"resourceRoot"], @""), + ] componentsJoinedByString:separator]; +} + +- (void)invalidate +{ + NSArray *sessionsToClose = nil; + @synchronized (self) { + sessionsToClose = _sessions.allValues; + [_sessions removeAllObjects]; + [_sessionKeys removeAllObjects]; + _pendingSessionKey = nil; + } + if (sessionsToClose.count == 0) { + return; + } + dispatch_group_t group = dispatch_group_create(); + for (OliphauntAdapterDatabase *database in sessionsToClose) { + dispatch_group_enter(group); + [database closeWithCompletion:^(__unused NSError *_Nullable error) { + dispatch_group_leave(group); + }]; + } + dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); +} + +#ifdef RCT_NEW_ARCH_ENABLED +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#endif + +@end diff --git a/src/sdks/react-native/ios/OliphauntAdapter.h b/src/sdks/react-native/ios/OliphauntAdapter.h new file mode 100644 index 00000000..dfde585f --- /dev/null +++ b/src/sdks/react-native/ios/OliphauntAdapter.h @@ -0,0 +1,39 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@class OliphauntAdapterDatabase; + +typedef void (^OliphauntOpenCompletion)(OliphauntAdapterDatabase *_Nullable database, NSError *_Nullable error); +typedef void (^OliphauntStringCompletion)(NSString *_Nullable value, NSError *_Nullable error); +typedef void (^OliphauntDataCompletion)(NSData *_Nullable value, NSError *_Nullable error); +typedef void (^OliphauntDictionaryCompletion)(NSDictionary *_Nullable value, NSError *_Nullable error); +typedef void (^OliphauntArrayCompletion)(NSArray *_Nullable value, NSError *_Nullable error); +typedef void (^OliphauntStreamChunk)(NSData *value); +typedef void (^OliphauntVoidCompletion)(NSError *_Nullable error); + +@interface OliphauntAdapterDatabase : NSObject + ++ (void)openWithConfig:(NSDictionary *)config completion:(OliphauntOpenCompletion)completion; ++ (void)supportedModesWithCompletion:(OliphauntArrayCompletion)completion; ++ (void)packageSizeReportWithConfig:(NSDictionary *)config completion:(OliphauntDictionaryCompletion)completion; ++ (void)processMemoryWithCompletion:(OliphauntDictionaryCompletion)completion; ++ (void)restoreWithRoot:(NSString *)root + format:(NSString *)format + artifactData:(NSData *)artifactData + replaceExisting:(BOOL)replaceExisting + libraryPath:(NSString *_Nullable)libraryPath + completion:(OliphauntStringCompletion)completion; + +- (void)execProtocolData:(NSData *)request completion:(OliphauntDataCompletion)completion; +- (void)execProtocolStreamData:(NSData *)request + onChunk:(OliphauntStreamChunk)onChunk + completion:(OliphauntVoidCompletion)completion; +- (void)backupDataWithFormat:(NSString *)format completion:(OliphauntDataCompletion)completion; +- (void)cancelWithCompletion:(OliphauntVoidCompletion)completion; +- (void)closeWithCompletion:(OliphauntVoidCompletion)completion; +- (void)capabilitiesWithCompletion:(OliphauntDictionaryCompletion)completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/sdks/react-native/ios/OliphauntAdapter.swift b/src/sdks/react-native/ios/OliphauntAdapter.swift new file mode 100644 index 00000000..23335447 --- /dev/null +++ b/src/sdks/react-native/ios/OliphauntAdapter.swift @@ -0,0 +1,843 @@ +import Foundation +import Oliphaunt +import Darwin + +@objc(OliphauntAdapterDatabase) +public final class OliphauntAdapterDatabase: NSObject, @unchecked Sendable { + private static let errorDomain = "dev.oliphaunt.reactnative.ios" + + private let database: OliphauntDatabase + + private init(database: OliphauntDatabase) { + self.database = database + } + + @objc(openWithConfig:completion:) + public static func open( + config: NSDictionary, + completion: @escaping (OliphauntAdapterDatabase?, NSError?) -> Void + ) { + let parsed: ParsedOpenConfig + do { + parsed = try parseOpenConfig(config) + } catch { + completion(nil, nsError(error)) + return + } + let completionBox = CompletionBox(completion) + Task.detached(priority: .userInitiated) { + do { + let database = try await OliphauntDatabase.open( + configuration: parsed.configuration, + engine: parsed.engine + ) + completionBox.value(OliphauntAdapterDatabase(database: database), nil) + } catch { + completionBox.value(nil, nsError(error)) + } + } + } + + @objc(supportedModesWithCompletion:) + public static func supportedModes( + completion: @escaping (NSArray?, NSError?) -> Void + ) { + let modes = OliphauntDatabase.supportedModes().map(modeSupportDictionary) + completion(modes as NSArray, nil) + } + + @objc(packageSizeReportWithConfig:completion:) + public static func packageSizeReport( + config: NSDictionary, + completion: @escaping (NSDictionary?, NSError?) -> Void + ) { + do { + let report = try runtimeResources(config: config)?.packageSizeReport() + completion(report.map(packageSizeReportDictionary), nil) + } catch { + completion(nil, nsError(error)) + } + } + + @objc(processMemoryWithCompletion:) + public static func processMemory( + completion: @escaping (NSDictionary?, NSError?) -> Void + ) { + completion(processMemoryDictionary(), nil) + } + + @objc(restoreWithRoot:format:artifactData:replaceExisting:libraryPath:completion:) + public static func restore( + root: String, + format: String, + artifactData: Data, + replaceExisting: Bool, + libraryPath: String?, + completion: @escaping (NSString?, NSError?) -> Void + ) { + let request: OliphauntRestoreRequest + let engine: OliphauntNativeDirectEngine + do { + guard !root.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw adapterError("restore root must not be empty") + } + let backupFormat = try parseBackupFormat(format) + guard backupFormat == .physicalArchive else { + throw adapterError("React Native iOS restore currently requires physicalArchive") + } + request = OliphauntRestoreRequest( + artifact: OliphauntBackupArtifact(format: backupFormat, bytes: artifactData), + root: URL(fileURLWithPath: root, isDirectory: true), + targetPolicy: replaceExisting ? .replaceExisting : .failIfExists + ) + let config = NSMutableDictionary() + if let libraryPath = try nonBlankValue( + libraryPath, + "libraryPath", + emptyMessage: "libraryPath must not be empty" + ) { + config["libraryPath"] = libraryPath + } + engine = try nativeDirectEngine(config: config, username: nil, database: nil) + } catch { + completion(nil, nsError(error)) + return + } + let completionBox = CompletionBox(completion) + Task.detached(priority: .userInitiated) { + do { + let restored = try await OliphauntDatabase.restore(request, engine: engine) + completionBox.value(restored.path as NSString, nil) + } catch { + completionBox.value(nil, nsError(error)) + } + } + } + + @objc(execProtocolData:completion:) + public func execProtocolData( + _ request: Data, + completion: @escaping (NSData?, NSError?) -> Void + ) { + let completionBox = CompletionBox(completion) + Task.detached(priority: .userInitiated) { [database] in + do { + let response = try await database.execProtocolRaw(request) + completionBox.value(response as NSData, nil) + } catch { + completionBox.value(nil, Self.nsError(error)) + } + } + } + + @objc(execProtocolStreamData:onChunk:completion:) + public func execProtocolStreamData( + _ request: Data, + onChunk: @escaping (NSData) -> Void, + completion: @escaping (NSError?) -> Void + ) { + let completionBox = CompletionBox(completion) + let chunkBox = CompletionBox(onChunk) + Task.detached(priority: .userInitiated) { [database] in + do { + try await database.execProtocolStream(request) { chunk in + chunkBox.value(chunk as NSData) + } + completionBox.value(nil) + } catch { + completionBox.value(Self.nsError(error)) + } + } + } + + @objc(backupDataWithFormat:completion:) + public func backupData( + format: String, + completion: @escaping (NSData?, NSError?) -> Void + ) { + let request: OliphauntBackupRequest + do { + request = OliphauntBackupRequest(format: try Self.parseBackupFormat(format)) + } catch { + completion(nil, Self.nsError(error)) + return + } + let completionBox = CompletionBox(completion) + Task.detached(priority: .userInitiated) { [database] in + do { + let artifact = try await database.backup(request) + completionBox.value(artifact.bytes as NSData, nil) + } catch { + completionBox.value(nil, Self.nsError(error)) + } + } + } + + @objc(cancelWithCompletion:) + public func cancel(completion: @escaping (NSError?) -> Void) { + let completionBox = CompletionBox(completion) + Task.detached(priority: .userInitiated) { [database] in + do { + try await database.cancel() + completionBox.value(nil) + } catch { + completionBox.value(Self.nsError(error)) + } + } + } + + @objc(closeWithCompletion:) + public func close(completion: @escaping (NSError?) -> Void) { + let completionBox = CompletionBox(completion) + Task.detached(priority: .userInitiated) { [database] in + do { + try await database.close() + completionBox.value(nil) + } catch { + completionBox.value(Self.nsError(error)) + } + } + } + + @objc(capabilitiesWithCompletion:) + public func capabilities(completion: @escaping (NSDictionary?, NSError?) -> Void) { + let completionBox = CompletionBox(completion) + Task.detached(priority: .userInitiated) { [database] in + do { + let capabilities = try await database.capabilities() + completionBox.value(Self.capabilitiesDictionary(capabilities), nil) + } catch { + completionBox.value(nil, Self.nsError(error)) + } + } + } + + private struct CompletionBox: @unchecked Sendable { + let value: Value + + init(_ value: Value) { + self.value = value + } + } + + private struct ParsedOpenConfig { + var configuration: OliphauntConfiguration + var engine: OliphauntNativeDirectEngine + } + + private static func parseOpenConfig(_ config: NSDictionary) throws -> ParsedOpenConfig { + let mode = try parseEngineMode(try string(config, "engine") ?? "nativeDirect") + guard mode == .nativeDirect else { + throw adapterError("React Native iOS currently supports nativeDirect, got \(mode.rawValue)") + } + let configuredRoot = try nonBlankString( + config, + "root", + emptyMessage: "database root must not be empty" + ) + let root = try configuredRoot.map { try resolveRootSpecifier($0) } + let username = try startupIdentity(config, "username") + let database = try startupIdentity(config, "database") + let extensions = try stringArray(config, "extensions") + let configuration = OliphauntConfiguration( + mode: mode, + root: root, + durability: try parseDurability(try string(config, "durability") ?? "balanced"), + runtimeFootprint: try parseRuntimeFootprint( + try string(config, "runtimeFootprint") ?? "balancedMobile" + ), + startupGUCs: try startupGUCs(config, "startupGUCs"), + username: username, + database: database, + extensions: extensions + ) + return ParsedOpenConfig( + configuration: configuration, + engine: try nativeDirectEngine( + config: config, + username: username, + database: database, + extensions: extensions + ) + ) + } + + private static func modeSupportDictionary(_ support: OliphauntEngineModeSupport) -> NSDictionary { + let values = NSMutableDictionary() + values["engine"] = support.mode.rawValue + values["available"] = support.available + values["capabilities"] = capabilitiesDictionary(support.capabilities) + if let unavailableReason = support.unavailableReason { + values["unavailableReason"] = unavailableReason + } + return values + } + + private static func capabilitiesDictionary(_ capabilities: OliphauntCapabilities) -> NSDictionary { + let values = NSMutableDictionary() + values["engine"] = capabilities.mode.rawValue + values["processIsolated"] = capabilities.processIsolated + values["multiRoot"] = capabilities.multiRoot + values["reopenable"] = capabilities.reopenable + values["sameRootLogicalReopen"] = capabilities.sameRootLogicalReopen + values["rootSwitchable"] = capabilities.rootSwitchable + values["crashRestartable"] = capabilities.crashRestartable + values["independentSessions"] = capabilities.independentSessions + values["maxClientSessions"] = capabilities.maxClientSessions + values["protocolRaw"] = capabilities.protocolRaw + values["protocolStream"] = capabilities.protocolStream + values["queryCancel"] = capabilities.queryCancel + values["backupRestore"] = capabilities.backupRestore + values["backupFormats"] = capabilities.backupFormats.map(\.rawValue) + values["restoreFormats"] = capabilities.restoreFormats.map(\.rawValue) + values["simpleQuery"] = capabilities.simpleQuery + values["extensions"] = capabilities.extensions + values["rawProtocolTransport"] = "jsi-array-buffer" + if let connectionString = capabilities.connectionString { + values["connectionString"] = connectionString + } + return values + } + + private static func packageSizeReportDictionary( + _ report: OliphauntRuntimeResourceSizeReport + ) -> NSDictionary { + let values = NSMutableDictionary() + values["packageBytes"] = NSNumber(value: report.packageBytes) + values["runtimeBytes"] = NSNumber(value: report.runtimeBytes) + values["templatePgdataBytes"] = NSNumber(value: report.templatePgdataBytes) + values["staticRegistryBytes"] = NSNumber(value: report.staticRegistryBytes) + values["selectedExtensionBytes"] = NSNumber(value: report.selectedExtensionBytes) + values["extensions"] = report.extensions.map(extensionSizeReportDictionary) + if let state = report.mobileStaticRegistryState { + values["mobileStaticRegistryState"] = state + } + values["mobileStaticRegistryRegistered"] = report.mobileStaticRegistryRegistered + values["mobileStaticRegistryPending"] = report.mobileStaticRegistryPending + values["nativeModuleStems"] = report.nativeModuleStems + return values + } + + private static func extensionSizeReportDictionary( + _ report: OliphauntExtensionSizeReport + ) -> NSDictionary { + let values = NSMutableDictionary() + values["name"] = report.name + values["fileCount"] = report.fileCount + values["bytes"] = NSNumber(value: report.bytes) + return values + } + + private static func processMemoryDictionary() -> NSDictionary { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size + ) + let status = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { rebound in + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), rebound, &count) + } + } + if status == KERN_SUCCESS { + let values = NSMutableDictionary() + values["source"] = "ios-task-vm-info" + values["residentBytes"] = NSNumber(value: info.resident_size) + values["physicalFootprintBytes"] = NSNumber(value: info.phys_footprint) + values["virtualBytes"] = NSNumber(value: info.virtual_size) + values["peakResidentBytes"] = NSNumber(value: info.resident_size_peak) + return values + } + + var basic = task_basic_info_64_data_t() + var basicCount = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size + ) + let basicStatus = withUnsafeMutablePointer(to: &basic) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(basicCount)) { rebound in + task_info(mach_task_self_, task_flavor_t(TASK_BASIC_INFO_64), rebound, &basicCount) + } + } + let values = NSMutableDictionary() + values["source"] = "ios-task-basic-info-64" + if basicStatus == KERN_SUCCESS { + values["residentBytes"] = NSNumber(value: basic.resident_size) + values["virtualBytes"] = NSNumber(value: basic.virtual_size) + } + return values + } + + private static func nativeDirectEngine( + config: NSDictionary, + username: String?, + database: String?, + extensions: [String] = [] + ) throws -> OliphauntNativeDirectEngine { + let resources = try runtimeResources(config: config, requestedExtensions: extensions) + return OliphauntNativeDirectEngine( + libraryURL: try libraryURL(config: config, resources: resources), + runtimeDirectory: urlFromPath( + try nonBlankString( + config, + "runtimeDirectory", + emptyMessage: "runtimeDirectory must not be empty" + ) + ?? env("OLIPHAUNT_REACT_NATIVE_IOS_RUNTIME_DIR") + ?? env("OLIPHAUNT_SWIFT_RUNTIME_DIR") + ?? env("OLIPHAUNT_INSTALL_DIR") + ?? env("OLIPHAUNT_RUNTIME_DIR") + ), + runtimeResources: resources, + username: username ?? "postgres", + database: database ?? "postgres" + ) + } + + private static func libraryURL( + config: NSDictionary, + resources: OliphauntRuntimeResources? + ) throws -> URL? { + if let configured = try nonBlankString( + config, + "libraryPath", + emptyMessage: "libraryPath must not be empty" + ) + ?? env("OLIPHAUNT_REACT_NATIVE_IOS_LIBRARY") + ?? env("OLIPHAUNT_SWIFT_LIBRARY") + ?? env("LIBOLIPHAUNT_PATH") + ?? env("OLIPHAUNT_LIBRARY") + { + return URL(fileURLWithPath: configured, isDirectory: false) + } + if let resources, + let bundled = bundledLibraryURL(inResourceRoot: resources.resourceRoot) { + return bundled + } + return bundledLibraryURLFromBundles() + } + + private static func runtimeResources( + config: NSDictionary, + requestedExtensions: [String] = [] + ) throws -> OliphauntRuntimeResources? { + let configured = try nonBlankString( + config, + "resourceRoot", + emptyMessage: "resourceRoot must not be empty" + ) ?? nonBlankString( + config, + "iosResourceRoot", + emptyMessage: "resourceRoot must not be empty" + ) + if let configured { + return OliphauntRuntimeResources( + resourceRoot: URL(fileURLWithPath: configured, isDirectory: true), + cacheRoot: cacheRoot() + ) + } + + if let bundled = try bundledRuntimeResourcesFromKnownBundles( + containing: requestedExtensions + ) { + return bundled + } + + return try OliphauntRuntimeResources.bundled( + containing: requestedExtensions, + cacheRoot: cacheRoot() + ) + } + + private static func bundledRuntimeResourcesFromKnownBundles( + containing requestedExtensions: [String] + ) throws -> OliphauntRuntimeResources? { + for bundle in candidateRuntimeResourceBundles() { + let resources = try OliphauntRuntimeResources(bundle: bundle, cacheRoot: cacheRoot()) + if try resourcesContainPackagedAssets( + resources, + requestedExtensions: requestedExtensions + ) { + return resources + } + } + return nil + } + + private static func candidateRuntimeResourceBundles() -> [Bundle] { + let candidates = [ + Bundle.main, + Bundle(for: OliphauntAdapterDatabase.self) + ] + explicitResourceBundles() + + var seen = Set() + var bundles: [Bundle] = [] + for bundle in candidates { + let key = (bundle.bundleURL.standardizedFileURL.path as NSString).standardizingPath + if seen.insert(key).inserted { + bundles.append(bundle) + } + } + return bundles + } + + private static func explicitResourceBundles() -> [Bundle] { + ["OliphauntReactNativeResources", "OliphauntResources"].compactMap { bundleName in + guard let bundleURL = Bundle.main.url( + forResource: bundleName, + withExtension: "bundle" + ) else { + return nil + } + return Bundle(url: bundleURL) + } + } + + private static func resourcesContainPackagedAssets( + _ resources: OliphauntRuntimeResources, + requestedExtensions: [String] + ) throws -> Bool { + let runtimeManifest = resources.resourceRoot.appendingPathComponent( + "runtime/manifest.properties", + isDirectory: false + ) + let templateManifest = resources.resourceRoot.appendingPathComponent( + "template-pgdata/manifest.properties", + isDirectory: false + ) + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: runtimeManifest.path) || + fileManager.fileExists(atPath: templateManifest.path) + else { + return false + } + guard !requestedExtensions.isEmpty else { + return true + } + guard fileManager.fileExists(atPath: runtimeManifest.path) else { + return false + } + let manifest = try manifestProperties(at: runtimeManifest) + let available = Set( + (manifest["extensions"] ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + ) + return Set(requestedExtensions).isSubset(of: available) + } + + private static func manifestProperties(at url: URL) throws -> [String: String] { + let contents = try String(contentsOf: url, encoding: .utf8) + var values: [String: String] = [:] + for rawLine in contents.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty || line.hasPrefix("#") { + continue + } + guard let separator = line.firstIndex(of: "=") else { + continue + } + let key = line[.. URL? { + for bundle in [Bundle.main, Bundle(for: OliphauntAdapterDatabase.self)] { + if let url = bundledLibraryURL(in: bundle) { + return url + } + } + for bundleName in ["OliphauntReactNativeResources", "OliphauntResources", "OliphauntResources"] { + guard let bundleURL = Bundle.main.url(forResource: bundleName, withExtension: "bundle"), + let bundle = Bundle(url: bundleURL), + let url = bundledLibraryURL(in: bundle) + else { + continue + } + return url + } + return nil + } + + private static func bundledLibraryURL(in bundle: Bundle) -> URL? { + if let root = bundle.url(forResource: "oliphaunt", withExtension: nil), + let url = bundledLibraryURL(inResourceRoot: root) { + return url + } + if let url = bundle.url(forResource: "liboliphaunt", withExtension: "dylib") { + return url + } + if let frameworkRoot = bundle.privateFrameworksURL, + let url = bundledFrameworkExecutableURL(in: frameworkRoot) { + return url + } + if let frameworkRoot = bundle.builtInPlugInsURL, + let url = bundledFrameworkExecutableURL(in: frameworkRoot) { + return url + } + return nil + } + + private static func bundledLibraryURL(inResourceRoot root: URL) -> URL? { + let candidates = [ + root.appendingPathComponent("lib/liboliphaunt.dylib", isDirectory: false), + root.appendingPathComponent("liboliphaunt.dylib", isDirectory: false) + ] + return candidates.first { FileManager.default.fileExists(atPath: $0.path) } + } + + private static func bundledFrameworkExecutableURL(in root: URL) -> URL? { + let candidates = [ + root.appendingPathComponent("liboliphaunt.framework/liboliphaunt", isDirectory: false), + root.appendingPathComponent("Oliphaunt.framework/Oliphaunt", isDirectory: false), + root.appendingPathComponent("LibOliphaunt.framework/LibOliphaunt", isDirectory: false) + ] + return candidates.first { FileManager.default.fileExists(atPath: $0.path) } + } + + private static func cacheRoot() -> URL { + let base = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + let root = base.appendingPathComponent("oliphaunt-react-native-ios", isDirectory: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var mutableRoot = root + try? mutableRoot.setResourceValues(values) + return root + } + + private static func parseEngineMode(_ value: String) throws -> OliphauntEngineMode { + guard let mode = OliphauntEngineMode(rawValue: value) else { + throw adapterError("unknown liboliphaunt engine '\(value)'") + } + return mode + } + + private static func parseDurability(_ value: String) throws -> OliphauntDurability { + guard let durability = OliphauntDurability(rawValue: value) else { + throw adapterError("unknown liboliphaunt durability profile '\(value)'") + } + return durability + } + + private static func parseRuntimeFootprint(_ value: String) throws -> OliphauntRuntimeFootprintProfile { + guard let profile = OliphauntRuntimeFootprintProfile(rawValue: value) else { + throw adapterError("unknown liboliphaunt runtime footprint profile '\(value)'") + } + return profile + } + + private static func parseBackupFormat(_ value: String) throws -> OliphauntBackupFormat { + guard let format = OliphauntBackupFormat(rawValue: value) else { + throw adapterError("unknown liboliphaunt backup format '\(value)'") + } + return format + } + + private static func string(_ dictionary: NSDictionary, _ key: String) throws -> String? { + guard let raw = dictionary[key] else { + return nil + } + guard !(raw is NSNull) else { + return nil + } + guard let value = raw as? String else { + throw adapterError("\(key) must be a string") + } + return value + } + + private static func nonBlankString( + _ dictionary: NSDictionary, + _ key: String, + emptyMessage: String + ) throws -> String? { + return try nonBlankValue(try string(dictionary, key), key, emptyMessage: emptyMessage) + } + + private static func nonBlankValue( + _ value: String?, + _ key: String, + emptyMessage: String + ) throws -> String? { + guard let value else { + return nil + } + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw adapterError(emptyMessage) + } + if value.utf8.contains(0) { + throw adapterError("\(key) must not contain NUL bytes") + } + return value + } + + private static func resolveRootSpecifier(_ value: String) throws -> URL { + if let suffix = value.removingPrefix("app-support://") { + return try sandboxRoot(base: .applicationSupportDirectory, suffix: suffix) + } + if let suffix = value.removingPrefix("documents://") { + return try sandboxRoot(base: .documentDirectory, suffix: suffix) + } + return URL(fileURLWithPath: value, isDirectory: true) + } + + private static func sandboxRoot( + base: FileManager.SearchPathDirectory, + suffix: String + ) throws -> URL { + let components = try validatedSandboxRootComponents(suffix) + guard let baseURL = FileManager.default.urls(for: base, in: .userDomainMask).first else { + throw adapterError("failed to resolve app sandbox directory for database root") + } + return components.reduce(baseURL.appendingPathComponent("Oliphaunt", isDirectory: true)) { + $0.appendingPathComponent($1, isDirectory: true) + } + } + + private static func validatedSandboxRootComponents(_ suffix: String) throws -> [String] { + let trimmed = suffix.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if trimmed.isEmpty { + throw adapterError("database root sandbox specifier must include a relative path") + } + let components = trimmed.split(separator: "/").map(String.init) + if components.contains(where: { $0 == "." || $0 == ".." }) { + throw adapterError("database root sandbox specifier must not contain '.' or '..'") + } + return components + } + + private static func startupIdentity(_ dictionary: NSDictionary, _ key: String) throws -> String? { + guard let value = try string(dictionary, key) else { return nil } + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw adapterError(startupIdentityMessage(key, reason: .empty)) + } + if value.utf8.contains(0) { + throw adapterError(startupIdentityMessage(key, reason: .nul)) + } + return value + } + + private enum StartupIdentityReason { + case empty + case nul + } + + private static func startupIdentityMessage(_ key: String, reason: StartupIdentityReason) -> String { + switch (key, reason) { + case ("username", .empty): + return "username must not be empty" + case ("username", .nul): + return "username must not contain NUL bytes" + case ("database", .empty): + return "database must not be empty" + case ("database", .nul): + return "database must not contain NUL bytes" + case (_, .empty): + return "\(key) must not be empty" + case (_, .nul): + return "\(key) must not contain NUL bytes" + } + } + + private static func stringArray(_ dictionary: NSDictionary, _ key: String) throws -> [String] { + guard let raw = dictionary[key] else { + return [] + } + guard !(raw is NSNull) else { + return [] + } + guard let values = raw as? [Any] else { + throw adapterError(arrayOfStringsMessage(key)) + } + return try values.map { value in + guard let string = value as? String else { + throw adapterError(arrayOfStringsMessage(key)) + } + return string + } + } + + private static func startupGUCs(_ dictionary: NSDictionary, _ key: String) throws -> [OliphauntStartupGUC] { + try stringArray(dictionary, key).map { assignment in + guard let separator = assignment.firstIndex(of: "=") else { + throw adapterError("PostgreSQL startup GUC string must use name=value") + } + let name = String(assignment[.. String { + if key == "extensions" { + return "extensions must be an array of strings" + } + if key == "startupGUCs" { + return "startupGUCs must be an array of strings" + } + return "\(key) must be an array of strings" + } + + private static func env(_ key: String) -> String? { + guard let value = ProcessInfo.processInfo.environment[key], + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + return value + } + + private static func urlFromPath(_ path: String?) -> URL? { + guard let path, !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path) + } + + private static func adapterError(_ message: String) -> NSError { + NSError( + domain: errorDomain, + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + + private static func nsError(_ error: Error) -> NSError { + if let nsError = error as NSError?, nsError.domain == errorDomain { + return nsError + } + return NSError( + domain: errorDomain, + code: 2, + userInfo: [NSLocalizedDescriptionKey: message(error)] + ) + } + + private static func message(_ error: Error) -> String { + switch error { + case OliphauntError.runtimeUnavailable(let mode): + return "native Oliphaunt runtime is unavailable for \(mode.rawValue)" + case OliphauntError.databaseClosed: + return "Oliphaunt database is closed" + case OliphauntError.engine(let message): + return message + default: + return (error as NSError).localizedDescription + } + } +} + +private extension String { + func removingPrefix(_ prefix: String) -> String? { + guard hasPrefix(prefix) else { + return nil + } + return String(dropFirst(prefix.count)) + } +} diff --git a/src/sdks/react-native/ios/OliphauntReactNative.h b/src/sdks/react-native/ios/OliphauntReactNative.h new file mode 100644 index 00000000..0ba391c1 --- /dev/null +++ b/src/sdks/react-native/ios/OliphauntReactNative.h @@ -0,0 +1,14 @@ +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import + +@interface Oliphaunt : NSObject +@end +#else +#import + +@interface Oliphaunt : NSObject +@end +#endif diff --git a/src/sdks/react-native/ios/podspecs/COliphaunt.podspec b/src/sdks/react-native/ios/podspecs/COliphaunt.podspec new file mode 100644 index 00000000..7e82d181 --- /dev/null +++ b/src/sdks/react-native/ios/podspecs/COliphaunt.podspec @@ -0,0 +1,35 @@ +require "json" + +package = JSON.parse(File.read(File.expand_path("../../package.json", __dir__))) +swift_sdk_version = ENV.fetch("OLIPHAUNT_REACT_NATIVE_SWIFT_SDK_VERSION") do + package.fetch("oliphaunt", {}).fetch("swiftSdkVersion", package["version"]) +end +swift_sdk_git = ENV.fetch("OLIPHAUNT_SWIFT_SDK_GIT_URL", "https://github.com/f0rr0/oliphaunt.git") +swift_sdk_tag = ENV.fetch("OLIPHAUNT_SWIFT_SDK_TAG", "oliphaunt-swift-v#{swift_sdk_version}") +swift_sdk_commit = ENV["OLIPHAUNT_SWIFT_SDK_COMMIT"] +swift_sdk_branch = ENV["OLIPHAUNT_SWIFT_SDK_BRANCH"] +swift_sdk_source = { :git => swift_sdk_git } +if swift_sdk_commit && !swift_sdk_commit.empty? + swift_sdk_source[:commit] = swift_sdk_commit +elsif swift_sdk_branch && !swift_sdk_branch.empty? + swift_sdk_source[:branch] = swift_sdk_branch +else + swift_sdk_source[:tag] = swift_sdk_tag +end + +Pod::Spec.new do |s| + s.name = "COliphaunt" + s.version = swift_sdk_version + s.summary = "C bridge for the Oliphaunt Swift SDK." + s.license = package["license"] + s.homepage = "https://oliphaunt.dev" + s.authors = { "Oliphaunt" => "opensource@oliphaunt.dev" } + s.source = swift_sdk_source + s.platforms = { :ios => "17.0" } + s.source_files = "src/sdks/swift/Sources/COliphaunt/**/*.{c,h}" + s.public_header_files = "src/sdks/swift/Sources/COliphaunt/include/COliphaunt.h", "src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h" + s.header_mappings_dir = "src/sdks/swift/Sources/COliphaunt/include" + s.module_map = "src/sdks/swift/Sources/COliphaunt/include/module.modulemap" + s.module_name = "COliphaunt" + s.requires_arc = false +end diff --git a/src/sdks/react-native/ios/podspecs/Oliphaunt.podspec b/src/sdks/react-native/ios/podspecs/Oliphaunt.podspec new file mode 100644 index 00000000..e6d7d61c --- /dev/null +++ b/src/sdks/react-native/ios/podspecs/Oliphaunt.podspec @@ -0,0 +1,33 @@ +require "json" + +package = JSON.parse(File.read(File.expand_path("../../package.json", __dir__))) +swift_sdk_version = ENV.fetch("OLIPHAUNT_REACT_NATIVE_SWIFT_SDK_VERSION") do + package.fetch("oliphaunt", {}).fetch("swiftSdkVersion", package["version"]) +end +swift_sdk_git = ENV.fetch("OLIPHAUNT_SWIFT_SDK_GIT_URL", "https://github.com/f0rr0/oliphaunt.git") +swift_sdk_tag = ENV.fetch("OLIPHAUNT_SWIFT_SDK_TAG", "oliphaunt-swift-v#{swift_sdk_version}") +swift_sdk_commit = ENV["OLIPHAUNT_SWIFT_SDK_COMMIT"] +swift_sdk_branch = ENV["OLIPHAUNT_SWIFT_SDK_BRANCH"] +swift_sdk_source = { :git => swift_sdk_git } +if swift_sdk_commit && !swift_sdk_commit.empty? + swift_sdk_source[:commit] = swift_sdk_commit +elsif swift_sdk_branch && !swift_sdk_branch.empty? + swift_sdk_source[:branch] = swift_sdk_branch +else + swift_sdk_source[:tag] = swift_sdk_tag +end + +Pod::Spec.new do |s| + s.name = "Oliphaunt" + s.version = swift_sdk_version + s.summary = "Swift SDK for embedded Oliphaunt databases." + s.license = package["license"] + s.homepage = "https://oliphaunt.dev" + s.authors = { "Oliphaunt" => "opensource@oliphaunt.dev" } + s.source = swift_sdk_source + s.platforms = { :ios => "17.0" } + s.swift_version = "6.0" + s.source_files = "src/sdks/swift/Sources/Oliphaunt/**/*.swift" + s.requires_arc = true + s.dependency "COliphaunt", swift_sdk_version +end diff --git a/src/sdks/react-native/moon.yml b/src/sdks/react-native/moon.yml new file mode 100644 index 00000000..bde0e724 --- /dev/null +++ b/src/sdks/react-native/moon.yml @@ -0,0 +1,671 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-react-native" +language: "typescript" +layer: "library" +stack: "frontend" +tags: ["sdk", "react-native", "typescript", "ios", "android", "release-product"] +dependsOn: + - "oliphaunt-swift" + - "oliphaunt-kotlin" + - "shared-js-core" + - id: "shared-fixtures" + scope: "build" + +project: + title: "Oliphaunt React Native SDK" + description: "React Native New Architecture SDK over the Swift and Kotlin Oliphaunt SDKs." + owner: "oliphaunt" + release: + component: "oliphaunt-react-native" + packagePath: "src/sdks/react-native" + +owners: + defaultOwner: "@oliphaunt/sdk-react-native" + paths: + "**/*.ts": ["@oliphaunt/sdk-react-native"] + "**/*.tsx": ["@oliphaunt/sdk-react-native"] + "ios/**": ["@oliphaunt/sdk-react-native", "@oliphaunt/sdk-swift"] + "android/**": ["@oliphaunt/sdk-react-native", "@oliphaunt/sdk-android"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash src/sdks/react-native/tools/check-sdk.sh check-static" + deps: + - "shared-contracts:check" + - "shared-js-core:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/tools/release/release.py" + options: + cache: true + runFromWorkspaceRoot: true + build-ios-bridge: + tags: ["build", "ios", "react-native-new-architecture"] + command: "bash src/sdks/react-native/tools/check-sdk.sh build-ios-bridge" + deps: + - "oliphaunt-swift:check" + inputs: + - "/Package.swift" + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/tools/release/release.py" + options: + cache: true + runFromWorkspaceRoot: true + build-android-bridge: + tags: ["build", "android", "react-native-new-architecture"] + command: "bash src/sdks/react-native/tools/check-sdk.sh build-android-bridge" + deps: + - "oliphaunt-kotlin:check" + - "shared-contracts:check" + - "shared-js-core:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/tools/release/release.py" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "unit"] + command: "bash src/sdks/react-native/tools/check-sdk.sh test-unit" + deps: + - "shared-fixtures:check" + - "shared-js-core:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/tools/test/**/*" + options: + cache: true + runFromWorkspaceRoot: true + smoke: + tags: ["runtime", "smoke"] + command: "pnpm --dir src/sdks/react-native/examples/expo run smoke" + deps: + - "oliphaunt-swift:smoke" + - "oliphaunt-kotlin:smoke" + inputs: + - "/Package.swift" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: local + runFromWorkspaceRoot: true + smoke-android: + tags: ["runtime", "smoke", "android"] + command: "pnpm --dir src/sdks/react-native/examples/expo run smoke:android" + deps: + - "oliphaunt-kotlin:smoke" + inputs: + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: local + runFromWorkspaceRoot: true + smoke-ios: + tags: ["runtime", "smoke", "ios"] + command: "pnpm --dir src/sdks/react-native/examples/expo run smoke:ios" + deps: + - "oliphaunt-swift:smoke" + inputs: + - "/Package.swift" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: local + runFromWorkspaceRoot: true + smoke-mobile: + tags: ["runtime", "smoke", "mobile"] + command: "pnpm --dir src/sdks/react-native/examples/expo run smoke" + deps: + - "oliphaunt-swift:smoke" + - "oliphaunt-kotlin:smoke" + inputs: + - "/Package.swift" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: local + runFromWorkspaceRoot: true + e2e: + tags: ["mobile", "e2e"] + command: "pnpm --dir src/sdks/react-native/examples/expo run mobile-e2e" + deps: + - "oliphaunt-react-native:mobile-build-android" + - "oliphaunt-react-native:mobile-e2e-android" + - "oliphaunt-react-native:mobile-build-ios" + - "oliphaunt-react-native:mobile-e2e-ios" + inputs: + - "/Package.swift" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/src/sources/toolchains/android-emulator-runner.toml" + - "/src/sources/toolchains/maestro.toml" + - "/tools/dev/setup-maestro.sh" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + mobile-build-android: + tags: ["mobile", "build", "android", "ci-mobile-build-android"] + command: "pnpm --dir src/sdks/react-native/examples/expo run mobile-build:android" + deps: + - "oliphaunt-kotlin:package-artifacts" + - "oliphaunt-react-native:package-artifacts" + inputs: + - "/src/extensions/**/*" + - "/target/extension-artifacts/**/*" + - "/target/sdk-artifacts/oliphaunt-kotlin/**/*" + - "/target/sdk-artifacts/oliphaunt-react-native/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/tools/expo-android-runner.sh" + outputs: + - "/target/mobile-build/react-native/android/**/*" + options: + cache: false + runFromWorkspaceRoot: true + mobile-e2e-android: + tags: ["mobile", "e2e", "android"] + command: "pnpm --dir src/sdks/react-native/examples/expo run mobile-e2e:android" + deps: + - target: "oliphaunt-react-native:mobile-build-android" + cacheStrategy: "outputs" + inputs: + - "/src/sdks/react-native/examples/expo/src/**/*" + - "/src/sdks/react-native/examples/expo/maestro/**/*" + - "/src/sdks/react-native/examples/expo/package.json" + - "/src/sdks/react-native/tools/mobile-e2e.sh" + - "/src/sdks/react-native/tools/expo-runner-common.sh" + - "/src/sdks/react-native/tools/expo-runner-metro.sh" + - "/src/sdks/react-native/tools/expo-runner-reporting.sh" + - "/src/sdks/react-native/tools/expo-runner-runtime-resources.sh" + - "/src/sdks/react-native/tools/expo-runner-workspace.sh" + - "/src/sdks/react-native/tools/expo-runner-android-device.sh" + - "/src/sdks/react-native/tools/mobile-extension-runtime.sh" + - "/src/sources/toolchains/android-emulator-runner.toml" + - "/src/sources/toolchains/maestro.toml" + - "/tools/dev/setup-maestro.sh" + - "/target/mobile-build/react-native/android/**/*" + - "/src/sdks/react-native/tools/expo-android-runner.sh" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: skip + mobile-drill-android: + tags: ["mobile", "drill", "android"] + command: "pnpm --dir src/sdks/react-native/examples/expo run mobile-drill:android" + deps: + - "oliphaunt-kotlin:smoke" + inputs: + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/tools/expo-android-runner.sh" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + mobile-build-ios: + tags: ["mobile", "build", "ios", "ci-mobile-build-ios"] + command: "pnpm --dir src/sdks/react-native/examples/expo run mobile-build:ios" + deps: + - "oliphaunt-swift:package-artifacts" + - "oliphaunt-react-native:package-artifacts" + inputs: + - "/Package.swift" + - "/src/extensions/**/*" + - "/target/extension-artifacts/**/*" + - "/target/sdk-artifacts/oliphaunt-react-native/**/*" + - "/target/sdk-artifacts/oliphaunt-swift/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/tools/expo-ios-runner.sh" + outputs: + - "/target/mobile-build/react-native/ios/**/*" + options: + cache: false + runFromWorkspaceRoot: true + mobile-e2e-ios: + tags: ["mobile", "e2e", "ios"] + command: "pnpm --dir src/sdks/react-native/examples/expo run mobile-e2e:ios" + deps: + - target: "oliphaunt-react-native:mobile-build-ios" + cacheStrategy: "outputs" + inputs: + - "/src/sdks/react-native/examples/expo/src/**/*" + - "/src/sdks/react-native/examples/expo/maestro/**/*" + - "/src/sdks/react-native/examples/expo/package.json" + - "/src/sdks/react-native/tools/mobile-e2e.sh" + - "/src/sdks/react-native/tools/expo-runner-common.sh" + - "/src/sdks/react-native/tools/expo-runner-metro.sh" + - "/src/sdks/react-native/tools/expo-runner-reporting.sh" + - "/src/sdks/react-native/tools/expo-runner-runtime-resources.sh" + - "/src/sdks/react-native/tools/expo-runner-workspace.sh" + - "/src/sdks/react-native/tools/expo-runner-ios-device.sh" + - "/src/sdks/react-native/tools/expo-runner-ios-installed-app.sh" + - "/src/sdks/react-native/tools/mobile-extension-runtime.sh" + - "/src/sources/toolchains/maestro.toml" + - "/tools/dev/setup-maestro.sh" + - "/target/mobile-build/react-native/ios/**/*" + - "/src/sdks/react-native/tools/expo-ios-runner.sh" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: skip + mobile-drill-ios: + tags: ["mobile", "drill", "ios"] + command: "pnpm --dir src/sdks/react-native/examples/expo run mobile-drill:ios" + deps: + - "oliphaunt-swift:smoke" + inputs: + - "/Package.swift" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/tools/expo-ios-runner.sh" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + package: + tags: ["package"] + command: "bash src/sdks/react-native/tools/check-sdk.sh package-shape" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/release.py" + options: + cache: true + runFromWorkspaceRoot: true + package-artifacts: + tags: ["release", "artifact-package", "ci-react-native-sdk-package"] + command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-react-native" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/release/release.py" + outputs: + - "/target/sdk-artifacts/oliphaunt-react-native/**/*" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "bash src/sdks/react-native/tools/check-sdk.sh release-check" + deps: + - "oliphaunt-swift:release-check" + - "oliphaunt-kotlin:release-check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/tools/release/**/*" + - "/tools/test/**/*" + options: + cache: local + runFromWorkspaceRoot: true + bench: + tags: ["bench", "plan"] + command: "bash tools/perf/matrix/run_mobile_footprint_matrix.sh --plan-only --quick" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false + regression: + tags: ["regression", "runtime"] + command: "bash src/sdks/react-native/tools/check-sdk.sh regression" + deps: + - "oliphaunt-swift:check" + - "oliphaunt-kotlin:check" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/runtimes/liboliphaunt/native/include/oliphaunt.h" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/tools/release/release.py" + - "/tools/test/**/*" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + coverage: + tags: ["coverage", "quality"] + command: "tools/coverage/run-product oliphaunt-react-native" + inputs: + - "/coverage/baseline.toml" + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/pnpm-workspace.yaml" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/tools/coverage/**/*" + - "/tools/test/**/*" + outputs: + - "/target/coverage/oliphaunt-react-native/**/*" + options: + cache: true + runFromWorkspaceRoot: true + bench-run: + tags: ["bench", "measured"] + command: "bash tools/perf/matrix/run_mobile_footprint_matrix.sh" + inputs: + - "/src/shared/fixtures/**/*" + - "/src/shared/js-core/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/**/.build" + - "!/src/sdks/react-native/**/.build/**" + - "!/src/sdks/react-native/**/.gradle/**" + - "!/src/sdks/react-native/**/.cxx/**" + - "!/src/sdks/react-native/**/build/**" + - "!/src/sdks/react-native/**/lib/**" + - "!/src/sdks/react-native/ios/vendor/**" + - "/src/sdks/swift/**/*" + - "!/src/sdks/swift/.build" + - "!/src/sdks/swift/.build/**" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false diff --git a/src/sdks/react-native/package.json b/src/sdks/react-native/package.json new file mode 100644 index 00000000..3a78f48b --- /dev/null +++ b/src/sdks/react-native/package.json @@ -0,0 +1,94 @@ +{ + "name": "@oliphaunt/react-native", + "version": "0.1.0", + "description": "React Native New Architecture SDK for Oliphaunt.", + "license": "MIT AND Apache-2.0 AND PostgreSQL", + "repository": { + "type": "git", + "url": "git+https://github.com/f0rr0/oliphaunt.git", + "directory": "src/sdks/react-native" + }, + "bugs": { + "url": "https://github.com/f0rr0/oliphaunt/issues" + }, + "homepage": "https://oliphaunt.dev", + "publishConfig": { + "access": "public", + "provenance": true + }, + "main": "lib/commonjs/index.js", + "module": "lib/module/index.js", + "types": "lib/typescript/index.d.ts", + "react-native": "lib/module/index.js", + "oliphaunt": { + "swiftSdkVersion": "0.6.0", + "kotlinSdkVersion": "0.1.0" + }, + "files": [ + "android", + "app.plugin.js", + "ios", + "lib", + "src", + "tools/codegen-check.cjs", + "OliphauntReactNative.podspec", + "react-native.config.js", + "!android/.gradle", + "!android/.cxx", + "!android/build", + "!android/src/main/assets", + "!android/src/main/assets/**", + "!android/src/main/jniLibs", + "!android/src/main/jniLibs/**", + "!android/src/test", + "!ios/resources", + "!ios/resources/**", + "!src/__tests__" + ], + "scripts": { + "build": "tsc -p tsconfig.build.types.json && tsc -p tsconfig.build.module.json && tsc -p tsconfig.build.commonjs.json", + "codegen:check": "node ./tools/codegen-check.cjs /tmp/oliphaunt-react-native-schema.json src/specs/NativeOliphaunt.ts", + "docs:api": "typedoc --options typedoc.json", + "test": "node ../../../tools/test/run-js-tests.mjs src/__tests__", + "typecheck": "tsc --noEmit", + "clean": "rm -rf lib" + }, + "peerDependencies": { + "expo": ">=56.0.0", + "react": ">=19.0.0", + "react-native": ">=0.85.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + }, + "devDependencies": { + "@react-native/codegen": "^0.85.3", + "@react-native/typescript-config": "^0.85.0", + "@types/node": "^24.10.1", + "@vitest/coverage-v8": "catalog:", + "react": "^19.2.0", + "react-native": "^0.85.0", + "tsx": "catalog:", + "typedoc": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "codegenConfig": { + "name": "OliphauntReactNativeSpec", + "type": "modules", + "jsSrcsDir": "src/specs", + "android": { + "javaPackageName": "dev.oliphaunt.reactnative" + }, + "ios": { + "modules": { + "Oliphaunt": { + "className": "Oliphaunt", + "unstableRequiresMainQueueSetup": false + } + } + } + } +} diff --git a/src/sdks/react-native/react-native.config.js b/src/sdks/react-native/react-native.config.js new file mode 100644 index 00000000..8cac105a --- /dev/null +++ b/src/sdks/react-native/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + }, + ios: {}, + }, + }, +}; diff --git a/src/sdks/react-native/release.toml b/src/sdks/react-native/release.toml new file mode 100644 index 00000000..37113999 --- /dev/null +++ b/src/sdks/react-native/release.toml @@ -0,0 +1,19 @@ +id = "oliphaunt-react-native" +owner = "@oliphaunt/sdk-react-native" +kind = "sdk" +publish_targets = ["npm"] +registry_packages = ["npm:@oliphaunt/react-native"] +release_artifacts = [ + "npm-package", + "react-native-podspec", + "expo-dev-client-example", +] +derived_version_files = ["src/sdks/react-native/OliphauntReactNative.podspec"] + +[compatibility_versions.oliphaunt-react-native-swift-sdk] +path = "src/sdks/react-native/package.json" +parser = "json:oliphaunt.swiftSdkVersion" + +[compatibility_versions.oliphaunt-react-native-kotlin-sdk] +path = "src/sdks/react-native/package.json" +parser = "json:oliphaunt.kotlinSdkVersion" diff --git a/src/sdks/react-native/src/__tests__/client.test.ts b/src/sdks/react-native/src/__tests__/client.test.ts new file mode 100644 index 00000000..5c7c4634 --- /dev/null +++ b/src/sdks/react-native/src/__tests__/client.test.ts @@ -0,0 +1,1768 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { + createOliphauntClient, + supportsBackupFormat, + supportsRestoreFormat, + type OliphauntTransaction, +} from '../client'; +import { simpleQuery } from '../protocol'; +import { extendedQuery, parseQueryResponse, PostgresError } from '../query'; +import { runOliphauntReactNativeBenchmark } from '../benchmark'; +import { runOliphauntReactNativeSmoke } from '../smoke'; +import type { NativeCapabilities, Spec } from '../specs/NativeOliphaunt'; + +async function main(): Promise { + await testPackageEntrypointWiresDefaultTurboModuleClient(); + await testSupportedModesExposePlatformRuntimeContract(); + await testPackageSizeReportDelegatesToNativeSdk(); + await testPackageSizeReportRejectsBlankResourceRootBeforeNativeCall(); + await testProcessMemoryReportDelegatesToNativeSdk(); + testJsiBinaryTransportFixturesAreModeled(); + await testOpenExecCapabilitiesAndClose(); + await testJsiArrayBufferTransportIsRequiredAndUsedForBinaryCalls(); + await testJsiStreamTransportAdvertisesAndUsesNativeChunks(); + await testJsiStreamTransportRejectsNonBinaryChunks(); + await testJsiStreamTransportPropagatesChunkCallbackErrors(); + await testOpenRequiresJsiTransportBeforeNativeCall(); + await testJsiArrayBufferTransportRejectsNonBinaryResponses(); + await testReusableReactNativeSmokeRunnerExercisesInstalledTransportShape(); + await testReusableReactNativeBenchmarkRunnerExercisesInstalledTransportShape(); + await testRawProtocolStreamFallsBackToOwnedResponse(); + await testQueryParsesSimpleQueryResults(); + await testQueryParametersUseExtendedProtocol(); + testSimpleQueryRejectsNulSQLBeforeBuildingProtocol(); + testExtendedQueryRejectsInvalidFrontendInputsBeforeBuildingProtocol(); + await testQuerySurfacesPostgresErrors(); + await testExecuteSurfacesPostgresErrors(); + await testQueryNormalizesCancellationPostgresErrors(); + testQueryParserRejectsInvalidUTF8FieldNames(); + testQueryTextAccessorsRejectInvalidUTF8Values(); + testQueryParserAcceptsExtendedQueryControlMessages(); + testQueryParserAcceptsAsyncBackendControlMessages(); + testQueryParserRejectsMalformedEmptyControlMessages(); + testQueryParserRejectsMalformedAsyncBackendControlMessages(); + testQueryParserRejectsUnexpectedBackendMessageTags(); + testQueryParserAcceptsReadyForQueryTransactionStates(); + testQueryParserRejectsMalformedReadyForQueryStatus(); + await testConnectionStringIsOnlyPresentForServerCapabilities(); + await testTransactionCommitsAndRejectsUnpinnedInterleaving(); + await testTransactionRollsBackWhenBodyThrows(); + await testCloseDuringTransactionClosesSessionAndRejectsPinnedWork(); + await testBackupRejectsUnsupportedFormatsBeforeNativeCall(); + await testOpenForwardsNativeRuntimeOverrides(); + await testOpenRejectsBlankNativeRuntimeOverridesBeforeNativeCall(); + await testOpenRejectsEmptyRootBeforeNativeCall(); + await testOpenRejectsInvalidConnectionIdentityBeforeNativeCall(); + await testOpenValidatesExtensionIdsBeforeNativeCall(); + await testOpenValidatesStartupGUCsBeforeNativeCall(); + await testRestoreUsesPhysicalArchiveShape(); + await testRestoreForwardsNativeLibraryOverride(); + await testRestoreRejectsBlankLibraryOverrideBeforeNativeCall(); + await testRestoreRejectsUnsupportedFormatsBeforeNativeCall(); + await testRestoreRejectsBlankRootBeforeNativeCall(); + await testMutuallyExclusiveRoots(); + await testCancelUsesNativeOutOfBandPath(); + await testCloseDoesNotIssueSpuriousCancel(); + await testPrepareForBackgroundCheckpointsWhenIdleAndResumeProbesSession(); + await testPrepareForBackgroundCancelsActiveWorkAndSkipsCheckpoint(); + await testPrepareForBackgroundSkipsCheckpointDuringTransaction(); + await testExecutionAfterCloseFailsBeforeNativeCall(); +} + +async function testPackageEntrypointWiresDefaultTurboModuleClient(): Promise { + vi.resetModules(); + vi.doMock('react-native', () => ({ + TurboModuleRegistry: { + getEnforcing(name: string) { + assert.equal(name, 'Oliphaunt'); + return new MockNative(); + }, + }, + })); + try { + const entrypoint = await import('../index'); + assert.equal(typeof entrypoint.createOliphauntClient, 'function'); + assert.equal(typeof entrypoint.Oliphaunt.supportedModes, 'function'); + const support = await entrypoint.Oliphaunt.supportedModes(); + assert.deepEqual( + support.map((entry) => entry.engine), + ['nativeDirect', 'nativeBroker', 'nativeServer'], + ); + } finally { + vi.doUnmock('react-native'); + vi.resetModules(); + } +} + +async function testSupportedModesExposePlatformRuntimeContract(): Promise { + const client = createOliphauntClient(new MockNative()); + const support = await client.supportedModes(); + + assert.deepEqual( + support.map((entry) => entry.engine), + ['nativeDirect', 'nativeBroker', 'nativeServer'], + ); + assert.equal(support[0]?.available, true); + assert.equal(support[0]?.capabilities.maxClientSessions, 1); + assert.deepEqual(support[0]?.capabilities.backupFormats, ['physicalArchive']); + assert.equal(support[0]?.capabilities.independentSessions, false); + assert.equal(support[0]?.capabilities.multiRoot, false); + assert.equal(support[0]?.capabilities.reopenable, true); + assert.equal(support[0]?.capabilities.sameRootLogicalReopen, true); + assert.equal(support[0]?.capabilities.rootSwitchable, false); + assert.equal(support[0]?.capabilities.crashRestartable, false); + assert.equal(support[1]?.available, false); + assert.equal(support[1]?.capabilities.processIsolated, true); + assert.equal(support[1]?.capabilities.multiRoot, true); + assert.equal(support[1]?.capabilities.reopenable, true); + assert.equal(support[1]?.capabilities.sameRootLogicalReopen, false); + assert.equal(support[1]?.capabilities.rootSwitchable, true); + assert.equal(support[1]?.capabilities.crashRestartable, true); + assert.match(support[1]?.unavailableReason ?? '', /broker/); + assert.equal(support[2]?.available, false); + assert.equal(support[2]?.capabilities.independentSessions, true); + assert.equal(support[2]?.capabilities.multiRoot, false); + assert.equal(support[2]?.capabilities.reopenable, true); + assert.equal(support[2]?.capabilities.sameRootLogicalReopen, false); + assert.equal(support[2]?.capabilities.rootSwitchable, true); + assert.equal(support[2]?.capabilities.crashRestartable, false); + assert.deepEqual(support[2]?.capabilities.backupFormats, ['sql', 'physicalArchive']); + assert.match(support[2]?.unavailableReason ?? '', /server/); +} + +async function testPackageSizeReportDelegatesToNativeSdk(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + const report = await client.packageSizeReport({ + resourceRoot: '/tmp/oliphaunt-rn-resources', + }); + + assert.deepEqual(native.packageSizeReportCalls, [ + { resourceRoot: '/tmp/oliphaunt-rn-resources' }, + ]); + assert.deepEqual(report, { + packageBytes: 185, + runtimeBytes: 100, + templatePgdataBytes: 40, + staticRegistryBytes: 45, + selectedExtensionBytes: 30, + mobileStaticRegistryState: null, + mobileStaticRegistryRegistered: [], + mobileStaticRegistryPending: [], + nativeModuleStems: [], + extensions: [ + { + name: 'vector', + fileCount: 3, + bytes: 30, + }, + ], + }); +} + +async function testPackageSizeReportRejectsBlankResourceRootBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.packageSizeReport({ resourceRoot: ' \n' }); + }, /resourceRoot must not be empty/); + await assert.rejects(async () => { + await client.packageSizeReport({ resourceRoot: '/tmp/oliphaunt\0resources' }); + }, /resourceRoot must not contain NUL bytes/); + assert.deepEqual(native.packageSizeReportCalls, []); +} + +async function testProcessMemoryReportDelegatesToNativeSdk(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + const report = await client.processMemory(); + + assert.deepEqual(report, { + source: 'test-process-memory', + residentBytes: 4096, + physicalFootprintBytes: 8192, + totalPssKb: 12, + }); +} + +function testJsiBinaryTransportFixturesAreModeled(): void { + const fixturePath = sharedFixturePath('react-native-jsi/binary-transport.json'); + assert.ok(fixturePath, 'shared React Native JSI fixture corpus must exist'); + + const corpus = JSON.parse(readFileSync(fixturePath, 'utf8')) as { + schemaVersion: number; + kind: string; + cases: Array<{ name: string; valid?: boolean; requiresNativeChunkCallback?: boolean }>; + }; + + assert.equal(corpus.schemaVersion, 1); + assert.equal(corpus.kind, 'oliphaunt-react-native-jsi-binary-transport'); + const cases = new Map(corpus.cases.map((fixture) => [fixture.name, fixture])); + assert.equal(cases.get('array-buffer-request')?.valid, true); + assert.equal(cases.get('uint8array-offset')?.valid, true); + assert.equal(cases.get('stream-chunks')?.requiresNativeChunkCallback, true); + assert.equal(cases.get('base64-rejected')?.valid, false); + assert.equal(cases.get('unsafe-handle-rejected')?.valid, false); +} + +function sharedFixturePath(relativePath: string): string | undefined { + const candidates = [ + path.resolve(process.cwd(), '..', '..', '..', '..', 'fixtures', relativePath), + path.resolve(process.cwd(), '..', '..', 'shared', 'fixtures', relativePath), + path.resolve(process.cwd(), '..', '..', '..', 'shared', 'fixtures', relativePath), + path.resolve(process.cwd(), '..', '..', '..', 'src', 'shared', 'fixtures', relativePath), + path.resolve(process.cwd(), '..', '..', 'src', 'shared', 'fixtures', relativePath), + path.resolve(process.cwd(), 'src', 'shared', 'fixtures', relativePath), + ]; + return candidates.find(existsSync); +} + +async function testOpenExecCapabilitiesAndClose(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + const db = await client.open({ + engine: 'nativeServer', + temporary: true, + durability: 'balanced', + extensions: ['hstore'], + }); + + assert.equal(db.handle, 1); + assert.deepEqual(native.openCalls[0], { + engine: 'nativeServer', + root: undefined, + temporary: true, + durability: 'balanced', + runtimeFootprint: 'balancedMobile', + startupGUCs: undefined, + username: undefined, + database: undefined, + extensions: ['hstore'], + libraryPath: undefined, + runtimeDirectory: undefined, + resourceRoot: undefined, + }); + const capabilities = await db.capabilities(); + assert.equal(capabilities.engine, 'nativeServer'); + assert.equal(capabilities.rawProtocolTransport, 'jsi-array-buffer'); + assert.equal(capabilities.multiRoot, false); + assert.equal(capabilities.queryCancel, true); + assert.equal(capabilities.backupRestore, true); + assert.deepEqual(capabilities.backupFormats, ['sql', 'physicalArchive']); + assert.deepEqual(capabilities.restoreFormats, ['physicalArchive']); + assert.equal(supportsBackupFormat(capabilities, 'sql'), true); + assert.equal(supportsBackupFormat(capabilities, 'physicalArchive'), true); + assert.equal(supportsBackupFormat(capabilities, 'oliphauntArchive'), false); + assert.equal(supportsRestoreFormat(capabilities, 'physicalArchive'), true); + assert.equal(supportsRestoreFormat(capabilities, 'sql'), false); + assert.equal(await db.supportsBackupFormat('sql'), true); + assert.equal(await db.supportsRestoreFormat('sql'), false); + assert.equal(capabilities.simpleQuery, true); + assert.equal(capabilities.connectionString, 'postgres://postgres@127.0.0.1:55432/template1'); + + const response = await db.execProtocolRaw(Uint8Array.from([0x51])); + assert.deepEqual(Array.from(response), [1, 0x51]); + + const query = await db.execute('SELECT 1'); + assert.ok(query.includes(0x54), 'missing RowDescription'); + assert.ok(query.includes(0x44), 'missing DataRow'); + assert.ok(query.includes(0x5a), 'missing ReadyForQuery'); + + const backup = await db.backup('sql'); + assert.equal(backup.format, 'sql'); + assert.equal(new TextDecoder().decode(backup.bytes), 'sql-backup'); + + await db.close(); + await db.close(); + assert.deepEqual(native.closedHandles, [1]); +} + +async function testJsiArrayBufferTransportIsRequiredAndUsedForBinaryCalls(): Promise { + const native = new MockNative(); + const globalWithJsi = globalThis as GlobalWithJsiTransport; + const previous = globalWithJsi.__oliphauntReactNativeJsi; + const jsiRequests: number[][] = []; + globalWithJsi.__oliphauntReactNativeJsi = { + version: 1, + async execProtocolRaw(handle: number, request: Uint8Array): Promise { + jsiRequests.push(Array.from(request)); + return Uint8Array.from([handle, ...request]); + }, + async backup(handle: number, format: string): Promise { + return new TextEncoder().encode(`${handle}:${format}`); + }, + async restore(): Promise { + return '/tmp/oliphaunt-jsi-restored'; + }, + }; + try { + const client = createOliphauntClient(native); + const support = await client.supportedModes(); + assert.equal(support[0]?.capabilities.rawProtocolTransport, 'jsi-array-buffer'); + assert.equal(support[0]?.capabilities.protocolStream, false); + + const db = await client.open(); + assert.equal((await db.capabilities()).rawProtocolTransport, 'jsi-array-buffer'); + assert.equal((await db.capabilities()).protocolStream, false); + const response = await db.execProtocolRaw(Uint8Array.from([0x51, 0x00])); + + assert.deepEqual(Array.from(response), [1, 0x51, 0x00]); + assert.deepEqual(jsiRequests, [[0x51, 0x00]]); + await db.close(); + } finally { + globalWithJsi.__oliphauntReactNativeJsi = previous; + } +} + +async function testJsiStreamTransportAdvertisesAndUsesNativeChunks(): Promise { + const native = new MockNative(); + const globalWithJsi = globalThis as GlobalWithJsiTransport; + const previous = globalWithJsi.__oliphauntReactNativeJsi; + const streamRequests: number[][] = []; + globalWithJsi.__oliphauntReactNativeJsi = { + version: 1, + execProtocolRaw: (handle, request) => native.execProtocolRawJsi(handle, request), + execProtocolStream: async (_handle, request, onChunk) => { + streamRequests.push(Array.from(request)); + onChunk(Uint8Array.from([0xaa])); + onChunk(Uint8Array.from([0xbb])); + }, + backup: (handle, format) => native.backupJsi(handle, format), + restore: (root, format, artifact, replaceExisting, libraryPath) => + native.restoreJsi(root, format, artifact, replaceExisting, libraryPath), + }; + try { + const client = createOliphauntClient(native); + const support = await client.supportedModes(); + assert.equal(support[0]?.capabilities.protocolStream, true); + + const db = await client.open(); + assert.equal((await db.capabilities()).protocolStream, true); + const chunks: number[][] = []; + await db.execProtocolStream(Uint8Array.from([0x51, 0x10]), (chunk) => { + chunks.push(Array.from(chunk)); + }); + + assert.deepEqual(chunks, [[0xaa], [0xbb]]); + assert.deepEqual(streamRequests, [[0x51, 0x10]]); + assert.equal(native.execCalls, 0); + await db.close(); + } finally { + globalWithJsi.__oliphauntReactNativeJsi = previous; + } +} + +async function testJsiStreamTransportRejectsNonBinaryChunks(): Promise { + const native = new MockNative(); + const globalWithJsi = globalThis as GlobalWithJsiTransport; + const previous = globalWithJsi.__oliphauntReactNativeJsi; + globalWithJsi.__oliphauntReactNativeJsi = { + version: 1, + execProtocolRaw: (handle, request) => native.execProtocolRawJsi(handle, request), + execProtocolStream: async (_handle, _request, onChunk) => { + onChunk({} as ArrayBuffer); + }, + backup: (handle, format) => native.backupJsi(handle, format), + restore: (root, format, artifact, replaceExisting, libraryPath) => + native.restoreJsi(root, format, artifact, replaceExisting, libraryPath), + }; + try { + const db = await createOliphauntClient(native).open(); + await assert.rejects( + () => db.execProtocolStream(Uint8Array.from([0x51]), () => {}), + /JSI transport returned a non-binary response/, + ); + await db.close(); + } finally { + globalWithJsi.__oliphauntReactNativeJsi = previous; + } +} + +async function testJsiStreamTransportPropagatesChunkCallbackErrors(): Promise { + const native = new MockNative(); + const globalWithJsi = globalThis as GlobalWithJsiTransport; + const previous = globalWithJsi.__oliphauntReactNativeJsi; + globalWithJsi.__oliphauntReactNativeJsi = { + version: 1, + execProtocolRaw: (handle, request) => native.execProtocolRawJsi(handle, request), + execProtocolStream: async (_handle, _request, onChunk) => { + onChunk(Uint8Array.from([0xaa])); + onChunk(Uint8Array.from([0xbb])); + }, + backup: (handle, format) => native.backupJsi(handle, format), + restore: (root, format, artifact, replaceExisting, libraryPath) => + native.restoreJsi(root, format, artifact, replaceExisting, libraryPath), + }; + try { + const db = await createOliphauntClient(native).open(); + const chunks: number[][] = []; + await assert.rejects( + () => + db.execProtocolStream(Uint8Array.from([0x51]), (chunk) => { + chunks.push(Array.from(chunk)); + throw new Error('chunk callback failed'); + }), + /chunk callback failed/, + ); + assert.deepEqual(chunks, [[0xaa]]); + await db.close(); + } finally { + globalWithJsi.__oliphauntReactNativeJsi = previous; + } +} + +async function testOpenRequiresJsiTransportBeforeNativeCall(): Promise { + const native = new MockNative({ installJsi: false }); + const globalWithJsi = globalThis as GlobalWithJsiTransport; + const previous = globalWithJsi.__oliphauntReactNativeJsi; + delete globalWithJsi.__oliphauntReactNativeJsi; + try { + const client = createOliphauntClient(native); + const support = await client.supportedModes(); + assert.equal(support[0]?.available, false); + assert.match( + support[0]?.unavailableReason ?? '', + /New Architecture JSI ArrayBuffer transport is not installed/, + ); + await assert.rejects( + () => client.open(), + /requires React Native New Architecture JSI ArrayBuffer bindings/, + ); + assert.deepEqual(native.openCalls, []); + } finally { + globalWithJsi.__oliphauntReactNativeJsi = previous; + } +} + +async function testJsiArrayBufferTransportRejectsNonBinaryResponses(): Promise { + const native = new MockNative(); + const globalWithJsi = globalThis as GlobalWithJsiTransport; + const previous = globalWithJsi.__oliphauntReactNativeJsi; + globalWithJsi.__oliphauntReactNativeJsi = { + version: 1, + async execProtocolRaw(): Promise { + return 'not-bytes' as unknown as ArrayBuffer; + }, + async backup(): Promise { + return Uint8Array.from([]); + }, + async restore(): Promise { + return '/tmp/oliphaunt-jsi-restored'; + }, + }; + try { + const db = await createOliphauntClient(native).open(); + await assert.rejects( + () => db.execProtocolRaw(Uint8Array.from([0x51])), + /JSI transport returned a non-binary response/, + ); + await db.close(); + } finally { + globalWithJsi.__oliphauntReactNativeJsi = previous; + } +} + +async function testReusableReactNativeSmokeRunnerExercisesInstalledTransportShape(): Promise { + const native = new MockNative(); + let afterSmokeValue = ''; + // liboliphaunt-doc-example:react-native-smoke-runner + const report = await runOliphauntReactNativeSmoke(createOliphauntClient(native), { + open: { + temporary: true, + extensions: ['vector'], + resourceRoot: '/tmp/oliphaunt-rn-smoke-resources', + }, + expectedEngine: 'nativeServer', + requirePackageSizeReport: true, + afterSmoke: async (database) => { + assert.deepEqual(native.closedHandles, []); + const result = await database.query('SELECT 1::text AS value'); + afterSmokeValue = result.getText(0, 'value') ?? ''; + }, + }); + + assert.equal(report.engine, 'nativeServer'); + assert.equal(report.rawProtocolTransport, 'jsi-array-buffer'); + assert.equal(report.selectOne, '1'); + assert.equal(report.parameterRoundTrip, 'hello'); + assert.equal(afterSmokeValue, '1'); + assert.equal(typeof report.jsTimerTicks, 'number'); + assert.ok(report.elapsedMs >= 0); + assert.equal(report.packageSizeReport?.extensions[0]?.name, 'vector'); + assert.deepEqual(native.openCalls[0], { + engine: 'nativeDirect', + root: undefined, + temporary: true, + durability: 'balanced', + runtimeFootprint: 'balancedMobile', + startupGUCs: undefined, + username: 'postgres', + database: 'postgres', + extensions: ['vector'], + libraryPath: undefined, + runtimeDirectory: undefined, + resourceRoot: '/tmp/oliphaunt-rn-smoke-resources', + }); + assert.deepEqual(native.packageSizeReportCalls, [ + { resourceRoot: '/tmp/oliphaunt-rn-smoke-resources' }, + ]); + assert.deepEqual(native.closedHandles, [1]); +} + +async function testReusableReactNativeBenchmarkRunnerExercisesInstalledTransportShape(): Promise { + const native = new DirectCapabilitiesNative(); + // liboliphaunt-doc-example:react-native-benchmark-runner + const report = await runOliphauntReactNativeBenchmark(createOliphauntClient(native), { + requirePackageSizeReport: true, + warmupIterations: 1, + rawRttIterations: 1, + typedRttIterations: 1, + parameterizedRttIterations: 1, + insertRows: 1, + lookupIterations: 1, + aggregateIterations: 1, + updateIterations: 1, + checkpointIterations: 1, + largeResultRows: 1, + }); + + assert.equal(report.engine, 'nativeDirect'); + assert.equal(report.rawProtocolTransport, 'jsi-array-buffer'); + assert.equal(report.postgresSettings.shared_buffers, '32MB'); + assert.equal(report.postgresSettings.wal_buffers, '-1'); + assert.equal(report.postgresSettings.wal_segment_size, '16MB'); + assert.equal(report.postgresSettings.synchronous_commit, 'off'); + assert.equal(report.packageSizeReport?.extensions[0]?.name, 'vector'); + assert.equal(report.processMemoryReport.source, 'test-process-memory'); + assert.equal(report.processMemoryReport.physicalFootprintBytes, 8192); + assert.deepEqual( + report.workloads.map((workload) => workload.id), + [ + 'raw_simple_query_rtt', + 'typed_select_rtt', + 'parameterized_select_rtt', + 'transaction_insert', + 'indexed_lookup', + 'indexed_aggregate', + 'indexed_update', + 'background_checkpoint', + 'large_result_raw', + ], + ); + assert.ok( + native.execRequestTexts().some((request) => request.includes('CHECKPOINT')), + 'benchmark must measure background checkpoint latency', + ); + assert.ok( + native.execRequestTexts().some((request) => request.includes('current_setting(name, true)')), + 'benchmark must record effective PostgreSQL settings', + ); + assert.deepEqual(native.closedHandles, [1]); +} + +async function testRawProtocolStreamFallsBackToOwnedResponse(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + const chunks: Uint8Array[] = []; + + await db.execProtocolStream(Uint8Array.from([0x51]), (chunk) => { + chunks.push(chunk); + }); + + assert.deepEqual( + chunks.map((chunk) => Array.from(chunk)), + [[1, 0x51]], + ); + await db.close(); +} + +async function testQueryParsesSimpleQueryResults(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + // liboliphaunt-doc-example:react-native-open-query + // OLIPHAUNT_DOCS_SNIPPET react-native-quickstart + const result = await db.query('SELECT 1::text AS value, NULL AS empty'); + + assert.deepEqual( + result.fields.map((field) => field.name), + ['value', 'empty'], + ); + assert.equal(result.fields[0]?.typeOid, 25); + assert.equal(result.rowCount, 1); + assert.equal(result.commandTag, 'SELECT 1'); + assert.equal(result.getText(0, 'value'), '1'); + assert.equal(result.getText(0, 'empty'), null); + await db.close(); +} + +async function testQueryParametersUseExtendedProtocol(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + const request = extendedQuery('SELECT $1::text AS value, $2::text AS empty', ['1', null]); + assert.equal(request[0], 0x50); + assert.ok(request.includes(0x42), 'missing Bind'); + assert.ok(request.includes(0x45), 'missing Execute'); + + // liboliphaunt-doc-example:react-native-parameterized-query + const result = await db.query('SELECT $1::text AS value, $2::text AS empty', [ + { format: 'text', value: '1' }, + null, + ]); + + assert.equal(native.execRequests[0]?.[0], 0x50); + assert.equal(result.getText(0, 'value'), '1'); + assert.equal(result.getText(0, 'empty'), null); + await db.close(); +} + +function testSimpleQueryRejectsNulSQLBeforeBuildingProtocol(): void { + assert.throws( + () => simpleQuery('SELECT 1\0SELECT 2'), + /simple query SQL must not contain NUL bytes/, + ); +} + +function testExtendedQueryRejectsInvalidFrontendInputsBeforeBuildingProtocol(): void { + assert.throws( + () => extendedQuery('SELECT \0', [null]), + /extended query SQL must not contain NUL bytes/, + ); + assert.throws( + () => + extendedQuery( + 'SELECT 1', + Array.from({ length: 0x8000 }, () => null), + ), + /extended query supports at most 32767 parameters, got 32768/, + ); +} + +async function testQuerySurfacesPostgresErrors(): Promise { + const native = new ErroringQueryNative('42P01', 'relation does not exist'); + const db = await createOliphauntClient(native).open(); + + await assert.rejects( + async () => { + await db.query('SELECT * FROM missing'); + }, + (error: unknown) => { + assert.ok(error instanceof PostgresError); + assert.equal(error.severity, 'ERROR'); + assert.equal(error.sqlstate, '42P01'); + assert.equal(error.postgresMessage, 'relation does not exist'); + return true; + }, + ); + await db.close(); +} + +async function testExecuteSurfacesPostgresErrors(): Promise { + const native = new ErroringQueryNative('42601', 'syntax error at or near "TRIGGER"'); + const db = await createOliphauntClient(native).open(); + + await assert.rejects( + async () => { + await db.execute('CREATE TRIGGER broken'); + }, + (error: unknown) => { + assert.ok(error instanceof PostgresError); + assert.equal(error.severity, 'ERROR'); + assert.equal(error.sqlstate, '42601'); + assert.equal(error.postgresMessage, 'syntax error at or near "TRIGGER"'); + return true; + }, + ); + await db.close(); +} + +async function testQueryNormalizesCancellationPostgresErrors(): Promise { + const native = new ErroringQueryNative('57014', 'canceling statement due to user request'); + const db = await createOliphauntClient(native).open(); + + await assert.rejects( + async () => { + await db.query('SELECT pg_sleep(5)'); + }, + (error: unknown) => { + assert.ok(error instanceof PostgresError); + assert.equal(error.severity, 'ERROR'); + assert.equal(error.sqlstate, '57014'); + assert.equal(error.postgresMessage, 'canceling statement due to user request'); + return true; + }, + ); + await db.close(); +} + +function testQueryParserRejectsInvalidUTF8FieldNames(): void { + const out: number[] = []; + pushRawRowDescription(out, [[Uint8Array.from([0xff]), 25]]); + pushReadyForQuery(out); + + assert.throws(() => parseQueryResponse(Uint8Array.from(out)), /field name is not valid UTF-8/); +} + +function testQueryTextAccessorsRejectInvalidUTF8Values(): void { + const out: number[] = []; + pushRowDescription(out, [['value', 25]]); + pushDataRow(out, [Uint8Array.from([0xff])]); + pushCommandComplete(out, 'SELECT 1'); + pushReadyForQuery(out); + + const result = parseQueryResponse(Uint8Array.from(out)); + assert.throws(() => result.getText(0, 'value'), /query value is not valid UTF-8/); +} + +function testQueryParserAcceptsExtendedQueryControlMessages(): void { + const out: number[] = []; + pushBackendMessage(out, 0x31, []); + pushBackendMessage(out, 0x32, []); + pushBackendMessage(out, 0x6e, []); + pushCommandComplete(out, 'INSERT 0 0'); + pushReadyForQuery(out); + + const result = parseQueryResponse(Uint8Array.from(out)); + assert.deepEqual(result.fields, []); + assert.deepEqual(result.rows, []); + assert.equal(result.commandTag, 'INSERT 0 0'); +} + +function testQueryParserAcceptsAsyncBackendControlMessages(): void { + const out: number[] = []; + pushParameterStatus(out, 'client_encoding', 'UTF8'); + pushNoticeResponse(out, 'NOTICE', 'hello'); + pushNotificationResponse(out, 123, 'channel', 'payload'); + pushCommandComplete(out, 'SELECT 0'); + pushReadyForQuery(out); + + const result = parseQueryResponse(Uint8Array.from(out)); + assert.equal(result.commandTag, 'SELECT 0'); +} + +function testQueryParserRejectsMalformedEmptyControlMessages(): void { + const out: number[] = []; + pushBackendMessage(out, 0x31, [0]); + pushReadyForQuery(out); + + assert.throws( + () => parseQueryResponse(Uint8Array.from(out)), + /ParseComplete contained trailing bytes/, + ); +} + +function testQueryParserRejectsMalformedAsyncBackendControlMessages(): void { + const malformedParameter: number[] = []; + pushBackendMessage(malformedParameter, 0x53, [...new TextEncoder().encode('client_encoding'), 0]); + pushReadyForQuery(malformedParameter); + assert.throws( + () => parseQueryResponse(Uint8Array.from(malformedParameter)), + /ParameterStatus value is missing null terminator/, + ); + + const malformedNotice: number[] = []; + pushBackendMessage(malformedNotice, 0x4e, [0x53, ...new TextEncoder().encode('NOTICE'), 0]); + pushReadyForQuery(malformedNotice); + assert.throws( + () => parseQueryResponse(Uint8Array.from(malformedNotice)), + /NoticeResponse is missing terminator/, + ); + + const malformedNotification: number[] = []; + const notificationBody: number[] = []; + pushI32(notificationBody, 123); + notificationBody.push(...new TextEncoder().encode('channel')); + pushBackendMessage(malformedNotification, 0x41, notificationBody); + pushReadyForQuery(malformedNotification); + assert.throws( + () => parseQueryResponse(Uint8Array.from(malformedNotification)), + /NotificationResponse channel is missing null terminator/, + ); +} + +function testQueryParserRejectsUnexpectedBackendMessageTags(): void { + const out: number[] = []; + pushBackendMessage(out, 0x52, [0, 0, 0, 0]); + pushReadyForQuery(out); + + assert.throws( + () => parseQueryResponse(Uint8Array.from(out)), + /unexpected backend message tag 0x52/, + ); +} + +function testQueryParserAcceptsReadyForQueryTransactionStates(): void { + for (const status of [0x49, 0x54, 0x45]) { + const out: number[] = []; + pushCommandComplete(out, 'SELECT 0'); + pushReadyForQuery(out, status); + + const result = parseQueryResponse(Uint8Array.from(out)); + assert.equal(result.commandTag, 'SELECT 0'); + } +} + +function testQueryParserRejectsMalformedReadyForQueryStatus(): void { + const missing: number[] = []; + pushBackendMessage(missing, 0x5a, []); + assert.throws( + () => parseQueryResponse(Uint8Array.from(missing)), + /ReadyForQuery contained 0 bytes, expected 1/, + ); + + const invalid: number[] = []; + pushReadyForQuery(invalid, 0); + assert.throws( + () => parseQueryResponse(Uint8Array.from(invalid)), + /ReadyForQuery contained invalid transaction status 0x00/, + ); +} + +async function testConnectionStringIsOnlyPresentForServerCapabilities(): Promise { + const direct = await createOliphauntClient(new DirectCapabilitiesNative()).open({ + engine: 'nativeDirect', + }); + assert.equal(await direct.connectionString(), undefined); + assert.equal((await direct.capabilities()).independentSessions, false); + assert.equal((await direct.capabilities()).reopenable, true); + assert.equal((await direct.capabilities()).sameRootLogicalReopen, true); + assert.equal((await direct.capabilities()).rootSwitchable, false); + assert.equal((await direct.capabilities()).crashRestartable, false); + await direct.close(); + + const server = await createOliphauntClient(new MockNative()).open({ + engine: 'nativeServer', + }); + assert.equal(await server.connectionString(), 'postgres://postgres@127.0.0.1:55432/template1'); + assert.equal((await server.capabilities()).independentSessions, true); + assert.equal((await server.capabilities()).reopenable, true); + assert.equal((await server.capabilities()).sameRootLogicalReopen, false); + assert.equal((await server.capabilities()).rootSwitchable, true); + assert.equal((await server.capabilities()).crashRestartable, false); + await server.close(); +} + +async function testTransactionCommitsAndRejectsUnpinnedInterleaving(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + const value = await db.transaction(async (tx) => { + await assert.rejects( + () => db.execute('SELECT outside_transaction'), + /active OliphauntTransaction/, + ); + await assert.rejects(() => db.checkpoint(), /active OliphauntTransaction/); + await tx.execute('INSERT INTO rn_tx VALUES (1)'); + const chunks: number[][] = []; + await tx.execProtocolStream(Uint8Array.from([0x52]), (chunk) => { + chunks.push(Array.from(chunk)); + }); + assert.deepEqual(chunks, [[1, 0x52]]); + return 7; + }); + + await db.checkpoint(); + assert.equal(value, 7); + const requests = native.execRequestTexts(); + assert.ok(requests.some((request) => request.includes('BEGIN'))); + assert.ok(requests.some((request) => request.includes('INSERT INTO rn_tx'))); + assert.ok(requests.some((request) => request.includes('COMMIT'))); + assert.ok(requests.some((request) => request.includes('CHECKPOINT'))); + assert.ok(!requests.some((request) => request.includes('ROLLBACK'))); + + const escaped = await db.transaction((tx) => tx); + await assert.rejects( + () => escaped.execute('SELECT after_commit'), + /transaction is no longer active/, + ); + await db.close(); +} + +async function testTransactionRollsBackWhenBodyThrows(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + let captured: OliphauntTransaction | undefined; + await assert.rejects(async () => { + await db.transaction(async (tx) => { + captured = tx; + await tx.execute('INSERT INTO rn_tx VALUES (2)'); + throw new Error('boom'); + }); + }, /boom/); + + const requests = native.execRequestTexts(); + assert.ok(requests.some((request) => request.includes('BEGIN'))); + assert.ok(requests.some((request) => request.includes('INSERT INTO rn_tx'))); + assert.ok(requests.some((request) => request.includes('ROLLBACK'))); + assert.ok(captured); + await assert.rejects( + () => captured!.execute('SELECT after_rollback'), + /transaction is no longer active/, + ); + await db.close(); +} + +async function testCloseDuringTransactionClosesSessionAndRejectsPinnedWork(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + await assert.rejects(async () => { + await db.transaction(async (tx) => { + await db.close(); + await tx.execute('SELECT after_close'); + }); + }, /Oliphaunt database is closed/); + + await assert.rejects( + () => db.execute('SELECT after_closed_database'), + /Oliphaunt database is closed/, + ); + + const requests = native.execRequestTexts(); + assert.ok(requests.some((request) => request.includes('BEGIN'))); + assert.ok(!requests.some((request) => request.includes('SELECT after_close'))); + assert.ok(!requests.some((request) => request.includes('COMMIT'))); + assert.deepEqual(native.closedHandles, [1]); +} + +async function testBackupRejectsUnsupportedFormatsBeforeNativeCall(): Promise { + const native = new DirectCapabilitiesNative(); + const db = await createOliphauntClient(native).open({ engine: 'nativeDirect' }); + + await assert.rejects(async () => { + await db.backup('sql'); + }, /sql backup is not supported by nativeDirect/); + assert.deepEqual(native.backupCalls, []); + await db.close(); +} + +async function testOpenForwardsNativeRuntimeOverrides(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + await client.open({ + engine: 'nativeDirect', + root: '/tmp/oliphaunt-rn-root', + durability: 'fastDev', + runtimeFootprint: 'balancedMobile', + startupGUCs: [{ name: 'shared_buffers', value: '16MB' }, 'wal_buffers=256kB'], + username: 'app_user', + database: 'app_db', + libraryPath: '/tmp/oliphaunt.dylib', + runtimeDirectory: '/tmp/postgres-install', + resourceRoot: '/tmp/oliphaunt-resources', + }); + + assert.deepEqual(native.openCalls[0], { + engine: 'nativeDirect', + root: '/tmp/oliphaunt-rn-root', + temporary: undefined, + durability: 'fastDev', + runtimeFootprint: 'balancedMobile', + startupGUCs: ['shared_buffers=16MB', 'wal_buffers=256kB'], + username: 'app_user', + database: 'app_db', + extensions: undefined, + libraryPath: '/tmp/oliphaunt.dylib', + runtimeDirectory: '/tmp/postgres-install', + resourceRoot: '/tmp/oliphaunt-resources', + }); +} + +async function testOpenRejectsBlankNativeRuntimeOverridesBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.open({ libraryPath: ' \n' }); + }, /libraryPath must not be empty/); + await assert.rejects(async () => { + await client.open({ runtimeDirectory: '\t' }); + }, /runtimeDirectory must not be empty/); + await assert.rejects(async () => { + await client.open({ resourceRoot: ' \n' }); + }, /resourceRoot must not be empty/); + await assert.rejects(async () => { + await client.open({ libraryPath: '/tmp/oliphaunt\0.dylib' }); + }, /libraryPath must not contain NUL bytes/); + await assert.rejects(async () => { + await client.open({ runtimeDirectory: '/tmp/oliphaunt\0runtime' }); + }, /runtimeDirectory must not contain NUL bytes/); + await assert.rejects(async () => { + await client.open({ resourceRoot: '/tmp/oliphaunt\0resources' }); + }, /resourceRoot must not contain NUL bytes/); + assert.deepEqual(native.openCalls, []); +} + +async function testOpenRejectsEmptyRootBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.open({ root: ' \t' }); + }, /database root must not be empty/); + await assert.rejects(async () => { + await client.open({ root: '/tmp/oliphaunt-rn\0root' }); + }, /database root must not contain NUL bytes/); + assert.deepEqual(native.openCalls, []); +} + +async function testOpenRejectsInvalidConnectionIdentityBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.open({ username: ' \n' }); + }, /username must not be empty/); + await assert.rejects(async () => { + await client.open({ database: 'app\0db' }); + }, /database must not contain NUL bytes/); + assert.deepEqual(native.openCalls, []); +} + +async function testOpenValidatesExtensionIdsBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.open({ extensions: ['mobile/vector'] }); + }, /extension id 'mobile\/vector' must contain 1 to 128 ASCII/); + assert.equal(native.openCalls.length, 0); + + await client.open({ + extensions: [' pg_trgm ', '', 'vector', 'hstore'], + }); + const forwardedConfig = native.openCalls[0] as { extensions?: string[] }; + assert.deepEqual(forwardedConfig.extensions, ['pg_trgm', 'vector', 'hstore']); +} + +async function testOpenValidatesStartupGUCsBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.open({ startupGUCs: ['shared-buffers=16MB'] }); + }, /startup GUC name 'shared-buffers'/); + await assert.rejects(async () => { + await client.open({ startupGUCs: [{ name: 'shared_buffers', value: ' \n' }] }); + }, /startup GUC 'shared_buffers' value must not be empty/); + await assert.rejects(async () => { + await client.open({ startupGUCs: ['shared_buffers'] }); + }, /startup GUC string must use name=value/); + await assert.rejects(async () => { + await client.open({ startupGUCs: [{ name: 'shared_buffers', value: '16\0MB' }] }); + }, /startup GUC must not contain NUL bytes/); + assert.equal(native.openCalls.length, 0); +} + +async function testRestoreUsesPhysicalArchiveShape(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + const restored = await client.restore({ + root: '/tmp/oliphaunt-react-native-restore', + artifact: { + format: 'physicalArchive', + bytes: new TextEncoder().encode('physical-backup'), + }, + replaceExisting: true, + }); + + assert.equal(restored, '/tmp/oliphaunt-react-native-restore'); + assert.deepEqual(native.restoreCalls, [ + { + root: '/tmp/oliphaunt-react-native-restore', + format: 'physicalArchive', + payload: 'physical-backup', + replaceExisting: true, + libraryPath: null, + }, + ]); +} + +async function testRestoreForwardsNativeLibraryOverride(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await client.restore({ + root: '/tmp/oliphaunt-react-native-restore-library', + artifact: { + format: 'physicalArchive', + bytes: new TextEncoder().encode('physical-backup'), + }, + libraryPath: '/tmp/oliphaunt-rn-restore.dylib', + }); + + assert.equal(native.restoreCalls[0]?.libraryPath, '/tmp/oliphaunt-rn-restore.dylib'); +} + +async function testRestoreRejectsBlankLibraryOverrideBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.restore({ + root: '/tmp/oliphaunt-react-native-restore-library', + artifact: { + format: 'physicalArchive', + bytes: new TextEncoder().encode('physical-backup'), + }, + libraryPath: ' \n', + }); + }, /libraryPath must not be empty/); + await assert.rejects(async () => { + await client.restore({ + root: '/tmp/oliphaunt-react-native-restore-library', + artifact: { + format: 'physicalArchive', + bytes: new TextEncoder().encode('physical-backup'), + }, + libraryPath: '/tmp/oliphaunt\0restore.dylib', + }); + }, /libraryPath must not contain NUL bytes/); + assert.deepEqual(native.restoreCalls, []); +} + +async function testRestoreRejectsUnsupportedFormatsBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.restore({ + root: '/tmp/oliphaunt-react-native-restore-sql', + artifact: { + format: 'sql', + bytes: new TextEncoder().encode('sql-backup'), + }, + }); + }, /restore currently requires a physicalArchive artifact, got sql/); + assert.deepEqual(native.restoreCalls, []); +} + +async function testRestoreRejectsBlankRootBeforeNativeCall(): Promise { + const native = new MockNative(); + const client = createOliphauntClient(native); + + await assert.rejects(async () => { + await client.restore({ + root: '\n', + artifact: { + format: 'physicalArchive', + bytes: new TextEncoder().encode('physical-backup'), + }, + }); + }, /restore root must not be empty/); + await assert.rejects(async () => { + await client.restore({ + root: '/tmp/oliphaunt-rn\0restore', + artifact: { + format: 'physicalArchive', + bytes: new TextEncoder().encode('physical-backup'), + }, + }); + }, /restore root must not contain NUL bytes/); + assert.deepEqual(native.restoreCalls, []); +} + +async function testMutuallyExclusiveRoots(): Promise { + const client = createOliphauntClient(new MockNative()); + await assert.rejects( + () => client.open({ root: '/tmp/db', temporary: true }), + /mutually exclusive/, + ); +} + +async function testExecutionAfterCloseFailsBeforeNativeCall(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + await db.close(); + await assert.rejects(() => db.execProtocolRaw([1]), /closed/); + assert.equal(native.execCalls, 0); +} + +async function testCancelUsesNativeOutOfBandPath(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + await db.cancel(); + + assert.deepEqual(native.cancelledHandles, [1]); + await db.close(); +} + +async function testCloseDoesNotIssueSpuriousCancel(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + await db.close(); + await db.close(); + + assert.deepEqual(native.cancelledHandles, []); + assert.deepEqual(native.closedHandles, [1]); +} + +async function testPrepareForBackgroundCheckpointsWhenIdleAndResumeProbesSession(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + const prepared = await db.prepareForBackground(); + await db.resumeFromBackground(); + + assert.deepEqual(prepared, { + cancelledActiveWork: false, + checkpointed: true, + }); + const requests = native.execRequestTexts(); + assert.ok(requests.some((request) => request.includes('CHECKPOINT'))); + assert.ok(requests.some((request) => request.includes('SELECT 1'))); + assert.deepEqual(native.cancelledHandles, []); + await db.close(); +} + +async function testPrepareForBackgroundCancelsActiveWorkAndSkipsCheckpoint(): Promise { + const native = new MockNative({ installJsi: false }); + const globalWithJsi = globalThis as GlobalWithJsiTransport; + const previous = globalWithJsi.__oliphauntReactNativeJsi; + let markStarted: () => void = () => {}; + let finishActiveWork: (value: Uint8Array) => void = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const activeWork = new Promise((resolve) => { + finishActiveWork = resolve; + }); + globalWithJsi.__oliphauntReactNativeJsi = { + version: 1, + async execProtocolRaw(): Promise { + markStarted(); + return activeWork; + }, + backup: (handle, format) => native.backupJsi(handle, format), + restore: (root, format, artifact, replaceExisting, libraryPath) => + native.restoreJsi(root, format, artifact, replaceExisting, libraryPath), + }; + try { + const db = await createOliphauntClient(native).open(); + const running = db.execProtocolRaw(Uint8Array.from([0x51])); + await started; + + const prepared = await db.prepareForBackground(); + + assert.deepEqual(prepared, { + cancelledActiveWork: true, + checkpointed: false, + skippedCheckpointReason: 'activeWork', + }); + assert.deepEqual(native.cancelledHandles, [1]); + finishActiveWork(Uint8Array.from([0xca])); + assert.deepEqual(Array.from(await running), [0xca]); + await db.close(); + } finally { + finishActiveWork(Uint8Array.from([0xca])); + globalWithJsi.__oliphauntReactNativeJsi = previous; + } +} + +async function testPrepareForBackgroundSkipsCheckpointDuringTransaction(): Promise { + const native = new MockNative(); + const db = await createOliphauntClient(native).open(); + + const prepared = await db.transaction(() => db.prepareForBackground()); + + assert.deepEqual(prepared, { + cancelledActiveWork: false, + checkpointed: false, + skippedCheckpointReason: 'transactionActive', + }); + assert.equal( + native.execRequestTexts().some((request) => request.includes('CHECKPOINT')), + false, + ); + await db.close(); +} + +class MockNative implements Spec { + readonly closedHandles: number[] = []; + readonly cancelledHandles: number[] = []; + readonly openCalls: unknown[] = []; + readonly packageSizeReportCalls: unknown[] = []; + readonly execRequests: Uint8Array[] = []; + readonly backupCalls: string[] = []; + readonly restoreCalls: Array<{ + root: string; + format: string; + payload: string; + replaceExisting: boolean; + libraryPath: string | null; + }> = []; + execCalls = 0; + private nextHandle = 1; + + constructor(options: { installJsi?: boolean } = {}) { + if (options.installJsi !== false) { + installMockJsiTransport(this); + } + } + + getConstants(): {} { + return {}; + } + + async open(config?: unknown): Promise { + this.openCalls.push(config); + return this.nextHandle++; + } + + async supportedModes() { + return [ + { + engine: 'nativeDirect', + available: true, + capabilities: { + engine: 'nativeDirect', + processIsolated: false, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: true, + rootSwitchable: false, + crashRestartable: false, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + rawProtocolTransport: 'jsi-array-buffer', + }, + }, + { + engine: 'nativeBroker', + available: false, + capabilities: { + engine: 'nativeBroker', + processIsolated: true, + multiRoot: true, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: true, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + rawProtocolTransport: 'jsi-array-buffer', + }, + unavailableReason: 'broker adapter is unavailable', + }, + { + engine: 'nativeServer', + available: false, + capabilities: { + engine: 'nativeServer', + processIsolated: true, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: false, + independentSessions: true, + maxClientSessions: 32, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['sql', 'physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + rawProtocolTransport: 'jsi-array-buffer', + }, + unavailableReason: 'server adapter is unavailable', + }, + ]; + } + + async packageSizeReport(config: unknown) { + this.packageSizeReportCalls.push(config); + return { + packageBytes: 185, + runtimeBytes: 100, + templatePgdataBytes: 40, + staticRegistryBytes: 45, + selectedExtensionBytes: 30, + extensions: [ + { + name: 'vector', + fileCount: 3, + bytes: 30, + }, + ], + }; + } + + async processMemory() { + return { + source: 'test-process-memory', + residentBytes: 4096, + physicalFootprintBytes: 8192, + totalPssKb: 12, + virtualBytes: Number.NaN, + }; + } + + async execProtocolRawJsi(handle: number, request: Uint8Array): Promise { + this.execCalls += 1; + this.execRequests.push(request); + if (request.length > 5 && (request[0] === 0x51 || request[0] === 0x50)) { + const text = new TextDecoder().decode(request); + if (text.includes('current_setting(name, true)')) { + return backendNamedRowsResponse( + ['name', 'value'], + [ + ['autovacuum_worker_slots', '1'], + ['fsync', 'on'], + ['full_page_writes', 'on'], + ['io_max_concurrency', '1'], + ['io_method', 'sync'], + ['maintenance_work_mem', '16MB'], + ['max_connections', '1'], + ['max_replication_slots', '0'], + ['max_wal_senders', '0'], + ['max_wal_size', '64MB'], + ['min_wal_size', '32MB'], + ['reserved_connections', '0'], + ['shared_buffers', '32MB'], + ['superuser_reserved_connections', '0'], + ['synchronous_commit', 'off'], + ['wal_buffers', '-1'], + ['wal_segment_size', '16MB'], + ['work_mem', '4MB'], + ], + ); + } + if (text.includes('SELECT payload FROM rn_bench_events')) { + return backendNamedValuesResponse([['payload', 'payload-1']]); + } + if (text.includes('count(*)::text AS rows')) { + return backendNamedValuesResponse([ + ['rows', '1'], + ['total', '1'], + ]); + } + if (text.includes('RETURNING amount::text AS amount')) { + return backendNamedValuesResponse([['amount', '1']]); + } + if (text.includes('sum(amount), 0)::text AS checksum')) { + return backendNamedValuesResponse([['checksum', '1']]); + } + if (text.includes('hello')) { + return backendSingleValueResponse('hello'); + } + return backendSelectResponse(); + } + return Uint8Array.from([handle, ...request]); + } + + execRequestTexts(): string[] { + return this.execRequests.map((request) => new TextDecoder().decode(request)); + } + + async backupJsi(_handle: number, format: string): Promise { + this.backupCalls.push(format); + return new TextEncoder().encode(`${format}-backup`); + } + + async restoreJsi( + root: string, + format: string, + artifact: Uint8Array, + replaceExisting: boolean, + libraryPath: string | null, + ): Promise { + this.restoreCalls.push({ + root, + format, + payload: new TextDecoder().decode(artifact), + replaceExisting, + libraryPath, + }); + return root; + } + + async cancel(handle: number): Promise { + this.cancelledHandles.push(handle); + } + + async close(handle: number): Promise { + this.closedHandles.push(handle); + } + + async capabilities(): Promise { + return { + engine: 'nativeServer', + processIsolated: true, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: false, + independentSessions: true, + maxClientSessions: 32, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['sql', 'physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + connectionString: 'postgres://postgres@127.0.0.1:55432/template1', + rawProtocolTransport: 'jsi-array-buffer', + }; + } +} + +class DirectCapabilitiesNative extends MockNative { + override async capabilities(): Promise { + return { + engine: 'nativeDirect', + processIsolated: false, + multiRoot: false, + reopenable: true, + sameRootLogicalReopen: true, + rootSwitchable: false, + crashRestartable: false, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: true, + protocolStream: true, + queryCancel: true, + backupRestore: true, + backupFormats: ['physicalArchive'], + restoreFormats: ['physicalArchive'], + simpleQuery: true, + extensions: true, + connectionString: undefined, + rawProtocolTransport: 'jsi-array-buffer', + }; + } +} + +class ErroringQueryNative extends MockNative { + constructor( + private readonly sqlstate: string, + private readonly message: string, + ) { + super(); + } + + override async execProtocolRawJsi(_handle: number, _request: Uint8Array): Promise { + return backendErrorResponse('ERROR', this.sqlstate, this.message); + } +} + +type GlobalWithJsiTransport = typeof globalThis & { + __oliphauntReactNativeJsi?: { + version: 1; + execProtocolRaw: ( + handle: number, + request: Uint8Array, + ) => Promise; + execProtocolStream?: ( + handle: number, + request: Uint8Array, + onChunk: (chunk: ArrayBuffer | ArrayBufferView) => void, + ) => Promise; + backup: (handle: number, format: string) => Promise; + restore: ( + root: string, + format: string, + artifact: Uint8Array, + replaceExisting: boolean, + libraryPath: string | null, + ) => Promise; + }; +}; + +function installMockJsiTransport(native: MockNative): void { + (globalThis as GlobalWithJsiTransport).__oliphauntReactNativeJsi = { + version: 1, + execProtocolRaw: (handle, request) => native.execProtocolRawJsi(handle, request), + backup: (handle, format) => native.backupJsi(handle, format), + restore: (root, format, artifact, replaceExisting, libraryPath) => + native.restoreJsi(root, format, artifact, replaceExisting, libraryPath), + }; +} + +function backendSelectResponse(): Uint8Array { + const out: number[] = []; + pushRowDescription(out, [ + ['value', 25], + ['empty', 25], + ]); + pushDataRow(out, [new TextEncoder().encode('1'), null]); + pushCommandComplete(out, 'SELECT 1'); + pushReadyForQuery(out); + return Uint8Array.from(out); +} + +function backendSingleValueResponse(value: string): Uint8Array { + return backendNamedValuesResponse([['value', value]]); +} + +function backendNamedValuesResponse(fields: Array<[string, string | null]>): Uint8Array { + return backendNamedRowsResponse( + fields.map(([name]) => name), + [fields.map(([, value]) => value)], + ); +} + +function backendNamedRowsResponse(fields: string[], rows: Array>): Uint8Array { + const out: number[] = []; + pushRowDescription( + out, + fields.map((name) => [name, 25]), + ); + for (const row of rows) { + pushDataRow( + out, + row.map((value) => (value == null ? null : new TextEncoder().encode(value))), + ); + } + pushCommandComplete(out, 'SELECT 1'); + pushReadyForQuery(out); + return Uint8Array.from(out); +} + +function backendErrorResponse(severity: string, sqlstate: string, message: string): Uint8Array { + const body: number[] = []; + body.push(0x53, ...new TextEncoder().encode(severity), 0); + body.push(0x43, ...new TextEncoder().encode(sqlstate), 0); + body.push(0x4d, ...new TextEncoder().encode(message), 0); + body.push(0); + const out: number[] = []; + pushBackendMessage(out, 0x45, body); + pushReadyForQuery(out); + return Uint8Array.from(out); +} + +function pushRowDescription(out: number[], fields: Array<[string, number]>): void { + pushRawRowDescription( + out, + fields.map(([name, typeOid]): [Uint8Array, number] => [ + new TextEncoder().encode(name), + typeOid, + ]), + ); +} + +function pushRawRowDescription(out: number[], fields: Array<[Uint8Array, number]>): void { + const body: number[] = []; + pushI16(body, fields.length); + for (const [name, typeOid] of fields) { + body.push(...name, 0); + pushU32(body, 0); + pushI16(body, 0); + pushU32(body, typeOid); + pushI16(body, -1); + pushI32(body, -1); + pushI16(body, 0); + } + pushBackendMessage(out, 0x54, body); +} + +function pushDataRow(out: number[], values: Array): void { + const body: number[] = []; + pushI16(body, values.length); + for (const value of values) { + if (value === null) { + pushI32(body, -1); + } else { + pushI32(body, value.length); + body.push(...value); + } + } + pushBackendMessage(out, 0x44, body); +} + +function pushCommandComplete(out: number[], tag: string): void { + pushBackendMessage(out, 0x43, [...new TextEncoder().encode(tag), 0]); +} + +function pushNoticeResponse(out: number[], severity: string, message: string): void { + const body: number[] = []; + body.push(0x53, ...new TextEncoder().encode(severity), 0); + body.push(0x4d, ...new TextEncoder().encode(message), 0); + body.push(0); + pushBackendMessage(out, 0x4e, body); +} + +function pushParameterStatus(out: number[], name: string, value: string): void { + pushBackendMessage(out, 0x53, [ + ...new TextEncoder().encode(name), + 0, + ...new TextEncoder().encode(value), + 0, + ]); +} + +function pushNotificationResponse( + out: number[], + pid: number, + channel: string, + payload: string, +): void { + const body: number[] = []; + pushI32(body, pid); + body.push(...new TextEncoder().encode(channel), 0); + body.push(...new TextEncoder().encode(payload), 0); + pushBackendMessage(out, 0x41, body); +} + +function pushReadyForQuery(out: number[], status = 0x49): void { + pushBackendMessage(out, 0x5a, [status]); +} + +function pushBackendMessage(out: number[], tag: number, body: number[]): void { + out.push(tag); + pushI32(out, body.length + 4); + out.push(...body); +} + +function pushI32(out: number[], value: number): void { + pushU32(out, value >>> 0); +} + +function pushU32(out: number[], value: number): void { + out.push((value >>> 24) & 0xff); + out.push((value >>> 16) & 0xff); + out.push((value >>> 8) & 0xff); + out.push(value & 0xff); +} + +function pushI16(out: number[], value: number): void { + const bits = value & 0xffff; + out.push((bits >>> 8) & 0xff); + out.push(bits & 0xff); +} + +test('client', async () => { + await main(); +}); diff --git a/src/sdks/react-native/src/__tests__/config-plugin.test.ts b/src/sdks/react-native/src/__tests__/config-plugin.test.ts new file mode 100644 index 00000000..54395d2a --- /dev/null +++ b/src/sdks/react-native/src/__tests__/config-plugin.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + insertAppGradlePlugin, + insertIosPodfileBlock, + normalizeOptions, +} = require('../../app.plugin.js'); + +const normalized = normalizeOptions({ + extensions: ['vector', 'pg_trgm', 'vector'], + liboliphauntVersion: '0.1.0', +}); + +assert.deepEqual(normalized, { + extensions: ['pg_trgm', 'vector'], + liboliphauntVersion: '0.1.0', + assetBaseUrl: undefined, + kotlinPluginVersion: '0.1.0', +}); + +assert.throws( + () => normalizeOptions({ extensions: ['core,vector'] }), + /exact PostgreSQL extension name/, +); + +assert.throws( + () => normalizeOptions({ extensions: ['pg_search'] }), + /not in the generated exact-extension catalog/, +); + +assert.deepEqual(normalizeOptions({ extensions: ['postgis'] }).extensions, ['postgis']); + +const podfile = [ + "target 'OliphauntExample' do", + ' use_expo_modules!', + ' config = use_native_modules!', + 'end', + '', +].join('\n'); + +const patchedPodfile = insertIosPodfileBlock(podfile); +assert.match(patchedPodfile, /# @oliphaunt\/react-native begin/); +assert.match( + patchedPodfile, + /pod 'COliphaunt', :podspec => File\.join\(oliphaunt_podspecs_path, 'COliphaunt\.podspec'\), :modular_headers => true/, +); +assert.match( + patchedPodfile, + /pod 'Oliphaunt', :podspec => File\.join\(oliphaunt_podspecs_path, 'Oliphaunt\.podspec'\)/, +); +assert.equal(insertIosPodfileBlock(patchedPodfile), patchedPodfile); + +assert.throws( + () => insertIosPodfileBlock("target 'App' do\nend\n"), + /use_native_modules! or use_expo_modules!/, +); + +const appGradle = "plugins {\n id 'com.android.application'\n}\n"; +const patchedAppGradle = insertAppGradlePlugin(appGradle, '0.1.0'); +assert.match(patchedAppGradle, /id 'dev\.oliphaunt\.android' version '0\.1\.0'/); +assert.equal(insertAppGradlePlugin(patchedAppGradle, '0.1.0'), patchedAppGradle); + +test('config plugin', () => { + assert.ok(true); +}); diff --git a/src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts b/src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts new file mode 100644 index 00000000..d910139e --- /dev/null +++ b/src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { parseQueryResponse, PostgresError } from '../query'; + +function testQueryParserMatchesSharedProtocolFixtures(): void { + const fixturePath = sharedProtocolFixturePath(); + if (fixturePath === undefined) { + return; + } + + const corpus = JSON.parse(readFileSync(fixturePath, 'utf8')) as SharedProtocolFixtureCorpus; + assert.equal(corpus.schemaVersion, 1); + assert.equal(corpus.kind, 'postgres-backend-query-response'); + assert.ok(corpus.cases.length > 0, 'shared protocol corpus is empty'); + + const names = new Set(); + for (const fixture of corpus.cases) { + assert.equal(names.has(fixture.name), false, `duplicate fixture ${fixture.name}`); + names.add(fixture.name); + const expectation = fixture.queryExpectation; + if (expectation === undefined) { + continue; + } + const bytes = hexToBytes(fixture.responseHex); + if (expectation.ok !== undefined) { + assertSharedProtocolOkFixture(fixture, expectation.ok, bytes); + } else if (expectation.postgresError !== undefined) { + assertSharedProtocolPostgresErrorFixture(fixture, expectation.postgresError, bytes); + } else if (expectation.engineErrorContains !== undefined) { + assertSharedProtocolEngineErrorFixture(fixture, expectation.engineErrorContains, bytes); + } else { + assert.fail(`shared protocol fixture ${fixture.name} has no query expectation`); + } + } +} + +function assertSharedProtocolOkFixture( + fixture: SharedProtocolFixtureCase, + expected: SharedProtocolOkExpectation, + bytes: Uint8Array, +): void { + const result = parseQueryResponse(bytes); + assert.equal(result.rowCount, expected.rowCount, `${fixture.name} row count`); + assert.equal(result.commandTag, expected.commandTag, `${fixture.name} command tag`); + assert.equal(result.fields.length, expected.fields.length, `${fixture.name} field count`); + assert.equal(result.rows.length, expected.rows.length, `${fixture.name} rows size`); + + for (const [index, expectedField] of expected.fields.entries()) { + const actual = result.fields[index]; + assert.ok(actual, `${fixture.name} missing field ${index}`); + assert.equal(actual.name, expectedField.name, `${fixture.name} field name`); + assert.equal(actual.typeOid, expectedField.typeOid, `${fixture.name} type OID`); + if (expectedField.format === 'text') { + assert.equal(actual.format, 'text', `${fixture.name} field format`); + } + } + + for (const [rowIndex, expectedRow] of expected.rows.entries()) { + assert.equal(expectedRow.length, expected.fields.length, `${fixture.name} expected row width`); + for (const [columnIndex, expectedValue] of expectedRow.entries()) { + const field = expected.fields[columnIndex]; + assert.ok(field, `${fixture.name} missing expected field ${columnIndex}`); + assert.equal( + result.getText(rowIndex, field.name), + expectedValue, + `${fixture.name} row ${rowIndex} column ${field.name}`, + ); + } + } +} + +function assertSharedProtocolPostgresErrorFixture( + fixture: SharedProtocolFixtureCase, + expected: SharedProtocolPostgresErrorExpectation, + bytes: Uint8Array, +): void { + const thrown = thrownBy(() => parseQueryResponse(bytes)); + assert.ok(thrown instanceof PostgresError, `${fixture.name} should throw PostgresError`); + assert.equal(thrown.severity, expected.severity, `${fixture.name} severity`); + assert.equal(thrown.sqlstate, expected.sqlstate, `${fixture.name} SQLSTATE`); + assert.equal(thrown.postgresMessage, expected.message, `${fixture.name} PostgreSQL message`); +} + +function assertSharedProtocolEngineErrorFixture( + fixture: SharedProtocolFixtureCase, + expected: string, + bytes: Uint8Array, +): void { + const thrown = thrownBy(() => parseQueryResponse(bytes)); + assert.ok(thrown instanceof Error, `${fixture.name} should throw Error`); + assert.ok( + thrown.message.includes(expected), + `${fixture.name} error ${JSON.stringify(thrown.message)} did not contain ${JSON.stringify( + expected, + )}`, + ); +} + +function sharedProtocolFixturePath(): string | undefined { + const candidates = [ + path.resolve( + process.cwd(), + '..', + '..', + '..', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + '..', + '..', + '..', + '..', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + '..', + '..', + 'shared', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + '..', + '..', + '..', + 'shared', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + '..', + '..', + '..', + 'src', + 'shared', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + path.resolve( + process.cwd(), + 'src', + 'shared', + 'fixtures', + 'protocol', + 'query-response-cases.json', + ), + ]; + return candidates.find(existsSync); +} + +function hexToBytes(hex: string): Uint8Array { + const compact = hex.replace(/\s+/g, ''); + assert.equal(compact.length % 2, 0, 'hex fixture must have an even digit count'); + const bytes = new Uint8Array(compact.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + const byte = Number.parseInt(compact.slice(index * 2, index * 2 + 2), 16); + assert.ok(Number.isInteger(byte), 'hex fixture contains invalid byte'); + bytes[index] = byte; + } + return bytes; +} + +function thrownBy(callback: () => unknown): unknown { + try { + callback(); + } catch (error) { + return error; + } + assert.fail('expected callback to throw'); +} + +type SharedProtocolFixtureCorpus = { + schemaVersion: number; + kind: string; + cases: SharedProtocolFixtureCase[]; +}; + +type SharedProtocolFixtureCase = { + name: string; + responseHex: string; + queryExpectation?: SharedProtocolQueryExpectation; +}; + +type SharedProtocolQueryExpectation = { + ok?: SharedProtocolOkExpectation; + postgresError?: SharedProtocolPostgresErrorExpectation; + engineErrorContains?: string; +}; + +type SharedProtocolOkExpectation = { + fields: SharedProtocolFieldExpectation[]; + rows: Array>; + commandTag?: string; + rowCount: number; +}; + +type SharedProtocolFieldExpectation = { + name: string; + typeOid: number; + format?: string; +}; + +type SharedProtocolPostgresErrorExpectation = { + severity: string; + sqlstate: string; + message: string; +}; + +test('protocol fixtures', () => { + testQueryParserMatchesSharedProtocolFixtures(); +}); diff --git a/src/sdks/react-native/src/benchmark.ts b/src/sdks/react-native/src/benchmark.ts new file mode 100644 index 00000000..4a7b83f1 --- /dev/null +++ b/src/sdks/react-native/src/benchmark.ts @@ -0,0 +1,597 @@ +import type { + EngineCapabilities, + OpenConfig, + PackageSizeReport, + OliphauntClient, + OliphauntDatabase, + ProcessMemoryReport, +} from './client'; +import { simpleQuery } from './protocol'; + +export type ReactNativeBenchmarkOptions = { + readonly open?: OpenConfig; + readonly requirePackageSizeReport?: boolean; + readonly warmupIterations?: number; + readonly rawRttIterations?: number; + readonly typedRttIterations?: number; + readonly parameterizedRttIterations?: number; + readonly insertRows?: number; + readonly lookupIterations?: number; + readonly aggregateIterations?: number; + readonly updateIterations?: number; + readonly checkpointIterations?: number; + readonly largeResultRows?: number; + readonly metadata?: Record; +}; + +export type LatencySummary = { + readonly iterations: number; + readonly totalMs: number; + readonly minMs: number; + readonly meanMs: number; + readonly p50Ms: number; + readonly p90Ms: number; + readonly p95Ms: number; + readonly p99Ms: number; + readonly maxMs: number; +}; + +export type ThroughputSummary = { + readonly rows: number; + readonly totalMs: number; + readonly rowsPerSecond: number; +}; + +export type ReactNativeBenchmarkWorkload = { + readonly id: string; + readonly description: string; + readonly latency?: LatencySummary; + readonly throughput?: ThroughputSummary; + readonly rows?: number; + readonly responseBytes?: number; + readonly checksum?: string; +}; + +export type PostgresSettings = Record; + +export type ReactNativeBenchmarkReport = { + readonly schemaVersion: 1; + readonly startedAt: string; + readonly elapsedMs: number; + readonly openMs: number; + readonly closeMs: number; + readonly engine: EngineCapabilities['engine']; + readonly rawProtocolTransport: EngineCapabilities['rawProtocolTransport']; + readonly capabilities: EngineCapabilities; + readonly options: Required< + Pick< + ReactNativeBenchmarkOptions, + | 'warmupIterations' + | 'rawRttIterations' + | 'typedRttIterations' + | 'parameterizedRttIterations' + | 'insertRows' + | 'lookupIterations' + | 'aggregateIterations' + | 'updateIterations' + | 'checkpointIterations' + | 'largeResultRows' + > + >; + readonly metadata: Record; + readonly postgresSettings: PostgresSettings; + readonly packageSizeReport?: PackageSizeReport | null; + readonly processMemoryReport: ProcessMemoryReport; + readonly jsTimerTicks: number; + readonly workloads: ReactNativeBenchmarkWorkload[]; +}; + +type ResolvedBenchmarkOptions = ReactNativeBenchmarkReport['options']; + +const defaultBenchmarkOptions: ResolvedBenchmarkOptions = { + warmupIterations: 50, + rawRttIterations: 500, + typedRttIterations: 500, + parameterizedRttIterations: 500, + insertRows: 1_000, + lookupIterations: 500, + aggregateIterations: 250, + updateIterations: 200, + checkpointIterations: 20, + largeResultRows: 500, +}; + +const benchmarkPostgresSettings = [ + 'shared_buffers', + 'wal_buffers', + 'wal_segment_size', + 'min_wal_size', + 'max_wal_size', + 'max_connections', + 'superuser_reserved_connections', + 'reserved_connections', + 'autovacuum_worker_slots', + 'max_wal_senders', + 'max_replication_slots', + 'io_method', + 'io_max_concurrency', + 'fsync', + 'full_page_writes', + 'synchronous_commit', + 'work_mem', + 'maintenance_work_mem', +] as const; + +export async function runOliphauntReactNativeBenchmark( + client: OliphauntClient, + options: ReactNativeBenchmarkOptions = {}, +): Promise { + const resolved = resolveOptions(options); + const startedAt = new Date().toISOString(); + const totalStart = monotonicNow(); + const liveness = startTimerLivenessProbe(); + const packageSizePromise = + options.requirePackageSizeReport === true + ? client.packageSizeReport({ resourceRoot: options.open?.resourceRoot }) + : Promise.resolve(undefined); + + let closeMs = 0; + const openStart = monotonicNow(); + const db = await client.open({ + engine: 'nativeDirect', + temporary: true, + durability: 'balanced', + username: 'postgres', + database: 'postgres', + ...options.open, + }); + const openMs = monotonicNow() - openStart; + + try { + const capabilities = await db.capabilities(); + assertBenchmarkCapabilities(capabilities); + const postgresSettings = await readPostgresSettings(db); + + await runWarmup(db, resolved.warmupIterations); + const workloads: ReactNativeBenchmarkWorkload[] = []; + workloads.push(await runRawSimpleQueryRtt(db, resolved.rawRttIterations)); + workloads.push(await runTypedSelectRtt(db, resolved.typedRttIterations)); + workloads.push(await runParameterizedSelectRtt(db, resolved.parameterizedRttIterations)); + workloads.push(await prepareDataset(db, resolved.insertRows)); + workloads.push(await runIndexedLookup(db, resolved.lookupIterations, resolved.insertRows)); + workloads.push(await runAggregateScan(db, resolved.aggregateIterations)); + workloads.push(await runIndexedUpdates(db, resolved.updateIterations, resolved.insertRows)); + workloads.push(await runBackgroundCheckpointLatency(db, resolved.checkpointIterations)); + workloads.push(await runLargeResult(db, resolved.largeResultRows)); + + const processMemoryReport = await client.processMemory(); + const closeStart = monotonicNow(); + await db.close(); + closeMs = monotonicNow() - closeStart; + liveness.stop(); + + const packageSizeReport = await packageSizePromise; + if (options.requirePackageSizeReport === true && packageSizeReport == null) { + throw new Error('Oliphaunt React Native benchmark expected packaged resource size evidence'); + } + + return { + schemaVersion: 1, + startedAt, + elapsedMs: monotonicNow() - totalStart, + openMs, + closeMs, + engine: capabilities.engine, + rawProtocolTransport: capabilities.rawProtocolTransport, + capabilities, + options: resolved, + metadata: options.metadata ?? {}, + postgresSettings, + packageSizeReport, + processMemoryReport, + jsTimerTicks: liveness.ticks(), + workloads, + }; + } finally { + liveness.stop(); + await db.close(); + } +} + +export async function runInstalledOliphauntReactNativeBenchmark( + options: ReactNativeBenchmarkOptions = {}, +): Promise { + const { Oliphaunt } = await import('./index.js'); + return runOliphauntReactNativeBenchmark(Oliphaunt, options); +} + +function resolveOptions(options: ReactNativeBenchmarkOptions): ResolvedBenchmarkOptions { + return { + warmupIterations: positiveInteger( + options.warmupIterations, + defaultBenchmarkOptions.warmupIterations, + 'warmupIterations', + ), + rawRttIterations: positiveInteger( + options.rawRttIterations, + defaultBenchmarkOptions.rawRttIterations, + 'rawRttIterations', + ), + typedRttIterations: positiveInteger( + options.typedRttIterations, + defaultBenchmarkOptions.typedRttIterations, + 'typedRttIterations', + ), + parameterizedRttIterations: positiveInteger( + options.parameterizedRttIterations, + defaultBenchmarkOptions.parameterizedRttIterations, + 'parameterizedRttIterations', + ), + insertRows: positiveInteger( + options.insertRows, + defaultBenchmarkOptions.insertRows, + 'insertRows', + ), + lookupIterations: positiveInteger( + options.lookupIterations, + defaultBenchmarkOptions.lookupIterations, + 'lookupIterations', + ), + aggregateIterations: positiveInteger( + options.aggregateIterations, + defaultBenchmarkOptions.aggregateIterations, + 'aggregateIterations', + ), + updateIterations: positiveInteger( + options.updateIterations, + defaultBenchmarkOptions.updateIterations, + 'updateIterations', + ), + checkpointIterations: positiveInteger( + options.checkpointIterations, + defaultBenchmarkOptions.checkpointIterations, + 'checkpointIterations', + ), + largeResultRows: positiveInteger( + options.largeResultRows, + defaultBenchmarkOptions.largeResultRows, + 'largeResultRows', + ), + }; +} + +function positiveInteger(value: number | undefined, fallback: number, name: string): number { + const selected = value ?? fallback; + if (!Number.isInteger(selected) || selected <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return selected; +} + +async function readPostgresSettings(db: OliphauntDatabase): Promise { + const values = benchmarkPostgresSettings.map((name) => `('${sqlLiteral(name)}')`).join(', '); + const result = await db.query(` + SELECT name, current_setting(name, true) AS value + FROM (VALUES ${values}) AS settings(name) + ORDER BY name + `); + const nameColumn = result.fieldIndex('name'); + const valueColumn = result.fieldIndex('value'); + if (nameColumn === undefined || valueColumn === undefined) { + throw new Error('PostgreSQL settings probe returned an unexpected row shape'); + } + + const settings: PostgresSettings = {}; + for (const row of result.rows) { + const name = row.text(nameColumn); + if (name == null || name.length === 0) { + continue; + } + settings[name] = row.text(valueColumn); + } + return settings; +} + +function sqlLiteral(value: string): string { + return value.replace(/'/g, "''"); +} + +async function runWarmup(db: OliphauntDatabase, iterations: number): Promise { + for (let index = 0; index < iterations; index += 1) { + await db.execProtocolRaw(simpleQuery('SELECT 1')); + } +} + +async function runRawSimpleQueryRtt( + db: OliphauntDatabase, + iterations: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async (index) => { + const response = await db.execProtocolRaw(simpleQuery(`SELECT ${index % 17}::int AS value`)); + checksum += response.byteLength; + }); + return { + id: 'raw_simple_query_rtt', + description: 'Raw PostgreSQL simple-query protocol round trip through JSI ArrayBuffer', + latency, + checksum: String(checksum), + }; +} + +async function runTypedSelectRtt( + db: OliphauntDatabase, + iterations: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async (index) => { + const result = await db.query(`SELECT ${index % 17}::text AS value`); + checksum += Number(result.getText(0, 'value') ?? '0'); + }); + return { + id: 'typed_select_rtt', + description: 'Typed query() SELECT round trip including JS protocol response parsing', + latency, + checksum: String(checksum), + }; +} + +async function runParameterizedSelectRtt( + db: OliphauntDatabase, + iterations: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async (index) => { + const result = await db.query('SELECT $1::text AS value', [`value-${index}`]); + checksum += result.getText(0, 'value')?.length ?? 0; + }); + return { + id: 'parameterized_select_rtt', + description: 'Extended-query parameter binding round trip through query()', + latency, + checksum: String(checksum), + }; +} + +async function prepareDataset( + db: OliphauntDatabase, + rows: number, +): Promise { + await db.execute(` + DROP TABLE IF EXISTS rn_bench_events; + CREATE TABLE rn_bench_events ( + id integer PRIMARY KEY, + bucket integer NOT NULL, + label text NOT NULL, + amount integer NOT NULL, + payload text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX rn_bench_events_bucket_idx ON rn_bench_events(bucket); + CREATE INDEX rn_bench_events_label_idx ON rn_bench_events(label); + `); + + const started = monotonicNow(); + await db.transaction(async (tx) => { + for (let index = 1; index <= rows; index += 1) { + await tx.query( + `INSERT INTO rn_bench_events (id, bucket, label, amount, payload) + VALUES ($1, $2, $3, $4, $5)`, + [ + index, + index % 32, + `label-${index % 128}`, + index % 10_000, + `payload-${index}-${'x'.repeat(48)}`, + ], + ); + } + }); + const totalMs = monotonicNow() - started; + + return { + id: 'transaction_insert', + description: 'Parameterized INSERT workload inside one transaction', + throughput: { + rows, + totalMs, + rowsPerSecond: rows / (totalMs / 1_000), + }, + rows, + }; +} + +async function runIndexedLookup( + db: OliphauntDatabase, + iterations: number, + rows: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async (index) => { + const id = (index % rows) + 1; + const result = await db.query('SELECT payload FROM rn_bench_events WHERE id = $1', [id]); + checksum += result.getText(0, 'payload')?.length ?? 0; + }); + return { + id: 'indexed_lookup', + description: 'Parameterized primary-key lookup against the benchmark table', + latency, + checksum: String(checksum), + }; +} + +async function runAggregateScan( + db: OliphauntDatabase, + iterations: number, +): Promise { + let checksum = 0; + const latency = await measureLatency(iterations, async (index) => { + const result = await db.query( + `SELECT count(*)::text AS rows, coalesce(sum(amount), 0)::text AS total + FROM rn_bench_events + WHERE bucket = $1`, + [index % 32], + ); + checksum += Number(result.getText(0, 'rows') ?? '0'); + checksum += Number(result.getText(0, 'total') ?? '0'); + }); + return { + id: 'indexed_aggregate', + description: 'Indexed aggregate with count and sum over a bucket predicate', + latency, + checksum: String(checksum), + }; +} + +async function runIndexedUpdates( + db: OliphauntDatabase, + iterations: number, + rows: number, +): Promise { + const latency = await measureLatency(iterations, async (index) => { + const id = ((index * 17) % rows) + 1; + await db.query( + `UPDATE rn_bench_events + SET amount = amount + 1, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING amount::text AS amount`, + [id], + ); + }); + const checksum = await db.query( + 'SELECT coalesce(sum(amount), 0)::text AS checksum FROM rn_bench_events', + ); + return { + id: 'indexed_update', + description: 'Single-row parameterized UPDATE by primary key', + latency, + checksum: checksum.getText(0, 'checksum') ?? '0', + }; +} + +async function runBackgroundCheckpointLatency( + db: OliphauntDatabase, + iterations: number, +): Promise { + const latency = await measureLatency(iterations, async () => { + const result = await db.prepareForBackground({ + cancelActiveWork: false, + checkpointWhenIdle: true, + }); + if (!result.checkpointed) { + throw new Error( + `background checkpoint was skipped: ${result.skippedCheckpointReason ?? 'unknown'}`, + ); + } + }); + return { + id: 'background_checkpoint', + description: 'prepareForBackground checkpoint latency while the direct session is idle', + latency, + }; +} + +async function runLargeResult( + db: OliphauntDatabase, + rows: number, +): Promise { + const sql = ` + SELECT id::text AS id, label, payload + FROM rn_bench_events + ORDER BY id + LIMIT ${rows} + `; + let responseBytes = 0; + const latency = await measureLatency(20, async () => { + const response = await db.execProtocolRaw(simpleQuery(sql)); + responseBytes = response.byteLength; + }); + return { + id: 'large_result_raw', + description: 'Large raw protocol result transfer without JS row parsing', + latency, + rows, + responseBytes, + checksum: String(responseBytes), + }; +} + +async function measureLatency( + iterations: number, + run: (index: number) => Promise, +): Promise { + const values: number[] = []; + const totalStart = monotonicNow(); + for (let index = 0; index < iterations; index += 1) { + const started = monotonicNow(); + await run(index); + values.push(monotonicNow() - started); + } + const totalMs = monotonicNow() - totalStart; + const sorted = [...values].sort((left, right) => left - right); + return { + iterations, + totalMs, + minMs: sorted[0] ?? 0, + meanMs: values.reduce((sum, value) => sum + value, 0) / values.length, + p50Ms: percentileSorted(sorted, 0.5), + p90Ms: percentileSorted(sorted, 0.9), + p95Ms: percentileSorted(sorted, 0.95), + p99Ms: percentileSorted(sorted, 0.99), + maxMs: sorted[sorted.length - 1] ?? 0, + }; +} + +function percentileSorted(sorted: readonly number[], percentile: number): number { + if (sorted.length === 0) { + return 0; + } + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * percentile) - 1); + return sorted[index] ?? 0; +} + +function assertBenchmarkCapabilities(capabilities: EngineCapabilities): void { + if (capabilities.engine !== 'nativeDirect') { + throw new Error( + `React Native benchmark currently requires nativeDirect, got ${capabilities.engine}`, + ); + } + if (capabilities.rawProtocolTransport !== 'jsi-array-buffer') { + throw new Error( + `React Native benchmark requires JSI ArrayBuffer transport, got ${capabilities.rawProtocolTransport}`, + ); + } + if (!capabilities.protocolRaw || !capabilities.simpleQuery) { + throw new Error('React Native benchmark requires raw protocol and simple-query support'); + } +} + +function monotonicNow(): number { + const performanceNow = globalThis.performance?.now.bind(globalThis.performance); + return performanceNow ? performanceNow() : Date.now(); +} + +function startTimerLivenessProbe(): { ticks: () => number; stop: () => void } { + let active = true; + let ticks = 0; + let timeout: ReturnType | undefined; + const schedule = () => { + timeout = setTimeout(() => { + if (!active) { + return; + } + ticks += 1; + schedule(); + }, 0); + }; + schedule(); + return { + ticks: () => ticks, + stop: () => { + active = false; + if (timeout !== undefined) { + clearTimeout(timeout); + } + }, + }; +} diff --git a/src/sdks/react-native/src/client.ts b/src/sdks/react-native/src/client.ts new file mode 100644 index 00000000..6bb3b360 --- /dev/null +++ b/src/sdks/react-native/src/client.ts @@ -0,0 +1,778 @@ +import { + backupJsi, + execProtocolRawJsi, + execProtocolStreamJsi, + jsiTransportSupportsProtocolStream, + requireJsiRawProtocolTransport, + resolveJsiRawProtocolTransport, + restoreJsi, + type JsiRawProtocolTransport, +} from './jsiTransport'; +import { simpleQuery } from './protocol'; +import { + assertSuccessfulQueryResponse, + extendedQuery, + parseQueryResponse, + type QueryParam, + type QueryResult, +} from './query'; +import type { + NativeCapabilities, + NativeEngineModeSupport, + NativeOpenConfig, + NativePackageSizeReport, + NativeProcessMemoryReport, + NativeResourceConfig, + Spec as NativeOliphauntModule, +} from './specs/NativeOliphaunt'; + +export type EngineMode = 'nativeDirect' | 'nativeBroker' | 'nativeServer'; +export type DurabilityProfile = 'safe' | 'balanced' | 'fastDev'; +export type RuntimeFootprintProfile = 'throughput' | 'balancedMobile' | 'smallMobile'; +export type RawProtocolTransport = 'jsi-array-buffer'; +export type BackupFormat = 'sql' | 'physicalArchive' | 'oliphauntArchive'; +export type PostgresStartupGUC = + | string + | { + readonly name: string; + readonly value: string; + }; + +export type BinaryInput = ArrayBuffer | ArrayBufferView | Uint8Array | ReadonlyArray; + +export type OpenConfig = { + engine?: EngineMode; + root?: string; + temporary?: boolean; + durability?: DurabilityProfile; + runtimeFootprint?: RuntimeFootprintProfile; + startupGUCs?: ReadonlyArray; + username?: string; + database?: string; + extensions?: ReadonlyArray; + libraryPath?: string; + runtimeDirectory?: string; + resourceRoot?: string; +}; + +export type PackageSizeReportOptions = { + resourceRoot?: string; +}; + +export type ExtensionSizeReport = { + name: string; + fileCount: number; + bytes: number; +}; + +export type PackageSizeReport = { + packageBytes: number; + runtimeBytes: number; + templatePgdataBytes: number; + staticRegistryBytes: number; + selectedExtensionBytes: number; + mobileStaticRegistryState: string | null; + mobileStaticRegistryRegistered: string[]; + mobileStaticRegistryPending: string[]; + nativeModuleStems: string[]; + extensions: ExtensionSizeReport[]; +}; + +export type ProcessMemoryReport = { + source: string; + residentBytes?: number; + physicalFootprintBytes?: number; + virtualBytes?: number; + peakResidentBytes?: number; + totalPssKb?: number; + totalPrivateDirtyKb?: number; + totalSharedDirtyKb?: number; + nativeHeapAllocatedBytes?: number; + nativeHeapSizeBytes?: number; + runtimeTotalBytes?: number; + runtimeFreeBytes?: number; +}; + +export type EngineCapabilities = { + engine: EngineMode; + processIsolated: boolean; + multiRoot: boolean; + reopenable: boolean; + sameRootLogicalReopen: boolean; + rootSwitchable: boolean; + crashRestartable: boolean; + independentSessions: boolean; + maxClientSessions: number; + protocolRaw: boolean; + protocolStream: boolean; + queryCancel: boolean; + backupRestore: boolean; + backupFormats: BackupFormat[]; + restoreFormats: BackupFormat[]; + simpleQuery: boolean; + extensions: boolean; + connectionString?: string; + rawProtocolTransport: RawProtocolTransport; +}; + +export type EngineModeSupport = { + engine: EngineMode; + available: boolean; + capabilities: EngineCapabilities; + unavailableReason?: string; +}; + +export type BackupArtifact = { + format: BackupFormat; + bytes: Uint8Array; +}; + +export type RestoreOptions = { + root: string; + artifact: BackupArtifact; + replaceExisting?: boolean; + libraryPath?: string; +}; + +export type BackgroundPreparationOptions = { + cancelActiveWork?: boolean; + checkpointWhenIdle?: boolean; +}; + +export type BackgroundPreparationResult = { + cancelledActiveWork: boolean; + checkpointed: boolean; + skippedCheckpointReason?: 'activeWork' | 'transactionActive'; +}; + +export type OliphauntClient = { + supportedModes(): Promise; + packageSizeReport(options?: PackageSizeReportOptions): Promise; + processMemory(): Promise; + open(config?: OpenConfig): Promise; + restore(options: RestoreOptions): Promise; +}; + +export type ProtocolChunkCallback = (chunk: Uint8Array) => void; + +export type OliphauntTransaction = { + execute(sql: string): Promise; + query(sql: string, parameters?: ReadonlyArray): Promise; + execProtocolRaw(input: BinaryInput): Promise; + execProtocolStream(input: BinaryInput, onChunk: ProtocolChunkCallback): Promise; +}; + +export class OliphauntDatabase { + readonly #native: NativeOliphauntModule; + readonly #handle: number; + readonly #jsiTransport: JsiRawProtocolTransport; + #closed = false; + #activeTransaction = false; + #activeOperations = 0; + + constructor( + native: NativeOliphauntModule, + handle: number, + jsiTransport: JsiRawProtocolTransport, + ) { + this.#native = native; + this.#handle = handle; + this.#jsiTransport = jsiTransport; + } + + get handle(): number { + return this.#handle; + } + + async capabilities(): Promise { + this.#assertOpen(); + return normalizeCapabilities(await this.#native.capabilities(this.#handle), this.#jsiTransport); + } + + async connectionString(): Promise { + return (await this.capabilities()).connectionString; + } + + async supportsBackupFormat(format: BackupFormat): Promise { + return supportsBackupFormat(await this.capabilities(), format); + } + + async supportsRestoreFormat(format: BackupFormat): Promise { + return supportsRestoreFormat(await this.capabilities(), format); + } + + async execute(sql: string): Promise { + const response = await this.execProtocolRaw(simpleQuery(sql)); + assertSuccessfulQueryResponse(response); + return response; + } + + async query(sql: string, parameters: ReadonlyArray = []): Promise { + if (parameters.length === 0) { + return parseQueryResponse(await this.execute(sql)); + } + return parseQueryResponse(await this.execProtocolRaw(extendedQuery(sql, parameters))); + } + + async execProtocolRaw(input: BinaryInput): Promise { + this.#assertNoActiveTransaction(); + return this.#execProtocolRawUnlocked(input); + } + + async #execProtocolRawUnlocked(input: BinaryInput): Promise { + this.#assertOpen(); + const requestBytes = toUint8Array(input); + return this.#runNativeOperation(() => + execProtocolRawJsi(this.#jsiTransport, this.#handle, requestBytes), + ); + } + + async execProtocolStream(input: BinaryInput, onChunk: ProtocolChunkCallback): Promise { + this.#assertNoActiveTransaction(); + await this.#execProtocolStreamUnlocked(input, onChunk); + } + + async #execProtocolStreamUnlocked( + input: BinaryInput, + onChunk: ProtocolChunkCallback, + ): Promise { + this.#assertOpen(); + const requestBytes = toUint8Array(input); + const streamed = await this.#runNativeOperation(() => + execProtocolStreamJsi(this.#jsiTransport, this.#handle, requestBytes, onChunk), + ); + if (!streamed) { + onChunk(await this.#execProtocolRawUnlocked(requestBytes)); + } + } + + async backup(format: BackupFormat = 'physicalArchive'): Promise { + this.#assertOpen(); + this.#assertNoActiveTransaction(); + const capabilities = await this.capabilities(); + if (!supportsBackupFormat(capabilities, format)) { + throw new Error(`${format} backup is not supported by ${capabilities.engine}`); + } + return { + format, + bytes: await this.#runNativeOperation(() => + backupJsi(this.#jsiTransport, this.#handle, format), + ), + }; + } + + async checkpoint(): Promise { + await this.execute('CHECKPOINT'); + } + + async prepareForBackground( + options: BackgroundPreparationOptions = {}, + ): Promise { + this.#assertOpen(); + const hadActiveWork = this.#activeOperations > 0; + const shouldCancel = options.cancelActiveWork !== false; + const shouldCheckpoint = options.checkpointWhenIdle !== false; + let cancelledActiveWork = false; + if (shouldCancel && hadActiveWork) { + await this.#native.cancel(this.#handle); + cancelledActiveWork = true; + } + if (!shouldCheckpoint) { + return { cancelledActiveWork, checkpointed: false }; + } + if (this.#activeTransaction) { + return { + cancelledActiveWork, + checkpointed: false, + skippedCheckpointReason: 'transactionActive', + }; + } + if (hadActiveWork || this.#activeOperations > 0) { + return { + cancelledActiveWork, + checkpointed: false, + skippedCheckpointReason: 'activeWork', + }; + } + await this.checkpoint(); + return { cancelledActiveWork, checkpointed: true }; + } + + async resumeFromBackground(): Promise { + await this.execute('SELECT 1'); + } + + async cancel(): Promise { + this.#assertOpen(); + await this.#native.cancel(this.#handle); + } + + async transaction(body: (transaction: OliphauntTransaction) => Promise | T): Promise { + this.#assertOpen(); + if (this.#activeTransaction) { + throw new Error(transactionPinnedMessage); + } + this.#activeTransaction = true; + const transaction = new OliphauntTransactionHandle( + (input) => this.#execProtocolRawUnlocked(input), + (input, onChunk) => this.#execProtocolStreamUnlocked(input, onChunk), + ); + try { + await transaction.execute('BEGIN'); + const result = await body(transaction); + await transaction.execute('COMMIT'); + transaction.deactivate(); + return result; + } catch (error) { + try { + await transaction.execute('ROLLBACK'); + } catch { + // Preserve the original transaction failure; rollback is best-effort cleanup. + } + transaction.deactivate(); + throw error; + } finally { + this.#activeTransaction = false; + } + } + + async close(): Promise { + if (this.#closed) { + return; + } + this.#closed = true; + await this.#native.close(this.#handle); + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + #assertOpen(): void { + if (this.#closed) { + throw new Error('Oliphaunt database is closed'); + } + } + + #assertNoActiveTransaction(): void { + if (this.#activeTransaction) { + throw new Error(transactionPinnedMessage); + } + } + + async #runNativeOperation(body: () => Promise): Promise { + this.#activeOperations += 1; + try { + return await body(); + } finally { + this.#activeOperations -= 1; + } + } +} + +class OliphauntTransactionHandle implements OliphauntTransaction { + readonly #execRaw: (input: BinaryInput) => Promise; + readonly #execStream: (input: BinaryInput, onChunk: ProtocolChunkCallback) => Promise; + #active = true; + + constructor( + execRaw: (input: BinaryInput) => Promise, + execStream: (input: BinaryInput, onChunk: ProtocolChunkCallback) => Promise, + ) { + this.#execRaw = execRaw; + this.#execStream = execStream; + } + + async execute(sql: string): Promise { + const response = await this.execProtocolRaw(simpleQuery(sql)); + assertSuccessfulQueryResponse(response); + return response; + } + + async query(sql: string, parameters: ReadonlyArray = []): Promise { + if (parameters.length === 0) { + return parseQueryResponse(await this.execute(sql)); + } + return parseQueryResponse(await this.execProtocolRaw(extendedQuery(sql, parameters))); + } + + async execProtocolRaw(input: BinaryInput): Promise { + this.#assertActive(); + return this.#execRaw(input); + } + + async execProtocolStream(input: BinaryInput, onChunk: ProtocolChunkCallback): Promise { + this.#assertActive(); + await this.#execStream(input, onChunk); + } + + deactivate(): void { + this.#active = false; + } + + #assertActive(): void { + if (!this.#active) { + throw new Error('transaction is no longer active'); + } + } +} + +const transactionPinnedMessage = 'physical session is pinned; use the active OliphauntTransaction'; + +export function createOliphauntClient(native: NativeOliphauntModule): OliphauntClient { + return { + async supportedModes(): Promise { + const jsiTransport = resolveJsiRawProtocolTransport(); + return (await native.supportedModes()).map((support) => + normalizeEngineModeSupport(support, jsiTransport), + ); + }, + async packageSizeReport( + options: PackageSizeReportOptions = {}, + ): Promise { + const report = await native.packageSizeReport(normalizeResourceConfig(options)); + return report == null ? null : normalizePackageSizeReport(report); + }, + async processMemory(): Promise { + return normalizeProcessMemoryReport(await native.processMemory()); + }, + async open(config: OpenConfig = {}): Promise { + const jsiTransport = requireJsiRawProtocolTransport(); + const nativeConfig = normalizeOpenConfig(config); + const handle = await native.open(nativeConfig); + return new OliphauntDatabase(native, handle, jsiTransport); + }, + async restore(options: RestoreOptions): Promise { + validateRootPath(options.root, 'restore root'); + const artifact = options.artifact; + if (artifact.format !== 'physicalArchive') { + throw new Error( + `restore currently requires a physicalArchive artifact, got ${artifact.format}`, + ); + } + const libraryPath = validateOptionalPathOverride(options.libraryPath, 'libraryPath'); + return restoreJsi( + requireJsiRawProtocolTransport(), + options.root, + artifact.format, + toUint8Array(artifact.bytes), + options.replaceExisting === true, + libraryPath ?? null, + ); + }, + }; +} + +export function supportsBackupFormat( + capabilities: EngineCapabilities, + format: BackupFormat, +): boolean { + return capabilities.backupRestore && capabilities.backupFormats.includes(format); +} + +export function supportsRestoreFormat( + capabilities: EngineCapabilities, + format: BackupFormat, +): boolean { + return capabilities.backupRestore && capabilities.restoreFormats.includes(format); +} + +function normalizeEngineModeSupport( + native: NativeEngineModeSupport, + jsiTransport: JsiRawProtocolTransport | null, +): EngineModeSupport { + const transportAvailable = jsiTransport != null; + return { + engine: parseEngine(native.engine), + available: native.available && transportAvailable, + capabilities: normalizeCapabilities(native.capabilities, jsiTransport), + unavailableReason: transportAvailable + ? native.unavailableReason + : 'React Native New Architecture JSI ArrayBuffer transport is not installed', + }; +} + +function normalizeOpenConfig(config: OpenConfig): NativeOpenConfig { + if (config.root !== undefined && config.temporary === true) { + throw new Error('root and temporary are mutually exclusive'); + } + validateRootPath(config.root, 'database root'); + validateStartupIdentity(config.username, 'username'); + validateStartupIdentity(config.database, 'database'); + const startupGUCs = config.startupGUCs ? validateStartupGUCs(config.startupGUCs) : undefined; + const runtimeFootprint = normalizeRuntimeFootprint(config.runtimeFootprint ?? 'balancedMobile'); + const libraryPath = validateOptionalPathOverride(config.libraryPath, 'libraryPath'); + const runtimeDirectory = validateOptionalPathOverride( + config.runtimeDirectory, + 'runtimeDirectory', + ); + const resourceRoot = validateOptionalPathOverride(config.resourceRoot, 'resourceRoot'); + return { + engine: config.engine ?? 'nativeDirect', + root: config.root, + temporary: config.temporary, + durability: config.durability ?? 'balanced', + runtimeFootprint, + startupGUCs, + username: config.username, + database: config.database, + extensions: config.extensions ? validateExtensionIds(config.extensions) : undefined, + libraryPath, + runtimeDirectory, + resourceRoot, + }; +} + +function normalizeResourceConfig(options: PackageSizeReportOptions): NativeResourceConfig { + return { + resourceRoot: validateOptionalPathOverride(options.resourceRoot, 'resourceRoot'), + }; +} + +function normalizeProcessMemoryReport(native: NativeProcessMemoryReport): ProcessMemoryReport { + const source = + typeof native.source === 'string' && native.source.trim().length > 0 + ? native.source + : 'unknown'; + return compactUndefined({ + source, + residentBytes: finiteNonNegative(native.residentBytes), + physicalFootprintBytes: finiteNonNegative(native.physicalFootprintBytes), + virtualBytes: finiteNonNegative(native.virtualBytes), + peakResidentBytes: finiteNonNegative(native.peakResidentBytes), + totalPssKb: finiteNonNegative(native.totalPssKb), + totalPrivateDirtyKb: finiteNonNegative(native.totalPrivateDirtyKb), + totalSharedDirtyKb: finiteNonNegative(native.totalSharedDirtyKb), + nativeHeapAllocatedBytes: finiteNonNegative(native.nativeHeapAllocatedBytes), + nativeHeapSizeBytes: finiteNonNegative(native.nativeHeapSizeBytes), + runtimeTotalBytes: finiteNonNegative(native.runtimeTotalBytes), + runtimeFreeBytes: finiteNonNegative(native.runtimeFreeBytes), + }); +} + +function finiteNonNegative(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function compactUndefined>(value: T): T { + for (const key of Object.keys(value)) { + if (value[key] === undefined) { + delete value[key]; + } + } + return value; +} + +function validateRootPath(value: string | undefined, label: string): void { + if (value === undefined) { + return; + } + if (value.trim().length === 0) { + throw new Error(rootPathMessage(label, 'empty')); + } + if (value.includes('\0')) { + throw new Error(rootPathMessage(label, 'nul')); + } +} + +function normalizeRuntimeFootprint(profile: RuntimeFootprintProfile): RuntimeFootprintProfile { + if (profile === 'throughput' || profile === 'balancedMobile' || profile === 'smallMobile') { + return profile; + } + throw new Error(`unknown liboliphaunt runtime footprint profile '${profile}'`); +} + +function rootPathMessage(label: string, reason: 'empty' | 'nul'): string { + switch (`${label}:${reason}`) { + case 'database root:empty': + return 'database root must not be empty'; + case 'database root:nul': + return 'database root must not contain NUL bytes'; + case 'restore root:empty': + return 'restore root must not be empty'; + case 'restore root:nul': + return 'restore root must not contain NUL bytes'; + default: + return reason === 'empty' + ? `${label} must not be empty` + : `${label} must not contain NUL bytes`; + } +} + +function validateStartupIdentity(value: string | undefined, label: string): void { + if (value === undefined) { + return; + } + if (value.trim().length === 0) { + throw new Error(`${label} must not be empty`); + } + if (value.includes('\0')) { + throw new Error(`${label} must not contain NUL bytes`); + } +} + +function validateStartupGUCs(gucs: ReadonlyArray): string[] { + return gucs.map((guc) => { + const [name, value] = + typeof guc === 'string' ? splitStartupGUCAssignment(guc) : [guc.name, guc.value]; + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + throw new Error('PostgreSQL startup GUC name must not be empty'); + } + if (trimmedName.includes('\0') || value.includes('\0')) { + throw new Error('PostgreSQL startup GUC must not contain NUL bytes'); + } + if (!/^[A-Za-z0-9_.]+$/.test(trimmedName)) { + throw new Error( + `PostgreSQL startup GUC name '${name}' must contain only ASCII letters, digits, '_' or '.'`, + ); + } + if (value.trim().length === 0) { + throw new Error(`PostgreSQL startup GUC '${name}' value must not be empty`); + } + return `${trimmedName}=${value}`; + }); +} + +function splitStartupGUCAssignment(assignment: string): [string, string] { + const index = assignment.indexOf('='); + if (index < 0) { + throw new Error('PostgreSQL startup GUC string must use name=value'); + } + return [assignment.slice(0, index), assignment.slice(index + 1)]; +} + +function validateOptionalPathOverride( + value: string | undefined, + label: string, +): string | undefined { + if (value === undefined) { + return undefined; + } + if (value.trim().length === 0) { + throw new Error(pathOverrideMessage(label, 'empty')); + } + if (value.includes('\0')) { + throw new Error(pathOverrideMessage(label, 'nul')); + } + return value; +} + +function pathOverrideMessage(label: string, reason: 'empty' | 'nul'): string { + switch (`${label}:${reason}`) { + case 'libraryPath:empty': + return 'libraryPath must not be empty'; + case 'libraryPath:nul': + return 'libraryPath must not contain NUL bytes'; + case 'runtimeDirectory:empty': + return 'runtimeDirectory must not be empty'; + case 'runtimeDirectory:nul': + return 'runtimeDirectory must not contain NUL bytes'; + case 'resourceRoot:empty': + return 'resourceRoot must not be empty'; + case 'resourceRoot:nul': + return 'resourceRoot must not contain NUL bytes'; + default: + return reason === 'empty' + ? `${label} must not be empty` + : `${label} must not contain NUL bytes`; + } +} + +function validateExtensionIds(extensions: ReadonlyArray): string[] { + const normalized: string[] = []; + for (const extension of extensions) { + const trimmed = extension.trim(); + if (trimmed.length === 0) { + continue; + } + if (!/^[A-Za-z0-9._-]{1,128}$/.test(trimmed)) { + throw new Error( + `React Native Oliphaunt extension id '${trimmed}' must contain 1 to 128 ASCII letters, digits, '.', '_' or '-'`, + ); + } + normalized.push(trimmed); + } + return normalized; +} + +function normalizePackageSizeReport(native: NativePackageSizeReport): PackageSizeReport { + return { + packageBytes: native.packageBytes, + runtimeBytes: native.runtimeBytes, + templatePgdataBytes: native.templatePgdataBytes, + staticRegistryBytes: native.staticRegistryBytes, + selectedExtensionBytes: native.selectedExtensionBytes, + mobileStaticRegistryState: native.mobileStaticRegistryState ?? null, + mobileStaticRegistryRegistered: [...(native.mobileStaticRegistryRegistered ?? [])], + mobileStaticRegistryPending: [...(native.mobileStaticRegistryPending ?? [])], + nativeModuleStems: [...(native.nativeModuleStems ?? [])], + extensions: native.extensions.map((extension) => ({ + name: extension.name, + fileCount: extension.fileCount, + bytes: extension.bytes, + })), + }; +} + +function normalizeCapabilities( + native: NativeCapabilities, + jsiTransport: JsiRawProtocolTransport | null = resolveJsiRawProtocolTransport(), +): EngineCapabilities { + return { + engine: parseEngine(native.engine), + processIsolated: native.processIsolated, + multiRoot: native.multiRoot, + reopenable: native.reopenable, + sameRootLogicalReopen: native.sameRootLogicalReopen, + rootSwitchable: native.rootSwitchable, + crashRestartable: native.crashRestartable, + independentSessions: native.independentSessions, + maxClientSessions: native.maxClientSessions, + protocolRaw: native.protocolRaw && jsiTransport != null, + protocolStream: native.protocolStream && jsiTransportSupportsProtocolStream(jsiTransport), + queryCancel: native.queryCancel, + backupRestore: native.backupRestore, + backupFormats: native.backupFormats.map(parseBackupFormat), + restoreFormats: native.restoreFormats.map(parseBackupFormat), + simpleQuery: native.simpleQuery, + extensions: native.extensions, + connectionString: native.connectionString, + rawProtocolTransport: 'jsi-array-buffer', + }; +} + +function parseBackupFormat(format: string): BackupFormat { + switch (format) { + case 'sql': + case 'physicalArchive': + case 'oliphauntArchive': + return format; + default: + throw new Error(`unknown backup format '${format}'`); + } +} + +function parseEngine(engine: string): EngineMode { + switch (engine) { + case 'nativeDirect': + case 'nativeBroker': + case 'nativeServer': + return engine; + default: + throw new Error(`unknown native engine '${engine}'`); + } +} + +function toUint8Array(input: BinaryInput): Uint8Array { + if (input instanceof Uint8Array) { + return input; + } + if (ArrayBuffer.isView(input)) { + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + return Uint8Array.from(input); +} diff --git a/src/sdks/react-native/src/generated/extensions.json b/src/sdks/react-native/src/generated/extensions.json new file mode 100644 index 00000000..641b2756 --- /dev/null +++ b/src/sdks/react-native/src/generated/extensions.json @@ -0,0 +1,1249 @@ +{ + "consumer": "react-native", + "extensions": [ + { + "archive": "extensions/amcheck.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "amcheck", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "amcheck", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "amcheck", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "amcheck", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/auto_explain.tar.zst", + "creates-extension": false, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "auto_explain", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "auto_explain", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "auto_explain", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "auto_explain", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/bloom.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "bloom", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "bloom", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "bloom", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "bloom", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gin.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gin", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gin", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gin", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gin", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/btree_gist.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "btree_gist", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "btree_gist", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "btree_gist", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "btree_gist", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/citext.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "citext", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "citext", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "citext", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "citext", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/cube.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "cube", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "cube", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "cube", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "cube", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_int.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_int", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_int", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_int", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/dict_xsyn.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/xsyn_sample.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "dict_xsyn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "dict_xsyn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "dict_xsyn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/xsyn_sample.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "dict_xsyn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/earthdistance.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "cube" + ], + "desktop-release-ready": true, + "display-name": "earthdistance", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "earthdistance", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "earthdistance", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [ + "cube" + ], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "earthdistance", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/file_fdw.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "file_fdw", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "file_fdw", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "file_fdw", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "file_fdw", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/fuzzystrmatch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "fuzzystrmatch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "fuzzystrmatch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "fuzzystrmatch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "fuzzystrmatch", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/hstore.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "hstore", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "hstore", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "hstore", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "hstore", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/intarray.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "intarray", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "intarray", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "_int", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "intarray", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/isn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "isn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "isn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "isn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "isn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/lo.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "lo", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "lo", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "lo", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "lo", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/ltree.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "ltree", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "ltree", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "ltree", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "ltree", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pageinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pageinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pageinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pageinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pageinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_buffercache.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_buffercache", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_buffercache", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_buffercache", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_buffercache", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_freespacemap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_freespacemap", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_freespacemap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_freespacemap", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_freespacemap", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_hashids.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_hashids", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_hashids", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_hashids", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_hashids", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_ivm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_ivm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_ivm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_ivm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_ivm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_surgery.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_surgery", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_surgery", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_surgery", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_surgery", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_textsearch.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_textsearch", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_textsearch", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_textsearch", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_textsearch", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_trgm.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_trgm", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_trgm", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_trgm", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_trgm", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_uuidv7.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_uuidv7", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_uuidv7", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_uuidv7", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pg_uuidv7", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_visibility.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_visibility", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_visibility", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_visibility", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_visibility", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pg_walinspect.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pg_walinspect", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pg_walinspect", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "pg_walinspect", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pg_walinspect", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgcrypto.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgcrypto", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "pgcrypto", + "mobile-release-ready": true, + "native-dependencies": [ + "openssl:3.5.6-libcrypto-wasix-static" + ], + "native-module-stem": "pgcrypto", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "pgcrypto", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/pgtap.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [ + "plpgsql" + ], + "desktop-release-ready": true, + "display-name": "pgtap", + "extension-sql-file-names": [ + "uninstall_pgtap.sql" + ], + "extension-sql-file-prefixes": [ + "pgtap-core", + "pgtap-schema" + ], + "id": "pgtap", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": null, + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "pgtap", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/postgis.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/contrib/postgis-3.6/legacy.sql", + "share/postgresql/contrib/postgis-3.6/legacy_gist.sql", + "share/postgresql/contrib/postgis-3.6/legacy_minimal.sql", + "share/postgresql/contrib/postgis-3.6/postgis.sql", + "share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql", + "share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql", + "share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql", + "share/postgresql/proj/proj.db" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "PostGIS", + "extension-sql-file-names": [ + "uninstall_postgis.sql" + ], + "extension-sql-file-prefixes": [ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis" + ], + "id": "postgis", + "mobile-release-ready": true, + "native-dependencies": [ + "geos:3.14.1-static", + "proj:9.8.1-static", + "sqlite:3.53.1-static", + "libxml2:2.14.6-static", + "json-c:0.18-static", + "libiconv:1.19-static" + ], + "native-module-stem": "postgis-3", + "postgres-major": 18, + "public": true, + "runtime-environment": [ + { + "name": "PROJ_DATA", + "path": "share/postgresql/proj", + "required_file": "proj.db" + } + ], + "runtime-share-data-files": [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgis", + "sql-name": "postgis", + "stable": true, + "support": { + "mobile": { + "android": "supported", + "ios": "supported" + }, + "native": { + "broker": "supported", + "direct": "supported", + "server": "supported" + }, + "wasix": { + "direct": "supported", + "server": "supported" + } + }, + "target-status": { + "mobile": null, + "native": "supported", + "wasix": "supported" + } + }, + { + "archive": "extensions/seg.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "seg", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "seg", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "seg", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "seg", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tablefunc.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tablefunc", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tablefunc", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tablefunc", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tablefunc", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tcn.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tcn", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tcn", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tcn", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tcn", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_rows.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_rows", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_rows", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_rows", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_rows", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/tsm_system_time.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "tsm_system_time", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "tsm_system_time", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "tsm_system_time", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "tsm_system_time", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/unaccent.tar.zst", + "creates-extension": true, + "data-files": [ + "share/postgresql/tsearch_data/unaccent.rules" + ], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "unaccent", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "unaccent", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "unaccent", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [ + "tsearch_data/unaccent.rules" + ], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "unaccent", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/uuid-ossp.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "uuid-ossp", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "uuid_ossp", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "uuid-ossp", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "postgres-contrib", + "sql-name": "uuid-ossp", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + }, + { + "archive": "extensions/vector.tar.zst", + "creates-extension": true, + "data-files": [], + "dependencies": [], + "desktop-release-ready": true, + "display-name": "pgvector", + "extension-sql-file-names": [], + "extension-sql-file-prefixes": [], + "id": "vector", + "mobile-release-ready": true, + "native-dependencies": [], + "native-module-stem": "vector", + "postgres-major": 18, + "public": true, + "runtime-environment": [], + "runtime-share-data-files": [], + "selected-extension-dependencies": [], + "shared-preload-libraries": [], + "source-kind": "oliphaunt-other-extension", + "sql-name": "vector", + "stable": true, + "support": {}, + "target-status": { + "mobile": null, + "native": null, + "wasix": null + } + } + ], + "format-version": 1, + "generated-from": [ + { + "name": "extension-catalog", + "path": "src/extensions/generated/extensions.catalog.json" + }, + { + "name": "extension-evidence", + "path": "src/extensions/generated/docs/extension-evidence.json" + } + ] +} diff --git a/src/sdks/react-native/src/generated/extensions.ts b/src/sdks/react-native/src/generated/extensions.ts new file mode 100644 index 00000000..4dc78a3e --- /dev/null +++ b/src/sdks/react-native/src/generated/extensions.ts @@ -0,0 +1,1132 @@ +// This file is generated by src/extensions/tools/check-extension-model.py. +// Do not edit by hand. + +export type GeneratedExtensionMetadata = { + readonly id: string; + readonly sqlName: string; + readonly displayName: string; + readonly postgresMajor: number; + readonly createsExtension: boolean; + readonly nativeModuleStem: string | null; + readonly dependencies: readonly string[]; + readonly selectedExtensionDependencies: readonly string[]; + readonly nativeDependencies: readonly string[]; + readonly sharedPreloadLibraries: readonly string[]; + readonly dataFiles: readonly string[]; + readonly runtimeShareDataFiles: readonly string[]; + readonly public: boolean; + readonly stable: boolean; + readonly desktopReleaseReady: boolean; + readonly mobileReleaseReady: boolean; + readonly targetStatus: { + readonly native?: string | null; + readonly wasix?: string | null; + readonly mobile?: string | null; + }; + readonly support: Readonly>>>; + readonly sourceKind: string; + readonly archive: string; +}; + +export const GENERATED_EXTENSION_METADATA = [ + { + archive: 'extensions/amcheck.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'amcheck', + id: 'amcheck', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'amcheck', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'amcheck', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/auto_explain.tar.zst', + createsExtension: false, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'auto_explain', + id: 'auto_explain', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'auto_explain', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'auto_explain', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/bloom.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'bloom', + id: 'bloom', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'bloom', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'bloom', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/btree_gin.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'btree_gin', + id: 'btree_gin', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'btree_gin', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'btree_gin', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/btree_gist.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'btree_gist', + id: 'btree_gist', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'btree_gist', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'btree_gist', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/citext.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'citext', + id: 'citext', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'citext', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'citext', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/cube.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'cube', + id: 'cube', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'cube', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'cube', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/dict_int.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'dict_int', + id: 'dict_int', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'dict_int', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'dict_int', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/dict_xsyn.tar.zst', + createsExtension: true, + dataFiles: ['share/postgresql/tsearch_data/xsyn_sample.rules'], + dependencies: [], + desktopReleaseReady: true, + displayName: 'dict_xsyn', + id: 'dict_xsyn', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'dict_xsyn', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: ['tsearch_data/xsyn_sample.rules'], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'dict_xsyn', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/earthdistance.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: ['cube'], + desktopReleaseReady: true, + displayName: 'earthdistance', + id: 'earthdistance', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'earthdistance', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: ['cube'], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'earthdistance', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/file_fdw.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'file_fdw', + id: 'file_fdw', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'file_fdw', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'file_fdw', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/fuzzystrmatch.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'fuzzystrmatch', + id: 'fuzzystrmatch', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'fuzzystrmatch', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'fuzzystrmatch', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/hstore.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'hstore', + id: 'hstore', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'hstore', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'hstore', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/intarray.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'intarray', + id: 'intarray', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: '_int', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'intarray', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/isn.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'isn', + id: 'isn', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'isn', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'isn', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/lo.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'lo', + id: 'lo', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'lo', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'lo', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/ltree.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'ltree', + id: 'ltree', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'ltree', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'ltree', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pageinspect.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pageinspect', + id: 'pageinspect', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pageinspect', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pageinspect', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_buffercache.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_buffercache', + id: 'pg_buffercache', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_buffercache', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_buffercache', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_freespacemap.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_freespacemap', + id: 'pg_freespacemap', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_freespacemap', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_freespacemap', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_hashids.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_hashids', + id: 'pg_hashids', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_hashids', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_hashids', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_ivm.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_ivm', + id: 'pg_ivm', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_ivm', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_ivm', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_surgery.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_surgery', + id: 'pg_surgery', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_surgery', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_surgery', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_textsearch.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_textsearch', + id: 'pg_textsearch', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_textsearch', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_textsearch', + stable: true, + support: { + mobile: { + android: 'supported', + ios: 'supported', + }, + native: { + broker: 'supported', + direct: 'supported', + server: 'supported', + }, + wasix: { + direct: 'supported', + server: 'supported', + }, + }, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_trgm.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_trgm', + id: 'pg_trgm', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_trgm', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_trgm', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_uuidv7.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_uuidv7', + id: 'pg_uuidv7', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_uuidv7', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pg_uuidv7', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_visibility.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_visibility', + id: 'pg_visibility', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_visibility', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_visibility', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pg_walinspect.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pg_walinspect', + id: 'pg_walinspect', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'pg_walinspect', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pg_walinspect', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pgcrypto.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pgcrypto', + id: 'pgcrypto', + mobileReleaseReady: true, + nativeDependencies: ['openssl:3.5.6-libcrypto-wasix-static'], + nativeModuleStem: 'pgcrypto', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'pgcrypto', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/pgtap.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: ['plpgsql'], + desktopReleaseReady: true, + displayName: 'pgtap', + id: 'pgtap', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: null, + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'pgtap', + stable: true, + support: { + mobile: { + android: 'supported', + ios: 'supported', + }, + native: { + broker: 'supported', + direct: 'supported', + server: 'supported', + }, + wasix: { + direct: 'supported', + server: 'supported', + }, + }, + targetStatus: { + mobile: null, + native: 'supported', + wasix: 'supported', + }, + }, + { + archive: 'extensions/postgis.tar.zst', + createsExtension: true, + dataFiles: [ + 'share/postgresql/contrib/postgis-3.6/legacy.sql', + 'share/postgresql/contrib/postgis-3.6/legacy_gist.sql', + 'share/postgresql/contrib/postgis-3.6/legacy_minimal.sql', + 'share/postgresql/contrib/postgis-3.6/postgis.sql', + 'share/postgresql/contrib/postgis-3.6/postgis_upgrade.sql', + 'share/postgresql/contrib/postgis-3.6/spatial_ref_sys.sql', + 'share/postgresql/contrib/postgis-3.6/uninstall_legacy.sql', + 'share/postgresql/contrib/postgis-3.6/uninstall_postgis.sql', + 'share/postgresql/proj/proj.db', + ], + dependencies: [], + desktopReleaseReady: true, + displayName: 'PostGIS', + id: 'postgis', + mobileReleaseReady: true, + nativeDependencies: [ + 'geos:3.14.1-static', + 'proj:9.8.1-static', + 'sqlite:3.53.1-static', + 'libxml2:2.14.6-static', + 'json-c:0.18-static', + 'libiconv:1.19-static', + ], + nativeModuleStem: 'postgis-3', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [ + 'contrib/postgis-3.6/legacy.sql', + 'contrib/postgis-3.6/legacy_gist.sql', + 'contrib/postgis-3.6/legacy_minimal.sql', + 'contrib/postgis-3.6/postgis.sql', + 'contrib/postgis-3.6/postgis_upgrade.sql', + 'contrib/postgis-3.6/spatial_ref_sys.sql', + 'contrib/postgis-3.6/uninstall_legacy.sql', + 'contrib/postgis-3.6/uninstall_postgis.sql', + 'proj/proj.db', + ], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgis', + sqlName: 'postgis', + stable: true, + support: { + mobile: { + android: 'supported', + ios: 'supported', + }, + native: { + broker: 'supported', + direct: 'supported', + server: 'supported', + }, + wasix: { + direct: 'supported', + server: 'supported', + }, + }, + targetStatus: { + mobile: null, + native: 'supported', + wasix: 'supported', + }, + }, + { + archive: 'extensions/seg.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'seg', + id: 'seg', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'seg', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'seg', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tablefunc.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tablefunc', + id: 'tablefunc', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tablefunc', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tablefunc', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tcn.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tcn', + id: 'tcn', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tcn', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tcn', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tsm_system_rows.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tsm_system_rows', + id: 'tsm_system_rows', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tsm_system_rows', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tsm_system_rows', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/tsm_system_time.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'tsm_system_time', + id: 'tsm_system_time', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'tsm_system_time', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'tsm_system_time', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/unaccent.tar.zst', + createsExtension: true, + dataFiles: ['share/postgresql/tsearch_data/unaccent.rules'], + dependencies: [], + desktopReleaseReady: true, + displayName: 'unaccent', + id: 'unaccent', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'unaccent', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: ['tsearch_data/unaccent.rules'], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'unaccent', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/uuid-ossp.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'uuid-ossp', + id: 'uuid_ossp', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'uuid-ossp', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'postgres-contrib', + sqlName: 'uuid-ossp', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, + { + archive: 'extensions/vector.tar.zst', + createsExtension: true, + dataFiles: [], + dependencies: [], + desktopReleaseReady: true, + displayName: 'pgvector', + id: 'vector', + mobileReleaseReady: true, + nativeDependencies: [], + nativeModuleStem: 'vector', + postgresMajor: 18, + public: true, + runtimeShareDataFiles: [], + selectedExtensionDependencies: [], + sharedPreloadLibraries: [], + sourceKind: 'oliphaunt-other-extension', + sqlName: 'vector', + stable: true, + support: {}, + targetStatus: { + mobile: null, + native: null, + wasix: null, + }, + }, +] as const satisfies readonly GeneratedExtensionMetadata[]; + +export function generatedExtensionBySqlName( + sqlName: string, +): GeneratedExtensionMetadata | undefined { + return GENERATED_EXTENSION_METADATA.find((extension) => extension.sqlName === sqlName); +} + +export function generatedSharedPreloadLibraries(extensionSqlNames: readonly string[]): string[] { + const libraries = new Set(); + for (const sqlName of extensionSqlNames) { + const extension = generatedExtensionBySqlName(sqlName); + for (const library of extension?.sharedPreloadLibraries ?? []) { + libraries.add(library); + } + } + return [...libraries].sort(); +} diff --git a/src/sdks/react-native/src/index.ts b/src/sdks/react-native/src/index.ts new file mode 100644 index 00000000..3a3a957f --- /dev/null +++ b/src/sdks/react-native/src/index.ts @@ -0,0 +1,76 @@ +import NativeOliphaunt from './specs/NativeOliphaunt'; +import { createOliphauntClient } from './client'; + +export type { + BackupArtifact, + BackupFormat, + BackgroundPreparationOptions, + BackgroundPreparationResult, + BinaryInput, + DurabilityProfile, + EngineCapabilities, + EngineMode, + EngineModeSupport, + ExtensionSizeReport, + OpenConfig, + PackageSizeReport, + PackageSizeReportOptions, + ProcessMemoryReport, + OliphauntClient, + OliphauntTransaction, + ProtocolChunkCallback, + RawProtocolTransport, + RuntimeFootprintProfile, + PostgresStartupGUC, + RestoreOptions, +} from './client'; +export { + OliphauntDatabase, + createOliphauntClient, + supportsBackupFormat, + supportsRestoreFormat, +} from './client'; +export { simpleQuery } from './protocol'; +export type { + QueryField, + QueryFormat, + QueryParam, + QueryResult, + QueryRow, + QueryBinaryInput, + PostgresErrorField, +} from './query'; +export { extendedQuery, parseQueryResponse, PostgresError } from './query'; +export type { + LatencySummary, + ReactNativeBenchmarkOptions, + ReactNativeBenchmarkReport, + ReactNativeBenchmarkWorkload, + PostgresSettings, + ThroughputSummary, +} from './benchmark'; +export { + runInstalledOliphauntReactNativeBenchmark, + runOliphauntReactNativeBenchmark, +} from './benchmark'; +export type { + ReactNativeSmokeOptions, + ReactNativeSmokeReport, +} from './smoke'; +export { + runInstalledOliphauntReactNativeSmoke, + runOliphauntReactNativeSmoke, +} from './smoke'; +export type { JsiRawProtocolTransport } from './jsiTransport'; +export type { + NativeCapabilities, + NativeEngineModeSupport, + NativeExtensionSizeReport, + NativeOpenConfig, + NativePackageSizeReport, + NativeProcessMemoryReport, + NativeResourceConfig, + Spec as NativeOliphauntModule, +} from './specs/NativeOliphaunt'; + +export const Oliphaunt = createOliphauntClient(NativeOliphaunt); diff --git a/src/sdks/react-native/src/jsiTransport.ts b/src/sdks/react-native/src/jsiTransport.ts new file mode 100644 index 00000000..88885843 --- /dev/null +++ b/src/sdks/react-native/src/jsiTransport.ts @@ -0,0 +1,123 @@ +export type JsiRawProtocolTransport = { + readonly version: 1; + readonly execProtocolRaw: ( + handle: number, + request: Uint8Array, + ) => Promise; + readonly execProtocolStream?: ( + handle: number, + request: Uint8Array, + onChunk: (chunk: ArrayBuffer | ArrayBufferView) => void, + ) => Promise; + readonly backup: (handle: number, format: string) => Promise; + readonly restore: ( + root: string, + format: string, + artifact: Uint8Array, + replaceExisting: boolean, + libraryPath: string | null, + ) => Promise; +}; + +type GlobalWithOliphauntJsi = typeof globalThis & { + __oliphauntReactNativeJsi?: Partial; +}; + +export function resolveJsiRawProtocolTransport(): JsiRawProtocolTransport | null { + const candidate = (globalThis as GlobalWithOliphauntJsi).__oliphauntReactNativeJsi; + if ( + candidate?.version === 1 && + typeof candidate.execProtocolRaw === 'function' && + typeof candidate.backup === 'function' && + typeof candidate.restore === 'function' + ) { + return candidate as JsiRawProtocolTransport; + } + return null; +} + +export function requireJsiRawProtocolTransport(): JsiRawProtocolTransport { + const transport = resolveJsiRawProtocolTransport(); + if (transport) { + return transport; + } + throw new Error( + 'Oliphaunt requires React Native New Architecture JSI ArrayBuffer bindings; rebuild the app with the Oliphaunt TurboModule installed', + ); +} + +export async function execProtocolRawJsi( + transport: JsiRawProtocolTransport, + handle: number, + request: Uint8Array, +): Promise { + return binaryResponseToUint8Array(await transport.execProtocolRaw(handle, request)); +} + +export async function execProtocolStreamJsi( + transport: JsiRawProtocolTransport, + handle: number, + request: Uint8Array, + onChunk: (chunk: Uint8Array) => void, +): Promise { + if (!jsiTransportSupportsProtocolStream(transport)) { + return false; + } + let chunkError: unknown; + await transport.execProtocolStream(handle, request, (chunk) => { + if (chunkError !== undefined) { + return; + } + try { + onChunk(binaryResponseToUint8Array(chunk)); + } catch (error) { + chunkError = error; + } + }); + if (chunkError !== undefined) { + throw chunkError; + } + return true; +} + +export function jsiTransportSupportsProtocolStream( + transport: JsiRawProtocolTransport | null | undefined, +): transport is JsiRawProtocolTransport & + Required> { + return typeof transport?.execProtocolStream === 'function'; +} +export async function backupJsi( + transport: JsiRawProtocolTransport, + handle: number, + format: string, +): Promise { + return binaryResponseToUint8Array(await transport.backup(handle, format)); +} + +export async function restoreJsi( + transport: JsiRawProtocolTransport, + root: string, + format: string, + artifact: Uint8Array, + replaceExisting: boolean, + libraryPath: string | null, +): Promise { + const restored = await transport.restore(root, format, artifact, replaceExisting, libraryPath); + if (typeof restored !== 'string') { + throw new Error('liboliphaunt JSI restore returned a non-string response'); + } + return restored; +} + +function binaryResponseToUint8Array(response: ArrayBuffer | ArrayBufferView): Uint8Array { + if (response instanceof Uint8Array) { + return response; + } + if (ArrayBuffer.isView(response)) { + return new Uint8Array(response.buffer, response.byteOffset, response.byteLength); + } + if (response instanceof ArrayBuffer) { + return new Uint8Array(response); + } + throw new Error('liboliphaunt JSI transport returned a non-binary response'); +} diff --git a/src/sdks/react-native/src/protocol.ts b/src/sdks/react-native/src/protocol.ts new file mode 100644 index 00000000..fe1ccd98 --- /dev/null +++ b/src/sdks/react-native/src/protocol.ts @@ -0,0 +1,20 @@ +export function simpleQuery(sql: string): Uint8Array { + if (sql.includes('\0')) { + throw new Error('simple query SQL must not contain NUL bytes'); + } + const encoder = new TextEncoder(); + const body = encoder.encode(sql); + const packet = new Uint8Array(body.length + 6); + packet[0] = 'Q'.charCodeAt(0); + writeI32(packet, 1, body.length + 5); + packet.set(body, 5); + packet[packet.length - 1] = 0; + return packet; +} + +function writeI32(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = (value >>> 24) & 0xff; + bytes[offset + 1] = (value >>> 16) & 0xff; + bytes[offset + 2] = (value >>> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} diff --git a/src/sdks/react-native/src/query.ts b/src/sdks/react-native/src/query.ts new file mode 100644 index 00000000..7849f5d8 --- /dev/null +++ b/src/sdks/react-native/src/query.ts @@ -0,0 +1,656 @@ +import { simpleQuery } from './protocol.js'; + +export type QueryBinaryInput = ArrayBuffer | ArrayBufferView | Uint8Array | ReadonlyArray; + +export type QueryParam = + | null + | string + | number + | boolean + | QueryBinaryInput + | { format: 'text'; value: string | number | boolean } + | { format: 'binary'; value: QueryBinaryInput }; + +export type QueryFormat = 'text' | 'binary' | { code: number; kind: 'other' }; + +export type QueryField = { + name: string; + tableOid: number; + tableAttribute: number; + typeOid: number; + typeSize: number; + typeModifier: number; + format: QueryFormat; +}; + +export type QueryRow = { + values: Array; + text(column: number): string | null; +}; + +export type QueryResult = { + fields: QueryField[]; + rows: QueryRow[]; + commandTag?: string; + rowCount: number; + fieldIndex(name: string): number | undefined; + getText(row: number, column: string): string | null; +}; + +export { simpleQuery }; + +export type PostgresErrorField = { + code: number; + value: string; +}; + +export class PostgresError extends Error { + readonly severity?: string; + readonly sqlstate?: string; + readonly detail?: string; + readonly hint?: string; + readonly position?: string; + readonly whereText?: string; + readonly schemaName?: string; + readonly tableName?: string; + readonly columnName?: string; + readonly dataTypeName?: string; + readonly constraintName?: string; + readonly fields: PostgresErrorField[]; + readonly postgresMessage: string; + + constructor(fields: PostgresErrorField[]) { + const severity = fieldValue(fields, 0x53) ?? fieldValue(fields, 0x56); + const sqlstate = fieldValue(fields, 0x43); + const postgresMessage = fieldValue(fields, 0x4d) ?? 'PostgreSQL ErrorResponse'; + super(formatPostgresError(severity, sqlstate, postgresMessage)); + this.name = 'PostgresError'; + this.severity = severity; + this.sqlstate = sqlstate; + this.postgresMessage = postgresMessage; + this.detail = fieldValue(fields, 0x44); + this.hint = fieldValue(fields, 0x48); + this.position = fieldValue(fields, 0x50); + this.whereText = fieldValue(fields, 0x57); + this.schemaName = fieldValue(fields, 0x73); + this.tableName = fieldValue(fields, 0x74); + this.columnName = fieldValue(fields, 0x63); + this.dataTypeName = fieldValue(fields, 0x64); + this.constraintName = fieldValue(fields, 0x6e); + this.fields = fields; + } + + static fallback(): PostgresError { + return new PostgresError([{ code: 0x4d, value: 'PostgreSQL ErrorResponse' }]); + } +} + +export function extendedQuery(sql: string, parameters: ReadonlyArray): Uint8Array { + if (parameters.length > 0x7fff) { + throw new Error( + `extended query supports at most ${0x7fff} parameters, got ${parameters.length}`, + ); + } + if (sql.includes('\0')) { + throw new Error('extended query SQL must not contain NUL bytes'); + } + + const packet: number[] = []; + pushParse(packet, sql); + pushBind(packet, parameters.map(normalizeQueryParam)); + pushDescribePortal(packet); + pushExecute(packet); + pushFrontendMessage(packet, 0x53, []); + return Uint8Array.from(packet); +} + +export function parseQueryResponse(bytes: Uint8Array): QueryResult { + const cursor = new ByteCursor(bytes); + let fields: QueryField[] | undefined; + const rows: QueryRow[] = []; + let commandTag: string | undefined; + let sawReady = false; + + while (!cursor.isAtEnd()) { + const tag = cursor.readU8('backend message tag'); + const length = cursor.readI32('backend message length'); + if (length < 4) { + throw new Error(`invalid backend message length ${length}`); + } + const body = new ByteCursor(cursor.readBytes(length - 4, 'backend message body')); + + switch (tag) { + case 0x54: + if (fields !== undefined) { + throw new Error( + 'query() received multiple result sets; use execProtocolRaw for multi-statement row results', + ); + } + fields = parseRowDescription(body); + body.requireEnd('RowDescription'); + break; + case 0x44: + if (fields === undefined) { + throw new Error('DataRow arrived before RowDescription'); + } + rows.push(parseDataRow(body, fields.length)); + body.requireEnd('DataRow'); + break; + case 0x43: + commandTag = body.readCString('CommandComplete tag'); + body.requireEnd('CommandComplete'); + break; + case 0x45: + throw parseErrorResponse(body); + case 0x47: + case 0x48: + case 0x57: + case 0x64: + case 0x63: + throw new Error( + 'query() does not support COPY protocol responses; use execProtocolRaw for COPY traffic', + ); + case 0x5a: + validateReadyForQuery(body); + sawReady = true; + if (!cursor.isAtEnd()) { + throw new Error('backend returned bytes after ReadyForQuery'); + } + break; + case 0x31: + body.requireEnd('ParseComplete'); + break; + case 0x32: + body.requireEnd('BindComplete'); + break; + case 0x33: + body.requireEnd('CloseComplete'); + break; + case 0x49: + body.requireEnd('EmptyQueryResponse'); + break; + case 0x6e: + body.requireEnd('NoData'); + break; + case 0x53: + validateParameterStatus(body); + break; + case 0x4e: + validateFieldResponse(body, 'NoticeResponse'); + break; + case 0x41: + validateNotificationResponse(body); + break; + default: + throw new Error(`query() received unexpected backend message tag ${hexBackendTag(tag)}`); + } + } + + if (!sawReady) { + throw new Error('query response ended before ReadyForQuery'); + } + + const resultFields = fields ?? []; + return { + fields: resultFields, + rows, + commandTag, + rowCount: rows.length, + fieldIndex(name: string): number | undefined { + const index = resultFields.findIndex((field) => field.name === name); + return index >= 0 ? index : undefined; + }, + getText(row: number, column: string): string | null { + const columnIndex = this.fieldIndex(column); + if (columnIndex === undefined) { + throw new Error(`query result has no column named ${JSON.stringify(column)}`); + } + const queryRow = rows[row]; + if (queryRow === undefined) { + throw new Error(`query result has no row at index ${row}`); + } + return queryRow.text(columnIndex); + }, + }; +} + +export function assertSuccessfulQueryResponse(bytes: Uint8Array): void { + const cursor = new ByteCursor(bytes); + let sawReady = false; + + while (!cursor.isAtEnd()) { + const tag = cursor.readU8('backend message tag'); + const length = cursor.readI32('backend message length'); + if (length < 4) { + throw new Error(`invalid backend message length ${length}`); + } + const body = new ByteCursor(cursor.readBytes(length - 4, 'backend message body')); + + switch (tag) { + case 0x45: + throw parseErrorResponse(body); + case 0x5a: + validateReadyForQuery(body); + sawReady = true; + if (!cursor.isAtEnd()) { + throw new Error('backend returned bytes after ReadyForQuery'); + } + break; + default: + break; + } + } + + if (!sawReady) { + throw new Error('query response ended before ReadyForQuery'); + } +} + +type NormalizedParam = + | { kind: 'null' } + | { kind: 'text'; value: Uint8Array } + | { kind: 'binary'; value: Uint8Array }; + +function normalizeQueryParam(parameter: QueryParam): NormalizedParam { + if (parameter === null) { + return { kind: 'null' }; + } + if ( + typeof parameter === 'string' || + typeof parameter === 'number' || + typeof parameter === 'boolean' + ) { + return { kind: 'text', value: new TextEncoder().encode(String(parameter)) }; + } + if (isQueryBinaryInput(parameter)) { + return { kind: 'binary', value: toUint8Array(parameter) }; + } + if (parameter.format === 'text') { + return { kind: 'text', value: new TextEncoder().encode(String(parameter.value)) }; + } + return { kind: 'binary', value: toUint8Array(parameter.value) }; +} + +function isQueryBinaryInput(value: unknown): value is QueryBinaryInput { + return value instanceof ArrayBuffer || ArrayBuffer.isView(value) || Array.isArray(value); +} + +function pushParse(out: number[], sql: string): void { + const body: number[] = []; + pushCString(body, ''); + pushCString(body, sql); + pushI16(body, 0); + pushFrontendMessage(out, 0x50, body); +} + +function pushBind(out: number[], parameters: NormalizedParam[]): void { + const body: number[] = []; + pushCString(body, ''); + pushCString(body, ''); + + pushI16(body, parameters.length); + for (const parameter of parameters) { + pushI16(body, parameter.kind === 'binary' ? 1 : 0); + } + + pushI16(body, parameters.length); + for (const parameter of parameters) { + if (parameter.kind === 'null') { + pushI32(body, -1); + } else { + pushSizedValue(body, parameter.value); + } + } + + pushI16(body, 1); + pushI16(body, 0); + pushFrontendMessage(out, 0x42, body); +} + +function pushDescribePortal(out: number[]): void { + const body: number[] = [0x50]; + pushCString(body, ''); + pushFrontendMessage(out, 0x44, body); +} + +function pushExecute(out: number[]): void { + const body: number[] = []; + pushCString(body, ''); + pushI32(body, 0); + pushFrontendMessage(out, 0x45, body); +} + +function pushFrontendMessage(out: number[], tag: number, body: ReadonlyArray): void { + out.push(tag); + pushI32(out, body.length + 4); + out.push(...body); +} + +function pushCString(out: number[], value: string): void { + if (value.includes('\0')) { + throw new Error('frontend protocol string must not contain NUL bytes'); + } + out.push(...new TextEncoder().encode(value), 0); +} + +function pushSizedValue(out: number[], value: Uint8Array): void { + pushI32(out, value.length); + out.push(...value); +} + +function pushI32(out: number[], value: number): void { + pushU32(out, value >>> 0); +} + +function pushU32(out: number[], value: number): void { + out.push((value >>> 24) & 0xff); + out.push((value >>> 16) & 0xff); + out.push((value >>> 8) & 0xff); + out.push(value & 0xff); +} + +function pushI16(out: number[], value: number): void { + const bits = value & 0xffff; + out.push((bits >>> 8) & 0xff); + out.push(bits & 0xff); +} + +export function toUint8Array(input: QueryBinaryInput): Uint8Array { + if (input instanceof Uint8Array) { + return input; + } + if (ArrayBuffer.isView(input)) { + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + return Uint8Array.from(input); +} + +function parseRowDescription(cursor: ByteCursor): QueryField[] { + const count = cursor.readI16('RowDescription field count'); + if (count < 0) { + throw new Error(`invalid RowDescription field count ${count}`); + } + const fields: QueryField[] = []; + for (let index = 0; index < count; index += 1) { + fields.push({ + name: cursor.readCString('field name'), + tableOid: cursor.readU32('field table oid'), + tableAttribute: cursor.readI16('field table attribute'), + typeOid: cursor.readU32('field type oid'), + typeSize: cursor.readI16('field type size'), + typeModifier: cursor.readI32('field type modifier'), + format: queryFormat(cursor.readI16('field format')), + }); + } + return fields; +} + +function parseDataRow(cursor: ByteCursor, expectedColumns: number): QueryRow { + const count = cursor.readI16('DataRow column count'); + if (count < 0) { + throw new Error(`invalid DataRow column count ${count}`); + } + if (count !== expectedColumns) { + throw new Error( + `DataRow column count ${count} does not match RowDescription count ${expectedColumns}`, + ); + } + const values: Array = []; + for (let index = 0; index < count; index += 1) { + const length = cursor.readI32('DataRow value length'); + if (length === -1) { + values.push(null); + } else if (length < 0) { + throw new Error(`invalid DataRow value length ${length}`); + } else { + values.push(cursor.readBytes(length, 'DataRow value')); + } + } + return { + values, + text(column: number): string | null { + if (column < 0 || column >= values.length) { + throw new Error(`query row has no column at index ${column}`); + } + const value = values[column]!; + return value === null ? null : decodeUtf8Strict(value, 'query value'); + }, + }; +} + +function parseErrorResponse(cursor: ByteCursor): PostgresError { + const fields: PostgresErrorField[] = []; + while (!cursor.isAtEnd()) { + let code: number; + try { + code = cursor.readU8('ErrorResponse field code'); + } catch { + return PostgresError.fallback(); + } + if (code === 0) { + break; + } + let value: string; + try { + value = cursor.readCString('ErrorResponse field'); + } catch { + return PostgresError.fallback(); + } + fields.push({ code, value }); + } + return new PostgresError(fields); +} + +function fieldValue(fields: ReadonlyArray, code: number): string | undefined { + return fields.find((field) => field.code === code)?.value; +} + +function formatPostgresError( + severity: string | undefined, + sqlstate: string | undefined, + message: string, +): string { + if (severity !== undefined && sqlstate !== undefined) { + return `${severity} [${sqlstate}]: ${message}`; + } + if (severity !== undefined) { + return `${severity}: ${message}`; + } + if (sqlstate !== undefined) { + return `[${sqlstate}]: ${message}`; + } + return message; +} + +function queryFormat(code: number): QueryFormat { + if (code === 0) { + return 'text'; + } + if (code === 1) { + return 'binary'; + } + return { code, kind: 'other' }; +} + +function hexBackendTag(tag: number): string { + return `0x${tag.toString(16).padStart(2, '0')}`; +} + +function validateReadyForQuery(body: ByteCursor): void { + const remaining = body.remainingBytes(); + if (remaining !== 1) { + throw new Error(`ReadyForQuery contained ${remaining} bytes, expected 1`); + } + const status = body.readU8('ReadyForQuery transaction status'); + if (status !== 0x49 && status !== 0x54 && status !== 0x45) { + throw new Error(`ReadyForQuery contained invalid transaction status ${hexBackendTag(status)}`); + } +} + +function validateParameterStatus(body: ByteCursor): void { + body.readCString('ParameterStatus name'); + body.readCString('ParameterStatus value'); + body.requireEnd('ParameterStatus'); +} + +function validateNotificationResponse(body: ByteCursor): void { + body.readI32('NotificationResponse process id'); + body.readCString('NotificationResponse channel'); + body.readCString('NotificationResponse payload'); + body.requireEnd('NotificationResponse'); +} + +function validateFieldResponse(body: ByteCursor, label: string): void { + for (;;) { + if (body.isAtEnd()) { + throw new Error(`${label} is missing terminator`); + } + const code = body.readU8(`${label} field code`); + if (code === 0) { + body.requireEnd(label); + return; + } + body.readCString(`${label} field`); + } +} + +class ByteCursor { + readonly #bytes: Uint8Array; + #offset = 0; + + constructor(bytes: Uint8Array) { + this.#bytes = bytes; + } + + isAtEnd(): boolean { + return this.#offset === this.#bytes.length; + } + + remainingBytes(): number { + return this.#bytes.length - this.#offset; + } + + requireEnd(label: string): void { + if (!this.isAtEnd()) { + throw new Error(`${label} contained trailing bytes`); + } + } + + readU8(label: string): number { + return this.readBytes(1, label)[0]!; + } + + readU32(label: string): number { + return ( + (this.readU8(label) * 0x1000000 + + (this.readU8(label) << 16) + + (this.readU8(label) << 8) + + this.readU8(label)) >>> + 0 + ); + } + + readI32(label: string): number { + const value = this.readU32(label); + return value > 0x7fffffff ? value - 0x100000000 : value; + } + + readI16(label: string): number { + const value = (this.readU8(label) << 8) | this.readU8(label); + return value > 0x7fff ? value - 0x10000 : value; + } + + readCString(label: string): string { + const end = this.#bytes.indexOf(0, this.#offset); + if (end < 0) { + throw new Error(`${label} is missing null terminator`); + } + const value = decodeUtf8Strict(this.#bytes.subarray(this.#offset, end), label); + this.#offset = end + 1; + return value; + } + + readBytes(count: number, label: string): Uint8Array { + if (count < 0 || this.#offset + count > this.#bytes.length) { + throw new Error(`truncated ${label}`); + } + const value = this.#bytes.slice(this.#offset, this.#offset + count); + this.#offset += count; + return value; + } +} + +function decodeUtf8Strict(bytes: Uint8Array, label: string): string { + validateUtf8(bytes, label); + return new TextDecoder().decode(bytes); +} + +function validateUtf8(bytes: Uint8Array, label: string): void { + let index = 0; + while (index < bytes.length) { + const first = bytes[index]!; + if (first <= 0x7f) { + index += 1; + } else if (first >= 0xc2 && first <= 0xdf) { + requireContinuation(bytes, index + 1, label); + index += 2; + } else if (first === 0xe0) { + requireRange(bytes, index + 1, 0xa0, 0xbf, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first >= 0xe1 && first <= 0xec) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first === 0xed) { + requireRange(bytes, index + 1, 0x80, 0x9f, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first >= 0xee && first <= 0xef) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first === 0xf0) { + requireRange(bytes, index + 1, 0x90, 0xbf, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else if (first >= 0xf1 && first <= 0xf3) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else if (first === 0xf4) { + requireRange(bytes, index + 1, 0x80, 0x8f, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else { + throw invalidUtf8(label, index); + } + } +} + +function requireContinuation(bytes: Uint8Array, index: number, label: string): void { + requireRange(bytes, index, 0x80, 0xbf, label); +} + +function requireRange( + bytes: Uint8Array, + index: number, + min: number, + max: number, + label: string, +): void { + const byte = bytes[index]; + if (byte === undefined || byte < min || byte > max) { + throw invalidUtf8(label, index); + } +} + +function invalidUtf8(label: string, index: number): Error { + return new Error(`${label} is not valid UTF-8 at byte ${index}`); +} diff --git a/src/sdks/react-native/src/smoke.ts b/src/sdks/react-native/src/smoke.ts new file mode 100644 index 00000000..fc1c6f25 --- /dev/null +++ b/src/sdks/react-native/src/smoke.ts @@ -0,0 +1,141 @@ +import type { + EngineMode, + OpenConfig, + PackageSizeReport, + OliphauntClient, + OliphauntDatabase, + RawProtocolTransport, +} from './client'; + +export type ReactNativeSmokeOptions = { + readonly open?: OpenConfig; + readonly expectedTransport?: RawProtocolTransport; + readonly expectedEngine?: EngineMode; + readonly requirePackageSizeReport?: boolean; + readonly afterSmoke?: (database: OliphauntDatabase) => Promise | void; +}; + +export type ReactNativeSmokeReport = { + readonly engine: EngineMode; + readonly rawProtocolTransport: RawProtocolTransport; + readonly selectOne: string; + readonly parameterRoundTrip: string; + readonly jsTimerTicks: number; + readonly elapsedMs: number; + readonly packageSizeReport?: PackageSizeReport | null; +}; + +export async function runOliphauntReactNativeSmoke( + client: OliphauntClient, + options: ReactNativeSmokeOptions = {}, +): Promise { + const start = monotonicNow(); + const liveness = startTimerLivenessProbe(); + const db = await client.open({ + engine: 'nativeDirect', + temporary: true, + username: 'postgres', + database: 'postgres', + ...options.open, + }); + + try { + const capabilities = await db.capabilities(); + const expectedEngine = options.expectedEngine ?? options.open?.engine ?? 'nativeDirect'; + if (capabilities.engine !== expectedEngine) { + throw new Error( + `Oliphaunt React Native smoke opened ${capabilities.engine}, expected ${expectedEngine}`, + ); + } + const expectedTransport = options.expectedTransport ?? 'jsi-array-buffer'; + if (capabilities.rawProtocolTransport !== expectedTransport) { + throw new Error( + `Oliphaunt React Native smoke used ${capabilities.rawProtocolTransport}, expected ${expectedTransport}`, + ); + } + if (!capabilities.protocolRaw || !capabilities.simpleQuery) { + throw new Error( + 'Oliphaunt React Native smoke requires raw protocol and simple-query support', + ); + } + + const select = await db.query('SELECT 1::text AS value'); + const selectOne = select.getText(0, 'value'); + if (selectOne !== '1') { + throw new Error(`Oliphaunt React Native smoke SELECT 1 returned ${String(selectOne)}`); + } + + const parameterized = await db.query('SELECT $1::text AS value', ['hello']); + const parameterRoundTrip = parameterized.getText(0, 'value'); + if (parameterRoundTrip !== 'hello') { + throw new Error( + `Oliphaunt React Native smoke parameter query returned ${String(parameterRoundTrip)}`, + ); + } + + let packageSizeReport: PackageSizeReport | null | undefined; + if (options.requirePackageSizeReport === true) { + packageSizeReport = await client.packageSizeReport({ + resourceRoot: options.open?.resourceRoot, + }); + if (packageSizeReport == null) { + throw new Error('Oliphaunt React Native smoke expected packaged resource size evidence'); + } + } + + const report = { + engine: capabilities.engine, + rawProtocolTransport: capabilities.rawProtocolTransport, + selectOne, + parameterRoundTrip, + jsTimerTicks: liveness.ticks(), + elapsedMs: monotonicNow() - start, + packageSizeReport, + }; + liveness.stop(); + + await options.afterSmoke?.(db); + + return report; + } finally { + liveness.stop(); + await db.close(); + } +} + +export async function runInstalledOliphauntReactNativeSmoke( + options: ReactNativeSmokeOptions = {}, +): Promise { + const { Oliphaunt } = await import('./index.js'); + return runOliphauntReactNativeSmoke(Oliphaunt, options); +} + +function monotonicNow(): number { + const performanceNow = globalThis.performance?.now.bind(globalThis.performance); + return performanceNow ? performanceNow() : Date.now(); +} + +function startTimerLivenessProbe(): { ticks: () => number; stop: () => void } { + let active = true; + let ticks = 0; + let timeout: ReturnType | undefined; + const schedule = () => { + timeout = setTimeout(() => { + if (!active) { + return; + } + ticks += 1; + schedule(); + }, 0); + }; + schedule(); + return { + ticks: () => ticks, + stop: () => { + active = false; + if (timeout !== undefined) { + clearTimeout(timeout); + } + }, + }; +} diff --git a/src/sdks/react-native/src/specs/NativeOliphaunt.ts b/src/sdks/react-native/src/specs/NativeOliphaunt.ts new file mode 100644 index 00000000..083313bc --- /dev/null +++ b/src/sdks/react-native/src/specs/NativeOliphaunt.ts @@ -0,0 +1,96 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export type NativeOpenConfig = { + engine?: string; + root?: string; + temporary?: boolean; + durability?: string; + runtimeFootprint?: string; + startupGUCs?: Array; + username?: string; + database?: string; + extensions?: Array; + libraryPath?: string; + runtimeDirectory?: string; + resourceRoot?: string; +}; + +export type NativeResourceConfig = { + resourceRoot?: string; +}; + +export type NativeCapabilities = { + engine: string; + processIsolated: boolean; + multiRoot: boolean; + reopenable: boolean; + sameRootLogicalReopen: boolean; + rootSwitchable: boolean; + crashRestartable: boolean; + independentSessions: boolean; + maxClientSessions: number; + protocolRaw: boolean; + protocolStream: boolean; + queryCancel: boolean; + backupRestore: boolean; + backupFormats: Array; + restoreFormats: Array; + simpleQuery: boolean; + extensions: boolean; + connectionString?: string; + rawProtocolTransport: string; +}; + +export type NativeExtensionSizeReport = { + name: string; + fileCount: number; + bytes: number; +}; + +export type NativePackageSizeReport = { + packageBytes: number; + runtimeBytes: number; + templatePgdataBytes: number; + staticRegistryBytes: number; + selectedExtensionBytes: number; + mobileStaticRegistryState?: string; + mobileStaticRegistryRegistered?: Array; + mobileStaticRegistryPending?: Array; + nativeModuleStems?: Array; + extensions: Array; +}; + +export type NativeProcessMemoryReport = { + source: string; + residentBytes?: number; + physicalFootprintBytes?: number; + virtualBytes?: number; + peakResidentBytes?: number; + totalPssKb?: number; + totalPrivateDirtyKb?: number; + totalSharedDirtyKb?: number; + nativeHeapAllocatedBytes?: number; + nativeHeapSizeBytes?: number; + runtimeTotalBytes?: number; + runtimeFreeBytes?: number; +}; + +export type NativeEngineModeSupport = { + engine: string; + available: boolean; + capabilities: NativeCapabilities; + unavailableReason?: string; +}; + +export interface Spec extends TurboModule { + supportedModes(): Promise>; + packageSizeReport(config: NativeResourceConfig): Promise; + processMemory(): Promise; + open(config: NativeOpenConfig): Promise; + cancel(handle: number): Promise; + close(handle: number): Promise; + capabilities(handle: number): Promise; +} + +export default TurboModuleRegistry.getEnforcing('Oliphaunt'); diff --git a/src/sdks/react-native/tools/android-smoke-artifacts.sh b/src/sdks/react-native/tools/android-smoke-artifacts.sh new file mode 100644 index 00000000..ac9bc4d1 --- /dev/null +++ b/src/sdks/react-native/tools/android-smoke-artifacts.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env sh + +oliphaunt_android_ndk_bin_dir() { + if [ -z "${ANDROID_HOME:-}" ]; then + echo "ANDROID_HOME is required to build Android smoke native artifacts" >&2 + return 1 + fi + for candidate in "$ANDROID_HOME"/ndk/*/toolchains/llvm/prebuilt/*/bin; do + if [ -x "$candidate/llvm-ar" ]; then + printf '%s\n' "$candidate" + fi + done | + sort | + tail -n 1 +} + +oliphaunt_android_clang_name_for_abi() { + case "$1" in + arm64-v8a) + printf '%s\n' aarch64-linux-android24-clang + ;; + armeabi-v7a) + printf '%s\n' armv7a-linux-androideabi24-clang + ;; + x86) + printf '%s\n' i686-linux-android24-clang + ;; + x86_64) + printf '%s\n' x86_64-linux-android24-clang + ;; + *) + echo "unsupported Android smoke ABI: $1" >&2 + return 1 + ;; + esac +} + +oliphaunt_android_create_static_extension_smoke_artifacts() { + scratch_root="$1" + abi="$2" + runtime_resources_root="$3" + jni_libs_root="$4" + stem="$5" + + ndk_bin="$(oliphaunt_android_ndk_bin_dir)" + if [ -z "$ndk_bin" ]; then + echo "could not find Android NDK LLVM toolchain under ANDROID_HOME=$ANDROID_HOME" >&2 + return 1 + fi + clang_name="$(oliphaunt_android_clang_name_for_abi "$abi")" + clang="$ndk_bin/$clang_name" + ar="$ndk_bin/llvm-ar" + if [ ! -x "$clang" ]; then + echo "missing Android clang for $abi: $clang" >&2 + return 1 + fi + if [ ! -x "$ar" ]; then + echo "missing Android llvm-ar: $ar" >&2 + return 1 + fi + + work="$scratch_root/android-smoke-native/$abi/$stem" + archive_dir="$runtime_resources_root/oliphaunt/static-registry/archives/$abi/extensions/$stem" + jni_dir="$jni_libs_root/jniLibs/$abi" + rm -rf "$work" + mkdir -p "$work" "$archive_dir" "$jni_dir" + + symbol_stem="$( + printf '%s' "$stem" | + tr -c 'A-Za-z0-9_' '_' | + sed 's/^/x_/' + )" + cat >"$work/extension.c" <"$work/liboliphaunt.c" <<'C' +void oliphaunt_android_smoke_liboliphaunt(void) {} +C + "$clang" -shared -fPIC "$work/liboliphaunt.c" -Wl,-soname,liboliphaunt.so \ + -o "$jni_dir/liboliphaunt.so" +} diff --git a/src/sdks/react-native/tools/check-sdk.sh b/src/sdks/react-native/tools/check-sdk.sh new file mode 100755 index 00000000..86d7d888 --- /dev/null +++ b/src/sdks/react-native/tools/check-sdk.sh @@ -0,0 +1,945 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +. "$root/src/sdks/react-native/tools/android-smoke-artifacts.sh" + +scratch_root_base="${OLIPHAUNT_SDK_CHECK_SCRATCH:-$root/target/liboliphaunt-sdk-check/oliphaunt-react-native}" +source_package_dir="src/sdks/react-native" +mode="${1:-release-check}" + +case "$mode" in + check-static|build-android-bridge|build-ios-bridge|test-unit|package-shape|smoke-runtime|regression|coverage|release-check) + ;; + "") + mode="release-check" + ;; + *) + echo "usage: src/sdks/react-native/tools/check-sdk.sh [check-static|build-android-bridge|build-ios-bridge|test-unit|package-shape|smoke-runtime|regression|coverage|release-check]" >&2 + exit 2 + ;; +esac + +scratch_root="$scratch_root_base/$mode" +package_dir="$scratch_root/$source_package_dir" +android_dir="$package_dir/android" + +if [ -z "${ANDROID_HOME:-}" ] && [ -d "$HOME/Library/Android/sdk" ]; then + export ANDROID_HOME="$HOME/Library/Android/sdk" +fi +if [ -n "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then + export ANDROID_SDK_ROOT="$ANDROID_HOME" +fi +if [ -z "${JAVA_HOME:-}" ] && + [ -d /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ]; then + export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + export PATH="$JAVA_HOME/bin:$PATH" +fi +java_major="$( + java -version 2>&1 | + awk -F '[\".]' '/version/ { print $2; exit }' || true +)" +case "$java_major" in + 2[4-9]|[3-9][0-9]*) + case " ${JAVA_TOOL_OPTIONS:-} " in + *" --enable-native-access=ALL-UNNAMED "*) + ;; + *) + export JAVA_TOOL_OPTIONS="--enable-native-access=ALL-UNNAMED ${JAVA_TOOL_OPTIONS:-}" + ;; + esac + ;; +esac + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +require_manifest_line() { + manifest="$1" + expected="$2" + message="$3" + if ! grep -Fxq "$expected" "$manifest"; then + echo "$message" >&2 + echo "expected '$expected' in $manifest" >&2 + exit 1 + fi +} + +require_source_text() { + file="$1" + expected="$2" + message="$3" + if ! grep -Fq "$expected" "$file"; then + echo "$message" >&2 + echo "expected '$expected' in $file" >&2 + exit 1 + fi +} + +link_required_header() { + destination="$1" + shift + for candidate in "$@"; do + if [ -f "$candidate" ]; then + ln -sf "$candidate" "$destination" + return 0 + fi + done + echo "missing required React Native header for syntax check: $destination" >&2 + for candidate in "$@"; do + echo " tried: $candidate" >&2 + done + exit 1 +} + +prepare_scratch_dir() { + dir="$scratch_root/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s\n' "$dir" +} + +prepare_react_native_package_worktree() { + require rsync + rm -rf "$package_dir" + mkdir -p "$package_dir" + cat >"$scratch_root/package.json" <<'JSON' +{ + "name": "oliphaunt-react-native-sdk-check-workspace", + "private": true, + "packageManager": "pnpm@11.5.0" +} +JSON + cat >"$scratch_root/pnpm-workspace.yaml" <<'YAML' +packages: + - "src/sdks/react-native" +catalog: + "@vitest/coverage-v8": ^4.1.8 + tsx: ^4.20.6 + typedoc: ^0.28.16 + typescript: ^5.9.3 + vitest: ^4.1.8 +minimumReleaseAge: 1440 +nodeLinker: hoisted +saveWorkspaceProtocol: rolling +updateNotifier: false +verifyDepsBeforeRun: false +confirmModulesPurge: false +autoInstallPeers: false + +allowBuilds: + core-js: false + esbuild: true + msgpackr-extract: true + sharp: true + unrs-resolver: true +YAML + cp pnpm-lock.yaml "$scratch_root/pnpm-lock.yaml" + mkdir -p "$scratch_root/fixtures" + mkdir -p "$scratch_root/tools/test" + rsync -a --delete src/shared/fixtures/ "$scratch_root/fixtures/" + rsync -a --delete tools/test/ "$scratch_root/tools/test/" + rsync -a --delete \ + --exclude node_modules \ + --exclude lib \ + --exclude .build \ + --exclude android/.gradle \ + --exclude android/.cxx \ + --exclude android/build \ + --exclude ios/vendor \ + "$source_package_dir/" "$package_dir/" + rm -rf "$scratch_root/node_modules" "$package_dir/node_modules" + run pnpm --dir "$scratch_root" install --frozen-lockfile + if [ ! -e "$package_dir/node_modules" ]; then + ln -s "$scratch_root/node_modules" "$package_dir/node_modules" + fi +} + +node_package_dir() { + node - "$package_dir" "$1" <<'NODE' +const path = require("node:path"); +const { createRequire } = require("node:module"); + +const packageDir = process.argv[2]; +const packageName = process.argv[3]; +const requireFromPackage = createRequire(path.join(packageDir, "package.json")); +process.stdout.write(path.dirname(requireFromPackage.resolve(`${packageName}/package.json`))); +NODE +} + +require node +require pnpm +export CI="${CI:-1}" + +if [ "$mode" = "coverage" ]; then + exec tools/coverage/run-product oliphaunt-react-native +fi + +if [ "$mode" = "smoke-runtime" ]; then + exec pnpm --dir src/sdks/react-native/examples/expo run smoke +fi + +case "${OLIPHAUNT_GRADLE_CONFIGURATION_CACHE:-1}" in + 1|true|TRUE|yes|YES) + gradle_cache_args="--configuration-cache" + ;; + 0|false|FALSE|no|NO) + gradle_cache_args="" + ;; + *) + echo "OLIPHAUNT_GRADLE_CONFIGURATION_CACHE must be 0 or 1" >&2 + exit 2 + ;; +esac +case "${OLIPHAUNT_GRADLE_SMOKE_CONFIGURATION_CACHE:-0}" in + 1|true|TRUE|yes|YES) + gradle_smoke_cache_args="--configuration-cache" + ;; + 0|false|FALSE|no|NO) + gradle_smoke_cache_args="--no-configuration-cache" + ;; + *) + echo "OLIPHAUNT_GRADLE_SMOKE_CONFIGURATION_CACHE must be 0 or 1" >&2 + exit 2 + ;; +esac + +default_android_abi_filter() { + machine="$(uname -m 2>/dev/null || true)" + case "$machine" in + arm64|aarch64) + printf '%s\n' arm64-v8a + ;; + *) + printf '%s\n' x86_64 + ;; + esac +} + +normalize_android_abi_filters() { + raw="$1" + case "$raw" in + ""|all|ALL|All) + return 0 + ;; + auto|AUTO|Auto) + default_android_abi_filter + return 0 + ;; + esac + normalized="" + old_ifs="$IFS" + IFS="," + # shellcheck disable=SC2086 + set -- $raw + IFS="$old_ifs" + for abi in "$@"; do + abi="$(printf '%s\n' "$abi" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -n "$abi" ] || continue + case "$abi" in + arm64-v8a|armeabi-v7a|x86|x86_64) + case ",$normalized," in + *",$abi,"*) + ;; + *) + if [ -n "$normalized" ]; then + normalized="$normalized,$abi" + else + normalized="$abi" + fi + ;; + esac + ;; + *) + echo "unsupported OLIPHAUNT_REACT_NATIVE_ANDROID_ABI_FILTERS value: $abi" >&2 + echo "expected comma-separated Android ABIs from: arm64-v8a, armeabi-v7a, x86, x86_64, or all" >&2 + exit 2 + ;; + esac + done + printf '%s\n' "$normalized" +} + +android_abi_filters="$(normalize_android_abi_filters "${OLIPHAUNT_REACT_NATIVE_ANDROID_ABI_FILTERS:-${OLIPHAUNT_ANDROID_ABI_FILTERS:-auto}}")" +android_abi_gradle_args="" +if [ -n "$android_abi_filters" ]; then + android_abi_gradle_args="-PoliphauntAndroidAbiFilters=$android_abi_filters" +fi +android_smoke_abi="${android_abi_filters%%,*}" +if [ -z "$android_smoke_abi" ]; then + android_smoke_abi="$(default_android_abi_filter)" +fi +gradle_build_root="$scratch_root/gradle/oliphaunt-react-native" +gradle_project_cache="$scratch_root/gradle-cache/oliphaunt-react-native" +gradle_cxx_root="$scratch_root/cxx/oliphaunt-react-native" +node_executable="$(node -p 'process.execPath')" +gradle_scratch_args="-PoliphauntBuildRoot=$gradle_build_root -PoliphauntCxxBuildRoot=$gradle_cxx_root --project-cache-dir $gradle_project_cache -PoliphauntKotlinSdkDir=$root/src/sdks/kotlin/oliphaunt -PnodeExecutable=$node_executable" +android_build_dir="$gradle_build_root/root" +kotlin_build_dir="$gradle_build_root/oliphaunt" + +prepare_react_native_package_worktree +if [ "$mode" = "test-unit" ]; then + run pnpm --dir "$package_dir" test --if-present + exit 0 +fi + +run pnpm --dir "$package_dir" run build +run pnpm --dir "$package_dir" run typecheck +require_source_text "$package_dir/package.json" '"react-native": "lib/module/index.js"' \ + "React Native package must expose its compiled module build to Metro instead of raw TypeScript source" +require_source_text "$package_dir/OliphauntReactNative.podspec" 's.dependency "Oliphaunt", native_sdk_version' \ + "React Native iOS package must consume the published Swift SDK pod instead of vendoring Swift sources" +require_source_text "$package_dir/android/build.gradle" '?: "dev.oliphaunt:oliphaunt:${kotlinSdkVersion}"' \ + "React Native Android package must default to the published Kotlin SDK Maven coordinate" +require_source_text "$package_dir/android/settings.gradle" "if (configuredKotlinSdkDir != null && !configuredKotlinSdkDir.isBlank())" \ + "React Native Android local Kotlin SDK composite builds must be explicit development overrides" +require_source_text "$package_dir/tools/expo-android-runner.sh" "kotlin_sdk_dependency_from_maven_repo" \ + "React Native Android mobile runner must derive the Kotlin SDK dependency from staged Maven artifacts" +if grep -Fq "dev.oliphaunt:oliphaunt-android:0.1.0" "$package_dir/tools/expo-android-runner.sh"; then + echo "React Native Android mobile runner must not hardcode the Kotlin SDK version" >&2 + exit 1 +fi +if [ "$mode" = "release-check" ] || [ "$mode" = "regression" ]; then + run pnpm --dir "$package_dir" test --if-present +fi +run pnpm --dir "$package_dir" run codegen:check +base64_runtime_hits="$( + if command -v rg >/dev/null 2>&1; then + rg -n -i --glob '!**/README.md' --glob '!**/node_modules/**' \ + --glob '!**/__tests__/**' \ + 'base64|atob|btoa|Buffer\.from|Buffer\.alloc' \ + "$package_dir/src" \ + "$package_dir/ios" \ + "$package_dir/android/src/main" \ + "$package_dir/OliphauntReactNative.podspec" \ + "$package_dir/react-native.config.js" \ + "$package_dir/package.json" || true + else + grep -RInEi \ + --exclude='README.md' \ + --exclude-dir='node_modules' \ + --exclude-dir='__tests__' \ + 'base64|atob|btoa|Buffer\.from|Buffer\.alloc' \ + "$package_dir/src" \ + "$package_dir/ios" \ + "$package_dir/android/src/main" \ + "$package_dir/OliphauntReactNative.podspec" \ + "$package_dir/react-native.config.js" \ + "$package_dir/package.json" || true + fi +)" +if [ -n "$base64_runtime_hits" ]; then + echo "React Native runtime must not use base64 or Node Buffer binary transport:" >&2 + echo "$base64_runtime_hits" >&2 + exit 1 +fi + +codegen_binary_hits="$( + if command -v rg >/dev/null 2>&1; then + rg -n 'execProtocolRaw|execProtocolStream|backup\(|restore\(' \ + "$package_dir/src/specs/NativeOliphaunt.ts" || true + else + grep -nE 'execProtocolRaw|execProtocolStream|backup\(|restore\(' \ + "$package_dir/src/specs/NativeOliphaunt.ts" || true + fi +)" +if [ -n "$codegen_binary_hits" ]; then + echo "React Native Codegen spec must stay lifecycle/control-only; binary protocol, backup, and restore bytes belong to the JSI ArrayBuffer transport:" >&2 + echo "$codegen_binary_hits" >&2 + exit 1 +fi + +for jsi_source in \ + "$package_dir/ios/Oliphaunt.mm" \ + "$package_dir/android/src/main/cpp/OliphauntJsiBindings.cpp" +do + require_source_text "$jsi_source" "std::isfinite" \ + "React Native JSI numeric arguments must reject non-finite values before native casts" + require_source_text "$jsi_source" "typed-array byteOffset" \ + "React Native JSI typed-array offsets must be validated before native casts" + require_source_text "$jsi_source" "typed-array byteLength" \ + "React Native JSI typed-array lengths must be validated before native casts" + require_source_text "$jsi_source" "positive safe integer" \ + "React Native JSI handles must be validated as positive safe integers before native calls" +done + +if [ "$mode" = "check-static" ]; then + exit 0 +fi + +if command -v ruby >/dev/null 2>&1; then + run ruby -c "$package_dir/OliphauntReactNative.podspec" + run ruby -c "$package_dir/ios/podspecs/COliphaunt.podspec" + run ruby -c "$package_dir/ios/podspecs/Oliphaunt.podspec" +fi + +mkdir -p "$scratch_root" +tmp_pack="$scratch_root/react-native-npm-pack.json" +rm -f "$tmp_pack" +printf '\n==> pnpm --dir %s pack --dry-run --json\n' "$package_dir" +pnpm --dir "$package_dir" pack --dry-run --json >"$tmp_pack" +cat "$tmp_pack" + +for required in \ + "android/settings.gradle" \ + "android/src/main/cpp/CMakeLists.txt" \ + "android/src/main/cpp/include/oliphaunt.h" \ + "android/src/main/cpp/OliphauntJsiBindings.cpp" \ + "android/src/main/java/dev/oliphaunt/reactnative/OliphauntJsiPromiseCallback.kt" \ + "android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt" \ + "android/src/main/java/dev/oliphaunt/reactnative/OliphauntPackage.kt" \ + "ios/Oliphaunt.mm" \ + "ios/OliphauntReactNative.h" \ + "ios/OliphauntAdapter.h" \ + "ios/OliphauntAdapter.swift" \ + "ios/podspecs/COliphaunt.podspec" \ + "ios/podspecs/Oliphaunt.podspec" \ + "lib/commonjs/index.js" \ + "lib/commonjs/protocol.js" \ + "lib/module/index.js" \ + "lib/module/protocol.js" \ + "lib/typescript/index.d.ts" \ + "lib/typescript/smoke.d.ts" \ + "src/smoke.ts" \ + "lib/typescript/specs/NativeOliphaunt.d.ts" +do + if ! grep -Fq "$required" "$tmp_pack"; then + echo "React Native package dry-run did not include $required" >&2 + rm -f "$tmp_pack" + exit 1 + fi +done + +for removed in \ + "android/CMakeLists.txt" \ + "android/src/main/cpp/oliphaunt_android_bridge.cpp" \ + "android/src/main/java/dev/oliphaunt/reactnative/OliphauntAndroidRuntimeAssets.kt" \ + "android/src/main/java/dev/oliphaunt/reactnative/OliphauntAndroidSession.kt" \ + "android/src/main/java/dev/oliphaunt/reactnative/OliphauntNativeBridge.kt" \ + "ios/Oliphaunt.h" \ + "ios/OliphauntAssets.h" \ + "ios/OliphauntAssets.mm" \ + "ios/vendor/oliphaunt-swift" \ + "ios/vendor/liboliphaunt.xcframework" \ + "Sources/COliphaunt/include/oliphaunt.h" \ + "Sources/Oliphaunt/Oliphaunt.swift" \ + "Sources/Oliphaunt/OliphauntNativeDirect.swift" \ + "liboliphaunt.dylib" \ + "liboliphaunt.xcframework" +do + if grep -Fq "$removed" "$tmp_pack"; then + echo "React Native package dry-run still included duplicate Android native runtime file $removed" >&2 + rm -f "$tmp_pack" + exit 1 + fi +done + +if grep -Eq '"path"[[:space:]]*:[[:space:]]*"android/(\.gradle|\.cxx|build|src/test)/' "$tmp_pack"; then + echo "React Native package dry-run included Android build artifacts or test fixtures" >&2 + rm -f "$tmp_pack" + exit 1 +fi +rm -f "$tmp_pack" + +if [ -d "$android_dir/src/main/cpp" ]; then + unexpected_android_cpp="$( + find "$android_dir/src/main/cpp" -type f | + sed "s#^$android_dir/##" | + grep -Ev '^(src/main/cpp/CMakeLists\.txt|src/main/cpp/OliphauntJsiBindings\.cpp|src/main/cpp/include/oliphaunt\.h)$' || true + )" + if [ -n "$unexpected_android_cpp" ]; then + echo "React Native Android should only carry the JSI installer and must delegate the native runtime to the Kotlin SDK; found:" >&2 + echo "$unexpected_android_cpp" >&2 + exit 1 + fi +fi + +if [ -n "${JAVA_HOME:-}" ]; then + java_home="$JAVA_HOME" +elif command -v /usr/libexec/java_home >/dev/null 2>&1; then + java_home="$(/usr/libexec/java_home)" +else + java_home="" +fi + +if [ -n "$java_home" ] && [ -f "$java_home/include/jni.h" ]; then + jni_platform_dir="$(find "$java_home/include" -mindepth 1 -maxdepth 1 -type d | head -n 1 || true)" + cxx="${CXX:-c++}" + if command -v xcrun >/dev/null 2>&1; then + cxx="xcrun clang++" + elif ! command -v "$cxx" >/dev/null 2>&1; then + cxx="clang++" + fi + # shellcheck disable=SC2086 + run $cxx -fsyntax-only -std=c++17 \ + -I "$java_home/include" \ + ${jni_platform_dir:+-I "$jni_platform_dir"} \ + -I "src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include" \ + "src/sdks/kotlin/oliphaunt/src/androidMain/cpp/oliphaunt_android_bridge.cpp" +else + echo "warning: skipping Android JNI syntax check because JAVA_HOME/JDK headers are unavailable" >&2 +fi + +if [ "$mode" = "package-shape" ]; then + exit 0 +fi + +run_ios_platform_checks=0 +case "$mode" in + build-ios-bridge|release-check|regression) + run_ios_platform_checks=1 + ;; +esac + +ios_platform_checks=0 +if [ "$run_ios_platform_checks" = "1" ] && + [ "$(uname -s)" = "Darwin" ] && + command -v xcrun >/dev/null 2>&1; then + tmp_swift_adapter="$(prepare_scratch_dir react-native-swift-adapter)" + mkdir -p "$tmp_swift_adapter/Sources/RNAdapterCheck" + cp "$package_dir/ios/OliphauntAdapter.swift" \ + "$tmp_swift_adapter/Sources/RNAdapterCheck/OliphauntAdapter.swift" + cat >"$tmp_swift_adapter/Package.swift" <&2 + exit 1 + fi + exit 0 +fi + +run_android_platform_checks=0 +case "$mode" in + build-android-bridge|release-check|regression) + run_android_platform_checks=1 + ;; +esac + +if [ "$run_android_platform_checks" = "1" ]; then + [ -n "${ANDROID_HOME:-}" ] || { + echo "React Native Android adapter checks require ANDROID_HOME" >&2 + exit 1 + } + run gradle -p "$android_dir" $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args --quiet help + run gradle -p "$android_dir" assembleDebug $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args + + tmp_split_runtime="$(prepare_scratch_dir react-native-split-runtime)" + tmp_split_template="$(prepare_scratch_dir react-native-split-template)" + mkdir -p \ + "$tmp_split_runtime/share/postgresql" \ + "$tmp_split_runtime/lib/postgresql" \ + "$tmp_split_template/base" + printf 'runtime split smoke\n' >"$tmp_split_runtime/share/postgresql/README.liboliphaunt-split-smoke" + printf '18\n' >"$tmp_split_template/PG_VERSION" + printf 'template split smoke\n' >"$tmp_split_template/base/README.liboliphaunt-split-smoke" + run gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=vector" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + generated_assets="$android_build_dir/generated/liboliphaunt-assets" + split_runtime_manifest="$generated_assets/oliphaunt/runtime/manifest.properties" + split_template_manifest="$generated_assets/oliphaunt/template-pgdata/manifest.properties" + require_manifest_line "$split_runtime_manifest" "schema=oliphaunt-runtime-resources-v1" \ + "React Native Android split runtime manifest did not emit the shared runtime-resources schema" + require_manifest_line "$split_runtime_manifest" "layout=postgres-runtime-files-v1" \ + "React Native Android split runtime manifest did not emit the runtime resources layout" + require_manifest_line "$split_runtime_manifest" "extensions=vector" \ + "React Native Android split runtime manifest did not record selected vector extension" + require_manifest_line "$split_runtime_manifest" "sharedPreloadLibraries=" \ + "React Native Android split runtime manifest did not record shared preload libraries" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryState=pending" \ + "React Native Android split runtime manifest did not mark mobile static registry as pending" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryRegistered=" \ + "React Native Android split runtime manifest should not claim registered mobile static modules" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryPending=vector" \ + "React Native Android split runtime manifest did not record pending mobile static registry modules" + require_manifest_line "$split_runtime_manifest" "nativeModuleStems=vector" \ + "React Native Android split runtime manifest did not record expected native module stems" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistrySource=" \ + "React Native Android split runtime manifest should not claim generated mobile static-registry source" + require_manifest_line "$split_template_manifest" "mobileStaticRegistryState=not-required" \ + "React Native Android split template manifest should not require mobile static registry work" + require_manifest_line "$split_template_manifest" "mobileStaticRegistryPending=" \ + "React Native Android split template manifest should not list pending mobile static registry modules" + require_manifest_line "$split_template_manifest" "sharedPreloadLibraries=" \ + "React Native Android split template manifest should not list shared preload libraries" + require_manifest_line "$split_template_manifest" "nativeModuleStems=" \ + "React Native Android split template manifest should not list native module stems" + require_manifest_line "$split_template_manifest" "mobileStaticRegistrySource=" \ + "React Native Android split template manifest should not claim generated mobile static-registry source" + + split_static_log="$scratch_root/react-native-split-static.log" + rm -f "$split_static_log" + printf '\n==> %s\n' "gradle -p $android_dir prepareOliphauntAndroidAssets -PoliphauntMobileStaticModules=vector" + if gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=vector" \ + "-PoliphauntMobileStaticModules=vector" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args >"$split_static_log" 2>&1; then + echo "React Native Android split runtime packaging accepted a mobile static module declaration without generated registry source" >&2 + cat "$split_static_log" >&2 + rm -f "$split_static_log" + exit 1 + fi + if ! grep -Fq "split runtime packaging cannot declare mobile static module stems" "$split_static_log"; then + echo "React Native Android split runtime packaging failed without the expected static-registry diagnostic" >&2 + cat "$split_static_log" >&2 + rm -f "$split_static_log" + exit 1 + fi + rm -f "$split_static_log" + + run gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=earthdistance" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + require_manifest_line "$split_runtime_manifest" "extensions=cube,earthdistance" \ + "React Native Android split runtime manifest did not include exact extension dependencies" + require_manifest_line "$split_runtime_manifest" "sharedPreloadLibraries=" \ + "React Native Android split runtime manifest should not record shared preload libraries for earthdistance" + require_manifest_line "$split_runtime_manifest" "mobileStaticRegistryPending=cube,earthdistance" \ + "React Native Android split runtime manifest did not map earthdistance mobile pending extensions" + require_manifest_line "$split_runtime_manifest" "nativeModuleStems=cube,earthdistance" \ + "React Native Android split runtime manifest did not map earthdistance native module stems" + + split_unknown_extension_log="$scratch_root/react-native-split-unknown-extension.log" + rm -f "$split_unknown_extension_log" + printf '\n==> %s\n' "gradle -p $android_dir prepareOliphauntAndroidAssets -PoliphauntExtensions=acme_unknown" + if gradle -p "$android_dir" prepareOliphauntAndroidAssets \ + "-PoliphauntRuntimeDir=$tmp_split_runtime" \ + "-PoliphauntTemplatePgdataDir=$tmp_split_template" \ + "-PoliphauntExtensions=acme_unknown" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args >"$split_unknown_extension_log" 2>&1; then + echo "React Native Android split runtime packaging accepted an extension absent from generated metadata" >&2 + cat "$split_unknown_extension_log" >&2 + rm -f "$split_unknown_extension_log" + exit 1 + fi + if ! grep -Fq "cannot select unknown extension 'acme_unknown'" "$split_unknown_extension_log"; then + echo "React Native Android split runtime packaging failed without the expected unknown-extension diagnostic" >&2 + cat "$split_unknown_extension_log" >&2 + rm -f "$split_unknown_extension_log" + exit 1 + fi + rm -f "$split_unknown_extension_log" + rm -rf "$tmp_split_runtime" "$tmp_split_template" + + tmp_assets="$(prepare_scratch_dir react-native-runtime-resources)" + tmp_static_jni="$(prepare_scratch_dir react-native-static-jni)" + mkdir -p \ + "$tmp_assets/oliphaunt/runtime/files/share/postgresql/extension" \ + "$tmp_assets/oliphaunt/runtime/files/lib/postgresql" \ + "$tmp_assets/oliphaunt/static-registry" \ + "$tmp_assets/oliphaunt/template-pgdata/files/base" + printf '18\n' >"$tmp_assets/oliphaunt/template-pgdata/files/PG_VERSION" + printf 'runtime smoke\n' >"$tmp_assets/oliphaunt/runtime/files/share/postgresql/README.liboliphaunt-smoke" + printf "comment = 'vector smoke control'\n" >"$tmp_assets/oliphaunt/runtime/files/share/postgresql/extension/vector.control" + printf "select 'vector smoke sql';\n" >"$tmp_assets/oliphaunt/runtime/files/share/postgresql/extension/vector--1.0.sql" + printf '/* static registry smoke */\n' >"$tmp_assets/oliphaunt/static-registry/oliphaunt_static_registry.c" + cat >"$tmp_assets/oliphaunt/static-registry/manifest.properties" <"$tmp_assets/oliphaunt/template-pgdata/files/base/README.liboliphaunt-smoke" + cat >"$tmp_assets/oliphaunt/runtime/manifest.properties" <<'MANIFEST' +schema=oliphaunt-runtime-resources-v1 +cacheKey=runtime-smoke +layout=postgres-runtime-files-v1 +extensions=vector +sharedPreloadLibraries= +mobileStaticRegistryState=complete +mobileStaticRegistryRegistered=vector +mobileStaticRegistryPending= +nativeModuleStems=vector +mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c +MANIFEST + cat >"$tmp_assets/oliphaunt/template-pgdata/manifest.properties" <<'MANIFEST' +schema=oliphaunt-runtime-resources-v1 +cacheKey=template-smoke +layout=postgres-template-pgdata-v1 +extensions= +sharedPreloadLibraries= +mobileStaticRegistryState=not-required +mobileStaticRegistryRegistered= +mobileStaticRegistryPending= +nativeModuleStems= +mobileStaticRegistrySource= +MANIFEST + cat >"$tmp_assets/oliphaunt/package-size.tsv" <<'REPORT' +kind id extensions files bytes +package total - - 185 +package runtime - - 100 +package template-pgdata - - 40 +package static-registry - - 45 +extensions selected - - 30 +extension vector - 3 30 +REPORT + android_link_evidence="$scratch_root/android-static-extension-link-$android_smoke_abi.tsv" + rm -f "$android_link_evidence" + run gradle -p "$android_dir" assembleDebug \ + "-PoliphauntRuntimeResourcesDir=$tmp_assets" \ + "-PoliphauntAndroidJniLibsDir=$tmp_static_jni" \ + "-PoliphauntAndroidAbiFilters=$android_smoke_abi" \ + "-PoliphauntAndroidLinkEvidenceFile=$android_link_evidence" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + require_manifest_line "$android_link_evidence" "schema oliphaunt-android-static-extension-link-v1" \ + "Android static extension link evidence did not record schema" + require_manifest_line "$android_link_evidence" "abi $android_smoke_abi" \ + "Android static extension link evidence did not record ABI" + if ! grep -Fq "extension vector " "$android_link_evidence"; then + echo "Android static extension link evidence did not record selected vector extension" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fq "liboliphaunt_extension_vector.a" "$android_link_evidence"; then + echo "Android static extension link evidence did not record selected vector archive" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + aar="$android_build_dir/outputs/aar/android-debug.aar" + kotlin_aar="$kotlin_build_dir/outputs/aar/oliphaunt-debug.aar" + asset_aar="$aar" + if [ -f "$kotlin_aar" ] && + jar tf "$kotlin_aar" | grep -Fxq "assets/oliphaunt/runtime/manifest.properties"; then + asset_aar="$kotlin_aar" + fi + for required_asset in \ + "assets/oliphaunt/runtime/manifest.properties" \ + "assets/oliphaunt/runtime/files/share/postgresql/README.liboliphaunt-smoke" \ + "assets/oliphaunt/runtime/files/share/postgresql/extension/vector.control" \ + "assets/oliphaunt/runtime/files/share/postgresql/extension/vector--1.0.sql" \ + "assets/oliphaunt/package-size.tsv" \ + "assets/oliphaunt/static-registry/oliphaunt_static_registry.c" \ + "assets/oliphaunt/static-registry/manifest.properties" \ + "assets/oliphaunt/template-pgdata/manifest.properties" \ + "assets/oliphaunt/template-pgdata/files/PG_VERSION" + do + if ! jar tf "$asset_aar" | grep -Fxq "$required_asset"; then + echo "Android AAR did not include generated asset $required_asset" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + done + if jar tf "$asset_aar" | grep -Fxq "assets/oliphaunt/runtime/files/share/postgresql/extension/hstore.control"; then + echo "Android AAR included unselected hstore extension control file" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if jar tf "$asset_aar" | grep -Fq "assets/oliphaunt/static-registry/archives/"; then + echo "Android AAR included build-only static extension archives" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + tmp_aar_extract="$tmp_assets/aar" + mkdir -p "$tmp_aar_extract" + (cd "$tmp_aar_extract" && jar xf "$asset_aar" assets/oliphaunt/runtime/manifest.properties assets/oliphaunt/package-size.tsv) + if ! grep -Fxq "schema=oliphaunt-runtime-resources-v1" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then + echo "Android AAR runtime manifest did not preserve runtime-resources layout schema" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "layout=postgres-runtime-files-v1" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then + echo "Android AAR runtime manifest did not preserve runtime resources layout" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "extensions=vector" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then + echo "Android AAR runtime manifest did not record selected extensions" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "mobileStaticRegistryState=complete" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then + echo "Android AAR runtime manifest did not preserve mobile static-registry state" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "sharedPreloadLibraries=" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then + echo "Android AAR runtime manifest did not preserve shared preload metadata" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c" "$tmp_aar_extract/assets/oliphaunt/runtime/manifest.properties"; then + echo "Android AAR runtime manifest did not preserve mobile static-registry source" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + if ! grep -Fxq "extension vector - 3 30" "$tmp_aar_extract/assets/oliphaunt/package-size.tsv"; then + echo "Android AAR did not preserve runtime-resources size report" >&2 + rm -rf "$tmp_assets" "$tmp_static_jni" + exit 1 + fi + rm -rf "$tmp_assets" "$tmp_static_jni" + + tmp_jni="$(prepare_scratch_dir react-native-jni)" + mkdir -p "$tmp_jni/jniLibs/arm64-v8a" + printf 'not-a-real-android-elf-for-packaging-smoke\n' >"$tmp_jni/jniLibs/arm64-v8a/liboliphaunt.so" + run gradle -p "$android_dir" prepareOliphauntAndroidJniLibs \ + "-PoliphauntAndroidJniLibsDir=$tmp_jni" \ + $gradle_scratch_args \ + $gradle_smoke_cache_args + generated_jni="$android_build_dir/generated/liboliphaunt-jniLibs" + if [ ! -f "$generated_jni/arm64-v8a/liboliphaunt.so" ]; then + echo "React Native Android generated JNI libs did not include packaged liboliphaunt.so" >&2 + rm -rf "$tmp_jni" + exit 1 + fi + rm -rf "$tmp_jni" + + run gradle -p "$android_dir" testDebugUnitTest lintDebug $android_abi_gradle_args $gradle_scratch_args $gradle_cache_args +fi + +if [ "$mode" = "build-android-bridge" ]; then + exit 0 +fi diff --git a/src/sdks/react-native/tools/codegen-check.cjs b/src/sdks/react-native/tools/codegen-check.cjs new file mode 100644 index 00000000..ff362ba1 --- /dev/null +++ b/src/sdks/react-native/tools/codegen-check.cjs @@ -0,0 +1,13 @@ +const path = require("node:path"); +const { createRequire } = require("node:module"); + +const requireFromPackage = createRequire(path.join(process.cwd(), "package.json")); +const codegenPackageJson = requireFromPackage.resolve("@react-native/codegen/package.json"); +const codegenRoot = path.dirname(codegenPackageJson); +const cliPath = path.join( + codegenRoot, + "lib/cli/combine/combine-js-to-schema-cli.js", +); + +process.argv = [process.execPath, cliPath, ...process.argv.slice(2)]; +require(cliPath); diff --git a/src/sdks/react-native/tools/expo-android-runner.sh b/src/sdks/react-native/tools/expo-android-runner.sh new file mode 100755 index 00000000..35e705db --- /dev/null +++ b/src/sdks/react-native/tools/expo-android-runner.sh @@ -0,0 +1,749 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" +. "$root/src/runtimes/liboliphaunt/native/bin/build-output.bash" +. "$root/src/sdks/react-native/tools/expo-runner-common.sh" +. "$root/src/sdks/react-native/tools/expo-runner-metro.sh" +. "$root/src/sdks/react-native/tools/expo-runner-reporting.sh" +. "$root/src/sdks/react-native/tools/expo-runner-workspace.sh" +. "$root/src/sdks/react-native/tools/mobile-extension-runtime.sh" +. "$root/src/sdks/react-native/tools/expo-runner-runtime-resources.sh" +. "$root/src/sdks/react-native/tools/expo-runner-android-device.sh" + +source_example_dir="$root/src/sdks/react-native/examples/expo" +rn_dir="$root/src/sdks/react-native" +scratch_workspace_name="oliphaunt-react-native-expo-android-workspace" +runner="${OLIPHAUNT_EXPO_ANDROID_RUNNER:-smoke}" +case "$runner" in + smoke|benchmark|crash) + ;; + *) + echo "error: OLIPHAUNT_EXPO_ANDROID_RUNNER must be smoke, benchmark, or crash, got $runner" >&2 + exit 1 + ;; +esac +success_tag="OLIPHAUNT_EXPO_SMOKE_PASS" +failure_tag="OLIPHAUNT_EXPO_SMOKE_FAIL" +if [ "$runner" = "benchmark" ]; then + success_tag="OLIPHAUNT_EXPO_BENCH_PASS" + failure_tag="OLIPHAUNT_EXPO_BENCH_FAIL" +elif [ "$runner" = "crash" ]; then + success_tag="OLIPHAUNT_EXPO_CRASH_RECOVERY_PASS" + failure_tag="OLIPHAUNT_EXPO_CRASH_RECOVERY_FAIL" +fi +scratch_root="${OLIPHAUNT_EXPO_ANDROID_SCRATCH:-$root/target/oliphaunt-expo-android-$runner}" +example_dir="${OLIPHAUNT_EXPO_ANDROID_EXAMPLE_DIR:-$scratch_root/src/sdks/react-native/examples/expo}" +package_work="$scratch_root/src/sdks/react-native" +crash_root_suffix="$(printf '%s' "$(basename "$scratch_root")" | LC_ALL=C tr -c 'A-Za-z0-9_.-' '-')" +[ -n "$crash_root_suffix" ] || crash_root_suffix="run" +pack_dir="$root/target/oliphaunt-rn-expo-pack/android" +tarball="$pack_dir/$(react_native_package_tarball_name "$rn_dir")" +local_maven_repo="$scratch_root/maven-local" +build_type="${OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE:-debug}" +case "$build_type" in + debug|release) + ;; + *) + echo "error: OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE must be debug or release, got $build_type" >&2 + exit 1 + ;; +esac +build_only="${OLIPHAUNT_EXPO_ANDROID_BUILD_ONLY:-0}" +e2e_only="${OLIPHAUNT_EXPO_ANDROID_E2E_ONLY:-0}" +e2e_assertion_runner="${OLIPHAUNT_EXPO_ANDROID_E2E_ASSERTION_RUNNER:-${OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER:-log}}" +case "$e2e_assertion_runner" in + auto|log|maestro) + ;; + *) + echo "error: OLIPHAUNT_EXPO_ANDROID_E2E_ASSERTION_RUNNER must be auto, log, or maestro, got $e2e_assertion_runner" >&2 + exit 1 + ;; +esac +build_type_capitalized="$(printf '%s' "$build_type" | awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }')" +apk="${OLIPHAUNT_EXPO_ANDROID_APK:-$example_dir/android/app/build/outputs/apk/$build_type/app-$build_type.apk}" +build_artifact_dir="${OLIPHAUNT_EXPO_ANDROID_BUILD_ARTIFACT_DIR:-$root/target/mobile-build/react-native/android}" +maestro_flow="${OLIPHAUNT_EXPO_ANDROID_MAESTRO_FLOW:-$source_example_dir/maestro/installed-smoke.yaml}" +app_id="${OLIPHAUNT_EXPO_ANDROID_APP_ID:-dev.oliphaunt.reactnative.example}" +scheme="${OLIPHAUNT_EXPO_ANDROID_SCHEME:-reactnativeoliphauntexpo}" +dev_client_scheme="${OLIPHAUNT_EXPO_ANDROID_DEV_CLIENT_SCHEME:-exp+react-native-oliphaunt-expo}" +metro_host="${OLIPHAUNT_EXPO_ANDROID_METRO_HOST:-10.0.2.2}" +if [ -n "${OLIPHAUNT_EXPO_ANDROID_METRO_PORT:-}" ]; then + metro_port="$OLIPHAUNT_EXPO_ANDROID_METRO_PORT" + metro_port_explicit=1 +else + metro_port=8081 + metro_port_explicit=0 +fi +reuse_metro="${OLIPHAUNT_EXPO_ANDROID_REUSE_METRO:-0}" +keep_metro="${OLIPHAUNT_EXPO_ANDROID_KEEP_METRO:-0}" +reuse_metro_env_name="OLIPHAUNT_EXPO_ANDROID_REUSE_METRO" +metro_port_env_name="OLIPHAUNT_EXPO_ANDROID_METRO_PORT" +default_timeout_seconds=600 +[ "$runner" = "benchmark" ] && default_timeout_seconds=720 +timeout_seconds="${OLIPHAUNT_EXPO_ANDROID_TIMEOUT_SECONDS:-$default_timeout_seconds}" +android_abi="${OLIPHAUNT_EXPO_ANDROID_ABI:-arm64-v8a}" +default_lifecycle_smoke=0 +[ "$runner" = "smoke" ] && default_lifecycle_smoke=1 +lifecycle_smoke="${OLIPHAUNT_EXPO_ANDROID_LIFECYCLE_SMOKE:-$default_lifecycle_smoke}" +background_seconds="${OLIPHAUNT_EXPO_ANDROID_BACKGROUND_SECONDS:-3}" +if [ "${OLIPHAUNT_EXPO_ANDROID_EXTENSIONS+x}" = "x" ]; then + mobile_extensions_raw="$OLIPHAUNT_EXPO_ANDROID_EXTENSIONS" +elif [ "${OLIPHAUNT_EXPO_MOBILE_EXTENSIONS+x}" = "x" ]; then + mobile_extensions_raw="$OLIPHAUNT_EXPO_MOBILE_EXTENSIONS" +else + mobile_extensions_raw="vector" +fi +runtime_footprint="${OLIPHAUNT_EXPO_ANDROID_RUNTIME_FOOTPRINT:-${OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT:-balancedMobile}}" +default_durability_profile=balanced +[ "$runner" = "crash" ] && default_durability_profile=safe +durability_profile="${OLIPHAUNT_EXPO_ANDROID_DURABILITY:-${OLIPHAUNT_EXPO_MOBILE_DURABILITY:-$default_durability_profile}}" +startup_gucs="${OLIPHAUNT_EXPO_ANDROID_STARTUP_GUCS:-${OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS:-}}" +wal_segsize_mb="${OLIPHAUNT_EXPO_ANDROID_WAL_SEGSIZE_MB:-${OLIPHAUNT_EXPO_MOBILE_WAL_SEGSIZE_MB:-16}}" +benchmark_preset="${OLIPHAUNT_EXPO_ANDROID_BENCHMARK_PRESET:-${OLIPHAUNT_EXPO_MOBILE_BENCHMARK_PRESET:-full}}" +crash_root_override="${OLIPHAUNT_EXPO_ANDROID_CRASH_ROOT:-}" +crash_root="${crash_root_override:-/data/data/$app_id/files/oliphaunt-crash-recovery-root-$crash_root_suffix}" +mobile_template_initdb="${OLIPHAUNT_EXPO_ANDROID_INITDB:-}" +react_native_package_extra_excludes=(--exclude ios/vendor) +metro_pid="" +metro_bundle_runner="" +metro_bundle_root="" + +android_ndk_root() { + local configured="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}" + if [ -n "$configured" ] && [ -d "$configured" ]; then + printf '%s\n' "$configured" + return + fi + find "$ANDROID_HOME/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1 +} + +android_toolchain_bin() { + local ndk_root toolchain_dir + ndk_root="$(android_ndk_root)" + [ -n "$ndk_root" ] || return 1 + while IFS= read -r toolchain_dir; do + if [ -d "$toolchain_dir" ]; then + printf '%s\n' "$toolchain_dir" + return + fi + done < <(android_ndk_toolchain_bin_candidates "$ndk_root") + return 1 +} + +android_ndk_toolchain_bin_candidates() { + local ndk_root="$1" + case "$(uname -s):$(uname -m)" in + Darwin:arm64 | Darwin:aarch64) + printf '%s\n' \ + "$ndk_root/toolchains/llvm/prebuilt/darwin-arm64/bin" \ + "$ndk_root/toolchains/llvm/prebuilt/darwin-x86_64/bin" + ;; + Darwin:x86_64) + printf '%s\n' "$ndk_root/toolchains/llvm/prebuilt/darwin-x86_64/bin" + ;; + Linux:x86_64 | Linux:amd64) + printf '%s\n' "$ndk_root/toolchains/llvm/prebuilt/linux-x86_64/bin" + ;; + Linux:aarch64 | Linux:arm64) + printf '%s\n' \ + "$ndk_root/toolchains/llvm/prebuilt/linux-aarch64/bin" \ + "$ndk_root/toolchains/llvm/prebuilt/linux-x86_64/bin" + ;; + esac +} + +android_liboliphaunt_has_current_abi() { + local library="$1" + local toolchain_bin symbols symbol + [ -f "$library" ] || return 1 + toolchain_bin="$(android_toolchain_bin)" || return 1 + [ -x "$toolchain_bin/llvm-nm" ] || return 1 + symbols="$("$toolchain_bin/llvm-nm" -D --defined-only "$library" 2>/dev/null || true)" + for symbol in \ + oliphaunt_init \ + oliphaunt_exec_protocol \ + oliphaunt_exec_protocol_stream \ + oliphaunt_backup \ + oliphaunt_restore \ + oliphaunt_cancel \ + oliphaunt_detach \ + oliphaunt_close \ + oliphaunt_last_error \ + oliphaunt_version \ + oliphaunt_capabilities \ + oliphaunt_free_response + do + case "$symbols" in + *" T $symbol"*|*" D $symbol"*|*" B $symbol"*) ;; + *) return 1 ;; + esac + done +} + +android_build_root_for_abi() { + case "$android_abi" in + arm64-v8a) printf '%s\n' "$root/target/liboliphaunt-pg18-android-arm64" ;; + x86_64) printf '%s\n' "$root/target/liboliphaunt-pg18-android-x86_64" ;; + *) fail "unsupported Android ABI: $android_abi" ;; + esac +} + +android_build_script_for_abi() { + case "$android_abi" in + arm64-v8a) printf '%s\n' "$root/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh" ;; + x86_64) printf '%s\n' "$root/src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh" ;; + *) fail "unsupported Android ABI: $android_abi" ;; + esac +} + +normalize_mobile_extensions() { + oliphaunt_dev_normalize_mobile_extensions "$mobile_extensions_raw" "Android" +} + +mobile_static_extensions_for_selection() { + oliphaunt_dev_mobile_static_extensions_for_selection "$1" +} + +mobile_static_registry_source_for_library() { + local library="$1" + local configured="${OLIPHAUNT_EXPO_ANDROID_STATIC_REGISTRY_SOURCE:-${OLIPHAUNT_EXPO_MOBILE_STATIC_REGISTRY_SOURCE:-}}" + if [ -n "$configured" ]; then + [ -f "$configured" ] || fail "configured Android static registry source does not exist: $configured" + printf '%s\n' "$configured" + return + fi + local candidate + candidate="$(dirname "$library")/liboliphaunt_mobile_static_registry.c" + [ -f "$candidate" ] && printf '%s\n' "$candidate" + return 0 # exact-extension packages may provide the static registry source. +} + +ensure_android_env() { + if [ -z "${ANDROID_HOME:-}" ] && [ -d "$HOME/Library/Android/sdk" ]; then + export ANDROID_HOME="$HOME/Library/Android/sdk" + fi + if [ -n "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then + export ANDROID_SDK_ROOT="$ANDROID_HOME" + fi + [ -n "${ANDROID_HOME:-}" ] || fail "ANDROID_HOME is not set" + [ -x "$ANDROID_HOME/platform-tools/adb" ] || fail "adb not found under ANDROID_HOME=$ANDROID_HOME" + + if [ -z "${JAVA_HOME:-}" ] && + [ -d /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ]; then + export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + fi +} + +pack_react_native_sdk_if_needed() { + if expo_requires_sdk_artifacts; then + tarball="$(expo_single_sdk_artifact_file oliphaunt-react-native '*.tgz')" + install_react_native_sdk_tarball + return + fi + + need_cmd pnpm + mkdir -p "$pack_dir" + + local needs_pack=0 + if [ ! -f "$tarball" ]; then + needs_pack=1 + elif [ -n "$( + find \ + "$rn_dir/src" \ + "$rn_dir/android" \ + "$rn_dir/ios" \ + "$rn_dir/package.json" \ + "$rn_dir/tsconfig.build.json" \ + "$source_example_dir/package.json" \ + -path "$rn_dir/android/.gradle" -prune -o \ + -path "$rn_dir/android/.cxx" -prune -o \ + -path "$rn_dir/android/build" -prune -o \ + -path "$rn_dir/lib" -prune -o \ + -type f -newer "$tarball" -print -quit + )" ]; then + needs_pack=1 + fi + + if [ "$needs_pack" -eq 1 ]; then + prepare_react_native_package_worktree + run pnpm --dir "$package_work" run build + echo + echo "==> (cd $package_work && pnpm pack --pack-destination $pack_dir)" + ( + cd "$package_work" + pnpm pack --pack-destination "$pack_dir" + ) + else + echo "React Native SDK tarball is current: $tarball" + fi + + patch_expo_example_react_native_dependency "file:$tarball" + if [ ! -d "$example_dir/node_modules/@oliphaunt/react-native" ] || + [ "$tarball" -nt "$example_dir/node_modules/@oliphaunt/react-native/package.json" ]; then + install_expo_example_dependencies + else + echo "Expo example dependencies are current" + fi +} + +install_react_native_sdk_tarball() { + patch_expo_example_react_native_dependency "file:$tarball" + rm -rf "$example_dir/node_modules/@oliphaunt/react-native" + install_expo_example_dependencies +} + +ensure_android_project() { + if [ -x "$example_dir/android/gradlew" ]; then + ensure_android_local_kotlin_sdk_repository + return + fi + + echo "Generating Expo Android project for smoke validation" + ( + cd "$example_dir" + CI=1 EXPO_NO_TELEMETRY=1 npx expo prebuild --platform android + ) + ensure_android_local_kotlin_sdk_repository +} + +ensure_android_local_kotlin_sdk_repository() { + local settings="$example_dir/android/settings.gradle" + local root_build="$example_dir/android/build.gradle" + local gradle_properties="$example_dir/android/gradle.properties" + [ -f "$settings" ] || fail "generated Android settings.gradle is missing: $settings" + [ -f "$root_build" ] || fail "generated Android build.gradle is missing: $root_build" + [ -f "$gradle_properties" ] || fail "generated Android gradle.properties is missing: $gradle_properties" + if rg -q "liboliphaunt local Kotlin SDK smoke include" "$settings"; then + local tmp_settings="$settings.liboliphaunt" + awk '/\/\/ liboliphaunt local Kotlin SDK smoke include/ { exit } { print }' "$settings" > "$tmp_settings" + mv "$tmp_settings" "$settings" + fi + cat >>"$settings" <&2 + local extensions static_extensions + extensions="$(normalize_mobile_extensions)" + static_extensions="$(mobile_static_extensions_for_selection "$extensions")" + source_so="$(oliphaunt_capture_build_artifact_path \ + "Android $android_abi liboliphaunt build" \ + "$scratch_root/logs/build-android-$android_abi.log" \ + env ANDROID_HOME="$ANDROID_HOME" OLIPHAUNT_ANDROID_ABI="$android_abi" OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$static_extensions" "$(android_build_script_for_abi)")" + fi + if [ -z "$source_so" ] && [ -f "$root/target/liboliphaunt-android-jni-smoke/$android_abi/liboliphaunt.so" ]; then + source_so="$root/target/liboliphaunt-android-jni-smoke/$android_abi/liboliphaunt.so" + fi + if [ -z "$source_so" ] && [ -x "$(android_build_script_for_abi)" ]; then + expo_allows_native_builds || + fail "missing Android liboliphaunt.so and native builds are disabled; set OLIPHAUNT_EXPO_ANDROID_OLIPHAUNT_SO to a prebuilt artifact" + local extensions static_extensions + extensions="$(normalize_mobile_extensions)" + static_extensions="$(mobile_static_extensions_for_selection "$extensions")" + source_so="$(oliphaunt_capture_build_artifact_path \ + "Android $android_abi liboliphaunt build" \ + "$scratch_root/logs/build-android-$android_abi.log" \ + env ANDROID_HOME="$ANDROID_HOME" OLIPHAUNT_ANDROID_ABI="$android_abi" OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$static_extensions" "$(android_build_script_for_abi)")" + fi + [ -f "$source_so" ] || fail "missing Android liboliphaunt.so; set OLIPHAUNT_EXPO_ANDROID_OLIPHAUNT_SO or build $android_build_root/out/liboliphaunt.so" + android_liboliphaunt_has_current_abi "$source_so" || + fail "Android liboliphaunt.so is stale or missing required ABI symbols: $source_so" + printf '%s\n' "$source_so" +} + +prepare_jni_libs() { + local source_so="$1" + local jni_root="$scratch_root/jniLibs" + local target_dir="$jni_root/$android_abi" + mkdir -p "$target_dir" + if [ ! -f "$target_dir/liboliphaunt.so" ] || ! cmp -s "$source_so" "$target_dir/liboliphaunt.so"; then + cp "$source_so" "$target_dir/liboliphaunt.so" + fi + printf '%s\n' "$jni_root" +} + +prepare_runtime_resources() { + local static_registry_source="$1" + + local runtime_source="${OLIPHAUNT_EXPO_ANDROID_RUNTIME_DIR:-}" + if [ -z "$runtime_source" ]; then + local android_runtime_source + android_runtime_source="$(android_build_root_for_abi)/install" + if [ -f "$root/target/liboliphaunt-android-runtime-smoke/share/postgresql/postgres.bki" ]; then + runtime_source="$root/target/liboliphaunt-android-runtime-smoke" + elif [ -f "$android_runtime_source/share/postgresql/postgres.bki" ]; then + runtime_source="$android_runtime_source" + else + runtime_source="$(ensure_host_runtime_assets)" + fi + fi + [ -f "$runtime_source/share/postgresql/postgres.bki" ] || + fail "runtime assets are missing postgres.bki: $runtime_source" + ensure_mobile_runtime_tool_permissions "$runtime_source" + ensure_mobile_tool_executable "$mobile_template_initdb" + + local template_source + template_source="$( + find_latest_mobile_pgdata \ + Android \ + "${OLIPHAUNT_EXPO_ANDROID_TEMPLATE_PGDATA_DIR:-}" \ + OLIPHAUNT_EXPO_ANDROID_TEMPLATE_PGDATA_DIR \ + OLIPHAUNT_EXPO_ANDROID_INITDB + )" + local selected_extensions + selected_extensions="$(normalize_mobile_extensions)" + local package_root="$scratch_root/runtime-resources" + if oliphaunt_dev_prepare_prebuilt_mobile_runtime_resource_package \ + Android \ + "$runtime_source" \ + "$mobile_template_initdb" \ + "$selected_extensions" \ + "$package_root"; then + return 0 + fi + prepare_mobile_runtime_resource_package \ + Android \ + "$runtime_source" \ + "$template_source" \ + "$static_registry_source" \ + "$selected_extensions" \ + "${OLIPHAUNT_EXPO_ANDROID_REPACKAGE_ASSETS:-0}" \ + "$package_root" +} + +install_kotlin_sdk_maven_artifacts_if_required() { + if ! expo_requires_sdk_artifacts; then + return 1 + fi + + local product_root source_repo marker + product_root="$(expo_sdk_artifact_product_root oliphaunt-kotlin)" + source_repo="$product_root/maven" + marker="$source_repo/dev/oliphaunt/oliphaunt-android" + [ -d "$marker" ] || + fail "required Kotlin SDK Maven artifact is missing: $marker" + + rm -rf "$local_maven_repo" + mkdir -p "$local_maven_repo" + cp -R "$source_repo/." "$local_maven_repo/" + return 0 +} + +kotlin_sdk_dependency_from_maven_repo() { + local package_root="$local_maven_repo/dev/oliphaunt/oliphaunt-android" + [ -d "$package_root" ] || + fail "Kotlin SDK Maven repository is missing oliphaunt-android coordinates: $package_root" + local versions + versions="$(find "$package_root" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | LC_ALL=C sort)" + [ -n "$versions" ] || + fail "Kotlin SDK Maven repository has no oliphaunt-android versions: $package_root" + local count + count="$(printf '%s\n' "$versions" | wc -l | tr -d '[:space:]')" + [ "$count" = "1" ] || + fail "Kotlin SDK Maven repository contains $count oliphaunt-android versions; expected exactly one" + local version + version="$(printf '%s\n' "$versions")" + [ -f "$package_root/$version/oliphaunt-android-$version.aar" ] || + fail "Kotlin SDK Maven repository is missing oliphaunt-android-$version.aar" + printf 'dev.oliphaunt:oliphaunt-android:%s\n' "$version" +} + +publish_local_kotlin_sdk() { + local runtime_resources="$1" + local jni_libs="$2" + local kotlin_build_root="$scratch_root/kotlin-gradle-build" + local kotlin_cxx_root="$scratch_root/kotlin-cxx-build" + local kotlin_cache_root="$scratch_root/kotlin-gradle-cache" + local extension_archives_root + extension_archives_root="$runtime_resources/oliphaunt/static-registry/archives" + if [ ! -d "$extension_archives_root" ]; then + extension_archives_root="$(android_build_root_for_abi)/out" + fi + + if install_kotlin_sdk_maven_artifacts_if_required; then + return + fi + + rm -rf "$local_maven_repo/dev/oliphaunt/oliphaunt-android" + mkdir -p "$local_maven_repo" + run "$root/src/sdks/kotlin/gradlew" -p "$root/src/sdks/kotlin" \ + :oliphaunt:publishAndroidReleasePublicationToMavenLocal \ + "-Dmaven.repo.local=$local_maven_repo" \ + "-PoliphauntAndroidAbiFilters=$android_abi" \ + "-PoliphauntBuildRoot=$kotlin_build_root" \ + "-PoliphauntCxxBuildRoot=$kotlin_cxx_root" \ + --project-cache-dir "$kotlin_cache_root" \ + --no-configuration-cache +} + +build_apk() { + local runtime_resources="$1" + local jni_libs="$2" + local gradle_cache_arg="--no-configuration-cache" + + if [ "${OLIPHAUNT_EXPO_ANDROID_GRADLE_CONFIGURATION_CACHE:-0}" = "1" ]; then + gradle_cache_arg="--configuration-cache" + fi + + if [ "${OLIPHAUNT_EXPO_ANDROID_SKIP_BUILD:-0}" = "1" ] && [ -f "$apk" ]; then + echo "Skipping APK build: $apk" + else + local node_binary + node_binary="$(node -p 'process.execPath')" + local selected_extensions extension_archives_root kotlin_sdk_dependency android_link_evidence module_stems + selected_extensions="$(normalize_mobile_extensions)" + module_stems="$(oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions")" + kotlin_sdk_dependency="$(kotlin_sdk_dependency_from_maven_repo)" + extension_archives_root="$runtime_resources/oliphaunt/static-registry/archives" + if [ ! -d "$extension_archives_root" ]; then + extension_archives_root="$(android_build_root_for_abi)/out" + fi + android_link_evidence="$scratch_root/android-static-extension-link-$android_abi.tsv" + rm -f "$android_link_evidence" + run env \ + NODE_ENV=development \ + NODE_BINARY="$node_binary" \ + OLIPHAUNT_REACT_NATIVE_ANDROID_PACKAGE_RUNTIME=1 \ + OLIPHAUNT_REACT_NATIVE_ANDROID_RUNTIME_RESOURCES_DIR="$runtime_resources" \ + OLIPHAUNT_REACT_NATIVE_ANDROID_JNI_LIBS_DIR="$jni_libs" \ + OLIPHAUNT_REACT_NATIVE_ANDROID_EXTENSION_ARCHIVES_DIR="$extension_archives_root" \ + OLIPHAUNT_REACT_NATIVE_ANDROID_EXTENSIONS="$selected_extensions" \ + OLIPHAUNT_REACT_NATIVE_ANDROID_LINK_EVIDENCE_FILE="$android_link_evidence" \ + OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_MAVEN_REPOSITORY="$local_maven_repo" \ + OLIPHAUNT_REACT_NATIVE_KOTLIN_SDK_DEPENDENCY="$kotlin_sdk_dependency" \ + "$example_dir/android/gradlew" \ + --project-dir "$example_dir/android" \ + ":app:assemble$build_type_capitalized" \ + "-PoliphauntAndroidAbiFilters=$android_abi" \ + "-PreactNativeArchitectures=$android_abi" \ + "-PoliphauntKotlinSdkMavenRepository=$local_maven_repo" \ + "-PliboliphauntKotlinSdkDependency=$kotlin_sdk_dependency" \ + "-PoliphauntReactNativePackageRuntime=true" \ + "-PoliphauntRuntimeResourcesDir=$runtime_resources" \ + "-PoliphauntAndroidJniLibsDir=$jni_libs" \ + "-PoliphauntAndroidExtensionArchivesDir=$extension_archives_root" \ + "-PoliphauntAndroidLinkEvidenceFile=$android_link_evidence" \ + "-PoliphauntExtensions=$selected_extensions" \ + "-PoliphauntBuildRoot=$scratch_root/gradle-build" \ + "-PoliphauntCxxBuildRoot=$scratch_root/cxx-build" \ + "-PnodeExecutable=$node_binary" \ + "$gradle_cache_arg" + if [ -n "$module_stems" ] && [ ! -s "$android_link_evidence" ]; then + fail "Android build did not emit static extension link evidence: $android_link_evidence" + fi + fi + + local apk_files="$scratch_root/apk-files.txt" + zipinfo -1 "$apk" >"$apk_files" + grep -Fxq "lib/$android_abi/liboliphaunt.so" "$apk_files" || + fail "APK is missing lib/$android_abi/liboliphaunt.so" + grep -Fxq "assets/oliphaunt/runtime/manifest.properties" "$apk_files" || + fail "APK is missing Oliphaunt runtime manifest" + grep -Fxq "assets/oliphaunt/template-pgdata/manifest.properties" "$apk_files" || + fail "APK is missing liboliphaunt template manifest" + grep -Fxq "assets/oliphaunt/package-size.tsv" "$apk_files" || + fail "APK is missing Oliphaunt package-size report" + local selected_extensions + selected_extensions="$(normalize_mobile_extensions)" + oliphaunt_dev_assert_runtime_file_list "$selected_extensions" "Android" <"$apk_files" +} + +start_metro_if_needed() { + local bundle_runner="${1:-$runner}" + local bundle_root="${2:-}" + if [ "$build_type" = "release" ]; then + return + fi + + mkdir -p "$scratch_root" + if [ -n "${metro_pid:-}" ] && kill -0 "$metro_pid" >/dev/null 2>&1; then + if [ "$metro_bundle_runner" = "$bundle_runner" ] && [ "$metro_bundle_root" = "$bundle_root" ]; then + return 0 + fi + stop_owned_metro + fi + reserve_metro_port + if port_is_listening; then + if [ "$reuse_metro" = "1" ]; then + echo "Reusing Metro on port $metro_port" + return + fi + fail "Expo Metro port $metro_port is already in use; stop it, set OLIPHAUNT_EXPO_ANDROID_REUSE_METRO=1, or choose OLIPHAUNT_EXPO_ANDROID_METRO_PORT" + fi + + echo "Starting Expo Metro on port $metro_port for runner $bundle_runner" + ( + cd "$example_dir" + CI=1 EXPO_NO_TELEMETRY=1 EXPO_UNSTABLE_MCP_SERVER=1 \ + EXPO_PUBLIC_OLIPHAUNT_RUNNER="$bundle_runner" \ + EXPO_PUBLIC_OLIPHAUNT_LIFECYCLE_SMOKE="$lifecycle_smoke" \ + EXPO_PUBLIC_OLIPHAUNT_DURABILITY="$durability_profile" \ + EXPO_PUBLIC_OLIPHAUNT_RUNTIME_FOOTPRINT="$runtime_footprint" \ + EXPO_PUBLIC_OLIPHAUNT_BENCHMARK_PRESET="$benchmark_preset" \ + EXPO_PUBLIC_OLIPHAUNT_STARTUP_GUCS="$startup_gucs" \ + EXPO_PUBLIC_OLIPHAUNT_WAL_SEGSIZE_MB="$wal_segsize_mb" \ + EXPO_PUBLIC_OLIPHAUNT_ROOT="$bundle_root" \ + npx expo start --dev-client --port "$metro_port" --clear \ + >"$scratch_root/metro.log" 2>&1 + ) & + metro_pid="$!" + metro_bundle_runner="$bundle_runner" + metro_bundle_root="$bundle_root" + + for _ in $(seq 1 60); do + if port_is_listening; then + return + fi + sleep 1 + done + + tail -80 "$scratch_root/metro.log" >&2 || true + fail "Expo Metro did not start on port $metro_port" +} + +trap cleanup EXIT + +write_android_package_metrics() { + local apk_bytes="$1" + local rn_package_bytes="$2" + write_mobile_package_size_report apkBytes "$apk_bytes" "$rn_package_bytes" +} + +write_android_build_artifact_report() { + local selected_extensions="$1" + local apk_bytes rn_package_bytes apk_copy report android_link_evidence + mkdir -p "$build_artifact_dir" "$scratch_root/reports" + apk_copy="$build_artifact_dir/app-$build_type-$android_abi.apk" + android_link_evidence="$scratch_root/android-static-extension-link-$android_abi.tsv" + cp "$apk" "$apk_copy" + apk_bytes="$(wc -c <"$apk" | tr -d '[:space:]')" + rn_package_bytes="$(wc -c <"$tarball" | tr -d '[:space:]')" + report="$build_artifact_dir/build-report.json" + write_mobile_build_artifact_report_json \ + "$report" \ + android \ + "$apk_copy" \ + "$apk_bytes" \ + "$tarball" \ + "$rn_package_bytes" \ + "$selected_extensions" \ + "$scratch_root" \ + buildType "$build_type" \ + abi "$android_abi" \ + androidLinkEvidence "$android_link_evidence" + cp "$report" "$scratch_root/reports/build-report.json" + echo "Android mobile build artifact: $apk_copy" + echo "Android mobile build report: $report" +} + +main() { + if ! { is_truthy "$e2e_only" && [ "$build_type" = "release" ] && [ "$e2e_assertion_runner" = "maestro" ]; }; then + need_cmd rg + fi + if [ "$build_type" = "debug" ]; then + need_cmd pgrep + need_cmd lsof + fi + ensure_android_env + if is_truthy "$e2e_only"; then + need_cmd node + [ -f "$apk" ] || + fail "Android E2E-only mode requires an existing APK at $apk; run mobile-build:android first or set OLIPHAUNT_EXPO_ANDROID_SCRATCH to its scratch root" + install_and_launch + local apk_bytes rn_package_bytes + apk_bytes="$(wc -c <"$apk" | tr -d '[:space:]')" + rn_package_bytes="$(file_bytes "$tarball")" + write_android_package_metrics "$apk_bytes" "$rn_package_bytes" + exit 0 + fi + need_cmd zipinfo + prepare_expo_example_workspace + pack_react_native_sdk_if_needed + ensure_android_project + local runtime_resources jni_libs source_so static_registry_source + source_so="$(find_android_liboliphaunt_so)" + static_registry_source="$(mobile_static_registry_source_for_library "$source_so")" + runtime_resources="$(prepare_runtime_resources "$static_registry_source")" + jni_libs="$(prepare_jni_libs "$source_so")" + publish_local_kotlin_sdk "$runtime_resources" "$jni_libs" + build_apk "$runtime_resources" "$jni_libs" + local selected_extensions + selected_extensions="$(normalize_mobile_extensions)" + write_android_build_artifact_report "$selected_extensions" + if is_truthy "$build_only"; then + printf '\nAndroid build-only mobile artifact complete: %s\n' "$apk" + exit 0 + fi + install_and_launch + + local apk_bytes rn_package_bytes + apk_bytes="$(wc -c <"$apk" | tr -d '[:space:]')" + rn_package_bytes="$(wc -c <"$tarball" | tr -d '[:space:]')" + write_android_package_metrics "$apk_bytes" "$rn_package_bytes" + + printf '\nAPK bytes: ' + printf '%s' "$apk_bytes" + printf '\nRN package bytes: ' + printf '%s' "$rn_package_bytes" + printf '\n' +} + +main "$@" diff --git a/src/sdks/react-native/tools/expo-ios-runner.sh b/src/sdks/react-native/tools/expo-ios-runner.sh new file mode 100755 index 00000000..918989b1 --- /dev/null +++ b/src/sdks/react-native/tools/expo-ios-runner.sh @@ -0,0 +1,1010 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" +. "$root/src/runtimes/liboliphaunt/native/bin/build-output.bash" +. "$root/src/sdks/react-native/tools/expo-runner-common.sh" +. "$root/src/sdks/react-native/tools/expo-runner-metro.sh" +. "$root/src/sdks/react-native/tools/expo-runner-reporting.sh" +. "$root/src/sdks/react-native/tools/expo-runner-workspace.sh" +. "$root/src/sdks/react-native/tools/mobile-extension-runtime.sh" +. "$root/src/sdks/react-native/tools/expo-runner-runtime-resources.sh" +. "$root/src/sdks/react-native/tools/expo-runner-ios-device.sh" +. "$root/src/sdks/react-native/tools/expo-runner-ios-installed-app.sh" + +source_example_dir="$root/src/sdks/react-native/examples/expo" +rn_dir="$root/src/sdks/react-native" +scratch_workspace_name="oliphaunt-react-native-expo-ios-workspace" +runner="${OLIPHAUNT_EXPO_IOS_RUNNER:-smoke}" +case "$runner" in + smoke|benchmark|crash) + ;; + *) + echo "error: OLIPHAUNT_EXPO_IOS_RUNNER must be smoke, benchmark, or crash, got $runner" >&2 + exit 1 + ;; +esac +success_tag="OLIPHAUNT_EXPO_SMOKE_PASS" +failure_tag="OLIPHAUNT_EXPO_SMOKE_FAIL" +if [ "$runner" = "benchmark" ]; then + success_tag="OLIPHAUNT_EXPO_BENCH_PASS" + failure_tag="OLIPHAUNT_EXPO_BENCH_FAIL" +elif [ "$runner" = "crash" ]; then + success_tag="OLIPHAUNT_EXPO_CRASH_RECOVERY_PASS" + failure_tag="OLIPHAUNT_EXPO_CRASH_RECOVERY_FAIL" +fi +scratch_root="${OLIPHAUNT_EXPO_IOS_SCRATCH:-$root/target/oliphaunt-expo-ios-$runner}" +example_dir="${OLIPHAUNT_EXPO_IOS_EXAMPLE_DIR:-$scratch_root/src/sdks/react-native/examples/expo}" +crash_root_suffix="$(printf '%s' "$(basename "$scratch_root")" | LC_ALL=C tr -c 'A-Za-z0-9_.-' '-')" +[ -n "$crash_root_suffix" ] || crash_root_suffix="run" +package_work="$scratch_root/src/sdks/react-native" +pack_dir="${OLIPHAUNT_EXPO_IOS_PACK_DIR:-$root/target/oliphaunt-rn-expo-pack/ios}" +tarball="$pack_dir/$(react_native_package_tarball_name "$rn_dir")" +app_id="${OLIPHAUNT_EXPO_IOS_APP_ID:-dev.oliphaunt.reactnative.example}" +scheme="${OLIPHAUNT_EXPO_IOS_SCHEME:-reactnativeoliphauntexpo}" +if [ -n "${OLIPHAUNT_EXPO_IOS_METRO_PORT:-}" ]; then + metro_port="$OLIPHAUNT_EXPO_IOS_METRO_PORT" + metro_port_explicit=1 +else + metro_port=8081 + metro_port_explicit=0 +fi +reuse_metro="${OLIPHAUNT_EXPO_IOS_REUSE_METRO:-0}" +keep_metro="${OLIPHAUNT_EXPO_IOS_KEEP_METRO:-0}" +reuse_metro_env_name="OLIPHAUNT_EXPO_IOS_REUSE_METRO" +metro_port_env_name="OLIPHAUNT_EXPO_IOS_METRO_PORT" +default_timeout_seconds=600 +[ "$runner" = "benchmark" ] && default_timeout_seconds=720 +timeout_seconds="${OLIPHAUNT_EXPO_IOS_TIMEOUT_SECONDS:-$default_timeout_seconds}" +default_lifecycle_smoke=0 +[ "$runner" = "smoke" ] && default_lifecycle_smoke=1 +lifecycle_smoke="${OLIPHAUNT_EXPO_IOS_LIFECYCLE_SMOKE:-$default_lifecycle_smoke}" +background_seconds="${OLIPHAUNT_EXPO_IOS_BACKGROUND_SECONDS:-3}" +reuse_installed_app="${OLIPHAUNT_EXPO_IOS_REUSE_INSTALLED_APP:-0}" +clean_simulator_install="${OLIPHAUNT_EXPO_IOS_CLEAN_INSTALL:-1}" +e2e_only="${OLIPHAUNT_EXPO_IOS_E2E_ONLY:-0}" +e2e_assertion_runner="${OLIPHAUNT_EXPO_IOS_E2E_ASSERTION_RUNNER:-${OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER:-log}}" +case "$e2e_assertion_runner" in + auto|log|maestro) + ;; + *) + echo "error: OLIPHAUNT_EXPO_IOS_E2E_ASSERTION_RUNNER must be auto, log, or maestro, got $e2e_assertion_runner" >&2 + exit 1 + ;; +esac +configuration="${OLIPHAUNT_EXPO_IOS_CONFIGURATION:-Debug}" +sdk="${OLIPHAUNT_EXPO_IOS_SDK:-iphonesimulator}" +destination="${OLIPHAUNT_EXPO_IOS_DESTINATION:-}" +simulator_udid="${OLIPHAUNT_EXPO_IOS_SIMULATOR_UDID:-${OLIPHAUNT_EXPO_IOS_DEVICE_UDID:-}}" +physical_device_id="${OLIPHAUNT_EXPO_IOS_DEVICE_ID:-${OLIPHAUNT_EXPO_IOS_DEVICE_UDID:-}}" +simulator_name="${OLIPHAUNT_EXPO_IOS_DEVICE_NAME:-iPhone 15 Pro}" +derived_data="$scratch_root/DerivedData" +workspace="$example_dir/ios/reactnativeoliphauntexpo.xcworkspace" +xcode_scheme="reactnativeoliphauntexpo" +build_artifact_dir="${OLIPHAUNT_EXPO_IOS_BUILD_ARTIFACT_DIR:-$root/target/mobile-build/react-native/ios}" +maestro_flow="${OLIPHAUNT_EXPO_IOS_MAESTRO_FLOW:-$source_example_dir/maestro/installed-smoke.yaml}" +expo_use_precompiled_modules="${OLIPHAUNT_EXPO_IOS_USE_PRECOMPILED_MODULES:-true}" +use_ccache="${OLIPHAUNT_EXPO_IOS_USE_CCACHE:-1}" +liboliphaunt_pod_mode="vendored-framework" +code_signing_allowed="${OLIPHAUNT_EXPO_IOS_CODE_SIGNING_ALLOWED:-}" +development_team="${OLIPHAUNT_EXPO_IOS_DEVELOPMENT_TEAM:-}" +code_sign_style="${OLIPHAUNT_EXPO_IOS_CODE_SIGN_STYLE:-}" +code_sign_identity="${OLIPHAUNT_EXPO_IOS_CODE_SIGN_IDENTITY:-}" +provisioning_profile_specifier="${OLIPHAUNT_EXPO_IOS_PROVISIONING_PROFILE_SPECIFIER:-}" +allow_provisioning_updates="${OLIPHAUNT_EXPO_IOS_ALLOW_PROVISIONING_UPDATES:-}" +allow_device_registration="${OLIPHAUNT_EXPO_IOS_ALLOW_PROVISIONING_DEVICE_REGISTRATION:-}" +metro_dev_log="$example_dir/.expo/dev/logs/start.log" +if [ "${OLIPHAUNT_EXPO_IOS_EXTENSIONS+x}" = "x" ]; then + mobile_extensions_raw="$OLIPHAUNT_EXPO_IOS_EXTENSIONS" +elif [ "${OLIPHAUNT_EXPO_MOBILE_EXTENSIONS+x}" = "x" ]; then + mobile_extensions_raw="$OLIPHAUNT_EXPO_MOBILE_EXTENSIONS" +else + mobile_extensions_raw="vector" +fi +runtime_footprint="${OLIPHAUNT_EXPO_IOS_RUNTIME_FOOTPRINT:-${OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT:-balancedMobile}}" +default_durability_profile=balanced +[ "$runner" = "crash" ] && default_durability_profile=safe +durability_profile="${OLIPHAUNT_EXPO_IOS_DURABILITY:-${OLIPHAUNT_EXPO_MOBILE_DURABILITY:-$default_durability_profile}}" +startup_gucs="${OLIPHAUNT_EXPO_IOS_STARTUP_GUCS:-${OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS:-}}" +wal_segsize_mb="${OLIPHAUNT_EXPO_IOS_WAL_SEGSIZE_MB:-${OLIPHAUNT_EXPO_MOBILE_WAL_SEGSIZE_MB:-16}}" +benchmark_preset="${OLIPHAUNT_EXPO_IOS_BENCHMARK_PRESET:-${OLIPHAUNT_EXPO_MOBILE_BENCHMARK_PRESET:-full}}" +crash_root_override="${OLIPHAUNT_EXPO_IOS_CRASH_ROOT:-}" +mobile_template_initdb="${OLIPHAUNT_EXPO_IOS_INITDB:-}" +metro_pid="" +metro_bundle_runner="" +metro_bundle_root="" + +is_ios_build_only() { + is_truthy "${OLIPHAUNT_EXPO_IOS_BUILD_ONLY:-0}" +} + +is_physical_ios_launch() { + [ "$sdk" = "iphoneos" ] && ! is_ios_build_only +} + +is_ios_debug_configuration() { + case "$configuration" in + Debug|debug|DEBUG) + return 0 + ;; + *) + return 1 + ;; + esac +} + +uses_ios_metro() { + is_ios_debug_configuration +} + +is_reuse_installed_physical_ios_app() { + is_physical_ios_launch && is_truthy "$reuse_installed_app" +} + +normalize_mobile_extensions() { + oliphaunt_dev_normalize_mobile_extensions "$mobile_extensions_raw" "iOS" +} + +mobile_static_extensions_for_selection() { + oliphaunt_dev_mobile_static_extensions_for_selection "$1" +} + +mobile_static_registry_source_for_library() { + local artifact="$1" + local configured="${OLIPHAUNT_EXPO_IOS_STATIC_REGISTRY_SOURCE:-${OLIPHAUNT_EXPO_MOBILE_STATIC_REGISTRY_SOURCE:-}}" + if [ -n "$configured" ]; then + [ -f "$configured" ] || fail "configured iOS static registry source does not exist: $configured" + printf '%s\n' "$configured" + return + fi + case "$artifact" in + *.xcframework) + local candidate + candidate="$(dirname "$artifact")/liboliphaunt_mobile_static_registry.c" + [ -f "$candidate" ] && printf '%s\n' "$candidate" + return 0 # exact-extension packages may provide the static registry source. + ;; + *.dylib) + local candidate + candidate="$(dirname "$artifact")/liboliphaunt_mobile_static_registry.c" + [ -f "$candidate" ] && printf '%s\n' "$candidate" + return 0 # exact-extension packages may provide the static registry source. + ;; + esac + return 0 # exact-extension packages may provide the static registry source. +} + +prepare_runtime_resources() { + local static_registry_source="$1" + + local runtime_source="${OLIPHAUNT_EXPO_IOS_RUNTIME_DIR:-}" + if [ -z "$runtime_source" ]; then + if [ -f "$root/target/liboliphaunt-ios-runtime-smoke/share/postgresql/postgres.bki" ]; then + runtime_source="$root/target/liboliphaunt-ios-runtime-smoke" + elif [ -f "$root/target/liboliphaunt-ios-simulator/install/share/postgresql/postgres.bki" ]; then + runtime_source="$root/target/liboliphaunt-ios-simulator/install" + else + runtime_source="$(ensure_host_runtime_assets)" + fi + fi + [ -f "$runtime_source/share/postgresql/postgres.bki" ] || + fail "runtime assets are missing postgres.bki: $runtime_source" + ensure_mobile_runtime_tool_permissions "$runtime_source" + ensure_mobile_tool_executable "$mobile_template_initdb" + + local template_source + template_source="$( + find_latest_mobile_pgdata \ + iOS \ + "${OLIPHAUNT_EXPO_IOS_TEMPLATE_PGDATA_DIR:-}" \ + OLIPHAUNT_EXPO_IOS_TEMPLATE_PGDATA_DIR \ + OLIPHAUNT_EXPO_IOS_INITDB + )" + local selected_extensions + selected_extensions="$(normalize_mobile_extensions)" + local package_root="$scratch_root/runtime-resources" + if oliphaunt_dev_prepare_prebuilt_mobile_runtime_resource_package \ + iOS \ + "$runtime_source" \ + "$mobile_template_initdb" \ + "$selected_extensions" \ + "$package_root"; then + return 0 + fi + prepare_mobile_runtime_resource_package \ + iOS \ + "$runtime_source" \ + "$template_source" \ + "$static_registry_source" \ + "$selected_extensions" \ + "${OLIPHAUNT_EXPO_IOS_REPACKAGE_ASSETS:-0}" \ + "$package_root" +} + +find_ios_library_artifact() { + local artifact="${OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK:-}" + [ -n "$artifact" ] || artifact="${OLIPHAUNT_EXPO_IOS_OLIPHAUNT_FRAMEWORK:-}" + [ -n "$artifact" ] || artifact="${OLIPHAUNT_EXPO_IOS_OLIPHAUNT_DYLIB:-}" + if [ -z "$artifact" ] && [ "$sdk" = "iphonesimulator" ]; then + expo_allows_native_builds || + fail "missing iOS liboliphaunt artifact and native builds are disabled; set OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK, OLIPHAUNT_EXPO_IOS_OLIPHAUNT_FRAMEWORK, or OLIPHAUNT_EXPO_IOS_OLIPHAUNT_DYLIB" + local extensions static_extensions + extensions="$(normalize_mobile_extensions)" + static_extensions="$(mobile_static_extensions_for_selection "$extensions")" + artifact="$(oliphaunt_capture_build_artifact_path \ + "iOS simulator liboliphaunt build" \ + "$scratch_root/logs/build-ios-simulator.log" \ + env OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$static_extensions" src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh)" + fi + [ -n "$artifact" ] || + fail "missing iOS liboliphaunt artifact; set OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK, OLIPHAUNT_EXPO_IOS_OLIPHAUNT_FRAMEWORK, or OLIPHAUNT_EXPO_IOS_OLIPHAUNT_DYLIB. macOS dylibs are not accepted." + [ -e "$artifact" ] || fail "iOS liboliphaunt artifact does not exist: $artifact" + printf '%s\n' "$artifact" +} + +validate_ios_library_artifact() { + local artifact="$1" + case "$artifact" in + *.xcframework) + [ -d "$artifact" ] || fail "XCFramework path is not a directory: $artifact" + if [ "$sdk" = "iphonesimulator" ] && + ! find "$artifact" -maxdepth 2 -type d -name '*simulator*' | grep -q .; then + fail "XCFramework has no iOS simulator slice: $artifact" + fi + if [ "$sdk" = "iphoneos" ] && + ! find "$artifact" -maxdepth 2 -type d -name 'ios-*' ! -name '*simulator*' | grep -q .; then + fail "XCFramework has no iOS device slice: $artifact" + fi + ;; + *.framework) + [ -d "$artifact" ] || fail "framework path is not a directory: $artifact" + ;; + *.dylib) + [ -f "$artifact" ] || fail "dylib path is not a file: $artifact" + local platform + platform="$(xcrun vtool -show-build "$artifact" 2>/dev/null | awk '/platform /{print $2; exit}')" + case "$sdk:$platform" in + iphonesimulator:IOSSIMULATOR|iphoneos:IOS) + ;; + *:MACOS) + fail "refusing macOS liboliphaunt.dylib for iOS smoke: $artifact" + ;; + *) + fail "liboliphaunt.dylib platform $platform does not match sdk $sdk: $artifact" + ;; + esac + ;; + *) + fail "unsupported iOS liboliphaunt artifact type: $artifact" + ;; + esac +} + +validate_ios_static_extension_linkage() { + local selected_extensions="$1" + local xcode_log="$2" + local resource_root="$3" + local derived_data_root="$4" + local stems + stems="$(oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions")" + [ -n "$stems" ] || return 0 + + if find "$resource_root/static-registry" -type f -name 'oliphaunt_static_registry.c' -print -quit | grep -q .; then + fail "iOS app bundled build-only static-registry source; it must be compiled by CocoaPods, not copied as a resource" + fi + if find "$resource_root" -type d -name '*.xcframework' -print -quit | grep -q .; then + fail "iOS app bundled extension XCFramework inputs as resources; selected extensions must be linked by Xcode" + fi + + local pods_support="$example_dir/ios/Pods/Target Support Files/OliphauntReactNative" + local input_file="$pods_support/OliphauntReactNative-xcframeworks-input-files.xcfilelist" + local output_file="$pods_support/OliphauntReactNative-xcframeworks-output-files.xcfilelist" + [ -f "$input_file" ] || + fail "iOS extension link evidence is missing CocoaPods XCFramework input file list: $input_file" + [ -f "$output_file" ] || + fail "iOS extension link evidence is missing CocoaPods XCFramework output file list: $output_file" + + local expected_file pod_file built_file missing_pods missing_built extra_pods extra_built + expected_file="$(mktemp "${TMPDIR:-/tmp}/oliphaunt-ios-linked-expected.XXXXXX")" + pod_file="$(mktemp "${TMPDIR:-/tmp}/oliphaunt-ios-linked-pods.XXXXXX")" + built_file="$(mktemp "${TMPDIR:-/tmp}/oliphaunt-ios-linked-built.XXXXXX")" + printf '%s\n' "$stems" | + tr ',' '\n' | + sed '/^$/d' | + sed 's#^#liboliphaunt_extension_#' | + LC_ALL=C sort -u >"$expected_file" + + rg -o 'liboliphaunt_extension_[A-Za-z0-9_]+' "$input_file" "$output_file" | + sed 's#^.*:##' | + LC_ALL=C sort -u >"$pod_file" || true + find "$derived_data_root/Build/Products" \ + \( -name 'liboliphaunt_extension_*.a' -o -name 'liboliphaunt_extension_*.framework' \) \ + -print 2>/dev/null | + while IFS= read -r linked_artifact; do + basename "$linked_artifact" | + sed 's#\.a$##;s#\.framework$##' + done | + LC_ALL=C sort -u >"$built_file" + + missing_pods="$(comm -23 "$expected_file" "$pod_file" | paste -sd ',' -)" + missing_built="$(comm -23 "$expected_file" "$built_file" | paste -sd ',' -)" + extra_pods="$(comm -13 "$expected_file" "$pod_file" | paste -sd ',' -)" + extra_built="$(comm -13 "$expected_file" "$built_file" | paste -sd ',' -)" + rm -f "$expected_file" "$pod_file" "$built_file" + [ -z "$missing_pods" ] || + fail "iOS CocoaPods file lists do not include selected extension link input(s): $missing_pods" + [ -z "$missing_built" ] || + fail "iOS build products do not include selected extension linked artifact(s): $missing_built" + [ -z "$extra_pods" ] || + fail "iOS CocoaPods file lists include unselected extension link input(s): $extra_pods" + [ -z "$extra_built" ] || + fail "iOS build products include unselected extension linked artifact(s): $extra_built" + if ! rg -q "\\*\\* BUILD SUCCEEDED \\*\\*" "$xcode_log"; then + fail "iOS extension link evidence requires a successful xcodebuild log: $xcode_log" + fi +} + +ios_resource_root_for_package() { + printf '%s\n' "$1/ios/resources/OliphauntReactNativeResources.bundle/oliphaunt" +} + +copy_ios_library_artifact_to_package() { + local package_root="$1" + local artifact="$2" + local resource_root + resource_root="$(ios_resource_root_for_package "$package_root")" + case "$artifact" in + *.xcframework) + liboliphaunt_pod_mode="vendored-framework" + mkdir -p "$package_root/ios/frameworks" + rsync -a --delete "$artifact" "$package_root/ios/frameworks/" + ;; + *.framework) + liboliphaunt_pod_mode="vendored-framework" + mkdir -p "$package_root/ios/frameworks" + rsync -a --delete "$artifact" "$package_root/ios/frameworks/" + ;; + *.dylib) + liboliphaunt_pod_mode="runtime-resource" + mkdir -p "$resource_root/lib" + rsync -a "$artifact" "$resource_root/lib/liboliphaunt.dylib" + ;; + esac +} + +copy_ios_library_artifact() { + copy_ios_library_artifact_to_package "$package_work" "$1" +} + +stage_ios_static_registry_source_to_package() { + local package_root="$1" + local runtime_resources="$2" + local selected_extensions="$3" + local generated_root="$package_root/ios/generated/static-registry" + local resource_source + resource_source="$(ios_resource_root_for_package "$package_root")/static-registry/oliphaunt_static_registry.c" + local stems + + stems="$(oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions")" + rm -rf "$generated_root" + rm -f "$resource_source" + [ -n "$stems" ] || return 0 + + local source="$runtime_resources/oliphaunt/static-registry/oliphaunt_static_registry.c" + [ -f "$source" ] || + fail "selected iOS extension(s) require generated static-registry source, but it is missing: $source" + mkdir -p "$generated_root" + rsync -a "$source" "$generated_root/oliphaunt_static_registry.c" +} + +install_react_native_sdk_tarball() { + patch_expo_example_react_native_dependency "file:$tarball" + rm -rf "$example_dir/node_modules/@oliphaunt/react-native" + install_expo_example_dependencies +} + +install_react_native_sdk_from_source_for_reuse() { + need_cmd pnpm + patch_expo_example_react_native_dependency "file:$rn_dir" + rm -rf "$example_dir/node_modules/@oliphaunt/react-native" + install_expo_example_dependencies +} + +install_ios_mobile_assets_into_react_native_package() { + local installed_package="$1" + local runtime_resources="$2" + local library_artifact="$3" + [ -d "$installed_package" ] || + fail "installed React Native SDK package is missing after artifact install: $installed_package" + rm -rf \ + "$installed_package/ios/resources" \ + "$installed_package/ios/frameworks" \ + "$installed_package/ios/extension-frameworks" + local installed_resource_root + installed_resource_root="$(ios_resource_root_for_package "$installed_package")" + mkdir -p "$installed_resource_root" + rsync -a --delete \ + --exclude '/static-registry/archives/***' \ + "$runtime_resources/oliphaunt/" "$installed_resource_root/" + local selected_extensions selected_module_extensions + selected_extensions="$(normalize_mobile_extensions)" + stage_ios_static_registry_source_to_package "$installed_package" "$runtime_resources" "$selected_extensions" + selected_module_extensions="$(oliphaunt_dev_mobile_module_extensions_for_selection "$selected_extensions")" + if [ -n "$selected_module_extensions" ]; then + oliphaunt_dev_unpack_ios_extension_frameworks_for_selection \ + "$selected_module_extensions" \ + "$installed_package/ios/extension-frameworks" + fi + copy_ios_library_artifact_to_package "$installed_package" "$library_artifact" +} + +pack_react_native_sdk() { + local runtime_resources="$1" + local library_artifact="$2" + + case "$library_artifact" in + *.dylib) + liboliphaunt_pod_mode="runtime-resource" + ;; + *) + liboliphaunt_pod_mode="vendored-framework" + ;; + esac + + need_cmd pnpm + mkdir -p "$pack_dir" "$scratch_root" + + if expo_requires_sdk_artifacts; then + tarball="$(expo_single_sdk_artifact_file oliphaunt-react-native '*.tgz')" + install_react_native_sdk_tarball + local installed_package="$example_dir/node_modules/@oliphaunt/react-native" + install_ios_mobile_assets_into_react_native_package "$installed_package" "$runtime_resources" "$library_artifact" + return + fi + + local package_stamp="$pack_dir/.ios-package.stamp" + if [ "${OLIPHAUNT_EXPO_IOS_REPACK_RN:-0}" != "1" ] && + [ -f "$tarball" ] && + [ -f "$package_stamp" ] && + [ ! "$runtime_resources/.prepared" -nt "$package_stamp" ] && + [ ! "$library_artifact" -nt "$package_stamp" ] && + [ -z "$( + find "$rn_dir" \ + -path "$rn_dir/node_modules" -prune -o \ + -path "$rn_dir/lib" -prune -o \ + -path "$rn_dir/.build" -prune -o \ + -path "$rn_dir/android/.gradle" -prune -o \ + -path "$rn_dir/android/.cxx" -prune -o \ + -path "$rn_dir/android/build" -prune -o \ + -type f -newer "$package_stamp" -print -quit + )" ]; then + echo "Reusing React Native SDK package: $tarball" >&2 + if [ ! -f "$example_dir/node_modules/@oliphaunt/react-native/package.json" ]; then + install_react_native_sdk_tarball + fi + local installed_package="$example_dir/node_modules/@oliphaunt/react-native" + install_ios_mobile_assets_into_react_native_package "$installed_package" "$runtime_resources" "$library_artifact" + return + fi + + prepare_react_native_package_worktree + run pnpm --dir "$package_work" run build + local package_resource_root + package_resource_root="$(ios_resource_root_for_package "$package_work")" + mkdir -p "$package_resource_root" + rsync -a --delete \ + --exclude '/static-registry/archives/***' \ + "$runtime_resources/oliphaunt/" "$package_resource_root/" + local selected_extensions + selected_extensions="$(normalize_mobile_extensions)" + stage_ios_static_registry_source_to_package "$package_work" "$runtime_resources" "$selected_extensions" + local selected_module_stems + selected_module_stems="$(oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions")" + if [ -n "$selected_module_stems" ]; then + local selected_module_extensions + selected_module_extensions="$(oliphaunt_dev_mobile_module_extensions_for_selection "$selected_extensions")" + oliphaunt_dev_unpack_ios_extension_frameworks_for_selection \ + "$selected_module_extensions" \ + "$package_work/ios/extension-frameworks" + fi + copy_ios_library_artifact "$library_artifact" + + echo + echo "==> (cd $package_work && pnpm pack --pack-destination $pack_dir)" + ( + cd "$package_work" + pnpm pack --pack-destination "$pack_dir" + ) + install_react_native_sdk_tarball + local installed_package="$example_dir/node_modules/@oliphaunt/react-native" + install_ios_mobile_assets_into_react_native_package "$installed_package" "$runtime_resources" "$library_artifact" + touch "$package_stamp" +} + +prepare_swift_sdk_artifact_git_repo_if_required() { + if ! expo_requires_sdk_artifacts; then + return 0 + fi + + local archive artifact_repo extract_root package_archive_root source_root + need_cmd unzip + need_cmd git + archive="$(expo_single_sdk_artifact_file oliphaunt-swift 'Oliphaunt-source.zip')" + artifact_repo="$scratch_root/swift-sdk-artifact-repo" + extract_root="$scratch_root/swift-sdk-artifact-extract" + source_root="$artifact_repo/src/sdks/swift" + rm -rf "$artifact_repo" "$extract_root" + mkdir -p "$source_root" + unzip -q "$archive" -d "$extract_root" + package_archive_root="$extract_root" + if [ ! -f "$package_archive_root/Sources/Oliphaunt/Oliphaunt.swift" ]; then + local -a archive_dirs=() + local archive_dir + while IFS= read -r archive_dir; do + archive_dirs+=("$archive_dir") + done < <(find "$extract_root" -mindepth 1 -maxdepth 1 -type d -print | sort) + if [ "${#archive_dirs[@]}" -eq 1 ]; then + package_archive_root="${archive_dirs[0]}" + else + package_archive_root="" + fi + fi + [ -n "$package_archive_root" ] && + [ -f "$package_archive_root/Sources/Oliphaunt/Oliphaunt.swift" ] || + fail "Swift SDK source artifact did not contain Sources/Oliphaunt/Oliphaunt.swift at the archive root or one top-level package directory: $archive" + cp -R "$package_archive_root/." "$source_root/" + [ -f "$source_root/Sources/Oliphaunt/Oliphaunt.swift" ] || + fail "Swift SDK source artifact did not unpack to Sources/Oliphaunt/Oliphaunt.swift: $archive" + if [ -f "$(expo_sdk_artifact_product_root oliphaunt-swift)/Package.swift.release" ]; then + cp "$(expo_sdk_artifact_product_root oliphaunt-swift)/Package.swift.release" "$artifact_repo/Package.swift" + fi + ( + cd "$artifact_repo" + git init -q + git config user.name "Oliphaunt CI" + git config user.email "ci@oliphaunt.dev" + git checkout -q -b artifact + git add . + git commit -q -m "artifact: stage swift sdk source" + ) + export OLIPHAUNT_SWIFT_SDK_GIT_URL="file://$artifact_repo" + export OLIPHAUNT_SWIFT_SDK_BRANCH="artifact" + unset OLIPHAUNT_SWIFT_SDK_COMMIT + unset OLIPHAUNT_SWIFT_SDK_TAG +} + +ensure_ios_project() { + if [ ! -f "$example_dir/ios/Podfile" ]; then + echo "Generating Expo iOS project for smoke validation" + ( + cd "$example_dir" + CI=1 EXPO_NO_TELEMETRY=1 npx expo prebuild --platform ios --no-install + ) + fi + patch_podfile_for_installed_swift_podspecs +} + +patch_podfile_for_installed_swift_podspecs() { + local podfile="$example_dir/ios/Podfile" + local podspecs_path="$example_dir/node_modules/@oliphaunt/react-native/ios/podspecs" + [ -f "$podfile" ] || fail "missing generated iOS Podfile: $podfile" + [ -f "$podspecs_path/COliphaunt.podspec" ] || + fail "missing installed React Native COliphaunt podspec shim: $podspecs_path/COliphaunt.podspec" + [ -f "$podspecs_path/Oliphaunt.podspec" ] || + fail "missing installed React Native Oliphaunt podspec shim: $podspecs_path/Oliphaunt.podspec" + ruby - "$podfile" <<'RUBY' +path = ARGV.fetch(0) +text = File.read(path) +block = <<~PODS + # @oliphaunt/react-native begin + oliphaunt_podspecs_path = File.expand_path('../node_modules/@oliphaunt/react-native/ios/podspecs', __dir__) + pod 'COliphaunt', :podspec => File.join(oliphaunt_podspecs_path, 'COliphaunt.podspec'), :modular_headers => true + pod 'Oliphaunt', :podspec => File.join(oliphaunt_podspecs_path, 'Oliphaunt.podspec') + # @oliphaunt/react-native end +PODS +text = text.gsub(/^\s*# OLIPHAUNT_LOCAL_PODS_BEGIN\n.*?^\s*# OLIPHAUNT_LOCAL_PODS_END\n/m, "") +text = text.gsub(/^\s*# @oliphaunt\/react-native begin\n.*?^\s*# @oliphaunt\/react-native end\n/m, "") +unless text.sub!(/(target ['"]reactnativeoliphauntexpo['"] do\n)/, "\\1#{block}") + abort "could not find reactnativeoliphauntexpo target in Podfile" +end +text.gsub!( + /platform :ios, podfile_properties\['ios\.deploymentTarget'\] \|\| '[^']+'/, + "platform :ios, podfile_properties['ios.deploymentTarget'] || '17.0'" +) +File.write(path, text) +RUBY +} + +install_pods() { + need_cmd pod + if [ -z "${OLIPHAUNT_SWIFT_SDK_GIT_URL:-}" ]; then + export OLIPHAUNT_SWIFT_SDK_GIT_URL="$root" + fi + if [ -z "${OLIPHAUNT_SWIFT_SDK_TAG:-}" ] && + [ -z "${OLIPHAUNT_SWIFT_SDK_BRANCH:-}" ] && + [ -z "${OLIPHAUNT_SWIFT_SDK_COMMIT:-}" ]; then + export OLIPHAUNT_SWIFT_SDK_COMMIT="$(git rev-parse HEAD)" + fi + if [ "$expo_use_precompiled_modules" = "false" ]; then + ( + cd "$example_dir/ios" + OLIPHAUNT_LIBOLIPHAUNT_POD_MODE="$liboliphaunt_pod_mode" \ + USE_CCACHE="$use_ccache" \ + EXPO_USE_PRECOMPILED_MODULES="$expo_use_precompiled_modules" \ + pod install --repo-update + ) + else + ( + cd "$example_dir/ios" + OLIPHAUNT_LIBOLIPHAUNT_POD_MODE="$liboliphaunt_pod_mode" \ + USE_CCACHE="$use_ccache" \ + EXPO_USE_PRECOMPILED_MODULES="$expo_use_precompiled_modules" \ + pod install + ) + fi +} + +patch_expo_modules_jsi_for_host_toolchain() { + [ "${OLIPHAUNT_EXPO_IOS_PATCH_EXPO_JSI:-1}" = "1" ] || return 0 + + local swift_version package_dir + swift_version="$(xcrun swiftc -version 2>/dev/null || true)" + case "$swift_version" in + *"Swift version 6.2"*) + ;; + *) + return 0 + ;; + esac + + package_dir="$example_dir/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI" + [ -d "$package_dir" ] || return 0 + find "$package_dir" -name '*.swift' -print0 | + xargs -0 perl -pi -e 's/\b(nonisolated\(unsafe\)\s+)?weak\s+(let|var)\b/nonisolated(unsafe) weak var/g' + echo "Patched ExpoModulesJSI weak references for local Swift 6.2 source builds" >&2 +} + +stamp_expo_modules_jsi_prebuilt() { + [ "$expo_use_precompiled_modules" != "false" ] || return 0 + [ "${OLIPHAUNT_EXPO_IOS_REUSE_EXPO_JSI_PREBUILT:-1}" = "1" ] || return 0 + + local package_dir="$example_dir/node_modules/expo-modules-jsi/apple" + local pods_root="$example_dir/ios/Pods" + local xcframework="$package_dir/Products/ExpoModulesJSI.xcframework" + local simulator_binary="$xcframework/ios-arm64_x86_64-simulator/ExpoModulesJSI.framework/ExpoModulesJSI" + [ -f "$simulator_binary" ] || return 0 + [ -x "$package_dir/scripts/generate-modulemap.sh" ] || return 0 + [ -f "$pods_root/Headers/Public/React-jsi/jsi/jsi.h" ] || return 0 + + local pods_root_abs rn_root generated_module_map react_core_podspec + pods_root_abs="$(cd "$pods_root" && pwd)" + rn_root="$(cd "$example_dir/node_modules/react-native" && pwd)" + PODS_ROOT="$pods_root_abs" RN_ROOT="$rn_root" "$package_dir/scripts/generate-modulemap.sh" + generated_module_map="$package_dir/.generated/module.modulemap" + react_core_podspec="$pods_root_abs/Local Podspecs/React-Core.podspec.json" + + local all_files current_hash + all_files="$( + find "$package_dir/Sources/ExpoModulesJSI" -type f + find "$package_dir/Sources/ExpoModulesJSI-Cxx" -type f + find "$package_dir/APINotes" -type f + for file in \ + "$package_dir/Package.swift" \ + "$package_dir/scripts/build-xcframework.sh" \ + "$package_dir/scripts/create-stub-xcframework.sh" \ + "$package_dir/scripts/xcframework-helpers.sh" \ + "$pods_root_abs/Headers/Public/React-jsi/jsi/jsi.h" \ + "$pods_root_abs/Headers/Public/React-jsi/jsi/jsi-inl.h" \ + "$react_core_podspec" \ + "$generated_module_map"; do + [ -f "$file" ] && printf '%s\n' "$file" + done + )" + current_hash="$( + { + printf 'PODS_ROOT=%s\n' "$pods_root_abs" + printf 'RN_ROOT=%s\n' "$rn_root" + printf '%s\n' "$all_files" | LC_ALL=C sort | while IFS= read -r file; do + printf '%s\n' "$file" + cat "$file" + done + } | shasum -a 256 | awk '{print $1}' + )" + + local slice + for slice in "$xcframework"/*; do + [ -d "$slice" ] || continue + printf '%s\n' "$current_hash" >"$slice/.build-hash" + done + echo "Reusing ExpoModulesJSI prebuilt xcframework for local smoke validation: $current_hash" >&2 +} + +build_ios_app() { + [ -d "$workspace" ] || fail "missing Xcode workspace: $workspace" + if [ "${OLIPHAUNT_EXPO_IOS_CLEAN_BUILD:-0}" = "1" ]; then + rm -rf "$derived_data" + fi + if [ -d "$derived_data/Build/Products" ]; then + find "$derived_data/Build/Products" -name 'OliphauntReactNativeResources.bundle' -type d -prune -exec rm -rf {} + + fi + local xcode_log="$scratch_root/xcodebuild.log" + local xcode_package_cache="$scratch_root/xcodebuild-package-cache" + local xcode_source_packages="$scratch_root/xcodebuild-source-packages" + local resolved_destination + resolved_destination="$(resolve_xcode_destination)" || + fail "failed to resolve an available iOS simulator for xcodebuild" + local build_settings=( + USE_CCACHE="$use_ccache" + EXPO_USE_PRECOMPILED_MODULES="$expo_use_precompiled_modules" + ) + if [ -n "$code_signing_allowed" ]; then + build_settings+=(CODE_SIGNING_ALLOWED="$code_signing_allowed") + elif [ "$sdk" != "iphoneos" ]; then + build_settings+=(CODE_SIGNING_ALLOWED=NO) + fi + if [ -n "$development_team" ]; then + build_settings+=(DEVELOPMENT_TEAM="$development_team") + fi + if [ -n "$code_sign_style" ]; then + build_settings+=(CODE_SIGN_STYLE="$code_sign_style") + fi + if [ -n "$code_sign_identity" ]; then + build_settings+=(CODE_SIGN_IDENTITY="$code_sign_identity") + fi + if [ -n "$provisioning_profile_specifier" ]; then + build_settings+=(PROVISIONING_PROFILE_SPECIFIER="$provisioning_profile_specifier") + fi + if [ "$sdk" = "iphonesimulator" ] && [ -z "$destination" ] && [ "$(uname -m)" = "arm64" ]; then + build_settings+=(ONLY_ACTIVE_ARCH=YES ARCHS=arm64) + fi + local -a xcodebuild_flags + xcodebuild_flags=() + if is_truthy "$allow_provisioning_updates"; then + xcodebuild_flags+=(-allowProvisioningUpdates) + fi + if is_truthy "$allow_device_registration"; then + xcodebuild_flags+=(-allowProvisioningDeviceRegistration) + fi + local -a xcodebuild_command + xcodebuild_command=( + xcodebuild + -workspace "$workspace" + -scheme "$xcode_scheme" + -configuration "$configuration" + -sdk "$sdk" + -destination "$resolved_destination" + -derivedDataPath "$derived_data" + -clonedSourcePackagesDirPath "$xcode_source_packages" + -packageCachePath "$xcode_package_cache" + -skipPackageUpdates + ) + xcodebuild_command+=("${build_settings[@]}") + if [ "${#xcodebuild_flags[@]}" -gt 0 ]; then + xcodebuild_command+=("${xcodebuild_flags[@]}") + fi + xcodebuild_command+=(build) + mkdir -p "$scratch_root" "$xcode_package_cache" "$xcode_source_packages" + echo >&2 + printf '==>' >&2 + printf ' %q' "${xcodebuild_command[@]}" >&2 + printf '\n' >&2 + if ! "${xcodebuild_command[@]}" >"$xcode_log" 2>&1; then + rg -n -C 40 "Could not resolve package dependencies" "$xcode_log" >&2 || + rg -n "error:|BUILD FAILED|The following build commands failed" "$xcode_log" | tail -160 >&2 || + tail -200 "$xcode_log" >&2 + fail "xcodebuild failed; full log: $xcode_log" + fi + rg -n "\\*\\* BUILD SUCCEEDED \\*\\*" "$xcode_log" >&2 || tail -40 "$xcode_log" >&2 + + local app + app="$(find "$derived_data/Build/Products" -path "*$configuration-*" -name '*.app' -type d | head -1)" + [ -n "$app" ] || fail "xcodebuild succeeded but no .app was found under $derived_data" + local resource_root="$app/OliphauntReactNativeResources.bundle/oliphaunt" + [ -d "$resource_root" ] || + fail "iOS app is missing OliphauntReactNativeResources.bundle/oliphaunt resource root" + echo "bundled: $resource_root ($(directory_files "$resource_root") files, $(directory_bytes "$resource_root") bytes)" >&2 + for required in \ + "$resource_root/template-pgdata/files/PG_VERSION" \ + "$resource_root/runtime/files/share/postgresql/postgres.bki"; do + [ -e "$required" ] || fail "iOS app is missing packaged Oliphaunt resource: $required" + echo "bundled: $required" >&2 + done + if [ -e "$resource_root/lib/liboliphaunt.dylib" ]; then + echo "bundled: $resource_root/lib/liboliphaunt.dylib" >&2 + fi + local selected_extensions app_resource_files + selected_extensions="$(normalize_mobile_extensions)" + app_resource_files="$scratch_root/ios-resource-files.txt" + find "$resource_root" -type f -print >"$app_resource_files" + oliphaunt_dev_assert_runtime_file_list "$selected_extensions" "iOS" <"$app_resource_files" + validate_ios_static_extension_linkage "$selected_extensions" "$xcode_log" "$resource_root" "$derived_data" + printf '%s\n' "$app" +} + +start_metro_if_needed() { + local bundle_runner="${1:-$runner}" + local bundle_root="${2:-}" + mkdir -p "$scratch_root" + mkdir -p "$(dirname "$metro_dev_log")" + if [ -n "${metro_pid:-}" ] && kill -0 "$metro_pid" >/dev/null 2>&1; then + if [ "$metro_bundle_runner" = "$bundle_runner" ] && [ "$metro_bundle_root" = "$bundle_root" ]; then + return 0 + fi + stop_owned_metro + fi + reserve_metro_port + if port_is_listening; then + if [ "$reuse_metro" = "1" ]; then + echo "Reusing Metro on port $metro_port" + return 0 + fi + fail "Expo Metro port $metro_port is already in use; stop it, set OLIPHAUNT_EXPO_IOS_REUSE_METRO=1, or choose OLIPHAUNT_EXPO_IOS_METRO_PORT" + else + echo "Starting Expo Metro on port $metro_port for runner $bundle_runner" + ( + cd "$example_dir" + CI=1 EXPO_NO_TELEMETRY=1 EXPO_UNSTABLE_MCP_SERVER=1 \ + EXPO_PUBLIC_OLIPHAUNT_RUNNER="$bundle_runner" \ + EXPO_PUBLIC_OLIPHAUNT_LIFECYCLE_SMOKE="$lifecycle_smoke" \ + EXPO_PUBLIC_OLIPHAUNT_DURABILITY="$durability_profile" \ + EXPO_PUBLIC_OLIPHAUNT_RUNTIME_FOOTPRINT="$runtime_footprint" \ + EXPO_PUBLIC_OLIPHAUNT_BENCHMARK_PRESET="$benchmark_preset" \ + EXPO_PUBLIC_OLIPHAUNT_STARTUP_GUCS="$startup_gucs" \ + EXPO_PUBLIC_OLIPHAUNT_WAL_SEGSIZE_MB="$wal_segsize_mb" \ + EXPO_PUBLIC_OLIPHAUNT_ROOT="$bundle_root" \ + npx expo start --dev-client --port "$metro_port" --host lan --clear \ + >"$scratch_root/metro.log" 2>&1 + ) & + metro_pid="$!" + metro_bundle_runner="$bundle_runner" + metro_bundle_root="$bundle_root" + + for _ in $(seq 1 60); do + if port_is_listening; then + break + fi + sleep 1 + done + fi + + port_is_listening || { + tail -80 "$scratch_root/metro.log" >&2 || true + fail "Expo Metro did not start on port $metro_port" + } + + if command -v curl >/dev/null 2>&1; then + for _ in $(seq 1 60); do + if curl -4 -fsS "http://127.0.0.1:$metro_port/status" 2>/dev/null | rg -q "packager-status:running"; then + return + fi + sleep 1 + done + tail -80 "$scratch_root/metro.log" >&2 || true + fail "Expo Metro did not become ready on port $metro_port" + fi + + return + + tail -80 "$scratch_root/metro.log" >&2 || true + fail "Expo Metro did not start on port $metro_port" +} + +write_ios_package_metrics() { + local app_bytes="$1" + local rn_package_bytes="$2" + write_mobile_package_size_report iosAppBytes "$app_bytes" "$rn_package_bytes" +} + +write_ios_build_artifact_report() { + local app="$1" + local selected_extensions="$2" + local app_bytes rn_package_bytes app_copy report + mkdir -p "$build_artifact_dir" "$scratch_root/reports" + app_copy="$build_artifact_dir/$(basename "$app")" + rm -rf "$app_copy" + rsync -a --delete "$app/" "$app_copy/" + app_bytes="$(directory_bytes "$app")" + rn_package_bytes="$(wc -c <"$tarball" | tr -d '[:space:]')" + report="$build_artifact_dir/build-report.json" + write_mobile_build_artifact_report_json \ + "$report" \ + ios \ + "$app_copy" \ + "$app_bytes" \ + "$tarball" \ + "$rn_package_bytes" \ + "$selected_extensions" \ + "$scratch_root" \ + configuration "$configuration" \ + sdk "$sdk" + cp "$report" "$scratch_root/reports/build-report.json" + echo "iOS mobile build artifact: $app_copy" + echo "iOS mobile build report: $report" +} + +trap cleanup EXIT + +main() { + need_cmd node + need_cmd xcrun + if is_truthy "$e2e_only"; then + local app + app="$(resolve_prebuilt_ios_app)" + install_and_launch "$app" + local ios_app_bytes rn_package_bytes + ios_app_bytes="$(directory_bytes "$app")" + rn_package_bytes="$(file_bytes "$tarball")" + write_ios_package_metrics "$ios_app_bytes" "$rn_package_bytes" + exit 0 + fi + need_cmd rg + need_cmd ruby + need_cmd rsync + need_cmd pgrep + need_cmd lsof + need_cmd xcodebuild + prepare_expo_example_workspace + preflight_physical_ios_device + if is_reuse_installed_physical_ios_app; then + local device_id crash_root scratch_metro_offset dev_metro_offset + device_id="$(select_ios_physical_device_id)" || + fail "failed to resolve a paired physical iOS device; set OLIPHAUNT_EXPO_IOS_DEVICE_ID" + echo "Reusing installed iOS app $app_id on physical device: $device_id" >&2 + install_react_native_sdk_from_source_for_reuse + if [ "$runner" = "crash" ]; then + crash_root="$crash_root_override" + [ -n "$crash_root" ] || crash_root="app-support://oliphaunt-crash-recovery-root-$crash_root_suffix" + exercise_ios_device_crash_recovery "$device_id" "$crash_root" + return + fi + if uses_ios_metro; then + start_metro_if_needed "$runner" + fi + scratch_metro_offset="$(file_bytes "$scratch_root/metro.log")" + dev_metro_offset="$(file_bytes "$metro_dev_log")" + launch_ios_device_runner "$device_id" "$runner" >/dev/null || + fail "failed to launch Expo iOS $runner on physical device" + wait_for_ios_device_runner "$device_id" "$scratch_metro_offset" "$dev_metro_offset" || + fail "timed out waiting for $success_tag from physical iOS device" + return + fi + configure_iphoneos_signing + local runtime_resources library_artifact static_registry_source app + library_artifact="$(find_ios_library_artifact)" + validate_ios_library_artifact "$library_artifact" + static_registry_source="$(mobile_static_registry_source_for_library "$library_artifact")" + runtime_resources="$(prepare_runtime_resources "$static_registry_source")" + pack_react_native_sdk "$runtime_resources" "$library_artifact" + ensure_ios_project + prepare_swift_sdk_artifact_git_repo_if_required + patch_expo_modules_jsi_for_host_toolchain + install_pods + stamp_expo_modules_jsi_prebuilt + app="$(build_ios_app)" + local selected_extensions + selected_extensions="$(normalize_mobile_extensions)" + write_ios_build_artifact_report "$app" "$selected_extensions" + if is_ios_build_only; then + printf '\niOS build-only mobile artifact complete: %s\n' "$app" + exit 0 + fi + install_and_launch "$app" + + local ios_app_bytes rn_package_bytes + ios_app_bytes="$(directory_bytes "$app")" + rn_package_bytes="$(wc -c <"$tarball" | tr -d '[:space:]')" + write_ios_package_metrics "$ios_app_bytes" "$rn_package_bytes" + + printf '\niOS app bytes: ' + printf '%s\n' "$ios_app_bytes" + printf 'RN package bytes: ' + printf '%s' "$rn_package_bytes" + printf '\n' +} + +main "$@" diff --git a/src/sdks/react-native/tools/expo-runner-android-device.sh b/src/sdks/react-native/tools/expo-runner-android-device.sh new file mode 100644 index 00000000..2511d644 --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-android-device.sh @@ -0,0 +1,377 @@ +#!/usr/bin/env bash +# Shared Android device, logcat, lifecycle, and installed-app helpers for the +# Expo Android runner. This file is sourced by expo-android-runner.sh. + +should_use_maestro_e2e() { + [ "$runner" = "smoke" ] || return 1 + case "$e2e_assertion_runner" in + maestro) + maestro_binary >/dev/null || fail "missing required command: maestro; run tools/dev/setup-maestro.sh" + return 0 + ;; + auto) + maestro_binary >/dev/null + return + ;; + *) + return 1 + ;; + esac +} + +run_maestro_installed_smoke() { + local device_id="$1" + local reports_dir="$scratch_root/reports" + [ -f "$maestro_flow" ] || fail "missing Maestro installed-app smoke flow: $maestro_flow" + local maestro + maestro="$(maestro_binary)" || fail "missing required command: maestro; run tools/dev/setup-maestro.sh" + mkdir -p "$reports_dir" + echo "==> $maestro --device $device_id test $maestro_flow" + MAESTRO_CLI_NO_ANALYTICS=true \ + MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED=true \ + "$maestro" --device "$device_id" test \ + -e APP_ID="$app_id" \ + -e SMOKE_TIMEOUT_MS="$((timeout_seconds * 1000))" \ + "$maestro_flow" \ + >"$reports_dir/maestro.log" 2>&1 || { + tail -160 "$reports_dir/maestro.log" >&2 || true + return 1 + } + tail -80 "$reports_dir/maestro.log" >&2 || true +} + +latest_metro_tag() { + local offset="$1" + local tag="$2" + file_from_offset "$scratch_root/metro.log" "$offset" | grep -F "$tag" | tail -1 || true +} + +write_android_process_metrics() { + local adb="$1" + local reports_dir="$scratch_root/reports" + mkdir -p "$reports_dir" + "$adb" shell dumpsys meminfo "$app_id" >"$reports_dir/$runner-meminfo.txt" + sed -n '/TOTAL PSS/p;/TOTAL RSS/p' "$reports_dir/$runner-meminfo.txt" +} + +logs_have_lifecycle_ready() { + local input="${1:-}" + if [ "$#" -eq 0 ]; then + input="$(cat)" + fi + case "$input" in + *OLIPHAUNT_EXPO_SMOKE_STAGE*'"stage":"lifecycle:ready"'*) return 0 ;; + *) return 1 ;; + esac +} + +android_failure_log_pattern() { + local fail_tag="$1" + printf '%s\n' "$fail_tag|ANR in Window.*$app_id|Process: $app_id|$app_id.*FATAL EXCEPTION|\\sE AndroidRuntime:.*$app_id" +} + +print_android_timeout_diagnostics() { + local adb="$1" + local logs + logs="$("$adb" logcat -d)" + printf '%s\n' "$logs" | + grep -E "DevLauncherErrorActivity|Couldn't connect to ws://10\\.0\\.2\\.2:$metro_port|Couldn't connect to ws://127\\.0\\.0\\.1:$metro_port" | + tail -40 >&2 || true + printf '%s\n' "$logs" | tail -200 >&2 +} + +write_android_e2e_diagnostics() { + local adb="$1" + local reason="${2:-failure}" + local reports_dir="$scratch_root/reports" + local log_file ui_file screenshot_file + mkdir -p "$reports_dir" + log_file="$reports_dir/$runner-logcat-$reason.txt" + ui_file="$reports_dir/$runner-window-$reason.xml" + screenshot_file="$reports_dir/$runner-screen-$reason.png" + + "$adb" logcat -d >"$log_file" 2>/dev/null || true + "$adb" shell dumpsys activity top >"$reports_dir/$runner-activity-$reason.txt" 2>/dev/null || true + "$adb" shell uiautomator dump /sdcard/liboliphaunt-window.xml >/dev/null 2>&1 || true + "$adb" pull /sdcard/liboliphaunt-window.xml "$ui_file" >/dev/null 2>&1 || true + "$adb" exec-out screencap -p >"$screenshot_file" 2>/dev/null || true + [ -s "$screenshot_file" ] || rm -f "$screenshot_file" + + if [ -s "$log_file" ]; then + tail -200 "$log_file" >&2 || true + fi +} + +exercise_android_lifecycle() { + local adb="$1" + local task_id="" + echo + echo "==> Android lifecycle: HOME, wait ${background_seconds}s, foreground $app_id" + task_id="$(android_task_id "$adb" || true)" + run "$adb" shell input keyevent KEYCODE_HOME + sleep "$background_seconds" + wake_android_device "$adb" + foreground_android_app "$adb" "$task_id" +} + +android_task_id() { + local adb="$1" + "$adb" shell dumpsys activity activities | + sed -n "s/.*Task{[^#]*#\\([0-9][0-9]*\\).*${app_id}.*/\\1/p" | + head -1 | + tr -d '\r' +} + +foreground_android_app() { + local adb="$1" + local task_id="${2:-}" + + # Bring the existing task to the foreground. Relaunching the Expo dev-client + # deep link here reloads the JS bundle and turns a resume smoke into a restart. + if [ -n "$task_id" ]; then + if "$adb" shell cmd activity task lock "$task_id" >/dev/null 2>&1; then + sleep 1 + "$adb" shell cmd activity task lock stop >/dev/null 2>&1 || true + return + fi + fi + + run "$adb" shell am start -W \ + -a android.intent.action.MAIN \ + -c android.intent.category.LAUNCHER \ + --activity-reorder-to-front \ + -n "$app_id/.MainActivity" +} + +android_runner_url() { + local selected_runner="$1" + local root_arg="${2:-}" + local url="$scheme://oliphaunt-smoke?liboliphauntRunner=$selected_runner&liboliphauntLifecycle=$lifecycle_smoke&liboliphauntDurability=$(urlencode "$durability_profile")&liboliphauntRuntimeFootprint=$(urlencode "$runtime_footprint")" + if [ "$selected_runner" = "benchmark" ]; then + url="$url&liboliphauntBenchmarkPreset=$(urlencode "$benchmark_preset")" + fi + if [ -n "$startup_gucs" ]; then + url="$url&liboliphauntStartupGUCs=$(urlencode "$startup_gucs")" + fi + url="$url&liboliphauntWalSegsizeMB=$(urlencode "$wal_segsize_mb")" + if [ -n "$root_arg" ]; then + url="$url&liboliphauntRoot=$(urlencode "$root_arg")" + fi + if [ "$build_type" = "debug" ]; then + local metro_url + metro_url="http://$metro_host:$metro_port" + url="$dev_client_scheme://expo-development-client/?url=$(urlencode "$metro_url")&disableOnboarding=1&liboliphauntRunner=$selected_runner&liboliphauntLifecycle=$lifecycle_smoke&liboliphauntDurability=$(urlencode "$durability_profile")&liboliphauntRuntimeFootprint=$(urlencode "$runtime_footprint")" + if [ "$selected_runner" = "benchmark" ]; then + url="$url&liboliphauntBenchmarkPreset=$(urlencode "$benchmark_preset")" + fi + if [ -n "$startup_gucs" ]; then + url="$url&liboliphauntStartupGUCs=$(urlencode "$startup_gucs")" + fi + url="$url&liboliphauntWalSegsizeMB=$(urlencode "$wal_segsize_mb")" + if [ -n "$root_arg" ]; then + url="$url&liboliphauntRoot=$(urlencode "$root_arg")" + fi + fi + printf '%s' "$url" +} + +wait_for_android_tag() { + local adb="$1" + local tag="$2" + local fail_tag="$3" + local deadline=$((SECONDS + timeout_seconds)) + local logs pass fail_line + while [ "$SECONDS" -lt "$deadline" ]; do + logs="$("$adb" logcat -d)" + pass="$(printf '%s\n' "$logs" | grep -F "$tag" | tail -1 || true)" + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + return 0 + fi + fail_line="$( + printf '%s\n' "$logs" | + grep -E "$(android_failure_log_pattern "$fail_tag")" | + tail -20 || true + )" + if [ -n "$fail_line" ]; then + printf '%s\n' "$fail_line" >&2 + return 1 + fi + sleep 2 + done + print_android_timeout_diagnostics "$adb" + return 1 +} + +wake_android_device() { + local adb="$1" + "$adb" shell settings put secure screensaver_enabled 0 >/dev/null 2>&1 || true + "$adb" shell settings put system screen_off_timeout 2147483647 >/dev/null 2>&1 || true + "$adb" shell svc power stayon true >/dev/null 2>&1 || true + "$adb" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true + "$adb" shell wm dismiss-keyguard >/dev/null 2>&1 || true + "$adb" shell input keyevent 82 >/dev/null 2>&1 || true +} + +exercise_android_crash_recovery() { + local adb="$1" + local write_url verify_url write_line pass + if [ -z "$crash_root_override" ]; then + run "$adb" shell rm -rf "$crash_root" || true + fi + start_metro_if_needed crash-write "$crash_root" + if [ "$build_type" = "debug" ]; then + run "$adb" reverse "tcp:$metro_port" "tcp:$metro_port" + fi + write_url="$(android_runner_url crash-write "$crash_root")" + + echo + echo "==> Android crash recovery: write phase" + run "$adb" shell am force-stop "$app_id" + run "$adb" shell am start -a android.intent.action.VIEW -d "'$write_url'" "$app_id" + if [ "$build_type" = "debug" ]; then + dismiss_expo_dev_menu_onboarding "$adb" + fi + write_line="$(wait_for_android_tag "$adb" OLIPHAUNT_EXPO_CRASH_WRITE_READY "$failure_tag")" || + fail "Expo Android crash recovery write phase failed" + mkdir -p "$scratch_root/reports" + printf '%s\n' "$write_line" >"$scratch_root/reports/crash-write-ready.log" + + echo + echo "==> Android crash recovery: force-stop app process, then verify phase" + run "$adb" shell am force-stop "$app_id" + stop_owned_metro + run "$adb" logcat -c + start_metro_if_needed crash-verify "$crash_root" + if [ "$build_type" = "debug" ]; then + run "$adb" reverse "tcp:$metro_port" "tcp:$metro_port" + fi + verify_url="$(android_runner_url crash-verify "$crash_root")" + run "$adb" shell am start -a android.intent.action.VIEW -d "'$verify_url'" "$app_id" + if [ "$build_type" = "debug" ]; then + dismiss_expo_dev_menu_onboarding "$adb" + fi + pass="$(wait_for_android_tag "$adb" "$success_tag" "$failure_tag")" || + fail "Expo Android crash recovery verify phase failed" + write_runner_report "$pass" + write_android_process_metrics "$adb" +} + +install_and_launch() { + local adb="$ANDROID_HOME/platform-tools/adb" + "$adb" devices | grep -Eq 'device$' || fail "no Android emulator/device is connected" + local device_id + device_id="$("$adb" devices | awk 'NR > 1 && $2 == "device" {print $1; exit}')" + [ -n "$device_id" ] || fail "failed to resolve connected Android device id" + + run "$adb" install -r "$apk" + run "$adb" shell am force-stop "$app_id" + run "$adb" shell pm clear "$app_id" + run "$adb" logcat -c + wake_android_device "$adb" + + if [ "$build_type" = "debug" ] && [ "$runner" != "crash" ]; then + start_metro_if_needed "$runner" + run "$adb" reverse "tcp:$metro_port" "tcp:$metro_port" + fi + if [ "$runner" = "crash" ]; then + exercise_android_crash_recovery "$adb" + return + fi + local metro_offset=0 + if [ "$build_type" = "debug" ]; then + metro_offset="$(file_bytes "$scratch_root/metro.log")" + fi + local url + url="$(android_runner_url "$runner")" + local shell_url="'$url'" + run "$adb" shell am start -a android.intent.action.VIEW -d "$shell_url" "$app_id" + if [ "$build_type" = "debug" ]; then + dismiss_expo_dev_menu_onboarding "$adb" + fi + + local logs pass + if should_use_maestro_e2e; then + [ "$lifecycle_smoke" != "1" ] || + fail "Maestro mobile E2E does not drive lifecycle transitions; use mobile-drill or set OLIPHAUNT_EXPO_ANDROID_LIFECYCLE_SMOKE=0" + if ! run_maestro_installed_smoke "$device_id"; then + write_android_e2e_diagnostics "$adb" "maestro-failure" + fail "Expo Android installed-app Maestro smoke failed" + fi + logs="$("$adb" logcat -d)" + pass="$(latest_metro_tag "$metro_offset" "$success_tag")" + if [ -z "$pass" ]; then + pass="$(printf '%s\n' "$logs" | grep -F "$success_tag" | tail -1 || true)" + fi + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + write_runner_report "$pass" + else + write_maestro_runner_report android + fi + write_android_process_metrics "$adb" + return + fi + + local deadline=$((SECONDS + timeout_seconds)) + local fail_line lifecycle_exercised=0 + while [ "$SECONDS" -lt "$deadline" ]; do + logs="$("$adb" logcat -d)" + if [ "$lifecycle_smoke" = "1" ] && + [ "$lifecycle_exercised" = "0" ] && + logs_have_lifecycle_ready "$logs"; then + exercise_android_lifecycle "$adb" + lifecycle_exercised=1 + sleep 2 + continue + fi + pass="$(latest_metro_tag "$metro_offset" "$success_tag")" + if [ -z "$pass" ]; then + pass="$(printf '%s\n' "$logs" | grep -F "$success_tag" | tail -1 || true)" + fi + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + write_runner_report "$pass" + write_android_process_metrics "$adb" + return + fi + fail_line="$( + printf '%s\n' "$logs" | + grep -E "$(android_failure_log_pattern "$failure_tag")" | + tail -20 || true + )" + if [ -n "$fail_line" ]; then + printf '%s\n' "$fail_line" >&2 + fail "Expo Android $runner failed" + fi + sleep 2 + done + + write_android_e2e_diagnostics "$adb" "timeout" + print_android_timeout_diagnostics "$adb" + fail "timed out waiting for $success_tag" +} + +dismiss_expo_dev_menu_onboarding() { + local adb="$1" + local width height size xml + size="$("$adb" shell wm size 2>/dev/null | tr -d '\r' || true)" + width="$(printf '%s\n' "$size" | sed -n 's/.*Physical size: \([0-9][0-9]*\)x\([0-9][0-9]*\).*/\1/p')" + height="$(printf '%s\n' "$size" | sed -n 's/.*Physical size: \([0-9][0-9]*\)x\([0-9][0-9]*\).*/\2/p')" + [ -n "$width" ] || width=1080 + [ -n "$height" ] || height=2424 + + for _ in $(seq 1 20); do + sleep 1 + "$adb" shell uiautomator dump /sdcard/liboliphaunt-window.xml >/dev/null 2>&1 || continue + xml="$("$adb" exec-out cat /sdcard/liboliphaunt-window.xml 2>/dev/null | tr -d '\r' || true)" + if printf '%s\n' "$xml" | grep -Eq 'This is the developer menu|text="Continue"'; then + "$adb" shell input tap "$((width / 2))" "$((height * 91 / 100))" + sleep 1 + return + fi + if printf '%s\n' "$xml" | grep -Eq 'OLIPHAUNT REACT NATIVE|Embedded Postgres smoke'; then + return + fi + done +} diff --git a/src/sdks/react-native/tools/expo-runner-common.sh b/src/sdks/react-native/tools/expo-runner-common.sh new file mode 100644 index 00000000..b1c43bbb --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-common.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +# Shared primitives for the React Native Expo mobile runners. Platform-specific +# packaging, build, and launch logic stays in the platform runner scripts. + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +is_truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_falsey() { + case "${1:-}" in + 0|false|FALSE|no|NO|off|OFF) + return 0 + ;; + *) + return 1 + ;; + esac +} + +expo_allows_native_builds() { + is_truthy "${OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS:-1}" +} + +expo_requires_sdk_artifacts() { + is_truthy "${OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS:-0}" +} + +expo_sdk_artifact_product_root() { + local product="$1" + local base="${OLIPHAUNT_EXPO_SDK_ARTIFACT_ROOT:-$root/target/sdk-artifacts}" + printf '%s/%s\n' "$base" "$product" +} + +expo_single_sdk_artifact_file() { + local product="$1" + local pattern="$2" + local product_root + product_root="$(expo_sdk_artifact_product_root "$product")" + [ -d "$product_root" ] || + fail "required SDK artifact directory is missing for $product: $product_root" + local matches + matches="$(find "$product_root" -maxdepth 1 -type f -name "$pattern" | LC_ALL=C sort)" + [ -n "$matches" ] || + fail "required SDK artifact for $product did not match $pattern under $product_root" + local count + count="$(printf '%s\n' "$matches" | wc -l | tr -d '[:space:]')" + [ "$count" = "1" ] || + fail "required SDK artifact for $product matched $count files under $product_root; expected exactly one $pattern" + printf '%s\n' "$matches" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +maestro_binary() { + if command -v maestro >/dev/null 2>&1; then + command -v maestro + return + fi + if [ -x "$HOME/.maestro/bin/maestro" ]; then + printf '%s\n' "$HOME/.maestro/bin/maestro" + return + fi + return 1 +} + +stat_mtime() { + stat -f '%m' "$1" 2>/dev/null || stat -c '%Y' "$1" +} + +directory_bytes() { + local total=0 + local file size + while IFS= read -r -d '' file; do + size="$(wc -c <"$file" | tr -d '[:space:]')" + total=$((total + size)) + done < <(find "$1" -type f -print0) + printf '%s\n' "$total" +} + +directory_files() { + find "$1" -type f | wc -l | tr -d '[:space:]' +} + +file_bytes() { + if [ -f "$1" ]; then + wc -c <"$1" | tr -d '[:space:]' + else + printf '0\n' + fi +} + +file_from_offset() { + local file="$1" + local offset="$2" + [ -f "$file" ] || return 0 + tail -c "+$((offset + 1))" "$file" 2>/dev/null || true +} + +urlencode() { + node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1" +} + +react_native_package_tarball_name() { + node - "$1/package.json" <<'NODE' +const fs = require('node:fs'); +const packageJson = process.argv[2]; +const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8')); +const name = String(pkg.name || '').replace(/^@/, '').replace(/\//g, '-'); +const version = String(pkg.version || ''); +if (!name || !version) { + throw new Error(`package name/version is missing from ${packageJson}`); +} +process.stdout.write(`${name}-${version}.tgz`); +NODE +} diff --git a/src/sdks/react-native/tools/expo-runner-ios-device.sh b/src/sdks/react-native/tools/expo-runner-ios-device.sh new file mode 100755 index 00000000..2bd6d067 --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-ios-device.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# Shared iOS simulator, physical-device, and signing helpers for the Expo +# installed-app runner. This file is sourced by expo-ios-runner.sh. + +select_ios_simulator_udid() { + if [ -n "$simulator_udid" ]; then + printf '%s\n' "$simulator_udid" + return + fi + + local booted + booted="$( + xcrun simctl list devices booted -j | + node -e ' +const fs = require("fs"); +const data = JSON.parse(fs.readFileSync(0, "utf8")); +for (const devices of Object.values(data.devices || {})) { + const found = devices.find(device => device.isAvailable && device.state === "Booted"); + if (found) { + process.stdout.write(found.udid); + process.exit(0); + } +} +' + )" + if [ -n "$booted" ]; then + printf '%s\n' "$booted" + return + fi + + xcrun simctl list devices available -j | + OLIPHAUNT_EXPO_IOS_DEVICE_NAME="$simulator_name" node -e ' +const fs = require("fs"); +const preferredName = process.env.OLIPHAUNT_EXPO_IOS_DEVICE_NAME || "iPhone 15 Pro"; +const preferredRuntime = process.env.OLIPHAUNT_EXPO_IOS_RUNTIME || ""; +const data = JSON.parse(fs.readFileSync(0, "utf8")); +const candidates = []; +for (const [runtime, devices] of Object.entries(data.devices || {})) { + if (!runtime.includes("iOS")) { + continue; + } + const versionMatch = runtime.match(/iOS-(\d+)-(\d+)/); + const major = versionMatch ? Number(versionMatch[1]) : 0; + const minor = versionMatch ? Number(versionMatch[2]) : 0; + for (const device of devices) { + if (!device.isAvailable) { + continue; + } + const exactName = device.name === preferredName ? 1 : 0; + const iphone = device.name.startsWith("iPhone") ? 1 : 0; + const runtimeMatch = preferredRuntime && runtime.includes(preferredRuntime) ? 1 : 0; + candidates.push({device, exactName, iphone, runtimeMatch, major, minor}); + } +} +candidates.sort((left, right) => + right.runtimeMatch - left.runtimeMatch || + right.exactName - left.exactName || + right.iphone - left.iphone || + right.major - left.major || + right.minor - left.minor || + left.device.name.localeCompare(right.device.name) +); +if (!candidates.length) { + process.exit(1); +} +process.stdout.write(candidates[0].device.udid); +' +} + +select_ios_physical_device_id() { + if [ -n "$physical_device_id" ]; then + printf '%s\n' "$physical_device_id" + return + fi + + mkdir -p "$scratch_root" + local json="$scratch_root/devicectl-devices.json" + xcrun devicectl list devices --timeout 10 --json-output "$json" >/dev/null 2>&1 || + return 1 + node - "$json" <<'NODE' +const fs = require('fs'); +const file = process.argv[2]; +const data = JSON.parse(fs.readFileSync(file, 'utf8')); +const devices = data?.result?.devices ?? []; +const candidates = devices.filter(device => { + const hardware = device.hardwareProperties ?? {}; + const connection = device.connectionProperties ?? {}; + return hardware.platform === 'iOS' && + hardware.reality === 'physical' && + connection.pairingState === 'paired'; +}); +candidates.sort((left, right) => { + const leftLocal = left.connectionProperties?.transportType === 'localNetwork' ? 1 : 0; + const rightLocal = right.connectionProperties?.transportType === 'localNetwork' ? 1 : 0; + return rightLocal - leftLocal || + String(left.deviceProperties?.name ?? '').localeCompare(String(right.deviceProperties?.name ?? '')); +}); +if (!candidates.length) { + process.exit(1); +} +process.stdout.write(candidates[0].identifier || candidates[0].hardwareProperties?.udid); +NODE +} + +select_xcode_development_team() { + { + defaults read com.apple.dt.Xcode IDEProvisioningTeams 2>/dev/null || true + defaults read com.apple.dt.Xcode IDEProvisioningTeamByIdentifier 2>/dev/null || true + } | + awk -F'= ' '/teamID =/ { value = $2; gsub(/[;[:space:]]/, "", value); print value }' | + sort -u | + awk 'NR == 1 { first = $0 } NR > 1 { multiple = 1 } END { if (!multiple && first != "") print first; else exit 1 }' +} + +valid_code_signing_identity_count() { + security find-identity -v -p codesigning 2>/dev/null | + awk '/valid identities found/ { print $1; found = 1 } END { if (!found) print 0 }' +} + +configure_iphoneos_signing() { + [ "$sdk" = "iphoneos" ] || return 0 + + if is_falsey "$code_signing_allowed"; then + if is_physical_ios_launch; then + fail "physical iOS runs require code signing; do not set OLIPHAUNT_EXPO_IOS_CODE_SIGNING_ALLOWED=NO for install/launch benchmarks" + fi + echo "iPhoneOS code signing disabled by OLIPHAUNT_EXPO_IOS_CODE_SIGNING_ALLOWED=$code_signing_allowed" >&2 + return 0 + fi + + if [ -z "$development_team" ]; then + local selected_team + selected_team="$(select_xcode_development_team || true)" + if [ -n "$selected_team" ]; then + development_team="$selected_team" + echo "Using Xcode development team: $development_team" >&2 + fi + fi + + [ -n "$development_team" ] || + fail "iPhoneOS builds require a development team; set OLIPHAUNT_EXPO_IOS_DEVELOPMENT_TEAM explicitly when Xcode has zero or multiple teams configured" + + if [ -z "$code_sign_style" ]; then + code_sign_style=Automatic + fi + + local identity_count + identity_count="$(valid_code_signing_identity_count)" + if [ "${identity_count:-0}" -eq 0 ]; then + if ! is_truthy "$allow_provisioning_updates"; then + fail "iPhoneOS builds require a local Apple Development signing identity; install one in Xcode or set OLIPHAUNT_EXPO_IOS_ALLOW_PROVISIONING_UPDATES=1 to let xcodebuild create/update signing assets" + fi + if [ -z "$allow_device_registration" ]; then + allow_device_registration=1 + fi + echo "No valid local code-signing identity found; using xcodebuild automatic provisioning updates" >&2 + fi +} + +preflight_physical_ios_device() { + is_physical_ios_launch || return 0 + + local device_id + device_id="$(select_ios_physical_device_id)" || + fail "failed to resolve a paired physical iOS device; set OLIPHAUNT_EXPO_IOS_DEVICE_ID" + + mkdir -p "$scratch_root" + local json="$scratch_root/devicectl-device-details.json" + xcrun devicectl device info details \ + --device "$device_id" \ + --timeout 10 \ + --json-output "$json" >/dev/null 2>&1 || + fail "failed to inspect physical iOS device with devicectl; device may be locked, untrusted, or unavailable" + + node - "$json" <<'NODE' || exit $? +const fs = require('fs'); +const file = process.argv[2]; +const data = JSON.parse(fs.readFileSync(file, 'utf8')); +const result = data.result ?? {}; +const props = result.deviceProperties ?? {}; +const hardware = result.hardwareProperties ?? {}; +const name = props.name ?? 'physical iOS device'; +const os = props.osVersionNumber ?? 'unknown iOS'; +const devMode = props.developerModeStatus ?? 'unknown'; +if (devMode !== 'enabled') { + console.error(`error: physical iOS runs require Developer Mode enabled on ${name}; current developerModeStatus=${devMode}, os=${os}`); + process.exit(1); +} +if (props.ddiServicesAvailable === false) { + const product = hardware.productType ?? 'unknown product'; + console.error(`error: physical iOS runs require Developer Disk Image services on ${name}; ddiServicesAvailable=false, product=${product}, os=${os}`); + process.exit(1); +} +NODE +} + +resolve_xcode_destination() { + if [ -n "$destination" ]; then + printf '%s\n' "$destination" + return + fi + case "$sdk" in + iphonesimulator) + printf 'id=%s\n' "$(select_ios_simulator_udid)" + ;; + iphoneos) + if is_physical_ios_launch; then + printf 'id=%s\n' "$(select_ios_physical_device_id)" + else + printf 'generic/platform=iOS\n' + fi + ;; + *) + printf 'generic/platform=iOS Simulator\n' + ;; + esac +} + +boot_ios_simulator() { + local udid="$1" + xcrun simctl boot "$udid" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$udid" -b >/dev/null +} diff --git a/src/sdks/react-native/tools/expo-runner-ios-installed-app.sh b/src/sdks/react-native/tools/expo-runner-ios-installed-app.sh new file mode 100644 index 00000000..7fe560c0 --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-ios-installed-app.sh @@ -0,0 +1,752 @@ +#!/usr/bin/env bash +# Shared iOS installed-app, lifecycle, crash, log, and process-metric helpers +# for the Expo iOS runner. This file is sourced by expo-ios-runner.sh. + +latest_metro_runner_pass() { + local scratch_offset="$1" + local dev_offset="$2" + { + file_from_offset "$scratch_root/metro.log" "$scratch_offset" + file_from_offset "$metro_dev_log" "$dev_offset" + } | grep -F "$success_tag" | tail -1 || true +} + +latest_metro_tag() { + local scratch_offset="$1" + local dev_offset="$2" + local tag="$3" + { + file_from_offset "$scratch_root/metro.log" "$scratch_offset" + file_from_offset "$metro_dev_log" "$dev_offset" + } | grep -F "$tag" | tail -1 || true +} + +latest_metro_runner_failure() { + local scratch_offset="$1" + local dev_offset="$2" + { + file_from_offset "$scratch_root/metro.log" "$scratch_offset" + file_from_offset "$metro_dev_log" "$dev_offset" + } | grep -E "$failure_tag|metro:bundling:failed|Unable to resolve" | tail -20 || true +} + +should_use_maestro_e2e() { + [ "$runner" = "smoke" ] || return 1 + [ "$sdk" = "iphonesimulator" ] || return 1 + case "$e2e_assertion_runner" in + maestro) + maestro_binary >/dev/null || fail "missing required command: maestro; run tools/dev/setup-maestro.sh" + return 0 + ;; + auto) + maestro_binary >/dev/null + return + ;; + *) + return 1 + ;; + esac +} + +run_maestro_installed_smoke() { + local device_udid="$1" + local reports_dir="$scratch_root/reports" + [ -f "$maestro_flow" ] || fail "missing Maestro installed-app smoke flow: $maestro_flow" + local maestro + maestro="$(maestro_binary)" || fail "missing required command: maestro; run tools/dev/setup-maestro.sh" + mkdir -p "$reports_dir" + echo "==> $maestro --device $device_udid test $maestro_flow" + MAESTRO_CLI_NO_ANALYTICS=true \ + MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED=true \ + "$maestro" --device "$device_udid" test \ + -e APP_ID="$app_id" \ + -e SMOKE_TIMEOUT_MS="$((timeout_seconds * 1000))" \ + "$maestro_flow" \ + >"$reports_dir/maestro.log" 2>&1 || { + tail -160 "$reports_dir/maestro.log" >&2 || true + return 1 + } + tail -80 "$reports_dir/maestro.log" >&2 || true +} + +write_ios_process_metrics() { + local launch_pid="$1" + [ -n "$launch_pid" ] || return 0 + local metrics + metrics="$(ps -o pid=,rss=,pcpu=,comm= -p "$launch_pid" 2>/dev/null || true)" + [ -n "$metrics" ] || return 0 + local reports_dir="$scratch_root/reports" + mkdir -p "$reports_dir" + { + printf 'pid\trss_kb\tcpu_percent\tcommand\n' + printf '%s\n' "$metrics" | awk '{ + pid=$1; rss=$2; cpu=$3; $1=""; $2=""; $3=""; + sub(/^[[:space:]]+/, "", $0); + printf "%s\t%s\t%s\t%s\n", pid, rss, cpu, $0 + }' + } >"$reports_dir/$runner-process.tsv" + cat "$reports_dir/$runner-process.tsv" >&2 +} + +resolve_prebuilt_ios_app() { + local configured="${OLIPHAUNT_EXPO_IOS_APP:-}" + if [ -n "$configured" ]; then + [ -d "$configured" ] || fail "OLIPHAUNT_EXPO_IOS_APP is not an .app directory: $configured" + printf '%s\n' "$configured" + return + fi + local artifact_app + artifact_app="$(find "$build_artifact_dir" -maxdepth 1 -name '*.app' -type d 2>/dev/null | head -1 || true)" + if [ -n "$artifact_app" ]; then + printf '%s\n' "$artifact_app" + return + fi + artifact_app="$(find "$derived_data/Build/Products" -path "*$configuration-*" -name '*.app' -type d 2>/dev/null | head -1 || true)" + [ -n "$artifact_app" ] || + fail "iOS E2E-only mode requires OLIPHAUNT_EXPO_IOS_APP or a previous mobile-build:ios artifact under $build_artifact_dir" + printf '%s\n' "$artifact_app" +} + +extract_devicectl_pid() { + local json="$1" + [ -s "$json" ] || return 1 + node - "$json" <<'NODE' +const fs = require('fs'); +const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const seen = new Set(); +function visit(value) { + if (value == null || typeof value !== 'object' || seen.has(value)) { + return undefined; + } + seen.add(value); + for (const key of ['processIdentifier', 'pid']) { + if (Number.isInteger(value[key]) && value[key] > 0) { + return value[key]; + } + } + for (const child of Object.values(value)) { + const found = visit(child); + if (found !== undefined) { + return found; + } + } + return undefined; +} +const pid = visit(data); +if (!pid) { + process.exit(1); +} +process.stdout.write(String(pid)); +NODE +} + +write_ios_device_process_metrics() { + local device_id="$1" + local reports_dir="$scratch_root/reports" + local json="$reports_dir/$runner-device-processes.json" + mkdir -p "$reports_dir" + xcrun devicectl device info processes \ + --device "$device_id" \ + --columns '*' \ + --timeout 30 \ + --json-output "$json" >/dev/null 2>&1 || true + [ -s "$json" ] || return 0 + node - "$json" "$app_id" <<'NODE' >"$reports_dir/$runner-process.tsv" || true +const fs = require('node:fs'); +const [file, bundleId] = process.argv.slice(2); +const processName = bundleId.split('.').slice(-1)[0]?.toLowerCase() ?? ''; +const data = JSON.parse(fs.readFileSync(file, 'utf8')); +const rows = []; +const seen = new Set(); + +function visit(value) { + if (value == null || typeof value !== 'object' || seen.has(value)) { + return; + } + seen.add(value); + if (!Array.isArray(value)) { + const pid = integerFor(value, [ + 'processIdentifier', + 'processID', + 'pid', + 'identifier', + ]); + if (pid != null && matchesProcess(value)) { + rows.push({ + pid, + rssKb: memoryKbFor(value), + cpuPercent: numberFor(value, [ + 'cpuPercent', + 'cpuPercentage', + 'cpuUsage', + 'percentCPU', + ]), + command: commandFor(value), + }); + } + } + for (const child of Object.values(value)) { + visit(child); + } +} + +function integerFor(record, names) { + for (const name of names) { + const value = valueFor(record, name); + if (Number.isInteger(value) && value > 0) { + return value; + } + } + return null; +} + +function numberFor(record, names) { + for (const name of names) { + const value = valueFor(record, name); + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return null; +} + +function valueFor(record, wanted) { + const normalizedWanted = normalizeKey(wanted); + for (const [key, value] of Object.entries(record)) { + if (normalizeKey(key) === normalizedWanted) { + if (typeof value === 'object' && value != null && typeof value.value === 'number') { + return value.value; + } + return value; + } + } + return undefined; +} + +function normalizeKey(key) { + return String(key).replace(/[^a-z0-9]/gi, '').toLowerCase(); +} + +function matchesProcess(record) { + const haystack = Object.values(record) + .filter(value => typeof value === 'string') + .join('\n') + .toLowerCase(); + return haystack.includes(bundleId.toLowerCase()) || + (processName.length > 0 && haystack.includes(processName)) || + haystack.includes('reactnativeoliphaunt'); +} + +function memoryKbFor(record) { + for (const [key, value] of Object.entries(record)) { + const normalized = normalizeKey(key); + if (!/(rss|resident|memory)/.test(normalized)) { + continue; + } + const number = typeof value === 'number' + ? value + : (typeof value === 'object' && value != null && typeof value.value === 'number' + ? value.value + : null); + if (number == null || !Number.isFinite(number)) { + continue; + } + const unit = typeof value === 'object' && value != null && typeof value.unit === 'string' + ? value.unit.toLowerCase() + : ''; + if (unit.includes('byte')) { + return Math.round(number / 1024); + } + if (unit.includes('mb') || unit.includes('mib')) { + return Math.round(number * 1024); + } + return Math.round(number); + } + return null; +} + +function commandFor(record) { + for (const name of ['executableName', 'name', 'command', 'bundleIdentifier', 'bundleID']) { + const value = valueFor(record, name); + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return bundleId; +} + +visit(data); +process.stdout.write('pid\trss_kb\tcpu_percent\tcommand\n'); +for (const row of rows.slice(0, 1)) { + process.stdout.write([ + row.pid, + row.rssKb ?? '', + row.cpuPercent ?? '', + String(row.command).replace(/\t/g, ' '), + ].join('\t') + '\n'); +} +NODE + if [ -s "$reports_dir/$runner-process.tsv" ]; then + cat "$reports_dir/$runner-process.tsv" >&2 + fi +} + +logs_have_lifecycle_ready() { + local input="${1:-}" + if [ "$#" -eq 0 ]; then + input="$(cat)" + fi + case "$input" in + *OLIPHAUNT_EXPO_SMOKE_STAGE*'"stage":"lifecycle:ready"'*) return 0 ;; + *) return 1 ;; + esac +} + +exercise_ios_lifecycle() { + local device_udid="$1" + echo + echo "==> iOS lifecycle: open Safari, wait ${background_seconds}s, foreground $app_id" + run xcrun simctl openurl "$device_udid" "https://example.com/liboliphaunt-lifecycle-background" + sleep "$background_seconds" + run xcrun simctl launch "$device_udid" "$app_id" +} + +exercise_ios_device_lifecycle() { + local device_id="$1" + echo + echo "==> physical iOS lifecycle: open Safari, wait ${background_seconds}s, foreground $app_id" + run xcrun devicectl device process launch \ + --device "$device_id" \ + --payload-url "https://example.com/liboliphaunt-lifecycle-background" \ + --timeout 30 \ + com.apple.mobilesafari + sleep "$background_seconds" + run xcrun devicectl device process launch \ + --device "$device_id" \ + --timeout 30 \ + "$app_id" +} + +ios_metro_url() { + if [ "$sdk" != "iphoneos" ]; then + printf 'http://127.0.0.1:%s' "$metro_port" + return + fi + if [ -n "${OLIPHAUNT_EXPO_IOS_METRO_URL:-}" ]; then + printf '%s' "$OLIPHAUNT_EXPO_IOS_METRO_URL" + return + fi + local host="${OLIPHAUNT_EXPO_IOS_METRO_HOST:-}" + if [ -z "$host" ]; then + host="$(ipconfig getifaddr en0 2>/dev/null || true)" + fi + if [ -z "$host" ]; then + host="$(ipconfig getifaddr en1 2>/dev/null || true)" + fi + [ -n "$host" ] || + fail "failed to resolve host LAN address for physical iOS Metro; set OLIPHAUNT_EXPO_IOS_METRO_URL" + printf 'http://%s:%s' "$host" "$metro_port" +} + +ios_runner_url() { + local selected_runner="$1" + local root_arg="${2:-}" + local url="$scheme://oliphaunt-smoke?liboliphauntRunner=$selected_runner&liboliphauntLifecycle=$lifecycle_smoke&liboliphauntDurability=$(urlencode "$durability_profile")&liboliphauntRuntimeFootprint=$(urlencode "$runtime_footprint")" + if uses_ios_metro; then + local metro_url encoded_metro_url + metro_url="$(ios_metro_url)" + encoded_metro_url="$(urlencode "$metro_url")" + url="$scheme://expo-development-client/?url=$encoded_metro_url&disableOnboarding=1&liboliphauntRunner=$selected_runner&liboliphauntLifecycle=$lifecycle_smoke&liboliphauntDurability=$(urlencode "$durability_profile")&liboliphauntRuntimeFootprint=$(urlencode "$runtime_footprint")" + fi + if [ "$selected_runner" = "benchmark" ]; then + url="$url&liboliphauntBenchmarkPreset=$(urlencode "$benchmark_preset")" + fi + if [ -n "$startup_gucs" ]; then + url="$url&liboliphauntStartupGUCs=$(urlencode "$startup_gucs")" + fi + url="$url&liboliphauntWalSegsizeMB=$(urlencode "$wal_segsize_mb")" + if [ -n "$root_arg" ]; then + url="$url&liboliphauntRoot=$(urlencode "$root_arg")" + fi + printf '%s' "$url" +} + +wait_for_ios_tag() { + local device_udid="$1" + local tag="$2" + local fail_tag="$3" + local scratch_offset="$4" + local dev_offset="$5" + local deadline=$((SECONDS + timeout_seconds)) + local logs pass fail_line + while [ "$SECONDS" -lt "$deadline" ]; do + logs="$(xcrun simctl spawn "$device_udid" log show --style compact --last 30s --predicate "process == 'reactnativeoliphauntexpo'" 2>/dev/null || true)" + pass="$(printf '%s\n' "$logs" | grep -F "$tag" | tail -1 || true)" + if [ -z "$pass" ]; then + pass="$(latest_metro_tag "$scratch_offset" "$dev_offset" "$tag")" + fi + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + return 0 + fi + fail_line="$(printf '%s\n' "$logs" | grep -E "$fail_tag|Fatal error|terminating" | tail -20 || true)" + if [ -z "$fail_line" ]; then + fail_line="$(latest_metro_runner_failure "$scratch_offset" "$dev_offset")" + fi + if [ -n "$fail_line" ]; then + printf '%s\n' "$fail_line" >&2 + return 1 + fi + sleep 2 + done + return 1 +} + +exercise_ios_crash_recovery() { + local device_udid="$1" + local root_path="$2" + local write_url verify_url launch_output launch_pid write_line pass scratch_offset dev_offset + + if [ -z "$crash_root_override" ]; then + case "$root_path" in + app-support://*) + ;; + /*) + rm -rf "$root_path" + ;; + esac + fi + + start_metro_if_needed crash-write "$root_path" + scratch_offset="$(file_bytes "$scratch_root/metro.log")" + dev_offset="$(file_bytes "$metro_dev_log")" + write_url="$(ios_runner_url crash-write "$root_path")" + + echo + echo "==> iOS crash recovery: write phase" + run xcrun simctl terminate "$device_udid" "$app_id" || true + launch_output="$(xcrun simctl launch "$device_udid" "$app_id" --initialUrl "$write_url")" + printf '%s\n' "$launch_output" + launch_pid="$(printf '%s\n' "$launch_output" | awk -F': ' -v app_id="$app_id" '$1 == app_id {print $2; exit}')" + write_line="$(wait_for_ios_tag "$device_udid" OLIPHAUNT_EXPO_CRASH_WRITE_READY "$failure_tag" "$scratch_offset" "$dev_offset")" || + fail "Expo iOS crash recovery write phase failed" + mkdir -p "$scratch_root/reports" + printf '%s\n' "$write_line" >"$scratch_root/reports/crash-write-ready.log" + + echo + echo "==> iOS crash recovery: terminate app process, then verify phase" + run xcrun simctl terminate "$device_udid" "$app_id" || true + stop_owned_metro + start_metro_if_needed crash-verify "$root_path" + scratch_offset="$(file_bytes "$scratch_root/metro.log")" + dev_offset="$(file_bytes "$metro_dev_log")" + verify_url="$(ios_runner_url crash-verify "$root_path")" + launch_output="$(xcrun simctl launch "$device_udid" "$app_id" --initialUrl "$verify_url")" + printf '%s\n' "$launch_output" + launch_pid="$(printf '%s\n' "$launch_output" | awk -F': ' -v app_id="$app_id" '$1 == app_id {print $2; exit}')" + pass="$(wait_for_ios_tag "$device_udid" "$success_tag" "$failure_tag" "$scratch_offset" "$dev_offset")" || + fail "Expo iOS crash recovery verify phase failed" + write_runner_report "$pass" + write_ios_process_metrics "$launch_pid" +} + +launch_ios_device_runner() { + local device_id="$1" + local selected_runner="$2" + local root_arg="${3:-}" + local json="$scratch_root/devicectl-launch-$selected_runner.json" + local log="$scratch_root/devicectl-launch-$selected_runner.log" + local url deadline attempt launch_timeout + url="$(ios_runner_url "$selected_runner" "$root_arg")" + deadline=$((SECONDS + timeout_seconds)) + attempt=1 + launch_timeout="${OLIPHAUNT_EXPO_IOS_DEVICE_LAUNCH_ATTEMPT_TIMEOUT_SECONDS:-20}" + + while [ "$SECONDS" -lt "$deadline" ]; do + echo >&2 + echo "==> xcrun devicectl device process launch --device $device_id --terminate-existing --payload-url $url $app_id" >&2 + if xcrun devicectl device process launch \ + --device "$device_id" \ + --terminate-existing \ + --payload-url "$url" \ + --timeout "$launch_timeout" \ + --json-output "$json" \ + "$app_id" >"$log" 2>&1; then + cat "$log" >&2 + extract_devicectl_pid "$json" 2>/dev/null || true + return 0 + fi + + if grep -Eq 'Locked|could not be unlocked|device was not, or could not be, unlocked' "$log"; then + echo "physical iOS device is locked; waiting for unlock before retrying launch (attempt $attempt)" >&2 + sleep 5 + attempt=$((attempt + 1)) + continue + fi + cat "$log" >&2 + return 1 + done + + cat "$log" >&2 + echo "physical iOS device stayed locked until launch timeout; unlock it and rerun with OLIPHAUNT_EXPO_IOS_REUSE_INSTALLED_APP=1" >&2 + return 1 +} + +wait_for_ios_device_runner() { + local device_id="$1" + local scratch_offset="$2" + local dev_offset="$3" + local deadline=$((SECONDS + timeout_seconds)) + local pass fail_line lifecycle_exercised=0 lifecycle_logs + while [ "$SECONDS" -lt "$deadline" ]; do + if [ "$lifecycle_smoke" = "1" ] && [ "$lifecycle_exercised" = "0" ]; then + lifecycle_logs="$( + { + file_from_offset "$scratch_root/metro.log" "$scratch_offset" + file_from_offset "$metro_dev_log" "$dev_offset" + } || true + )" + if logs_have_lifecycle_ready "$lifecycle_logs"; then + exercise_ios_device_lifecycle "$device_id" + lifecycle_exercised=1 + sleep 2 + continue + fi + fi + pass="$(latest_metro_runner_pass "$scratch_offset" "$dev_offset")" + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + write_runner_report "$pass" + write_ios_device_process_metrics "$device_id" + return 0 + fi + fail_line="$(latest_metro_runner_failure "$scratch_offset" "$dev_offset")" + if [ -n "$fail_line" ]; then + printf '%s\n' "$fail_line" >&2 + return 1 + fi + sleep 2 + done + return 1 +} + +exercise_ios_device_crash_recovery() { + local device_id="$1" + local root_path="$2" + local write_pid write_line pass scratch_offset dev_offset + + if [ -z "$crash_root_override" ]; then + case "$root_path" in + app-support://*) + ;; + /*) + rm -rf "$root_path" + ;; + esac + fi + + start_metro_if_needed crash-write "$root_path" + scratch_offset="$(file_bytes "$scratch_root/metro.log")" + dev_offset="$(file_bytes "$metro_dev_log")" + write_pid="$(launch_ios_device_runner "$device_id" crash-write "$root_path")" || + fail "failed to launch iOS device crash recovery write phase" + write_line="$(wait_for_ios_tag_from_metro OLIPHAUNT_EXPO_CRASH_WRITE_READY "$failure_tag" "$scratch_offset" "$dev_offset")" || + fail "Expo iOS device crash recovery write phase failed" + mkdir -p "$scratch_root/reports" + printf '%s\n' "$write_line" >"$scratch_root/reports/crash-write-ready.log" + + if [ -n "$write_pid" ]; then + run xcrun devicectl device process terminate \ + --device "$device_id" \ + --pid "$write_pid" \ + --kill \ + --timeout 30 || true + fi + + stop_owned_metro + start_metro_if_needed crash-verify "$root_path" + scratch_offset="$(file_bytes "$scratch_root/metro.log")" + dev_offset="$(file_bytes "$metro_dev_log")" + launch_ios_device_runner "$device_id" crash-verify "$root_path" >/dev/null || + fail "failed to launch iOS device crash recovery verify phase" + pass="$(wait_for_ios_tag_from_metro "$success_tag" "$failure_tag" "$scratch_offset" "$dev_offset")" || + fail "Expo iOS device crash recovery verify phase failed" + write_runner_report "$pass" + write_ios_device_process_metrics "$device_id" +} + +wait_for_ios_tag_from_metro() { + local tag="$1" + local fail_tag="$2" + local scratch_offset="$3" + local dev_offset="$4" + local deadline=$((SECONDS + timeout_seconds)) + local pass fail_line + while [ "$SECONDS" -lt "$deadline" ]; do + pass="$(latest_metro_tag "$scratch_offset" "$dev_offset" "$tag")" + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + return 0 + fi + fail_line="$(latest_metro_runner_failure "$scratch_offset" "$dev_offset")" + if [ -n "$fail_line" ]; then + printf '%s\n' "$fail_line" >&2 + return 1 + fi + sleep 2 + done + return 1 +} + +install_and_launch() { + local app="$1" + if [ "${OLIPHAUNT_EXPO_IOS_BUILD_ONLY:-0}" = "1" ]; then + echo "iOS build-only smoke complete: $app" + return + fi + + if [ "$sdk" = "iphoneos" ]; then + local device_id + device_id="$(select_ios_physical_device_id)" || + fail "failed to resolve a paired physical iOS device; set OLIPHAUNT_EXPO_IOS_DEVICE_ID" + echo "Using physical iOS device: $device_id" + run xcrun devicectl device install app \ + --device "$device_id" \ + --timeout 120 \ + --json-output "$scratch_root/devicectl-install.json" \ + "$app" + + if [ "$runner" = "crash" ]; then + local crash_root="$crash_root_override" + [ -n "$crash_root" ] || crash_root="app-support://oliphaunt-crash-recovery-root-$crash_root_suffix" + exercise_ios_device_crash_recovery "$device_id" "$crash_root" + return + fi + + if uses_ios_metro; then + start_metro_if_needed "$runner" + fi + local scratch_metro_offset dev_metro_offset + scratch_metro_offset="$(file_bytes "$scratch_root/metro.log")" + dev_metro_offset="$(file_bytes "$metro_dev_log")" + launch_ios_device_runner "$device_id" "$runner" >/dev/null || + fail "failed to launch Expo iOS $runner on physical device" + wait_for_ios_device_runner "$device_id" "$scratch_metro_offset" "$dev_metro_offset" || + fail "timed out waiting for $success_tag from physical iOS device" + return + fi + + local device_udid + device_udid="$(select_ios_simulator_udid)" || + fail "failed to resolve an available iOS simulator for installed-app smoke" + boot_ios_simulator "$device_udid" + if is_truthy "$clean_simulator_install"; then + run xcrun simctl uninstall "$device_udid" "$app_id" || true + fi + run xcrun simctl install "$device_udid" "$app" + run xcrun simctl terminate "$device_udid" "$app_id" || true + + if [ "$runner" = "crash" ]; then + local crash_root="$crash_root_override" + if [ -z "$crash_root" ]; then + local container + container="$(xcrun simctl get_app_container "$device_udid" "$app_id" data)" || + fail "failed to resolve iOS app data container for crash recovery" + crash_root="$container/Documents/oliphaunt-crash-recovery-root-$crash_root_suffix" + fi + exercise_ios_crash_recovery "$device_udid" "$crash_root" + return + fi + + if uses_ios_metro; then + start_metro_if_needed "$runner" + fi + local scratch_metro_offset dev_metro_offset + scratch_metro_offset="$(file_bytes "$scratch_root/metro.log")" + dev_metro_offset="$(file_bytes "$metro_dev_log")" + local url + url="$(ios_runner_url "$runner")" + local launch_output launch_pid + echo + echo "==> xcrun simctl launch $device_udid $app_id --initialUrl $url" + launch_output="$(xcrun simctl launch "$device_udid" "$app_id" --initialUrl "$url")" + printf '%s\n' "$launch_output" + launch_pid="$(printf '%s\n' "$launch_output" | awk -F': ' -v app_id="$app_id" '$1 == app_id {print $2; exit}')" + if [ "${OLIPHAUNT_EXPO_IOS_OPENURL_FALLBACK:-0}" = "1" ]; then + sleep 2 + run xcrun simctl openurl "$device_udid" "$url" || true + fi + + local logs pass + if should_use_maestro_e2e; then + [ "$lifecycle_smoke" != "1" ] || + fail "Maestro mobile E2E does not drive lifecycle transitions; use mobile-drill or set OLIPHAUNT_EXPO_IOS_LIFECYCLE_SMOKE=0" + run_maestro_installed_smoke "$device_udid" || + fail "Expo iOS installed-app Maestro smoke failed" + logs="$(xcrun simctl spawn "$device_udid" log show --style compact --last 2m --predicate "process == 'reactnativeoliphauntexpo'" 2>/dev/null || true)" + pass="$(printf '%s\n' "$logs" | grep -F "$success_tag" | tail -1 || true)" + if [ -z "$pass" ]; then + pass="$(latest_metro_runner_pass "$scratch_metro_offset" "$dev_metro_offset")" + fi + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + write_runner_report "$pass" + else + write_maestro_runner_report ios + fi + write_ios_process_metrics "$launch_pid" + return + fi + + local deadline=$((SECONDS + timeout_seconds)) + local fail_line lifecycle_exercised=0 + while [ "$SECONDS" -lt "$deadline" ]; do + logs="$(xcrun simctl spawn "$device_udid" log show --style compact --last 30s --predicate "process == 'reactnativeoliphauntexpo'" 2>/dev/null || true)" + if [ "$lifecycle_smoke" = "1" ] && [ "$lifecycle_exercised" = "0" ]; then + if { + printf '%s\n' "$logs" + file_from_offset "$scratch_root/metro.log" "$scratch_metro_offset" + file_from_offset "$metro_dev_log" "$dev_metro_offset" + } | logs_have_lifecycle_ready; then + exercise_ios_lifecycle "$device_udid" + lifecycle_exercised=1 + sleep 2 + continue + fi + fi + pass="$(printf '%s\n' "$logs" | grep -F "$success_tag" | tail -1 || true)" + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + write_runner_report "$pass" + write_ios_process_metrics "$launch_pid" + return + fi + if uses_ios_metro; then + pass="$(latest_metro_runner_pass "$scratch_metro_offset" "$dev_metro_offset")" + if [ -n "$pass" ]; then + printf '\n%s\n' "$pass" + write_runner_report "$pass" + write_ios_process_metrics "$launch_pid" + return + fi + fi + fail_line="$(printf '%s\n' "$logs" | grep -E "$failure_tag|Fatal error|terminating" | tail -20 || true)" + if [ -n "$fail_line" ]; then + printf '%s\n' "$fail_line" >&2 + fail "Expo iOS $runner failed" + fi + if uses_ios_metro; then + fail_line="$(latest_metro_runner_failure "$scratch_metro_offset" "$dev_metro_offset")" + if [ -n "$fail_line" ]; then + printf '%s\n' "$fail_line" >&2 + fail "Expo iOS $runner failed" + fi + fi + sleep 2 + done + + xcrun simctl spawn "$device_udid" log show --style compact --last 2m 2>/dev/null | tail -200 >&2 || true + file_from_offset "$scratch_root/metro.log" "$scratch_metro_offset" | tail -120 >&2 || true + file_from_offset "$metro_dev_log" "$dev_metro_offset" | tail -120 >&2 || true + fail "timed out waiting for $success_tag" +} diff --git a/src/sdks/react-native/tools/expo-runner-metro.sh b/src/sdks/react-native/tools/expo-runner-metro.sh new file mode 100644 index 00000000..af789f8c --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-metro.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# Shared Metro process/port helpers for React Native Expo mobile runners. +# Platform runners still own the actual Metro start command because iOS and +# Android use different host/release behavior. + +port_is_listening() { + lsof -nP -iTCP:"$metro_port" -sTCP:LISTEN >/dev/null 2>&1 +} + +reserve_metro_port() { + [ "${reuse_metro:-0}" != "1" ] || return 0 + [ -z "${metro_pid:-}" ] || return 0 + port_is_listening || return 0 + [ "$metro_port_explicit" = "0" ] || + fail "Expo Metro port $metro_port is already in use; stop it, set ${reuse_metro_env_name:-OLIPHAUNT_REUSE_METRO}=1, or choose ${metro_port_env_name:-OLIPHAUNT_METRO_PORT}" + + local requested_port="$metro_port" + local candidate + for candidate in $(seq 8082 8099); do + metro_port="$candidate" + if ! port_is_listening; then + echo "Metro port $requested_port is busy; using $metro_port for this controlled dev-client run" + return 0 + fi + done + metro_port="$requested_port" + fail "Expo Metro port $requested_port is busy and no free fallback port was found in 8082-8099" +} + +kill_process_tree() { + local pid="$1" + local child + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + kill_process_tree "$child" + done + kill "$pid" >/dev/null 2>&1 || true +} + +stop_owned_metro() { + if [ -n "${metro_pid:-}" ]; then + kill_process_tree "$metro_pid" + wait "$metro_pid" >/dev/null 2>&1 || true + fi + metro_pid="" + metro_bundle_runner="" + metro_bundle_root="" +} + +cleanup() { + if [ "${keep_metro:-0}" != "1" ]; then + stop_owned_metro + fi +} diff --git a/src/sdks/react-native/tools/expo-runner-reporting.sh b/src/sdks/react-native/tools/expo-runner-reporting.sh new file mode 100644 index 00000000..f4c41fb0 --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-reporting.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash + +# Shared report helpers for React Native Expo mobile runners. Platform runners +# own platform metrics and artifact copying; this file only normalizes runner +# pass/report JSON emitted from Metro logs or Maestro installed-app flows. + +write_runner_report() { + local line="$1" + local reports_dir="$scratch_root/reports" + mkdir -p "$reports_dir" + printf '%s\n' "$line" >"$reports_dir/$runner-pass.log" + OLIPHAUNT_EXPO_LOG_TAG="$success_tag" \ + OLIPHAUNT_EXPO_LOG_LINE="$line" \ + node <<'NODE' >"$reports_dir/$runner-report.json" || true +const fs = require('fs'); +const input = process.env.OLIPHAUNT_EXPO_LOG_LINE || fs.readFileSync(0, 'utf8').trim(); +const tag = process.env.OLIPHAUNT_EXPO_LOG_TAG; +let payload; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +try { + const jsonStart = input.indexOf('{'); + if (jsonStart >= 0) { + const event = JSON.parse(input.slice(jsonStart)); + if (Array.isArray(event.data)) { + const index = event.data.indexOf(tag); + if (index >= 0) { + payload = event.data[index + 1]; + } + } + } +} catch {} + +if (payload === undefined) { + const tagIndex = input.indexOf(tag); + if (tagIndex >= 0) { + const rest = input.slice(tagIndex + tag.length); + const jsonStart = rest.indexOf('{'); + if (jsonStart >= 0) { + payload = rest.slice(jsonStart).trim(); + } + } +} + +if (payload === undefined) { + const reactNativeMatch = input.match( + new RegExp(`ReactNativeJS:\\s*'${escapeRegExp(tag)}',\\s*'([\\s\\S]*)'\\s*$`), + ); + if (reactNativeMatch) { + payload = reactNativeMatch[1]; + } +} + +if (typeof payload === 'string') { + payload = payload.trim(); + if (payload.startsWith("'") && payload.endsWith("'")) { + payload = payload.slice(1, -1); + } else if (payload.endsWith("'")) { + payload = payload.slice(0, -1); + } + payload = JSON.parse(payload); +} +if (payload === undefined) { + process.exit(1); +} +process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); +NODE + if [ -s "$reports_dir/$runner-report.json" ]; then + echo "$runner report: $reports_dir/$runner-report.json" >&2 + fi +} + +write_maestro_runner_report() { + local platform="$1" + local reports_dir="$scratch_root/reports" + mkdir -p "$reports_dir" + OLIPHAUNT_MAESTRO_PLATFORM="$platform" \ + OLIPHAUNT_MAESTRO_APP_ID="$app_id" \ + OLIPHAUNT_MAESTRO_FLOW="$maestro_flow" \ + node <<'NODE' >"$reports_dir/$runner-report.json" +const report = { + runner: 'maestro', + platform: process.env.OLIPHAUNT_MAESTRO_PLATFORM, + appId: process.env.OLIPHAUNT_MAESTRO_APP_ID, + flow: process.env.OLIPHAUNT_MAESTRO_FLOW, + passedAt: new Date().toISOString(), +}; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +NODE + OLIPHAUNT_MAESTRO_PLATFORM="$platform" \ + OLIPHAUNT_MAESTRO_APP_ID="$app_id" \ + OLIPHAUNT_MAESTRO_FLOW="$maestro_flow" \ + node <<'NODE' >"$reports_dir/$runner-pass.log" +const report = { + runner: 'maestro', + platform: process.env.OLIPHAUNT_MAESTRO_PLATFORM, + appId: process.env.OLIPHAUNT_MAESTRO_APP_ID, + flow: process.env.OLIPHAUNT_MAESTRO_FLOW, +}; +process.stdout.write(`OLIPHAUNT_EXPO_MAESTRO_PASS ${JSON.stringify(report)}\n`); +NODE +} + +write_mobile_package_size_report() { + local artifact_size_key="$1" + local artifact_bytes="$2" + local rn_package_bytes="$3" + local reports_dir="$scratch_root/reports" + mkdir -p "$reports_dir" + node - "$reports_dir/$runner-package-sizes.json" "$artifact_size_key" "$artifact_bytes" "$rn_package_bytes" <<'NODE' +const fs = require('node:fs'); +const [report, artifactSizeKey, artifactBytes, rnPackageBytes] = process.argv.slice(2); +const payload = { + [artifactSizeKey]: Number(artifactBytes), + rnPackageBytes: Number(rnPackageBytes), +}; +fs.writeFileSync(report, `${JSON.stringify(payload, null, 2)}\n`); +NODE +} + +write_mobile_build_artifact_report_json() { + local report="$1" + local platform="$2" + local artifact="$3" + local artifact_bytes="$4" + local rn_package="$5" + local rn_package_bytes="$6" + local selected_extensions="$7" + local report_scratch_root="$8" + shift 8 + node - "$report" "$platform" "$artifact" "$artifact_bytes" "$rn_package" "$rn_package_bytes" "$selected_extensions" "$report_scratch_root" "$@" <<'NODE' +const fs = require('node:fs'); +const [ + report, + platform, + appArtifact, + appArtifactBytes, + rnPackage, + rnPackageBytes, + extensions, + scratchRoot, + ...metadataArgs +] = process.argv.slice(2); + +if (metadataArgs.length % 2 !== 0) { + throw new Error('metadata arguments must be key/value pairs'); +} + +const metadata = {}; +for (let index = 0; index < metadataArgs.length; index += 2) { + metadata[metadataArgs[index]] = metadataArgs[index + 1]; +} + +const payload = { + schema: 'oliphaunt-react-native-mobile-build-v1', + platform, + ...metadata, + appArtifact, + appArtifactBytes: Number(appArtifactBytes), + reactNativePackage: rnPackage, + reactNativePackageBytes: Number(rnPackageBytes), + selectedExtensions: extensions ? extensions.split(',').filter(Boolean) : [], + scratchRoot, +}; +fs.writeFileSync(report, `${JSON.stringify(payload, null, 2)}\n`); +NODE +} diff --git a/src/sdks/react-native/tools/expo-runner-runtime-resources.sh b/src/sdks/react-native/tools/expo-runner-runtime-resources.sh new file mode 100644 index 00000000..5ae1a5d3 --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-runtime-resources.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash + +# Shared runtime-resource packaging for React Native Expo mobile runners. +# Platform runners choose platform artifacts and runtime/template sources; this +# helper owns the common mobile resource layout, exact-extension filtering, and +# package metadata. + +expo_runner_runtime_resources_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" + +copy_mobile_runtime_files() { + local runtime_source="$1" + local runtime_dest="$2" + local optional_data_file optional_data_rel + local -a optional_data_excludes=() + + while IFS= read -r optional_data_file; do + [ -n "$optional_data_file" ] || continue + optional_data_rel="${optional_data_file#share/postgresql/}" + [ "$optional_data_rel" != "$optional_data_file" ] || continue + optional_data_excludes+=(--exclude "/$optional_data_rel") + done < <(oliphaunt_dev_mobile_registry_data_files all) + + mkdir -p "$runtime_dest/bin" "$runtime_dest/share/postgresql/extension" + rsync -a --delete \ + --prune-empty-dirs \ + --exclude '/extension/***' \ + ${optional_data_excludes[@]+"${optional_data_excludes[@]}"} \ + "$runtime_source/share/postgresql/" "$runtime_dest/share/postgresql/" + + # The embedded backend uses argv[0] only as an absolute install-root anchor + # for deriving share/lib paths. Mobile app resources must not include host + # postgres binaries or host dynamic libraries. + printf 'liboliphaunt embedded runtime anchor\n' > "$runtime_dest/bin/postgres" + chmod 0644 "$runtime_dest/bin/postgres" +} + +prepare_mobile_runtime_resource_package() { + local platform="$1" + local runtime_source="$2" + local template_source="$3" + local static_registry_source="$4" + local selected_extensions="$5" + local repackage_assets="$6" + local package_root="$7" + + need_cmd rsync + need_cmd shasum + + local selected_module_stems + selected_module_stems="$(oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions")" + if [ -n "$selected_module_stems" ] && [ ! -f "$static_registry_source" ]; then + fail "$platform mobile extension '$selected_extensions' requires a linked liboliphaunt static registry source" + fi + + local source_stamp="$package_root/.sources" + local prepared_stamp="$package_root/.prepared" + local current_sources + current_sources="$( + printf '%s\n%s\nnormalizer=mobile-template-v1\nruntime-layout=mobile-minimal-v1\nwal-segsize-mb=%s\nextensions=%s\n' "$runtime_source" "$template_source" "$wal_segsize_mb" "$selected_extensions" + [ -n "$static_registry_source" ] && shasum -a 256 "$static_registry_source" + shasum -a 256 "$root/src/extensions/generated/mobile/static-registry.json" + oliphaunt_dev_hash_mobile_runtime_extension_assets "$runtime_source" "$selected_extensions" + shasum -a 256 \ + "$script_path" \ + "$expo_runner_runtime_resources_script" \ + "$root/src/sdks/react-native/tools/mobile-extension-runtime.sh" + )" + if [ "$repackage_assets" != "1" ] && + [ -f "$prepared_stamp" ] && + [ -f "$source_stamp" ] && + [ "$current_sources" = "$(cat "$source_stamp")" ] && + [ -z "$(find "$runtime_source" "$template_source" -type f -newer "$prepared_stamp" -print)" ]; then + echo "Reusing $platform runtime resources: $package_root" >&2 + printf '%s\n' "$package_root" + return + fi + + local runtime_dest="$package_root/oliphaunt/runtime/files" + local template_dest="$package_root/oliphaunt/template-pgdata/files" + local static_registry_dest="$package_root/oliphaunt/static-registry" + rm -rf "$package_root" + mkdir -p "$runtime_dest" "$template_dest" "$static_registry_dest" + + copy_mobile_runtime_files "$runtime_source" "$runtime_dest" + oliphaunt_dev_copy_mobile_runtime_extension_assets "$runtime_source" "$runtime_dest" "$selected_extensions" + oliphaunt_dev_assert_runtime_extension_tree "$runtime_dest" "$selected_extensions" "$platform" + oliphaunt_dev_assert_runtime_data_files "$runtime_dest" "$selected_extensions" "$platform" + rsync -a --delete \ + --exclude postmaster.pid \ + --exclude postmaster.opts \ + --exclude 'pg_stat_tmp/*' \ + "$template_source/" "$template_dest/" + rm -f "$template_dest/postmaster.pid" "$template_dest/postmaster.opts" + normalize_template_pgdata "$template_dest" + + local static_registry_files=0 static_registry_bytes=0 + local manifest_extensions="" mobile_static_state="not-required" + local mobile_static_registered="" native_module_stems="" mobile_static_source="" + local selected_extension_files=0 selected_extension_bytes=0 + local extension extension_files extension_bytes extension_size_rows + extension_size_rows="$package_root/.extension-size-rows" + : >"$extension_size_rows" + if [ -n "$selected_extensions" ]; then + manifest_extensions="$selected_extensions" + native_module_stems="$selected_module_stems" + if [ -n "$native_module_stems" ]; then + mobile_static_state="complete" + mobile_static_registered="$(oliphaunt_dev_mobile_module_extensions_for_selection "$selected_extensions")" + mobile_static_source="static-registry/oliphaunt_static_registry.c" + oliphaunt_dev_write_static_registry_manifest "$static_registry_dest" "$selected_extensions" "$static_registry_source" + else + oliphaunt_dev_write_static_registry_manifest "$static_registry_dest" "" "" + fi + while IFS= read -r extension; do + [ -n "$extension" ] || continue + read -r extension_files extension_bytes < <(oliphaunt_dev_extension_runtime_stats "$runtime_dest" "$extension") + selected_extension_files=$((selected_extension_files + extension_files)) + selected_extension_bytes=$((selected_extension_bytes + extension_bytes)) + printf 'extension\t%s\t-\t%s\t%s\n' "$extension" "$extension_files" "$extension_bytes" >>"$extension_size_rows" + done < <(printf '%s\n' "$selected_extensions" | tr ',' '\n') + else + oliphaunt_dev_write_static_registry_manifest "$static_registry_dest" "" "" + fi + + local runtime_bytes template_bytes total_bytes runtime_files template_files total_files + runtime_bytes="$(directory_bytes "$runtime_dest")" + template_bytes="$(directory_bytes "$template_dest")" + static_registry_bytes="$(directory_bytes "$static_registry_dest")" + total_bytes=$((runtime_bytes + template_bytes + static_registry_bytes)) + runtime_files="$(directory_files "$runtime_dest")" + template_files="$(directory_files "$template_dest")" + static_registry_files="$(directory_files "$static_registry_dest")" + total_files=$((runtime_files + template_files + static_registry_files)) + + local runtime_key template_key + runtime_key="$(directory_fingerprint "$runtime_dest")" + template_key="$(directory_fingerprint "$template_dest")" + + mkdir -p "$package_root/oliphaunt/runtime" "$package_root/oliphaunt/template-pgdata" + cat >"$package_root/oliphaunt/runtime/manifest.properties" <"$package_root/oliphaunt/template-pgdata/manifest.properties" <"$package_root/oliphaunt/package-size.tsv" <>"$package_root/oliphaunt/package-size.tsv" + + printf '%s' "$current_sources" >"$source_stamp" + touch "$prepared_stamp" + + printf '%s\n' "$package_root" +} diff --git a/src/sdks/react-native/tools/expo-runner-workspace.sh b/src/sdks/react-native/tools/expo-runner-workspace.sh new file mode 100644 index 00000000..4e6ff29e --- /dev/null +++ b/src/sdks/react-native/tools/expo-runner-workspace.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash + +# Shared scratch-workspace and template-PGDATA helpers for the React Native +# Expo mobile runners. Callers provide platform-specific variables such as +# scratch_root, example_dir, package_work, source_example_dir, rn_dir, +# mobile_template_initdb, wal_segsize_mb, and react_native_package_extra_excludes. + +react_native_package_extra_excludes=() + +host_runtime_label() { + case "$(uname -s):$(uname -m)" in + Darwin:*) printf '%s\n' macos ;; + Linux:x86_64|Linux:amd64) printf '%s\n' linux-x64-gnu ;; + Linux:aarch64|Linux:arm64) printf '%s\n' linux-arm64-gnu ;; + *) fail "unsupported host runtime build platform for mobile packaging: $(uname -s)/$(uname -m)" ;; + esac +} + +host_runtime_work_root() { + case "$(host_runtime_label)" in + macos) printf '%s\n' "${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18}" ;; + linux-x64-gnu) printf '%s\n' "${OLIPHAUNT_LINUX_WORK_ROOT:-${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18-linux-x64-gnu}}" ;; + linux-arm64-gnu) printf '%s\n' "${OLIPHAUNT_LINUX_WORK_ROOT:-${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18-linux-arm64-gnu}}" ;; + *) fail "unsupported host runtime build platform for mobile packaging: $(uname -s)/$(uname -m)" ;; + esac +} + +host_runtime_install_dir() { + printf '%s/install\n' "$(host_runtime_work_root)" +} + +host_runtime_build_script() { + case "$(host_runtime_label)" in + macos) printf '%s\n' "$root/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh" ;; + linux-x64-gnu|linux-arm64-gnu) printf '%s\n' "$root/src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh" ;; + *) fail "unsupported host runtime build platform for mobile packaging: $(uname -s)/$(uname -m)" ;; + esac +} + +host_runtime_ready() { + local runtime_source="$1" + [ -x "$runtime_source/bin/initdb" ] && + [ -f "$runtime_source/share/postgresql/postgres.bki" ] && + [ -f "$runtime_source/share/postgresql/postgresql.conf.sample" ] +} + +ensure_host_runtime_assets() { + local runtime_source + runtime_source="$(host_runtime_install_dir)" + if host_runtime_ready "$runtime_source"; then + printf '%s\n' "$runtime_source" + return + fi + if ! expo_allows_native_builds; then + fail "host PostgreSQL runtime assets are missing and native builds are disabled; set OLIPHAUNT_EXPO_*_RUNTIME_DIR and OLIPHAUNT_EXPO_*_INITDB to prebuilt liboliphaunt artifacts" + fi + + local label log build_script + label="$(host_runtime_label)" + build_script="$(host_runtime_build_script)" + log="$scratch_root/logs/build-host-runtime-$label.log" + mkdir -p "$(dirname "$log")" + if ! "$build_script" --runtime-only >"$log" 2>&1; then + tail -120 "$log" >&2 || true + fail "failed to build host PostgreSQL runtime assets for mobile packaging; see $log" + fi + if ! host_runtime_ready "$runtime_source"; then + tail -120 "$log" >&2 || true + fail "host PostgreSQL runtime assets are incomplete after build: $runtime_source" + fi + printf '%s\n' "$runtime_source" +} + +normalize_template_pgdata() { + local pgdata="$1" + local conf="$pgdata/postgresql.conf" + [ -f "$conf" ] || return 0 + + local tmp="$conf.liboliphaunt-normalized" + awk ' + /^[[:space:]]*dynamic_shared_memory_type[[:space:]]*=/ { + print "dynamic_shared_memory_type = mmap" + next + } + /^[[:space:]]*log_timezone[[:space:]]*=/ { + print "log_timezone = '\''UTC'\''" + next + } + /^[[:space:]]*timezone[[:space:]]*=/ { + print "timezone = '\''UTC'\''" + next + } + /^[[:space:]]*lc_messages[[:space:]]*=/ { + print "lc_messages = '\''C'\''" + next + } + /^[[:space:]]*lc_monetary[[:space:]]*=/ { + print "lc_monetary = '\''C'\''" + next + } + /^[[:space:]]*lc_numeric[[:space:]]*=/ { + print "lc_numeric = '\''C'\''" + next + } + /^[[:space:]]*lc_time[[:space:]]*=/ { + print "lc_time = '\''C'\''" + next + } + { print } + ' "$conf" > "$tmp" + mv "$tmp" "$conf" +} + +ensure_mobile_tool_executable() { + local tool="$1" + [ -n "$tool" ] || return 0 + [ -f "$tool" ] || return 0 + [ -x "$tool" ] && return 0 + chmod u+x "$tool" || + fail "mobile runtime tool is not executable and could not be repaired: $tool" +} + +ensure_mobile_runtime_tool_permissions() { + local runtime_source="$1" + local tool + for tool in postgres initdb pg_ctl pg_dump psql; do + ensure_mobile_tool_executable "$runtime_source/bin/$tool" + done +} + +prepare_mobile_template_pgdata() { + local initdb="${mobile_template_initdb:-}" + if [ -z "$initdb" ]; then + local runtime_source + runtime_source="$(ensure_host_runtime_assets)" + initdb="$runtime_source/bin/initdb" + fi + local pgdata="$scratch_root/mobile-template-pgdata" + local stamp="$pgdata/.liboliphaunt-mobile-template-v1" + + [ -x "$initdb" ] || return 1 + + local wanted + wanted="$( + printf 'initdb=%s\n' "$initdb" + shasum -a 256 "$initdb" + shasum -a 256 "$script_path" + printf 'locale=C\nencoding=UTF8\nnormalizer=mobile-template-v1\n' + printf 'walSegsizeMB=%s\n' "$wal_segsize_mb" + )" + if [ -f "$pgdata/PG_VERSION" ] && + [ -f "$stamp" ] && + [ "$wanted" = "$(cat "$stamp")" ]; then + printf '%s\n' "$pgdata" + return + fi + + rm -rf "$pgdata" + mkdir -p "$pgdata" + "$initdb" \ + -D "$pgdata" \ + -U postgres \ + --auth=trust \ + --no-sync \ + --locale=C \ + --wal-segsize="$wal_segsize_mb" \ + --encoding=UTF8 >/dev/null + normalize_template_pgdata "$pgdata" + printf '%s' "$wanted" > "$stamp" + printf '%s\n' "$pgdata" +} + +find_latest_mobile_pgdata() { + local platform="$1" + local configured="$2" + local configured_template_env="$3" + local initdb_env="$4" + if [ -n "$configured" ]; then + [ -f "$configured/PG_VERSION" ] || fail "template PGDATA is missing PG_VERSION: $configured" + printf '%s\n' "$configured" + return + fi + + if prepare_mobile_template_pgdata; then + return + fi + + if [ "$wal_segsize_mb" != "16" ]; then + fail "OLIPHAUNT_EXPO_MOBILE_WAL_SEGSIZE_MB=$wal_segsize_mb requires initdb so the mobile template PGDATA can be generated with --wal-segsize=$wal_segsize_mb; set $initdb_env or $configured_template_env" + fi + + local selected="" + local selected_mtime=0 + local version_file pgdata mtime + while IFS= read -r version_file; do + pgdata="$(dirname "$version_file")" + [ -f "$pgdata/postgresql.conf" ] || continue + mtime="$(stat_mtime "$pgdata")" + if [ "$mtime" -gt "$selected_mtime" ]; then + selected="$pgdata" + selected_mtime="$mtime" + fi + done < <(find "$root/target/liboliphaunt-pg18" -path '*/.oliphaunt-pgdata/PG_VERSION' -type f 2>/dev/null) + + [ -n "$selected" ] || fail "no template PGDATA found for $platform; run src/runtimes/liboliphaunt/native/bin/smoke-host-happy-path.sh once or set $configured_template_env" + printf '%s\n' "$selected" +} + +directory_fingerprint() { + local dir="$1" + ( + cd "$dir" + find . -type f | LC_ALL=C sort | while IFS= read -r file; do + shasum -a 256 "$file" + done + ) | shasum -a 256 | awk '{print $1}' +} + +patch_expo_example_react_native_dependency() { + local dependency_spec="$1" + node - "$example_dir/package.json" "$dependency_spec" <<'NODE' +const fs = require('node:fs'); +const [packageJson, dependencySpec] = process.argv.slice(2); +const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8')); +pkg.dependencies ??= {}; +pkg.dependencies['@oliphaunt/react-native'] = dependencySpec; +fs.writeFileSync(packageJson, `${JSON.stringify(pkg, null, 2)}\n`); +NODE +} + +write_scratch_pnpm_workspace() { + mkdir -p "$scratch_root" + cat >"$scratch_root/package.json" <"$scratch_root/pnpm-workspace.yaml" <<'YAML' +packages: + - "src/sdks/react-native" + - "src/sdks/react-native/examples/expo" + +catalog: + "@vitest/coverage-v8": ^4.1.8 + tsx: ^4.20.6 + typedoc: ^0.28.16 + typescript: ^5.9.3 + vitest: ^4.1.8 + +minimumReleaseAge: 1440 +saveWorkspaceProtocol: rolling +updateNotifier: false + +allowBuilds: + esbuild: true + msgpackr-extract: true + unrs-resolver: true +YAML + if [ "$scratch_root/pnpm-lock.yaml" != "$root/pnpm-lock.yaml" ]; then + cp "$root/pnpm-lock.yaml" "$scratch_root/pnpm-lock.yaml" + fi +} + +install_expo_example_dependencies() { + if [ "$example_dir" = "$scratch_root/src/sdks/react-native/examples/expo" ]; then + run pnpm --dir "$scratch_root" install --no-frozen-lockfile --prefer-offline --filter react-native-oliphaunt-expo + else + run pnpm --dir "$example_dir" install --no-frozen-lockfile --prefer-offline + fi +} + +install_react_native_package_dependencies() { + if [ "$package_work" = "$scratch_root/src/sdks/react-native" ]; then + run pnpm --dir "$scratch_root" install --frozen-lockfile --filter @oliphaunt/react-native + else + run pnpm --dir "$package_work" install --frozen-lockfile + fi +} + +prepare_expo_example_workspace() { + need_cmd node + need_cmd rsync + write_scratch_pnpm_workspace + mkdir -p "$scratch_root" + if [ "$example_dir" = "$source_example_dir" ]; then + return + fi + mkdir -p "$example_dir" + rsync -a --delete \ + --exclude node_modules \ + --exclude .expo \ + --exclude android \ + --exclude ios \ + --exclude dist \ + --exclude web-build \ + "$source_example_dir/" "$example_dir/" +} + +prepare_react_native_package_worktree() { + need_cmd rsync + write_scratch_pnpm_workspace + rm -rf "$package_work" + mkdir -p "$package_work" + local rsync_args=( + -a + --delete + --exclude node_modules + --exclude lib + --exclude .build + --exclude android/.gradle + --exclude android/.cxx + --exclude android/build + ) + if [ "${#react_native_package_extra_excludes[@]}" -gt 0 ]; then + rsync_args+=(${react_native_package_extra_excludes[@]+"${react_native_package_extra_excludes[@]}"}) + fi + rsync_args+=("$rn_dir/" "$package_work/") + rsync "${rsync_args[@]}" + if [ -d "$rn_dir/node_modules" ]; then + ln -s "$rn_dir/node_modules" "$package_work/node_modules" + else + install_react_native_package_dependencies + fi +} diff --git a/src/sdks/react-native/tools/mobile-build.sh b/src/sdks/react-native/tools/mobile-build.sh new file mode 100755 index 00000000..9bc2d6d4 --- /dev/null +++ b/src/sdks/react-native/tools/mobile-build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +platform="${1:-}" +case "$platform" in + android|ios) + ;; + *) + echo "usage: src/sdks/react-native/tools/mobile-build.sh [android|ios]" >&2 + exit 2 + ;; +esac + +case "$platform" in + android) + export OLIPHAUNT_EXPO_ANDROID_RUNNER="${OLIPHAUNT_EXPO_ANDROID_RUNNER:-smoke}" + export OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE="${OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE:-release}" + export OLIPHAUNT_EXPO_ANDROID_BUILD_ONLY=1 + export OLIPHAUNT_EXPO_ANDROID_SCRATCH="${OLIPHAUNT_EXPO_ANDROID_SCRATCH:-$root/target/mobile/react-native/android-build}" + exec "$root/src/sdks/react-native/tools/expo-android-runner.sh" + ;; + ios) + export OLIPHAUNT_EXPO_IOS_RUNNER="${OLIPHAUNT_EXPO_IOS_RUNNER:-smoke}" + export OLIPHAUNT_EXPO_IOS_CONFIGURATION="${OLIPHAUNT_EXPO_IOS_CONFIGURATION:-Release}" + export OLIPHAUNT_EXPO_IOS_BUILD_ONLY=1 + export OLIPHAUNT_EXPO_IOS_SCRATCH="${OLIPHAUNT_EXPO_IOS_SCRATCH:-$root/target/mobile/react-native/ios-build}" + exec "$root/src/sdks/react-native/tools/expo-ios-runner.sh" + ;; +esac diff --git a/src/sdks/react-native/tools/mobile-drill.sh b/src/sdks/react-native/tools/mobile-drill.sh new file mode 100755 index 00000000..76f3f250 --- /dev/null +++ b/src/sdks/react-native/tools/mobile-drill.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +platform="${1:-}" +drill="${2:-crash}" +case "$platform" in + android|ios) + ;; + *) + echo "usage: src/sdks/react-native/tools/mobile-drill.sh [android|ios] [crash|benchmark]" >&2 + exit 2 + ;; +esac +case "$drill" in + crash|benchmark) + ;; + *) + echo "usage: src/sdks/react-native/tools/mobile-drill.sh [android|ios] [crash|benchmark]" >&2 + exit 2 + ;; +esac + +case "$platform:$drill" in + android:crash) + export OLIPHAUNT_EXPO_ANDROID_RUNNER=crash + export OLIPHAUNT_EXPO_ANDROID_SCRATCH="${OLIPHAUNT_EXPO_ANDROID_SCRATCH:-$root/target/mobile/react-native/android-crash}" + exec "$root/src/sdks/react-native/tools/expo-android-runner.sh" + ;; + android:benchmark) + export OLIPHAUNT_EXPO_ANDROID_RUNNER=benchmark + export OLIPHAUNT_EXPO_ANDROID_SCRATCH="${OLIPHAUNT_EXPO_ANDROID_SCRATCH:-$root/target/mobile/react-native/android-benchmark}" + exec "$root/src/sdks/react-native/tools/expo-android-runner.sh" + ;; + ios:crash) + export OLIPHAUNT_EXPO_IOS_RUNNER=crash + export OLIPHAUNT_EXPO_IOS_SCRATCH="${OLIPHAUNT_EXPO_IOS_SCRATCH:-$root/target/mobile/react-native/ios-crash}" + exec "$root/src/sdks/react-native/tools/expo-ios-runner.sh" + ;; + ios:benchmark) + export OLIPHAUNT_EXPO_IOS_RUNNER=benchmark + export OLIPHAUNT_EXPO_IOS_SCRATCH="${OLIPHAUNT_EXPO_IOS_SCRATCH:-$root/target/mobile/react-native/ios-benchmark}" + exec "$root/src/sdks/react-native/tools/expo-ios-runner.sh" + ;; +esac diff --git a/src/sdks/react-native/tools/mobile-e2e.sh b/src/sdks/react-native/tools/mobile-e2e.sh new file mode 100755 index 00000000..920ccbdb --- /dev/null +++ b/src/sdks/react-native/tools/mobile-e2e.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +platform="${1:-}" +case "$platform" in + android|ios) + ;; + *) + echo "usage: src/sdks/react-native/tools/mobile-e2e.sh [android|ios]" >&2 + exit 2 + ;; +esac + +case "$platform" in + android) + artifact_dir="${OLIPHAUNT_EXPO_ANDROID_BUILD_ARTIFACT_DIR:-$root/target/mobile-build/react-native/android}" + apk="${OLIPHAUNT_EXPO_ANDROID_APK:-}" + if [ -z "$apk" ]; then + apk="$(find "$artifact_dir" -maxdepth 1 -name 'app-*.apk' -type f 2>/dev/null | head -1 || true)" + fi + [ -n "$apk" ] || { + echo "Android mobile E2E requires a built APK. Run mobile-build:android first or set OLIPHAUNT_EXPO_ANDROID_APK." >&2 + exit 1 + } + export OLIPHAUNT_EXPO_ANDROID_APK="$apk" + export OLIPHAUNT_EXPO_ANDROID_RUNNER="${OLIPHAUNT_EXPO_ANDROID_RUNNER:-smoke}" + export OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE="${OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE:-release}" + export OLIPHAUNT_EXPO_ANDROID_E2E_ONLY=1 + export OLIPHAUNT_EXPO_ANDROID_LIFECYCLE_SMOKE="${OLIPHAUNT_EXPO_ANDROID_LIFECYCLE_SMOKE:-0}" + export OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER="${OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER:-maestro}" + export OLIPHAUNT_EXPO_ANDROID_SCRATCH="${OLIPHAUNT_EXPO_ANDROID_SCRATCH:-$root/target/mobile/react-native/android-e2e}" + exec "$root/src/sdks/react-native/tools/expo-android-runner.sh" + ;; + ios) + artifact_dir="${OLIPHAUNT_EXPO_IOS_BUILD_ARTIFACT_DIR:-$root/target/mobile-build/react-native/ios}" + app="${OLIPHAUNT_EXPO_IOS_APP:-}" + if [ -z "$app" ]; then + app="$(find "$artifact_dir" -maxdepth 1 -name '*.app' -type d 2>/dev/null | head -1 || true)" + fi + [ -n "$app" ] || { + echo "iOS mobile E2E requires a built .app. Run mobile-build:ios first or set OLIPHAUNT_EXPO_IOS_APP." >&2 + exit 1 + } + export OLIPHAUNT_EXPO_IOS_APP="$app" + export OLIPHAUNT_EXPO_IOS_RUNNER="${OLIPHAUNT_EXPO_IOS_RUNNER:-smoke}" + export OLIPHAUNT_EXPO_IOS_CONFIGURATION="${OLIPHAUNT_EXPO_IOS_CONFIGURATION:-Release}" + export OLIPHAUNT_EXPO_IOS_E2E_ONLY=1 + export OLIPHAUNT_EXPO_IOS_LIFECYCLE_SMOKE="${OLIPHAUNT_EXPO_IOS_LIFECYCLE_SMOKE:-0}" + export OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER="${OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER:-maestro}" + export OLIPHAUNT_EXPO_IOS_SCRATCH="${OLIPHAUNT_EXPO_IOS_SCRATCH:-$root/target/mobile/react-native/ios-e2e}" + exec "$root/src/sdks/react-native/tools/expo-ios-runner.sh" + ;; +esac diff --git a/src/sdks/react-native/tools/mobile-extension-runtime.sh b/src/sdks/react-native/tools/mobile-extension-runtime.sh new file mode 100644 index 00000000..991af6da --- /dev/null +++ b/src/sdks/react-native/tools/mobile-extension-runtime.sh @@ -0,0 +1,707 @@ +#!/usr/bin/env bash + +# Shared helpers for local React Native mobile smoke resource packaging. +# The public selection model is exact SQL extension names. Runtime/source +# metadata remains owned by src/extensions and the liboliphaunt native build +# scripts; this file only adapts that metadata for smoke-package assertions. + +. "$root/src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh" + +oliphaunt_dev_mobile_registry_json() { + printf '%s\n' "$root/src/extensions/generated/mobile/static-registry.json" +} + +oliphaunt_dev_sdk_extension_json() { + printf '%s\n' "$root/src/extensions/generated/sdk/react-native.json" +} + +oliphaunt_dev_csv_contains() { + local needle="$1" + shift || true + local value + for value in "$@"; do + [ "$value" = "$needle" ] && return 0 + done + return 1 +} + +oliphaunt_dev_join_csv() { + local old_ifs="$IFS" + IFS="," + printf '%s' "$*" + IFS="$old_ifs" +} + +oliphaunt_dev_supported_mobile_static_extensions_csv() { + oliphaunt_mobile_static_supported_extensions | paste -sd ',' - +} + +oliphaunt_dev_normalize_mobile_extensions() { + local raw="$1" + local platform="$2" + local extension sql + local -a requested=() + while IFS= read -r extension; do + extension="$(printf '%s' "$extension" | xargs)" + [ -n "$extension" ] || continue + if ! oliphaunt_mobile_static_extension_spec "$extension" >/dev/null; then + fail "unsupported mobile extension for $platform Expo smoke: $extension (supported: $(oliphaunt_dev_supported_mobile_static_extensions_csv))" + fi + sql="$(oliphaunt_mobile_static_extension_sql_name "$extension")" + oliphaunt_dev_csv_contains "$sql" ${requested[@]+"${requested[@]}"} || requested+=("$sql") + done < <(printf '%s\n' "$raw" | tr ',' '\n') + + [ "${#requested[@]}" -gt 0 ] || return 0 + node - "$(oliphaunt_dev_sdk_extension_json)" "$(oliphaunt_dev_join_csv "${requested[@]}")" <<'NODE' +const fs = require('node:fs'); +const [metadataPath, requestedRaw] = process.argv.slice(2); +const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +const bySqlName = new Map(); +for (const row of metadata.extensions ?? []) { + if (typeof row['sql-name'] === 'string') { + bySqlName.set(row['sql-name'], row); + } +} +const ordered = []; +const seen = new Set(); +function visit(sqlName) { + if (seen.has(sqlName)) { + return; + } + const row = bySqlName.get(sqlName); + if (!row) { + throw new Error(`extension ${sqlName} is not present in generated React Native extension metadata`); + } + seen.add(sqlName); + for (const dependency of row['selected-extension-dependencies'] ?? []) { + visit(dependency); + } + ordered.push(sqlName); +} +for (const sqlName of requestedRaw.split(',').map((value) => value.trim()).filter(Boolean)) { + visit(sqlName); +} +process.stdout.write(ordered.join(',')); +NODE +} + +oliphaunt_dev_mobile_static_extensions_for_selection() { + local selected_extensions="$1" + local extension + while IFS= read -r extension; do + [ -n "$extension" ] || continue + oliphaunt_mobile_static_extension_spec "$extension" >/dev/null || + fail "selected mobile extension is not static-linkable by the native smoke lane: $extension" + done < <(printf '%s\n' "$selected_extensions" | tr ',' '\n') + printf '%s\n' "$selected_extensions" +} + +oliphaunt_dev_mobile_module_stems_for_selection() { + local selected_extensions="$1" + local extension stem + local -a stems=() + while IFS= read -r extension; do + [ -n "$extension" ] || continue + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + [ -n "$stem" ] && [ "$stem" != "-" ] || continue + oliphaunt_dev_csv_contains "$stem" ${stems[@]+"${stems[@]}"} || stems+=("$stem") + done < <(printf '%s\n' "$selected_extensions" | tr ',' '\n') + oliphaunt_dev_join_csv ${stems[@]+"${stems[@]}"} +} + +oliphaunt_dev_mobile_module_extensions_for_selection() { + local selected_extensions="$1" + local extension stem + local -a extensions=() + while IFS= read -r extension; do + [ -n "$extension" ] || continue + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + [ -n "$stem" ] && [ "$stem" != "-" ] || continue + oliphaunt_dev_csv_contains "$extension" ${extensions[@]+"${extensions[@]}"} || extensions+=("$extension") + done < <(printf '%s\n' "$selected_extensions" | tr ',' '\n') + oliphaunt_dev_join_csv ${extensions[@]+"${extensions[@]}"} +} + +oliphaunt_dev_extension_artifact_root() { + printf '%s\n' "${OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT:-${OLIPHAUNT_EXPO_MOBILE_EXTENSION_ARTIFACT_ROOT:-$root/target/extension-artifacts}}" +} + +oliphaunt_dev_prebuilt_extension_asset_paths_for_selection() { + local selected_extensions="$1" + local asset_kind="$2" + local asset_target="${3:-*}" + local artifact_root + artifact_root="$(oliphaunt_dev_extension_artifact_root)" + if [ -z "$selected_extensions" ]; then + return 0 + fi + if [ ! -d "$artifact_root" ]; then + if [ "${OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS:-0}" = "1" ]; then + fail "selected mobile extension(s) require prebuilt exact-extension artifacts, but $artifact_root does not exist" + fi + return 1 + fi + + python3 - "$root" "$artifact_root" "$selected_extensions" "$asset_kind" "$asset_target" "${OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS:-0}" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +artifact_root = Path(sys.argv[2]) +selected = [item.strip() for item in sys.argv[3].split(",") if item.strip()] +asset_kind = sys.argv[4] +asset_target = sys.argv[5] +required = sys.argv[6] == "1" + +manifests = sorted(artifact_root.glob("*/extension-artifacts.json")) +by_sql = {} +for manifest_path in manifests: + with manifest_path.open("r", encoding="utf-8") as handle: + manifest = json.load(handle) + sql_name = manifest.get("sqlName") + if not isinstance(sql_name, str) or not sql_name: + raise SystemExit(f"{manifest_path} does not declare sqlName") + if sql_name in by_sql: + raise SystemExit(f"duplicate exact-extension artifact package for SQL extension {sql_name}") + by_sql[sql_name] = (manifest_path, manifest) + +def asset_matches(asset): + if asset.get("family") != "native": + return False + if asset_target != "*" and asset.get("target") != asset_target: + return False + kind = asset.get("kind") + if asset_kind == "runtime": + return kind == "runtime" + if asset_kind == "ios-xcframework": + return kind == "ios-xcframework" + raise SystemExit(f"unknown extension asset kind: {asset_kind}") + +paths = [] +missing = [] +for sql_name in selected: + entry = by_sql.get(sql_name) + if entry is None: + missing.append(f"{sql_name}: package") + continue + manifest_path, manifest = entry + matches = [asset for asset in manifest.get("assets", []) if isinstance(asset, dict) and asset_matches(asset)] + if not matches: + missing.append(f"{sql_name}: {asset_kind} asset") + continue + if len(matches) != 1: + raise SystemExit(f"{manifest_path} must contain exactly one {asset_kind} asset for {sql_name}, got {len(matches)}") + raw_path = matches[0].get("path") + if not isinstance(raw_path, str) or not raw_path: + raise SystemExit(f"{manifest_path} {asset_kind} asset for {sql_name} does not declare path") + path = root / raw_path + if not path.is_file(): + missing.append(f"{sql_name}: {path}") + continue + paths.append(path) + +if missing: + message = "missing exact-extension artifact(s): " + ", ".join(missing) + if required: + raise SystemExit(message) + raise SystemExit(3) + +for path in paths: + print(path) +PY +} + +oliphaunt_dev_prebuilt_extension_runtime_artifacts_for_selection() { + oliphaunt_dev_prebuilt_extension_asset_paths_for_selection "$1" runtime "$2" +} + +oliphaunt_dev_prebuilt_ios_extension_framework_zips_for_selection() { + oliphaunt_dev_prebuilt_extension_asset_paths_for_selection "$1" ios-xcframework ios-xcframework +} + +oliphaunt_dev_prepare_prebuilt_mobile_runtime_resource_package() { + local platform="$1" + local runtime_source="$2" + local initdb_source="$3" + local selected_extensions="$4" + local package_root="$5" + + [ -n "$selected_extensions" ] || return 1 + + local prebuilt_runtime_artifacts + need_cmd cargo + local extension_target + case "$platform" in + iOS*) extension_target="ios-xcframework" ;; + Android*) + if [ -n "${OLIPHAUNT_EXPO_ANDROID_EXTENSION_TARGET:-}" ]; then + extension_target="$OLIPHAUNT_EXPO_ANDROID_EXTENSION_TARGET" + else + case "${OLIPHAUNT_EXPO_ANDROID_ABI:-arm64-v8a}" in + arm64-v8a) extension_target="android-arm64-v8a" ;; + x86_64) extension_target="android-x86_64" ;; + *) fail "unsupported Android extension ABI: ${OLIPHAUNT_EXPO_ANDROID_ABI:-}" ;; + esac + fi + ;; + *) extension_target="host" ;; + esac + if ! prebuilt_runtime_artifacts="$(oliphaunt_dev_prebuilt_extension_runtime_artifacts_for_selection "$selected_extensions" "$extension_target")"; then + return 1 + fi + [ -n "$prebuilt_runtime_artifacts" ] || return 1 + local module_stems + module_stems="$(oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions")" + local -a package_args=( + run -p oliphaunt --bin oliphaunt-resources --locked -- + --mode server + --output "$package_root" + --extension-target "$extension_target" + --force + --require-mobile-static-registry + ) + if [ -n "$module_stems" ]; then + package_args+=(--mobile-static-module "$module_stems") + fi + local artifact + while IFS= read -r artifact; do + [ -n "$artifact" ] || continue + package_args+=(--prebuilt-extension "$artifact") + done < <(printf '%s\n' "$prebuilt_runtime_artifacts") + + local -a resource_env=(OLIPHAUNT_INSTALL_DIR="$runtime_source") + if [ -n "$initdb_source" ]; then + resource_env+=(OLIPHAUNT_INITDB="$initdb_source") + fi + + echo "Preparing $platform runtime resources from exact-extension package artifacts: $selected_extensions" >&2 + if ! env "${resource_env[@]}" cargo "${package_args[@]}" >&2; then + if [ "${OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS:-0}" = "1" ]; then + fail "failed to prepare $platform runtime resources from exact-extension package artifacts: $selected_extensions" + fi + return 1 + fi + if [ ! -f "$package_root/oliphaunt/runtime/manifest.properties" ]; then + if [ "${OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS:-0}" = "1" ]; then + fail "prebuilt $platform runtime resource package did not produce oliphaunt/runtime/manifest.properties" + fi + return 1 + fi + touch "$package_root/.prepared" + printf '%s\n' "$package_root" +} + +oliphaunt_dev_unpack_ios_extension_frameworks_for_selection() { + local selected_extensions="$1" + local dest="$2" + [ -n "$selected_extensions" ] || return 0 + + local framework_zips + if ! framework_zips="$(oliphaunt_dev_prebuilt_ios_extension_framework_zips_for_selection "$selected_extensions")"; then + return 1 + fi + + rm -rf "$dest" + mkdir -p "$dest" + local archive + while IFS= read -r archive; do + [ -n "$archive" ] || continue + if command -v ditto >/dev/null 2>&1; then + ditto -x -k "$archive" "$dest" + else + unzip -q "$archive" -d "$dest" + fi + done < <(printf '%s\n' "$framework_zips") + + find "$dest" -type d -name '*.xcframework' -print -quit | grep -q . || + fail "selected iOS extension artifacts did not unpack any XCFrameworks into $dest" + + local expected_file actual_file missing extra + expected_file="$(mktemp "${TMPDIR:-/tmp}/oliphaunt-ios-extension-frameworks-expected.XXXXXX")" + actual_file="$(mktemp "${TMPDIR:-/tmp}/oliphaunt-ios-extension-frameworks-actual.XXXXXX")" + oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions" | + tr ',' '\n' | + sed '/^$/d' | + sed 's#^#liboliphaunt_extension_#;s#$#.xcframework#' | + LC_ALL=C sort -u >"$expected_file" + find "$dest" -type d -name 'liboliphaunt_extension_*.xcframework' -print | + while IFS= read -r framework; do + basename "$framework" + done | + LC_ALL=C sort -u >"$actual_file" + missing="$(comm -23 "$expected_file" "$actual_file" | paste -sd ',' -)" + extra="$(comm -13 "$expected_file" "$actual_file" | paste -sd ',' -)" + rm -f "$expected_file" "$actual_file" + [ -z "$missing" ] || + fail "selected iOS extension artifacts are missing XCFrameworks: $missing" + [ -z "$extra" ] || + fail "selected iOS extension artifacts unpacked unselected XCFrameworks: $extra" +} + +oliphaunt_dev_extension_file_belongs() { + local extension="$1" + local file_name="$2" + case "$file_name" in + "$extension.control"|"$extension"--*.sql) return 0 ;; + *) return 1 ;; + esac +} + +oliphaunt_dev_extension_name_for_file() { + local file_name="$1" + case "$file_name" in + *.control) printf '%s\n' "${file_name%.control}" ;; + *--*.sql) printf '%s\n' "${file_name%%--*}" ;; + *) return 1 ;; + esac +} + +oliphaunt_dev_extension_default_version() { + local control_file="$1" + [ -f "$control_file" ] || return 1 + sed -n "s/^[[:space:]]*default_version[[:space:]]*=[[:space:]]*'\\([^']*\\)'.*/\\1/p" "$control_file" | + head -1 +} + +oliphaunt_dev_installed_runtime_extension_complete() { + local extension_dir="$1" + local extension="$2" + local control_file="$extension_dir/$extension.control" + local default_version + + [ -f "$control_file" ] || return 1 + compgen -G "$extension_dir/$extension--*.sql" >/dev/null || return 1 + default_version="$(oliphaunt_dev_extension_default_version "$control_file")" + [ -z "$default_version" ] || [ -f "$extension_dir/$extension--$default_version.sql" ] +} + +oliphaunt_dev_runtime_extension_files() { + local runtime_source="$1" + local extension="$2" + local extension_dir="$runtime_source/share/postgresql/extension" + if [ -d "$extension_dir" ] && + oliphaunt_dev_installed_runtime_extension_complete "$extension_dir" "$extension"; then + find "$extension_dir" -maxdepth 1 -type f \( -name "$extension.control" -o -name "$extension--*.sql" \) -print | LC_ALL=C sort + return 0 + fi + + local source_dir + source_dir="$( + oliphaunt_mobile_static_extension_source_dir \ + "$root" \ + "$root/target/liboliphaunt-pg18/build" \ + "$extension" + )" + [ -d "$source_dir" ] || + fail "selected mobile extension source directory is missing for $extension: $source_dir" + + local control + control="$( + find "$source_dir" -type f \( -name "$extension.control" -o -name "$extension.control.in" \) -print | + LC_ALL=C sort | + head -1 + )" + [ -n "$control" ] || + fail "selected mobile extension $extension is missing a control file under $source_dir" + printf '%s\n' "$control" + + local sql_files default_version generated_sql_template default_install_sql + sql_files="$( + find "$source_dir" -type f -name "$extension--*.sql" -print | LC_ALL=C sort + )" + default_version="$(oliphaunt_dev_extension_default_version "$control" || true)" + default_install_sql="" + if [ -n "$default_version" ]; then + default_install_sql="$source_dir/sql/$extension--$default_version.sql" + generated_sql_template="$source_dir/sql/$extension.sql" + if ! printf '%s\n' "$sql_files" | grep -Fxq "$default_install_sql" && + [ -f "$generated_sql_template" ]; then + sql_files="$( + { + printf '%s\n' "$sql_files" + printf '%s\n' "$generated_sql_template" + } | sed '/^$/d' | LC_ALL=C sort + )" + fi + fi + [ -n "$sql_files" ] || + fail "selected mobile extension $extension is missing SQL files under $source_dir" + printf '%s\n' "$sql_files" +} + +oliphaunt_dev_mobile_registry_data_files() { + local mode="$1" + local selected_extensions="${2:-}" + node - "$(oliphaunt_dev_mobile_registry_json)" "$mode" "$selected_extensions" <<'NODE' +const fs = require('node:fs'); +const [registryPath, mode, selectedRaw] = process.argv.slice(2); +const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); +const selected = new Set( + selectedRaw + .split(',') + .map((value) => value.trim()) + .filter(Boolean), +); +const files = new Set(); +for (const module of registry.modules ?? []) { + const sqlName = module['sql-name']; + if (mode === 'selected' && !selected.has(sqlName)) { + continue; + } + for (const file of module['data-files'] ?? []) { + if (typeof file === 'string' && file.length > 0) { + files.add(file); + } + } +} +for (const file of [...files].sort()) { + console.log(file); +} +NODE +} + +oliphaunt_dev_hash_mobile_runtime_extension_assets() { + local runtime_source="$1" + local extensions="$2" + local extension file data_file + while IFS= read -r extension; do + [ -n "$extension" ] || continue + while IFS= read -r file; do + [ -n "$file" ] || continue + shasum -a 256 "$file" + done < <(oliphaunt_dev_runtime_extension_files "$runtime_source" "$extension") + done < <(printf '%s\n' "$extensions" | tr ',' '\n') + while IFS= read -r data_file; do + [ -n "$data_file" ] || continue + [ -f "$runtime_source/$data_file" ] || + fail "selected mobile extension data file is missing from runtime source: $runtime_source/$data_file" + shasum -a 256 "$runtime_source/$data_file" + done < <(oliphaunt_dev_mobile_registry_data_files selected "$extensions") +} + +oliphaunt_dev_copy_mobile_runtime_extension_assets() { + local runtime_source="$1" + local runtime_dest="$2" + local extensions="$3" + local extension file file_name dest_file data_file default_version + local extension_dest="$runtime_dest/share/postgresql/extension" + mkdir -p "$extension_dest" + + while IFS= read -r extension; do + [ -n "$extension" ] || continue + default_version="" + while IFS= read -r file; do + [ -n "$file" ] || continue + file_name="$(basename "$file")" + case "$file_name" in + "$extension.control"|"$extension.control.in") + default_version="$(oliphaunt_dev_extension_default_version "$file" || true)" + [ "$file_name" = "$extension.control.in" ] && file_name="$extension.control" + ;; + "$extension.sql") + if [ -n "$default_version" ]; then + file_name="$extension--$default_version.sql" + fi + ;; + esac + rsync -a "$file" "$extension_dest/$file_name" + done < <(oliphaunt_dev_runtime_extension_files "$runtime_source" "$extension") + done < <(printf '%s\n' "$extensions" | tr ',' '\n') + + while IFS= read -r data_file; do + [ -n "$data_file" ] || continue + [ -f "$runtime_source/$data_file" ] || + fail "selected mobile extension data file is missing from runtime source: $runtime_source/$data_file" + dest_file="$runtime_dest/$data_file" + mkdir -p "$(dirname "$dest_file")" + rsync -a "$runtime_source/$data_file" "$dest_file" + done < <(oliphaunt_dev_mobile_registry_data_files selected "$extensions") +} + +oliphaunt_dev_extension_runtime_stats() { + local runtime_dest="$1" + local extension="$2" + local files=0 bytes=0 file size data_file + local extension_dir="$runtime_dest/share/postgresql/extension" + while IFS= read -r -d '' file; do + size="$(wc -c <"$file" | tr -d '[:space:]')" + files=$((files + 1)) + bytes=$((bytes + size)) + done < <(find "$extension_dir" -maxdepth 1 -type f \( -name "$extension.control" -o -name "$extension--*.sql" \) -print0) + while IFS= read -r data_file; do + [ -n "$data_file" ] || continue + file="$runtime_dest/$data_file" + [ -f "$file" ] || continue + size="$(wc -c <"$file" | tr -d '[:space:]')" + files=$((files + 1)) + bytes=$((bytes + size)) + done < <(oliphaunt_dev_mobile_registry_data_files selected "$extension") + printf '%s %s\n' "$files" "$bytes" +} + +oliphaunt_dev_write_static_registry_manifest() { + local dest="$1" + local selected_extensions="$2" + local source_file="$3" + local registered="" stems="" state="not-required" source="" + stems="$(oliphaunt_dev_mobile_module_stems_for_selection "$selected_extensions")" + if [ -n "$stems" ]; then + state="complete" + source="oliphaunt_static_registry.c" + registered="$(oliphaunt_dev_mobile_module_extensions_for_selection "$selected_extensions")" + rsync -a "$source_file" "$dest/$source" + fi + + { + printf 'packageLayout=oliphaunt-static-registry-v1\n' + printf 'abiVersion=1\n' + printf 'state=%s\n' "$state" + printf 'source=%s\n' "$source" + printf 'registeredExtensions=%s\n' "$registered" + printf 'pendingExtensions=\n' + printf 'nativeModuleStems=%s\n' "$stems" + printf 'modules=%s\n' "$stems" + local extension stem + while IFS= read -r extension; do + [ -n "$extension" ] || continue + stem="$(oliphaunt_mobile_static_extension_module_stem "$extension")" + [ -n "$stem" ] && [ "$stem" != "-" ] || continue + printf 'module.%s.extension=%s\n' "$stem" "$extension" + printf 'module.%s.symbolPrefix=%s\n' "$stem" "$(oliphaunt_static_symbol_prefix "$stem")" + printf 'module.%s.sqlSymbols=\n' "$stem" + done < <(printf '%s\n' "$selected_extensions" | tr ',' '\n') + } >"$dest/manifest.properties" +} + +oliphaunt_dev_assert_runtime_extension_tree() { + local runtime_dest="$1" + local selected_extensions="$2" + local platform="$3" + local extension file_name extension_name matched_sql control_file default_version + local extension_dir="$runtime_dest/share/postgresql/extension" + + while IFS= read -r extension; do + [ -n "$extension" ] || continue + control_file="$extension_dir/$extension.control" + [ -f "$control_file" ] || + fail "$platform runtime is missing selected $extension extension control file" + matched_sql=0 + if compgen -G "$extension_dir/$extension--*.sql" >/dev/null; then + matched_sql=1 + fi + [ "$matched_sql" = "1" ] || + fail "$platform runtime is missing selected $extension extension SQL files" + default_version="$(oliphaunt_dev_extension_default_version "$control_file")" + if [ -n "$default_version" ] && [ ! -f "$extension_dir/$extension--$default_version.sql" ]; then + fail "$platform runtime is missing selected $extension extension install script for default_version=$default_version" + fi + done < <(printf '%s\n' "$selected_extensions" | tr ',' '\n') + + if [ ! -d "$extension_dir" ]; then + [ -z "$selected_extensions" ] || fail "$platform runtime extension directory is missing" + return 0 + fi + while IFS= read -r -d '' file; do + file_name="$(basename "$file")" + extension_name="$(oliphaunt_dev_extension_name_for_file "$file_name" || true)" + [ -n "$extension_name" ] || continue + if ! printf '%s\n' "$selected_extensions" | tr ',' '\n' | grep -Fxq "$extension_name"; then + fail "$platform runtime included unselected PostgreSQL extension asset: $file_name" + fi + done < <(find "$extension_dir" -maxdepth 1 -type f -print0) +} + +oliphaunt_dev_assert_runtime_data_files() { + local runtime_dest="$1" + local selected_extensions="$2" + local platform="$3" + local selected_file unselected_file + local -a selected_files=() + + while IFS= read -r selected_file; do + [ -n "$selected_file" ] || continue + selected_files+=("$selected_file") + [ -e "$runtime_dest/$selected_file" ] || + fail "$platform runtime is missing selected extension data file: $selected_file" + done < <(oliphaunt_dev_mobile_registry_data_files selected "$selected_extensions") + + while IFS= read -r unselected_file; do + [ -n "$unselected_file" ] || continue + local selected=0 + for selected_file in ${selected_files[@]+"${selected_files[@]}"}; do + if [ "$selected_file" = "$unselected_file" ]; then + selected=1 + break + fi + done + [ "$selected" = "0" ] || continue + if [ -e "$runtime_dest/$unselected_file" ]; then + fail "$platform runtime included unselected extension data file: $unselected_file" + fi + done < <(oliphaunt_dev_mobile_registry_data_files all) +} + +oliphaunt_dev_assert_runtime_file_list() { + local selected_extensions="$1" + local platform="$2" + local file_list + file_list="$(mktemp "${TMPDIR:-/tmp}/oliphaunt-runtime-file-list.XXXXXX")" + cat >"$file_list" + if ! node - "$(oliphaunt_dev_mobile_registry_json)" "$selected_extensions" "$platform" "$file_list" <<'NODE' +const fs = require('node:fs'); +const [registryPath, selectedRaw, platform, fileListPath] = process.argv.slice(2); +const selected = new Set(selectedRaw.split(',').map((value) => value.trim()).filter(Boolean)); +const lines = fs.readFileSync(fileListPath, 'utf8').split(/\r?\n/).filter(Boolean); +const extensionFiles = lines.filter((line) => line.includes('/runtime/files/share/postgresql/extension/')); +const byExtension = new Map(); +for (const line of extensionFiles) { + const fileName = line.split('/').pop(); + let sqlName = ''; + let kind = ''; + if (fileName.endsWith('.control')) { + sqlName = fileName.slice(0, -'.control'.length); + kind = 'control'; + } else if (/--.*\.sql$/.test(fileName)) { + sqlName = fileName.split('--')[0]; + kind = 'sql'; + } else { + continue; + } + if (!selected.has(sqlName)) { + throw new Error(`${platform} app includes unselected PostgreSQL extension asset: ${line}`); + } + const state = byExtension.get(sqlName) ?? {control: false, sql: false}; + state[kind] = true; + byExtension.set(sqlName, state); +} +for (const sqlName of selected) { + const state = byExtension.get(sqlName); + if (!state?.control) { + throw new Error(`${platform} app is missing selected ${sqlName} extension control file`); + } + if (!state?.sql) { + throw new Error(`${platform} app is missing selected ${sqlName} extension SQL file`); + } +} + +const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); +for (const module of registry.modules ?? []) { + const sqlName = module['sql-name']; + for (const dataFile of module['data-files'] ?? []) { + const present = lines.some((line) => line.endsWith(`/runtime/files/${dataFile}`)); + if (selected.has(sqlName) && !present) { + throw new Error(`${platform} app is missing selected ${sqlName} extension data file: ${dataFile}`); + } + if (!selected.has(sqlName) && present) { + throw new Error(`${platform} app includes unselected ${sqlName} extension data file: ${dataFile}`); + } + } +} +NODE + then + rm -f "$file_list" + return 1 + fi + rm -f "$file_list" +} diff --git a/src/sdks/react-native/tsconfig.build.commonjs.json b/src/sdks/react-native/tsconfig.build.commonjs.json new file mode 100644 index 00000000..2f2f7550 --- /dev/null +++ b/src/sdks/react-native/tsconfig.build.commonjs.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "customConditions": [], + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "module": "Node16", + "moduleResolution": "Node16", + "noEmit": false, + "outDir": "lib/commonjs" + }, + "exclude": ["src/__tests__/**", "lib", "node_modules"] +} diff --git a/src/sdks/react-native/tsconfig.build.json b/src/sdks/react-native/tsconfig.build.json new file mode 100644 index 00000000..34573c6a --- /dev/null +++ b/src/sdks/react-native/tsconfig.build.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.build.types.json" +} diff --git a/src/sdks/react-native/tsconfig.build.module.json b/src/sdks/react-native/tsconfig.build.module.json new file mode 100644 index 00000000..8e7c0dc2 --- /dev/null +++ b/src/sdks/react-native/tsconfig.build.module.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": false, + "outDir": "lib/module" + }, + "exclude": ["src/__tests__/**", "lib", "node_modules"] +} diff --git a/src/sdks/react-native/tsconfig.build.types.json b/src/sdks/react-native/tsconfig.build.types.json new file mode 100644 index 00000000..469225e8 --- /dev/null +++ b/src/sdks/react-native/tsconfig.build.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "lib/typescript" + }, + "exclude": ["src/__tests__/**", "lib", "node_modules"] +} diff --git a/src/sdks/react-native/tsconfig.json b/src/sdks/react-native/tsconfig.json new file mode 100644 index 00000000..e55f8e21 --- /dev/null +++ b/src/sdks/react-native/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@react-native/typescript-config", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "moduleResolution": "Bundler", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "outDir": "lib/typescript", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["lib", "node_modules"] +} diff --git a/src/sdks/react-native/typedoc.json b/src/sdks/react-native/typedoc.json new file mode 100644 index 00000000..1d49110a --- /dev/null +++ b/src/sdks/react-native/typedoc.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src/index.ts"], + "exclude": ["src/__tests__/**"], + "excludePrivate": true, + "excludeProtected": true, + "gitRevision": "main", + "json": "../../target/docs/generated/api/react-native/typedoc.json", + "name": "Oliphaunt React Native SDK", + "out": "../../target/docs/generated/api/react-native/html", + "plugin": [], + "readme": "README.md", + "tsconfig": "tsconfig.build.json" +} diff --git a/src/sdks/rust/.gitignore b/src/sdks/rust/.gitignore new file mode 100644 index 00000000..042776aa --- /dev/null +++ b/src/sdks/rust/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target/ diff --git a/src/sdks/rust/ARCHITECTURE.md b/src/sdks/rust/ARCHITECTURE.md new file mode 100644 index 00000000..ff0a948d --- /dev/null +++ b/src/sdks/rust/ARCHITECTURE.md @@ -0,0 +1,147 @@ +# Native Rust SDK Architecture + +`oliphaunt` is the clean native path for the Rust SDK. It is not a +compatibility layer over the current WASIX runtime and it should not grow +WASIX-specific fallback policy. + +## Runtime Modes + +- `NativeDirect` is the embedded default. It owns one physical PostgreSQL + backend session and serializes all work through an owner executor. Handles are + cloneable, but they share the same physical session. +- `NativeBroker` is the robust desktop shape. A helper process owns database + roots, workers, root locks, recovery, upgrades, and extension loading. +- `NativeServer` is the true multi-client mode. It is the only mode that should + advertise independent PostgreSQL client sessions or support general-purpose + pools. + +The SDK must not fake independent Postgres sessions in direct mode. + +## Rust Boundary + +The public Rust boundary is `OliphauntBuilder -> Oliphaunt`. Concrete PostgreSQL +bindings implement `NativeRuntime` and return an `EngineSession`. The SDK owns +configuration, capabilities, extension selection, and serialized execution; +the runtime owns PostgreSQL lifecycle and protocol execution. + +`OliphauntRuntime` is the concrete runtime for the native C ABI. It loads +`liboliphaunt` from `LIBOLIPHAUNT_PATH` or an explicit path and serves +`NativeDirect`. + +`NativeBrokerRuntime` supervises `oliphaunt-broker` worker processes. Each +worker owns one root and one direct backend, and the shared Rust runtime admits +up to `.broker_max_roots(n)` active roots. This keeps broker crash/process +isolation real instead of simulating it inside the client process, while still +supporting multi-root desktop apps without violating the native direct +process-global backend constraint. On Unix platforms the SDK uses a +Unix-domain socket by default to keep broker traffic off the TCP stack; set +`OLIPHAUNT_BROKER_TRANSPORT=tcp` only when debugging or forcing the +portable fallback path. The SDK generates a per-session authentication token, +passes it to the helper through the child environment, and sends it as the first +IPC frame before protocol or control messages are accepted. Cancellation uses a +separate authenticated IPC endpoint, so a cancel request is not blocked behind +the query response stream. The parent bootstrap policy is passed to the helper, +so `ExistingOnly` and tooling-only `initdb` behave the same way in broker mode +as they do in direct mode. If the helper exits between operations, the session +relaunches a fresh helper against the same root before the next operation. If a +helper dies while a request is in flight, that request returns an error rather +than being replayed with unknown commit state; later operations can relaunch and +recover through PostgreSQL WAL recovery. + +`NativeServerRuntime` starts a real local PostgreSQL server process, connects to +it using the PostgreSQL v3 startup/query protocol, and exposes a connection +string. This is the only mode that advertises independent sessions. SDK-owned +query cancellation uses PostgreSQL's native CancelRequest packet with the +`BackendKeyData` returned during startup. + +Internally, the `liboliphaunt` runtime is split so the C boundary does not become a +catch-all module: + +- `oliphaunt/mod.rs`: runtime/session behavior and `EngineSession` + implementation. +- `oliphaunt/ffi.rs`: ABI structs, symbol loading, and native library + resolution. +- `oliphaunt/root.rs`: root locking, PGDATA path preparation, and temporary-root + cleanup. +- `oliphaunt/root/runtime.rs`: profile-aware runtime-cache orchestration. +- `oliphaunt/root/runtime/locate.rs`: native PostgreSQL install and embedded + module discovery. +- `oliphaunt/root/runtime/install.rs`: selected runtime asset installation for + direct/broker and server profiles. +- `oliphaunt/root/runtime/cache_key.rs`: runtime cache key, manifest, and + validation logic. +- `oliphaunt/root/files.rs`: deterministic filesystem copying, APFS clone + fallback behavior, directory utilities, and cleanup helpers. +- `oliphaunt/root/fingerprint.rs`: content fingerprinting used by runtime and + template cache keys. +- `oliphaunt/root/extensions.rs`: selected extension SQL/data/module + materialization and filters that keep unselected extension assets invisible. +- `oliphaunt/root/template.rs`: packaged-template PGDATA cache construction, + `initdb` bootstrap, and atomic root hydration. +- `broker.rs` and `ipc.rs`: helper process supervision and local IPC. +- `server.rs` and `pgwire.rs`: local PostgreSQL server lifecycle and raw wire + protocol client. + +## Concurrency + +Direct mode uses an owner thread. `Oliphaunt` handles are cheap clones that send +commands to that owner. `SessionPin` reserves the physical session for +transaction or session-state-sensitive work, and unpinned work is rejected while +the pin is active. + +`Transaction` is built on `SessionPin`: it sends `BEGIN`, keeps all work pinned, +and releases the physical session on `COMMIT` or `ROLLBACK`. + +Close is a lifecycle boundary, not another ordinary queued query. When close +begins, new and already queued non-close work is rejected with `EngineStopped`. +If backend work is active, close waits for that work to finish before queueing +the runtime close or direct-mode logical detach. Query interruption is explicit +through `Oliphaunt::cancel()`; idle close does not send a spurious cancel. + +## Storage + +The live database is a root directory. The SDK models root locking, bootstrap +strategy, and backup formats explicitly. + +`BootstrapStrategy::PackagedTemplate` is the production first-open path. New +roots are hydrated from a content-keyed base PGDATA template before the engine is +entered, which avoids paying `initdb` on every fresh open. The template is built +with the standalone PostgreSQL server runtime, then copied into roots with +copy-on-write cloning on macOS when the filesystem supports it. The diagnostic +environment variable `OLIPHAUNT_PGDATA_COPY_MODE=copy` forces physical +byte copies when investigating first-write copy-on-write effects. Direct and +broker execution still use the liboliphaunt-embedded runtime profile after the root +exists. + +Runtime resources are also content-keyed. Direct/broker runtimes use +liboliphaunt-linked extension modules; server runtimes use standalone PostgreSQL +extension modules. Both profiles share the same manifest-gated +`share/postgresql` filtering so unselected extensions stay invisible. + +Physical backup follows PostgreSQL's online backup protocol instead of copying a +live data directory blindly. Direct and server mode call `pg_backup_start`, +archive the `pgdata` tree with transient files omitted, then append the +`backup_label` and `tablespace_map` returned by `pg_backup_stop`. The `pg_wal` +contents are collected after `pg_backup_stop` so the archive carries the WAL +needed to recover a same-version clone. Broker mode delegates to the direct +runtime inside the helper process. Logical SQL backup is server-only and uses +packaged `pg_dump`. + +`initdb` remains an explicit tooling fallback through +`BootstrapStrategy::InitdbToolingOnly`. `ExistingOnly` refuses to open an empty +or partial root in direct, broker, and server mode. + +## Extensions + +Extensions are opt-in exact PostgreSQL extension names. `CREATE EXTENSION` +should only succeed when the selected extension assets are present and, on +mobile, when required static registry rows are linked. Static registry loading is +the portable mobile path; signed dynamic desktop loading is a separate future +capability and not a grouping abstraction. + +## Performance Contract + +Native implementations should benchmark direct protocol RTT, typed query +overhead, batched writes, large result streaming, cold/warm open, package size, +memory, backup/restore, SQLite comparison, and native PostgreSQL controls before +becoming a default. diff --git a/src/sdks/rust/CHANGELOG.md b/src/sdks/rust/CHANGELOG.md new file mode 100644 index 00000000..18749e6a --- /dev/null +++ b/src/sdks/rust/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + + +## [0.1.0] - 2026-06-01 + +### Changed + +- Initial Rust SDK release lane. +- Rename project to Oliphaunt +- Organize polyglot release tooling diff --git a/src/sdks/rust/Cargo.toml b/src/sdks/rust/Cargo.toml new file mode 100644 index 00000000..4bbd6ec0 --- /dev/null +++ b/src/sdks/rust/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "oliphaunt" +version = "0.1.0" +edition = "2024" +rust-version = "1.93" +description = "Native-first Rust SDK surface for embedded PostgreSQL through liboliphaunt." +readme = "README.md" +repository.workspace = true +homepage.workspace = true +license = "MIT AND Apache-2.0 AND PostgreSQL" + +[lib] +name = "oliphaunt" +path = "src/lib.rs" + +[features] +default = [] +extension-download = ["dep:ureq"] +extension-signing = ["dep:ed25519-dalek"] + +[package.metadata.oliphaunt] +broker-helper = "oliphaunt-broker" +broker-version = "0.1.0" + +[[bin]] +name = "oliphaunt-resources" +path = "src/bin/package_resources.rs" + +[[bin]] +name = "oliphaunt-extension-artifact" +path = "src/bin/extension_artifact.rs" + +[[bin]] +name = "oliphaunt-extension-index" +path = "src/bin/extension_index.rs" + +[dependencies] +crossbeam-channel = "0.5" +ed25519-dalek = { version = "2.2", default-features = false, features = ["alloc"], optional = true } +flate2 = "1" +fs2 = "0.4" +getrandom = "0.3" +libloading = "0.8" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" +tar = "0.4" +toml = "0.9" +ureq = { version = "2.12", default-features = false, features = ["tls"], optional = true } +zip = { version = "2", default-features = false, features = ["deflate"] } +zstd = { version = "0.13", default-features = false } + +[dev-dependencies] +futures-util = "0.3" +serde_json = "1" +sqlx = { version = "0.8", default-features = false, features = [ + "postgres", + "runtime-tokio", +] } +tokio = { version = "1", features = ["rt", "time"] } +tokio-postgres = "0.7" diff --git a/src/sdks/rust/README.md b/src/sdks/rust/README.md new file mode 100644 index 00000000..95a25683 --- /dev/null +++ b/src/sdks/rust/README.md @@ -0,0 +1,371 @@ +# oliphaunt + +## Install + +Add the Rust SDK like any other Cargo dependency: + +```toml +[dependencies] +oliphaunt = "0.1.0" +``` + +For Tauri or native Rust apps that use `NativeDirect`, resolve the matching +native runtime assets during your app packaging step: + +```bash +cargo install oliphaunt --version 0.1.0 +oliphaunt-resources \ + --resolve-release-assets \ + --liboliphaunt-native-version 0.1.0 \ + --release-target linux-x64-gnu \ + --extension vector \ + --output target/oliphaunt-resources \ + --force +``` + +`--extension` accepts exact PostgreSQL extension names only. If you select +`vector`, the resolver downloads and verifies the base runtime plus the +`vector` artifact and its mandatory dependencies; it does not download unrelated +extensions. + +## Compatibility + +| SDK | Native core | Distribution | +| --- | --- | --- | +| `oliphaunt` `0.1.0` | `liboliphaunt` `0.1.0` | crates.io plus checksum-covered GitHub release assets | + +The resolver verifies `liboliphaunt--release-assets.sha256` before it +uses downloaded runtime or extension artifacts. + +Apps that use `NativeBroker` also resolve the matching helper binary during +packaging: + +```bash +oliphaunt-resources \ + --resolve-broker-release-assets \ + --broker-version 0.1.0 \ + --broker-release-target linux-x64-gnu \ + --output target/oliphaunt-broker \ + --force +``` + +Set `OLIPHAUNT_BROKER_ASSET_DIR=target/oliphaunt-broker` for packaged apps when +the helper is not installed next to the application executable. The broker +resolver verifies `oliphaunt-broker--release-assets.sha256` before +extracting the selected helper archive. + +## Quickstart + +```text +use oliphaunt::Oliphaunt; + +# async fn demo() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .extension(oliphaunt::Extension::Vector) + .open() + .await?; + +let result = db.query("SELECT 1::text AS value").await?; +assert_eq!(result.get_text(0, "value")?, Some("1")); + +db.close().await?; +# Ok(()) +# } +``` + +This crate is the native-first Rust SDK path for Oliphaunt. Rust is a product SDK +surface for Tauri and Rust desktop apps, not an internal implementation detail. +It is intentionally separate from the existing WASIX-oriented `oliphaunt-wasix` +API so the final shape can be designed around native PostgreSQL instead of +compatibility constraints. + +The public model is: + +- `NativeDirect`: in-process, one physical PostgreSQL session, serialized by an + owner executor. +- `NativeBroker`: helper-process mode that isolates roots from the application + process. A shared broker runtime supervises one worker process per root and + admits up to `.broker_max_roots(n)` active roots. +- `NativeServer`: PostgreSQL-compatible local server mode for true independent + client sessions. + +`EngineCapabilities::reopenable` is true for all modes, but the semantics are +mode-specific and exposed explicitly. `NativeDirect` sets +`same_root_logical_reopen=true`, `root_switchable=false`, and +`crash_restartable=false`: it can logically close and reopen the same resident +root in the same process, but it remains single-root and process-global. +`NativeBroker` is process-isolated, root-switchable, and crash-restartable for +its helper process. `NativeServer` is root-switchable and exposes independent +client sessions, but the current SDK-owned server handle does not restart a +crashed server process in place. + +The crate defines the SDK contract, configuration model, exact-extension model, +typed query helpers, structured PostgreSQL errors, startup user/database +identity, capabilities, and owner-thread execution boundary. Concrete +PostgreSQL 18 bindings plug in through `NativeRuntime`. + +## Runtime Footprint + +`OliphauntBuilder::runtime_footprint(...)` selects the PostgreSQL startup +footprint before the backend starts: + +- `RuntimeFootprintProfile::Throughput`: current throughput lane + (`shared_buffers=128MB`, `wal_buffers=4MB`, `min_wal_size=80MB`). +- `RuntimeFootprintProfile::BalancedMobile`: one-session mobile defaults with + lower server slot counts, `shared_buffers=32MB`, `min_wal_size=32MB`, and + PG18 sync I/O. +- `RuntimeFootprintProfile::SmallMobile`: the same one-session shape with + `shared_buffers=8MB`, smaller work memory, `min_wal_size=32MB`, and PG18 + sync I/O. + +The current PG18 artifact uses 16MB WAL segments, so `min_wal_size` below 32MB +is not a valid runtime GUC override. Testing 8MB/16MB WAL minima requires a +separate PostgreSQL build with a smaller WAL segment size. + +`OliphauntBuilder::startup_guc(name, value)` appends explicit PostgreSQL `-c` +overrides after the selected footprint and durability profile, so benchmark +matrices can test individual GUCs without adding new API for each PostgreSQL +knob. Server mode still appends its configured `max_client_sessions` as +`max_connections`, because that API is the server-mode session contract. + +Swift, Kotlin, and React Native should preserve this contract where their +platforms can do so honestly: + +- Swift owns iOS and macOS runtime behavior. +- Kotlin owns Android runtime behavior. +- React Native owns the TypeScript and TurboModule layer while delegating + runtime behavior to those platform SDKs. + +Parity gaps must be explicit unsupported errors with a documented reason, not +silent API drift. + +The default builder runtime matches the selected mode: + +- `NativeDirect` loads the in-process C ABI through `OliphauntRuntime`. +- `NativeBroker` starts the packaged `oliphaunt-broker` helper and talks + to it over local IPC. Unix platforms use Unix-domain sockets by default; + `OLIPHAUNT_BROKER_TRANSPORT=tcp` forces the portable TCP fallback. The + helper requires a generated per-session authentication frame before accepting + protocol, backup, checkpoint, or close requests. Builder bootstrap policy is + passed through to the helper, so `.existing_only()` remains strict in broker + mode. Multi-root broker apps use one isolated helper per active root, bounded + by `.broker_max_roots(n)`. +- `NativeServer` starts a real local PostgreSQL server process and exposes a + connection string. + +The crate does not depend on `oliphaunt-wasix`; native PostgreSQL lifecycle, +runtime resources, and exact extension materialization are owned here. + +## Extensions + +Extensions are opt-in by exact PostgreSQL SQL extension name. Rust callers use +`.extension(Extension::Vector)` or `.extension(Extension::PgTrgm)`, and package +tools use `--extension vector,pg_trgm`. Selecting `vector` includes `vector` +only, plus mandatory dependencies declared by the manifest. For example, +`earthdistance` materializes `cube` because PostgreSQL requires it. + +The public `NATIVE_EXTENSION_MANIFEST` records supported PG18 extension rows, +including SQL/control assets, module requirements, dependencies, smoke SQL +strategy, direct-C-ABI/broker/server coverage, mobile static-link status, and +artifact policy. Extensions that require preload hooks, currently ParadeDB +`pg_search`, are reflected in startup automatically through +`shared_preload_libraries` when selected. + +Release readiness is explicit and target-specific. +`oliphaunt-resources --list-extensions` prints a TSV catalog without requiring a +local PostgreSQL build; `desktop_prebuilt=yes` means the extension is available +from Oliphaunt desktop release artifacts, and `mobile_prebuilt=yes` means +iOS/Android apps can include it without compiling extension source. A row may +be known first-party metadata without being release-ready for every target. +PostGIS is visible in the catalog but remains `desktop_prebuilt=no` and +`mobile_prebuilt=no` while its native/mobile target files are candidate. +`pgcrypto` is mobile-prebuilt through the first-party OpenSSL static +`libcrypto` archive. `uuid-ossp` is mobile-prebuilt through the first-party +portable UUID static `libuuid` archive. `mobile_prebuilt=no` remains a hard +release boundary for extensions outside the validated set. + +Third-party extensions use the same exact-selection contract through prebuilt +artifacts. `oliphaunt-resources --prebuilt-extension ` consumes an +unpacked directory, `.tar`, or `.tar.zst` with an +`oliphaunt-extension-artifact-v1` manifest plus a `files/` PostgreSQL runtime +tree that already contains the extension's control, SQL, data, and native +module files. The app project links/copies binary artifacts only; it does not +build PostgreSQL or extension source. Prebuilt artifacts cannot override +built-in release-ready names, and their dependencies must resolve to exact +built-in extensions or other provided prebuilt artifacts. +`oliphaunt-extension-artifact` creates the same artifact schema from already +built runtime files, copying only the exact selected extension's declared +control, SQL, data, and native module files into a directory, `.tar`, or +`.tar.zst` artifact. For mobile module extensions, +`--mobile-static-archive :` stores the selected iOS/Android +static archive inside the artifact so app projects link binary artifacts only. +Dependency-backed modules can also pass +`--mobile-static-dependency-archive ::`; runtime +resources copy those archives into the static-registry package, Android SDKs +link them from the selected archive root, and the iOS helper packages them as +`liboliphaunt_dependency_.xcframework`. +For distribution, publish an `oliphaunt-extension-artifact-index-v1` TOML file +with exact `sql_name`, `target`, dependency, preload, mobile-prebuilt, relative +artifact `path`, optional artifact `url`, mobile static archive target names, +`sha256`, and `bytes` rows. +`oliphaunt-resources --list-extensions --extension-index +--extension-target ` lists built-in plus signed external exact +extension availability without downloading artifacts or building source. +`oliphaunt-extension-index --output +vendor/oliphaunt-extensions.toml --target macos-arm64 --artifact +vendor/acme_ext-macos-arm64.tar.zst --base-url +https://cdn.example.com/oliphaunt/extensions/macos-arm64 +--signing-key-file acme-release-2026q2:keys/acme-extension-index.ed25519` +writes that index from validated artifact archives and signs the exact index +bytes into `.sig`. `oliphaunt-resources --extension acme_ext +--extension-index vendor/oliphaunt-extensions.toml --extension-target +macos-arm64 --extension-cache ~/.cache/oliphaunt/extensions +--trusted-extension-index-key-file +acme-release-2026q2:keys/acme-extension-index.ed25519.pub` verifies the signed +index, then verifies the indexed artifact and resolves transitive exact +dependencies before building the SDK runtime resources. Local sidecar artifacts +are preferred. Missing URL-backed artifacts are downloaded into the cache, then +byte-count, SHA-256, and manifest verified before use. HTTPS downloads are +behind the `extension-download` tooling feature, and signed-index verification +is behind `extension-signing`, so embedded Rust/Tauri apps do not compile +HTTP/TLS or signing code just because they use the SDK library. + +Mobile static registries are intentionally marked per generated resource +package. SQL-only extensions do not need static registration. Module-backed +extensions remain `pending` until the selected extension has an Oliphaunt +prebuilt mobile artifact and the platform package declares its exact module +stem. +Runtime resources also record package-level `mobileStaticRegistryState` metadata; +use `oliphaunt-resources --require-mobile-static-registry` for iOS/Android +release packaging. Platform package builds that actually link static extension +registry rows declare exact module stems with `--mobile-static-module `; +unknown, unselected, or non-mobile-ready stems are rejected. Complete mobile +packages also emit +`static-registry/oliphaunt_static_registry.c`, generated from selected +extension SQL assets, copied selected third-party archives under +`static-registry/archives`, plus `mobileStaticRegistrySource` in the runtime +manifest. Swift/Kotlin/React Native bridges register that generated table before +`oliphaunt_init`. Selected extension preload requirements are recorded as +`sharedPreloadLibraries` in the generated runtime manifest. + +The runtime-resource CLI only accepts exact release-ready extension names. External +candidate metadata, such as pgGraph and ParadeDB, remains internal until the +extension has pinned artifacts, redistribution clearance, and direct, broker, +server, restart, backup, restore, and mobile static-registry evidence. + +## Backup + +`BackupRequest::physical_archive()` is the same-version clone/export path for +native roots. Direct and server mode enter PostgreSQL backup mode with +`pg_backup_start`, archive the `pgdata` tree, then write PostgreSQL's generated +`backup_label` and `tablespace_map` from `pg_backup_stop` into the archive. WAL +is collected after `pg_backup_stop`, making the archive self-contained for +same-version restore. Broker mode forwards the same operation through its helper +process. + +`Oliphaunt::restore(RestoreRequest::physical_archive(...))` restores those +physical archives through the SDK instead of exposing tar layout details to +applications. Restore is staged in a sibling directory, rejects path traversal +and unsafe archive entries, extracts only through validated canonical archive +paths, validates archive tree shape before writing staging files, validates the +required `pgdata` recovery files, and refuses to overwrite an existing root +unless the request uses `replace_existing()`. Physical +archives are deliberately concrete and +single-root: they contain only regular files and directories under `pgdata`, so +links, device nodes, FIFOs, sockets, sparse/special tar records, and external +tablespace indirection fail instead of producing a non-portable mobile/Desktop +artifact. + +`BackupRequest::sql()` is available in `NativeServer`, where the SDK can run the +packaged `pg_dump` against the real local server connection string. Direct mode +does not fake a logical dump path because it intentionally exposes one raw +embedded protocol session, not a general server endpoint. + + +```rust +use oliphaunt::{BackupRequest, Oliphaunt, RestoreRequest}; + +# async fn backup_restore() -> oliphaunt::Result<()> { +let source = Oliphaunt::builder() + .path(".liboliphaunt-source") + .native_direct() + .open() + .await?; + +let archive = source.backup(BackupRequest::physical_archive()).await?; +source.close().await?; + +Oliphaunt::restore(RestoreRequest::physical_archive( + ".liboliphaunt-restored", + archive, +)) +.await?; +# Ok(()) +# } +``` + +## Capability Honesty + +Direct mode is a serialized single physical PostgreSQL session. Broker mode is +process-isolated but still serializes one physical backend session per opened +root. Server mode is the only mode that advertises independent sessions. +`Oliphaunt` is cloneable as an SDK handle, but every clone shares the same owner +executor, session pin, cancellation handle, and close state. Cloning a handle +never creates an independent PostgreSQL connection; in `NativeServer`, true +independent sessions come from the exposed connection string and normal +PostgreSQL clients. Work accepted by the shared executor runs FIFO on that +single owner, so cloned handles do not interleave direct, broker, or SDK-owned +server protocol calls inside one physical session. + +`NativeDirect` advertises `protocol_stream` when the loaded C ABI exports +`oliphaunt_exec_protocol_stream`. `NativeBroker` forwards those native chunks over +IPC and also advertises streaming. `NativeServer` streams complete PostgreSQL +wire frames from the local server connection. + +All three modes expose `Oliphaunt::cancel()` for the SDK-owned active query. +Direct mode calls the native C ABI cancellation hook, broker mode uses a +separate authenticated cancel IPC endpoint, and server mode sends PostgreSQL's +native CancelRequest packet. + +`Oliphaunt::close()` rejects queued work with `EngineStopped`. For native direct it +logically detaches the SDK handle and keeps the resident PostgreSQL backend +alive for same-root reopen; terminal PostgreSQL shutdown is not part of ordinary +SDK close. + + +```rust +use oliphaunt::Oliphaunt; + +# async fn demo() -> oliphaunt::Result<()> { +let db = Oliphaunt::builder() + .path(".oliphaunt") + .native_direct() + .open() + .await?; + +let result = db.query("SELECT 1::text AS value").await?; +assert_eq!(result.get_text(0, "value")?, Some("1")); + +let parameterized = db + .query_params( + "SELECT ($1::int4 + $2::int4)::text AS sum", + [1_i32, 41_i32], + ) + .await?; +assert_eq!(parameterized.get_text(0, "sum")?, Some("42")); + +db.execute("CREATE TABLE items(id bigint PRIMARY KEY)").await?; + +db.with_transaction(async |tx| { + tx.query_params("INSERT INTO items VALUES ($1)", [1_i64]) + .await?; + Ok(()) +}) +.await?; + +db.close().await?; +# Ok(()) +# } +``` diff --git a/src/sdks/rust/moon.yml b/src/sdks/rust/moon.yml new file mode 100644 index 00000000..0572e458 --- /dev/null +++ b/src/sdks/rust/moon.yml @@ -0,0 +1,231 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "oliphaunt-rust" +language: "rust" +layer: "library" +stack: "systems" +tags: ["sdk", "rust", "tauri", "native", "release-product"] +dependsOn: + - "liboliphaunt-native" + - id: "shared-fixtures" + scope: "build" + +project: + title: "Oliphaunt Rust SDK" + description: "Canonical Rust SDK for native embedded PostgreSQL in Tauri and Rust desktop apps." + owner: "oliphaunt" + release: + component: "oliphaunt-rust" + packagePath: "src/sdks/rust" + +owners: + defaultOwner: "@oliphaunt/sdk-rust" + paths: + "**/*.rs": ["@oliphaunt/sdk-rust"] + "Cargo.toml": ["@oliphaunt/sdk-rust"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash src/sdks/rust/tools/check-sdk.sh check-static" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/check" + deps: + - "liboliphaunt-native:check" + - "shared-contracts:check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/clippy.toml" + - "/rust-toolchain.toml" + - "/src/sdks/rust/**/*" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "unit"] + command: "bash src/sdks/rust/tools/check-sdk.sh test-unit" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/test" + deps: + - "liboliphaunt-native:check" + - "shared-fixtures:check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/sdks/rust/**/*" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + smoke: + tags: ["runtime", "smoke"] + command: "bash src/sdks/rust/tools/check-sdk.sh smoke-runtime" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/smoke" + deps: + - "liboliphaunt-native:test" + inputs: + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/tools/runtime/**/*" + - "/tools/xtask/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + package: + tags: ["package"] + command: "bash src/sdks/rust/tools/check-sdk.sh package-shape" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/package" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/sdks/rust/**/*" + - "/tools/runtime/**/*" + options: + cache: true + runFromWorkspaceRoot: true + package-artifacts: + tags: ["release", "artifact-package", "ci-rust-sdk-package"] + command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-rust" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/package-artifacts" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/sdks/rust/**/*" + - "/tools/release/build-sdk-ci-artifacts.sh" + - "/tools/runtime/**/*" + outputs: + - "/target/sdk-artifacts/oliphaunt-rust/**/*" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "bash src/sdks/rust/tools/check-sdk.sh release-check" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/release-check" + deps: + - "liboliphaunt-native:check" + - "liboliphaunt-native:release-runtime" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/sdks/rust/**/*" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + bench: + tags: ["bench", "plan"] + command: "bash tools/perf/matrix/run_native_oliphaunt_matrix.sh --plan-only" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/bench" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/sdks/rust/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + - "/rust-toolchain.toml" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false + regression: + tags: ["regression", "runtime"] + command: "bash src/sdks/rust/tools/check-sdk.sh regression" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/regression" + deps: + - "liboliphaunt-native:test" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/sdks/rust/**/*" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + extension-regression: + tags: ["regression", "runtime", "extensions"] + command: "bash src/sdks/rust/tools/check-sdk.sh extension-regression" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/extension-regression" + deps: + - "extension-artifacts-native:release-check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/extensions/**/*" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/tools/runtime/**/*" + options: + cache: local + runFromWorkspaceRoot: true + runInCI: false + coverage: + tags: ["coverage", "quality"] + command: "tools/coverage/run-product oliphaunt-rust" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/coverage" + inputs: + - "/.config/nextest.toml" + - "/Cargo.lock" + - "/Cargo.toml" + - "/coverage/baseline.toml" + - "/src/shared/fixtures/**/*" + - "/rust-toolchain.toml" + - "/src/sdks/rust/**/*" + - "/tools/coverage/**/*" + outputs: + - "/target/coverage/oliphaunt-rust/**/*" + options: + cache: true + runFromWorkspaceRoot: true + bench-run: + tags: ["bench", "measured"] + command: "bash tools/perf/matrix/run_native_oliphaunt_matrix.sh --engines direct,broker,server" + env: + CARGO_TARGET_DIR: "target/moon/oliphaunt-rust/bench-run" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/shared/fixtures/**/*" + - "/src/sdks/rust/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/src/**/targets/**/*.toml" + - "/rust-toolchain.toml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false diff --git a/src/sdks/rust/release.toml b/src/sdks/rust/release.toml new file mode 100644 index 00000000..5f54542e --- /dev/null +++ b/src/sdks/rust/release.toml @@ -0,0 +1,6 @@ +id = "oliphaunt-rust" +owner = "@oliphaunt/sdk-rust" +kind = "sdk" +publish_targets = ["crates-io"] +registry_packages = ["crates:oliphaunt"] +release_artifacts = ["cargo-crate", "runtime-resource-cli"] diff --git a/src/sdks/rust/src/backup.rs b/src/sdks/rust/src/backup.rs new file mode 100644 index 00000000..66ef736c --- /dev/null +++ b/src/sdks/rust/src/backup.rs @@ -0,0 +1,2386 @@ +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{self, Cursor, Read}; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tar::{Builder, EntryType, Header}; + +use crate::error::{Error, Result}; +use crate::extension::Extension; +use crate::liboliphaunt::{ + NATIVE_ROOT_MANIFEST_FILE, NativeRootLock, ensure_native_root_manifest, + native_root_manifest_text, validate_native_root_manifest_text, +}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; +use crate::storage::{ + BackupArtifact, BackupFormat, DatabaseRoot, RestoreRequest, RestoreTargetPolicy, + path_contains_nul, +}; + +const BACKUP_LABEL: &str = "oliphaunt physical archive"; +pub(crate) const PHYSICAL_ARCHIVE_MANIFEST_PATH: &str = ".oliphaunt/backup-manifest.properties"; +const PHYSICAL_ARCHIVE_MANIFEST_LAYOUT: &str = "oliphaunt-physical-archive-v1"; +const PHYSICAL_ARCHIVE_POSTGRES_MAJOR: &str = "18"; +const PHYSICAL_ARCHIVE_METADATA_MAX_BYTES: usize = 64 * 1024; +const TRANSIENT_CONTENT_DIRS: &[&str] = &[ + "pg_dynshmem", + "pg_notify", + "pg_serial", + "pg_snapshots", + "pg_stat_tmp", + "pg_subtrans", +]; + +pub(crate) fn annotate_physical_archive_backup( + artifact: BackupArtifact, + pgdata: &Path, + selected_extensions: &[Extension], + mut exec_sql: impl FnMut(ProtocolRequest) -> Result, +) -> Result { + if artifact.format != BackupFormat::PhysicalArchive { + return Err(Error::Engine(format!( + "physical archive annotation requires a PhysicalArchive artifact, got {:?}", + artifact.format + ))); + } + + let metadata_files = + physical_archive_metadata_files(pgdata, selected_extensions, &mut exec_sql)?; + let bytes = append_physical_archive_metadata( + artifact.bytes.as_slice(), + metadata_files.root_manifest, + metadata_files.backup_manifest, + )?; + Ok(BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + }) +} + +pub(crate) struct PhysicalArchiveMetadataFiles { + pub(crate) root_manifest: String, + pub(crate) backup_manifest: String, +} + +pub(crate) fn physical_archive_metadata_files( + pgdata: &Path, + selected_extensions: &[Extension], + mut exec_sql: impl FnMut(ProtocolRequest) -> Result, +) -> Result { + let metadata = collect_physical_archive_metadata(pgdata, selected_extensions, &mut exec_sql)?; + Ok(PhysicalArchiveMetadataFiles { + root_manifest: native_root_manifest_text(Some(&metadata.pgdata_version)), + backup_manifest: physical_archive_manifest_text(&metadata), + }) +} + +pub(crate) fn physical_archive_backup( + pgdata: &Path, + mut exec_sql: impl FnMut(ProtocolRequest) -> Result, +) -> Result { + let start_backup = ProtocolRequest::simple_query(&format!( + "SELECT pg_backup_start(label => '{}', fast => true)", + BACKUP_LABEL + ))?; + ensure_simple_query_ok(exec_sql(start_backup)?, "start physical backup")?; + + let mut bytes = Vec::new(); + let mut backup_stopped = false; + let archive_result = { + let mut archive = Builder::new(&mut bytes); + append_pgdata_tree(&mut archive, pgdata).and_then(|()| { + let stop_files = stop_physical_backup(&mut exec_sql)?; + backup_stopped = true; + append_pg_wal_tree(&mut archive, pgdata)?; + append_generated_file(&mut archive, "pgdata/backup_label", stop_files.backup_label)?; + if let Some(tablespace_map) = stop_files.tablespace_map + && !tablespace_map.is_empty() + { + append_generated_file(&mut archive, "pgdata/tablespace_map", tablespace_map)?; + } + archive + .finish() + .map_err(|err| Error::Engine(format!("finish physical backup archive: {err}"))) + }) + }; + + match archive_result { + Ok(()) => Ok(BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + }), + Err(error) => { + if backup_stopped { + Err(error) + } else { + let stop_error = stop_physical_backup(&mut exec_sql).err(); + match stop_error { + Some(stop_error) => Err(Error::Engine(format!( + "{error}; also failed to leave PostgreSQL backup mode cleanly: {stop_error}" + ))), + None => Err(error), + } + } + } + } +} + +struct PhysicalArchiveMetadata { + pgdata_version: String, + postgres_version_num: String, + server_encoding: String, + lc_collate: String, + lc_ctype: String, + data_checksums: String, + shared_preload_libraries: String, + required_preload_libraries: Vec, + selected_extensions: Vec, + installed_extensions: String, +} + +fn collect_physical_archive_metadata( + pgdata: &Path, + selected_extensions: &[Extension], + exec_sql: &mut impl FnMut(ProtocolRequest) -> Result, +) -> Result { + let pgdata_version = read_pgdata_version(pgdata)?; + if pgdata_version != PHYSICAL_ARCHIVE_POSTGRES_MAJOR { + return Err(Error::Engine(format!( + "physical archive metadata requires PostgreSQL {PHYSICAL_ARCHIVE_POSTGRES_MAJOR} PGDATA, got {pgdata_version}" + ))); + } + + let metadata_query = ProtocolRequest::simple_query( + "SELECT \ + current_setting('server_version_num'), \ + current_setting('server_encoding'), \ + datcollate, \ + datctype, \ + current_setting('data_checksums'), \ + current_setting('shared_preload_libraries'), \ + COALESCE((SELECT string_agg(extname, ',' ORDER BY extname) FROM pg_extension), '') \ + FROM pg_database WHERE datname = current_database()", + )?; + let row = first_data_row( + exec_sql(metadata_query)?, + "collect physical archive metadata", + )?; + if row.len() != 7 { + return Err(Error::Engine(format!( + "physical archive metadata query returned {} columns, expected 7", + row.len() + ))); + } + + let mut selected_extension_names = selected_extensions + .iter() + .map(|extension| extension.sql_name().to_owned()) + .collect::>(); + selected_extension_names.sort(); + selected_extension_names.dedup(); + let mut required_preload_libraries = selected_extensions + .iter() + .filter_map(|extension| extension.required_shared_preload_library()) + .map(ToOwned::to_owned) + .collect::>(); + required_preload_libraries.sort(); + required_preload_libraries.dedup(); + Ok(PhysicalArchiveMetadata { + pgdata_version, + postgres_version_num: required_utf8_column(&row, 0, "server_version_num")?, + server_encoding: required_utf8_column(&row, 1, "server_encoding")?, + lc_collate: required_utf8_column(&row, 2, "lc_collate")?, + lc_ctype: required_utf8_column(&row, 3, "lc_ctype")?, + data_checksums: required_utf8_column(&row, 4, "data_checksums")?, + shared_preload_libraries: required_utf8_column(&row, 5, "shared_preload_libraries")?, + installed_extensions: required_utf8_column(&row, 6, "installed_extensions")?, + required_preload_libraries, + selected_extensions: selected_extension_names, + }) +} + +fn physical_archive_manifest_text(metadata: &PhysicalArchiveMetadata) -> String { + format!( + "archiveLayout={PHYSICAL_ARCHIVE_MANIFEST_LAYOUT}\n\ + product=oliphaunt\n\ + postgresMajor={PHYSICAL_ARCHIVE_POSTGRES_MAJOR}\n\ + pgdataVersion={}\n\ + postgresVersionNum={}\n\ + serverEncoding={}\n\ + lcCollate={}\n\ + lcCtype={}\n\ + dataChecksums={}\n\ + sharedPreloadLibraries={}\n\ + requiredPreloadLibraries={}\n\ + selectedExtensions={}\n\ + installedExtensions={}\n", + manifest_value(&metadata.pgdata_version), + manifest_value(&metadata.postgres_version_num), + manifest_value(&metadata.server_encoding), + manifest_value(&metadata.lc_collate), + manifest_value(&metadata.lc_ctype), + manifest_value(&metadata.data_checksums), + manifest_value(&metadata.shared_preload_libraries), + manifest_value(&metadata.required_preload_libraries.join(",")), + manifest_value(&metadata.selected_extensions.join(",")), + manifest_value(&metadata.installed_extensions) + ) +} + +fn manifest_value(value: &str) -> String { + value.replace(['\n', '\r'], " ").trim().to_owned() +} + +fn required_utf8_column(row: &[Option>], index: usize, label: &str) -> Result { + let bytes = row + .get(index) + .ok_or_else(|| Error::Engine(format!("metadata row is missing column {label}")))? + .as_ref() + .ok_or_else(|| Error::Engine(format!("metadata column {label} was null")))?; + String::from_utf8(bytes.clone()) + .map_err(|err| Error::Engine(format!("metadata column {label} is not UTF-8: {err}"))) +} + +fn read_pgdata_version(pgdata: &Path) -> Result { + let version_path = pgdata.join("PG_VERSION"); + let version = fs::read_to_string(&version_path).map_err(|err| { + Error::Engine(format!( + "read native PGDATA version file {}: {err}", + version_path.display() + )) + })?; + let version = version.trim(); + if version.is_empty() { + return Err(Error::Engine(format!( + "native PGDATA version file {} is empty", + version_path.display() + ))); + } + Ok(version.to_owned()) +} + +fn stop_physical_backup( + exec_sql: &mut impl FnMut(ProtocolRequest) -> Result, +) -> Result { + let stop_backup = ProtocolRequest::simple_query( + "SELECT labelfile, spcmapfile FROM pg_backup_stop(wait_for_archive => false)", + )?; + let response = exec_sql(stop_backup)?; + let row = first_data_row(response, "stop physical backup")?; + if row.len() != 2 { + return Err(Error::Engine(format!( + "stop physical backup returned {} columns, expected 2", + row.len() + ))); + } + let backup_label = row[0] + .clone() + .ok_or_else(|| Error::Engine("pg_backup_stop returned a null backup label".to_owned()))?; + let tablespace_map = row[1].clone(); + Ok(BackupStopFiles { + backup_label: String::from_utf8(backup_label) + .map_err(|err| Error::Engine(format!("backup label is not UTF-8: {err}")))?, + tablespace_map: tablespace_map + .map(String::from_utf8) + .transpose() + .map_err(|err| Error::Engine(format!("tablespace map is not UTF-8: {err}")))?, + }) +} + +pub(crate) fn sql_backup_with_pg_dump( + pg_dump: &Path, + connection_string: &str, +) -> Result { + if !pg_dump.is_file() { + return Err(Error::Engine(format!( + "logical SQL backup requires pg_dump at {}", + pg_dump.display() + ))); + } + let output = std::process::Command::new(pg_dump) + .arg("--dbname") + .arg(connection_string) + .arg("--format=plain") + .arg("--no-password") + .output() + .map_err(|err| Error::Engine(format!("run pg_dump for logical SQL backup: {err}")))?; + if output.status.success() { + Ok(BackupArtifact { + format: BackupFormat::Sql, + bytes: output.stdout, + }) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(Error::Engine(format!( + "pg_dump failed with status {}: {}", + output.status, + stderr.trim() + ))) + } +} + +pub(crate) fn restore_backup(request: RestoreRequest) -> Result { + if request.artifact.format != BackupFormat::PhysicalArchive { + return Err(Error::Engine(format!( + "restore currently requires a physical archive artifact, got {:?}", + request.artifact.format + ))); + } + + let DatabaseRoot::Path(target_root) = request.target else { + return Err(Error::Engine( + "restore requires an explicit persistent target root".to_owned(), + )); + }; + + restore_physical_archive(&target_root, &request.artifact, request.target_policy) +} + +fn restore_physical_archive( + target_root: &Path, + artifact: &BackupArtifact, + target_policy: RestoreTargetPolicy, +) -> Result { + let target_root = normalize_restore_target(target_root)?; + let parent = target_root.parent().ok_or_else(|| { + Error::Engine(format!( + "restore target {} has no parent directory", + target_root.display() + )) + })?; + fs::create_dir_all(parent).map_err(|err| { + Error::Engine(format!( + "create restore parent directory {}: {err}", + parent.display() + )) + })?; + let _target_lock = acquire_restore_target_lock(&target_root)?; + + let staging_root = unique_sibling_path(&target_root, "restore-staging"); + let cleanup_staging = CleanupDir::new(staging_root.clone()); + fs::create_dir(&staging_root).map_err(|err| { + Error::Engine(format!( + "create restore staging directory {}: {err}", + staging_root.display() + )) + })?; + + unpack_physical_archive(artifact, &staging_root)?; + validate_restored_pgdata(&staging_root)?; + publish_restored_root(&staging_root, &target_root, target_policy)?; + cleanup_staging.disarm(); + Ok(target_root) +} + +fn normalize_restore_target(target_root: &Path) -> Result { + if target_root.as_os_str().is_empty() { + return Err(Error::Engine("restore target root is empty".to_owned())); + } + if path_contains_nul(target_root) { + return Err(Error::Engine( + "restore target root must not contain NUL bytes".to_owned(), + )); + } + if target_root == Path::new("/") { + return Err(Error::Engine( + "refusing to restore over filesystem root".to_owned(), + )); + } + if fs::symlink_metadata(target_root) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(Error::Engine(format!( + "refusing to restore over symlink target {}; choose the real database root path", + target_root.display() + ))); + } + Ok(target_root.to_path_buf()) +} + +fn unpack_physical_archive(artifact: &BackupArtifact, staging_root: &Path) -> Result<()> { + validate_physical_archive_framing(artifact.bytes.as_slice())?; + let entries = validate_physical_archive_entries(artifact.bytes.as_slice())?; + validate_physical_archive_compatibility_metadata(artifact.bytes.as_slice(), &entries)?; + restore_physical_archive_entries(artifact.bytes.as_slice(), staging_root, &entries) +} + +fn append_physical_archive_metadata( + bytes: &[u8], + root_manifest: String, + backup_manifest: String, +) -> Result> { + validate_physical_archive_framing(bytes)?; + let entries = validate_physical_archive_entries(bytes)?; + let mut output = if entries + .iter() + .any(|entry| archive_path_is_metadata(&entry.canonical_path)) + { + physical_archive_without_metadata(bytes, &entries)? + } else { + let end = physical_archive_data_end_offset(bytes)?; + bytes[..end].to_vec() + }; + { + let mut archive = Builder::new(&mut output); + append_generated_file(&mut archive, NATIVE_ROOT_MANIFEST_FILE, root_manifest)?; + append_generated_file( + &mut archive, + PHYSICAL_ARCHIVE_MANIFEST_PATH, + backup_manifest, + )?; + archive + .finish() + .map_err(|err| Error::Engine(format!("finish annotated physical archive: {err}")))?; + } + Ok(output) +} + +#[cfg(test)] +fn archive_contains_path(entries: &[ArchiveEntryPlan], path: &Path) -> bool { + entries.iter().any(|entry| entry.canonical_path == path) +} + +fn archive_path_is_metadata(path: &Path) -> bool { + path == Path::new(NATIVE_ROOT_MANIFEST_FILE) + || path == Path::new(PHYSICAL_ARCHIVE_MANIFEST_PATH) +} + +fn physical_archive_without_metadata( + bytes: &[u8], + entry_plans: &[ArchiveEntryPlan], +) -> Result> { + let mut output = Vec::with_capacity(bytes.len()); + { + let mut output_archive = Builder::new(&mut output); + let mut input_archive = tar::Archive::new(Cursor::new(bytes)); + let entries = input_archive + .entries() + .map_err(|err| Error::Engine(format!("read physical archive entries: {err}")))?; + let mut plans = entry_plans.iter(); + for entry in entries { + let mut entry = entry + .map_err(|err| Error::Engine(format!("read physical archive entry: {err}")))?; + let plan = plans.next().ok_or_else(|| { + Error::Engine("physical archive entry plan ended before archive entries".to_owned()) + })?; + if archive_path_is_metadata(&plan.canonical_path) { + continue; + } + let header = entry.header().clone(); + output_archive.append(&header, &mut entry).map_err(|err| { + Error::Engine(format!( + "copy physical archive entry {} while refreshing metadata: {err}", + plan.canonical_path.display() + )) + })?; + } + if plans.next().is_some() { + return Err(Error::Engine( + "physical archive ended before validated entry plan".to_owned(), + )); + } + output_archive.finish().map_err(|err| { + Error::Engine(format!( + "finish physical archive while refreshing metadata: {err}" + )) + })?; + } + if output.len() >= 1024 { + output.truncate(output.len() - 1024); + } + Ok(output) +} + +fn validate_physical_archive_compatibility_metadata( + bytes: &[u8], + entries: &[ArchiveEntryPlan], +) -> Result<()> { + let pgdata_version = archive_text_file(bytes, entries, Path::new("pgdata/PG_VERSION"))? + .map(|version| version.trim().to_owned()); + if let Some(version) = pgdata_version.as_deref() + && version != PHYSICAL_ARCHIVE_POSTGRES_MAJOR + { + return Err(Error::Engine(format!( + "physical archive contains PostgreSQL {version} PGDATA; oliphaunt currently supports PostgreSQL {PHYSICAL_ARCHIVE_POSTGRES_MAJOR} restores" + ))); + } + + if let Some(root_manifest) = + archive_text_file(bytes, entries, Path::new(NATIVE_ROOT_MANIFEST_FILE))? + { + validate_native_root_manifest_text( + Path::new(NATIVE_ROOT_MANIFEST_FILE), + &root_manifest, + pgdata_version.as_deref(), + )?; + } + + if let Some(backup_manifest) = + archive_text_file(bytes, entries, Path::new(PHYSICAL_ARCHIVE_MANIFEST_PATH))? + { + validate_physical_archive_manifest_text( + Path::new(PHYSICAL_ARCHIVE_MANIFEST_PATH), + &backup_manifest, + pgdata_version.as_deref(), + )?; + } + + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ArchiveEntryKind { + File, + Directory, +} + +struct ArchiveEntryPlan { + canonical_path: PathBuf, + kind: ArchiveEntryKind, + mode: usize, + size: usize, +} + +fn validate_physical_archive_entries(bytes: &[u8]) -> Result> { + let mut archive = tar::Archive::new(Cursor::new(bytes)); + let entries = archive + .entries() + .map_err(|err| Error::Engine(format!("read physical archive entries: {err}")))?; + let mut seen_paths = HashSet::new(); + let mut seen_file_paths = HashSet::new(); + let mut seen_entry_ancestors = HashSet::new(); + let mut plans = Vec::new(); + for entry in entries { + let entry = + entry.map_err(|err| Error::Engine(format!("read physical archive entry: {err}")))?; + validate_archive_header_format(entry.header())?; + validate_archive_numeric_fields(entry.header())?; + let path = entry + .path() + .map_err(|err| Error::Engine(format!("read physical archive entry path: {err}")))? + .into_owned(); + let canonical_path = canonical_archive_path(&path)?; + if !seen_paths.insert(canonical_path.clone()) { + return Err(Error::Engine(format!( + "physical archive contains duplicate entry {}", + canonical_path.display() + ))); + } + let entry_type = entry.header().entry_type(); + let kind = validate_archive_entry_type(entry_type, &canonical_path)?; + if let Some(link_name) = entry + .link_name() + .map_err(|err| Error::Engine(format!("read archive link target: {err}")))? + { + return Err(Error::Engine(format!( + "physical archive entry {} has an unexpected link target {}; liboliphaunt physical archives must contain concrete root files", + canonical_path.display(), + link_name.display() + ))); + } + let (mode, size) = archive_entry_mode_and_size(entry.header())?; + validate_archive_tree_shape( + &canonical_path, + kind, + size, + &seen_file_paths, + &seen_entry_ancestors, + )?; + remember_archive_tree_shape( + &canonical_path, + kind, + &mut seen_file_paths, + &mut seen_entry_ancestors, + ); + plans.push(ArchiveEntryPlan { + canonical_path, + kind, + mode, + size, + }); + } + Ok(plans) +} + +fn restore_physical_archive_entries( + bytes: &[u8], + staging_root: &Path, + entry_plans: &[ArchiveEntryPlan], +) -> Result<()> { + let mut archive = tar::Archive::new(Cursor::new(bytes)); + let entries = archive + .entries() + .map_err(|err| Error::Engine(format!("read physical archive entries: {err}")))?; + let mut plans = entry_plans.iter(); + for entry in entries { + let mut entry = + entry.map_err(|err| Error::Engine(format!("read physical archive entry: {err}")))?; + let plan = plans.next().ok_or_else(|| { + Error::Engine("physical archive entry plan ended before archive entries".to_owned()) + })?; + restore_archive_entry(&mut entry, staging_root, plan)?; + } + if plans.next().is_some() { + return Err(Error::Engine( + "physical archive ended before validated entry plan".to_owned(), + )); + } + Ok(()) +} + +fn archive_entry_mode_and_size(header: &Header) -> Result<(usize, usize)> { + let bytes = header.as_bytes(); + Ok(( + parse_tar_octal_field(&bytes[100..108], "mode", false)?, + parse_tar_octal_field(&bytes[124..136], "size", false)?, + )) +} + +fn validate_archive_tree_shape( + canonical_path: &Path, + kind: ArchiveEntryKind, + size: usize, + seen_file_paths: &HashSet, + seen_entry_ancestors: &HashSet, +) -> Result<()> { + if kind == ArchiveEntryKind::Directory && size != 0 { + return Err(Error::Engine(format!( + "physical archive directory entry {} has non-zero size", + canonical_path.display() + ))); + } + if let Some(ancestor) = archive_file_ancestor(canonical_path, seen_file_paths) { + return Err(Error::Engine(format!( + "physical archive entry {} is nested under file entry {}", + canonical_path.display(), + ancestor.display() + ))); + } + if kind == ArchiveEntryKind::File && seen_entry_ancestors.contains(canonical_path) { + return Err(Error::Engine(format!( + "physical archive file entry {} conflicts with existing child entries", + canonical_path.display() + ))); + } + Ok(()) +} + +fn remember_archive_tree_shape( + canonical_path: &Path, + kind: ArchiveEntryKind, + seen_file_paths: &mut HashSet, + seen_entry_ancestors: &mut HashSet, +) { + if kind == ArchiveEntryKind::File { + seen_file_paths.insert(canonical_path.to_path_buf()); + } + for ancestor in archive_path_ancestors(canonical_path) { + seen_entry_ancestors.insert(ancestor); + } +} + +fn archive_file_ancestor(path: &Path, seen_file_paths: &HashSet) -> Option { + archive_path_ancestors(path) + .into_iter() + .find(|ancestor| seen_file_paths.contains(ancestor)) +} + +fn archive_path_ancestors(path: &Path) -> Vec { + let mut ancestors = Vec::new(); + let mut ancestor = PathBuf::new(); + let mut components = path.components().peekable(); + while let Some(component) = components.next() { + if components.peek().is_none() { + break; + } + ancestor.push(component.as_os_str()); + ancestors.push(ancestor.clone()); + } + ancestors +} + +fn restore_archive_entry( + entry: &mut tar::Entry<'_, R>, + staging_root: &Path, + plan: &ArchiveEntryPlan, +) -> Result<()> { + let destination = staging_root.join(&plan.canonical_path); + if plan.kind == ArchiveEntryKind::Directory { + if plan.size != 0 { + return Err(Error::Engine(format!( + "physical archive directory entry {} has non-zero size", + plan.canonical_path.display() + ))); + } + fs::create_dir_all(&destination).map_err(|err| { + Error::Engine(format!( + "create restored directory {}: {err}", + destination.display() + )) + })?; + apply_restored_permissions(&destination, plan.mode, 0o700)?; + return Ok(()); + } + + let parent = destination.parent().ok_or_else(|| { + Error::Engine(format!( + "restore physical archive entry {} has no parent directory", + plan.canonical_path.display() + )) + })?; + fs::create_dir_all(parent).map_err(|err| { + Error::Engine(format!( + "create restored parent directory {}: {err}", + parent.display() + )) + })?; + let mut file = File::create(&destination).map_err(|err| { + Error::Engine(format!( + "create restored file {}: {err}", + destination.display() + )) + })?; + let copied = io::copy(entry, &mut file).map_err(|err| { + Error::Engine(format!( + "write restored file {}: {err}", + destination.display() + )) + })?; + if copied != plan.size as u64 { + return Err(Error::Engine(format!( + "physical archive entry {} restored {} bytes, expected {}", + plan.canonical_path.display(), + copied, + plan.size + ))); + } + apply_restored_permissions(&destination, plan.mode, 0o600) +} + +#[cfg(unix)] +fn apply_restored_permissions(path: &Path, mode: usize, default_mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let effective_mode = if mode == 0 { default_mode } else { mode as u32 }; + fs::set_permissions(path, fs::Permissions::from_mode(effective_mode)).map_err(|err| { + Error::Engine(format!( + "set restored permissions on {}: {err}", + path.display() + )) + }) +} + +#[cfg(not(unix))] +fn apply_restored_permissions(path: &Path, mode: usize, default_mode: u32) -> Result<()> { + let _ = (path, mode, default_mode); + Ok(()) +} + +fn validate_physical_archive_framing(bytes: &[u8]) -> Result<()> { + physical_archive_data_end_offset(bytes).map(|_| ()) +} + +fn physical_archive_data_end_offset(bytes: &[u8]) -> Result { + if bytes.len() < 1024 || !bytes.len().is_multiple_of(512) { + return Err(Error::Engine( + "physical archive has invalid tar block framing".to_owned(), + )); + } + if !tar_block_is_zero(&bytes[bytes.len() - 1024..bytes.len() - 512]) + || !tar_block_is_zero(&bytes[bytes.len() - 512..]) + { + return Err(Error::Engine( + "physical archive ended before final tar zero block".to_owned(), + )); + } + + let mut offset = 0usize; + while offset + 512 <= bytes.len() { + let header = &bytes[offset..offset + 512]; + offset += 512; + if tar_block_is_zero(header) { + if offset + 512 > bytes.len() { + return Err(Error::Engine( + "physical archive ended before final tar zero block".to_owned(), + )); + } + if !tar_block_is_zero(&bytes[offset..offset + 512]) { + return Err(Error::Engine( + "physical archive has trailing data after tar terminator".to_owned(), + )); + } + offset += 512; + for block in bytes[offset..].chunks_exact(512) { + if !tar_block_is_zero(block) { + return Err(Error::Engine( + "physical archive has trailing data after tar terminator".to_owned(), + )); + } + } + return Ok(offset - 1024); + } + + validate_tar_header_checksum(header)?; + validate_tar_header_numeric_fields(header)?; + validate_tar_header_string_fields(header)?; + let size = parse_tar_octal_field(&header[124..136], "size", false)?; + let padded = size + .checked_add(511) + .map(|size| size & !511) + .ok_or_else(|| Error::Engine("physical archive entry size overflows".to_owned()))?; + if padded > bytes.len().saturating_sub(offset) { + return Err(Error::Engine( + "physical archive entry is truncated".to_owned(), + )); + } + offset += padded; + } + + Err(Error::Engine( + "physical archive ended before final tar zero block".to_owned(), + )) +} + +fn archive_text_file( + bytes: &[u8], + entry_plans: &[ArchiveEntryPlan], + target_path: &Path, +) -> Result> { + let mut archive = tar::Archive::new(Cursor::new(bytes)); + let entries = archive + .entries() + .map_err(|err| Error::Engine(format!("read physical archive entries: {err}")))?; + let mut plans = entry_plans.iter(); + for entry in entries { + let mut entry = + entry.map_err(|err| Error::Engine(format!("read physical archive entry: {err}")))?; + let plan = plans.next().ok_or_else(|| { + Error::Engine("physical archive entry plan ended before archive entries".to_owned()) + })?; + if plan.canonical_path != target_path { + continue; + } + if plan.kind != ArchiveEntryKind::File { + return Err(Error::Engine(format!( + "physical archive metadata entry {} must be a regular file", + target_path.display() + ))); + } + if plan.size > PHYSICAL_ARCHIVE_METADATA_MAX_BYTES { + return Err(Error::Engine(format!( + "physical archive metadata entry {} is too large", + target_path.display() + ))); + } + let mut bytes = Vec::with_capacity(plan.size); + entry.read_to_end(&mut bytes).map_err(|err| { + Error::Engine(format!( + "read physical archive metadata entry {}: {err}", + target_path.display() + )) + })?; + return String::from_utf8(bytes).map(Some).map_err(|err| { + Error::Engine(format!( + "physical archive metadata entry {} is not UTF-8: {err}", + target_path.display() + )) + }); + } + Ok(None) +} + +fn validate_physical_archive_manifest_text( + manifest_path: &Path, + text: &str, + pgdata_version: Option<&str>, +) -> Result<()> { + let properties = parse_manifest_properties(manifest_path, text)?; + require_manifest_value( + manifest_path, + &properties, + "archiveLayout", + PHYSICAL_ARCHIVE_MANIFEST_LAYOUT, + )?; + require_manifest_value(manifest_path, &properties, "product", "oliphaunt")?; + require_manifest_value( + manifest_path, + &properties, + "postgresMajor", + PHYSICAL_ARCHIVE_POSTGRES_MAJOR, + )?; + let manifest_pgdata_version = manifest_property(manifest_path, &properties, "pgdataVersion")?; + if let Some(version) = pgdata_version + && manifest_pgdata_version != version + { + return Err(Error::Engine(format!( + "physical archive manifest {} declares PGDATA version '{}', but pgdata/PG_VERSION contains PostgreSQL {version}", + manifest_path.display(), + manifest_pgdata_version + ))); + } + let version_num = manifest_property(manifest_path, &properties, "postgresVersionNum")?; + if !version_num.starts_with(PHYSICAL_ARCHIVE_POSTGRES_MAJOR) { + return Err(Error::Engine(format!( + "physical archive manifest {} declares PostgreSQL version number '{}', expected major {PHYSICAL_ARCHIVE_POSTGRES_MAJOR}", + manifest_path.display(), + version_num + ))); + } + for key in [ + "serverEncoding", + "lcCollate", + "lcCtype", + "dataChecksums", + "sharedPreloadLibraries", + "requiredPreloadLibraries", + "selectedExtensions", + "installedExtensions", + ] { + let _ = manifest_property(manifest_path, &properties, key)?; + } + Ok(()) +} + +fn parse_manifest_properties( + manifest_path: &Path, + text: &str, +) -> Result> { + let mut properties = std::collections::BTreeMap::new(); + for (index, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(Error::Engine(format!( + "physical archive manifest {} line {} must use key=value syntax", + manifest_path.display(), + index + 1 + ))); + }; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() { + return Err(Error::Engine(format!( + "physical archive manifest {} line {} must not use an empty key", + manifest_path.display(), + index + 1 + ))); + } + if properties + .insert(key.to_owned(), value.to_owned()) + .is_some() + { + return Err(Error::Engine(format!( + "physical archive manifest {} repeats key '{key}'", + manifest_path.display() + ))); + } + } + Ok(properties) +} + +fn require_manifest_value( + manifest_path: &Path, + properties: &std::collections::BTreeMap, + key: &str, + expected: &str, +) -> Result<()> { + let actual = manifest_property(manifest_path, properties, key)?; + if actual == expected { + return Ok(()); + } + Err(Error::Engine(format!( + "physical archive manifest {} has {key}='{actual}', expected '{expected}'", + manifest_path.display() + ))) +} + +fn manifest_property<'a>( + manifest_path: &Path, + properties: &'a std::collections::BTreeMap, + key: &str, +) -> Result<&'a str> { + properties.get(key).map(String::as_str).ok_or_else(|| { + Error::Engine(format!( + "physical archive manifest {} is missing required key '{key}'", + manifest_path.display() + )) + }) +} + +fn tar_block_is_zero(block: &[u8]) -> bool { + block.iter().all(|byte| *byte == 0) +} + +fn tar_header_checksum(header: &[u8]) -> usize { + header + .iter() + .enumerate() + .map(|(index, byte)| { + if (148..156).contains(&index) { + usize::from(b' ') + } else { + usize::from(*byte) + } + }) + .sum() +} + +fn parse_tar_octal_field(field: &[u8], label: &str, allow_empty: bool) -> Result { + let mut value = 0usize; + let mut saw_digit = false; + let mut index = 0usize; + while index < field.len() && (field[index] == b' ' || field[index] == 0) { + index += 1; + } + while index < field.len() { + let byte = field[index]; + match byte { + b'0'..=b'7' => { + saw_digit = true; + value = value + .checked_mul(8) + .and_then(|current| current.checked_add(usize::from(byte - b'0'))) + .ok_or_else(|| { + Error::Engine("physical archive entry size overflows".to_owned()) + })?; + } + b' ' | 0 => break, + _ => { + return Err(Error::Engine(format!( + "physical archive entry has invalid tar {label} field" + ))); + } + } + index += 1; + } + while index < field.len() { + if field[index] != b' ' && field[index] != 0 { + return Err(Error::Engine(format!( + "physical archive entry has invalid tar {label} field" + ))); + } + index += 1; + } + if !saw_digit { + if allow_empty { + return Ok(0); + } + return Err(Error::Engine(format!( + "physical archive entry has invalid tar {label} field" + ))); + } + Ok(value) +} + +fn validate_tar_header_checksum(bytes: &[u8]) -> Result<()> { + let stored = parse_tar_octal_field(&bytes[148..156], "checksum", false)?; + if stored != tar_header_checksum(bytes) { + return Err(Error::Engine( + "physical archive entry has invalid tar checksum".to_owned(), + )); + } + Ok(()) +} + +fn validate_archive_numeric_fields(header: &Header) -> Result<()> { + validate_tar_header_numeric_fields(header.as_bytes()) +} + +fn validate_tar_header_numeric_fields(bytes: &[u8]) -> Result<()> { + parse_tar_octal_field(&bytes[100..108], "mode", false)?; + parse_tar_octal_field(&bytes[108..116], "uid", true)?; + parse_tar_octal_field(&bytes[116..124], "gid", true)?; + parse_tar_octal_field(&bytes[124..136], "size", false)?; + parse_tar_octal_field(&bytes[136..148], "mtime", true)?; + parse_tar_octal_field(&bytes[148..156], "checksum", false)?; + Ok(()) +} + +fn validate_tar_header_string_fields(bytes: &[u8]) -> Result<()> { + validate_tar_string_field(&bytes[0..100], "name", false)?; + validate_tar_string_field(&bytes[157..257], "linkname", true)?; + validate_tar_string_field(&bytes[345..500], "prefix", true)?; + Ok(()) +} + +fn validate_tar_string_field(field: &[u8], label: &str, allow_empty: bool) -> Result<()> { + let terminator = field + .iter() + .position(|byte| *byte == 0) + .unwrap_or(field.len()); + if terminator == 0 && !allow_empty { + return Err(Error::Engine(format!( + "physical archive entry has invalid tar {label} field" + ))); + } + if terminator == field.len() { + return Ok(()); + } + if field[terminator + 1..].iter().any(|byte| *byte != 0) { + return Err(Error::Engine(format!( + "physical archive entry has invalid tar {label} field" + ))); + } + Ok(()) +} + +fn validate_archive_header_format(header: &Header) -> Result<()> { + if header.as_ustar().is_some() || header.as_gnu().is_some() { + return Ok(()); + } + Err(Error::Engine( + "physical archive entry has unsupported tar header format".to_owned(), + )) +} + +fn validate_archive_entry_type(entry_type: EntryType, path: &Path) -> Result { + if entry_type.is_file() { + return Ok(ArchiveEntryKind::File); + } + if entry_type.is_dir() { + return Ok(ArchiveEntryKind::Directory); + } + if entry_type.is_symlink() || entry_type.is_hard_link() { + return Err(Error::Engine(format!( + "physical archive entry {} is a link; liboliphaunt physical archives must contain concrete root files", + path.display() + ))); + } + Err(Error::Engine(format!( + "physical archive entry {} has unsupported tar entry type {:?}; liboliphaunt physical archives only support regular files and directories", + path.display(), + entry_type + ))) +} + +fn canonical_archive_path(path: &Path) -> Result { + let mut canonical = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => canonical.push(value), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(Error::Engine(format!( + "physical archive entry {} contains an unsafe path component", + path.display() + ))); + } + } + } + if canonical.as_os_str().is_empty() { + return Err(Error::Engine(format!( + "physical archive entry {} is not relative", + path.display() + ))); + } + if !archive_path_is_allowed(&canonical) { + return Err(Error::Engine(format!( + "physical archive entry {} is outside supported liboliphaunt archive paths", + path.display() + ))); + } + Ok(canonical) +} + +fn archive_path_is_allowed(path: &Path) -> bool { + path == Path::new(NATIVE_ROOT_MANIFEST_FILE) + || path == Path::new(PHYSICAL_ARCHIVE_MANIFEST_PATH) + || path == Path::new("pgdata") + || path.starts_with("pgdata/") +} + +fn validate_restored_pgdata(root: &Path) -> Result<()> { + for required in [ + "pgdata/PG_VERSION", + "pgdata/global/pg_control", + "pgdata/backup_label", + ] { + let path = root.join(required); + if !path.is_file() { + return Err(Error::Engine(format!( + "physical archive is missing required file {required}" + ))); + } + } + ensure_native_root_manifest(root, &root.join("pgdata")) +} + +fn publish_restored_root( + staging_root: &Path, + target_root: &Path, + target_policy: RestoreTargetPolicy, +) -> Result<()> { + match target_policy { + RestoreTargetPolicy::FailIfExists => { + publish_restore_without_replacement(staging_root, target_root) + } + RestoreTargetPolicy::ReplaceExisting => { + publish_restore_with_replacement(staging_root, target_root) + } + } +} + +fn publish_restore_without_replacement(staging_root: &Path, target_root: &Path) -> Result<()> { + if target_root.exists() { + if !target_root.is_dir() { + return Err(Error::Engine(format!( + "refusing to restore over non-directory target {}", + target_root.display() + ))); + } + if !directory_is_empty(target_root)? { + return Err(Error::Engine(format!( + "refusing to restore into non-empty target {}; use replace_existing() to replace it", + target_root.display() + ))); + } + fs::remove_dir(target_root).map_err(|err| { + Error::Engine(format!( + "remove empty restore target {} before publish: {err}", + target_root.display() + )) + })?; + } + rename_dir(staging_root, target_root, "publish restored root") +} + +fn publish_restore_with_replacement(staging_root: &Path, target_root: &Path) -> Result<()> { + if !target_root.exists() { + return rename_dir(staging_root, target_root, "publish restored root"); + } + if !target_root.is_dir() { + return Err(Error::Engine(format!( + "refusing to replace non-directory restore target {}", + target_root.display() + ))); + } + + let displaced_root = unique_sibling_path(target_root, "restore-replaced"); + rename_dir( + target_root, + &displaced_root, + "move existing root aside for restore", + )?; + if let Err(error) = rename_dir(staging_root, target_root, "publish restored root") { + let _ = rename_dir( + &displaced_root, + target_root, + "restore previous root after failure", + ); + return Err(error); + } + fs::remove_dir_all(&displaced_root).map_err(|err| { + Error::Engine(format!( + "remove replaced restore target {}: {err}", + displaced_root.display() + )) + }) +} + +fn acquire_restore_target_lock(target_root: &Path) -> Result { + NativeRootLock::reserve_path(target_root, "restore target") +} + +fn directory_is_empty(path: &Path) -> Result { + let mut entries = fs::read_dir(path) + .map_err(|err| Error::Engine(format!("read directory {}: {err}", path.display())))?; + Ok(entries.next().is_none()) +} + +fn rename_dir(source: &Path, destination: &Path, context: &str) -> Result<()> { + fs::rename(source, destination).map_err(|err| { + Error::Engine(format!( + "{context}: rename {} to {}: {err}", + source.display(), + destination.display() + )) + }) +} + +fn unique_sibling_path(target_root: &Path, suffix: &str) -> PathBuf { + let parent = target_root.parent().unwrap_or_else(|| Path::new(".")); + let name = target_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("root"); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + for attempt in 0..100_u32 { + let candidate = parent.join(format!(".{name}-{suffix}-{pid}-{nanos}-{attempt}")); + if !candidate.exists() { + return candidate; + } + } + parent.join(format!(".{name}-{suffix}-{pid}-{nanos}-fallback")) +} + +struct CleanupDir { + path: PathBuf, + armed: std::cell::Cell, +} + +impl CleanupDir { + fn new(path: PathBuf) -> Self { + Self { + path, + armed: std::cell::Cell::new(true), + } + } + + fn disarm(&self) { + self.armed.set(false); + } +} + +impl Drop for CleanupDir { + fn drop(&mut self) { + if self.armed.get() { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +fn append_pgdata_tree(archive: &mut Builder<&mut Vec>, pgdata: &Path) -> Result<()> { + append_directory(archive, pgdata, Path::new("pgdata"))?; + for entry in sorted_read_dir(pgdata)? { + append_pgdata_entry(archive, pgdata, &entry.path(), false)?; + } + Ok(()) +} + +fn append_pg_wal_tree(archive: &mut Builder<&mut Vec>, pgdata: &Path) -> Result<()> { + let pg_wal = pgdata.join("pg_wal"); + if !pg_wal.is_dir() { + return Ok(()); + } + for entry in sorted_read_dir(&pg_wal)? { + append_pgdata_entry(archive, pgdata, &entry.path(), true)?; + } + Ok(()) +} + +fn append_pgdata_entry( + archive: &mut Builder<&mut Vec>, + pgdata: &Path, + source: &Path, + include_wal_contents: bool, +) -> Result<()> { + let relative = source.strip_prefix(pgdata).map_err(|err| { + Error::Engine(format!( + "strip PGDATA prefix {} from {}: {err}", + pgdata.display(), + source.display() + )) + })?; + if should_skip_pgdata_entry(relative, include_wal_contents) { + return Ok(()); + } + + let archive_path = Path::new("pgdata").join(relative); + let metadata = fs::symlink_metadata(source) + .map_err(|err| Error::Engine(format!("stat {} for backup: {err}", source.display())))?; + let file_type = metadata.file_type(); + if file_type.is_dir() { + append_directory(archive, source, &archive_path)?; + for entry in sorted_read_dir(source)? { + append_pgdata_entry(archive, pgdata, &entry.path(), include_wal_contents)?; + } + } else if file_type.is_file() { + append_file(archive, source, &archive_path)?; + } else if file_type.is_symlink() { + return Err(Error::Engine(format!( + "physical archive does not support symlinked PGDATA entry {}; external tablespaces and linked WAL directories are not portable in liboliphaunt archives", + archive_path.display() + ))); + } else { + return Err(Error::Engine(format!( + "physical archive does not support non-regular PGDATA entry {}; liboliphaunt archives only support regular files and directories", + archive_path.display() + ))); + } + Ok(()) +} + +fn should_skip_pgdata_entry(relative: &Path, include_wal_contents: bool) -> bool { + if relative == Path::new("postmaster.pid") || relative == Path::new("postmaster.opts") { + return true; + } + if relative + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "pg_internal.init" || name.starts_with("pgsql_tmp")) + { + return true; + } + let mut components = relative.components(); + let Some(Component::Normal(first)) = components.next() else { + return false; + }; + let has_child = components.next().is_some(); + if !has_child { + return false; + } + first.to_str().is_some_and(|name| { + TRANSIENT_CONTENT_DIRS.contains(&name) || (name == "pg_wal" && !include_wal_contents) + }) +} + +fn append_directory( + archive: &mut Builder<&mut Vec>, + source: &Path, + archive_path: &Path, +) -> Result<()> { + archive + .append_dir(archive_path, source) + .map_err(|err| Error::Engine(format!("archive directory {}: {err}", source.display()))) +} + +fn append_file( + archive: &mut Builder<&mut Vec>, + source: &Path, + archive_path: &Path, +) -> Result<()> { + let mut file = File::open(source) + .map_err(|err| Error::Engine(format!("open {} for backup: {err}", source.display())))?; + archive + .append_file(archive_path, &mut file) + .map_err(|err| Error::Engine(format!("archive file {}: {err}", source.display()))) +} + +fn append_generated_file( + archive: &mut Builder<&mut Vec>, + archive_path: &str, + contents: String, +) -> Result<()> { + let bytes = contents.into_bytes(); + let mut header = Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o600); + header.set_cksum(); + archive + .append_data(&mut header, archive_path, Cursor::new(bytes)) + .map_err(|err| Error::Engine(format!("archive generated file {archive_path}: {err}"))) +} + +fn ensure_simple_query_ok(response: ProtocolResponse, context: &str) -> Result<()> { + for message in BackendMessages::new(response.as_bytes()) { + let (tag, body) = message?; + if tag == b'E' { + return Err(Error::Engine(format!( + "{context} failed: {}", + postgres_error_message(body) + ))); + } + } + Ok(()) +} + +fn first_data_row(response: ProtocolResponse, context: &str) -> Result>>> { + for message in BackendMessages::new(response.as_bytes()) { + let (tag, body) = message?; + match tag { + b'D' => return parse_data_row(body), + b'E' => { + return Err(Error::Engine(format!( + "{context} failed: {}", + postgres_error_message(body) + ))); + } + _ => {} + } + } + Err(Error::Engine(format!("{context} returned no data row"))) +} + +fn parse_data_row(mut body: &[u8]) -> Result>>> { + let columns = read_i16(&mut body)? as usize; + let mut values = Vec::with_capacity(columns); + for _ in 0..columns { + let len = read_i32(&mut body)?; + if len == -1 { + values.push(None); + } else if len < 0 { + return Err(Error::Engine(format!( + "invalid DataRow column length {len}" + ))); + } else { + let len = len as usize; + if body.len() < len { + return Err(Error::Engine("truncated DataRow column value".to_owned())); + } + values.push(Some(body[..len].to_vec())); + body = &body[len..]; + } + } + Ok(values) +} + +fn postgres_error_message(mut body: &[u8]) -> String { + let mut severity = None; + let mut message = None; + while let Some((&field_type, rest)) = body.split_first() { + if field_type == 0 { + break; + } + let Some(end) = rest.iter().position(|byte| *byte == 0) else { + break; + }; + let value = String::from_utf8_lossy(&rest[..end]).into_owned(); + match field_type { + b'S' | b'V' if severity.is_none() => severity = Some(value), + b'M' => message = Some(value), + _ => {} + } + body = &rest[end + 1..]; + } + match (severity, message) { + (Some(severity), Some(message)) => format!("{severity}: {message}"), + (None, Some(message)) => message, + _ => "PostgreSQL ErrorResponse".to_owned(), + } +} + +struct BackendMessages<'a> { + bytes: &'a [u8], +} + +impl<'a> BackendMessages<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes } + } +} + +impl<'a> Iterator for BackendMessages<'a> { + type Item = Result<(u8, &'a [u8])>; + + fn next(&mut self) -> Option { + if self.bytes.is_empty() { + return None; + } + if self.bytes.len() < 5 { + self.bytes = &[]; + return Some(Err(Error::Engine( + "truncated PostgreSQL backend message header".to_owned(), + ))); + } + let tag = self.bytes[0]; + let len = i32::from_be_bytes([self.bytes[1], self.bytes[2], self.bytes[3], self.bytes[4]]); + if len < 4 { + self.bytes = &[]; + return Some(Err(Error::Engine(format!( + "invalid PostgreSQL backend message length {len}" + )))); + } + let total_len = 1 + len as usize; + if self.bytes.len() < total_len { + self.bytes = &[]; + return Some(Err(Error::Engine( + "truncated PostgreSQL backend message body".to_owned(), + ))); + } + let body = &self.bytes[5..total_len]; + self.bytes = &self.bytes[total_len..]; + Some(Ok((tag, body))) + } +} + +fn read_i16(bytes: &mut &[u8]) -> Result { + if bytes.len() < 2 { + return Err(Error::Engine("truncated PostgreSQL int16".to_owned())); + } + let value = i16::from_be_bytes([bytes[0], bytes[1]]); + *bytes = &bytes[2..]; + Ok(value) +} + +fn read_i32(bytes: &mut &[u8]) -> Result { + if bytes.len() < 4 { + return Err(Error::Engine("truncated PostgreSQL int32".to_owned())); + } + let value = i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + *bytes = &bytes[4..]; + Ok(value) +} + +fn sorted_read_dir(path: &Path) -> Result> { + let mut entries = fs::read_dir(path) + .map_err(|err| Error::Engine(format!("read directory {}: {err}", path.display())))? + .collect::>>() + .map_err(|err| { + Error::Engine(format!("read directory entry in {}: {err}", path.display())) + })?; + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries) +} + +struct BackupStopFiles { + backup_label: String, + tablespace_map: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restore_rejects_symlink_archive_entries() { + let artifact = archive_with_link_entry(EntryType::symlink()); + let root = unique_temp_root("liboliphaunt-restore-symlink-entry"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("is a link"), + "unexpected symlink-entry restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_hardlink_archive_entries() { + let artifact = archive_with_link_entry(EntryType::hard_link()); + let root = unique_temp_root("liboliphaunt-restore-hardlink-entry"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("is a link"), + "unexpected hardlink-entry restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_fifo_archive_entries() { + let artifact = archive_with_special_entry(EntryType::fifo()); + let root = unique_temp_root("liboliphaunt-restore-fifo-entry"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported tar entry type Fifo"), + "unexpected fifo-entry restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_directory_entries_with_payload() { + let artifact = archive_with_nonzero_directory_entry(); + let root = unique_temp_root("liboliphaunt-restore-nonzero-dir"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("directory entry pgdata/base/nonzero-dir has non-zero size"), + "unexpected nonzero-dir restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_invalid_tar_checksum() { + let artifact = archive_with_invalid_header_checksum(); + let root = unique_temp_root("liboliphaunt-restore-invalid-checksum"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("invalid tar checksum"), + "unexpected checksum restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_invalid_tar_checksum_field() { + let artifact = archive_with_invalid_checksum_field(); + let root = unique_temp_root("liboliphaunt-restore-invalid-checksum-field"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("invalid tar checksum field"), + "unexpected checksum-field restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_invalid_tar_magic() { + let artifact = archive_with_invalid_header_magic(); + let root = unique_temp_root("liboliphaunt-restore-invalid-magic"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("unsupported tar header format"), + "unexpected tar-format restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_invalid_tar_size_field() { + let artifact = archive_with_invalid_numeric_header_field(124); + let root = unique_temp_root("liboliphaunt-restore-invalid-size-field"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("invalid tar size field"), + "unexpected tar-size restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_invalid_tar_mode_field() { + let artifact = archive_with_invalid_numeric_header_field(100); + let root = unique_temp_root("liboliphaunt-restore-invalid-mode-field"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("invalid tar mode field"), + "unexpected tar-mode restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_invalid_tar_ignored_metadata_fields() { + for (field_offset, label) in [(108, "uid"), (116, "gid"), (136, "mtime")] { + let artifact = archive_with_invalid_numeric_header_field(field_offset); + let root = unique_temp_root(&format!("liboliphaunt-restore-invalid-{label}-field")); + let error = + restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("invalid tar {label} field")), + "unexpected tar-{label} restore error: {error}" + ); + assert!(!root.exists()); + } + } + + #[test] + fn restore_rejects_invalid_tar_string_fields() { + for (field_offset, label) in [ + ("pgdata/PG_VERSION".len() + 1, "name"), + (158, "linkname"), + (346, "prefix"), + ] { + let artifact = archive_with_invalid_string_header_field(field_offset); + let root = unique_temp_root(&format!("liboliphaunt-restore-invalid-{label}-field")); + let error = + restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("invalid tar {label} field")), + "unexpected tar-{label} restore error: {error}" + ); + assert!(!root.exists()); + } + } + + #[test] + fn restore_rejects_truncated_tar_terminator() { + let artifact = archive_with_truncated_terminator(); + let root = unique_temp_root("liboliphaunt-restore-truncated-terminator"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("final tar zero block"), + "unexpected truncated-terminator restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_trailing_data_after_tar_terminator() { + let artifact = archive_with_trailing_data_after_terminator(); + let root = unique_temp_root("liboliphaunt-restore-trailing-data"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("trailing data after tar terminator"), + "unexpected trailing-data restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_duplicate_archive_entries() { + let artifact = archive_with_duplicate_entry("pgdata/PG_VERSION"); + let root = unique_temp_root("liboliphaunt-restore-duplicate-entry"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("duplicate entry pgdata/PG_VERSION"), + "unexpected duplicate-entry restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_duplicate_canonical_archive_entries() { + let artifact = archive_with_duplicate_entry("pgdata/./PG_VERSION"); + let root = unique_temp_root("liboliphaunt-restore-duplicate-canonical-entry"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("duplicate entry pgdata/PG_VERSION"), + "unexpected canonical duplicate-entry restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_archive_entries_nested_under_file_entries() { + let artifact = archive_with_file_tree_collision(true); + let root = unique_temp_root("liboliphaunt-restore-file-ancestor-collision"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("entry pgdata/base/child is nested under file entry pgdata/base"), + "unexpected file-ancestor collision restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_file_entries_that_replace_seen_subtrees() { + let artifact = archive_with_file_tree_collision(false); + let root = unique_temp_root("liboliphaunt-restore-file-child-collision"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("file entry pgdata/base conflicts with existing child entries"), + "unexpected file-child collision restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_writes_to_canonical_archive_paths() { + let artifact = archive_with_canonicalized_required_path(); + let root = unique_temp_root("liboliphaunt-restore-canonical-output"); + let restored = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap(); + assert_eq!(restored, root); + assert_eq!( + fs::read(root.join("pgdata/global/pg_control")).unwrap(), + b"control" + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn restore_rejects_regular_file_with_link_metadata() { + let artifact = archive_with_regular_file_link_metadata(); + let root = unique_temp_root("liboliphaunt-restore-regular-link-metadata"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error.to_string().contains("unexpected link target"), + "unexpected regular-file link metadata restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn annotated_physical_archive_includes_root_and_backup_manifests() { + let root = unique_temp_root("liboliphaunt-backup-metadata"); + let pgdata = root.join("pgdata"); + fs::create_dir_all(&pgdata).unwrap(); + fs::write(pgdata.join("PG_VERSION"), b"18\n").unwrap(); + + let annotated = annotate_physical_archive_backup( + valid_test_archive(), + &pgdata, + &[Extension::Hstore, Extension::Vector], + |_request| Ok(metadata_response()), + ) + .unwrap(); + let entries = validate_physical_archive_entries(annotated.bytes.as_slice()).unwrap(); + assert!(archive_contains_path( + &entries, + Path::new(NATIVE_ROOT_MANIFEST_FILE) + )); + assert!(archive_contains_path( + &entries, + Path::new(PHYSICAL_ARCHIVE_MANIFEST_PATH) + )); + + let backup_manifest = archive_text_file( + annotated.bytes.as_slice(), + &entries, + Path::new(PHYSICAL_ARCHIVE_MANIFEST_PATH), + ) + .unwrap() + .unwrap(); + assert!(backup_manifest.contains("archiveLayout=oliphaunt-physical-archive-v1\n")); + assert!(backup_manifest.contains("postgresMajor=18\n")); + assert!(backup_manifest.contains("postgresVersionNum=180000\n")); + assert!(backup_manifest.contains("serverEncoding=UTF8\n")); + assert!(backup_manifest.contains("selectedExtensions=hstore,vector\n")); + assert!(backup_manifest.contains("installedExtensions=plpgsql,vector\n")); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn restore_rejects_incompatible_root_manifest_before_materializing_target() { + let artifact = archive_with_root_manifest( + b"layout=oliphaunt-root-v1\nproduct=oliphaunt\npostgresMajor=17\npgdata=pgdata\npgdataVersion=18\n", + ); + let root = unique_temp_root("liboliphaunt-restore-incompatible-root-manifest"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("postgresMajor='17', expected '18'"), + "unexpected incompatible root-manifest restore error: {error}" + ); + assert!(!root.exists()); + } + + #[test] + fn restore_rejects_incompatible_backup_manifest_before_materializing_target() { + let artifact = archive_with_backup_manifest( + b"archiveLayout=oliphaunt-physical-archive-v1\nproduct=oliphaunt\npostgresMajor=17\npgdataVersion=18\npostgresVersionNum=170000\nserverEncoding=UTF8\nlcCollate=C\nlcCtype=C\ndataChecksums=off\nsharedPreloadLibraries=\nrequiredPreloadLibraries=\nselectedExtensions=\ninstalledExtensions=plpgsql\n", + ); + let root = unique_temp_root("liboliphaunt-restore-incompatible-backup-manifest"); + let error = restore_backup(RestoreRequest::physical_archive(&root, artifact)).unwrap_err(); + assert!( + error + .to_string() + .contains("postgresMajor='17', expected '18'"), + "unexpected incompatible backup-manifest restore error: {error}" + ); + assert!(!root.exists()); + } + + #[cfg(unix)] + #[test] + fn physical_archive_rejects_symlinked_pgdata_entries() { + let root = unique_temp_root("liboliphaunt-backup-symlink-entry"); + let pgdata = root.join("pgdata"); + fs::create_dir_all(pgdata.join("pg_tblspc")).unwrap(); + fs::write(pgdata.join("PG_VERSION"), b"18\n").unwrap(); + std::os::unix::fs::symlink( + root.join("external-tablespace"), + pgdata.join("pg_tblspc/16384"), + ) + .unwrap(); + + let mut calls = 0usize; + let error = physical_archive_backup(&pgdata, |request| { + calls += 1; + let sql = String::from_utf8_lossy(request.as_bytes()); + if sql.contains("pg_backup_stop") { + Ok(stop_backup_response()) + } else { + Ok(ProtocolResponse::new(Vec::new())) + } + }) + .unwrap_err(); + + assert!( + error.to_string().contains("symlinked PGDATA entry"), + "unexpected backup symlink error: {error}" + ); + assert!( + calls >= 2, + "backup failure should still attempt to leave PostgreSQL backup mode" + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn physical_archive_rejects_non_regular_pgdata_entries() { + let root = unique_short_temp_root("lp-bu-sock"); + let pgdata = root.join("pgdata"); + fs::create_dir_all(pgdata.join("base")).unwrap(); + fs::write(pgdata.join("PG_VERSION"), b"18\n").unwrap(); + let socket_path = pgdata.join("base/socket-entry"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + + let mut calls = 0usize; + let error = physical_archive_backup(&pgdata, |request| { + calls += 1; + let sql = String::from_utf8_lossy(request.as_bytes()); + if sql.contains("pg_backup_stop") { + Ok(stop_backup_response()) + } else { + Ok(ProtocolResponse::new(Vec::new())) + } + }) + .unwrap_err(); + + assert!( + error.to_string().contains("non-regular PGDATA entry"), + "unexpected backup non-regular-entry error: {error}" + ); + assert!( + calls >= 2, + "backup failure should still attempt to leave PostgreSQL backup mode" + ); + drop(listener); + let _ = fs::remove_dir_all(root); + } + + fn archive_with_link_entry(entry_type: EntryType) -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + let mut header = Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_size(0); + header.set_mode(0o777); + header.set_cksum(); + archive + .append_link(&mut header, "pgdata/base/link-entry", "pgdata/PG_VERSION") + .unwrap(); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_special_entry(entry_type: EntryType) -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + let mut header = Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_size(0); + header.set_mode(0o600); + header.set_cksum(); + archive + .append_data(&mut header, "pgdata/base/special-entry", Cursor::new([])) + .unwrap(); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_nonzero_directory_entry() -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::dir()); + header.set_size(1); + header.set_mode(0o700); + header.set_cksum(); + archive + .append_data(&mut header, "pgdata/base/nonzero-dir", Cursor::new([b'x'])) + .unwrap(); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_regular_file_link_metadata() -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::file()); + header.set_size(0); + header.set_mode(0o600); + header.set_link_name("pgdata/PG_VERSION").unwrap(); + header.set_cksum(); + archive + .append_data( + &mut header, + "pgdata/base/regular-link-metadata", + Cursor::new([]), + ) + .unwrap(); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_invalid_header_checksum() -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + archive.finish().unwrap(); + } + assert!(bytes.len() >= 512, "test archive must contain a tar header"); + bytes[148] = if bytes[148] == b'0' { b'1' } else { b'0' }; + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_invalid_checksum_field() -> BackupArtifact { + let mut artifact = valid_test_archive(); + assert!( + artifact.bytes.len() >= 512, + "test archive must contain a tar header" + ); + artifact.bytes[148] = b'x'; + artifact + } + + fn archive_with_invalid_header_magic() -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + archive.finish().unwrap(); + } + assert!(bytes.len() >= 512, "test archive must contain a tar header"); + bytes[257] = if bytes[257] == b'u' { b'x' } else { b'u' }; + rewrite_tar_checksum(&mut bytes[..512]); + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_invalid_numeric_header_field(field_offset: usize) -> BackupArtifact { + let mut artifact = valid_test_archive(); + assert!( + artifact.bytes.len() >= 512, + "test archive must contain a tar header" + ); + artifact.bytes[field_offset] = b'x'; + rewrite_tar_checksum(&mut artifact.bytes[..512]); + artifact + } + + fn archive_with_invalid_string_header_field(field_offset: usize) -> BackupArtifact { + let mut artifact = valid_test_archive(); + assert!( + artifact.bytes.len() >= 512, + "test archive must contain a tar header" + ); + artifact.bytes[field_offset] = b'x'; + rewrite_tar_checksum(&mut artifact.bytes[..512]); + artifact + } + + fn archive_with_truncated_terminator() -> BackupArtifact { + let mut artifact = valid_test_archive(); + let len = artifact.bytes.len(); + artifact.bytes.truncate(len - 512); + artifact + } + + fn archive_with_trailing_data_after_terminator() -> BackupArtifact { + let valid = valid_test_archive(); + let len = valid.bytes.len(); + let mut bytes = Vec::with_capacity(len + 1024); + bytes.extend_from_slice(&valid.bytes[..len - 512]); + bytes.extend_from_slice(&[b'x'; 512]); + bytes.extend_from_slice(&valid.bytes[len - 512..]); + bytes.extend_from_slice(&[0; 512]); + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn valid_test_archive() -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_root_manifest(manifest: &'static [u8]) -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + append_test_file(&mut archive, NATIVE_ROOT_MANIFEST_FILE, manifest); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_backup_manifest(manifest: &'static [u8]) -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + append_test_file(&mut archive, PHYSICAL_ARCHIVE_MANIFEST_PATH, manifest); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_canonicalized_required_path() -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_test_file(&mut archive, "pgdata/PG_VERSION", b"18\n"); + append_test_file(&mut archive, "pgdata/./global/pg_control", b"control"); + append_test_file(&mut archive, "pgdata/backup_label", b"label"); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_duplicate_entry(path: &str) -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + append_test_file(&mut archive, path, b"duplicate"); + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn archive_with_file_tree_collision(parent_first: bool) -> BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = Builder::new(&mut bytes); + append_required_test_files(&mut archive); + if parent_first { + append_test_file(&mut archive, "pgdata/base", b"parent-file"); + append_test_file(&mut archive, "pgdata/base/child", b"child-file"); + } else { + append_test_file(&mut archive, "pgdata/base/child", b"child-file"); + append_test_file(&mut archive, "pgdata/base", b"parent-file"); + } + archive.finish().unwrap(); + } + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } + } + + fn rewrite_tar_checksum(header: &mut [u8]) { + assert!( + header.len() >= 512, + "test archive header must be at least one tar block" + ); + header[148..156].fill(b' '); + let checksum = header[..512] + .iter() + .fold(0_u32, |sum, byte| sum + u32::from(*byte)); + let encoded = format!("{checksum:06o}"); + header[148..154].copy_from_slice(encoded.as_bytes()); + header[154] = 0; + header[155] = b' '; + } + + fn append_required_test_files(archive: &mut Builder<&mut Vec>) { + append_test_file(archive, "pgdata/PG_VERSION", b"18\n"); + append_test_file(archive, "pgdata/global/pg_control", b"control"); + append_test_file(archive, "pgdata/backup_label", b"label"); + } + + fn append_test_file(archive: &mut Builder<&mut Vec>, path: &str, bytes: &'static [u8]) { + let mut header = Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o600); + header.set_cksum(); + archive + .append_data(&mut header, path, Cursor::new(bytes)) + .unwrap(); + } + + fn stop_backup_response() -> ProtocolResponse { + let label = b"START WAL LOCATION: 0/1\n"; + let mut row = Vec::new(); + row.extend_from_slice(&2_i16.to_be_bytes()); + row.extend_from_slice(&(label.len() as i32).to_be_bytes()); + row.extend_from_slice(label); + row.extend_from_slice(&(-1_i32).to_be_bytes()); + ProtocolResponse::new(protocol_frame(b'D', &row)) + } + + fn metadata_response() -> ProtocolResponse { + let columns = ["180000", "UTF8", "C", "C", "off", "", "plpgsql,vector"]; + let mut row = Vec::new(); + row.extend_from_slice(&(columns.len() as i16).to_be_bytes()); + for column in columns { + row.extend_from_slice(&(column.len() as i32).to_be_bytes()); + row.extend_from_slice(column.as_bytes()); + } + ProtocolResponse::new(protocol_frame(b'D', &row)) + } + + fn protocol_frame(tag: u8, body: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(1 + 4 + body.len()); + frame.push(tag); + frame.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + frame.extend_from_slice(body); + frame + } + + fn unique_temp_root(prefix: &str) -> PathBuf { + let parent = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("{prefix}-{pid}-{nanos}-{attempt}")); + if !path.exists() { + return path; + } + } + panic!("failed to allocate temp root for {prefix}"); + } + + #[cfg(unix)] + fn unique_short_temp_root(prefix: &str) -> PathBuf { + let parent = Path::new("/tmp"); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("{prefix}-{pid}-{nanos}-{attempt}")); + if !path.exists() { + return path; + } + } + panic!("failed to allocate short temp root for {prefix}"); + } +} diff --git a/src/sdks/rust/src/bin/extension_artifact.rs b/src/sdks/rust/src/bin/extension_artifact.rs new file mode 100644 index 00000000..04eb97dd --- /dev/null +++ b/src/sdks/rust/src/bin/extension_artifact.rs @@ -0,0 +1,516 @@ +use std::env; +use std::path::PathBuf; +use std::process; + +use oliphaunt::{ + NativeExtensionArtifactFormat, NativeExtensionArtifactOptions, + NativeExtensionMobileStaticArchive, NativeExtensionMobileStaticDependencyArchive, + NativeExtensionStaticSymbolAlias, create_prebuilt_extension_artifact, +}; + +fn main() { + match run() { + Ok(()) => {} + Err(error) => { + eprintln!("oliphaunt-extension-artifact: {error}"); + process::exit(2); + } + } +} + +fn run() -> oliphaunt::Result<()> { + let args = ArtifactArgs::parse(env::args().skip(1))?; + if args.help { + print_help(); + return Ok(()); + } + let output = args.output.ok_or_else(|| { + oliphaunt::Error::InvalidConfig("missing required --output ".to_owned()) + })?; + let runtime = args.runtime.ok_or_else(|| { + oliphaunt::Error::InvalidConfig("missing required --runtime ".to_owned()) + })?; + let sql_name = args.sql_name.ok_or_else(|| { + oliphaunt::Error::InvalidConfig("missing required --sql-name ".to_owned()) + })?; + + let mut options = NativeExtensionArtifactOptions::new(output, runtime, sql_name) + .creates_extension(args.creates_extension) + .format(args.format) + .replace_existing(args.force) + .dependencies(args.dependencies) + .data_files(args.data_files) + .shared_preload_libraries(args.shared_preload_libraries) + .mobile_prebuilt(args.mobile_prebuilt) + .mobile_static_archives(args.mobile_static_archives) + .mobile_static_dependency_archives(args.mobile_static_dependency_archives) + .static_symbol_aliases(args.static_symbol_aliases); + if let Some(stem) = args.native_module_stem { + options = options.native_module_stem(stem); + } + if let Some(file) = args.native_module_file { + options = options.native_module_file(file); + } + if let Some(target) = args.native_target { + options = options.native_target(target); + } + if let Some(prefix) = args.static_symbol_prefix { + options = options.static_symbol_prefix(prefix); + } + + let artifact = create_prebuilt_extension_artifact(options)?; + println!("path={}", artifact.path.display()); + println!("sqlName={}", artifact.sql_name); + println!("format={}", artifact_format_label(artifact.format)); + println!( + "manifest={}", + artifact + .manifest_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default() + ); + Ok(()) +} + +struct ArtifactArgs { + output: Option, + runtime: Option, + sql_name: Option, + creates_extension: bool, + native_module_stem: Option, + native_module_file: Option, + native_target: Option, + dependencies: Vec, + data_files: Vec, + shared_preload_libraries: Vec, + mobile_prebuilt: bool, + mobile_static_archives: Vec, + mobile_static_dependency_archives: Vec, + static_symbol_prefix: Option, + static_symbol_aliases: Vec, + format: NativeExtensionArtifactFormat, + force: bool, + help: bool, +} + +impl ArtifactArgs { + fn parse(args: impl IntoIterator) -> oliphaunt::Result { + let mut parsed = Self { + output: None, + runtime: None, + sql_name: None, + creates_extension: true, + native_module_stem: None, + native_module_file: None, + native_target: None, + dependencies: Vec::new(), + data_files: Vec::new(), + shared_preload_libraries: Vec::new(), + mobile_prebuilt: false, + mobile_static_archives: Vec::new(), + mobile_static_dependency_archives: Vec::new(), + static_symbol_prefix: None, + static_symbol_aliases: Vec::new(), + format: NativeExtensionArtifactFormat::Directory, + force: false, + help: false, + }; + + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => parsed.help = true, + "--force" => parsed.force = true, + "--no-create-extension" => parsed.creates_extension = false, + "--mobile-prebuilt" => parsed.mobile_prebuilt = true, + "--no-mobile-prebuilt" => parsed.mobile_prebuilt = false, + "--output" | "-o" => { + parsed.output = Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--runtime" => { + parsed.runtime = Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--sql-name" => { + parsed.sql_name = Some(next_value(&mut args, &arg)?); + } + "--format" => { + parsed.format = parse_format(&next_value(&mut args, &arg)?)?; + } + "--creates-extension" => { + parsed.creates_extension = parse_bool(&next_value(&mut args, &arg)?)?; + } + "--native-module-stem" => { + parsed.native_module_stem = Some(next_value(&mut args, &arg)?); + } + "--native-module-file" => { + parsed.native_module_file = Some(next_value(&mut args, &arg)?); + } + "--native-target" | "--target" => { + parsed.native_target = Some(next_value(&mut args, &arg)?); + } + "--dependency" | "--dependencies" => { + push_strings(&mut parsed.dependencies, &next_value(&mut args, &arg)?); + } + "--data-file" | "--data-files" => { + push_paths(&mut parsed.data_files, &next_value(&mut args, &arg)?); + } + "--shared-preload-library" | "--shared-preload-libraries" => { + push_strings( + &mut parsed.shared_preload_libraries, + &next_value(&mut args, &arg)?, + ); + } + "--mobile-static-archive" | "--mobile-static-archives" => { + push_mobile_static_archives( + &mut parsed.mobile_static_archives, + &next_value(&mut args, &arg)?, + )?; + } + "--mobile-static-dependency-archive" | "--mobile-static-dependency-archives" => { + push_mobile_static_dependency_archives( + &mut parsed.mobile_static_dependency_archives, + &next_value(&mut args, &arg)?, + )?; + } + "--static-symbol-prefix" => { + parsed.static_symbol_prefix = Some(next_value(&mut args, &arg)?); + } + "--static-symbol-alias" | "--static-symbol-aliases" => { + push_static_symbol_aliases( + &mut parsed.static_symbol_aliases, + &next_value(&mut args, &arg)?, + )?; + } + value if value.starts_with("--output=") => { + parsed.output = Some(PathBuf::from(value_without_prefix(value, "--output="))); + } + value if value.starts_with("--runtime=") => { + parsed.runtime = Some(PathBuf::from(value_without_prefix(value, "--runtime="))); + } + value if value.starts_with("--sql-name=") => { + parsed.sql_name = Some(value_without_prefix(value, "--sql-name=").to_owned()); + } + value if value.starts_with("--format=") => { + parsed.format = parse_format(value_without_prefix(value, "--format="))?; + } + value if value.starts_with("--creates-extension=") => { + parsed.creates_extension = + parse_bool(value_without_prefix(value, "--creates-extension="))?; + } + value if value.starts_with("--native-module-stem=") => { + parsed.native_module_stem = + Some(value_without_prefix(value, "--native-module-stem=").to_owned()); + } + value if value.starts_with("--native-module-file=") => { + parsed.native_module_file = + Some(value_without_prefix(value, "--native-module-file=").to_owned()); + } + value if value.starts_with("--native-target=") => { + parsed.native_target = + Some(value_without_prefix(value, "--native-target=").to_owned()); + } + value if value.starts_with("--target=") => { + parsed.native_target = + Some(value_without_prefix(value, "--target=").to_owned()); + } + value if value.starts_with("--dependency=") => { + push_strings( + &mut parsed.dependencies, + value_without_prefix(value, "--dependency="), + ); + } + value if value.starts_with("--dependencies=") => { + push_strings( + &mut parsed.dependencies, + value_without_prefix(value, "--dependencies="), + ); + } + value if value.starts_with("--data-file=") => { + push_paths( + &mut parsed.data_files, + value_without_prefix(value, "--data-file="), + ); + } + value if value.starts_with("--data-files=") => { + push_paths( + &mut parsed.data_files, + value_without_prefix(value, "--data-files="), + ); + } + value if value.starts_with("--shared-preload-library=") => { + push_strings( + &mut parsed.shared_preload_libraries, + value_without_prefix(value, "--shared-preload-library="), + ); + } + value if value.starts_with("--shared-preload-libraries=") => { + push_strings( + &mut parsed.shared_preload_libraries, + value_without_prefix(value, "--shared-preload-libraries="), + ); + } + value if value.starts_with("--mobile-prebuilt=") => { + parsed.mobile_prebuilt = + parse_bool(value_without_prefix(value, "--mobile-prebuilt="))?; + } + value if value.starts_with("--mobile-static-archive=") => { + push_mobile_static_archives( + &mut parsed.mobile_static_archives, + value_without_prefix(value, "--mobile-static-archive="), + )?; + } + value if value.starts_with("--mobile-static-archives=") => { + push_mobile_static_archives( + &mut parsed.mobile_static_archives, + value_without_prefix(value, "--mobile-static-archives="), + )?; + } + value if value.starts_with("--mobile-static-dependency-archive=") => { + push_mobile_static_dependency_archives( + &mut parsed.mobile_static_dependency_archives, + value_without_prefix(value, "--mobile-static-dependency-archive="), + )?; + } + value if value.starts_with("--mobile-static-dependency-archives=") => { + push_mobile_static_dependency_archives( + &mut parsed.mobile_static_dependency_archives, + value_without_prefix(value, "--mobile-static-dependency-archives="), + )?; + } + value if value.starts_with("--static-symbol-prefix=") => { + parsed.static_symbol_prefix = + Some(value_without_prefix(value, "--static-symbol-prefix=").to_owned()); + } + value if value.starts_with("--static-symbol-alias=") => { + push_static_symbol_aliases( + &mut parsed.static_symbol_aliases, + value_without_prefix(value, "--static-symbol-alias="), + )?; + } + value if value.starts_with("--static-symbol-aliases=") => { + push_static_symbol_aliases( + &mut parsed.static_symbol_aliases, + value_without_prefix(value, "--static-symbol-aliases="), + )?; + } + _ => { + return Err(oliphaunt::Error::InvalidConfig(format!( + "unknown argument '{arg}'" + ))); + } + } + } + Ok(parsed) + } +} + +fn next_value(args: &mut impl Iterator, flag: &str) -> oliphaunt::Result { + args.next() + .ok_or_else(|| oliphaunt::Error::InvalidConfig(format!("{flag} requires a value"))) +} + +fn value_without_prefix<'a>(value: &'a str, prefix: &str) -> &'a str { + value.strip_prefix(prefix).expect("prefix was checked") +} + +fn parse_format(value: &str) -> oliphaunt::Result { + match value { + "directory" | "dir" => Ok(NativeExtensionArtifactFormat::Directory), + "tar" => Ok(NativeExtensionArtifactFormat::Tar), + "tar-gz" | "tar.gz" | "tgz" | "gz" => Ok(NativeExtensionArtifactFormat::TarGz), + "tar-zst" | "tar.zst" | "zst" => Ok(NativeExtensionArtifactFormat::TarZst), + _ => Err(oliphaunt::Error::InvalidConfig(format!( + "unknown extension artifact format '{value}'" + ))), + } +} + +fn parse_bool(value: &str) -> oliphaunt::Result { + match value { + "true" | "yes" | "1" => Ok(true), + "false" | "no" | "0" => Ok(false), + _ => Err(oliphaunt::Error::InvalidConfig(format!( + "expected true/false, got '{value}'" + ))), + } +} + +fn push_strings(target: &mut Vec, value: &str) { + for item in split_csv(value) { + target.push(item.to_owned()); + } +} + +fn push_paths(target: &mut Vec, value: &str) { + for item in split_csv(value) { + target.push(PathBuf::from(item)); + } +} + +fn push_mobile_static_archives( + target: &mut Vec, + value: &str, +) -> oliphaunt::Result<()> { + for item in split_csv(value) { + target.push(parse_mobile_static_archive(item)?); + } + Ok(()) +} + +fn parse_mobile_static_archive( + value: &str, +) -> oliphaunt::Result { + let separator = value.find('=').or_else(|| value.find(':')).ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--mobile-static-archive values must use : or =" + .to_owned(), + ) + })?; + let (target, archive) = value.split_at(separator); + let archive = &archive[1..]; + if target.trim().is_empty() || archive.trim().is_empty() { + return Err(oliphaunt::Error::InvalidConfig( + "--mobile-static-archive values must include both target and archive path".to_owned(), + )); + } + Ok(NativeExtensionMobileStaticArchive::new( + target.trim(), + PathBuf::from(archive.trim()), + )) +} + +fn push_mobile_static_dependency_archives( + target: &mut Vec, + value: &str, +) -> oliphaunt::Result<()> { + for item in split_csv(value) { + target.push(parse_mobile_static_dependency_archive(item)?); + } + Ok(()) +} + +fn parse_mobile_static_dependency_archive( + value: &str, +) -> oliphaunt::Result { + let (target_and_name, archive) = if let Some(separator) = value.find('=') { + let (left, right) = value.split_at(separator); + (left, &right[1..]) + } else { + let mut parts = value.splitn(3, ':'); + let target = parts.next().unwrap_or_default(); + let name = parts.next().unwrap_or_default(); + let archive = parts.next().unwrap_or_default(); + if target.trim().is_empty() || name.trim().is_empty() || archive.trim().is_empty() { + return Err(oliphaunt::Error::InvalidConfig( + "--mobile-static-dependency-archive values must use :: or :=".to_owned(), + )); + } + return Ok(NativeExtensionMobileStaticDependencyArchive::new( + target.trim(), + name.trim(), + PathBuf::from(archive.trim()), + )); + }; + let Some((target, name)) = target_and_name.split_once(':') else { + return Err(oliphaunt::Error::InvalidConfig( + "--mobile-static-dependency-archive values must use :: or :=".to_owned(), + )); + }; + if target.trim().is_empty() || name.trim().is_empty() || archive.trim().is_empty() { + return Err(oliphaunt::Error::InvalidConfig( + "--mobile-static-dependency-archive values must include target, name, and archive path" + .to_owned(), + )); + } + Ok(NativeExtensionMobileStaticDependencyArchive::new( + target.trim(), + name.trim(), + PathBuf::from(archive.trim()), + )) +} + +fn push_static_symbol_aliases( + target: &mut Vec, + value: &str, +) -> oliphaunt::Result<()> { + for item in split_csv(value) { + target.push(parse_static_symbol_alias(item)?); + } + Ok(()) +} + +fn parse_static_symbol_alias(value: &str) -> oliphaunt::Result { + let separator = value.find('=').or_else(|| value.find(':')).ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--static-symbol-alias values must use : or =".to_owned(), + ) + })?; + let (sql_symbol, linked_symbol) = value.split_at(separator); + let linked_symbol = &linked_symbol[1..]; + if sql_symbol.trim().is_empty() || linked_symbol.trim().is_empty() { + return Err(oliphaunt::Error::InvalidConfig( + "--static-symbol-alias values must include both SQL and linked C symbols".to_owned(), + )); + } + Ok(NativeExtensionStaticSymbolAlias::new( + sql_symbol.trim(), + linked_symbol.trim(), + )) +} + +fn split_csv(value: &str) -> impl Iterator { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn artifact_format_label(format: NativeExtensionArtifactFormat) -> &'static str { + match format { + NativeExtensionArtifactFormat::Directory => "directory", + NativeExtensionArtifactFormat::Tar => "tar", + NativeExtensionArtifactFormat::TarGz => "tar-gz", + NativeExtensionArtifactFormat::TarZst => "tar-zst", + } +} + +fn print_help() { + println!( + "\ +Create one exact prebuilt Oliphaunt extension artifact from already-built PostgreSQL runtime files. + +Usage: + oliphaunt-extension-artifact --runtime --sql-name --output [--format directory|tar|tar-gz|tar-zst] [options] + +Options: + --native-module-stem Native module stem used by extension SQL + --native-module-file Target-specific file under lib/postgresql + --target Public target id that built the module + --dependency Exact extension dependencies + --data-file Extra files relative to share/postgresql + --shared-preload-library Required shared_preload_libraries entry + --mobile-static-archive : + Include a selected prebuilt iOS/Android .a + --mobile-static-dependency-archive :: + Include a static dependency archive linked + with selected mobile extension archives + --mobile-prebuilt[=yes|no] Require carried mobile static archives + --static-symbol-prefix C symbol prefix for mobile static artifacts + --static-symbol-alias : + Map a SQL C symbol to a linked archive symbol + --creates-extension Whether control/SQL files are required + --no-create-extension Alias for --creates-extension no + --force Replace an existing output path + +The command copies only files declared by the exact SQL extension name and the +explicit metadata above. It never builds PostgreSQL or extension source in an +app project. The resulting directory, .tar, or .tar.zst can be passed to +oliphaunt-resources --prebuilt-extension. Passing --mobile-static-archive marks +the artifact mobile-prebuilt and stores the static archive inside the artifact. +Dependency archives are copied alongside selected mobile static archives and +linked by SDK builds when present. Native-module artifacts must declare a +target so consumers cannot install a module built for a different platform. +" + ); +} diff --git a/src/sdks/rust/src/bin/extension_index.rs b/src/sdks/rust/src/bin/extension_index.rs new file mode 100644 index 00000000..b69da542 --- /dev/null +++ b/src/sdks/rust/src/bin/extension_index.rs @@ -0,0 +1,245 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process; + +use oliphaunt::{ + NativeExtensionArtifactIndexCreateOptions, NativeExtensionArtifactIndexSigningOptions, + create_prebuilt_extension_artifact_index, sign_prebuilt_extension_artifact_index, +}; + +fn main() { + match run() { + Ok(()) => {} + Err(error) => { + eprintln!("oliphaunt-extension-index: {error}"); + process::exit(2); + } + } +} + +fn run() -> oliphaunt::Result<()> { + let args = IndexArgs::parse(env::args().skip(1))?; + if args.help { + print_help(); + return Ok(()); + } + let output = args.output.ok_or_else(|| { + oliphaunt::Error::InvalidConfig("missing required --output ".to_owned()) + })?; + let target = args.target.ok_or_else(|| { + oliphaunt::Error::InvalidConfig("missing required --target ".to_owned()) + })?; + let index = create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(output, target) + .artifacts(args.artifacts) + .maybe_artifact_base_url(args.base_url) + .replace_existing(args.force), + )?; + println!("path={}", index.path.display()); + println!("target={}", index.target); + println!( + "extensions={}", + index + .artifacts + .iter() + .map(|artifact| artifact.sql_name.as_str()) + .collect::>() + .join(",") + ); + println!( + "artifacts={}", + index + .artifacts + .iter() + .map(|artifact| format!( + "{}:{}:{}", + artifact.sql_name, + artifact.path.display(), + artifact.sha256 + )) + .collect::>() + .join(",") + ); + if let Some((key_id, signing_key_hex)) = args.signing_key { + let signature = sign_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexSigningOptions::new(&index.path, key_id, signing_key_hex) + .maybe_signature_path(args.signature) + .replace_existing(args.force), + )?; + println!("signature={}", signature.path.display()); + println!("signatureKeyId={}", signature.key_id); + println!("signaturePublicKey={}", signature.public_key_hex); + } + Ok(()) +} + +struct IndexArgs { + output: Option, + target: Option, + artifacts: Vec, + base_url: Option, + signing_key: Option<(String, String)>, + signature: Option, + force: bool, + help: bool, +} + +impl IndexArgs { + fn parse(args: impl IntoIterator) -> oliphaunt::Result { + let mut parsed = Self { + output: None, + target: None, + artifacts: Vec::new(), + base_url: None, + signing_key: None, + signature: None, + force: false, + help: false, + }; + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => parsed.help = true, + "--force" => parsed.force = true, + "--output" | "-o" => { + parsed.output = Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--target" | "--extension-target" | "--artifact-target" => { + parsed.target = Some(next_value(&mut args, &arg)?); + } + "--artifact" | "--extension-artifact" => { + parsed + .artifacts + .push(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--base-url" | "--artifact-base-url" => { + parsed.base_url = Some(next_value(&mut args, &arg)?); + } + "--signing-key" => { + parsed.signing_key = Some(parse_key_value(&next_value(&mut args, &arg)?)?); + } + "--signing-key-file" => { + parsed.signing_key = Some(read_key_file_value(&next_value(&mut args, &arg)?)?); + } + "--signature" | "--signature-output" => { + parsed.signature = Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + value if value.starts_with("--output=") => { + parsed.output = Some(PathBuf::from(value_without_prefix(value, "--output="))); + } + value if value.starts_with("--target=") => { + parsed.target = Some(value_without_prefix(value, "--target=").to_owned()); + } + value if value.starts_with("--extension-target=") => { + parsed.target = + Some(value_without_prefix(value, "--extension-target=").to_owned()); + } + value if value.starts_with("--artifact-target=") => { + parsed.target = + Some(value_without_prefix(value, "--artifact-target=").to_owned()); + } + value if value.starts_with("--artifact=") => { + parsed + .artifacts + .push(PathBuf::from(value_without_prefix(value, "--artifact="))); + } + value if value.starts_with("--extension-artifact=") => { + parsed.artifacts.push(PathBuf::from(value_without_prefix( + value, + "--extension-artifact=", + ))); + } + value if value.starts_with("--base-url=") => { + parsed.base_url = Some(value_without_prefix(value, "--base-url=").to_owned()); + } + value if value.starts_with("--artifact-base-url=") => { + parsed.base_url = + Some(value_without_prefix(value, "--artifact-base-url=").to_owned()); + } + value if value.starts_with("--signing-key=") => { + parsed.signing_key = Some(parse_key_value(value_without_prefix( + value, + "--signing-key=", + ))?); + } + value if value.starts_with("--signing-key-file=") => { + parsed.signing_key = Some(read_key_file_value(value_without_prefix( + value, + "--signing-key-file=", + ))?); + } + value if value.starts_with("--signature=") => { + parsed.signature = + Some(PathBuf::from(value_without_prefix(value, "--signature="))); + } + value if value.starts_with("--signature-output=") => { + parsed.signature = Some(PathBuf::from(value_without_prefix( + value, + "--signature-output=", + ))); + } + _ => { + return Err(oliphaunt::Error::InvalidConfig(format!( + "unknown argument '{arg}'" + ))); + } + } + } + Ok(parsed) + } +} + +fn next_value(args: &mut impl Iterator, flag: &str) -> oliphaunt::Result { + args.next() + .ok_or_else(|| oliphaunt::Error::InvalidConfig(format!("{flag} requires a value"))) +} + +fn value_without_prefix<'a>(value: &'a str, prefix: &str) -> &'a str { + value.strip_prefix(prefix).expect("prefix was checked") +} + +fn parse_key_value(value: &str) -> oliphaunt::Result<(String, String)> { + let Some((key_id, hex)) = value.split_once(':') else { + return Err(oliphaunt::Error::InvalidConfig( + "key values must use :".to_owned(), + )); + }; + Ok((key_id.to_owned(), hex.trim().to_owned())) +} + +fn read_key_file_value(value: &str) -> oliphaunt::Result<(String, String)> { + let Some((key_id, path)) = value.split_once(':') else { + return Err(oliphaunt::Error::InvalidConfig( + "key file values must use :".to_owned(), + )); + }; + let text = fs::read_to_string(path).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!("read signing key file {path}: {err}")) + })?; + Ok((key_id.to_owned(), text.trim().to_owned())) +} + +fn print_help() { + println!( + "\ +Create a verified Oliphaunt extension artifact index for one target. + +Usage: + oliphaunt-extension-index --output --target --artifact [--artifact ...] [--base-url ] [--signing-key-file :] [--signature ] [--force] + +The index writer validates every artifact manifest, rejects built-in extension +name overrides, computes byte counts and SHA-256 digests, and records relative +artifact paths plus dependency, preload, native-module, and mobile-prebuilt +metadata for catalog discovery. Put the index next to the artifact archives, +then use oliphaunt-resources --extension --extension-index . +Pass --base-url when publishing artifacts through an HTTPS release URL; +consumers can then use oliphaunt-resources --extension-cache to download +and verify missing sidecar artifacts without building extension source. +Pass --signing-key-file to write an Ed25519 detached signature sidecar for the +exact index bytes. The file must contain a hex-encoded 32-byte Ed25519 signing +key. For local automation only, --signing-key : is also +accepted. +" + ); +} diff --git a/src/sdks/rust/src/bin/package_resources.rs b/src/sdks/rust/src/bin/package_resources.rs new file mode 100644 index 00000000..c0877941 --- /dev/null +++ b/src/sdks/rust/src/bin/package_resources.rs @@ -0,0 +1,1274 @@ +use std::env; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process; +use std::time::{SystemTime, UNIX_EPOCH}; + +use flate2::read::GzDecoder; +use oliphaunt::{ + EngineMode, Extension, ExtensionArtifactPolicy, ExtensionModuleAsset, + NativeExtensionArtifactIndexOptions, NativeExtensionArtifactIndexTrustRoot, + NativePrebuiltExtensionArtifact, NativeRuntimeResourceOptions, build_native_runtime_resources, + list_prebuilt_extension_artifact_index_catalog, + resolve_prebuilt_extension_artifacts_from_indexes, +}; +use sha2::{Digest, Sha256}; + +fn main() { + match run() { + Ok(()) => {} + Err(error) => { + eprintln!("oliphaunt-resources: {error}"); + process::exit(2); + } + } +} + +fn run() -> oliphaunt::Result<()> { + let args = PackageArgs::parse(env::args().skip(1))?; + if args.help { + print_help(); + return Ok(()); + } + if args.list_extensions { + print_extension_catalog(&args)?; + return Ok(()); + } + if args.resolve_broker_release_assets { + resolve_broker_release_assets(&args)?; + return Ok(()); + } + if args.resolve_release_assets { + resolve_release_assets(&args)?; + return Ok(()); + } + let output_dir = args.output_dir.ok_or_else(|| { + oliphaunt::Error::InvalidConfig("missing required --output ".to_owned()) + })?; + let extension_target = args + .extension_target + .clone() + .unwrap_or_else(default_extension_artifact_target); + + let mut built_in_extensions = Vec::new(); + let mut indexed_extensions = Vec::new(); + for extension in args.extensions { + if let Some(extension) = Extension::by_release_ready_sql_name(&extension) { + built_in_extensions.push(extension); + } else { + indexed_extensions.push(extension); + } + } + let mut prebuilt_extensions = args.prebuilt_extensions; + if !indexed_extensions.is_empty() { + let resolution = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new(extension_target.clone()) + .indexes(args.extension_indexes) + .maybe_artifact_cache_dir(args.extension_cache_dir) + .trusted_signing_keys(args.trusted_extension_index_keys) + .require_signatures(args.require_signed_extension_indexes) + .extensions(indexed_extensions), + )?; + prebuilt_extensions.extend(resolution.artifacts); + } + + let mut options = NativeRuntimeResourceOptions::new(output_dir) + .mode(args.mode) + .replace_existing(args.force) + .require_mobile_static_registry(args.require_mobile_static_registry) + .mobile_static_module_stems(args.mobile_static_module_stems) + .extension_target(extension_target); + for extension in built_in_extensions { + options = options.extension(extension); + } + for artifact in prebuilt_extensions { + options = options.prebuilt_extension(artifact.root); + } + + let package = build_native_runtime_resources(options)?; + println!("root={}", package.root.display()); + println!("runtimeFiles={}", package.runtime_files.display()); + println!( + "templatePgdataFiles={}", + package.template_pgdata_files.display() + ); + println!("runtimeCacheKey={}", package.runtime_cache_key); + println!("templateCacheKey={}", package.template_cache_key); + println!("extensions={}", package.extension_names.join(",")); + println!( + "mobileStaticRegistryState={}", + match package.mobile_static_registry.state { + oliphaunt::MobileStaticRegistryState::NotRequired => "not-required", + oliphaunt::MobileStaticRegistryState::Complete => "complete", + oliphaunt::MobileStaticRegistryState::Pending => "pending", + } + ); + println!( + "mobileStaticRegistryPending={}", + package.mobile_static_registry.pending_extensions.join(",") + ); + println!( + "mobileStaticRegistryRegistered={}", + package + .mobile_static_registry + .registered_extensions + .join(",") + ); + println!( + "sharedPreloadLibraries={}", + package.shared_preload_libraries.join(",") + ); + println!( + "nativeModuleStems={}", + package.mobile_static_registry.native_module_stems.join(",") + ); + println!( + "staticRegistryManifest={}", + package.static_registry_manifest.display() + ); + println!( + "staticRegistrySource={}", + package + .static_registry_source + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default() + ); + println!("packageSizeReport={}", package.size_report.path.display()); + println!("packageBytes={}", package.size_report.package_bytes); + println!("runtimeBytes={}", package.size_report.runtime_bytes); + println!( + "templatePgdataBytes={}", + package.size_report.template_pgdata_bytes + ); + println!( + "staticRegistryBytes={}", + package.size_report.static_registry_bytes + ); + println!( + "selectedExtensionBytes={}", + package.size_report.selected_extension_bytes + ); + println!( + "extensionBytes={}", + package + .size_report + .extensions + .iter() + .map(|extension| format!("{}:{}", extension.name, extension.bytes)) + .collect::>() + .join(",") + ); + Ok(()) +} + +struct PackageArgs { + output_dir: Option, + mode: EngineMode, + extensions: Vec, + extension_indexes: Vec, + extension_target: Option, + extension_cache_dir: Option, + trusted_extension_index_keys: Vec, + require_signed_extension_indexes: bool, + prebuilt_extensions: Vec, + mobile_static_module_stems: Vec, + force: bool, + require_mobile_static_registry: bool, + resolve_release_assets: bool, + liboliphaunt_version: Option, + release_asset_base_url: Option, + release_asset_cache_dir: Option, + release_asset_target: Option, + release_assets: Vec, + resolve_broker_release_assets: bool, + broker_version: Option, + broker_release_asset_base_url: Option, + broker_release_asset_cache_dir: Option, + broker_release_asset_target: Option, + list_extensions: bool, + help: bool, +} + +impl PackageArgs { + fn parse(args: impl IntoIterator) -> oliphaunt::Result { + let mut parsed = Self { + output_dir: None, + mode: EngineMode::NativeDirect, + extensions: Vec::new(), + extension_indexes: Vec::new(), + extension_target: None, + extension_cache_dir: None, + trusted_extension_index_keys: Vec::new(), + require_signed_extension_indexes: false, + prebuilt_extensions: Vec::new(), + mobile_static_module_stems: Vec::new(), + force: false, + require_mobile_static_registry: false, + resolve_release_assets: false, + liboliphaunt_version: env::var("OLIPHAUNT_LIBOLIPHAUNT_VERSION") + .ok() + .filter(|value| !value.trim().is_empty()), + release_asset_base_url: env::var("OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSET_BASE_URL") + .ok() + .filter(|value| !value.trim().is_empty()), + release_asset_cache_dir: env::var("OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSET_CACHE") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from), + release_asset_target: env::var("OLIPHAUNT_LIBOLIPHAUNT_RELEASE_TARGET") + .ok() + .filter(|value| !value.trim().is_empty()), + release_assets: Vec::new(), + resolve_broker_release_assets: false, + broker_version: env::var("OLIPHAUNT_BROKER_VERSION") + .ok() + .filter(|value| !value.trim().is_empty()), + broker_release_asset_base_url: env::var("OLIPHAUNT_BROKER_RELEASE_ASSET_BASE_URL") + .ok() + .filter(|value| !value.trim().is_empty()), + broker_release_asset_cache_dir: env::var("OLIPHAUNT_BROKER_RELEASE_ASSET_CACHE") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from), + broker_release_asset_target: env::var("OLIPHAUNT_BROKER_RELEASE_TARGET") + .ok() + .filter(|value| !value.trim().is_empty()), + list_extensions: false, + help: false, + }; + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => parsed.help = true, + "--list-extensions" => parsed.list_extensions = true, + "--resolve-release-assets" | "--resolve-liboliphaunt-release" => { + parsed.resolve_release_assets = true; + } + "--resolve-broker-release-assets" | "--resolve-oliphaunt-broker-release" => { + parsed.resolve_broker_release_assets = true; + } + "--force" => parsed.force = true, + "--require-mobile-static-registry" => { + parsed.require_mobile_static_registry = true; + } + "--mobile-static-module" | "--mobile-static-registry-module" => { + let value = next_value(&mut args, &arg)?; + push_mobile_static_module_stems(&mut parsed.mobile_static_module_stems, &value); + } + "--output" | "-o" => { + let value = next_value(&mut args, &arg)?; + parsed.output_dir = Some(PathBuf::from(value)); + } + "--liboliphaunt-native-version" => { + parsed.liboliphaunt_version = Some(next_value(&mut args, &arg)?); + } + "--broker-version" | "--oliphaunt-broker-version" => { + parsed.broker_version = Some(next_value(&mut args, &arg)?); + } + "--release-asset-base-url" => { + parsed.release_asset_base_url = Some(next_value(&mut args, &arg)?); + } + "--broker-release-asset-base-url" => { + parsed.broker_release_asset_base_url = Some(next_value(&mut args, &arg)?); + } + "--release-asset-cache" | "--release-asset-cache-dir" => { + parsed.release_asset_cache_dir = + Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--broker-release-asset-cache" | "--broker-release-asset-cache-dir" => { + parsed.broker_release_asset_cache_dir = + Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--release-asset-target" | "--release-target" => { + parsed.release_asset_target = Some(next_value(&mut args, &arg)?); + } + "--broker-release-target" | "--broker-release-asset-target" => { + parsed.broker_release_asset_target = Some(next_value(&mut args, &arg)?); + } + "--release-asset" => { + parsed.release_assets.push(next_value(&mut args, &arg)?); + parsed.resolve_release_assets = true; + } + "--mode" => { + let value = next_value(&mut args, &arg)?; + parsed.mode = parse_mode(&value)?; + } + "--extension" => { + let value = next_value(&mut args, &arg)?; + push_extension_names(&mut parsed.extensions, &value); + } + "--extension-index" | "--external-extension-index" => { + let value = next_value(&mut args, &arg)?; + parsed.extension_indexes.push(PathBuf::from(value)); + } + "--extension-target" | "--artifact-target" => { + parsed.extension_target = Some(next_value(&mut args, &arg)?); + } + "--extension-cache" | "--extension-artifact-cache" => { + parsed.extension_cache_dir = Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--trusted-extension-index-key" => { + let (key_id, key) = parse_key_value(&next_value(&mut args, &arg)?)?; + parsed + .trusted_extension_index_keys + .push(NativeExtensionArtifactIndexTrustRoot::new(key_id, key)); + parsed.require_signed_extension_indexes = true; + } + "--trusted-extension-index-key-file" => { + let (key_id, key) = read_key_file_value(&next_value(&mut args, &arg)?)?; + parsed + .trusted_extension_index_keys + .push(NativeExtensionArtifactIndexTrustRoot::new(key_id, key)); + parsed.require_signed_extension_indexes = true; + } + "--require-signed-extension-index" | "--require-signed-extension-indexes" => { + parsed.require_signed_extension_indexes = true; + } + "--prebuilt-extension" | "--prebuilt-extension-artifact" => { + let value = next_value(&mut args, &arg)?; + parsed + .prebuilt_extensions + .push(NativePrebuiltExtensionArtifact::new(PathBuf::from(value))); + } + value if value.starts_with("--output=") => { + parsed.output_dir = + Some(PathBuf::from(value_without_prefix(value, "--output="))); + } + value if value.starts_with("--liboliphaunt-native-version=") => { + parsed.liboliphaunt_version = Some( + value_without_prefix(value, "--liboliphaunt-native-version=").to_owned(), + ); + } + value if value.starts_with("--broker-version=") => { + parsed.broker_version = + Some(value_without_prefix(value, "--broker-version=").to_owned()); + } + value if value.starts_with("--oliphaunt-broker-version=") => { + parsed.broker_version = + Some(value_without_prefix(value, "--oliphaunt-broker-version=").to_owned()); + } + value if value.starts_with("--release-asset-base-url=") => { + parsed.release_asset_base_url = + Some(value_without_prefix(value, "--release-asset-base-url=").to_owned()); + } + value if value.starts_with("--broker-release-asset-base-url=") => { + parsed.broker_release_asset_base_url = Some( + value_without_prefix(value, "--broker-release-asset-base-url=").to_owned(), + ); + } + value if value.starts_with("--release-asset-cache=") => { + parsed.release_asset_cache_dir = Some(PathBuf::from(value_without_prefix( + value, + "--release-asset-cache=", + ))); + } + value if value.starts_with("--release-asset-cache-dir=") => { + parsed.release_asset_cache_dir = Some(PathBuf::from(value_without_prefix( + value, + "--release-asset-cache-dir=", + ))); + } + value if value.starts_with("--broker-release-asset-cache=") => { + parsed.broker_release_asset_cache_dir = Some(PathBuf::from( + value_without_prefix(value, "--broker-release-asset-cache="), + )); + } + value if value.starts_with("--broker-release-asset-cache-dir=") => { + parsed.broker_release_asset_cache_dir = Some(PathBuf::from( + value_without_prefix(value, "--broker-release-asset-cache-dir="), + )); + } + value if value.starts_with("--release-asset-target=") => { + parsed.release_asset_target = + Some(value_without_prefix(value, "--release-asset-target=").to_owned()); + } + value if value.starts_with("--release-target=") => { + parsed.release_asset_target = + Some(value_without_prefix(value, "--release-target=").to_owned()); + } + value if value.starts_with("--broker-release-target=") => { + parsed.broker_release_asset_target = + Some(value_without_prefix(value, "--broker-release-target=").to_owned()); + } + value if value.starts_with("--broker-release-asset-target=") => { + parsed.broker_release_asset_target = Some( + value_without_prefix(value, "--broker-release-asset-target=").to_owned(), + ); + } + value if value.starts_with("--release-asset=") => { + parsed + .release_assets + .push(value_without_prefix(value, "--release-asset=").to_owned()); + parsed.resolve_release_assets = true; + } + value if value.starts_with("--mode=") => { + parsed.mode = parse_mode(value_without_prefix(value, "--mode="))?; + } + value if value.starts_with("--extension=") => { + push_extension_names( + &mut parsed.extensions, + value_without_prefix(value, "--extension="), + ); + } + value if value.starts_with("--extension-index=") => { + parsed + .extension_indexes + .push(PathBuf::from(value_without_prefix( + value, + "--extension-index=", + ))); + } + value if value.starts_with("--external-extension-index=") => { + parsed + .extension_indexes + .push(PathBuf::from(value_without_prefix( + value, + "--external-extension-index=", + ))); + } + value if value.starts_with("--extension-target=") => { + parsed.extension_target = + Some(value_without_prefix(value, "--extension-target=").to_owned()); + } + value if value.starts_with("--artifact-target=") => { + parsed.extension_target = + Some(value_without_prefix(value, "--artifact-target=").to_owned()); + } + value if value.starts_with("--extension-cache=") => { + parsed.extension_cache_dir = Some(PathBuf::from(value_without_prefix( + value, + "--extension-cache=", + ))); + } + value if value.starts_with("--extension-artifact-cache=") => { + parsed.extension_cache_dir = Some(PathBuf::from(value_without_prefix( + value, + "--extension-artifact-cache=", + ))); + } + value if value.starts_with("--trusted-extension-index-key=") => { + let (key_id, key) = parse_key_value(value_without_prefix( + value, + "--trusted-extension-index-key=", + ))?; + parsed + .trusted_extension_index_keys + .push(NativeExtensionArtifactIndexTrustRoot::new(key_id, key)); + parsed.require_signed_extension_indexes = true; + } + value if value.starts_with("--trusted-extension-index-key-file=") => { + let (key_id, key) = read_key_file_value(value_without_prefix( + value, + "--trusted-extension-index-key-file=", + ))?; + parsed + .trusted_extension_index_keys + .push(NativeExtensionArtifactIndexTrustRoot::new(key_id, key)); + parsed.require_signed_extension_indexes = true; + } + value if value.starts_with("--prebuilt-extension=") => { + parsed + .prebuilt_extensions + .push(NativePrebuiltExtensionArtifact::new(PathBuf::from( + value_without_prefix(value, "--prebuilt-extension="), + ))); + } + value if value.starts_with("--prebuilt-extension-artifact=") => { + parsed + .prebuilt_extensions + .push(NativePrebuiltExtensionArtifact::new(PathBuf::from( + value_without_prefix(value, "--prebuilt-extension-artifact="), + ))); + } + value if value.starts_with("--mobile-static-module=") => { + push_mobile_static_module_stems( + &mut parsed.mobile_static_module_stems, + value_without_prefix(value, "--mobile-static-module="), + ); + } + value if value.starts_with("--mobile-static-registry-module=") => { + push_mobile_static_module_stems( + &mut parsed.mobile_static_module_stems, + value_without_prefix(value, "--mobile-static-registry-module="), + ); + } + _ => { + return Err(oliphaunt::Error::InvalidConfig(format!( + "unknown argument '{arg}'" + ))); + } + } + } + Ok(parsed) + } +} + +fn resolve_release_assets(args: &PackageArgs) -> oliphaunt::Result<()> { + let version = args.liboliphaunt_version.as_deref().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--resolve-release-assets requires --liboliphaunt-native-version ".to_owned(), + ) + })?; + validate_release_version(version, "liboliphaunt")?; + let base_url = args.release_asset_base_url.clone().unwrap_or_else(|| { + format!( + "https://github.com/f0rr0/oliphaunt/releases/download/liboliphaunt-native-v{version}" + ) + }); + let cache_dir = args + .release_asset_cache_dir + .clone() + .unwrap_or_else(default_release_asset_cache_dir) + .join(version); + fs::create_dir_all(&cache_dir).map_err(|err| { + oliphaunt::Error::Engine(format!( + "create liboliphaunt release asset cache {}: {err}", + cache_dir.display() + )) + })?; + + let checksum_name = format!("liboliphaunt-{version}-release-assets.sha256"); + let checksum_path = + download_release_asset(&base_url, &checksum_name, &cache_dir, "liboliphaunt")?; + let checksums = parse_release_checksum_file(&checksum_path, "liboliphaunt")?; + let release_target = args + .release_asset_target + .clone() + .unwrap_or_else(|| default_release_asset_target().to_owned()); + let mut assets = release_asset_names_for_target(version, &release_target)?; + assets.extend(args.release_assets.iter().cloned()); + assets.sort(); + assets.dedup(); + for asset in &assets { + let path = download_release_asset(&base_url, asset, &cache_dir, "liboliphaunt")?; + verify_release_asset_checksum(&checksums, asset, &path, "liboliphaunt")?; + } + verify_release_asset_checksum(&checksums, &checksum_name, &checksum_path, "liboliphaunt").ok(); + + if let Some(output_dir) = &args.output_dir { + let runtime_asset = format!("liboliphaunt-{version}-runtime-resources.tar.gz"); + let runtime_path = cache_dir.join(&runtime_asset); + if runtime_path.is_file() { + extract_runtime_resources_archive(&runtime_path, output_dir, args.force)?; + } + } + + println!("liboliphauntReleaseVersion={version}"); + println!("liboliphauntReleaseAssetBaseUrl={base_url}"); + println!("liboliphauntReleaseAssetCache={}", cache_dir.display()); + println!("liboliphauntReleaseAssets={}", assets.join(",")); + Ok(()) +} + +fn resolve_broker_release_assets(args: &PackageArgs) -> oliphaunt::Result<()> { + let version = args.broker_version.as_deref().ok_or_else(|| { + oliphaunt::Error::InvalidConfig( + "--resolve-broker-release-assets requires --broker-version ".to_owned(), + ) + })?; + validate_release_version(version, "oliphaunt-broker")?; + let base_url = args + .broker_release_asset_base_url + .clone() + .or_else(|| args.release_asset_base_url.clone()) + .unwrap_or_else(|| { + format!( + "https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-broker-v{version}" + ) + }); + let cache_dir = args + .broker_release_asset_cache_dir + .clone() + .or_else(|| args.release_asset_cache_dir.clone()) + .unwrap_or_else(default_broker_release_asset_cache_dir) + .join(version); + fs::create_dir_all(&cache_dir).map_err(|err| { + oliphaunt::Error::Engine(format!( + "create oliphaunt-broker release asset cache {}: {err}", + cache_dir.display() + )) + })?; + + let checksum_name = format!("oliphaunt-broker-{version}-release-assets.sha256"); + let checksum_path = + download_release_asset(&base_url, &checksum_name, &cache_dir, "oliphaunt-broker")?; + let checksums = parse_release_checksum_file(&checksum_path, "oliphaunt-broker")?; + let release_target = args + .broker_release_asset_target + .clone() + .or_else(|| args.release_asset_target.clone()) + .unwrap_or_else(default_broker_release_asset_target); + let asset = broker_release_asset_name_for_target(version, &release_target)?; + let asset_path = download_release_asset(&base_url, &asset, &cache_dir, "oliphaunt-broker")?; + verify_release_asset_checksum(&checksums, &asset, &asset_path, "oliphaunt-broker")?; + + if let Some(output_dir) = &args.output_dir { + extract_broker_release_archive(&asset_path, output_dir, args.force)?; + } + + println!("oliphauntBrokerReleaseVersion={version}"); + println!("oliphauntBrokerReleaseAssetBaseUrl={base_url}"); + println!("oliphauntBrokerReleaseAssetCache={}", cache_dir.display()); + println!("oliphauntBrokerReleaseAssets={asset}"); + Ok(()) +} + +fn validate_release_version(version: &str, product_label: &str) -> oliphaunt::Result<()> { + let valid = version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); + if !valid || version.is_empty() { + return Err(oliphaunt::Error::InvalidConfig(format!( + "invalid {product_label} release version '{version}'" + ))); + } + Ok(()) +} + +fn default_release_asset_cache_dir() -> PathBuf { + if let Ok(value) = env::var("XDG_CACHE_HOME") { + if !value.trim().is_empty() { + return PathBuf::from(value).join("oliphaunt/release-assets/liboliphaunt"); + } + } + if let Ok(value) = env::var("HOME") { + if !value.trim().is_empty() { + return PathBuf::from(value).join(".cache/oliphaunt/release-assets/liboliphaunt"); + } + } + env::temp_dir().join("oliphaunt/release-assets/liboliphaunt") +} + +fn default_broker_release_asset_cache_dir() -> PathBuf { + if let Ok(value) = env::var("XDG_CACHE_HOME") { + if !value.trim().is_empty() { + return PathBuf::from(value).join("oliphaunt/release-assets/oliphaunt-broker"); + } + } + if let Ok(value) = env::var("HOME") { + if !value.trim().is_empty() { + return PathBuf::from(value).join(".cache/oliphaunt/release-assets/oliphaunt-broker"); + } + } + env::temp_dir().join("oliphaunt/release-assets/oliphaunt-broker") +} + +fn default_release_asset_target() -> &'static str { + match (env::consts::OS, env::consts::ARCH) { + ("macos", "aarch64") => "macos-arm64", + ("linux", "x86_64") => "linux-x64-gnu", + ("linux", "aarch64") => "linux-arm64-gnu", + ("windows", "x86_64") => "windows-x64-msvc", + ("ios", _) => "ios-xcframework", + ("android", "aarch64") => "android-arm64-v8a", + ("android", "x86_64") => "android-x86_64", + _ => "runtime-resources", + } +} + +fn default_broker_release_asset_target() -> String { + match (env::consts::OS, env::consts::ARCH) { + ("macos", "aarch64") => "macos-arm64", + ("linux", "x86_64") => "linux-x64-gnu", + ("linux", "aarch64") => "linux-arm64-gnu", + ("windows", "x86_64") => "windows-x64-msvc", + _ => "unsupported", + } + .to_owned() +} + +fn release_asset_names_for_target(version: &str, target: &str) -> oliphaunt::Result> { + let mut assets = vec![format!("liboliphaunt-{version}-runtime-resources.tar.gz")]; + match target { + "runtime-resources" | "runtime-only" => {} + "macos-arm64" => assets.push(format!("liboliphaunt-{version}-macos-arm64.tar.gz")), + "linux-x64-gnu" => assets.push(format!("liboliphaunt-{version}-linux-x64-gnu.tar.gz")), + "linux-arm64-gnu" => { + assets.push(format!("liboliphaunt-{version}-linux-arm64-gnu.tar.gz")); + } + "windows-x64-msvc" => { + assets.push(format!("liboliphaunt-{version}-windows-x64-msvc.zip")); + } + "ios-xcframework" | "ios" => { + assets.push(format!("liboliphaunt-{version}-ios-xcframework.tar.gz")); + } + "android-arm64-v8a" | "arm64-v8a" => { + assets.push(format!("liboliphaunt-{version}-android-arm64-v8a.tar.gz")); + } + "android-x86_64" | "x86_64" => { + assets.push(format!("liboliphaunt-{version}-android-x86_64.tar.gz")); + } + value => { + return Err(oliphaunt::Error::InvalidConfig(format!( + "unsupported liboliphaunt release asset target '{value}'" + ))); + } + } + Ok(assets) +} + +fn broker_release_asset_name_for_target(version: &str, target: &str) -> oliphaunt::Result { + match target { + "macos-arm64" => Ok(format!("oliphaunt-broker-{version}-macos-arm64.tar.gz")), + "linux-x64-gnu" => Ok(format!("oliphaunt-broker-{version}-linux-x64-gnu.tar.gz")), + "linux-arm64-gnu" => Ok(format!("oliphaunt-broker-{version}-linux-arm64-gnu.tar.gz")), + "windows-x64-msvc" => Ok(format!("oliphaunt-broker-{version}-windows-x64-msvc.zip")), + value => Err(oliphaunt::Error::InvalidConfig(format!( + "unsupported oliphaunt-broker release asset target '{value}'" + ))), + } +} + +fn download_release_asset( + base_url: &str, + asset: &str, + cache_dir: &Path, + product_label: &str, +) -> oliphaunt::Result { + if asset.contains('/') || asset.contains('\\') || asset == "." || asset == ".." { + return Err(oliphaunt::Error::InvalidConfig(format!( + "release asset name must be a plain file name: {asset}" + ))); + } + let output = cache_dir.join(asset); + if output.is_file() { + return Ok(output); + } + let tmp_path = cache_dir.join(format!(".{asset}.{}.tmp", unique_timestamp_suffix())); + let url = format!("{}/{}", base_url.trim_end_matches('/'), asset); + let result = download_release_asset_url(&url, &tmp_path); + if let Err(error) = result { + let _ = fs::remove_file(&tmp_path); + return Err(error); + } + fs::rename(&tmp_path, &output).map_err(|err| { + oliphaunt::Error::Engine(format!( + "publish downloaded {product_label} release asset {} to {}: {err}", + url, + output.display() + )) + })?; + Ok(output) +} + +fn download_release_asset_url(url: &str, output: &Path) -> oliphaunt::Result<()> { + if let Some(path) = url.strip_prefix("file://") { + let source = PathBuf::from(path); + fs::copy(&source, output).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "copy release asset URL {} to {}: {err}", + url, + output.display() + )) + })?; + return Ok(()); + } + download_release_asset_https_url(url, output) +} + +#[cfg(feature = "extension-download")] +fn download_release_asset_https_url(url: &str, output: &Path) -> oliphaunt::Result<()> { + let response = ureq::get(url).call().map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "download liboliphaunt release asset URL {url}: {err}" + )) + })?; + let mut reader = response.into_reader(); + let mut file = File::create(output) + .map_err(|err| oliphaunt::Error::Engine(format!("create {}: {err}", output.display())))?; + std::io::copy(&mut reader, &mut file).map_err(|err| { + oliphaunt::Error::Engine(format!( + "write downloaded liboliphaunt release asset URL {} to {}: {err}", + url, + output.display() + )) + })?; + Ok(()) +} + +#[cfg(not(feature = "extension-download"))] +fn download_release_asset_https_url(url: &str, _output: &Path) -> oliphaunt::Result<()> { + Err(oliphaunt::Error::InvalidConfig(format!( + "liboliphaunt release asset URL {url} requires an oliphaunt-resources binary built with the extension-download feature" + ))) +} + +fn parse_release_checksum_file( + path: &Path, + product_label: &str, +) -> oliphaunt::Result> { + let text = fs::read_to_string(path).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "read {product_label} release checksum file {}: {err}", + path.display() + )) + })?; + let mut checksums = Vec::new(); + for (index, line) in text.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + let digest = parts.next().unwrap_or_default(); + let filename = parts.next().unwrap_or_default(); + if parts.next().is_some() || !filename.starts_with("./") { + return Err(oliphaunt::Error::InvalidConfig(format!( + "malformed {product_label} release checksum line {} in {}: {line}", + index + 1, + path.display() + ))); + } + checksums.push((filename[2..].to_owned(), digest.to_owned())); + } + Ok(checksums) +} + +fn verify_release_asset_checksum( + checksums: &[(String, String)], + asset: &str, + path: &Path, + product_label: &str, +) -> oliphaunt::Result<()> { + let expected = checksums + .iter() + .find_map(|(name, digest)| (name == asset).then_some(digest)) + .ok_or_else(|| { + oliphaunt::Error::InvalidConfig(format!( + "{product_label} release checksum manifest does not cover {asset}" + )) + })?; + let actual = sha256_file(path)?; + if expected != &actual { + return Err(oliphaunt::Error::InvalidConfig(format!( + "{product_label} release asset checksum mismatch for {asset}: expected {expected}, got {actual}" + ))); + } + Ok(()) +} + +fn sha256_file(path: &Path) -> oliphaunt::Result { + let mut file = File::open(path).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!("open {}: {err}", path.display())) + })?; + let mut digest = Sha256::new(); + let mut buffer = [0; 8192]; + loop { + let read = file.read(&mut buffer).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!("hash {}: {err}", path.display())) + })?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn extract_runtime_resources_archive( + archive_path: &Path, + output_dir: &Path, + replace_existing: bool, +) -> oliphaunt::Result<()> { + let resource_root = output_dir.join("oliphaunt"); + if resource_root.exists() { + if !replace_existing { + return Err(oliphaunt::Error::InvalidConfig(format!( + "runtime-resource output already exists at {}; pass --force to replace it", + resource_root.display() + ))); + } + fs::remove_dir_all(&resource_root).map_err(|err| { + oliphaunt::Error::Engine(format!("remove {}: {err}", resource_root.display())) + })?; + } + fs::create_dir_all(output_dir).map_err(|err| { + oliphaunt::Error::Engine(format!("create {}: {err}", output_dir.display())) + })?; + let file = File::open(archive_path).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!("open {}: {err}", archive_path.display())) + })?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + archive.unpack(output_dir).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "extract liboliphaunt runtime resources {} into {}: {err}", + archive_path.display(), + output_dir.display() + )) + })?; + Ok(()) +} + +fn extract_broker_release_archive( + archive_path: &Path, + output_dir: &Path, + replace_existing: bool, +) -> oliphaunt::Result<()> { + prepare_archive_output_dir(output_dir, replace_existing, "oliphaunt-broker")?; + if archive_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".tar.gz")) + { + extract_tar_gz_archive(archive_path, output_dir, "oliphaunt-broker")?; + } else if archive_path.extension().and_then(|value| value.to_str()) == Some("zip") { + extract_zip_archive(archive_path, output_dir, "oliphaunt-broker")?; + } else { + return Err(oliphaunt::Error::InvalidConfig(format!( + "unsupported oliphaunt-broker release archive {}", + archive_path.display() + ))); + } + Ok(()) +} + +fn prepare_archive_output_dir( + output_dir: &Path, + replace_existing: bool, + product_label: &str, +) -> oliphaunt::Result<()> { + if output_dir.exists() { + let has_entries = fs::read_dir(output_dir) + .map_err(|err| { + oliphaunt::Error::Engine(format!("read {}: {err}", output_dir.display())) + })? + .next() + .transpose() + .map_err(|err| { + oliphaunt::Error::Engine(format!("read {}: {err}", output_dir.display())) + })? + .is_some(); + if has_entries { + if !replace_existing { + return Err(oliphaunt::Error::InvalidConfig(format!( + "{product_label} release output already exists at {}; pass --force to replace it", + output_dir.display() + ))); + } + fs::remove_dir_all(output_dir).map_err(|err| { + oliphaunt::Error::Engine(format!("remove {}: {err}", output_dir.display())) + })?; + } + } + fs::create_dir_all(output_dir) + .map_err(|err| oliphaunt::Error::Engine(format!("create {}: {err}", output_dir.display()))) +} + +fn extract_tar_gz_archive( + archive_path: &Path, + output_dir: &Path, + product_label: &str, +) -> oliphaunt::Result<()> { + let file = File::open(archive_path).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!("open {}: {err}", archive_path.display())) + })?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + archive.unpack(output_dir).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "extract {product_label} release archive {} into {}: {err}", + archive_path.display(), + output_dir.display() + )) + }) +} + +fn extract_zip_archive( + archive_path: &Path, + output_dir: &Path, + product_label: &str, +) -> oliphaunt::Result<()> { + let file = File::open(archive_path).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!("open {}: {err}", archive_path.display())) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "open {product_label} release zip archive {}: {err}", + archive_path.display() + )) + })?; + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "read {product_label} release zip entry {index} from {}: {err}", + archive_path.display() + )) + })?; + let enclosed = entry.enclosed_name().ok_or_else(|| { + oliphaunt::Error::InvalidConfig(format!( + "{product_label} release zip entry {} is not safely relative", + entry.name() + )) + })?; + let output_path = output_dir.join(enclosed); + if entry.is_dir() { + fs::create_dir_all(&output_path).map_err(|err| { + oliphaunt::Error::Engine(format!("create {}: {err}", output_path.display())) + })?; + continue; + } + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + oliphaunt::Error::Engine(format!("create {}: {err}", parent.display())) + })?; + } + let mut output = File::create(&output_path).map_err(|err| { + oliphaunt::Error::Engine(format!("create {}: {err}", output_path.display())) + })?; + std::io::copy(&mut entry, &mut output).map_err(|err| { + oliphaunt::Error::Engine(format!("extract {}: {err}", output_path.display())) + })?; + } + Ok(()) +} + +fn unique_timestamp_suffix() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos().to_string()) + .unwrap_or_else(|_| "0".to_owned()) +} + +fn print_extension_catalog(args: &PackageArgs) -> oliphaunt::Result<()> { + println!( + "sql_name\tpg_major\tcreates_extension\tnative_module_stem\tdependencies\tshared_preload\tdesktop_prebuilt\tmobile_prebuilt\tmobile_static_registry_required\tmobile_static_archive_targets\tdata_files\tartifact" + ); + for entry in oliphaunt::NATIVE_EXTENSION_MANIFEST + .iter() + .filter(|entry| entry.first_party_artifact()) + { + let module_stem = match entry.module { + ExtensionModuleAsset::SqlOnly => "-", + ExtensionModuleAsset::NativeModule { stem } => stem, + }; + let dependencies = entry + .dependencies + .iter() + .map(|extension| extension.sql_name()) + .collect::>() + .join(","); + let shared_preload = entry + .extension + .required_shared_preload_library() + .unwrap_or("-"); + println!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + entry.sql_name, + entry.pg_major, + yes_no(entry.creates_extension), + module_stem, + empty_as_dash(&dependencies), + shared_preload, + yes_no(entry.extension.desktop_release_ready()), + yes_no(entry.extension.mobile_release_ready()), + yes_no(entry.extension.requires_mobile_static_registry()), + "-", + empty_as_dash(&entry.data_files.join(",")), + artifact_label(entry.artifact_policy), + ); + } + if !args.extension_indexes.is_empty() { + let catalog = list_prebuilt_extension_artifact_index_catalog( + NativeExtensionArtifactIndexOptions::new( + args.extension_target + .clone() + .unwrap_or_else(default_extension_artifact_target), + ) + .indexes(args.extension_indexes.clone()) + .trusted_signing_keys(args.trusted_extension_index_keys.clone()) + .require_signatures(args.require_signed_extension_indexes), + )?; + for entry in catalog.extensions { + println!( + "{}\t18\t{}\t{}\t{}\t{}\tyes\t{}\t{}\t{}\t{}\texternal-index:{}", + entry.sql_name, + yes_no(entry.creates_extension), + empty_as_dash(entry.native_module_stem.as_deref().unwrap_or("-")), + empty_as_dash(&entry.dependencies.join(",")), + empty_as_dash(&entry.shared_preload_libraries.join(",")), + yes_no(entry.mobile_prebuilt), + yes_no(entry.native_module_stem.is_some()), + empty_as_dash(&entry.mobile_static_archive_targets.join(",")), + "-", + entry.target, + ); + } + } + Ok(()) +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn empty_as_dash(value: &str) -> &str { + if value.is_empty() { "-" } else { value } +} + +fn artifact_label(policy: ExtensionArtifactPolicy) -> &'static str { + match policy { + ExtensionArtifactPolicy::FirstParty => "first-party", + ExtensionArtifactPolicy::External { .. } => "external", + } +} + +fn next_value(args: &mut impl Iterator, flag: &str) -> oliphaunt::Result { + args.next() + .ok_or_else(|| oliphaunt::Error::InvalidConfig(format!("{flag} requires a value"))) +} + +fn value_without_prefix<'a>(value: &'a str, prefix: &str) -> &'a str { + value.strip_prefix(prefix).expect("prefix was checked") +} + +fn parse_key_value(value: &str) -> oliphaunt::Result<(String, String)> { + let Some((key_id, hex)) = value.split_once(':') else { + return Err(oliphaunt::Error::InvalidConfig( + "key values must use :".to_owned(), + )); + }; + Ok((key_id.to_owned(), hex.trim().to_owned())) +} + +fn read_key_file_value(value: &str) -> oliphaunt::Result<(String, String)> { + let Some((key_id, path)) = value.split_once(':') else { + return Err(oliphaunt::Error::InvalidConfig( + "key file values must use :".to_owned(), + )); + }; + let text = fs::read_to_string(path).map_err(|err| { + oliphaunt::Error::InvalidConfig(format!( + "read trusted extension index key file {path}: {err}" + )) + })?; + Ok((key_id.to_owned(), text.trim().to_owned())) +} + +fn parse_mode(value: &str) -> oliphaunt::Result { + match value { + "native-direct" | "direct" => Ok(EngineMode::NativeDirect), + "native-broker" | "broker" => Ok(EngineMode::NativeBroker), + "native-server" | "server" => Ok(EngineMode::NativeServer), + _ => Err(oliphaunt::Error::InvalidConfig(format!( + "unknown native runtime-resource mode '{value}'" + ))), + } +} + +fn push_extension_names(target: &mut Vec, value: &str) { + for extension in split_csv(value) { + target.push(extension.to_owned()); + } +} + +fn push_mobile_static_module_stems(target: &mut Vec, value: &str) { + for stem in split_csv(value) { + target.push(stem.to_owned()); + } +} + +fn split_csv(value: &str) -> impl Iterator { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn default_extension_artifact_target() -> String { + if let Ok(target) = env::var("OLIPHAUNT_EXTENSION_TARGET") { + if !target.trim().is_empty() { + return target; + } + } + match (env::consts::ARCH, env::consts::OS) { + ("aarch64", "macos") => "macos-arm64", + ("x86_64", "macos") => "macos-x64", + ("aarch64", "linux") => "linux-arm64-gnu", + ("x86_64", "linux") => "linux-x64-gnu", + ("x86_64", "windows") => "windows-x64-msvc", + _ => "host", + } + .to_owned() +} + +fn print_help() { + println!( + "\ +Build portable Oliphaunt runtime resources from the Rust SDK for Swift, Kotlin, and React Native. + +Usage: + oliphaunt-resources --output [--mode direct|broker|server] [--extension hstore,vector] [--extension-index ] [--extension-target ] [--extension-cache ] [--trusted-extension-index-key-file :] [--prebuilt-extension ] [--mobile-static-module vector] [--force] [--require-mobile-static-registry] + oliphaunt-resources --resolve-release-assets --liboliphaunt-native-version [--output ] [--release-target macos-arm64|linux-x64-gnu|linux-arm64-gnu|windows-x64-msvc|ios-xcframework|android-arm64-v8a|android-x86_64|runtime-resources] [--release-asset-cache ] [--release-asset-base-url ] [--force] + oliphaunt-resources --resolve-broker-release-assets --broker-version [--output ] [--broker-release-target macos-arm64|linux-x64-gnu|linux-arm64-gnu|windows-x64-msvc] [--broker-release-asset-cache ] [--broker-release-asset-base-url ] [--force] + oliphaunt-resources --list-extensions [--extension-index ] [--extension-target ] [--trusted-extension-index-key-file :] + +The output directory receives: + oliphaunt/runtime/manifest.properties + oliphaunt/runtime/files/... + oliphaunt/template-pgdata/manifest.properties + oliphaunt/template-pgdata/files/... + oliphaunt/static-registry/manifest.properties + oliphaunt/static-registry/oliphaunt_static_registry.c when mobile-ready + oliphaunt/package-size.tsv + +Use --require-mobile-static-registry for iOS/Android release resources. It +fails when selected native-module extensions still need static registry rows. +Pass --mobile-static-module only from platform packaging that has +actually linked that module for static loading. Mobile-ready packages emit a +C registry source that platform builds compile and call before oliphaunt_init. +Extensions are selected by exact PostgreSQL SQL name. App bundles receive only +the selected extension files plus mandatory extension dependencies. +Use --prebuilt-extension for exact third-party extensions that were +built outside the app project. The artifact can be an unpacked directory, .tar, +or .tar.zst. It must contain manifest.properties with +packageLayout=oliphaunt-extension-artifact-v1 and a files/ runtime tree; the app +build consumes binary artifacts only. +Use --extension-index to resolve external --extension names through +a local oliphaunt-extension-artifact-index-v1 file. The command verifies +artifact byte counts and sha256 digests before consuming each artifact. The +target defaults to OLIPHAUNT_EXTENSION_TARGET or the current host target; pass +--extension-target for iOS, Android, or cross-compiled artifact indexes. +If an index row has a URL and the sidecar artifact file is missing, pass +--extension-cache to download the artifact into a deterministic cache +location before byte-count, sha256, and manifest verification. HTTPS downloads +require an oliphaunt-resources binary built with the extension-download feature. +For release consumption, pass --trusted-extension-index-key-file : +to require and verify an Ed25519 detached signature sidecar at .sig +before any indexed artifact is used. The key file must contain a hex-encoded +32-byte Ed25519 public key. For local automation only, +--trusted-extension-index-key : is also accepted. +package-size.tsv records the runtime/template/static-registry byte footprint, +the de-duplicated selected extension asset bytes, and each selected extension's +asset bytes. + +Use --list-extensions to print the release-ready exact extension catalog +without requiring a local PostgreSQL build. When --extension-index is also +provided, signed external index metadata is listed for --extension-target +without downloading artifacts or building extension source. desktop_prebuilt=yes +means the extension is available to Rust/Tauri and desktop SDK resource +artifacts. mobile_prebuilt=yes means iOS/Android app bundles can include it from +Oliphaunt prebuilt mobile artifacts without compiling extension source; the +mobile_static_archive_targets column lists carried static archive targets for +external native-module artifacts. data_files lists extra files relative to +share/postgresql that are shipped only when the exact extension is selected. + +Use --resolve-release-assets for app-developer installs from a published +liboliphaunt-native-v GitHub release. The resolver downloads +liboliphaunt--release-assets.sha256, verifies each selected asset +against it, caches the exact artifacts, and unpacks +liboliphaunt--runtime-resources.tar.gz into --output when provided. +The default base URL is +https://github.com/f0rr0/oliphaunt/releases/download/liboliphaunt-native-v. +HTTPS downloads require the extension-download feature; file:// release asset +URLs are supported for clean local release verification without network access. +Use --resolve-broker-release-assets for broker-mode Rust installs from a +published oliphaunt-broker-v GitHub release. The resolver downloads +and verifies oliphaunt-broker--release-assets.sha256, selects the +current or requested desktop helper target, and unpacks it into --output. Point +OLIPHAUNT_BROKER_ASSET_DIR at that output directory when using NativeBroker +without placing oliphaunt-broker next to the application executable. +" + ); +} diff --git a/src/sdks/rust/src/broker.rs b/src/sdks/rust/src/broker.rs new file mode 100644 index 00000000..1f3ae87e --- /dev/null +++ b/src/sdks/rust/src/broker.rs @@ -0,0 +1,1409 @@ +use std::collections::HashSet; +use std::env; +use std::ffi::OsString; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crate::backup::annotate_physical_archive_backup; +use crate::config::{EngineMode, NativeBrokerConfig, OpenConfig}; +use crate::engine::{ + EngineCancel, EngineCapabilities, EngineSession, NativeRuntime, SessionConcurrency, +}; +use crate::error::{Error, Result}; +use crate::extension::Extension; +use crate::ipc::{RequestFrame, ResponseFrame, read_response, write_request}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; +use crate::storage::{ + BackupArtifact, BackupFormat, BackupRequest, BootstrapStrategy, DatabaseRoot, +}; + +const ENV_BROKER: &str = "OLIPHAUNT_BROKER"; +const ENV_BROKER_ASSET_DIR: &str = "OLIPHAUNT_BROKER_ASSET_DIR"; +const ENV_BROKER_TRANSPORT: &str = "OLIPHAUNT_BROKER_TRANSPORT"; +const ENV_BROKER_AUTH_TOKEN: &str = "OLIPHAUNT_BROKER_AUTH_TOKEN"; +const READY_PREFIX: &str = "OLIPHAUNT_BROKER_READY "; +const ERROR_PREFIX: &str = "OLIPHAUNT_BROKER_ERROR "; +const BROKER_RELEASE_VERSION: &str = "0.1.0"; +const BROKER_STARTUP_TIMEOUT: Duration = Duration::from_secs(20); +const BROKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + +trait BrokerTransport: Read + Write + Send {} + +impl BrokerTransport for T where T: Read + Write + Send {} + +/// Broker runtime backed by a local helper process. +/// +/// Broker mode is intentionally separate from direct mode. The helper process +/// owns the native root and the direct PostgreSQL backend; the Rust SDK client +/// talks to it over a small length-prefixed local IPC protocol. +#[derive(Debug, Clone)] +pub struct NativeBrokerRuntime { + executable: Option, + supervisor: Arc, +} + +impl NativeBrokerRuntime { + /// Create a broker runtime that resolves the broker executable from package + /// assets. + pub fn from_package() -> Self { + Self { + executable: None, + supervisor: Arc::new(BrokerSupervisor::new(1)), + } + } + + /// Create a broker runtime from builder/broker configuration. + pub fn from_config(config: &NativeBrokerConfig) -> Self { + Self { + executable: config.executable.clone(), + supervisor: Arc::new(BrokerSupervisor::new(config.max_roots)), + } + } + + /// Create a broker runtime with an explicit helper executable. + pub fn from_executable(path: impl Into) -> Self { + Self { + executable: Some(path.into()), + supervisor: Arc::new(BrokerSupervisor::new(1)), + } + } + + /// Set the maximum number of active database roots this runtime + /// supervises. + /// + /// Broker mode uses one helper process per root while the native direct + /// backend remains process-global. This limit therefore controls the + /// number of concurrently supervised helper processes, not the number of + /// sessions within a root. + pub fn with_max_roots(mut self, max_roots: usize) -> Self { + self.supervisor = Arc::new(BrokerSupervisor::new(max_roots)); + self + } + + /// Return the configured helper executable, if any. + pub fn executable(&self) -> Option<&PathBuf> { + self.executable.as_ref() + } + + /// Return the maximum number of active roots this runtime admits. + pub fn max_roots(&self) -> usize { + self.supervisor.max_roots() + } +} + +impl Default for NativeBrokerRuntime { + fn default() -> Self { + Self::from_package() + } +} + +impl NativeRuntime for NativeBrokerRuntime { + fn open(&self, config: OpenConfig) -> Result> { + if config.mode != EngineMode::NativeBroker { + return Err(Error::UnsupportedEngineMode { + mode: config.mode, + reason: "NativeBrokerRuntime only serves native-broker mode".to_owned(), + }); + } + config.validate()?; + let executable = self + .executable + .clone() + .or_else(|| config.broker.executable.clone()) + .or_else(resolve_broker_executable) + .ok_or(Error::RuntimeUnavailable { + mode: EngineMode::NativeBroker, + })?; + let (root_path, temporary_root) = materialize_broker_root(&config.storage.root)?; + let root_lease = self.supervisor.acquire_root(&root_path)?; + let mut open_guard = BrokerOpenGuard { + child: None, + temporary_root, + ipc_cleanup: None, + root_lease: Some(root_lease), + }; + let endpoint = BrokerEndpoint::allocate()?; + open_guard.ipc_cleanup = endpoint.cleanup_path(); + let extensions = config.resolved_extensions()?; + let auth_token = BrokerAuthToken::generate()?; + let launch_plan = BrokerLaunchPlan { + executable, + config: config.clone(), + root_path, + extensions, + endpoint, + auth_token, + }; + let launch = launch_plan.launch()?; + open_guard.child = Some(launch.child); + let cancel = Arc::new(BrokerCancel::new( + launch.cancel_endpoint, + launch_plan.auth_token.as_str().to_owned(), + )); + let (child, temporary_root, ipc_cleanup, root_lease) = open_guard.into_session_parts(); + + Ok(Box::new(NativeBrokerSession { + child: Some(child), + transport: Some(launch.transport), + cancel, + launch_plan, + temporary_root, + ipc_cleanup, + root_lease: Some(root_lease), + max_roots: self.supervisor.max_roots(), + closed: false, + })) + } +} + +struct NativeBrokerSession { + child: Option, + transport: Option>, + cancel: Arc, + launch_plan: BrokerLaunchPlan, + temporary_root: Option, + ipc_cleanup: Option, + root_lease: Option, + max_roots: usize, + closed: bool, +} + +impl EngineSession for NativeBrokerSession { + fn capabilities(&self) -> EngineCapabilities { + EngineCapabilities { + mode: EngineMode::NativeBroker, + session_concurrency: SessionConcurrency::SerializedSingleSession, + process_isolated: true, + multi_root: self.max_roots > 1, + reopenable: true, + same_root_logical_reopen: false, + root_switchable: true, + crash_restartable: true, + max_client_sessions: 1, + protocol_raw: true, + protocol_stream: true, + query_cancel: true, + backup_restore: true, + backup_formats: vec![BackupFormat::PhysicalArchive], + restore_formats: vec![BackupFormat::PhysicalArchive], + simple_query: true, + extensions: true, + connection_strings: false, + connection_string: None, + } + } + + fn cancel_handle(&self) -> Option> { + let cancel: Arc = self.cancel.clone(); + Some(cancel) + } + + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result { + let response = { + let transport = self.ensure_transport()?; + write_request( + transport, + RequestFrame::ExecProtocol(request.as_bytes().to_vec()), + ) + .and_then(|()| read_response(transport)) + }; + match self.read_response_or_mark_failed(response)? { + ResponseFrame::Ok(bytes) => Ok(ProtocolResponse::new(bytes)), + ResponseFrame::Error(message) => Err(Error::Engine(message)), + ResponseFrame::Chunk(_) => Err(Error::Engine( + "broker returned a stream chunk for raw protocol execution".to_owned(), + )), + } + } + + fn exec_simple_query(&mut self, sql: &str) -> Result { + let response = { + let transport = self.ensure_transport()?; + write_request(transport, RequestFrame::ExecSimpleQuery(sql.to_owned())) + .and_then(|()| read_response(transport)) + }; + match self.read_response_or_mark_failed(response)? { + ResponseFrame::Ok(bytes) => Ok(ProtocolResponse::new(bytes)), + ResponseFrame::Error(message) => Err(Error::Engine(message)), + ResponseFrame::Chunk(_) => Err(Error::Engine( + "broker returned a stream chunk for simple-query execution".to_owned(), + )), + } + } + + fn checkpoint(&mut self) -> Result<()> { + let response = { + let transport = self.ensure_transport()?; + write_request(transport, RequestFrame::Checkpoint) + .and_then(|()| read_response(transport)) + }; + match self.read_response_or_mark_failed(response)? { + ResponseFrame::Ok(_) => Ok(()), + ResponseFrame::Error(message) => Err(Error::Engine(message)), + ResponseFrame::Chunk(_) => Err(Error::Engine( + "broker returned a stream chunk for checkpoint".to_owned(), + )), + } + } + + fn exec_protocol_stream( + &mut self, + request: ProtocolRequest, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, + ) -> Result<()> { + { + let transport = self.ensure_transport()?; + if let Err(error) = write_request( + transport, + RequestFrame::ExecProtocolStream(request.as_bytes().to_vec()), + ) { + self.mark_broker_failed(); + return Err(error); + } + } + + let mut callback_error = None; + loop { + let response = { + let transport = self.ensure_transport()?; + read_response(transport) + }; + match self.read_response_or_mark_failed(response)? { + ResponseFrame::Chunk(bytes) => { + if callback_error.is_none() + && let Err(error) = on_chunk(&bytes) + { + callback_error = Some(error); + } + } + ResponseFrame::Ok(_) => return callback_error.map_or(Ok(()), Err), + ResponseFrame::Error(message) => { + return callback_error.map_or(Err(Error::Engine(message)), Err); + } + } + } + } + + fn backup(&mut self, request: BackupRequest) -> Result { + let response = { + let transport = self.ensure_transport()?; + write_request(transport, RequestFrame::Backup(request.format)) + .and_then(|()| read_response(transport)) + }; + match self.read_response_or_mark_failed(response)? { + ResponseFrame::Ok(bytes) => { + let artifact = BackupArtifact { + format: request.format, + bytes, + }; + if request.format != BackupFormat::PhysicalArchive { + return Ok(artifact); + } + let pgdata = self.launch_plan.root_path.join("pgdata"); + let selected_extensions = self.launch_plan.extensions.clone(); + annotate_physical_archive_backup( + artifact, + &pgdata, + &selected_extensions, + |request| self.exec_protocol_raw(request), + ) + } + ResponseFrame::Error(message) => Err(Error::Engine(message)), + ResponseFrame::Chunk(_) => Err(Error::Engine( + "broker returned a stream chunk for backup".to_owned(), + )), + } + } + + fn close(&mut self) -> Result<()> { + self.close_broker() + } +} + +#[derive(Clone)] +struct BrokerLaunchPlan { + executable: PathBuf, + config: OpenConfig, + root_path: PathBuf, + extensions: Vec, + endpoint: BrokerEndpoint, + auth_token: BrokerAuthToken, +} + +struct BrokerLaunch { + child: Child, + transport: Box, + cancel_endpoint: String, +} + +impl BrokerLaunchPlan { + fn launch(&self) -> Result { + let child = spawn_broker( + &self.executable, + &self.config, + &self.root_path, + &self.extensions, + &self.endpoint, + &self.auth_token, + )?; + let mut guard = BrokerChildLaunchGuard { child: Some(child) }; + let stdout = guard + .child + .as_mut() + .expect("broker launch guard owns child until session handoff") + .stdout + .take() + .ok_or_else(|| Error::Engine("broker child stdout was not captured".to_owned()))?; + let ready = read_ready_line_from_child( + guard + .child + .as_mut() + .expect("broker launch guard owns child while waiting for ready line"), + stdout, + )?; + let mut transport = self.endpoint.connect_primary(&ready)?; + authenticate_broker(&mut transport, &self.auth_token)?; + Ok(BrokerLaunch { + child: guard + .child + .take() + .expect("broker child exists after successful launch"), + transport, + cancel_endpoint: ready.cancel, + }) + } +} + +struct BrokerChildLaunchGuard { + child: Option, +} + +impl Drop for BrokerChildLaunchGuard { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +struct BrokerCancel { + endpoint: Mutex, + auth_token: String, +} + +impl BrokerCancel { + fn new(endpoint: String, auth_token: String) -> Self { + Self { + endpoint: Mutex::new(endpoint), + auth_token, + } + } + + fn set_endpoint(&self, endpoint: String) -> Result<()> { + *self.endpoint.lock().map_err(|_| { + Error::Engine("native broker cancel endpoint lock poisoned".to_owned()) + })? = endpoint; + Ok(()) + } +} + +impl EngineCancel for BrokerCancel { + fn cancel(&self) -> Result<()> { + let endpoint = self + .endpoint + .lock() + .map_err(|_| Error::Engine("native broker cancel endpoint lock poisoned".to_owned()))? + .clone(); + let mut transport = connect_ready_endpoint(&endpoint)?; + let token = BrokerAuthToken(self.auth_token.clone()); + authenticate_broker(&mut transport, &token)?; + write_request(&mut transport, RequestFrame::Cancel)?; + match read_response(&mut transport)? { + ResponseFrame::Ok(_) => Ok(()), + ResponseFrame::Error(message) => Err(Error::Engine(format!( + "native broker cancel failed: {message}" + ))), + ResponseFrame::Chunk(_) => Err(Error::Engine( + "broker returned a stream chunk for cancellation".to_owned(), + )), + } + } +} + +impl NativeBrokerSession { + fn ensure_transport(&mut self) -> Result<&mut Box> { + if self.closed { + return Err(Error::EngineStopped); + } + if self.reap_exited_child()?.is_some() { + self.transport = None; + } + if self.transport.is_none() { + self.restart_broker()?; + } + self.transport.as_mut().ok_or(Error::EngineStopped) + } + + fn read_response_or_mark_failed( + &mut self, + response: Result, + ) -> Result { + match response { + Ok(frame) => Ok(frame), + Err(error) => { + self.mark_broker_failed(); + Err(error) + } + } + } + + fn restart_broker(&mut self) -> Result<()> { + if self.closed { + return Err(Error::EngineStopped); + } + let launch = self.launch_plan.launch()?; + self.cancel.set_endpoint(launch.cancel_endpoint)?; + self.child = Some(launch.child); + self.transport = Some(launch.transport); + Ok(()) + } + + fn reap_exited_child(&mut self) -> Result> { + let status = match self.child.as_mut() { + Some(child) => child + .try_wait() + .map_err(|err| Error::Engine(format!("poll native broker helper: {err}")))?, + None => None, + }; + if status.is_some() { + self.child = None; + self.transport = None; + } + Ok(status) + } + + fn mark_broker_failed(&mut self) { + self.transport = None; + if let Some(mut child) = self.child.take() { + match child.try_wait() { + Ok(Some(_)) => {} + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + } + + fn close_broker(&mut self) -> Result<()> { + if self.closed { + return Ok(()); + } + self.closed = true; + if let Some(transport) = self.transport.as_mut() { + let _ = write_request(transport, RequestFrame::Close); + let _ = read_response(transport); + } + self.transport = None; + if let Some(mut child) = self.child.take() { + match wait_for_child_exit(&mut child, BROKER_SHUTDOWN_TIMEOUT) { + Ok(Some(_)) => {} + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + } + Err(err) => return Err(Error::Engine(format!("wait for native broker: {err}"))), + } + } + if let Some(root) = self.temporary_root.take() { + let _ = fs::remove_dir_all(root); + } + if let Some(path) = self.ipc_cleanup.take() { + let _ = fs::remove_dir_all(path); + } + drop(self.root_lease.take()); + Ok(()) + } +} + +impl Drop for NativeBrokerSession { + fn drop(&mut self) { + let _ = self.close_broker(); + } +} + +struct BrokerOpenGuard { + child: Option, + temporary_root: Option, + ipc_cleanup: Option, + root_lease: Option, +} + +impl BrokerOpenGuard { + fn into_session_parts(mut self) -> (Child, Option, Option, BrokerRootLease) { + ( + self.child + .take() + .expect("broker child exists after successful startup"), + self.temporary_root.take(), + self.ipc_cleanup.take(), + self.root_lease + .take() + .expect("broker root lease exists after successful startup"), + ) + } +} + +impl Drop for BrokerOpenGuard { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(root) = self.temporary_root.take() { + let _ = fs::remove_dir_all(root); + } + if let Some(path) = self.ipc_cleanup.take() { + let _ = fs::remove_dir_all(path); + } + drop(self.root_lease.take()); + } +} + +fn wait_for_child_exit( + child: &mut Child, + timeout: Duration, +) -> std::io::Result> { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= deadline { + return Ok(None); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn materialize_broker_root(root: &DatabaseRoot) -> Result<(PathBuf, Option)> { + match root { + DatabaseRoot::Path(path) => Ok((path.clone(), None)), + DatabaseRoot::Temporary => { + let path = create_temporary_root()?; + Ok((path.clone(), Some(path))) + } + } +} + +fn create_temporary_root() -> Result { + let parent = env::temp_dir(); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| Error::Engine(format!("system clock before epoch: {err}")))? + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("oliphaunt-broker-{pid}-{nanos}-{attempt}")); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(Error::Engine(format!( + "create temporary broker root {}: {err}", + path.display() + ))); + } + } + } + Err(Error::Engine( + "failed to allocate a unique temporary broker root".to_owned(), + )) +} + +#[derive(Debug)] +struct BrokerSupervisor { + max_roots: usize, + roots: Mutex>, +} + +impl BrokerSupervisor { + fn new(max_roots: usize) -> Self { + Self { + max_roots, + roots: Mutex::new(HashSet::new()), + } + } + + fn max_roots(&self) -> usize { + self.max_roots + } + + fn acquire_root(self: &Arc, root: &Path) -> Result { + if self.max_roots == 0 { + return Err(Error::InvalidConfig( + "native broker max_roots must be greater than zero".to_owned(), + )); + } + let key = broker_root_key(root)?; + let mut roots = self + .roots + .lock() + .map_err(|_| Error::Engine("native broker root registry was poisoned".to_owned()))?; + if roots.contains(&key) { + return Err(Error::Engine(format!( + "native broker root {} is already open in this broker runtime", + key.display() + ))); + } + if roots.len() >= self.max_roots { + return Err(Error::Engine(format!( + "native broker runtime already owns {} root(s), at configured capacity {}", + roots.len(), + self.max_roots + ))); + } + roots.insert(key.clone()); + Ok(BrokerRootLease { + supervisor: Arc::clone(self), + key: Some(key), + }) + } + + fn release_root(&self, key: &Path) { + if let Ok(mut roots) = self.roots.lock() { + roots.remove(key); + } + } +} + +#[derive(Debug)] +struct BrokerRootLease { + supervisor: Arc, + key: Option, +} + +impl Drop for BrokerRootLease { + fn drop(&mut self) { + if let Some(key) = self.key.take() { + self.supervisor.release_root(&key); + } + } +} + +fn broker_root_key(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .map_err(|err| Error::Engine(format!("resolve broker root current directory: {err}")))? + .join(path) + }; + if let Ok(canonical) = absolute.canonicalize() { + return Ok(canonical); + } + + let mut cursor = absolute.as_path(); + let mut missing = Vec::::new(); + while let Some(name) = cursor.file_name() { + missing.push(name.to_os_string()); + let Some(parent) = cursor.parent() else { + break; + }; + if let Ok(canonical_parent) = parent.canonicalize() { + let mut key = canonical_parent; + for component in missing.iter().rev() { + key.push(component); + } + return Ok(normalize_path(&key)); + } + cursor = parent; + } + + Ok(normalize_path(&absolute)) +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::Normal(part) => normalized.push(part), + } + } + normalized +} + +fn spawn_broker( + executable: &PathBuf, + config: &OpenConfig, + root: &PathBuf, + extensions: &[Extension], + endpoint: &BrokerEndpoint, + auth_token: &BrokerAuthToken, +) -> Result { + let mut command = Command::new(executable); + command + .args(broker_spawn_args(config, root, extensions, endpoint)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .env(ENV_BROKER_AUTH_TOKEN, auth_token.as_str()); + if let BootstrapStrategy::InitdbToolingOnly { initdb } = &config.storage.bootstrap { + command.env("OLIPHAUNT_INITDB", initdb); + } + command.spawn().map_err(|err| { + Error::Engine(format!( + "spawn native broker {}: {err}", + executable.display() + )) + }) +} + +fn broker_spawn_args( + config: &OpenConfig, + root: &PathBuf, + extensions: &[Extension], + endpoint: &BrokerEndpoint, +) -> Vec { + let mut args = vec![ + OsString::from("--root"), + root.as_os_str().to_os_string(), + OsString::from("--bootstrap"), + OsString::from(match &config.storage.bootstrap { + BootstrapStrategy::PackagedTemplate => "packaged-template", + BootstrapStrategy::ExistingOnly => "existing-only", + BootstrapStrategy::InitdbToolingOnly { .. } => "initdb-tooling-only", + }), + OsString::from("--durability"), + OsString::from(match config.durability { + crate::DurabilityProfile::Safe => "safe", + crate::DurabilityProfile::Balanced => "balanced", + crate::DurabilityProfile::FastDev => "fast-dev", + }), + OsString::from("--runtime-footprint"), + OsString::from(match config.runtime_footprint { + crate::RuntimeFootprintProfile::Throughput => "throughput", + crate::RuntimeFootprintProfile::BalancedMobile => "balanced-mobile", + crate::RuntimeFootprintProfile::SmallMobile => "small-mobile", + }), + ]; + if let BootstrapStrategy::InitdbToolingOnly { initdb } = &config.storage.bootstrap { + args.push(OsString::from("--initdb")); + args.push(initdb.as_os_str().to_os_string()); + } + args.push(OsString::from("--username")); + args.push(OsString::from(&config.username)); + args.push(OsString::from("--database")); + args.push(OsString::from(&config.database)); + endpoint.add_args_to(&mut args); + for extension in extensions { + args.push(OsString::from("--extension")); + args.push(OsString::from(extension.sql_name())); + } + for guc in &config.startup_gucs { + args.push(OsString::from("--startup-guc")); + args.push(OsString::from(format!("{}={}", guc.name, guc.value))); + } + args +} + +fn authenticate_broker( + transport: &mut Box, + auth_token: &BrokerAuthToken, +) -> Result<()> { + write_request( + transport, + RequestFrame::Authenticate(auth_token.as_str().to_owned()), + )?; + match read_response(transport)? { + ResponseFrame::Ok(_) => Ok(()), + ResponseFrame::Error(message) => Err(Error::Engine(format!( + "native broker authentication failed: {message}" + ))), + ResponseFrame::Chunk(_) => Err(Error::Engine( + "broker returned a stream chunk during authentication".to_owned(), + )), + } +} + +#[derive(Clone)] +struct BrokerAuthToken(String); + +impl BrokerAuthToken { + fn generate() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes) + .map_err(|err| Error::Engine(format!("generate native broker auth token: {err}")))?; + Ok(Self(hex_encode(&bytes))) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +struct BrokerReadyEndpoints { + primary: String, + cancel: String, +} + +fn read_ready_line(stdout: &mut impl BufRead) -> Result { + let mut line = String::new(); + stdout + .read_line(&mut line) + .map_err(|err| Error::Engine(format!("read native broker startup line: {err}")))?; + if let Some(endpoints) = line.trim().strip_prefix(READY_PREFIX) { + let mut parts = endpoints.split_whitespace(); + let primary = parts.next().ok_or_else(|| { + Error::Engine("native broker ready line did not include a primary endpoint".to_owned()) + })?; + let cancel = parts + .next() + .and_then(|part| part.strip_prefix("cancel=")) + .ok_or_else(|| { + Error::Engine( + "native broker ready line did not include a cancel endpoint".to_owned(), + ) + })?; + return Ok(BrokerReadyEndpoints { + primary: primary.to_owned(), + cancel: cancel.to_owned(), + }); + } + if let Some(message) = line.trim().strip_prefix(ERROR_PREFIX) { + return Err(Error::Engine(format!( + "native broker failed to start: {message}" + ))); + } + Err(Error::Engine(format!( + "native broker did not print a ready line: {}", + line.trim() + ))) +} + +fn read_ready_line_from_child( + child: &mut Child, + stdout: impl Read + Send + 'static, +) -> Result { + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + thread::Builder::new() + .name("oliphaunt-broker-ready-reader".to_owned()) + .spawn(move || { + let mut stdout = BufReader::new(stdout); + let _ = ready_tx.send(read_ready_line(&mut stdout)); + }) + .map_err(|err| Error::Engine(format!("spawn native broker ready reader: {err}")))?; + + let deadline = Instant::now() + BROKER_STARTUP_TIMEOUT; + loop { + match ready_rx.recv_timeout(Duration::from_millis(50)) { + Ok(result) => return result, + Err(mpsc::RecvTimeoutError::Timeout) => { + if let Some(status) = child + .try_wait() + .map_err(|err| Error::Engine(format!("poll native broker startup: {err}")))? + { + return Err(Error::Engine(format!( + "native broker exited before printing a ready line: {status}" + ))); + } + if Instant::now() >= deadline { + return Err(Error::Engine(format!( + "native broker did not print a ready line within {:?}", + BROKER_STARTUP_TIMEOUT + ))); + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let status = child.try_wait().map_err(|err| { + Error::Engine(format!("poll native broker after ready reader exit: {err}")) + })?; + let status = status + .map(|status| status.to_string()) + .unwrap_or_else(|| "still running".to_owned()); + return Err(Error::Engine(format!( + "native broker ready reader exited without a startup line; child is {status}" + ))); + } + } + } +} + +fn resolve_broker_executable() -> Option { + if let Some(path) = env::var_os(ENV_BROKER).map(PathBuf::from) { + return Some(path); + } + if let Some(path) = resolve_broker_executable_next_to_current_exe() { + return Some(path); + } + resolve_broker_executable_from_asset_dir() +} + +fn resolve_broker_executable_next_to_current_exe() -> Option { + let current = env::current_exe().ok()?; + let dir = current.parent()?; + for name in [ + "oliphaunt-broker", + "oliphaunt-broker.exe", + "oliphaunt_broker", + "oliphaunt_broker.exe", + ] { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn resolve_broker_executable_from_asset_dir() -> Option { + let root = env::var_os(ENV_BROKER_ASSET_DIR).map(PathBuf::from)?; + let target = current_broker_release_target()?; + for candidate in target.unpacked_executable_candidates(&root) { + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct BrokerReleaseTarget { + target: &'static str, + asset_template: &'static str, + executable_relative_path: &'static str, +} + +impl BrokerReleaseTarget { + fn asset_name(self) -> String { + self.asset_template + .replace("{version}", BROKER_RELEASE_VERSION) + } + + fn archive_stem(self) -> String { + self.asset_name() + .trim_end_matches(".tar.gz") + .trim_end_matches(".zip") + .to_owned() + } + + fn unpacked_executable_candidates(self, root: &Path) -> Vec { + let executable = Path::new(self.executable_relative_path); + vec![ + root.join(executable), + root.join(self.target).join(executable), + root.join(self.archive_stem()).join(executable), + ] + } +} + +fn current_broker_release_target() -> Option { + broker_release_target(env::consts::OS, env::consts::ARCH) +} + +fn broker_release_target(os: &str, arch: &str) -> Option { + match (os, arch) { + ("macos", "aarch64" | "arm64") => Some(BrokerReleaseTarget { + target: "macos-arm64", + asset_template: "oliphaunt-broker-{version}-macos-arm64.tar.gz", + executable_relative_path: "bin/oliphaunt-broker", + }), + ("linux", "x86_64" | "x64" | "amd64") => Some(BrokerReleaseTarget { + target: "linux-x64-gnu", + asset_template: "oliphaunt-broker-{version}-linux-x64-gnu.tar.gz", + executable_relative_path: "bin/oliphaunt-broker", + }), + ("linux", "aarch64" | "arm64") => Some(BrokerReleaseTarget { + target: "linux-arm64-gnu", + asset_template: "oliphaunt-broker-{version}-linux-arm64-gnu.tar.gz", + executable_relative_path: "bin/oliphaunt-broker", + }), + ("windows", "x86_64" | "x64" | "amd64") => Some(BrokerReleaseTarget { + target: "windows-x64-msvc", + asset_template: "oliphaunt-broker-{version}-windows-x64-msvc.zip", + executable_relative_path: "bin/oliphaunt-broker.exe", + }), + _ => None, + } +} + +enum BrokerEndpoint { + #[cfg(unix)] + Unix { + dir: PathBuf, + socket: PathBuf, + cancel_socket: PathBuf, + }, + Tcp { + listen: String, + cancel_listen: String, + }, +} + +impl Clone for BrokerEndpoint { + fn clone(&self) -> Self { + match self { + #[cfg(unix)] + Self::Unix { + dir, + socket, + cancel_socket, + } => Self::Unix { + dir: dir.clone(), + socket: socket.clone(), + cancel_socket: cancel_socket.clone(), + }, + Self::Tcp { + listen, + cancel_listen, + } => Self::Tcp { + listen: listen.clone(), + cancel_listen: cancel_listen.clone(), + }, + } + } +} + +impl BrokerEndpoint { + fn allocate() -> Result { + if env::var(ENV_BROKER_TRANSPORT).ok().as_deref() == Some("tcp") { + Ok(Self::Tcp { + listen: "127.0.0.1:0".to_owned(), + cancel_listen: "127.0.0.1:0".to_owned(), + }) + } else { + #[cfg(unix)] + { + let dir = create_temporary_ipc_dir()?; + let socket = dir.join("s"); + let cancel_socket = dir.join("c"); + Ok(Self::Unix { + dir, + socket, + cancel_socket, + }) + } + + #[cfg(not(unix))] + { + Ok(Self::Tcp { + listen: "127.0.0.1:0".to_owned(), + cancel_listen: "127.0.0.1:0".to_owned(), + }) + } + } + } + + fn add_args_to(&self, args: &mut Vec) { + match self { + #[cfg(unix)] + Self::Unix { + socket, + cancel_socket, + .. + } => { + args.push(OsString::from("--socket")); + args.push(socket.as_os_str().to_os_string()); + args.push(OsString::from("--cancel-socket")); + args.push(cancel_socket.as_os_str().to_os_string()); + } + Self::Tcp { + listen, + cancel_listen, + } => { + args.push(OsString::from("--listen")); + args.push(OsString::from(listen)); + args.push(OsString::from("--cancel-listen")); + args.push(OsString::from(cancel_listen)); + } + } + } + + fn connect_primary(&self, ready: &BrokerReadyEndpoints) -> Result> { + match self { + #[cfg(unix)] + Self::Unix { socket, .. } => { + let ready_socket = ready + .primary + .strip_prefix("unix:") + .map(PathBuf::from) + .ok_or_else(|| { + Error::Engine(format!( + "native broker printed unexpected Unix ready endpoint '{}'", + ready.primary + )) + })?; + if ready_socket != *socket { + return Err(Error::Engine(format!( + "native broker ready socket {} did not match requested socket {}", + ready_socket.display(), + socket.display() + ))); + } + connect_ready_endpoint(&ready.primary) + } + Self::Tcp { .. } => connect_ready_endpoint(&ready.primary), + } + } + + fn cleanup_path(&self) -> Option { + match self { + #[cfg(unix)] + Self::Unix { dir, .. } => Some(dir.clone()), + Self::Tcp { .. } => None, + } + } +} + +fn connect_ready_endpoint(ready_endpoint: &str) -> Result> { + if let Some(path) = ready_endpoint.strip_prefix("unix:") { + #[cfg(unix)] + { + let path = PathBuf::from(path); + return UnixStream::connect(&path) + .map(|stream| Box::new(stream) as Box) + .map_err(|err| { + Error::Engine(format!( + "connect to native broker Unix socket {}: {err}", + path.display() + )) + }); + } + + #[cfg(not(unix))] + { + let _ = path; + return Err(Error::Engine( + "native broker returned a Unix socket endpoint on a non-Unix platform".to_owned(), + )); + } + } + + let addr = ready_endpoint + .strip_prefix("tcp:") + .unwrap_or(ready_endpoint); + let stream = TcpStream::connect(addr) + .map_err(|err| Error::Engine(format!("connect to native broker {addr}: {err}")))?; + stream + .set_nodelay(true) + .map_err(|err| Error::Engine(format!("set TCP_NODELAY for broker IPC: {err}")))?; + Ok(Box::new(stream)) +} + +#[cfg(unix)] +fn create_temporary_ipc_dir() -> Result { + let parent = PathBuf::from("/tmp"); + let parent = if parent.is_dir() { + parent + } else { + env::temp_dir() + }; + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| Error::Engine(format!("system clock before epoch: {err}")))? + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("lpgo-{pid}-{nanos:x}-{attempt}")); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(Error::Engine(format!( + "create native broker IPC directory {}: {err}", + path.display() + ))); + } + } + } + Err(Error::Engine( + "failed to allocate a unique native broker IPC directory".to_owned(), + )) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn supervisor_admits_distinct_roots_until_capacity() { + let supervisor = Arc::new(BrokerSupervisor::new(2)); + let first = supervisor + .acquire_root(Path::new("target/liboliphaunt-broker-root-a")) + .unwrap(); + let second = supervisor + .acquire_root(Path::new("target/liboliphaunt-broker-root-b")) + .unwrap(); + + let error = supervisor + .acquire_root(Path::new("target/liboliphaunt-broker-root-c")) + .unwrap_err(); + assert!( + error.to_string().contains("configured capacity 2"), + "unexpected capacity error: {error}" + ); + + drop(first); + let reopened = supervisor + .acquire_root(Path::new("target/liboliphaunt-broker-root-c")) + .unwrap(); + drop(reopened); + drop(second); + } + + #[test] + fn supervisor_rejects_duplicate_open_roots() { + let supervisor = Arc::new(BrokerSupervisor::new(2)); + let root = + Path::new("target/liboliphaunt-broker-duplicate/../liboliphaunt-broker-duplicate"); + let _lease = supervisor.acquire_root(root).unwrap(); + + let error = supervisor + .acquire_root(Path::new("target/liboliphaunt-broker-duplicate")) + .unwrap_err(); + assert!( + error.to_string().contains("already open"), + "unexpected duplicate-root error: {error}" + ); + } + + #[test] + fn supervisor_rejects_zero_capacity() { + let supervisor = Arc::new(BrokerSupervisor::new(0)); + let error = supervisor + .acquire_root(Path::new("target/liboliphaunt-broker-root")) + .unwrap_err(); + assert_eq!( + error, + Error::InvalidConfig("native broker max_roots must be greater than zero".to_owned()) + ); + } + + #[test] + fn broker_spawn_args_forward_preload_required_extensions_to_helper_before_startup() { + let mut config = OpenConfig::native_direct("target/liboliphaunt-broker-preload"); + config.mode = EngineMode::NativeBroker; + config.username = "app_user".to_owned(); + config.database = "app_db".to_owned(); + config.extensions = vec![Extension::PgSearch, Extension::PgSearch]; + let extensions = config.resolved_extensions().unwrap(); + let endpoint = BrokerEndpoint::Tcp { + listen: "127.0.0.1:0".to_owned(), + cancel_listen: "127.0.0.1:0".to_owned(), + }; + let args = broker_spawn_args( + &config, + &PathBuf::from("/tmp/oliphaunt-broker-preload-root"), + &extensions, + &endpoint, + ); + let args = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_arg_pair(&args, "--username", "app_user"); + assert_arg_pair(&args, "--database", "app_db"); + assert_arg_pair(&args, "--extension", "pg_search"); + assert_eq!( + args.windows(2) + .filter(|window| window[0] == "--extension" && window[1] == "pg_search") + .count(), + 1, + "broker must forward deduplicated resolved extensions to the helper" + ); + } + + #[test] + fn broker_release_targets_match_published_artifact_layout() { + let cases = [ + ( + "macos", + "aarch64", + "macos-arm64", + "oliphaunt-broker-0.1.0-macos-arm64.tar.gz", + "bin/oliphaunt-broker", + ), + ( + "linux", + "x86_64", + "linux-x64-gnu", + "oliphaunt-broker-0.1.0-linux-x64-gnu.tar.gz", + "bin/oliphaunt-broker", + ), + ( + "linux", + "aarch64", + "linux-arm64-gnu", + "oliphaunt-broker-0.1.0-linux-arm64-gnu.tar.gz", + "bin/oliphaunt-broker", + ), + ( + "windows", + "x86_64", + "windows-x64-msvc", + "oliphaunt-broker-0.1.0-windows-x64-msvc.zip", + "bin/oliphaunt-broker.exe", + ), + ]; + + for (os, arch, target_id, asset, executable) in cases { + let target = broker_release_target(os, arch).expect("published broker target"); + assert_eq!(target.target, target_id); + assert_eq!(target.asset_name(), asset); + assert_eq!(target.executable_relative_path, executable); + } + assert!(broker_release_target("freebsd", "x86_64").is_none()); + } + + #[test] + fn broker_release_asset_dir_candidates_cover_package_shapes() { + let target = broker_release_target("windows", "x86_64").unwrap(); + let candidates = target.unpacked_executable_candidates(Path::new("/cache/broker")); + assert_eq!( + candidates, + vec![ + PathBuf::from("/cache/broker/bin/oliphaunt-broker.exe"), + PathBuf::from("/cache/broker/windows-x64-msvc/bin/oliphaunt-broker.exe"), + PathBuf::from( + "/cache/broker/oliphaunt-broker-0.1.0-windows-x64-msvc/bin/oliphaunt-broker.exe" + ), + ] + ); + } + + fn assert_arg_pair(args: &[String], flag: &str, value: &str) { + assert!( + args.windows(2) + .any(|window| window[0] == flag && window[1] == value), + "missing broker helper argument pair {flag} {value} in {args:?}" + ); + } +} diff --git a/src/sdks/rust/src/builder.rs b/src/sdks/rust/src/builder.rs new file mode 100644 index 00000000..3ce61b51 --- /dev/null +++ b/src/sdks/rust/src/builder.rs @@ -0,0 +1,270 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use crate::broker::NativeBrokerRuntime; +use crate::config::{ + DEFAULT_DATABASE, DEFAULT_USERNAME, DurabilityProfile, EngineMode, NativeBrokerConfig, + NativeDirectConfig, NativeServerConfig, OpenConfig, PostgresStartupGuc, + RuntimeFootprintProfile, +}; +use crate::database::Oliphaunt; +use crate::engine::NativeRuntime; +use crate::error::{Error, Result}; +use crate::executor::EngineExecutor; +use crate::extension::Extension; +use crate::liboliphaunt::OliphauntRuntime; +use crate::server::NativeServerRuntime; +use crate::storage::{BootstrapStrategy, DatabaseRoot, RootLockPolicy, StorageConfig}; + +/// Builder for opening native Oliphaunt databases. +pub struct OliphauntBuilder { + mode: EngineMode, + root: Option, + bootstrap: BootstrapStrategy, + lock_policy: RootLockPolicy, + direct: NativeDirectConfig, + broker: NativeBrokerConfig, + server: NativeServerConfig, + durability: DurabilityProfile, + runtime_footprint: RuntimeFootprintProfile, + startup_gucs: Vec, + username: String, + database: String, + extensions: Vec, + runtime: Option>, +} + +impl Default for OliphauntBuilder { + fn default() -> Self { + Self { + mode: EngineMode::NativeDirect, + root: None, + bootstrap: BootstrapStrategy::PackagedTemplate, + lock_policy: RootLockPolicy::ExclusiveProcess, + direct: NativeDirectConfig::default(), + broker: NativeBrokerConfig::default(), + server: NativeServerConfig::default(), + durability: DurabilityProfile::Safe, + runtime_footprint: RuntimeFootprintProfile::Throughput, + startup_gucs: Vec::new(), + username: DEFAULT_USERNAME.to_owned(), + database: DEFAULT_DATABASE.to_owned(), + extensions: Vec::new(), + runtime: None, + } + } +} + +impl OliphauntBuilder { + /// Create a native builder. Defaults to `NativeDirect`. + pub fn new() -> Self { + Self::default() + } + + /// Select native direct mode. + pub fn native_direct(mut self) -> Self { + self.mode = EngineMode::NativeDirect; + self.lock_policy = RootLockPolicy::ExclusiveProcess; + self + } + + /// Select native broker mode. + pub fn native_broker(mut self) -> Self { + self.mode = EngineMode::NativeBroker; + self.lock_policy = RootLockPolicy::BrokerOwned; + self + } + + /// Select native server mode. + pub fn native_server(mut self) -> Self { + self.mode = EngineMode::NativeServer; + self.lock_policy = RootLockPolicy::BrokerOwned; + self + } + + /// Select a native engine mode. + pub fn engine(mut self, mode: EngineMode) -> Self { + self.mode = mode; + self.lock_policy = match mode { + EngineMode::NativeDirect => RootLockPolicy::ExclusiveProcess, + EngineMode::NativeBroker | EngineMode::NativeServer => RootLockPolicy::BrokerOwned, + }; + self + } + + /// Open a persistent database root directory. + pub fn path(mut self, path: impl Into) -> Self { + self.root = Some(DatabaseRoot::Path(path.into())); + self + } + + /// Open a temporary database root owned by the SDK. + pub fn temporary(mut self) -> Self { + self.root = Some(DatabaseRoot::Temporary); + self + } + + /// Use a packaged template cluster for first-open bootstrap. + pub fn packaged_template(mut self) -> Self { + self.bootstrap = BootstrapStrategy::PackagedTemplate; + self + } + + /// Require an existing already-bootstrapped root. + pub fn existing_only(mut self) -> Self { + self.bootstrap = BootstrapStrategy::ExistingOnly; + self + } + + /// Use initdb only for development/tooling flows. + pub fn initdb_tooling_only(mut self, initdb: impl Into) -> Self { + self.bootstrap = BootstrapStrategy::InitdbToolingOnly { + initdb: initdb.into(), + }; + self + } + + /// Set logical client sessions for modes that expose client sessions. + /// + /// Direct and broker mode validate this as exactly `1`; server mode is the + /// mode for true independent PostgreSQL client sessions. + pub fn max_client_sessions(mut self, sessions: usize) -> Self { + self.direct.max_client_sessions = sessions; + self.broker.max_client_sessions = sessions; + self.server.max_client_sessions = sessions; + self + } + + /// Configure broker maximum roots. + /// + /// Broker mode supervises one isolated helper process per active root while + /// each helper owns one physical PostgreSQL backend session. Use this to + /// bound how many roots one shared broker runtime may own at once. + pub fn broker_max_roots(mut self, roots: usize) -> Self { + self.broker.max_roots = roots; + self + } + + /// Use an explicit broker helper executable. + pub fn broker_executable(mut self, path: impl Into) -> Self { + self.broker.executable = Some(path.into()); + self + } + + /// Use an explicit PostgreSQL server executable. + pub fn server_executable(mut self, path: impl Into) -> Self { + self.server.executable = Some(path.into()); + self + } + + /// Use an explicit server port instead of allocating an ephemeral one. + pub fn server_port(mut self, port: u16) -> Self { + self.server.port = Some(port); + self + } + + /// Set durability profile. + pub fn durability(mut self, durability: DurabilityProfile) -> Self { + self.durability = durability; + self + } + + /// Set runtime footprint profile. + pub fn runtime_footprint(mut self, profile: RuntimeFootprintProfile) -> Self { + self.runtime_footprint = profile; + self + } + + /// Add an explicit PostgreSQL startup GUC override. + /// + /// Later overrides win when PostgreSQL receives the generated `-c` + /// arguments, so this method can intentionally override the selected + /// footprint or durability profile. + pub fn startup_guc(mut self, name: impl Into, value: impl Into) -> Self { + self.startup_gucs.push(PostgresStartupGuc::new(name, value)); + self + } + + /// Add explicit PostgreSQL startup GUC overrides. + pub fn startup_gucs(mut self, gucs: impl IntoIterator) -> Self { + self.startup_gucs.extend(gucs); + self + } + + /// Set the PostgreSQL startup user/role for SDK-owned connections. + pub fn username(mut self, username: impl Into) -> Self { + self.username = username.into(); + self + } + + /// Set the PostgreSQL database name for SDK-owned connections. + pub fn database(mut self, database: impl Into) -> Self { + self.database = database.into(); + self + } + + /// Opt into one native PostgreSQL extension. + pub fn extension(mut self, extension: Extension) -> Self { + self.extensions.push(extension); + self + } + + /// Opt into native PostgreSQL extensions. + pub fn extensions(mut self, extensions: impl IntoIterator) -> Self { + self.extensions.extend(extensions); + self + } + + /// Use a concrete native runtime implementation. + pub fn runtime(mut self, runtime: impl NativeRuntime) -> Self { + self.runtime = Some(Arc::new(runtime)); + self + } + + /// Use a shared native runtime implementation. + pub fn runtime_arc(mut self, runtime: Arc) -> Self { + self.runtime = Some(runtime); + self + } + + /// Build and validate the open configuration without opening the engine. + pub fn build_config(&self) -> Result { + let root = self.root.clone().ok_or(Error::MissingDatabaseRoot)?; + let config = OpenConfig { + mode: self.mode, + storage: StorageConfig { + root, + bootstrap: self.bootstrap.clone(), + lock_policy: self.lock_policy, + }, + direct: self.direct.clone(), + broker: self.broker.clone(), + server: self.server.clone(), + durability: self.durability, + runtime_footprint: self.runtime_footprint, + startup_gucs: self.startup_gucs.clone(), + username: self.username.clone(), + database: self.database.clone(), + extensions: self.extensions.clone(), + }; + config.validate()?; + Ok(config) + } + + /// Open the database. + pub async fn open(self) -> Result { + let config = self.build_config()?; + let runtime = self.runtime.unwrap_or_else(|| default_runtime_for(&config)); + let session = runtime.open(config)?; + let executor = EngineExecutor::spawn(session); + Ok(Oliphaunt::from_executor(executor)) + } +} + +fn default_runtime_for(config: &OpenConfig) -> Arc { + match config.mode { + EngineMode::NativeDirect => Arc::new(OliphauntRuntime::from_env()), + EngineMode::NativeBroker => Arc::new(NativeBrokerRuntime::from_config(&config.broker)), + EngineMode::NativeServer => Arc::new(NativeServerRuntime::from_config(&config.server)), + } +} diff --git a/src/sdks/rust/src/config.rs b/src/sdks/rust/src/config.rs new file mode 100644 index 00000000..a3260a80 --- /dev/null +++ b/src/sdks/rust/src/config.rs @@ -0,0 +1,435 @@ +use std::fmt; +use std::path::PathBuf; + +use crate::error::{Error, Result}; +use crate::extension::{Extension, resolve_extensions}; +use crate::storage::{ + BootstrapStrategy, DatabaseRoot, RootLockPolicy, StorageConfig, path_contains_nul, +}; + +/// Default PostgreSQL role used by SDK-managed native sessions. +pub const DEFAULT_USERNAME: &str = "postgres"; + +/// Default PostgreSQL database used by SDK-managed native sessions. +pub const DEFAULT_DATABASE: &str = "postgres"; + +/// Native runtime mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EngineMode { + /// In-process embedded mode with one physical PostgreSQL backend session. + NativeDirect, + /// Helper-process mode for process-isolated desktop operation. + NativeBroker, + /// Local PostgreSQL-compatible server mode with true independent sessions. + NativeServer, +} + +impl EngineMode { + /// All native engine modes in the canonical Rust SDK order. + pub const ALL: [Self; 3] = [Self::NativeDirect, Self::NativeBroker, Self::NativeServer]; + + /// Return every native engine mode in the canonical Rust SDK order. + pub fn all() -> [Self; 3] { + Self::ALL + } +} + +impl fmt::Display for EngineMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NativeDirect => f.write_str("native-direct"), + Self::NativeBroker => f.write_str("native-broker"), + Self::NativeServer => f.write_str("native-server"), + } + } +} + +/// Durability profile selected by the application. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum DurabilityProfile { + /// PostgreSQL-safe durability defaults. + #[default] + Safe, + /// Lower commit latency while keeping filesystem durability enabled. + Balanced, + /// Development/test profile that may lose recent data on crash. + FastDev, +} + +impl DurabilityProfile { + /// PostgreSQL GUCs implied by this profile. + pub fn postgres_gucs(self) -> &'static [(&'static str, &'static str)] { + match self { + Self::Safe => &[ + ("fsync", "on"), + ("full_page_writes", "on"), + ("synchronous_commit", "on"), + ], + Self::Balanced => &[ + ("fsync", "on"), + ("full_page_writes", "on"), + ("synchronous_commit", "off"), + ], + Self::FastDev => &[ + ("fsync", "off"), + ("full_page_writes", "off"), + ("synchronous_commit", "off"), + ], + } + } +} + +/// PostgreSQL runtime footprint profile selected by the application. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum RuntimeFootprintProfile { + /// Throughput-oriented defaults matching the current native runtime lane. + #[default] + Throughput, + /// Mobile-oriented defaults that keep durability reasonable while reducing + /// shared-memory and WAL footprint for resident embedded use. + BalancedMobile, + /// Smallest supported resident footprint for apps that prioritize package + /// and memory pressure over peak throughput. + SmallMobile, +} + +impl RuntimeFootprintProfile { + /// PostgreSQL GUCs implied by this profile. + pub fn postgres_gucs(self) -> &'static [(&'static str, &'static str)] { + match self { + Self::Throughput => &[ + ("shared_buffers", "128MB"), + ("wal_buffers", "4MB"), + ("min_wal_size", "80MB"), + ], + Self::BalancedMobile => &[ + ("max_connections", "1"), + ("superuser_reserved_connections", "0"), + ("reserved_connections", "0"), + ("autovacuum_worker_slots", "1"), + ("max_wal_senders", "0"), + ("max_replication_slots", "0"), + ("shared_buffers", "32MB"), + ("wal_buffers", "-1"), + ("min_wal_size", "32MB"), + ("max_wal_size", "64MB"), + ("io_method", "sync"), + ("io_max_concurrency", "1"), + ], + Self::SmallMobile => &[ + ("max_connections", "1"), + ("superuser_reserved_connections", "0"), + ("reserved_connections", "0"), + ("autovacuum_worker_slots", "1"), + ("max_wal_senders", "0"), + ("max_replication_slots", "0"), + ("shared_buffers", "8MB"), + ("wal_buffers", "256kB"), + ("min_wal_size", "32MB"), + ("max_wal_size", "64MB"), + ("work_mem", "1MB"), + ("maintenance_work_mem", "16MB"), + ("io_method", "sync"), + ("io_max_concurrency", "1"), + ], + } + } +} + +impl fmt::Display for RuntimeFootprintProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Throughput => f.write_str("throughput"), + Self::BalancedMobile => f.write_str("balanced-mobile"), + Self::SmallMobile => f.write_str("small-mobile"), + } + } +} + +/// Explicit PostgreSQL startup GUC override. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PostgresStartupGuc { + /// PostgreSQL GUC name, such as `shared_buffers`. + pub name: String, + /// PostgreSQL GUC value, such as `32MB`. + pub value: String, +} + +impl PostgresStartupGuc { + /// Create a startup GUC override. Validation runs when the database is + /// opened or when `OpenConfig::validate` is called. + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } + + fn startup_assignment(&self) -> String { + format!("{}={}", self.name.trim(), self.value) + } +} + +/// Direct-mode configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeDirectConfig { + /// Maximum logical client sessions allowed through this handle. + pub max_client_sessions: usize, +} + +impl Default for NativeDirectConfig { + fn default() -> Self { + Self { + max_client_sessions: 1, + } + } +} + +/// Broker-mode configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeBrokerConfig { + /// Optional broker executable path. None means resolve from package assets. + pub executable: Option, + /// Maximum logical client sessions allowed through each broker-owned root. + /// + /// Broker mode may supervise multiple roots, but each root still has one + /// physical PostgreSQL backend session. Values other than `1` are rejected + /// before the helper process is started. + pub max_client_sessions: usize, + /// Maximum roots this broker may own for the application. + /// + /// The Rust SDK broker supervisor admits up to this many active roots and + /// starts one isolated helper process per root. A single helper still owns + /// one physical PostgreSQL backend session. + pub max_roots: usize, +} + +impl Default for NativeBrokerConfig { + fn default() -> Self { + Self { + executable: None, + max_client_sessions: 1, + max_roots: 1, + } + } +} + +/// Server-mode configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeServerConfig { + /// Optional PostgreSQL server executable. None means use the packaged + /// runtime tree selected for the database root. + pub executable: Option, + /// Maximum independent PostgreSQL client sessions. + pub max_client_sessions: usize, + /// Optional fixed localhost port. None means allocate an ephemeral port. + pub port: Option, +} + +impl Default for NativeServerConfig { + fn default() -> Self { + Self { + executable: None, + max_client_sessions: 32, + port: None, + } + } +} + +/// Fully validated configuration used to open a native Oliphaunt database. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenConfig { + /// Runtime mode. + pub mode: EngineMode, + /// Storage and bootstrap policy. + pub storage: StorageConfig, + /// Direct-mode settings. + pub direct: NativeDirectConfig, + /// Broker-mode settings. + pub broker: NativeBrokerConfig, + /// Server-mode settings. + pub server: NativeServerConfig, + /// Durability profile. + pub durability: DurabilityProfile, + /// Runtime footprint profile. + pub runtime_footprint: RuntimeFootprintProfile, + /// Explicit PostgreSQL startup GUC overrides. + pub startup_gucs: Vec, + /// PostgreSQL startup user/role for SDK-owned connections. + pub username: String, + /// PostgreSQL database name for SDK-owned connections. + pub database: String, + /// Explicitly selected PostgreSQL extensions. + pub extensions: Vec, +} + +impl OpenConfig { + /// Build a direct-mode config for a persistent root. + pub fn native_direct(root: impl Into) -> Self { + Self { + mode: EngineMode::NativeDirect, + storage: StorageConfig { + root: DatabaseRoot::Path(root.into()), + bootstrap: BootstrapStrategy::PackagedTemplate, + lock_policy: RootLockPolicy::ExclusiveProcess, + }, + direct: NativeDirectConfig::default(), + broker: NativeBrokerConfig::default(), + server: NativeServerConfig::default(), + durability: DurabilityProfile::Safe, + runtime_footprint: RuntimeFootprintProfile::Throughput, + startup_gucs: Vec::new(), + username: DEFAULT_USERNAME.to_owned(), + database: DEFAULT_DATABASE.to_owned(), + extensions: Vec::new(), + } + } + + /// Validate cross-field constraints. + pub fn validate(&self) -> Result<()> { + for guc in &self.startup_gucs { + validate_postgres_startup_guc(guc)?; + } + if let DatabaseRoot::Path(root) = &self.storage.root { + if root.as_os_str().is_empty() { + return Err(Error::InvalidConfig( + "database root must not be empty".to_owned(), + )); + } + if path_contains_nul(root) { + return Err(Error::InvalidConfig( + "database root must not contain NUL bytes".to_owned(), + )); + } + } + if let BootstrapStrategy::InitdbToolingOnly { initdb } = &self.storage.bootstrap { + validate_config_path("initdb path", initdb)?; + } + validate_startup_identity("username", &self.username)?; + validate_startup_identity("database", &self.database)?; + match self.mode { + EngineMode::NativeDirect if self.direct.max_client_sessions == 0 => { + Err(Error::InvalidConfig( + "native direct max_client_sessions must be exactly 1".to_owned(), + )) + } + EngineMode::NativeDirect if self.direct.max_client_sessions > 1 => { + Err(Error::UnsupportedClientSessions { + mode: self.mode, + requested: self.direct.max_client_sessions, + supported: 1, + }) + } + EngineMode::NativeBroker if self.broker.max_client_sessions == 0 => { + Err(Error::InvalidConfig( + "native broker max_client_sessions must be exactly 1".to_owned(), + )) + } + EngineMode::NativeBroker if self.broker.max_client_sessions > 1 => { + Err(Error::UnsupportedClientSessions { + mode: self.mode, + requested: self.broker.max_client_sessions, + supported: 1, + }) + } + EngineMode::NativeBroker if self.broker.max_roots == 0 => Err(Error::InvalidConfig( + "native broker max_roots must be greater than zero".to_owned(), + )), + EngineMode::NativeBroker => { + if let Some(executable) = &self.broker.executable { + validate_config_path("native broker executable path", executable)?; + } + Ok(()) + } + EngineMode::NativeServer if self.server.max_client_sessions == 0 => { + Err(Error::InvalidConfig( + "native server max_client_sessions must be greater than zero".to_owned(), + )) + } + EngineMode::NativeServer if self.server.port == Some(0) => Err(Error::InvalidConfig( + "native server port must be greater than zero; omit the port to allocate one" + .to_owned(), + )), + EngineMode::NativeServer => { + if let Some(executable) = &self.server.executable { + validate_config_path("native server executable path", executable)?; + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Resolve selected extensions and their hard PostgreSQL dependencies. + pub fn resolved_extensions(&self) -> Result> { + resolve_extensions(&self.extensions) + } + + pub(crate) fn postgres_startup_assignments(&self) -> Vec { + let mut assignments = Vec::new(); + for (name, value) in self.runtime_footprint.postgres_gucs() { + assignments.push(format!("{name}={value}")); + } + for (name, value) in self.durability.postgres_gucs() { + assignments.push(format!("{name}={value}")); + } + for guc in &self.startup_gucs { + assignments.push(guc.startup_assignment()); + } + assignments + } +} + +fn validate_config_path(label: &str, path: &PathBuf) -> Result<()> { + if path.as_os_str().is_empty() { + return Err(Error::InvalidConfig(format!("{label} must not be empty"))); + } + if path_contains_nul(path) { + return Err(Error::InvalidConfig(format!( + "{label} must not contain NUL bytes" + ))); + } + Ok(()) +} + +fn validate_startup_identity(label: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + return Err(Error::InvalidConfig(format!("{label} must not be empty"))); + } + if value.as_bytes().contains(&0) { + return Err(Error::InvalidConfig(format!( + "{label} must not contain NUL bytes" + ))); + } + Ok(()) +} + +fn validate_postgres_startup_guc(guc: &PostgresStartupGuc) -> Result<()> { + let name = guc.name.trim(); + if name.is_empty() { + return Err(Error::InvalidConfig( + "PostgreSQL startup GUC name must not be empty".to_owned(), + )); + } + if name.as_bytes().contains(&0) || guc.value.as_bytes().contains(&0) { + return Err(Error::InvalidConfig( + "PostgreSQL startup GUC must not contain NUL bytes".to_owned(), + )); + } + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) + { + return Err(Error::InvalidConfig(format!( + "PostgreSQL startup GUC name '{}' must contain only ASCII letters, digits, '_' or '.'", + guc.name + ))); + } + if guc.value.trim().is_empty() { + return Err(Error::InvalidConfig(format!( + "PostgreSQL startup GUC '{}' value must not be empty", + guc.name + ))); + } + Ok(()) +} diff --git a/src/sdks/rust/src/database.rs b/src/sdks/rust/src/database.rs new file mode 100644 index 00000000..85b2520f --- /dev/null +++ b/src/sdks/rust/src/database.rs @@ -0,0 +1,391 @@ +use std::sync::Arc; + +use crate::builder::OliphauntBuilder; +use crate::engine::EngineCapabilities; +use crate::error::Result; +use crate::executor::EngineExecutor; +use crate::lifecycle::{BackgroundPreparationOptions, BackgroundPreparationResult}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; +use crate::query::{QueryParam, QueryResult, extended_query_request, parse_query_response}; +use crate::storage::{BackupArtifact, BackupFormat, BackupRequest, RestoreRequest}; + +/// Open native Oliphaunt database handle. +#[derive(Clone)] +pub struct Oliphaunt { + executor: Arc, +} + +impl Oliphaunt { + /// Create a native Oliphaunt builder. + pub fn builder() -> OliphauntBuilder { + OliphauntBuilder::new() + } + + /// Restore a backup artifact into a database root. + pub async fn restore(request: RestoreRequest) -> Result { + Self::restore_blocking(request) + } + + /// Restore a backup artifact into a database root from synchronous host + /// tooling. + pub fn restore_blocking(request: RestoreRequest) -> Result { + crate::backup::restore_backup(request) + } + + pub(crate) fn from_executor(executor: Arc) -> Self { + Self { executor } + } + + /// Return the capabilities of the opened native engine. + pub fn capabilities(&self) -> EngineCapabilities { + self.executor.capabilities() + } + + /// Return a PostgreSQL-compatible connection string when the engine exposes + /// one. Direct mode intentionally returns `None`. + pub fn connection_string(&self) -> Option { + self.executor.connection_string() + } + + /// True when the opened engine can produce the requested backup format. + pub fn supports_backup_format(&self, format: BackupFormat) -> bool { + self.capabilities().supports_backup_format(format) + } + + /// True when the opened engine can restore the requested backup artifact + /// format. + pub fn supports_restore_format(&self, format: BackupFormat) -> bool { + self.capabilities().supports_restore_format(format) + } + + /// Request cancellation of the currently active backend query. + /// + /// Engines that support cancellation issue this out of band rather than + /// queueing behind normal SQL work. + pub fn cancel(&self) -> Result<()> { + self.executor.cancel() + } + + /// Execute raw PostgreSQL protocol bytes through the owner executor. + pub async fn exec_protocol_raw( + &self, + request: impl Into, + ) -> Result { + self.executor.exec_protocol_raw(request.into()).await + } + + /// Execute SQL through PostgreSQL's simple-query protocol. + pub async fn execute(&self, sql: &str) -> Result { + self.executor.exec_simple_query(sql.to_owned()).await + } + + /// Execute SQL through PostgreSQL's simple-query protocol and parse one + /// result set into rows and fields. + /// + /// Use `exec_protocol_raw` or `exec_protocol_raw_stream` for COPY, + /// multi-result-set protocol handling, or custom frontend protocol flows. + pub async fn query(&self, sql: &str) -> Result { + let response = self.execute(sql).await?; + parse_query_response(&response) + } + + /// Execute a parameterized SQL statement through PostgreSQL's extended + /// protocol and parse one result set. + pub async fn query_params(&self, sql: &str, params: I) -> Result + where + I: IntoIterator, + P: Into, + { + let request = extended_query_request(sql, params)?; + let response = self.exec_protocol_raw(request).await?; + parse_query_response(&response) + } + + /// Execute raw PostgreSQL protocol bytes and stream backend bytes. + pub async fn exec_protocol_raw_stream( + &self, + request: impl Into, + on_chunk: F, + ) -> Result<()> + where + F: FnMut(&[u8]) -> Result<()> + Send + 'static, + { + self.executor + .exec_protocol_stream(request.into(), on_chunk) + .await + } + + /// Pin the single physical session for transaction/session-state-sensitive + /// work. While the pin is active, unpinned work is rejected. + pub async fn pin_session(&self) -> Result { + let token = self.executor.pin_session().await?; + Ok(SessionPin { + executor: Arc::clone(&self.executor), + token, + released: false, + }) + } + + /// Start an explicit SQL transaction pinned to the physical session. + pub async fn transaction(&self) -> Result { + let pin = self.pin_session().await?; + pin.exec_protocol_raw(ProtocolRequest::simple_query("BEGIN")?) + .await?; + Ok(Transaction { + pin: Some(pin), + finished: false, + }) + } + + /// Run a closure inside an explicit SQL transaction pinned to the physical + /// session. + /// + /// This is the ergonomic counterpart to `transaction()`: it sends `BEGIN`, + /// gives the closure access to the active transaction handle, commits on + /// success, and rolls back best-effort when the closure returns an error. + /// While the closure runs, unpinned work on the same `Oliphaunt` handle is + /// rejected. + pub async fn with_transaction( + &self, + body: impl for<'tx> AsyncFnOnce(&'tx Transaction) -> Result, + ) -> Result { + let tx = self.transaction().await?; + match body(&tx).await { + Ok(value) => { + tx.commit().await?; + Ok(value) + } + Err(error) => { + let _ = tx.rollback().await; + Err(error) + } + } + } + + /// Force a checkpoint. + pub async fn checkpoint(&self) -> Result<()> { + self.executor.checkpoint().await + } + + /// Prepare the database for mobile or desktop app suspension. + /// + /// The SDK sends cancellation out of band when active work is running and + /// checkpoints only when the physical session is idle. It never fakes + /// checkpoint success while a transaction or explicit session pin owns the + /// single direct-mode session. + pub async fn prepare_for_background( + &self, + options: BackgroundPreparationOptions, + ) -> Result { + self.executor.prepare_for_background(options).await + } + + /// Resume the database after app foregrounding. + /// + /// This probes the owner executor with a cheap PostgreSQL query so callers + /// observe any runtime failure immediately instead of on the next user + /// query. + pub async fn resume_from_background(&self) -> Result<()> { + self.executor.resume_from_background().await + } + + /// Create a backup. + pub async fn backup(&self, request: BackupRequest) -> Result { + self.executor.backup(request).await + } + + /// Close the database. + /// + /// Once close starts, queued work is rejected. Active work is allowed to + /// finish before the engine closes; call `cancel()` explicitly when a + /// running statement should be interrupted. + pub async fn close(&self) -> Result<()> { + self.executor.close().await + } +} + +/// Session pin used for transaction or session-state-sensitive protocol work. +pub struct SessionPin { + executor: Arc, + token: u64, + released: bool, +} + +impl SessionPin { + /// Execute raw protocol bytes while holding the physical-session pin. + pub async fn exec_protocol_raw( + &self, + request: impl Into, + ) -> Result { + self.executor + .pinned_exec_protocol_raw(self.token, request.into()) + .await + } + + /// Execute raw PostgreSQL protocol bytes and stream backend bytes while + /// holding the physical-session pin. + pub async fn exec_protocol_raw_stream( + &self, + request: impl Into, + on_chunk: F, + ) -> Result<()> + where + F: FnMut(&[u8]) -> Result<()> + Send + 'static, + { + self.executor + .pinned_exec_protocol_stream(self.token, request.into(), on_chunk) + .await + } + + /// Execute a parameterized SQL statement while holding the physical-session + /// pin. + pub async fn query_params(&self, sql: &str, params: I) -> Result + where + I: IntoIterator, + P: Into, + { + let request = extended_query_request(sql, params)?; + let response = self.exec_protocol_raw(request).await?; + parse_query_response(&response) + } + + /// Execute SQL through PostgreSQL's simple-query protocol while holding the + /// physical-session pin. + pub async fn query(&self, sql: &str) -> Result { + let response = self + .exec_protocol_raw(ProtocolRequest::simple_query(sql)?) + .await?; + parse_query_response(&response) + } + + /// Release the session pin. + pub async fn release(mut self) -> Result<()> { + let result = self.executor.release_pin(self.token).await; + if result.is_ok() { + self.released = true; + } + result + } +} + +impl Drop for SessionPin { + fn drop(&mut self) { + if !self.released { + self.executor.release_pin_best_effort(self.token); + self.released = true; + } + } +} + +/// Explicit transaction pinned to one physical PostgreSQL session. +pub struct Transaction { + pin: Option, + finished: bool, +} + +impl Transaction { + /// Execute SQL through PostgreSQL's simple-query protocol inside the + /// transaction. + pub async fn execute(&self, sql: &str) -> Result { + self.pin + .as_ref() + .expect("transaction pin is present until commit or rollback") + .exec_protocol_raw(ProtocolRequest::simple_query(sql)?) + .await + } + + /// Execute SQL through PostgreSQL's simple-query protocol inside the + /// transaction and parse one result set. + pub async fn query(&self, sql: &str) -> Result { + self.pin + .as_ref() + .expect("transaction pin is present until commit or rollback") + .query(sql) + .await + } + + /// Execute a parameterized SQL statement through PostgreSQL's extended + /// protocol inside the transaction and parse one result set. + pub async fn query_params(&self, sql: &str, params: I) -> Result + where + I: IntoIterator, + P: Into, + { + self.pin + .as_ref() + .expect("transaction pin is present until commit or rollback") + .query_params(sql, params) + .await + } + + /// Execute raw protocol bytes inside the transaction. + pub async fn exec_protocol_raw( + &self, + request: impl Into, + ) -> Result { + self.pin + .as_ref() + .expect("transaction pin is present until commit or rollback") + .exec_protocol_raw(request) + .await + } + + /// Execute raw PostgreSQL protocol bytes and stream backend bytes inside + /// the transaction. + pub async fn exec_protocol_raw_stream( + &self, + request: impl Into, + on_chunk: F, + ) -> Result<()> + where + F: FnMut(&[u8]) -> Result<()> + Send + 'static, + { + self.pin + .as_ref() + .expect("transaction pin is present until commit or rollback") + .exec_protocol_raw_stream(request, on_chunk) + .await + } + + /// Commit the transaction and release the session pin. + pub async fn commit(mut self) -> Result<()> { + self.pin + .as_ref() + .expect("transaction pin is present until commit or rollback") + .exec_protocol_raw(ProtocolRequest::simple_query("COMMIT")?) + .await?; + self.finished = true; + self.pin + .take() + .expect("transaction pin is present until commit or rollback") + .release() + .await + } + + /// Roll back the transaction and release the session pin. + pub async fn rollback(mut self) -> Result<()> { + self.pin + .as_ref() + .expect("transaction pin is present until commit or rollback") + .exec_protocol_raw(ProtocolRequest::simple_query("ROLLBACK")?) + .await?; + self.finished = true; + self.pin + .take() + .expect("transaction pin is present until commit or rollback") + .release() + .await + } +} + +impl Drop for Transaction { + fn drop(&mut self) { + if !self.finished { + self.finished = true; + if let Some(mut pin) = self.pin.take() { + pin.released = true; + pin.executor.rollback_and_release_pin_best_effort(pin.token); + } + } + } +} diff --git a/src/sdks/rust/src/engine.rs b/src/sdks/rust/src/engine.rs new file mode 100644 index 00000000..a868462f --- /dev/null +++ b/src/sdks/rust/src/engine.rs @@ -0,0 +1,265 @@ +use crate::config::{EngineMode, OpenConfig}; +use crate::error::{Error, Result}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; +use crate::storage::{BackupArtifact, BackupFormat, BackupRequest}; +use std::sync::Arc; + +/// Concurrency semantics advertised by an engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SessionConcurrency { + /// One physical PostgreSQL session. Calls may be concurrent at the Rust + /// handle level but are serialized by the owner executor. + SerializedSingleSession, + /// Multiple independent PostgreSQL client sessions. + IndependentSessions, +} + +/// Capabilities exposed by an opened engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EngineCapabilities { + /// Engine mode. + pub mode: EngineMode, + /// Session concurrency semantics. + pub session_concurrency: SessionConcurrency, + /// True if the engine is isolated in a helper/server process. + pub process_isolated: bool, + /// True if this engine/runtime can own multiple database roots. + pub multi_root: bool, + /// True if the same host process can close this session and later open a + /// root again through the same mode. + pub reopenable: bool, + /// True when `close` is a logical detach from a resident backend and the + /// same root can be reopened in this process without reinitializing the + /// physical backend. + pub same_root_logical_reopen: bool, + /// True when this mode can open a different root in the same application + /// process after closing the current session. + pub root_switchable: bool, + /// True when this mode can recover the opened handle after its managed + /// PostgreSQL process exits unexpectedly. + pub crash_restartable: bool, + /// Maximum independent client sessions. + pub max_client_sessions: usize, + /// Raw protocol execution. + pub protocol_raw: bool, + /// Streaming protocol responses. + pub protocol_stream: bool, + /// Out-of-band query cancellation. + pub query_cancel: bool, + /// Physical/logical backup and restore APIs. + pub backup_restore: bool, + /// Backup formats this mode can produce. + pub backup_formats: Vec, + /// Backup formats this SDK can restore for this mode family. + pub restore_formats: Vec, + /// PostgreSQL simple-query execution. + pub simple_query: bool, + /// Opt-in PostgreSQL extensions. + pub extensions: bool, + /// PostgreSQL-compatible connection strings. + pub connection_strings: bool, + /// Connection string for server-style clients when this opened session + /// exposes one. + pub connection_string: Option, +} + +/// SDK-level support status for one engine mode. +/// +/// This is separate from opened-session capabilities: it tells an application +/// whether the SDK can create a runtime for a mode before calling `open`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EngineModeSupport { + /// Engine mode. + pub mode: EngineMode, + /// True when the SDK surface can open this mode. + pub available: bool, + /// Canonical PostgreSQL/session semantics for the mode. + pub capabilities: EngineCapabilities, + /// Product reason when `available` is false. + pub unavailable_reason: Option<&'static str>, +} + +impl EngineModeSupport { + /// Available support entry for a native mode. + pub fn available(mode: EngineMode) -> Self { + Self { + mode, + available: true, + capabilities: EngineCapabilities::for_mode(mode), + unavailable_reason: None, + } + } + + /// Unavailable support entry for a native mode. + pub fn unavailable(mode: EngineMode, reason: &'static str) -> Self { + Self { + mode, + available: false, + capabilities: EngineCapabilities::for_mode(mode), + unavailable_reason: Some(reason), + } + } +} + +impl EngineCapabilities { + /// Canonical capabilities for a mode before runtime-specific refinements. + pub fn for_mode(mode: EngineMode) -> Self { + match mode { + EngineMode::NativeDirect => Self { + mode, + session_concurrency: SessionConcurrency::SerializedSingleSession, + process_isolated: false, + multi_root: false, + reopenable: true, + same_root_logical_reopen: true, + root_switchable: false, + crash_restartable: false, + max_client_sessions: 1, + protocol_raw: true, + protocol_stream: true, + query_cancel: true, + backup_restore: true, + backup_formats: vec![BackupFormat::PhysicalArchive], + restore_formats: vec![BackupFormat::PhysicalArchive], + simple_query: true, + extensions: true, + connection_strings: false, + connection_string: None, + }, + EngineMode::NativeBroker => Self { + mode, + session_concurrency: SessionConcurrency::SerializedSingleSession, + process_isolated: true, + multi_root: true, + reopenable: true, + same_root_logical_reopen: false, + root_switchable: true, + crash_restartable: true, + max_client_sessions: 1, + protocol_raw: true, + protocol_stream: true, + query_cancel: true, + backup_restore: true, + backup_formats: vec![BackupFormat::PhysicalArchive], + restore_formats: vec![BackupFormat::PhysicalArchive], + simple_query: true, + extensions: true, + connection_strings: false, + connection_string: None, + }, + EngineMode::NativeServer => Self { + mode, + session_concurrency: SessionConcurrency::IndependentSessions, + process_isolated: true, + multi_root: false, + reopenable: true, + same_root_logical_reopen: false, + root_switchable: true, + crash_restartable: false, + max_client_sessions: 32, + protocol_raw: true, + protocol_stream: true, + query_cancel: true, + backup_restore: true, + backup_formats: vec![BackupFormat::Sql, BackupFormat::PhysicalArchive], + restore_formats: vec![BackupFormat::PhysicalArchive], + simple_query: true, + extensions: true, + connection_strings: true, + connection_string: None, + }, + } + } + + /// True when this engine can produce the requested backup format. + pub fn supports_backup_format(&self, format: BackupFormat) -> bool { + self.backup_restore && self.backup_formats.contains(&format) + } + + /// True when this engine can restore the requested backup artifact format. + pub fn supports_restore_format(&self, format: BackupFormat) -> bool { + self.backup_restore && self.restore_formats.contains(&format) + } + + /// Rust SDK mode support. The Rust SDK owns all three native modes; runtime + /// opening can still fail if the configured helper binaries or native + /// PostgreSQL package are missing. + pub fn rust_sdk_support() -> [EngineModeSupport; 3] { + EngineMode::all().map(EngineModeSupport::available) + } +} + +/// Concrete native runtime provider. +pub trait NativeRuntime: Send + Sync + 'static { + /// Open an engine session for the validated config. + fn open(&self, config: OpenConfig) -> Result>; +} + +/// Opened engine session owned by the SDK executor thread. +pub trait EngineSession: Send + 'static { + /// Capabilities for this opened session. + fn capabilities(&self) -> EngineCapabilities; + + /// PostgreSQL connection string exposed by server-capable modes. + fn connection_string(&self) -> Option { + self.capabilities().connection_string + } + + /// Out-of-band cancellation handle for the current backend query. + fn cancel_handle(&self) -> Option> { + None + } + + /// Execute raw PostgreSQL protocol bytes. + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result; + + /// Execute SQL through PostgreSQL's simple-query protocol. + fn exec_simple_query(&mut self, sql: &str) -> Result { + self.exec_protocol_raw(ProtocolRequest::simple_query(sql)?) + } + + /// Execute raw PostgreSQL protocol bytes and stream backend bytes. + fn exec_protocol_stream( + &mut self, + request: ProtocolRequest, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, + ) -> Result<()> { + let response = self.exec_protocol_raw(request)?; + on_chunk(response.as_bytes()) + } + + /// Force a checkpoint. + fn checkpoint(&mut self) -> Result<()> { + Ok(()) + } + + /// Produce a backup artifact. + fn backup(&mut self, request: BackupRequest) -> Result { + let _ = request; + Err(Error::Engine( + "backup is not supported by this runtime".into(), + )) + } + + /// Close the session. + fn close(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Out-of-band query cancellation for engines that can interrupt the active +/// backend without waiting for the serialized owner executor. +pub trait EngineCancel: Send + Sync + 'static { + /// Request cancellation of the currently active backend query. + fn cancel(&self) -> Result<()>; +} + +/// Default runtime used until a concrete PostgreSQL 18 binding is supplied. +#[derive(Debug, Clone, Copy, Default)] +pub struct RuntimeUnavailable; + +impl NativeRuntime for RuntimeUnavailable { + fn open(&self, config: OpenConfig) -> Result> { + Err(Error::RuntimeUnavailable { mode: config.mode }) + } +} diff --git a/src/sdks/rust/src/error.rs b/src/sdks/rust/src/error.rs new file mode 100644 index 00000000..c97d801e --- /dev/null +++ b/src/sdks/rust/src/error.rs @@ -0,0 +1,195 @@ +use std::error; +use std::fmt; +use std::str; + +/// Result alias used by the native SDK. +pub type Result = std::result::Result; + +/// Error type for SDK configuration, lifecycle, and engine execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// A database root was required but not configured. + MissingDatabaseRoot, + /// The selected engine mode cannot provide the requested client sessions. + UnsupportedClientSessions { + /// Engine mode that rejected the request. + mode: crate::EngineMode, + /// Requested client sessions. + requested: usize, + /// Maximum supported client sessions. + supported: usize, + }, + /// No concrete native runtime has been linked into the builder. + RuntimeUnavailable { + /// Engine mode the caller attempted to open. + mode: crate::EngineMode, + }, + /// The selected runtime does not implement the selected engine mode. + UnsupportedEngineMode { + /// Engine mode the caller attempted to open. + mode: crate::EngineMode, + /// Reason this runtime cannot serve the mode. + reason: String, + }, + /// The owner executor has stopped. + EngineStopped, + /// A runtime returned an execution failure. + Engine(String), + /// PostgreSQL returned an ErrorResponse. + Postgres(PostgresError), + /// A session pin is already active, so unpinned work would violate session + /// isolation. + SessionPinned, + /// A session pin token no longer owns the physical session. + InvalidSessionPin, + /// A configuration value was invalid. + InvalidConfig(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingDatabaseRoot => { + f.write_str("database root is not configured; call path or temporary") + } + Self::UnsupportedClientSessions { + mode, + requested, + supported, + } => write!( + f, + "{mode} supports at most {supported} client session(s), requested {requested}" + ), + Self::RuntimeUnavailable { mode } => write!( + f, + "no native runtime is linked for {mode}; provide a NativeRuntime implementation" + ), + Self::UnsupportedEngineMode { mode, reason } => { + write!(f, "{mode} is not supported by this runtime: {reason}") + } + Self::EngineStopped => f.write_str("native engine executor has stopped"), + Self::Engine(message) => f.write_str(message), + Self::Postgres(error) => error.fmt(f), + Self::SessionPinned => { + f.write_str("physical session is pinned; use the active SessionPin") + } + Self::InvalidSessionPin => { + f.write_str("session pin is not active for this physical session") + } + Self::InvalidConfig(message) => f.write_str(message), + } + } +} + +impl error::Error for Error {} + +/// Structured PostgreSQL `ErrorResponse` decoded from backend protocol bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PostgresError { + /// Backend severity, such as `ERROR` or `FATAL`. + pub severity: Option, + /// SQLSTATE code, such as `23505` for unique violations. + pub sqlstate: Option, + /// Primary human-readable PostgreSQL error message. + pub message: String, + /// Optional detailed explanation from PostgreSQL. + pub detail: Option, + /// Optional hint from PostgreSQL. + pub hint: Option, + /// Optional source statement position. + pub position: Option, + /// Optional context stack, exposed as `where` by PostgreSQL. + pub where_: Option, + /// Optional schema name reported by PostgreSQL. + pub schema_name: Option, + /// Optional table name reported by PostgreSQL. + pub table_name: Option, + /// Optional column name reported by PostgreSQL. + pub column_name: Option, + /// Optional data type name reported by PostgreSQL. + pub data_type_name: Option, + /// Optional constraint name reported by PostgreSQL. + pub constraint_name: Option, + /// Raw ErrorResponse fields in backend order. + pub fields: Vec, +} + +impl PostgresError { + /// Build a structured PostgreSQL error from raw protocol fields. + pub fn from_fields(fields: Vec) -> Self { + Self { + severity: field_value(&fields, b'S').or_else(|| field_value(&fields, b'V')), + sqlstate: field_value(&fields, b'C'), + message: field_value(&fields, b'M') + .unwrap_or_else(|| "PostgreSQL ErrorResponse".to_owned()), + detail: field_value(&fields, b'D'), + hint: field_value(&fields, b'H'), + position: field_value(&fields, b'P'), + where_: field_value(&fields, b'W'), + schema_name: field_value(&fields, b's'), + table_name: field_value(&fields, b't'), + column_name: field_value(&fields, b'c'), + data_type_name: field_value(&fields, b'd'), + constraint_name: field_value(&fields, b'n'), + fields, + } + } + + pub(crate) fn fallback() -> Self { + Self::from_fields(vec![PostgresErrorField { + code: b'M', + value: "PostgreSQL ErrorResponse".to_owned(), + }]) + } +} + +impl fmt::Display for PostgresError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (&self.severity, &self.sqlstate) { + (Some(severity), Some(sqlstate)) => { + write!(f, "{severity} [{sqlstate}]: {}", self.message) + } + (Some(severity), None) => write!(f, "{severity}: {}", self.message), + (None, Some(sqlstate)) => write!(f, "[{sqlstate}]: {}", self.message), + (None, None) => f.write_str(&self.message), + } + } +} + +/// One raw field from a PostgreSQL `ErrorResponse`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PostgresErrorField { + /// Single-byte PostgreSQL field code. + pub code: u8, + /// Field value decoded as UTF-8. + pub value: String, +} + +pub(crate) fn parse_postgres_error_response(mut body: &[u8]) -> PostgresError { + let mut fields = Vec::new(); + while let Some((&code, rest)) = body.split_first() { + body = rest; + if code == 0 { + break; + } + let Some((value, remaining)) = read_error_cstring(body) else { + return PostgresError::fallback(); + }; + fields.push(PostgresErrorField { code, value }); + body = remaining; + } + PostgresError::from_fields(fields) +} + +fn field_value(fields: &[PostgresErrorField], code: u8) -> Option { + fields + .iter() + .find(|field| field.code == code) + .map(|field| field.value.clone()) +} + +fn read_error_cstring(input: &[u8]) -> Option<(String, &[u8])> { + let nul = input.iter().position(|byte| *byte == 0)?; + let value = str::from_utf8(&input[..nul]).ok()?.to_owned(); + Some((value, &input[nul + 1..])) +} diff --git a/src/sdks/rust/src/executor.rs b/src/sdks/rust/src/executor.rs new file mode 100644 index 00000000..f24c3736 --- /dev/null +++ b/src/sdks/rust/src/executor.rs @@ -0,0 +1,504 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::{self, JoinHandle}; + +use crossbeam_channel::{Sender, unbounded}; + +use crate::engine::{EngineCancel, EngineCapabilities, EngineSession}; +use crate::error::{Error, Result}; +use crate::lifecycle::{ + BackgroundCheckpointSkipReason, BackgroundPreparationOptions, BackgroundPreparationResult, +}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; +use crate::reply; +use crate::storage::{BackupArtifact, BackupRequest}; + +type StreamSink = Box Result<()> + Send>; + +pub(crate) struct EngineExecutor { + sender: Sender, + capabilities: EngineCapabilities, + connection_string: Option, + cancel: Option>, + active_work: Arc, + session_pinned: Arc, + closed: Arc, + owner: Option>, +} + +impl EngineExecutor { + pub(crate) fn spawn(mut session: Box) -> Arc { + let capabilities = session.capabilities(); + let connection_string = session.connection_string(); + let cancel = session.cancel_handle(); + let active_work = Arc::new(AtomicBool::new(false)); + let owner_active_work = Arc::clone(&active_work); + let session_pinned = Arc::new(AtomicBool::new(false)); + let owner_session_pinned = Arc::clone(&session_pinned); + let closed = Arc::new(AtomicBool::new(false)); + let owner_closed = Arc::clone(&closed); + let (sender, receiver) = unbounded::(); + let owner = thread::Builder::new() + .name("oliphaunt-owner".to_owned()) + .spawn(move || { + let mut active_pin = None; + let mut next_pin = 1_u64; + for command in receiver { + if owner_closed.load(Ordering::SeqCst) && !command.is_close() { + command.reply_engine_stopped(); + continue; + } + match command { + Command::Exec { request, reply } => { + let result = if active_pin.is_some() { + Err(Error::SessionPinned) + } else { + run_active_work(&owner_active_work, || { + session.exec_protocol_raw(request) + }) + }; + reply.send(result); + } + Command::SimpleQuery { sql, reply } => { + let result = if active_pin.is_some() { + Err(Error::SessionPinned) + } else { + run_active_work(&owner_active_work, || { + session.exec_simple_query(&sql) + }) + }; + reply.send(result); + } + Command::PinnedExec { + token, + request, + reply, + } => { + let result = if active_pin == Some(token) { + run_active_work(&owner_active_work, || { + session.exec_protocol_raw(request) + }) + } else { + Err(Error::InvalidSessionPin) + }; + reply.send(result); + } + Command::PinnedStream { + token, + request, + mut on_chunk, + reply, + } => { + let result = if active_pin == Some(token) { + run_active_work(&owner_active_work, || { + session.exec_protocol_stream(request, &mut on_chunk) + }) + } else { + Err(Error::InvalidSessionPin) + }; + reply.send(result); + } + Command::Stream { + request, + mut on_chunk, + reply, + } => { + let result = if active_pin.is_some() { + Err(Error::SessionPinned) + } else { + run_active_work(&owner_active_work, || { + session.exec_protocol_stream(request, &mut on_chunk) + }) + }; + reply.send(result); + } + Command::Pin { reply } => { + if active_pin.is_some() { + reply.send(Err(Error::SessionPinned)); + } else { + let token = next_pin; + next_pin = next_pin.saturating_add(1); + active_pin = Some(token); + owner_session_pinned.store(true, Ordering::SeqCst); + reply.send(Ok(token)); + } + } + Command::ReleasePin { token, reply } => { + let result = if active_pin == Some(token) { + active_pin = None; + owner_session_pinned.store(false, Ordering::SeqCst); + Ok(()) + } else { + Err(Error::InvalidSessionPin) + }; + if let Some(reply) = reply { + reply.send(result); + } + } + Command::RollbackAndReleasePin { token } => { + if active_pin == Some(token) { + let _ = run_active_work(&owner_active_work, || { + let rollback = ProtocolRequest::simple_query("ROLLBACK")?; + session.exec_protocol_raw(rollback) + }); + active_pin = None; + owner_session_pinned.store(false, Ordering::SeqCst); + } + } + Command::Checkpoint { reply } => { + let result = if active_pin.is_some() { + Err(Error::SessionPinned) + } else { + run_active_work(&owner_active_work, || session.checkpoint()) + }; + reply.send(result); + } + Command::Backup { request, reply } => { + let result = if active_pin.is_some() { + Err(Error::SessionPinned) + } else { + run_active_work(&owner_active_work, || session.backup(request)) + }; + reply.send(result); + } + Command::Close { reply } => { + let result = session.close(); + drop(session); + owner_session_pinned.store(false, Ordering::SeqCst); + if let Some(reply) = reply { + reply.send(result); + } + return; + } + } + } + }) + .expect("spawn oliphaunt owner thread"); + + Arc::new(Self { + sender, + capabilities, + connection_string, + cancel, + active_work, + session_pinned, + closed, + owner: Some(owner), + }) + } + + pub(crate) fn capabilities(&self) -> EngineCapabilities { + self.capabilities.clone() + } + + pub(crate) fn connection_string(&self) -> Option { + self.connection_string.clone() + } + + pub(crate) fn cancel(&self) -> Result<()> { + if self.closed.load(Ordering::SeqCst) { + return Err(Error::EngineStopped); + } + let cancel = self.cancel.as_ref().ok_or_else(|| { + Error::Engine("query cancellation is not supported by this engine".to_owned()) + })?; + cancel.cancel() + } + + pub(crate) async fn exec_protocol_raw( + &self, + request: ProtocolRequest, + ) -> Result { + let (reply, receiver) = reply::channel(); + self.send(Command::Exec { request, reply })?; + receiver.await + } + + pub(crate) async fn exec_simple_query(&self, sql: String) -> Result { + let (reply, receiver) = reply::channel(); + self.send(Command::SimpleQuery { sql, reply })?; + receiver.await + } + + pub(crate) async fn pinned_exec_protocol_raw( + &self, + token: u64, + request: ProtocolRequest, + ) -> Result { + let (reply, receiver) = reply::channel(); + self.send(Command::PinnedExec { + token, + request, + reply, + })?; + receiver.await + } + + pub(crate) async fn exec_protocol_stream( + &self, + request: ProtocolRequest, + on_chunk: F, + ) -> Result<()> + where + F: FnMut(&[u8]) -> Result<()> + Send + 'static, + { + let (reply, receiver) = reply::channel(); + self.send(Command::Stream { + request, + on_chunk: Box::new(on_chunk), + reply, + })?; + receiver.await + } + + pub(crate) async fn pinned_exec_protocol_stream( + &self, + token: u64, + request: ProtocolRequest, + on_chunk: F, + ) -> Result<()> + where + F: FnMut(&[u8]) -> Result<()> + Send + 'static, + { + let (reply, receiver) = reply::channel(); + self.send(Command::PinnedStream { + token, + request, + on_chunk: Box::new(on_chunk), + reply, + })?; + receiver.await + } + + pub(crate) async fn pin_session(&self) -> Result { + let (reply, receiver) = reply::channel(); + self.send(Command::Pin { reply })?; + receiver.await + } + + pub(crate) async fn release_pin(&self, token: u64) -> Result<()> { + let (reply, receiver) = reply::channel(); + self.send(Command::ReleasePin { + token, + reply: Some(reply), + })?; + receiver.await + } + + pub(crate) fn release_pin_best_effort(&self, token: u64) { + let _ = self.sender.send(Command::ReleasePin { token, reply: None }); + } + + pub(crate) fn rollback_and_release_pin_best_effort(&self, token: u64) { + let _ = self.sender.send(Command::RollbackAndReleasePin { token }); + } + + pub(crate) async fn checkpoint(&self) -> Result<()> { + let (reply, receiver) = reply::channel(); + self.send(Command::Checkpoint { reply })?; + receiver.await + } + + pub(crate) async fn prepare_for_background( + &self, + options: BackgroundPreparationOptions, + ) -> Result { + if self.closed.load(Ordering::SeqCst) { + return Err(Error::EngineStopped); + } + + let had_active_work = self.active_work.load(Ordering::SeqCst); + let cancelled_active_work = if options.cancel_active_work && had_active_work { + self.cancel_active_work()?; + true + } else { + false + }; + + if !options.checkpoint_when_idle { + return Ok(BackgroundPreparationResult::skipped( + cancelled_active_work, + None, + )); + } + if self.session_pinned.load(Ordering::SeqCst) { + return Ok(BackgroundPreparationResult::skipped( + cancelled_active_work, + Some(BackgroundCheckpointSkipReason::SessionPinned), + )); + } + if had_active_work || self.active_work.load(Ordering::SeqCst) { + return Ok(BackgroundPreparationResult::skipped( + cancelled_active_work, + Some(BackgroundCheckpointSkipReason::ActiveWork), + )); + } + + match self.checkpoint().await { + Ok(()) => Ok(BackgroundPreparationResult::checkpointed()), + Err(Error::SessionPinned) => Ok(BackgroundPreparationResult::skipped( + cancelled_active_work, + Some(BackgroundCheckpointSkipReason::SessionPinned), + )), + Err(error) => Err(error), + } + } + + pub(crate) async fn resume_from_background(&self) -> Result<()> { + self.exec_simple_query("SELECT 1".to_owned()) + .await + .map(|_| ()) + } + + pub(crate) async fn backup(&self, request: BackupRequest) -> Result { + if !self.capabilities.supports_backup_format(request.format) { + return Err(Error::Engine(format!( + "{:?} backup is not supported by {}", + request.format, self.capabilities.mode + ))); + } + let (reply, receiver) = reply::channel(); + self.send(Command::Backup { request, reply })?; + receiver.await + } + + pub(crate) async fn close(&self) -> Result<()> { + if self.closed.swap(true, Ordering::SeqCst) { + return Ok(()); + } + let (reply, receiver) = reply::channel(); + self.sender + .send(Command::Close { reply: Some(reply) }) + .map_err(|_| Error::EngineStopped)?; + receiver.await + } + + fn send(&self, command: Command) -> Result<()> { + if self.closed.load(Ordering::SeqCst) { + return Err(Error::EngineStopped); + } + self.sender.send(command).map_err(|_| Error::EngineStopped) + } + + fn cancel_active_work_best_effort(&self) { + if !self.active_work.load(Ordering::SeqCst) { + return; + } + let _ = self.cancel_active_work(); + } + + fn cancel_active_work(&self) -> Result<()> { + let cancel = self.cancel.as_ref().ok_or_else(|| { + Error::Engine("query cancellation is not supported by this engine".to_owned()) + })?; + cancel.cancel() + } +} + +impl Drop for EngineExecutor { + fn drop(&mut self) { + if !self.closed.swap(true, Ordering::SeqCst) { + self.cancel_active_work_best_effort(); + let _ = self.sender.send(Command::Close { reply: None }); + } + if let Some(owner) = self.owner.take() { + let _ = owner.join(); + } + } +} + +enum Command { + Exec { + request: ProtocolRequest, + reply: reply::Sender>, + }, + SimpleQuery { + sql: String, + reply: reply::Sender>, + }, + PinnedExec { + token: u64, + request: ProtocolRequest, + reply: reply::Sender>, + }, + PinnedStream { + token: u64, + request: ProtocolRequest, + on_chunk: StreamSink, + reply: reply::Sender>, + }, + Stream { + request: ProtocolRequest, + on_chunk: StreamSink, + reply: reply::Sender>, + }, + Pin { + reply: reply::Sender>, + }, + ReleasePin { + token: u64, + reply: Option>>, + }, + RollbackAndReleasePin { + token: u64, + }, + Checkpoint { + reply: reply::Sender>, + }, + Backup { + request: BackupRequest, + reply: reply::Sender>, + }, + Close { + reply: Option>>, + }, +} + +impl Command { + fn is_close(&self) -> bool { + matches!(self, Self::Close { .. }) + } + + fn reply_engine_stopped(self) { + match self { + Self::Exec { reply, .. } + | Self::SimpleQuery { reply, .. } + | Self::PinnedExec { reply, .. } => { + reply.send(Err(Error::EngineStopped)); + } + Self::PinnedStream { reply, .. } => reply.send(Err(Error::EngineStopped)), + Self::Stream { reply, .. } => reply.send(Err(Error::EngineStopped)), + Self::Pin { reply } => reply.send(Err(Error::EngineStopped)), + Self::Checkpoint { reply } => reply.send(Err(Error::EngineStopped)), + Self::Backup { reply, .. } => reply.send(Err(Error::EngineStopped)), + Self::RollbackAndReleasePin { .. } => {} + Self::ReleasePin { reply, .. } | Self::Close { reply } => { + if let Some(reply) = reply { + reply.send(Err(Error::EngineStopped)); + } + } + } + } +} + +fn run_active_work(active_work: &AtomicBool, work: impl FnOnce() -> T) -> T { + let _guard = ActiveWorkGuard::new(active_work); + work() +} + +struct ActiveWorkGuard<'a> { + active_work: &'a AtomicBool, +} + +impl<'a> ActiveWorkGuard<'a> { + fn new(active_work: &'a AtomicBool) -> Self { + active_work.store(true, Ordering::SeqCst); + Self { active_work } + } +} + +impl Drop for ActiveWorkGuard<'_> { + fn drop(&mut self) { + self.active_work.store(false, Ordering::SeqCst); + } +} diff --git a/src/sdks/rust/src/extension.rs b/src/sdks/rust/src/extension.rs new file mode 100644 index 00000000..412c6310 --- /dev/null +++ b/src/sdks/rust/src/extension.rs @@ -0,0 +1,459 @@ +use std::collections::BTreeSet; + +use crate::error::{Error, Result}; + +#[path = "generated/extensions.rs"] +mod generated_extensions; +pub use generated_extensions::Extension; + +impl Extension { + /// First-party PostgreSQL 18 extensions distributed by the native release lane. + pub const FIRST_PARTY_PG18_SUPPORTED: &'static [Self] = + generated_extensions::FIRST_PARTY_PG18_SUPPORTED; + + /// PostgreSQL 18 extensions that public release packaging may ship as + /// exact prebuilt app-bundle assets today. + pub const RELEASE_READY_PG18_SUPPORTED: &'static [Self] = + generated_extensions::RELEASE_READY_PG18_SUPPORTED; + + /// PostgreSQL 18 extensions that have release-ready mobile artifacts today. + /// + /// SQL-only extensions do not need a mobile static registry. Native-module + /// extensions appear here only after the iOS and Android release builds can + /// link their prebuilt static objects without application developers + /// compiling extension source. + pub const MOBILE_RELEASE_READY_PG18_SUPPORTED: &'static [Self] = + generated_extensions::MOBILE_RELEASE_READY_PG18_SUPPORTED; + + /// Externally sourced PostgreSQL 18 extensions known to the native lane. + pub const EXTERNAL_PG18_SUPPORTED: &'static [Self] = + generated_extensions::EXTERNAL_PG18_SUPPORTED; + + /// All PostgreSQL 18 extensions known to the native lane. + pub const ALL_PG18_SUPPORTED: &'static [Self] = generated_extensions::ALL_PG18_SUPPORTED; + + /// SQL extension name used by `CREATE EXTENSION`. + pub const fn sql_name(self) -> &'static str { + generated_extensions::sql_name(self) + } + + /// Native module stem before the platform dynamic-library suffix. + pub const fn native_module_stem(self) -> Option<&'static str> { + generated_extensions::native_module_stem(self) + } + + /// Native module filename expected under `lib/postgresql`. + pub fn native_module_file(self) -> Option { + self.native_module_stem() + .map(|stem| format!("{}{}", stem, std::env::consts::DLL_SUFFIX)) + } + + /// Whether this extension has a `CREATE EXTENSION` control file. + pub const fn creates_extension(self) -> bool { + generated_extensions::creates_extension(self) + } + + /// SQL extension dependencies that must be materialized with this extension. + pub const fn dependencies(self) -> &'static [Extension] { + generated_extensions::dependencies(self) + } + + /// Packaging policy for this extension. + pub const fn artifact_policy(self) -> ExtensionArtifactPolicy { + generated_extensions::artifact_policy(self) + } + + /// Whether the native release build currently owns first-party artifacts + /// for this extension. + pub const fn first_party_artifact(self) -> bool { + matches!(self.artifact_policy(), ExtensionArtifactPolicy::FirstParty) + } + + /// Whether desktop release artifacts may include this extension today. + pub const fn desktop_release_ready(self) -> bool { + generated_extensions::desktop_release_ready(self) + } + + /// Whether iOS and Android release artifacts may include this extension + /// without app developers building extension source. + pub const fn mobile_release_ready(self) -> bool { + generated_extensions::mobile_release_ready(self) + } + + /// Whether this extension needs a mobile static-registry row when selected + /// for iOS or Android. + pub const fn requires_mobile_static_registry(self) -> bool { + matches!(self.native_module_stem(), Some(_)) + } + + /// Shared-preload library that must be present when this extension is + /// selected. + pub const fn required_shared_preload_library(self) -> Option<&'static str> { + generated_extensions::required_shared_preload_library(self) + } + + /// Resolve an extension by SQL name. + pub fn by_sql_name(sql_name: &str) -> Option { + Self::ALL_PG18_SUPPORTED + .iter() + .copied() + .find(|extension| extension.sql_name() == sql_name) + } + + /// Resolve a public release-ready extension by exact SQL name. + /// + /// This intentionally does not accept catalog labels, aliases, or grouped + /// selectors. App artifacts are selected one extension at a time so + /// unrequested extensions cannot be shipped accidentally. + pub fn by_release_ready_sql_name(sql_name: &str) -> Option { + let extension = Self::by_sql_name(sql_name)?; + extension.desktop_release_ready().then_some(extension) + } + + /// Static release manifest row for this extension. + pub const fn manifest_entry(self) -> ExtensionManifestEntry { + let module = match self.native_module_stem() { + Some(stem) => ExtensionModuleAsset::NativeModule { stem }, + None => ExtensionModuleAsset::SqlOnly, + }; + let sql_assets = if self.creates_extension() { + ExtensionSqlAsset::ControlAndSql + } else { + ExtensionSqlAsset::LoadableModuleOnly + }; + let smoke = if self.creates_extension() { + ExtensionSmokePlan::CreateExtensionCascade + } else { + ExtensionSmokePlan::LoadSharedLibrary + }; + let mobile_static_link = match module { + ExtensionModuleAsset::NativeModule { .. } => MobileStaticLinkStatus::PendingRegistry, + ExtensionModuleAsset::SqlOnly => MobileStaticLinkStatus::NotRequiredSqlOnly, + }; + ExtensionManifestEntry { + extension: self, + sql_name: self.sql_name(), + pg_major: 18, + pg18_supported: true, + creates_extension: self.creates_extension(), + sql_assets, + module, + dependencies: self.dependencies(), + data_files: extension_data_files(self), + smoke, + coverage: ExtensionCoverage::GATED_RELEASE_MATRIX, + mobile_static_link, + artifact_policy: self.artifact_policy(), + } + } +} + +/// How an extension's source is built. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExtensionSourceKind { + /// PostgreSQL contrib or PGXS-style Makefile extension. + Pgxs, + /// Rust extension built with pgrx. + Pgrx, +} + +/// Binary redistribution policy for a known extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExtensionRedistribution { + /// The extension license permits first-party binary redistribution. + Allowed, + /// Binary redistribution needs a separate commercial or enterprise license. + RequiresCommercialLicense, +} + +/// Packaging policy for a known PostgreSQL 18 extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExtensionArtifactPolicy { + /// First-party release-lane extension built and tested by oliphaunt. + FirstParty, + /// Known external extension that requires explicit assets and release gates. + External { + /// Upstream source URL. + upstream: &'static str, + /// Upstream license summary. + license: &'static str, + /// Source/build kind. + source_kind: ExtensionSourceKind, + /// Binary redistribution policy. + redistribution: ExtensionRedistribution, + /// Whether the extension must be loaded through shared_preload_libraries. + requires_shared_preload: bool, + /// Short release-note detail for this extension. + notes: &'static str, + }, +} + +/// Runtime environment variable required by selected extension data files. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExtensionRuntimeEnvironment { + /// Environment variable name. + pub name: &'static str, + /// Directory path relative to the materialized runtime root. + pub relative_path: &'static str, + /// File that must exist under `relative_path` before the variable is set. + pub required_file: &'static str, +} + +impl ExtensionArtifactPolicy { + /// Whether this extension is owned by the current first-party release lane. + pub const fn is_first_party(self) -> bool { + matches!(self, Self::FirstParty) + } + + /// Whether binary redistribution needs separate license approval. + pub const fn requires_commercial_license(self) -> bool { + matches!( + self, + Self::External { + redistribution: ExtensionRedistribution::RequiresCommercialLicense, + .. + } + ) + } +} + +/// PostgreSQL extension SQL asset class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExtensionSqlAsset { + /// Extension provides a `.control` file plus install/upgrade SQL files. + ControlAndSql, + /// Extension is loaded as a shared library and does not use `CREATE EXTENSION`. + LoadableModuleOnly, +} + +/// Native module asset required by an extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExtensionModuleAsset { + /// Extension is SQL-only and does not require a native module file. + SqlOnly, + /// Extension requires a native module with the given platform-independent stem. + NativeModule { + /// Module filename stem before the platform dynamic-library suffix. + stem: &'static str, + }, +} + +impl ExtensionModuleAsset { + /// Platform-specific native module filename, if this extension needs one. + pub fn module_file_name(self) -> Option { + match self { + Self::SqlOnly => None, + Self::NativeModule { stem } => { + Some(format!("{}{}", stem, std::env::consts::DLL_SUFFIX)) + } + } + } +} + +/// Smoke SQL strategy for proving an extension is usable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExtensionSmokePlan { + /// Run `CREATE EXTENSION CASCADE`. + CreateExtensionCascade, + /// Run `LOAD ''`. + LoadSharedLibrary, +} + +impl ExtensionSmokePlan { + /// Render the SQL used by the native extension smoke matrix. + pub fn sql(self, sql_name: &str) -> String { + match self { + Self::CreateExtensionCascade => { + format!( + "CREATE EXTENSION {} CASCADE", + quote_sql_identifier(sql_name) + ) + } + Self::LoadSharedLibrary => format!("LOAD '{sql_name}'"), + } + } +} + +fn quote_sql_identifier(identifier: &str) -> String { + let mut chars = identifier.chars(); + let bare = matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase() || ch == '_') + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_'); + if bare { + identifier.to_owned() + } else { + format!("\"{}\"", identifier.replace('"', "\"\"")) + } +} + +/// Mobile static-link status for an extension module. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MobileStaticLinkStatus { + /// No native module exists, so mobile static linking is not required. + NotRequiredSqlOnly, + /// The extension's native module is present in the mobile static registry. + RegisteredStaticRegistry, + /// A mobile static registry entry is required before mobile release. + PendingRegistry, +} + +/// Regression evidence represented by the extension manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExtensionCoverage { + /// Direct C ABI coverage exists through the broker helper's embedded backend. + pub direct_c_abi: ExtensionSmokeCoverage, + /// Broker mode coverage. + pub broker: ExtensionSmokeCoverage, + /// Server mode coverage. + pub server: ExtensionSmokeCoverage, +} + +impl ExtensionCoverage { + /// Coverage provided by `tests/native_extensions.rs` when the gated native + /// extension matrix is enabled. + pub const GATED_RELEASE_MATRIX: Self = Self { + direct_c_abi: ExtensionSmokeCoverage::InstallLoadRestartBackupRestore, + broker: ExtensionSmokeCoverage::InstallLoadRestartBackupRestore, + server: ExtensionSmokeCoverage::InstallLoadRestartBackupRestore, + }; +} + +/// Per-mode extension smoke coverage level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExtensionSmokeCoverage { + /// Install/load, reopen, physical backup, restore, and restored reopen pass. + InstallLoadRestartBackupRestore, +} + +/// Static native extension release-manifest row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExtensionManifestEntry { + /// Extension enum value. + pub extension: Extension, + /// SQL name used by PostgreSQL. + pub sql_name: &'static str, + /// PostgreSQL major version this manifest row targets. + pub pg_major: u16, + /// Whether this extension is supported in the PostgreSQL 18 native lane. + pub pg18_supported: bool, + /// Whether this extension is installed with `CREATE EXTENSION`. + pub creates_extension: bool, + /// SQL/control asset class. + pub sql_assets: ExtensionSqlAsset, + /// Native module asset requirement. + pub module: ExtensionModuleAsset, + /// SQL extension dependencies that must be materialized with this extension. + pub dependencies: &'static [Extension], + /// Transitive runtime data files required by this extension. + pub data_files: &'static [&'static str], + /// Smoke SQL strategy. + pub smoke: ExtensionSmokePlan, + /// Regression coverage evidence expected for this release lane. + pub coverage: ExtensionCoverage, + /// Mobile static-link readiness. + pub mobile_static_link: MobileStaticLinkStatus, + /// First-party or external packaging policy. + pub artifact_policy: ExtensionArtifactPolicy, +} + +impl ExtensionManifestEntry { + /// Render the smoke SQL for this extension. + pub fn smoke_sql(self) -> String { + self.smoke.sql(self.sql_name) + } + + /// Platform-specific native module filename, if any. + pub fn module_file_name(self) -> Option { + self.module.module_file_name() + } + + /// Whether the native release build currently owns first-party artifacts + /// and gated smoke coverage for this extension. + pub const fn first_party_artifact(self) -> bool { + self.artifact_policy.is_first_party() + } +} + +/// Static manifest for every PostgreSQL 18 extension supported by the native lane. +pub const NATIVE_EXTENSION_MANIFEST: &[ExtensionManifestEntry] = + generated_extensions::NATIVE_EXTENSION_MANIFEST; + +pub(crate) fn resolve_extensions(direct_extensions: &[Extension]) -> Result> { + let mut requested = Vec::new(); + requested.extend_from_slice(direct_extensions); + + let mut resolved = Vec::new(); + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + for extension in requested { + visit_extension(extension, &mut visiting, &mut visited, &mut resolved)?; + } + Ok(resolved) +} + +/// Sorted, deduplicated `shared_preload_libraries` entries required by a +/// resolved extension selection. +pub fn required_shared_preload_libraries(extensions: &[Extension]) -> Vec<&'static str> { + extensions + .iter() + .filter_map(|extension| extension.required_shared_preload_library()) + .collect::>() + .into_iter() + .collect() +} + +/// Resolve explicit extension selections into the concrete extension set that +/// must be present in a native runtime resources. +pub fn resolve_extension_selection(extensions: &[Extension]) -> Result> { + resolve_extensions(extensions) +} + +fn visit_extension( + extension: Extension, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + resolved: &mut Vec, +) -> Result<()> { + if visited.contains(&extension) { + return Ok(()); + } + if !visiting.insert(extension) { + return Err(Error::Engine(format!( + "cyclic native extension dependency involving '{}'", + extension.sql_name() + ))); + } + for dependency in extension.dependencies() { + visit_extension(*dependency, visiting, visited, resolved)?; + } + visiting.remove(&extension); + visited.insert(extension); + resolved.push(extension); + Ok(()) +} + +pub(crate) fn extension_sql_file_belongs(sql_name: &str, file_name: &str) -> bool { + file_name == format!("{sql_name}.control") + || file_name == format!("{sql_name}.sql") + || (file_name.starts_with(&format!("{sql_name}--")) && file_name.ends_with(".sql")) + || extension_extra_sql_file_belongs(sql_name, file_name) +} + +pub(crate) const fn extension_runtime_environment( + extension: Extension, +) -> &'static [ExtensionRuntimeEnvironment] { + generated_extensions::runtime_environment(extension) +} + +fn extension_extra_sql_file_belongs(sql_name: &str, file_name: &str) -> bool { + let Some(extension) = Extension::by_sql_name(sql_name) else { + return false; + }; + generated_extensions::extension_sql_file_names(extension).contains(&file_name) + || generated_extensions::extension_sql_file_prefixes(extension) + .iter() + .any(|prefix| file_name.starts_with(prefix)) +} + +pub(crate) const fn extension_data_files(extension: Extension) -> &'static [&'static str] { + generated_extensions::extension_data_files(extension) +} diff --git a/src/sdks/rust/src/generated/extensions.rs b/src/sdks/rust/src/generated/extensions.rs new file mode 100644 index 00000000..706ff9bc --- /dev/null +++ b/src/sdks/rust/src/generated/extensions.rs @@ -0,0 +1,947 @@ +// @generated by src/extensions/tools/check-extension-model.py --write +// Do not edit by hand. + +use super::{ + ExtensionArtifactPolicy, ExtensionCoverage, ExtensionManifestEntry, ExtensionModuleAsset, + ExtensionRedistribution, ExtensionRuntimeEnvironment, ExtensionSmokePlan, ExtensionSourceKind, + ExtensionSqlAsset, MobileStaticLinkStatus, +}; + +/// Native PostgreSQL 18 extension that can be explicitly selected by an app. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Extension { + /// PostgreSQL `amcheck`. + Amcheck, + /// PostgreSQL `auto_explain`. + AutoExplain, + /// PostgreSQL `bloom`. + Bloom, + /// PostgreSQL `btree_gin`. + BtreeGin, + /// PostgreSQL `btree_gist`. + BtreeGist, + /// PostgreSQL `citext`. + Citext, + /// PostgreSQL `cube`. + Cube, + /// PostgreSQL `dict_int`. + DictInt, + /// PostgreSQL `dict_xsyn`. + DictXsyn, + /// PostgreSQL `earthdistance`. + Earthdistance, + /// PostgreSQL `file_fdw`. + FileFdw, + /// PostgreSQL `fuzzystrmatch`. + Fuzzystrmatch, + /// pgGraph `graph`. + Graph, + /// PostgreSQL `hstore`. + Hstore, + /// PostgreSQL `intarray`. + Intarray, + /// PostgreSQL `isn`. + Isn, + /// PostgreSQL `lo`. + Lo, + /// PostgreSQL `ltree`. + Ltree, + /// PostgreSQL `pageinspect`. + Pageinspect, + /// PostgreSQL `pg_buffercache`. + PgBuffercache, + /// PostgreSQL `pg_freespacemap`. + PgFreespacemap, + /// PostgreSQL `pg_hashids`. + PgHashids, + /// PostgreSQL `pg_ivm`. + PgIvm, + /// ParadeDB `pg_search`. + PgSearch, + /// PostgreSQL `pg_surgery`. + PgSurgery, + /// PostgreSQL `pg_textsearch`. + PgTextsearch, + /// PostgreSQL `pg_trgm`. + PgTrgm, + /// PostgreSQL `pg_uuidv7`. + PgUuidv7, + /// PostgreSQL `pg_visibility`. + PgVisibility, + /// PostgreSQL `pg_walinspect`. + PgWalinspect, + /// PostgreSQL `pgcrypto`. + Pgcrypto, + /// PostgreSQL `pgtap`. + Pgtap, + /// PostgreSQL `postgis`. + Postgis, + /// PostgreSQL `seg`. + Seg, + /// PostgreSQL `tablefunc`. + Tablefunc, + /// PostgreSQL `tcn`. + Tcn, + /// PostgreSQL `tsm_system_rows`. + TsmSystemRows, + /// PostgreSQL `tsm_system_time`. + TsmSystemTime, + /// PostgreSQL `unaccent`. + Unaccent, + /// PostgreSQL `uuid-ossp`. + UuidOssp, + /// PostgreSQL `vector`. + Vector, +} + +/// First-party PostgreSQL 18 extensions generated from the shared catalog. +pub(super) const FIRST_PARTY_PG18_SUPPORTED: &[Extension] = &[ + Extension::Amcheck, + Extension::AutoExplain, + Extension::Bloom, + Extension::BtreeGin, + Extension::BtreeGist, + Extension::Citext, + Extension::Cube, + Extension::DictInt, + Extension::DictXsyn, + Extension::Earthdistance, + Extension::FileFdw, + Extension::Fuzzystrmatch, + Extension::Hstore, + Extension::Intarray, + Extension::Isn, + Extension::Lo, + Extension::Ltree, + Extension::Pageinspect, + Extension::PgBuffercache, + Extension::PgFreespacemap, + Extension::PgHashids, + Extension::PgIvm, + Extension::PgSurgery, + Extension::PgTextsearch, + Extension::PgTrgm, + Extension::PgUuidv7, + Extension::PgVisibility, + Extension::PgWalinspect, + Extension::Pgcrypto, + Extension::Pgtap, + Extension::Postgis, + Extension::Seg, + Extension::Tablefunc, + Extension::Tcn, + Extension::TsmSystemRows, + Extension::TsmSystemTime, + Extension::Unaccent, + Extension::UuidOssp, + Extension::Vector, +]; +/// Public release-ready PostgreSQL 18 extensions generated from the shared catalog. +pub(super) const RELEASE_READY_PG18_SUPPORTED: &[Extension] = &[ + Extension::Amcheck, + Extension::AutoExplain, + Extension::Bloom, + Extension::BtreeGin, + Extension::BtreeGist, + Extension::Citext, + Extension::Cube, + Extension::DictInt, + Extension::DictXsyn, + Extension::Earthdistance, + Extension::FileFdw, + Extension::Fuzzystrmatch, + Extension::Hstore, + Extension::Intarray, + Extension::Isn, + Extension::Lo, + Extension::Ltree, + Extension::Pageinspect, + Extension::PgBuffercache, + Extension::PgFreespacemap, + Extension::PgHashids, + Extension::PgIvm, + Extension::PgSurgery, + Extension::PgTextsearch, + Extension::PgTrgm, + Extension::PgUuidv7, + Extension::PgVisibility, + Extension::PgWalinspect, + Extension::Pgcrypto, + Extension::Pgtap, + Extension::Postgis, + Extension::Seg, + Extension::Tablefunc, + Extension::Tcn, + Extension::TsmSystemRows, + Extension::TsmSystemTime, + Extension::Unaccent, + Extension::UuidOssp, + Extension::Vector, +]; +/// Mobile release-ready PostgreSQL 18 extensions generated from the shared catalog. +pub(super) const MOBILE_RELEASE_READY_PG18_SUPPORTED: &[Extension] = &[ + Extension::Amcheck, + Extension::AutoExplain, + Extension::Bloom, + Extension::BtreeGin, + Extension::BtreeGist, + Extension::Citext, + Extension::Cube, + Extension::DictInt, + Extension::DictXsyn, + Extension::Earthdistance, + Extension::FileFdw, + Extension::Fuzzystrmatch, + Extension::Hstore, + Extension::Intarray, + Extension::Isn, + Extension::Lo, + Extension::Ltree, + Extension::Pageinspect, + Extension::PgBuffercache, + Extension::PgFreespacemap, + Extension::PgHashids, + Extension::PgIvm, + Extension::PgSurgery, + Extension::PgTextsearch, + Extension::PgTrgm, + Extension::PgUuidv7, + Extension::PgVisibility, + Extension::PgWalinspect, + Extension::Pgcrypto, + Extension::Pgtap, + Extension::Postgis, + Extension::Seg, + Extension::Tablefunc, + Extension::Tcn, + Extension::TsmSystemRows, + Extension::TsmSystemTime, + Extension::Unaccent, + Extension::UuidOssp, + Extension::Vector, +]; +/// External PostgreSQL 18 extension candidates generated from explicit metadata. +pub(super) const EXTERNAL_PG18_SUPPORTED: &[Extension] = &[Extension::Graph, Extension::PgSearch]; +/// All PostgreSQL 18 extension rows known to the Rust SDK. +pub(super) const ALL_PG18_SUPPORTED: &[Extension] = &[ + Extension::Amcheck, + Extension::AutoExplain, + Extension::Bloom, + Extension::BtreeGin, + Extension::BtreeGist, + Extension::Citext, + Extension::Cube, + Extension::DictInt, + Extension::DictXsyn, + Extension::Earthdistance, + Extension::FileFdw, + Extension::Fuzzystrmatch, + Extension::Graph, + Extension::Hstore, + Extension::Intarray, + Extension::Isn, + Extension::Lo, + Extension::Ltree, + Extension::Pageinspect, + Extension::PgBuffercache, + Extension::PgFreespacemap, + Extension::PgHashids, + Extension::PgIvm, + Extension::PgSearch, + Extension::PgSurgery, + Extension::PgTextsearch, + Extension::PgTrgm, + Extension::PgUuidv7, + Extension::PgVisibility, + Extension::PgWalinspect, + Extension::Pgcrypto, + Extension::Pgtap, + Extension::Postgis, + Extension::Seg, + Extension::Tablefunc, + Extension::Tcn, + Extension::TsmSystemRows, + Extension::TsmSystemTime, + Extension::Unaccent, + Extension::UuidOssp, + Extension::Vector, +]; + +/// Generated extension metadata accessor. +pub(super) const fn sql_name(extension: Extension) -> &'static str { + match extension { + Extension::Amcheck => "amcheck", + Extension::AutoExplain => "auto_explain", + Extension::Bloom => "bloom", + Extension::BtreeGin => "btree_gin", + Extension::BtreeGist => "btree_gist", + Extension::Citext => "citext", + Extension::Cube => "cube", + Extension::DictInt => "dict_int", + Extension::DictXsyn => "dict_xsyn", + Extension::Earthdistance => "earthdistance", + Extension::FileFdw => "file_fdw", + Extension::Fuzzystrmatch => "fuzzystrmatch", + Extension::Graph => "graph", + Extension::Hstore => "hstore", + Extension::Intarray => "intarray", + Extension::Isn => "isn", + Extension::Lo => "lo", + Extension::Ltree => "ltree", + Extension::Pageinspect => "pageinspect", + Extension::PgBuffercache => "pg_buffercache", + Extension::PgFreespacemap => "pg_freespacemap", + Extension::PgHashids => "pg_hashids", + Extension::PgIvm => "pg_ivm", + Extension::PgSearch => "pg_search", + Extension::PgSurgery => "pg_surgery", + Extension::PgTextsearch => "pg_textsearch", + Extension::PgTrgm => "pg_trgm", + Extension::PgUuidv7 => "pg_uuidv7", + Extension::PgVisibility => "pg_visibility", + Extension::PgWalinspect => "pg_walinspect", + Extension::Pgcrypto => "pgcrypto", + Extension::Pgtap => "pgtap", + Extension::Postgis => "postgis", + Extension::Seg => "seg", + Extension::Tablefunc => "tablefunc", + Extension::Tcn => "tcn", + Extension::TsmSystemRows => "tsm_system_rows", + Extension::TsmSystemTime => "tsm_system_time", + Extension::Unaccent => "unaccent", + Extension::UuidOssp => "uuid-ossp", + Extension::Vector => "vector", + } +} + +/// Generated extension metadata accessor. +pub(super) const fn native_module_stem(extension: Extension) -> Option<&'static str> { + match extension { + Extension::Amcheck => Some("amcheck"), + Extension::AutoExplain => Some("auto_explain"), + Extension::Bloom => Some("bloom"), + Extension::BtreeGin => Some("btree_gin"), + Extension::BtreeGist => Some("btree_gist"), + Extension::Citext => Some("citext"), + Extension::Cube => Some("cube"), + Extension::DictInt => Some("dict_int"), + Extension::DictXsyn => Some("dict_xsyn"), + Extension::Earthdistance => Some("earthdistance"), + Extension::FileFdw => Some("file_fdw"), + Extension::Fuzzystrmatch => Some("fuzzystrmatch"), + Extension::Graph => Some("graph"), + Extension::Hstore => Some("hstore"), + Extension::Intarray => Some("_int"), + Extension::Isn => Some("isn"), + Extension::Lo => Some("lo"), + Extension::Ltree => Some("ltree"), + Extension::Pageinspect => Some("pageinspect"), + Extension::PgBuffercache => Some("pg_buffercache"), + Extension::PgFreespacemap => Some("pg_freespacemap"), + Extension::PgHashids => Some("pg_hashids"), + Extension::PgIvm => Some("pg_ivm"), + Extension::PgSearch => Some("pg_search"), + Extension::PgSurgery => Some("pg_surgery"), + Extension::PgTextsearch => Some("pg_textsearch"), + Extension::PgTrgm => Some("pg_trgm"), + Extension::PgUuidv7 => Some("pg_uuidv7"), + Extension::PgVisibility => Some("pg_visibility"), + Extension::PgWalinspect => Some("pg_walinspect"), + Extension::Pgcrypto => Some("pgcrypto"), + Extension::Pgtap => None, + Extension::Postgis => Some("postgis-3"), + Extension::Seg => Some("seg"), + Extension::Tablefunc => Some("tablefunc"), + Extension::Tcn => Some("tcn"), + Extension::TsmSystemRows => Some("tsm_system_rows"), + Extension::TsmSystemTime => Some("tsm_system_time"), + Extension::Unaccent => Some("unaccent"), + Extension::UuidOssp => Some("uuid-ossp"), + Extension::Vector => Some("vector"), + } +} + +/// Generated extension metadata accessor. +pub(super) const fn creates_extension(extension: Extension) -> bool { + match extension { + Extension::Amcheck => true, + Extension::AutoExplain => false, + Extension::Bloom => true, + Extension::BtreeGin => true, + Extension::BtreeGist => true, + Extension::Citext => true, + Extension::Cube => true, + Extension::DictInt => true, + Extension::DictXsyn => true, + Extension::Earthdistance => true, + Extension::FileFdw => true, + Extension::Fuzzystrmatch => true, + Extension::Graph => true, + Extension::Hstore => true, + Extension::Intarray => true, + Extension::Isn => true, + Extension::Lo => true, + Extension::Ltree => true, + Extension::Pageinspect => true, + Extension::PgBuffercache => true, + Extension::PgFreespacemap => true, + Extension::PgHashids => true, + Extension::PgIvm => true, + Extension::PgSearch => true, + Extension::PgSurgery => true, + Extension::PgTextsearch => true, + Extension::PgTrgm => true, + Extension::PgUuidv7 => true, + Extension::PgVisibility => true, + Extension::PgWalinspect => true, + Extension::Pgcrypto => true, + Extension::Pgtap => true, + Extension::Postgis => true, + Extension::Seg => true, + Extension::Tablefunc => true, + Extension::Tcn => true, + Extension::TsmSystemRows => true, + Extension::TsmSystemTime => true, + Extension::Unaccent => true, + Extension::UuidOssp => true, + Extension::Vector => true, + } +} + +/// Generated extension metadata accessor. +pub(super) const fn dependencies(extension: Extension) -> &'static [Extension] { + match extension { + Extension::Amcheck => &[], + Extension::AutoExplain => &[], + Extension::Bloom => &[], + Extension::BtreeGin => &[], + Extension::BtreeGist => &[], + Extension::Citext => &[], + Extension::Cube => &[], + Extension::DictInt => &[], + Extension::DictXsyn => &[], + Extension::Earthdistance => &[Extension::Cube], + Extension::FileFdw => &[], + Extension::Fuzzystrmatch => &[], + Extension::Graph => &[], + Extension::Hstore => &[], + Extension::Intarray => &[], + Extension::Isn => &[], + Extension::Lo => &[], + Extension::Ltree => &[], + Extension::Pageinspect => &[], + Extension::PgBuffercache => &[], + Extension::PgFreespacemap => &[], + Extension::PgHashids => &[], + Extension::PgIvm => &[], + Extension::PgSearch => &[], + Extension::PgSurgery => &[], + Extension::PgTextsearch => &[], + Extension::PgTrgm => &[], + Extension::PgUuidv7 => &[], + Extension::PgVisibility => &[], + Extension::PgWalinspect => &[], + Extension::Pgcrypto => &[], + Extension::Pgtap => &[], + Extension::Postgis => &[], + Extension::Seg => &[], + Extension::Tablefunc => &[], + Extension::Tcn => &[], + Extension::TsmSystemRows => &[], + Extension::TsmSystemTime => &[], + Extension::Unaccent => &[], + Extension::UuidOssp => &[], + Extension::Vector => &[], + } +} + +/// Generated extension metadata accessor. +pub(super) const fn desktop_release_ready(extension: Extension) -> bool { + match extension { + Extension::Amcheck => true, + Extension::AutoExplain => true, + Extension::Bloom => true, + Extension::BtreeGin => true, + Extension::BtreeGist => true, + Extension::Citext => true, + Extension::Cube => true, + Extension::DictInt => true, + Extension::DictXsyn => true, + Extension::Earthdistance => true, + Extension::FileFdw => true, + Extension::Fuzzystrmatch => true, + Extension::Graph => false, + Extension::Hstore => true, + Extension::Intarray => true, + Extension::Isn => true, + Extension::Lo => true, + Extension::Ltree => true, + Extension::Pageinspect => true, + Extension::PgBuffercache => true, + Extension::PgFreespacemap => true, + Extension::PgHashids => true, + Extension::PgIvm => true, + Extension::PgSearch => false, + Extension::PgSurgery => true, + Extension::PgTextsearch => true, + Extension::PgTrgm => true, + Extension::PgUuidv7 => true, + Extension::PgVisibility => true, + Extension::PgWalinspect => true, + Extension::Pgcrypto => true, + Extension::Pgtap => true, + Extension::Postgis => true, + Extension::Seg => true, + Extension::Tablefunc => true, + Extension::Tcn => true, + Extension::TsmSystemRows => true, + Extension::TsmSystemTime => true, + Extension::Unaccent => true, + Extension::UuidOssp => true, + Extension::Vector => true, + } +} + +/// Generated extension metadata accessor. +pub(super) const fn mobile_release_ready(extension: Extension) -> bool { + match extension { + Extension::Amcheck => true, + Extension::AutoExplain => true, + Extension::Bloom => true, + Extension::BtreeGin => true, + Extension::BtreeGist => true, + Extension::Citext => true, + Extension::Cube => true, + Extension::DictInt => true, + Extension::DictXsyn => true, + Extension::Earthdistance => true, + Extension::FileFdw => true, + Extension::Fuzzystrmatch => true, + Extension::Graph => false, + Extension::Hstore => true, + Extension::Intarray => true, + Extension::Isn => true, + Extension::Lo => true, + Extension::Ltree => true, + Extension::Pageinspect => true, + Extension::PgBuffercache => true, + Extension::PgFreespacemap => true, + Extension::PgHashids => true, + Extension::PgIvm => true, + Extension::PgSearch => false, + Extension::PgSurgery => true, + Extension::PgTextsearch => true, + Extension::PgTrgm => true, + Extension::PgUuidv7 => true, + Extension::PgVisibility => true, + Extension::PgWalinspect => true, + Extension::Pgcrypto => true, + Extension::Pgtap => true, + Extension::Postgis => true, + Extension::Seg => true, + Extension::Tablefunc => true, + Extension::Tcn => true, + Extension::TsmSystemRows => true, + Extension::TsmSystemTime => true, + Extension::Unaccent => true, + Extension::UuidOssp => true, + Extension::Vector => true, + } +} + +/// Generated extension metadata accessor. +pub(super) const fn required_shared_preload_library(extension: Extension) -> Option<&'static str> { + match extension { + Extension::Amcheck => None, + Extension::AutoExplain => None, + Extension::Bloom => None, + Extension::BtreeGin => None, + Extension::BtreeGist => None, + Extension::Citext => None, + Extension::Cube => None, + Extension::DictInt => None, + Extension::DictXsyn => None, + Extension::Earthdistance => None, + Extension::FileFdw => None, + Extension::Fuzzystrmatch => None, + Extension::Graph => None, + Extension::Hstore => None, + Extension::Intarray => None, + Extension::Isn => None, + Extension::Lo => None, + Extension::Ltree => None, + Extension::Pageinspect => None, + Extension::PgBuffercache => None, + Extension::PgFreespacemap => None, + Extension::PgHashids => None, + Extension::PgIvm => None, + Extension::PgSearch => Some("pg_search"), + Extension::PgSurgery => None, + Extension::PgTextsearch => None, + Extension::PgTrgm => None, + Extension::PgUuidv7 => None, + Extension::PgVisibility => None, + Extension::PgWalinspect => None, + Extension::Pgcrypto => None, + Extension::Pgtap => None, + Extension::Postgis => None, + Extension::Seg => None, + Extension::Tablefunc => None, + Extension::Tcn => None, + Extension::TsmSystemRows => None, + Extension::TsmSystemTime => None, + Extension::Unaccent => None, + Extension::UuidOssp => None, + Extension::Vector => None, + } +} + +/// Generated extension metadata accessor. +pub(super) const fn extension_data_files(extension: Extension) -> &'static [&'static str] { + match extension { + Extension::Amcheck => &[], + Extension::AutoExplain => &[], + Extension::Bloom => &[], + Extension::BtreeGin => &[], + Extension::BtreeGist => &[], + Extension::Citext => &[], + Extension::Cube => &[], + Extension::DictInt => &[], + Extension::DictXsyn => &["tsearch_data/xsyn_sample.rules"], + Extension::Earthdistance => &[], + Extension::FileFdw => &[], + Extension::Fuzzystrmatch => &[], + Extension::Graph => &[], + Extension::Hstore => &[], + Extension::Intarray => &[], + Extension::Isn => &[], + Extension::Lo => &[], + Extension::Ltree => &[], + Extension::Pageinspect => &[], + Extension::PgBuffercache => &[], + Extension::PgFreespacemap => &[], + Extension::PgHashids => &[], + Extension::PgIvm => &[], + Extension::PgSearch => &[], + Extension::PgSurgery => &[], + Extension::PgTextsearch => &[], + Extension::PgTrgm => &[], + Extension::PgUuidv7 => &[], + Extension::PgVisibility => &[], + Extension::PgWalinspect => &[], + Extension::Pgcrypto => &[], + Extension::Pgtap => &[], + Extension::Postgis => &[ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db", + ], + Extension::Seg => &[], + Extension::Tablefunc => &[], + Extension::Tcn => &[], + Extension::TsmSystemRows => &[], + Extension::TsmSystemTime => &[], + Extension::Unaccent => &["tsearch_data/unaccent.rules"], + Extension::UuidOssp => &[], + Extension::Vector => &[], + } +} + +/// Generated extension metadata accessor. +pub(super) const fn extension_sql_file_prefixes(extension: Extension) -> &'static [&'static str] { + match extension { + Extension::Amcheck => &[], + Extension::AutoExplain => &[], + Extension::Bloom => &[], + Extension::BtreeGin => &[], + Extension::BtreeGist => &[], + Extension::Citext => &[], + Extension::Cube => &[], + Extension::DictInt => &[], + Extension::DictXsyn => &[], + Extension::Earthdistance => &[], + Extension::FileFdw => &[], + Extension::Fuzzystrmatch => &[], + Extension::Graph => &[], + Extension::Hstore => &[], + Extension::Intarray => &[], + Extension::Isn => &[], + Extension::Lo => &[], + Extension::Ltree => &[], + Extension::Pageinspect => &[], + Extension::PgBuffercache => &[], + Extension::PgFreespacemap => &[], + Extension::PgHashids => &[], + Extension::PgIvm => &[], + Extension::PgSearch => &[], + Extension::PgSurgery => &[], + Extension::PgTextsearch => &[], + Extension::PgTrgm => &[], + Extension::PgUuidv7 => &[], + Extension::PgVisibility => &[], + Extension::PgWalinspect => &[], + Extension::Pgcrypto => &[], + Extension::Pgtap => &["pgtap-core", "pgtap-schema"], + Extension::Postgis => &[ + "postgis_comments", + "postgis_proc_set_search_path", + "rtpostgis", + ], + Extension::Seg => &[], + Extension::Tablefunc => &[], + Extension::Tcn => &[], + Extension::TsmSystemRows => &[], + Extension::TsmSystemTime => &[], + Extension::Unaccent => &[], + Extension::UuidOssp => &[], + Extension::Vector => &[], + } +} + +/// Generated extension metadata accessor. +pub(super) const fn extension_sql_file_names(extension: Extension) -> &'static [&'static str] { + match extension { + Extension::Amcheck => &[], + Extension::AutoExplain => &[], + Extension::Bloom => &[], + Extension::BtreeGin => &[], + Extension::BtreeGist => &[], + Extension::Citext => &[], + Extension::Cube => &[], + Extension::DictInt => &[], + Extension::DictXsyn => &[], + Extension::Earthdistance => &[], + Extension::FileFdw => &[], + Extension::Fuzzystrmatch => &[], + Extension::Graph => &[], + Extension::Hstore => &[], + Extension::Intarray => &[], + Extension::Isn => &[], + Extension::Lo => &[], + Extension::Ltree => &[], + Extension::Pageinspect => &[], + Extension::PgBuffercache => &[], + Extension::PgFreespacemap => &[], + Extension::PgHashids => &[], + Extension::PgIvm => &[], + Extension::PgSearch => &[], + Extension::PgSurgery => &[], + Extension::PgTextsearch => &[], + Extension::PgTrgm => &[], + Extension::PgUuidv7 => &[], + Extension::PgVisibility => &[], + Extension::PgWalinspect => &[], + Extension::Pgcrypto => &[], + Extension::Pgtap => &["uninstall_pgtap.sql"], + Extension::Postgis => &["uninstall_postgis.sql"], + Extension::Seg => &[], + Extension::Tablefunc => &[], + Extension::Tcn => &[], + Extension::TsmSystemRows => &[], + Extension::TsmSystemTime => &[], + Extension::Unaccent => &[], + Extension::UuidOssp => &[], + Extension::Vector => &[], + } +} + +/// Generated extension metadata accessor. +pub(super) const fn runtime_environment( + extension: Extension, +) -> &'static [ExtensionRuntimeEnvironment] { + match extension { + Extension::Amcheck => &[], + Extension::AutoExplain => &[], + Extension::Bloom => &[], + Extension::BtreeGin => &[], + Extension::BtreeGist => &[], + Extension::Citext => &[], + Extension::Cube => &[], + Extension::DictInt => &[], + Extension::DictXsyn => &[], + Extension::Earthdistance => &[], + Extension::FileFdw => &[], + Extension::Fuzzystrmatch => &[], + Extension::Graph => &[], + Extension::Hstore => &[], + Extension::Intarray => &[], + Extension::Isn => &[], + Extension::Lo => &[], + Extension::Ltree => &[], + Extension::Pageinspect => &[], + Extension::PgBuffercache => &[], + Extension::PgFreespacemap => &[], + Extension::PgHashids => &[], + Extension::PgIvm => &[], + Extension::PgSearch => &[], + Extension::PgSurgery => &[], + Extension::PgTextsearch => &[], + Extension::PgTrgm => &[], + Extension::PgUuidv7 => &[], + Extension::PgVisibility => &[], + Extension::PgWalinspect => &[], + Extension::Pgcrypto => &[], + Extension::Pgtap => &[], + Extension::Postgis => &[ExtensionRuntimeEnvironment { + name: "PROJ_DATA", + relative_path: "share/postgresql/proj", + required_file: "proj.db", + }], + Extension::Seg => &[], + Extension::Tablefunc => &[], + Extension::Tcn => &[], + Extension::TsmSystemRows => &[], + Extension::TsmSystemTime => &[], + Extension::Unaccent => &[], + Extension::UuidOssp => &[], + Extension::Vector => &[], + } +} + +/// Generated extension packaging policy accessor. +pub(super) const fn artifact_policy(extension: Extension) -> ExtensionArtifactPolicy { + match extension { + Extension::Amcheck => ExtensionArtifactPolicy::FirstParty, + Extension::AutoExplain => ExtensionArtifactPolicy::FirstParty, + Extension::Bloom => ExtensionArtifactPolicy::FirstParty, + Extension::BtreeGin => ExtensionArtifactPolicy::FirstParty, + Extension::BtreeGist => ExtensionArtifactPolicy::FirstParty, + Extension::Citext => ExtensionArtifactPolicy::FirstParty, + Extension::Cube => ExtensionArtifactPolicy::FirstParty, + Extension::DictInt => ExtensionArtifactPolicy::FirstParty, + Extension::DictXsyn => ExtensionArtifactPolicy::FirstParty, + Extension::Earthdistance => ExtensionArtifactPolicy::FirstParty, + Extension::FileFdw => ExtensionArtifactPolicy::FirstParty, + Extension::Fuzzystrmatch => ExtensionArtifactPolicy::FirstParty, + Extension::Graph => ExtensionArtifactPolicy::External { + upstream: "https://github.com/evokoa/pggraph", + license: "Apache-2.0", + source_kind: ExtensionSourceKind::Pgrx, + redistribution: ExtensionRedistribution::Allowed, + requires_shared_preload: false, + notes: "Optional shared_preload_libraries='graph' enables startup _PG_init behavior; background-worker maintenance paths must be tested per engine mode.", + }, + Extension::Hstore => ExtensionArtifactPolicy::FirstParty, + Extension::Intarray => ExtensionArtifactPolicy::FirstParty, + Extension::Isn => ExtensionArtifactPolicy::FirstParty, + Extension::Lo => ExtensionArtifactPolicy::FirstParty, + Extension::Ltree => ExtensionArtifactPolicy::FirstParty, + Extension::Pageinspect => ExtensionArtifactPolicy::FirstParty, + Extension::PgBuffercache => ExtensionArtifactPolicy::FirstParty, + Extension::PgFreespacemap => ExtensionArtifactPolicy::FirstParty, + Extension::PgHashids => ExtensionArtifactPolicy::FirstParty, + Extension::PgIvm => ExtensionArtifactPolicy::FirstParty, + Extension::PgSearch => ExtensionArtifactPolicy::External { + upstream: "https://github.com/paradedb/paradedb", + license: "AGPL-3.0 community edition", + source_kind: ExtensionSourceKind::Pgrx, + redistribution: ExtensionRedistribution::RequiresCommercialLicense, + requires_shared_preload: true, + notes: "ParadeDB pg_search requires shared_preload_libraries='pg_search', registers preload-time WAL machinery, and uses PostgreSQL parallel workers.", + }, + Extension::PgSurgery => ExtensionArtifactPolicy::FirstParty, + Extension::PgTextsearch => ExtensionArtifactPolicy::FirstParty, + Extension::PgTrgm => ExtensionArtifactPolicy::FirstParty, + Extension::PgUuidv7 => ExtensionArtifactPolicy::FirstParty, + Extension::PgVisibility => ExtensionArtifactPolicy::FirstParty, + Extension::PgWalinspect => ExtensionArtifactPolicy::FirstParty, + Extension::Pgcrypto => ExtensionArtifactPolicy::FirstParty, + Extension::Pgtap => ExtensionArtifactPolicy::FirstParty, + Extension::Postgis => ExtensionArtifactPolicy::FirstParty, + Extension::Seg => ExtensionArtifactPolicy::FirstParty, + Extension::Tablefunc => ExtensionArtifactPolicy::FirstParty, + Extension::Tcn => ExtensionArtifactPolicy::FirstParty, + Extension::TsmSystemRows => ExtensionArtifactPolicy::FirstParty, + Extension::TsmSystemTime => ExtensionArtifactPolicy::FirstParty, + Extension::Unaccent => ExtensionArtifactPolicy::FirstParty, + Extension::UuidOssp => ExtensionArtifactPolicy::FirstParty, + Extension::Vector => ExtensionArtifactPolicy::FirstParty, + } +} + +/// Static native extension manifest generated from the shared catalog. +pub(super) const NATIVE_EXTENSION_MANIFEST: &[ExtensionManifestEntry] = &[ + manifest_entry(Extension::Amcheck), + manifest_entry(Extension::AutoExplain), + manifest_entry(Extension::Bloom), + manifest_entry(Extension::BtreeGin), + manifest_entry(Extension::BtreeGist), + manifest_entry(Extension::Citext), + manifest_entry(Extension::Cube), + manifest_entry(Extension::DictInt), + manifest_entry(Extension::DictXsyn), + manifest_entry(Extension::Earthdistance), + manifest_entry(Extension::FileFdw), + manifest_entry(Extension::Fuzzystrmatch), + manifest_entry(Extension::Graph), + manifest_entry(Extension::Hstore), + manifest_entry(Extension::Intarray), + manifest_entry(Extension::Isn), + manifest_entry(Extension::Lo), + manifest_entry(Extension::Ltree), + manifest_entry(Extension::Pageinspect), + manifest_entry(Extension::PgBuffercache), + manifest_entry(Extension::PgFreespacemap), + manifest_entry(Extension::PgHashids), + manifest_entry(Extension::PgIvm), + manifest_entry(Extension::PgSearch), + manifest_entry(Extension::PgSurgery), + manifest_entry(Extension::PgTextsearch), + manifest_entry(Extension::PgTrgm), + manifest_entry(Extension::PgUuidv7), + manifest_entry(Extension::PgVisibility), + manifest_entry(Extension::PgWalinspect), + manifest_entry(Extension::Pgcrypto), + manifest_entry(Extension::Pgtap), + manifest_entry(Extension::Postgis), + manifest_entry(Extension::Seg), + manifest_entry(Extension::Tablefunc), + manifest_entry(Extension::Tcn), + manifest_entry(Extension::TsmSystemRows), + manifest_entry(Extension::TsmSystemTime), + manifest_entry(Extension::Unaccent), + manifest_entry(Extension::UuidOssp), + manifest_entry(Extension::Vector), +]; + +const fn manifest_entry(extension: Extension) -> ExtensionManifestEntry { + let module = match native_module_stem(extension) { + Some(stem) => ExtensionModuleAsset::NativeModule { stem }, + None => ExtensionModuleAsset::SqlOnly, + }; + let sql_assets = if creates_extension(extension) { + ExtensionSqlAsset::ControlAndSql + } else { + ExtensionSqlAsset::LoadableModuleOnly + }; + let smoke = if creates_extension(extension) { + ExtensionSmokePlan::CreateExtensionCascade + } else { + ExtensionSmokePlan::LoadSharedLibrary + }; + let mobile_static_link = match module { + ExtensionModuleAsset::NativeModule { .. } => MobileStaticLinkStatus::PendingRegistry, + ExtensionModuleAsset::SqlOnly => MobileStaticLinkStatus::NotRequiredSqlOnly, + }; + ExtensionManifestEntry { + extension, + sql_name: sql_name(extension), + pg_major: 18, + pg18_supported: true, + creates_extension: creates_extension(extension), + sql_assets, + module, + dependencies: dependencies(extension), + data_files: extension_data_files(extension), + smoke, + coverage: ExtensionCoverage::GATED_RELEASE_MATRIX, + mobile_static_link, + artifact_policy: artifact_policy(extension), + } +} diff --git a/src/sdks/rust/src/ipc.rs b/src/sdks/rust/src/ipc.rs new file mode 100644 index 00000000..d50b2f11 --- /dev/null +++ b/src/sdks/rust/src/ipc.rs @@ -0,0 +1,277 @@ +use std::io::{Read, Write}; + +use crate::error::{Error, Result}; +use crate::storage::{BackupFormat, BackupRequest}; + +const MAGIC: &[u8; 4] = b"PGOB"; +const HEADER_LEN: usize = 13; +const MAX_FRAME_LEN: u64 = 128 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RequestFrame { + Authenticate(String), + ExecProtocol(Vec), + ExecSimpleQuery(String), + Checkpoint, + Close, + ExecProtocolStream(Vec), + Backup(BackupFormat), + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ResponseFrame { + Ok(Vec), + Error(String), + Chunk(Vec), +} + +/// Internal broker IPC request used by the packaged broker helper. +#[doc(hidden)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrokerIpcRequest { + /// Authenticate the parent SDK process to the broker helper. + Authenticate(String), + /// Execute raw PostgreSQL protocol bytes. + ExecProtocol(Vec), + /// Execute SQL through PostgreSQL's simple-query protocol. + ExecSimpleQuery(String), + /// Execute raw PostgreSQL protocol bytes and stream backend response chunks. + ExecProtocolStream(Vec), + /// Force a checkpoint. + Checkpoint, + /// Create a backup artifact. + Backup(BackupRequest), + /// Cancel the active backend query. + Cancel, + /// Close the broker session. + Close, +} + +/// Read one broker IPC request from a stream. +#[doc(hidden)] +pub fn broker_ipc_read_request(reader: &mut impl Read) -> Result { + match read_request(reader)? { + RequestFrame::Authenticate(token) => Ok(BrokerIpcRequest::Authenticate(token)), + RequestFrame::ExecProtocol(bytes) => Ok(BrokerIpcRequest::ExecProtocol(bytes)), + RequestFrame::ExecSimpleQuery(sql) => Ok(BrokerIpcRequest::ExecSimpleQuery(sql)), + RequestFrame::ExecProtocolStream(bytes) => Ok(BrokerIpcRequest::ExecProtocolStream(bytes)), + RequestFrame::Checkpoint => Ok(BrokerIpcRequest::Checkpoint), + RequestFrame::Backup(format) => Ok(BrokerIpcRequest::Backup(BackupRequest { format })), + RequestFrame::Cancel => Ok(BrokerIpcRequest::Cancel), + RequestFrame::Close => Ok(BrokerIpcRequest::Close), + } +} + +/// Write a successful broker IPC response. +#[doc(hidden)] +pub fn broker_ipc_write_ok(writer: &mut impl Write, bytes: Vec) -> Result<()> { + write_response(writer, ResponseFrame::Ok(bytes)) +} + +/// Write one successful broker IPC stream chunk. +#[doc(hidden)] +pub fn broker_ipc_write_chunk(writer: &mut impl Write, bytes: &[u8]) -> Result<()> { + write_response(writer, ResponseFrame::Chunk(bytes.to_vec())) +} + +/// Write a failed broker IPC response. +#[doc(hidden)] +pub fn broker_ipc_write_error(writer: &mut impl Write, message: String) -> Result<()> { + write_response(writer, ResponseFrame::Error(message)) +} + +pub(crate) fn write_request(writer: &mut impl Write, frame: RequestFrame) -> Result<()> { + match frame { + RequestFrame::Authenticate(token) => write_frame(writer, 6, token.as_bytes()), + RequestFrame::ExecProtocol(bytes) => write_frame(writer, 1, &bytes), + RequestFrame::ExecSimpleQuery(sql) => write_frame(writer, 8, sql.as_bytes()), + RequestFrame::Checkpoint => write_frame(writer, 2, &[]), + RequestFrame::Close => write_frame(writer, 3, &[]), + RequestFrame::ExecProtocolStream(bytes) => write_frame(writer, 4, &bytes), + RequestFrame::Backup(format) => write_frame(writer, 5, &[encode_backup_format(format)]), + RequestFrame::Cancel => write_frame(writer, 7, &[]), + } +} + +pub(crate) fn read_request(reader: &mut impl Read) -> Result { + let (kind, payload) = read_frame(reader)?; + match kind { + 6 => String::from_utf8(payload) + .map(RequestFrame::Authenticate) + .map_err(|err| Error::Engine(format!("broker auth frame is not UTF-8: {err}"))), + 1 => Ok(RequestFrame::ExecProtocol(payload)), + 8 => String::from_utf8(payload) + .map(RequestFrame::ExecSimpleQuery) + .map_err(|err| Error::Engine(format!("broker simple-query frame is not UTF-8: {err}"))), + 2 => empty_payload(payload, RequestFrame::Checkpoint), + 3 => empty_payload(payload, RequestFrame::Close), + 4 => Ok(RequestFrame::ExecProtocolStream(payload)), + 5 => decode_backup_request(payload).map(RequestFrame::Backup), + 7 => empty_payload(payload, RequestFrame::Cancel), + _ => Err(Error::Engine(format!( + "unknown broker request frame {kind}" + ))), + } +} + +fn encode_backup_format(format: BackupFormat) -> u8 { + match format { + BackupFormat::Sql => 1, + BackupFormat::PhysicalArchive => 2, + BackupFormat::OliphauntArchive => 3, + } +} + +fn decode_backup_request(payload: Vec) -> Result { + match payload.as_slice() { + [1] => Ok(BackupFormat::Sql), + [2] => Ok(BackupFormat::PhysicalArchive), + [3] => Ok(BackupFormat::OliphauntArchive), + [] => Err(Error::Engine( + "broker backup request frame is missing a format".to_owned(), + )), + [value] => Err(Error::Engine(format!( + "unknown broker backup format {value}" + ))), + _ => Err(Error::Engine( + "broker backup request frame unexpectedly had extra payload".to_owned(), + )), + } +} + +pub(crate) fn write_response(writer: &mut impl Write, frame: ResponseFrame) -> Result<()> { + match frame { + ResponseFrame::Ok(bytes) => write_frame(writer, 101, &bytes), + ResponseFrame::Error(message) => write_frame(writer, 102, message.as_bytes()), + ResponseFrame::Chunk(bytes) => write_frame(writer, 103, &bytes), + } +} + +pub(crate) fn read_response(reader: &mut impl Read) -> Result { + let (kind, payload) = read_frame(reader)?; + match kind { + 101 => Ok(ResponseFrame::Ok(payload)), + 102 => String::from_utf8(payload) + .map(ResponseFrame::Error) + .map_err(|err| Error::Engine(format!("broker error frame is not UTF-8: {err}"))), + 103 => Ok(ResponseFrame::Chunk(payload)), + _ => Err(Error::Engine(format!( + "unknown broker response frame {kind}" + ))), + } +} + +fn empty_payload(payload: Vec, frame: RequestFrame) -> Result { + if payload.is_empty() { + Ok(frame) + } else { + Err(Error::Engine( + "broker control frame unexpectedly had a payload".to_owned(), + )) + } +} + +fn write_frame(writer: &mut impl Write, kind: u8, payload: &[u8]) -> Result<()> { + let len = u64::try_from(payload.len()) + .map_err(|_| Error::Engine("broker frame payload is too large".to_owned()))?; + let mut header = [0_u8; HEADER_LEN]; + header[..4].copy_from_slice(MAGIC); + header[4] = kind; + header[5..].copy_from_slice(&len.to_be_bytes()); + writer + .write_all(&header) + .and_then(|()| writer.write_all(payload)) + .and_then(|()| writer.flush()) + .map_err(|err| Error::Engine(format!("write broker frame: {err}"))) +} + +fn read_frame(reader: &mut impl Read) -> Result<(u8, Vec)> { + let mut header = [0_u8; HEADER_LEN]; + reader + .read_exact(&mut header) + .map_err(|err| Error::Engine(format!("read broker frame header: {err}")))?; + if &header[..4] != MAGIC { + return Err(Error::Engine("broker frame magic mismatch".to_owned())); + } + let kind = header[4]; + let len = u64::from_be_bytes( + header[5..] + .try_into() + .expect("frame header contains an 8-byte payload length"), + ); + if len > MAX_FRAME_LEN { + return Err(Error::Engine(format!( + "broker frame payload length {len} exceeds limit {MAX_FRAME_LEN}" + ))); + } + let mut payload = vec![0_u8; len as usize]; + reader + .read_exact(&mut payload) + .map_err(|err| Error::Engine(format!("read broker frame payload: {err}")))?; + Ok((kind, payload)) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn auth_frame_round_trips() { + let mut bytes = Vec::new(); + write_request( + &mut bytes, + RequestFrame::Authenticate("token-123".to_owned()), + ) + .unwrap(); + + let mut cursor = Cursor::new(bytes); + assert_eq!( + read_request(&mut cursor).unwrap(), + RequestFrame::Authenticate("token-123".to_owned()) + ); + } + + #[test] + fn backup_frame_still_round_trips() { + let mut bytes = Vec::new(); + write_request( + &mut bytes, + RequestFrame::Backup(BackupFormat::PhysicalArchive), + ) + .unwrap(); + + let mut cursor = Cursor::new(bytes); + assert_eq!( + read_request(&mut cursor).unwrap(), + RequestFrame::Backup(BackupFormat::PhysicalArchive) + ); + } + + #[test] + fn simple_query_frame_round_trips() { + let mut bytes = Vec::new(); + write_request( + &mut bytes, + RequestFrame::ExecSimpleQuery("SELECT 1".to_owned()), + ) + .unwrap(); + + let mut cursor = Cursor::new(bytes); + assert_eq!( + read_request(&mut cursor).unwrap(), + RequestFrame::ExecSimpleQuery("SELECT 1".to_owned()) + ); + } + + #[test] + fn cancel_frame_round_trips() { + let mut bytes = Vec::new(); + write_request(&mut bytes, RequestFrame::Cancel).unwrap(); + + let mut cursor = Cursor::new(bytes); + assert_eq!(read_request(&mut cursor).unwrap(), RequestFrame::Cancel); + } +} diff --git a/src/sdks/rust/src/lib.rs b/src/sdks/rust/src/lib.rs new file mode 100644 index 00000000..d213aad0 --- /dev/null +++ b/src/sdks/rust/src/lib.rs @@ -0,0 +1,82 @@ +#![deny(unsafe_op_in_unsafe_fn)] +#![forbid(missing_docs)] +//! Native-first Rust SDK surface for embedded Oliphaunt. +//! +//! This crate is deliberately native-only. It does not expose a WASIX engine +//! and it does not depend on the current `oliphaunt-wasix` runtime layout. + +mod backup; +mod broker; +mod builder; +mod config; +mod database; +mod engine; +mod error; +mod executor; +mod extension; +mod ipc; +#[allow(unsafe_code)] +mod liboliphaunt; +mod lifecycle; +mod performance; +mod pgwire; +mod protocol; +mod query; +mod reply; +mod runtime_resources; +mod server; +mod storage; + +pub use broker::NativeBrokerRuntime; +pub use builder::OliphauntBuilder; +pub use config::{ + DEFAULT_DATABASE, DEFAULT_USERNAME, DurabilityProfile, EngineMode, NativeBrokerConfig, + NativeDirectConfig, NativeServerConfig, OpenConfig, PostgresStartupGuc, + RuntimeFootprintProfile, +}; +pub use database::{Oliphaunt, SessionPin, Transaction}; +pub use engine::{ + EngineCancel, EngineCapabilities, EngineModeSupport, EngineSession, NativeRuntime, + RuntimeUnavailable, SessionConcurrency, +}; +pub use error::{Error, PostgresError, PostgresErrorField, Result}; +pub use extension::{ + Extension, ExtensionArtifactPolicy, ExtensionCoverage, ExtensionManifestEntry, + ExtensionModuleAsset, ExtensionRedistribution, ExtensionSmokeCoverage, ExtensionSmokePlan, + ExtensionSourceKind, ExtensionSqlAsset, MobileStaticLinkStatus, NATIVE_EXTENSION_MANIFEST, + required_shared_preload_libraries, resolve_extension_selection, +}; +#[doc(hidden)] +pub use ipc::{ + BrokerIpcRequest, broker_ipc_read_request, broker_ipc_write_chunk, broker_ipc_write_error, + broker_ipc_write_ok, +}; +pub use liboliphaunt::{OliphauntRuntime, OliphauntRuntimeSource}; +pub use lifecycle::{ + BackgroundCheckpointSkipReason, BackgroundPreparationOptions, BackgroundPreparationResult, +}; +pub use performance::{ + BenchmarkMetric, BenchmarkTarget, PerformanceGate, PerformanceGateSet, PerformanceOperator, +}; +pub use protocol::{ProtocolRequest, ProtocolResponse}; +pub use query::{QueryField, QueryFormat, QueryParam, QueryResult, QueryRow, parse_query_response}; +pub use runtime_resources::{ + ExtensionSizeReport, MobileStaticRegistryMetadata, MobileStaticRegistryState, + NativeExtensionArtifact, NativeExtensionArtifactFormat, NativeExtensionArtifactIndex, + NativeExtensionArtifactIndexArtifact, NativeExtensionArtifactIndexCatalog, + NativeExtensionArtifactIndexCatalogEntry, NativeExtensionArtifactIndexCreateOptions, + NativeExtensionArtifactIndexOptions, NativeExtensionArtifactIndexResolution, + NativeExtensionArtifactIndexSignature, NativeExtensionArtifactIndexSigningOptions, + NativeExtensionArtifactIndexTrustRoot, NativeExtensionArtifactOptions, + NativeExtensionMobileStaticArchive, NativeExtensionMobileStaticDependencyArchive, + NativeExtensionStaticSymbolAlias, NativePrebuiltExtensionArtifact, + NativeRuntimeResourceOptions, NativeRuntimeResourceSizeReport, NativeRuntimeResources, + build_native_runtime_resources, create_prebuilt_extension_artifact, + create_prebuilt_extension_artifact_index, list_prebuilt_extension_artifact_index_catalog, + resolve_prebuilt_extension_artifacts_from_indexes, sign_prebuilt_extension_artifact_index, +}; +pub use server::NativeServerRuntime; +pub use storage::{ + BackupArtifact, BackupFormat, BackupRequest, BootstrapStrategy, DatabaseRoot, RestoreRequest, + RestoreTargetPolicy, RootLockPolicy, StorageConfig, +}; diff --git a/src/sdks/rust/src/liboliphaunt/ffi.rs b/src/sdks/rust/src/liboliphaunt/ffi.rs new file mode 100644 index 00000000..1a9f055c --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/ffi.rs @@ -0,0 +1,253 @@ +use std::ffi::{CStr, CString, c_char, c_int, c_uchar, c_void}; +use std::mem::ManuallyDrop; +use std::path::{Path, PathBuf}; + +use libloading::Library; + +use super::OliphauntRuntimeSource; +use crate::error::{Error, Result}; + +pub(super) const ABI_VERSION: u32 = 6; +pub(super) const CAP_PROTOCOL_RAW: u64 = 1 << 0; +pub(super) const CAP_PROTOCOL_STREAM: u64 = 1 << 1; +pub(super) const CAP_MULTI_INSTANCE: u64 = 1 << 2; +pub(super) const CAP_SERVER_MODE: u64 = 1 << 3; +pub(super) const CAP_EXTENSIONS: u64 = 1 << 4; +pub(super) const CAP_QUERY_CANCEL: u64 = 1 << 5; +pub(super) const CAP_BACKUP_RESTORE: u64 = 1 << 6; +pub(super) const CAP_SIMPLE_QUERY: u64 = 1 << 7; +pub(super) const CAP_LOGICAL_REOPEN: u64 = 1 << 9; + +pub(super) const CONFIG_EXTERNAL_ROOT_LOCK: u64 = 1 << 0; + +pub(super) const BACKUP_FORMAT_SQL: u32 = 1; +pub(super) const BACKUP_FORMAT_PHYSICAL_ARCHIVE: u32 = 2; +pub(super) const BACKUP_FORMAT_OLIPHAUNT_ARCHIVE: u32 = 3; + +pub(super) const ENV_OLIPHAUNT: &str = "LIBOLIPHAUNT_PATH"; +pub(super) const ENV_INSTALL_DIR: &str = "OLIPHAUNT_INSTALL_DIR"; +pub(super) const ENV_POSTGRES: &str = "OLIPHAUNT_POSTGRES"; +pub(super) const ENV_INITDB: &str = "OLIPHAUNT_INITDB"; + +#[repr(C)] +pub(super) struct NativeConfig { + pub(super) abi_version: u32, + pub(super) pgdata: *const c_char, + pub(super) runtime_dir: *const c_char, + pub(super) username: *const c_char, + pub(super) database: *const c_char, + pub(super) reserved_flags: u64, + pub(super) startup_args: *const *const c_char, + pub(super) startup_arg_count: usize, +} + +#[repr(C)] +pub(super) struct NativeResponse { + pub(super) data: *mut c_uchar, + pub(super) len: usize, +} + +#[repr(C)] +pub(super) struct NativeArchiveFile { + pub(super) path: *const c_char, + pub(super) data: *const c_uchar, + pub(super) len: usize, + pub(super) mode: u32, + pub(super) reserved_flags: u64, +} + +#[repr(C)] +pub(super) struct NativeBackupOptions { + pub(super) abi_version: u32, + pub(super) format: u32, + pub(super) generated_files: *const NativeArchiveFile, + pub(super) generated_file_count: usize, + pub(super) reserved_flags: u64, +} + +pub(super) type NativeHandle = c_void; +type InitFn = unsafe extern "C" fn(*const NativeConfig, *mut *mut NativeHandle) -> c_int; +type ExecProtocolFn = + unsafe extern "C" fn(*mut NativeHandle, *const c_uchar, usize, *mut NativeResponse) -> c_int; +type ExecSimpleQueryFn = + unsafe extern "C" fn(*mut NativeHandle, *const c_char, usize, *mut NativeResponse) -> c_int; +pub(super) type StreamCallbackFn = + unsafe extern "C" fn(*mut c_void, *const c_uchar, usize) -> c_int; +type ExecProtocolStreamFn = unsafe extern "C" fn( + *mut NativeHandle, + *const c_uchar, + usize, + StreamCallbackFn, + *mut c_void, +) -> c_int; +type CloseFn = unsafe extern "C" fn(*mut NativeHandle) -> c_int; +type DetachFn = unsafe extern "C" fn(*mut NativeHandle) -> c_int; +type CancelFn = unsafe extern "C" fn(*mut NativeHandle) -> c_int; +type LastErrorFn = unsafe extern "C" fn(*mut NativeHandle) -> *const c_char; +type VersionFn = unsafe extern "C" fn() -> *const c_char; +type CapabilitiesFn = unsafe extern "C" fn() -> u64; +type FreeResponseFn = unsafe extern "C" fn(*mut NativeResponse); +type BackupFn = unsafe extern "C" fn(*mut NativeHandle, u32, *mut NativeResponse) -> c_int; +type BackupExFn = unsafe extern "C" fn( + *mut NativeHandle, + *const NativeBackupOptions, + *mut NativeResponse, +) -> c_int; + +pub(super) struct NativeSymbols { + _library: ManuallyDrop, + pub(super) init: InitFn, + pub(super) exec_protocol: ExecProtocolFn, + pub(super) exec_simple_query: Option, + pub(super) exec_protocol_stream: Option, + pub(super) cancel: CancelFn, + pub(super) detach: DetachFn, + _close: CloseFn, + pub(super) last_error: LastErrorFn, + _version: VersionFn, + pub(super) capabilities: CapabilitiesFn, + pub(super) free_response: FreeResponseFn, + pub(super) backup: Option, + pub(super) backup_ex: Option, +} + +// SAFETY: NativeSymbols is immutable after load. Function pointers are plain C +// symbols tied to `_library`, and the library is intentionally leaked for the +// process lifetime so those pointers cannot dangle while shared between the SDK +// executor and cancellation paths. +unsafe impl Send for NativeSymbols {} +// SAFETY: See the Send impl. Calling through a symbol still requires the caller +// to provide a valid synchronized handle; this table only shares immutable +// function addresses and the pinned dynamic library ownership. +unsafe impl Sync for NativeSymbols {} + +impl NativeSymbols { + pub(super) fn load(source: &OliphauntRuntimeSource) -> Result { + let path = resolve_library_path(source)?; + let library = load_native_library(&path)?; + let init = load_symbol(&library, b"oliphaunt_init\0")?; + let exec_protocol = load_symbol(&library, b"oliphaunt_exec_protocol\0")?; + let exec_simple_query = load_optional_symbol(&library, b"oliphaunt_exec_simple_query\0"); + let exec_protocol_stream = + load_optional_symbol(&library, b"oliphaunt_exec_protocol_stream\0"); + let cancel = load_symbol(&library, b"oliphaunt_cancel\0")?; + let detach = load_symbol(&library, b"oliphaunt_detach\0")?; + let close = load_symbol(&library, b"oliphaunt_close\0")?; + let last_error = load_symbol(&library, b"oliphaunt_last_error\0")?; + let version = load_symbol(&library, b"oliphaunt_version\0")?; + let capabilities = load_symbol(&library, b"oliphaunt_capabilities\0")?; + let free_response = load_symbol(&library, b"oliphaunt_free_response\0")?; + let backup = load_optional_symbol(&library, b"oliphaunt_backup\0"); + let backup_ex = load_optional_symbol(&library, b"oliphaunt_backup_ex\0"); + Ok(Self { + // liboliphaunt embeds PostgreSQL, which owns process-global runtime + // state while a backend session is active. Logical SDK close uses + // oliphaunt_detach; oliphaunt_close remains terminal for the process + // lifetime. Dropping the dynamic library can invalidate callbacks, + // signal handlers, or other global runtime pointers that PostgreSQL + // installed inside the host process. + _library: ManuallyDrop::new(library), + init, + exec_protocol, + exec_simple_query, + exec_protocol_stream, + cancel, + detach, + _close: close, + last_error, + _version: version, + capabilities, + free_response, + backup, + backup_ex, + }) + } + + pub(super) fn last_error_text(&self, handle: *mut NativeHandle) -> Option { + let ptr = unsafe { (self.last_error)(handle) }; + c_string_lossy(ptr) + } +} + +fn resolve_library_path(source: &OliphauntRuntimeSource) -> Result { + match source { + OliphauntRuntimeSource::Path(path) => Ok(path.clone()), + OliphauntRuntimeSource::Env => resolve_library_path_candidates() + .into_iter() + .next() + .ok_or_else(|| { + Error::Engine(format!( + "{ENV_OLIPHAUNT} is not set; set it to a native liboliphaunt dynamic library" + )) + }), + } +} + +pub(super) fn resolve_library_path_candidates() -> Vec { + env_path_candidates([ENV_OLIPHAUNT]) +} + +pub(super) fn env_path_candidates(names: [&str; N]) -> Vec { + names + .into_iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .collect() +} + +fn load_native_library(path: &Path) -> Result { + #[cfg(unix)] + { + use libloading::os::unix::{Library as UnixLibrary, RTLD_GLOBAL, RTLD_NOW}; + + let library = unsafe { UnixLibrary::open(Some(path.as_os_str()), RTLD_NOW | RTLD_GLOBAL) } + .map_err(|err| { + Error::Engine(format!( + "load native liboliphaunt library {}: {err}", + path.display() + )) + })?; + Ok(Library::from(library)) + } + #[cfg(not(unix))] + { + let library = unsafe { Library::new(path) }.map_err(|err| { + Error::Engine(format!( + "load native liboliphaunt library {}: {err}", + path.display() + )) + })?; + Ok(library) + } +} + +fn load_symbol(library: &Library, name: &[u8]) -> Result { + let symbol = unsafe { library.get::(name) }.map_err(|err| { + Error::Engine(format!( + "native liboliphaunt is missing required symbol {}: {err}", + String::from_utf8_lossy(name).trim_end_matches('\0') + )) + })?; + Ok(*symbol) +} + +fn load_optional_symbol(library: &Library, name: &[u8]) -> Option { + unsafe { library.get::(name) }.ok().map(|symbol| *symbol) +} + +pub(super) fn path_to_cstring(path: &Path, label: &str) -> Result { + let text = path.to_string_lossy(); + CString::new(text.as_bytes()) + .map_err(|_| Error::InvalidConfig(format!("{label} contains an interior NUL"))) +} + +fn c_string_lossy(ptr: *const c_char) -> Option { + if ptr.is_null() { + return None; + } + Some( + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) +} diff --git a/src/sdks/rust/src/liboliphaunt/mod.rs b/src/sdks/rust/src/liboliphaunt/mod.rs new file mode 100644 index 00000000..72232050 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/mod.rs @@ -0,0 +1,861 @@ +use std::ffi::{CString, c_char}; +use std::path::PathBuf; +use std::ptr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; + +mod ffi; +mod root; + +pub(crate) use self::root::{ + MaterializedNativeResources, materialize_native_resources_for_runtime, +}; +pub(crate) use self::root::{ + NativeRootLock, PreparedNativeRoot, ROOT_MANIFEST_FILE as NATIVE_ROOT_MANIFEST_FILE, + ensure_root_manifest as ensure_native_root_manifest, native_root_key, + root_manifest_text as native_root_manifest_text, + validate_root_manifest_text as validate_native_root_manifest_text, +}; + +use self::ffi::{ + ABI_VERSION, BACKUP_FORMAT_OLIPHAUNT_ARCHIVE, BACKUP_FORMAT_PHYSICAL_ARCHIVE, + BACKUP_FORMAT_SQL, CAP_BACKUP_RESTORE, CAP_EXTENSIONS, CAP_LOGICAL_REOPEN, CAP_MULTI_INSTANCE, + CAP_PROTOCOL_RAW, CAP_PROTOCOL_STREAM, CAP_QUERY_CANCEL, CAP_SERVER_MODE, CAP_SIMPLE_QUERY, + NativeArchiveFile, NativeBackupOptions, NativeConfig, NativeHandle, NativeResponse, + NativeSymbols, path_to_cstring, +}; +use crate::backup::{ + PHYSICAL_ARCHIVE_MANIFEST_PATH, annotate_physical_archive_backup, + physical_archive_metadata_files, +}; +use crate::config::{EngineMode, OpenConfig}; +use crate::engine::{ + EngineCancel, EngineCapabilities, EngineSession, NativeRuntime, SessionConcurrency, +}; +use crate::error::{Error, Result}; +use crate::extension::{Extension, required_shared_preload_libraries}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; +use crate::storage::DatabaseRoot; +use crate::storage::{BackupArtifact, BackupFormat, BackupRequest}; + +static DIRECT_INSTANCE_ACTIVE: AtomicBool = AtomicBool::new(false); +static DIRECT_RESIDENT_ROOT: OnceLock>> = OnceLock::new(); + +/// Source used to locate the native `liboliphaunt` dynamic library. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OliphauntRuntimeSource { + /// Resolve from `LIBOLIPHAUNT_PATH`, falling back to legacy + /// native-spike environment variables during migration. + Env, + /// Load from an explicit path. + Path(PathBuf), +} + +/// Runtime implementation backed by the native PostgreSQL `liboliphaunt` C ABI. +#[derive(Debug, Clone)] +pub struct OliphauntRuntime { + source: OliphauntRuntimeSource, +} + +impl OliphauntRuntime { + /// Create a runtime that resolves the library path from the environment. + pub fn from_env() -> Self { + Self { + source: OliphauntRuntimeSource::Env, + } + } + + /// Create a runtime that loads a specific library path. + pub fn from_path(path: impl Into) -> Self { + Self { + source: OliphauntRuntimeSource::Path(path.into()), + } + } +} + +impl Default for OliphauntRuntime { + fn default() -> Self { + Self::from_env() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DirectResidentKey { + requested_root_key: Option, + actual_root_key: PathBuf, + username: String, + database: String, + startup_args: Vec, + selected_extensions: Vec, +} + +impl DirectResidentKey { + fn requested( + config: &OpenConfig, + extensions: &[Extension], + startup_args: Vec, + ) -> Result { + let requested_root_key = match &config.storage.root { + DatabaseRoot::Path(root) => Some(native_root_key(root)?), + DatabaseRoot::Temporary => None, + }; + Ok(Self { + actual_root_key: requested_root_key.clone().unwrap_or_default(), + requested_root_key, + username: config.username.clone(), + database: config.database.clone(), + startup_args, + selected_extensions: extensions.to_vec(), + }) + } + + fn bind_actual_root(mut self, root: &PreparedNativeRoot) -> Result { + self.actual_root_key = root.root_key()?; + Ok(self) + } + + fn matches_request(&self, requested: &Self) -> bool { + requested + .requested_root_key + .as_ref() + .is_some_and(|requested_root| requested_root == &self.actual_root_key) + && self.username == requested.username + && self.database == requested.database + && self.startup_args == requested.startup_args + && self.selected_extensions == requested.selected_extensions + } +} + +struct DirectResidentRoot { + root: PreparedNativeRoot, + key: DirectResidentKey, +} + +impl NativeRuntime for OliphauntRuntime { + fn open(&self, config: OpenConfig) -> Result> { + if config.mode != EngineMode::NativeDirect { + return Err(Error::UnsupportedEngineMode { + mode: config.mode, + reason: "the current liboliphaunt C ABI is an in-process direct engine; broker and true server modes need their own runtimes".to_owned(), + }); + } + config.validate()?; + let instance_lease = acquire_direct_instance_lease()?; + let extensions = config.resolved_extensions()?; + let startup_args = startup_arg_strings(&config, &extensions); + let requested_key = DirectResidentKey::requested(&config, &extensions, startup_args)?; + let symbols = Arc::new(NativeSymbols::load(&self.source)?); + let (root, root_was_resident) = + take_or_prepare_direct_root(&config, &extensions, &requested_key)?; + let resident_key = requested_key.bind_actual_root(&root)?; + match OliphauntSession::open( + symbols, + root, + config, + &extensions, + resident_key.clone(), + instance_lease, + ) { + Ok(session) => Ok(Box::new(session)), + Err((root, error)) => { + if root_was_resident { + store_direct_resident_root(root, resident_key)?; + } + Err(error) + } + } + } +} + +fn take_or_prepare_direct_root( + config: &OpenConfig, + extensions: &[Extension], + requested_key: &DirectResidentKey, +) -> Result<(PreparedNativeRoot, bool)> { + let slot = DIRECT_RESIDENT_ROOT.get_or_init(|| Mutex::new(None)); + let mut resident = slot + .lock() + .map_err(|_| Error::Engine("native direct resident root lock was poisoned".to_owned()))?; + if let Some(existing) = resident.take() { + if existing.key.matches_request(requested_key) { + return Ok((existing.root, true)); + } + let bound_root = existing.key.actual_root_key.display().to_string(); + *resident = Some(existing); + return Err(Error::Engine(format!( + "native direct resident runtime is already bound to root {bound_root}; use NativeBroker or NativeServer for multiple roots in one process" + ))); + } + drop(resident); + + PreparedNativeRoot::prepare(config, extensions).map(|root| (root, false)) +} + +fn store_direct_resident_root(root: PreparedNativeRoot, key: DirectResidentKey) -> Result<()> { + let slot = DIRECT_RESIDENT_ROOT.get_or_init(|| Mutex::new(None)); + let mut resident = slot + .lock() + .map_err(|_| Error::Engine("native direct resident root lock was poisoned".to_owned()))?; + *resident = Some(DirectResidentRoot { root, key }); + Ok(()) +} + +fn acquire_direct_instance_lease() -> Result { + DIRECT_INSTANCE_ACTIVE + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map(|_| DirectInstanceLease) + .map_err(|_| { + Error::Engine("native direct already has an active process-wide instance".to_owned()) + }) +} + +struct DirectInstanceLease; + +impl Drop for DirectInstanceLease { + fn drop(&mut self) { + DIRECT_INSTANCE_ACTIVE.store(false, Ordering::Release); + } +} + +struct OliphauntSession { + symbols: Arc, + handle: Arc, + cancel: Arc, + root: Option, + resident_key: DirectResidentKey, + _lease: Option, + selected_extensions: Vec, +} + +struct SharedNativeHandle { + handle: RwLock<*mut NativeHandle>, +} + +// SAFETY: The raw native handle is never accessed directly through shared +// references. All users first take the RwLock: executor-owned protocol/backup +// work holds a read lock, cancellation holds a read lock, and logical close +// takes the write lock, calls `oliphaunt_detach`, then replaces the pointer +// with null before releasing the process-wide direct-instance lease. +unsafe impl Send for SharedNativeHandle {} +// SAFETY: See the Send impl. The RwLock serializes pointer reads against close, +// so shared references can only observe either the still-open handle or null. +unsafe impl Sync for SharedNativeHandle {} + +impl SharedNativeHandle { + fn new(handle: *mut NativeHandle) -> Self { + Self { + handle: RwLock::new(handle), + } + } +} + +struct OliphauntCancel { + symbols: Arc, + handle: Arc, +} + +impl EngineCancel for OliphauntCancel { + fn cancel(&self) -> Result<()> { + let guard = + self.handle.handle.read().map_err(|_| { + Error::Engine("native liboliphaunt handle lock poisoned".to_owned()) + })?; + let handle = *guard; + if handle.is_null() { + return Err(Error::EngineStopped); + } + let rc = unsafe { (self.symbols.cancel)(handle) }; + if rc != 0 { + let message = self + .symbols + .last_error_text(handle) + .unwrap_or_else(|| format!("oliphaunt_cancel failed with status {rc}")); + return Err(Error::Engine(format!( + "native liboliphaunt cancel failed: {message}" + ))); + } + Ok(()) + } +} + +impl OliphauntSession { + fn open( + symbols: Arc, + root: PreparedNativeRoot, + config: OpenConfig, + extensions: &[Extension], + resident_key: DirectResidentKey, + lease: DirectInstanceLease, + ) -> std::result::Result { + if let Err(error) = root.refresh_manifest() { + return Err((root, error)); + } + let pgdata = match path_to_cstring(&root.pgdata, "PGDATA") { + Ok(value) => value, + Err(error) => return Err((root, error)), + }; + let runtime_dir = match path_to_cstring(&root.runtime_dir, "runtime dir") { + Ok(value) => value, + Err(error) => return Err((root, error)), + }; + let username = match CString::new(config.username.as_str()) { + Ok(value) => value, + Err(_) => { + return Err(( + root, + Error::InvalidConfig("username contains an interior NUL".to_owned()), + )); + } + }; + let database = match CString::new(config.database.as_str()) { + Ok(value) => value, + Err(_) => { + return Err(( + root, + Error::InvalidConfig("database contains an interior NUL".to_owned()), + )); + } + }; + let startup_args = match startup_args(&config, extensions) { + Ok(value) => value, + Err(error) => return Err((root, error)), + }; + let startup_arg_ptrs = startup_args + .iter() + .map(|arg| arg.as_ptr()) + .collect::>(); + let native_config = NativeConfig { + abi_version: ABI_VERSION, + pgdata: pgdata.as_ptr(), + runtime_dir: runtime_dir.as_ptr(), + username: username.as_ptr(), + database: database.as_ptr(), + reserved_flags: ffi::CONFIG_EXTERNAL_ROOT_LOCK, + startup_args: startup_arg_ptrs.as_ptr(), + startup_arg_count: startup_arg_ptrs.len(), + }; + + let mut handle = ptr::null_mut(); + let rc = unsafe { (symbols.init)(&native_config, &mut handle) }; + if rc != 0 || handle.is_null() { + let message = symbols + .last_error_text(handle) + .unwrap_or_else(|| format!("oliphaunt_init failed with status {rc}")); + return Err(( + root, + Error::Engine(format!("native liboliphaunt init failed: {message}")), + )); + } + + let handle = Arc::new(SharedNativeHandle::new(handle)); + let cancel = Arc::new(OliphauntCancel { + symbols: Arc::clone(&symbols), + handle: Arc::clone(&handle), + }); + + Ok(Self { + symbols, + handle, + cancel, + root: Some(root), + resident_key, + _lease: Some(lease), + selected_extensions: extensions.to_vec(), + }) + } + + fn close_handle(&mut self) -> Result<()> { + let mut guard = + self.handle.handle.write().map_err(|_| { + Error::Engine("native liboliphaunt handle lock poisoned".to_owned()) + })?; + let handle = *guard; + if handle.is_null() { + return Ok(()); + } + let rc = unsafe { (self.symbols.detach)(handle) }; + if rc != 0 { + let message = self + .symbols + .last_error_text(handle) + .unwrap_or_else(|| format!("oliphaunt_detach failed with status {rc}")); + return Err(Error::Engine(format!( + "native liboliphaunt detach failed: {message}" + ))); + } + *guard = ptr::null_mut(); + if let Some(root) = self.root.take() { + store_direct_resident_root(root, self.resident_key.clone())?; + } + self._lease = None; + Ok(()) + } + + fn bytes_from_native_response(&self, mut response: NativeResponse) -> Vec { + let bytes = if response.data.is_null() { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(response.data, response.len).to_vec() } + }; + unsafe { (self.symbols.free_response)(&mut response) }; + bytes + } + + fn protocol_response_from_native(&self, response: NativeResponse) -> ProtocolResponse { + let bytes = self.bytes_from_native_response(response); + ProtocolResponse::new(bytes) + } + + fn free_failed_response(&self, response: &mut NativeResponse) { + if !response.data.is_null() { + unsafe { (self.symbols.free_response)(response) }; + } + } +} + +impl EngineSession for OliphauntSession { + fn capabilities(&self) -> EngineCapabilities { + let flags = unsafe { (self.symbols.capabilities)() }; + EngineCapabilities { + mode: EngineMode::NativeDirect, + session_concurrency: SessionConcurrency::SerializedSingleSession, + process_isolated: false, + multi_root: flags & CAP_MULTI_INSTANCE != 0, + reopenable: flags & CAP_LOGICAL_REOPEN != 0, + same_root_logical_reopen: flags & CAP_LOGICAL_REOPEN != 0, + root_switchable: false, + crash_restartable: false, + max_client_sessions: 1, + protocol_raw: flags & CAP_PROTOCOL_RAW != 0, + protocol_stream: flags & CAP_PROTOCOL_STREAM != 0, + query_cancel: flags & CAP_QUERY_CANCEL != 0, + backup_restore: flags & CAP_BACKUP_RESTORE != 0, + backup_formats: vec![BackupFormat::PhysicalArchive], + restore_formats: vec![BackupFormat::PhysicalArchive], + simple_query: flags & CAP_SIMPLE_QUERY != 0, + extensions: flags & CAP_EXTENSIONS != 0, + connection_strings: flags & CAP_SERVER_MODE != 0, + connection_string: None, + } + } + + fn cancel_handle(&self) -> Option> { + let cancel: Arc = self.cancel.clone(); + Some(cancel) + } + + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result { + let guard = + self.handle.handle.read().map_err(|_| { + Error::Engine("native liboliphaunt handle lock poisoned".to_owned()) + })?; + let handle = *guard; + if handle.is_null() { + return Err(Error::EngineStopped); + } + let bytes = request.as_bytes(); + let mut response = NativeResponse { + data: ptr::null_mut(), + len: 0, + }; + let rc = unsafe { + (self.symbols.exec_protocol)(handle, bytes.as_ptr(), bytes.len(), &mut response) + }; + if rc != 0 { + self.free_failed_response(&mut response); + let message = self + .symbols + .last_error_text(handle) + .unwrap_or_else(|| format!("oliphaunt_exec_protocol failed with status {rc}")); + return Err(Error::Engine(format!( + "native liboliphaunt protocol execution failed: {message}" + ))); + } + if response.data.is_null() { + return Ok(ProtocolResponse::new(Vec::new())); + } + Ok(self.protocol_response_from_native(response)) + } + + fn exec_simple_query(&mut self, sql: &str) -> Result { + let Some(exec_simple_query) = self.symbols.exec_simple_query else { + return self.exec_protocol_raw(ProtocolRequest::simple_query(sql)?); + }; + if sql.as_bytes().contains(&0) { + return Err(Error::InvalidConfig( + "simple query contains an interior NUL byte".to_owned(), + )); + } + let guard = + self.handle.handle.read().map_err(|_| { + Error::Engine("native liboliphaunt handle lock poisoned".to_owned()) + })?; + let handle = *guard; + if handle.is_null() { + return Err(Error::EngineStopped); + } + let mut response = NativeResponse { + data: ptr::null_mut(), + len: 0, + }; + let rc = unsafe { + exec_simple_query( + handle, + sql.as_ptr().cast::(), + sql.len(), + &mut response, + ) + }; + if rc != 0 { + self.free_failed_response(&mut response); + let message = self + .symbols + .last_error_text(handle) + .unwrap_or_else(|| format!("oliphaunt_exec_simple_query failed with status {rc}")); + return Err(Error::Engine(format!( + "native liboliphaunt simple query failed: {message}" + ))); + } + Ok(self.protocol_response_from_native(response)) + } + + fn exec_protocol_stream( + &mut self, + request: ProtocolRequest, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, + ) -> Result<()> { + let guard = + self.handle.handle.read().map_err(|_| { + Error::Engine("native liboliphaunt handle lock poisoned".to_owned()) + })?; + let handle = *guard; + if handle.is_null() { + return Err(Error::EngineStopped); + } + let Some(exec_stream) = self.symbols.exec_protocol_stream else { + drop(guard); + let response = self.exec_protocol_raw(request)?; + return on_chunk(response.as_bytes()); + }; + + struct StreamContext<'a> { + on_chunk: &'a mut dyn FnMut(&[u8]) -> Result<()>, + error: Option, + } + + unsafe extern "C" fn stream_callback( + context: *mut std::ffi::c_void, + data: *const std::ffi::c_uchar, + len: usize, + ) -> std::ffi::c_int { + let context = unsafe { &mut *(context as *mut StreamContext<'_>) }; + if data.is_null() && len > 0 { + context.error = Some(Error::Engine( + "native liboliphaunt stream callback received null data".to_owned(), + )); + return -1; + } + let bytes = if len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(data, len) } + }; + match (context.on_chunk)(bytes) { + Ok(()) => 0, + Err(error) => { + context.error = Some(error); + -1 + } + } + } + + let bytes = request.as_bytes(); + let mut context = StreamContext { + on_chunk, + error: None, + }; + let rc = unsafe { + exec_stream( + handle, + bytes.as_ptr(), + bytes.len(), + stream_callback, + &mut context as *mut StreamContext<'_> as *mut std::ffi::c_void, + ) + }; + if rc != 0 { + if let Some(error) = context.error { + return Err(error); + } + let message = self.symbols.last_error_text(handle).unwrap_or_else(|| { + format!("oliphaunt_exec_protocol_stream failed with status {rc}") + }); + return Err(Error::Engine(format!( + "native liboliphaunt protocol stream failed: {message}" + ))); + } + Ok(()) + } + + fn checkpoint(&mut self) -> Result<()> { + self.exec_simple_query("CHECKPOINT").map(|_| ()) + } + + fn backup(&mut self, request: BackupRequest) -> Result { + match request.format { + BackupFormat::PhysicalArchive => { + let root = self.root.as_ref().ok_or(Error::EngineStopped)?; + let pgdata = root.pgdata.clone(); + let selected_extensions = self.selected_extensions.clone(); + + if let Some(backup_ex) = self.symbols.backup_ex { + let metadata_files = physical_archive_metadata_files( + &pgdata, + &selected_extensions, + |request| self.exec_protocol_raw(request), + )?; + let root_manifest_path = CString::new(NATIVE_ROOT_MANIFEST_FILE) + .expect("native root manifest path is a static C string"); + let backup_manifest_path = CString::new(PHYSICAL_ARCHIVE_MANIFEST_PATH) + .expect("physical archive manifest path is a static C string"); + let root_manifest_bytes = metadata_files.root_manifest.as_bytes(); + let backup_manifest_bytes = metadata_files.backup_manifest.as_bytes(); + let generated_files = [ + NativeArchiveFile { + path: root_manifest_path.as_ptr(), + data: root_manifest_bytes.as_ptr(), + len: root_manifest_bytes.len(), + mode: 0o600, + reserved_flags: 0, + }, + NativeArchiveFile { + path: backup_manifest_path.as_ptr(), + data: backup_manifest_bytes.as_ptr(), + len: backup_manifest_bytes.len(), + mode: 0o600, + reserved_flags: 0, + }, + ]; + let options = NativeBackupOptions { + abi_version: ABI_VERSION, + format: BACKUP_FORMAT_PHYSICAL_ARCHIVE, + generated_files: generated_files.as_ptr(), + generated_file_count: generated_files.len(), + reserved_flags: 0, + }; + let guard = self.handle.handle.read().map_err(|_| { + Error::Engine("native liboliphaunt handle lock poisoned".to_owned()) + })?; + let handle = *guard; + if handle.is_null() { + return Err(Error::EngineStopped); + } + let mut response = NativeResponse { + data: ptr::null_mut(), + len: 0, + }; + let rc = unsafe { backup_ex(handle, &options, &mut response) }; + if rc != 0 { + self.free_failed_response(&mut response); + let message = self.symbols.last_error_text(handle).unwrap_or_else(|| { + format!("oliphaunt_backup_ex failed with status {rc}") + }); + return Err(Error::Engine(format!( + "native liboliphaunt physical backup failed: {message}" + ))); + } + let bytes = self.bytes_from_native_response(response); + return Ok(BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + }); + } + + let backup = self.symbols.backup.ok_or_else(|| { + Error::Engine( + "native liboliphaunt is missing required oliphaunt_backup symbol" + .to_owned(), + ) + })?; + let guard = self.handle.handle.read().map_err(|_| { + Error::Engine("native liboliphaunt handle lock poisoned".to_owned()) + })?; + let handle = *guard; + if handle.is_null() { + return Err(Error::EngineStopped); + } + let mut response = NativeResponse { + data: ptr::null_mut(), + len: 0, + }; + let rc = unsafe { backup(handle, BACKUP_FORMAT_PHYSICAL_ARCHIVE, &mut response) }; + if rc != 0 { + let message = self + .symbols + .last_error_text(handle) + .unwrap_or_else(|| format!("oliphaunt_backup failed with status {rc}")); + return Err(Error::Engine(format!( + "native liboliphaunt physical backup failed: {message}" + ))); + } + let bytes = self.bytes_from_native_response(response); + drop(guard); + let artifact = BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + }; + annotate_physical_archive_backup( + artifact, + &pgdata, + &selected_extensions, + |request| self.exec_protocol_raw(request), + ) + } + BackupFormat::Sql => Err(Error::Engine(format!( + "logical SQL backup requires NativeServer mode with pg_dump; direct mode C ABI format {} is intentionally unavailable", + BACKUP_FORMAT_SQL + ))), + BackupFormat::OliphauntArchive => Err(Error::Engine(format!( + "OliphauntArchive has no stable on-disk format yet; direct mode C ABI format {} is intentionally unavailable", + BACKUP_FORMAT_OLIPHAUNT_ARCHIVE + ))), + } + } + + fn close(&mut self) -> Result<()> { + self.close_handle() + } +} + +impl Drop for OliphauntSession { + fn drop(&mut self) { + let _ = self.close_handle(); + } +} + +fn startup_arg_strings(config: &OpenConfig, extensions: &[Extension]) -> Vec { + let mut args = Vec::new(); + for assignment in config.postgres_startup_assignments() { + args.push("-c".to_owned()); + args.push(assignment); + } + let preload_libraries = required_shared_preload_libraries(extensions); + if !preload_libraries.is_empty() { + args.push("-c".to_owned()); + args.push(format!( + "shared_preload_libraries={}", + preload_libraries.join(",") + )); + } + args +} + +fn startup_args(config: &OpenConfig, extensions: &[Extension]) -> Result> { + let args = startup_arg_strings(config, extensions); + args.into_iter() + .map(|arg| { + CString::new(arg).map_err(|_| { + Error::InvalidConfig("startup argument contains an interior NUL".to_owned()) + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_startup_args_include_required_preload_libraries_before_init() { + let mut config = OpenConfig::native_direct("target/test-roots/native-direct-preload"); + config.extensions = vec![Extension::PgSearch, Extension::PgSearch]; + let extensions = config.resolved_extensions().unwrap(); + let args = startup_args(&config, &extensions).unwrap(); + let args = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_startup_config_arg(&args, "shared_preload_libraries=pg_search"); + assert_eq!( + args.iter() + .filter(|arg| arg.as_str() == "shared_preload_libraries=pg_search") + .count(), + 1, + "preload libraries must be deduplicated before oliphaunt_init" + ); + } + + #[test] + fn direct_startup_args_omit_preload_when_selected_extensions_do_not_require_it() { + let config = OpenConfig::native_direct("target/test-roots/native-direct-no-preload"); + let args = startup_args(&config, &[Extension::Graph]).unwrap(); + let args = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert!( + !args + .iter() + .any(|arg| arg.starts_with("shared_preload_libraries=")), + "direct startup args must not add preload settings for extensions that do not require them: {args:?}" + ); + } + + #[test] + fn direct_startup_args_apply_footprint_before_durability_and_overrides() { + let mut config = OpenConfig::native_direct("target/test-roots/native-direct-footprint"); + config.runtime_footprint = crate::RuntimeFootprintProfile::BalancedMobile; + config.durability = crate::DurabilityProfile::Balanced; + config.startup_gucs = vec![ + crate::PostgresStartupGuc::new("shared_buffers", "64MB"), + crate::PostgresStartupGuc::new("synchronous_commit", "local"), + ]; + let args = startup_arg_strings(&config, &[]); + + assert_startup_config_arg(&args, "shared_buffers=32MB"); + assert_startup_config_arg(&args, "synchronous_commit=off"); + assert_startup_config_arg(&args, "shared_buffers=64MB"); + assert_startup_config_arg(&args, "synchronous_commit=local"); + assert!( + index_of(&args, "shared_buffers=32MB") < index_of(&args, "shared_buffers=64MB"), + "explicit startup GUCs must be able to override the runtime footprint: {args:?}" + ); + assert!( + index_of(&args, "synchronous_commit=off") < index_of(&args, "synchronous_commit=local"), + "explicit startup GUCs must be able to override durability defaults: {args:?}" + ); + } + + #[test] + fn invalid_startup_gucs_are_rejected_before_open() { + let mut config = OpenConfig::native_direct("target/test-roots/native-direct-invalid-guc"); + config.startup_gucs = vec![crate::PostgresStartupGuc::new("shared-buffers", "16MB")]; + + let error = config.validate().unwrap_err(); + assert!( + error + .to_string() + .contains("must contain only ASCII letters, digits, '_' or '.'"), + "{error}" + ); + } + + fn assert_startup_config_arg(args: &[String], expected: &str) { + let Some(index) = args.iter().position(|arg| arg == expected) else { + panic!("missing direct startup argument {expected:?} in {args:?}"); + }; + assert_eq!( + args.get(index.saturating_sub(1)).map(String::as_str), + Some("-c"), + "direct startup argument {expected:?} must be passed through postgres -c" + ); + } + + fn index_of(args: &[String], expected: &str) -> usize { + args.iter() + .position(|arg| arg == expected) + .unwrap_or_else(|| panic!("missing startup argument {expected:?} in {args:?}")) + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root.rs b/src/sdks/rust/src/liboliphaunt/root.rs new file mode 100644 index 00000000..38cb1007 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root.rs @@ -0,0 +1,445 @@ +mod extensions; +mod files; +mod fingerprint; +mod manifest; +mod runtime; +mod template; + +use std::env; +use std::ffi::OsString; +use std::fmt::Write as _; +use std::fs::{self, File, OpenOptions}; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs2::FileExt; +use sha2::{Digest, Sha256}; + +use crate::config::{EngineMode, OpenConfig}; +use crate::error::{Error, Result}; +use crate::extension::Extension; +use crate::storage::DatabaseRoot; + +static ACTIVE_ROOTS: OnceLock>> = OnceLock::new(); + +pub(crate) struct MaterializedNativeResources { + pub(crate) runtime_dir: PathBuf, + pub(crate) template_pgdata: PathBuf, + pub(crate) runtime_cache_key: String, + pub(crate) template_cache_key: String, +} + +pub(crate) use self::manifest::{ + ROOT_MANIFEST_FILE, ensure_root_manifest, root_manifest_text, validate_root_manifest_text, +}; + +pub(crate) struct PreparedNativeRoot { + pub(crate) root: PathBuf, + pub(crate) pgdata: PathBuf, + pub(crate) runtime_dir: PathBuf, + lock: Option, + temporary: bool, +} + +impl PreparedNativeRoot { + pub(crate) fn prepare(config: &OpenConfig, extensions: &[Extension]) -> Result { + let (root, temporary) = match &config.storage.root { + DatabaseRoot::Path(root) => (root.clone(), false), + DatabaseRoot::Temporary => (create_temporary_root()?, true), + }; + fs::create_dir_all(&root).map_err(|err| { + Error::Engine(format!( + "create native database root {}: {err}", + root.display() + )) + })?; + let lock = NativeRootLock::acquire(&root, "native root")?; + + let pgdata = root.join("pgdata"); + fs::create_dir_all(&pgdata).map_err(|err| { + Error::Engine(format!("create native PGDATA {}: {err}", pgdata.display())) + })?; + let runtime_dir = + runtime::materialize_runtime(NativeRuntimeProfile::for_mode(config.mode), extensions)?; + template::bootstrap_pgdata_if_needed( + NativeRuntimeProfile::for_mode(config.mode), + &pgdata, + &config.storage.bootstrap, + )?; + ensure_root_manifest(&root, &pgdata)?; + + Ok(Self { + root, + pgdata, + runtime_dir, + lock: Some(lock), + temporary, + }) + } + + pub(crate) fn tool_path(&self, tool_name: &str) -> PathBuf { + self.runtime_dir.join("bin").join(tool_name) + } + + pub(crate) fn refresh_manifest(&self) -> Result<()> { + ensure_root_manifest(&self.root, &self.pgdata) + } + + pub(crate) fn root_key(&self) -> Result { + native_root_key(&self.root) + } +} + +impl Drop for PreparedNativeRoot { + fn drop(&mut self) { + drop(self.lock.take()); + if self.temporary { + let _ = fs::remove_dir_all(&self.root); + } + } +} + +#[derive(Debug)] +pub(crate) struct NativeRootLock { + key: PathBuf, + stable_file: File, + root_file: Option, +} + +impl NativeRootLock { + pub(crate) fn acquire(root: &Path, label: &str) -> Result { + Self::acquire_inner(root, label, true) + } + + pub(crate) fn reserve_path(root: &Path, label: &str) -> Result { + Self::acquire_inner(root, label, false) + } + + fn acquire_inner(root: &Path, label: &str, lock_root_marker: bool) -> Result { + let key = canonical_root_key(root)?; + let roots = ACTIVE_ROOTS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + { + let mut active = roots + .lock() + .map_err(|_| Error::Engine("native root lock registry was poisoned".to_owned()))?; + if !active.insert(key.clone()) { + return Err(Error::Engine(format!( + "{label} {} is already open in this process", + key.display() + ))); + } + } + + let stable_lock_path = stable_root_lock_path(&key)?; + let stable_file = match lock_file(&stable_lock_path) { + Ok(file) => file, + Err(err) => { + release_active_root(&key); + return Err(Error::Engine(format!( + "lock {label} {}: {err}", + root.display() + ))); + } + }; + + let root_file = if lock_root_marker { + let root_lock_path = root.join(".oliphaunt.lock"); + match lock_file(&root_lock_path) { + Ok(file) => Some(file), + Err(err) => { + drop(stable_file); + release_active_root(&key); + return Err(Error::Engine(format!( + "lock {label} {}: {err}", + root.display() + ))); + } + } + } else { + None + }; + + Ok(Self { + key, + stable_file, + root_file, + }) + } +} + +impl Drop for NativeRootLock { + fn drop(&mut self) { + if let Some(root_file) = &self.root_file { + let _ = root_file.unlock(); + } + let _ = self.stable_file.unlock(); + release_active_root(&self.key); + } +} + +fn lock_file(path: &Path) -> std::io::Result { + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .read(true) + .open(path)?; + lock.try_lock_exclusive()?; + Ok(lock) +} + +fn stable_root_lock_path(key: &Path) -> Result { + let parent = stable_root_lock_dir(key).ok_or_else(|| { + Error::Engine(format!( + "native root {} has no parent directory for stable lock", + key.display() + )) + })?; + let digest = Sha256::digest(path_identity_bytes(key)); + let mut suffix = String::with_capacity(32); + for byte in &digest[..16] { + write!(&mut suffix, "{byte:02x}").expect("writing to String cannot fail"); + } + Ok(parent.join(format!(".oliphaunt-root-{suffix}.lock"))) +} + +fn stable_root_lock_dir(key: &Path) -> Option { + let mut cursor = key.parent()?; + loop { + if cursor.is_dir() { + return Some(cursor.to_path_buf()); + } + cursor = cursor.parent()?; + } +} + +fn canonical_root_key(root: &Path) -> Result { + let absolute = if root.is_absolute() { + root.to_path_buf() + } else { + env::current_dir() + .map_err(|err| Error::Engine(format!("resolve native root current directory: {err}")))? + .join(root) + }; + if let Ok(canonical) = absolute.canonicalize() { + return Ok(canonical); + } + + let mut cursor = absolute.as_path(); + let mut missing = Vec::::new(); + while let Some(name) = cursor.file_name() { + missing.push(name.to_os_string()); + let Some(parent) = cursor.parent() else { + break; + }; + if let Ok(canonical_parent) = parent.canonicalize() { + let mut key = canonical_parent; + for component in missing.iter().rev() { + key.push(component); + } + return Ok(normalize_path(&key)); + } + cursor = parent; + } + + Ok(normalize_path(&absolute)) +} + +pub(crate) fn native_root_key(root: &Path) -> Result { + canonical_root_key(root) +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::Normal(part) => normalized.push(part), + } + } + normalized +} + +#[cfg(unix)] +fn path_identity_bytes(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + + path.as_os_str().as_bytes().to_vec() +} + +#[cfg(not(unix))] +fn path_identity_bytes(path: &Path) -> Vec { + path.to_string_lossy().as_bytes().to_vec() +} + +fn release_active_root(key: &Path) { + if let Some(roots) = ACTIVE_ROOTS.get() + && let Ok(mut active) = roots.lock() + { + active.remove(key); + } +} + +pub(crate) fn materialize_native_resources_for_runtime( + mode: EngineMode, + extensions: &[Extension], +) -> Result { + let profile = NativeRuntimeProfile::for_mode(mode); + let runtime_dir = runtime::materialize_runtime(profile, extensions)?; + let template_pgdata = template::materialize_pgdata_template(profile)?; + let runtime_cache_key = cache_key_from_leaf(&runtime_dir, "native runtime cache")?; + let template_cache_key = template_pgdata + .parent() + .ok_or_else(|| { + Error::Engine(format!( + "native PGDATA template path {} has no cache-key parent", + template_pgdata.display() + )) + }) + .and_then(|parent| cache_key_from_leaf(parent, "native PGDATA template cache"))?; + + Ok(MaterializedNativeResources { + runtime_dir, + template_pgdata, + runtime_cache_key, + template_cache_key, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum NativeRuntimeProfile { + OliphauntEmbedded, + PostgresServer, +} + +impl NativeRuntimeProfile { + fn for_mode(mode: EngineMode) -> Self { + match mode { + EngineMode::NativeDirect | EngineMode::NativeBroker => Self::OliphauntEmbedded, + EngineMode::NativeServer => Self::PostgresServer, + } + } + + pub(super) const fn cache_id(self) -> &'static str { + match self { + Self::OliphauntEmbedded => "liboliphaunt-embedded", + Self::PostgresServer => "postgres-server", + } + } + + pub(super) const fn needs_embedded_modules(self) -> bool { + matches!(self, Self::OliphauntEmbedded) + } +} + +fn cache_key_from_leaf(path: &std::path::Path, label: &str) -> Result { + let key = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + Error::Engine(format!( + "{label} path {} does not end in a UTF-8 cache key", + path.display() + )) + })?; + if key.is_empty() + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(Error::Engine(format!( + "{label} path {} has invalid cache key '{key}'", + path.display() + ))); + } + Ok(key.to_owned()) +} + +fn create_temporary_root() -> Result { + let parent = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = temporary_file_nonce()?; + for attempt in 0..100_u32 { + let path = parent.join(format!("oliphaunt-{pid}-{nanos}-{attempt}")); + match fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(Error::Engine(format!( + "create temporary native root {}: {err}", + path.display() + ))); + } + } + } + Err(Error::Engine( + "failed to allocate a unique temporary native root".to_owned(), + )) +} + +fn temporary_file_nonce() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .map_err(|err| Error::Engine(format!("system clock before epoch: {err}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_root_lock_rejects_same_process_duplicate_and_reopens() { + let root = create_temporary_root().unwrap(); + let first = NativeRootLock::acquire(&root, "native root").unwrap(); + let duplicate = NativeRootLock::acquire(&root, "native root").unwrap_err(); + assert!( + duplicate + .to_string() + .contains("already open in this process"), + "unexpected duplicate lock error: {duplicate}" + ); + + drop(first); + NativeRootLock::acquire(&root, "native root").unwrap(); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn native_root_lock_reserves_missing_target_paths() { + let parent = create_temporary_root().unwrap(); + let root = parent.join("missing-target"); + let first = NativeRootLock::reserve_path(&root, "restore target").unwrap(); + assert!( + !root.exists(), + "path reservation must not materialize the restore target" + ); + + let duplicate = NativeRootLock::reserve_path(&root, "restore target").unwrap_err(); + assert!( + duplicate + .to_string() + .contains("already open in this process"), + "unexpected duplicate reservation error: {duplicate}" + ); + + fs::create_dir_all(&root).unwrap(); + let open_error = NativeRootLock::acquire(&root, "native root").unwrap_err(); + assert!( + open_error + .to_string() + .contains("already open in this process"), + "missing-target reservation did not block later root open: {open_error}" + ); + + drop(first); + NativeRootLock::acquire(&root, "native root").unwrap(); + let _ = fs::remove_dir_all(parent); + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root/extensions.rs b/src/sdks/rust/src/liboliphaunt/root/extensions.rs new file mode 100644 index 00000000..92f1d6c7 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/extensions.rs @@ -0,0 +1,263 @@ +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::fs; +use std::path::Path; + +use super::files::copy_file_preserving_permissions; +use crate::error::{Error, Result}; +use crate::extension::{Extension, extension_data_files, extension_sql_file_belongs}; + +pub(super) fn selected_extension_names(extensions: &[Extension]) -> Vec<&'static str> { + let mut names = extensions + .iter() + .map(|extension| extension.sql_name()) + .collect::>(); + names.sort_unstable(); + names.dedup(); + names +} + +pub(super) fn core_share_file(relative: &Path) -> bool { + if relative + .components() + .next() + .is_some_and(|component| component.as_os_str() == OsStr::new("extension")) + { + return false; + } + !matches!( + relative.to_str(), + Some("tsearch_data/unaccent.rules" | "tsearch_data/xsyn_sample.rules") + ) +} + +pub(super) fn packaged_extension_module_files() -> BTreeSet { + Extension::ALL_PG18_SUPPORTED + .iter() + .filter_map(|extension| extension.native_module_file()) + .collect() +} + +pub(super) fn embedded_core_module_files() -> BTreeSet { + [format!("plpgsql{}", std::env::consts::DLL_SUFFIX)] + .into_iter() + .collect() +} + +pub(super) fn data_files(extension: Extension) -> &'static [&'static str] { + extension_data_files(extension) +} + +pub(super) fn copy_extension_sql_files( + source_share: &Path, + target_share: &Path, + extension: Extension, +) -> Result<()> { + copy_named_extension_sql_files( + source_share, + target_share, + extension.sql_name(), + extension.creates_extension(), + ) +} + +pub(super) fn copy_named_extension_sql_files( + source_share: &Path, + target_share: &Path, + sql_name: &str, + require_control: bool, +) -> Result<()> { + let source_dir = source_share.join("extension"); + let target_dir = target_share.join("extension"); + let mut copied = 0usize; + let mut copied_control = false; + let mut copied_sql = false; + for entry in fs::read_dir(&source_dir).map_err(|err| { + Error::Engine(format!( + "read extension dir {}: {err}", + source_dir.display() + )) + })? { + let entry = entry.map_err(|err| { + Error::Engine(format!("read entry in {}: {err}", source_dir.display())) + })?; + let file_name = entry.file_name().to_string_lossy().into_owned(); + if extension_sql_file_belongs(sql_name, &file_name) { + if extension_sql_file_is_control(sql_name, &file_name) { + copied_control = true; + } else if extension_sql_file_is_sql(&file_name) { + copied_sql = true; + } + copy_file_preserving_permissions(&entry.path(), &target_dir.join(&file_name))?; + copied += 1; + } + } + if require_control { + if !copied_control || !target_dir.join(format!("{sql_name}.control")).is_file() { + return Err(Error::Engine(format!( + "native extension '{sql_name}' is not available for PostgreSQL 18: missing control file in {}", + source_dir.display() + ))); + } + if !copied_sql { + return Err(Error::Engine(format!( + "native extension '{sql_name}' is not available for PostgreSQL 18: missing SQL install file in {}", + source_dir.display() + ))); + } + } else if copied == 0 && sql_name != "auto_explain" { + return Err(Error::Engine(format!( + "native extension '{sql_name}' did not match any SQL/control files in {}", + source_dir.display() + ))); + } + Ok(()) +} + +pub(super) fn copy_extension_data_files( + source_share: &Path, + target_share: &Path, + extension: Extension, +) -> Result<()> { + for relative in extension_data_files(extension) { + copy_file_preserving_permissions( + &source_share.join(relative), + &target_share.join(relative), + )?; + } + Ok(()) +} + +pub(super) fn copy_embedded_module( + embedded_modules: &Path, + target_lib: &Path, + module: &str, +) -> Result<()> { + let source = embedded_modules.join(module); + if !source.is_file() { + return Err(Error::Engine(format!( + "native embedded PostgreSQL 18 module is missing {}", + source.display() + ))); + } + copy_file_preserving_permissions(&source, &target_lib.join(module)) +} + +pub(super) fn extension_sql_file_is_control(sql_name: &str, file_name: &str) -> bool { + file_name == format!("{sql_name}.control") +} + +pub(super) fn extension_sql_file_is_sql(file_name: &str) -> bool { + file_name.ends_with(".sql") +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + #[test] + fn create_extension_assets_require_control_and_sql_files() { + let temp = TempTree::new("extension-assets"); + let source_share = temp.path().join("source/share/postgresql"); + let target_share = temp.path().join("target/share/postgresql"); + + write_file( + &source_share.join("extension/hstore--1.0.sql"), + b"select 'hstore install';\n", + ); + let missing_control = + copy_extension_sql_files(&source_share, &target_share, Extension::Hstore).unwrap_err(); + assert!( + missing_control.to_string().contains("missing control file"), + "unexpected missing-control error: {missing_control}" + ); + + fs::remove_dir_all(&target_share).unwrap(); + fs::remove_file(source_share.join("extension/hstore--1.0.sql")).unwrap(); + write_file( + &source_share.join("extension/hstore.control"), + b"comment = 'hstore'\n", + ); + let missing_sql = + copy_extension_sql_files(&source_share, &target_share, Extension::Hstore).unwrap_err(); + assert!( + missing_sql.to_string().contains("missing SQL install file"), + "unexpected missing-SQL error: {missing_sql}" + ); + + write_file( + &source_share.join("extension/hstore--1.0.sql"), + b"select 'hstore install';\n", + ); + copy_extension_sql_files(&source_share, &target_share, Extension::Hstore).unwrap(); + assert!(target_share.join("extension/hstore.control").is_file()); + assert!(target_share.join("extension/hstore--1.0.sql").is_file()); + } + + #[test] + fn loadable_module_extension_does_not_require_create_extension_sql() { + let temp = TempTree::new("loadable-module-extension-assets"); + let source_share = temp.path().join("source/share/postgresql"); + let target_share = temp.path().join("target/share/postgresql"); + fs::create_dir_all(source_share.join("extension")).unwrap(); + + copy_extension_sql_files(&source_share, &target_share, Extension::AutoExplain).unwrap(); + } + + #[test] + fn extension_sql_file_belongs_uses_generated_extra_file_metadata() { + let postgis = Extension::Postgis.sql_name(); + + assert!(extension_sql_file_belongs("pgtap", "pgtap-core--1.3.5.sql")); + assert!(extension_sql_file_belongs("pgtap", "pgtap-schema.sql")); + assert!(extension_sql_file_belongs("pgtap", "uninstall_pgtap.sql")); + assert!(extension_sql_file_belongs(postgis, "postgis_comments.sql")); + assert!(extension_sql_file_belongs( + postgis, + "postgis_proc_set_search_path.sql" + )); + assert!(extension_sql_file_belongs(postgis, "rtpostgis--3.6.sql")); + + assert!(!extension_sql_file_belongs("pgtap", "postgis_comments.sql")); + assert!(!extension_sql_file_belongs(postgis, "pgtap-core.sql")); + } + + struct TempTree { + path: PathBuf, + } + + impl TempTree { + fn new(name: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "oliphaunt-extension-assets-test-{}-{name}-{nanos}", + std::process::id() + )); + fs::create_dir(&path).expect("create temp test tree"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TempTree { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn write_file(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directory"); + } + fs::write(path, contents).expect("write fixture file"); + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root/files.rs b/src/sdks/rust/src/liboliphaunt/root/files.rs new file mode 100644 index 00000000..669e3596 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/files.rs @@ -0,0 +1,219 @@ +#[cfg(target_os = "macos")] +use std::ffi::CString; +use std::fs; +#[cfg(target_os = "macos")] +use std::os::raw::c_char; +#[cfg(target_os = "macos")] +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use crate::error::{Error, Result}; + +const ENV_PGDATA_COPY_MODE: &str = "OLIPHAUNT_PGDATA_COPY_MODE"; + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn clonefile(src: *const c_char, dst: *const c_char, flags: u32) -> i32; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CopyMode { + PreferClone, + ByteCopy, +} + +pub(super) fn pgdata_template_copy_mode() -> CopyMode { + match std::env::var(ENV_PGDATA_COPY_MODE) { + Ok(value) if matches!(value.as_str(), "clone" | "prefer-clone" | "prefer_clone") => { + CopyMode::PreferClone + } + Ok(value) + if matches!( + value.as_str(), + "copy" | "byte-copy" | "byte_copy" | "physical-copy" | "physical_copy" + ) => + { + CopyMode::ByteCopy + } + _ => CopyMode::ByteCopy, + } +} + +pub(super) fn copy_directory_filtered( + source: &Path, + destination: &Path, + should_copy_file: fn(&Path) -> bool, +) -> Result<()> { + fn walk( + source_root: &Path, + current: &Path, + destination: &Path, + should_copy_file: fn(&Path) -> bool, + ) -> Result<()> { + for entry in fs::read_dir(current) + .map_err(|err| Error::Engine(format!("read directory {}: {err}", current.display())))? + { + let entry = + entry.map_err(|err| Error::Engine(format!("read directory entry: {err}")))?; + let source_path = entry.path(); + let relative = source_path.strip_prefix(source_root).map_err(|err| { + Error::Engine(format!( + "strip source prefix {} from {}: {err}", + source_root.display(), + source_path.display() + )) + })?; + let target_path = destination.join(relative); + if source_path.is_dir() { + fs::create_dir_all(&target_path).map_err(|err| { + Error::Engine(format!("create directory {}: {err}", target_path.display())) + })?; + walk(source_root, &source_path, destination, should_copy_file)?; + } else if source_path.is_file() && should_copy_file(relative) { + copy_file_preserving_permissions(&source_path, &target_path)?; + } + } + Ok(()) + } + + fs::create_dir_all(destination).map_err(|err| { + Error::Engine(format!("create directory {}: {err}", destination.display())) + })?; + walk(source, source, destination, should_copy_file) +} + +pub(super) fn copy_directory_tree(source: &Path, destination: &Path, mode: CopyMode) -> Result<()> { + let metadata = fs::metadata(source) + .map_err(|err| Error::Engine(format!("stat directory {}: {err}", source.display())))?; + fs::create_dir_all(destination).map_err(|err| { + Error::Engine(format!("create directory {}: {err}", destination.display())) + })?; + fs::set_permissions(destination, metadata.permissions()).map_err(|err| { + Error::Engine(format!( + "set permissions on directory {}: {err}", + destination.display() + )) + })?; + + for entry in sorted_read_dir(source)? { + let source_path = entry.path(); + let target_path = destination.join(entry.file_name()); + let file_type = entry.file_type().map_err(|err| { + Error::Engine(format!( + "read file type for {}: {err}", + source_path.display() + )) + })?; + if file_type.is_dir() { + copy_directory_tree(&source_path, &target_path, mode)?; + } else if file_type.is_file() { + copy_file_with_mode(&source_path, &target_path, mode)?; + } else if file_type.is_symlink() { + copy_symlink(&source_path, &target_path)?; + } + } + Ok(()) +} + +pub(super) fn copy_file_preserving_permissions(source: &Path, destination: &Path) -> Result<()> { + copy_file_with_mode(source, destination, CopyMode::PreferClone) +} + +fn copy_file_with_mode(source: &Path, destination: &Path, mode: CopyMode) -> Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|err| Error::Engine(format!("create {}: {err}", parent.display())))?; + } + let permissions = fs::metadata(source) + .map_err(|err| Error::Engine(format!("stat {}: {err}", source.display())))? + .permissions(); + if mode != CopyMode::PreferClone || try_clone_file(source, destination).is_err() { + fs::copy(source, destination).map_err(|err| { + Error::Engine(format!( + "copy {} -> {}: {err}", + source.display(), + destination.display() + )) + })?; + } + fs::set_permissions(destination, permissions).map_err(|err| { + Error::Engine(format!( + "set permissions on {}: {err}", + destination.display() + )) + }) +} + +#[cfg(target_os = "macos")] +fn try_clone_file(source: &Path, destination: &Path) -> std::io::Result<()> { + let source = CString::new(source.as_os_str().as_bytes())?; + let destination = CString::new(destination.as_os_str().as_bytes())?; + let rc = unsafe { clonefile(source.as_ptr(), destination.as_ptr(), 0) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(not(target_os = "macos"))] +fn try_clone_file(_source: &Path, _destination: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "copy-on-write clone is not available on this platform", + )) +} + +#[cfg(unix)] +fn copy_symlink(source: &Path, destination: &Path) -> Result<()> { + let target = fs::read_link(source) + .map_err(|err| Error::Engine(format!("read symlink {}: {err}", source.display())))?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|err| Error::Engine(format!("create {}: {err}", parent.display())))?; + } + std::os::unix::fs::symlink(&target, destination).map_err(|err| { + Error::Engine(format!( + "copy symlink {} -> {}: {err}", + source.display(), + destination.display() + )) + }) +} + +#[cfg(not(unix))] +fn copy_symlink(source: &Path, _destination: &Path) -> Result<()> { + Err(Error::Engine(format!( + "cannot copy symlink {} on this platform", + source.display() + ))) +} + +pub(super) fn sorted_read_dir(path: &Path) -> Result> { + let mut entries = fs::read_dir(path) + .map_err(|err| Error::Engine(format!("read directory {}: {err}", path.display())))? + .collect::, _>>() + .map_err(|err| { + Error::Engine(format!("read directory entry in {}: {err}", path.display())) + })?; + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries) +} + +pub(super) fn directory_is_empty(path: &Path) -> Result { + let mut entries = fs::read_dir(path) + .map_err(|err| Error::Engine(format!("read directory {}: {err}", path.display())))?; + entries + .next() + .transpose() + .map(|entry| entry.is_none()) + .map_err(|err| Error::Engine(format!("read directory entry in {}: {err}", path.display()))) +} + +pub(super) fn remove_file_if_exists(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(Error::Engine(format!("remove {}: {err}", path.display()))), + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root/fingerprint.rs b/src/sdks/rust/src/liboliphaunt/root/fingerprint.rs new file mode 100644 index 00000000..9a7127da --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/fingerprint.rs @@ -0,0 +1,117 @@ +use std::fs::{self, File}; +use std::io::Read; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use super::files::sorted_read_dir; +use crate::error::{Error, Result}; +use crate::extension::extension_sql_file_belongs; + +const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +pub(super) fn new_state() -> u64 { + FNV_OFFSET_BASIS +} + +pub(super) fn canonical_or_original(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +pub(super) fn fingerprint_directory_filtered( + state: &mut u64, + source_root: &Path, + current: &Path, + should_include_file: fn(&Path) -> bool, +) -> Result<()> { + for entry in sorted_read_dir(current)? { + let source = entry.path(); + let relative = source.strip_prefix(source_root).map_err(|err| { + Error::Engine(format!( + "strip source prefix {} from {}: {err}", + source_root.display(), + source.display() + )) + })?; + if source.is_dir() { + fingerprint_directory_filtered(state, source_root, &source, should_include_file)?; + } else if source.is_file() && should_include_file(relative) { + fingerprint_file(state, source_root, &source)?; + } + } + Ok(()) +} + +pub(super) fn fingerprint_named_extension_sql_files( + state: &mut u64, + source_share: &Path, + sql_name: &str, +) -> Result<()> { + let source_dir = source_share.join("extension"); + for entry in sorted_read_dir(&source_dir)? { + let file_name = entry.file_name().to_string_lossy().into_owned(); + if extension_sql_file_belongs(sql_name, &file_name) { + fingerprint_file(state, source_share, &entry.path())?; + } + } + Ok(()) +} + +pub(super) fn fingerprint_optional_file( + state: &mut u64, + source_root: &Path, + path: &Path, +) -> Result<()> { + if path.is_file() { + fingerprint_file(state, source_root, path) + } else { + hash_str(state, "missing"); + hash_path(state, path); + Ok(()) + } +} + +pub(super) fn fingerprint_file(state: &mut u64, source_root: &Path, path: &Path) -> Result<()> { + let relative = path.strip_prefix(source_root).unwrap_or(path); + hash_path(state, relative); + let metadata = fs::metadata(path) + .map_err(|err| Error::Engine(format!("stat {}: {err}", path.display())))?; + hash_u64(state, metadata.len()); + #[cfg(unix)] + hash_u64(state, u64::from(metadata.permissions().mode())); + let mut file = File::open(path).map_err(|err| { + Error::Engine(format!("open {} for fingerprinting: {err}", path.display())) + })?; + let mut buffer = [0u8; 32 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|err| { + Error::Engine(format!("read {} for fingerprinting: {err}", path.display())) + })?; + if read == 0 { + break; + } + hash_bytes(state, &buffer[..read]); + } + Ok(()) +} + +pub(super) fn hash_path(state: &mut u64, path: &Path) { + hash_str(state, &path.to_string_lossy()); +} + +pub(super) fn hash_str(state: &mut u64, value: &str) { + hash_bytes(state, value.as_bytes()); + hash_bytes(state, &[0]); +} + +fn hash_u64(state: &mut u64, value: u64) { + hash_bytes(state, &value.to_be_bytes()); +} + +fn hash_bytes(state: &mut u64, bytes: &[u8]) { + for byte in bytes { + *state ^= u64::from(*byte); + *state = state.wrapping_mul(FNV_PRIME); + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root/manifest.rs b/src/sdks/rust/src/liboliphaunt/root/manifest.rs new file mode 100644 index 00000000..aa44a457 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/manifest.rs @@ -0,0 +1,302 @@ +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::{Error, Result}; + +pub(crate) const ROOT_MANIFEST_FILE: &str = "manifest.properties"; +const ROOT_MANIFEST_LAYOUT: &str = "oliphaunt-root-v1"; +const ROOT_MANIFEST_PRODUCT: &str = "oliphaunt"; +const ROOT_POSTGRES_MAJOR: &str = "18"; +const ROOT_PGDATA_RELATIVE: &str = "pgdata"; +const ROOT_PGDATA_UNINITIALIZED: &str = "uninitialized"; + +pub(crate) fn ensure_root_manifest(root: &Path, pgdata: &Path) -> Result<()> { + let pgdata_version = read_pgdata_version(pgdata)?; + if let Some(version) = pgdata_version.as_deref() + && version != ROOT_POSTGRES_MAJOR + { + return Err(Error::Engine(format!( + "native root {} contains PostgreSQL {version} PGDATA; oliphaunt currently supports PostgreSQL {ROOT_POSTGRES_MAJOR} roots", + root.display() + ))); + } + + let manifest_path = root.join(ROOT_MANIFEST_FILE); + let desired_manifest = root_manifest_text(pgdata_version.as_deref()); + match fs::read_to_string(&manifest_path) { + Ok(text) => { + validate_root_manifest_text(&manifest_path, &text, pgdata_version.as_deref())?; + if text == desired_manifest { + return Ok(()); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(Error::Engine(format!( + "read native root manifest {}: {err}", + manifest_path.display() + ))); + } + } + + write_root_manifest(root, desired_manifest) +} + +fn read_pgdata_version(pgdata: &Path) -> Result> { + let version_path = pgdata.join("PG_VERSION"); + match fs::read_to_string(&version_path) { + Ok(text) => { + let version = text.trim(); + if version.is_empty() { + return Err(Error::Engine(format!( + "native PGDATA version file {} is empty", + version_path.display() + ))); + } + Ok(Some(version.to_owned())) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(Error::Engine(format!( + "read native PGDATA version file {}: {err}", + version_path.display() + ))), + } +} + +pub(crate) fn validate_root_manifest_text( + manifest_path: &Path, + text: &str, + pgdata_version: Option<&str>, +) -> Result<()> { + let properties = parse_manifest_properties(manifest_path, text)?; + require_manifest_value(manifest_path, &properties, "layout", ROOT_MANIFEST_LAYOUT)?; + require_manifest_value(manifest_path, &properties, "product", ROOT_MANIFEST_PRODUCT)?; + require_manifest_value( + manifest_path, + &properties, + "postgresMajor", + ROOT_POSTGRES_MAJOR, + )?; + require_manifest_value(manifest_path, &properties, "pgdata", ROOT_PGDATA_RELATIVE)?; + + let manifest_pgdata_version = manifest_property(manifest_path, &properties, "pgdataVersion")?; + match pgdata_version { + Some(_) if manifest_pgdata_version == ROOT_PGDATA_UNINITIALIZED => Ok(()), + Some(version) if manifest_pgdata_version == version => Ok(()), + Some(version) => Err(Error::Engine(format!( + "native root manifest {} declares PGDATA version '{}', but {} contains PostgreSQL {version}", + manifest_path.display(), + manifest_pgdata_version, + manifest_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(ROOT_PGDATA_RELATIVE) + .join("PG_VERSION") + .display() + ))), + None if manifest_pgdata_version == ROOT_PGDATA_UNINITIALIZED => Ok(()), + None => Err(Error::Engine(format!( + "native root manifest {} declares initialized PGDATA version '{}', but PG_VERSION is missing", + manifest_path.display(), + manifest_pgdata_version + ))), + } +} + +fn parse_manifest_properties( + manifest_path: &Path, + text: &str, +) -> Result> { + let mut properties = std::collections::BTreeMap::new(); + for (index, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(Error::Engine(format!( + "native root manifest {} line {} must use key=value syntax", + manifest_path.display(), + index + 1 + ))); + }; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return Err(Error::Engine(format!( + "native root manifest {} line {} must not use empty keys or values", + manifest_path.display(), + index + 1 + ))); + } + if properties + .insert(key.to_owned(), value.to_owned()) + .is_some() + { + return Err(Error::Engine(format!( + "native root manifest {} repeats key '{key}'", + manifest_path.display() + ))); + } + } + Ok(properties) +} + +fn require_manifest_value( + manifest_path: &Path, + properties: &std::collections::BTreeMap, + key: &str, + expected: &str, +) -> Result<()> { + let actual = manifest_property(manifest_path, properties, key)?; + if actual == expected { + return Ok(()); + } + Err(Error::Engine(format!( + "native root manifest {} has {key}='{actual}', expected '{expected}'", + manifest_path.display() + ))) +} + +fn manifest_property<'a>( + manifest_path: &Path, + properties: &'a std::collections::BTreeMap, + key: &str, +) -> Result<&'a str> { + properties.get(key).map(String::as_str).ok_or_else(|| { + Error::Engine(format!( + "native root manifest {} is missing required key '{key}'", + manifest_path.display() + )) + }) +} + +pub(crate) fn root_manifest_text(pgdata_version: Option<&str>) -> String { + let pgdata_version = pgdata_version.unwrap_or(ROOT_PGDATA_UNINITIALIZED); + format!( + "layout={ROOT_MANIFEST_LAYOUT}\nproduct={ROOT_MANIFEST_PRODUCT}\npostgresMajor={ROOT_POSTGRES_MAJOR}\npgdata={ROOT_PGDATA_RELATIVE}\npgdataVersion={pgdata_version}\n" + ) +} + +fn write_root_manifest(root: &Path, text: String) -> Result<()> { + let manifest_path = root.join(ROOT_MANIFEST_FILE); + let staging = root.join(format!( + ".{ROOT_MANIFEST_FILE}.tmp-{}-{}", + std::process::id(), + temporary_file_nonce()? + )); + let write_result = fs::write(&staging, text) + .map_err(|err| { + Error::Engine(format!( + "write native root manifest staging file {}: {err}", + staging.display() + )) + }) + .and_then(|()| { + publish_root_manifest(&staging, &manifest_path).map_err(|err| { + Error::Engine(format!( + "publish native root manifest {}: {err}", + manifest_path.display() + )) + }) + }); + if write_result.is_err() { + let _ = fs::remove_file(&staging); + } + write_result +} + +fn publish_root_manifest(staging: &Path, manifest_path: &Path) -> std::io::Result<()> { + #[cfg(windows)] + if manifest_path.exists() { + fs::remove_file(manifest_path)?; + } + fs::rename(staging, manifest_path) +} + +fn temporary_file_nonce() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .map_err(|err| Error::Engine(format!("system clock before epoch: {err}"))) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn native_root_manifest_adopts_pg18_roots_and_rejects_incompatible_versions() { + let root = unique_temp_root("native-root-manifest-adopt"); + let pgdata = root.join(ROOT_PGDATA_RELATIVE); + fs::create_dir_all(&pgdata).unwrap(); + fs::write(pgdata.join("PG_VERSION"), b"18\n").unwrap(); + + ensure_root_manifest(&root, &pgdata).unwrap(); + let manifest = fs::read_to_string(root.join(ROOT_MANIFEST_FILE)).unwrap(); + assert!(manifest.contains("layout=oliphaunt-root-v1\n")); + assert!(manifest.contains("product=oliphaunt\n")); + assert!(manifest.contains("postgresMajor=18\n")); + assert!(manifest.contains("pgdata=pgdata\n")); + assert!(manifest.contains("pgdataVersion=18\n")); + + fs::write( + root.join(ROOT_MANIFEST_FILE), + b"layout=oliphaunt-root-v1\nproduct=oliphaunt\npostgresMajor=17\npgdata=pgdata\npgdataVersion=18\n", + ) + .unwrap(); + let manifest_error = ensure_root_manifest(&root, &pgdata).unwrap_err(); + assert!( + manifest_error + .to_string() + .contains("postgresMajor='17', expected '18'"), + "unexpected manifest-version error: {manifest_error}" + ); + + fs::remove_file(root.join(ROOT_MANIFEST_FILE)).unwrap(); + fs::write(pgdata.join("PG_VERSION"), b"17\n").unwrap(); + let pgdata_error = ensure_root_manifest(&root, &pgdata).unwrap_err(); + assert!( + pgdata_error + .to_string() + .contains("contains PostgreSQL 17 PGDATA"), + "unexpected PGDATA-version error: {pgdata_error}" + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn native_root_manifest_tracks_uninitialized_then_initialized_pgdata() { + let root = unique_temp_root("native-root-manifest-uninitialized"); + let pgdata = root.join(ROOT_PGDATA_RELATIVE); + fs::create_dir_all(&pgdata).unwrap(); + + ensure_root_manifest(&root, &pgdata).unwrap(); + let pending_manifest = fs::read_to_string(root.join(ROOT_MANIFEST_FILE)).unwrap(); + assert!(pending_manifest.contains("pgdataVersion=uninitialized\n")); + + fs::write(pgdata.join("PG_VERSION"), b"18\n").unwrap(); + ensure_root_manifest(&root, &pgdata).unwrap(); + let initialized_manifest = fs::read_to_string(root.join(ROOT_MANIFEST_FILE)).unwrap(); + assert!(initialized_manifest.contains("pgdataVersion=18\n")); + assert!(!initialized_manifest.contains("pgdataVersion=uninitialized\n")); + + let _ = fs::remove_dir_all(root); + } + + fn unique_temp_root(prefix: &str) -> PathBuf { + let parent = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = temporary_file_nonce().unwrap(); + for attempt in 0..100_u32 { + let path = parent.join(format!("oliphaunt-{prefix}-{pid}-{nanos}-{attempt}")); + if fs::create_dir(&path).is_ok() { + return path; + } + } + panic!("failed to allocate a unique temp root for {prefix}"); + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime.rs b/src/sdks/rust/src/liboliphaunt/root/runtime.rs new file mode 100644 index 00000000..272fd9ad --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/runtime.rs @@ -0,0 +1,161 @@ +mod cache_key; +mod install; +mod locate; + +use std::fs::{self, OpenOptions}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs2::FileExt; + +use cache_key::{cached_runtime_is_valid, runtime_cache_key, runtime_cache_manifest}; +use install::install_cached_runtime; +use locate::{locate_native_embedded_modules_dir, locate_native_install_dir}; + +use super::NativeRuntimeProfile; +use crate::error::{Error, Result}; +use crate::extension::Extension; + +const ENV_RUNTIME_CACHE_DIR: &str = "OLIPHAUNT_RUNTIME_CACHE_DIR"; + +pub(super) fn materialize_runtime( + profile: NativeRuntimeProfile, + extensions: &[Extension], +) -> Result { + let install_dir = locate_native_install_dir()?; + let embedded_modules = if profile.needs_embedded_modules() { + Some(locate_native_embedded_modules_dir(&install_dir)?) + } else { + None + }; + let key = runtime_cache_key( + profile, + &install_dir, + embedded_modules.as_deref(), + extensions, + )?; + let cache_root = runtime_cache_root()?; + fs::create_dir_all(&cache_root).map_err(|err| { + Error::Engine(format!( + "create native runtime cache root {}: {err}", + cache_root.display() + )) + })?; + #[cfg(unix)] + fs::set_permissions(&cache_root, fs::Permissions::from_mode(0o700)).map_err(|err| { + Error::Engine(format!( + "set permissions on native runtime cache root {}: {err}", + cache_root.display() + )) + })?; + + let cache_dir = cache_root.join(&key); + let lock_path = cache_root.join(format!("{key}.lock")); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .read(true) + .open(&lock_path) + .map_err(|err| { + Error::Engine(format!( + "open native runtime cache lock {}: {err}", + lock_path.display() + )) + })?; + lock.lock_exclusive().map_err(|err| { + Error::Engine(format!( + "lock native runtime cache {}: {err}", + lock_path.display() + )) + })?; + + if !cached_runtime_is_valid(&cache_dir, &key, extensions) { + let build_dir = cache_root.join(format!( + ".build-{}-{}", + std::process::id(), + monotonic_cache_nonce()? + )); + if build_dir.exists() { + fs::remove_dir_all(&build_dir).map_err(|err| { + Error::Engine(format!( + "remove stale native runtime build dir {}: {err}", + build_dir.display() + )) + })?; + } + fs::create_dir_all(&build_dir).map_err(|err| { + Error::Engine(format!( + "create native runtime build dir {}: {err}", + build_dir.display() + )) + })?; + + let build_result = install_cached_runtime( + profile, + &install_dir, + embedded_modules.as_deref(), + &build_dir, + extensions, + ); + if let Err(error) = build_result { + let _ = fs::remove_dir_all(&build_dir); + return Err(error); + } + fs::write( + build_dir.join(".manifest"), + runtime_cache_manifest(profile, &key, extensions), + ) + .map_err(|err| { + Error::Engine(format!( + "write native runtime cache manifest {}: {err}", + build_dir.display() + )) + })?; + fs::write(build_dir.join(".complete"), b"ok\n").map_err(|err| { + Error::Engine(format!( + "write native runtime cache completion marker {}: {err}", + build_dir.display() + )) + })?; + if cache_dir.exists() { + fs::remove_dir_all(&cache_dir).map_err(|err| { + Error::Engine(format!( + "remove invalid native runtime cache {}: {err}", + cache_dir.display() + )) + })?; + } + fs::rename(&build_dir, &cache_dir).map_err(|err| { + Error::Engine(format!( + "publish native runtime cache {} -> {}: {err}", + build_dir.display(), + cache_dir.display() + )) + })?; + } + + lock.unlock().map_err(|err| { + Error::Engine(format!( + "unlock native runtime cache {}: {err}", + lock_path.display() + )) + })?; + Ok(cache_dir) +} + +pub(super) fn runtime_cache_root() -> Result { + if let Some(path) = std::env::var_os(ENV_RUNTIME_CACHE_DIR) { + return Ok(PathBuf::from(path)); + } + Ok(std::env::temp_dir().join("oliphaunt-runtime-cache")) +} + +pub(super) fn monotonic_cache_nonce() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .map_err(|err| Error::Engine(format!("system clock before epoch: {err}"))) +} diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs b/src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs new file mode 100644 index 00000000..f99efb4c --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/runtime/cache_key.rs @@ -0,0 +1,425 @@ +use std::fs; +use std::path::Path; + +use super::super::NativeRuntimeProfile; +use super::super::extensions::{ + core_share_file, data_files, embedded_core_module_files, extension_sql_file_is_control, + extension_sql_file_is_sql, packaged_extension_module_files, selected_extension_names, +}; +use super::super::files::sorted_read_dir; +use super::super::fingerprint::{ + canonical_or_original, fingerprint_directory_filtered, fingerprint_file, + fingerprint_named_extension_sql_files, fingerprint_optional_file, hash_path, hash_str, + new_state, +}; +use crate::error::{Error, Result}; +use crate::extension::Extension; + +const RUNTIME_CACHE_VERSION: &str = "pg18-runtime-cache-v2"; + +pub(super) fn runtime_cache_key( + profile: NativeRuntimeProfile, + install_dir: &Path, + embedded_modules: Option<&Path>, + extensions: &[Extension], +) -> Result { + let mut state = new_state(); + hash_str(&mut state, RUNTIME_CACHE_VERSION); + hash_str(&mut state, profile.cache_id()); + hash_path(&mut state, &canonical_or_original(install_dir)); + if let Some(embedded_modules) = embedded_modules { + hash_path(&mut state, &canonical_or_original(embedded_modules)); + } + hash_str(&mut state, std::env::consts::DLL_SUFFIX); + + for name in selected_extension_names(extensions) { + hash_str(&mut state, name); + } + + for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { + fingerprint_optional_file(&mut state, install_dir, &install_dir.join("bin").join(tool))?; + } + + let source_share = install_dir.join("share/postgresql"); + fingerprint_directory_filtered(&mut state, &source_share, &source_share, core_share_file)?; + fingerprint_named_extension_sql_files(&mut state, &source_share, "plpgsql")?; + for extension in extensions { + fingerprint_named_extension_sql_files(&mut state, &source_share, extension.sql_name())?; + for relative in data_files(*extension) { + fingerprint_optional_file(&mut state, &source_share, &source_share.join(relative))?; + } + } + + let source_lib = install_dir.join("lib/postgresql"); + let extension_modules = packaged_extension_module_files(); + let embedded_core_modules = embedded_core_module_files(); + for entry in sorted_read_dir(&source_lib)? { + let source = entry.path(); + if !source.is_file() { + continue; + } + let file_name = entry.file_name().to_string_lossy().into_owned(); + if extension_modules.contains(&file_name) + || (profile.needs_embedded_modules() && embedded_core_modules.contains(&file_name)) + { + continue; + } + fingerprint_file(&mut state, &source_lib, &source)?; + } + match profile { + NativeRuntimeProfile::OliphauntEmbedded => { + let embedded_modules = embedded_modules.ok_or_else(|| { + Error::Engine( + "native liboliphaunt runtime requires embedded PostgreSQL modules".to_owned(), + ) + })?; + for module in embedded_core_modules { + fingerprint_optional_file( + &mut state, + embedded_modules, + &embedded_modules.join(&module), + )?; + } + for extension in extensions { + if let Some(module) = extension.native_module_file() { + fingerprint_optional_file( + &mut state, + embedded_modules, + &embedded_modules.join(module), + )?; + } + } + } + NativeRuntimeProfile::PostgresServer => { + for extension in extensions { + if let Some(module) = extension.native_module_file() { + fingerprint_optional_file(&mut state, &source_lib, &source_lib.join(module))?; + } + } + } + } + + Ok(format!("{state:016x}")) +} + +pub(super) fn cached_runtime_is_valid( + cache_dir: &Path, + key: &str, + extensions: &[Extension], +) -> bool { + if !cache_dir.join(".complete").is_file() + || !cache_dir.join("bin/postgres").is_file() + || !cache_dir.join("bin/initdb").is_file() + || !cache_dir + .join("share/postgresql/postgresql.conf.sample") + .is_file() + || !cache_dir + .join("share/postgresql/extension/plpgsql.control") + .is_file() + { + return false; + } + let Ok(manifest) = fs::read_to_string(cache_dir.join(".manifest")) else { + return false; + }; + if !manifest + .lines() + .any(|line| line == format!("version={RUNTIME_CACHE_VERSION}")) + || !manifest.lines().any(|line| line == format!("key={key}")) + { + return false; + } + + for extension in extensions { + if extension.creates_extension() + && (!cache_dir + .join("share/postgresql/extension") + .join(format!("{}.control", extension.sql_name())) + .is_file() + || !cache_contains_extension_sql_file(cache_dir, *extension)) + { + return false; + } + if let Some(module) = extension.native_module_file() + && !cache_dir.join("lib/postgresql").join(module).is_file() + { + return false; + } + for relative in data_files(*extension) { + if !cache_dir.join("share/postgresql").join(relative).is_file() { + return false; + } + } + } + true +} + +fn cache_contains_extension_sql_file(cache_dir: &Path, extension: Extension) -> bool { + let extension_dir = cache_dir.join("share/postgresql/extension"); + let Ok(entries) = fs::read_dir(extension_dir) else { + return false; + }; + entries.filter_map(|entry| entry.ok()).any(|entry| { + let file_name = entry.file_name().to_string_lossy().into_owned(); + !extension_sql_file_is_control(extension.sql_name(), &file_name) + && extension_sql_file_is_sql(&file_name) + && crate::extension::extension_sql_file_belongs(extension.sql_name(), &file_name) + && entry.path().is_file() + }) +} + +pub(super) fn runtime_cache_manifest( + profile: NativeRuntimeProfile, + key: &str, + extensions: &[Extension], +) -> String { + let extension_names = selected_extension_names(extensions); + format!( + "version={RUNTIME_CACHE_VERSION}\nprofile={}\nkey={key}\nextensions={}\n", + profile.cache_id(), + extension_names.join(",") + ) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + struct TempTree { + path: PathBuf, + } + + impl TempTree { + fn new(name: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "oliphaunt-cache-key-test-{}-{}-{nanos}", + std::process::id(), + name + )); + std::fs::create_dir(&path).expect("create temp test tree"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TempTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[test] + fn selected_extension_sql_and_module_content_participate_in_cache_key() { + let temp = TempTree::new("selected-extension"); + let install_dir = temp.path().join("install"); + write_fake_install(&install_dir); + + let first = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[Extension::Hstore], + ) + .expect("create first runtime cache key"); + + write_file( + &install_dir.join("share/postgresql/extension/hstore--1.0.sql"), + b"create function hstore_version() returns text language sql as 'select ''v2''';\n", + ); + let changed_sql = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[Extension::Hstore], + ) + .expect("create SQL-mutated runtime cache key"); + assert_ne!( + first, changed_sql, + "selected extension SQL changes must invalidate the runtime cache" + ); + + write_file( + &install_dir + .join("lib/postgresql") + .join(format!("hstore{}", std::env::consts::DLL_SUFFIX)), + b"hstore-module-v2", + ); + let changed_module = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[Extension::Hstore], + ) + .expect("create module-mutated runtime cache key"); + assert_ne!( + changed_sql, changed_module, + "selected extension module changes must invalidate the runtime cache" + ); + } + + #[test] + fn unselected_extension_assets_do_not_pollute_cache_key() { + let temp = TempTree::new("unselected-extension"); + let install_dir = temp.path().join("install"); + write_fake_install(&install_dir); + + let first = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[], + ) + .expect("create first runtime cache key"); + + write_file( + &install_dir.join("share/postgresql/extension/hstore.control"), + b"comment = 'mutated but unselected'\n", + ); + write_file( + &install_dir.join("share/postgresql/extension/hstore--1.0.sql"), + b"select 'mutated but unselected';\n", + ); + write_file( + &install_dir + .join("lib/postgresql") + .join(format!("hstore{}", std::env::consts::DLL_SUFFIX)), + b"hstore-module-mutated-but-unselected", + ); + + let second = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[], + ) + .expect("create second runtime cache key"); + assert_eq!( + first, second, + "unselected extension assets must stay invisible to runtime cache identity" + ); + } + + #[test] + fn runtime_validation_requires_selected_extension_assets() { + let temp = TempTree::new("validation"); + let cache_dir = temp.path().join("cache"); + write_minimal_cache_dir(&cache_dir, "cache-key"); + + assert!( + cached_runtime_is_valid(&cache_dir, "cache-key", &[]), + "minimal cache is valid when no optional extensions are selected" + ); + assert!( + !cached_runtime_is_valid(&cache_dir, "cache-key", &[Extension::Hstore]), + "selected extension cache must require the extension control and module files" + ); + + let sql_without_module = temp.path().join("cache-sql-without-module"); + write_minimal_cache_dir(&sql_without_module, "cache-key"); + write_file( + &sql_without_module.join("share/postgresql/extension/hstore.control"), + b"comment = 'hstore'\n", + ); + write_file( + &sql_without_module.join("share/postgresql/extension/hstore--1.0.sql"), + b"select 'hstore install';\n", + ); + assert!( + !cached_runtime_is_valid(&sql_without_module, "cache-key", &[Extension::Hstore]), + "selected extension cache must reject SQL/control assets without the matching native module" + ); + + write_file( + &cache_dir.join("share/postgresql/extension/hstore.control"), + b"comment = 'hstore'\n", + ); + write_file( + &cache_dir + .join("lib/postgresql") + .join(format!("hstore{}", std::env::consts::DLL_SUFFIX)), + b"hstore-module", + ); + + assert!( + !cached_runtime_is_valid(&cache_dir, "cache-key", &[Extension::Hstore]), + "selected extension cache must require an extension SQL install file, not only control and module files" + ); + + write_file( + &cache_dir.join("share/postgresql/extension/hstore--1.0.sql"), + b"select 'hstore install';\n", + ); + + assert!( + cached_runtime_is_valid(&cache_dir, "cache-key", &[Extension::Hstore]), + "selected extension cache is valid only after required assets are present" + ); + } + + fn write_fake_install(install_dir: &Path) { + for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { + write_file(&install_dir.join("bin").join(tool), tool.as_bytes()); + } + write_file( + &install_dir.join("share/postgresql/postgresql.conf.sample"), + b"# sample\n", + ); + write_file( + &install_dir.join("share/postgresql/extension/plpgsql.control"), + b"comment = 'PL/pgSQL'\n", + ); + write_file( + &install_dir.join("share/postgresql/extension/hstore.control"), + b"comment = 'hstore'\n", + ); + write_file( + &install_dir.join("share/postgresql/extension/hstore--1.0.sql"), + b"select 'hstore-v1';\n", + ); + write_file( + &install_dir.join("lib/postgresql/postgres_core_fixture.so"), + b"core-module", + ); + write_file( + &install_dir + .join("lib/postgresql") + .join(format!("hstore{}", std::env::consts::DLL_SUFFIX)), + b"hstore-module-v1", + ); + } + + fn write_minimal_cache_dir(cache_dir: &Path, key: &str) { + write_file(&cache_dir.join(".complete"), b"ok\n"); + write_file( + &cache_dir.join(".manifest"), + runtime_cache_manifest(NativeRuntimeProfile::PostgresServer, key, &[]).as_bytes(), + ); + write_file(&cache_dir.join("bin/postgres"), b"postgres"); + write_file(&cache_dir.join("bin/initdb"), b"initdb"); + write_file( + &cache_dir.join("share/postgresql/postgresql.conf.sample"), + b"# sample\n", + ); + write_file( + &cache_dir.join("share/postgresql/extension/plpgsql.control"), + b"comment = 'PL/pgSQL'\n", + ); + } + + fn write_file(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent directory"); + } + std::fs::write(path, contents).expect("write fixture file"); + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime/install.rs b/src/sdks/rust/src/liboliphaunt/root/runtime/install.rs new file mode 100644 index 00000000..7306e183 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/runtime/install.rs @@ -0,0 +1,382 @@ +use std::fs; +use std::path::Path; + +use super::super::NativeRuntimeProfile; +use super::super::extensions::{ + copy_embedded_module, copy_extension_data_files, copy_extension_sql_files, + copy_named_extension_sql_files, core_share_file, embedded_core_module_files, + packaged_extension_module_files, +}; +use super::super::files::{ + copy_directory_filtered, copy_file_preserving_permissions, remove_file_if_exists, +}; +use crate::error::{Error, Result}; +use crate::extension::Extension; + +pub(super) fn install_cached_runtime( + profile: NativeRuntimeProfile, + install_dir: &Path, + embedded_modules: Option<&Path>, + runtime_dir: &Path, + extensions: &[Extension], +) -> Result<()> { + fs::create_dir_all(runtime_dir).map_err(|err| { + Error::Engine(format!( + "create native runtime dir {}: {err}", + runtime_dir.display() + )) + })?; + + for tool in ["postgres", "initdb", "pg_ctl", "pg_dump", "psql"] { + let source = install_dir.join("bin").join(tool); + if source.is_file() { + install_runtime_tool(&source, &runtime_dir.join("bin").join(tool))?; + } + } + + install_native_share_tree(install_dir, runtime_dir, extensions)?; + install_native_library_tree( + profile, + install_dir, + embedded_modules, + runtime_dir, + extensions, + ) +} + +fn install_runtime_tool(source: &Path, destination: &Path) -> Result<()> { + copy_file_preserving_permissions(source, destination)?; + ensure_runtime_tool_executable(destination) +} + +#[cfg(unix)] +fn ensure_runtime_tool_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path).map_err(|err| { + Error::Engine(format!( + "stat native runtime tool {}: {err}", + path.display() + )) + })?; + let mut permissions = metadata.permissions(); + let mode = permissions.mode(); + if mode & 0o111 != 0 { + return Ok(()); + } + permissions.set_mode(mode | 0o111); + fs::set_permissions(path, permissions).map_err(|err| { + Error::Engine(format!( + "set executable permissions on native runtime tool {}: {err}", + path.display() + )) + }) +} + +#[cfg(not(unix))] +fn ensure_runtime_tool_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +fn install_native_share_tree( + install_dir: &Path, + runtime_dir: &Path, + extensions: &[Extension], +) -> Result<()> { + let source_share = install_dir.join("share/postgresql"); + let target_share = runtime_dir.join("share/postgresql"); + if !source_share.is_dir() { + return Err(Error::Engine(format!( + "native PostgreSQL install is missing share/postgresql at {}", + source_share.display() + ))); + } + + copy_directory_filtered(&source_share, &target_share, core_share_file)?; + remove_file_if_exists(&target_share.join("tsearch_data/unaccent.rules"))?; + remove_file_if_exists(&target_share.join("tsearch_data/xsyn_sample.rules"))?; + + let target_extension_dir = target_share.join("extension"); + fs::create_dir_all(&target_extension_dir).map_err(|err| { + Error::Engine(format!("create {}: {err}", target_extension_dir.display())) + })?; + + copy_named_extension_sql_files(&source_share, &target_share, "plpgsql", true)?; + for extension in extensions { + copy_extension_sql_files(&source_share, &target_share, *extension)?; + copy_extension_data_files(&source_share, &target_share, *extension)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + use crate::extension::resolve_extension_selection; + + #[test] + fn install_rejects_missing_transitive_extension_dependency_assets() { + let temp = TempTree::new("missing-transitive-extension-assets"); + let install_dir = temp.path().join("install"); + write_minimal_install(&install_dir); + write_extension_assets(&install_dir, Extension::Earthdistance); + + let extensions = resolve_extension_selection(&[Extension::Earthdistance]).unwrap(); + assert_eq!(extensions, vec![Extension::Cube, Extension::Earthdistance]); + + let error = install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &temp.path().join("runtime"), + &extensions, + ) + .unwrap_err(); + assert!( + error.to_string().contains("native extension 'cube'"), + "unexpected missing-dependency error: {error}" + ); + assert!( + error.to_string().contains("missing control file"), + "unexpected missing-dependency error: {error}" + ); + } + + #[test] + fn install_copies_only_selected_extension_assets() { + let temp = TempTree::new("selected-extension-assets"); + let install_dir = temp.path().join("install"); + let runtime_dir = temp.path().join("runtime"); + write_minimal_install(&install_dir); + write_extension_assets(&install_dir, Extension::Vector); + write_extension_assets(&install_dir, Extension::Hstore); + + install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &runtime_dir, + &[Extension::Vector], + ) + .unwrap(); + + assert!( + runtime_dir + .join("share/postgresql/extension/vector.control") + .is_file() + ); + assert!( + runtime_dir + .join("lib/postgresql") + .join(Extension::Vector.native_module_file().unwrap()) + .is_file() + ); + assert!( + !runtime_dir + .join("share/postgresql/extension/hstore.control") + .exists(), + "unselected hstore SQL assets must not leak into vector-only packages" + ); + assert!( + !runtime_dir + .join("lib/postgresql") + .join(Extension::Hstore.native_module_file().unwrap()) + .exists(), + "unselected hstore module must not leak into vector-only packages" + ); + } + + #[cfg(unix)] + #[test] + fn install_restores_executable_bits_for_runtime_tools() { + use std::os::unix::fs::PermissionsExt; + + let temp = TempTree::new("runtime-tool-permissions"); + let install_dir = temp.path().join("install"); + let runtime_dir = temp.path().join("runtime"); + write_minimal_install(&install_dir); + write_file(&install_dir.join("bin/initdb"), b"initdb"); + for tool in ["postgres", "initdb"] { + fs::set_permissions( + install_dir.join("bin").join(tool), + fs::Permissions::from_mode(0o644), + ) + .expect("make source runtime tool non-executable"); + } + + install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &runtime_dir, + &[], + ) + .unwrap(); + + for tool in ["postgres", "initdb"] { + let mode = fs::metadata(runtime_dir.join("bin").join(tool)) + .expect("stat copied runtime tool") + .permissions() + .mode(); + assert_ne!( + mode & 0o111, + 0, + "copied runtime tool should be executable: {tool}" + ); + } + } + + struct TempTree { + path: PathBuf, + } + + impl TempTree { + fn new(name: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "oliphaunt-runtime-install-test-{}-{name}-{nanos}", + std::process::id() + )); + fs::create_dir(&path).expect("create temp test tree"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TempTree { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn write_minimal_install(install_dir: &Path) { + write_file(&install_dir.join("bin/postgres"), b"postgres"); + write_file( + &install_dir.join("share/postgresql/postgresql.conf.sample"), + b"# sample\n", + ); + write_file( + &install_dir.join("share/postgresql/extension/plpgsql.control"), + b"comment = 'PL/pgSQL'\n", + ); + write_file( + &install_dir.join("share/postgresql/extension/plpgsql--1.0.sql"), + b"select 'plpgsql install';\n", + ); + fs::create_dir_all(install_dir.join("lib/postgresql")).expect("create lib dir"); + } + + fn write_extension_assets(install_dir: &Path, extension: Extension) { + write_file( + &install_dir + .join("share/postgresql/extension") + .join(format!("{}.control", extension.sql_name())), + format!("comment = '{}'\n", extension.sql_name()).as_bytes(), + ); + write_file( + &install_dir + .join("share/postgresql/extension") + .join(format!("{}--1.0.sql", extension.sql_name())), + format!("select '{} install';\n", extension.sql_name()).as_bytes(), + ); + if let Some(module) = extension.native_module_file() { + write_file( + &install_dir.join("lib/postgresql").join(module), + format!("{} module\n", extension.sql_name()).as_bytes(), + ); + } + } + + fn write_file(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directory"); + } + fs::write(path, contents).expect("write fixture file"); + } +} + +fn install_native_library_tree( + profile: NativeRuntimeProfile, + install_dir: &Path, + embedded_modules: Option<&Path>, + runtime_dir: &Path, + extensions: &[Extension], +) -> Result<()> { + let source_lib = install_dir.join("lib/postgresql"); + let target_lib = runtime_dir.join("lib/postgresql"); + if !source_lib.is_dir() { + return Err(Error::Engine(format!( + "native PostgreSQL install is missing lib/postgresql at {}", + source_lib.display() + ))); + } + fs::create_dir_all(&target_lib).map_err(|err| { + Error::Engine(format!( + "create native library dir {}: {err}", + target_lib.display() + )) + })?; + + let extension_modules = packaged_extension_module_files(); + let embedded_core_modules = embedded_core_module_files(); + for entry in fs::read_dir(&source_lib) + .map_err(|err| Error::Engine(format!("read native library dir: {err}")))? + { + let entry = + entry.map_err(|err| Error::Engine(format!("read native library entry: {err}")))?; + let source = entry.path(); + if !source.is_file() { + continue; + } + let file_name = entry.file_name().to_string_lossy().into_owned(); + if extension_modules.contains(&file_name) + || (profile.needs_embedded_modules() && embedded_core_modules.contains(&file_name)) + { + continue; + } + copy_file_preserving_permissions(&source, &target_lib.join(&file_name))?; + } + + if profile.needs_embedded_modules() { + let embedded_modules = embedded_modules.ok_or_else(|| { + Error::Engine( + "native liboliphaunt runtime requires embedded PostgreSQL modules".to_owned(), + ) + })?; + for module in embedded_core_modules { + copy_embedded_module(embedded_modules, &target_lib, &module)?; + } + } + for extension in extensions { + let Some(module) = extension.native_module_file() else { + continue; + }; + match profile { + NativeRuntimeProfile::OliphauntEmbedded => { + let embedded_modules = embedded_modules.ok_or_else(|| { + Error::Engine( + "native liboliphaunt runtime requires embedded PostgreSQL extension modules" + .to_owned(), + ) + })?; + copy_embedded_module(embedded_modules, &target_lib, &module)?; + } + NativeRuntimeProfile::PostgresServer => { + copy_file_preserving_permissions( + &source_lib.join(&module), + &target_lib.join(&module), + )?; + } + } + } + Ok(()) +} diff --git a/src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs b/src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs new file mode 100644 index 00000000..6635ae38 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/runtime/locate.rs @@ -0,0 +1,92 @@ +use std::path::{Path, PathBuf}; + +use super::super::super::ffi::{ + ENV_INITDB, ENV_INSTALL_DIR, ENV_POSTGRES, env_path_candidates, resolve_library_path_candidates, +}; +use crate::error::{Error, Result}; + +pub(super) fn locate_native_install_dir() -> Result { + let mut candidates = Vec::new(); + candidates.extend(env_path_candidates([ENV_INSTALL_DIR])); + for env_name in [ENV_POSTGRES, ENV_INITDB] { + if let Some(path) = std::env::var_os(env_name) { + let path = PathBuf::from(path); + if let Some(install_dir) = path.parent().and_then(Path::parent) { + candidates.push(install_dir.to_path_buf()); + } + } + } + for path in resolve_library_path_candidates() { + if let Some(work_root) = path.parent().and_then(Path::parent) { + candidates.push(work_root.join("install")); + } + } + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("target/liboliphaunt-pg18/install")); + candidates.push(cwd.join("target/native-liboliphaunt-pg18/install")); + if let Some(target_id) = native_host_target_id() { + candidates.push(cwd.join(format!("target/liboliphaunt-pg18-{target_id}/install"))); + } + } + + for candidate in candidates { + if native_install_dir_is_valid(&candidate) { + return Ok(candidate); + } + } + Err(Error::Engine(format!( + "could not locate native PostgreSQL 18 install tree; set {ENV_INSTALL_DIR} or {ENV_POSTGRES}" + ))) +} + +pub(super) fn locate_native_embedded_modules_dir(install_dir: &Path) -> Result { + let mut candidates = Vec::new(); + for path in resolve_library_path_candidates() { + if let Some(out_dir) = path.parent() { + candidates.push(out_dir.join("modules")); + } + } + if let Some(work_root) = install_dir.parent() { + candidates.push(work_root.join("out/modules")); + } + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("target/liboliphaunt-pg18/out/modules")); + candidates.push(cwd.join("target/native-liboliphaunt-pg18/out/modules")); + if let Some(target_id) = native_host_target_id() { + candidates.push(cwd.join(format!("target/liboliphaunt-pg18-{target_id}/out/modules"))); + } + } + + for candidate in candidates { + if candidate.is_dir() { + return Ok(candidate); + } + } + Err(Error::Engine( + "could not locate native embedded PostgreSQL 18 module artifacts; build native liboliphaunt first" + .to_owned(), + )) +} + +fn native_install_dir_is_valid(path: &Path) -> bool { + native_tool_is_file(path, "postgres") + && path + .join("share/postgresql/postgresql.conf.sample") + .is_file() + && path.join("lib/postgresql").is_dir() +} + +fn native_tool_is_file(path: &Path, tool: &str) -> bool { + path.join("bin").join(tool).is_file() || path.join("bin").join(format!("{tool}.exe")).is_file() +} + +fn native_host_target_id() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Some("macos-arm64"), + ("macos", "x86_64") => Some("macos-x64"), + ("linux", "x86_64") => Some("linux-x64-gnu"), + ("linux", "aarch64") => Some("linux-arm64-gnu"), + ("windows", "x86_64") => Some("windows-x64-msvc"), + _ => None, + } +} diff --git a/src/sdks/rust/src/liboliphaunt/root/template.rs b/src/sdks/rust/src/liboliphaunt/root/template.rs new file mode 100644 index 00000000..a0c723a3 --- /dev/null +++ b/src/sdks/rust/src/liboliphaunt/root/template.rs @@ -0,0 +1,412 @@ +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use fs2::FileExt; + +use super::NativeRuntimeProfile; +use super::files::{ + copy_directory_tree, directory_is_empty, pgdata_template_copy_mode, remove_file_if_exists, +}; +use super::fingerprint::{hash_path, hash_str, new_state}; +use super::runtime::{materialize_runtime, monotonic_cache_nonce, runtime_cache_root}; +use crate::error::{Error, Result}; +use crate::storage::BootstrapStrategy; + +const PGDATA_TEMPLATE_VERSION: &str = "pg18-pgdata-template-v3"; + +pub(super) fn bootstrap_pgdata_if_needed( + profile: NativeRuntimeProfile, + pgdata: &Path, + strategy: &BootstrapStrategy, +) -> Result<()> { + if pgdata.join("PG_VERSION").is_file() { + return Ok(()); + } + + match strategy { + BootstrapStrategy::PackagedTemplate => restore_pgdata_template(profile, pgdata), + BootstrapStrategy::ExistingOnly => Err(Error::Engine(format!( + "native PGDATA at {} has not been bootstrapped", + pgdata.display() + ))), + BootstrapStrategy::InitdbToolingOnly { .. } => Ok(()), + } +} + +fn restore_pgdata_template(profile: NativeRuntimeProfile, pgdata: &Path) -> Result<()> { + let template_pgdata = materialize_pgdata_template(profile)?; + copy_pgdata_template(&template_pgdata, pgdata) +} + +pub(super) fn materialize_pgdata_template(_profile: NativeRuntimeProfile) -> Result { + let bootstrap_runtime = materialize_runtime(NativeRuntimeProfile::PostgresServer, &[])?; + let key = pgdata_template_key(&bootstrap_runtime)?; + let cache_root = runtime_cache_root()?.join("pgdata-templates"); + fs::create_dir_all(&cache_root).map_err(|err| { + Error::Engine(format!( + "create native PGDATA template cache root {}: {err}", + cache_root.display() + )) + })?; + #[cfg(unix)] + fs::set_permissions(&cache_root, fs::Permissions::from_mode(0o700)).map_err(|err| { + Error::Engine(format!( + "set permissions on native PGDATA template cache root {}: {err}", + cache_root.display() + )) + })?; + + let template_dir = cache_root.join(&key); + let lock_path = cache_root.join(format!("{key}.lock")); + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .read(true) + .open(&lock_path) + .map_err(|err| { + Error::Engine(format!( + "open native PGDATA template lock {}: {err}", + lock_path.display() + )) + })?; + lock.lock_exclusive().map_err(|err| { + Error::Engine(format!( + "lock native PGDATA template {}: {err}", + lock_path.display() + )) + })?; + + if !pgdata_template_is_valid(&template_dir, &key) { + let build_dir = cache_root.join(format!( + ".build-{}-{}", + std::process::id(), + monotonic_cache_nonce()? + )); + if build_dir.exists() { + fs::remove_dir_all(&build_dir).map_err(|err| { + Error::Engine(format!( + "remove stale native PGDATA template build dir {}: {err}", + build_dir.display() + )) + })?; + } + fs::create_dir_all(&build_dir).map_err(|err| { + Error::Engine(format!( + "create native PGDATA template build dir {}: {err}", + build_dir.display() + )) + })?; + + let pgdata = build_dir.join("pgdata"); + let build_result = run_template_initdb(&bootstrap_runtime, &pgdata) + .and_then(|()| clean_pgdata_template(&pgdata)) + .and_then(|()| { + fs::write(build_dir.join(".manifest"), pgdata_template_manifest(&key)).map_err( + |err| { + Error::Engine(format!( + "write native PGDATA template manifest {}: {err}", + build_dir.display() + )) + }, + ) + }) + .and_then(|()| { + fs::write(build_dir.join(".complete"), b"ok\n").map_err(|err| { + Error::Engine(format!( + "write native PGDATA template completion marker {}: {err}", + build_dir.display() + )) + }) + }); + + if let Err(error) = build_result { + let _ = fs::remove_dir_all(&build_dir); + return Err(error); + } + if template_dir.exists() { + fs::remove_dir_all(&template_dir).map_err(|err| { + Error::Engine(format!( + "remove invalid native PGDATA template {}: {err}", + template_dir.display() + )) + })?; + } + fs::rename(&build_dir, &template_dir).map_err(|err| { + Error::Engine(format!( + "publish native PGDATA template {} -> {}: {err}", + build_dir.display(), + template_dir.display() + )) + })?; + } + + lock.unlock().map_err(|err| { + Error::Engine(format!( + "unlock native PGDATA template {}: {err}", + lock_path.display() + )) + })?; + Ok(template_dir.join("pgdata")) +} + +fn pgdata_template_key(bootstrap_runtime: &Path) -> Result { + let runtime_manifest = + fs::read_to_string(bootstrap_runtime.join(".manifest")).map_err(|err| { + Error::Engine(format!( + "read native runtime manifest {}: {err}", + bootstrap_runtime.join(".manifest").display() + )) + })?; + let mut state = new_state(); + hash_str(&mut state, PGDATA_TEMPLATE_VERSION); + hash_path(&mut state, bootstrap_runtime); + hash_str(&mut state, &runtime_manifest); + Ok(format!("{state:016x}")) +} + +fn pgdata_template_manifest(key: &str) -> String { + format!("version={PGDATA_TEMPLATE_VERSION}\nkey={key}\n") +} + +fn pgdata_template_is_valid(template_dir: &Path, key: &str) -> bool { + if !template_dir.join(".complete").is_file() + || !template_dir.join("pgdata/PG_VERSION").is_file() + || !template_dir.join("pgdata/global/pg_control").is_file() + { + return false; + } + let Ok(manifest) = fs::read_to_string(template_dir.join(".manifest")) else { + return false; + }; + manifest + .lines() + .any(|line| line == format!("version={PGDATA_TEMPLATE_VERSION}")) + && manifest.lines().any(|line| line == format!("key={key}")) +} + +fn run_template_initdb(runtime_dir: &Path, pgdata: &Path) -> Result<()> { + let initdb = runtime_dir.join("bin/initdb"); + if !initdb.is_file() { + return Err(Error::Engine(format!( + "native PGDATA template bootstrap requires initdb at {}", + initdb.display() + ))); + } + let output = Command::new(&initdb) + .args(template_initdb_args(runtime_dir, pgdata)) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|err| Error::Engine(format!("run native PGDATA template initdb: {err}")))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(Error::Engine(format!( + "native PGDATA template initdb failed with status {}: {}", + output.status, + stderr.trim() + ))) +} + +fn template_initdb_args(runtime_dir: &Path, pgdata: &Path) -> Vec { + vec![ + "-D".into(), + pgdata.as_os_str().to_owned(), + "-U".into(), + "postgres".into(), + "--auth=trust".into(), + "--no-sync".into(), + "--locale=C".into(), + "--encoding=UTF8".into(), + "-L".into(), + runtime_dir.join("share/postgresql").into_os_string(), + ] +} + +fn clean_pgdata_template(pgdata: &Path) -> Result<()> { + for relative in ["postmaster.pid", "postmaster.opts"] { + remove_file_if_exists(&pgdata.join(relative))?; + } + normalize_pgdata_template_conf(pgdata)?; + Ok(()) +} + +fn normalize_pgdata_template_conf(pgdata: &Path) -> Result<()> { + let conf = pgdata.join("postgresql.conf"); + if !conf.is_file() { + return Ok(()); + } + let contents = fs::read_to_string(&conf).map_err(|err| { + Error::Engine(format!( + "read native PGDATA template config {}: {err}", + conf.display() + )) + })?; + let settings = [ + ("dynamic_shared_memory_type", "mmap"), + ("log_timezone", "'UTC'"), + ("timezone", "'UTC'"), + ("lc_messages", "'C'"), + ("lc_monetary", "'C'"), + ("lc_numeric", "'C'"), + ("lc_time", "'C'"), + ]; + let mut seen = vec![false; settings.len()]; + let mut normalized = String::with_capacity(contents.len()); + for line in contents.lines() { + if let Some(index) = settings + .iter() + .position(|(key, _)| active_config_key(line) == Some(*key)) + { + let (key, value) = settings[index]; + normalized.push_str(key); + normalized.push_str(" = "); + normalized.push_str(value); + seen[index] = true; + } else { + normalized.push_str(line); + } + normalized.push('\n'); + } + for (index, (key, value)) in settings.iter().enumerate() { + if !seen[index] { + normalized.push_str(key); + normalized.push_str(" = "); + normalized.push_str(value); + normalized.push('\n'); + } + } + if normalized != contents { + fs::write(&conf, normalized).map_err(|err| { + Error::Engine(format!( + "write native PGDATA template config {}: {err}", + conf.display() + )) + })?; + } + Ok(()) +} + +fn active_config_key(line: &str) -> Option<&str> { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + return None; + } + let (key, _) = trimmed.split_once('=')?; + let key = key.trim_end(); + (!key.is_empty()).then_some(key) +} + +fn copy_pgdata_template(template_pgdata: &Path, pgdata: &Path) -> Result<()> { + if pgdata.join("PG_VERSION").is_file() { + return Ok(()); + } + if pgdata.exists() { + if !directory_is_empty(pgdata)? { + return Err(Error::Engine(format!( + "refusing to bootstrap non-empty native PGDATA without PG_VERSION at {}", + pgdata.display() + ))); + } + fs::remove_dir_all(pgdata).map_err(|err| { + Error::Engine(format!("remove empty PGDATA {}: {err}", pgdata.display())) + })?; + } + let parent = pgdata.parent().ok_or_else(|| { + Error::Engine(format!( + "native PGDATA {} does not have a parent directory", + pgdata.display() + )) + })?; + let staging = parent.join(format!( + ".pgdata-bootstrap-{}-{}", + std::process::id(), + monotonic_cache_nonce()? + )); + if staging.exists() { + fs::remove_dir_all(&staging).map_err(|err| { + Error::Engine(format!( + "remove stale PGDATA bootstrap staging dir {}: {err}", + staging.display() + )) + })?; + } + + let copy_result = copy_directory_tree(template_pgdata, &staging, pgdata_template_copy_mode()); + if let Err(error) = copy_result { + let _ = fs::remove_dir_all(&staging); + let _ = fs::create_dir_all(pgdata); + return Err(error); + } + fs::rename(&staging, pgdata).map_err(|err| { + let _ = fs::remove_dir_all(&staging); + Error::Engine(format!( + "publish native PGDATA bootstrap {} -> {}: {err}", + staging.display(), + pgdata.display() + )) + }) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + use std::fs; + use std::path::Path; + + use super::{normalize_pgdata_template_conf, template_initdb_args}; + + #[test] + fn template_initdb_forces_mobile_safe_locale() { + let args = template_initdb_args(Path::new("/runtime"), Path::new("/cache/template/pgdata")); + + assert!(args.iter().any(|arg| arg == OsStr::new("--locale=C"))); + assert!(args.iter().any(|arg| arg == OsStr::new("--encoding=UTF8"))); + } + + #[test] + fn template_config_normalization_forces_mobile_safe_values() { + let root = std::env::temp_dir().join(format!( + "oliphaunt-template-normalize-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + let conf = root.join("postgresql.conf"); + fs::write( + &conf, + [ + "# dynamic_shared_memory_type = posix", + "dynamic_shared_memory_type = posix", + "log_timezone = 'America/Los_Angeles'", + "timezone = 'America/Los_Angeles'", + "lc_messages = 'en_US.UTF-8'", + "lc_monetary = 'en_US.UTF-8'", + "lc_numeric = 'en_US.UTF-8'", + "lc_time = 'en_US.UTF-8'", + ] + .join("\n"), + ) + .unwrap(); + + normalize_pgdata_template_conf(&root).unwrap(); + + let normalized = fs::read_to_string(&conf).unwrap(); + assert!(normalized.contains("# dynamic_shared_memory_type = posix")); + assert!(normalized.contains("dynamic_shared_memory_type = mmap")); + assert!(normalized.contains("log_timezone = 'UTC'")); + assert!(normalized.contains("timezone = 'UTC'")); + assert!(normalized.contains("lc_messages = 'C'")); + assert!(normalized.contains("lc_monetary = 'C'")); + assert!(normalized.contains("lc_numeric = 'C'")); + assert!(normalized.contains("lc_time = 'C'")); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/sdks/rust/src/lifecycle.rs b/src/sdks/rust/src/lifecycle.rs new file mode 100644 index 00000000..756565c9 --- /dev/null +++ b/src/sdks/rust/src/lifecycle.rs @@ -0,0 +1,78 @@ +/// Options for preparing an opened database for app suspension. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackgroundPreparationOptions { + /// Request cancellation of active work before the app is suspended. + pub cancel_active_work: bool, + /// Run a PostgreSQL checkpoint when the session is idle. + pub checkpoint_when_idle: bool, +} + +impl Default for BackgroundPreparationOptions { + fn default() -> Self { + Self { + cancel_active_work: true, + checkpoint_when_idle: true, + } + } +} + +impl BackgroundPreparationOptions { + /// Create options with the default production behavior. + pub fn new() -> Self { + Self::default() + } + + /// Set whether active work should be cancelled out of band. + pub fn cancel_active_work(mut self, enabled: bool) -> Self { + self.cancel_active_work = enabled; + self + } + + /// Set whether an idle session should checkpoint before suspension. + pub fn checkpoint_when_idle(mut self, enabled: bool) -> Self { + self.checkpoint_when_idle = enabled; + self + } +} + +/// Reason a background checkpoint was intentionally skipped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackgroundCheckpointSkipReason { + /// Work was active when background preparation started. + ActiveWork, + /// The physical PostgreSQL session was pinned by a transaction or explicit + /// session pin. + SessionPinned, +} + +/// Result of preparing an opened database for app suspension. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackgroundPreparationResult { + /// True when an out-of-band cancellation request was sent for active work. + pub cancelled_active_work: bool, + /// True when a checkpoint completed. + pub checkpointed: bool, + /// Reason checkpointing was skipped. + pub skipped_checkpoint_reason: Option, +} + +impl BackgroundPreparationResult { + pub(crate) fn checkpointed() -> Self { + Self { + cancelled_active_work: false, + checkpointed: true, + skipped_checkpoint_reason: None, + } + } + + pub(crate) fn skipped( + cancelled_active_work: bool, + reason: Option, + ) -> Self { + Self { + cancelled_active_work, + checkpointed: false, + skipped_checkpoint_reason: reason, + } + } +} diff --git a/src/sdks/rust/src/performance.rs b/src/sdks/rust/src/performance.rs new file mode 100644 index 00000000..d0ff3ee8 --- /dev/null +++ b/src/sdks/rust/src/performance.rs @@ -0,0 +1,100 @@ +/// Benchmark targets used by the native release contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BenchmarkTarget { + /// Native direct Rust SDK. + NativeDirect, + /// Native broker mode. + NativeBroker, + /// Native server mode. + NativeServer, + /// SQLite comparison target. + Sqlite, +} + +/// Metrics tracked before native defaults are allowed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BenchmarkMetric { + /// Warm open time. + WarmOpen, + /// Cold open time. + ColdOpen, + /// Direct simple-query round trip. + SimpleQueryRtt, + /// Typed query overhead. + TypedQueryOverhead, + /// Batched write throughput. + BatchedWrites, + /// Large result streaming throughput. + LargeResultStreaming, + /// Backup and restore latency. + BackupRestore, + /// Resident memory footprint. + MemoryFootprint, + /// Packaged artifact size. + ArtifactSize, + /// Crash recovery latency. + CrashRecovery, +} + +/// Comparison operator for a performance gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PerformanceOperator { + /// Lower values are better and must be at or below the threshold. + LessThanOrEqual, + /// Higher values are better and must be at or above the threshold. + GreaterThanOrEqual, +} + +/// One release/performance gate. +#[derive(Debug, Clone, PartialEq)] +pub struct PerformanceGate { + /// Metric being gated. + pub metric: BenchmarkMetric, + /// Target being measured. + pub target: BenchmarkTarget, + /// Operator used against `threshold`. + pub operator: PerformanceOperator, + /// Numeric threshold in the metric's documented unit. + pub threshold: f64, + /// Unit for the threshold, for example `ms`, `ops/s`, or `bytes`. + pub unit: &'static str, +} + +/// Set of gates required before a runtime becomes a default. +#[derive(Debug, Clone, PartialEq)] +pub struct PerformanceGateSet { + /// Gates in evaluation order. + pub gates: Vec, +} + +impl PerformanceGateSet { + /// Baseline native-direct gates. These are intentionally explicit so CI + /// can later attach real measurements without inventing policy. + pub fn native_direct_release_baseline() -> Self { + Self { + gates: vec![ + PerformanceGate { + metric: BenchmarkMetric::WarmOpen, + target: BenchmarkTarget::NativeDirect, + operator: PerformanceOperator::LessThanOrEqual, + threshold: 75.0, + unit: "ms", + }, + PerformanceGate { + metric: BenchmarkMetric::SimpleQueryRtt, + target: BenchmarkTarget::NativeDirect, + operator: PerformanceOperator::LessThanOrEqual, + threshold: 3.0, + unit: "ms", + }, + PerformanceGate { + metric: BenchmarkMetric::LargeResultStreaming, + target: BenchmarkTarget::NativeDirect, + operator: PerformanceOperator::GreaterThanOrEqual, + threshold: 1.0, + unit: "baseline", + }, + ], + } + } +} diff --git a/src/sdks/rust/src/pgwire.rs b/src/sdks/rust/src/pgwire.rs new file mode 100644 index 00000000..82069cfe --- /dev/null +++ b/src/sdks/rust/src/pgwire.rs @@ -0,0 +1,443 @@ +use std::io::{self, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpStream}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use crate::error::{Error, Result}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; + +const PROTOCOL_VERSION_3: i32 = 196_608; +const CANCEL_REQUEST_CODE: i32 = 80_877_102; +const POSTGRES_WIRE_READ_BUFFER: usize = 64 * 1024; +const DUPLEX_RAW_REQUEST_THRESHOLD: usize = 256 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct BackendKeyData { + process_id: i32, + secret_key: i32, +} + +trait PostgresStream: Read + Write + Send { + fn try_clone_stream(&self) -> io::Result>; + fn set_stream_timeouts( + &self, + read_timeout: Option, + write_timeout: Option, + ) -> io::Result<()>; +} + +impl PostgresStream for TcpStream { + fn try_clone_stream(&self) -> io::Result> { + self.try_clone() + .map(|stream| Box::new(stream) as Box) + } + + fn set_stream_timeouts( + &self, + read_timeout: Option, + write_timeout: Option, + ) -> io::Result<()> { + self.set_read_timeout(read_timeout)?; + self.set_write_timeout(write_timeout) + } +} + +#[cfg(unix)] +impl PostgresStream for UnixStream { + fn try_clone_stream(&self) -> io::Result> { + self.try_clone() + .map(|stream| Box::new(stream) as Box) + } + + fn set_stream_timeouts( + &self, + read_timeout: Option, + write_timeout: Option, + ) -> io::Result<()> { + self.set_read_timeout(read_timeout)?; + self.set_write_timeout(write_timeout) + } +} + +pub(crate) struct PostgresWireClient { + stream: BufReader>, + endpoint: PostgresEndpoint, + backend_key: BackendKeyData, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PostgresCancelToken { + endpoint: PostgresEndpoint, + backend_key: BackendKeyData, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PostgresEndpoint { + Tcp(SocketAddr), + #[cfg(unix)] + Unix(PathBuf), +} + +impl PostgresCancelToken { + pub(crate) fn cancel(&self, connect_timeout: Duration, io_timeout: Duration) -> Result<()> { + send_cancel_request( + &self.endpoint, + self.backend_key, + connect_timeout, + io_timeout, + ) + } +} + +impl PostgresWireClient { + pub(crate) fn connect_endpoint( + endpoint: PostgresEndpoint, + user: &str, + database: &str, + connect_timeout: Duration, + io_timeout: Duration, + ) -> Result { + let mut stream = BufReader::with_capacity( + POSTGRES_WIRE_READ_BUFFER, + connect_stream(&endpoint, connect_timeout, io_timeout)?, + ); + write_startup_message(stream.get_mut().as_mut(), user, database)?; + let mut backend_key = None; + read_until_ready(&mut stream, false, true, Some(&mut backend_key))?; + stream + .get_ref() + .as_ref() + .set_stream_timeouts(None, None) + .map_err(|err| { + Error::Engine(format!( + "clear steady-state native server protocol socket timeouts: {err}" + )) + })?; + let backend_key = backend_key.ok_or_else(|| { + Error::Engine("native server did not return BackendKeyData during startup".to_owned()) + })?; + Ok(Self { + stream, + endpoint, + backend_key, + }) + } + + pub(crate) fn exec_protocol_raw( + &mut self, + request: ProtocolRequest, + ) -> Result { + if request.as_bytes().len() >= DUPLEX_RAW_REQUEST_THRESHOLD { + return self.exec_protocol_raw_duplex(request); + } + self.exec_protocol_raw_sequential(request) + } + + fn exec_protocol_raw_sequential( + &mut self, + request: ProtocolRequest, + ) -> Result { + write_protocol_request(self.stream.get_mut().as_mut(), request.as_bytes())?; + let bytes = read_until_ready(&mut self.stream, true, false, None)?; + Ok(ProtocolResponse::new(bytes)) + } + + fn exec_protocol_raw_duplex(&mut self, request: ProtocolRequest) -> Result { + if !self.stream.buffer().is_empty() { + return self.exec_protocol_raw_sequential(request); + } + let reader_stream = self + .stream + .get_ref() + .as_ref() + .try_clone_stream() + .map_err(|err| Error::Engine(format!("clone native server protocol stream: {err}")))?; + let reader = thread::Builder::new() + .name("liboliphaunt-native-server-reader".to_owned()) + .spawn(move || { + let mut reader = BufReader::with_capacity(POSTGRES_WIRE_READ_BUFFER, reader_stream); + read_until_ready(&mut reader, true, false, None) + }) + .map_err(|err| Error::Engine(format!("spawn native server protocol reader: {err}")))?; + + let write_result = + write_protocol_request(self.stream.get_mut().as_mut(), request.as_bytes()); + let read_result = reader + .join() + .map_err(|_| Error::Engine("native server protocol reader panicked".to_owned()))?; + + write_result?; + let bytes = read_result?; + Ok(ProtocolResponse::new(bytes)) + } + + pub(crate) fn exec_protocol_stream( + &mut self, + request: ProtocolRequest, + mut on_chunk: impl FnMut(&[u8]) -> Result<()>, + ) -> Result<()> { + write_protocol_request(self.stream.get_mut().as_mut(), request.as_bytes())?; + read_until_ready_stream(&mut self.stream, false, &mut on_chunk) + } + + pub(crate) fn terminate(&mut self) -> Result<()> { + let stream = self.stream.get_mut(); + stream + .write_all(&[b'X', 0, 0, 0, 4]) + .and_then(|()| stream.flush()) + .map_err(|err| Error::Engine(format!("terminate native server connection: {err}"))) + } + + pub(crate) fn cancel_token(&self) -> PostgresCancelToken { + PostgresCancelToken { + endpoint: self.endpoint.clone(), + backend_key: self.backend_key, + } + } +} + +fn write_protocol_request(stream: &mut dyn Write, bytes: &[u8]) -> Result<()> { + stream + .write_all(bytes) + .and_then(|()| stream.flush()) + .map_err(|err| Error::Engine(format!("write native server protocol request: {err}"))) +} + +fn connect_stream( + endpoint: &PostgresEndpoint, + connect_timeout: Duration, + io_timeout: Duration, +) -> Result> { + match endpoint { + PostgresEndpoint::Tcp(addr) => { + let stream = connect_tcp_stream(*addr, connect_timeout, io_timeout)?; + Ok(Box::new(stream)) + } + #[cfg(unix)] + PostgresEndpoint::Unix(path) => { + let stream = UnixStream::connect(path).map_err(|err| { + Error::Engine(format!( + "connect to native server socket {}: {err}", + path.display() + )) + })?; + stream.set_read_timeout(Some(io_timeout)).map_err(|err| { + Error::Engine(format!( + "set native server socket read timeout {}: {err}", + path.display() + )) + })?; + stream.set_write_timeout(Some(io_timeout)).map_err(|err| { + Error::Engine(format!( + "set native server socket write timeout {}: {err}", + path.display() + )) + })?; + Ok(Box::new(stream)) + } + } +} + +fn connect_tcp_stream( + addr: SocketAddr, + connect_timeout: Duration, + io_timeout: Duration, +) -> Result { + let stream = TcpStream::connect_timeout(&addr, connect_timeout) + .map_err(|err| Error::Engine(format!("connect to native server {addr}: {err}")))?; + stream + .set_nodelay(true) + .map_err(|err| Error::Engine(format!("set TCP_NODELAY on native server: {err}")))?; + stream + .set_read_timeout(Some(io_timeout)) + .map_err(|err| Error::Engine(format!("set native server read timeout: {err}")))?; + stream + .set_write_timeout(Some(io_timeout)) + .map_err(|err| Error::Engine(format!("set native server write timeout: {err}")))?; + Ok(stream) +} + +fn write_startup_message(stream: &mut dyn Write, user: &str, database: &str) -> Result<()> { + let mut body = Vec::new(); + body.extend_from_slice(&PROTOCOL_VERSION_3.to_be_bytes()); + push_cstr(&mut body, "user"); + push_cstr(&mut body, user); + push_cstr(&mut body, "database"); + push_cstr(&mut body, database); + body.push(0); + + let total_len = i32::try_from(body.len() + 4) + .map_err(|_| Error::Engine("startup message is too large".to_owned()))?; + let mut packet = Vec::with_capacity(body.len() + 4); + packet.extend_from_slice(&total_len.to_be_bytes()); + packet.extend_from_slice(&body); + stream + .write_all(&packet) + .map_err(|err| Error::Engine(format!("write native server startup message: {err}"))) +} + +fn push_cstr(out: &mut Vec, value: &str) { + out.extend_from_slice(value.as_bytes()); + out.push(0); +} + +fn read_until_ready( + stream: &mut dyn Read, + include_messages: bool, + error_is_fatal: bool, + backend_key: Option<&mut Option>, +) -> Result> { + let mut out = Vec::new(); + read_until_ready_stream_with_key( + stream, + error_is_fatal, + &mut |frame| { + if include_messages { + out.extend_from_slice(frame); + } + Ok(()) + }, + backend_key, + )?; + Ok(out) +} + +fn read_until_ready_stream( + stream: &mut dyn Read, + error_is_fatal: bool, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, +) -> Result<()> { + read_until_ready_stream_with_key(stream, error_is_fatal, on_chunk, None) +} + +fn read_until_ready_stream_with_key( + stream: &mut dyn Read, + error_is_fatal: bool, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, + mut backend_key: Option<&mut Option>, +) -> Result<()> { + let mut callback_error = None; + let mut frame = Vec::with_capacity(8192); + loop { + frame.resize(5, 0); + stream.read_exact(&mut frame[..5]).map_err(|err| { + Error::Engine(format!("read native server protocol message header: {err}")) + })?; + let tag = frame[0]; + let len = i32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]]); + if len < 4 { + return Err(Error::Engine(format!( + "native server returned invalid message length {len}" + ))); + } + let body_len = (len as usize).saturating_sub(4); + frame.resize(5 + body_len, 0); + stream.read_exact(&mut frame[5..]).map_err(|err| { + Error::Engine(format!("read native server protocol message body: {err}")) + })?; + let body = &frame[5..]; + if callback_error.is_none() + && let Err(error) = on_chunk(&frame) + { + callback_error = Some(error); + } + + match tag { + b'R' => handle_authentication(body)?, + b'K' => { + if let Some(target) = backend_key.as_deref_mut() { + *target = Some(parse_backend_key_data(body)?); + } + } + b'E' if error_is_fatal => return Err(Error::Engine(parse_error_response(body))), + b'Z' => return callback_error.map_or(Ok(()), Err), + _ => {} + } + } +} + +fn parse_backend_key_data(body: &[u8]) -> Result { + if body.len() != 8 { + return Err(Error::Engine(format!( + "native server returned invalid BackendKeyData length {}", + body.len() + ))); + } + Ok(BackendKeyData { + process_id: i32::from_be_bytes([body[0], body[1], body[2], body[3]]), + secret_key: i32::from_be_bytes([body[4], body[5], body[6], body[7]]), + }) +} + +fn send_cancel_request( + endpoint: &PostgresEndpoint, + backend_key: BackendKeyData, + connect_timeout: Duration, + io_timeout: Duration, +) -> Result<()> { + let mut stream = connect_stream(endpoint, connect_timeout, io_timeout)?; + let mut packet = Vec::with_capacity(16); + packet.extend_from_slice(&16_i32.to_be_bytes()); + packet.extend_from_slice(&CANCEL_REQUEST_CODE.to_be_bytes()); + packet.extend_from_slice(&backend_key.process_id.to_be_bytes()); + packet.extend_from_slice(&backend_key.secret_key.to_be_bytes()); + stream + .write_all(&packet) + .and_then(|()| stream.flush()) + .map_err(|err| Error::Engine(format!("write native server cancel request: {err}"))) +} + +fn handle_authentication(body: &[u8]) -> Result<()> { + if body.len() < 4 { + return Err(Error::Engine( + "native server returned truncated authentication message".to_owned(), + )); + } + let method = i32::from_be_bytes([body[0], body[1], body[2], body[3]]); + if method == 0 { + Ok(()) + } else { + Err(Error::Engine(format!( + "native server requested unsupported authentication method {method}" + ))) + } +} + +fn parse_error_response(body: &[u8]) -> String { + let mut message = None; + for field in body + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + { + if field[0] == b'M' { + message = Some(String::from_utf8_lossy(&field[1..]).into_owned()); + break; + } + } + message.unwrap_or_else(|| "native server returned an error response".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_backend_key_data() { + let key = parse_backend_key_data(&[0, 0, 0, 7, 0, 0, 0, 11]).unwrap(); + assert_eq!( + key, + BackendKeyData { + process_id: 7, + secret_key: 11, + } + ); + } + + #[test] + fn rejects_malformed_backend_key_data() { + assert!(parse_backend_key_data(&[0, 1, 2]).is_err()); + } +} diff --git a/src/sdks/rust/src/protocol.rs b/src/sdks/rust/src/protocol.rs new file mode 100644 index 00000000..2f546f47 --- /dev/null +++ b/src/sdks/rust/src/protocol.rs @@ -0,0 +1,107 @@ +use crate::error::{Error, Result}; + +/// Raw PostgreSQL frontend protocol bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolRequest { + bytes: Vec, +} + +impl ProtocolRequest { + /// Create a raw protocol request. + pub fn new(bytes: impl Into>) -> Self { + Self { + bytes: bytes.into(), + } + } + + /// Create a PostgreSQL simple-query protocol request. + pub fn simple_query(sql: &str) -> Result { + if sql.as_bytes().contains(&0) { + return Err(Error::Engine( + "simple query SQL must not contain NUL bytes".to_owned(), + )); + } + let mut body = Vec::new(); + body.extend_from_slice(sql.as_bytes()); + body.push(0); + + let len = i32::try_from(body.len() + 4) + .map_err(|_| Error::Engine("simple query protocol message is too large".to_owned()))?; + let mut packet = Vec::with_capacity(body.len() + 5); + packet.push(b'Q'); + packet.extend_from_slice(&len.to_be_bytes()); + packet.extend_from_slice(&body); + Ok(Self { bytes: packet }) + } + + /// Borrow the raw bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// Consume into raw bytes. + pub fn into_bytes(self) -> Vec { + self.bytes + } +} + +impl From> for ProtocolRequest { + fn from(bytes: Vec) -> Self { + Self::new(bytes) + } +} + +impl From<&[u8]> for ProtocolRequest { + fn from(bytes: &[u8]) -> Self { + Self::new(bytes) + } +} + +/// Raw PostgreSQL backend protocol bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolResponse { + bytes: Vec, +} + +impl ProtocolResponse { + /// Create a raw protocol response. + pub fn new(bytes: impl Into>) -> Self { + Self { + bytes: bytes.into(), + } + } + + /// Borrow the raw bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// Consume into raw bytes. + pub fn into_bytes(self) -> Vec { + self.bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_query_rejects_nul_sql_before_building_protocol() { + assert_eq!( + ProtocolRequest::simple_query("SELECT 1\0SELECT 2").unwrap_err(), + Error::Engine("simple query SQL must not contain NUL bytes".to_owned()) + ); + } + + #[test] + fn simple_query_builds_cstring_frontend_frame() { + let request = ProtocolRequest::simple_query("SELECT 1").unwrap(); + assert_eq!( + request.as_bytes(), + &[ + b'Q', 0, 0, 0, 13, b'S', b'E', b'L', b'E', b'C', b'T', b' ', b'1', 0 + ] + ); + } +} diff --git a/src/sdks/rust/src/query.rs b/src/sdks/rust/src/query.rs new file mode 100644 index 00000000..5d1eb6ed --- /dev/null +++ b/src/sdks/rust/src/query.rs @@ -0,0 +1,1004 @@ +use std::str; + +use crate::error::{Error, Result, parse_postgres_error_response}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; + +/// Parameter value for a PostgreSQL extended-query execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueryParam { + /// SQL `NULL`. + Null, + /// Text-format parameter value. + Text(String), + /// Binary-format parameter value. + Binary(Vec), +} + +impl QueryParam { + /// Construct a text parameter. + pub fn text(value: impl Into) -> Self { + Self::Text(value.into()) + } + + /// Construct a binary parameter. + pub fn binary(value: impl Into>) -> Self { + Self::Binary(value.into()) + } +} + +impl From<&str> for QueryParam { + fn from(value: &str) -> Self { + Self::Text(value.to_owned()) + } +} + +impl From for QueryParam { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From<&String> for QueryParam { + fn from(value: &String) -> Self { + Self::Text(value.clone()) + } +} + +impl From for QueryParam { + fn from(value: i16) -> Self { + Self::Text(value.to_string()) + } +} + +impl From for QueryParam { + fn from(value: i32) -> Self { + Self::Text(value.to_string()) + } +} + +impl From for QueryParam { + fn from(value: i64) -> Self { + Self::Text(value.to_string()) + } +} + +impl From for QueryParam { + fn from(value: f32) -> Self { + Self::Text(value.to_string()) + } +} + +impl From for QueryParam { + fn from(value: f64) -> Self { + Self::Text(value.to_string()) + } +} + +impl From for QueryParam { + fn from(value: bool) -> Self { + Self::Text(if value { "true" } else { "false" }.to_owned()) + } +} + +impl From<&[u8]> for QueryParam { + fn from(value: &[u8]) -> Self { + Self::Binary(value.to_vec()) + } +} + +impl From> for QueryParam { + fn from(value: Vec) -> Self { + Self::Binary(value) + } +} + +impl From> for QueryParam +where + T: Into, +{ + fn from(value: Option) -> Self { + value.map(Into::into).unwrap_or(Self::Null) + } +} + +/// Result of a PostgreSQL simple-query execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryResult { + fields: Vec, + rows: Vec, + command_tag: Option, +} + +impl QueryResult { + /// Field metadata in result-column order. + pub fn fields(&self) -> &[QueryField] { + &self.fields + } + + /// Rows returned by the query. + pub fn rows(&self) -> &[QueryRow] { + &self.rows + } + + /// PostgreSQL command tag returned by the last command in the query. + pub fn command_tag(&self) -> Option<&str> { + self.command_tag.as_deref() + } + + /// Number of rows returned by the query. + pub fn row_count(&self) -> usize { + self.rows.len() + } + + /// Return the index for a column name. + pub fn field_index(&self, name: &str) -> Option { + self.fields.iter().position(|field| field.name == name) + } + + /// Read a text-format value by row index and column name. + pub fn get_text(&self, row: usize, column: &str) -> Result> { + let column = self + .field_index(column) + .ok_or_else(|| Error::Engine(format!("query result has no column named {column:?}")))?; + let row = self + .rows + .get(row) + .ok_or_else(|| Error::Engine(format!("query result has no row at index {row}")))?; + row.text(column) + } +} + +/// Metadata for one PostgreSQL result column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryField { + /// Column name. + pub name: String, + /// Table OID reported by PostgreSQL, or `0` when not tied to a table. + pub table_oid: u32, + /// Table attribute number reported by PostgreSQL. + pub table_attribute: i16, + /// PostgreSQL type OID. + pub type_oid: u32, + /// PostgreSQL type size. + pub type_size: i16, + /// PostgreSQL type modifier. + pub type_modifier: i32, + /// Format used for values in this column. + pub format: QueryFormat, +} + +/// PostgreSQL result-column value format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryFormat { + /// Text format. + Text, + /// Binary format. + Binary, + /// Unknown or extension format code. + Other(i16), +} + +impl From for QueryFormat { + fn from(value: i16) -> Self { + match value { + 0 => Self::Text, + 1 => Self::Binary, + other => Self::Other(other), + } + } +} + +/// One PostgreSQL query row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryRow { + values: Vec>>, +} + +impl QueryRow { + /// Raw column values in result-column order. + pub fn values(&self) -> &[Option>] { + &self.values + } + + /// Read a text-format value by column index. + pub fn text(&self, column: usize) -> Result> { + let value = self + .values + .get(column) + .ok_or_else(|| Error::Engine(format!("query row has no column at index {column}")))?; + value + .as_deref() + .map(|bytes| { + str::from_utf8(bytes) + .map_err(|err| Error::Engine(format!("query value is not valid UTF-8: {err}"))) + }) + .transpose() + } +} + +/// Parse a simple-query backend response into a single result set. +/// +/// This parser intentionally supports the normal simple-query shape used by +/// the Rust SDK `query()` API: zero or one row-producing statement followed by +/// `ReadyForQuery`. Multi-result-set and COPY responses should use +/// `exec_protocol_raw` or streaming APIs instead. +pub fn parse_query_response(response: &ProtocolResponse) -> Result { + parse_query_response_bytes(response.as_bytes()) +} + +pub(crate) fn extended_query_request(sql: &str, params: I) -> Result +where + I: IntoIterator, + P: Into, +{ + if sql.as_bytes().contains(&0) { + return Err(Error::Engine( + "extended query SQL must not contain NUL bytes".to_owned(), + )); + } + let params = params.into_iter().map(Into::into).collect::>(); + if params.len() > i16::MAX as usize { + return Err(Error::Engine(format!( + "extended query supports at most {} parameters, got {}", + i16::MAX, + params.len() + ))); + } + + let mut packet = Vec::new(); + push_parse(&mut packet, sql)?; + push_bind(&mut packet, ¶ms)?; + push_describe_portal(&mut packet)?; + push_execute(&mut packet)?; + push_sync(&mut packet)?; + Ok(ProtocolRequest::new(packet)) +} + +pub(crate) fn parse_query_response_bytes(bytes: &[u8]) -> Result { + let mut input = bytes; + let mut fields: Option> = None; + let mut rows = Vec::new(); + let mut command_tag = None; + let mut saw_ready = false; + + while !input.is_empty() { + let (tag, body, rest) = read_backend_message(input)?; + input = rest; + match tag { + b'T' => { + if fields.is_some() { + return Err(Error::Engine( + "query() received multiple result sets; use exec_protocol_raw for multi-statement row results" + .to_owned(), + )); + } + fields = Some(parse_row_description(body)?); + } + b'D' => { + let field_count = fields + .as_ref() + .ok_or_else(|| { + Error::Engine("DataRow arrived before RowDescription".to_owned()) + })? + .len(); + rows.push(parse_data_row(body, field_count)?); + } + b'C' => { + command_tag = Some(parse_command_complete(body)?); + } + b'E' => return Err(Error::Postgres(parse_postgres_error_response(body))), + b'G' | b'H' | b'W' | b'd' | b'c' => { + return Err(Error::Engine( + "query() does not support COPY protocol responses; use exec_protocol_raw_stream" + .to_owned(), + )); + } + b'Z' => { + validate_ready_for_query(body)?; + saw_ready = true; + if !input.is_empty() { + return Err(Error::Engine( + "backend returned bytes after ReadyForQuery".to_owned(), + )); + } + } + b'1' => require_empty_backend_message(body, "ParseComplete")?, + b'2' => require_empty_backend_message(body, "BindComplete")?, + b'3' => require_empty_backend_message(body, "CloseComplete")?, + b'I' => require_empty_backend_message(body, "EmptyQueryResponse")?, + b'n' => require_empty_backend_message(body, "NoData")?, + b'S' => validate_parameter_status(body)?, + b'N' => validate_field_response(body, "NoticeResponse")?, + b'A' => validate_notification_response(body)?, + _ => { + return Err(Error::Engine(format!( + "query() received unexpected backend message tag 0x{tag:02x}" + ))); + } + } + } + + if !saw_ready { + return Err(Error::Engine( + "query response ended before ReadyForQuery".to_owned(), + )); + } + + Ok(QueryResult { + fields: fields.unwrap_or_default(), + rows, + command_tag, + }) +} + +fn push_parse(out: &mut Vec, sql: &str) -> Result<()> { + let mut body = Vec::new(); + push_cstring(&mut body, "")?; + push_cstring(&mut body, sql)?; + body.extend_from_slice(&0_i16.to_be_bytes()); + push_frontend_message(out, b'P', &body) +} + +fn push_bind(out: &mut Vec, params: &[QueryParam]) -> Result<()> { + let mut body = Vec::new(); + push_cstring(&mut body, "")?; + push_cstring(&mut body, "")?; + + body.extend_from_slice(&(params.len() as i16).to_be_bytes()); + for param in params { + let format = match param { + QueryParam::Binary(_) => 1_i16, + QueryParam::Null | QueryParam::Text(_) => 0_i16, + }; + body.extend_from_slice(&format.to_be_bytes()); + } + + body.extend_from_slice(&(params.len() as i16).to_be_bytes()); + for param in params { + match param { + QueryParam::Null => body.extend_from_slice(&(-1_i32).to_be_bytes()), + QueryParam::Text(value) => { + push_sized_value(&mut body, value.as_bytes())?; + } + QueryParam::Binary(value) => { + push_sized_value(&mut body, value)?; + } + } + } + + body.extend_from_slice(&1_i16.to_be_bytes()); + body.extend_from_slice(&0_i16.to_be_bytes()); + push_frontend_message(out, b'B', &body) +} + +fn push_describe_portal(out: &mut Vec) -> Result<()> { + let mut body = Vec::new(); + body.push(b'P'); + push_cstring(&mut body, "")?; + push_frontend_message(out, b'D', &body) +} + +fn push_execute(out: &mut Vec) -> Result<()> { + let mut body = Vec::new(); + push_cstring(&mut body, "")?; + body.extend_from_slice(&0_i32.to_be_bytes()); + push_frontend_message(out, b'E', &body) +} + +fn push_sync(out: &mut Vec) -> Result<()> { + push_frontend_message(out, b'S', &[]) +} + +fn push_frontend_message(out: &mut Vec, tag: u8, body: &[u8]) -> Result<()> { + let len = i32::try_from(body.len() + 4) + .map_err(|_| Error::Engine("frontend protocol message is too large".to_owned()))?; + out.push(tag); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(body); + Ok(()) +} + +fn push_cstring(out: &mut Vec, value: &str) -> Result<()> { + if value.as_bytes().contains(&0) { + return Err(Error::Engine( + "frontend protocol string must not contain NUL bytes".to_owned(), + )); + } + out.extend_from_slice(value.as_bytes()); + out.push(0); + Ok(()) +} + +fn push_sized_value(out: &mut Vec, value: &[u8]) -> Result<()> { + let len = i32::try_from(value.len()) + .map_err(|_| Error::Engine("query parameter is too large".to_owned()))?; + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(value); + Ok(()) +} + +fn read_backend_message(bytes: &[u8]) -> Result<(u8, &[u8], &[u8])> { + if bytes.len() < 5 { + return Err(Error::Engine("truncated backend message header".to_owned())); + } + let tag = bytes[0]; + let len = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + if len < 4 { + return Err(Error::Engine(format!( + "invalid backend message length {len}" + ))); + } + let total = 1usize + .checked_add(len as usize) + .ok_or_else(|| Error::Engine("backend message length overflow".to_owned()))?; + if bytes.len() < total { + return Err(Error::Engine("truncated backend message body".to_owned())); + } + Ok((tag, &bytes[5..total], &bytes[total..])) +} + +fn parse_row_description(mut body: &[u8]) -> Result> { + let count = read_i16(&mut body, "RowDescription field count")?; + if count < 0 { + return Err(Error::Engine(format!( + "invalid RowDescription field count {count}" + ))); + } + let mut fields = Vec::with_capacity(count as usize); + for _ in 0..count { + let name = read_cstring(&mut body, "field name")?.to_owned(); + fields.push(QueryField { + name, + table_oid: read_u32(&mut body, "field table oid")?, + table_attribute: read_i16(&mut body, "field table attribute")?, + type_oid: read_u32(&mut body, "field type oid")?, + type_size: read_i16(&mut body, "field type size")?, + type_modifier: read_i32(&mut body, "field type modifier")?, + format: QueryFormat::from(read_i16(&mut body, "field format")?), + }); + } + if !body.is_empty() { + return Err(Error::Engine( + "RowDescription contained trailing bytes".to_owned(), + )); + } + Ok(fields) +} + +fn parse_data_row(mut body: &[u8], expected_columns: usize) -> Result { + let count = read_i16(&mut body, "DataRow column count")?; + if count < 0 { + return Err(Error::Engine(format!( + "invalid DataRow column count {count}" + ))); + } + if count as usize != expected_columns { + return Err(Error::Engine(format!( + "DataRow column count {count} does not match RowDescription count {expected_columns}" + ))); + } + let mut values = Vec::with_capacity(count as usize); + for _ in 0..count { + let len = read_i32(&mut body, "DataRow value length")?; + if len == -1 { + values.push(None); + continue; + } + if len < 0 { + return Err(Error::Engine(format!("invalid DataRow value length {len}"))); + } + let len = len as usize; + if body.len() < len { + return Err(Error::Engine("truncated DataRow value".to_owned())); + } + values.push(Some(body[..len].to_vec())); + body = &body[len..]; + } + if !body.is_empty() { + return Err(Error::Engine("DataRow contained trailing bytes".to_owned())); + } + Ok(QueryRow { values }) +} + +fn parse_command_complete(body: &[u8]) -> Result { + let mut body = body; + let tag = read_cstring(&mut body, "CommandComplete tag")?.to_owned(); + if !body.is_empty() { + return Err(Error::Engine( + "CommandComplete contained trailing bytes".to_owned(), + )); + } + Ok(tag) +} + +fn require_empty_backend_message(body: &[u8], label: &str) -> Result<()> { + if body.is_empty() { + return Ok(()); + } + Err(Error::Engine(format!("{label} contained trailing bytes"))) +} + +fn validate_ready_for_query(body: &[u8]) -> Result<()> { + match body { + [b'I' | b'T' | b'E'] => Ok(()), + [status] => Err(Error::Engine(format!( + "ReadyForQuery contained invalid transaction status 0x{status:02x}" + ))), + _ => Err(Error::Engine(format!( + "ReadyForQuery contained {} bytes, expected 1", + body.len() + ))), + } +} + +fn validate_parameter_status(mut body: &[u8]) -> Result<()> { + read_cstring(&mut body, "ParameterStatus name")?; + read_cstring(&mut body, "ParameterStatus value")?; + if !body.is_empty() { + return Err(Error::Engine( + "ParameterStatus contained trailing bytes".to_owned(), + )); + } + Ok(()) +} + +fn validate_notification_response(mut body: &[u8]) -> Result<()> { + read_i32(&mut body, "NotificationResponse process id")?; + read_cstring(&mut body, "NotificationResponse channel")?; + read_cstring(&mut body, "NotificationResponse payload")?; + if !body.is_empty() { + return Err(Error::Engine( + "NotificationResponse contained trailing bytes".to_owned(), + )); + } + Ok(()) +} + +fn validate_field_response(mut body: &[u8], label: &str) -> Result<()> { + loop { + let Some((&code, rest)) = body.split_first() else { + return Err(Error::Engine(format!("{label} is missing terminator"))); + }; + body = rest; + if code == 0 { + if !body.is_empty() { + return Err(Error::Engine(format!("{label} contained trailing bytes"))); + } + return Ok(()); + } + read_cstring(&mut body, &format!("{label} field"))?; + } +} + +fn read_u32(input: &mut &[u8], label: &str) -> Result { + let bytes = take(input, 4, label)?; + Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +fn read_i32(input: &mut &[u8], label: &str) -> Result { + let bytes = take(input, 4, label)?; + Ok(i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +fn read_i16(input: &mut &[u8], label: &str) -> Result { + let bytes = take(input, 2, label)?; + Ok(i16::from_be_bytes([bytes[0], bytes[1]])) +} + +fn read_cstring<'a>(input: &mut &'a [u8], label: &str) -> Result<&'a str> { + let nul = input + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| Error::Engine(format!("{label} is missing null terminator")))?; + let raw = &input[..nul]; + let value = str::from_utf8(raw) + .map_err(|err| Error::Engine(format!("{label} is not valid UTF-8: {err}")))?; + *input = &input[nul + 1..]; + Ok(value) +} + +fn take<'a>(input: &mut &'a [u8], len: usize, label: &str) -> Result<&'a [u8]> { + if input.len() < len { + return Err(Error::Engine(format!("truncated {label}"))); + } + let (head, tail) = input.split_at(len); + *input = tail; + Ok(head) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_simple_query_result() { + let mut bytes = Vec::new(); + push_row_description(&mut bytes, &[("value", 23), ("empty", 25)]); + push_data_row(&mut bytes, &[Some("1"), None]); + push_command_complete(&mut bytes, "SELECT 1"); + push_ready_for_query(&mut bytes); + + let result = parse_query_response_bytes(&bytes).unwrap(); + assert_eq!(result.fields()[0].name, "value"); + assert_eq!(result.fields()[0].type_oid, 23); + assert_eq!(result.row_count(), 1); + assert_eq!(result.command_tag(), Some("SELECT 1")); + assert_eq!(result.get_text(0, "value").unwrap(), Some("1")); + assert_eq!(result.get_text(0, "empty").unwrap(), None); + } + + #[test] + fn returns_sql_errors_as_errors() { + let mut bytes = Vec::new(); + push_error_response(&mut bytes, "ERROR", "42P01", "relation does not exist"); + push_ready_for_query(&mut bytes); + + let error = parse_query_response_bytes(&bytes).unwrap_err(); + let Error::Postgres(postgres) = error else { + panic!("expected structured PostgreSQL error, got {error:?}"); + }; + assert_eq!(postgres.severity.as_deref(), Some("ERROR")); + assert_eq!(postgres.sqlstate.as_deref(), Some("42P01")); + assert_eq!(postgres.message, "relation does not exist"); + } + + #[test] + fn returns_query_cancellation_as_structured_postgres_error() { + let mut bytes = Vec::new(); + push_error_response( + &mut bytes, + "ERROR", + "57014", + "canceling statement due to user request", + ); + push_ready_for_query(&mut bytes); + + let error = parse_query_response_bytes(&bytes).unwrap_err(); + let Error::Postgres(postgres) = error else { + panic!("expected structured PostgreSQL cancellation error, got {error:?}"); + }; + assert_eq!(postgres.severity.as_deref(), Some("ERROR")); + assert_eq!(postgres.sqlstate.as_deref(), Some("57014")); + assert_eq!(postgres.message, "canceling statement due to user request"); + } + + #[test] + fn rejects_invalid_utf8_in_backend_cstrings() { + let mut bytes = Vec::new(); + push_raw_row_description(&mut bytes, &[(&[0xff], 25)]); + push_ready_for_query(&mut bytes); + + assert!(matches!( + parse_query_response_bytes(&bytes), + Err(Error::Engine(message)) + if message.contains("field name is not valid UTF-8") + )); + } + + #[test] + fn text_accessors_reject_invalid_utf8_values() { + let mut bytes = Vec::new(); + push_row_description(&mut bytes, &[("value", 25)]); + push_data_row_raw(&mut bytes, &[Some(&[0xff])]); + push_command_complete(&mut bytes, "SELECT 1"); + push_ready_for_query(&mut bytes); + + let result = parse_query_response_bytes(&bytes).unwrap(); + assert!(matches!( + result.get_text(0, "value"), + Err(Error::Engine(message)) if message.contains("query value is not valid UTF-8") + )); + } + + #[test] + fn rejects_multiple_result_sets() { + let mut bytes = Vec::new(); + push_row_description(&mut bytes, &[("one", 23)]); + push_data_row(&mut bytes, &[Some("1")]); + push_command_complete(&mut bytes, "SELECT 1"); + push_row_description(&mut bytes, &[("two", 23)]); + push_data_row(&mut bytes, &[Some("2")]); + push_command_complete(&mut bytes, "SELECT 1"); + push_ready_for_query(&mut bytes); + + assert!(matches!( + parse_query_response_bytes(&bytes), + Err(Error::Engine(message)) if message.contains("multiple result sets") + )); + } + + #[test] + fn accepts_extended_query_control_messages() { + let mut bytes = Vec::new(); + push_backend_message(&mut bytes, b'1', &[]); + push_backend_message(&mut bytes, b'2', &[]); + push_backend_message(&mut bytes, b'n', &[]); + push_command_complete(&mut bytes, "INSERT 0 0"); + push_ready_for_query(&mut bytes); + + let result = parse_query_response_bytes(&bytes).unwrap(); + assert!(result.fields().is_empty()); + assert!(result.rows().is_empty()); + assert_eq!(result.command_tag(), Some("INSERT 0 0")); + } + + #[test] + fn accepts_backend_async_control_messages() { + let mut bytes = Vec::new(); + push_parameter_status(&mut bytes, "client_encoding", "UTF8"); + push_notice_response(&mut bytes, "NOTICE", "hello"); + push_notification_response(&mut bytes, 123, "channel", "payload"); + push_command_complete(&mut bytes, "SELECT 0"); + push_ready_for_query(&mut bytes); + + let result = parse_query_response_bytes(&bytes).unwrap(); + assert_eq!(result.command_tag(), Some("SELECT 0")); + } + + #[test] + fn rejects_malformed_empty_control_messages() { + let mut bytes = Vec::new(); + push_backend_message(&mut bytes, b'1', &[0]); + push_ready_for_query(&mut bytes); + + assert!(matches!( + parse_query_response_bytes(&bytes), + Err(Error::Engine(message)) if message.contains("ParseComplete contained trailing bytes") + )); + } + + #[test] + fn rejects_malformed_async_control_messages() { + let mut malformed_parameter = Vec::new(); + push_backend_message(&mut malformed_parameter, b'S', b"client_encoding\0"); + push_ready_for_query(&mut malformed_parameter); + assert!(matches!( + parse_query_response_bytes(&malformed_parameter), + Err(Error::Engine(message)) + if message.contains("ParameterStatus value is missing null terminator") + )); + + let mut malformed_notice = Vec::new(); + push_backend_message(&mut malformed_notice, b'N', b"SNOTICE\0"); + push_ready_for_query(&mut malformed_notice); + assert!(matches!( + parse_query_response_bytes(&malformed_notice), + Err(Error::Engine(message)) if message.contains("NoticeResponse is missing terminator") + )); + + let mut malformed_notification = Vec::new(); + let mut body = 123_i32.to_be_bytes().to_vec(); + body.extend_from_slice(b"channel"); + push_backend_message(&mut malformed_notification, b'A', &body); + push_ready_for_query(&mut malformed_notification); + assert!(matches!( + parse_query_response_bytes(&malformed_notification), + Err(Error::Engine(message)) + if message.contains("NotificationResponse channel is missing null terminator") + )); + } + + #[test] + fn rejects_unexpected_backend_message_tags() { + let mut bytes = Vec::new(); + push_backend_message(&mut bytes, b'R', &[0, 0, 0, 0]); + push_ready_for_query(&mut bytes); + + assert!(matches!( + parse_query_response_bytes(&bytes), + Err(Error::Engine(message)) if message.contains("unexpected backend message tag 0x52") + )); + } + + #[test] + fn accepts_ready_for_query_transaction_states() { + for status in [b'I', b'T', b'E'] { + let mut bytes = Vec::new(); + push_command_complete(&mut bytes, "SELECT 0"); + push_backend_message(&mut bytes, b'Z', &[status]); + + let result = parse_query_response_bytes(&bytes).unwrap(); + assert_eq!(result.command_tag(), Some("SELECT 0")); + } + } + + #[test] + fn rejects_malformed_ready_for_query_status() { + let mut missing = Vec::new(); + push_backend_message(&mut missing, b'Z', &[]); + assert!(matches!( + parse_query_response_bytes(&missing), + Err(Error::Engine(message)) + if message.contains("ReadyForQuery contained 0 bytes, expected 1") + )); + + let mut invalid = Vec::new(); + push_backend_message(&mut invalid, b'Z', &[0]); + assert!(matches!( + parse_query_response_bytes(&invalid), + Err(Error::Engine(message)) + if message.contains("ReadyForQuery contained invalid transaction status 0x00") + )); + } + + #[test] + fn builds_extended_query_protocol_request() { + let request = extended_query_request( + "SELECT $1::int4, $2::text, $3::bytea, $4::text", + [ + QueryParam::from(7_i32), + QueryParam::from(Some("hello")), + QueryParam::binary([0_u8, 1, 2]), + QueryParam::from(None::<&str>), + ], + ) + .unwrap(); + + assert_eq!( + frontend_message_tags(request.as_bytes()), + vec![b'P', b'B', b'D', b'E', b'S'] + ); + assert!( + request + .as_bytes() + .windows(b"hello".len()) + .any(|window| window == b"hello") + ); + assert!( + request + .as_bytes() + .windows([0_u8, 1, 2].len()) + .any(|window| window == [0_u8, 1, 2]) + ); + } + + #[test] + fn rejects_nul_in_extended_query_sql() { + assert_eq!( + extended_query_request("SELECT '\0'", [QueryParam::Null]).unwrap_err(), + Error::Engine("extended query SQL must not contain NUL bytes".to_owned()) + ); + } + + #[test] + fn rejects_too_many_extended_query_parameters() { + let params = std::iter::repeat(QueryParam::Null).take(i16::MAX as usize + 1); + + assert_eq!( + extended_query_request("SELECT 1", params).unwrap_err(), + Error::Engine(format!( + "extended query supports at most {} parameters, got {}", + i16::MAX, + i16::MAX as usize + 1 + )) + ); + } + + fn frontend_message_tags(mut bytes: &[u8]) -> Vec { + let mut tags = Vec::new(); + while bytes.len() >= 5 { + let tag = bytes[0]; + let len = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + if len < 4 { + break; + } + let total = 1 + len as usize; + if bytes.len() < total { + break; + } + tags.push(tag); + bytes = &bytes[total..]; + } + tags + } + + fn push_backend_message(bytes: &mut Vec, tag: u8, body: &[u8]) { + bytes.push(tag); + bytes.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + bytes.extend_from_slice(body); + } + + fn push_row_description(bytes: &mut Vec, fields: &[(&str, u32)]) { + let fields = fields + .iter() + .map(|(name, type_oid)| (name.as_bytes(), *type_oid)) + .collect::>(); + push_raw_row_description(bytes, &fields); + } + + fn push_raw_row_description(bytes: &mut Vec, fields: &[(&[u8], u32)]) { + let mut body = Vec::new(); + body.extend_from_slice(&(fields.len() as i16).to_be_bytes()); + for (name, type_oid) in fields { + body.extend_from_slice(name); + body.push(0); + body.extend_from_slice(&0_u32.to_be_bytes()); + body.extend_from_slice(&0_i16.to_be_bytes()); + body.extend_from_slice(&type_oid.to_be_bytes()); + body.extend_from_slice(&(-1_i16).to_be_bytes()); + body.extend_from_slice(&(-1_i32).to_be_bytes()); + body.extend_from_slice(&0_i16.to_be_bytes()); + } + push_backend_message(bytes, b'T', &body); + } + + fn push_data_row(bytes: &mut Vec, values: &[Option<&str>]) { + let values = values + .iter() + .map(|value| value.map(str::as_bytes)) + .collect::>(); + push_data_row_raw(bytes, &values); + } + + fn push_data_row_raw(bytes: &mut Vec, values: &[Option<&[u8]>]) { + let mut body = Vec::new(); + body.extend_from_slice(&(values.len() as i16).to_be_bytes()); + for value in values { + match value { + Some(value) => { + body.extend_from_slice(&(value.len() as i32).to_be_bytes()); + body.extend_from_slice(value); + } + None => body.extend_from_slice(&(-1_i32).to_be_bytes()), + } + } + push_backend_message(bytes, b'D', &body); + } + + fn push_command_complete(bytes: &mut Vec, tag: &str) { + let mut body = Vec::new(); + body.extend_from_slice(tag.as_bytes()); + body.push(0); + push_backend_message(bytes, b'C', &body); + } + + fn push_error_response(bytes: &mut Vec, severity: &str, sqlstate: &str, message: &str) { + let mut body = Vec::new(); + body.push(b'S'); + body.extend_from_slice(severity.as_bytes()); + body.push(0); + body.push(b'C'); + body.extend_from_slice(sqlstate.as_bytes()); + body.push(0); + body.push(b'M'); + body.extend_from_slice(message.as_bytes()); + body.push(0); + body.push(0); + push_backend_message(bytes, b'E', &body); + } + + fn push_notice_response(bytes: &mut Vec, severity: &str, message: &str) { + let mut body = Vec::new(); + body.push(b'S'); + body.extend_from_slice(severity.as_bytes()); + body.push(0); + body.push(b'M'); + body.extend_from_slice(message.as_bytes()); + body.push(0); + body.push(0); + push_backend_message(bytes, b'N', &body); + } + + fn push_parameter_status(bytes: &mut Vec, name: &str, value: &str) { + let mut body = Vec::new(); + body.extend_from_slice(name.as_bytes()); + body.push(0); + body.extend_from_slice(value.as_bytes()); + body.push(0); + push_backend_message(bytes, b'S', &body); + } + + fn push_notification_response(bytes: &mut Vec, pid: i32, channel: &str, payload: &str) { + let mut body = Vec::new(); + body.extend_from_slice(&pid.to_be_bytes()); + body.extend_from_slice(channel.as_bytes()); + body.push(0); + body.extend_from_slice(payload.as_bytes()); + body.push(0); + push_backend_message(bytes, b'A', &body); + } + + fn push_ready_for_query(bytes: &mut Vec) { + push_backend_message(bytes, b'Z', b"I"); + } +} diff --git a/src/sdks/rust/src/reply.rs b/src/sdks/rust/src/reply.rs new file mode 100644 index 00000000..70f892be --- /dev/null +++ b/src/sdks/rust/src/reply.rs @@ -0,0 +1,57 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; + +pub(crate) fn channel() -> (Sender, Receiver) { + let state = Arc::new(Mutex::new(State { + value: None, + waker: None, + })); + ( + Sender { + state: Arc::clone(&state), + }, + Receiver { state }, + ) +} + +pub(crate) struct Sender { + state: Arc>>, +} + +impl Sender { + pub(crate) fn send(self, value: T) { + let waker = { + let mut state = self.state.lock().expect("reply mutex poisoned"); + state.value = Some(value); + state.waker.take() + }; + if let Some(waker) = waker { + waker.wake(); + } + } +} + +pub(crate) struct Receiver { + state: Arc>>, +} + +impl Future for Receiver { + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut state = self.state.lock().expect("reply mutex poisoned"); + if let Some(value) = state.value.take() { + Poll::Ready(value) + } else { + state.waker = Some(cx.waker().clone()); + Poll::Pending + } + } +} + +struct State { + value: Option, + waker: Option, +} diff --git a/src/sdks/rust/src/runtime_resources.rs b/src/sdks/rust/src/runtime_resources.rs new file mode 100644 index 00000000..bb7f8607 --- /dev/null +++ b/src/sdks/rust/src/runtime_resources.rs @@ -0,0 +1,3234 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::config::EngineMode; +use crate::error::{Error, Result}; +use crate::extension::Extension; +use crate::extension::extension_sql_file_belongs; +use crate::liboliphaunt::{MaterializedNativeResources, materialize_native_resources_for_runtime}; + +mod extension_artifact; +mod extension_index; +mod manifest; +mod package; +mod static_registry; + +pub use extension_artifact::create_prebuilt_extension_artifact; +use extension_artifact::*; +pub use extension_index::{ + create_prebuilt_extension_artifact_index, list_prebuilt_extension_artifact_index_catalog, + resolve_prebuilt_extension_artifacts_from_indexes, sign_prebuilt_extension_artifact_index, +}; +use manifest::*; +use package::*; +use static_registry::*; + +const RUNTIME_RESOURCES_SCHEMA: &str = "oliphaunt-runtime-resources-v1"; +const EXTENSION_ARTIFACT_LAYOUT: &str = "oliphaunt-extension-artifact-v1"; +const EXTENSION_ARTIFACT_INDEX_LAYOUT: &str = "oliphaunt-extension-artifact-index-v1"; +const EXTENSION_ARTIFACT_INDEX_SIGNATURE_LAYOUT: &str = + "oliphaunt-extension-artifact-index-signature-v1"; +const RUNTIME_FILES_LAYOUT: &str = "postgres-runtime-files-v1"; +const TEMPLATE_PGDATA_LAYOUT: &str = "postgres-template-pgdata-v1"; +const STATIC_REGISTRY_PACKAGE_LAYOUT: &str = "oliphaunt-static-registry-v1"; +const STATIC_REGISTRY_SOURCE_FILE: &str = "oliphaunt_static_registry.c"; +const STATIC_REGISTRY_SOURCE_MANIFEST_VALUE: &str = "static-registry/oliphaunt_static_registry.c"; +// Resource-relative directory under the runtime path `static-registry/archives`. +const STATIC_REGISTRY_ARCHIVES_DIR: &str = "archives"; + +/// Options for building platform SDK runtime resources. +#[derive(Debug, Clone)] +pub struct NativeRuntimeResourceOptions { + /// Directory that receives the generated `oliphaunt/...` resource tree. + pub output_dir: PathBuf, + /// Native engine mode whose runtime resources should be generated for. + pub mode: EngineMode, + /// Exact PostgreSQL extensions made available by these runtime resources. + pub extensions: Vec, + /// Replace an existing `liboliphaunt` resource tree under `output_dir`. + pub replace_existing: bool, + /// Fail packaging when selected native-module extensions do not have a + /// mobile static-registry entry. + pub require_mobile_static_registry: bool, + /// Native module stems that the platform build has registered for static + /// mobile loading. + pub mobile_static_module_stems: Vec, + /// Exact third-party extension artifacts that are already built for the + /// target PostgreSQL runtime. + pub prebuilt_extensions: Vec, + /// Public artifact target the runtime resources are being packaged for. + /// + /// This is required before copying dynamic native extension modules from + /// prebuilt artifacts. iOS and Android resources still use this target, but + /// statically registered extension modules are linked through + /// `mobile-static` archives instead of copied as desktop dynamic modules. + pub extension_target: Option, +} + +impl NativeRuntimeResourceOptions { + /// Create options for native-direct runtime resources. + pub fn new(output_dir: impl Into) -> Self { + Self { + output_dir: output_dir.into(), + mode: EngineMode::NativeDirect, + extensions: Vec::new(), + replace_existing: false, + require_mobile_static_registry: false, + mobile_static_module_stems: Vec::new(), + prebuilt_extensions: Vec::new(), + extension_target: None, + } + } + + /// Select the engine mode whose resources should be packaged. + pub fn mode(mut self, mode: EngineMode) -> Self { + self.mode = mode; + self + } + + /// Add one exact PostgreSQL extension to the runtime resources. + pub fn extension(mut self, extension: Extension) -> Self { + self.extensions.push(extension); + self + } + + /// Add exact PostgreSQL extensions to the runtime resources. + pub fn extensions(mut self, extensions: impl IntoIterator) -> Self { + self.extensions.extend(extensions); + self + } + + /// Allow replacement of an existing generated `liboliphaunt` resource tree. + pub fn replace_existing(mut self, replace_existing: bool) -> Self { + self.replace_existing = replace_existing; + self + } + + /// Require every selected native-module extension to be mobile static-ready. + pub fn require_mobile_static_registry(mut self, required: bool) -> Self { + self.require_mobile_static_registry = required; + self + } + + /// Declare one native module stem as present in the platform static + /// registry. + pub fn mobile_static_module_stem(mut self, stem: impl Into) -> Self { + self.mobile_static_module_stems.push(stem.into()); + self + } + + /// Declare native module stems as present in the platform static registry. + pub fn mobile_static_module_stems(mut self, stems: Vec) -> Self { + self.mobile_static_module_stems.extend(stems); + self + } + + /// Add one exact prebuilt extension artifact directory. + pub fn prebuilt_extension(mut self, root: impl Into) -> Self { + self.prebuilt_extensions + .push(NativePrebuiltExtensionArtifact::new(root)); + self + } + + /// Add exact prebuilt extension artifact directories. + pub fn prebuilt_extensions(mut self, roots: impl IntoIterator) -> Self { + self.prebuilt_extensions + .extend(roots.into_iter().map(NativePrebuiltExtensionArtifact::new)); + self + } + + /// Set the public artifact target these runtime resources are packaged for. + pub fn extension_target(mut self, target: impl Into) -> Self { + self.extension_target = Some(target.into()); + self + } +} + +/// One exact third-party extension artifact that has already been built. +/// +/// The artifact may be an unpacked directory, `.tar`, or `.tar.zst`. Its root +/// must contain `manifest.properties` with +/// `packageLayout=oliphaunt-extension-artifact-v1` and a `files/` tree whose +/// paths mirror PostgreSQL runtime paths, such as +/// `files/share/postgresql/extension/.control`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativePrebuiltExtensionArtifact { + /// Artifact root directory or archive file. + pub root: PathBuf, +} + +impl NativePrebuiltExtensionArtifact { + /// Create a prebuilt extension artifact reference. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +/// One target-specific mobile static archive for an exact prebuilt extension. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionMobileStaticArchive { + /// Mobile target key, for example `ios-simulator`, `ios-device`, or + /// `arm64-v8a`. + pub target: String, + /// Already-built static archive file for the extension module. + pub archive: PathBuf, +} + +impl NativeExtensionMobileStaticArchive { + /// Create a mobile static archive reference. + pub fn new(target: impl Into, archive: impl Into) -> Self { + Self { + target: target.into(), + archive: archive.into(), + } + } +} + +/// One target-specific dependency archive needed by mobile static extension +/// archives in an exact prebuilt extension artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionMobileStaticDependencyArchive { + /// Mobile target key, for example `ios-simulator`, `ios-device`, or + /// `arm64-v8a`. + pub target: String, + /// Portable dependency name, for example `openssl`, `geos`, or `proj`. + pub name: String, + /// Already-built static archive file for this dependency. + pub archive: PathBuf, +} + +impl NativeExtensionMobileStaticDependencyArchive { + /// Create a mobile static dependency archive reference. + pub fn new( + target: impl Into, + name: impl Into, + archive: impl Into, + ) -> Self { + Self { + target: target.into(), + name: name.into(), + archive: archive.into(), + } + } +} + +/// One mobile static-registry symbol alias for an exact prebuilt extension. +/// +/// `sql_symbol` is the C symbol name referenced by extension SQL. `linked_symbol` +/// is the actual C identifier exported by the carried mobile static archive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionStaticSymbolAlias { + /// SQL-visible C symbol name. + pub sql_symbol: String, + /// Link-time C identifier in the mobile static archive. + pub linked_symbol: String, +} + +impl NativeExtensionStaticSymbolAlias { + /// Create a static-registry symbol alias. + pub fn new(sql_symbol: impl Into, linked_symbol: impl Into) -> Self { + Self { + sql_symbol: sql_symbol.into(), + linked_symbol: linked_symbol.into(), + } + } +} + +/// Output format for an exact prebuilt extension artifact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeExtensionArtifactFormat { + /// Write an unpacked artifact directory. + Directory, + /// Write an uncompressed tar archive. + Tar, + /// Write a gzip-compressed tar archive. + TarGz, + /// Write a zstd-compressed tar archive. + TarZst, +} + +/// Options for creating one exact prebuilt extension artifact from built +/// PostgreSQL runtime files. +#[derive(Debug, Clone)] +pub struct NativeExtensionArtifactOptions { + /// Artifact directory or archive path to write. + pub output: PathBuf, + /// Built PostgreSQL runtime root containing `share/postgresql` and + /// `lib/postgresql`. + pub runtime_files: PathBuf, + /// Exact SQL extension name used by `CREATE EXTENSION`. + pub sql_name: String, + /// Whether the artifact represents a SQL extension with control/SQL files. + pub creates_extension: bool, + /// Native module stem used by PostgreSQL extension SQL. + pub native_module_stem: Option, + /// Target-specific native module filename under `lib/postgresql`. + pub native_module_file: Option, + /// Public target id that produced the dynamic native module payload. + pub native_target: Option, + /// Exact extension dependencies. + pub dependencies: Vec, + /// Additional files under `share/postgresql` required by the extension. + pub data_files: Vec, + /// PostgreSQL shared-preload libraries required when this extension is + /// selected. + pub shared_preload_libraries: Vec, + /// Whether matching iOS/Android static artifacts are available. + pub mobile_prebuilt: bool, + /// Target-specific mobile static archives carried by this artifact. + pub mobile_static_archives: Vec, + /// Target-specific static dependency archives needed by carried mobile + /// extension archives. + pub mobile_static_dependency_archives: Vec, + /// Static registry C symbol prefix for mobile artifacts. + pub static_symbol_prefix: Option, + /// SQL-visible to link-time C symbol aliases for mobile static artifacts. + pub static_symbol_aliases: Vec, + /// Artifact output format. + pub format: NativeExtensionArtifactFormat, + /// Replace an existing output path. + pub replace_existing: bool, +} + +impl NativeExtensionArtifactOptions { + /// Create artifact options for one exact SQL extension. + pub fn new( + output: impl Into, + runtime_files: impl Into, + sql_name: impl Into, + ) -> Self { + Self { + output: output.into(), + runtime_files: runtime_files.into(), + sql_name: sql_name.into(), + creates_extension: true, + native_module_stem: None, + native_module_file: None, + native_target: None, + dependencies: Vec::new(), + data_files: Vec::new(), + shared_preload_libraries: Vec::new(), + mobile_prebuilt: false, + mobile_static_archives: Vec::new(), + mobile_static_dependency_archives: Vec::new(), + static_symbol_prefix: None, + static_symbol_aliases: Vec::new(), + format: NativeExtensionArtifactFormat::Directory, + replace_existing: false, + } + } + + /// Set whether control/SQL extension files are required. + pub fn creates_extension(mut self, creates_extension: bool) -> Self { + self.creates_extension = creates_extension; + self + } + + /// Set the native module stem. + pub fn native_module_stem(mut self, stem: impl Into) -> Self { + self.native_module_stem = Some(stem.into()); + self + } + + /// Set the target-specific native module filename under `lib/postgresql`. + pub fn native_module_file(mut self, file_name: impl Into) -> Self { + self.native_module_file = Some(file_name.into()); + self + } + + /// Set the public target id that produced the dynamic native module. + pub fn native_target(mut self, target: impl Into) -> Self { + self.native_target = Some(target.into()); + self + } + + /// Add one exact dependency. + pub fn dependency(mut self, dependency: impl Into) -> Self { + self.dependencies.push(dependency.into()); + self + } + + /// Add exact dependencies. + pub fn dependencies(mut self, dependencies: impl IntoIterator) -> Self { + self.dependencies.extend(dependencies); + self + } + + /// Add one data file path relative to `share/postgresql`. + pub fn data_file(mut self, data_file: impl Into) -> Self { + self.data_files.push(data_file.into()); + self + } + + /// Add data file paths relative to `share/postgresql`. + pub fn data_files(mut self, data_files: impl IntoIterator) -> Self { + self.data_files.extend(data_files); + self + } + + /// Add one required shared-preload library. + pub fn shared_preload_library(mut self, library: impl Into) -> Self { + self.shared_preload_libraries.push(library.into()); + self + } + + /// Add required shared-preload libraries. + pub fn shared_preload_libraries(mut self, libraries: impl IntoIterator) -> Self { + self.shared_preload_libraries.extend(libraries); + self + } + + /// Mark whether matching mobile static artifacts exist. + pub fn mobile_prebuilt(mut self, mobile_prebuilt: bool) -> Self { + self.mobile_prebuilt = mobile_prebuilt; + self + } + + /// Add one target-specific mobile static archive. + pub fn mobile_static_archive( + mut self, + target: impl Into, + archive: impl Into, + ) -> Self { + self.mobile_static_archives + .push(NativeExtensionMobileStaticArchive::new(target, archive)); + self.mobile_prebuilt = true; + self + } + + /// Add target-specific mobile static archives. + pub fn mobile_static_archives( + mut self, + archives: impl IntoIterator, + ) -> Self { + let mut any = false; + for archive in archives { + any = true; + self.mobile_static_archives.push(archive); + } + if any { + self.mobile_prebuilt = true; + } + self + } + + /// Add one target-specific mobile static dependency archive. + pub fn mobile_static_dependency_archive( + mut self, + target: impl Into, + name: impl Into, + archive: impl Into, + ) -> Self { + self.mobile_static_dependency_archives.push( + NativeExtensionMobileStaticDependencyArchive::new(target, name, archive), + ); + self + } + + /// Add target-specific mobile static dependency archives. + pub fn mobile_static_dependency_archives( + mut self, + archives: impl IntoIterator, + ) -> Self { + self.mobile_static_dependency_archives.extend(archives); + self + } + + /// Set the generated mobile static registry symbol prefix. + pub fn static_symbol_prefix(mut self, prefix: impl Into) -> Self { + self.static_symbol_prefix = Some(prefix.into()); + self + } + + /// Add one static-registry symbol alias. + pub fn static_symbol_alias( + mut self, + sql_symbol: impl Into, + linked_symbol: impl Into, + ) -> Self { + self.static_symbol_aliases + .push(NativeExtensionStaticSymbolAlias::new( + sql_symbol, + linked_symbol, + )); + self + } + + /// Add static-registry symbol aliases. + pub fn static_symbol_aliases( + mut self, + aliases: impl IntoIterator, + ) -> Self { + self.static_symbol_aliases.extend(aliases); + self + } + + /// Select the artifact output format. + pub fn format(mut self, format: NativeExtensionArtifactFormat) -> Self { + self.format = format; + self + } + + /// Allow replacement of an existing artifact path. + pub fn replace_existing(mut self, replace_existing: bool) -> Self { + self.replace_existing = replace_existing; + self + } +} + +/// Prebuilt extension artifact created by the Rust SDK tooling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifact { + /// Artifact directory or archive path. + pub path: PathBuf, + /// Manifest path when the artifact is an unpacked directory. + pub manifest_path: Option, + /// Exact SQL extension name. + pub sql_name: String, + /// Artifact output format. + pub format: NativeExtensionArtifactFormat, +} + +/// Options for resolving exact prebuilt extension artifacts from release +/// indexes. +#[derive(Debug, Clone)] +pub struct NativeExtensionArtifactIndexOptions { + /// Index TOML files to read. Later indexes may not redefine the same + /// `(target, sql_name)` pair. + pub indexes: Vec, + /// Target artifact key, such as `aarch64-apple-darwin`. + pub target: String, + /// Exact SQL extension names to resolve from indexes. Dependencies are + /// resolved transitively. + pub extensions: Vec, + /// Optional cache directory for URL-backed artifact rows. Local sidecar + /// artifacts next to an index are preferred; missing URL-backed artifacts + /// are downloaded here and then verified before use. + pub artifact_cache_dir: Option, + /// Trusted publisher keys for signed artifact indexes. + pub trusted_signing_keys: Vec, + /// Require every artifact index to have a valid sidecar signature. + pub require_signatures: bool, +} + +impl NativeExtensionArtifactIndexOptions { + /// Create artifact-index resolution options for one target. + pub fn new(target: impl Into) -> Self { + Self { + indexes: Vec::new(), + target: target.into(), + extensions: Vec::new(), + artifact_cache_dir: None, + trusted_signing_keys: Vec::new(), + require_signatures: false, + } + } + + /// Add one index file. + pub fn index(mut self, index: impl Into) -> Self { + self.indexes.push(index.into()); + self + } + + /// Add index files. + pub fn indexes(mut self, indexes: impl IntoIterator) -> Self { + self.indexes.extend(indexes); + self + } + + /// Select one exact SQL extension name. + pub fn extension(mut self, extension: impl Into) -> Self { + self.extensions.push(extension.into()); + self + } + + /// Select exact SQL extension names. + pub fn extensions(mut self, extensions: impl IntoIterator) -> Self { + self.extensions.extend(extensions); + self + } + + /// Cache directory for URL-backed artifact index rows. + pub fn artifact_cache_dir(mut self, cache_dir: impl Into) -> Self { + self.artifact_cache_dir = Some(cache_dir.into()); + self + } + + /// Set an optional cache directory for URL-backed artifact index rows. + pub fn maybe_artifact_cache_dir(mut self, cache_dir: Option) -> Self { + self.artifact_cache_dir = cache_dir; + self + } + + /// Trust one Ed25519 publisher key for artifact index signatures. + pub fn trusted_signing_key(mut self, key: NativeExtensionArtifactIndexTrustRoot) -> Self { + self.trusted_signing_keys.push(key); + self + } + + /// Trust Ed25519 publisher keys for artifact index signatures. + pub fn trusted_signing_keys( + mut self, + keys: impl IntoIterator, + ) -> Self { + self.trusted_signing_keys.extend(keys); + self + } + + /// Require signed artifact indexes. + pub fn require_signatures(mut self, required: bool) -> Self { + self.require_signatures = required; + self + } +} + +/// Resolution result for exact extension artifact indexes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifactIndexResolution { + /// Verified artifact paths in dependency order. + pub artifacts: Vec, + /// Exact external extension names resolved from indexes. + pub extension_names: Vec, +} + +/// Catalog entries advertised by exact prebuilt extension artifact indexes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifactIndexCatalog { + /// Exact external extension rows available for the selected target. + pub extensions: Vec, +} + +/// One exact external extension advertised by an artifact index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifactIndexCatalogEntry { + /// Exact SQL extension name. + pub sql_name: String, + /// Target artifact key. + pub target: String, + /// Whether `CREATE EXTENSION` control/SQL files are present. + pub creates_extension: bool, + /// Native module stem required by the extension, if any. + pub native_module_stem: Option, + /// Exact extension dependencies advertised by the index. + pub dependencies: Vec, + /// Required `shared_preload_libraries` entries advertised by the index. + pub shared_preload_libraries: Vec, + /// Whether iOS/Android app bundles can consume this artifact without + /// building extension source. + pub mobile_prebuilt: bool, + /// Mobile targets whose static archives are carried by the artifact. + pub mobile_static_archive_targets: Vec, + /// Optional artifact URL advertised by the index. + pub url: Option, +} + +/// Options for creating an exact prebuilt extension artifact index. +#[derive(Debug, Clone)] +pub struct NativeExtensionArtifactIndexCreateOptions { + /// Index TOML path to write. + pub output: PathBuf, + /// Target artifact key shared by every indexed artifact. + pub target: String, + /// Archive artifact files to index. + pub artifacts: Vec, + /// Optional HTTPS base URL used to publish each relative artifact path. + pub artifact_base_url: Option, + /// Replace an existing output path. + pub replace_existing: bool, +} + +/// Trusted Ed25519 publisher key for exact extension artifact indexes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifactIndexTrustRoot { + /// Stable publisher key identifier. + pub key_id: String, + /// Hex-encoded 32-byte Ed25519 public key. + pub public_key_hex: String, +} + +impl NativeExtensionArtifactIndexTrustRoot { + /// Create a trusted artifact-index publisher key. + pub fn new(key_id: impl Into, public_key_hex: impl Into) -> Self { + Self { + key_id: key_id.into(), + public_key_hex: public_key_hex.into(), + } + } +} + +/// Options for signing one exact extension artifact index. +#[derive(Debug, Clone)] +pub struct NativeExtensionArtifactIndexSigningOptions { + /// Index TOML path whose exact bytes will be signed. + pub index: PathBuf, + /// Stable publisher key identifier. + pub key_id: String, + /// Hex-encoded 32-byte Ed25519 signing key. + pub signing_key_hex: String, + /// Detached signature path. Defaults to `.sig`. + pub signature_path: Option, + /// Replace an existing signature file. + pub replace_existing: bool, +} + +impl NativeExtensionArtifactIndexSigningOptions { + /// Create signing options for one artifact index. + pub fn new( + index: impl Into, + key_id: impl Into, + signing_key_hex: impl Into, + ) -> Self { + Self { + index: index.into(), + key_id: key_id.into(), + signing_key_hex: signing_key_hex.into(), + signature_path: None, + replace_existing: false, + } + } + + /// Write the detached signature to a specific path. + pub fn signature_path(mut self, path: impl Into) -> Self { + self.signature_path = Some(path.into()); + self + } + + /// Set an optional detached signature path. + pub fn maybe_signature_path(mut self, path: Option) -> Self { + self.signature_path = path; + self + } + + /// Allow replacement of an existing detached signature file. + pub fn replace_existing(mut self, replace_existing: bool) -> Self { + self.replace_existing = replace_existing; + self + } +} + +/// Detached signature created for one exact extension artifact index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifactIndexSignature { + /// Signature sidecar path. + pub path: PathBuf, + /// Signed artifact index path. + pub index: PathBuf, + /// Stable publisher key identifier. + pub key_id: String, + /// Hex-encoded Ed25519 public key derived from the signing key. + pub public_key_hex: String, + /// Hex-encoded Ed25519 signature. + pub signature_hex: String, +} + +impl NativeExtensionArtifactIndexCreateOptions { + /// Create options for one target artifact index. + pub fn new(output: impl Into, target: impl Into) -> Self { + Self { + output: output.into(), + target: target.into(), + artifacts: Vec::new(), + artifact_base_url: None, + replace_existing: false, + } + } + + /// Add one artifact archive file. + pub fn artifact(mut self, artifact: impl Into) -> Self { + self.artifacts.push(artifact.into()); + self + } + + /// Add artifact archive files. + pub fn artifacts(mut self, artifacts: impl IntoIterator) -> Self { + self.artifacts.extend(artifacts); + self + } + + /// Set an HTTPS base URL for artifact rows in the generated index. + pub fn artifact_base_url(mut self, base_url: impl Into) -> Self { + self.artifact_base_url = Some(base_url.into()); + self + } + + /// Set an optional base URL for artifact rows in the generated index. + pub fn maybe_artifact_base_url(mut self, base_url: Option) -> Self { + self.artifact_base_url = base_url; + self + } + + /// Allow replacement of an existing index path. + pub fn replace_existing(mut self, replace_existing: bool) -> Self { + self.replace_existing = replace_existing; + self + } +} + +/// Exact prebuilt extension artifact index created by the Rust SDK tooling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifactIndex { + /// Index TOML path. + pub path: PathBuf, + /// Target artifact key. + pub target: String, + /// Indexed artifacts. + pub artifacts: Vec, +} + +/// One artifact row in an exact prebuilt extension artifact index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeExtensionArtifactIndexArtifact { + /// Exact SQL extension name. + pub sql_name: String, + /// Target artifact key. + pub target: String, + /// Whether `CREATE EXTENSION` control/SQL files are present. + pub creates_extension: bool, + /// Native module stem required by the extension, if any. + pub native_module_stem: Option, + /// Exact extension dependencies. + pub dependencies: Vec, + /// Required `shared_preload_libraries` entries. + pub shared_preload_libraries: Vec, + /// Whether iOS/Android app bundles can consume this artifact without + /// building extension source. + pub mobile_prebuilt: bool, + /// Mobile targets whose static archives are carried by the artifact. + pub mobile_static_archive_targets: Vec, + /// Relative artifact path recorded in the index. + pub path: PathBuf, + /// Optional HTTPS artifact URL recorded in the index. + pub url: Option, + /// Hex-encoded SHA-256 digest of the artifact archive file. + pub sha256: String, + /// Artifact archive byte length. + pub bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExtensionArtifactIndexEntry { + index_path: PathBuf, + sql_name: String, + target: String, + creates_extension: bool, + native_module_stem: Option, + dependencies: Vec, + shared_preload_libraries: Vec, + mobile_prebuilt: bool, + mobile_static_archive_targets: Vec, + relative_path: PathBuf, + path: PathBuf, + url: Option, + sha256: String, + bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExtensionArtifactIndexToml { + schema: String, + pg_major: u16, + artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExtensionArtifactIndexEntryToml { + sql_name: String, + target: String, + #[serde(default = "default_true")] + creates_extension: bool, + native_module_stem: Option, + #[serde(default)] + dependencies: Vec, + #[serde(default)] + shared_preload_libraries: Vec, + #[serde(default)] + mobile_prebuilt: bool, + #[serde(default)] + mobile_static_archive_targets: Vec, + path: String, + url: Option, + sha256: String, + bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExtensionArtifactIndexSignatureToml { + schema: String, + algorithm: String, + key_id: String, + public_key: Option, + signature: String, +} + +fn default_true() -> bool { + true +} + +/// Mobile static-registry readiness of generated runtime resources. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MobileStaticRegistryState { + /// The selected extensions do not require native modules. + NotRequired, + /// Every selected native-module extension has a mobile static-registry row. + Complete, + /// At least one selected native-module extension still needs registry work. + Pending, +} + +impl MobileStaticRegistryState { + fn as_manifest_value(self) -> &'static str { + match self { + Self::NotRequired => "not-required", + Self::Complete => "complete", + Self::Pending => "pending", + } + } +} + +/// Mobile static-registry metadata recorded in generated runtime resources. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MobileStaticRegistryMetadata { + /// Runtime-resource readiness state. + pub state: MobileStaticRegistryState, + /// Selected SQL extension names that are registered for mobile static use. + pub registered_extensions: Vec, + /// Selected SQL extension names that still need mobile static registry rows. + pub pending_extensions: Vec, + /// Native module stems required by the selected extensions. + pub native_module_stems: Vec, +} + +/// Size report for generated runtime resources. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeRuntimeResourceSizeReport { + /// Stable TSV report path under the resource root. + pub path: PathBuf, + /// Bytes in runtime, template, and static-registry resource trees. This + /// intentionally excludes the report file itself to avoid circular output. + pub package_bytes: u64, + /// Bytes in `runtime/files`. + pub runtime_bytes: u64, + /// Bytes in `template-pgdata/files`. + pub template_pgdata_bytes: u64, + /// Bytes in `static-registry`. + pub static_registry_bytes: u64, + /// De-duplicated bytes for all selected extension assets present in the + /// runtime tree. + pub selected_extension_bytes: u64, + /// Per-extension asset footprints. + pub extensions: Vec, +} + +/// Size report row for one selected extension. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionSizeReport { + /// SQL extension name. + pub name: String, + /// Number of runtime files counted for this extension. + pub file_count: usize, + /// Runtime bytes counted for this extension. + pub bytes: u64, +} + +/// Runtime resources generated by the Rust SDK and consumed by Swift, Kotlin, +/// and React Native SDKs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeRuntimeResources { + /// Root directory containing `runtime` and `template-pgdata` resources. + pub root: PathBuf, + /// Runtime files directory copied into app storage before opening. + pub runtime_files: PathBuf, + /// Template PGDATA files directory copied for first open on mobile. + pub template_pgdata_files: PathBuf, + /// Content key of the source runtime cache. + pub runtime_cache_key: String, + /// Content key of the source template PGDATA cache. + pub template_cache_key: String, + /// Built-in extensions materialized into the runtime resources. + pub extensions: Vec, + /// Exact extension names materialized into the runtime resources, including + /// built-in and concrete prebuilt extension artifacts. + pub extension_names: Vec, + /// Mobile static-registry metadata for the materialized runtime resources. + pub mobile_static_registry: MobileStaticRegistryMetadata, + /// PostgreSQL shared-preload libraries required by the selected extensions. + pub shared_preload_libraries: Vec, + /// Static registry manifest generated for platform SDK resources. + pub static_registry_manifest: PathBuf, + /// Generated static registry source when the runtime resources are + /// mobile-ready. + pub static_registry_source: Option, + /// Package and extension size report. + pub size_report: NativeRuntimeResourceSizeReport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimeResourceExtension { + sql_name: String, + creates_extension: bool, + native_module_stem: Option, + native_module_file: Option, + native_target: Option, + dependencies: Vec, + data_files: Vec, + shared_preload_libraries: Vec, + mobile_prebuilt: bool, + mobile_static_archives: Vec, + mobile_static_dependency_archives: Vec, + static_symbol_prefix: Option, + static_symbol_aliases: Vec, + source: RuntimeResourceExtensionSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RuntimeResourceExtensionSource { + BuiltIn(Extension), + Prebuilt { root: PathBuf, files_root: PathBuf }, +} + +#[derive(Debug)] +struct PreparedPrebuiltExtensionArtifacts { + artifacts: Vec, + extraction_root: Option, +} + +impl PreparedPrebuiltExtensionArtifacts { + fn prepare(artifacts: &[NativePrebuiltExtensionArtifact]) -> Result { + let mut prepared = Vec::new(); + let mut extraction_root = None; + for (index, artifact) in artifacts.iter().enumerate() { + if artifact.root.is_dir() { + prepared.push(artifact.clone()); + } else if artifact.root.is_file() { + let root = extraction_root.get_or_insert_with(unique_extension_extraction_root); + fs::create_dir_all(&root).map_err(|err| { + Error::Engine(format!( + "create prebuilt extension artifact extraction root {}: {err}", + root.display() + )) + })?; + let destination = root.join(format!("artifact-{index}")); + let extracted_root = + extract_prebuilt_extension_archive(&artifact.root, &destination)?; + prepared.push(NativePrebuiltExtensionArtifact::new(extracted_root)); + } else { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact {} must be an unpacked directory, .tar archive, or .tar.zst archive", + artifact.root.display() + ))); + } + } + Ok(Self { + artifacts: prepared, + extraction_root, + }) + } + + fn artifacts(&self) -> &[NativePrebuiltExtensionArtifact] { + &self.artifacts + } +} + +impl Drop for PreparedPrebuiltExtensionArtifacts { + fn drop(&mut self) { + if let Some(root) = &self.extraction_root { + let _ = fs::remove_dir_all(root); + } + } +} + +/// Build the portable runtime-resource layout produced by the Rust SDK and used +/// by the platform SDKs. +pub fn build_native_runtime_resources( + options: NativeRuntimeResourceOptions, +) -> Result { + if options.output_dir.as_os_str().is_empty() { + return Err(Error::InvalidConfig( + "native runtime-resource output directory must not be empty".to_owned(), + )); + } + + let prebuilt_artifacts = + PreparedPrebuiltExtensionArtifacts::prepare(&options.prebuilt_extensions)?; + let selected_extensions = + resolve_runtime_resource_extensions(&options.extensions, prebuilt_artifacts.artifacts())?; + let extensions = built_in_extensions(&selected_extensions); + let extension_names = selected_extension_names(&selected_extensions); + let shared_preload_libraries = shared_preload_libraries(&selected_extensions); + let mobile_static_registry = + mobile_static_registry_metadata(&selected_extensions, &options.mobile_static_module_stems)?; + if options.require_mobile_static_registry { + require_mobile_static_registry_ready(&mobile_static_registry)?; + } + let materialized = materialize_native_resources_for_runtime(options.mode, &extensions)?; + let root = options.output_dir.join("oliphaunt"); + prepare_output_root(&root, options.replace_existing)?; + + write_runtime_resource_tree( + &root, + options.mode, + &materialized, + &selected_extensions, + &shared_preload_libraries, + &mobile_static_registry, + options.extension_target.as_deref(), + )?; + let size_report = runtime_resource_size_report( + &root, + &selected_extensions, + options.extension_target.as_deref(), + &mobile_static_registry, + )?; + write_runtime_resource_size_report(&size_report)?; + + Ok(NativeRuntimeResources { + runtime_files: root.join("runtime/files"), + template_pgdata_files: root.join("template-pgdata/files"), + static_registry_manifest: root.join("static-registry/manifest.properties"), + static_registry_source: (mobile_static_registry.state + == MobileStaticRegistryState::Complete) + .then(|| root.join(format!("static-registry/{STATIC_REGISTRY_SOURCE_FILE}"))), + root, + runtime_cache_key: materialized.runtime_cache_key, + template_cache_key: materialized.template_cache_key, + extensions, + extension_names, + mobile_static_registry, + shared_preload_libraries, + size_report, + }) +} + +fn resolve_runtime_resource_extensions( + built_in: &[Extension], + prebuilt_artifacts: &[NativePrebuiltExtensionArtifact], +) -> Result> { + let mut prebuilt = BTreeMap::new(); + for artifact in prebuilt_artifacts { + let extension = load_prebuilt_extension_artifact(&artifact.root)?; + if prebuilt + .insert(extension.sql_name.clone(), extension) + .is_some() + { + return Err(Error::InvalidConfig( + "prebuilt extension artifacts must not repeat the same SQL extension name" + .to_owned(), + )); + } + } + + let mut requested = built_in + .iter() + .map(|extension| extension.sql_name().to_owned()) + .collect::>(); + requested.extend(prebuilt.keys().cloned()); + + let mut resolved = Vec::new(); + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + for sql_name in requested { + visit_runtime_resource_extension( + &sql_name, + &prebuilt, + &mut visiting, + &mut visited, + &mut resolved, + )?; + } + Ok(resolved) +} + +fn visit_runtime_resource_extension( + sql_name: &str, + prebuilt: &BTreeMap, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + resolved: &mut Vec, +) -> Result<()> { + if visited.contains(sql_name) { + return Ok(()); + } + if !visiting.insert(sql_name.to_owned()) { + return Err(Error::InvalidConfig(format!( + "cyclic native extension dependency involving '{sql_name}'" + ))); + } + + let (extension, dependencies) = if let Some(extension) = prebuilt.get(sql_name) { + ( + extension.clone(), + extension + .dependencies() + .into_iter() + .map(str::to_owned) + .collect::>(), + ) + } else { + let Some(extension) = Extension::by_release_ready_sql_name(sql_name) else { + return Err(Error::InvalidConfig(format!( + "selected extension '{sql_name}' is neither built into this Oliphaunt release nor provided as a prebuilt extension artifact" + ))); + }; + let selected_extension = built_in_runtime_resource_extension(extension); + ( + selected_extension, + extension + .dependencies() + .iter() + .map(|dependency| dependency.sql_name().to_owned()) + .collect::>(), + ) + }; + + for dependency in dependencies { + visit_runtime_resource_extension(&dependency, prebuilt, visiting, visited, resolved)?; + } + visiting.remove(sql_name); + visited.insert(sql_name.to_owned()); + resolved.push(extension); + Ok(()) +} + +fn built_in_runtime_resource_extension(extension: Extension) -> RuntimeResourceExtension { + RuntimeResourceExtension { + sql_name: extension.sql_name().to_owned(), + creates_extension: extension.creates_extension(), + native_module_stem: extension.native_module_stem().map(str::to_owned), + native_module_file: extension.native_module_file(), + native_target: None, + dependencies: extension + .dependencies() + .iter() + .map(|dependency| dependency.sql_name().to_owned()) + .collect(), + data_files: extension_data_paths(extension), + shared_preload_libraries: extension + .required_shared_preload_library() + .map(|library| vec![library.to_owned()]) + .unwrap_or_default(), + mobile_prebuilt: extension.mobile_release_ready(), + mobile_static_archives: Vec::new(), + mobile_static_dependency_archives: Vec::new(), + static_symbol_prefix: None, + static_symbol_aliases: Vec::new(), + source: RuntimeResourceExtensionSource::BuiltIn(extension), + } +} + +fn built_in_extensions(extensions: &[RuntimeResourceExtension]) -> Vec { + extensions + .iter() + .filter_map(|extension| match extension.source { + RuntimeResourceExtensionSource::BuiltIn(extension) => Some(extension), + RuntimeResourceExtensionSource::Prebuilt { .. } => None, + }) + .collect() +} + +fn selected_extension_names(extensions: &[RuntimeResourceExtension]) -> Vec { + let mut names = extensions + .iter() + .map(|extension| extension.sql_name.clone()) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +fn mobile_static_archive_targets(archives: &[MobileStaticArchive]) -> Vec { + archives + .iter() + .map(|archive| archive.target.clone()) + .collect::>() + .into_iter() + .collect() +} + +fn extension_data_paths(extension: Extension) -> Vec { + crate::extension::extension_data_files(extension) + .iter() + .map(PathBuf::from) + .collect() +} + +fn load_prebuilt_extension_artifact(root: &Path) -> Result { + let manifest_path = root.join("manifest.properties"); + let manifest_text = fs::read_to_string(&manifest_path).map_err(|err| { + Error::InvalidConfig(format!( + "read prebuilt extension artifact manifest {}: {err}", + manifest_path.display() + )) + })?; + let manifest = parse_properties_manifest(&manifest_path, &manifest_text)?; + require_property( + &manifest_path, + &manifest, + "packageLayout", + EXTENSION_ARTIFACT_LAYOUT, + )?; + let pg_major = required_manifest_value(&manifest_path, &manifest, "pgMajor")?; + if pg_major != "18" { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact {} targets PostgreSQL {pg_major}; Oliphaunt native packages require PostgreSQL 18", + manifest_path.display() + ))); + } + let files_value = manifest + .get("files") + .map(String::as_str) + .unwrap_or("files") + .trim(); + if files_value != "files" { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact {} must use files=files", + manifest_path.display() + ))); + } + let files_root = root.join("files"); + if !files_root.is_dir() { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact {} is missing files/ runtime tree", + root.display() + ))); + } + + let sql_name = required_manifest_value(&manifest_path, &manifest, "sqlName")?.to_owned(); + validate_portable_id(&sql_name, "prebuilt extension sqlName")?; + let creates_extension = + parse_manifest_bool(&manifest_path, &manifest, "createsExtension", true)?; + let native_module_stem = optional_manifest_id(&manifest_path, &manifest, "nativeModuleStem")?; + let native_module_file = optional_manifest_id(&manifest_path, &manifest, "nativeModuleFile")?; + if native_module_file.is_some() && native_module_stem.is_none() { + return Err(Error::InvalidConfig(format!( + "manifest {} uses nativeModuleFile without nativeModuleStem", + manifest_path.display() + ))); + } + let native_module_file = native_module_stem.as_ref().map(|stem| { + native_module_file + .clone() + .unwrap_or_else(|| format!("{}{}", stem, std::env::consts::DLL_SUFFIX)) + }); + let native_target = optional_manifest_id(&manifest_path, &manifest, "nativeTarget")?; + if native_module_stem.is_some() && native_target.is_none() { + return Err(Error::InvalidConfig(format!( + "manifest {} declares nativeModuleStem but is missing nativeTarget", + manifest_path.display() + ))); + } + let dependencies = parse_manifest_id_list(&manifest_path, &manifest, "dependencies")?; + let data_files = parse_manifest_relative_path_list(&manifest_path, &manifest, "dataFiles")?; + let shared_preload_libraries = + parse_manifest_id_list(&manifest_path, &manifest, "sharedPreloadLibraries")?; + let mobile_prebuilt = parse_manifest_bool(&manifest_path, &manifest, "mobilePrebuilt", false)?; + let mobile_static_archives = + parse_manifest_mobile_static_archives(&manifest_path, &manifest, "mobileStaticArchives")?; + let mobile_static_dependency_archives = parse_manifest_mobile_static_dependency_archives( + &manifest_path, + &manifest, + "mobileStaticDependencyArchives", + )?; + let static_symbol_prefix = + optional_manifest_c_identifier(&manifest_path, &manifest, "staticSymbolPrefix")?; + let static_symbol_aliases = + parse_manifest_static_symbol_aliases(&manifest_path, &manifest, "staticSymbolAliases")?; + validate_prebuilt_extension_mobile_static_archives( + root, + &manifest_path, + native_module_stem.as_deref(), + mobile_prebuilt, + &mobile_static_archives, + )?; + validate_prebuilt_extension_mobile_static_dependency_archives( + root, + &manifest_path, + &mobile_static_archives, + &mobile_static_dependency_archives, + )?; + + Ok(RuntimeResourceExtension { + sql_name, + creates_extension, + native_module_stem, + native_module_file, + native_target, + dependencies, + data_files, + shared_preload_libraries, + mobile_prebuilt, + mobile_static_archives, + mobile_static_dependency_archives, + static_symbol_prefix, + static_symbol_aliases, + source: RuntimeResourceExtensionSource::Prebuilt { + root: root.to_path_buf(), + files_root, + }, + }) +} + +impl RuntimeResourceExtension { + fn dependencies(&self) -> Vec<&str> { + self.dependencies.iter().map(String::as_str).collect() + } +} + +fn require_mobile_static_registry_ready(metadata: &MobileStaticRegistryMetadata) -> Result<()> { + if metadata.state != MobileStaticRegistryState::Pending { + return Ok(()); + } + Err(Error::InvalidConfig(format!( + "selected extension(s) require mobile static registry entries before iOS/Android packaging: {}", + metadata.pending_extensions.join(",") + ))) +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "extension-signing")] + use super::extension_index::hex_bytes; + use super::*; + use crate::extension::resolve_extension_selection; + use std::time::{SystemTime, UNIX_EPOCH}; + use tar::EntryType; + + #[test] + fn mobile_static_registry_metadata_marks_sql_only_packages_not_required() { + let extensions = runtime_resource_extensions(&[Extension::Pgtap]); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + assert_eq!(metadata.state, MobileStaticRegistryState::NotRequired); + assert!(metadata.registered_extensions.is_empty()); + assert!(metadata.pending_extensions.is_empty()); + assert!(metadata.native_module_stems.is_empty()); + } + + #[test] + fn mobile_static_registry_metadata_marks_module_extensions_pending() { + let extensions = runtime_resource_extensions(&[Extension::Vector]); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + assert_eq!(metadata.state, MobileStaticRegistryState::Pending); + assert_eq!(metadata.pending_extensions, vec!["vector"]); + assert_eq!(metadata.native_module_stems, vec!["vector"]); + assert!(metadata.registered_extensions.is_empty()); + } + + #[test] + fn mobile_static_registry_requirement_rejects_pending_modules() { + let extensions = runtime_resource_extensions(&[Extension::Vector]); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + let error = require_mobile_static_registry_ready(&metadata).unwrap_err(); + assert_eq!( + error, + Error::InvalidConfig( + "selected extension(s) require mobile static registry entries before iOS/Android packaging: vector" + .to_owned() + ) + ); + } + + #[test] + fn mobile_static_registry_metadata_marks_declared_modules_complete() { + let extensions = runtime_resource_extensions(&[Extension::Vector]); + let metadata = + mobile_static_registry_metadata(&extensions, &["vector".to_owned()]).unwrap(); + assert_eq!(metadata.state, MobileStaticRegistryState::Complete); + assert_eq!(metadata.registered_extensions, vec!["vector"]); + assert!(metadata.pending_extensions.is_empty()); + assert_eq!(metadata.native_module_stems, vec!["vector"]); + require_mobile_static_registry_ready(&metadata).unwrap(); + } + + #[test] + fn mobile_static_registry_metadata_marks_hstore_complete_after_prebuilt_artifact_support() { + let extensions = runtime_resource_extensions(&[Extension::Hstore]); + let metadata = + mobile_static_registry_metadata(&extensions, &["hstore".to_owned()]).unwrap(); + assert_eq!(metadata.state, MobileStaticRegistryState::Complete); + assert_eq!(metadata.registered_extensions, vec!["hstore"]); + assert!(metadata.pending_extensions.is_empty()); + assert_eq!(metadata.native_module_stems, vec!["hstore"]); + require_mobile_static_registry_ready(&metadata).unwrap(); + } + + #[test] + fn mobile_static_registry_metadata_rejects_unavailable_mobile_artifacts() { + let extensions = runtime_resource_extensions(&[Extension::Graph]); + let error = + mobile_static_registry_metadata(&extensions, &["graph".to_owned()]).unwrap_err(); + assert_eq!( + error, + Error::InvalidConfig( + "selected extension 'graph' does not have release-ready iOS/Android static artifacts; app bundles cannot mark module stem 'graph' complete without a prebuilt mobile artifact" + .to_owned() + ) + ); + } + + #[test] + fn mobile_static_registry_metadata_rejects_unknown_registered_modules() { + let extensions = runtime_resource_extensions(&[Extension::Vector]); + let error = + mobile_static_registry_metadata(&extensions, &["hstore".to_owned()]).unwrap_err(); + assert_eq!( + error, + Error::InvalidConfig( + "mobile static registry module stem(s) were not selected by these runtime resources: hstore" + .to_owned() + ) + ); + } + + #[test] + fn manifest_records_mobile_static_registry_metadata() { + let extensions = runtime_resource_extensions(&[Extension::Vector]); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + let manifest = RuntimeResourceManifest { + cache_key: "runtime-smoke", + layout: RUNTIME_FILES_LAYOUT, + mode: EngineMode::NativeDirect, + extensions: &extensions, + shared_preload_libraries: &[], + mobile_static_registry: &metadata, + }; + let text = manifest_text(&manifest); + assert!(text.contains("extensions=vector\n")); + assert!(text.contains("sharedPreloadLibraries=\n")); + assert!(text.contains("mobileStaticRegistryState=pending\n")); + assert!(text.contains("mobileStaticRegistryPending=vector\n")); + assert!(text.contains("nativeModuleStems=vector\n")); + assert!(text.contains("mobileStaticRegistrySource=\n")); + } + + #[test] + fn manifest_records_required_shared_preload_libraries() { + let extensions = runtime_resource_extensions(&[Extension::PgSearch, Extension::PgSearch]); + let preload = shared_preload_libraries(&extensions); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + let manifest = RuntimeResourceManifest { + cache_key: "runtime-smoke", + layout: RUNTIME_FILES_LAYOUT, + mode: EngineMode::NativeDirect, + extensions: &extensions, + shared_preload_libraries: &preload, + mobile_static_registry: &metadata, + }; + let text = manifest_text(&manifest); + assert!(text.contains("extensions=pg_search\n")); + assert!(text.contains("sharedPreloadLibraries=pg_search\n")); + } + + #[test] + fn package_size_report_counts_selected_extension_assets() { + let temp = unique_temp_root("oliphaunt-runtime-resources-size-report"); + let root = temp.join("oliphaunt"); + write_file( + &root.join("runtime/files/share/postgresql/extension/vector.control"), + b"vector-control", + ); + write_file( + &root.join("runtime/files/share/postgresql/extension/vector--1.0.sql"), + b"vector-sql", + ); + write_file( + &root + .join("runtime/files/lib/postgresql") + .join(format!("vector{}", std::env::consts::DLL_SUFFIX)), + b"vector-module", + ); + write_file( + &root.join("runtime/files/share/postgresql/postgresql.conf.sample"), + b"core-runtime", + ); + write_file(&root.join("template-pgdata/files/PG_VERSION"), b"18\n"); + write_file( + &root.join("static-registry/manifest.properties"), + b"state=pending\n", + ); + + let selected_extensions = runtime_resource_extensions( + &resolve_extension_selection(&[Extension::Vector]).unwrap(), + ); + let metadata = mobile_static_registry_metadata(&selected_extensions, &[]).unwrap(); + let report = runtime_resource_size_report( + &root, + &selected_extensions, + Some("test-target"), + &metadata, + ) + .unwrap(); + write_runtime_resource_size_report(&report).unwrap(); + + let vector_bytes = b"vector-control".len() as u64 + + b"vector-sql".len() as u64 + + b"vector-module".len() as u64; + assert_eq!(report.selected_extension_bytes, vector_bytes); + assert_eq!(report.extensions.len(), 1); + assert_eq!(report.extensions[0].name, "vector"); + assert_eq!(report.extensions[0].file_count, 3); + assert_eq!(report.extensions[0].bytes, vector_bytes); + + let text = fs::read_to_string(root.join("package-size.tsv")).unwrap(); + assert!(text.contains("kind\tid\textensions\tfiles\tbytes\n")); + assert!(text.contains(&format!("extensions\tselected\t-\t-\t{vector_bytes}\n"))); + assert!(text.contains(&format!("extension\tvector\t-\t3\t{vector_bytes}\n"))); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn package_size_report_counts_selected_extension_data_files_under_share() { + let temp = unique_temp_root("oliphaunt-runtime-resources-data-file-report"); + let root = temp.join("oliphaunt"); + write_file( + &root.join("runtime/files/share/postgresql/extension/unaccent.control"), + b"unaccent-control", + ); + write_file( + &root.join("runtime/files/share/postgresql/extension/unaccent--1.1.sql"), + b"unaccent-sql", + ); + write_file( + &root.join("runtime/files/share/postgresql/tsearch_data/unaccent.rules"), + b"unaccent-rules", + ); + write_file( + &root + .join("runtime/files/lib/postgresql") + .join(format!("unaccent{}", std::env::consts::DLL_SUFFIX)), + b"unaccent-module", + ); + write_file( + &root.join("runtime/files/share/postgresql/postgresql.conf.sample"), + b"core-runtime", + ); + write_file(&root.join("template-pgdata/files/PG_VERSION"), b"18\n"); + write_file( + &root.join("static-registry/manifest.properties"), + b"state=pending\n", + ); + + let selected_extensions = runtime_resource_extensions( + &resolve_extension_selection(&[Extension::Unaccent]).unwrap(), + ); + let metadata = mobile_static_registry_metadata(&selected_extensions, &[]).unwrap(); + let report = runtime_resource_size_report( + &root, + &selected_extensions, + Some("test-target"), + &metadata, + ) + .unwrap(); + + let unaccent_bytes = b"unaccent-control".len() as u64 + + b"unaccent-sql".len() as u64 + + b"unaccent-rules".len() as u64 + + b"unaccent-module".len() as u64; + assert_eq!(report.selected_extension_bytes, unaccent_bytes); + assert_eq!(report.extensions.len(), 1); + assert_eq!(report.extensions[0].name, "unaccent"); + assert_eq!(report.extensions[0].file_count, 4); + assert_eq!(report.extensions[0].bytes, unaccent_bytes); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn module_pathname_symbol_parser_finds_explicit_and_implicit_symbols() { + let symbols = module_pathname_c_symbols( + r#" +-- Commented AS 'MODULE_PATHNAME', 'ignored_symbol' LANGUAGE C; +CREATE FUNCTION public.implicit_symbol(integer) RETURNS integer + AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT; +CREATE OR REPLACE FUNCTION public.explicit_sql_name(integer) RETURNS integer + AS 'MODULE_PATHNAME', 'explicit_c_symbol' + LANGUAGE C STRICT; +CREATE FUNCTION sql_only(integer) RETURNS integer + LANGUAGE sql AS 'SELECT $1'; +"#, + ) + .unwrap(); + assert_eq!(symbols, vec!["explicit_c_symbol", "implicit_symbol"]); + } + + #[test] + fn static_registry_source_declares_magic_init_and_sql_symbols() { + let modules = vec![StaticRegistryModule { + extension_sql_name: "vector".to_owned(), + module_stem: "vector".to_owned(), + symbol_prefix: "oliphaunt_static_vector".to_owned(), + sql_symbols: vec!["vector_in".to_owned(), "vector_out".to_owned()], + symbol_aliases: BTreeMap::new(), + }]; + let source = static_registry_source_text(&modules); + assert!(source.contains("liboliphaunt_selected_static_extensions")); + assert!(source.contains("oliphaunt_static_vector_Pg_magic_func")); + assert!(source.contains("oliphaunt_static_vector__PG_init")); + assert!(source.contains("OLIPHAUNT_STATIC_OPTIONAL")); + assert!(source.contains("extern const void *oliphaunt_static_vector_Pg_magic_func(void);")); + assert!(source.contains( + "extern void oliphaunt_static_vector__PG_init(void) OLIPHAUNT_STATIC_OPTIONAL;" + )); + assert!(source.contains("extern void vector_in(void);")); + assert!(!source.contains(&format!("OLIPHAUNT_STATIC_{}", "WEAK"))); + assert!(!source.contains("extern void vector_in(void) OLIPHAUNT_STATIC_OPTIONAL")); + assert!(source.contains("{ .name = \"vector_in\", .address = (void *)vector_in }")); + assert!( + source.contains( + "{ .name = \"pg_finfo_vector_in\", .address = (void *)pg_finfo_vector_in }" + ) + ); + let manifest = static_registry_manifest_text( + &MobileStaticRegistryMetadata { + state: MobileStaticRegistryState::Complete, + registered_extensions: vec!["vector".to_owned()], + pending_extensions: vec![], + native_module_stems: vec!["vector".to_owned()], + }, + &modules, + &[], + &[], + ); + assert!(manifest.contains("packageLayout=oliphaunt-static-registry-v1\n")); + assert!(manifest.contains("source=oliphaunt_static_registry.c\n")); + assert!(manifest.contains("module.vector.sqlSymbols=vector_in,vector_out\n")); + } + + #[test] + fn prebuilt_extension_artifact_is_exact_and_mobile_registry_ready() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-artifact"); + let artifact = temp.join("acme_ext"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + true, + ); + write_file( + &artifact.join("files/share/postgresql/extension/hstore.control"), + b"comment = 'should not leak'\n", + ); + + let extensions = resolve_runtime_resource_extensions( + &[], + &[NativePrebuiltExtensionArtifact::new(&artifact)], + ) + .unwrap(); + assert_eq!(selected_extension_names(&extensions), vec!["acme_ext"]); + + let runtime_files = temp.join("runtime/files"); + write_file( + &runtime_files.join("share/postgresql/postgresql.conf.sample"), + b"core-runtime", + ); + write_file(&temp.join("template-pgdata/files/PG_VERSION"), b"18\n"); + let pending_metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + copy_prebuilt_extension_artifacts( + &runtime_files, + &extensions, + Some("test-target"), + &pending_metadata, + ) + .unwrap(); + + assert!( + runtime_files + .join("share/postgresql/extension/acme_ext.control") + .is_file() + ); + assert!( + runtime_files + .join("share/postgresql/extension/acme_ext--1.0.sql") + .is_file() + ); + assert!( + runtime_files + .join("share/postgresql/data/acme_ext.rules") + .is_file() + ); + assert!( + runtime_files + .join("lib/postgresql") + .join(format!("acme_ext{}", std::env::consts::DLL_SUFFIX)) + .is_file() + ); + assert!( + !runtime_files + .join("share/postgresql/extension/hstore.control") + .exists(), + "unselected files inside a prebuilt extension artifact must not leak" + ); + + let metadata = + mobile_static_registry_metadata(&extensions, &["acme_ext".to_owned()]).unwrap(); + assert_eq!(metadata.state, MobileStaticRegistryState::Complete); + assert_eq!(metadata.registered_extensions, vec!["acme_ext"]); + assert_eq!(metadata.native_module_stems, vec!["acme_ext"]); + + let modules = static_registry_modules(&runtime_files, &extensions, &metadata).unwrap(); + assert_eq!(modules.len(), 1); + assert_eq!(modules[0].extension_sql_name, "acme_ext"); + assert_eq!(modules[0].symbol_prefix, "acme_static"); + assert_eq!(modules[0].sql_symbols, vec!["acme_ext_echo"]); + let static_registry_dir = temp.join("oliphaunt/static-registry"); + let archives = copy_prebuilt_mobile_static_archives(&static_registry_dir, &extensions) + .expect("copy selected mobile static archives"); + assert_eq!(archives.len(), 1); + assert_eq!(archives[0].target, "ios-simulator"); + assert!( + static_registry_dir + .join( + "archives/ios-simulator/extensions/acme_ext/liboliphaunt_extension_acme_ext.a" + ) + .is_file(), + "selected external mobile static archive must be copied into runtime resources" + ); + let dependency_archives = + copy_prebuilt_mobile_static_dependency_archives(&static_registry_dir, &extensions) + .expect("copy selected mobile static dependency archives"); + assert_eq!(dependency_archives.len(), 1); + assert_eq!(dependency_archives[0].target, "ios-simulator"); + assert_eq!(dependency_archives[0].name, "openssl"); + assert!( + static_registry_dir + .join("archives/ios-simulator/dependencies/openssl/libcrypto.a") + .is_file(), + "selected external mobile static dependency archive must be copied into runtime resources" + ); + let static_manifest = + static_registry_manifest_text(&metadata, &modules, &archives, &dependency_archives); + assert!(static_manifest.contains("archiveTargets=ios-simulator\n")); + assert!(static_manifest.contains("dependencyArchiveTargets=ios-simulator\n")); + assert!(static_manifest.contains("dependencyArchives=openssl\n")); + assert!(static_manifest.contains("module.acme_ext.archiveTargets=ios-simulator\n")); + assert!(static_manifest.contains( + "module.acme_ext.archive.ios-simulator=archives/ios-simulator/extensions/acme_ext/liboliphaunt_extension_acme_ext.a\n" + )); + assert!(static_manifest.contains("dependency.openssl.archiveTargets=ios-simulator\n")); + assert!(static_manifest.contains( + "dependency.openssl.archive.ios-simulator=archives/ios-simulator/dependencies/openssl/libcrypto.a\n" + )); + + write_file( + &temp.join("oliphaunt/static-registry/manifest.properties"), + b"state=complete\n", + ); + copy_portable_tree(&runtime_files, &temp.join("oliphaunt/runtime/files")).unwrap(); + copy_portable_tree( + &temp.join("template-pgdata"), + &temp.join("oliphaunt/template-pgdata"), + ) + .unwrap(); + let report = runtime_resource_size_report( + &temp.join("oliphaunt"), + &extensions, + Some("test-target"), + &pending_metadata, + ) + .unwrap(); + assert_eq!(report.extensions.len(), 1); + assert_eq!(report.extensions[0].name, "acme_ext"); + assert_eq!(report.extensions[0].file_count, 4); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_mobile_static_registry_skips_desktop_dynamic_module() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-mobile-static"); + let artifact = temp.join("acme_ext"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + true, + ); + let extensions = resolve_runtime_resource_extensions( + &[], + &[NativePrebuiltExtensionArtifact::new(&artifact)], + ) + .unwrap(); + let metadata = + mobile_static_registry_metadata(&extensions, &["acme_ext".to_owned()]).unwrap(); + let runtime_files = temp.join("runtime/files"); + copy_prebuilt_extension_artifacts( + &runtime_files, + &extensions, + Some("ios-xcframework"), + &metadata, + ) + .unwrap(); + + assert!( + runtime_files + .join("share/postgresql/extension/acme_ext.control") + .is_file() + ); + assert!( + !runtime_files + .join("lib/postgresql") + .join(format!("acme_ext{}", std::env::consts::DLL_SUFFIX)) + .exists(), + "mobile-static extension packaging must not copy a desktop dynamic module" + ); + let root = temp.join("oliphaunt"); + write_file( + &root.join("static-registry/manifest.properties"), + b"state=complete\n", + ); + copy_portable_tree(&runtime_files, &root.join("runtime/files")).unwrap(); + write_file(&root.join("template-pgdata/files/PG_VERSION"), b"18\n"); + let report = + runtime_resource_size_report(&root, &extensions, Some("ios-xcframework"), &metadata) + .unwrap(); + assert_eq!(report.extensions[0].file_count, 3); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn runtime_resource_tree_generates_static_registry_from_packaged_prebuilt_sql() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-packaged-static-registry"); + let base_runtime = temp.join("base-runtime"); + let template_pgdata = temp.join("template-pgdata"); + write_file( + &base_runtime.join("share/postgresql/postgresql.conf.sample"), + b"core-runtime\n", + ); + write_file( + &base_runtime.join("share/postgresql/extension/plpgsql.control"), + b"comment = 'must not leak'\n", + ); + write_file( + &base_runtime.join("share/postgresql/extension/plpgsql--1.0.sql"), + b"select 'must not leak';\n", + ); + write_file( + &base_runtime.join("share/postgresql/extension/acme_ext--base.sql"), + b"select 'base acme must not shadow prebuilt';\n", + ); + write_file( + &base_runtime + .join("lib/postgresql") + .join(format!("acme_ext{}", std::env::consts::DLL_SUFFIX)), + b"base-acme-module\n", + ); + write_file(&template_pgdata.join("PG_VERSION"), b"18\n"); + + let artifact = temp.join("acme_ext"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + true, + ); + let manifest = artifact.join("manifest.properties"); + let alias_line = "staticSymbolAliases=acme_ext_echo:acme_static_acme_ext_echo,pg_finfo_acme_ext_echo:acme_static_pg_finfo_acme_ext_echo,helper_symbol:acme_static_helper_symbol\n"; + let mut manifest_text = fs::read_to_string(&manifest).unwrap(); + if manifest_text.contains("staticSymbolAliases=\n") { + manifest_text = manifest_text.replace("staticSymbolAliases=\n", alias_line); + } else { + manifest_text.push_str(alias_line); + } + write_file(&manifest, manifest_text.as_bytes()); + let extensions = resolve_runtime_resource_extensions( + &[], + &[NativePrebuiltExtensionArtifact::new(&artifact)], + ) + .unwrap(); + let metadata = + mobile_static_registry_metadata(&extensions, &["acme_ext".to_owned()]).unwrap(); + + let root = temp.join("oliphaunt"); + write_runtime_resource_tree( + &root, + EngineMode::NativeServer, + &MaterializedNativeResources { + runtime_dir: base_runtime, + template_pgdata, + runtime_cache_key: "runtime-cache".to_owned(), + template_cache_key: "template-cache".to_owned(), + }, + &extensions, + &[], + &metadata, + Some("test-target"), + ) + .unwrap(); + + let registry_source = + fs::read_to_string(root.join("static-registry/oliphaunt_static_registry.c")).unwrap(); + assert!(registry_source.contains("liboliphaunt_selected_static_extensions")); + assert!( + registry_source.contains("acme_ext_echo"), + "static registry must parse SQL copied from the prebuilt extension artifact" + ); + assert!( + registry_source.contains("extern void acme_static_acme_ext_echo(void);"), + "static registry must reference aliased link-time symbols" + ); + assert!( + registry_source.contains( + "{ .name = \"acme_ext_echo\", .address = (void *)acme_static_acme_ext_echo }" + ), + "static registry must keep SQL symbol names while pointing at aliased symbols" + ); + assert!( + registry_source.contains( + "{ .name = \"helper_symbol\", .address = (void *)acme_static_helper_symbol }" + ), + "static registry must include explicit aliases outside main extension SQL" + ); + assert!( + root.join("runtime/files/share/postgresql/extension/acme_ext--1.0.sql") + .is_file(), + "prebuilt SQL must be part of the final runtime package" + ); + assert!( + !root + .join("runtime/files/share/postgresql/extension/plpgsql--1.0.sql") + .exists(), + "unselected built-in extension SQL must not leak into exact-extension packages" + ); + assert!( + !root + .join("runtime/files/share/postgresql/extension/acme_ext--base.sql") + .exists(), + "base runtime files for a prebuilt-selected extension must not shadow the exact artifact" + ); + assert!( + !root + .join("runtime/files/lib/postgresql") + .join(format!("acme_ext{}", std::env::consts::DLL_SUFFIX)) + .exists(), + "mobile-static prebuilt extensions must not retain base dynamic modules" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_artifact_rejects_missing_native_target() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-missing-target"); + let artifact = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let manifest = artifact.join("manifest.properties"); + let text = fs::read_to_string(&manifest).unwrap(); + fs::write(&manifest, text.replace("nativeTarget=test-target\n", "")).unwrap(); + + let error = load_prebuilt_extension_artifact(&artifact).unwrap_err(); + assert!( + error.to_string().contains("missing nativeTarget"), + "unexpected missing-target error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_artifact_rejects_wrong_runtime_target() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-wrong-target"); + let artifact = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let extensions = resolve_runtime_resource_extensions( + &[], + &[NativePrebuiltExtensionArtifact::new(&artifact)], + ) + .unwrap(); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + let error = copy_prebuilt_extension_artifacts( + &temp.join("runtime/files"), + &extensions, + Some("linux-x64-gnu"), + &metadata, + ) + .unwrap_err(); + assert!( + error.to_string().contains( + "prebuilt extension artifact for 'acme_ext' targets 'test-target', but runtime packaging target is 'linux-x64-gnu'" + ), + "unexpected wrong-target error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_tar_archive_is_validated_and_consumed() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-tar"); + let artifact = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar"); + write_tar_archive_from_dir(&archive, &artifact, "acme_ext"); + + let prepared = + PreparedPrebuiltExtensionArtifacts::prepare(&[NativePrebuiltExtensionArtifact::new( + &archive, + )]) + .unwrap(); + let extensions = resolve_runtime_resource_extensions(&[], prepared.artifacts()).unwrap(); + assert_eq!(selected_extension_names(&extensions), vec!["acme_ext"]); + assert_eq!( + extensions[0].native_module_stem.as_deref(), + Some("acme_ext") + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_artifact_rejects_mobile_archive_path_escape() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-mobile-path"); + let artifact = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + true, + ); + let wrong_relative = "files/lib/postgresql/liboliphaunt_extension_acme_ext.a"; + write_file(&artifact.join(wrong_relative), b"wrong-place-static\n"); + let manifest = artifact.join("manifest.properties"); + let text = fs::read_to_string(&manifest).unwrap(); + fs::write( + &manifest, + text.replace( + "mobileStaticArchives=ios-simulator:mobile-static/ios-simulator/extensions/acme_ext/liboliphaunt_extension_acme_ext.a\n", + &format!("mobileStaticArchives=ios-simulator:{wrong_relative}\n"), + ), + ) + .unwrap(); + + let error = load_prebuilt_extension_artifact(&artifact).unwrap_err(); + assert!( + error.to_string().contains("must use mobile-static"), + "unexpected mobile archive path error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_tar_zst_archive_is_validated_and_consumed() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-tar-zst"); + let artifact = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact, "acme_ext"); + + let prepared = + PreparedPrebuiltExtensionArtifacts::prepare(&[NativePrebuiltExtensionArtifact::new( + &archive, + )]) + .unwrap(); + let extensions = resolve_runtime_resource_extensions(&[], prepared.artifacts()).unwrap(); + assert_eq!(selected_extension_names(&extensions), vec!["acme_ext"]); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_archive_rejects_non_file_entries() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-tar-symlink"); + let archive_path = temp.join("malicious.tar"); + let mut bytes = Vec::new(); + { + let mut archive = tar::Builder::new(&mut bytes); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(EntryType::symlink()); + header.set_path("manifest.properties").unwrap(); + header.set_link_name("/tmp/not-allowed").unwrap(); + header.set_mode(0o777); + header.set_size(0); + header.set_cksum(); + archive.append(&header, std::io::empty()).unwrap(); + archive.finish().unwrap(); + } + write_file(&archive_path, &bytes); + + let error = + PreparedPrebuiltExtensionArtifacts::prepare(&[NativePrebuiltExtensionArtifact::new( + &archive_path, + )]) + .unwrap_err(); + assert!( + error + .to_string() + .contains("must be a regular file or directory"), + "unexpected symlink-entry error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn create_prebuilt_extension_artifact_copies_only_exact_declared_runtime_files() { + let temp = unique_temp_root("oliphaunt-create-prebuilt-extension-artifact"); + let runtime = temp.join("runtime"); + write_extension_source_runtime(&runtime, "acme_ext", "acme_ext.so"); + write_file( + &runtime.join("share/postgresql/extension/hstore.control"), + b"comment = 'should not leak'\n", + ); + write_file( + &runtime.join("share/postgresql/data/unused.rules"), + b"unused\n", + ); + let ios_archive = temp.join("liboliphaunt_extension_acme_ext_ios_simulator.a"); + write_file(&ios_archive, b"acme-ios-simulator-static\n"); + let ios_dependency_archive = temp.join("libcrypto.a"); + write_file(&ios_dependency_archive, b"acme-ios-simulator-libcrypto\n"); + let artifact_root = temp.join("artifact"); + + let created = create_prebuilt_extension_artifact( + NativeExtensionArtifactOptions::new(&artifact_root, &runtime, "acme_ext") + .native_module_stem("acme_ext") + .native_module_file("acme_ext.so") + .native_target("test-target") + .dependency("cube") + .data_file("data/acme_ext.rules") + .shared_preload_library("acme_preload") + .mobile_prebuilt(true) + .mobile_static_archive("ios-simulator", &ios_archive) + .mobile_static_dependency_archive( + "ios-simulator", + "openssl", + &ios_dependency_archive, + ) + .static_symbol_prefix("acme_static"), + ) + .unwrap(); + + assert_eq!(created.path, artifact_root); + assert_eq!(created.sql_name, "acme_ext"); + assert_eq!(created.format, NativeExtensionArtifactFormat::Directory); + assert!(created.manifest_path.unwrap().is_file()); + let manifest = fs::read_to_string(artifact_root.join("manifest.properties")).unwrap(); + assert!(manifest.contains("packageLayout=oliphaunt-extension-artifact-v1\n")); + assert!(manifest.contains("sqlName=acme_ext\n")); + assert!(manifest.contains("nativeModuleStem=acme_ext\n")); + assert!(manifest.contains("nativeModuleFile=acme_ext.so\n")); + assert!(manifest.contains("nativeTarget=test-target\n")); + assert!(manifest.contains("dependencies=cube\n")); + assert!(manifest.contains("dataFiles=data/acme_ext.rules\n")); + assert!(manifest.contains("sharedPreloadLibraries=acme_preload\n")); + assert!(manifest.contains("mobilePrebuilt=yes\n")); + assert!(manifest.contains( + "mobileStaticArchives=ios-simulator:mobile-static/ios-simulator/extensions/acme_ext/liboliphaunt_extension_acme_ext.a\n" + )); + assert!(manifest.contains( + "mobileStaticDependencyArchives=ios-simulator:openssl:mobile-static/ios-simulator/dependencies/openssl/libcrypto.a\n" + )); + assert!(manifest.contains("staticSymbolPrefix=acme_static\n")); + assert!( + artifact_root + .join("files/share/postgresql/extension/acme_ext.control") + .is_file() + ); + assert!( + artifact_root + .join("files/share/postgresql/extension/acme_ext--1.0.sql") + .is_file() + ); + assert!( + artifact_root + .join("files/lib/postgresql/acme_ext.so") + .is_file() + ); + assert!( + artifact_root + .join("mobile-static/ios-simulator/extensions/acme_ext/liboliphaunt_extension_acme_ext.a") + .is_file() + ); + assert!( + artifact_root + .join("mobile-static/ios-simulator/dependencies/openssl/libcrypto.a") + .is_file() + ); + assert!( + !artifact_root + .join("files/share/postgresql/extension/hstore.control") + .exists() + ); + assert!( + !artifact_root + .join("files/share/postgresql/data/unused.rules") + .exists() + ); + + let loaded = load_prebuilt_extension_artifact(&artifact_root).unwrap(); + assert_eq!(loaded.sql_name, "acme_ext"); + assert_eq!(loaded.native_module_file.as_deref(), Some("acme_ext.so")); + assert_eq!(loaded.native_target.as_deref(), Some("test-target")); + assert_eq!(loaded.dependencies, vec!["cube"]); + assert_eq!(loaded.shared_preload_libraries, vec!["acme_preload"]); + assert!(loaded.mobile_prebuilt); + assert_eq!( + mobile_static_archive_targets(&loaded.mobile_static_archives), + vec!["ios-simulator"] + ); + assert_eq!(loaded.mobile_static_dependency_archives.len(), 1); + assert_eq!( + loaded.mobile_static_dependency_archives[0].relative_path, + PathBuf::from("mobile-static/ios-simulator/dependencies/openssl/libcrypto.a") + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn create_prebuilt_extension_tar_zst_artifact_roundtrips_through_consumer() { + let temp = unique_temp_root("oliphaunt-create-prebuilt-extension-tar-zst"); + let runtime = temp.join("runtime"); + write_extension_source_runtime( + &runtime, + "acme_ext", + &format!("acme_ext{}", std::env::consts::DLL_SUFFIX), + ); + write_file( + &runtime.join("share/postgresql/extension/hstore.control"), + b"comment = 'should not leak'\n", + ); + let archive = temp.join("acme_ext.tar.zst"); + + let created = create_prebuilt_extension_artifact( + NativeExtensionArtifactOptions::new(&archive, &runtime, "acme_ext") + .native_module_stem("acme_ext") + .native_target("test-target") + .data_file("data/acme_ext.rules") + .format(NativeExtensionArtifactFormat::TarZst), + ) + .unwrap(); + assert_eq!(created.path, archive); + assert_eq!(created.format, NativeExtensionArtifactFormat::TarZst); + assert!(created.manifest_path.is_none()); + + let prepared = + PreparedPrebuiltExtensionArtifacts::prepare(&[NativePrebuiltExtensionArtifact::new( + &created.path, + )]) + .unwrap(); + let extensions = resolve_runtime_resource_extensions(&[], prepared.artifacts()).unwrap(); + assert_eq!(selected_extension_names(&extensions), vec!["acme_ext"]); + + let runtime_files = temp.join("packaged-runtime"); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + copy_prebuilt_extension_artifacts( + &runtime_files, + &extensions, + Some("test-target"), + &metadata, + ) + .unwrap(); + assert!( + runtime_files + .join("share/postgresql/extension/acme_ext.control") + .is_file() + ); + assert!( + !runtime_files + .join("share/postgresql/extension/hstore.control") + .exists(), + "producer archives must preserve selected-only consumer behavior" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn create_prebuilt_extension_tar_gz_artifact_roundtrips_through_consumer() { + let temp = unique_temp_root("oliphaunt-create-prebuilt-extension-tar-gz"); + let runtime = temp.join("runtime"); + write_extension_source_runtime( + &runtime, + "acme_ext", + &format!("acme_ext{}", std::env::consts::DLL_SUFFIX), + ); + write_file( + &runtime.join("share/postgresql/extension/hstore.control"), + b"comment = 'should not leak'\n", + ); + let archive = temp.join("acme_ext.tar.gz"); + + let created = create_prebuilt_extension_artifact( + NativeExtensionArtifactOptions::new(&archive, &runtime, "acme_ext") + .native_module_stem("acme_ext") + .native_target("test-target") + .data_file("data/acme_ext.rules") + .format(NativeExtensionArtifactFormat::TarGz), + ) + .unwrap(); + assert_eq!(created.path, archive); + assert_eq!(created.format, NativeExtensionArtifactFormat::TarGz); + assert!(created.manifest_path.is_none()); + + let prepared = + PreparedPrebuiltExtensionArtifacts::prepare(&[NativePrebuiltExtensionArtifact::new( + &created.path, + )]) + .unwrap(); + let extensions = resolve_runtime_resource_extensions(&[], prepared.artifacts()).unwrap(); + assert_eq!(selected_extension_names(&extensions), vec!["acme_ext"]); + + let runtime_files = temp.join("packaged-runtime"); + let metadata = mobile_static_registry_metadata(&extensions, &[]).unwrap(); + copy_prebuilt_extension_artifacts( + &runtime_files, + &extensions, + Some("test-target"), + &metadata, + ) + .unwrap(); + assert!( + runtime_files + .join("share/postgresql/extension/acme_ext.control") + .is_file() + ); + assert!( + !runtime_files + .join("share/postgresql/extension/hstore.control") + .exists(), + "gzip producer archives must preserve selected-only consumer behavior" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn extension_artifact_index_resolves_verified_dependency_closure() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index"); + let dep_artifact_root = temp.join("dep-root"); + write_prebuilt_extension_artifact( + &dep_artifact_root, + "acme_dep", + "acme_dep", + "acme_dep_static", + "data/acme_ext.rules", + false, + ); + let dep_archive = temp.join("acme_dep.tar.zst"); + write_tar_zst_archive_from_dir(&dep_archive, &dep_artifact_root, "acme_dep"); + + let ext_artifact_root = temp.join("ext-root"); + write_prebuilt_extension_artifact( + &ext_artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let manifest = ext_artifact_root.join("manifest.properties"); + let text = fs::read_to_string(&manifest).unwrap(); + fs::write( + &manifest, + text.replace("dependencies=\n", "dependencies=acme_dep\n"), + ) + .unwrap(); + let ext_archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&ext_archive, &ext_artifact_root, "acme_ext"); + + let index = temp.join("extensions.toml"); + write_extension_artifact_index( + &index, + "test-target", + &[("acme_dep", &dep_archive), ("acme_ext", &ext_archive)], + ); + + let resolution = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&index) + .extension("acme_ext"), + ) + .unwrap(); + assert_eq!(resolution.extension_names, vec!["acme_dep", "acme_ext"]); + assert_eq!(resolution.artifacts.len(), 2); + assert_eq!(resolution.artifacts[0].root, dep_archive); + assert_eq!(resolution.artifacts[1].root, ext_archive); + + let prepared = PreparedPrebuiltExtensionArtifacts::prepare(&resolution.artifacts).unwrap(); + let extensions = resolve_runtime_resource_extensions(&[], prepared.artifacts()).unwrap(); + assert_eq!( + selected_extension_names(&extensions), + vec!["acme_dep", "acme_ext"] + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn create_extension_artifact_index_writes_canonical_verified_toml() { + let temp = unique_temp_root("oliphaunt-create-extension-artifact-index"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + + let created = create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(&index, "test-target") + .artifact(&archive), + ) + .unwrap(); + assert_eq!(created.path, index); + assert_eq!(created.target, "test-target"); + assert_eq!(created.artifacts.len(), 1); + assert_eq!(created.artifacts[0].sql_name, "acme_ext"); + assert!(created.artifacts[0].creates_extension); + assert_eq!( + created.artifacts[0].native_module_stem.as_deref(), + Some("acme_ext") + ); + assert_eq!(created.artifacts[0].dependencies, Vec::::new()); + assert_eq!( + created.artifacts[0].shared_preload_libraries, + Vec::::new() + ); + assert!(!created.artifacts[0].mobile_prebuilt); + assert_eq!(created.artifacts[0].path, PathBuf::from("acme_ext.tar.zst")); + assert_eq!( + created.artifacts[0].bytes, + fs::metadata(&archive).unwrap().len() + ); + assert_eq!( + created.artifacts[0].sha256, + sha256_file_hex(&archive).unwrap() + ); + + let text = fs::read_to_string(&created.path).unwrap(); + assert!(text.contains("schema = \"oliphaunt-extension-artifact-index-v1\"\n")); + assert!(text.contains("pg_major = 18\n")); + assert!(text.contains("sql_name = \"acme_ext\"\n")); + assert!(text.contains("target = \"test-target\"\n")); + assert!(text.contains("creates_extension = true\n")); + assert!(text.contains("native_module_stem = \"acme_ext\"\n")); + assert!(text.contains("dependencies = []\n")); + assert!(text.contains("shared_preload_libraries = []\n")); + assert!(text.contains("mobile_prebuilt = false\n")); + assert!(text.contains("path = \"acme_ext.tar.zst\"\n")); + assert!(text.contains("sha256 = \"")); + assert!(text.contains("bytes = ")); + + let resolved = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&created.path) + .extension("acme_ext"), + ) + .unwrap(); + assert_eq!(resolved.extension_names, vec!["acme_ext"]); + assert_eq!(resolved.artifacts[0].root, archive); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn extension_artifact_index_catalog_lists_external_metadata_without_native_env() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index-catalog"); + let runtime = temp.join("runtime"); + write_extension_source_runtime(&runtime, "acme_ext", "acme_ext.so"); + let ios_archive = temp.join("liboliphaunt_extension_acme_ext_ios_simulator.a"); + write_file(&ios_archive, b"acme-ios-simulator-static\n"); + let archive = temp.join("acme_ext.tar.zst"); + + create_prebuilt_extension_artifact( + NativeExtensionArtifactOptions::new(&archive, &runtime, "acme_ext") + .native_module_stem("acme_ext") + .native_module_file("acme_ext.so") + .native_target("test-target") + .dependency("cube") + .shared_preload_library("acme_preload") + .mobile_prebuilt(true) + .mobile_static_archive("ios-simulator", &ios_archive) + .static_symbol_prefix("acme_static") + .format(NativeExtensionArtifactFormat::TarZst), + ) + .unwrap(); + + let index = temp.join("extensions.toml"); + create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(&index, "test-target") + .artifact(&archive), + ) + .unwrap(); + + let catalog = list_prebuilt_extension_artifact_index_catalog( + NativeExtensionArtifactIndexOptions::new("test-target").index(&index), + ) + .unwrap(); + + assert_eq!(catalog.extensions.len(), 1); + let entry = &catalog.extensions[0]; + assert_eq!(entry.sql_name, "acme_ext"); + assert_eq!(entry.target, "test-target"); + assert!(entry.creates_extension); + assert_eq!(entry.native_module_stem.as_deref(), Some("acme_ext")); + assert_eq!(entry.dependencies, vec!["cube"]); + assert_eq!(entry.shared_preload_libraries, vec!["acme_preload"]); + assert!(entry.mobile_prebuilt); + assert_eq!(entry.mobile_static_archive_targets, vec!["ios-simulator"]); + + let other_target = list_prebuilt_extension_artifact_index_catalog( + NativeExtensionArtifactIndexOptions::new("other-target").index(&index), + ) + .unwrap(); + assert!(other_target.extensions.is_empty()); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn extension_artifact_index_downloads_url_backed_artifacts_to_verified_cache() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index-download"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("published/acme_ext.tar.zst"); + fs::create_dir_all(archive.parent().unwrap()).unwrap(); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let bytes = fs::metadata(&archive).unwrap().len(); + let sha256 = sha256_file_hex(&archive).unwrap(); + + let index = temp.join("index/extensions.toml"); + fs::create_dir_all(index.parent().unwrap()).unwrap(); + fs::write( + &index, + format!( + "\ +schema = \"oliphaunt-extension-artifact-index-v1\" +pg_major = 18 + +[[artifacts]] +sql_name = \"acme_ext\" +target = \"test-target\" +path = \"downloads/acme_ext.tar.zst\" +url = \"file://{}\" +sha256 = \"{sha256}\" +bytes = {bytes} +", + archive.display() + ), + ) + .unwrap(); + let cache = temp.join("cache"); + + let resolution = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&index) + .extension("acme_ext") + .artifact_cache_dir(&cache), + ) + .unwrap(); + + let cached = cache.join("test-target/downloads/acme_ext.tar.zst"); + assert!(cached.is_file()); + assert_eq!(resolution.extension_names, vec!["acme_ext"]); + assert_eq!(resolution.artifacts[0].root, cached); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn extension_artifact_index_requires_cache_for_url_backed_missing_artifacts() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index-download-cache"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("published/acme_ext.tar.zst"); + fs::create_dir_all(archive.parent().unwrap()).unwrap(); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + fs::write( + &index, + format!( + "\ +schema = \"oliphaunt-extension-artifact-index-v1\" +pg_major = 18 + +[[artifacts]] +sql_name = \"acme_ext\" +target = \"test-target\" +path = \"missing/acme_ext.tar.zst\" +url = \"file://{}\" +sha256 = \"{}\" +bytes = {} +", + archive.display(), + sha256_file_hex(&archive).unwrap(), + fs::metadata(&archive).unwrap().len() + ), + ) + .unwrap(); + + let error = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&index) + .extension("acme_ext"), + ) + .unwrap_err(); + assert!( + error.to_string().contains("--extension-cache"), + "unexpected missing cache error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn create_extension_artifact_index_can_publish_url_rows() { + let temp = unique_temp_root("oliphaunt-create-extension-artifact-index-url"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + + let created = create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(&index, "test-target") + .artifact(&archive) + .artifact_base_url("https://example.invalid/oliphaunt/extensions"), + ) + .unwrap(); + + assert_eq!( + created.artifacts[0].url.as_deref(), + Some("https://example.invalid/oliphaunt/extensions/acme_ext.tar.zst") + ); + let text = fs::read_to_string(&created.path).unwrap(); + assert!( + text.contains( + "url = \"https://example.invalid/oliphaunt/extensions/acme_ext.tar.zst\"\n" + ) + ); + + let _ = fs::remove_dir_all(temp); + } + + #[cfg(feature = "extension-signing")] + #[test] + fn extension_artifact_index_signature_verifies_trusted_publisher_key() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index-signature"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(&index, "test-target") + .artifact(&archive), + ) + .unwrap(); + let (signing_key, public_key) = test_extension_index_key_pair(); + let signature = sign_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexSigningOptions::new(&index, "test-publisher", signing_key), + ) + .unwrap(); + assert!(signature.path.is_file()); + assert_eq!(signature.public_key_hex, public_key); + + let resolution = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&index) + .extension("acme_ext") + .trusted_signing_key(NativeExtensionArtifactIndexTrustRoot::new( + "test-publisher", + public_key, + )) + .require_signatures(true), + ) + .unwrap(); + assert_eq!(resolution.extension_names, vec!["acme_ext"]); + + let _ = fs::remove_dir_all(temp); + } + + #[cfg(feature = "extension-signing")] + #[test] + fn extension_artifact_index_signature_rejects_modified_index() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index-signature-modified"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(&index, "test-target") + .artifact(&archive), + ) + .unwrap(); + let (signing_key, public_key) = test_extension_index_key_pair(); + sign_prebuilt_extension_artifact_index(NativeExtensionArtifactIndexSigningOptions::new( + &index, + "test-publisher", + signing_key, + )) + .unwrap(); + let mut index_text = fs::read_to_string(&index).unwrap(); + index_text.push('\n'); + fs::write(&index, index_text).unwrap(); + + let error = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&index) + .extension("acme_ext") + .trusted_signing_key(NativeExtensionArtifactIndexTrustRoot::new( + "test-publisher", + public_key, + )) + .require_signatures(true), + ) + .unwrap_err(); + assert!( + error.to_string().contains("failed verification"), + "unexpected modified signature error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[cfg(feature = "extension-signing")] + #[test] + fn extension_artifact_index_requires_signature_when_trust_is_required() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index-signature-required"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(&index, "test-target") + .artifact(&archive), + ) + .unwrap(); + let (_, public_key) = test_extension_index_key_pair(); + + let error = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&index) + .extension("acme_ext") + .trusted_signing_key(NativeExtensionArtifactIndexTrustRoot::new( + "test-publisher", + public_key, + )) + .require_signatures(true), + ) + .unwrap_err(); + assert!( + error.to_string().contains(".sig"), + "unexpected missing signature error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn create_extension_artifact_index_rejects_artifacts_outside_index_dir() { + let temp = unique_temp_root("oliphaunt-create-extension-artifact-index-outside"); + let outside = unique_temp_root("oliphaunt-extension-artifact-outside"); + let artifact_root = outside.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = outside.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + + let error = create_prebuilt_extension_artifact_index( + NativeExtensionArtifactIndexCreateOptions::new(&index, "test-target") + .artifact(&archive), + ) + .unwrap_err(); + assert!( + error.to_string().contains("must be inside index directory"), + "unexpected outside-index error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + let _ = fs::remove_dir_all(outside); + } + + #[test] + fn extension_artifact_index_rejects_checksum_mismatch() { + let temp = unique_temp_root("oliphaunt-extension-artifact-index-checksum"); + let artifact_root = temp.join("artifact-root"); + write_prebuilt_extension_artifact( + &artifact_root, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + false, + ); + let archive = temp.join("acme_ext.tar.zst"); + write_tar_zst_archive_from_dir(&archive, &artifact_root, "acme_ext"); + let index = temp.join("extensions.toml"); + let bytes = fs::metadata(&archive).unwrap().len(); + fs::write( + &index, + format!( + "\ +schema = \"oliphaunt-extension-artifact-index-v1\" +pg_major = 18 + +[[artifacts]] +sql_name = \"acme_ext\" +target = \"test-target\" +path = \"acme_ext.tar.zst\" +sha256 = \"{}\" +bytes = {bytes} +", + "0".repeat(64) + ), + ) + .unwrap(); + + let error = resolve_prebuilt_extension_artifacts_from_indexes( + NativeExtensionArtifactIndexOptions::new("test-target") + .index(&index) + .extension("acme_ext"), + ) + .unwrap_err(); + assert!( + error.to_string().contains("has sha256"), + "unexpected checksum error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_artifact_can_override_builtin_artifact_payload() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-override"); + let artifact = temp.join("vector"); + write_prebuilt_extension_artifact( + &artifact, + "vector", + "vector", + "oliphaunt_static_vector", + "data/vector.rules", + true, + ); + + let resolved = resolve_runtime_resource_extensions( + &[], + &[NativePrebuiltExtensionArtifact::new(&artifact)], + ) + .unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].sql_name, "vector"); + assert!(matches!( + resolved[0].source, + RuntimeResourceExtensionSource::Prebuilt { .. } + )); + assert_eq!(resolved[0].mobile_static_archives.len(), 1); + + let _ = fs::remove_dir_all(temp); + } + + #[test] + fn prebuilt_extension_artifact_dependencies_must_be_available() { + let temp = unique_temp_root("oliphaunt-prebuilt-extension-missing-dependency"); + let artifact = temp.join("acme_ext"); + write_prebuilt_extension_artifact( + &artifact, + "acme_ext", + "acme_ext", + "acme_static", + "data/acme_ext.rules", + true, + ); + let manifest = artifact.join("manifest.properties"); + let text = fs::read_to_string(&manifest).unwrap(); + fs::write( + &manifest, + text.replace("dependencies=\n", "dependencies=missing_ext\n"), + ) + .unwrap(); + + let error = resolve_runtime_resource_extensions( + &[], + &[NativePrebuiltExtensionArtifact::new(&artifact)], + ) + .unwrap_err(); + assert!( + error.to_string().contains( + "selected extension 'missing_ext' is neither built into this Oliphaunt release nor provided as a prebuilt extension artifact" + ), + "unexpected missing-dependency error: {error}" + ); + + let _ = fs::remove_dir_all(temp); + } + + fn write_file(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directory"); + } + fs::write(path, contents).expect("write fixture file"); + } + + fn write_prebuilt_extension_artifact( + root: &Path, + sql_name: &str, + module_stem: &str, + static_symbol_prefix: &str, + data_file: &str, + mobile_prebuilt: bool, + ) { + let mobile_static_archives = if mobile_prebuilt { + format!( + "ios-simulator:mobile-static/ios-simulator/extensions/{module_stem}/liboliphaunt_extension_{module_stem}.a" + ) + } else { + String::new() + }; + let mobile_static_dependency_archives = if mobile_prebuilt { + "ios-simulator:openssl:mobile-static/ios-simulator/dependencies/openssl/libcrypto.a" + .to_owned() + } else { + String::new() + }; + write_file( + &root.join("manifest.properties"), + format!( + "\ +packageLayout=oliphaunt-extension-artifact-v1 +pgMajor=18 +sqlName={sql_name} +createsExtension=true +nativeModuleStem={module_stem} +nativeModuleFile= +nativeTarget=test-target +dependencies= +dataFiles={data_file} +sharedPreloadLibraries= +mobilePrebuilt={} +mobileStaticArchives={mobile_static_archives} +mobileStaticDependencyArchives={mobile_static_dependency_archives} +staticSymbolPrefix={static_symbol_prefix} +files=files +", + if mobile_prebuilt { "yes" } else { "no" } + ) + .as_bytes(), + ); + write_file( + &root + .join("files/share/postgresql/extension") + .join(format!("{sql_name}.control")), + b"comment = 'acme extension'\n", + ); + write_file( + &root + .join("files/share/postgresql/extension") + .join(format!("{sql_name}--1.0.sql")), + b"CREATE FUNCTION acme_ext_echo(integer) RETURNS integer AS 'MODULE_PATHNAME' LANGUAGE C STRICT;\n", + ); + write_file( + &root.join("files/share/postgresql").join(data_file), + b"acme-data\n", + ); + write_file( + &root + .join("files/lib/postgresql") + .join(format!("{module_stem}{}", std::env::consts::DLL_SUFFIX)), + b"acme-module\n", + ); + if mobile_prebuilt { + write_file( + &root + .join("mobile-static/ios-simulator/extensions") + .join(module_stem) + .join(format!("liboliphaunt_extension_{module_stem}.a")), + b"acme-ios-simulator-static\n", + ); + write_file( + &root.join("mobile-static/ios-simulator/dependencies/openssl/libcrypto.a"), + b"acme-ios-simulator-libcrypto\n", + ); + } + } + + fn write_extension_source_runtime(root: &Path, sql_name: &str, module_file: &str) { + write_file( + &root + .join("share/postgresql/extension") + .join(format!("{sql_name}.control")), + b"comment = 'acme extension'\n", + ); + write_file( + &root + .join("share/postgresql/extension") + .join(format!("{sql_name}--1.0.sql")), + b"CREATE FUNCTION acme_ext_echo(integer) RETURNS integer AS 'MODULE_PATHNAME' LANGUAGE C STRICT;\n", + ); + write_file( + &root.join("share/postgresql/data/acme_ext.rules"), + b"acme-data\n", + ); + write_file( + &root.join("lib/postgresql").join(module_file), + b"acme-module\n", + ); + } + + fn write_tar_archive_from_dir(archive_path: &Path, source: &Path, prefix: &str) { + if let Some(parent) = archive_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let file = File::create(archive_path).unwrap(); + let mut archive = tar::Builder::new(file); + archive.append_dir_all(prefix, source).unwrap(); + archive.finish().unwrap(); + } + + fn write_tar_zst_archive_from_dir(archive_path: &Path, source: &Path, prefix: &str) { + let tar_path = archive_path.with_extension("tar"); + write_tar_archive_from_dir(&tar_path, source, prefix); + let tar_bytes = fs::read(&tar_path).unwrap(); + let compressed = zstd::stream::encode_all(tar_bytes.as_slice(), 0).unwrap(); + write_file(archive_path, &compressed); + let _ = fs::remove_file(tar_path); + } + + fn write_extension_artifact_index(index: &Path, target: &str, artifacts: &[(&str, &Path)]) { + let mut text = String::from( + "\ +schema = \"oliphaunt-extension-artifact-index-v1\" +pg_major = 18 +", + ); + for (sql_name, artifact) in artifacts { + let file_name = artifact.file_name().unwrap().to_string_lossy(); + let bytes = fs::metadata(artifact).unwrap().len(); + let sha256 = sha256_file_hex(artifact).unwrap(); + text.push_str(&format!( + "\n[[artifacts]]\nsql_name = \"{sql_name}\"\ntarget = \"{target}\"\npath = \"{file_name}\"\nsha256 = \"{sha256}\"\nbytes = {bytes}\n" + )); + } + write_file(index, text.as_bytes()); + } + + #[cfg(feature = "extension-signing")] + fn test_extension_index_key_pair() -> (String, String) { + use ed25519_dalek::SigningKey; + + let signing_key_bytes = [7u8; 32]; + let signing_key = SigningKey::from_bytes(&signing_key_bytes); + ( + hex_bytes(&signing_key_bytes), + hex_bytes(&signing_key.verifying_key().to_bytes()), + ) + } + + fn runtime_resource_extensions(extensions: &[Extension]) -> Vec { + extensions + .iter() + .copied() + .map(built_in_runtime_resource_extension) + .collect() + } + + fn unique_temp_root(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) + } +} diff --git a/src/sdks/rust/src/runtime_resources/extension_artifact.rs b/src/sdks/rust/src/runtime_resources/extension_artifact.rs new file mode 100644 index 00000000..7ab5a631 --- /dev/null +++ b/src/sdks/rust/src/runtime_resources/extension_artifact.rs @@ -0,0 +1,1094 @@ +use super::*; +use std::path::Component; + +/// Create one exact prebuilt extension artifact from already-built PostgreSQL +/// runtime files. +/// +/// This is the producer-side companion to `--prebuilt-extension`: it copies +/// only the selected extension's declared control, SQL, data, and native module +/// files into the portable artifact schema. It never builds PostgreSQL or +/// extension source. +pub fn create_prebuilt_extension_artifact( + options: NativeExtensionArtifactOptions, +) -> Result { + validate_extension_artifact_options(&options)?; + + let output = options.output.clone(); + let mut staging_root = None; + let artifact_root = match options.format { + NativeExtensionArtifactFormat::Directory => { + prepare_output_root(&output, options.replace_existing)?; + output.clone() + } + NativeExtensionArtifactFormat::Tar + | NativeExtensionArtifactFormat::TarGz + | NativeExtensionArtifactFormat::TarZst => { + prepare_output_file(&output, options.replace_existing)?; + let staging = RemoveOnDrop::create(unique_extension_artifact_staging_root())?; + let path = staging.path.clone(); + staging_root = Some(staging); + path + } + }; + + write_prebuilt_extension_artifact_directory(&artifact_root, &options)?; + let loaded = load_prebuilt_extension_artifact(&artifact_root)?; + if loaded.sql_name != options.sql_name { + return Err(Error::Engine(format!( + "created prebuilt extension artifact for '{}', expected '{}'", + loaded.sql_name, options.sql_name + ))); + } + + if options.format != NativeExtensionArtifactFormat::Directory { + write_prebuilt_extension_artifact_archive(&artifact_root, &output, options.format)?; + if let Some(mut staging) = staging_root { + staging.remove()?; + } + } + + Ok(NativeExtensionArtifact { + path: output.clone(), + manifest_path: (options.format == NativeExtensionArtifactFormat::Directory) + .then(|| output.join("manifest.properties")), + sql_name: options.sql_name, + format: options.format, + }) +} + +pub(super) fn unique_timestamp_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + format!("{}-{nanos}", std::process::id()) +} + +pub(super) fn sha256_file_hex(path: &Path) -> Result { + let mut file = File::open(path).map_err(|err| { + Error::InvalidConfig(format!("open {} for sha256: {err}", path.display())) + })?; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher) + .map_err(|err| Error::Engine(format!("hash {}: {err}", path.display())))?; + Ok(format!("{:x}", hasher.finalize())) +} + +#[derive(Debug)] +struct RemoveOnDrop { + path: PathBuf, + removed: bool, +} + +impl RemoveOnDrop { + fn create(path: PathBuf) -> Result { + fs::create_dir_all(&path).map_err(|err| { + Error::Engine(format!( + "create prebuilt extension artifact staging root {}: {err}", + path.display() + )) + })?; + Ok(Self { + path, + removed: false, + }) + } + + fn remove(&mut self) -> Result<()> { + if self.removed { + return Ok(()); + } + fs::remove_dir_all(&self.path).map_err(|err| { + Error::Engine(format!( + "remove prebuilt extension artifact staging root {}: {err}", + self.path.display() + )) + })?; + self.removed = true; + Ok(()) + } +} + +impl Drop for RemoveOnDrop { + fn drop(&mut self) { + if !self.removed { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +fn validate_extension_artifact_options(options: &NativeExtensionArtifactOptions) -> Result<()> { + if options.output.as_os_str().is_empty() { + return Err(Error::InvalidConfig( + "prebuilt extension artifact output path must not be empty".to_owned(), + )); + } + if options.runtime_files.as_os_str().is_empty() { + return Err(Error::InvalidConfig( + "prebuilt extension artifact runtime root must not be empty".to_owned(), + )); + } + if !options.runtime_files.is_dir() { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact runtime root {} must be an existing directory", + options.runtime_files.display() + ))); + } + validate_portable_id(&options.sql_name, "prebuilt extension sqlName")?; + for dependency in &options.dependencies { + validate_portable_id(dependency, "prebuilt extension dependency")?; + } + for library in &options.shared_preload_libraries { + validate_portable_id(library, "prebuilt extension shared preload library")?; + } + if let Some(stem) = &options.native_module_stem { + validate_portable_id(stem, "prebuilt extension native module stem")?; + } + if let Some(file_name) = &options.native_module_file { + validate_portable_id(file_name, "prebuilt extension native module file")?; + if options.native_module_stem.is_none() { + return Err(Error::InvalidConfig( + "prebuilt extension nativeModuleFile requires nativeModuleStem".to_owned(), + )); + } + } + if let Some(target) = &options.native_target { + validate_portable_id(target, "prebuilt extension native target")?; + } + if options.native_module_stem.is_some() && options.native_target.is_none() { + return Err(Error::InvalidConfig( + "prebuilt extension artifacts with nativeModuleStem must declare nativeTarget" + .to_owned(), + )); + } + if let Some(prefix) = &options.static_symbol_prefix { + if !is_c_identifier(prefix) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension static symbol prefix '{prefix}' must be a portable C identifier" + ))); + } + } + let mut alias_sql_symbols = BTreeSet::new(); + for alias in &options.static_symbol_aliases { + if !is_c_identifier(&alias.sql_symbol) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension static symbol alias '{}' must use a portable C identifier", + alias.sql_symbol + ))); + } + if !is_c_identifier(&alias.linked_symbol) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension static symbol alias target '{}' must use a portable C identifier", + alias.linked_symbol + ))); + } + if !alias_sql_symbols.insert(alias.sql_symbol.clone()) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension repeats static symbol alias for '{}'", + alias.sql_symbol + ))); + } + } + if !options.mobile_static_archives.is_empty() && options.native_module_stem.is_none() { + return Err(Error::InvalidConfig( + "prebuilt extension mobile static archives require nativeModuleStem".to_owned(), + )); + } + let mobile_prebuilt = artifact_mobile_prebuilt(options); + if mobile_prebuilt + && options.native_module_stem.is_some() + && options.mobile_static_archives.is_empty() + { + return Err(Error::InvalidConfig( + "mobilePrebuilt native-module artifacts must carry at least one mobile static archive" + .to_owned(), + )); + } + let mut mobile_targets = BTreeSet::new(); + for archive in &options.mobile_static_archives { + validate_portable_id( + &archive.target, + "prebuilt extension mobile static archive target", + )?; + if !mobile_targets.insert(archive.target.clone()) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension mobile static archives repeat target '{}'", + archive.target + ))); + } + if !archive.archive.is_file() { + return Err(Error::InvalidConfig(format!( + "prebuilt extension mobile static archive for target '{}' must be a file: {}", + archive.target, + archive.archive.display() + ))); + } + } + let mut mobile_dependency_keys = BTreeSet::new(); + for archive in &options.mobile_static_dependency_archives { + validate_portable_id( + &archive.target, + "prebuilt extension mobile static dependency archive target", + )?; + validate_portable_id( + &archive.name, + "prebuilt extension mobile static dependency archive name", + )?; + if !mobile_targets.contains(&archive.target) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension mobile static dependency archive '{}' for target '{}' requires a matching mobile static archive target", + archive.name, archive.target + ))); + } + if !mobile_dependency_keys.insert((archive.target.clone(), archive.name.clone())) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension mobile static dependency archives repeat '{}' for target '{}'", + archive.name, archive.target + ))); + } + validate_mobile_static_dependency_archive_file_name(&archive.archive)?; + if !archive.archive.is_file() { + return Err(Error::InvalidConfig(format!( + "prebuilt extension mobile static dependency archive '{}' for target '{}' must be a file: {}", + archive.name, + archive.target, + archive.archive.display() + ))); + } + } + for data_file in &options.data_files { + validate_relative_artifact_path(&options.output, "data file", data_file)?; + if data_file + .components() + .next() + .and_then(|component| match component { + Component::Normal(value) => value.to_str(), + _ => None, + }) + == Some("extension") + { + return Err(Error::InvalidConfig(format!( + "prebuilt extension data file '{}' must not be under share/postgresql/extension; control and SQL files are selected from sqlName", + data_file.display() + ))); + } + } + Ok(()) +} + +pub(super) fn prepare_output_file(path: &Path, replace_existing: bool) -> Result<()> { + if path.exists() { + if !replace_existing { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact output {} already exists; pass --force or replace_existing(true)", + path.display() + ))); + } + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } + .map_err(|err| Error::Engine(format!("remove {}: {err}", path.display())))?; + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|err| Error::Engine(format!("create {}: {err}", parent.display())))?; + } + Ok(()) +} + +fn write_prebuilt_extension_artifact_directory( + artifact_root: &Path, + options: &NativeExtensionArtifactOptions, +) -> Result<()> { + let extension = artifact_options_runtime_resource_extension(options, artifact_root)?; + copy_extension_artifact_sql_files( + artifact_root, + &options.runtime_files, + &artifact_root.join("files"), + &extension, + )?; + for relative in &extension.data_files { + copy_artifact_source_file( + &options.runtime_files, + &artifact_root.join("files"), + &PathBuf::from("share/postgresql").join(relative), + )?; + } + if let Some(module_file) = &extension.native_module_file { + copy_artifact_source_file( + &options.runtime_files, + &artifact_root.join("files"), + &PathBuf::from("lib/postgresql").join(module_file), + )?; + } + copy_mobile_static_archives_to_artifact(artifact_root, options, &extension)?; + copy_mobile_static_dependency_archives_to_artifact(artifact_root, options, &extension)?; + write_prebuilt_extension_artifact_manifest(artifact_root, options, &extension)?; + Ok(()) +} + +fn artifact_options_runtime_resource_extension( + options: &NativeExtensionArtifactOptions, + artifact_root: &Path, +) -> Result { + let native_module_file = options.native_module_stem.as_ref().map(|stem| { + options + .native_module_file + .clone() + .unwrap_or_else(|| format!("{}{}", stem, std::env::consts::DLL_SUFFIX)) + }); + Ok(RuntimeResourceExtension { + sql_name: options.sql_name.clone(), + creates_extension: options.creates_extension, + native_module_stem: options.native_module_stem.clone(), + native_module_file, + native_target: options.native_target.clone(), + dependencies: sorted_deduped_strings(&options.dependencies), + data_files: sorted_deduped_paths(&options.data_files), + shared_preload_libraries: sorted_deduped_strings(&options.shared_preload_libraries), + mobile_prebuilt: artifact_mobile_prebuilt(options), + mobile_static_archives: mobile_static_archives_for_artifact_options(options), + mobile_static_dependency_archives: mobile_static_dependency_archives_for_artifact_options( + options, + )?, + static_symbol_prefix: options.static_symbol_prefix.clone(), + static_symbol_aliases: sorted_static_symbol_aliases(&options.static_symbol_aliases), + source: RuntimeResourceExtensionSource::Prebuilt { + root: artifact_root.to_path_buf(), + files_root: artifact_root.join("files"), + }, + }) +} + +fn artifact_mobile_prebuilt(options: &NativeExtensionArtifactOptions) -> bool { + options.mobile_prebuilt || !options.mobile_static_archives.is_empty() +} + +fn mobile_static_archives_for_artifact_options( + options: &NativeExtensionArtifactOptions, +) -> Vec { + let Some(stem) = options.native_module_stem.as_deref() else { + return Vec::new(); + }; + let mut archives = options + .mobile_static_archives + .iter() + .map(|archive| MobileStaticArchive { + target: archive.target.clone(), + relative_path: mobile_static_archive_artifact_relative_path(&archive.target, stem), + }) + .collect::>(); + archives.sort_by(|left, right| left.target.cmp(&right.target)); + archives +} + +pub(super) fn mobile_static_archive_artifact_relative_path(target: &str, stem: &str) -> PathBuf { + PathBuf::from("mobile-static") + .join(target) + .join("extensions") + .join(stem) + .join(format!("liboliphaunt_extension_{stem}.a")) +} + +fn mobile_static_dependency_archives_for_artifact_options( + options: &NativeExtensionArtifactOptions, +) -> Result> { + let mut archives = Vec::new(); + for archive in &options.mobile_static_dependency_archives { + let file_name = validate_mobile_static_dependency_archive_file_name(&archive.archive)?; + archives.push(MobileStaticDependencyArchive { + target: archive.target.clone(), + name: archive.name.clone(), + relative_path: mobile_static_dependency_archive_artifact_relative_path( + &archive.target, + &archive.name, + &file_name, + ), + }); + } + archives.sort_by(|left, right| { + left.target + .cmp(&right.target) + .then_with(|| left.name.cmp(&right.name)) + }); + Ok(archives) +} + +fn sorted_static_symbol_aliases( + aliases: &[NativeExtensionStaticSymbolAlias], +) -> Vec { + let mut aliases = aliases.to_vec(); + aliases.sort_by(|left, right| { + left.sql_symbol + .cmp(&right.sql_symbol) + .then_with(|| left.linked_symbol.cmp(&right.linked_symbol)) + }); + aliases.dedup(); + aliases +} + +pub(super) fn mobile_static_dependency_archive_artifact_relative_path( + target: &str, + name: &str, + file_name: &str, +) -> PathBuf { + PathBuf::from("mobile-static") + .join(target) + .join("dependencies") + .join(name) + .join(file_name) +} + +fn validate_mobile_static_dependency_archive_file_name(path: &Path) -> Result { + let file_name = path.file_name().and_then(|name| name.to_str()).ok_or_else(|| { + Error::InvalidConfig(format!( + "prebuilt extension mobile static dependency archive path {} must include a portable file name", + path.display() + )) + })?; + validate_portable_id( + file_name, + "prebuilt extension mobile static dependency archive file", + )?; + Ok(file_name.to_owned()) +} + +fn copy_mobile_static_archives_to_artifact( + artifact_root: &Path, + options: &NativeExtensionArtifactOptions, + extension: &RuntimeResourceExtension, +) -> Result<()> { + if options.mobile_static_archives.is_empty() { + return Ok(()); + } + let archive_by_target = options + .mobile_static_archives + .iter() + .map(|archive| (archive.target.as_str(), archive.archive.as_path())) + .collect::>(); + for archive in &extension.mobile_static_archives { + let Some(source) = archive_by_target.get(archive.target.as_str()) else { + return Err(Error::Engine(format!( + "internal error: missing mobile static archive source for target '{}'", + archive.target + ))); + }; + copy_portable_tree(source, &artifact_root.join(&archive.relative_path))?; + } + Ok(()) +} + +fn copy_mobile_static_dependency_archives_to_artifact( + artifact_root: &Path, + options: &NativeExtensionArtifactOptions, + extension: &RuntimeResourceExtension, +) -> Result<()> { + if options.mobile_static_dependency_archives.is_empty() { + return Ok(()); + } + let archive_by_key = options + .mobile_static_dependency_archives + .iter() + .map(|archive| { + ( + (archive.target.as_str(), archive.name.as_str()), + archive.archive.as_path(), + ) + }) + .collect::>(); + for archive in &extension.mobile_static_dependency_archives { + let Some(source) = archive_by_key.get(&(archive.target.as_str(), archive.name.as_str())) + else { + return Err(Error::Engine(format!( + "internal error: missing mobile static dependency archive source for target '{}' dependency '{}'", + archive.target, archive.name + ))); + }; + copy_portable_tree(source, &artifact_root.join(&archive.relative_path))?; + } + Ok(()) +} + +pub(super) fn sorted_deduped_strings(values: &[String]) -> Vec { + values + .iter() + .cloned() + .collect::>() + .into_iter() + .collect() +} + +fn sorted_deduped_paths(values: &[PathBuf]) -> Vec { + values + .iter() + .cloned() + .collect::>() + .into_iter() + .collect() +} + +fn copy_extension_artifact_sql_files( + artifact_root: &Path, + runtime_files: &Path, + artifact_files: &Path, + extension: &RuntimeResourceExtension, +) -> Result<()> { + let source_dir = runtime_files.join("share/postgresql/extension"); + let target_dir = artifact_files.join("share/postgresql/extension"); + if !source_dir.is_dir() { + if extension.creates_extension { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact source runtime {} is missing share/postgresql/extension for '{}'", + runtime_files.display(), + extension.sql_name + ))); + } + return Ok(()); + } + + let mut copied_control = false; + let mut copied_sql = false; + let mut copied = 0usize; + let mut entries = fs::read_dir(&source_dir) + .map_err(|err| Error::Engine(format!("read {}: {err}", source_dir.display())))? + .collect::, _>>() + .map_err(|err| Error::Engine(format!("read entry in {}: {err}", source_dir.display())))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let file_name = entry.file_name().to_string_lossy().into_owned(); + if !extension_sql_file_belongs(&extension.sql_name, &file_name) { + continue; + } + copied += 1; + if file_name == format!("{}.control", extension.sql_name) { + copied_control = true; + } else if file_name.ends_with(".sql") { + copied_sql = true; + } + copy_extension_runtime_file(runtime_files, &entry.path(), &target_dir.join(file_name))?; + } + + if extension.creates_extension && (!copied_control || !copied_sql) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact {} for '{}' must include a control file and at least one SQL install file", + artifact_root.display(), + extension.sql_name + ))); + } + if !extension.creates_extension && copied == 0 { + return Ok(()); + } + Ok(()) +} + +fn copy_artifact_source_file( + source_root: &Path, + artifact_files: &Path, + relative: &Path, +) -> Result<()> { + validate_relative_artifact_path(source_root, "runtime file", relative)?; + let source = source_root.join(relative); + if !source.is_file() { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact source runtime is missing declared file {}", + source.display() + ))); + } + copy_extension_runtime_file(source_root, &source, &artifact_files.join(relative)) +} + +fn copy_extension_runtime_file( + runtime_root: &Path, + source: &Path, + destination: &Path, +) -> Result<()> { + let symlink_metadata = fs::symlink_metadata(source) + .map_err(|err| Error::Engine(format!("stat {}: {err}", source.display())))?; + let file_metadata = if symlink_metadata.file_type().is_symlink() { + let canonical_root = runtime_root.canonicalize().map_err(|err| { + Error::Engine(format!( + "canonicalize runtime root {}: {err}", + runtime_root.display() + )) + })?; + let canonical_source = source.canonicalize().map_err(|err| { + Error::Engine(format!( + "canonicalize selected extension runtime symlink {}: {err}", + source.display() + )) + })?; + if !canonical_source.starts_with(&canonical_root) { + return Err(Error::InvalidConfig(format!( + "selected extension runtime symlink {} resolves outside runtime root {}", + source.display(), + runtime_root.display() + ))); + } + fs::metadata(source).map_err(|err| { + Error::Engine(format!( + "stat selected extension runtime symlink target {}: {err}", + source.display() + )) + })? + } else { + symlink_metadata + }; + if !file_metadata.is_file() { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact source runtime file {} must be a regular file", + source.display() + ))); + } + copy_portable_file(source, destination, &file_metadata) +} + +fn write_prebuilt_extension_artifact_manifest( + artifact_root: &Path, + options: &NativeExtensionArtifactOptions, + extension: &RuntimeResourceExtension, +) -> Result<()> { + fs::create_dir_all(artifact_root) + .map_err(|err| Error::Engine(format!("create {}: {err}", artifact_root.display())))?; + let text = format!( + "packageLayout={EXTENSION_ARTIFACT_LAYOUT}\npgMajor=18\nsqlName={}\ncreatesExtension={}\nnativeModuleStem={}\nnativeModuleFile={}\nnativeTarget={}\ndependencies={}\ndataFiles={}\nsharedPreloadLibraries={}\nmobilePrebuilt={}\nmobileStaticArchives={}\nmobileStaticDependencyArchives={}\nstaticSymbolPrefix={}\nstaticSymbolAliases={}\nfiles=files\n", + extension.sql_name, + yes_no_manifest(options.creates_extension), + extension.native_module_stem.as_deref().unwrap_or(""), + extension.native_module_file.as_deref().unwrap_or(""), + extension.native_target.as_deref().unwrap_or(""), + extension.dependencies.join(","), + extension + .data_files + .iter() + .map(|path| path.to_string_lossy()) + .collect::>() + .join(","), + extension.shared_preload_libraries.join(","), + yes_no_manifest(extension.mobile_prebuilt), + mobile_static_archive_manifest_value(&extension.mobile_static_archives), + mobile_static_dependency_archive_manifest_value( + &extension.mobile_static_dependency_archives + ), + extension.static_symbol_prefix.as_deref().unwrap_or(""), + static_symbol_alias_manifest_value(&extension.static_symbol_aliases), + ); + fs::write(artifact_root.join("manifest.properties"), text).map_err(|err| { + Error::Engine(format!( + "write prebuilt extension artifact manifest {}: {err}", + artifact_root.join("manifest.properties").display() + )) + }) +} + +fn static_symbol_alias_manifest_value(aliases: &[NativeExtensionStaticSymbolAlias]) -> String { + aliases + .iter() + .map(|alias| format!("{}:{}", alias.sql_symbol, alias.linked_symbol)) + .collect::>() + .join(",") +} + +fn mobile_static_archive_manifest_value(archives: &[MobileStaticArchive]) -> String { + archives + .iter() + .map(|archive| { + format!( + "{}:{}", + archive.target, + archive.relative_path.to_string_lossy() + ) + }) + .collect::>() + .join(",") +} + +fn mobile_static_dependency_archive_manifest_value( + archives: &[MobileStaticDependencyArchive], +) -> String { + archives + .iter() + .map(|archive| { + format!( + "{}:{}:{}", + archive.target, + archive.name, + archive.relative_path.to_string_lossy() + ) + }) + .collect::>() + .join(",") +} + +fn yes_no_manifest(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn write_prebuilt_extension_artifact_archive( + artifact_root: &Path, + output: &Path, + format: NativeExtensionArtifactFormat, +) -> Result<()> { + let file = File::create(output) + .map_err(|err| Error::Engine(format!("create {}: {err}", output.display())))?; + match format { + NativeExtensionArtifactFormat::Directory => Ok(()), + NativeExtensionArtifactFormat::Tar => { + write_prebuilt_extension_artifact_tar(file, artifact_root).map(|_| ()) + } + NativeExtensionArtifactFormat::TarGz => { + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let encoder = write_prebuilt_extension_artifact_tar(encoder, artifact_root)?; + encoder.finish().map_err(|err| { + Error::Engine(format!( + "finish gzip prebuilt extension artifact archive {}: {err}", + output.display() + )) + })?; + Ok(()) + } + NativeExtensionArtifactFormat::TarZst => { + let encoder = zstd::stream::write::Encoder::new(file, 0).map_err(|err| { + Error::Engine(format!( + "create zstd prebuilt extension artifact archive {}: {err}", + output.display() + )) + })?; + let encoder = write_prebuilt_extension_artifact_tar(encoder, artifact_root)?; + encoder.finish().map_err(|err| { + Error::Engine(format!( + "finish zstd prebuilt extension artifact archive {}: {err}", + output.display() + )) + })?; + Ok(()) + } + } +} + +fn write_prebuilt_extension_artifact_tar( + writer: W, + artifact_root: &Path, +) -> Result { + let mut archive = tar::Builder::new(writer); + append_artifact_files_to_tar(&mut archive, artifact_root, artifact_root)?; + archive.finish().map_err(|err| { + Error::Engine(format!( + "finish prebuilt extension artifact tar from {}: {err}", + artifact_root.display() + )) + })?; + archive.into_inner().map_err(|err| { + Error::Engine(format!( + "finish prebuilt extension artifact tar writer from {}: {err}", + artifact_root.display() + )) + }) +} + +fn append_artifact_files_to_tar( + archive: &mut tar::Builder, + artifact_root: &Path, + current: &Path, +) -> Result<()> { + let mut entries = fs::read_dir(current) + .map_err(|err| Error::Engine(format!("read {}: {err}", current.display())))? + .collect::, _>>() + .map_err(|err| Error::Engine(format!("read entry in {}: {err}", current.display())))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|err| Error::Engine(format!("stat {}: {err}", path.display())))?; + if metadata.file_type().is_symlink() { + return Err(Error::Engine(format!( + "prebuilt extension artifact archives do not support symlinks: {}", + path.display() + ))); + } + if metadata.is_dir() { + append_artifact_files_to_tar(archive, artifact_root, &path)?; + continue; + } + if !metadata.is_file() { + return Err(Error::Engine(format!( + "prebuilt extension artifact archives only support files and directories: {}", + path.display() + ))); + } + let relative = path.strip_prefix(artifact_root).map_err(|err| { + Error::Engine(format!( + "derive prebuilt extension artifact archive path for {}: {err}", + path.display() + )) + })?; + validate_relative_artifact_path(artifact_root, "archive file", relative)?; + let mut header = tar::Header::new_gnu(); + header.set_size(metadata.len()); + header.set_mode(portable_tar_mode(&metadata)); + header.set_mtime(0); + header.set_cksum(); + let mut file = File::open(&path) + .map_err(|err| Error::Engine(format!("open {}: {err}", path.display())))?; + archive + .append_data(&mut header, relative, &mut file) + .map_err(|err| { + Error::Engine(format!( + "append {} to prebuilt extension artifact archive: {err}", + relative.display() + )) + })?; + } + Ok(()) +} + +fn portable_tar_mode(metadata: &fs::Metadata) -> u32 { + if metadata.is_dir() { + return 0o755; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 != 0 { + 0o755 + } else { + 0o644 + } + } + #[cfg(not(unix))] + { + 0o644 + } +} + +fn unique_extension_artifact_staging_root() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "oliphaunt-extension-artifact-create-{}-{nanos}", + std::process::id() + )) +} + +pub(super) fn unique_extension_extraction_root() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "oliphaunt-extension-artifacts-{}-{nanos}", + std::process::id() + )) +} + +pub(super) fn extract_prebuilt_extension_archive( + archive_path: &Path, + destination: &Path, +) -> Result { + fs::create_dir_all(destination).map_err(|err| { + Error::Engine(format!( + "create prebuilt extension artifact extraction dir {}: {err}", + destination.display() + )) + })?; + let file = File::open(archive_path).map_err(|err| { + Error::InvalidConfig(format!( + "open prebuilt extension artifact archive {}: {err}", + archive_path.display() + )) + })?; + if archive_is_tar_zst(archive_path) { + let decoder = zstd::stream::read::Decoder::new(file).map_err(|err| { + Error::InvalidConfig(format!( + "open zstd prebuilt extension artifact archive {}: {err}", + archive_path.display() + )) + })?; + extract_prebuilt_extension_tar(archive_path, decoder, destination)?; + } else if archive_is_tar_gz(archive_path) { + let decoder = flate2::read::GzDecoder::new(file); + extract_prebuilt_extension_tar(archive_path, decoder, destination)?; + } else if archive_is_tar(archive_path) { + extract_prebuilt_extension_tar(archive_path, file, destination)?; + } else { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive {} must end in .tar, .tar.gz, or .tar.zst", + archive_path.display() + ))); + } + extracted_extension_artifact_root(destination) +} + +fn archive_is_tar(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".tar")) +} + +fn archive_is_tar_zst(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".tar.zst")) +} + +fn archive_is_tar_gz(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".tar.gz") || name.ends_with(".tgz")) +} + +fn extract_prebuilt_extension_tar( + archive_path: &Path, + reader: impl io::Read, + destination: &Path, +) -> Result<()> { + let mut archive = tar::Archive::new(reader); + let entries = archive.entries().map_err(|err| { + Error::InvalidConfig(format!( + "read prebuilt extension artifact archive {}: {err}", + archive_path.display() + )) + })?; + let mut seen_files = BTreeSet::new(); + let mut seen_dirs = BTreeSet::new(); + for entry in entries { + let mut entry = entry.map_err(|err| { + Error::InvalidConfig(format!( + "read prebuilt extension artifact archive entry in {}: {err}", + archive_path.display() + )) + })?; + let relative = entry.path().map_err(|err| { + Error::InvalidConfig(format!( + "read prebuilt extension artifact archive path in {}: {err}", + archive_path.display() + )) + })?; + let relative = relative.into_owned(); + validate_relative_artifact_path(archive_path, "archive entry", &relative)?; + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + validate_archive_entry_plan(&relative, true, &mut seen_files, &mut seen_dirs)?; + fs::create_dir_all(destination.join(&relative)).map_err(|err| { + Error::Engine(format!( + "create prebuilt extension artifact archive dir {}: {err}", + destination.join(&relative).display() + )) + })?; + } else if entry_type.is_file() { + validate_archive_entry_plan(&relative, false, &mut seen_files, &mut seen_dirs)?; + if let Some(parent) = destination.join(&relative).parent() { + fs::create_dir_all(parent).map_err(|err| { + Error::Engine(format!( + "create prebuilt extension artifact archive parent {}: {err}", + parent.display() + )) + })?; + } + entry.unpack(destination.join(&relative)).map_err(|err| { + Error::Engine(format!( + "extract prebuilt extension artifact archive entry {} from {}: {err}", + relative.display(), + archive_path.display() + )) + })?; + } else { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive {} entry {} must be a regular file or directory, not {:?}", + archive_path.display(), + relative.display(), + entry_type + ))); + } + } + Ok(()) +} + +fn validate_archive_entry_plan( + relative: &Path, + is_dir: bool, + seen_files: &mut BTreeSet, + seen_dirs: &mut BTreeSet, +) -> Result<()> { + let mut ancestors = relative.ancestors(); + let _ = ancestors.next(); + for ancestor in ancestors { + if ancestor.as_os_str().is_empty() { + continue; + } + if seen_files.contains(ancestor) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive entry {} is nested under file entry {}", + relative.display(), + ancestor.display() + ))); + } + } + if is_dir { + if seen_files.contains(relative) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive has both file and directory entries for {}", + relative.display() + ))); + } + if !seen_dirs.insert(relative.to_path_buf()) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive repeats directory entry {}", + relative.display() + ))); + } + } else { + if seen_dirs.contains(relative) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive has both directory and file entries for {}", + relative.display() + ))); + } + if !seen_files.insert(relative.to_path_buf()) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive repeats file entry {}", + relative.display() + ))); + } + } + Ok(()) +} + +fn extracted_extension_artifact_root(destination: &Path) -> Result { + if destination.join("manifest.properties").is_file() { + return Ok(destination.to_path_buf()); + } + let mut children = fs::read_dir(destination) + .map_err(|err| { + Error::Engine(format!( + "read prebuilt extension artifact extraction dir {}: {err}", + destination.display() + )) + })? + .collect::, _>>() + .map_err(|err| { + Error::Engine(format!( + "read entry in prebuilt extension artifact extraction dir {}: {err}", + destination.display() + )) + })?; + children.sort_by_key(|entry| entry.file_name()); + let nested = children + .iter() + .filter(|entry| entry.path().join("manifest.properties").is_file()) + .map(|entry| entry.path()) + .collect::>(); + if nested.len() == 1 { + return Ok(nested[0].clone()); + } + Err(Error::InvalidConfig(format!( + "prebuilt extension artifact archive extracted to {} but did not contain manifest.properties at archive root or under one top-level directory", + destination.display() + ))) +} diff --git a/src/sdks/rust/src/runtime_resources/extension_index.rs b/src/sdks/rust/src/runtime_resources/extension_index.rs new file mode 100644 index 00000000..b62c7a3b --- /dev/null +++ b/src/sdks/rust/src/runtime_resources/extension_index.rs @@ -0,0 +1,1081 @@ +use super::*; +use std::path::Component; + +/// Resolve exact prebuilt extension artifacts from local release index files. +/// +/// The index is only a locator and integrity manifest. Every referenced +/// artifact is checksum-verified, loaded through the same +/// `oliphaunt-extension-artifact-v1` parser used by the package consumer, and +/// resolved transitively by exact dependency names. +pub fn resolve_prebuilt_extension_artifacts_from_indexes( + options: NativeExtensionArtifactIndexOptions, +) -> Result { + if options.target.trim().is_empty() { + return Err(Error::InvalidConfig( + "extension artifact index target must not be empty".to_owned(), + )); + } + validate_portable_id(&options.target, "extension artifact index target")?; + if options.extensions.is_empty() { + return Ok(NativeExtensionArtifactIndexResolution { + artifacts: Vec::new(), + extension_names: Vec::new(), + }); + } + if options.indexes.is_empty() { + return Err(Error::InvalidConfig( + "external extension selection requires at least one --extension-index " + .to_owned(), + )); + } + + validate_extension_artifact_index_trust_options(&options)?; + let entries = load_extension_artifact_indexes( + &options.indexes, + &options.trusted_signing_keys, + options.require_signatures, + )?; + let mut artifacts = Vec::new(); + let mut extension_names = Vec::new(); + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let artifact_cache_dir = options.artifact_cache_dir.as_deref(); + for extension in options.extensions { + validate_portable_id(&extension, "extension artifact index selection")?; + visit_extension_artifact_index_entry( + &extension, + &options.target, + &entries, + artifact_cache_dir, + &mut visiting, + &mut visited, + &mut artifacts, + &mut extension_names, + )?; + } + extension_names.sort(); + extension_names.dedup(); + Ok(NativeExtensionArtifactIndexResolution { + artifacts, + extension_names, + }) +} + +/// List exact external extensions advertised by prebuilt artifact indexes. +/// +/// This is a discovery path for app/release tooling: it verifies signed indexes +/// when trust roots are configured, then returns the target-specific metadata +/// the publisher recorded for each external extension. Artifact bytes are still +/// verified when a selected extension is resolved for packaging. +pub fn list_prebuilt_extension_artifact_index_catalog( + options: NativeExtensionArtifactIndexOptions, +) -> Result { + if options.target.trim().is_empty() { + return Err(Error::InvalidConfig( + "extension artifact index target must not be empty".to_owned(), + )); + } + validate_portable_id(&options.target, "extension artifact index target")?; + validate_extension_artifact_index_trust_options(&options)?; + if options.indexes.is_empty() { + return Ok(NativeExtensionArtifactIndexCatalog { + extensions: Vec::new(), + }); + } + let entries = load_extension_artifact_indexes( + &options.indexes, + &options.trusted_signing_keys, + options.require_signatures, + )?; + let mut extensions = entries + .values() + .filter(|entry| entry.target == options.target) + .map(|entry| NativeExtensionArtifactIndexCatalogEntry { + sql_name: entry.sql_name.clone(), + target: entry.target.clone(), + creates_extension: entry.creates_extension, + native_module_stem: entry.native_module_stem.clone(), + dependencies: entry.dependencies.clone(), + shared_preload_libraries: entry.shared_preload_libraries.clone(), + mobile_prebuilt: entry.native_module_stem.is_none() || entry.mobile_prebuilt, + mobile_static_archive_targets: entry.mobile_static_archive_targets.clone(), + url: entry.url.clone(), + }) + .collect::>(); + extensions.sort_by(|left, right| left.sql_name.cmp(&right.sql_name)); + Ok(NativeExtensionArtifactIndexCatalog { extensions }) +} + +/// Create a local exact prebuilt extension artifact index from validated +/// archive artifacts. +/// +/// The index producer verifies every artifact through the same schema parser +/// used by package consumption, rejects built-in release-ready extension names, +/// computes byte counts and SHA-256 digests, and writes relative paths only. +pub fn create_prebuilt_extension_artifact_index( + options: NativeExtensionArtifactIndexCreateOptions, +) -> Result { + validate_extension_artifact_index_create_options(&options)?; + prepare_output_file(&options.output, options.replace_existing)?; + let index_parent = options.output.parent().unwrap_or_else(|| Path::new("")); + let mut seen = BTreeSet::new(); + let mut rows = Vec::new(); + for artifact_path in &options.artifacts { + let mut row = + create_extension_artifact_index_row(index_parent, &options.target, artifact_path)?; + if let Some(base_url) = &options.artifact_base_url { + row.url = Some(join_extension_artifact_base_url(base_url, &row.path)?); + } + if !seen.insert(row.sql_name.clone()) { + return Err(Error::InvalidConfig(format!( + "extension artifact index cannot contain duplicate extension '{}'", + row.sql_name + ))); + } + rows.push(row); + } + rows.sort_by(|left, right| left.sql_name.cmp(&right.sql_name)); + let text = extension_artifact_index_toml(&rows); + fs::write(&options.output, text).map_err(|err| { + Error::Engine(format!( + "write extension artifact index {}: {err}", + options.output.display() + )) + })?; + Ok(NativeExtensionArtifactIndex { + path: options.output, + target: options.target, + artifacts: rows, + }) +} + +/// Sign an exact prebuilt extension artifact index with Ed25519. +/// +/// The detached signature covers the exact index bytes on disk. The signature +/// file is a small TOML sidecar at `.sig` unless an explicit path is +/// supplied. +pub fn sign_prebuilt_extension_artifact_index( + options: NativeExtensionArtifactIndexSigningOptions, +) -> Result { + validate_extension_artifact_index_signing_options(&options)?; + let index_bytes = fs::read(&options.index).map_err(|err| { + Error::InvalidConfig(format!( + "read extension artifact index {} for signing: {err}", + options.index.display() + )) + })?; + let signature_path = options + .signature_path + .clone() + .unwrap_or_else(|| default_extension_artifact_index_signature_path(&options.index)); + prepare_output_file(&signature_path, options.replace_existing)?; + let signed = sign_extension_artifact_index_bytes( + &options.key_id, + &options.signing_key_hex, + &index_bytes, + )?; + let text = extension_artifact_index_signature_toml(&signed); + fs::write(&signature_path, text).map_err(|err| { + Error::Engine(format!( + "write extension artifact index signature {}: {err}", + signature_path.display() + )) + })?; + Ok(NativeExtensionArtifactIndexSignature { + path: signature_path, + index: options.index, + key_id: signed.key_id, + public_key_hex: signed.public_key_hex, + signature_hex: signed.signature_hex, + }) +} + +fn validate_extension_artifact_index_create_options( + options: &NativeExtensionArtifactIndexCreateOptions, +) -> Result<()> { + if options.output.as_os_str().is_empty() { + return Err(Error::InvalidConfig( + "extension artifact index output path must not be empty".to_owned(), + )); + } + if options.target.trim().is_empty() { + return Err(Error::InvalidConfig( + "extension artifact index target must not be empty".to_owned(), + )); + } + validate_portable_id(&options.target, "extension artifact index target")?; + if options.artifacts.is_empty() { + return Err(Error::InvalidConfig( + "extension artifact index requires at least one artifact archive".to_owned(), + )); + } + if let Some(base_url) = &options.artifact_base_url { + validate_extension_artifact_url(&options.output, base_url)?; + if !base_url.starts_with("https://") && !base_url.starts_with("file://") { + return Err(Error::InvalidConfig(format!( + "extension artifact index base URL '{}' must start with https://", + base_url + ))); + } + } + Ok(()) +} + +fn validate_extension_artifact_index_trust_options( + options: &NativeExtensionArtifactIndexOptions, +) -> Result<()> { + let mut keys = BTreeMap::new(); + for key in &options.trusted_signing_keys { + validate_portable_id(&key.key_id, "extension artifact index trusted key id")?; + let normalized = normalize_hex(&key.public_key_hex); + decode_hex_fixed::<32>( + "extension artifact index trusted Ed25519 public key", + &normalized, + )?; + if let Some(previous) = keys.insert(key.key_id.clone(), normalized.clone()) { + if previous != normalized { + return Err(Error::InvalidConfig(format!( + "extension artifact index trusted key '{}' was provided with multiple public keys", + key.key_id + ))); + } + } + } + if options.require_signatures && options.trusted_signing_keys.is_empty() { + return Err(Error::InvalidConfig( + "signed extension artifact indexes require at least one trusted publisher key" + .to_owned(), + )); + } + Ok(()) +} + +fn validate_extension_artifact_index_signing_options( + options: &NativeExtensionArtifactIndexSigningOptions, +) -> Result<()> { + if options.index.as_os_str().is_empty() { + return Err(Error::InvalidConfig( + "extension artifact index signing path must not be empty".to_owned(), + )); + } + if !options.index.is_file() { + return Err(Error::InvalidConfig(format!( + "extension artifact index {} must be an existing file before signing", + options.index.display() + ))); + } + validate_portable_id(&options.key_id, "extension artifact index signing key id")?; + decode_hex_fixed::<32>( + "extension artifact index Ed25519 signing key", + &options.signing_key_hex, + )?; + Ok(()) +} + +fn create_extension_artifact_index_row( + index_parent: &Path, + target: &str, + artifact_path: &Path, +) -> Result { + let metadata = fs::metadata(artifact_path).map_err(|err| { + Error::InvalidConfig(format!( + "stat extension artifact {} for index: {err}", + artifact_path.display() + )) + })?; + if !metadata.is_file() { + return Err(Error::InvalidConfig(format!( + "extension artifact index can only reference archive files, got {}", + artifact_path.display() + ))); + } + let prepared = + PreparedPrebuiltExtensionArtifacts::prepare(&[NativePrebuiltExtensionArtifact::new( + artifact_path, + )])?; + let loaded = load_prebuilt_extension_artifact(&prepared.artifacts()[0].root)?; + if let Some(native_target) = &loaded.native_target { + if native_target != target { + return Err(Error::InvalidConfig(format!( + "extension artifact {} declares nativeTarget='{}' but index target is '{}'", + artifact_path.display(), + native_target, + target + ))); + } + } + if Extension::by_release_ready_sql_name(&loaded.sql_name).is_some() { + return Err(Error::InvalidConfig(format!( + "extension artifact index cannot override built-in release-ready extension '{}'", + loaded.sql_name + ))); + } + let relative = artifact_path.strip_prefix(index_parent).map_err(|_| { + Error::InvalidConfig(format!( + "extension artifact {} must be inside index directory {} so the index can record a relative path", + artifact_path.display(), + index_parent.display() + )) + })?; + validate_relative_artifact_path(index_parent, "artifact path", relative)?; + let mobile_static_archive_targets = + mobile_static_archive_targets(&loaded.mobile_static_archives); + Ok(NativeExtensionArtifactIndexArtifact { + sql_name: loaded.sql_name, + target: target.to_owned(), + creates_extension: loaded.creates_extension, + native_module_stem: loaded.native_module_stem, + dependencies: loaded.dependencies, + shared_preload_libraries: loaded.shared_preload_libraries, + mobile_prebuilt: loaded.mobile_prebuilt, + mobile_static_archive_targets, + path: relative.to_path_buf(), + url: None, + sha256: sha256_file_hex(artifact_path)?, + bytes: metadata.len(), + }) +} + +fn extension_artifact_index_toml(rows: &[NativeExtensionArtifactIndexArtifact]) -> String { + let mut text = format!( + "schema = {schema}\npg_major = 18\n", + schema = toml_string(EXTENSION_ARTIFACT_INDEX_LAYOUT) + ); + for row in rows { + text.push_str(&format!( + "\n[[artifacts]]\nsql_name = {}\ntarget = {}\ncreates_extension = {}\n", + toml_string(&row.sql_name), + toml_string(&row.target), + row.creates_extension, + )); + if let Some(stem) = &row.native_module_stem { + text.push_str(&format!("native_module_stem = {}\n", toml_string(stem))); + } + text.push_str(&format!( + "dependencies = {}\nshared_preload_libraries = {}\nmobile_prebuilt = {}\nmobile_static_archive_targets = {}\npath = {}\n", + toml_string_array(&row.dependencies), + toml_string_array(&row.shared_preload_libraries), + row.mobile_prebuilt, + toml_string_array(&row.mobile_static_archive_targets), + toml_string(&row.path.to_string_lossy()), + )); + if let Some(url) = &row.url { + text.push_str(&format!("url = {}\n", toml_string(url))); + } + text.push_str(&format!( + "sha256 = {}\nbytes = {}\n", + toml_string(&row.sha256), + row.bytes, + )); + } + text +} + +fn toml_string(value: &str) -> String { + let mut out = String::from("\""); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + ch if ch.is_control() => out.push_str(&format!("\\u{:04x}", ch as u32)), + _ => out.push(ch), + } + } + out.push('"'); + out +} + +fn toml_string_array(values: &[String]) -> String { + format!( + "[{}]", + values + .iter() + .map(|value| toml_string(value)) + .collect::>() + .join(", ") + ) +} + +fn validate_extension_artifact_url(index_path: &Path, url: &str) -> Result<()> { + if url.trim() != url || url.is_empty() || url.chars().any(char::is_control) { + return Err(Error::InvalidConfig(format!( + "extension artifact index {} has invalid artifact URL '{}'", + index_path.display(), + url + ))); + } + if url.starts_with("https://") || url.starts_with("file://") { + return Ok(()); + } + Err(Error::InvalidConfig(format!( + "extension artifact index {} artifact URL '{}' must start with https:// or file://", + index_path.display(), + url + ))) +} + +fn join_extension_artifact_base_url(base_url: &str, relative: &Path) -> Result { + validate_relative_artifact_path(Path::new("extension-index"), "artifact URL path", relative)?; + let relative = relative + .components() + .map(|component| match component { + Component::Normal(part) => part + .to_str() + .map(percent_encode_url_path_segment) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "extension artifact URL path '{}' must be valid UTF-8", + relative.display() + )) + }), + _ => Err(Error::InvalidConfig(format!( + "extension artifact URL path '{}' must be relative", + relative.display() + ))), + }) + .collect::>>()? + .join("/"); + let separator = if base_url.ends_with('/') { "" } else { "/" }; + Ok(format!("{base_url}{separator}{relative}")) +} + +fn percent_encode_url_path_segment(segment: &str) -> String { + let mut out = String::new(); + for byte in segment.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn load_extension_artifact_indexes( + index_paths: &[PathBuf], + trusted_signing_keys: &[NativeExtensionArtifactIndexTrustRoot], + require_signatures: bool, +) -> Result> { + let mut entries = BTreeMap::new(); + for index_path in index_paths { + let index = + load_extension_artifact_index(index_path, trusted_signing_keys, require_signatures)?; + for entry in index { + let key = (entry.target.clone(), entry.sql_name.clone()); + if entries.insert(key.clone(), entry).is_some() { + return Err(Error::InvalidConfig(format!( + "extension artifact indexes define duplicate artifact for target '{}' extension '{}'", + key.0, key.1 + ))); + } + } + } + Ok(entries) +} + +fn load_extension_artifact_index( + index_path: &Path, + trusted_signing_keys: &[NativeExtensionArtifactIndexTrustRoot], + require_signatures: bool, +) -> Result> { + verify_extension_artifact_index_signature_if_required( + index_path, + trusted_signing_keys, + require_signatures, + )?; + let text = fs::read_to_string(index_path).map_err(|err| { + Error::InvalidConfig(format!( + "read extension artifact index {}: {err}", + index_path.display() + )) + })?; + let parsed = toml::from_str::(&text).map_err(|err| { + Error::InvalidConfig(format!( + "parse extension artifact index {}: {err}", + index_path.display() + )) + })?; + if parsed.schema != EXTENSION_ARTIFACT_INDEX_LAYOUT { + return Err(Error::InvalidConfig(format!( + "extension artifact index {} has schema='{}', expected '{}'", + index_path.display(), + parsed.schema, + EXTENSION_ARTIFACT_INDEX_LAYOUT + ))); + } + if parsed.pg_major != 18 { + return Err(Error::InvalidConfig(format!( + "extension artifact index {} targets PostgreSQL {}; Oliphaunt native packages require PostgreSQL 18", + index_path.display(), + parsed.pg_major + ))); + } + if parsed.artifacts.is_empty() { + return Err(Error::InvalidConfig(format!( + "extension artifact index {} must contain at least one [[artifacts]] entry", + index_path.display() + ))); + } + let base = index_path.parent().unwrap_or_else(|| Path::new("")); + let mut out = Vec::new(); + for artifact in parsed.artifacts { + validate_portable_id(&artifact.sql_name, "extension artifact index sql_name")?; + validate_portable_id(&artifact.target, "extension artifact index target")?; + if let Some(stem) = &artifact.native_module_stem { + validate_portable_id(stem, "extension artifact index native_module_stem")?; + } + for dependency in &artifact.dependencies { + validate_portable_id(dependency, "extension artifact index dependency")?; + } + for library in &artifact.shared_preload_libraries { + validate_portable_id(library, "extension artifact index shared_preload_libraries")?; + } + for target in &artifact.mobile_static_archive_targets { + validate_portable_id( + target, + "extension artifact index mobile_static_archive_targets", + )?; + } + validate_sha256_hex(index_path, &artifact.sha256)?; + if Extension::by_release_ready_sql_name(&artifact.sql_name).is_some() { + return Err(Error::InvalidConfig(format!( + "extension artifact index {} cannot override built-in release-ready extension '{}'", + index_path.display(), + artifact.sql_name + ))); + } + let relative = PathBuf::from(&artifact.path); + validate_relative_artifact_path(index_path, "artifact path", &relative)?; + if let Some(url) = &artifact.url { + validate_extension_artifact_url(index_path, url)?; + } + out.push(ExtensionArtifactIndexEntry { + index_path: index_path.to_path_buf(), + sql_name: artifact.sql_name, + target: artifact.target, + creates_extension: artifact.creates_extension, + native_module_stem: artifact.native_module_stem, + dependencies: sorted_deduped_strings(&artifact.dependencies), + shared_preload_libraries: sorted_deduped_strings(&artifact.shared_preload_libraries), + mobile_prebuilt: artifact.mobile_prebuilt, + mobile_static_archive_targets: sorted_deduped_strings( + &artifact.mobile_static_archive_targets, + ), + relative_path: relative.clone(), + path: base.join(relative), + url: artifact.url, + sha256: artifact.sha256.to_ascii_lowercase(), + bytes: artifact.bytes, + }); + } + Ok(out) +} + +fn validate_sha256_hex(index_path: &Path, value: &str) -> Result<()> { + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(()); + } + Err(Error::InvalidConfig(format!( + "extension artifact index {} has invalid sha256 '{}'", + index_path.display(), + value + ))) +} + +fn verify_extension_artifact_index_signature_if_required( + index_path: &Path, + trusted_signing_keys: &[NativeExtensionArtifactIndexTrustRoot], + require_signatures: bool, +) -> Result<()> { + if trusted_signing_keys.is_empty() && !require_signatures { + return Ok(()); + } + if trusted_signing_keys.is_empty() { + return Err(Error::InvalidConfig( + "signed extension artifact index verification requires at least one trusted publisher key" + .to_owned(), + )); + } + let signature_path = default_extension_artifact_index_signature_path(index_path); + let signature_text = fs::read_to_string(&signature_path).map_err(|err| { + Error::InvalidConfig(format!( + "read extension artifact index signature {} for {}: {err}", + signature_path.display(), + index_path.display() + )) + })?; + let signature = toml::from_str::(&signature_text) + .map_err(|err| { + Error::InvalidConfig(format!( + "parse extension artifact index signature {}: {err}", + signature_path.display() + )) + })?; + if signature.schema != EXTENSION_ARTIFACT_INDEX_SIGNATURE_LAYOUT { + return Err(Error::InvalidConfig(format!( + "extension artifact index signature {} has schema='{}', expected '{}'", + signature_path.display(), + signature.schema, + EXTENSION_ARTIFACT_INDEX_SIGNATURE_LAYOUT + ))); + } + if signature.algorithm != "ed25519" { + return Err(Error::InvalidConfig(format!( + "extension artifact index signature {} has algorithm='{}', expected 'ed25519'", + signature_path.display(), + signature.algorithm + ))); + } + validate_portable_id( + &signature.key_id, + "extension artifact index signature key id", + )?; + validate_sha256_hex_like( + &signature_path, + "extension artifact index signature", + &signature.signature, + 128, + )?; + let trusted = trusted_signing_keys + .iter() + .find(|key| key.key_id == signature.key_id) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "extension artifact index signature {} uses untrusted key '{}'", + signature_path.display(), + signature.key_id + )) + })?; + let trusted_public_key = normalize_hex(&trusted.public_key_hex); + if let Some(public_key) = &signature.public_key { + let signature_public_key = normalize_hex(public_key); + decode_hex_fixed::<32>( + "extension artifact index signature public key", + &signature_public_key, + )?; + if signature_public_key != trusted_public_key { + return Err(Error::InvalidConfig(format!( + "extension artifact index signature {} public key does not match trusted key '{}'", + signature_path.display(), + signature.key_id + ))); + } + } + let index_bytes = fs::read(index_path).map_err(|err| { + Error::InvalidConfig(format!( + "read extension artifact index {} for signature verification: {err}", + index_path.display() + )) + })?; + verify_extension_artifact_index_signature_bytes( + &trusted_public_key, + &signature.signature, + &index_bytes, + &signature_path, + ) +} + +fn default_extension_artifact_index_signature_path(index_path: &Path) -> PathBuf { + let mut value = index_path.as_os_str().to_os_string(); + value.push(".sig"); + PathBuf::from(value) +} + +fn validate_sha256_hex_like( + path: &Path, + label: &str, + value: &str, + expected_len: usize, +) -> Result<()> { + if value.len() == expected_len && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(()); + } + Err(Error::InvalidConfig(format!( + "{} {} has invalid {} '{}'", + label, + path.display(), + if expected_len == 64 { "sha256" } else { "hex" }, + value + ))) +} + +#[derive(Debug)] +struct SignedExtensionArtifactIndex { + key_id: String, + public_key_hex: String, + signature_hex: String, +} + +#[cfg(feature = "extension-signing")] +fn sign_extension_artifact_index_bytes( + key_id: &str, + signing_key_hex: &str, + index_bytes: &[u8], +) -> Result { + use ed25519_dalek::{Signer, SigningKey}; + + let signing_key_bytes = decode_hex_fixed::<32>( + "extension artifact index Ed25519 signing key", + signing_key_hex, + )?; + let signing_key = SigningKey::from_bytes(&signing_key_bytes); + let public_key = signing_key.verifying_key().to_bytes(); + let signature = signing_key.sign(index_bytes).to_bytes(); + Ok(SignedExtensionArtifactIndex { + key_id: key_id.to_owned(), + public_key_hex: hex_bytes(&public_key), + signature_hex: hex_bytes(&signature), + }) +} + +#[cfg(not(feature = "extension-signing"))] +fn sign_extension_artifact_index_bytes( + _key_id: &str, + _signing_key_hex: &str, + _index_bytes: &[u8], +) -> Result { + Err(Error::InvalidConfig( + "signing extension artifact indexes requires an oliphaunt-extension-index binary built with the extension-signing feature" + .to_owned(), + )) +} + +#[cfg(feature = "extension-signing")] +fn verify_extension_artifact_index_signature_bytes( + public_key_hex: &str, + signature_hex: &str, + index_bytes: &[u8], + signature_path: &Path, +) -> Result<()> { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + let public_key = decode_hex_fixed::<32>( + "extension artifact index trusted Ed25519 public key", + public_key_hex, + )?; + let signature = + decode_hex_fixed::<64>("extension artifact index Ed25519 signature", signature_hex)?; + let public_key = VerifyingKey::from_bytes(&public_key).map_err(|err| { + Error::InvalidConfig(format!( + "extension artifact index signature {} has invalid Ed25519 public key: {err}", + signature_path.display() + )) + })?; + let signature = Signature::from_bytes(&signature); + public_key.verify(index_bytes, &signature).map_err(|err| { + Error::InvalidConfig(format!( + "extension artifact index signature {} failed verification: {err}", + signature_path.display() + )) + }) +} + +#[cfg(not(feature = "extension-signing"))] +fn verify_extension_artifact_index_signature_bytes( + _public_key_hex: &str, + _signature_hex: &str, + _index_bytes: &[u8], + _signature_path: &Path, +) -> Result<()> { + Err(Error::InvalidConfig( + "verifying signed extension artifact indexes requires an oliphaunt-resources binary built with the extension-signing feature" + .to_owned(), + )) +} + +fn extension_artifact_index_signature_toml(signature: &SignedExtensionArtifactIndex) -> String { + format!( + "schema = {}\nalgorithm = \"ed25519\"\nkey_id = {}\npublic_key = {}\nsignature = {}\n", + toml_string(EXTENSION_ARTIFACT_INDEX_SIGNATURE_LAYOUT), + toml_string(&signature.key_id), + toml_string(&signature.public_key_hex), + toml_string(&signature.signature_hex), + ) +} + +fn normalize_hex(value: &str) -> String { + value + .bytes() + .filter(|byte| !byte.is_ascii_whitespace()) + .map(|byte| (byte as char).to_ascii_lowercase()) + .collect() +} + +fn decode_hex_fixed(label: &str, value: &str) -> Result<[u8; N]> { + let value = normalize_hex(value); + if value.len() != N * 2 { + return Err(Error::InvalidConfig(format!( + "{label} must be {} hex characters", + N * 2 + ))); + } + let mut out = [0u8; N]; + let bytes = value.as_bytes(); + for index in 0..N { + let high = hex_nibble(bytes[index * 2]) + .ok_or_else(|| Error::InvalidConfig(format!("{label} contains a non-hex character")))?; + let low = hex_nibble(bytes[index * 2 + 1]) + .ok_or_else(|| Error::InvalidConfig(format!("{label} contains a non-hex character")))?; + out[index] = (high << 4) | low; + } + Ok(out) +} + +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(feature = "extension-signing")] +pub(super) fn hex_bytes(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + out +} + +fn visit_extension_artifact_index_entry( + sql_name: &str, + target: &str, + entries: &BTreeMap<(String, String), ExtensionArtifactIndexEntry>, + artifact_cache_dir: Option<&Path>, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + artifacts: &mut Vec, + extension_names: &mut Vec, +) -> Result<()> { + if Extension::by_release_ready_sql_name(sql_name).is_some() { + return Ok(()); + } + if visited.contains(sql_name) { + return Ok(()); + } + if !visiting.insert(sql_name.to_owned()) { + return Err(Error::InvalidConfig(format!( + "cyclic extension artifact index dependency involving '{sql_name}'" + ))); + } + let entry = entries + .get(&(target.to_owned(), sql_name.to_owned())) + .ok_or_else(|| missing_extension_artifact_index_entry(sql_name, target, entries))?; + let artifact_path = verify_extension_artifact_index_entry(entry, artifact_cache_dir)?; + let prepared = + PreparedPrebuiltExtensionArtifacts::prepare(&[NativePrebuiltExtensionArtifact::new( + &artifact_path, + )])?; + let loaded = load_prebuilt_extension_artifact(&prepared.artifacts()[0].root)?; + if loaded.sql_name != entry.sql_name { + return Err(Error::InvalidConfig(format!( + "extension artifact index {} maps '{}' to {}, but artifact manifest declares '{}'", + entry.index_path.display(), + entry.sql_name, + entry.path.display(), + loaded.sql_name + ))); + } + for dependency in loaded.dependencies() { + visit_extension_artifact_index_entry( + dependency, + target, + entries, + artifact_cache_dir, + visiting, + visited, + artifacts, + extension_names, + )?; + } + visiting.remove(sql_name); + visited.insert(sql_name.to_owned()); + artifacts.push(NativePrebuiltExtensionArtifact::new(artifact_path)); + extension_names.push(sql_name.to_owned()); + Ok(()) +} + +fn missing_extension_artifact_index_entry( + sql_name: &str, + target: &str, + entries: &BTreeMap<(String, String), ExtensionArtifactIndexEntry>, +) -> Error { + let available_targets = entries + .keys() + .filter_map(|(entry_target, entry_sql_name)| { + (entry_sql_name == sql_name).then_some(entry_target.as_str()) + }) + .collect::>(); + let target_hint = if available_targets.is_empty() { + "no targets are available".to_owned() + } else { + format!( + "available target(s): {}", + available_targets.into_iter().collect::>().join(",") + ) + }; + Error::InvalidConfig(format!( + "extension artifact index has no artifact for extension '{sql_name}' target '{target}' ({target_hint})" + )) +} + +fn verify_extension_artifact_index_entry( + entry: &ExtensionArtifactIndexEntry, + artifact_cache_dir: Option<&Path>, +) -> Result { + if entry.path.is_file() { + verify_extension_artifact_index_file(entry, &entry.path)?; + return Ok(entry.path.clone()); + } + + let Some(url) = &entry.url else { + return Err(Error::InvalidConfig(format!( + "stat extension artifact {} from index {}: file is missing and the index row has no url", + entry.path.display(), + entry.index_path.display() + ))); + }; + let Some(cache_dir) = artifact_cache_dir else { + return Err(Error::InvalidConfig(format!( + "extension artifact {} from index {} is URL-backed; pass --extension-cache so '{}' can be downloaded and verified", + entry.sql_name, + entry.index_path.display(), + url + ))); + }; + let cache_path = extension_artifact_cache_path(cache_dir, entry)?; + if cache_path.is_file() { + verify_extension_artifact_index_file(entry, &cache_path)?; + return Ok(cache_path); + } + download_extension_artifact_to_cache(entry, url, &cache_path)?; + verify_extension_artifact_index_file(entry, &cache_path)?; + Ok(cache_path) +} + +fn verify_extension_artifact_index_file( + entry: &ExtensionArtifactIndexEntry, + path: &Path, +) -> Result<()> { + let metadata = fs::metadata(path).map_err(|err| { + Error::InvalidConfig(format!( + "stat extension artifact {} from index {}: {err}", + path.display(), + entry.index_path.display() + )) + })?; + if !metadata.is_file() { + return Err(Error::InvalidConfig(format!( + "extension artifact {} from index {} must be a file", + path.display(), + entry.index_path.display() + ))); + } + if metadata.len() != entry.bytes { + return Err(Error::InvalidConfig(format!( + "extension artifact {} from index {} has {} bytes, expected {}", + path.display(), + entry.index_path.display(), + metadata.len(), + entry.bytes + ))); + } + let sha256 = sha256_file_hex(path)?; + if sha256 != entry.sha256 { + return Err(Error::InvalidConfig(format!( + "extension artifact {} from index {} has sha256 {}, expected {}", + path.display(), + entry.index_path.display(), + sha256, + entry.sha256 + ))); + } + Ok(()) +} + +fn extension_artifact_cache_path( + cache_dir: &Path, + entry: &ExtensionArtifactIndexEntry, +) -> Result { + validate_relative_artifact_path(cache_dir, "cached artifact path", &entry.relative_path)?; + Ok(cache_dir.join(&entry.target).join(&entry.relative_path)) +} + +fn download_extension_artifact_to_cache( + entry: &ExtensionArtifactIndexEntry, + url: &str, + cache_path: &Path, +) -> Result<()> { + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent) + .map_err(|err| Error::Engine(format!("create {}: {err}", parent.display())))?; + } + let tmp_path = cache_path.with_file_name(format!( + ".{}.{}.tmp", + cache_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("artifact"), + unique_timestamp_suffix() + )); + let download_result = download_extension_artifact_url(url, &tmp_path); + if let Err(error) = download_result { + let _ = fs::remove_file(&tmp_path); + return Err(error); + } + verify_extension_artifact_index_file(entry, &tmp_path)?; + fs::rename(&tmp_path, cache_path).map_err(|err| { + Error::Engine(format!( + "publish downloaded extension artifact {} to cache {}: {err}", + url, + cache_path.display() + )) + })?; + Ok(()) +} + +fn download_extension_artifact_url(url: &str, output: &Path) -> Result<()> { + if let Some(path) = url.strip_prefix("file://") { + let source = PathBuf::from(path); + fs::copy(&source, output).map_err(|err| { + Error::InvalidConfig(format!( + "copy extension artifact URL {} to {}: {err}", + url, + output.display() + )) + })?; + return Ok(()); + } + download_extension_artifact_https_url(url, output) +} + +#[cfg(feature = "extension-download")] +fn download_extension_artifact_https_url(url: &str, output: &Path) -> Result<()> { + let response = ureq::get(url).call().map_err(|err| { + Error::InvalidConfig(format!("download extension artifact URL {url}: {err}")) + })?; + let mut reader = response.into_reader(); + let mut file = File::create(output) + .map_err(|err| Error::Engine(format!("create {}: {err}", output.display())))?; + io::copy(&mut reader, &mut file).map_err(|err| { + Error::Engine(format!( + "write downloaded extension artifact URL {} to {}: {err}", + url, + output.display() + )) + })?; + Ok(()) +} + +#[cfg(not(feature = "extension-download"))] +fn download_extension_artifact_https_url(url: &str, _output: &Path) -> Result<()> { + Err(Error::InvalidConfig(format!( + "extension artifact URL {url} requires an oliphaunt-resources binary built with the extension-download feature" + ))) +} diff --git a/src/sdks/rust/src/runtime_resources/manifest.rs b/src/sdks/rust/src/runtime_resources/manifest.rs new file mode 100644 index 00000000..cf938266 --- /dev/null +++ b/src/sdks/rust/src/runtime_resources/manifest.rs @@ -0,0 +1,519 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use crate::error::{Error, Result}; + +use super::NativeExtensionStaticSymbolAlias; +use super::extension_artifact::{ + mobile_static_archive_artifact_relative_path, + mobile_static_dependency_archive_artifact_relative_path, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct MobileStaticArchive { + pub(super) target: String, + pub(super) relative_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct MobileStaticDependencyArchive { + pub(super) target: String, + pub(super) name: String, + pub(super) relative_path: PathBuf, +} + +pub(super) fn parse_properties_manifest( + path: &Path, + text: &str, +) -> Result> { + let mut properties = BTreeMap::new(); + for (index, raw_line) in text.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(Error::InvalidConfig(format!( + "manifest {} line {} must use key=value syntax", + path.display(), + index + 1 + ))); + }; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() { + return Err(Error::InvalidConfig(format!( + "manifest {} line {} must not use an empty key", + path.display(), + index + 1 + ))); + } + if properties + .insert(key.to_owned(), value.to_owned()) + .is_some() + { + return Err(Error::InvalidConfig(format!( + "manifest {} repeats key '{key}'", + path.display() + ))); + } + } + Ok(properties) +} + +pub(super) fn required_manifest_value<'a>( + path: &Path, + manifest: &'a BTreeMap, + key: &str, +) -> Result<&'a str> { + manifest + .get(key) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + Error::InvalidConfig(format!("manifest {} is missing '{key}'", path.display())) + }) +} + +pub(super) fn require_property( + path: &Path, + manifest: &BTreeMap, + key: &str, + expected: &str, +) -> Result<()> { + let actual = required_manifest_value(path, manifest, key)?; + if actual == expected { + Ok(()) + } else { + Err(Error::InvalidConfig(format!( + "manifest {} has {key}='{actual}', expected '{expected}'", + path.display() + ))) + } +} + +pub(super) fn parse_manifest_bool( + path: &Path, + manifest: &BTreeMap, + key: &str, + default: bool, +) -> Result { + let Some(value) = manifest.get(key) else { + return Ok(default); + }; + match value.trim() { + "true" | "yes" => Ok(true), + "false" | "no" => Ok(false), + other => Err(Error::InvalidConfig(format!( + "manifest {} has {key}='{other}', expected true/false", + path.display() + ))), + } +} + +pub(super) fn optional_manifest_id( + _path: &Path, + manifest: &BTreeMap, + key: &str, +) -> Result> { + let Some(value) = manifest.get(key).map(String::as_str).map(str::trim) else { + return Ok(None); + }; + if value.is_empty() { + return Ok(None); + } + validate_portable_id(value, key)?; + Ok(Some(value.to_owned())) +} + +pub(super) fn optional_manifest_c_identifier( + path: &Path, + manifest: &BTreeMap, + key: &str, +) -> Result> { + let Some(value) = manifest.get(key).map(String::as_str).map(str::trim) else { + return Ok(None); + }; + if value.is_empty() { + return Ok(None); + } + if !is_c_identifier(value) { + return Err(Error::InvalidConfig(format!( + "manifest {} has non-portable C identifier {key}='{value}'", + path.display() + ))); + } + Ok(Some(value.to_owned())) +} + +pub(super) fn parse_manifest_id_list( + _path: &Path, + manifest: &BTreeMap, + key: &str, +) -> Result> { + let Some(value) = manifest.get(key) else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for item in split_manifest_list(value) { + validate_portable_id(item, key)?; + out.push(item.to_owned()); + } + out.sort(); + out.dedup(); + Ok(out) +} + +pub(super) fn parse_manifest_relative_path_list( + path: &Path, + manifest: &BTreeMap, + key: &str, +) -> Result> { + let Some(value) = manifest.get(key) else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for item in split_manifest_list(value) { + let relative = PathBuf::from(item); + validate_relative_artifact_path(path, key, &relative)?; + out.push(relative); + } + out.sort(); + out.dedup(); + Ok(out) +} + +pub(super) fn parse_manifest_static_symbol_aliases( + path: &Path, + manifest: &BTreeMap, + key: &str, +) -> Result> { + let Some(value) = manifest.get(key) else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + let mut sql_symbols = BTreeSet::new(); + for item in split_manifest_list(value) { + let Some((sql_symbol, linked_symbol)) = item.split_once(':') else { + return Err(Error::InvalidConfig(format!( + "manifest {} {key} entry '{}' must use :", + path.display(), + item + ))); + }; + if !is_c_identifier(sql_symbol) { + return Err(Error::InvalidConfig(format!( + "manifest {} has non-portable static symbol alias source '{}'", + path.display(), + sql_symbol + ))); + } + if !is_c_identifier(linked_symbol) { + return Err(Error::InvalidConfig(format!( + "manifest {} has non-portable static symbol alias target '{}'", + path.display(), + linked_symbol + ))); + } + if !sql_symbols.insert(sql_symbol.to_owned()) { + return Err(Error::InvalidConfig(format!( + "manifest {} repeats static symbol alias for '{}'", + path.display(), + sql_symbol + ))); + } + out.push(NativeExtensionStaticSymbolAlias::new( + sql_symbol, + linked_symbol, + )); + } + out.sort_by(|left, right| { + left.sql_symbol + .cmp(&right.sql_symbol) + .then_with(|| left.linked_symbol.cmp(&right.linked_symbol)) + }); + Ok(out) +} + +pub(super) fn parse_manifest_mobile_static_archives( + path: &Path, + manifest: &BTreeMap, + key: &str, +) -> Result> { + let Some(value) = manifest.get(key) else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + let mut targets = BTreeSet::new(); + for item in split_manifest_list(value) { + let Some((target, relative)) = item.split_once(':') else { + return Err(Error::InvalidConfig(format!( + "manifest {} {key} entry '{}' must use :", + path.display(), + item + ))); + }; + validate_portable_id(target, key)?; + if !targets.insert(target.to_owned()) { + return Err(Error::InvalidConfig(format!( + "manifest {} repeats mobile static archive target '{}'", + path.display(), + target + ))); + } + let relative_path = PathBuf::from(relative); + validate_relative_artifact_path(path, key, &relative_path)?; + out.push(MobileStaticArchive { + target: target.to_owned(), + relative_path, + }); + } + out.sort_by(|left, right| left.target.cmp(&right.target)); + Ok(out) +} + +pub(super) fn parse_manifest_mobile_static_dependency_archives( + path: &Path, + manifest: &BTreeMap, + key: &str, +) -> Result> { + let Some(value) = manifest.get(key) else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + let mut keys = BTreeSet::new(); + for item in split_manifest_list(value) { + let mut parts = item.splitn(3, ':'); + let target = parts.next().unwrap_or_default(); + let name = parts.next().unwrap_or_default(); + let relative = parts.next().unwrap_or_default(); + if target.is_empty() || name.is_empty() || relative.is_empty() { + return Err(Error::InvalidConfig(format!( + "manifest {} {key} entry '{}' must use ::", + path.display(), + item + ))); + } + validate_portable_id(target, key)?; + validate_portable_id(name, key)?; + let entry_key = (target.to_owned(), name.to_owned()); + if !keys.insert(entry_key) { + return Err(Error::InvalidConfig(format!( + "manifest {} repeats mobile static dependency archive '{}' for target '{}'", + path.display(), + name, + target + ))); + } + let relative_path = PathBuf::from(relative); + validate_relative_artifact_path(path, key, &relative_path)?; + out.push(MobileStaticDependencyArchive { + target: target.to_owned(), + name: name.to_owned(), + relative_path, + }); + } + out.sort_by(|left, right| { + left.target + .cmp(&right.target) + .then_with(|| left.name.cmp(&right.name)) + }); + Ok(out) +} + +pub(super) fn validate_prebuilt_extension_mobile_static_archives( + root: &Path, + manifest_path: &Path, + native_module_stem: Option<&str>, + mobile_prebuilt: bool, + archives: &[MobileStaticArchive], +) -> Result<()> { + if !archives.is_empty() && native_module_stem.is_none() { + return Err(Error::InvalidConfig(format!( + "manifest {} declares mobileStaticArchives without nativeModuleStem", + manifest_path.display() + ))); + } + if mobile_prebuilt && native_module_stem.is_some() && archives.is_empty() { + return Err(Error::InvalidConfig(format!( + "manifest {} has mobilePrebuilt=yes for a native module but no mobileStaticArchives", + manifest_path.display() + ))); + } + let expected_relative_paths = native_module_stem.map(|stem| { + archives + .iter() + .map(|archive| { + ( + archive.target.clone(), + mobile_static_archive_artifact_relative_path(&archive.target, stem), + ) + }) + .collect::>() + }); + for archive in archives { + if let Some(expected_relative_paths) = &expected_relative_paths { + let expected = expected_relative_paths + .get(&archive.target) + .expect("target was created from archive list"); + if &archive.relative_path != expected { + return Err(Error::InvalidConfig(format!( + "mobile static archive {} from manifest {} must use {}", + archive.relative_path.display(), + manifest_path.display(), + expected.display() + ))); + } + } + let path = root.join(&archive.relative_path); + let metadata = fs::symlink_metadata(&path).map_err(|err| { + Error::InvalidConfig(format!( + "stat mobile static archive {} from manifest {}: {err}", + path.display(), + manifest_path.display() + )) + })?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(Error::InvalidConfig(format!( + "mobile static archive {} from manifest {} must be a regular file", + path.display(), + manifest_path.display() + ))); + } + } + Ok(()) +} + +pub(super) fn validate_prebuilt_extension_mobile_static_dependency_archives( + root: &Path, + manifest_path: &Path, + extension_archives: &[MobileStaticArchive], + dependency_archives: &[MobileStaticDependencyArchive], +) -> Result<()> { + if dependency_archives.is_empty() { + return Ok(()); + } + if extension_archives.is_empty() { + return Err(Error::InvalidConfig(format!( + "manifest {} declares mobileStaticDependencyArchives without mobileStaticArchives", + manifest_path.display() + ))); + } + let extension_targets = extension_archives + .iter() + .map(|archive| archive.target.as_str()) + .collect::>(); + for archive in dependency_archives { + if !extension_targets.contains(archive.target.as_str()) { + return Err(Error::InvalidConfig(format!( + "manifest {} declares mobile static dependency '{}' for target '{}' without a matching mobileStaticArchives target", + manifest_path.display(), + archive.name, + archive.target + ))); + } + let file_name = archive + .relative_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "mobile static dependency archive {} from manifest {} must include a portable file name", + archive.relative_path.display(), + manifest_path.display() + )) + })?; + validate_portable_id(file_name, "mobile static dependency archive file")?; + let expected = mobile_static_dependency_archive_artifact_relative_path( + &archive.target, + &archive.name, + file_name, + ); + if archive.relative_path != expected { + return Err(Error::InvalidConfig(format!( + "mobile static dependency archive {} from manifest {} must use {}", + archive.relative_path.display(), + manifest_path.display(), + expected.display() + ))); + } + let path = root.join(&archive.relative_path); + let metadata = fs::symlink_metadata(&path).map_err(|err| { + Error::InvalidConfig(format!( + "stat mobile static dependency archive {} from manifest {}: {err}", + path.display(), + manifest_path.display() + )) + })?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(Error::InvalidConfig(format!( + "mobile static dependency archive {} from manifest {} must be a regular file", + path.display(), + manifest_path.display() + ))); + } + } + Ok(()) +} + +fn split_manifest_list(value: &str) -> impl Iterator { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +pub(super) fn validate_portable_id(value: &str, label: &str) -> Result<()> { + if is_portable_module_stem(value) { + Ok(()) + } else { + Err(Error::InvalidConfig(format!( + "{label} '{value}' must contain 1 to 128 ASCII letters, digits, '.', '_' or '-'" + ))) + } +} + +pub(super) fn validate_relative_artifact_path( + manifest_path: &Path, + key: &str, + relative: &Path, +) -> Result<()> { + if relative.as_os_str().is_empty() || relative.is_absolute() { + return Err(Error::InvalidConfig(format!( + "manifest {} {key} entry '{}' must be a relative path", + manifest_path.display(), + relative.display() + ))); + } + for component in relative.components() { + match component { + Component::Normal(_) => {} + _ => { + return Err(Error::InvalidConfig(format!( + "manifest {} {key} entry '{}' must not contain '.', '..', prefixes, or root components", + manifest_path.display(), + relative.display() + ))); + } + } + } + Ok(()) +} + +pub(super) fn is_c_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + (first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +pub(super) fn is_portable_module_stem(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} diff --git a/src/sdks/rust/src/runtime_resources/package.rs b/src/sdks/rust/src/runtime_resources/package.rs new file mode 100644 index 00000000..1636b473 --- /dev/null +++ b/src/sdks/rust/src/runtime_resources/package.rs @@ -0,0 +1,699 @@ +use super::*; + +pub(super) fn prepare_output_root(root: &Path, replace_existing: bool) -> Result<()> { + if root.exists() { + if !replace_existing { + return Err(Error::InvalidConfig(format!( + "native runtime-resource output {} already exists; pass --force or replace_existing(true)", + root.display() + ))); + } + fs::remove_dir_all(root) + .map_err(|err| Error::Engine(format!("remove {}: {err}", root.display())))?; + } + fs::create_dir_all(root) + .map_err(|err| Error::Engine(format!("create {}: {err}", root.display()))) +} + +pub(super) fn write_runtime_resource_tree( + root: &Path, + mode: EngineMode, + materialized: &MaterializedNativeResources, + extensions: &[RuntimeResourceExtension], + shared_preload_libraries: &[String], + mobile_static_registry: &MobileStaticRegistryMetadata, + extension_target: Option<&str>, +) -> Result<()> { + let runtime_package = root.join("runtime"); + let runtime_files = runtime_package.join("files"); + copy_portable_tree(&materialized.runtime_dir, &runtime_files)?; + prune_unselected_built_in_extension_artifacts( + &runtime_files, + extensions, + extension_target, + mobile_static_registry, + )?; + copy_prebuilt_extension_artifacts( + &runtime_files, + extensions, + extension_target, + mobile_static_registry, + )?; + write_manifest( + &runtime_package, + &RuntimeResourceManifest { + cache_key: &materialized.runtime_cache_key, + layout: RUNTIME_FILES_LAYOUT, + mode, + extensions, + shared_preload_libraries, + mobile_static_registry, + }, + )?; + + let template_mobile_static_registry = mobile_static_registry_metadata(&[], &[])?; + let template_package = root.join("template-pgdata"); + let template_files = template_package.join("files"); + copy_portable_tree(&materialized.template_pgdata, &template_files)?; + write_manifest( + &template_package, + &RuntimeResourceManifest { + cache_key: &materialized.template_cache_key, + layout: TEMPLATE_PGDATA_LAYOUT, + mode, + extensions: &[], + shared_preload_libraries: &[], + mobile_static_registry: &template_mobile_static_registry, + }, + )?; + write_static_registry_package(root, &runtime_files, extensions, mobile_static_registry)?; + Ok(()) +} + +fn prune_unselected_built_in_extension_artifacts( + runtime_files: &Path, + extensions: &[RuntimeResourceExtension], + extension_target: Option<&str>, + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result<()> { + let selected_built_in = extensions + .iter() + .filter_map(|extension| match extension.source { + RuntimeResourceExtensionSource::BuiltIn(_) => Some(extension), + RuntimeResourceExtensionSource::Prebuilt { .. } => None, + }) + .collect::>(); + prune_built_in_extension_sql_files(runtime_files, &selected_built_in)?; + prune_built_in_extension_data_files(runtime_files, &selected_built_in)?; + prune_built_in_extension_module_files( + runtime_files, + &selected_built_in, + extension_target, + mobile_static_registry, + )?; + prune_prebuilt_extension_base_artifact_paths(runtime_files, extensions)?; + Ok(()) +} + +fn prune_built_in_extension_sql_files( + runtime_files: &Path, + selected_built_in: &[&RuntimeResourceExtension], +) -> Result<()> { + let extension_dir = runtime_files.join("share/postgresql/extension"); + if !extension_dir.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(&extension_dir) + .map_err(|err| Error::Engine(format!("read {}: {err}", extension_dir.display())))? + { + let entry = entry.map_err(|err| { + Error::Engine(format!("read entry in {}: {err}", extension_dir.display())) + })?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let file_name = entry.file_name().to_string_lossy().into_owned(); + let keep = selected_built_in + .iter() + .any(|extension| extension_sql_file_belongs(&extension.sql_name, &file_name)); + if !keep { + fs::remove_file(&path) + .map_err(|err| Error::Engine(format!("remove {}: {err}", path.display())))?; + } + } + Ok(()) +} + +fn prune_built_in_extension_data_files( + runtime_files: &Path, + selected_built_in: &[&RuntimeResourceExtension], +) -> Result<()> { + let selected_data_files = selected_built_in + .iter() + .flat_map(|extension| extension.data_files.iter().cloned()) + .collect::>(); + for extension in Extension::ALL_PG18_SUPPORTED { + for relative in extension_data_paths(*extension) { + if selected_data_files.contains(&relative) { + continue; + } + remove_runtime_file_if_present( + runtime_files, + &PathBuf::from("share/postgresql").join(relative), + )?; + } + } + Ok(()) +} + +fn prune_built_in_extension_module_files( + runtime_files: &Path, + selected_built_in: &[&RuntimeResourceExtension], + extension_target: Option<&str>, + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result<()> { + let mut selected_modules = BTreeSet::new(); + for extension in selected_built_in { + if extension_dynamic_module_required(extension, extension_target, mobile_static_registry)? { + if let Some(module) = &extension.native_module_file { + selected_modules.insert(module.clone()); + } + } + } + for extension in Extension::ALL_PG18_SUPPORTED { + let Some(module) = extension.native_module_file() else { + continue; + }; + if selected_modules.contains(&module) { + continue; + } + remove_runtime_file_if_present( + runtime_files, + &PathBuf::from("lib/postgresql").join(module), + )?; + } + Ok(()) +} + +fn remove_runtime_file_if_present(runtime_files: &Path, relative: &Path) -> Result<()> { + let path = runtime_files.join(relative); + if !path.exists() { + return Ok(()); + } + if path.is_file() { + return fs::remove_file(&path) + .map_err(|err| Error::Engine(format!("remove {}: {err}", path.display()))); + } + Err(Error::InvalidConfig(format!( + "expected extension runtime asset {} to be a regular file", + path.display() + ))) +} + +fn prune_prebuilt_extension_base_artifact_paths( + runtime_files: &Path, + extensions: &[RuntimeResourceExtension], +) -> Result<()> { + for extension in extensions { + let RuntimeResourceExtensionSource::Prebuilt { .. } = &extension.source else { + continue; + }; + for relative in &extension.data_files { + remove_runtime_file_if_present( + runtime_files, + &PathBuf::from("share/postgresql").join(relative), + )?; + } + if let Some(module) = &extension.native_module_file { + remove_runtime_file_if_present( + runtime_files, + &PathBuf::from("lib/postgresql").join(module), + )?; + } + } + Ok(()) +} + +pub(super) fn copy_prebuilt_extension_artifacts( + runtime_files: &Path, + extensions: &[RuntimeResourceExtension], + extension_target: Option<&str>, + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result<()> { + for extension in extensions { + let RuntimeResourceExtensionSource::Prebuilt { root, files_root } = &extension.source + else { + continue; + }; + copy_prebuilt_extension_sql_files(root, files_root, runtime_files, extension)?; + for relative in &extension.data_files { + copy_artifact_runtime_file( + files_root, + runtime_files, + &PathBuf::from("share/postgresql").join(relative), + )?; + } + if prebuilt_extension_dynamic_module_required( + extension, + extension_target, + mobile_static_registry, + )? { + let Some(module) = &extension.native_module_file else { + continue; + }; + copy_artifact_runtime_file( + files_root, + runtime_files, + &PathBuf::from("lib/postgresql").join(module), + )?; + } + } + Ok(()) +} + +fn copy_prebuilt_extension_sql_files( + artifact_root: &Path, + files_root: &Path, + runtime_files: &Path, + extension: &RuntimeResourceExtension, +) -> Result<()> { + let source_dir = files_root.join("share/postgresql/extension"); + let target_dir = runtime_files.join("share/postgresql/extension"); + if !source_dir.is_dir() { + if extension.creates_extension { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact {} is missing files/share/postgresql/extension for '{}'", + artifact_root.display(), + extension.sql_name + ))); + } + return Ok(()); + } + + let mut copied_control = false; + let mut copied_sql = false; + let mut copied = 0usize; + for entry in fs::read_dir(&source_dir) + .map_err(|err| Error::Engine(format!("read {}: {err}", source_dir.display())))? + { + let entry = entry.map_err(|err| { + Error::Engine(format!("read entry in {}: {err}", source_dir.display())) + })?; + let file_name = entry.file_name().to_string_lossy().into_owned(); + if !extension_sql_file_belongs(&extension.sql_name, &file_name) { + continue; + } + copied += 1; + if file_name == format!("{}.control", extension.sql_name) { + copied_control = true; + } else if file_name.ends_with(".sql") { + copied_sql = true; + } + copy_portable_tree(&entry.path(), &target_dir.join(file_name))?; + } + + if extension.creates_extension && (!copied_control || !copied_sql) { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact {} for '{}' must include a control file and at least one SQL install file", + artifact_root.display(), + extension.sql_name + ))); + } + if !extension.creates_extension && copied == 0 { + return Ok(()); + } + Ok(()) +} + +fn copy_artifact_runtime_file( + source_root: &Path, + runtime_files: &Path, + relative: &Path, +) -> Result<()> { + validate_relative_artifact_path(source_root, "runtime file", relative)?; + let source = source_root.join(relative); + if !source.is_file() { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact is missing declared file {}", + source.display() + ))); + } + copy_portable_tree(&source, &runtime_files.join(relative)) +} + +pub(super) fn runtime_resource_size_report( + root: &Path, + selected_extensions: &[RuntimeResourceExtension], + extension_target: Option<&str>, + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result { + let runtime_files = root.join("runtime/files"); + let template_pgdata_files = root.join("template-pgdata/files"); + let static_registry = root.join("static-registry"); + let selected_extension_paths = extension_asset_paths( + &runtime_files, + selected_extensions, + extension_target, + mobile_static_registry, + )?; + + let mut extension_reports = Vec::new(); + for extension in selected_extensions { + let extension_paths = extension_asset_paths( + &runtime_files, + std::slice::from_ref(extension), + extension_target, + mobile_static_registry, + )?; + extension_reports.push(ExtensionSizeReport { + name: extension.sql_name.clone(), + file_count: extension_paths.len(), + bytes: byte_sum(&runtime_files, &extension_paths)?, + }); + } + extension_reports.sort_by(|left, right| left.name.cmp(&right.name)); + + let runtime_bytes = tree_size(&runtime_files)?; + let template_pgdata_bytes = tree_size(&template_pgdata_files)?; + let static_registry_bytes = tree_size(&static_registry)?; + Ok(NativeRuntimeResourceSizeReport { + path: root.join("package-size.tsv"), + package_bytes: runtime_bytes + template_pgdata_bytes + static_registry_bytes, + runtime_bytes, + template_pgdata_bytes, + static_registry_bytes, + selected_extension_bytes: byte_sum(&runtime_files, &selected_extension_paths)?, + extensions: extension_reports, + }) +} + +pub(super) fn write_runtime_resource_size_report( + report: &NativeRuntimeResourceSizeReport, +) -> Result<()> { + let mut lines = vec![ + "kind\tid\textensions\tfiles\tbytes".to_owned(), + format!("package\ttotal\t-\t-\t{}", report.package_bytes), + format!("package\truntime\t-\t-\t{}", report.runtime_bytes), + format!( + "package\ttemplate-pgdata\t-\t-\t{}", + report.template_pgdata_bytes + ), + format!( + "package\tstatic-registry\t-\t-\t{}", + report.static_registry_bytes + ), + format!( + "extensions\tselected\t-\t-\t{}", + report.selected_extension_bytes + ), + ]; + for extension in &report.extensions { + lines.push(format!( + "extension\t{}\t-\t{}\t{}", + extension.name, extension.file_count, extension.bytes + )); + } + let text = format!("{}\n", lines.join("\n")); + fs::write(&report.path, text).map_err(|err| { + Error::Engine(format!( + "write native runtime resource size report {}: {err}", + report.path.display() + )) + }) +} + +fn extension_asset_paths( + runtime_files: &Path, + extensions: &[RuntimeResourceExtension], + extension_target: Option<&str>, + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result> { + let mut paths = BTreeSet::new(); + for extension in extensions { + if extension.creates_extension { + let extension_dir = runtime_files.join("share/postgresql/extension"); + let mut matched_sql = false; + for entry in fs::read_dir(&extension_dir) + .map_err(|err| Error::Engine(format!("read {}: {err}", extension_dir.display())))? + { + let entry = entry.map_err(|err| { + Error::Engine(format!("read entry in {}: {err}", extension_dir.display())) + })?; + let file_name = entry.file_name().to_string_lossy().into_owned(); + if extension_sql_file_belongs(&extension.sql_name, &file_name) { + let relative = PathBuf::from("share/postgresql/extension").join(&file_name); + require_report_file(runtime_files, &relative)?; + if file_name.ends_with(".sql") { + matched_sql = true; + } + paths.insert(relative); + } + } + if !matched_sql { + return Err(Error::Engine(format!( + "native runtime resource size report could not find SQL assets for selected extension '{}'", + extension.sql_name + ))); + } + } + for relative in &extension.data_files { + let relative = PathBuf::from("share/postgresql").join(relative); + require_report_file(runtime_files, &relative)?; + paths.insert(relative); + } + if extension_dynamic_module_required(extension, extension_target, mobile_static_registry)? { + let Some(module) = &extension.native_module_file else { + continue; + }; + let relative = PathBuf::from("lib/postgresql").join(module); + require_report_file(runtime_files, &relative)?; + paths.insert(relative); + } + } + Ok(paths) +} + +fn extension_dynamic_module_required( + extension: &RuntimeResourceExtension, + extension_target: Option<&str>, + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result { + let Some(stem) = extension.native_module_stem.as_deref() else { + return Ok(false); + }; + if mobile_static_registry.state == MobileStaticRegistryState::Complete + && mobile_static_registry + .native_module_stems + .iter() + .any(|registered| registered == stem) + { + return Ok(false); + } + if let RuntimeResourceExtensionSource::Prebuilt { .. } = &extension.source { + validate_prebuilt_extension_target(extension, extension_target)?; + } + Ok(extension.native_module_file.is_some()) +} + +fn prebuilt_extension_dynamic_module_required( + extension: &RuntimeResourceExtension, + extension_target: Option<&str>, + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result { + debug_assert!(matches!( + extension.source, + RuntimeResourceExtensionSource::Prebuilt { .. } + )); + extension_dynamic_module_required(extension, extension_target, mobile_static_registry) +} + +fn validate_prebuilt_extension_target( + extension: &RuntimeResourceExtension, + extension_target: Option<&str>, +) -> Result<()> { + let Some(native_target) = extension.native_target.as_deref() else { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact for '{}' declares a native module but no nativeTarget", + extension.sql_name + ))); + }; + let Some(extension_target) = extension_target else { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact for '{}' targets '{}', but runtime packaging did not declare --extension-target", + extension.sql_name, native_target + ))); + }; + if native_target != extension_target { + return Err(Error::InvalidConfig(format!( + "prebuilt extension artifact for '{}' targets '{}', but runtime packaging target is '{}'", + extension.sql_name, native_target, extension_target + ))); + } + Ok(()) +} + +fn require_report_file(root: &Path, relative: &Path) -> Result<()> { + let path = root.join(relative); + if path.is_file() { + return Ok(()); + } + Err(Error::Engine(format!( + "native runtime resource size report expected file {}", + path.display() + ))) +} + +fn byte_sum(root: &Path, relative_paths: &BTreeSet) -> Result { + relative_paths.iter().try_fold(0u64, |total, relative| { + fs::metadata(root.join(relative)) + .map(|metadata| total + metadata.len()) + .map_err(|err| { + Error::Engine(format!( + "stat native runtime resource size report file {}: {err}", + root.join(relative).display() + )) + }) + }) +} + +fn tree_size(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|err| { + Error::Engine(format!( + "stat {} for package size report: {err}", + path.display() + )) + })?; + let file_type = metadata.file_type(); + if file_type.is_file() { + return Ok(metadata.len()); + } + if file_type.is_symlink() { + return Err(Error::Engine(format!( + "native runtime resource size report does not support symlinks: {}", + path.display() + ))); + } + if !file_type.is_dir() { + return Err(Error::Engine(format!( + "native runtime resource size report only supports files and directories: {}", + path.display() + ))); + } + let mut total = 0u64; + let mut entries = fs::read_dir(path) + .map_err(|err| { + Error::Engine(format!( + "read {} for package size report: {err}", + path.display() + )) + })? + .collect::, _>>() + .map_err(|err| { + Error::Engine(format!( + "read entry in {} for package size report: {err}", + path.display() + )) + })?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + total += tree_size(&entry.path())?; + } + Ok(total) +} + +pub(super) struct RuntimeResourceManifest<'a> { + pub(super) cache_key: &'a str, + pub(super) layout: &'a str, + pub(super) mode: EngineMode, + pub(super) extensions: &'a [RuntimeResourceExtension], + pub(super) shared_preload_libraries: &'a [String], + pub(super) mobile_static_registry: &'a MobileStaticRegistryMetadata, +} + +fn write_manifest(package_dir: &Path, manifest: &RuntimeResourceManifest<'_>) -> Result<()> { + fs::create_dir_all(package_dir) + .map_err(|err| Error::Engine(format!("create {}: {err}", package_dir.display())))?; + fs::write( + package_dir.join("manifest.properties"), + manifest_text(manifest), + ) + .map_err(|err| { + Error::Engine(format!( + "write native resource manifest {}: {err}", + package_dir.join("manifest.properties").display() + )) + }) +} + +pub(super) fn manifest_text(manifest: &RuntimeResourceManifest<'_>) -> String { + format!( + "schema={RUNTIME_RESOURCES_SCHEMA}\nlayout={}\nmode={}\ncacheKey={}\nextensions={}\nsharedPreloadLibraries={}\nmobileStaticRegistryState={}\nmobileStaticRegistryRegistered={}\nmobileStaticRegistryPending={}\nnativeModuleStems={}\nmobileStaticRegistrySource={}\n", + manifest.layout, + manifest.mode, + manifest.cache_key, + selected_extension_names(manifest.extensions).join(","), + manifest.shared_preload_libraries.join(","), + manifest.mobile_static_registry.state.as_manifest_value(), + manifest + .mobile_static_registry + .registered_extensions + .join(","), + manifest.mobile_static_registry.pending_extensions.join(","), + manifest + .mobile_static_registry + .native_module_stems + .join(","), + mobile_static_registry_source_value(manifest.mobile_static_registry), + ) +} + +pub(super) fn copy_portable_tree(source: &Path, destination: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(source) + .map_err(|err| Error::Engine(format!("stat {}: {err}", source.display())))?; + let file_type = metadata.file_type(); + if file_type.is_symlink() { + return Err(Error::Engine(format!( + "native runtime resources do not support symlinks: {}", + source.display() + ))); + } + if file_type.is_file() { + copy_portable_file(source, destination, &metadata)?; + return Ok(()); + } + if !file_type.is_dir() { + return Err(Error::Engine(format!( + "native runtime resources only support files and directories: {}", + source.display() + ))); + } + + fs::create_dir_all(destination) + .map_err(|err| Error::Engine(format!("create {}: {err}", destination.display())))?; + fs::set_permissions(destination, metadata.permissions()).map_err(|err| { + Error::Engine(format!( + "set permissions on {}: {err}", + destination.display() + )) + })?; + + let mut entries = fs::read_dir(source) + .map_err(|err| Error::Engine(format!("read directory {}: {err}", source.display())))? + .collect::, _>>() + .map_err(|err| { + Error::Engine(format!( + "read directory entry in {}: {err}", + source.display() + )) + })?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + copy_portable_tree(&entry.path(), &destination.join(entry.file_name()))?; + } + Ok(()) +} + +pub(super) fn copy_portable_file( + source: &Path, + destination: &Path, + metadata: &fs::Metadata, +) -> Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|err| Error::Engine(format!("create {}: {err}", parent.display())))?; + } + fs::copy(source, destination).map_err(|err| { + Error::Engine(format!( + "copy {} -> {}: {err}", + source.display(), + destination.display() + )) + })?; + fs::set_permissions(destination, metadata.permissions()).map_err(|err| { + Error::Engine(format!( + "set permissions on {}: {err}", + destination.display() + )) + }) +} diff --git a/src/sdks/rust/src/runtime_resources/static_registry.rs b/src/sdks/rust/src/runtime_resources/static_registry.rs new file mode 100644 index 00000000..96dc3b7b --- /dev/null +++ b/src/sdks/rust/src/runtime_resources/static_registry.rs @@ -0,0 +1,779 @@ +use super::*; + +pub(super) fn mobile_static_registry_source_value( + metadata: &MobileStaticRegistryMetadata, +) -> &'static str { + if metadata.state == MobileStaticRegistryState::Complete { + STATIC_REGISTRY_SOURCE_MANIFEST_VALUE + } else { + "" + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct StaticRegistryModule { + pub(super) extension_sql_name: String, + pub(super) module_stem: String, + pub(super) symbol_prefix: String, + pub(super) sql_symbols: Vec, + pub(super) symbol_aliases: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct StaticRegistryArchive { + pub(super) module_stem: String, + pub(super) target: String, + pub(super) relative_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct StaticRegistryDependencyArchive { + pub(super) name: String, + pub(super) target: String, + pub(super) relative_path: PathBuf, +} + +pub(super) fn write_static_registry_package( + root: &Path, + runtime_dir: &Path, + extensions: &[RuntimeResourceExtension], + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result<()> { + let package_dir = root.join("static-registry"); + fs::create_dir_all(&package_dir) + .map_err(|err| Error::Engine(format!("create {}: {err}", package_dir.display())))?; + let modules = static_registry_modules(runtime_dir, extensions, mobile_static_registry)?; + if mobile_static_registry.state == MobileStaticRegistryState::Complete { + let source = static_registry_source_text(&modules); + fs::write(package_dir.join(STATIC_REGISTRY_SOURCE_FILE), source).map_err(|err| { + Error::Engine(format!( + "write static registry source {}: {err}", + package_dir.join(STATIC_REGISTRY_SOURCE_FILE).display() + )) + })?; + } + let archives = copy_prebuilt_mobile_static_archives(&package_dir, extensions)?; + let dependency_archives = + copy_prebuilt_mobile_static_dependency_archives(&package_dir, extensions)?; + fs::write( + package_dir.join("manifest.properties"), + static_registry_manifest_text( + mobile_static_registry, + &modules, + &archives, + &dependency_archives, + ), + ) + .map_err(|err| { + Error::Engine(format!( + "write static registry manifest {}: {err}", + package_dir.join("manifest.properties").display() + )) + }) +} + +pub(super) fn copy_prebuilt_mobile_static_archives( + package_dir: &Path, + extensions: &[RuntimeResourceExtension], +) -> Result> { + let mut copied = BTreeMap::<(String, String), StaticRegistryArchive>::new(); + for extension in extensions { + let Some(stem) = extension.native_module_stem.as_deref() else { + continue; + }; + let RuntimeResourceExtensionSource::Prebuilt { root, .. } = &extension.source else { + continue; + }; + for archive in &extension.mobile_static_archives { + validate_relative_artifact_path(root, "mobile static archive", &archive.relative_path)?; + let source = root.join(&archive.relative_path); + let target_relative = PathBuf::from(STATIC_REGISTRY_ARCHIVES_DIR) + .join(&archive.target) + .join("extensions") + .join(stem) + .join(format!("liboliphaunt_extension_{stem}.a")); + let key = (stem.to_owned(), archive.target.clone()); + if copied.contains_key(&key) { + return Err(Error::InvalidConfig(format!( + "selected extension '{}' repeats mobile static archive target '{}'", + extension.sql_name, archive.target + ))); + } + copy_portable_tree(&source, &package_dir.join(&target_relative))?; + copied.insert( + key, + StaticRegistryArchive { + module_stem: stem.to_owned(), + target: archive.target.clone(), + relative_path: target_relative, + }, + ); + } + } + Ok(copied.into_values().collect()) +} + +pub(super) fn copy_prebuilt_mobile_static_dependency_archives( + package_dir: &Path, + extensions: &[RuntimeResourceExtension], +) -> Result> { + let mut copied = BTreeMap::<(String, String), StaticRegistryDependencyArchive>::new(); + for extension in extensions { + let RuntimeResourceExtensionSource::Prebuilt { root, .. } = &extension.source else { + continue; + }; + for archive in &extension.mobile_static_dependency_archives { + validate_relative_artifact_path( + root, + "mobile static dependency archive", + &archive.relative_path, + )?; + let source = root.join(&archive.relative_path); + let file_name = archive + .relative_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "selected extension '{}' has mobile static dependency archive without a portable file name: {}", + extension.sql_name, + archive.relative_path.display() + )) + })?; + let target_relative = PathBuf::from(STATIC_REGISTRY_ARCHIVES_DIR) + .join(&archive.target) + .join("dependencies") + .join(&archive.name) + .join(file_name); + let key = (archive.name.clone(), archive.target.clone()); + if copied.contains_key(&key) { + return Err(Error::InvalidConfig(format!( + "selected extension '{}' repeats mobile static dependency archive '{}' for target '{}'", + extension.sql_name, archive.name, archive.target + ))); + } + copy_portable_tree(&source, &package_dir.join(&target_relative))?; + copied.insert( + key, + StaticRegistryDependencyArchive { + name: archive.name.clone(), + target: archive.target.clone(), + relative_path: target_relative, + }, + ); + } + } + Ok(copied.into_values().collect()) +} + +pub(super) fn static_registry_modules( + runtime_dir: &Path, + extensions: &[RuntimeResourceExtension], + mobile_static_registry: &MobileStaticRegistryMetadata, +) -> Result> { + if mobile_static_registry.state != MobileStaticRegistryState::Complete { + return Ok(Vec::new()); + } + let registered_stems = mobile_static_registry + .native_module_stems + .iter() + .cloned() + .collect::>(); + let mut modules_by_stem = BTreeMap::::new(); + let mut prefixes = BTreeSet::new(); + for extension in extensions { + let Some(module_stem) = extension.native_module_stem.as_deref() else { + continue; + }; + if !registered_stems.contains(module_stem) { + continue; + } + let symbol_prefix = extension + .static_symbol_prefix + .clone() + .unwrap_or_else(|| static_registry_symbol_prefix(module_stem)); + if !prefixes.insert(symbol_prefix.clone()) { + return Err(Error::InvalidConfig(format!( + "mobile static registry module stem '{module_stem}' generates duplicate symbol prefix '{symbol_prefix}'" + ))); + } + let mut sql_symbols = collect_extension_sql_symbols(runtime_dir, extension)?; + sql_symbols.sort(); + sql_symbols.dedup(); + let mut symbol_aliases = BTreeMap::new(); + for alias in &extension.static_symbol_aliases { + if symbol_aliases + .insert(alias.sql_symbol.clone(), alias.linked_symbol.clone()) + .is_some() + { + return Err(Error::InvalidConfig(format!( + "mobile static registry repeats alias '{}' for extension '{}'", + alias.sql_symbol, extension.sql_name + ))); + } + } + modules_by_stem.insert( + module_stem.to_owned(), + StaticRegistryModule { + extension_sql_name: extension.sql_name.clone(), + module_stem: module_stem.to_owned(), + symbol_prefix, + sql_symbols, + symbol_aliases, + }, + ); + } + let missing = registered_stems + .difference(&modules_by_stem.keys().cloned().collect::>()) + .cloned() + .collect::>(); + if !missing.is_empty() { + return Err(Error::InvalidConfig(format!( + "mobile static registry module stem(s) are marked complete but no selected native extension uses them: {}", + missing.join(",") + ))); + } + Ok(modules_by_stem.into_values().collect()) +} + +pub(super) fn collect_extension_sql_symbols( + runtime_dir: &Path, + extension: &RuntimeResourceExtension, +) -> Result> { + if !extension.creates_extension { + return Ok(Vec::new()); + } + let extension_dir = runtime_dir.join("share/postgresql/extension"); + let prefix = format!("{}--", extension.sql_name); + let mut symbols = BTreeSet::new(); + let mut found_sql_file = false; + for entry in fs::read_dir(&extension_dir) + .map_err(|err| Error::Engine(format!("read {}: {err}", extension_dir.display())))? + { + let entry = entry.map_err(|err| { + Error::Engine(format!("read entry in {}: {err}", extension_dir.display())) + })?; + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with(&prefix) || !file_name.ends_with(".sql") { + continue; + } + found_sql_file = true; + let path = entry.path(); + let text = fs::read_to_string(&path).map_err(|err| { + Error::Engine(format!("read extension SQL {}: {err}", path.display())) + })?; + for symbol in module_pathname_c_symbols(&text)? { + symbols.insert(symbol); + } + } + if !found_sql_file { + return Err(Error::InvalidConfig(format!( + "selected extension {} has no packaged SQL files in {}", + extension.sql_name, + extension_dir.display() + ))); + } + Ok(symbols.into_iter().collect()) +} + +pub(super) fn module_pathname_c_symbols(sql: &str) -> Result> { + let mut symbols = BTreeSet::new(); + let stripped = strip_sql_line_comments(sql); + for statement in split_sql_statements(&stripped) { + if !contains_ascii_case_insensitive(statement, "module_pathname") + || !has_language_c(statement) + { + continue; + } + let Some(symbol) = explicit_module_pathname_symbol(statement) + .or_else(|| implicit_function_symbol(statement)) + else { + continue; + }; + if !is_c_identifier(&symbol) { + return Err(Error::InvalidConfig(format!( + "extension SQL references non-portable C symbol '{symbol}'" + ))); + } + symbols.insert(symbol); + } + Ok(symbols.into_iter().collect()) +} + +fn strip_sql_line_comments(sql: &str) -> String { + let mut out = String::with_capacity(sql.len()); + let mut chars = sql.chars().peekable(); + let mut in_string = false; + while let Some(ch) = chars.next() { + if ch == '\'' { + out.push(ch); + if in_string && chars.peek() == Some(&'\'') { + if let Some(next) = chars.next() { + out.push(next); + } + } else { + in_string = !in_string; + } + continue; + } + if !in_string && ch == '-' && chars.peek() == Some(&'-') { + let _ = chars.next(); + for next in chars.by_ref() { + if next == '\n' { + out.push('\n'); + break; + } + } + continue; + } + out.push(ch); + } + out +} + +fn split_sql_statements(sql: &str) -> Vec<&str> { + let mut statements = Vec::new(); + let mut start = 0; + let mut in_string = false; + let mut iter = sql.char_indices().peekable(); + while let Some((index, ch)) = iter.next() { + if ch == '\'' { + if in_string && iter.peek().map(|(_, next)| *next) == Some('\'') { + let _ = iter.next(); + } else { + in_string = !in_string; + } + } else if !in_string && ch == ';' { + statements.push(sql[start..index].trim()); + start = index + ch.len_utf8(); + } + } + if start < sql.len() { + statements.push(sql[start..].trim()); + } + statements + .into_iter() + .filter(|value| !value.is_empty()) + .collect() +} + +fn explicit_module_pathname_symbol(statement: &str) -> Option { + let lower = statement.to_ascii_lowercase(); + let module_index = lower.find("module_pathname")?; + let mut rest = &statement[module_index + "module_pathname".len()..]; + rest = rest.trim_start(); + if let Some(after_quote) = rest.strip_prefix('\'') { + rest = after_quote.trim_start(); + } + rest = rest.strip_prefix(',')?.trim_start(); + parse_sql_single_quoted_literal(rest).map(|(symbol, _)| symbol) +} + +fn implicit_function_symbol(statement: &str) -> Option { + let lower = statement.to_ascii_lowercase(); + let function_index = lower.find("function")?; + let after_function = &statement[function_index + "function".len()..]; + let name_end = after_function.find('(')?; + let raw_name = after_function[..name_end].trim(); + let identifier = last_sql_identifier(raw_name)?; + if identifier.is_empty() { + None + } else { + Some(identifier) + } +} + +fn parse_sql_single_quoted_literal(value: &str) -> Option<(String, &str)> { + let mut chars = value.char_indices(); + let (_, first) = chars.next()?; + if first != '\'' { + return None; + } + let mut out = String::new(); + while let Some((index, ch)) = chars.next() { + if ch == '\'' { + if let Some((_, '\'')) = chars.clone().next() { + let _ = chars.next(); + out.push('\''); + continue; + } + let end = index + ch.len_utf8(); + return Some((out, &value[end..])); + } + out.push(ch); + } + None +} + +fn last_sql_identifier(raw_name: &str) -> Option { + let mut parts = Vec::new(); + let mut start = 0; + let mut in_quotes = false; + let mut iter = raw_name.char_indices().peekable(); + while let Some((index, ch)) = iter.next() { + if ch == '"' { + if in_quotes && iter.peek().map(|(_, next)| *next) == Some('"') { + let _ = iter.next(); + } else { + in_quotes = !in_quotes; + } + } else if !in_quotes && ch == '.' { + parts.push(raw_name[start..index].trim()); + start = index + ch.len_utf8(); + } + } + parts.push(raw_name[start..].trim()); + let part = parts.last()?.trim(); + if part.starts_with('"') && part.ends_with('"') && part.len() >= 2 { + Some(part[1..part.len() - 1].replace("\"\"", "\"")) + } else { + Some(part.to_owned()) + } +} + +fn has_language_c(statement: &str) -> bool { + let tokens = statement + .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') + .filter(|token| !token.is_empty()) + .map(|token| token.to_ascii_lowercase()) + .collect::>(); + tokens + .windows(2) + .any(|window| window[0] == "language" && window[1] == "c") +} + +fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + haystack + .to_ascii_lowercase() + .contains(&needle.to_ascii_lowercase()) +} + +fn static_registry_symbol_prefix(module_stem: &str) -> String { + let mut out = String::from("oliphaunt_static_"); + for byte in module_stem.bytes() { + if byte.is_ascii_alphanumeric() || byte == b'_' { + out.push(byte as char); + } else { + out.push('_'); + } + } + out +} + +pub(super) fn static_registry_manifest_text( + metadata: &MobileStaticRegistryMetadata, + modules: &[StaticRegistryModule], + archives: &[StaticRegistryArchive], + dependency_archives: &[StaticRegistryDependencyArchive], +) -> String { + let archive_targets = archives + .iter() + .map(|archive| archive.target.clone()) + .collect::>() + .into_iter() + .collect::>(); + let dependency_archive_targets = dependency_archives + .iter() + .map(|archive| archive.target.clone()) + .collect::>() + .into_iter() + .collect::>(); + let dependency_archive_names = dependency_archives + .iter() + .map(|archive| archive.name.clone()) + .collect::>() + .into_iter() + .collect::>(); + let mut text = format!( + "packageLayout={STATIC_REGISTRY_PACKAGE_LAYOUT}\nabiVersion=1\nstate={}\nsource={}\nregisteredExtensions={}\npendingExtensions={}\nnativeModuleStems={}\nmodules={}\narchiveTargets={}\ndependencyArchiveTargets={}\ndependencyArchives={}\n", + metadata.state.as_manifest_value(), + if metadata.state == MobileStaticRegistryState::Complete { + STATIC_REGISTRY_SOURCE_FILE + } else { + "" + }, + metadata.registered_extensions.join(","), + metadata.pending_extensions.join(","), + metadata.native_module_stems.join(","), + modules + .iter() + .map(|module| module.module_stem.as_str()) + .collect::>() + .join(","), + archive_targets.join(","), + dependency_archive_targets.join(","), + dependency_archive_names.join(","), + ); + for module in modules { + let module_archives = archives + .iter() + .filter(|archive| archive.module_stem == module.module_stem) + .collect::>(); + let module_archive_targets = module_archives + .iter() + .map(|archive| archive.target.as_str()) + .collect::>(); + text.push_str(&format!( + "module.{}.extension={}\nmodule.{}.symbolPrefix={}\nmodule.{}.sqlSymbols={}\nmodule.{}.symbolAliases={}\nmodule.{}.archiveTargets={}\n", + module.module_stem, + module.extension_sql_name, + module.module_stem, + module.symbol_prefix, + module.module_stem, + module.sql_symbols.join(","), + module.module_stem, + module + .symbol_aliases + .iter() + .map(|(sql_symbol, linked_symbol)| format!("{sql_symbol}:{linked_symbol}")) + .collect::>() + .join(","), + module.module_stem, + module_archive_targets.join(","), + )); + for archive in module_archives { + text.push_str(&format!( + "module.{}.archive.{}={}\n", + module.module_stem, + archive.target, + archive.relative_path.to_string_lossy(), + )); + } + } + for dependency in dependency_archive_names { + let archives_for_dependency = dependency_archives + .iter() + .filter(|archive| archive.name == dependency) + .collect::>(); + let archive_targets = archives_for_dependency + .iter() + .map(|archive| archive.target.as_str()) + .collect::>(); + text.push_str(&format!( + "dependency.{}.archiveTargets={}\n", + dependency, + archive_targets.join(","), + )); + for archive in archives_for_dependency { + text.push_str(&format!( + "dependency.{}.archive.{}={}\n", + dependency, + archive.target, + archive.relative_path.to_string_lossy(), + )); + } + } + text +} + +fn static_registry_linked_symbol(module: &StaticRegistryModule, symbol: &str) -> String { + module + .symbol_aliases + .get(symbol) + .cloned() + .unwrap_or_else(|| symbol.to_owned()) +} + +fn static_registry_sql_symbol_names(module: &StaticRegistryModule) -> BTreeSet { + let mut names = BTreeSet::new(); + for symbol in &module.sql_symbols { + names.insert(symbol.clone()); + names.insert(format!("pg_finfo_{symbol}")); + } + names +} + +pub(super) fn static_registry_source_text(modules: &[StaticRegistryModule]) -> String { + let mut out = String::new(); + out.push_str( + "/* Generated by oliphaunt. Do not edit by hand. */\n\ + #include \n\ + #include \n\ + #include \"oliphaunt.h\"\n\n\ + #if defined(__APPLE__)\n\ + #define OLIPHAUNT_STATIC_OPTIONAL __attribute__((weak_import))\n\ + #elif defined(__GNUC__) || defined(__clang__)\n\ + #define OLIPHAUNT_STATIC_OPTIONAL __attribute__((weak))\n\ + #else\n\ + #define OLIPHAUNT_STATIC_OPTIONAL\n\ + #endif\n\n", + ); + for module in modules { + out.push_str(&format!( + "extern const void *{}_Pg_magic_func(void);\n\ + extern void {}__PG_init(void) OLIPHAUNT_STATIC_OPTIONAL;\n", + module.symbol_prefix, module.symbol_prefix + )); + for symbol in &module.sql_symbols { + let linked_symbol = static_registry_linked_symbol(module, symbol); + let pg_finfo_symbol = format!("pg_finfo_{symbol}"); + let linked_pg_finfo_symbol = static_registry_linked_symbol(module, &pg_finfo_symbol); + out.push_str(&format!( + "extern void {linked_symbol}(void);\n\ + extern void {linked_pg_finfo_symbol}(void);\n" + )); + } + let sql_symbol_names = static_registry_sql_symbol_names(module); + for (sql_symbol, linked_symbol) in &module.symbol_aliases { + if sql_symbol_names.contains(sql_symbol) { + continue; + } + out.push_str(&format!("extern void {linked_symbol}(void);\n")); + } + out.push('\n'); + } + for module in modules { + out.push_str(&format!( + "static const OliphauntStaticExtensionSymbol {}_symbols[] = {{\n", + module.symbol_prefix + )); + for symbol in &module.sql_symbols { + let linked_symbol = static_registry_linked_symbol(module, symbol); + let pg_finfo_symbol = format!("pg_finfo_{symbol}"); + let linked_pg_finfo_symbol = static_registry_linked_symbol(module, &pg_finfo_symbol); + out.push_str(&format!( + " {{ .name = {}, .address = (void *){} }},\n\ + {{ .name = {}, .address = (void *){} }},\n", + c_string_literal(symbol), + linked_symbol, + c_string_literal(&pg_finfo_symbol), + linked_pg_finfo_symbol + )); + } + let sql_symbol_names = static_registry_sql_symbol_names(module); + for (sql_symbol, linked_symbol) in &module.symbol_aliases { + if sql_symbol_names.contains(sql_symbol) { + continue; + } + out.push_str(&format!( + " {{ .name = {}, .address = (void *){} }},\n", + c_string_literal(sql_symbol), + linked_symbol + )); + } + out.push_str("};\n\n"); + } + out.push_str("static const OliphauntStaticExtension liboliphaunt_static_extensions[] = {\n"); + for module in modules { + out.push_str(&format!( + " {{\n\ + .abi_version = OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION,\n\ + .name = {},\n\ + .magic = {}_Pg_magic_func,\n\ + .init = {}__PG_init,\n\ + .symbols = {}_symbols,\n\ + .symbol_count = sizeof({}_symbols) / sizeof({}_symbols[0]),\n\ + .reserved_flags = 0,\n\ + }},\n", + c_string_literal(&module.module_stem), + module.symbol_prefix, + module.symbol_prefix, + module.symbol_prefix, + module.symbol_prefix, + module.symbol_prefix + )); + } + out.push_str( + "};\n\n\ + const OliphauntStaticExtension *liboliphaunt_selected_static_extensions(size_t *count) {\n\ + if (count != NULL) {\n\ + *count = sizeof(liboliphaunt_static_extensions) / sizeof(liboliphaunt_static_extensions[0]);\n\ + }\n\ + return liboliphaunt_static_extensions;\n\ + }\n", + ); + out +} + +fn c_string_literal(value: &str) -> String { + let mut out = String::from("\""); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(ch), + } + } + out.push('"'); + out +} + +pub(super) fn shared_preload_libraries(extensions: &[RuntimeResourceExtension]) -> Vec { + let mut libraries = BTreeSet::new(); + for extension in extensions { + libraries.extend(extension.shared_preload_libraries.iter().cloned()); + } + libraries.into_iter().collect() +} + +pub(super) fn mobile_static_registry_metadata( + extensions: &[RuntimeResourceExtension], + registered_module_stems: &[String], +) -> Result { + let mut registered_stems = BTreeSet::new(); + for raw_stem in registered_module_stems { + let stem = raw_stem.trim(); + if !is_portable_module_stem(stem) { + return Err(Error::InvalidConfig(format!( + "mobile static registry module stem '{stem}' must contain only ASCII letters, digits, '.', '_' or '-'" + ))); + } + registered_stems.insert(stem.to_owned()); + } + + let mut registered_extensions = Vec::new(); + let mut pending_extensions = Vec::new(); + let mut native_module_stems = Vec::new(); + let mut selected_stems = BTreeSet::new(); + for extension in extensions { + let Some(stem) = extension.native_module_stem.as_deref() else { + continue; + }; + native_module_stems.push(stem.to_owned()); + selected_stems.insert(stem.to_owned()); + if registered_stems.contains(stem) { + if !extension.mobile_prebuilt { + return Err(Error::InvalidConfig(format!( + "selected extension '{}' does not have release-ready iOS/Android static artifacts; app bundles cannot mark module stem '{}' complete without a prebuilt mobile artifact", + extension.sql_name, stem + ))); + } + registered_extensions.push(extension.sql_name.clone()); + } else { + pending_extensions.push(extension.sql_name.clone()); + } + } + let unknown_stems = registered_stems + .difference(&selected_stems) + .cloned() + .collect::>(); + if !unknown_stems.is_empty() { + return Err(Error::InvalidConfig(format!( + "mobile static registry module stem(s) were not selected by these runtime resources: {}", + unknown_stems.join(",") + ))); + } + registered_extensions.sort(); + registered_extensions.dedup(); + pending_extensions.sort(); + pending_extensions.dedup(); + native_module_stems.sort(); + native_module_stems.dedup(); + let state = if native_module_stems.is_empty() { + MobileStaticRegistryState::NotRequired + } else if pending_extensions.is_empty() { + MobileStaticRegistryState::Complete + } else { + MobileStaticRegistryState::Pending + }; + Ok(MobileStaticRegistryMetadata { + state, + registered_extensions, + pending_extensions, + native_module_stems, + }) +} diff --git a/src/sdks/rust/src/server.rs b/src/sdks/rust/src/server.rs new file mode 100644 index 00000000..34319fb5 --- /dev/null +++ b/src/sdks/rust/src/server.rs @@ -0,0 +1,781 @@ +use std::ffi::OsString; +use std::fs; +use std::io::Read; +use std::net::{SocketAddr, TcpListener}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +#[cfg(unix)] +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::backup::{ + annotate_physical_archive_backup, physical_archive_backup, sql_backup_with_pg_dump, +}; +use crate::config::{EngineMode, NativeServerConfig, OpenConfig}; +use crate::engine::{ + EngineCancel, EngineCapabilities, EngineSession, NativeRuntime, SessionConcurrency, +}; +use crate::error::{Error, Result}; +use crate::extension::{ + Extension, extension_runtime_environment, required_shared_preload_libraries, +}; +use crate::liboliphaunt::PreparedNativeRoot; +use crate::pgwire::{PostgresCancelToken, PostgresEndpoint, PostgresWireClient}; +use crate::protocol::{ProtocolRequest, ProtocolResponse}; +use crate::storage::{BackupArtifact, BackupFormat, BackupRequest, BootstrapStrategy}; + +const SERVER_HOST: &str = "127.0.0.1"; +const ENV_SERVER_SDK_TRANSPORT: &str = "OLIPHAUNT_SERVER_SDK_TRANSPORT"; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(20); +const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(250); +const AUTO_PORT_START_ATTEMPTS: usize = 16; + +/// Native PostgreSQL server runtime. +/// +/// Server mode starts and owns a real local PostgreSQL-compatible server +/// process. It is the mode to use for independent client connections, pools, +/// `psql`, `pg_dump`, and ORMs. +#[derive(Debug, Clone, Default)] +pub struct NativeServerRuntime { + executable: Option, + port: Option, +} + +impl NativeServerRuntime { + /// Create a server runtime that resolves the server executable from package + /// assets. + pub fn from_package() -> Self { + Self { + executable: None, + port: None, + } + } + + /// Create a server runtime from builder/server configuration. + pub fn from_config(config: &NativeServerConfig) -> Self { + Self { + executable: config.executable.clone(), + port: config.port, + } + } + + /// Create a server runtime with an explicit executable. + pub fn from_executable(path: impl Into) -> Self { + Self { + executable: Some(path.into()), + port: None, + } + } + + /// Return the configured executable, if any. + pub fn executable(&self) -> Option<&PathBuf> { + self.executable.as_ref() + } + + /// Use a fixed localhost port. + pub fn with_port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } +} + +impl NativeRuntime for NativeServerRuntime { + fn open(&self, config: OpenConfig) -> Result> { + if config.mode != EngineMode::NativeServer { + return Err(Error::UnsupportedEngineMode { + mode: config.mode, + reason: "NativeServerRuntime only serves native-server mode".to_owned(), + }); + } + config.validate()?; + let extensions = config.resolved_extensions()?; + let root = PreparedNativeRoot::prepare(&config, &extensions)?; + initdb_if_needed(&root, &config)?; + let executable = self + .executable + .clone() + .or_else(|| config.server.executable.clone()) + .unwrap_or_else(|| root.tool_path("postgres")); + let fixed_port = self.port.or(config.server.port); + let attempts = if fixed_port.is_some() { + 1 + } else { + AUTO_PORT_START_ATTEMPTS + }; + let mut last_error = None; + for attempt in 0..attempts { + let port = match fixed_port { + Some(port) => port, + None => pick_port()?, + }; + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let socket_dir = create_server_socket_dir(port)?; + let sdk_endpoint = server_sdk_endpoint(addr, port, socket_dir.as_deref()); + let mut child = start_postgres( + &root, + &executable, + port, + &config, + &extensions, + socket_dir.as_deref(), + )?; + match wait_for_server(sdk_endpoint, &mut child, &config) { + Ok(connection) => { + let cancel = Arc::new(NativeServerCancel { + token: connection.cancel_token(), + }); + let connection_string = server_connection_string(&config, port); + return Ok(Box::new(NativeServerSession { + root, + child: Some(child), + connection: Some(connection), + cancel, + connection_string, + max_client_sessions: config.server.max_client_sessions, + socket_dir, + closed: false, + selected_extensions: extensions.clone(), + })); + } + Err(error) + if fixed_port.is_none() + && attempt + 1 < attempts + && is_auto_port_bind_conflict(&error) => + { + cleanup_failed_start(child); + cleanup_socket_dir(socket_dir.as_deref()); + last_error = Some(error); + } + Err(error) => { + cleanup_failed_start(child); + cleanup_socket_dir(socket_dir.as_deref()); + return Err(error); + } + } + } + Err(last_error.unwrap_or_else(|| { + Error::Engine(format!( + "native server failed to allocate a free localhost port after {attempts} attempts" + )) + })) + } +} + +struct NativeServerSession { + root: PreparedNativeRoot, + child: Option, + connection: Option, + cancel: Arc, + connection_string: String, + max_client_sessions: usize, + socket_dir: Option, + closed: bool, + selected_extensions: Vec, +} + +impl EngineSession for NativeServerSession { + fn capabilities(&self) -> EngineCapabilities { + EngineCapabilities { + mode: EngineMode::NativeServer, + session_concurrency: SessionConcurrency::IndependentSessions, + process_isolated: true, + multi_root: false, + reopenable: true, + same_root_logical_reopen: false, + root_switchable: true, + crash_restartable: false, + max_client_sessions: self.max_client_sessions, + protocol_raw: true, + protocol_stream: true, + query_cancel: true, + backup_restore: true, + backup_formats: vec![BackupFormat::Sql, BackupFormat::PhysicalArchive], + restore_formats: vec![BackupFormat::PhysicalArchive], + simple_query: true, + extensions: true, + connection_strings: true, + connection_string: Some(self.connection_string.clone()), + } + } + + fn cancel_handle(&self) -> Option> { + let cancel: Arc = self.cancel.clone(); + Some(cancel) + } + + fn connection_string(&self) -> Option { + Some(self.connection_string.clone()) + } + + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result { + self.connection + .as_mut() + .ok_or(Error::EngineStopped)? + .exec_protocol_raw(request) + } + + fn exec_protocol_stream( + &mut self, + request: ProtocolRequest, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, + ) -> Result<()> { + self.connection + .as_mut() + .ok_or(Error::EngineStopped)? + .exec_protocol_stream(request, on_chunk) + } + + fn checkpoint(&mut self) -> Result<()> { + self.exec_protocol_raw(ProtocolRequest::simple_query("CHECKPOINT")?) + .map(|_| ()) + } + + fn backup(&mut self, request: BackupRequest) -> Result { + match request.format { + BackupFormat::Sql => { + sql_backup_with_pg_dump(&self.root.tool_path("pg_dump"), &self.connection_string) + } + BackupFormat::PhysicalArchive => { + let pgdata = self.root.pgdata.clone(); + let artifact = + physical_archive_backup(&pgdata, |request| self.exec_protocol_raw(request))?; + let selected_extensions = self.selected_extensions.clone(); + annotate_physical_archive_backup( + artifact, + &pgdata, + &selected_extensions, + |request| self.exec_protocol_raw(request), + ) + } + BackupFormat::OliphauntArchive => Err(Error::Engine( + "OliphauntArchive has no stable on-disk format yet; request PhysicalArchive for same-version clones or Sql for portable logical dumps".to_owned(), + )), + } + } + + fn close(&mut self) -> Result<()> { + self.close_server() + } +} + +struct NativeServerCancel { + token: PostgresCancelToken, +} + +impl EngineCancel for NativeServerCancel { + fn cancel(&self) -> Result<()> { + self.token + .cancel(CONNECT_ATTEMPT_TIMEOUT, STARTUP_TIMEOUT) + .map_err(|err| Error::Engine(format!("native server cancel failed: {err}"))) + } +} + +impl NativeServerSession { + fn close_server(&mut self) -> Result<()> { + if self.closed { + return Ok(()); + } + self.closed = true; + if let Some(connection) = self.connection.as_mut() { + let _ = connection.terminate(); + } + self.connection = None; + + let mut stop_error = None; + let pg_ctl = self.root.tool_path("pg_ctl"); + if pg_ctl.is_file() { + let status = Command::new(&pg_ctl) + .arg("-D") + .arg(&self.root.pgdata) + .arg("-m") + .arg("fast") + .arg("-w") + .arg("stop") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match status { + Ok(status) if status.success() => {} + Ok(status) => stop_error = Some(format!("pg_ctl stop exited with {status}")), + Err(err) => stop_error = Some(format!("run pg_ctl stop: {err}")), + } + } + + if let Some(mut child) = self.child.take() { + match child.try_wait() { + Ok(Some(_)) => {} + Ok(None) => { + if stop_error.is_some() { + let _ = child.kill(); + } + let _ = child.wait(); + } + Err(err) => { + stop_error = Some(format!("wait for native server process: {err}")); + } + } + } + + if let Some(error) = stop_error { + return Err(Error::Engine(error)); + } + cleanup_socket_dir(self.socket_dir.as_deref()); + self.socket_dir = None; + Ok(()) + } +} + +impl Drop for NativeServerSession { + fn drop(&mut self) { + let _ = self.close_server(); + } +} + +fn initdb_if_needed(root: &PreparedNativeRoot, config: &OpenConfig) -> Result<()> { + if root.pgdata.join("PG_VERSION").is_file() { + return root.refresh_manifest(); + } + let initdb = match &config.storage.bootstrap { + BootstrapStrategy::InitdbToolingOnly { initdb } => initdb.clone(), + BootstrapStrategy::PackagedTemplate | BootstrapStrategy::ExistingOnly => { + root.tool_path("initdb") + } + }; + if !initdb.is_file() { + return Err(Error::Engine(format!( + "native server bootstrap requires initdb at {}", + initdb.display() + ))); + } + let status = Command::new(&initdb) + .arg("-D") + .arg(&root.pgdata) + .arg("-U") + .arg(&config.username) + .arg("--auth=trust") + .arg("--no-sync") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .map_err(|err| Error::Engine(format!("run native server initdb: {err}")))?; + if status.success() { + root.refresh_manifest() + } else { + Err(Error::Engine(format!( + "native server initdb failed with status {status}" + ))) + } +} + +fn pick_port() -> Result { + let listener = TcpListener::bind((SERVER_HOST, 0)) + .map_err(|err| Error::Engine(format!("allocate native server port: {err}")))?; + listener + .local_addr() + .map(|addr| addr.port()) + .map_err(|err| Error::Engine(format!("read native server port: {err}"))) +} + +fn start_postgres( + root: &PreparedNativeRoot, + executable: &Path, + port: u16, + config: &OpenConfig, + extensions: &[Extension], + socket_dir: Option<&Path>, +) -> Result { + if !executable.is_file() { + return Err(Error::Engine(format!( + "native server executable is missing at {}", + executable.display() + ))); + } + let mut command = Command::new(executable); + command.env("PGDATA", &root.pgdata); + configure_extension_runtime_env(&mut command, &root.runtime_dir, extensions); + command + .args(postgres_startup_args( + &root.pgdata, + port, + config, + extensions, + socket_dir, + )?) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + command + .spawn() + .map_err(|err| Error::Engine(format!("start native server postgres: {err}"))) +} + +fn configure_extension_runtime_env( + command: &mut Command, + runtime_dir: &Path, + extensions: &[Extension], +) { + for extension in extensions { + for entry in extension_runtime_environment(*extension) { + let value = runtime_dir.join(entry.relative_path); + if value.join(entry.required_file).is_file() { + command.env(entry.name, value); + } + } + } +} + +fn postgres_startup_args( + pgdata: &Path, + port: u16, + config: &OpenConfig, + extensions: &[Extension], + socket_dir: Option<&Path>, +) -> Result> { + let mut args = vec![ + OsString::from("-D"), + pgdata.as_os_str().to_os_string(), + OsString::from("-h"), + OsString::from(SERVER_HOST), + OsString::from("-p"), + OsString::from(port.to_string()), + OsString::from("-c"), + OsString::from("logging_collector=off"), + OsString::from("-c"), + OsString::from("listen_addresses=127.0.0.1"), + ]; + #[cfg(unix)] + { + let socket_dir = socket_dir.ok_or_else(|| { + Error::Engine("native server socket directory was not allocated".to_owned()) + })?; + args.push(OsString::from("-c")); + args.push(OsString::from(format!( + "unix_socket_directories={}", + socket_dir.display() + ))); + } + #[cfg(not(unix))] + { + let _ = socket_dir; + args.push(OsString::from("-c")); + args.push(OsString::from("unix_socket_directories=")); + } + + for assignment in config.postgres_startup_assignments() { + args.push(OsString::from("-c")); + args.push(OsString::from(assignment)); + } + args.push(OsString::from("-c")); + args.push(OsString::from(format!( + "max_connections={}", + config.server.max_client_sessions + ))); + let preload_libraries = required_shared_preload_libraries(extensions); + if !preload_libraries.is_empty() { + args.push(OsString::from("-c")); + args.push(OsString::from(format!( + "shared_preload_libraries={}", + preload_libraries.join(",") + ))); + } + Ok(args) +} + +fn wait_for_server( + endpoint: PostgresEndpoint, + child: &mut Child, + config: &OpenConfig, +) -> Result { + let deadline = Instant::now() + STARTUP_TIMEOUT; + let mut last_error = None; + while Instant::now() < deadline { + if let Some(status) = child + .try_wait() + .map_err(|err| Error::Engine(format!("poll native server startup: {err}")))? + { + let stderr = child_stderr(child); + return Err(Error::Engine(format!( + "native server exited before accepting connections: {status}{stderr}" + ))); + } + match PostgresWireClient::connect_endpoint( + endpoint.clone(), + &config.username, + &config.database, + CONNECT_ATTEMPT_TIMEOUT, + STARTUP_TIMEOUT, + ) { + Ok(connection) => return Ok(connection), + Err(err) => last_error = Some(err), + } + thread::sleep(Duration::from_millis(50)); + } + Err(last_error.unwrap_or_else(|| { + Error::Engine(format!( + "native server did not accept SDK connections on {:?} within {:?}", + endpoint, STARTUP_TIMEOUT + )) + })) +} + +fn server_connection_string(config: &OpenConfig, port: u16) -> String { + format!( + "postgres://{}@{}:{}/{}", + percent_encode_connection_component(&config.username), + SERVER_HOST, + port, + percent_encode_connection_component(&config.database) + ) +} + +fn percent_encode_connection_component(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + encoded.push('%'); + encoded.push(nibble_hex(byte >> 4)); + encoded.push(nibble_hex(byte & 0x0f)); + } + } + encoded +} + +fn nibble_hex(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + 10..=15 => (b'A' + value - 10) as char, + _ => unreachable!("hex nibble is out of range"), + } +} + +fn server_sdk_endpoint(addr: SocketAddr, port: u16, socket_dir: Option<&Path>) -> PostgresEndpoint { + #[cfg(unix)] + { + if std::env::var(ENV_SERVER_SDK_TRANSPORT) + .map(|value| value.eq_ignore_ascii_case("tcp")) + .unwrap_or(false) + { + return PostgresEndpoint::Tcp(addr); + } + let socket_dir = + socket_dir.expect("Unix native server socket directory is allocated before endpoint"); + PostgresEndpoint::Unix(socket_dir.join(format!(".s.PGSQL.{port}"))) + } + #[cfg(not(unix))] + { + let _ = port; + let _ = socket_dir; + PostgresEndpoint::Tcp(addr) + } +} + +#[cfg(unix)] +fn create_server_socket_dir(port: u16) -> Result> { + let base = Path::new("/tmp"); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| Error::Engine(format!("system clock before epoch: {err}")))? + .as_nanos(); + for attempt in 0..100_u32 { + let socket_dir = base.join(format!("lpo-s-{pid}-{port}-{nanos}-{attempt}")); + match fs::create_dir(&socket_dir) { + Ok(()) => { + fs::set_permissions(&socket_dir, fs::Permissions::from_mode(0o700)).map_err( + |err| { + Error::Engine(format!( + "set native server socket dir permissions {}: {err}", + socket_dir.display() + )) + }, + )?; + return Ok(Some(socket_dir)); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(Error::Engine(format!( + "create native server socket dir {}: {err}", + socket_dir.display() + ))); + } + } + } + Err(Error::Engine( + "failed to allocate a unique native server socket directory".to_owned(), + )) +} + +#[cfg(not(unix))] +fn create_server_socket_dir(_port: u16) -> Result> { + Ok(None) +} + +fn cleanup_socket_dir(socket_dir: Option<&Path>) { + if let Some(socket_dir) = socket_dir { + let _ = fs::remove_dir_all(socket_dir); + } +} + +fn cleanup_failed_start(mut child: Child) { + match child.try_wait() { + Ok(Some(_)) => {} + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + } + Err(_) => {} + } +} + +fn is_auto_port_bind_conflict(error: &Error) -> bool { + let message = error.to_string(); + message.contains("Address already in use") + || message.contains("could not bind IPv4 address") + || message.contains("could not create any TCP/IP sockets") +} + +fn child_stderr(child: &mut Child) -> String { + let Some(mut stderr) = child.stderr.take() else { + return String::new(); + }; + let mut output = String::new(); + match stderr.read_to_string(&mut output) { + Ok(_) if !output.trim().is_empty() => format!(": {}", output.trim()), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_port_retry_classifies_postgres_bind_conflicts() { + let error = Error::Engine( + "native server exited before accepting connections: exit status: 1: \ + LOG: could not bind IPv4 address \"127.0.0.1\": Address already in use\n\ + FATAL: could not create any TCP/IP sockets" + .to_owned(), + ); + assert!(is_auto_port_bind_conflict(&error)); + } + + #[test] + fn auto_port_retry_does_not_mask_unrelated_startup_errors() { + let error = Error::Engine( + "native server exited before accepting connections: exit status: 1: \ + FATAL: data directory has invalid permissions" + .to_owned(), + ); + assert!(!is_auto_port_bind_conflict(&error)); + } + + #[test] + fn server_startup_args_include_required_preload_libraries_before_spawn() { + let mut config = OpenConfig::native_direct("target/test-roots/native-server-preload"); + config.mode = EngineMode::NativeServer; + let args = postgres_startup_args( + Path::new("/tmp/oliphaunt-preload/pgdata"), + 15432, + &config, + &[Extension::PgSearch, Extension::PgSearch], + Some(Path::new("/tmp/oliphaunt-preload-socket")), + ) + .unwrap(); + let args = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_startup_config_arg(&args, "shared_preload_libraries=pg_search"); + assert_eq!( + args.iter() + .filter(|arg| arg.as_str() == "shared_preload_libraries=pg_search") + .count(), + 1, + "preload libraries must be deduplicated in server startup args" + ); + } + + #[test] + fn extension_runtime_env_is_set_only_when_required_file_is_materialized() { + let runtime_dir = std::env::temp_dir().join(format!( + "oliphaunt-extension-runtime-env-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos() + )); + let _cleanup = RuntimeDirCleanup(runtime_dir.clone()); + let mut missing = Command::new("postgres"); + configure_extension_runtime_env(&mut missing, &runtime_dir, &[Extension::Postgis]); + assert_eq!( + missing + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PROJ_DATA")), + None + ); + + let proj_data = runtime_dir.join("share/postgresql/proj"); + std::fs::create_dir_all(&proj_data).expect("create proj data dir"); + std::fs::write(proj_data.join("proj.db"), b"fixture").expect("write proj.db"); + + let mut present = Command::new("postgres"); + configure_extension_runtime_env(&mut present, &runtime_dir, &[Extension::Postgis]); + assert_eq!( + present + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PROJ_DATA")) + .and_then(|(_, value)| value) + .map(PathBuf::from), + Some(proj_data) + ); + + let mut unselected = Command::new("postgres"); + configure_extension_runtime_env(&mut unselected, &runtime_dir, &[]); + assert_eq!( + unselected + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PROJ_DATA")), + None + ); + } + + struct RuntimeDirCleanup(PathBuf); + + impl Drop for RuntimeDirCleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn server_connection_string_uses_configured_identity() { + let mut config = OpenConfig::native_direct("target/test-roots/native-server-identity"); + config.mode = EngineMode::NativeServer; + config.username = "app user".to_owned(); + config.database = "app/db".to_owned(); + + assert_eq!( + server_connection_string(&config, 15432), + "postgres://app%20user@127.0.0.1:15432/app%2Fdb" + ); + } + + fn assert_startup_config_arg(args: &[String], expected: &str) { + let Some(index) = args.iter().position(|arg| arg == expected) else { + panic!("missing server startup argument {expected:?} in {args:?}"); + }; + assert_eq!( + args.get(index.saturating_sub(1)).map(String::as_str), + Some("-c"), + "server startup argument {expected:?} must be passed through postgres -c" + ); + } +} diff --git a/src/sdks/rust/src/storage.rs b/src/sdks/rust/src/storage.rs new file mode 100644 index 00000000..372a5c15 --- /dev/null +++ b/src/sdks/rust/src/storage.rs @@ -0,0 +1,150 @@ +use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; + +/// Live database root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DatabaseRoot { + /// Persistent root directory. + Path(PathBuf), + /// Temporary root owned by the SDK. + Temporary, +} + +/// Bootstrap policy for a new database root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BootstrapStrategy { + /// Copy a packaged PostgreSQL template cluster into the root. + PackagedTemplate, + /// Open an existing root and fail if it has not been bootstrapped. + ExistingOnly, + /// Tooling-only fallback. Production mobile paths must not require this. + InitdbToolingOnly { + /// Path to the initdb executable. + initdb: PathBuf, + }, +} + +/// Root locking policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RootLockPolicy { + /// One process owns the root directly. + ExclusiveProcess, + /// A broker process owns the root. + BrokerOwned, +} + +/// Storage configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageConfig { + /// Database root. + pub root: DatabaseRoot, + /// Bootstrap strategy. + pub bootstrap: BootstrapStrategy, + /// Locking policy. + pub lock_policy: RootLockPolicy, +} + +/// Backup format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BackupFormat { + /// Portable logical SQL dump. + Sql, + /// Physical archive of the root directory. + PhysicalArchive, + /// Product-level portable archive. + OliphauntArchive, +} + +/// Backup request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackupRequest { + /// Requested format. + pub format: BackupFormat, +} + +impl BackupRequest { + /// Request a portable logical SQL backup. + pub fn sql() -> Self { + Self { + format: BackupFormat::Sql, + } + } + + /// Request a same-version physical archive of the database root. + pub fn physical_archive() -> Self { + Self { + format: BackupFormat::PhysicalArchive, + } + } +} + +/// Backup bytes returned by an engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackupArtifact { + /// Format of the bytes. + pub format: BackupFormat, + /// Backup payload. + pub bytes: Vec, +} + +/// Policy for an existing restore target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RestoreTargetPolicy { + /// Fail if the target root already contains files. + FailIfExists, + /// Atomically replace the existing root after taking its root lock. + ReplaceExisting, +} + +/// Restore/import request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestoreRequest { + /// Backup artifact to restore. + pub artifact: BackupArtifact, + /// Target database root. + pub target: DatabaseRoot, + /// Existing-target behavior. + pub target_policy: RestoreTargetPolicy, +} + +impl RestoreRequest { + /// Restore a same-version physical archive into a persistent root. + pub fn physical_archive(root: impl Into, artifact: BackupArtifact) -> Self { + Self { + artifact, + target: DatabaseRoot::Path(root.into()), + target_policy: RestoreTargetPolicy::FailIfExists, + } + } + + /// Set the target policy. + pub fn with_target_policy(mut self, target_policy: RestoreTargetPolicy) -> Self { + self.target_policy = target_policy; + self + } + + /// Replace an existing root. The existing root must not be open by another + /// process. + pub fn replace_existing(self) -> Self { + self.with_target_policy(RestoreTargetPolicy::ReplaceExisting) + } +} + +pub(crate) fn path_contains_nul(path: &Path) -> bool { + #[cfg(unix)] + { + path.as_os_str().as_bytes().contains(&0) + } + #[cfg(windows)] + { + path.as_os_str().encode_wide().any(|unit| unit == 0) + } + #[cfg(not(any(unix, windows)))] + { + path.to_string_lossy().bytes().any(|byte| byte == 0) + } +} diff --git a/src/sdks/rust/tests/native_extensions.rs b/src/sdks/rust/tests/native_extensions.rs new file mode 100644 index 00000000..defcb351 --- /dev/null +++ b/src/sdks/rust/tests/native_extensions.rs @@ -0,0 +1,1093 @@ +use std::fs; +use std::future::Future; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use oliphaunt::{ + BackupArtifact, BackupFormat, BackupRequest, EngineMode, Extension, ExtensionSmokeCoverage, + NATIVE_EXTENSION_MANIFEST, Oliphaunt, RestoreRequest, Result, +}; + +const DIRECT_CHILD_EXTENSION_ENV: &str = "OLIPHAUNT_EXTENSION_DIRECT_CHILD"; +const DIRECT_CHILD_ACTION_ENV: &str = "OLIPHAUNT_EXTENSION_DIRECT_ACTION"; +const DIRECT_CHILD_ROOT_ENV: &str = "OLIPHAUNT_EXTENSION_DIRECT_ROOT"; +const DIRECT_CHILD_BACKUP_ENV: &str = "OLIPHAUNT_EXTENSION_DIRECT_BACKUP"; +const EXTERNAL_MATRIX_ENV: &str = "OLIPHAUNT_EXTERNAL_EXTENSION_MATRIX"; +const EXTERNAL_MATRIX_MODES_ENV: &str = "OLIPHAUNT_EXTERNAL_EXTENSION_MODES"; + +#[test] +fn native_extension_matrix_when_enabled() { + if let Some(result) = run_direct_extension_child_from_env() { + result.unwrap(); + return; + } + + if std::env::var("OLIPHAUNT_EXTENSION_MATRIX").ok().as_deref() != Some("1") { + eprintln!("skipping native extension matrix: set OLIPHAUNT_EXTENSION_MATRIX=1"); + return; + } + if native_runtime_env_is_unavailable() { + eprintln!("skipping native extension matrix: no native library env var is set"); + return; + } + let Some(broker) = option_env!("CARGO_BIN_EXE_oliphaunt-broker") else { + eprintln!("skipping native extension matrix: cargo did not provide broker binary path"); + return; + }; + + for entry in NATIVE_EXTENSION_MANIFEST { + if !entry.first_party_artifact() { + eprintln!( + "skipping external extension {} in first-party native matrix", + entry.sql_name + ); + continue; + } + assert_eq!( + entry.coverage.direct_c_abi, + ExtensionSmokeCoverage::InstallLoadRestartBackupRestore + ); + assert_eq!( + entry.coverage.broker, + ExtensionSmokeCoverage::InstallLoadRestartBackupRestore + ); + assert_eq!( + entry.coverage.server, + ExtensionSmokeCoverage::InstallLoadRestartBackupRestore + ); + run_direct_extension_smoke(entry.extension); + run_extension_smoke(EngineMode::NativeBroker, Some(broker), entry.extension).unwrap(); + run_extension_smoke(EngineMode::NativeServer, None, entry.extension).unwrap(); + } +} + +#[test] +fn native_external_extension_matrix_when_enabled() { + let Some(selection) = std::env::var(EXTERNAL_MATRIX_ENV) + .ok() + .filter(|value| !value.trim().is_empty() && value.trim() != "0") + else { + eprintln!( + "skipping native external extension matrix: set {EXTERNAL_MATRIX_ENV}=graph,pg_search or all" + ); + return; + }; + if native_runtime_env_is_unavailable() { + eprintln!("skipping native external extension matrix: no native library env var is set"); + return; + } + + let modes = selected_external_modes(); + let broker = if modes.contains(&EngineMode::NativeBroker) { + Some( + option_env!("CARGO_BIN_EXE_oliphaunt-broker") + .expect("external broker extension matrix needs oliphaunt-broker binary"), + ) + } else { + None + }; + + for extension in selected_external_extensions(&selection) { + assert!( + !extension.first_party_artifact(), + "{} is not an external extension", + extension.sql_name() + ); + for mode in &modes { + match mode { + EngineMode::NativeDirect => run_direct_extension_smoke(extension), + EngineMode::NativeBroker => { + run_extension_smoke(EngineMode::NativeBroker, broker, extension).unwrap() + } + EngineMode::NativeServer => { + run_extension_smoke(EngineMode::NativeServer, None, extension).unwrap() + } + } + } + } +} + +fn selected_external_modes() -> Vec { + let raw = std::env::var(EXTERNAL_MATRIX_MODES_ENV).unwrap_or_else(|_| "direct".to_owned()); + let mut selected = Vec::new(); + for mode in raw.split(',') { + let mode = mode.trim(); + if mode.is_empty() { + continue; + } + let parsed = match mode { + "all" => { + for mode in [ + EngineMode::NativeDirect, + EngineMode::NativeBroker, + EngineMode::NativeServer, + ] { + if !selected.contains(&mode) { + selected.push(mode); + } + } + continue; + } + "direct" | "native-direct" => EngineMode::NativeDirect, + "broker" | "native-broker" => EngineMode::NativeBroker, + "server" | "native-server" => EngineMode::NativeServer, + _ => panic!( + "unknown external extension matrix mode in {EXTERNAL_MATRIX_MODES_ENV}: {mode}" + ), + }; + if !selected.contains(&parsed) { + selected.push(parsed); + } + } + assert!( + !selected.is_empty(), + "{EXTERNAL_MATRIX_MODES_ENV} did not select any native modes" + ); + selected +} + +fn selected_external_extensions(selection: &str) -> Vec { + if selection.trim() == "all" { + return Extension::EXTERNAL_PG18_SUPPORTED.to_vec(); + } + + let mut selected = Vec::new(); + for raw in selection.split(',') { + let name = raw.trim(); + if name.is_empty() { + continue; + } + let extension = Extension::by_sql_name(name).unwrap_or_else(|| { + panic!("unknown external extension in {EXTERNAL_MATRIX_ENV}: {name}") + }); + assert!( + Extension::EXTERNAL_PG18_SUPPORTED.contains(&extension), + "{name} is not an external PostgreSQL 18 extension" + ); + selected.push(extension); + } + selected.sort_unstable(); + selected.dedup(); + assert!( + !selected.is_empty(), + "{EXTERNAL_MATRIX_ENV} did not select any external extensions" + ); + selected +} + +fn run_direct_extension_smoke(extension: Extension) { + let root = unique_temp_root(&format!( + "oliphaunt-extension-direct-{}", + extension.sql_name() + )); + let restored_root = unique_temp_root(&format!( + "oliphaunt-extension-direct-{}-restore", + extension.sql_name() + )); + let backup_path = unique_temp_root(&format!( + "oliphaunt-extension-direct-{}-backup.tar", + extension.sql_name() + )); + + let result = std::panic::catch_unwind(|| { + run_direct_extension_child( + DirectExtensionChildAction::InstallBackup, + extension, + &root, + Some(&backup_path), + ); + run_direct_extension_child( + DirectExtensionChildAction::AssertExisting, + extension, + &root, + None, + ); + + let backup = BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes: fs::read(&backup_path).expect("direct extension child did not write backup"), + }; + block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + &restored_root, + backup, + ))) + .unwrap(); + + run_direct_extension_child( + DirectExtensionChildAction::AssertExisting, + extension, + &restored_root, + None, + ); + }); + + let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&restored_root); + let _ = fs::remove_file(&backup_path); + + if let Err(payload) = result { + std::panic::resume_unwind(payload); + } +} + +#[derive(Clone, Copy)] +enum DirectExtensionChildAction { + InstallBackup, + AssertExisting, +} + +impl DirectExtensionChildAction { + fn as_env(self) -> &'static str { + match self { + Self::InstallBackup => "install-backup", + Self::AssertExisting => "assert-existing", + } + } + + fn from_env(value: &str) -> Option { + match value { + "install-backup" => Some(Self::InstallBackup), + "assert-existing" => Some(Self::AssertExisting), + _ => None, + } + } +} + +fn run_direct_extension_child( + action: DirectExtensionChildAction, + extension: Extension, + root: &Path, + backup_path: Option<&Path>, +) { + let current_exe = std::env::current_exe().expect("current test executable is unavailable"); + let mut command = Command::new(current_exe); + command + .arg("native_extension_matrix_when_enabled") + .arg("--exact") + .arg("--nocapture") + .env("OLIPHAUNT_EXTENSION_MATRIX", "1") + .env(DIRECT_CHILD_EXTENSION_ENV, extension.sql_name()) + .env(DIRECT_CHILD_ACTION_ENV, action.as_env()) + .env(DIRECT_CHILD_ROOT_ENV, root); + if let Some(path) = backup_path { + command.env(DIRECT_CHILD_BACKUP_ENV, path); + } + + let output = command + .output() + .expect("failed to spawn direct extension child test process"); + assert!( + output.status.success(), + "direct extension child failed for {} ({})\nstdout:\n{}\nstderr:\n{}", + extension.sql_name(), + action.as_env(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn run_direct_extension_child_from_env() -> Option> { + let extension_name = std::env::var(DIRECT_CHILD_EXTENSION_ENV).ok()?; + let action = std::env::var(DIRECT_CHILD_ACTION_ENV) + .ok() + .and_then(|value| DirectExtensionChildAction::from_env(&value)) + .expect("direct extension child action is missing or invalid"); + let root = std::env::var_os(DIRECT_CHILD_ROOT_ENV) + .map(PathBuf::from) + .expect("direct extension child root is missing"); + let extension = Extension::by_sql_name(&extension_name) + .expect("direct extension child extension name is not in the manifest"); + + Some(match action { + DirectExtensionChildAction::InstallBackup => { + let backup_path = std::env::var_os(DIRECT_CHILD_BACKUP_ENV) + .map(PathBuf::from) + .expect("direct extension child backup path is missing"); + run_direct_extension_child_install_backup(extension, &root, &backup_path) + } + DirectExtensionChildAction::AssertExisting => { + run_direct_extension_child_assert_existing(extension, &root) + } + }) +} + +fn run_direct_extension_child_install_backup( + extension: Extension, + root: &Path, + backup_path: &Path, +) -> Result<()> { + let db = block_on( + Oliphaunt::builder() + .path(root) + .native_direct() + .extension(extension) + .open(), + )?; + install_or_load_extension(&db, EngineMode::NativeDirect, extension)?; + assert_repeated_create_extension_error_recovers(&db, EngineMode::NativeDirect, extension)?; + assert_extension_visible(&db, EngineMode::NativeDirect, extension)?; + setup_extension_functional_smoke(&db, EngineMode::NativeDirect, extension)?; + assert_extension_functional_smoke(&db, EngineMode::NativeDirect, extension)?; + assert_extension_root_artifacts(root, EngineMode::NativeDirect, extension); + let archive = block_on(db.backup(BackupRequest::physical_archive()))?; + assert_eq!(archive.format, BackupFormat::PhysicalArchive); + assert_physical_archive_contains_extension_catalog( + &archive, + EngineMode::NativeDirect, + extension, + ); + fs::write(backup_path, &archive.bytes) + .expect("failed to write direct extension backup artifact"); + block_on(db.close()) +} + +fn run_direct_extension_child_assert_existing(extension: Extension, root: &Path) -> Result<()> { + let db = block_on( + Oliphaunt::builder() + .path(root) + .native_direct() + .extension(extension) + .existing_only() + .open(), + )?; + assert_extension_visible(&db, EngineMode::NativeDirect, extension)?; + assert_extension_functional_smoke(&db, EngineMode::NativeDirect, extension)?; + assert_extension_root_artifacts(root, EngineMode::NativeDirect, extension); + block_on(db.close()) +} + +fn run_extension_smoke(mode: EngineMode, broker: Option<&str>, extension: Extension) -> Result<()> { + let root = unique_temp_root(&format!( + "oliphaunt-extension-{}-{}", + mode_label(mode), + extension.sql_name() + )); + let restored_root = unique_temp_root(&format!( + "oliphaunt-extension-{}-{}-restore", + mode_label(mode), + extension.sql_name() + )); + let result = run_extension_recovery_smoke(mode, broker, extension, &root, &restored_root); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&restored_root); + result +} + +fn run_extension_recovery_smoke( + mode: EngineMode, + broker: Option<&str>, + extension: Extension, + root: &Path, + restored_root: &Path, +) -> Result<()> { + let db = block_on(extension_builder(mode, broker, extension, root).open())?; + install_or_load_extension(&db, mode, extension)?; + assert_repeated_create_extension_error_recovers(&db, mode, extension)?; + assert_extension_visible(&db, mode, extension)?; + setup_extension_functional_smoke(&db, mode, extension)?; + assert_extension_functional_smoke(&db, mode, extension)?; + assert_extension_root_artifacts(root, mode, extension); + let archive = block_on(db.backup(BackupRequest::physical_archive()))?; + assert_eq!(archive.format, BackupFormat::PhysicalArchive); + assert_physical_archive_contains_extension_catalog(&archive, mode, extension); + block_on(db.close())?; + + let reopened = block_on( + extension_builder(mode, broker, extension, root) + .existing_only() + .open(), + )?; + assert_extension_visible(&reopened, mode, extension)?; + assert_extension_functional_smoke(&reopened, mode, extension)?; + assert_extension_root_artifacts(root, mode, extension); + block_on(reopened.close())?; + + block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + restored_root, + archive, + )))?; + let restored = block_on( + extension_builder(mode, broker, extension, restored_root) + .existing_only() + .open(), + )?; + assert_extension_visible(&restored, mode, extension)?; + assert_extension_functional_smoke(&restored, mode, extension)?; + assert_extension_root_artifacts(restored_root, mode, extension); + block_on(restored.close()) +} + +fn extension_builder( + mode: EngineMode, + broker: Option<&str>, + extension: Extension, + root: &Path, +) -> oliphaunt::OliphauntBuilder { + let mut builder = Oliphaunt::builder() + .path(root) + .engine(mode) + .extension(extension); + if let Some(broker) = broker { + builder = builder.broker_executable(broker); + } + builder +} + +fn install_or_load_extension(db: &Oliphaunt, mode: EngineMode, extension: Extension) -> Result<()> { + let sql = install_sql(extension); + let response = block_on(db.exec_protocol_raw(raw_query_message(&sql)))?; + assert_success_response(response.as_bytes(), mode, extension, "install/load") +} + +fn assert_repeated_create_extension_error_recovers( + db: &Oliphaunt, + mode: EngineMode, + extension: Extension, +) -> Result<()> { + if !extension.creates_extension() { + return Ok(()); + } + + let repeated = block_on(db.exec_protocol_raw(raw_query_message(&install_sql(extension))))?; + let tags = raw_message_tags(repeated.as_bytes()); + assert!( + tags.contains(&b'E'), + "{mode:?} repeated CREATE EXTENSION {} did not produce ErrorResponse: {tags:?}", + extension.sql_name() + ); + assert!( + tags.contains(&b'Z'), + "{mode:?} repeated CREATE EXTENSION {} did not return ReadyForQuery: {tags:?}", + extension.sql_name() + ); + + let recovered = exec_extension_sql( + db, + mode, + extension, + "post repeated-create recovery", + "SELECT 'ready'::text AS state", + )?; + assert_first_data_row_text_values( + recovered.as_bytes(), + mode, + extension, + "post repeated-create recovery", + &["ready"], + ); + Ok(()) +} + +fn assert_extension_visible(db: &Oliphaunt, mode: EngineMode, extension: Extension) -> Result<()> { + if extension.creates_extension() { + let response = block_on(db.exec_protocol_raw(raw_query_message(&format!( + "SELECT extname FROM pg_extension WHERE extname = '{}'", + extension.sql_name() + ))))?; + assert_success_response(response.as_bytes(), mode, extension, "catalog visibility")?; + assert_eq!( + first_data_row_text_values(response.as_bytes()), + vec![extension.sql_name().to_owned()], + "{mode:?} extension {} was not present in pg_extension after restart/restore", + extension.sql_name() + ); + Ok(()) + } else { + let response = block_on(db.exec_protocol_raw(raw_query_message(&install_sql(extension))))?; + assert_success_response(response.as_bytes(), mode, extension, "reload visibility") + } +} + +fn install_sql(extension: Extension) -> String { + extension.manifest_entry().smoke_sql() +} + +fn setup_extension_functional_smoke( + db: &Oliphaunt, + mode: EngineMode, + extension: Extension, +) -> Result<()> { + match extension { + Extension::Graph => exec_extension_sql( + db, + mode, + extension, + "functional setup", + r#" +SELECT graph.reset(); +DROP TABLE IF EXISTS liboliphaunt_graph_people CASCADE; +DROP TABLE IF EXISTS liboliphaunt_graph_companies CASCADE; +CREATE TABLE liboliphaunt_graph_companies ( + id text PRIMARY KEY, + name text NOT NULL +); +CREATE TABLE liboliphaunt_graph_people ( + id text PRIMARY KEY, + name text NOT NULL, + company_id text REFERENCES liboliphaunt_graph_companies(id) +); +INSERT INTO liboliphaunt_graph_companies VALUES + ('c1', 'Acme Bank'), + ('c2', 'Northwind Trading'); +INSERT INTO liboliphaunt_graph_people VALUES + ('p1', 'Alice', 'c1'), + ('p2', 'Bob', 'c1'), + ('p3', 'Carol', 'c2'); +SELECT graph.add_table( + 'public.liboliphaunt_graph_people'::regclass, + id_column := 'id', + columns := ARRAY['name'] +); +SELECT graph.add_table( + 'public.liboliphaunt_graph_companies'::regclass, + id_column := 'id', + columns := ARRAY['name'] +); +SELECT graph.add_edge( + from_table := 'public.liboliphaunt_graph_people'::regclass, + from_column := 'company_id', + to_table := 'public.liboliphaunt_graph_companies'::regclass, + to_column := 'id', + label := 'works_at', + bidirectional := true +); +SELECT * FROM graph.build(); +"#, + ) + .map(|_| ()), + Extension::PgSearch => exec_extension_sql( + db, + mode, + extension, + "functional setup", + r#" +DROP TABLE IF EXISTS liboliphaunt_pg_search_docs CASCADE; +CREATE TABLE liboliphaunt_pg_search_docs ( + id serial8 NOT NULL PRIMARY KEY, + body text NOT NULL +); +INSERT INTO liboliphaunt_pg_search_docs (body) VALUES + ('embedded postgres search with oliphaunt'), + ('sqlite compatibility layer notes'), + ('postgres full text search on mobile'); +CREATE INDEX liboliphaunt_pg_search_docs_bm25 + ON liboliphaunt_pg_search_docs + USING bm25 (id, body) + WITH (key_field = 'id'); +"#, + ) + .map(|_| ()), + _ => Ok(()), + } +} + +fn assert_extension_functional_smoke( + db: &Oliphaunt, + mode: EngineMode, + extension: Extension, +) -> Result<()> { + match extension { + Extension::Graph => { + let traverse = exec_extension_sql( + db, + mode, + extension, + "functional graph.traverse", + r#" +SELECT CASE WHEN count(*) >= 1 THEN 'ok' ELSE 'fail' END AS graph_traverse +FROM graph.traverse( + 'public.liboliphaunt_graph_people'::regclass, + 'p1', + 2, + hydrate := false +); +"#, + )?; + assert_first_data_row_text_values( + traverse.as_bytes(), + mode, + extension, + "functional graph.traverse", + &["ok"], + ); + + let status = exec_extension_sql( + db, + mode, + extension, + "functional graph.status", + r#" +SELECT CASE WHEN node_count = 5 AND edge_count >= 4 THEN 'ok' ELSE 'fail' END AS graph_status +FROM graph.status(); +"#, + )?; + assert_first_data_row_text_values( + status.as_bytes(), + mode, + extension, + "functional graph.status", + &["ok"], + ); + + let search = exec_extension_sql( + db, + mode, + extension, + "regression graph.search exact", + r#" +SELECT COALESCE(string_agg(node_id, ',' ORDER BY node_id), '') AS graph_search +FROM graph.search( + 'name', + 'Alice', + table_filter := 'public.liboliphaunt_graph_people'::regclass, + mode := 'exact', + hydrate := false +); +"#, + )?; + assert_first_data_row_text_values( + search.as_bytes(), + mode, + extension, + "regression graph.search exact", + &["p1"], + ); + + let path = exec_extension_sql( + db, + mode, + extension, + "regression graph.shortest_path", + r#" +SELECT CASE WHEN count(*) >= 2 AND bool_or(node_id = 'c1') THEN 'ok' ELSE 'fail' END AS graph_path +FROM graph.shortest_path( + 'public.liboliphaunt_graph_people'::regclass, + 'p1', + 'public.liboliphaunt_graph_companies'::regclass, + 'c1', + hydrate := false +); +"#, + )?; + assert_first_data_row_text_values( + path.as_bytes(), + mode, + extension, + "regression graph.shortest_path", + &["ok"], + ); + Ok(()) + } + Extension::PgSearch => { + let response = exec_extension_sql( + db, + mode, + extension, + "functional bm25 query", + r#" +SELECT COALESCE(string_agg(id::text, ',' ORDER BY id), '') AS hits +FROM liboliphaunt_pg_search_docs +WHERE body @@@ 'postgres'; +"#, + )?; + assert_first_data_row_text_values( + response.as_bytes(), + mode, + extension, + "functional bm25 query", + &["1,3"], + ); + + let all = exec_extension_sql( + db, + mode, + extension, + "regression paradedb.all", + r#" +SELECT count(*)::text AS all_docs +FROM liboliphaunt_pg_search_docs +WHERE id @@@ paradedb.all(); +"#, + )?; + assert_first_data_row_text_values( + all.as_bytes(), + mode, + extension, + "regression paradedb.all", + &["3"], + ); + + let scored = exec_extension_sql( + db, + mode, + extension, + "regression pdb.score", + r#" +SELECT CASE WHEN count(*) = 2 AND count(pdb.score(id)) = 2 THEN 'ok' ELSE 'fail' END AS scored +FROM liboliphaunt_pg_search_docs +WHERE body @@@ 'postgres'; +"#, + )?; + assert_first_data_row_text_values( + scored.as_bytes(), + mode, + extension, + "regression pdb.score", + &["ok"], + ); + + let tokenize = exec_extension_sql( + db, + mode, + extension, + "regression paradedb.tokenize stopwords", + r#" +SELECT COALESCE(string_agg(token, ',' ORDER BY token), '') AS tokens +FROM paradedb.tokenize( + paradedb.tokenizer('default', stopwords => ARRAY['stopword']), + 'something, stopword, else' +); +"#, + )?; + assert_first_data_row_text_values( + tokenize.as_bytes(), + mode, + extension, + "regression paradedb.tokenize stopwords", + &["else,something"], + ); + Ok(()) + } + Extension::Pgcrypto => exec_extension_sql( + db, + mode, + extension, + "functional pgcrypto coverage", + r#" +DO $$ +DECLARE + hashed text; + encrypted bytea; + armored text; + header_count int; + crypto_key bytea := decode('000102030405060708090a0b0c0d0e0f', 'hex'); + crypto_iv bytea := decode('101112131415161718191a1b1c1d1e1f', 'hex'); +BEGIN + IF encode(digest('abc', 'sha256'), 'hex') <> 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' THEN + RAISE EXCEPTION 'sha256 digest failed'; + END IF; + IF encode(hmac('test', 'key', 'sha1'), 'hex') <> '671f54ce0c540f78ffe1e26dcf9c2a047aea4fda' THEN + RAISE EXCEPTION 'hmac failed'; + END IF; + IF length(gen_random_bytes(16)) <> 16 THEN + RAISE EXCEPTION 'random bytes length failed'; + END IF; + IF gen_random_uuid()::text !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN + RAISE EXCEPTION 'random uuid format failed'; + END IF; + SELECT crypt('secret', gen_salt('bf', 4)) INTO hashed; + IF crypt('secret', hashed) <> hashed THEN + RAISE EXCEPTION 'password hash verify failed'; + END IF; + SELECT armor(digest('test', 'sha1'), ARRAY['Version'], ARRAY['oliphaunt']) INTO armored; + IF position('Version: oliphaunt' in armored) = 0 THEN + RAISE EXCEPTION 'armor header failed'; + END IF; + SELECT count(*) INTO header_count FROM pgp_armor_headers(armored); + IF header_count <> 1 THEN + RAISE EXCEPTION 'armor header count failed: %', header_count; + END IF; + SELECT pgp_sym_encrypt('oliphaunt secret', 'passphrase') INTO encrypted; + IF pgp_sym_decrypt(encrypted, 'passphrase') <> 'oliphaunt secret' THEN + RAISE EXCEPTION 'PGP symmetric decrypt failed'; + END IF; + IF pgp_key_id(encrypted) <> 'SYMKEY' THEN + RAISE EXCEPTION 'PGP symmetric key id failed'; + END IF; + SELECT encrypt(convert_to('oliphaunt raw cipher', 'UTF8'), crypto_key, 'aes') INTO encrypted; + IF convert_from(decrypt(encrypted, crypto_key, 'aes'), 'UTF8') <> 'oliphaunt raw cipher' THEN + RAISE EXCEPTION 'raw decrypt failed'; + END IF; + SELECT encrypt_iv(convert_to('oliphaunt iv cipher', 'UTF8'), crypto_key, crypto_iv, 'aes-cbc') INTO encrypted; + IF convert_from(decrypt_iv(encrypted, crypto_key, crypto_iv, 'aes-cbc'), 'UTF8') <> 'oliphaunt iv cipher' THEN + RAISE EXCEPTION 'raw iv decrypt failed'; + END IF; +END $$; +"#, + ) + .map(|_| ()), + Extension::Postgis => exec_extension_sql( + db, + mode, + extension, + "functional postgis coverage", + include_str!("../../../extensions/external/postgis/tests/smoke.sql"), + ) + .map(|_| ()), + Extension::UuidOssp => exec_extension_sql( + db, + mode, + extension, + "functional uuid-ossp coverage", + r#" +DO $$ +DECLARE + id uuid; +BEGIN + SELECT uuid_generate_v1() INTO id; + IF length(id::text) <> 36 THEN + RAISE EXCEPTION 'uuid-ossp v1 length failed'; + END IF; + SELECT uuid_generate_v4() INTO id; + IF length(id::text) <> 36 THEN + RAISE EXCEPTION 'uuid-ossp v4 length failed'; + END IF; + IF uuid_generate_v3(uuid_ns_dns(), 'www.example.com')::text <> '5df41881-3aed-3515-88a7-2f4a814cf09e' THEN + RAISE EXCEPTION 'uuid-ossp v3 failed'; + END IF; + IF uuid_generate_v5(uuid_ns_dns(), 'www.example.com')::text <> '2ed6657d-e927-568b-95e1-2665a8aea6a2' THEN + RAISE EXCEPTION 'uuid-ossp v5 failed'; + END IF; + IF uuid_nil()::text <> '00000000-0000-0000-0000-000000000000' THEN + RAISE EXCEPTION 'uuid-ossp nil failed'; + END IF; + IF uuid_ns_dns()::text <> '6ba7b810-9dad-11d1-80b4-00c04fd430c8' THEN + RAISE EXCEPTION 'uuid-ossp dns namespace failed'; + END IF; + IF uuid_ns_oid()::text <> '6ba7b812-9dad-11d1-80b4-00c04fd430c8' THEN + RAISE EXCEPTION 'uuid-ossp oid namespace failed'; + END IF; +END $$; +"#, + ) + .map(|_| ()), + _ => Ok(()), + } +} + +fn exec_extension_sql( + db: &Oliphaunt, + mode: EngineMode, + extension: Extension, + action: &str, + sql: &str, +) -> Result { + let response = block_on(db.exec_protocol_raw(raw_query_message(sql)))?; + assert_success_response(response.as_bytes(), mode, extension, action)?; + Ok(response) +} + +fn assert_first_data_row_text_values( + bytes: &[u8], + mode: EngineMode, + extension: Extension, + action: &str, + expected: &[&str], +) { + let expected = expected + .iter() + .map(|value| value.to_string()) + .collect::>(); + assert_eq!( + first_data_row_text_values(bytes), + expected, + "{mode:?} extension {} returned an unexpected row during {action}", + extension.sql_name() + ); +} + +fn assert_success_response( + bytes: &[u8], + mode: EngineMode, + extension: Extension, + action: &str, +) -> Result<()> { + let tags = raw_message_tags(bytes); + assert!( + !tags.contains(&b'E'), + "{mode:?} extension {} failed during {action} with tags {tags:?}", + extension.sql_name() + ); + assert!( + tags.contains(&b'Z'), + "{mode:?} extension {} did not return ReadyForQuery during {action}: {tags:?}", + extension.sql_name() + ); + Ok(()) +} + +fn assert_physical_archive_contains_extension_catalog( + artifact: &oliphaunt::BackupArtifact, + mode: EngineMode, + extension: Extension, +) { + let mut archive = tar::Archive::new(Cursor::new(artifact.bytes.as_slice())); + let has_catalog = archive.entries().unwrap().any(|entry| { + entry + .unwrap() + .path() + .map(|path| path.starts_with("pgdata/base")) + .unwrap_or(false) + }); + assert!( + has_catalog, + "{mode:?} extension {} physical archive did not include relation storage", + extension.sql_name() + ); +} + +fn assert_extension_root_artifacts(root: &Path, mode: EngineMode, extension: Extension) { + if extension == Extension::Graph { + let graph_file = root.join("pgdata/graph/main.pggraph"); + assert!( + graph_file.is_file(), + "{mode:?} extension graph did not persist its graph artifact under the database root at {}", + graph_file.display() + ); + } +} + +fn native_runtime_env_is_unavailable() -> bool { + std::env::var_os("LIBOLIPHAUNT_PATH").is_none() +} + +fn raw_query_message(sql: &str) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(sql.as_bytes()); + body.push(0); + + let mut packet = Vec::with_capacity(body.len() + 5); + packet.push(b'Q'); + packet.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + packet.extend_from_slice(&body); + packet +} + +fn raw_message_tags(mut bytes: &[u8]) -> Vec { + let mut tags = Vec::new(); + while bytes.len() >= 5 { + let tag = bytes[0]; + let len = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + if len < 4 { + break; + } + let total = 1 + len as usize; + if bytes.len() < total { + break; + } + tags.push(tag); + bytes = &bytes[total..]; + } + tags +} + +fn first_data_row_text_values(mut bytes: &[u8]) -> Vec { + while bytes.len() >= 5 { + let tag = bytes[0]; + let len = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + if len < 4 { + break; + } + let total = 1 + len as usize; + if bytes.len() < total { + break; + } + if tag == b'D' { + return parse_data_row_text_values(&bytes[5..total]); + } + bytes = &bytes[total..]; + } + Vec::new() +} + +fn parse_data_row_text_values(payload: &[u8]) -> Vec { + if payload.len() < 2 { + return Vec::new(); + } + let columns = i16::from_be_bytes([payload[0], payload[1]]); + if columns < 0 { + return Vec::new(); + } + let mut offset = 2; + let mut values = Vec::with_capacity(columns as usize); + for _ in 0..columns { + if payload.len().saturating_sub(offset) < 4 { + return Vec::new(); + } + let len = i32::from_be_bytes([ + payload[offset], + payload[offset + 1], + payload[offset + 2], + payload[offset + 3], + ]); + offset += 4; + if len == -1 { + values.push("NULL".to_owned()); + continue; + } + if len < 0 { + return Vec::new(); + } + let len = len as usize; + if payload.len().saturating_sub(offset) < len { + return Vec::new(); + } + values.push(String::from_utf8_lossy(&payload[offset..offset + len]).into_owned()); + offset += len; + } + values +} + +fn mode_label(mode: EngineMode) -> &'static str { + match mode { + EngineMode::NativeDirect => "direct", + EngineMode::NativeBroker => "broker", + EngineMode::NativeServer => "server", + } +} + +fn unique_temp_root(prefix: &str) -> PathBuf { + let parent = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("{prefix}-{pid}-{nanos}-{attempt}")); + if !path.exists() { + return path; + } + } + panic!("failed to allocate a unique temp root for {prefix}"); +} + +fn block_on(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => return value, + Poll::Pending => thread::park_timeout(Duration::from_millis(1)), + } + } +} + +struct ThreadWaker(thread::Thread); + +impl Wake for ThreadWaker { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} diff --git a/src/sdks/rust/tests/native_root_locking.rs b/src/sdks/rust/tests/native_root_locking.rs new file mode 100644 index 00000000..a653d393 --- /dev/null +++ b/src/sdks/rust/tests/native_root_locking.rs @@ -0,0 +1,595 @@ +use std::ffi::{CStr, CString, c_char, c_int, c_void}; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::ptr; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use libloading::Library; +use oliphaunt::{ + BackupRequest, NativeBrokerRuntime, NativeRuntime, Oliphaunt, RestoreRequest, Result, +}; + +const C_DIRECT_CHILD_ENV: &str = "OLIPHAUNT_ROOT_LOCK_C_DIRECT_CHILD"; + +#[test] +fn native_server_rejects_duplicate_root_and_reopens_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server root-lock smoke: no native runtime env is set"); + return; + } + + let root = unique_temp_root("oliphaunt-server-root-lock"); + let first = block_on(Oliphaunt::builder().path(&root).native_server().open()).unwrap(); + assert_query_value(&first, "SELECT 'server-open'::text AS value", "server-open"); + + assert_open_fails_with( + block_on(Oliphaunt::builder().path(&root).native_server().open()), + &["already open in this process", "lock native root"], + ); + let archive = block_on(first.backup(BackupRequest::physical_archive())).unwrap(); + assert_fails_with( + block_on(Oliphaunt::restore( + RestoreRequest::physical_archive(&root, archive).replace_existing(), + )), + &["already open in this process", "lock restore target"], + ); + + block_on(first.close()).unwrap(); + let reopened = block_on( + Oliphaunt::builder() + .path(&root) + .native_server() + .existing_only() + .open(), + ) + .unwrap(); + assert_query_value( + &reopened, + "SELECT 'server-reopened'::text AS value", + "server-reopened", + ); + block_on(reopened.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_broker_rejects_duplicate_root_across_helpers_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native broker root-lock smoke: no native runtime env is set"); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!( + "skipping native broker root-lock smoke: cargo did not provide broker binary path" + ); + return; + }; + + let root = unique_temp_root("oliphaunt-broker-root-lock"); + let first = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .open(), + ) + .unwrap(); + assert_query_value(&first, "SELECT 'broker-open'::text AS value", "broker-open"); + + assert_open_fails_with( + block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .open(), + ), + &["lock native root", "already open", ".oliphaunt.lock"], + ); + + block_on(first.close()).unwrap(); + let reopened = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .existing_only() + .open(), + ) + .unwrap(); + assert_query_value( + &reopened, + "SELECT 'broker-reopened'::text AS value", + "broker-reopened", + ); + block_on(reopened.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_broker_shared_runtime_rejects_duplicate_root_before_helper_spawn() { + if native_runtime_env_is_unavailable() { + eprintln!( + "skipping native broker supervisor root-lock smoke: no native runtime env is set" + ); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!( + "skipping native broker supervisor root-lock smoke: cargo did not provide broker binary path" + ); + return; + }; + + let runtime: Arc = + Arc::new(NativeBrokerRuntime::from_executable(broker).with_max_roots(2)); + let root = unique_temp_root("oliphaunt-broker-supervisor-root-lock"); + let first = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_max_roots(2) + .runtime_arc(Arc::clone(&runtime)) + .open(), + ) + .unwrap(); + + assert_open_fails_with( + block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_max_roots(2) + .runtime_arc(runtime) + .open(), + ), + &["already open in this broker runtime"], + ); + + block_on(first.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn c_direct_and_rust_sdk_root_locks_are_reciprocal_when_env_is_available() { + if std::env::var_os(C_DIRECT_CHILD_ENV).is_none() { + run_c_direct_root_lock_child(); + return; + } + + if native_runtime_env_is_unavailable() { + eprintln!("skipping reciprocal C/Rust root-lock smoke: no native runtime env is set"); + return; + } + let Some(runtime_dir) = native_install_dir() else { + eprintln!("skipping reciprocal C/Rust root-lock smoke: no native install dir is available"); + return; + }; + + let api = CDirectApi::load_from_env().unwrap(); + let root = unique_temp_root("oliphaunt-c-rust-root-lock"); + let pgdata = root.join("pgdata"); + let rust_server = block_on(Oliphaunt::builder().path(&root).native_server().open()).unwrap(); + assert_query_value( + &rust_server, + "SELECT 'rust-first'::text AS value", + "rust-first", + ); + + assert_c_open_fails_with( + api.open(&pgdata, &runtime_dir), + &["already locked", ".oliphaunt.lock", "lock"], + ); + assert_c_restore_fails_with( + api.restore_physical_archive_replace(&root, b"not a valid physical archive"), + &["already locked", "lock"], + ); + block_on(rust_server.close()).unwrap(); + + let mut c_direct = api.open(&pgdata, &runtime_dir).unwrap(); + assert!( + root.join(".oliphaunt.lock").is_file(), + "C direct open should create the visible native root lock marker" + ); + + assert_open_fails_with( + block_on( + Oliphaunt::builder() + .path(&root) + .native_server() + .existing_only() + .open(), + ), + &["lock native root", "already locked", ".oliphaunt.lock"], + ); + + c_direct.close().unwrap(); + let reopened = block_on( + Oliphaunt::builder() + .path(&root) + .native_server() + .existing_only() + .open(), + ) + .unwrap(); + assert_query_value( + &reopened, + "SELECT 'rust-reopened'::text AS value", + "rust-reopened", + ); + block_on(reopened.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +fn run_c_direct_root_lock_child() { + let current_exe = std::env::current_exe().expect("current test executable is unavailable"); + let output = Command::new(current_exe) + .arg("c_direct_and_rust_sdk_root_locks_are_reciprocal_when_env_is_available") + .arg("--exact") + .arg("--nocapture") + .env(C_DIRECT_CHILD_ENV, "1") + .output() + .expect("failed to spawn C direct root-lock child test process"); + assert!( + output.status.success(), + "C direct root-lock child failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_query_value(db: &Oliphaunt, sql: &str, expected: &str) { + let result = block_on(db.query(sql)).unwrap(); + assert_eq!(result.get_text(0, "value").unwrap(), Some(expected)); +} + +fn assert_open_fails_with(result: Result, expected_needles: &[&str]) { + match result { + Ok(db) => { + let _ = block_on(db.close()); + panic!("expected duplicate native root open to fail"); + } + Err(error) => { + let message = error.to_string(); + assert!( + expected_needles + .iter() + .any(|needle| message.contains(needle)), + "unexpected duplicate native root error: {message}" + ); + } + } +} + +fn assert_c_open_fails_with( + result: std::result::Result, String>, + expected_needles: &[&str], +) { + match result { + Ok(mut handle) => { + let _ = handle.close(); + panic!("expected C direct duplicate native root open to fail"); + } + Err(message) => { + assert!( + expected_needles + .iter() + .any(|needle| message.contains(needle)), + "unexpected C direct duplicate native root error: {message}" + ); + } + } +} + +fn assert_c_restore_fails_with(result: std::result::Result<(), String>, expected_needles: &[&str]) { + match result { + Ok(()) => panic!("expected C restore over an active native root to fail"), + Err(message) => { + assert!( + expected_needles + .iter() + .any(|needle| message.contains(needle)), + "unexpected C restore active-root error: {message}" + ); + } + } +} + +fn assert_fails_with(result: Result, expected_needles: &[&str]) { + match result { + Ok(_) => panic!("expected operation to fail"), + Err(error) => { + let message = error.to_string(); + assert!( + expected_needles + .iter() + .any(|needle| message.contains(needle)), + "unexpected error: {message}" + ); + } + } +} + +fn native_runtime_env_is_unavailable() -> bool { + std::env::var_os("LIBOLIPHAUNT_PATH").is_none() +} + +fn native_broker_executable() -> Option<&'static str> { + option_env!("CARGO_BIN_EXE_oliphaunt-broker") +} + +fn native_library_path() -> Option { + ["LIBOLIPHAUNT_PATH"] + .into_iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .find(|path| path.is_file()) +} + +fn native_install_dir() -> Option { + let mut candidates: Vec = ["OLIPHAUNT_INSTALL_DIR"] + .into_iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .collect(); + + if let Some(library) = native_library_path() { + if let Some(work_root) = library.parent().and_then(Path::parent) { + candidates.push(work_root.join("install")); + } + } + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("target/liboliphaunt-pg18/install")); + candidates.push(cwd.join("target/native-liboliphaunt-pg18/install")); + } + + candidates + .into_iter() + .find(|path| path.join("bin/postgres").is_file() && path.join("bin/initdb").is_file()) +} + +fn unique_temp_root(prefix: &str) -> PathBuf { + let parent = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("{prefix}-{pid}-{nanos}-{attempt}")); + if !path.exists() { + return path; + } + } + panic!("failed to allocate a unique temp root for {prefix}"); +} + +fn block_on(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => return value, + Poll::Pending => thread::park_timeout(Duration::from_millis(1)), + } + } +} + +struct ThreadWaker(thread::Thread); + +impl Wake for ThreadWaker { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} + +#[repr(C)] +struct CDirectConfig { + abi_version: u32, + pgdata: *const c_char, + runtime_dir: *const c_char, + username: *const c_char, + database: *const c_char, + reserved_flags: u64, + startup_args: *const *const c_char, + startup_arg_count: usize, +} + +#[repr(C)] +struct CDirectRestoreOptions { + abi_version: u32, + root: *const c_char, + format: u32, + data: *const u8, + len: usize, + flags: u64, +} + +type CDirectRawHandle = c_void; +type CDirectInit = unsafe extern "C" fn(*const CDirectConfig, *mut *mut CDirectRawHandle) -> c_int; +type CDirectRestore = unsafe extern "C" fn(*const CDirectRestoreOptions) -> c_int; +type CDirectClose = unsafe extern "C" fn(*mut CDirectRawHandle) -> c_int; +type CDirectLastError = unsafe extern "C" fn(*mut CDirectRawHandle) -> *const c_char; + +const OLIPHAUNT_ABI_VERSION: u32 = 6; + +struct CDirectApi { + _library: Library, + init: CDirectInit, + restore: CDirectRestore, + close: CDirectClose, + last_error: CDirectLastError, +} + +impl CDirectApi { + fn load_from_env() -> std::result::Result { + let path = native_library_path() + .ok_or_else(|| "native liboliphaunt dynamic library is not available".to_owned())?; + let library = load_c_direct_library(&path)?; + let init = load_c_symbol(&library, b"oliphaunt_init\0")?; + let restore = load_c_symbol(&library, b"oliphaunt_restore\0")?; + let close = load_c_symbol(&library, b"oliphaunt_close\0")?; + let last_error = load_c_symbol(&library, b"oliphaunt_last_error\0")?; + Ok(Self { + _library: library, + init, + restore, + close, + last_error, + }) + } + + fn open<'a>( + &'a self, + pgdata: &Path, + runtime_dir: &Path, + ) -> std::result::Result, String> { + let pgdata = path_to_c_string(pgdata, "pgdata")?; + let runtime_dir = path_to_c_string(runtime_dir, "runtime_dir")?; + let username = CString::new("postgres").unwrap(); + let database = CString::new("postgres").unwrap(); + let config = CDirectConfig { + abi_version: OLIPHAUNT_ABI_VERSION, + pgdata: pgdata.as_ptr(), + runtime_dir: runtime_dir.as_ptr(), + username: username.as_ptr(), + database: database.as_ptr(), + reserved_flags: 0, + startup_args: ptr::null(), + startup_arg_count: 0, + }; + let mut handle = ptr::null_mut(); + let rc = unsafe { (self.init)(&config, &mut handle) }; + if rc != 0 { + return Err(self.last_error_text(handle).unwrap_or_else(|| { + format!( + "oliphaunt_init failed with status {rc} for {}", + pgdata.to_string_lossy() + ) + })); + } + if handle.is_null() { + return Err("oliphaunt_init succeeded with a null handle".to_owned()); + } + Ok(CDirectHandle { + api: self, + handle, + closed: false, + }) + } + + fn restore_physical_archive_replace( + &self, + root: &Path, + bytes: &[u8], + ) -> std::result::Result<(), String> { + let root = path_to_c_string(root, "restore root")?; + let options = CDirectRestoreOptions { + abi_version: OLIPHAUNT_ABI_VERSION, + root: root.as_ptr(), + format: 2, + data: bytes.as_ptr(), + len: bytes.len(), + flags: 1, + }; + let rc = unsafe { (self.restore)(&options) }; + if rc != 0 { + return Err(self.last_error_text(ptr::null_mut()).unwrap_or_else(|| { + format!( + "oliphaunt_restore failed with status {rc} for {}", + root.to_string_lossy() + ) + })); + } + Ok(()) + } + + fn last_error_text(&self, handle: *mut CDirectRawHandle) -> Option { + let ptr = unsafe { (self.last_error)(handle) }; + if ptr.is_null() { + return None; + } + Some( + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) + } +} + +struct CDirectHandle<'a> { + api: &'a CDirectApi, + handle: *mut CDirectRawHandle, + closed: bool, +} + +impl CDirectHandle<'_> { + fn close(&mut self) -> std::result::Result<(), String> { + if self.closed { + return Ok(()); + } + let rc = unsafe { (self.api.close)(self.handle) }; + self.closed = true; + if rc != 0 { + return Err(format!("oliphaunt_close failed with status {rc}")); + } + Ok(()) + } +} + +impl Drop for CDirectHandle<'_> { + fn drop(&mut self) { + let _ = self.close(); + } +} + +fn load_c_direct_library(path: &Path) -> std::result::Result { + #[cfg(unix)] + { + use libloading::os::unix::{Library as UnixLibrary, RTLD_GLOBAL, RTLD_NOW}; + + let library = unsafe { UnixLibrary::open(Some(path.as_os_str()), RTLD_NOW | RTLD_GLOBAL) } + .map_err(|error| { + format!( + "load native liboliphaunt library {}: {error}", + path.display() + ) + })?; + Ok(Library::from(library)) + } + + #[cfg(not(unix))] + { + unsafe { Library::new(path) }.map_err(|error| { + format!( + "load native liboliphaunt library {}: {error}", + path.display() + ) + }) + } +} + +fn load_c_symbol(library: &Library, name: &[u8]) -> std::result::Result { + let symbol = unsafe { library.get::(name) }.map_err(|error| { + format!( + "native liboliphaunt is missing required symbol {}: {error}", + String::from_utf8_lossy(name).trim_end_matches('\0') + ) + })?; + Ok(*symbol) +} + +fn path_to_c_string(path: &Path, label: &str) -> std::result::Result { + let text = path.to_string_lossy(); + CString::new(text.as_bytes()).map_err(|_| format!("{label} contains an interior NUL")) +} diff --git a/src/sdks/rust/tests/native_sql_regression.rs b/src/sdks/rust/tests/native_sql_regression.rs new file mode 100644 index 00000000..ba0ad918 --- /dev/null +++ b/src/sdks/rust/tests/native_sql_regression.rs @@ -0,0 +1,1026 @@ +use std::future::Future; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::Duration; + +use oliphaunt::{EngineMode, Error, Oliphaunt, ProtocolRequest, QueryParam, Result}; + +#[test] +fn native_sql_regression_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native SQL regression: no native library env var is set"); + return; + } + + run_mode(EngineMode::NativeDirect, None).unwrap(); + + let Some(broker) = option_env!("CARGO_BIN_EXE_oliphaunt-broker") else { + eprintln!("skipping native broker SQL regression: no broker binary path from Cargo"); + run_mode(EngineMode::NativeServer, None).unwrap(); + return; + }; + + run_mode(EngineMode::NativeBroker, Some(broker)).unwrap(); + run_mode(EngineMode::NativeServer, None).unwrap(); +} + +fn run_mode(mode: EngineMode, broker: Option<&str>) -> Result<()> { + eprintln!("native_sql_regression::{mode:?} start"); + let mut builder = Oliphaunt::builder().temporary().engine(mode); + if let Some(broker) = broker { + builder = builder.broker_executable(broker); + } + let db = block_on(builder.open())?; + run_schema_types_and_recovery(&db)?; + block_on(db.close())?; + eprintln!("native_sql_regression::{mode:?} end"); + Ok(()) +} + +fn run_schema_types_and_recovery(db: &Oliphaunt) -> Result<()> { + block_on(db.execute( + r#" + CREATE SCHEMA reg; + CREATE TABLE reg.accounts ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + login text NOT NULL UNIQUE, + balance numeric(12,2) NOT NULL DEFAULT 0 CHECK (balance >= 0), + active boolean NOT NULL DEFAULT true, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + payload bytea, + tags text[] NOT NULL DEFAULT ARRAY[]::text[] + ); + CREATE TABLE reg.account_audit ( + account_id bigint NOT NULL, + action text NOT NULL + ); + CREATE FUNCTION reg.audit_account_insert() RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + INSERT INTO reg.account_audit(account_id, action) + VALUES (NEW.id, 'insert'); + RETURN NEW; + END + $$; + CREATE TRIGGER account_insert_audit + AFTER INSERT ON reg.accounts + FOR EACH ROW EXECUTE FUNCTION reg.audit_account_insert(); + CREATE VIEW reg.open_accounts AS + SELECT login, balance FROM reg.accounts WHERE active; + "#, + ))?; + + let inserted = block_on(db.query_params( + r#" + INSERT INTO reg.accounts(login, balance, active, metadata, payload, tags) + VALUES ($1, $2::numeric, $3::bool, $4::jsonb, $5::bytea, ARRAY[$6::text, $7::text]) + RETURNING + id::text, + login, + balance::text, + active::text, + metadata->>'tier' AS tier, + encode(payload, 'hex') AS payload_hex, + array_to_string(tags, ',') AS tags + "#, + vec![ + QueryParam::from("alpha@example.com"), + QueryParam::from("12.50"), + QueryParam::from(true), + QueryParam::from(r#"{"tier":"pro"}"#), + QueryParam::binary([0_u8, 1, 255]), + QueryParam::from("red"), + QueryParam::from("blue"), + ], + ))?; + assert_eq!(inserted.row_count(), 1); + assert_eq!(inserted.get_text(0, "login")?, Some("alpha@example.com")); + assert_eq!(inserted.get_text(0, "balance")?, Some("12.50")); + assert_eq!(inserted.get_text(0, "active")?, Some("true")); + assert_eq!(inserted.get_text(0, "tier")?, Some("pro")); + assert_eq!(inserted.get_text(0, "payload_hex")?, Some("0001ff")); + assert_eq!(inserted.get_text(0, "tags")?, Some("red,blue")); + assert_eq!(inserted.fields()[0].type_oid, 25); + + let audit = block_on(db.query("SELECT count(*)::text AS count FROM reg.account_audit"))?; + assert_eq!(audit.get_text(0, "count")?, Some("1")); + + let view = block_on(db.query("SELECT login FROM reg.open_accounts"))?; + assert_eq!(view.get_text(0, "login")?, Some("alpha@example.com")); + + run_curated_postgres_feature_regression(db)?; + run_icu_collation_regression(db)?; + + let constraint_error = block_on(db.query_params( + "INSERT INTO reg.accounts(login, balance) VALUES ($1, $2::numeric)", + [QueryParam::from("bad@example.com"), QueryParam::from("-1")], + )) + .unwrap_err(); + assert_postgres_error( + &constraint_error, + "23514", + "accounts_balance_check", + "constraint error", + ); + + let recovered = block_on(db.query("SELECT count(*)::text AS count FROM reg.accounts"))?; + assert_eq!(recovered.get_text(0, "count")?, Some("1")); + + let tx = block_on(db.transaction())?; + block_on(tx.query("SAVEPOINT before_duplicate"))?; + let duplicate = block_on(tx.query_params( + "INSERT INTO reg.accounts(login, balance) VALUES ($1, $2::numeric)", + [QueryParam::from("alpha@example.com"), QueryParam::from("1")], + )) + .unwrap_err(); + assert_postgres_error(&duplicate, "23505", "duplicate key", "duplicate-key error"); + block_on(tx.query("ROLLBACK TO SAVEPOINT before_duplicate"))?; + block_on(tx.query_params( + "INSERT INTO reg.accounts(login, balance, active) VALUES ($1, $2::numeric, $3::bool)", + [ + QueryParam::from("beta@example.com"), + QueryParam::from("5"), + QueryParam::from(false), + ], + ))?; + block_on(tx.commit())?; + + let committed = block_on(db.query("SELECT count(*)::text AS count FROM reg.accounts"))?; + assert_eq!(committed.get_text(0, "count")?, Some("2")); + + block_on(db.execute("CREATE INDEX accounts_login_idx ON reg.accounts(login)"))?; + block_on(db.execute("SET enable_seqscan = off"))?; + let plan = + block_on(db.query("EXPLAIN SELECT * FROM reg.accounts WHERE login = 'beta@example.com'"))?; + let plan_text = (0..plan.row_count()) + .filter_map(|row| plan.get_text(row, "QUERY PLAN").ok().flatten()) + .collect::>() + .join("\n"); + assert!( + plan_text.contains("Index"), + "expected indexed plan, got:\n{plan_text}" + ); + block_on(db.execute("RESET enable_seqscan"))?; + + let expected_error = block_on(db.query("SELECT * FROM reg.missing_relation")).unwrap_err(); + assert_postgres_error( + &expected_error, + "42P01", + "missing_relation", + "missing relation error", + ); + let after_error = block_on(db.query("SELECT 'ready'::text AS state"))?; + assert_eq!(after_error.get_text(0, "state")?, Some("ready")); + + run_extended_protocol_error_recovery(db)?; + run_privileges_utility_and_lock_regression(db)?; + run_copy_from_stdin_and_reuse(db)?; + run_copy_from_stdin_error_recovery(db)?; + run_copy_from_stdin_copyfail_recovery(db)?; + run_copy_streaming_and_reuse(db)?; + + Ok(()) +} + +fn run_icu_collation_regression(db: &Oliphaunt) -> Result<()> { + let available = block_on(db.query( + "SELECT (count(*) > 0)::text AS available FROM pg_collation WHERE collprovider = 'i'", + ))?; + assert_eq!(available.get_text(0, "available")?, Some("true")); + + block_on(db.execute( + r#" + CREATE COLLATION reg.und_numeric ( + provider = icu, + locale = 'und-u-kn-true', + deterministic = false + ); + "#, + ))?; + + let provider = block_on(db.query( + r#" + SELECT collprovider::text AS provider + FROM pg_collation + WHERE collname = 'und_numeric' + AND collnamespace = 'reg'::regnamespace + "#, + ))?; + assert_eq!(provider.get_text(0, "provider")?, Some("i")); + + let ordered = block_on(db.query( + r#" + SELECT string_agg(value, ',' ORDER BY value COLLATE reg.und_numeric) AS values + FROM (VALUES ('10'), ('2'), ('1')) AS input(value) + "#, + ))?; + assert_eq!(ordered.get_text(0, "values")?, Some("1,2,10")); + + Ok(()) +} + +fn run_curated_postgres_feature_regression(db: &Oliphaunt) -> Result<()> { + block_on(db.execute( + r#" + CREATE DOMAIN reg.positive_cents AS integer CHECK (VALUE >= 0); + CREATE TYPE reg.order_state AS ENUM ('new', 'paid', 'shipped'); + CREATE TABLE reg.customers ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + email text NOT NULL UNIQUE, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb + ); + CREATE TABLE reg.orders ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + customer_id integer NOT NULL REFERENCES reg.customers(id) ON DELETE CASCADE, + state reg.order_state NOT NULL DEFAULT 'new', + amount reg.positive_cents NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + amount_bucket integer GENERATED ALWAYS AS (amount / 1000) STORED, + UNIQUE (customer_id, id) DEFERRABLE INITIALLY IMMEDIATE + ); + CREATE INDEX orders_paid_amount_idx ON reg.orders ((amount * 2)) WHERE state = 'paid'; + CREATE TABLE reg.events ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + payload jsonb NOT NULL, + seen_at tstzrange NOT NULL + ); + CREATE TABLE reg.inventory ( + sku text PRIMARY KEY, + quantity integer NOT NULL CHECK (quantity >= 0) + ); + CREATE TABLE reg.inventory_delta ( + sku text NOT NULL REFERENCES reg.inventory(sku), + delta integer NOT NULL + ); + "#, + ))?; + + block_on(db.query_params( + "INSERT INTO reg.customers(email, metadata) VALUES ($1, $2::jsonb)", + [ + QueryParam::from("customer@example.com"), + QueryParam::from(r#"{"segment":"enterprise","region":"apac"}"#), + ], + ))?; + block_on(db.execute( + r#" + INSERT INTO reg.orders(customer_id, state, amount) + SELECT id, 'paid'::reg.order_state, amount + FROM reg.customers + CROSS JOIN (VALUES (1250), (4500), (9900)) AS input(amount); + INSERT INTO reg.events(payload, seen_at) + VALUES + ('{"kind":"signup","score":7}'::jsonb, tstzrange(clock_timestamp(), clock_timestamp() + interval '1 hour', '[)')), + ('{"kind":"purchase","score":11}'::jsonb, tstzrange(clock_timestamp() + interval '2 hours', clock_timestamp() + interval '3 hours', '[)')); + INSERT INTO reg.inventory VALUES ('sku-1', 10), ('sku-2', 20); + INSERT INTO reg.inventory_delta VALUES ('sku-1', 2), ('sku-1', -1), ('sku-2', 5); + "#, + ))?; + + let aggregate = block_on(db.query( + r#" + WITH ranked AS ( + SELECT + c.email, + o.amount, + o.amount_bucket, + row_number() OVER (PARTITION BY c.id ORDER BY o.amount DESC) AS rank + FROM reg.customers AS c + JOIN LATERAL ( + SELECT amount, amount_bucket FROM reg.orders + WHERE customer_id = c.id + ORDER BY amount DESC + LIMIT 2 + ) AS o ON true + ) + SELECT + count(*)::text AS rows, + sum(amount)::text AS total, + string_agg(amount::text || ':' || amount_bucket::text || ':' || rank::text, ',' ORDER BY amount DESC) AS ranked + FROM ranked + "#, + ))?; + assert_eq!(aggregate.get_text(0, "rows")?, Some("2")); + assert_eq!(aggregate.get_text(0, "total")?, Some("14400")); + assert_eq!(aggregate.get_text(0, "ranked")?, Some("9900:9:1,4500:4:2")); + + let json_range = block_on(db.query( + r#" + SELECT + payload->>'kind' AS kind, + (payload->>'score')::int::text AS score, + (upper(seen_at) > lower(seen_at))::text AS non_empty + FROM reg.events + WHERE payload @> '{"kind":"purchase"}'::jsonb + "#, + ))?; + assert_eq!(json_range.get_text(0, "kind")?, Some("purchase")); + assert_eq!(json_range.get_text(0, "score")?, Some("11")); + assert_eq!(json_range.get_text(0, "non_empty")?, Some("true")); + + let recursive = block_on(db.query( + r#" + WITH RECURSIVE walk(n, factorial) AS ( + VALUES (1, 1) + UNION ALL + SELECT n + 1, factorial * (n + 1) + FROM walk + WHERE n < 6 + ) + SELECT factorial::text FROM walk ORDER BY n DESC LIMIT 1 + "#, + ))?; + assert_eq!(recursive.get_text(0, "factorial")?, Some("720")); + + let merge = block_on(db.query( + r#" + MERGE INTO reg.inventory AS target + USING ( + SELECT sku, sum(delta) AS delta + FROM reg.inventory_delta + GROUP BY sku + ) AS source + ON target.sku = source.sku + WHEN MATCHED THEN + UPDATE SET quantity = target.quantity + source.delta + RETURNING target.sku, target.quantity::text + "#, + ))?; + assert_eq!(merge.row_count(), 2); + let inventory = block_on(db.query( + "SELECT string_agg(sku || '=' || quantity::text, ',' ORDER BY sku) AS stock FROM reg.inventory", + ))?; + assert_eq!(inventory.get_text(0, "stock")?, Some("sku-1=11,sku-2=25")); + + block_on(db.execute("SET enable_seqscan = off"))?; + let plan = block_on( + db.query("EXPLAIN SELECT * FROM reg.orders WHERE state = 'paid' AND amount * 2 = 9000"), + )?; + let plan_text = (0..plan.row_count()) + .filter_map(|row| plan.get_text(row, "QUERY PLAN").ok().flatten()) + .collect::>() + .join("\n"); + assert!( + plan_text.contains("orders_paid_amount_idx"), + "expected partial expression index in plan, got:\n{plan_text}" + ); + block_on(db.execute("RESET enable_seqscan"))?; + + let domain_error = + block_on(db.query("INSERT INTO reg.orders(customer_id, amount) VALUES (1, -10)")) + .unwrap_err(); + assert_postgres_error( + &domain_error, + "23514", + "positive_cents", + "domain check error", + ); + let after_domain_error = block_on(db.query("SELECT 'post-domain-error-ready'::text AS state"))?; + assert_eq!( + after_domain_error.get_text(0, "state")?, + Some("post-domain-error-ready") + ); + + let fk_error = + block_on(db.query("INSERT INTO reg.orders(customer_id, amount) VALUES (99999, 10)")) + .unwrap_err(); + assert_postgres_error( + &fk_error, + "23503", + "orders_customer_id_fkey", + "foreign-key delete error", + ); + let after_fk_error = block_on(db.query("SELECT 'post-fk-error-ready'::text AS state"))?; + assert_eq!( + after_fk_error.get_text(0, "state")?, + Some("post-fk-error-ready") + ); + + Ok(()) +} + +fn run_privileges_utility_and_lock_regression(db: &Oliphaunt) -> Result<()> { + block_on(db.execute( + r#" + CREATE ROLE reg_reader; + CREATE FUNCTION reg.account_balance_label(reg.accounts) RETURNS text + LANGUAGE sql + STABLE + AS $$ + SELECT $1.login || ':' || $1.balance::text + $$; + GRANT USAGE ON SCHEMA reg TO reg_reader; + GRANT SELECT ON reg.accounts TO reg_reader; + "#, + ))?; + + let function_result = block_on(db.query( + r#" + SELECT reg.account_balance_label(a) AS label + FROM reg.accounts AS a + WHERE a.login = 'alpha@example.com' + "#, + ))?; + assert_eq!( + function_result.get_text(0, "label")?, + Some("alpha@example.com:12.50") + ); + + block_on(db.execute("SET ROLE reg_reader"))?; + let visible_as_reader = block_on(db.query("SELECT count(*)::text AS count FROM reg.accounts"))?; + assert_eq!(visible_as_reader.get_text(0, "count")?, Some("2")); + + let privilege_error = block_on( + db.query("INSERT INTO reg.accounts(login, balance) VALUES ('reader@example.com', 1)"), + ) + .unwrap_err(); + assert_postgres_error( + &privilege_error, + "42501", + "permission denied", + "reader insert privilege error", + ); + block_on(db.execute("RESET ROLE"))?; + + let after_privilege_error = + block_on(db.query("SELECT count(*)::text AS count FROM reg.accounts"))?; + assert_eq!(after_privilege_error.get_text(0, "count")?, Some("2")); + + block_on(db.execute("VACUUM reg.accounts"))?; + block_on(db.execute("ANALYZE reg.accounts"))?; + let analyzed = block_on(db.query( + "SELECT (reltuples >= 0)::text AS analyzed FROM pg_class WHERE oid = 'reg.accounts'::regclass", + ))?; + assert_eq!(analyzed.get_text(0, "analyzed")?, Some("true")); + + let tx = block_on(db.transaction())?; + block_on(tx.query("LOCK TABLE reg.accounts IN SHARE MODE"))?; + let advisory_lock = block_on(tx.query("SELECT pg_try_advisory_lock(424242)::text AS locked"))?; + assert_eq!(advisory_lock.get_text(0, "locked")?, Some("true")); + let advisory_unlock = + block_on(tx.query("SELECT pg_advisory_unlock(424242)::text AS unlocked"))?; + assert_eq!(advisory_unlock.get_text(0, "unlocked")?, Some("true")); + block_on(tx.commit())?; + + let after_locks = block_on(db.query("SELECT 'post-lock-ready'::text AS state"))?; + assert_eq!(after_locks.get_text(0, "state")?, Some("post-lock-ready")); + Ok(()) +} + +fn run_extended_protocol_error_recovery(db: &Oliphaunt) -> Result<()> { + let response = block_on(db.exec_protocol_raw(extended_parse_sync_request( + "", + "SELECT * FROM reg.extended_missing_relation", + &[], + )))?; + let tags = backend_message_tags(response.as_bytes())?; + assert!( + tags.contains(&b'E'), + "extended Parse error response did not include ErrorResponse: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "extended Parse error response did not return ReadyForQuery: {tags:?}" + ); + assert!( + !tags.contains(&b'1'), + "extended Parse error unexpectedly returned ParseComplete: {tags:?}" + ); + + let after_parse_error = block_on(db.query("SELECT 'post-parse-error-ready'::text AS state"))?; + assert_eq!( + after_parse_error.get_text(0, "state")?, + Some("post-parse-error-ready") + ); + + let prepared = block_on(db.exec_protocol_raw(extended_parse_sync_request( + "reg_int_stmt", + "SELECT $1::int4 AS value", + &[23], + )))?; + let tags = backend_message_tags(prepared.as_bytes())?; + assert!( + tags.contains(&b'1'), + "valid extended Parse did not return ParseComplete: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "valid extended Parse did not return ReadyForQuery: {tags:?}" + ); + assert!( + !tags.contains(&b'E'), + "valid extended Parse returned ErrorResponse: {tags:?}" + ); + + let bind_error = block_on( + db.exec_protocol_raw(extended_bind_describe_execute_sync_request( + "reg_int_stmt", + &[Some(b"not-an-int".as_slice())], + )), + )?; + let tags = backend_message_tags(bind_error.as_bytes())?; + assert!( + tags.contains(&b'E'), + "extended Bind error response did not include ErrorResponse: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "extended Bind error response did not return ReadyForQuery: {tags:?}" + ); + + let recovered = block_on( + db.exec_protocol_raw(extended_bind_describe_execute_sync_request( + "reg_int_stmt", + &[Some(b"41".as_slice())], + )), + )?; + let tags = backend_message_tags(recovered.as_bytes())?; + assert!( + tags.contains(&b'2'), + "extended recovery response did not include BindComplete: {tags:?}" + ); + assert!( + tags.contains(&b'T'), + "extended recovery response did not include RowDescription: {tags:?}" + ); + assert!( + tags.contains(&b'D'), + "extended recovery response did not include DataRow: {tags:?}" + ); + assert!( + tags.contains(&b'C'), + "extended recovery response did not include CommandComplete: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "extended recovery response did not return ReadyForQuery: {tags:?}" + ); + assert!( + !tags.contains(&b'E'), + "extended recovery response returned ErrorResponse: {tags:?}" + ); + + let after_bind_error = block_on(db.query("SELECT 'post-bind-error-ready'::text AS state"))?; + assert_eq!( + after_bind_error.get_text(0, "state")?, + Some("post-bind-error-ready") + ); + Ok(()) +} + +fn run_copy_from_stdin_and_reuse(db: &Oliphaunt) -> Result<()> { + block_on(db.execute( + r#" + CREATE TABLE reg.copy_in_items ( + id integer PRIMARY KEY, + payload text NOT NULL + ) + "#, + ))?; + + let response = block_on(db.exec_protocol_raw(copy_from_stdin_request( + "COPY reg.copy_in_items(id, payload) FROM STDIN WITH (FORMAT csv)", + &[ + b"1,alpha\n".as_slice(), + b"2,\"with,comma\"\n".as_slice(), + b"3,\"with \"\"quote\"\"\"\n".as_slice(), + ], + )))?; + let tags = backend_message_tags(response.as_bytes())?; + assert!( + tags.contains(&b'G'), + "COPY FROM response did not include CopyInResponse: {tags:?}" + ); + assert!( + tags.contains(&b'C'), + "COPY FROM response did not include CommandComplete: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "COPY FROM response did not include ReadyForQuery: {tags:?}" + ); + assert!( + !tags.contains(&b'E'), + "COPY FROM response unexpectedly included ErrorResponse: {tags:?}" + ); + + let rows = block_on(db.query( + "SELECT count(*)::text AS count, string_agg(payload, '|' ORDER BY id) AS payloads FROM reg.copy_in_items", + ))?; + assert_eq!(rows.get_text(0, "count")?, Some("3")); + assert_eq!( + rows.get_text(0, "payloads")?, + Some("alpha|with,comma|with \"quote\"") + ); + + let after_copy = block_on(db.query("SELECT 'post-copy-from-ready'::text AS state"))?; + assert_eq!( + after_copy.get_text(0, "state")?, + Some("post-copy-from-ready") + ); + Ok(()) +} + +fn run_copy_from_stdin_error_recovery(db: &Oliphaunt) -> Result<()> { + block_on(db.execute( + r#" + CREATE TABLE reg.copy_in_error_items ( + id integer PRIMARY KEY, + payload text NOT NULL + ) + "#, + ))?; + + let response = block_on(db.exec_protocol_raw(copy_from_stdin_request( + "COPY reg.copy_in_error_items(id, payload) FROM STDIN WITH (FORMAT csv)", + &[b"not-an-integer,should-error\n".as_slice()], + )))?; + let tags = backend_message_tags(response.as_bytes())?; + assert!( + tags.contains(&b'G'), + "COPY FROM error response did not include CopyInResponse: {tags:?}" + ); + assert!( + tags.contains(&b'E'), + "COPY FROM invalid input did not produce ErrorResponse: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "COPY FROM invalid input did not return ReadyForQuery: {tags:?}" + ); + assert!( + !tags.contains(&b'C'), + "COPY FROM invalid input unexpectedly completed successfully: {tags:?}" + ); + + let rows = block_on(db.query("SELECT count(*)::text AS count FROM reg.copy_in_error_items"))?; + assert_eq!(rows.get_text(0, "count")?, Some("0")); + + let after_copy_error = block_on(db.query("SELECT 'post-copy-error-ready'::text AS state"))?; + assert_eq!( + after_copy_error.get_text(0, "state")?, + Some("post-copy-error-ready") + ); + Ok(()) +} + +fn run_copy_from_stdin_copyfail_recovery(db: &Oliphaunt) -> Result<()> { + block_on(db.execute( + r#" + CREATE TABLE reg.copy_in_copyfail_items ( + id integer PRIMARY KEY, + payload text NOT NULL + ) + "#, + ))?; + + let response = block_on(db.exec_protocol_raw(copy_from_stdin_copyfail_request( + "COPY reg.copy_in_copyfail_items(id, payload) FROM STDIN WITH (FORMAT csv)", + "liboliphaunt native regression aborted COPY", + )))?; + let tags = backend_message_tags(response.as_bytes())?; + assert!( + tags.contains(&b'G'), + "COPY FAIL response did not include CopyInResponse: {tags:?}" + ); + assert!( + tags.contains(&b'E'), + "COPY FAIL did not produce ErrorResponse: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "COPY FAIL did not return ReadyForQuery: {tags:?}" + ); + assert!( + !tags.contains(&b'C'), + "COPY FAIL unexpectedly completed successfully: {tags:?}" + ); + + let rows = + block_on(db.query("SELECT count(*)::text AS count FROM reg.copy_in_copyfail_items"))?; + assert_eq!(rows.get_text(0, "count")?, Some("0")); + + let after_copy_fail = block_on(db.query("SELECT 'post-copy-fail-ready'::text AS state"))?; + assert_eq!( + after_copy_fail.get_text(0, "state")?, + Some("post-copy-fail-ready") + ); + Ok(()) +} + +fn run_copy_streaming_and_reuse(db: &Oliphaunt) -> Result<()> { + let streamed = Arc::new(Mutex::new(Vec::new())); + let chunk_count = Arc::new(Mutex::new(0usize)); + let streamed_for_callback = Arc::clone(&streamed); + let chunk_count_for_callback = Arc::clone(&chunk_count); + + block_on(db.exec_protocol_raw_stream( + ProtocolRequest::simple_query( + "COPY ( + SELECT i, repeat('copy-stream-', 32) AS payload + FROM generate_series(1, 2048) AS i + ) TO STDOUT WITH (FORMAT csv)", + )?, + move |chunk| { + let mut chunks = chunk_count_for_callback.lock().map_err(|_| { + Error::Engine("native SQL regression COPY chunk counter lock poisoned".to_owned()) + })?; + *chunks = chunks.saturating_add(1); + streamed_for_callback + .lock() + .map_err(|_| { + Error::Engine( + "native SQL regression COPY stream buffer lock poisoned".to_owned(), + ) + })? + .extend_from_slice(chunk); + Ok(()) + }, + ))?; + + let streamed = streamed + .lock() + .map_err(|_| Error::Engine("native SQL regression COPY stream lock poisoned".to_owned()))?; + let chunks = *chunk_count.lock().map_err(|_| { + Error::Engine("native SQL regression COPY chunk count lock poisoned".to_owned()) + })?; + let copy = summarize_copy_stream(&streamed)?; + assert!( + chunks >= 1, + "COPY stream callback was not invoked; parsed tags: {:?}", + copy.tags + ); + assert!( + copy.tags.contains(&b'H'), + "COPY stream did not include CopyOutResponse: {:?}", + copy.tags + ); + assert!( + copy.tags.contains(&b'd'), + "COPY stream did not include CopyData: {:?}", + copy.tags + ); + assert!( + copy.tags.contains(&b'C'), + "COPY stream did not include CommandComplete: {:?}", + copy.tags + ); + assert!( + copy.tags.contains(&b'Z'), + "COPY stream did not include ReadyForQuery: {:?}", + copy.tags + ); + assert!(copy.copy_data_frames >= 1); + assert_eq!(copy.copy_data_lines, 2048); + assert!( + copy.copy_data_bytes > 512 * 1024, + "COPY stream carried too little data: {} bytes", + copy.copy_data_bytes + ); + drop(streamed); + + let after_copy = block_on(db.query("SELECT 'post-copy-ready'::text AS state"))?; + assert_eq!(after_copy.get_text(0, "state")?, Some("post-copy-ready")); + Ok(()) +} + +fn copy_from_stdin_request(sql: &str, data_frames: &[&[u8]]) -> ProtocolRequest { + let mut bytes = ProtocolRequest::simple_query(sql) + .expect("native SQL regression COPY SQL should be a valid simple-query frame") + .into_bytes(); + for data in data_frames { + push_frontend_message(&mut bytes, b'd', data); + } + push_frontend_message(&mut bytes, b'c', &[]); + ProtocolRequest::new(bytes) +} + +fn copy_from_stdin_copyfail_request(sql: &str, message: &str) -> ProtocolRequest { + let mut bytes = ProtocolRequest::simple_query(sql) + .expect("native SQL regression COPY SQL should be a valid simple-query frame") + .into_bytes(); + let mut body = Vec::with_capacity(message.len() + 1); + body.extend_from_slice(message.as_bytes()); + body.push(0); + push_frontend_message(&mut bytes, b'f', &body); + ProtocolRequest::new(bytes) +} + +fn push_frontend_message(out: &mut Vec, tag: u8, body: &[u8]) { + out.push(tag); + out.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + out.extend_from_slice(body); +} + +fn extended_parse_sync_request(name: &str, sql: &str, type_oids: &[i32]) -> ProtocolRequest { + let mut bytes = Vec::new(); + let mut body = Vec::new(); + push_protocol_cstr(&mut body, name); + push_protocol_cstr(&mut body, sql); + push_protocol_i16(&mut body, type_oids.len() as i16); + for oid in type_oids { + push_protocol_i32(&mut body, *oid); + } + push_frontend_message(&mut bytes, b'P', &body); + push_frontend_message(&mut bytes, b'S', &[]); + ProtocolRequest::new(bytes) +} + +fn extended_bind_describe_execute_sync_request( + statement: &str, + parameters: &[Option<&[u8]>], +) -> ProtocolRequest { + let mut bytes = Vec::new(); + let mut bind = Vec::new(); + push_protocol_cstr(&mut bind, ""); + push_protocol_cstr(&mut bind, statement); + push_protocol_i16(&mut bind, 1); + push_protocol_i16(&mut bind, 0); + push_protocol_i16(&mut bind, parameters.len() as i16); + for parameter in parameters { + match parameter { + Some(value) => { + push_protocol_i32(&mut bind, value.len() as i32); + bind.extend_from_slice(value); + } + None => push_protocol_i32(&mut bind, -1), + } + } + push_protocol_i16(&mut bind, 0); + push_frontend_message(&mut bytes, b'B', &bind); + + let mut describe = Vec::new(); + describe.push(b'P'); + push_protocol_cstr(&mut describe, ""); + push_frontend_message(&mut bytes, b'D', &describe); + + let mut execute = Vec::new(); + push_protocol_cstr(&mut execute, ""); + push_protocol_i32(&mut execute, 0); + push_frontend_message(&mut bytes, b'E', &execute); + push_frontend_message(&mut bytes, b'S', &[]); + ProtocolRequest::new(bytes) +} + +fn push_protocol_cstr(out: &mut Vec, value: &str) { + out.extend_from_slice(value.as_bytes()); + out.push(0); +} + +fn push_protocol_i16(out: &mut Vec, value: i16) { + out.extend_from_slice(&value.to_be_bytes()); +} + +fn push_protocol_i32(out: &mut Vec, value: i32) { + out.extend_from_slice(&value.to_be_bytes()); +} + +fn assert_postgres_error(error: &Error, sqlstate: &str, message: &str, context: &str) { + let Error::Postgres(postgres) = error else { + panic!("{context} was not structured PostgreSQL error: {error}"); + }; + assert_eq!( + postgres.sqlstate.as_deref(), + Some(sqlstate), + "{context} lost SQLSTATE: {postgres:?}" + ); + assert!( + postgres.message.contains(message) + || postgres + .constraint_name + .as_deref() + .is_some_and(|constraint| constraint.contains(message)), + "{context} lost PostgreSQL detail: {postgres:?}" + ); +} + +fn backend_message_tags(bytes: &[u8]) -> Result> { + let mut offset = 0usize; + let mut tags = Vec::new(); + while offset < bytes.len() { + if bytes.len() - offset < 5 { + return Err(Error::Engine(format!( + "truncated backend protocol frame at offset {offset}; {} trailing byte(s)", + bytes.len() - offset + ))); + } + let tag = bytes[offset]; + let len = i32::from_be_bytes([ + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + ]); + if len < 4 { + return Err(Error::Engine(format!( + "invalid backend protocol frame length {len} at offset {offset}" + ))); + } + let frame_end = offset + .checked_add(1 + len as usize) + .ok_or_else(|| Error::Engine("backend protocol frame length overflow".to_owned()))?; + if frame_end > bytes.len() { + return Err(Error::Engine(format!( + "truncated backend protocol body for tag {} at offset {offset}: frame ends at {frame_end}, response has {} bytes", + tag as char, + bytes.len() + ))); + } + tags.push(tag); + offset = frame_end; + } + Ok(tags) +} + +struct CopyStreamSummary { + tags: Vec, + copy_data_frames: usize, + copy_data_bytes: usize, + copy_data_lines: usize, +} + +fn summarize_copy_stream(bytes: &[u8]) -> Result { + let mut offset = 0usize; + let mut tags = Vec::new(); + let mut copy_data_frames = 0usize; + let mut copy_data_bytes = 0usize; + let mut copy_data_lines = 0usize; + + while offset < bytes.len() { + if bytes.len() - offset < 5 { + return Err(Error::Engine(format!( + "truncated backend protocol frame at offset {offset}; {} trailing byte(s)", + bytes.len() - offset + ))); + } + let tag = bytes[offset]; + let len = i32::from_be_bytes([ + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + ]); + if len < 4 { + return Err(Error::Engine(format!( + "invalid backend protocol frame length {len} at offset {offset}" + ))); + } + let frame_end = offset + .checked_add(1 + len as usize) + .ok_or_else(|| Error::Engine("backend protocol frame length overflow".to_owned()))?; + if frame_end > bytes.len() { + return Err(Error::Engine(format!( + "truncated backend protocol body for tag {} at offset {offset}: frame ends at {frame_end}, response has {} bytes", + tag as char, + bytes.len() + ))); + } + tags.push(tag); + if tag == b'd' { + copy_data_frames = copy_data_frames.saturating_add(1); + let body = &bytes[offset + 5..frame_end]; + copy_data_bytes = copy_data_bytes.saturating_add(body.len()); + copy_data_lines = + copy_data_lines.saturating_add(body.iter().filter(|byte| **byte == b'\n').count()); + } + offset = frame_end; + } + + Ok(CopyStreamSummary { + tags, + copy_data_frames, + copy_data_bytes, + copy_data_lines, + }) +} + +fn native_runtime_env_is_unavailable() -> bool { + std::env::var_os("LIBOLIPHAUNT_PATH").is_none() +} + +fn block_on(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => return value, + Poll::Pending => thread::park_timeout(Duration::from_millis(1)), + } + } +} + +struct ThreadWaker(thread::Thread); + +impl Wake for ThreadWaker { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} diff --git a/src/sdks/rust/tests/protocol_parser_fuzz.rs b/src/sdks/rust/tests/protocol_parser_fuzz.rs new file mode 100644 index 00000000..cae81e72 --- /dev/null +++ b/src/sdks/rust/tests/protocol_parser_fuzz.rs @@ -0,0 +1,219 @@ +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use oliphaunt::{Error, ProtocolRequest, ProtocolResponse, parse_query_response}; + +#[test] +fn backend_query_parser_is_panic_free_for_deterministic_fuzz_corpus() { + let mut rng = DeterministicRng::new(0x6f6c_6970_6861_756e); + for case in 0..1_000 { + let len = rng.usize(384); + let mut bytes = vec![0_u8; len]; + rng.fill(&mut bytes); + if len >= 5 && case % 4 == 0 { + bytes[0] = *[ + b'T', b'D', b'C', b'E', b'Z', b'S', b'N', b'A', b'G', b'H', b'd', + ] + .get(case % 11) + .unwrap(); + let declared = rng.usize(256) as i32 - 32; + bytes[1..5].copy_from_slice(&declared.to_be_bytes()); + } + + let parsed = catch_unwind(AssertUnwindSafe(|| { + parse_query_response(&ProtocolResponse::new(bytes)) + })); + assert!( + parsed.is_ok(), + "backend parser panicked for fuzz case {case}" + ); + } +} + +#[test] +fn backend_query_parser_rejects_mutated_valid_frames_without_panicking() { + let valid = valid_select_one_response(); + assert!(parse_query_response(&ProtocolResponse::new(valid.clone())).is_ok()); + + for mutation in mutated_frames(&valid) { + let parsed = catch_unwind(AssertUnwindSafe(|| { + parse_query_response(&ProtocolResponse::new(mutation.bytes)) + })); + assert!( + parsed.is_ok(), + "backend parser panicked for mutation {}", + mutation.label + ); + match parsed.unwrap() { + Ok(result) => { + assert!( + mutation.allow_success, + "mutation {} unexpectedly parsed successfully: {result:?}", + mutation.label + ); + } + Err(Error::Engine(_) | Error::Postgres(_)) => {} + Err(other) => panic!( + "mutation {} returned non-parser error variant: {other:?}", + mutation.label + ), + } + } +} + +#[test] +fn frontend_simple_query_builder_is_panic_free_for_deterministic_fuzz_corpus() { + let mut rng = DeterministicRng::new(0x7072_6f74_6f63_6f6c); + for case in 0..1_000 { + let len = rng.usize(512); + let mut bytes = vec![0_u8; len]; + rng.fill(&mut bytes); + let sql = String::from_utf8_lossy(&bytes); + let built = catch_unwind(AssertUnwindSafe(|| ProtocolRequest::simple_query(&sql))); + assert!( + built.is_ok(), + "frontend simple-query builder panicked for fuzz case {case}" + ); + if sql.as_bytes().contains(&0) { + assert!( + built.unwrap().is_err(), + "simple-query builder accepted NUL-containing SQL in case {case}" + ); + } + } +} + +struct Mutation { + label: &'static str, + bytes: Vec, + allow_success: bool, +} + +fn mutated_frames(valid: &[u8]) -> Vec { + let mut cases = Vec::new(); + cases.push(Mutation { + label: "empty", + bytes: Vec::new(), + allow_success: false, + }); + cases.push(Mutation { + label: "single trailing byte", + bytes: vec![b'Z'], + allow_success: false, + }); + cases.push(Mutation { + label: "invalid length below header", + bytes: { + let mut bytes = valid.to_vec(); + bytes[1..5].copy_from_slice(&3_i32.to_be_bytes()); + bytes + }, + allow_success: false, + }); + cases.push(Mutation { + label: "truncated declared body", + bytes: valid[..valid.len() - 2].to_vec(), + allow_success: false, + }); + cases.push(Mutation { + label: "unexpected backend tag", + bytes: { + let mut bytes = valid.to_vec(); + bytes[0] = b'R'; + bytes + }, + allow_success: false, + }); + cases.push(Mutation { + label: "ready before row data", + bytes: { + let mut bytes = valid_select_one_response(); + let ready_offset = bytes.len() - 6; + bytes.swap(0, ready_offset); + bytes + }, + allow_success: false, + }); + cases.push(Mutation { + label: "valid notice before result", + bytes: { + let mut bytes = Vec::new(); + push_notice_response(&mut bytes, "NOTICE", "deterministic fuzz notice"); + bytes.extend_from_slice(valid); + bytes + }, + allow_success: true, + }); + cases +} + +fn valid_select_one_response() -> Vec { + let mut bytes = Vec::new(); + let mut row_description = Vec::new(); + row_description.extend_from_slice(&1_i16.to_be_bytes()); + row_description.extend_from_slice(b"value\0"); + row_description.extend_from_slice(&0_u32.to_be_bytes()); + row_description.extend_from_slice(&0_i16.to_be_bytes()); + row_description.extend_from_slice(&23_u32.to_be_bytes()); + row_description.extend_from_slice(&4_i16.to_be_bytes()); + row_description.extend_from_slice(&(-1_i32).to_be_bytes()); + row_description.extend_from_slice(&0_i16.to_be_bytes()); + push_backend_message(&mut bytes, b'T', &row_description); + + let mut row = Vec::new(); + row.extend_from_slice(&1_i16.to_be_bytes()); + row.extend_from_slice(&1_i32.to_be_bytes()); + row.extend_from_slice(b"1"); + push_backend_message(&mut bytes, b'D', &row); + + push_backend_message(&mut bytes, b'C', b"SELECT 1\0"); + push_backend_message(&mut bytes, b'Z', b"I"); + bytes +} + +fn push_notice_response(out: &mut Vec, severity: &str, message: &str) { + let mut body = Vec::new(); + body.push(b'S'); + body.extend_from_slice(severity.as_bytes()); + body.push(0); + body.push(b'M'); + body.extend_from_slice(message.as_bytes()); + body.push(0); + body.push(0); + push_backend_message(out, b'N', &body); +} + +fn push_backend_message(out: &mut Vec, tag: u8, body: &[u8]) { + out.push(tag); + out.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + out.extend_from_slice(body); +} + +struct DeterministicRng(u64); + +impl DeterministicRng { + fn new(seed: u64) -> Self { + Self(seed) + } + + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + self.0 + } + + fn usize(&mut self, upper: usize) -> usize { + if upper == 0 { + 0 + } else { + (self.next() as usize) % upper + } + } + + fn fill(&mut self, bytes: &mut [u8]) { + for byte in bytes { + *byte = (self.next() >> 56) as u8; + } + } +} diff --git a/src/sdks/rust/tests/protocol_query_fixtures.rs b/src/sdks/rust/tests/protocol_query_fixtures.rs new file mode 100644 index 00000000..c0d47dcc --- /dev/null +++ b/src/sdks/rust/tests/protocol_query_fixtures.rs @@ -0,0 +1,248 @@ +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, +}; + +use oliphaunt::{Error, ProtocolResponse, QueryFormat, parse_query_response}; +use serde::Deserialize; + +#[test] +fn query_parser_matches_shared_protocol_fixtures() { + let Some(path) = shared_fixture_path() else { + eprintln!("skipping shared protocol fixtures outside the monorepo package"); + return; + }; + let corpus: ProtocolFixtureCorpus = + serde_json::from_str(&fs::read_to_string(&path).expect("read shared protocol fixtures")) + .expect("parse shared protocol fixtures"); + + assert_eq!(corpus.schema_version, 1); + assert_eq!(corpus.kind, "postgres-backend-query-response"); + assert!(!corpus.cases.is_empty(), "shared protocol corpus is empty"); + + let mut names = HashSet::new(); + for fixture in &corpus.cases { + assert!( + names.insert(fixture.name.clone()), + "duplicate shared protocol fixture {}", + fixture.name + ); + let Some(expectation) = &fixture.query_expectation else { + continue; + }; + let bytes = decode_hex(&fixture.response_hex); + match expectation { + QueryExpectation { + ok: Some(expected), .. + } => assert_ok_fixture(fixture, expected, &bytes), + QueryExpectation { + postgres_error: Some(expected), + .. + } => assert_postgres_error_fixture(fixture, expected, &bytes), + QueryExpectation { + engine_error_contains: Some(expected), + .. + } => assert_engine_error_fixture(fixture, expected, &bytes), + _ => panic!( + "shared protocol fixture {} has no query expectation", + fixture.name + ), + } + } +} + +fn assert_ok_fixture(fixture: &ProtocolFixtureCase, expected: &OkExpectation, bytes: &[u8]) { + let result = parse_query_response(&ProtocolResponse::new(bytes.to_vec())) + .unwrap_or_else(|err| panic!("fixture {} failed to parse: {err:?}", fixture.name)); + + assert_eq!( + result.row_count(), + expected.row_count, + "fixture {} row count", + fixture.name + ); + assert_eq!( + result.command_tag(), + expected.command_tag.as_deref(), + "fixture {} command tag", + fixture.name + ); + assert_eq!( + result.fields().len(), + expected.fields.len(), + "fixture {} field count", + fixture.name + ); + assert_eq!( + result.rows().len(), + expected.rows.len(), + "fixture {} row vector length", + fixture.name + ); + + for (index, expected_field) in expected.fields.iter().enumerate() { + let actual = &result.fields()[index]; + assert_eq!( + actual.name, expected_field.name, + "fixture {} field name", + fixture.name + ); + assert_eq!( + actual.type_oid, expected_field.type_oid, + "fixture {} field type OID", + fixture.name + ); + if expected_field.format.as_deref() == Some("text") { + assert_eq!( + actual.format, + QueryFormat::Text, + "fixture {} field format", + fixture.name + ); + } + } + + for (row_index, expected_row) in expected.rows.iter().enumerate() { + assert_eq!( + expected_row.len(), + expected.fields.len(), + "fixture {} expected row width", + fixture.name + ); + for (column_index, expected_value) in expected_row.iter().enumerate() { + let field = &expected.fields[column_index]; + assert_eq!( + result.get_text(row_index, &field.name).unwrap(), + expected_value.as_deref(), + "fixture {} row {row_index} column {}", + fixture.name, + field.name + ); + } + } +} + +fn assert_postgres_error_fixture( + fixture: &ProtocolFixtureCase, + expected: &PostgresErrorExpectation, + bytes: &[u8], +) { + match parse_query_response(&ProtocolResponse::new(bytes.to_vec())) { + Err(Error::Postgres(error)) => { + assert_eq!( + error.severity.as_deref(), + Some(expected.severity.as_str()), + "fixture {} severity", + fixture.name + ); + assert_eq!( + error.sqlstate.as_deref(), + Some(expected.sqlstate.as_str()), + "fixture {} SQLSTATE", + fixture.name + ); + assert_eq!( + error.message, expected.message, + "fixture {} message", + fixture.name + ); + } + other => panic!( + "fixture {} expected PostgreSQL error, got {other:?}", + fixture.name + ), + } +} + +fn assert_engine_error_fixture(fixture: &ProtocolFixtureCase, expected: &str, bytes: &[u8]) { + match parse_query_response(&ProtocolResponse::new(bytes.to_vec())) { + Err(Error::Engine(message)) => assert!( + message.contains(expected), + "fixture {} engine error {message:?} did not contain {expected:?}", + fixture.name + ), + other => panic!( + "fixture {} expected engine error, got {other:?}", + fixture.name + ), + } +} + +fn shared_fixture_path() -> Option { + if let Some(root) = std::env::var_os("OLIPHAUNT_SHARED_FIXTURES") { + let path = PathBuf::from(root).join("protocol/query-response-cases.json"); + if path.is_file() { + return Some(path); + } + } + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../shared/fixtures/protocol/query-response-cases.json"); + path.is_file().then_some(path) +} + +fn decode_hex(hex: &str) -> Vec { + let compact = hex + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::(); + assert!( + compact.len() % 2 == 0, + "hex fixture must have an even digit count" + ); + (0..compact.len()) + .step_by(2) + .map(|index| { + u8::from_str_radix(&compact[index..index + 2], 16) + .expect("hex fixture contains invalid byte") + }) + .collect() +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolFixtureCorpus { + schema_version: u32, + kind: String, + cases: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolFixtureCase { + name: String, + response_hex: String, + query_expectation: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct QueryExpectation { + ok: Option, + postgres_error: Option, + engine_error_contains: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OkExpectation { + fields: Vec, + rows: Vec>>, + command_tag: Option, + row_count: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FieldExpectation { + name: String, + type_oid: u32, + format: Option, +} + +#[derive(Debug, Deserialize)] +struct PostgresErrorExpectation { + severity: String, + sqlstate: String, + message: String, +} diff --git a/src/sdks/rust/tests/sdk_config_modes.rs b/src/sdks/rust/tests/sdk_config_modes.rs new file mode 100644 index 00000000..7cef7a46 --- /dev/null +++ b/src/sdks/rust/tests/sdk_config_modes.rs @@ -0,0 +1,735 @@ +use std::future::Future; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::Duration; + +use oliphaunt::{ + BackupArtifact, BackupFormat, BenchmarkMetric, BenchmarkTarget, EngineCapabilities, EngineMode, + EngineSession, Error, Extension, NativeBrokerRuntime, NativeRuntime, NativeServerRuntime, + Oliphaunt, OliphauntRuntime, PerformanceGateSet, RestoreRequest, Result, + RuntimeFootprintProfile, SessionConcurrency, SessionPin, Transaction, +}; +use serde::Deserialize; + +#[test] +fn config_is_native_only_and_extensions_are_explicit() { + let config = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .username("app_user") + .database("app_db") + .extension(Extension::Vector) + .build_config() + .unwrap(); + + assert_eq!(config.mode, EngineMode::NativeDirect); + assert_eq!(config.username, "app_user"); + assert_eq!(config.database, "app_db"); + assert_eq!(config.extensions, vec![Extension::Vector]); + assert_eq!( + config.durability.postgres_gucs(), + &[ + ("fsync", "on"), + ("full_page_writes", "on"), + ("synchronous_commit", "on"), + ] + ); +} + +#[test] +fn config_rejects_invalid_connection_identity() { + let username_error = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .username(" \n") + .build_config() + .unwrap_err(); + assert_eq!( + username_error, + Error::InvalidConfig("username must not be empty".to_owned()) + ); + + let database_error = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .database("app\0db") + .build_config() + .unwrap_err(); + assert_eq!( + database_error, + Error::InvalidConfig("database must not contain NUL bytes".to_owned()) + ); +} + +#[test] +fn rust_handle_types_are_thread_safe_shared_executor_handles() { + fn assert_clone_send_sync_static() {} + fn assert_send_static() {} + + assert_clone_send_sync_static::(); + assert_send_static::(); + assert_send_static::(); +} + +#[test] +fn runtime_footprint_profiles_define_the_mobile_pg18_startup_contract() { + assert_eq!( + RuntimeFootprintProfile::Throughput.postgres_gucs(), + &[ + ("shared_buffers", "128MB"), + ("wal_buffers", "4MB"), + ("min_wal_size", "80MB"), + ] + ); + assert_eq!( + RuntimeFootprintProfile::BalancedMobile.postgres_gucs(), + &[ + ("max_connections", "1"), + ("superuser_reserved_connections", "0"), + ("reserved_connections", "0"), + ("autovacuum_worker_slots", "1"), + ("max_wal_senders", "0"), + ("max_replication_slots", "0"), + ("shared_buffers", "32MB"), + ("wal_buffers", "-1"), + ("min_wal_size", "32MB"), + ("max_wal_size", "64MB"), + ("io_method", "sync"), + ("io_max_concurrency", "1"), + ] + ); + assert_eq!( + RuntimeFootprintProfile::SmallMobile.postgres_gucs(), + &[ + ("max_connections", "1"), + ("superuser_reserved_connections", "0"), + ("reserved_connections", "0"), + ("autovacuum_worker_slots", "1"), + ("max_wal_senders", "0"), + ("max_replication_slots", "0"), + ("shared_buffers", "8MB"), + ("wal_buffers", "256kB"), + ("min_wal_size", "32MB"), + ("max_wal_size", "64MB"), + ("work_mem", "1MB"), + ("maintenance_work_mem", "16MB"), + ("io_method", "sync"), + ("io_max_concurrency", "1"), + ] + ); +} + +#[test] +fn open_config_rejects_empty_persistent_root_before_runtime_selection() { + let error = Oliphaunt::builder().path("").build_config().unwrap_err(); + assert_eq!( + error, + Error::InvalidConfig("database root must not be empty".to_owned()) + ); +} + +#[test] +fn open_config_rejects_nul_persistent_root_before_runtime_selection() { + let error = Oliphaunt::builder() + .path("target/test-roots/native\0direct") + .build_config() + .unwrap_err(); + assert_eq!( + error, + Error::InvalidConfig("database root must not contain NUL bytes".to_owned()) + ); +} + +#[test] +fn restore_rejects_nul_target_root_before_archive_unpack() { + let error = block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + "target/test-roots/native\0restore", + BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes: Vec::new(), + }, + ))) + .unwrap_err(); + assert_eq!( + error, + Error::Engine("restore target root must not contain NUL bytes".to_owned()) + ); +} + +#[test] +fn tooling_and_runtime_executable_paths_are_validated_before_startup() { + let initdb_empty = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .initdb_tooling_only("") + .build_config() + .unwrap_err(); + assert_eq!( + initdb_empty, + Error::InvalidConfig("initdb path must not be empty".to_owned()) + ); + + let initdb_nul = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .initdb_tooling_only("target/native\0initdb") + .build_config() + .unwrap_err(); + assert_eq!( + initdb_nul, + Error::InvalidConfig("initdb path must not contain NUL bytes".to_owned()) + ); + + let broker_empty = Oliphaunt::builder() + .path("target/test-roots/native-broker") + .native_broker() + .broker_executable("") + .build_config() + .unwrap_err(); + assert_eq!( + broker_empty, + Error::InvalidConfig("native broker executable path must not be empty".to_owned()) + ); + + let broker_nul = Oliphaunt::builder() + .path("target/test-roots/native-broker") + .native_broker() + .broker_executable("target/native\0broker") + .build_config() + .unwrap_err(); + assert_eq!( + broker_nul, + Error::InvalidConfig("native broker executable path must not contain NUL bytes".to_owned()) + ); + + let server_empty = Oliphaunt::builder() + .path("target/test-roots/native-server") + .native_server() + .server_executable("") + .build_config() + .unwrap_err(); + assert_eq!( + server_empty, + Error::InvalidConfig("native server executable path must not be empty".to_owned()) + ); + + let server_nul = Oliphaunt::builder() + .path("target/test-roots/native-server") + .native_server() + .server_executable("target/native\0postgres") + .build_config() + .unwrap_err(); + assert_eq!( + server_nul, + Error::InvalidConfig("native server executable path must not contain NUL bytes".to_owned()) + ); +} + +#[test] +fn direct_mode_rejects_fake_multi_session_pools() { + let zero = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .native_direct() + .max_client_sessions(0) + .build_config() + .unwrap_err(); + assert_eq!( + zero, + Error::InvalidConfig("native direct max_client_sessions must be exactly 1".to_owned()) + ); + + let error = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .native_direct() + .max_client_sessions(2) + .build_config() + .unwrap_err(); + + assert_eq!( + error, + Error::UnsupportedClientSessions { + mode: EngineMode::NativeDirect, + requested: 2, + supported: 1, + } + ); +} + +#[test] +fn broker_mode_rejects_fake_multi_session_pools() { + let zero = Oliphaunt::builder() + .path("target/test-roots/native-broker") + .native_broker() + .max_client_sessions(0) + .build_config() + .unwrap_err(); + assert_eq!( + zero, + Error::InvalidConfig("native broker max_client_sessions must be exactly 1".to_owned()) + ); + + let error = Oliphaunt::builder() + .path("target/test-roots/native-broker") + .native_broker() + .max_client_sessions(2) + .build_config() + .unwrap_err(); + + assert_eq!( + error, + Error::UnsupportedClientSessions { + mode: EngineMode::NativeBroker, + requested: 2, + supported: 1, + } + ); +} + +#[test] +fn server_mode_advertises_true_independent_sessions() { + assert!(!EngineCapabilities::for_mode(EngineMode::NativeDirect).connection_strings); + assert!(!EngineCapabilities::for_mode(EngineMode::NativeBroker).connection_strings); + + let config = Oliphaunt::builder() + .path("target/test-roots/native-server") + .native_server() + .max_client_sessions(16) + .build_config() + .unwrap(); + + let capabilities = EngineCapabilities::for_mode(config.mode); + assert_eq!( + capabilities.session_concurrency, + SessionConcurrency::IndependentSessions + ); + assert!(capabilities.connection_strings); + assert_eq!(capabilities.connection_string, None); + assert_eq!(config.server.max_client_sessions, 16); +} + +#[test] +fn direct_broker_server_lifecycle_capabilities_are_honest() { + let direct = EngineCapabilities::for_mode(EngineMode::NativeDirect); + assert_eq!(direct.mode, EngineMode::NativeDirect); + assert_eq!( + direct.session_concurrency, + SessionConcurrency::SerializedSingleSession + ); + assert!(!direct.process_isolated); + assert!(!direct.multi_root); + assert!(direct.reopenable); + assert!(direct.same_root_logical_reopen); + assert!(!direct.root_switchable); + assert!(!direct.crash_restartable); + assert_eq!(direct.max_client_sessions, 1); + assert!(!direct.connection_strings); + assert_eq!(direct.connection_string, None); + + let broker = EngineCapabilities::for_mode(EngineMode::NativeBroker); + assert_eq!(broker.mode, EngineMode::NativeBroker); + assert_eq!( + broker.session_concurrency, + SessionConcurrency::SerializedSingleSession + ); + assert!(broker.process_isolated); + assert!(broker.multi_root); + assert!(broker.reopenable); + assert!(!broker.same_root_logical_reopen); + assert!(broker.root_switchable); + assert!(broker.crash_restartable); + assert_eq!(broker.max_client_sessions, 1); + assert!(!broker.connection_strings); + assert_eq!(broker.connection_string, None); + + let server = EngineCapabilities::for_mode(EngineMode::NativeServer); + assert_eq!(server.mode, EngineMode::NativeServer); + assert_eq!( + server.session_concurrency, + SessionConcurrency::IndependentSessions + ); + assert!(server.process_isolated); + assert!(!server.multi_root); + assert!(server.reopenable); + assert!(!server.same_root_logical_reopen); + assert!(server.root_switchable); + assert!(!server.crash_restartable); + assert_eq!(server.max_client_sessions, 32); + assert!(server.connection_strings); + assert_eq!(server.connection_string, None); +} + +#[test] +fn broker_and_server_modes_select_process_isolated_defaults() { + let broker_config = Oliphaunt::builder() + .path("target/test-roots/native-broker") + .native_broker() + .build_config() + .unwrap(); + assert_eq!(broker_config.mode, EngineMode::NativeBroker); + assert_eq!(broker_config.broker.max_client_sessions, 1); + assert_eq!(broker_config.broker.max_roots, 1); + let broker_capabilities = EngineCapabilities::for_mode(EngineMode::NativeBroker); + assert!(broker_capabilities.multi_root); + assert!(broker_capabilities.root_switchable); + assert!(broker_capabilities.crash_restartable); + + let server_config = Oliphaunt::builder() + .path("target/test-roots/native-server") + .native_server() + .server_port(55432) + .build_config() + .unwrap(); + assert_eq!(server_config.mode, EngineMode::NativeServer); + assert_eq!(server_config.server.port, Some(55432)); + let server_capabilities = EngineCapabilities::for_mode(EngineMode::NativeServer); + assert!(server_capabilities.root_switchable); + assert!(!server_capabilities.crash_restartable); +} + +#[test] +fn direct_mode_advertises_resident_single_root_lifecycle() { + let capabilities = EngineCapabilities::for_mode(EngineMode::NativeDirect); + + assert!(capabilities.reopenable); + assert!(capabilities.same_root_logical_reopen); + assert!(!capabilities.root_switchable); + assert!(!capabilities.crash_restartable); + assert!(!capabilities.process_isolated); + assert_eq!(capabilities.max_client_sessions, 1); + assert_eq!( + capabilities.session_concurrency, + SessionConcurrency::SerializedSingleSession + ); +} + +#[test] +fn broker_accepts_supervised_multi_root_configuration() { + let config = Oliphaunt::builder() + .path("target/test-roots/native-broker") + .native_broker() + .broker_max_roots(2) + .build_config() + .unwrap(); + + assert_eq!(config.broker.max_roots, 2); + let runtime = NativeBrokerRuntime::from_config(&config.broker); + assert_eq!(runtime.max_roots(), 2); +} + +#[test] +fn broker_and_server_runtimes_are_mode_specific() { + let broker_error = expect_open_error(block_on( + Oliphaunt::builder() + .native_direct() + .path("target/test-roots/wrong-broker-mode") + .runtime(NativeBrokerRuntime::from_package()) + .open(), + )); + assert!(matches!( + broker_error, + Error::UnsupportedEngineMode { + mode: EngineMode::NativeDirect, + .. + } + )); + + let server_error = expect_open_error(block_on( + Oliphaunt::builder() + .native_broker() + .path("target/test-roots/wrong-server-mode") + .runtime(NativeServerRuntime::from_package()) + .open(), + )); + assert!(matches!( + server_error, + Error::UnsupportedEngineMode { + mode: EngineMode::NativeBroker, + .. + } + )); +} + +#[test] +fn concrete_runtimes_validate_configs_before_external_startup() { + let mut direct_config = + oliphaunt::OpenConfig::native_direct("target/test-roots/direct-runtime-validation"); + direct_config.direct.max_client_sessions = 2; + let direct_error = expect_runtime_open_error(OliphauntRuntime::from_env().open(direct_config)); + assert_eq!( + direct_error, + Error::UnsupportedClientSessions { + mode: EngineMode::NativeDirect, + requested: 2, + supported: 1, + } + ); + + let mut broker_config = + oliphaunt::OpenConfig::native_direct("target/test-roots/broker-runtime-mode-validation"); + broker_config.direct.max_client_sessions = 2; + let broker_error = + expect_runtime_open_error(NativeBrokerRuntime::from_package().open(broker_config)); + assert!(matches!( + broker_error, + Error::UnsupportedEngineMode { + mode: EngineMode::NativeDirect, + .. + } + )); + + let mut server_config = + oliphaunt::OpenConfig::native_direct("target/test-roots/server-runtime-validation"); + server_config.mode = EngineMode::NativeServer; + server_config.server.port = Some(0); + let server_error = + expect_runtime_open_error(NativeServerRuntime::from_package().open(server_config)); + assert_eq!( + server_error, + Error::InvalidConfig( + "native server port must be greater than zero; omit the port to allocate one" + .to_owned() + ) + ); +} + +#[test] +fn performance_contract_is_native_direct_first() { + let gates = PerformanceGateSet::native_direct_release_baseline(); + assert!(gates.gates.iter().any(|gate| { + gate.target == BenchmarkTarget::NativeDirect && gate.metric == BenchmarkMetric::WarmOpen + })); + assert!(gates.gates.iter().any(|gate| { + gate.target == BenchmarkTarget::NativeDirect + && gate.metric == BenchmarkMetric::SimpleQueryRtt + })); +} + +#[test] +fn native_modes_advertise_core_sdk_capabilities() { + assert!(EngineCapabilities::for_mode(EngineMode::NativeDirect).protocol_stream); + assert!(EngineCapabilities::for_mode(EngineMode::NativeBroker).protocol_stream); + assert!(EngineCapabilities::for_mode(EngineMode::NativeServer).protocol_stream); + assert!(EngineCapabilities::for_mode(EngineMode::NativeDirect).query_cancel); + assert!(EngineCapabilities::for_mode(EngineMode::NativeBroker).query_cancel); + assert!(EngineCapabilities::for_mode(EngineMode::NativeServer).query_cancel); + assert!(EngineCapabilities::for_mode(EngineMode::NativeDirect).backup_restore); + assert!(EngineCapabilities::for_mode(EngineMode::NativeBroker).backup_restore); + assert!(EngineCapabilities::for_mode(EngineMode::NativeServer).backup_restore); + assert!(EngineCapabilities::for_mode(EngineMode::NativeDirect).simple_query); + assert!(EngineCapabilities::for_mode(EngineMode::NativeBroker).simple_query); + assert!(EngineCapabilities::for_mode(EngineMode::NativeServer).simple_query); + assert!(EngineCapabilities::for_mode(EngineMode::NativeDirect).reopenable); + assert!(EngineCapabilities::for_mode(EngineMode::NativeBroker).reopenable); + assert!(EngineCapabilities::for_mode(EngineMode::NativeServer).reopenable); + assert!(EngineCapabilities::for_mode(EngineMode::NativeDirect).same_root_logical_reopen); + assert!(!EngineCapabilities::for_mode(EngineMode::NativeBroker).same_root_logical_reopen); + assert!(!EngineCapabilities::for_mode(EngineMode::NativeServer).same_root_logical_reopen); + assert_eq!( + EngineCapabilities::for_mode(EngineMode::NativeDirect).backup_formats, + vec![BackupFormat::PhysicalArchive] + ); + assert_eq!( + EngineCapabilities::for_mode(EngineMode::NativeBroker).backup_formats, + vec![BackupFormat::PhysicalArchive] + ); + assert_eq!( + EngineCapabilities::for_mode(EngineMode::NativeServer).backup_formats, + vec![BackupFormat::Sql, BackupFormat::PhysicalArchive] + ); + assert_eq!( + EngineCapabilities::for_mode(EngineMode::NativeServer).restore_formats, + vec![BackupFormat::PhysicalArchive] + ); + assert!( + EngineCapabilities::for_mode(EngineMode::NativeDirect) + .supports_backup_format(BackupFormat::PhysicalArchive) + ); + assert!( + !EngineCapabilities::for_mode(EngineMode::NativeDirect) + .supports_backup_format(BackupFormat::Sql) + ); + assert!( + EngineCapabilities::for_mode(EngineMode::NativeServer) + .supports_backup_format(BackupFormat::Sql) + ); + assert!( + EngineCapabilities::for_mode(EngineMode::NativeServer) + .supports_restore_format(BackupFormat::PhysicalArchive) + ); + assert!( + !EngineCapabilities::for_mode(EngineMode::NativeServer) + .supports_restore_format(BackupFormat::Sql) + ); +} + +#[test] +fn rust_sdk_mode_support_is_explicit_and_complete() { + let support = EngineCapabilities::rust_sdk_support(); + assert_eq!(support.len(), EngineMode::all().len()); + assert_eq!( + support.iter().map(|entry| entry.mode).collect::>(), + EngineMode::all().to_vec() + ); + assert!(support.iter().all(|entry| entry.available)); + assert!( + support + .iter() + .all(|entry| entry.unavailable_reason.is_none()) + ); + assert_eq!( + support + .iter() + .map(|entry| entry.capabilities.mode) + .collect::>(), + EngineMode::all().to_vec() + ); +} + +#[test] +fn shared_sdk_capability_fixture_matches_rust_support() { + // Mirrors the cross-SDK supportedModes fixture consumed by the peer SDK test suites. + let fixture: SharedCapabilityFixture = serde_json::from_str(include_str!( + "../../../shared/fixtures/sdk-capabilities/mode-support.json" + )) + .expect("parse shared SDK capability fixture"); + let support = EngineCapabilities::rust_sdk_support(); + + assert_eq!(fixture.schema_version, 1); + assert_eq!(fixture.kind, "oliphaunt-sdk-capability-expectations"); + assert_eq!(fixture.modes.len(), support.len()); + + for expected in fixture.modes { + let mode = parse_engine_mode(&expected.engine); + let actual = support + .iter() + .find(|entry| entry.mode == mode) + .unwrap_or_else(|| panic!("missing Rust mode support for {}", expected.engine)); + + assert_eq!( + actual.available, expected.available_by_default.rust, + "{} Rust availability", + expected.engine + ); + assert_eq!( + actual.capabilities.session_concurrency == SessionConcurrency::IndependentSessions, + expected.capabilities.independent_sessions, + "{} independent session support", + expected.engine + ); + assert_eq!( + actual.capabilities.process_isolated, expected.capabilities.process_isolated, + "{} process isolation", + expected.engine + ); + if let Some(max_client_sessions) = expected.capabilities.max_client_sessions { + assert_eq!( + actual.capabilities.max_client_sessions, max_client_sessions as usize, + "{} max client sessions", + expected.engine + ); + } + assert_eq!( + actual.capabilities.backup_formats, + parse_backup_formats(&expected.capabilities.backup_formats), + "{} backup formats", + expected.engine + ); + assert_eq!( + actual.capabilities.restore_formats, + parse_backup_formats(&expected.capabilities.restore_formats), + "{} restore formats", + expected.engine + ); + } +} + +fn block_on(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => return value, + Poll::Pending => thread::park_timeout(Duration::from_millis(1)), + } + } +} + +fn expect_open_error(result: Result) -> Error { + match result { + Ok(_) => panic!("expected open to fail"), + Err(error) => error, + } +} + +fn expect_runtime_open_error(result: Result>) -> Error { + match result { + Ok(_) => panic!("expected runtime open to fail"), + Err(error) => error, + } +} + +struct ThreadWaker(thread::Thread); + +#[derive(Deserialize)] +struct SharedCapabilityFixture { + #[serde(rename = "schemaVersion")] + schema_version: u32, + kind: String, + modes: Vec, +} + +#[derive(Deserialize)] +struct SharedModeExpectation { + engine: String, + #[serde(rename = "availableByDefault")] + available_by_default: SharedModeAvailability, + capabilities: SharedModeCapabilities, +} + +#[derive(Deserialize)] +struct SharedModeAvailability { + rust: bool, +} + +#[derive(Deserialize)] +struct SharedModeCapabilities { + #[serde(rename = "maxClientSessions")] + max_client_sessions: Option, + #[serde(rename = "independentSessions")] + independent_sessions: bool, + #[serde(rename = "processIsolated")] + process_isolated: bool, + #[serde(rename = "backupFormats")] + backup_formats: Vec, + #[serde(rename = "restoreFormats")] + restore_formats: Vec, +} + +fn parse_engine_mode(mode: &str) -> EngineMode { + match mode { + "nativeDirect" => EngineMode::NativeDirect, + "nativeBroker" => EngineMode::NativeBroker, + "nativeServer" => EngineMode::NativeServer, + other => panic!("unknown shared SDK capability mode {other}"), + } +} + +fn parse_backup_formats(formats: &[String]) -> Vec { + formats + .iter() + .map(|format| match format.as_str() { + "sql" => BackupFormat::Sql, + "physicalArchive" => BackupFormat::PhysicalArchive, + "oliphauntArchive" => BackupFormat::OliphauntArchive, + other => panic!("unknown shared backup format {other}"), + }) + .collect() +} + +impl Wake for ThreadWaker { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} diff --git a/src/sdks/rust/tests/sdk_extensions.rs b/src/sdks/rust/tests/sdk_extensions.rs new file mode 100644 index 00000000..07dd5995 --- /dev/null +++ b/src/sdks/rust/tests/sdk_extensions.rs @@ -0,0 +1,651 @@ +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::process::Command; + +use oliphaunt::{ + Extension, ExtensionArtifactPolicy, ExtensionModuleAsset, ExtensionRedistribution, + ExtensionSmokeCoverage, ExtensionSmokePlan, ExtensionSourceKind, ExtensionSqlAsset, + MobileStaticLinkStatus, NATIVE_EXTENSION_MANIFEST, Oliphaunt, + required_shared_preload_libraries, resolve_extension_selection, +}; + +fn generated_wasm_extension_catalog() -> serde_json::Value { + let catalog_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../extensions/generated/extensions.catalog.json"); + let catalog_text = std::fs::read_to_string(&catalog_path) + .unwrap_or_else(|error| panic!("read {}: {error}", catalog_path.display())); + serde_json::from_str(&catalog_text) + .unwrap_or_else(|error| panic!("parse {}: {error}", catalog_path.display())) +} + +fn generated_rust_sdk_extension_metadata() -> serde_json::Value { + let catalog_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../extensions/generated/sdk/rust.json"); + let catalog_text = std::fs::read_to_string(&catalog_path) + .unwrap_or_else(|error| panic!("read {}: {error}", catalog_path.display())); + serde_json::from_str(&catalog_text) + .unwrap_or_else(|error| panic!("parse {}: {error}", catalog_path.display())) +} + +#[test] +fn extension_metadata_distinguishes_sql_only_modules_and_dependencies() { + assert_eq!(Extension::Pgtap.native_module_file(), None); + assert_eq!(Extension::Pgtap.native_module_stem(), None); + assert_eq!(Extension::Hstore.native_module_stem(), Some("hstore")); + assert_eq!( + Extension::PgHashids.native_module_stem(), + Some("pg_hashids") + ); + assert_eq!(Extension::Pgcrypto.native_module_stem(), Some("pgcrypto")); + assert_eq!(Extension::Postgis.native_module_stem(), Some("postgis-3")); + assert_eq!(Extension::UuidOssp.native_module_stem(), Some("uuid-ossp")); + let postgis_data_files = NATIVE_EXTENSION_MANIFEST + .iter() + .find(|entry| entry.extension == Extension::Postgis) + .expect("PostGIS must have a native extension manifest row") + .data_files; + assert!(postgis_data_files.contains(&"contrib/postgis-3.6/postgis.sql")); + assert!(postgis_data_files.contains(&"contrib/postgis-3.6/spatial_ref_sys.sql")); + assert!(postgis_data_files.contains(&"proj/proj.db")); + assert_eq!(Extension::Graph.native_module_stem(), Some("graph")); + assert_eq!(Extension::PgSearch.native_module_stem(), Some("pg_search")); + assert_eq!(Extension::Earthdistance.dependencies(), &[Extension::Cube]); + assert!(Extension::ALL_PG18_SUPPORTED.contains(&Extension::Pgtap)); + assert!(Extension::EXTERNAL_PG18_SUPPORTED.contains(&Extension::Graph)); + assert!(Extension::EXTERNAL_PG18_SUPPORTED.contains(&Extension::PgSearch)); + assert!(!Extension::Graph.first_party_artifact()); + assert!(!Extension::PgSearch.first_party_artifact()); + assert!(matches!( + Extension::Graph.artifact_policy(), + ExtensionArtifactPolicy::External { + source_kind: ExtensionSourceKind::Pgrx, + redistribution: ExtensionRedistribution::Allowed, + requires_shared_preload: false, + .. + } + )); + assert!(matches!( + Extension::PgSearch.artifact_policy(), + ExtensionArtifactPolicy::External { + source_kind: ExtensionSourceKind::Pgrx, + redistribution: ExtensionRedistribution::RequiresCommercialLicense, + requires_shared_preload: true, + .. + } + )); + assert_eq!( + Extension::PgSearch.required_shared_preload_library(), + Some("pg_search") + ); + assert_eq!(Extension::Graph.required_shared_preload_library(), None); + assert_eq!( + required_shared_preload_libraries(&[Extension::PgSearch, Extension::PgSearch]), + vec!["pg_search"] + ); +} + +#[test] +fn native_extension_manifest_covers_every_supported_pg18_extension() { + let manifest_extensions = NATIVE_EXTENSION_MANIFEST + .iter() + .map(|entry| entry.extension) + .collect::>(); + assert_eq!(manifest_extensions, Extension::ALL_PG18_SUPPORTED); + + for entry in NATIVE_EXTENSION_MANIFEST { + assert_eq!(entry.pg_major, 18); + assert!(entry.pg18_supported); + assert_eq!(entry.sql_name, entry.extension.sql_name()); + assert_eq!(entry.creates_extension, entry.extension.creates_extension()); + assert_eq!(entry.dependencies, entry.extension.dependencies()); + assert_eq!( + entry.module_file_name(), + entry.extension.native_module_file() + ); + assert_eq!( + Extension::by_sql_name(entry.sql_name), + Some(entry.extension) + ); + assert_eq!( + entry.coverage.direct_c_abi, + ExtensionSmokeCoverage::InstallLoadRestartBackupRestore + ); + assert_eq!( + entry.coverage.broker, + ExtensionSmokeCoverage::InstallLoadRestartBackupRestore + ); + assert_eq!( + entry.coverage.server, + ExtensionSmokeCoverage::InstallLoadRestartBackupRestore + ); + assert_eq!( + entry.first_party_artifact(), + entry.extension.first_party_artifact() + ); + match entry.extension.native_module_stem() { + Some(stem) => { + assert_eq!(entry.module, ExtensionModuleAsset::NativeModule { stem }); + assert_eq!( + entry.mobile_static_link, + MobileStaticLinkStatus::PendingRegistry + ); + } + None => { + assert_eq!(entry.module, ExtensionModuleAsset::SqlOnly); + assert_eq!( + entry.mobile_static_link, + MobileStaticLinkStatus::NotRequiredSqlOnly + ); + } + } + if entry.creates_extension { + assert_eq!(entry.sql_assets, ExtensionSqlAsset::ControlAndSql); + assert_eq!(entry.smoke, ExtensionSmokePlan::CreateExtensionCascade); + let sql_name = if entry.sql_name == "uuid-ossp" { + "\"uuid-ossp\"" + } else { + entry.sql_name + }; + assert_eq!( + entry.smoke_sql(), + format!("CREATE EXTENSION {sql_name} CASCADE") + ); + } else { + assert_eq!(entry.sql_assets, ExtensionSqlAsset::LoadableModuleOnly); + assert_eq!(entry.smoke, ExtensionSmokePlan::LoadSharedLibrary); + assert_eq!(entry.smoke_sql(), format!("LOAD '{}'", entry.sql_name)); + } + } +} + +#[test] +fn native_release_ready_manifest_matches_generated_rust_metadata() { + let metadata = generated_rust_sdk_extension_metadata(); + let generated_rows = metadata["extensions"] + .as_array() + .expect("generated Rust SDK extension metadata must define extensions"); + assert_eq!(metadata["consumer"].as_str(), Some("rust")); + + let release_ready_sql_names = Extension::RELEASE_READY_PG18_SUPPORTED + .iter() + .map(|extension| extension.sql_name().to_owned()) + .collect::>(); + let generated_release_ready_sql_names = generated_rows + .iter() + .filter(|row| row["desktop-release-ready"].as_bool() == Some(true)) + .map(|row| { + row["sql-name"] + .as_str() + .expect("generated Rust SDK extension rows must define sql-name") + .to_owned() + }) + .collect::>(); + assert_eq!( + generated_release_ready_sql_names, release_ready_sql_names, + "Rust SDK release-ready extension set must come from generated metadata" + ); + + for row in generated_rows { + let sql_name = row["sql-name"] + .as_str() + .expect("generated Rust SDK extension rows must define sql-name"); + let extension = Extension::by_sql_name(sql_name) + .unwrap_or_else(|| panic!("generated Rust SDK metadata contains unknown {sql_name}")); + assert_eq!(row["postgres-major"].as_u64(), Some(18)); + assert_eq!( + row["creates-extension"].as_bool(), + Some(extension.creates_extension()) + ); + assert_eq!( + row["native-module-stem"].as_str(), + extension.native_module_stem() + ); + assert_eq!( + row["mobile-release-ready"].as_bool(), + Some(extension.mobile_release_ready()) + ); + assert_eq!( + row["desktop-release-ready"].as_bool(), + Some(extension.desktop_release_ready()) + ); + assert_eq!( + Extension::by_release_ready_sql_name(sql_name), + extension.desktop_release_ready().then_some(extension) + ); + assert!( + row["target-status"].is_object(), + "generated Rust SDK metadata must include target-status for {sql_name}" + ); + assert!( + row["support"].is_object(), + "generated Rust SDK metadata must include support for {sql_name}" + ); + let dependencies = extension + .dependencies() + .iter() + .map(|dependency| dependency.sql_name()) + .collect::>(); + assert_eq!( + row["selected-extension-dependencies"] + .as_array() + .expect( + "generated Rust SDK extension rows must define selected-extension-dependencies" + ) + .iter() + .map(|value| value.as_str().expect("dependency names must be strings")) + .collect::>(), + dependencies + ); + assert_eq!( + row["runtime-share-data-files"] + .as_array() + .expect("generated Rust SDK extension rows must define runtime-share-data-files") + .iter() + .map(|value| value.as_str().expect("data file paths must be strings")) + .collect::>(), + NATIVE_EXTENSION_MANIFEST + .iter() + .find(|entry| entry.extension == extension) + .expect("native manifest must include generated extension") + .data_files + .to_vec() + ); + } +} + +#[test] +fn native_extension_manifest_matches_build_required_artifacts() { + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh"); + let output = Command::new(&script) + .arg("--print-required-extension-artifacts") + .output() + .unwrap_or_else(|error| { + panic!( + "failed to run native extension artifact inventory {}: {error}", + script.display() + ) + }); + assert!( + output.status.success(), + "native extension artifact inventory failed with status {} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let mut actual_controls = BTreeSet::new(); + let mut actual_modules = BTreeSet::new(); + for line in String::from_utf8(output.stdout).unwrap().lines() { + let Some((kind, name)) = line.split_once(':') else { + panic!("native extension artifact inventory line must use :: {line}"); + }; + match kind { + "control" => { + actual_controls.insert(name.to_owned()); + } + "module" => { + actual_modules.insert(name.to_owned()); + } + _ => panic!("unknown native extension artifact kind '{kind}' in line {line}"), + } + } + + let expected_controls = NATIVE_EXTENSION_MANIFEST + .iter() + .filter(|entry| entry.first_party_artifact()) + .filter(|entry| entry.creates_extension) + .map(|entry| entry.sql_name.to_owned()) + .collect::>(); + let expected_modules = NATIVE_EXTENSION_MANIFEST + .iter() + .filter(|entry| entry.first_party_artifact()) + .filter_map(|entry| match entry.module { + ExtensionModuleAsset::NativeModule { stem } => Some(stem.to_owned()), + ExtensionModuleAsset::SqlOnly => None, + }) + .collect::>(); + + assert_eq!( + actual_controls, expected_controls, + "build-required extension control files must match NATIVE_EXTENSION_MANIFEST" + ); + assert_eq!( + actual_modules, expected_modules, + "build-required native extension modules must match NATIVE_EXTENSION_MANIFEST" + ); +} + +#[test] +fn release_ready_extension_catalog_is_exact_and_excludes_external_candidates() { + assert!(Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::Hstore)); + assert!(Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::PgHashids)); + assert!(Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::Pgcrypto)); + assert!(Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::UuidOssp)); + assert!(Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::Vector)); + assert!(Extension::FIRST_PARTY_PG18_SUPPORTED.contains(&Extension::Postgis)); + assert!(Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::Postgis)); + assert!(!Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::Graph)); + assert!(!Extension::RELEASE_READY_PG18_SUPPORTED.contains(&Extension::PgSearch)); + let expected_mobile_ready = BTreeSet::from([ + Extension::Amcheck, + Extension::AutoExplain, + Extension::Bloom, + Extension::BtreeGin, + Extension::BtreeGist, + Extension::Citext, + Extension::Cube, + Extension::DictInt, + Extension::DictXsyn, + Extension::Earthdistance, + Extension::FileFdw, + Extension::Fuzzystrmatch, + Extension::Hstore, + Extension::Intarray, + Extension::Isn, + Extension::Lo, + Extension::Ltree, + Extension::Pageinspect, + Extension::PgBuffercache, + Extension::PgFreespacemap, + Extension::PgHashids, + Extension::PgIvm, + Extension::Pgcrypto, + Extension::PgSurgery, + Extension::PgTrgm, + Extension::PgUuidv7, + Extension::PgVisibility, + Extension::PgWalinspect, + Extension::Pgtap, + Extension::Postgis, + Extension::PgTextsearch, + Extension::Seg, + Extension::Tablefunc, + Extension::Tcn, + Extension::TsmSystemRows, + Extension::TsmSystemTime, + Extension::Unaccent, + Extension::UuidOssp, + Extension::Vector, + ]); + let actual_mobile_ready = Extension::MOBILE_RELEASE_READY_PG18_SUPPORTED + .iter() + .copied() + .collect::>(); + assert_eq!(actual_mobile_ready, expected_mobile_ready); + for extension in expected_mobile_ready { + assert!(extension.mobile_release_ready()); + } + for extension in [Extension::Graph, Extension::PgSearch] { + assert!(!extension.mobile_release_ready()); + } + assert!(Extension::Hstore.requires_mobile_static_registry()); + assert!(Extension::UuidOssp.requires_mobile_static_registry()); + assert!(!Extension::Pgtap.requires_mobile_static_registry()); + + assert_eq!( + Extension::by_release_ready_sql_name("vector"), + Some(Extension::Vector) + ); + assert_eq!( + Extension::by_release_ready_sql_name("uuid-ossp"), + Some(Extension::UuidOssp) + ); + assert_eq!( + Extension::by_release_ready_sql_name("pg_search"), + None, + "ParadeDB is tracked as an external candidate but must not enter release packages implicitly" + ); + assert_eq!( + Extension::by_release_ready_sql_name("postgis"), + Some(Extension::Postgis) + ); + let metadata = generated_rust_sdk_extension_metadata(); + let metadata_rows = metadata["extensions"].as_array().unwrap(); + let postgis_metadata = metadata_rows + .iter() + .find(|row| row["sql-name"] == "postgis") + .expect("PostGIS metadata row must exist"); + assert_eq!( + postgis_metadata["desktop-release-ready"].as_bool(), + Some(true) + ); + assert_eq!( + postgis_metadata["mobile-release-ready"].as_bool(), + Some(true) + ); + assert_eq!( + postgis_metadata["target-status"]["wasix"].as_str(), + Some("supported") + ); + assert_eq!( + postgis_metadata["target-status"]["native"].as_str(), + Some("supported") + ); + assert_eq!(postgis_metadata["target-status"]["mobile"].as_str(), None); + assert_eq!( + postgis_metadata["support"]["mobile"]["android"].as_str(), + Some("supported") + ); + assert_eq!( + postgis_metadata["support"]["mobile"]["ios"].as_str(), + Some("supported") + ); + for alias in [ + "core", + "search", + "geo", + "vector-pack", + "vector_pack", + "vector+search", + ] { + assert_eq!( + Extension::by_release_ready_sql_name(alias), + None, + "{alias} must not resolve as an extension selection alias or multi-extension selector" + ); + } +} + +#[test] +fn target_specific_release_readiness_can_diverge_from_wasix_support() { + let catalog = generated_wasm_extension_catalog(); + let wasm_postgis = catalog["extensions"] + .as_array() + .expect("generated wasm extension catalog must have an extensions array") + .iter() + .find(|extension| extension["sql-name"].as_str() == Some("postgis")) + .expect("generated wasm extension catalog must contain PostGIS"); + + assert_eq!(wasm_postgis["promotion"]["stable"].as_bool(), Some(true)); + assert!(Extension::Postgis.first_party_artifact()); + assert!(Extension::Postgis.desktop_release_ready()); + assert!(Extension::Postgis.mobile_release_ready()); + assert_eq!( + Extension::by_release_ready_sql_name("postgis"), + Some(Extension::Postgis) + ); +} + +#[test] +fn pg18_blocked_extensions_remain_out_of_release_ready_catalog() { + let catalog = generated_wasm_extension_catalog(); + let extensions = catalog["extensions"] + .as_array() + .expect("generated wasm extension catalog must have an extensions array"); + + let blocked_ids = extensions + .iter() + .filter(|extension| { + !extension["promotion"]["stable"] + .as_bool() + .expect("generated wasm extension catalog rows must have promotion.stable") + }) + .map(|extension| { + extension["id"] + .as_str() + .expect("generated wasm extension catalog rows must have an id") + .to_owned() + }) + .collect::>(); + assert_eq!( + blocked_ids, + BTreeSet::from(["age".to_owned()]), + "every PG18.4 non-stable extension needs an explicit blocker before release-ready parity can move" + ); + + for (id, sql_name, requested, packaged, blocker) in + [("age", "age", false, false, "ExecInitExtraTupleSlot")] + { + let extension = extensions + .iter() + .find(|extension| extension["id"].as_str() == Some(id)) + .unwrap_or_else(|| panic!("generated wasm extension catalog must contain {id}")); + assert_eq!(extension["sql-name"].as_str(), Some(sql_name)); + assert_eq!( + extension["promotion"]["requested"].as_bool(), + Some(requested), + "{id} build request state must match its current PG18.4 blocker status" + ); + assert_eq!( + extension["promotion"]["stable"].as_bool(), + Some(false), + "{id} must not be treated as PG18.4 release-ready until its blocker is resolved" + ); + assert_eq!( + extension["promotion"]["packaged"].as_bool(), + Some(packaged), + "{id} packaged state must match the generated WASIX artifact evidence" + ); + assert!( + extension["promotion"]["blocker"] + .as_str() + .unwrap_or_default() + .contains(blocker), + "{id} must record the concrete PG18.4 blocker" + ); + assert_eq!(Extension::by_release_ready_sql_name(sql_name), None); + } +} + +#[test] +fn extension_catalog_cli_lists_release_ready_prebuilt_availability_without_native_env() { + let Some(resources_bin) = option_env!("CARGO_BIN_EXE_oliphaunt-resources") else { + eprintln!( + "skipping extension catalog CLI smoke: cargo did not provide runtime-resource generator binary path" + ); + return; + }; + + let output = Command::new(resources_bin) + .arg("--list-extensions") + .env_remove("LIBOLIPHAUNT_PATH") + .env_remove("OLIPHAUNT_POSTGRES") + .env_remove("OLIPHAUNT_INITDB") + .env_remove("OLIPHAUNT_INSTALL_DIR") + .output() + .unwrap(); + assert!( + output.status.success(), + "extension catalog CLI failed with status {} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.starts_with( + "sql_name\tpg_major\tcreates_extension\tnative_module_stem\tdependencies\tshared_preload\tdesktop_prebuilt\tmobile_prebuilt\tmobile_static_registry_required\tmobile_static_archive_targets\tdata_files\tartifact\n" + )); + let catalog_lines = stdout.lines().skip(1).collect::>(); + let catalog = catalog_lines + .iter() + .map(|line| { + let columns = line.split('\t').collect::>(); + assert_eq!( + columns.len(), + 12, + "catalog row must have 12 columns: {line}" + ); + (columns[0], columns) + }) + .collect::>(); + for expected in Extension::RELEASE_READY_PG18_SUPPORTED { + let row = catalog + .get(expected.sql_name()) + .unwrap_or_else(|| panic!("catalog must advertise {}", expected.sql_name())); + assert_eq!(row[1], "18"); + assert_eq!( + row[2], + if expected.creates_extension() { + "yes" + } else { + "no" + } + ); + assert_eq!(row[3], expected.native_module_stem().unwrap_or("-")); + assert_eq!(row[6], "yes"); + assert_eq!( + row[7], + if expected.mobile_release_ready() { + "yes" + } else { + "no" + } + ); + assert_eq!( + row[8], + if expected.requires_mobile_static_registry() { + "yes" + } else { + "no" + } + ); + assert_eq!(row[11], "first-party"); + } + for extension in [Extension::DictXsyn, Extension::Postgis, Extension::Unaccent] { + let row = catalog + .get(extension.sql_name()) + .unwrap_or_else(|| panic!("catalog must advertise {}", extension.sql_name())); + let expected = NATIVE_EXTENSION_MANIFEST + .iter() + .find(|entry| entry.extension == extension) + .expect("native manifest must include extension") + .data_files + .join(","); + assert!( + row[10] == expected, + "catalog data_files for {} must be {}, got {}", + extension.sql_name(), + expected, + row[10], + ); + } + let postgis = catalog + .get("postgis") + .expect("catalog must advertise PostGIS first-party inventory"); + assert_eq!(postgis[6], "yes"); + assert_eq!(postgis[7], "yes"); + assert!( + !stdout.contains("pg_search\t"), + "ParadeDB must remain an internal external candidate until release gates and redistribution are resolved" + ); +} + +#[test] +fn extension_selection_resolves_only_exact_extensions_and_required_dependencies() { + let config = Oliphaunt::builder() + .path("target/test-roots/native-direct") + .extension(Extension::Earthdistance) + .extension(Extension::Earthdistance) + .build_config() + .unwrap(); + + assert_eq!( + config.resolved_extensions().unwrap(), + vec![Extension::Cube, Extension::Earthdistance] + ); + assert_eq!( + resolve_extension_selection(&[Extension::Vector, Extension::Vector]).unwrap(), + vec![Extension::Vector] + ); +} diff --git a/src/sdks/rust/tests/sdk_native_smoke.rs b/src/sdks/rust/tests/sdk_native_smoke.rs new file mode 100644 index 00000000..d5040b30 --- /dev/null +++ b/src/sdks/rust/tests/sdk_native_smoke.rs @@ -0,0 +1,2045 @@ +use std::future::Future; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use oliphaunt::{ + BackupFormat, BackupRequest, Error, Extension, NativeBrokerRuntime, NativeRuntime, Oliphaunt, + OliphauntRuntime, QueryParam, RestoreRequest, Result, +}; + +#[cfg(unix)] +const DIRECT_CRASH_ACTION_ENV: &str = "OLIPHAUNT_DIRECT_CRASH_ACTION"; +#[cfg(unix)] +const DIRECT_CRASH_ROOT_ENV: &str = "OLIPHAUNT_DIRECT_CRASH_ROOT"; +#[cfg(unix)] +const DIRECT_CRASH_MARKER_ENV: &str = "OLIPHAUNT_DIRECT_CRASH_MARKER"; + +#[test] +fn native_liboliphaunt_runtime_select_one_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native Oliphaunt runtime smoke: native runtime env is incomplete"); + return; + } + + let root = unique_temp_root("oliphaunt-native-direct"); + let db = block_on( + Oliphaunt::builder() + .path(&root) + .extension(Extension::Vector) + .runtime(OliphauntRuntime::from_env()) + .open(), + ) + .unwrap(); + assert!(db.capabilities().query_cancel); + assert!(db.capabilities().backup_restore); + assert!(db.capabilities().simple_query); + let response = block_on(db.exec_protocol_raw(raw_query_message("SELECT 1 AS value"))).unwrap(); + let tags = raw_message_tags(response.as_bytes()); + assert!(tags.contains(&b'T'), "missing RowDescription: {tags:?}"); + assert!(tags.contains(&b'D'), "missing DataRow: {tags:?}"); + assert!(tags.contains(&b'C'), "missing CommandComplete: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + // liboliphaunt-doc-example:rust-basic-query + let typed = block_on(db.query("SELECT 1::text AS value, NULL::text AS empty")).unwrap(); + assert_eq!(typed.fields()[0].name, "value"); + assert_eq!(typed.fields()[0].type_oid, 25); + assert_eq!(typed.row_count(), 1); + assert_eq!(typed.command_tag(), Some("SELECT 1")); + assert_eq!(typed.get_text(0, "value").unwrap(), Some("1")); + assert_eq!(typed.get_text(0, "empty").unwrap(), None); + + let query_error = + block_on(db.query("SELECT * FROM liboliphaunt_query_missing_table")).unwrap_err(); + assert!( + query_error + .to_string() + .contains("liboliphaunt_query_missing_table"), + "typed query error lost PostgreSQL detail: {query_error}" + ); + let recovered = block_on(db.query("SELECT 'ok'::text AS recovered")).unwrap(); + assert_eq!(recovered.get_text(0, "recovered").unwrap(), Some("ok")); + + let parameterized = block_on(db.query_params( + "SELECT ($1::int4 + $2::int4)::text AS sum, $3::text AS maybe_null, $4::bool::text AS flag", + [ + QueryParam::from(2_i32), + QueryParam::from(40_i32), + QueryParam::Null, + QueryParam::from(true), + ], + )) + .unwrap(); + assert_eq!(parameterized.get_text(0, "sum").unwrap(), Some("42")); + assert_eq!(parameterized.get_text(0, "maybe_null").unwrap(), None); + assert_eq!(parameterized.get_text(0, "flag").unwrap(), Some("true")); + + block_on(db.execute("CREATE TABLE tx_drop_smoke(value integer)")).unwrap(); + { + let tx = block_on(db.transaction()).unwrap(); + block_on(tx.query_params("INSERT INTO tx_drop_smoke VALUES ($1)", [7_i32])).unwrap(); + } + let rolled_back = + block_on(db.query("SELECT count(*)::text AS count FROM tx_drop_smoke")).unwrap(); + assert_eq!(rolled_back.get_text(0, "count").unwrap(), Some("0")); + + let absent_extension = + block_on(db.exec_protocol_raw(raw_query_message("CREATE EXTENSION hstore"))).unwrap(); + let tags = raw_message_tags(absent_extension.as_bytes()); + assert!( + tags.contains(&b'E'), + "unselected extension unexpectedly succeeded: {tags:?}" + ); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let vector_response = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE EXTENSION vector; SELECT '[1,2,3]'::vector", + ))) + .unwrap(); + let tags = raw_message_tags(vector_response.as_bytes()); + assert!(tags.contains(&b'C'), "missing CommandComplete: {tags:?}"); + assert!(tags.contains(&b'D'), "missing vector DataRow: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + assert!(db.capabilities().protocol_stream); + + let streamed = Arc::new(Mutex::new(Vec::new())); + let chunks = Arc::new(Mutex::new(0usize)); + let streamed_for_callback = Arc::clone(&streamed); + let chunks_for_callback = Arc::clone(&chunks); + block_on(db.exec_protocol_raw_stream( + raw_query_message("SELECT repeat('x', 65536) AS value"), + move |chunk| { + *chunks_for_callback.lock().unwrap() += 1; + streamed_for_callback + .lock() + .unwrap() + .extend_from_slice(chunk); + Ok(()) + }, + )) + .unwrap(); + + let bytes = streamed.lock().unwrap(); + let tags = raw_message_tags(&bytes); + assert!(*chunks.lock().unwrap() >= 1); + assert!(tags.contains(&b'T'), "missing RowDescription: {tags:?}"); + assert!(tags.contains(&b'D'), "missing DataRow: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + drop(bytes); + + assert_streaming_cancel_recovers(&db, 15); + assert_repeated_cancel_recovers(&db, 12); + + let archive = block_on(db.backup(BackupRequest::physical_archive())).unwrap(); + assert_physical_archive(&archive, "direct"); + + assert_close_waits_for_active_query(&db); + + let reopened = block_on( + Oliphaunt::builder() + .path(&root) + .extension(Extension::Vector) + .runtime(OliphauntRuntime::from_env()) + .open(), + ) + .unwrap(); + let reopened_value = + block_on(reopened.query("SELECT 42::text AS reopened_after_close")).unwrap(); + assert_eq!( + reopened_value.get_text(0, "reopened_after_close").unwrap(), + Some("42") + ); + block_on(reopened.close()).unwrap(); +} + +#[cfg(unix)] +#[test] +fn native_direct_crash_consistency_survives_process_death_when_env_is_available() { + if let Some(result) = run_direct_crash_child_from_env() { + result.unwrap(); + return; + } + if native_runtime_env_is_unavailable() { + eprintln!( + "skipping native direct crash-consistency smoke: no native library env var is set" + ); + return; + } + + let root = unique_temp_root("oliphaunt-direct-crash-consistency"); + let committed_marker = root.with_extension("committed-ready"); + let uncommitted_marker = root.with_extension("uncommitted-ready"); + + run_direct_crash_child_until_marker(DirectCrashAction::CommittedWait, &root, &committed_marker); + run_direct_crash_child(DirectCrashAction::VerifyCommitted, &root); + run_direct_crash_child_until_marker( + DirectCrashAction::UncommittedWait, + &root, + &uncommitted_marker, + ); + run_direct_crash_child(DirectCrashAction::VerifyUncommitted, &root); + + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_file(committed_marker); + let _ = std::fs::remove_file(uncommitted_marker); +} + +#[test] +fn native_runtime_resources_generator_exports_platform_sdk_bundle_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!( + "skipping native runtime resource generator smoke: no native library env var is set" + ); + return; + } + let Some(resources_bin) = option_env!("CARGO_BIN_EXE_oliphaunt-resources") else { + eprintln!( + "skipping native runtime resource generator smoke: cargo did not provide runtime-resource generator binary path" + ); + return; + }; + + let root = unique_temp_root("oliphaunt-runtime-resources"); + let output_dir = root.join("bundle"); + let output = Command::new(resources_bin) + .arg("--output") + .arg(&output_dir) + .arg("--extension") + .arg("vector") + .arg("--force") + .output() + .unwrap(); + assert!( + output.status.success(), + "runtime resource generator failed with status {} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("packageSizeReport=")); + assert!(stdout.contains("selectedExtensionBytes=")); + assert!(stdout.contains("extensionBytes=vector:")); + + let resource_root = output_dir.join("oliphaunt"); + let runtime_manifest = + std::fs::read_to_string(resource_root.join("runtime/manifest.properties")).unwrap(); + assert!(runtime_manifest.contains("schema=oliphaunt-runtime-resources-v1")); + assert!(runtime_manifest.contains("layout=postgres-runtime-files-v1")); + assert!(runtime_manifest.contains("mode=native-direct")); + assert!(runtime_manifest.contains("extensions=vector")); + assert!(runtime_manifest.contains("sharedPreloadLibraries=")); + assert!(runtime_manifest.contains("mobileStaticRegistryState=pending")); + assert!(runtime_manifest.contains("mobileStaticRegistryPending=vector")); + assert!(runtime_manifest.contains("nativeModuleStems=vector")); + assert!(runtime_manifest.contains("mobileStaticRegistrySource=")); + assert!( + resource_root + .join("runtime/files/share/postgresql/extension/plpgsql.control") + .is_file() + ); + assert!( + resource_root + .join("runtime/files/share/postgresql/extension/vector.control") + .is_file() + ); + assert!( + !resource_root + .join("runtime/files/share/postgresql/extension/hstore.control") + .exists(), + "unselected extension assets leaked into vector-only runtime resources" + ); + let runtime_resource_size_report = + std::fs::read_to_string(resource_root.join("package-size.tsv")) + .expect("runtime resources should include package-size.tsv"); + assert!(runtime_resource_size_report.contains("kind\tid\textensions\tfiles\tbytes\n")); + assert!(runtime_resource_size_report.contains("extensions\tselected\t")); + assert!(runtime_resource_size_report.contains("extension\tvector\t-\t")); + + let template_manifest = + std::fs::read_to_string(resource_root.join("template-pgdata/manifest.properties")).unwrap(); + assert!(template_manifest.contains("schema=oliphaunt-runtime-resources-v1")); + assert!(template_manifest.contains("layout=postgres-template-pgdata-v1")); + assert!(template_manifest.contains("extensions=")); + assert!(template_manifest.contains("sharedPreloadLibraries=")); + assert!(template_manifest.contains("mobileStaticRegistryState=not-required")); + assert!( + resource_root + .join("template-pgdata/files/PG_VERSION") + .is_file() + ); + + let mobile_ready_output_dir = root.join("mobile-ready-bundle"); + let mobile_ready = Command::new(resources_bin) + .arg("--output") + .arg(&mobile_ready_output_dir) + .arg("--extension") + .arg("vector") + .arg("--mobile-static-module") + .arg("vector") + .arg("--require-mobile-static-registry") + .output() + .unwrap(); + assert!( + mobile_ready.status.success(), + "mobile-ready runtime resource generator failed with status {} stdout={} stderr={}", + mobile_ready.status, + String::from_utf8_lossy(&mobile_ready.stdout), + String::from_utf8_lossy(&mobile_ready.stderr) + ); + let mobile_ready_manifest = std::fs::read_to_string( + mobile_ready_output_dir.join("oliphaunt/runtime/manifest.properties"), + ) + .unwrap(); + assert!(mobile_ready_manifest.contains("mobileStaticRegistryState=complete")); + assert!(mobile_ready_manifest.contains("mobileStaticRegistryRegistered=vector")); + assert!(mobile_ready_manifest.contains("mobileStaticRegistryPending=")); + assert!( + mobile_ready_manifest + .contains("mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c") + ); + let static_registry_manifest = std::fs::read_to_string( + mobile_ready_output_dir.join("oliphaunt/static-registry/manifest.properties"), + ) + .unwrap(); + assert!(static_registry_manifest.contains("packageLayout=oliphaunt-static-registry-v1")); + assert!(static_registry_manifest.contains("state=complete")); + assert!( + static_registry_manifest.contains("module.vector.symbolPrefix=oliphaunt_static_vector") + ); + let static_registry_source = std::fs::read_to_string( + mobile_ready_output_dir.join("oliphaunt/static-registry/oliphaunt_static_registry.c"), + ) + .unwrap(); + assert!(static_registry_source.contains("liboliphaunt_selected_static_extensions")); + assert!(static_registry_source.contains("oliphaunt_static_vector_Pg_magic_func")); + assert!( + static_registry_source + .contains("extern const void *oliphaunt_static_vector_Pg_magic_func(void);") + ); + assert!(static_registry_source.contains("OLIPHAUNT_STATIC_OPTIONAL")); + assert!(!static_registry_source.contains(&format!("OLIPHAUNT_STATIC_{}", "WEAK"))); + assert!(static_registry_source.contains("\"vector_in\"")); + assert!(static_registry_source.contains("\"pg_finfo_vector_in\"")); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_broker_runtime_select_one_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native broker smoke: no native library env var is set"); + return; + } + let Some(broker) = option_env!("CARGO_BIN_EXE_oliphaunt-broker") else { + eprintln!("skipping native broker smoke: cargo did not provide broker binary path"); + return; + }; + + let db = block_on( + Oliphaunt::builder() + .temporary() + .native_broker() + .extension(Extension::Vector) + .broker_executable(broker) + .open(), + ) + .unwrap(); + assert!(db.capabilities().process_isolated); + assert!(db.capabilities().protocol_stream); + assert!(db.capabilities().query_cancel); + assert!(db.capabilities().backup_restore); + assert!(db.capabilities().simple_query); + let response = block_on(db.exec_protocol_raw(raw_query_message("SELECT 1 AS value"))).unwrap(); + let tags = raw_message_tags(response.as_bytes()); + assert!(tags.contains(&b'D'), "missing DataRow: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let absent_extension = + block_on(db.exec_protocol_raw(raw_query_message("CREATE EXTENSION hstore"))).unwrap(); + let tags = raw_message_tags(absent_extension.as_bytes()); + assert!( + tags.contains(&b'E'), + "broker allowed an unselected extension: {tags:?}" + ); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let vector_response = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE EXTENSION vector; SELECT '[4,5,6]'::vector", + ))) + .unwrap(); + let tags = raw_message_tags(vector_response.as_bytes()); + assert!(tags.contains(&b'D'), "missing vector DataRow: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let streamed = Arc::new(Mutex::new(Vec::new())); + let streamed_for_callback = Arc::clone(&streamed); + block_on(db.exec_protocol_raw_stream( + raw_query_message("SELECT repeat('y', 65536) AS value"), + move |chunk| { + streamed_for_callback + .lock() + .unwrap() + .extend_from_slice(chunk); + Ok(()) + }, + )) + .unwrap(); + let tags = raw_message_tags(&streamed.lock().unwrap()); + assert!(tags.contains(&b'D'), "missing streamed DataRow: {tags:?}"); + assert!( + tags.contains(&b'Z'), + "missing streamed ReadyForQuery: {tags:?}" + ); + + assert_broker_cancel_reuses_helper(&db, 13); + + let archive = block_on(db.backup(BackupRequest::physical_archive())).unwrap(); + assert_physical_archive(&archive, "broker"); + + assert_close_waits_for_active_query(&db); +} + +#[test] +fn native_broker_existing_only_rejects_unbootstrapped_roots_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native broker existing-only smoke: no native library env var is set"); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!( + "skipping native broker existing-only smoke: cargo did not provide broker binary path" + ); + return; + }; + + let root = unique_temp_root("oliphaunt-broker-existing-only"); + let error = expect_open_error(block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .existing_only() + .open(), + )); + assert!( + error.to_string().contains("has not been bootstrapped"), + "unexpected broker existing-only error: {error}" + ); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_broker_rejects_incompatible_root_manifest_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native broker root-manifest smoke: no native library env var is set"); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!( + "skipping native broker root-manifest smoke: cargo did not provide broker binary path" + ); + return; + }; + + let root = unique_temp_root("oliphaunt-broker-root-manifest"); + let db = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .open(), + ) + .unwrap(); + block_on(db.close()).unwrap(); + + std::fs::write( + root.join("manifest.properties"), + b"layout=oliphaunt-root-v1\nproduct=oliphaunt\npostgresMajor=17\npgdata=pgdata\npgdataVersion=18\n", + ) + .unwrap(); + let error = expect_open_error(block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .existing_only() + .open(), + )); + let message = error.to_string(); + assert!( + message.contains("native root manifest") + && message.contains("postgresMajor='17', expected '18'"), + "unexpected broker root-manifest error: {error}" + ); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_broker_rejects_pgdata_version_manifest_mismatch_and_recovers_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!( + "skipping native broker PGDATA manifest-version smoke: no native library env var is set" + ); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!( + "skipping native broker PGDATA manifest-version smoke: cargo did not provide broker binary path" + ); + return; + }; + + let root = unique_temp_root("oliphaunt-broker-pgdata-version-manifest"); + let db = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .open(), + ) + .unwrap(); + let seed = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE TABLE metadata_recovery(value integer); INSERT INTO metadata_recovery VALUES (88)", + ))) + .unwrap(); + assert!( + raw_message_tags(seed.as_bytes()).contains(&b'C'), + "missing metadata-recovery seed command" + ); + block_on(db.close()).unwrap(); + + let manifest_path = root.join("manifest.properties"); + let valid_manifest = std::fs::read_to_string(&manifest_path).unwrap(); + assert!( + valid_manifest.contains("pgdataVersion=18\n"), + "expected bootstrapped root manifest to record PGDATA 18:\n{valid_manifest}" + ); + std::fs::write( + &manifest_path, + valid_manifest.replace("pgdataVersion=18\n", "pgdataVersion=17\n"), + ) + .unwrap(); + + let error = expect_open_error(block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .existing_only() + .open(), + )); + let message = error.to_string(); + assert!( + message.contains("native root manifest") + && message.contains("declares PGDATA version '17'") + && message.contains("contains PostgreSQL 18"), + "unexpected broker PGDATA manifest-version error: {error}" + ); + + std::fs::write(&manifest_path, valid_manifest).unwrap(); + let recovered = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .existing_only() + .open(), + ) + .unwrap(); + let response = block_on(recovered.exec_protocol_raw(raw_query_message( + "SELECT value::text FROM metadata_recovery", + ))) + .unwrap(); + assert_eq!(first_data_row_text_values(response.as_bytes()), vec!["88"]); + block_on(recovered.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_broker_reopens_persistent_root_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native broker restart smoke: no native library env var is set"); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!("skipping native broker restart smoke: cargo did not provide broker binary path"); + return; + }; + + let root = unique_temp_root("oliphaunt-broker-restart"); + let db = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .open(), + ) + .unwrap(); + let seed = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE TABLE restart_smoke(value integer); INSERT INTO restart_smoke VALUES (91)", + ))) + .unwrap(); + let tags = raw_message_tags(seed.as_bytes()); + assert!( + tags.contains(&b'C'), + "missing restart seed command: {tags:?}" + ); + block_on(db.close()).unwrap(); + + let reopened = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .existing_only() + .open(), + ) + .unwrap(); + let response = block_on( + reopened.exec_protocol_raw(raw_query_message("SELECT value::text FROM restart_smoke")), + ) + .unwrap(); + assert_eq!(first_data_row_text_values(response.as_bytes()), vec!["91"]); + block_on(reopened.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn native_broker_relaunches_helper_after_crash_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native broker crash-recovery smoke: no native library env var is set"); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!( + "skipping native broker crash-recovery smoke: cargo did not provide broker binary path" + ); + return; + }; + + let root = unique_temp_root("oliphaunt-broker-crash-recovery"); + let db = block_on( + Oliphaunt::builder() + .path(&root) + .native_broker() + .broker_executable(broker) + .open(), + ) + .unwrap(); + let seed = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE TABLE crash_recovery(value integer); \ + INSERT INTO crash_recovery VALUES (97); \ + SELECT pg_backend_pid()::text", + ))) + .unwrap(); + let values = first_data_row_text_values(seed.as_bytes()); + let helper_pid = values + .last() + .expect("pg_backend_pid result was not returned") + .clone(); + let status = Command::new("kill") + .arg("-KILL") + .arg(&helper_pid) + .status() + .unwrap(); + assert!( + status.success(), + "failed to kill broker helper {helper_pid}" + ); + thread::sleep(Duration::from_millis(200)); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match block_on( + db.exec_protocol_raw(raw_query_message("SELECT value::text FROM crash_recovery")), + ) { + Ok(response) => { + assert_eq!(first_data_row_text_values(response.as_bytes()), vec!["97"]); + break; + } + Err(error) if Instant::now() < deadline => { + eprintln!("waiting for broker relaunch after helper crash: {error}"); + thread::sleep(Duration::from_millis(100)); + } + Err(error) => panic!("broker did not recover after helper crash: {error}"), + } + } + + let pid_response = + block_on(db.exec_protocol_raw(raw_query_message("SELECT pg_backend_pid()::text"))).unwrap(); + let helper_pid = first_data_row_text_values(pid_response.as_bytes()) + .last() + .expect("restarted pg_backend_pid result was not returned") + .clone(); + let in_flight = db.clone(); + let worker = thread::spawn(move || { + block_on(in_flight.exec_protocol_raw(raw_query_message( + "SELECT pg_sleep(5) AS should_fail_with_helper", + ))) + }); + thread::sleep(Duration::from_millis(200)); + let status = Command::new("kill") + .arg("-KILL") + .arg(&helper_pid) + .status() + .unwrap(); + assert!( + status.success(), + "failed to kill restarted broker helper {helper_pid}" + ); + match worker.join().unwrap() { + Ok(response) => panic!( + "in-flight broker request unexpectedly succeeded after helper kill: {:?}", + raw_message_tags(response.as_bytes()) + ), + Err(error) => assert!( + error.to_string().contains("broker"), + "unexpected in-flight crash error: {error}" + ), + } + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match block_on( + db.exec_protocol_raw(raw_query_message("SELECT value::text FROM crash_recovery")), + ) { + Ok(response) => { + assert_eq!(first_data_row_text_values(response.as_bytes()), vec!["97"]); + break; + } + Err(error) if Instant::now() < deadline => { + eprintln!("waiting for broker relaunch after in-flight helper crash: {error}"); + thread::sleep(Duration::from_millis(100)); + } + Err(error) => panic!("broker did not recover after in-flight helper crash: {error}"), + } + } + + block_on(db.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_broker_shared_runtime_admits_multiple_roots_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native broker multi-root smoke: no native library env var is set"); + return; + } + let Some(broker) = native_broker_executable() else { + eprintln!( + "skipping native broker multi-root smoke: cargo did not provide broker binary path" + ); + return; + }; + + let runtime: Arc = + Arc::new(NativeBrokerRuntime::from_executable(broker).with_max_roots(2)); + let root_a = unique_temp_root("oliphaunt-broker-multi-a"); + let root_b = unique_temp_root("oliphaunt-broker-multi-b"); + let root_c = unique_temp_root("oliphaunt-broker-multi-c"); + + let db_a = block_on( + Oliphaunt::builder() + .path(&root_a) + .native_broker() + .broker_max_roots(2) + .runtime_arc(Arc::clone(&runtime)) + .open(), + ) + .unwrap(); + let db_b = block_on( + Oliphaunt::builder() + .path(&root_b) + .native_broker() + .broker_max_roots(2) + .runtime_arc(Arc::clone(&runtime)) + .open(), + ) + .unwrap(); + + assert!(db_a.capabilities().multi_root); + assert!(db_b.capabilities().multi_root); + assert_eq!( + first_data_row_text_values( + block_on(db_a.exec_protocol_raw(raw_query_message("SELECT 21::text"))) + .unwrap() + .as_bytes() + ), + vec!["21"] + ); + assert_eq!( + first_data_row_text_values( + block_on(db_b.exec_protocol_raw(raw_query_message("SELECT 22::text"))) + .unwrap() + .as_bytes() + ), + vec!["22"] + ); + + let capacity_error = expect_open_error(block_on( + Oliphaunt::builder() + .path(&root_c) + .native_broker() + .broker_max_roots(2) + .runtime_arc(Arc::clone(&runtime)) + .open(), + )); + assert!( + capacity_error.to_string().contains("configured capacity 2"), + "unexpected broker capacity error: {capacity_error}" + ); + + block_on(db_a.close()).unwrap(); + let db_c = block_on( + Oliphaunt::builder() + .path(&root_c) + .native_broker() + .broker_max_roots(2) + .runtime_arc(runtime) + .open(), + ) + .unwrap(); + assert_eq!( + first_data_row_text_values( + block_on(db_c.exec_protocol_raw(raw_query_message("SELECT 23::text"))) + .unwrap() + .as_bytes() + ), + vec!["23"] + ); + + block_on(db_b.close()).unwrap(); + block_on(db_c.close()).unwrap(); + let _ = std::fs::remove_dir_all(root_a); + let _ = std::fs::remove_dir_all(root_b); + let _ = std::fs::remove_dir_all(root_c); +} + +#[test] +fn native_server_runtime_select_one_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server smoke: no native library env var is set"); + return; + } + + let db = block_on( + Oliphaunt::builder() + .temporary() + .native_server() + .extension(Extension::Vector) + .open(), + ) + .unwrap(); + assert!(db.capabilities().connection_strings); + assert!(db.capabilities().query_cancel); + assert!(db.capabilities().backup_restore); + assert!(db.capabilities().simple_query); + let connection_string = db.connection_string().unwrap(); + assert_eq!( + db.capabilities().connection_string.as_deref(), + Some(connection_string.as_str()) + ); + let response = block_on(db.exec_protocol_raw(raw_query_message("SELECT 1 AS value"))).unwrap(); + let tags = raw_message_tags(response.as_bytes()); + assert!(tags.contains(&b'D'), "missing DataRow: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let error_response = block_on(db.exec_protocol_raw(raw_query_message( + "SELECT * FROM liboliphaunt_server_missing_table", + ))) + .unwrap(); + let tags = raw_message_tags(error_response.as_bytes()); + assert!(tags.contains(&b'E'), "missing ErrorResponse: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let absent_extension = + block_on(db.exec_protocol_raw(raw_query_message("CREATE EXTENSION hstore"))).unwrap(); + let tags = raw_message_tags(absent_extension.as_bytes()); + assert!( + tags.contains(&b'E'), + "server allowed an unselected extension: {tags:?}" + ); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let vector_response = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE EXTENSION vector; SELECT '[7,8,9]'::vector", + ))) + .unwrap(); + let tags = raw_message_tags(vector_response.as_bytes()); + assert!(tags.contains(&b'D'), "missing vector DataRow: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + // liboliphaunt-doc-example:rust-backup-restore + let backup_seed = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE TABLE backup_smoke(value integer); \ + INSERT INTO backup_smoke VALUES (42); \ + CREATE TABLE backup_vector(value vector(3)); \ + INSERT INTO backup_vector VALUES ('[1,2,3]')", + ))) + .unwrap(); + let tags = raw_message_tags(backup_seed.as_bytes()); + assert!( + tags.contains(&b'C'), + "missing backup seed CommandComplete: {tags:?}" + ); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + + let streamed = Arc::new(Mutex::new(Vec::new())); + let streamed_for_callback = Arc::clone(&streamed); + block_on(db.exec_protocol_raw_stream( + raw_query_message("SELECT repeat('z', 65536) AS value"), + move |chunk| { + streamed_for_callback + .lock() + .unwrap() + .extend_from_slice(chunk); + Ok(()) + }, + )) + .unwrap(); + let tags = raw_message_tags(&streamed.lock().unwrap()); + assert!(tags.contains(&b'D'), "missing streamed DataRow: {tags:?}"); + assert!( + tags.contains(&b'Z'), + "missing streamed ReadyForQuery: {tags:?}" + ); + + assert_large_server_raw_pipeline_recovers(&db); + + assert_repeated_cancel_recovers(&db, 14); + assert_eq!( + db.connection_string().as_deref(), + Some(connection_string.as_str()), + "server connection string changed after protocol, streaming, and cancel work" + ); + + let sql = block_on(db.backup(BackupRequest::sql())).unwrap(); + assert_eq!(sql.format, BackupFormat::Sql); + let sql = String::from_utf8(sql.bytes).unwrap(); + assert!( + sql.contains("PostgreSQL database dump"), + "server SQL backup did not look like pg_dump output" + ); + + let archive = block_on(db.backup(BackupRequest::physical_archive())).unwrap(); + assert_physical_archive(&archive, "server"); + let restored_root = unique_temp_root("oliphaunt-restore"); + block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + &restored_root, + archive, + ))) + .unwrap(); + + assert_close_waits_for_active_query(&db); + + let restored = block_on( + Oliphaunt::builder() + .path(&restored_root) + .native_server() + .extension(Extension::Vector) + .existing_only() + .open(), + ) + .unwrap(); + let restored_response = + block_on(restored.exec_protocol_raw(raw_query_message("SELECT value FROM backup_smoke"))) + .unwrap(); + let tags = raw_message_tags(restored_response.as_bytes()); + assert!(tags.contains(&b'D'), "missing restored DataRow: {tags:?}"); + assert!( + tags.contains(&b'Z'), + "missing restored ReadyForQuery: {tags:?}" + ); + let restored_vector_response = block_on( + restored.exec_protocol_raw(raw_query_message("SELECT value::text FROM backup_vector")), + ) + .unwrap(); + let tags = raw_message_tags(restored_vector_response.as_bytes()); + assert!( + tags.contains(&b'D'), + "missing restored vector DataRow: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "missing restored vector ReadyForQuery: {tags:?}" + ); + block_on(restored.close()).unwrap(); + let _ = std::fs::remove_dir_all(restored_root); +} + +#[test] +fn native_server_reopens_persistent_root_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server restart smoke: no native library env var is set"); + return; + } + + let root = unique_temp_root("oliphaunt-server-restart"); + let db = block_on(Oliphaunt::builder().path(&root).native_server().open()).unwrap(); + let seed = block_on(db.exec_protocol_raw(raw_query_message( + "CREATE TABLE restart_smoke(value integer); INSERT INTO restart_smoke VALUES (92)", + ))) + .unwrap(); + let tags = raw_message_tags(seed.as_bytes()); + assert!( + tags.contains(&b'C'), + "missing restart seed command: {tags:?}" + ); + block_on(db.close()).unwrap(); + + let reopened = block_on( + Oliphaunt::builder() + .path(&root) + .native_server() + .existing_only() + .open(), + ) + .unwrap(); + let response = block_on( + reopened.exec_protocol_raw(raw_query_message("SELECT value::text FROM restart_smoke")), + ) + .unwrap(); + assert_eq!(first_data_row_text_values(response.as_bytes()), vec!["92"]); + block_on(reopened.close()).unwrap(); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn native_server_accepts_independent_tokio_postgres_clients_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server client smoke: no native library env var is set"); + return; + } + + let db = block_on( + Oliphaunt::builder() + .temporary() + .native_server() + .max_client_sessions(4) + .open(), + ) + .unwrap(); + let connection_string = db.connection_string().unwrap(); + assert_eq!( + db.capabilities().connection_string.as_deref(), + Some(connection_string.as_str()) + ); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let (client_a, connection_a) = + tokio_postgres::connect(&connection_string, tokio_postgres::NoTls) + .await + .unwrap(); + let (client_b, connection_b) = + tokio_postgres::connect(&connection_string, tokio_postgres::NoTls) + .await + .unwrap(); + let connection_a = tokio::spawn(connection_a); + let connection_b = tokio::spawn(connection_b); + + client_a + .batch_execute( + "CREATE TABLE independent_clients(value integer); \ + INSERT INTO independent_clients VALUES (7)", + ) + .await + .unwrap(); + let row = client_b + .query_one("SELECT value FROM independent_clients", &[]) + .await + .unwrap(); + let value: i32 = row.get(0); + assert_eq!(value, 7); + + let cancel_token = client_b.cancel_token(); + let mut sleep = Box::pin(client_b.batch_execute("SELECT pg_sleep(5)")); + match tokio::time::timeout(Duration::from_millis(100), sleep.as_mut()).await { + Err(_) => {} + Ok(Ok(())) => panic!("external server client sleep query finished before cancel"), + Ok(Err(error)) => panic!("external server client failed before cancel: {error}"), + } + cancel_token + .cancel_query(tokio_postgres::NoTls) + .await + .unwrap(); + let cancelled = sleep.await.unwrap_err(); + assert_eq!( + cancelled.code(), + Some(&tokio_postgres::error::SqlState::QUERY_CANCELED), + "external server client did not receive PostgreSQL query-canceled SQLSTATE: {cancelled}" + ); + let row = client_b.query_one("SELECT 8", &[]).await.unwrap(); + let value: i32 = row.get(0); + assert_eq!(value, 8); + + drop(client_a); + drop(client_b); + connection_a.await.unwrap().unwrap(); + connection_b.await.unwrap().unwrap(); + }); + assert_eq!( + db.connection_string().as_deref(), + Some(connection_string.as_str()), + "server connection string changed after independent client use" + ); + + block_on(db.close()).unwrap(); +} + +#[test] +fn native_server_close_stops_active_external_clients_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!( + "skipping native server active-client shutdown smoke: no native library env var is set" + ); + return; + } + + let db = block_on(Oliphaunt::builder().temporary().native_server().open()).unwrap(); + let connection_string = db.connection_string().unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let (client, connection) = + tokio_postgres::connect(&connection_string, tokio_postgres::NoTls) + .await + .unwrap(); + let connection = tokio::spawn(connection); + + let mut sleep = Box::pin(client.batch_execute("SELECT pg_sleep(30)")); + match tokio::time::timeout(Duration::from_millis(100), sleep.as_mut()).await { + Err(_) => {} + Ok(Ok(())) => panic!("external server client sleep query finished before close"), + Ok(Err(error)) => panic!("external server client failed before close: {error}"), + } + + db.close().await.unwrap(); + let stopped = sleep.await.unwrap_err(); + assert!( + stopped.is_closed() + || stopped + .code() + .is_some_and(|code| code == &tokio_postgres::error::SqlState::ADMIN_SHUTDOWN), + "active external client saw unexpected shutdown error: {stopped}" + ); + assert!( + client.simple_query("SELECT 1").await.is_err(), + "external client query succeeded after owned server close" + ); + drop(client); + let _ = connection.await; + }); +} + +#[test] +fn native_server_accepts_sqlx_pool_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server sqlx smoke: no native library env var is set"); + return; + } + + let db = block_on( + Oliphaunt::builder() + .temporary() + .native_server() + .max_client_sessions(4) + .open(), + ) + .unwrap(); + let connection_string = db.connection_string().unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&connection_string) + .await + .unwrap(); + sqlx::query("CREATE TABLE sqlx_pool_smoke(value integer)") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO sqlx_pool_smoke VALUES ($1)") + .bind(93_i32) + .execute(&pool) + .await + .unwrap(); + let (value,): (i32,) = sqlx::query_as("SELECT value FROM sqlx_pool_smoke") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(value, 93); + db.close().await.unwrap(); + let rejected = sqlx::query("SELECT 1").execute(&pool).await.unwrap_err(); + assert!( + rejected.to_string().contains("closed") + || rejected.to_string().contains("terminat") + || rejected.to_string().contains("connection") + || rejected.to_string().contains("pool"), + "SQLx pool returned unexpected error after server close: {rejected}" + ); + pool.close().await; + }); +} + +#[test] +fn native_server_accepts_tokio_postgres_prepared_and_pipelined_clients_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server tokio-postgres smoke: no native library env var is set"); + return; + } + + let db = block_on( + Oliphaunt::builder() + .temporary() + .native_server() + .max_client_sessions(4) + .open(), + ) + .unwrap(); + let connection_string = db.connection_string().unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let (client, connection) = + tokio_postgres::connect(&connection_string, tokio_postgres::NoTls) + .await + .unwrap(); + let connection = tokio::spawn(connection); + + client + .batch_execute("CREATE TABLE tokio_client_smoke(id integer PRIMARY KEY, value integer)") + .await + .unwrap(); + let insert = client + .prepare("INSERT INTO tokio_client_smoke VALUES ($1, $2)") + .await + .unwrap(); + let pending = (1_i32..=16) + .map(|value| { + let client = &client; + let insert = &insert; + async move { + let doubled = value * 10; + client.execute(insert, &[&value, &doubled]).await + } + }) + .collect::>(); + let inserted = futures_util::future::try_join_all(pending).await.unwrap(); + assert_eq!(inserted, vec![1_u64; 16]); + + let select = client + .prepare( + "SELECT count(*)::int4, sum(value)::int4 FROM tokio_client_smoke WHERE id >= $1", + ) + .await + .unwrap(); + let row = client.query_one(&select, &[&4_i32]).await.unwrap(); + assert_eq!(row.get::<_, i32>(0), 13); + assert_eq!(row.get::<_, i32>(1), 1300); + + db.close().await.unwrap(); + let _ = connection.await; + }); +} + +#[test] +fn native_server_accepts_psql_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server psql smoke: no native library env var is set"); + return; + } + let Some(psql) = native_tool_path("psql") else { + eprintln!("skipping native server psql smoke: matching psql binary was not found"); + return; + }; + + let db = block_on(Oliphaunt::builder().temporary().native_server().open()).unwrap(); + let connection_string = db.connection_string().unwrap(); + let output = Command::new(&psql) + .arg(&connection_string) + .arg("--no-psqlrc") + .arg("--tuples-only") + .arg("--no-align") + .arg("--set") + .arg("ON_ERROR_STOP=1") + .arg("--command") + .arg("SELECT 11") + .output() + .unwrap(); + + assert!( + output.status.success(), + "psql failed with status {} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "11"); + block_on(db.close()).unwrap(); +} + +#[test] +fn native_server_accepts_pg_dump_when_env_is_available() { + if native_runtime_env_is_unavailable() { + eprintln!("skipping native server pg_dump smoke: no native library env var is set"); + return; + } + let Some(pg_dump) = native_tool_path("pg_dump") else { + eprintln!("skipping native server pg_dump smoke: matching pg_dump binary was not found"); + return; + }; + + let db = block_on(Oliphaunt::builder().temporary().native_server().open()).unwrap(); + let connection_string = db.connection_string().unwrap(); + block_on(db.execute( + "CREATE TABLE dump_client_smoke(id integer PRIMARY KEY, value text); \ + INSERT INTO dump_client_smoke VALUES (1, 'dumped')", + )) + .unwrap(); + + let output = Command::new(&pg_dump) + .arg(&connection_string) + .arg("--no-owner") + .arg("--no-privileges") + .arg("--data-only") + .arg("--table") + .arg("dump_client_smoke") + .output() + .unwrap(); + + assert!( + output.status.success(), + "pg_dump failed with status {} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("dumped") && stdout.contains("dump_client_smoke"), + "pg_dump output did not include expected table data:\n{stdout}" + ); + block_on(db.close()).unwrap(); +} + +fn raw_query_message(sql: &str) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(sql.as_bytes()); + body.push(0); + + let mut packet = Vec::with_capacity(body.len() + 5); + packet.push(b'Q'); + packet.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + packet.extend_from_slice(&body); + packet +} + +fn assert_large_server_raw_pipeline_recovers(db: &Oliphaunt) { + let rows = 6_000; + block_on(db.execute(&format!( + "CREATE TEMP TABLE server_duplex_updates(id integer PRIMARY KEY, value integer); \ + INSERT INTO server_duplex_updates SELECT i, 0 FROM generate_series(1, {rows}) AS i", + ))) + .unwrap(); + + let statement_name = "server_duplex_update"; + let mut prepare = Vec::new(); + prepare.extend(extended_parse( + Some(statement_name), + "UPDATE server_duplex_updates SET value = $1 WHERE id = $2", + &[23, 23], + )); + prepare.extend(extended_sync()); + let prepared = block_on(db.exec_protocol_raw(prepare)).unwrap(); + assert_raw_response_ok(prepared.as_bytes(), "large raw pipeline prepare"); + + let mut batch = Vec::new(); + for row in 1..=rows { + let portal = format!("server_duplex_portal_{row}"); + let value = row.to_string(); + let id = row.to_string(); + batch.extend(extended_bind( + Some(&portal), + statement_name, + &[value.as_str(), id.as_str()], + )); + batch.extend(extended_execute(Some(&portal))); + batch.extend(extended_close(b'P', Some(&portal))); + } + batch.extend(extended_sync()); + assert!( + batch.len() > 300 * 1024, + "large raw server pipeline request was too small to exercise duplex write/read: {} bytes", + batch.len() + ); + + let response = block_on(db.exec_protocol_raw(batch)).unwrap(); + assert_raw_response_ok(response.as_bytes(), "large raw server pipeline"); + let tags = raw_message_tags(response.as_bytes()); + assert!( + tags.iter().filter(|tag| **tag == b'C').count() >= rows, + "large raw server pipeline did not return expected CommandComplete frames" + ); + + let sum = block_on(db.query( + "SELECT count(*)::text AS count, sum(value)::text AS sum FROM server_duplex_updates", + )) + .unwrap(); + let expected_count = rows.to_string(); + let expected_sum = ((rows * (rows + 1)) / 2).to_string(); + assert_eq!( + sum.get_text(0, "count").unwrap(), + Some(expected_count.as_str()) + ); + assert_eq!(sum.get_text(0, "sum").unwrap(), Some(expected_sum.as_str())); +} + +fn assert_raw_response_ok(bytes: &[u8], context: &str) { + let tags = raw_message_tags(bytes); + assert!( + !tags.contains(&b'E'), + "{context} returned ErrorResponse: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "{context} did not return ReadyForQuery: {tags:?}" + ); +} + +fn extended_parse(name: Option<&str>, sql: &str, type_oids: &[i32]) -> Vec { + let mut body = Vec::new(); + push_protocol_cstr(&mut body, name.unwrap_or("")); + push_protocol_cstr(&mut body, sql); + push_protocol_i16(&mut body, type_oids.len() as i16); + for oid in type_oids { + push_protocol_i32(&mut body, *oid); + } + protocol_frame(b'P', &body) +} + +fn extended_bind(portal: Option<&str>, statement: &str, values: &[&str]) -> Vec { + let mut body = Vec::new(); + push_protocol_cstr(&mut body, portal.unwrap_or("")); + push_protocol_cstr(&mut body, statement); + push_protocol_i16(&mut body, values.len() as i16); + for _ in values { + push_protocol_i16(&mut body, 0); + } + push_protocol_i16(&mut body, values.len() as i16); + for value in values { + push_protocol_i32(&mut body, value.len() as i32); + body.extend_from_slice(value.as_bytes()); + } + push_protocol_i16(&mut body, 0); + protocol_frame(b'B', &body) +} + +fn extended_execute(portal: Option<&str>) -> Vec { + let mut body = Vec::new(); + push_protocol_cstr(&mut body, portal.unwrap_or("")); + push_protocol_i32(&mut body, 0); + protocol_frame(b'E', &body) +} + +fn extended_close(target_type: u8, name: Option<&str>) -> Vec { + let mut body = Vec::new(); + body.push(target_type); + push_protocol_cstr(&mut body, name.unwrap_or("")); + protocol_frame(b'C', &body) +} + +fn extended_sync() -> Vec { + protocol_frame(b'S', &[]) +} + +fn protocol_frame(tag: u8, body: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(1 + 4 + body.len()); + frame.push(tag); + frame.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + frame.extend_from_slice(body); + frame +} + +fn push_protocol_cstr(out: &mut Vec, value: &str) { + out.extend_from_slice(value.as_bytes()); + out.push(0); +} + +fn push_protocol_i16(out: &mut Vec, value: i16) { + out.extend_from_slice(&value.to_be_bytes()); +} + +fn push_protocol_i32(out: &mut Vec, value: i32) { + out.extend_from_slice(&value.to_be_bytes()); +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DirectCrashAction { + CommittedWait, + VerifyCommitted, + UncommittedWait, + VerifyUncommitted, +} + +#[cfg(unix)] +impl DirectCrashAction { + fn as_env(self) -> &'static str { + match self { + Self::CommittedWait => "committed-wait", + Self::VerifyCommitted => "verify-committed", + Self::UncommittedWait => "uncommitted-wait", + Self::VerifyUncommitted => "verify-uncommitted", + } + } + + fn from_env(value: &str) -> Option { + match value { + "committed-wait" => Some(Self::CommittedWait), + "verify-committed" => Some(Self::VerifyCommitted), + "uncommitted-wait" => Some(Self::UncommittedWait), + "verify-uncommitted" => Some(Self::VerifyUncommitted), + _ => None, + } + } +} + +#[cfg(unix)] +fn run_direct_crash_child_from_env() -> Option> { + let action = std::env::var(DIRECT_CRASH_ACTION_ENV).ok()?; + let action = DirectCrashAction::from_env(&action) + .ok_or_else(|| format!("unknown direct crash action '{action}'")); + let root = std::env::var_os(DIRECT_CRASH_ROOT_ENV) + .map(PathBuf::from) + .ok_or_else(|| format!("{DIRECT_CRASH_ROOT_ENV} is required")); + let marker = std::env::var_os(DIRECT_CRASH_MARKER_ENV).map(PathBuf::from); + Some((|| { + run_direct_crash_child_action(action?, &root?, marker.as_deref()) + .map_err(|error| error.to_string())?; + Ok(()) + })()) +} + +#[cfg(unix)] +fn run_direct_crash_child_until_marker(action: DirectCrashAction, root: &Path, marker: &Path) { + let mut child = spawn_direct_crash_child(action, root, Some(marker)); + let deadline = Instant::now() + Duration::from_secs(15); + while !marker.exists() { + if let Some(status) = child.try_wait().unwrap() { + let output = child.wait_with_output().unwrap(); + panic!( + "direct crash child exited before marker for {action:?}: status={status} stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + assert!( + Instant::now() < deadline, + "direct crash child did not create marker {marker:?} for {action:?}" + ); + thread::sleep(Duration::from_millis(50)); + } + + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + !output.status.success(), + "direct crash child unexpectedly exited cleanly for {action:?}: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +fn run_direct_crash_child(action: DirectCrashAction, root: &Path) { + let output = spawn_direct_crash_child(action, root, None) + .wait_with_output() + .unwrap(); + assert!( + output.status.success(), + "direct crash child failed for {action:?}: status={} stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +fn spawn_direct_crash_child( + action: DirectCrashAction, + root: &Path, + marker: Option<&Path>, +) -> std::process::Child { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .arg("native_direct_crash_consistency_survives_process_death_when_env_is_available") + .arg("--exact") + .arg("--nocapture") + .env(DIRECT_CRASH_ACTION_ENV, action.as_env()) + .env(DIRECT_CRASH_ROOT_ENV, root) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(marker) = marker { + command.env(DIRECT_CRASH_MARKER_ENV, marker); + } + command.spawn().unwrap() +} + +#[cfg(unix)] +fn run_direct_crash_child_action( + action: DirectCrashAction, + root: &Path, + marker: Option<&Path>, +) -> Result<()> { + match action { + DirectCrashAction::CommittedWait => { + let db = block_on( + Oliphaunt::builder() + .path(root) + .native_direct() + .runtime(OliphauntRuntime::from_env()) + .open(), + )?; + block_on(db.exec_protocol_raw(raw_query_message( + "CREATE TABLE crash_consistency(value integer); \ + INSERT INTO crash_consistency VALUES (1)", + )))?; + write_direct_crash_marker(marker); + loop { + thread::sleep(Duration::from_secs(60)); + } + } + DirectCrashAction::VerifyCommitted => { + assert_direct_crash_values(root, &["1", "1"])?; + Ok(()) + } + DirectCrashAction::UncommittedWait => { + let db = block_on( + Oliphaunt::builder() + .path(root) + .native_direct() + .runtime(OliphauntRuntime::from_env()) + .existing_only() + .open(), + )?; + block_on(db.exec_protocol_raw(raw_query_message( + "BEGIN; INSERT INTO crash_consistency VALUES (2)", + )))?; + write_direct_crash_marker(marker); + loop { + thread::sleep(Duration::from_secs(60)); + } + } + DirectCrashAction::VerifyUncommitted => { + assert_direct_crash_values(root, &["1", "1"])?; + Ok(()) + } + } +} + +#[cfg(unix)] +fn write_direct_crash_marker(marker: Option<&Path>) { + if let Some(marker) = marker { + std::fs::write(marker, b"ready").unwrap(); + } +} + +#[cfg(unix)] +fn assert_direct_crash_values(root: &Path, expected: &[&str]) -> Result<()> { + let db = block_on( + Oliphaunt::builder() + .path(root) + .native_direct() + .runtime(OliphauntRuntime::from_env()) + .existing_only() + .open(), + )?; + let response = block_on(db.exec_protocol_raw(raw_query_message( + "SELECT count(*)::text, COALESCE(sum(value), 0)::text FROM crash_consistency", + )))?; + let values = first_data_row_text_values(response.as_bytes()); + let expected = expected + .iter() + .map(|value| (*value).to_owned()) + .collect::>(); + assert_eq!(values, expected); + block_on(db.close())?; + Ok(()) +} + +fn native_runtime_env_is_unavailable() -> bool { + std::env::var_os("LIBOLIPHAUNT_PATH").is_none() + || native_extension_is_unavailable(Extension::Vector) +} + +fn native_extension_is_unavailable(extension: Extension) -> bool { + let Some(install_dir) = native_install_dir() else { + return true; + }; + !install_dir + .join("share/postgresql/extension") + .join(format!("{}.control", extension.sql_name())) + .is_file() +} + +fn native_install_dir() -> Option { + if let Some(install_dir) = std::env::var_os("OLIPHAUNT_INSTALL_DIR").map(PathBuf::from) { + return Some(install_dir); + } + if let Some(postgres) = std::env::var_os("OLIPHAUNT_POSTGRES").map(PathBuf::from) + && let Some(bin_dir) = postgres.parent() + && let Some(install_dir) = bin_dir.parent() + { + return Some(install_dir.to_path_buf()); + } + let cwd = std::env::current_dir().ok()?; + [ + cwd.join("target/liboliphaunt-pg18/install"), + cwd.join("target/native-liboliphaunt-pg18/install"), + ] + .into_iter() + .find(|candidate| candidate.is_dir()) +} + +fn native_tool_path(tool: &str) -> Option { + for env_name in ["OLIPHAUNT_POSTGRES"] { + if let Some(postgres) = std::env::var_os(env_name).map(PathBuf::from) + && let Some(bin_dir) = postgres.parent() + { + let candidate = bin_dir.join(tool); + if candidate.is_file() { + return Some(candidate); + } + } + } + let cwd = std::env::current_dir().ok()?; + [ + cwd.join("target/liboliphaunt-pg18/install/bin").join(tool), + cwd.join("target/native-liboliphaunt-pg18/install/bin") + .join(tool), + ] + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn native_broker_executable() -> Option<&'static str> { + option_env!("CARGO_BIN_EXE_oliphaunt-broker") +} + +fn assert_physical_archive(artifact: &oliphaunt::BackupArtifact, mode: &str) { + assert_eq!(artifact.format, BackupFormat::PhysicalArchive); + let mut archive = tar::Archive::new(Cursor::new(artifact.bytes.as_slice())); + let mut names = archive + .entries() + .unwrap() + .map(|entry| { + entry + .unwrap() + .path() + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + names.sort(); + assert!( + names.iter().any(|name| name == "pgdata/PG_VERSION"), + "{mode} physical archive is missing PG_VERSION" + ); + assert!( + names.iter().any(|name| name == "pgdata/backup_label"), + "{mode} physical archive is missing backup_label" + ); + assert!( + names.iter().any(|name| name == "manifest.properties"), + "{mode} physical archive is missing root manifest" + ); + assert!( + names + .iter() + .any(|name| name == ".oliphaunt/backup-manifest.properties"), + "{mode} physical archive is missing backup manifest" + ); + assert!( + names.iter().any(|name| name.starts_with("pgdata/base/")), + "{mode} physical archive is missing relation storage" + ); +} + +fn unique_temp_root(prefix: &str) -> PathBuf { + let parent = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("{prefix}-{pid}-{nanos}-{attempt}")); + if !path.exists() { + return path; + } + } + panic!("failed to allocate a unique temp root for {prefix}"); +} + +fn raw_message_tags(mut bytes: &[u8]) -> Vec { + let mut tags = Vec::new(); + while bytes.len() >= 5 { + let tag = bytes[0]; + let len = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + if len < 4 { + break; + } + let total = 1 + len as usize; + if bytes.len() < total { + break; + } + tags.push(tag); + bytes = &bytes[total..]; + } + tags +} + +fn raw_message_contains(bytes: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && bytes.windows(needle.len()).any(|window| window == needle) +} + +fn assert_cancel_recovers(db: &Oliphaunt, recovered_value: i32) { + let cancellable = db.clone(); + let cancel_worker = thread::spawn(move || { + block_on( + cancellable.exec_protocol_raw(raw_query_message("SELECT pg_sleep(5) AS should_cancel")), + ) + .unwrap() + }); + thread::sleep(Duration::from_millis(100)); + db.cancel().unwrap(); + let cancelled = cancel_worker.join().unwrap(); + let tags = raw_message_tags(cancelled.as_bytes()); + assert!(tags.contains(&b'E'), "missing ErrorResponse: {tags:?}"); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + assert!( + raw_message_contains( + cancelled.as_bytes(), + b"canceling statement due to user request" + ), + "cancel response did not include the PostgreSQL cancellation message" + ); + + let sql = format!("SELECT {recovered_value}::text AS recovered_after_cancel"); + let recovered = block_on(db.exec_protocol_raw(raw_query_message(&sql))).unwrap(); + assert_eq!( + first_data_row_text_values(recovered.as_bytes()), + vec![recovered_value.to_string()] + ); +} + +fn assert_repeated_cancel_recovers(db: &Oliphaunt, first_recovered_value: i32) { + for offset in 0..3 { + assert_cancel_recovers(db, first_recovered_value + offset); + } +} + +fn assert_broker_cancel_reuses_helper(db: &Oliphaunt, first_recovered_value: i32) { + let helper_pid_before = first_data_row_text_values( + block_on(db.exec_protocol_raw(raw_query_message("SELECT pg_backend_pid()::text"))) + .unwrap() + .as_bytes(), + ); + assert_eq!( + helper_pid_before.len(), + 1, + "broker backend pid probe returned unexpected rows" + ); + + assert_repeated_cancel_recovers(db, first_recovered_value); + + let helper_pid_after = first_data_row_text_values( + block_on(db.exec_protocol_raw(raw_query_message("SELECT pg_backend_pid()::text"))) + .unwrap() + .as_bytes(), + ); + assert_eq!( + helper_pid_after, helper_pid_before, + "broker helper/backend identity changed after cancellation" + ); +} + +fn assert_streaming_cancel_recovers(db: &Oliphaunt, recovered_value: i32) { + let streamed = Arc::new(Mutex::new(Vec::new())); + let streamed_for_callback = Arc::clone(&streamed); + let active = db.clone(); + let worker = thread::spawn(move || { + block_on(active.exec_protocol_raw_stream( + raw_query_message("SELECT pg_sleep(5), repeat('x', 1048576) AS should_cancel_stream"), + move |chunk| { + streamed_for_callback + .lock() + .unwrap() + .extend_from_slice(chunk); + Ok(()) + }, + )) + }); + + thread::sleep(Duration::from_millis(100)); + db.cancel().unwrap(); + worker.join().unwrap().unwrap(); + + let bytes = streamed.lock().unwrap(); + let tags = raw_message_tags(&bytes); + assert!( + tags.contains(&b'E'), + "missing streaming ErrorResponse: {tags:?}" + ); + assert!( + tags.contains(&b'Z'), + "missing streaming ReadyForQuery: {tags:?}" + ); + assert!( + raw_message_contains(&bytes, b"canceling statement due to user request"), + "streaming cancel response did not include the PostgreSQL cancellation message" + ); + drop(bytes); + + let sql = format!("SELECT {recovered_value}::text AS recovered_after_stream_cancel"); + let recovered = block_on(db.exec_protocol_raw(raw_query_message(&sql))).unwrap(); + assert_eq!( + first_data_row_text_values(recovered.as_bytes()), + vec![recovered_value.to_string()] + ); +} + +fn assert_close_waits_for_active_query(db: &Oliphaunt) { + let active = db.clone(); + let worker = thread::spawn(move || { + block_on(active.exec_protocol_raw(raw_query_message( + "SELECT pg_sleep(0.1) AS should_finish_before_close", + ))) + }); + thread::sleep(Duration::from_millis(25)); + block_on(db.close()).unwrap(); + + let finished = worker.join().unwrap().unwrap(); + let tags = raw_message_tags(finished.as_bytes()); + assert!( + tags.contains(&b'D'), + "close must wait for active query success, got tags: {tags:?}" + ); + assert!(tags.contains(&b'Z'), "missing ReadyForQuery: {tags:?}"); + assert_eq!( + block_on(db.execute("SELECT after close")).unwrap_err(), + Error::EngineStopped + ); +} + +fn first_data_row_text_values(mut bytes: &[u8]) -> Vec { + while bytes.len() >= 5 { + let tag = bytes[0]; + let len = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + if len < 4 { + break; + } + let total = 1 + len as usize; + if bytes.len() < total { + break; + } + if tag == b'D' { + return parse_data_row_text_values(&bytes[5..total]); + } + bytes = &bytes[total..]; + } + Vec::new() +} + +fn parse_data_row_text_values(payload: &[u8]) -> Vec { + if payload.len() < 2 { + return Vec::new(); + } + let columns = i16::from_be_bytes([payload[0], payload[1]]); + if columns < 0 { + return Vec::new(); + } + let mut offset = 2; + let mut values = Vec::with_capacity(columns as usize); + for _ in 0..columns { + if payload.len().saturating_sub(offset) < 4 { + return Vec::new(); + } + let len = i32::from_be_bytes([ + payload[offset], + payload[offset + 1], + payload[offset + 2], + payload[offset + 3], + ]); + offset += 4; + if len == -1 { + values.push("NULL".to_owned()); + continue; + } + if len < 0 { + return Vec::new(); + } + let len = len as usize; + if payload.len().saturating_sub(offset) < len { + return Vec::new(); + } + values.push(String::from_utf8_lossy(&payload[offset..offset + len]).into_owned()); + offset += len; + } + values +} + +fn block_on(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => return value, + Poll::Pending => thread::park_timeout(Duration::from_millis(1)), + } + } +} + +fn expect_open_error(result: Result) -> Error { + match result { + Ok(_) => panic!("expected open to fail"), + Err(error) => error, + } +} + +struct ThreadWaker(thread::Thread); + +impl Wake for ThreadWaker { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} diff --git a/src/sdks/rust/tests/sdk_shape.rs b/src/sdks/rust/tests/sdk_shape.rs new file mode 100644 index 00000000..ca287a90 --- /dev/null +++ b/src/sdks/rust/tests/sdk_shape.rs @@ -0,0 +1,1464 @@ +use std::future::Future; +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use oliphaunt::{ + BackgroundCheckpointSkipReason, BackgroundPreparationOptions, BackgroundPreparationResult, + BackupArtifact, BackupFormat, BackupRequest, DatabaseRoot, EngineCancel, EngineCapabilities, + EngineMode, EngineSession, Error, NativeRuntime, Oliphaunt, ProtocolRequest, ProtocolResponse, + RestoreRequest, RestoreTargetPolicy, Result, +}; + +#[test] +fn restore_physical_archive_materializes_pgdata_layout() { + let root = unique_temp_root("oliphaunt-restore-api"); + let restored = block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + &root, + minimal_physical_archive(), + ))) + .unwrap(); + + assert_eq!(restored, root); + assert!(root.join("pgdata/PG_VERSION").is_file()); + assert!(root.join("pgdata/global/pg_control").is_file()); + assert!(root.join("pgdata/backup_label").is_file()); + assert!(root.join("manifest.properties").is_file()); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn restore_physical_archive_can_publish_into_empty_existing_directory() { + let root = unique_temp_root("oliphaunt-restore-empty-existing"); + std::fs::create_dir_all(&root).unwrap(); + + let restored = block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + &root, + minimal_physical_archive(), + ))) + .unwrap(); + + assert_eq!(restored, root); + assert!(root.join("pgdata/PG_VERSION").is_file()); + assert!(!root.join(".oliphaunt.lock").exists()); + let _ = std::fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn restore_physical_archive_rejects_symlink_targets() { + let parent = unique_temp_root("oliphaunt-restore-symlink"); + let real = parent.join("real-root"); + let link = parent.join("link-root"); + std::fs::create_dir_all(&real).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let error = block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + &link, + minimal_physical_archive(), + ))) + .unwrap_err(); + assert!( + error.to_string().contains("symlink target"), + "unexpected restore symlink error: {error}" + ); + assert!( + link.symlink_metadata().unwrap().file_type().is_symlink(), + "restore modified the symlink target" + ); + assert!(std::fs::read_dir(&real).unwrap().next().is_none()); + let _ = std::fs::remove_dir_all(parent); +} + +#[test] +fn restore_physical_archive_rejects_non_empty_targets_by_default() { + let root = unique_temp_root("oliphaunt-restore-non-empty"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("sentinel"), b"existing").unwrap(); + + let error = block_on(Oliphaunt::restore(RestoreRequest::physical_archive( + &root, + minimal_physical_archive(), + ))) + .unwrap_err(); + assert!( + error + .to_string() + .contains("refusing to restore into non-empty target"), + "unexpected restore error: {error}" + ); + assert_eq!(std::fs::read(root.join("sentinel")).unwrap(), b"existing"); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn restore_physical_archive_can_replace_existing_roots() { + let root = unique_temp_root("oliphaunt-restore-replace"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("sentinel"), b"existing").unwrap(); + + let restored = block_on(Oliphaunt::restore( + RestoreRequest::physical_archive(&root, minimal_physical_archive()).replace_existing(), + )) + .unwrap(); + + assert_eq!(restored, root); + assert!(!root.join("sentinel").exists()); + assert!(root.join("pgdata/PG_VERSION").is_file()); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn restore_rejects_unsupported_formats_before_materializing_target() { + let root = unique_temp_root("oliphaunt-restore-sql-reject"); + let error = block_on(Oliphaunt::restore(RestoreRequest { + artifact: BackupArtifact { + format: BackupFormat::Sql, + bytes: b"sql-backup".to_vec(), + }, + target: DatabaseRoot::Path(root.clone()), + target_policy: RestoreTargetPolicy::FailIfExists, + })) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("restore currently requires a physical archive artifact, got Sql"), + "unexpected restore format error: {error}" + ); + assert!( + !root.exists(), + "unsupported restore format should not materialize the target root" + ); +} + +#[test] +fn opened_handle_exposes_backup_restore_format_helpers() { + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-format-helper") + .native_server() + .runtime(MockRuntime { + calls: Arc::new(Mutex::new(Vec::new())), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + assert!(db.supports_backup_format(BackupFormat::Sql)); + assert!(db.supports_backup_format(BackupFormat::PhysicalArchive)); + assert!(!db.supports_backup_format(BackupFormat::OliphauntArchive)); + assert!(db.supports_restore_format(BackupFormat::PhysicalArchive)); + assert!(!db.supports_restore_format(BackupFormat::Sql)); + + block_on(db.close()).unwrap(); +} + +#[test] +fn opened_handle_rejects_unsupported_backup_formats_before_engine_call() { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-format-helper-reject") + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let error = block_on(db.backup(BackupRequest::sql())).unwrap_err(); + assert!( + error + .to_string() + .contains("Sql backup is not supported by native-direct"), + "unexpected backup format error: {error}" + ); + assert!( + calls.lock().unwrap().is_empty(), + "unsupported backup format crossed into the engine" + ); + + block_on(db.close()).unwrap(); +} + +#[test] +fn cloned_handles_share_one_serial_owner_executor() { + let calls = Arc::new(Mutex::new(Vec::new())); + let cancels = Arc::new(AtomicUsize::new(0)); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::clone(&cancels), + }) + .open(), + ) + .unwrap(); + + let left = db.clone(); + let right = db.clone(); + let left = thread::spawn(move || block_on(left.exec_protocol_raw(vec![b'a'])).unwrap()); + let right = thread::spawn(move || block_on(right.exec_protocol_raw(vec![b'b'])).unwrap()); + + let responses = [ + left.join().unwrap().into_bytes(), + right.join().unwrap().into_bytes(), + ]; + let mut sequence_numbers = responses + .iter() + .map(|response| response[0]) + .collect::>(); + sequence_numbers.sort(); + let mut payloads = responses + .iter() + .map(|response| response[1]) + .collect::>(); + payloads.sort(); + + assert_eq!(sequence_numbers, vec![1, 2]); + assert_eq!(payloads, vec![b'a', b'b']); + assert_eq!(calls.lock().unwrap().len(), 2); + db.cancel().unwrap(); + assert_eq!(cancels.load(Ordering::SeqCst), 1); +} + +#[test] +fn cloned_handles_share_pin_and_close_state_for_every_sdk_mode() { + for mode in EngineMode::all() { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + builder_for_mode(mode, format!("target/test-roots/{mode}-clone-state")) + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let owner = db.clone(); + let peer = db.clone(); + let pin = block_on(owner.pin_session()).unwrap(); + assert_eq!( + block_on(peer.execute("SELECT outside cloned pin")).unwrap_err(), + Error::SessionPinned, + "{mode} clone executed unpinned work while another clone owned the session pin" + ); + assert_eq!( + block_on(pin.exec_protocol_raw(vec![b'p'])) + .unwrap() + .into_bytes(), + vec![1, b'p'], + "{mode} pinned work did not use the shared owner executor" + ); + block_on(pin.release()).unwrap(); + block_on(peer.execute("SELECT after cloned pin")).unwrap(); + + block_on(owner.close()).unwrap(); + assert_eq!( + block_on(peer.execute("SELECT after cloned close")).unwrap_err(), + Error::EngineStopped, + "{mode} close through one clone did not stop the shared executor" + ); + + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"SELECT after cloned pin")), + "{mode} did not release the cloned session pin for later work: {calls:?}" + ); + assert!( + !calls + .iter() + .any(|call| raw_message_contains(call, b"SELECT after cloned close")), + "{mode} executed work after clone-shared close: {calls:?}" + ); + } +} + +#[test] +fn cloned_handles_queue_fifo_on_one_owner_executor_for_every_sdk_mode() { + for mode in EngineMode::all() { + let state = Arc::new(BlockingState::default()); + let db = block_on( + builder_for_mode(mode, format!("target/test-roots/{mode}-fifo-queue")) + .runtime(BlockingRuntime { + state: Arc::clone(&state), + }) + .open(), + ) + .unwrap(); + + let active = db.clone(); + let active_worker = thread::spawn(move || block_on(active.exec_protocol_raw(vec![b'L']))); + state.wait_until_active(); + + let first_handle = db.clone(); + let second_handle = db.clone(); + let mut first = Box::pin(first_handle.exec_protocol_raw(vec![b'1'])); + let mut second = Box::pin(second_handle.exec_protocol_raw(vec![b'2'])); + poll_once_pending(&mut first); + poll_once_pending(&mut second); + + state.release(); + assert_eq!( + active_worker.join().unwrap().unwrap().into_bytes(), + b"finished".to_vec(), + "{mode} active owner work did not finish cleanly" + ); + assert_eq!( + block_on_pinned(first).unwrap().into_bytes(), + b"finished".to_vec(), + "{mode} first queued operation failed" + ); + assert_eq!( + block_on_pinned(second).unwrap().into_bytes(), + b"finished".to_vec(), + "{mode} second queued operation failed" + ); + block_on(db.close()).unwrap(); + + assert_eq!( + state.calls(), + vec![vec![b'L'], vec![b'1'], vec![b'2']], + "{mode} cloned handles did not preserve FIFO owner-executor order" + ); + } +} + +#[test] +fn raw_streaming_uses_the_same_owner_executor() { + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(MockRuntime { + calls: Arc::new(Mutex::new(Vec::new())), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let streamed = Arc::new(Mutex::new(Vec::new())); + let streamed_for_callback = Arc::clone(&streamed); + block_on(db.exec_protocol_raw_stream(vec![b's'], move |chunk| { + streamed_for_callback + .lock() + .unwrap() + .extend_from_slice(chunk); + Ok(()) + })) + .unwrap(); + + assert_eq!(*streamed.lock().unwrap(), vec![1, b's']); +} + +#[test] +fn streaming_cancel_uses_out_of_band_cancel_and_releases_owner() { + let state = Arc::new(StreamingCancelState::default()); + let cancels = Arc::new(AtomicUsize::new(0)); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(StreamingCancelRuntime { + state: Arc::clone(&state), + cancels: Arc::clone(&cancels), + }) + .open(), + ) + .unwrap(); + + let streamed_bytes = Arc::new(AtomicUsize::new(0)); + let streamed_for_callback = Arc::clone(&streamed_bytes); + let streaming = db.clone(); + let worker = thread::spawn(move || { + block_on( + streaming.exec_protocol_raw_stream(vec![b'L'], move |chunk| { + streamed_for_callback.fetch_add(chunk.len(), Ordering::SeqCst); + Ok(()) + }), + ) + }); + + state.wait_until_streaming(); + let started = Instant::now(); + db.cancel().unwrap(); + let result = worker.join().unwrap(); + assert!( + started.elapsed() < Duration::from_secs(1), + "streaming cancel was queued behind active owner work" + ); + result.unwrap(); + assert_eq!(cancels.load(Ordering::SeqCst), 1); + assert!( + streamed_bytes.load(Ordering::SeqCst) >= 128 * 1024, + "streaming fixture did not exercise a large response path" + ); + assert!(state.was_stream_cancelled()); + + let recovered = block_on(db.exec_protocol_raw(vec![b'r'])).unwrap(); + assert_eq!( + recovered.into_bytes(), + b"recovered-after-stream-cancel".to_vec() + ); +} + +#[test] +fn execute_uses_the_engine_simple_query_path() { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + // OLIPHAUNT_DOCS_SNIPPET rust-quickstart + let response = block_on(db.execute("SELECT simple_query_path")).unwrap(); + let expected = b"\x01SSELECT simple_query_path".to_vec(); + assert_eq!(response.into_bytes(), expected); + assert_eq!(*calls.lock().unwrap(), vec![expected]); +} + +#[test] +fn session_pin_prevents_unpinned_interleaving() { + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(MockRuntime { + calls: Arc::new(Mutex::new(Vec::new())), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let pin = block_on(db.pin_session()).unwrap(); + let error = block_on(db.exec_protocol_raw(vec![b'x'])).unwrap_err(); + assert_eq!(error, Error::SessionPinned); + let checkpoint_error = block_on(db.checkpoint()).unwrap_err(); + assert_eq!(checkpoint_error, Error::SessionPinned); + let backup_error = block_on(db.backup(BackupRequest::physical_archive())).unwrap_err(); + assert_eq!(backup_error, Error::SessionPinned); + + let pinned_response = block_on(pin.exec_protocol_raw(vec![b'p'])).unwrap(); + assert_eq!(pinned_response.into_bytes(), vec![1, b'p']); + + let streamed = Arc::new(Mutex::new(Vec::new())); + let streamed_for_callback = Arc::clone(&streamed); + block_on(pin.exec_protocol_raw_stream(vec![b's'], move |chunk| { + streamed_for_callback + .lock() + .unwrap() + .extend_from_slice(chunk); + Ok(()) + })) + .unwrap(); + assert_eq!(*streamed.lock().unwrap(), vec![2, b's']); + + block_on(pin.release()).unwrap(); + let unpinned_response = block_on(db.exec_protocol_raw(vec![b'u'])).unwrap(); + assert_eq!(unpinned_response.into_bytes(), vec![3, b'u']); +} + +#[test] +fn lifecycle_prepare_for_background_checkpoints_when_idle_and_resume_probes_session() { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct-lifecycle-idle") + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let prepared = + block_on(db.prepare_for_background(BackgroundPreparationOptions::default())).unwrap(); + assert_eq!( + prepared, + BackgroundPreparationResult { + cancelled_active_work: false, + checkpointed: true, + skipped_checkpoint_reason: None, + } + ); + block_on(db.resume_from_background()).unwrap(); + + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"CHECKPOINT")), + "background preparation did not checkpoint when idle: {calls:?}" + ); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"SELECT 1")), + "foreground resume did not probe the session: {calls:?}" + ); +} + +#[test] +fn lifecycle_prepare_for_background_cancels_active_work_without_checkpointing() { + let state = Arc::new(BlockingState::default()); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct-lifecycle-active") + .runtime(BlockingRuntime { + state: Arc::clone(&state), + }) + .open(), + ) + .unwrap(); + + let active = db.clone(); + let worker = thread::spawn(move || block_on(active.exec_protocol_raw(vec![b'L']))); + state.wait_until_active(); + + let prepared = + block_on(db.prepare_for_background(BackgroundPreparationOptions::default())).unwrap(); + assert_eq!( + prepared, + BackgroundPreparationResult { + cancelled_active_work: true, + checkpointed: false, + skipped_checkpoint_reason: Some(BackgroundCheckpointSkipReason::ActiveWork), + } + ); + assert!(state.was_cancelled()); + assert_eq!(worker.join().unwrap().unwrap().into_bytes(), b"cancelled"); +} + +#[test] +fn lifecycle_prepare_for_background_skips_checkpoint_when_session_is_pinned() { + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct-lifecycle-pinned") + .runtime(MockRuntime { + calls: Arc::new(Mutex::new(Vec::new())), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let pin = block_on(db.pin_session()).unwrap(); + let prepared = + block_on(db.prepare_for_background(BackgroundPreparationOptions::default())).unwrap(); + assert_eq!( + prepared, + BackgroundPreparationResult { + cancelled_active_work: false, + checkpointed: false, + skipped_checkpoint_reason: Some(BackgroundCheckpointSkipReason::SessionPinned), + } + ); + block_on(pin.release()).unwrap(); +} + +#[test] +fn transaction_pins_and_releases_the_direct_session() { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let tx = block_on(db.transaction()).unwrap(); + let error = block_on(db.execute("SELECT outside")).unwrap_err(); + assert_eq!(error, Error::SessionPinned); + block_on(tx.execute("SELECT inside")).unwrap(); + let streamed = Arc::new(Mutex::new(Vec::new())); + let streamed_for_callback = Arc::clone(&streamed); + block_on(tx.exec_protocol_raw_stream(vec![b't'], move |chunk| { + streamed_for_callback + .lock() + .unwrap() + .extend_from_slice(chunk); + Ok(()) + })) + .unwrap(); + assert_eq!(*streamed.lock().unwrap(), vec![3, b't']); + block_on(tx.commit()).unwrap(); + block_on(db.execute("SELECT after")).unwrap(); + + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"COMMIT")), + "committed transaction did not send COMMIT: {calls:?}" + ); + assert!( + !calls + .iter() + .any(|call| raw_message_contains(call, b"ROLLBACK")), + "committed transaction unexpectedly sent ROLLBACK: {calls:?}" + ); +} + +#[test] +fn with_transaction_commits_rolls_back_and_rejects_unpinned_interleaving() { + for mode in [EngineMode::NativeDirect, EngineMode::NativeBroker] { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + builder_for_mode( + mode, + format!("target/test-roots/{mode}-closure-transaction"), + ) + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let committed = block_on(db.with_transaction(async |tx| { + let error = db.execute("SELECT outside closure").await.unwrap_err(); + assert_eq!(error, Error::SessionPinned); + tx.execute("INSERT INTO rust_tx_scope VALUES (1)").await?; + Ok::<_, Error>(11) + })) + .unwrap(); + assert_eq!(committed, 11); + + let failed = block_on(db.with_transaction(async |tx| { + tx.execute("INSERT INTO rust_tx_scope VALUES (2)").await?; + Err::<(), Error>(Error::Engine("closure failed".to_owned())) + })) + .unwrap_err(); + assert_eq!(failed, Error::Engine("closure failed".to_owned())); + + block_on(db.execute("SELECT after closure transaction")).unwrap(); + + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"INSERT INTO rust_tx_scope VALUES (1)")), + "{mode} committed closure transaction did not execute its body: {calls:?}" + ); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"COMMIT")), + "{mode} successful closure transaction did not send COMMIT: {calls:?}" + ); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"ROLLBACK")), + "{mode} failed closure transaction did not send ROLLBACK: {calls:?}" + ); + assert!( + raw_message_contains(calls.last().unwrap(), b"SELECT after closure transaction"), + "{mode} session was not released after closure transaction failure: {calls:?}" + ); + } +} + +#[test] +fn transaction_commit_and_rollback_failures_release_serial_session() { + for mode in [EngineMode::NativeDirect, EngineMode::NativeBroker] { + assert_commit_failure_rolls_back_and_releases(mode); + assert_rollback_failure_releases(mode); + assert_body_failure_preserves_error_when_rollback_fails(mode); + } +} + +#[test] +fn close_during_transaction_stops_session_and_rejects_pinned_work() { + for mode in [EngineMode::NativeDirect, EngineMode::NativeBroker] { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + builder_for_mode(mode, format!("target/test-roots/{mode}-close-transaction")) + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + let error = block_on(db.with_transaction(async |tx| { + db.close().await?; + tx.execute("SELECT after close").await?; + Ok::<_, Error>(()) + })) + .unwrap_err(); + assert_eq!(error, Error::EngineStopped); + assert_eq!( + block_on(db.execute("SELECT after closed transaction")).unwrap_err(), + Error::EngineStopped + ); + + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"BEGIN")), + "{mode} transaction did not begin before close: {calls:?}" + ); + assert!( + !calls + .iter() + .any(|call| raw_message_contains(call, b"SELECT after close")), + "{mode} pinned work ran after close: {calls:?}" + ); + assert!( + !calls + .iter() + .any(|call| raw_message_contains(call, b"COMMIT")), + "{mode} transaction committed after close: {calls:?}" + ); + } +} + +#[test] +fn dropped_transaction_rolls_back_and_releases_the_direct_session() { + for mode in [EngineMode::NativeDirect, EngineMode::NativeBroker] { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = block_on( + builder_for_mode( + mode, + format!("target/test-roots/{mode}-dropped-transaction"), + ) + .runtime(MockRuntime { + calls: Arc::clone(&calls), + cancels: Arc::new(AtomicUsize::new(0)), + }) + .open(), + ) + .unwrap(); + + { + let tx = block_on(db.transaction()).unwrap(); + block_on(tx.execute("INSERT INTO dropped_transaction VALUES (1)")).unwrap(); + } + + block_on(db.execute("SELECT after dropped transaction")).unwrap(); + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"ROLLBACK")), + "{mode} dropped transaction did not send ROLLBACK: {calls:?}" + ); + assert!( + raw_message_contains(calls.last().unwrap(), b"SELECT after dropped transaction"), + "{mode} session was not released for work after dropped transaction: {calls:?}" + ); + } +} + +#[test] +fn close_waits_for_active_owner_work_before_shutdown() { + let state = Arc::new(BlockingState::default()); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(BlockingRuntime { + state: Arc::clone(&state), + }) + .open(), + ) + .unwrap(); + + let active = db.clone(); + let worker = thread::spawn(move || block_on(active.execute("SELECT pg_sleep(5)"))); + state.wait_until_active(); + + let closing = db.clone(); + let close_worker = thread::spawn(move || block_on(closing.close())); + thread::sleep(Duration::from_millis(50)); + assert!( + !state.was_closed(), + "close returned before active work finished" + ); + state.release(); + close_worker.join().unwrap().unwrap(); + assert_eq!( + worker.join().unwrap().unwrap().into_bytes(), + b"finished".to_vec() + ); + assert!(state.was_closed()); + assert!( + !state.was_cancelled(), + "close must not issue an implicit cancel" + ); + assert_eq!( + block_on(db.execute("SELECT after close")).unwrap_err(), + Error::EngineStopped + ); +} + +#[test] +fn close_rejects_work_already_queued_behind_active_query() { + let state = Arc::new(BlockingState::default()); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(BlockingRuntime { + state: Arc::clone(&state), + }) + .open(), + ) + .unwrap(); + + let active = db.clone(); + let active_worker = thread::spawn(move || block_on(active.execute("SELECT active"))); + state.wait_until_active(); + + let queued = db.clone(); + let queued_worker = thread::spawn(move || block_on(queued.execute("SELECT queued"))); + thread::sleep(Duration::from_millis(20)); + + let closing = db.clone(); + let close_worker = thread::spawn(move || block_on(closing.close())); + thread::sleep(Duration::from_millis(50)); + state.release(); + close_worker.join().unwrap().unwrap(); + assert_eq!( + active_worker.join().unwrap().unwrap().into_bytes(), + b"finished".to_vec() + ); + assert_eq!( + queued_worker.join().unwrap().unwrap_err(), + Error::EngineStopped + ); + assert!(state.was_closed()); + assert!( + !state.was_cancelled(), + "close must not issue an implicit cancel" + ); +} + +#[test] +fn idle_close_does_not_issue_spurious_cancel() { + let cancels = Arc::new(AtomicUsize::new(0)); + let db = block_on( + Oliphaunt::builder() + .path("target/test-roots/native-direct") + .runtime(MockRuntime { + calls: Arc::new(Mutex::new(Vec::new())), + cancels: Arc::clone(&cancels), + }) + .open(), + ) + .unwrap(); + + block_on(db.close()).unwrap(); + assert_eq!(cancels.load(Ordering::SeqCst), 0); +} + +struct MockRuntime { + calls: Arc>>>, + cancels: Arc, +} + +impl NativeRuntime for MockRuntime { + fn open(&self, config: oliphaunt::OpenConfig) -> Result> { + Ok(Box::new(MockSession { + mode: config.mode, + calls: Arc::clone(&self.calls), + cancels: Arc::clone(&self.cancels), + count: 0, + })) + } +} + +struct MockSession { + mode: EngineMode, + calls: Arc>>>, + cancels: Arc, + count: u8, +} + +struct MockCancel { + cancels: Arc, +} + +impl EngineCancel for MockCancel { + fn cancel(&self) -> Result<()> { + self.cancels.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +impl EngineSession for MockSession { + fn capabilities(&self) -> EngineCapabilities { + EngineCapabilities::for_mode(self.mode) + } + + fn cancel_handle(&self) -> Option> { + let cancel: Arc = Arc::new(MockCancel { + cancels: Arc::clone(&self.cancels), + }); + Some(cancel) + } + + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result { + self.count += 1; + let mut response = vec![self.count]; + response.extend_from_slice(request.as_bytes()); + self.calls.lock().unwrap().push(response.clone()); + Ok(ProtocolResponse::new(response)) + } + + fn exec_simple_query(&mut self, sql: &str) -> Result { + self.count += 1; + let mut response = vec![self.count, b'S']; + response.extend_from_slice(sql.as_bytes()); + self.calls.lock().unwrap().push(response.clone()); + Ok(ProtocolResponse::new(response)) + } + + fn checkpoint(&mut self) -> Result<()> { + self.count += 1; + let mut response = vec![self.count, b'S']; + response.extend_from_slice(b"CHECKPOINT"); + self.calls.lock().unwrap().push(response); + Ok(()) + } +} + +#[derive(Clone, Copy)] +enum ScriptedTransactionFailure { + Commit, + Rollback, +} + +struct ScriptedTransactionRuntime { + calls: Arc>>>, + failure: ScriptedTransactionFailure, +} + +impl NativeRuntime for ScriptedTransactionRuntime { + fn open(&self, config: oliphaunt::OpenConfig) -> Result> { + Ok(Box::new(ScriptedTransactionSession { + mode: config.mode, + calls: Arc::clone(&self.calls), + failure: self.failure, + })) + } +} + +struct ScriptedTransactionSession { + mode: EngineMode, + calls: Arc>>>, + failure: ScriptedTransactionFailure, +} + +impl EngineSession for ScriptedTransactionSession { + fn capabilities(&self) -> EngineCapabilities { + EngineCapabilities::for_mode(self.mode) + } + + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result { + let bytes = request.into_bytes(); + self.calls.lock().unwrap().push(bytes.clone()); + match self.failure { + ScriptedTransactionFailure::Commit if raw_message_contains(&bytes, b"COMMIT") => { + Err(Error::Engine("scripted COMMIT failure".to_owned())) + } + ScriptedTransactionFailure::Rollback if raw_message_contains(&bytes, b"ROLLBACK") => { + Err(Error::Engine("scripted ROLLBACK failure".to_owned())) + } + _ => Ok(ProtocolResponse::new(bytes)), + } + } +} + +#[derive(Default)] +struct BlockingState { + inner: Mutex, + condvar: Condvar, +} + +#[derive(Default)] +struct BlockingStateInner { + active: bool, + released: bool, + cancelled: bool, + closed: bool, + calls: Vec>, +} + +impl BlockingState { + fn wait_until_active(&self) { + let deadline = Instant::now() + Duration::from_secs(2); + let mut guard = self.inner.lock().unwrap(); + while !guard.active { + let now = Instant::now(); + assert!(now < deadline, "blocking runtime did not become active"); + let timeout = deadline.saturating_duration_since(now); + let (next_guard, _) = self.condvar.wait_timeout(guard, timeout).unwrap(); + guard = next_guard; + } + } + + fn was_closed(&self) -> bool { + self.inner.lock().unwrap().closed + } + + fn was_cancelled(&self) -> bool { + self.inner.lock().unwrap().cancelled + } + + fn calls(&self) -> Vec> { + self.inner.lock().unwrap().calls.clone() + } + + fn release(&self) { + let mut guard = self.inner.lock().unwrap(); + guard.released = true; + self.condvar.notify_all(); + } +} + +struct BlockingRuntime { + state: Arc, +} + +impl NativeRuntime for BlockingRuntime { + fn open(&self, config: oliphaunt::OpenConfig) -> Result> { + Ok(Box::new(BlockingSession { + mode: config.mode, + state: Arc::clone(&self.state), + })) + } +} + +struct BlockingSession { + mode: EngineMode, + state: Arc, +} + +struct BlockingCancel { + state: Arc, +} + +impl EngineCancel for BlockingCancel { + fn cancel(&self) -> Result<()> { + let mut guard = self.state.inner.lock().unwrap(); + guard.cancelled = true; + self.state.condvar.notify_all(); + Ok(()) + } +} + +impl EngineSession for BlockingSession { + fn capabilities(&self) -> EngineCapabilities { + EngineCapabilities::for_mode(self.mode) + } + + fn cancel_handle(&self) -> Option> { + let cancel: Arc = Arc::new(BlockingCancel { + state: Arc::clone(&self.state), + }); + Some(cancel) + } + + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result { + let deadline = Instant::now() + Duration::from_secs(2); + let mut guard = self.state.inner.lock().unwrap(); + guard.calls.push(request.as_bytes().to_vec()); + guard.active = true; + self.state.condvar.notify_all(); + while !guard.cancelled && !guard.released { + let now = Instant::now(); + if now >= deadline { + return Err(Error::Engine( + "blocking runtime query was not released".to_owned(), + )); + } + let timeout = deadline.saturating_duration_since(now); + let (next_guard, _) = self.state.condvar.wait_timeout(guard, timeout).unwrap(); + guard = next_guard; + } + if guard.cancelled { + Ok(ProtocolResponse::new(b"cancelled".to_vec())) + } else { + Ok(ProtocolResponse::new(b"finished".to_vec())) + } + } + + fn close(&mut self) -> Result<()> { + let mut guard = self.state.inner.lock().unwrap(); + guard.closed = true; + self.state.condvar.notify_all(); + Ok(()) + } +} + +#[derive(Default)] +struct StreamingCancelState { + inner: Mutex, + condvar: Condvar, +} + +#[derive(Default)] +struct StreamingCancelStateInner { + streaming: bool, + cancelled: bool, +} + +impl StreamingCancelState { + fn wait_until_streaming(&self) { + let deadline = Instant::now() + Duration::from_secs(2); + let mut guard = self.inner.lock().unwrap(); + while !guard.streaming { + let now = Instant::now(); + assert!(now < deadline, "streaming runtime did not become active"); + let timeout = deadline.saturating_duration_since(now); + let (next_guard, _) = self.condvar.wait_timeout(guard, timeout).unwrap(); + guard = next_guard; + } + } + + fn was_stream_cancelled(&self) -> bool { + self.inner.lock().unwrap().cancelled + } +} + +struct StreamingCancelRuntime { + state: Arc, + cancels: Arc, +} + +impl NativeRuntime for StreamingCancelRuntime { + fn open(&self, config: oliphaunt::OpenConfig) -> Result> { + Ok(Box::new(StreamingCancelSession { + mode: config.mode, + state: Arc::clone(&self.state), + cancels: Arc::clone(&self.cancels), + })) + } +} + +struct StreamingCancelSession { + mode: EngineMode, + state: Arc, + cancels: Arc, +} + +struct StreamingCancelHandle { + state: Arc, + cancels: Arc, +} + +impl EngineCancel for StreamingCancelHandle { + fn cancel(&self) -> Result<()> { + self.cancels.fetch_add(1, Ordering::SeqCst); + let mut guard = self.state.inner.lock().unwrap(); + guard.cancelled = true; + self.state.condvar.notify_all(); + Ok(()) + } +} + +impl EngineSession for StreamingCancelSession { + fn capabilities(&self) -> EngineCapabilities { + EngineCapabilities::for_mode(self.mode) + } + + fn cancel_handle(&self) -> Option> { + let cancel: Arc = Arc::new(StreamingCancelHandle { + state: Arc::clone(&self.state), + cancels: Arc::clone(&self.cancels), + }); + Some(cancel) + } + + fn exec_protocol_raw(&mut self, request: ProtocolRequest) -> Result { + if request.as_bytes() == [b'r'] { + Ok(ProtocolResponse::new( + b"recovered-after-stream-cancel".to_vec(), + )) + } else { + Ok(ProtocolResponse::new(request.into_bytes())) + } + } + + fn exec_protocol_stream( + &mut self, + _request: ProtocolRequest, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, + ) -> Result<()> { + let chunk = vec![b'x'; 128 * 1024]; + on_chunk(&chunk)?; + + let deadline = Instant::now() + Duration::from_secs(2); + let mut guard = self.state.inner.lock().unwrap(); + guard.streaming = true; + self.state.condvar.notify_all(); + while !guard.cancelled { + let now = Instant::now(); + if now >= deadline { + return Err(Error::Engine( + "streaming runtime was not cancelled".to_owned(), + )); + } + let timeout = deadline.saturating_duration_since(now); + let (next_guard, _) = self.state.condvar.wait_timeout(guard, timeout).unwrap(); + guard = next_guard; + } + drop(guard); + + on_chunk(b"cancelled")?; + Ok(()) + } +} + +fn builder_for_mode(mode: EngineMode, path: impl Into) -> oliphaunt::OliphauntBuilder { + let builder = Oliphaunt::builder().path(path); + match mode { + EngineMode::NativeDirect => builder.native_direct(), + EngineMode::NativeBroker => builder.native_broker(), + EngineMode::NativeServer => builder.native_server(), + } +} + +fn open_scripted_transaction_db( + mode: EngineMode, + label: &str, + failure: ScriptedTransactionFailure, + calls: Arc>>>, +) -> Oliphaunt { + block_on( + builder_for_mode(mode, format!("target/test-roots/{mode}-{label}")) + .runtime(ScriptedTransactionRuntime { calls, failure }) + .open(), + ) + .unwrap() +} + +fn assert_commit_failure_rolls_back_and_releases(mode: EngineMode) { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = open_scripted_transaction_db( + mode, + "commit-failure-transaction", + ScriptedTransactionFailure::Commit, + Arc::clone(&calls), + ); + + let tx = block_on(db.transaction()).unwrap(); + block_on(tx.execute("INSERT INTO commit_failure VALUES (1)")).unwrap(); + let error = block_on(tx.commit()).unwrap_err(); + assert!( + error.to_string().contains("scripted COMMIT failure"), + "{mode} returned unexpected commit failure: {error}" + ); + block_on(db.execute("SELECT after failed commit")).unwrap(); + + let calls = calls.lock().unwrap(); + assert_call_order( + &calls, + &[ + b"BEGIN".as_slice(), + b"INSERT INTO commit_failure VALUES (1)", + b"COMMIT", + b"ROLLBACK", + b"SELECT after failed commit", + ], + &format!("{mode} commit failure cleanup"), + ); +} + +fn assert_rollback_failure_releases(mode: EngineMode) { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = open_scripted_transaction_db( + mode, + "rollback-failure-transaction", + ScriptedTransactionFailure::Rollback, + Arc::clone(&calls), + ); + + let tx = block_on(db.transaction()).unwrap(); + block_on(tx.execute("INSERT INTO rollback_failure VALUES (1)")).unwrap(); + let error = block_on(tx.rollback()).unwrap_err(); + assert!( + error.to_string().contains("scripted ROLLBACK failure"), + "{mode} returned unexpected rollback failure: {error}" + ); + block_on(db.execute("SELECT after failed rollback")).unwrap(); + + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .filter(|call| raw_message_contains(call, b"ROLLBACK")) + .count() + >= 2, + "{mode} failed rollback did not trigger best-effort cleanup rollback: {calls:?}" + ); + assert!( + raw_message_contains(calls.last().unwrap(), b"SELECT after failed rollback"), + "{mode} session was not released after rollback failure: {calls:?}" + ); +} + +fn assert_body_failure_preserves_error_when_rollback_fails(mode: EngineMode) { + let calls = Arc::new(Mutex::new(Vec::new())); + let db = open_scripted_transaction_db( + mode, + "body-and-rollback-failure-transaction", + ScriptedTransactionFailure::Rollback, + Arc::clone(&calls), + ); + + let error = block_on(db.with_transaction(async |tx| { + tx.execute("INSERT INTO body_failure VALUES (1)").await?; + Err::<(), Error>(Error::Engine("body failed".to_owned())) + })) + .unwrap_err(); + assert_eq!(error, Error::Engine("body failed".to_owned())); + block_on(db.execute("SELECT after body failure")).unwrap(); + + let calls = calls.lock().unwrap(); + assert!( + calls + .iter() + .any(|call| raw_message_contains(call, b"ROLLBACK")), + "{mode} body failure did not attempt rollback: {calls:?}" + ); + assert!( + raw_message_contains(calls.last().unwrap(), b"SELECT after body failure"), + "{mode} session was not released after body and rollback failure: {calls:?}" + ); +} + +fn assert_call_order(calls: &[Vec], needles: &[&[u8]], context: &str) { + let mut next_index = 0; + for needle in needles { + let Some(relative_index) = calls[next_index..] + .iter() + .position(|call| raw_message_contains(call, needle)) + else { + panic!( + "{context} did not find call containing {} after index {next_index}: {calls:?}", + String::from_utf8_lossy(needle) + ); + }; + next_index += relative_index + 1; + } +} + +fn unique_temp_root(prefix: &str) -> PathBuf { + let parent = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + for attempt in 0..100_u32 { + let path = parent.join(format!("{prefix}-{pid}-{nanos}-{attempt}")); + if !path.exists() { + return path; + } + } + panic!("failed to allocate a unique temp root for {prefix}"); +} + +fn minimal_physical_archive() -> oliphaunt::BackupArtifact { + let mut bytes = Vec::new(); + { + let mut archive = tar::Builder::new(&mut bytes); + append_test_archive_file(&mut archive, "pgdata/PG_VERSION", b"18\n"); + append_test_archive_file(&mut archive, "pgdata/global/pg_control", b"control"); + append_test_archive_file(&mut archive, "pgdata/backup_label", b"label"); + archive.finish().unwrap(); + } + oliphaunt::BackupArtifact { + format: BackupFormat::PhysicalArchive, + bytes, + } +} + +fn append_test_archive_file( + archive: &mut tar::Builder<&mut Vec>, + path: &str, + bytes: &'static [u8], +) { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o600); + header.set_cksum(); + archive + .append_data(&mut header, path, Cursor::new(bytes)) + .unwrap(); +} + +fn raw_message_contains(bytes: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && bytes.windows(needle.len()).any(|window| window == needle) +} + +fn block_on(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => return value, + Poll::Pending => thread::park_timeout(Duration::from_millis(1)), + } + } +} + +fn block_on_pinned(mut future: std::pin::Pin>) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => return value, + Poll::Pending => thread::park_timeout(Duration::from_millis(1)), + } + } +} + +fn poll_once_pending(future: &mut std::pin::Pin>) { + let waker = Waker::from(Arc::new(ThreadWaker(thread::current()))); + let mut context = Context::from_waker(&waker); + if future.as_mut().poll(&mut context).is_ready() { + panic!("future completed before the owner executor became available"); + } +} + +struct ThreadWaker(thread::Thread); + +impl Wake for ThreadWaker { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} diff --git a/src/sdks/rust/tools/check-sdk.sh b/src/sdks/rust/tools/check-sdk.sh new file mode 100755 index 00000000..1eb33ba6 --- /dev/null +++ b/src/sdks/rust/tools/check-sdk.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +. "$root/tools/runtime/preflight.sh" + +native_runtime_ready=0 +mode="${1:-release-check}" +scratch_base="${OLIPHAUNT_SDK_CHECK_SCRATCH:-$root/target/liboliphaunt-sdk-check/oliphaunt-rust}" + +case "$mode" in + check-static|test-unit|package-shape|smoke-runtime|regression|extension-regression|coverage|release-check) + ;; + "") + mode="release-check" + ;; + *) + echo "usage: src/sdks/rust/tools/check-sdk.sh [check-static|test-unit|package-shape|smoke-runtime|regression|extension-regression|coverage|release-check]" >&2 + exit 2 + ;; +esac + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +native_runtime_lock() { + run tools/runtime/with-native-runtime-lock.py "$@" +} + +prepare_scratch_dir() { + dir="$scratch_base/$mode/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s\n' "$dir" +} + +require_cargo_package_entry() { + listing="$1" + entry="$2" + if ! grep -Fxq "$entry" "$listing"; then + echo "Rust SDK package file list did not include $entry" >&2 + exit 1 + fi +} + +require_text() { + file="$1" + text="$2" + message="$3" + if ! grep -Fq -- "$text" "$file"; then + echo "$message" >&2 + echo "expected '$text' in $file" >&2 + exit 1 + fi +} + +reject_cargo_package_entry_pattern() { + listing="$1" + pattern="$2" + if grep -Eq "$pattern" "$listing"; then + echo "Rust SDK package file list included generated or product-external files matching $pattern" >&2 + exit 1 + fi +} + +check_release_asset_fixture() { + liboliphaunt_version="$(cat src/runtimes/liboliphaunt/native/VERSION)" + fixture_assets="$(prepare_scratch_dir liboliphaunt-release-assets)" + fixture_cache="$(prepare_scratch_dir liboliphaunt-release-cache)" + fixture_output="$(prepare_scratch_dir liboliphaunt-release-output)" + fixture_log="$scratch_base/$mode/liboliphaunt-release-assets.log" + run python3 tools/test/create-liboliphaunt-release-fixture.py \ + --asset-dir "$fixture_assets" \ + --version "$liboliphaunt_version" + run cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ + --resolve-release-assets \ + --liboliphaunt-native-version "$liboliphaunt_version" \ + --release-asset-base-url "file://$fixture_assets" \ + --release-target linux-x64-gnu \ + --release-asset-cache "$fixture_cache" \ + --output "$fixture_output" \ + --force >"$fixture_log" + cat "$fixture_log" + if ! grep -Fq "liboliphauntReleaseAssets=liboliphaunt-$liboliphaunt_version-linux-x64-gnu.tar.gz,liboliphaunt-$liboliphaunt_version-runtime-resources.tar.gz" "$fixture_log"; then + echo "Rust SDK release asset resolver did not select the expected release-shaped liboliphaunt assets" >&2 + exit 1 + fi + if [ ! -f "$fixture_output/oliphaunt/runtime/manifest.properties" ]; then + echo "Rust SDK release asset resolver did not extract runtime-resources into the output directory" >&2 + exit 1 + fi +} + +check_broker_release_asset_fixture() { + broker_version="$(python3 tools/release/product_metadata.py version oliphaunt-broker)" + fixture_assets="$(prepare_scratch_dir broker-release-assets)" + fixture_cache="$(prepare_scratch_dir broker-release-cache)" + fixture_output="$(prepare_scratch_dir broker-release-output)" + fixture_log="$scratch_base/$mode/broker-release-assets.log" + run python3 tools/test/create-broker-release-fixture.py \ + --asset-dir "$fixture_assets" \ + --version "$broker_version" + run cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ + --resolve-broker-release-assets \ + --broker-version "$broker_version" \ + --broker-release-asset-base-url "file://$fixture_assets" \ + --broker-release-target linux-x64-gnu \ + --broker-release-asset-cache "$fixture_cache" \ + --output "$fixture_output" \ + --force >"$fixture_log" + cat "$fixture_log" + if ! grep -Fq "oliphauntBrokerReleaseAssets=oliphaunt-broker-$broker_version-linux-x64-gnu.tar.gz" "$fixture_log"; then + echo "Rust SDK broker release asset resolver did not select the expected release-shaped broker asset" >&2 + exit 1 + fi + if [ ! -x "$fixture_output/bin/oliphaunt-broker" ]; then + echo "Rust SDK broker release asset resolver did not extract an executable broker helper" >&2 + exit 1 + fi + windows_fixture_output="$(prepare_scratch_dir broker-release-output-windows)" + windows_fixture_log="$scratch_base/$mode/broker-release-assets-windows.log" + run cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ + --resolve-broker-release-assets \ + --broker-version "$broker_version" \ + --broker-release-asset-base-url "file://$fixture_assets" \ + --broker-release-target windows-x64-msvc \ + --broker-release-asset-cache "$fixture_cache" \ + --output "$windows_fixture_output" \ + --force >"$windows_fixture_log" + cat "$windows_fixture_log" + if ! grep -Fq "oliphauntBrokerReleaseAssets=oliphaunt-broker-$broker_version-windows-x64-msvc.zip" "$windows_fixture_log"; then + echo "Rust SDK broker release asset resolver did not select the expected Windows broker asset" >&2 + exit 1 + fi + if [ ! -f "$windows_fixture_output/bin/oliphaunt-broker.exe" ]; then + echo "Rust SDK broker release asset resolver did not extract the Windows broker helper" >&2 + exit 1 + fi +} + +if ! command -v cargo >/dev/null 2>&1; then + echo "missing required command: cargo" >&2 + exit 1 +fi + +if [ "$mode" = "coverage" ]; then + exec tools/coverage/run-product oliphaunt-rust +fi + +if [ "$mode" = "check-static" ]; then + run cargo check -p oliphaunt --locked --all-targets + exit 0 +fi + +if [ "$mode" = "regression" ]; then + if ! oliphaunt_runtime_native_host_ready basic; then + oliphaunt_runtime_native_host_diagnostics basic + exit 1 + fi + native_runtime_lock cargo test -p oliphaunt --locked \ + --test native_sql_regression \ + -- \ + --test-threads=1 + exit 0 +fi + +if oliphaunt_runtime_native_host_ready extensions; then + native_runtime_ready=1 + echo "using existing native Oliphaunt runtime at $(oliphaunt_runtime_native_host_work_root)" +elif [ -n "${OLIPHAUNT_REQUIRE_NATIVE:-}" ]; then + oliphaunt_runtime_native_host_diagnostics extensions + exit 1 +else + echo "warning: native Oliphaunt runtime unavailable or incomplete; env-gated Rust SDK tests will skip" >&2 + oliphaunt_runtime_native_host_diagnostics extensions +fi + +if [ "$mode" = "smoke-runtime" ]; then + if [ "$native_runtime_ready" -ne 1 ]; then + oliphaunt_runtime_native_host_diagnostics extensions + exit 1 + fi + native_runtime_lock cargo test -p oliphaunt --locked --test sdk_native_smoke -- --test-threads=1 + exit 0 +fi + +if [ "$mode" = "extension-regression" ]; then + if [ "$native_runtime_ready" -ne 1 ]; then + oliphaunt_runtime_native_host_diagnostics extensions + exit 1 + fi + native_runtime_lock cargo test -p oliphaunt --locked \ + --test native_extensions \ + -- \ + --test-threads=1 + exit 0 +fi + +if [ "$mode" = "test-unit" ]; then + if ! cargo nextest --version >/dev/null 2>&1; then + echo "missing cargo-nextest; run tools/dev/bootstrap-tools.sh" >&2 + exit 1 + fi + require_text src/sdks/rust/tests/sdk_config_modes.rs "rust_handle_types_are_thread_safe_shared_executor_handles" \ + "Rust SDK tests must prove Oliphaunt handles remain thread-safe shared-executor handles" + require_text src/sdks/rust/tests/sdk_shape.rs "cloned_handles_share_one_serial_owner_executor" \ + "Rust SDK tests must prove cloned handles share one serial owner executor" + require_text src/sdks/rust/tests/protocol_query_fixtures.rs "query-response-cases.json" \ + "Rust SDK tests must consume the shared protocol fixture corpus" + run cargo test -p oliphaunt --doc --locked + native_runtime_lock cargo nextest run -p oliphaunt --locked --profile ci --no-tests=fail --test-threads=1 + exit 0 +fi + +package_listing="$root/target/liboliphaunt-sdk-check/rust-cargo-package-list.txt" +mkdir -p "$(dirname "$package_listing")" +printf '\n==> cargo package -p oliphaunt --locked --allow-dirty --list\n' +cargo package -p oliphaunt --locked --allow-dirty --list >"$package_listing" +cat "$package_listing" +for required in \ + Cargo.toml \ + README.md \ + ARCHITECTURE.md \ + src/lib.rs \ + src/database.rs \ + src/query.rs \ + src/runtime_resources.rs \ + src/bin/extension_artifact.rs \ + src/bin/extension_index.rs \ + src/bin/package_resources.rs \ + tests/sdk_config_modes.rs \ + tests/sdk_shape.rs \ + tests/sdk_extensions.rs \ + tests/sdk_native_smoke.rs \ + tests/native_sql_regression.rs +do + require_cargo_package_entry "$package_listing" "$required" +done +reject_cargo_package_entry_pattern "$package_listing" '^(target/|oliphaunt/|sdks/|src/bindings/wasix-rust/crates/oliphaunt-wasix/)' + +require_text src/sdks/rust/tests/sdk_config_modes.rs "rust_handle_types_are_thread_safe_shared_executor_handles" \ + "Rust SDK tests must prove Oliphaunt handles remain thread-safe shared-executor handles" +require_text src/sdks/rust/tests/sdk_config_modes.rs "direct_mode_rejects_fake_multi_session_pools" \ + "Rust SDK tests must reject fake direct-mode multi-session pools" +require_text src/sdks/rust/tests/sdk_config_modes.rs "broker_mode_rejects_fake_multi_session_pools" \ + "Rust SDK tests must reject fake broker-mode multi-session pools" +require_text src/sdks/rust/tests/sdk_config_modes.rs "server_mode_advertises_true_independent_sessions" \ + "Rust SDK tests must prove server mode is the independent-session mode" +require_text src/sdks/rust/tests/sdk_config_modes.rs "direct_broker_server_lifecycle_capabilities_are_honest" \ + "Rust SDK tests must lock direct/broker/server lifecycle capability semantics" +require_text src/sdks/rust/tests/sdk_shape.rs "cloned_handles_share_one_serial_owner_executor" \ + "Rust SDK tests must prove cloned handles share one serial owner executor" +require_text src/sdks/rust/tests/sdk_shape.rs "cloned_handles_share_pin_and_close_state_for_every_sdk_mode" \ + "Rust SDK tests must prove clones share pin and close state for direct/broker/server" +require_text src/sdks/rust/tests/sdk_shape.rs "cloned_handles_queue_fifo_on_one_owner_executor_for_every_sdk_mode" \ + "Rust SDK tests must prove cloned handles queue fairly on one owner executor for direct/broker/server" +require_text src/sdks/rust/tests/sdk_extensions.rs "native_extension_manifest_covers_every_supported_pg18_extension" \ + "Rust SDK extension tests must lock the PG18 extension manifest" +require_text src/sdks/rust/tests/sdk_extensions.rs "release_ready_extension_catalog_is_exact_and_excludes_external_candidates" \ + "Rust SDK extension tests must prevent external candidates from entering release packages implicitly" +require_text src/sdks/rust/tests/sdk_native_smoke.rs "native_liboliphaunt_runtime_select_one_when_env_is_available" \ + "Rust SDK native smoke tests must cover direct liboliphaunt runtime selection" +require_text src/sdks/rust/README.md "never creates an independent PostgreSQL connection" \ + "Rust SDK README must document clone/executor semantics" +require_text src/sdks/rust/README.md "shared executor runs FIFO" \ + "Rust SDK README must document FIFO executor semantics" +check_release_asset_fixture +check_broker_release_asset_fixture + +if [ "$mode" = "package-shape" ]; then + exit 0 +fi + +if ! cargo nextest --version >/dev/null 2>&1; then + echo "missing cargo-nextest; run tools/dev/bootstrap-tools.sh" >&2 + exit 1 +fi +run cargo test -p oliphaunt --doc --locked +native_runtime_lock cargo nextest run -p oliphaunt --locked --profile ci --no-tests=fail --test-threads=1 diff --git a/src/sdks/swift/.gitignore b/src/sdks/swift/.gitignore new file mode 100644 index 00000000..30bcfa4e --- /dev/null +++ b/src/sdks/swift/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/src/sdks/swift/.swift-format b/src/sdks/swift/.swift-format new file mode 100644 index 00000000..c96ebd3d --- /dev/null +++ b/src/sdks/swift/.swift-format @@ -0,0 +1,39 @@ +{ + "version": 1, + "lineLength": 100, + "indentation": { + "spaces": 4 + }, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "prioritizeKeepingFunctionOutputTogether": true, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": true, + "DoNotUseSemicolons": true, + "FileScopedDeclarationPrivacy": true, + "NoAccessLevelOnExtensionDeclaration": true, + "NoBlockComments": false, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoVoidReturnOnFunctionSignature": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false + } +} diff --git a/src/sdks/swift/.swiftlint.yml b/src/sdks/swift/.swiftlint.yml new file mode 100644 index 00000000..3d9a1a8f --- /dev/null +++ b/src/sdks/swift/.swiftlint.yml @@ -0,0 +1,43 @@ +included: + - Sources + - Tests +excluded: + - .build + - Vendor + - DerivedData +analyzer_rules: + - unused_import +opt_in_rules: + - array_init + - closure_end_indentation + - closure_spacing + - collection_alignment + - contains_over_filter_count + - contains_over_filter_is_empty + - empty_collection_literal + - empty_count + - empty_string + - explicit_init + - first_where + - last_where + - literal_expression_end_indentation + - modifier_order + - operator_usage_whitespace + - redundant_nil_coalescing + - sorted_imports + - toggle_bool + - unneeded_parentheses_in_closure_argument + - vertical_parameter_alignment_on_call +disabled_rules: + - file_length + - function_body_length + - type_body_length +line_length: + warning: 110 + error: 140 +identifier_name: + min_length: + warning: 2 +type_name: + min_length: + warning: 3 diff --git a/src/sdks/swift/CHANGELOG.md b/src/sdks/swift/CHANGELOG.md new file mode 100644 index 00000000..39633f1e --- /dev/null +++ b/src/sdks/swift/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + + +## [0.6.0] - 2026-06-01 + +### Changed + +- Initial Swift SDK release lane. +- Rename project to Oliphaunt +- Organize polyglot release tooling diff --git a/src/sdks/swift/LIBOLIPHAUNT_VERSION b/src/sdks/swift/LIBOLIPHAUNT_VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/src/sdks/swift/LIBOLIPHAUNT_VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/src/sdks/swift/Package.resolved b/src/sdks/swift/Package.resolved new file mode 100644 index 00000000..27cbbcca --- /dev/null +++ b/src/sdks/swift/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "8c244acbd3ccdcf991a6369b84da9c46c46243ca1b556ff767215dc3b1501c79", + "pins" : [ + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-plugin", + "state" : { + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + } + ], + "version" : 3 +} diff --git a/src/sdks/swift/Package.swift b/src/sdks/swift/Package.swift new file mode 100644 index 00000000..65ae0d9e --- /dev/null +++ b/src/sdks/swift/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Oliphaunt", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library(name: "Oliphaunt", targets: ["Oliphaunt"]) + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0") + ], + targets: [ + .target( + name: "COliphaunt", + publicHeadersPath: "include" + ), + .target( + name: "Oliphaunt", + dependencies: ["COliphaunt"] + ), + .testTarget( + name: "OliphauntTests", + dependencies: ["Oliphaunt"] + ) + ] +) diff --git a/src/sdks/swift/README.md b/src/sdks/swift/README.md new file mode 100644 index 00000000..2f3a57be --- /dev/null +++ b/src/sdks/swift/README.md @@ -0,0 +1,237 @@ +# Oliphaunt Swift SDK + +## Install + +Add Oliphaunt from Swift Package Manager: + +```text +dependencies: [ + .package(url: "https://github.com/f0rr0/oliphaunt.git", exact: "0.6.0") +] +``` + +Then add the `Oliphaunt` product to the iOS or macOS app target. Release tags +are source tags for the Swift API and are paired with compatible +`liboliphaunt-native-v` GitHub release assets, for example +`liboliphaunt-native-v0.1.0`. Those assets contain the base Apple XCFramework, +portable runtime resources, and checksum manifest. +CocoaPods trunk is not a release path for Oliphaunt. The SwiftPM release tag +resolves a generated manifest with a checksum-pinned `liboliphaunt` binary +target; the SDK auto-discovers the bundled runtime resources from that framework +for ordinary native-direct opens. +Normal iOS and macOS app consumers do not install Rust, run Cargo, build +PostgreSQL, or copy local Oliphaunt artifacts. SwiftPM resolves the Swift API +and checksum-pinned binary/runtime assets for the selected release. + +Optional PostgreSQL extensions are separate exact-extension release artifacts, +for example `oliphaunt-extension-vector` for `CREATE EXTENSION vector`. The base +Swift package does not publish hidden extension products or bundle unselected +extension files. Each exact-extension release artifact carries a +`manifest.properties` file that lists assets by `family`, `target`, and `kind` +so Swift and React Native iOS integrations can resolve only the matching +`ios-xcframework` or desktop runtime assets for the SQL extension names the app +requested and their manifest dependencies. + +React Native iOS uses this Swift SDK through the npm package and its config +plugin. It does not carry a second native database runtime. + +## Compatibility + +| SDK | Native core | Apple distribution | +| --- | --- | --- | +| `Oliphaunt` `0.6.0` | `liboliphaunt` `0.1.0` | SwiftPM source tag plus checksum-covered GitHub release assets | + +Exact extensions are selected by PostgreSQL SQL extension name and released as +separate exact-extension artifacts. Selecting `vector` must only fetch/link +`vector` artifacts and mandatory manifest dependencies; unselected extension +XCFrameworks and runtime files must not enter the app bundle. + +## Quickstart + + +```swift +let db = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + runtimeFootprint: .balancedMobile, + startupGUCs: [ + OliphauntStartupGUC("shared_buffers", "32MB") + ], + username: "postgres", + database: "postgres" + ) +) +let response = try await db.execProtocolRaw(simpleQueryBytes) +try await db.close() +``` + +Swift package for iOS and macOS apps on the native `liboliphaunt` product line. + +The public API is actor-based and mirrors the Rust SDK shape: open a database, +execute raw PostgreSQL protocol bytes, inspect capabilities, create SQL or +physical backup artifacts, restore same-version physical archives into an +explicit root, run transaction closures on the active physical session, request +PostgreSQL checkpoints, configure startup `username`/`database` identity, +cancel active work, and close. The package includes +`OliphauntNativeDirectEngine`, a C-ABI-backed native direct runtime that loads +`liboliphaunt` dynamically or resolves already-linked symbols. `OliphauntDatabase` +uses that native-direct engine by default for `.nativeDirect`; broker and server +still fail explicitly until those Swift runtimes are linked. +Use `OliphauntDatabase.supportedModes()` to discover that support before opening a +database; the returned entries include canonical direct/broker/server +capabilities and the reason unavailable modes are not currently openable. +Capabilities report the same product contract as Rust: raw and streaming +protocol support, cancellation, backup/restore, simple-query execution, +extensions, session semantics, multi-root support, and the concrete backup/restore formats +the opened mode accepts. Use `supportsBackupFormat(_:)` and +`supportsRestoreFormat(_:)` on either `OliphauntCapabilities` or `OliphauntDatabase` +for UI/action gating instead of manually matching arrays. `backup(_:)` enforces +those capabilities before it calls the native session, and +`OliphauntDatabase.restore` rejects unsupported restore artifact formats before it +calls the engine. Lifecycle capability fields follow the Rust contract: +`sameRootLogicalReopen`, `rootSwitchable`, and `crashRestartable` distinguish +direct's same-root resident reopen from broker/server process-managed behavior. +Native direct is not root-switchable or crash-restartable. Mobile direct mode +has one resident backend per app process and one physical session. Use server +mode only where the SDK reports true server support; it is not a +crash-isolated server and it does not provide independent concurrent client +sessions. + +Swift defaults to the mobile resident profile: `runtimeFootprint: +.balancedMobile` and `durability: .balanced`. Use `.safe` when last-commit +survival matters more than commit latency, `.throughput` for throughput-lane +diagnostics, or `.smallMobile` for memory-pressure experiments. `startupGUCs` +are validated and appended after the footprint and durability defaults so +profiling builds can override specific PostgreSQL GUCs without changing the +public ABI. + +For large responses or COPY-style traffic, stream backend protocol bytes through +the C ABI instead of materializing one owned response first: + + +```swift +try await db.execProtocolStream(simpleQueryBytes) { chunk in + consume(chunk) +} +``` + +For ordinary one-result-set SQL, use the typed simple-query helper: + + +```swift +let result = try await db.query("SELECT 1::text AS value") +let value = try result.getText(row: 0, column: "value") +``` + +`query(_:)` parses normal PostgreSQL backend protocol frames into field +metadata, rows, command tags, nulls, and structured PostgreSQL errors through +`OliphauntError.postgres(OliphauntPostgresError)`, preserving SQLSTATE and raw +`ErrorResponse` fields. Multi-result-set and COPY traffic stay on +`execProtocolRaw`. +Pass `parameters:` for PostgreSQL extended-protocol parameters: + +Use `transaction {}` for multi-step work that must stay on the same physical +session. Database calls outside the active `OliphauntTransaction` are rejected +until the transaction commits or rolls back. +Use `checkpoint()` to request a PostgreSQL checkpoint through the same actor +session; it is rejected while a transaction is active. + + +```swift +let result = try await db.query( + "SELECT $1::text AS value", + parameters: [.text("hello")] +) +``` + +## Local Development + +For local contributor tests from this repository: + +```bash +cd src/sdks/swift +swift test +``` + +To run the native C ABI smoke from Swift: + +```bash +LIBOLIPHAUNT_PATH=/path/to/liboliphaunt.dylib \ +OLIPHAUNT_INSTALL_DIR=/path/to/postgres/install \ +swift test +``` + +The native-direct env-backed test opens a temporary root, executes `SELECT 1` +through raw and streaming PostgreSQL protocol bytes, cancels an active +`pg_sleep`, creates a +same-version physical backup through the C ABI, restores it into a new root, and +closes the runtime. Exact extensions are accepted when +`OliphauntNativeDirectEngine` is constructed with a `runtimeDirectory` built with +those extensions, or with `OliphauntRuntimeResources` pointing at packaged runtime +resources whose manifest lists the requested extensions. Extension names are validated +before loading native code. + +For iOS and app-bundled macOS builds, package resources using this layout and +construct the engine with `OliphauntRuntimeResources(bundle:)` or +`OliphauntRuntimeResources(resourceRoot:)`: + +```text +oliphaunt/ + runtime/ + manifest.properties + files/ + template-pgdata/ + manifest.properties + files/ + PG_VERSION +``` + +Release automation publishes `liboliphaunt--runtime-resources.tar.gz` +with that layout and covers it in +`liboliphaunt--release-assets.sha256`. App integrations should consume +that artifact through the SwiftPM/RN package integration or through a clean +release asset resolver, never by asking app developers to build PostgreSQL from +this repository. +`runtime/manifest.properties` must include +`schema=oliphaunt-runtime-resources-v1`, +`layout=postgres-runtime-files-v1`, `cacheKey=`, and +`extensions=`. `template-pgdata` manifests must +use `layout=postgres-template-pgdata-v1`. Current packages also record +`sharedPreloadLibraries`, +`mobileStaticRegistryState`, and `mobileStaticRegistryPending`; iOS-family +targets reject selected extensions while that state is `pending`. The Swift SDK +rejects unknown package layouts, materializes runtime +files into Application Support using the cache key, and hydrates new PGDATA +roots from `template-pgdata/files`. +Apple mobile platforms require either a packaged template PGDATA or an existing +root with `PG_VERSION`; they do not rely on executing `initdb` from app storage. +When a selected extension contains native modules, the Swift package must +link those modules with the generated static-registry source. Complete Rust +runtime-resource generator output includes `static-registry/oliphaunt_static_registry.c`; the +Swift C bridge discovers `liboliphaunt_selected_static_extensions` and registers +the returned rows through `oliphaunt_register_static_extensions` before the first +database open. The manifest state is a release gate, not a loader substitute. +For release builds with exact prebuilt mobile archives, select SQL extension +names in the app integration. `OliphauntExtensionArtifactResolver` resolves the +exact release asset names for the selected SQL extension names, their manifest +dependencies, and the requested native target. The app package integration then +fetches or bundles only those resolved artifacts from the extension release: +`liboliphaunt_extension_.xcframework`, any selected +`liboliphaunt_dependency_.xcframework` dependencies carried by that +extension artifact, and the matching runtime manifest. Optional extension and +dependency XCFrameworks are not fetched, bundled, or linked unless the app +selected their exact PostgreSQL extension name. +The resolver fetches only those extension XCFrameworks and runtime files, so +unselected extension artifacts never enter the app bundle as an implementation +side effect. +The generated registry source strongly references selected extension magic and +SQL symbols. If an app selects `vector` but omits the matching prebuilt +`liboliphaunt_extension_vector.xcframework`, the build should fail rather than +shipping an app that fails later at `CREATE EXTENSION vector`. +The resource root also includes `package-size.tsv`; call +`OliphauntRuntimeResources.packageSizeReport()` to inspect total package bytes, +runtime/template/static-registry bytes, de-duplicated selected extension bytes, +and per-extension footprints before shipping an app bundle. + +Broker and server engines still follow the Rust SDK shape and fail explicitly +until those Swift runtimes are linked. diff --git a/src/sdks/swift/Sources/COliphaunt/bridge.c b/src/sdks/swift/Sources/COliphaunt/bridge.c new file mode 100644 index 00000000..28416d21 --- /dev/null +++ b/src/sdks/swift/Sources/COliphaunt/bridge.c @@ -0,0 +1,345 @@ +#include "COliphaunt.h" + +#include +#include +#include +#include +#include + +typedef int32_t (*OliphauntInitFn)(const OliphauntConfig *config, OliphauntHandle **out); +typedef int32_t (*OliphauntExecProtocolFn)( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +typedef int32_t (*OliphauntExecProtocolStreamFn)( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +typedef int32_t (*OliphauntCancelFn)(OliphauntHandle *handle); +typedef int32_t (*OliphauntDetachFn)(OliphauntHandle *handle); +typedef int32_t (*OliphauntCloseFn)(OliphauntHandle *handle); +typedef int32_t (*OliphauntRegisterStaticExtensionsFn)(const OliphauntStaticExtension *extensions, size_t count); +typedef const OliphauntStaticExtension *(*OliphauntSelectedStaticExtensionsFn)(size_t *count); +typedef const char *(*OliphauntLastErrorFn)(OliphauntHandle *handle); +typedef const char *(*OliphauntVersionFn)(void); +typedef uint64_t (*OliphauntCapabilitiesFn)(void); +typedef void (*OliphauntFreeResponseFn)(OliphauntResponse *response); +typedef int32_t (*OliphauntBackupFn)(OliphauntHandle *handle, uint32_t format, OliphauntResponse *out); +typedef int32_t (*OliphauntRestoreFn)(const OliphauntRestoreOptions *options); + +typedef struct OliphauntSymbols { + void *library; + bool owns_library; + OliphauntInitFn init; + OliphauntExecProtocolFn exec_protocol; + OliphauntExecProtocolStreamFn exec_protocol_stream; + OliphauntCancelFn cancel; + OliphauntDetachFn detach; + OliphauntCloseFn close; + OliphauntRegisterStaticExtensionsFn register_static_extensions; + OliphauntLastErrorFn last_error; + OliphauntVersionFn version; + OliphauntCapabilitiesFn capabilities; + OliphauntFreeResponseFn free_response; + OliphauntBackupFn backup; + OliphauntRestoreFn restore; +} OliphauntSymbols; + +struct OliphauntSession { + OliphauntSymbols symbols; + OliphauntHandle *handle; + char last_error[1024]; +}; + +static char global_last_error[1024]; + +static void set_global_error(const char *message) { + snprintf(global_last_error, sizeof(global_last_error), "%s", message ? message : "unknown liboliphaunt Swift bridge error"); +} + +static void set_session_error(OliphauntSession *session, const char *message) { + if (session == NULL) { + set_global_error(message); + return; + } + snprintf(session->last_error, sizeof(session->last_error), "%s", message ? message : "unknown liboliphaunt Swift bridge error"); +} + +static const char *env_library_path(void) { + const char *path = getenv("OLIPHAUNT_SWIFT_LIBRARY"); + if (path == NULL || path[0] == '\0') { + path = getenv("LIBOLIPHAUNT_PATH"); + } + if (path == NULL || path[0] == '\0') { + path = getenv("OLIPHAUNT_LIBRARY"); + } + return path != NULL && path[0] != '\0' ? path : NULL; +} + +static void *symbol_lookup_handle(OliphauntSymbols *symbols) { + return symbols->library != NULL ? symbols->library : RTLD_DEFAULT; +} + +static int load_symbol(OliphauntSymbols *symbols, const char *name, void **out) { + dlerror(); + *out = dlsym(symbol_lookup_handle(symbols), name); + const char *error = dlerror(); + if (error != NULL || *out == NULL) { + char message[1024]; + snprintf(message, sizeof(message), "liboliphaunt symbol %s is unavailable: %s", name, error ? error : "symbol not found"); + set_global_error(message); + return -1; + } + return 0; +} + +static void unload_symbols(OliphauntSymbols *symbols) { + /* + * liboliphaunt embeds PostgreSQL, which owns process-global runtime state + * while a backend session is active. Ordinary SDK close calls oliphaunt_detach; + * oliphaunt_close is terminal for the process lifetime. Unloading the code + * image can leave host-process callbacks or handlers pointing at unmapped + * addresses. Keep the native engine resident once it has been loaded. + */ + memset(symbols, 0, sizeof(*symbols)); +} + +static int load_symbols(const char *library_path, OliphauntSymbols *symbols) { + memset(symbols, 0, sizeof(*symbols)); + + const char *path = library_path != NULL && library_path[0] != '\0' + ? library_path + : env_library_path(); + if (path != NULL) { + symbols->library = dlopen(path, RTLD_NOW | RTLD_LOCAL); + if (symbols->library == NULL) { + char message[1024]; + snprintf(message, sizeof(message), "failed to load liboliphaunt at %s: %s", path, dlerror()); + set_global_error(message); + return -1; + } + symbols->owns_library = true; + } + + if (load_symbol(symbols, "oliphaunt_init", (void **)&symbols->init) != 0 || + load_symbol(symbols, "oliphaunt_exec_protocol", (void **)&symbols->exec_protocol) != 0 || + load_symbol(symbols, "oliphaunt_exec_protocol_stream", (void **)&symbols->exec_protocol_stream) != 0 || + load_symbol(symbols, "oliphaunt_cancel", (void **)&symbols->cancel) != 0 || + load_symbol(symbols, "oliphaunt_detach", (void **)&symbols->detach) != 0 || + load_symbol(symbols, "oliphaunt_close", (void **)&symbols->close) != 0 || + load_symbol(symbols, "oliphaunt_register_static_extensions", (void **)&symbols->register_static_extensions) != 0 || + load_symbol(symbols, "oliphaunt_last_error", (void **)&symbols->last_error) != 0 || + load_symbol(symbols, "oliphaunt_version", (void **)&symbols->version) != 0 || + load_symbol(symbols, "oliphaunt_capabilities", (void **)&symbols->capabilities) != 0 || + load_symbol(symbols, "oliphaunt_free_response", (void **)&symbols->free_response) != 0 || + load_symbol(symbols, "oliphaunt_backup", (void **)&symbols->backup) != 0 || + load_symbol(symbols, "oliphaunt_restore", (void **)&symbols->restore) != 0) { + unload_symbols(symbols); + return -1; + } + + return 0; +} + +static int register_selected_static_extensions(OliphauntSymbols *symbols) { + dlerror(); + OliphauntSelectedStaticExtensionsFn selected = NULL; + if (symbols->library != NULL) { + selected = (OliphauntSelectedStaticExtensionsFn)dlsym( + symbols->library, + "liboliphaunt_selected_static_extensions"); + const char *library_error = dlerror(); + if (library_error != NULL) { + selected = NULL; + } + dlerror(); + } + if (selected == NULL) { + selected = (OliphauntSelectedStaticExtensionsFn)dlsym( + RTLD_DEFAULT, + "liboliphaunt_selected_static_extensions"); + } + const char *error = dlerror(); + if (selected == NULL || error != NULL) { + return 0; + } + size_t count = 0; + const OliphauntStaticExtension *extensions = selected(&count); + if (count == 0) { + return 0; + } + if (extensions == NULL) { + set_global_error("selected liboliphaunt static extension registry returned null extensions"); + return -1; + } + if (symbols->register_static_extensions(extensions, count) != 0) { + const char *message = symbols->last_error != NULL ? symbols->last_error(NULL) : NULL; + set_global_error(message ? message : "liboliphaunt static extension registration failed"); + return -1; + } + return 0; +} + +int32_t oliphaunt_swift_open( + const char *library_path, + const OliphauntConfig *config, + OliphauntSession **out) { + if (out == NULL) { + set_global_error("oliphaunt_swift_open out parameter is null"); + return -1; + } + *out = NULL; + if (config == NULL) { + set_global_error("oliphaunt_swift_open config is null"); + return -1; + } + + OliphauntSession *session = (OliphauntSession *)calloc(1, sizeof(OliphauntSession)); + if (session == NULL) { + set_global_error("out of memory allocating OliphauntSession"); + return -1; + } + if (load_symbols(library_path, &session->symbols) != 0) { + free(session); + return -1; + } + if (register_selected_static_extensions(&session->symbols) != 0) { + unload_symbols(&session->symbols); + free(session); + return -1; + } + if (session->symbols.init(config, &session->handle) != 0) { + const char *error = session->symbols.last_error != NULL + ? session->symbols.last_error(session->handle) + : NULL; + set_global_error(error); + unload_symbols(&session->symbols); + free(session); + return -1; + } + + *out = session; + return 0; +} + +int32_t oliphaunt_swift_exec_protocol( + OliphauntSession *session, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out) { + if (session == NULL || out == NULL) { + set_session_error(session, "invalid oliphaunt_swift_exec_protocol arguments"); + return -1; + } + int32_t rc = session->symbols.exec_protocol(session->handle, request, request_len, out); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_swift_exec_protocol_stream( + OliphauntSession *session, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context) { + if (session == NULL || callback == NULL) { + set_session_error(session, "invalid oliphaunt_swift_exec_protocol_stream arguments"); + return -1; + } + int32_t rc = session->symbols.exec_protocol_stream( + session->handle, + request, + request_len, + callback, + callback_context); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_swift_backup(OliphauntSession *session, uint32_t format, OliphauntResponse *out) { + if (session == NULL || out == NULL) { + set_session_error(session, "invalid oliphaunt_swift_backup arguments"); + return -1; + } + int32_t rc = session->symbols.backup(session->handle, format, out); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_swift_restore(const char *library_path, const OliphauntRestoreOptions *options) { + OliphauntSymbols symbols; + if (load_symbols(library_path, &symbols) != 0) { + return -1; + } + int32_t rc = symbols.restore(options); + if (rc != 0 && symbols.last_error != NULL) { + set_global_error(symbols.last_error(NULL)); + } + unload_symbols(&symbols); + return rc; +} + +int32_t oliphaunt_swift_cancel(OliphauntSession *session) { + if (session == NULL) { + set_global_error("invalid oliphaunt_swift_cancel arguments"); + return -1; + } + int32_t rc = session->symbols.cancel(session->handle); + if (rc != 0 && session->symbols.last_error != NULL) { + set_session_error(session, session->symbols.last_error(session->handle)); + } + return rc; +} + +int32_t oliphaunt_swift_close(OliphauntSession *session) { + if (session == NULL) { + return 0; + } + int32_t rc = 0; + if (session->symbols.detach != NULL && session->handle != NULL) { + rc = session->symbols.detach(session->handle); + if (rc != 0 && session->symbols.last_error != NULL) { + const char *message = session->symbols.last_error(session->handle); + set_session_error(session, message); + set_global_error(message); + } + session->handle = NULL; + } + unload_symbols(&session->symbols); + free(session); + return rc; +} + +const char *oliphaunt_swift_last_error(OliphauntSession *session) { + return session != NULL ? session->last_error : global_last_error; +} + +const char *oliphaunt_swift_version(OliphauntSession *session) { + if (session == NULL || session->symbols.version == NULL) { + return ""; + } + return session->symbols.version(); +} + +uint64_t oliphaunt_swift_capabilities(OliphauntSession *session) { + if (session == NULL || session->symbols.capabilities == NULL) { + return 0; + } + return session->symbols.capabilities(); +} + +void oliphaunt_swift_free_response(OliphauntSession *session, OliphauntResponse *response) { + if (session == NULL || response == NULL || session->symbols.free_response == NULL) { + return; + } + session->symbols.free_response(response); +} diff --git a/src/sdks/swift/Sources/COliphaunt/empty.c b/src/sdks/swift/Sources/COliphaunt/empty.c new file mode 100644 index 00000000..52f3bd77 --- /dev/null +++ b/src/sdks/swift/Sources/COliphaunt/empty.c @@ -0,0 +1 @@ +#include "COliphaunt.h" diff --git a/src/sdks/swift/Sources/COliphaunt/include/COliphaunt.h b/src/sdks/swift/Sources/COliphaunt/include/COliphaunt.h new file mode 100644 index 00000000..5c8fd149 --- /dev/null +++ b/src/sdks/swift/Sources/COliphaunt/include/COliphaunt.h @@ -0,0 +1,32 @@ +#ifndef C_OLIPHAUNT_H +#define C_OLIPHAUNT_H + +#include "oliphaunt.h" + +typedef struct OliphauntSession OliphauntSession; + +int32_t oliphaunt_swift_open( + const char *library_path, + const OliphauntConfig *config, + OliphauntSession **out); +int32_t oliphaunt_swift_exec_protocol( + OliphauntSession *session, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +int32_t oliphaunt_swift_exec_protocol_stream( + OliphauntSession *session, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +int32_t oliphaunt_swift_backup(OliphauntSession *session, uint32_t format, OliphauntResponse *out); +int32_t oliphaunt_swift_restore(const char *library_path, const OliphauntRestoreOptions *options); +int32_t oliphaunt_swift_cancel(OliphauntSession *session); +int32_t oliphaunt_swift_close(OliphauntSession *session); +const char *oliphaunt_swift_last_error(OliphauntSession *session); +const char *oliphaunt_swift_version(OliphauntSession *session); +uint64_t oliphaunt_swift_capabilities(OliphauntSession *session); +void oliphaunt_swift_free_response(OliphauntSession *session, OliphauntResponse *response); + +#endif diff --git a/src/sdks/swift/Sources/COliphaunt/include/module.modulemap b/src/sdks/swift/Sources/COliphaunt/include/module.modulemap new file mode 100644 index 00000000..6ba58ac7 --- /dev/null +++ b/src/sdks/swift/Sources/COliphaunt/include/module.modulemap @@ -0,0 +1,4 @@ +module COliphaunt { + umbrella header "COliphaunt.h" + export * +} diff --git a/src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h b/src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h new file mode 100644 index 00000000..262d46d5 --- /dev/null +++ b/src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h @@ -0,0 +1,172 @@ +#ifndef OLIPHAUNT_H +#define OLIPHAUNT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define OLIPHAUNT_ABI_VERSION 6u +#define OLIPHAUNT_STATIC_EXTENSION_ABI_VERSION 1u + +#define OLIPHAUNT_CAP_PROTOCOL_RAW (1ull << 0) +#define OLIPHAUNT_CAP_PROTOCOL_STREAM (1ull << 1) +#define OLIPHAUNT_CAP_MULTI_INSTANCE (1ull << 2) +#define OLIPHAUNT_CAP_SERVER_MODE (1ull << 3) +#define OLIPHAUNT_CAP_EXTENSIONS (1ull << 4) +#define OLIPHAUNT_CAP_QUERY_CANCEL (1ull << 5) +#define OLIPHAUNT_CAP_BACKUP_RESTORE (1ull << 6) +#define OLIPHAUNT_CAP_SIMPLE_QUERY (1ull << 7) +#define OLIPHAUNT_CAP_STATIC_EXTENSIONS (1ull << 8) +#define OLIPHAUNT_CAP_LOGICAL_REOPEN (1ull << 9) + +#define OLIPHAUNT_BACKUP_FORMAT_SQL 1u +#define OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE 2u +#define OLIPHAUNT_BACKUP_FORMAT_OLIPHAUNT_ARCHIVE 3u + +#if defined(_WIN32) && defined(OLIPHAUNT_BUILDING_DLL) +#define OLIPHAUNT_API __declspec(dllexport) +#elif defined(_WIN32) +#define OLIPHAUNT_API __declspec(dllimport) +#else +#define OLIPHAUNT_API +#endif + +/* + * The caller already owns an equivalent root lock for this PGDATA path. + * + * Leave this flag unset for plain C, Swift, Kotlin, and other direct C ABI + * callers; oliphaunt_init will then take a non-blocking stable filesystem lease + * for and create /.oliphaunt.lock as the + * visible root marker. The Rust SDK sets this flag because it owns a stronger + * process-plus-filesystem root coordinator across direct, broker, server, + * backup, and restore paths. + */ +#define OLIPHAUNT_CONFIG_EXTERNAL_ROOT_LOCK (1ull << 0) + +#define OLIPHAUNT_RESTORE_REPLACE_EXISTING (1ull << 0) + +typedef struct OliphauntHandle OliphauntHandle; + +typedef struct OliphauntStaticExtensionSymbol { + const char *name; + void *address; +} OliphauntStaticExtensionSymbol; + +typedef struct OliphauntStaticExtension { + uint32_t abi_version; + const char *name; + const void *(*magic)(void); + void (*init)(void); + const OliphauntStaticExtensionSymbol *symbols; + size_t symbol_count; + uint64_t reserved_flags; +} OliphauntStaticExtension; + +/* + * Registers statically linked PostgreSQL extension modules for the embedded + * backend's normal LOAD path. + * + * Call this before oliphaunt_init in processes that link extension code directly + * into the application or SDK library. The registry is process-wide and becomes + * immutable once backend startup begins. Each extension name is the module stem + * used by SQL, for example AS 'vector', and each symbol row exposes the C + * symbols PostgreSQL would otherwise resolve with dlsym(). + */ + +/* + * Direct-mode extension compatibility contract: + * + * oliphaunt_init sets the process PGDATA environment variable to this config's + * pgdata path while the embedded backend is active, because PostgreSQL + * extensions may read PGDATA through standard process APIs. oliphaunt_detach + * releases a logical direct-mode lease but keeps the resident backend alive; + * oliphaunt_close is terminal for the process lifetime and restores the caller's + * previous PGDATA value, or unsets it if it was unset. + * + * Callers that require process environment isolation should use broker/server + * mode through the Rust SDK instead of keeping multiple direct-mode backends in + * one process. + */ +typedef struct OliphauntConfig { + uint32_t abi_version; + const char *pgdata; + const char *runtime_dir; + const char *username; + const char *database; + uint64_t reserved_flags; + const char *const *startup_args; + size_t startup_arg_count; +} OliphauntConfig; + +typedef struct OliphauntResponse { + uint8_t *data; + size_t len; +} OliphauntResponse; + +typedef struct OliphauntArchiveFile { + const char *path; + const uint8_t *data; + size_t len; + uint32_t mode; + uint64_t reserved_flags; +} OliphauntArchiveFile; + +typedef struct OliphauntBackupOptions { + uint32_t abi_version; + uint32_t format; + const OliphauntArchiveFile *generated_files; + size_t generated_file_count; + uint64_t reserved_flags; +} OliphauntBackupOptions; + +typedef struct OliphauntRestoreOptions { + uint32_t abi_version; + const char *root; + uint32_t format; + const uint8_t *data; + size_t len; + uint64_t flags; +} OliphauntRestoreOptions; + +typedef int32_t (*OliphauntStreamCallback)(void *context, const uint8_t *data, size_t len); + +OLIPHAUNT_API int32_t oliphaunt_init(const OliphauntConfig *config, OliphauntHandle **out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_simple_query( + OliphauntHandle *handle, + const char *sql, + size_t sql_len, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_exec_protocol_stream( + OliphauntHandle *handle, + const uint8_t *request, + size_t request_len, + OliphauntStreamCallback callback, + void *callback_context); +OLIPHAUNT_API int32_t oliphaunt_backup(OliphauntHandle *handle, uint32_t format, OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_backup_ex( + OliphauntHandle *handle, + const OliphauntBackupOptions *options, + OliphauntResponse *out); +OLIPHAUNT_API int32_t oliphaunt_restore(const OliphauntRestoreOptions *options); +OLIPHAUNT_API int32_t oliphaunt_cancel(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_detach(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_close(OliphauntHandle *handle); +OLIPHAUNT_API int32_t oliphaunt_register_static_extensions(const OliphauntStaticExtension *extensions, size_t count); +OLIPHAUNT_API const char *oliphaunt_last_error(OliphauntHandle *handle); +OLIPHAUNT_API const char *oliphaunt_version(void); +OLIPHAUNT_API uint64_t oliphaunt_capabilities(void); +OLIPHAUNT_API void oliphaunt_free_response(OliphauntResponse *response); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift b/src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift new file mode 100644 index 00000000..01b5982d --- /dev/null +++ b/src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift @@ -0,0 +1,855 @@ +import Foundation + +public enum OliphauntEngineMode: String, Sendable { + case nativeDirect + case nativeBroker + case nativeServer +} + +public enum OliphauntDurability: String, Sendable { + case safe + case balanced + case fastDev +} + +public enum OliphauntRuntimeFootprintProfile: String, Sendable { + case throughput + case balancedMobile + case smallMobile +} + +public struct OliphauntStartupGUC: Equatable, Sendable { + public var name: String + public var value: String + + public init(_ name: String, _ value: String) { + self.name = name + self.value = value + } +} + +public struct OliphauntCapabilities: Equatable, Sendable { + public var mode: OliphauntEngineMode + public var processIsolated: Bool + public var multiRoot: Bool + public var reopenable: Bool + public var sameRootLogicalReopen: Bool + public var rootSwitchable: Bool + public var crashRestartable: Bool + public var independentSessions: Bool + public var maxClientSessions: Int + public var protocolRaw: Bool + public var protocolStream: Bool + public var queryCancel: Bool + public var backupRestore: Bool + public var backupFormats: [OliphauntBackupFormat] + public var restoreFormats: [OliphauntBackupFormat] + public var simpleQuery: Bool + public var extensions: Bool + public var connectionString: String? + + public init( + mode: OliphauntEngineMode, + processIsolated: Bool, + multiRoot: Bool = false, + reopenable: Bool? = nil, + sameRootLogicalReopen: Bool? = nil, + rootSwitchable: Bool? = nil, + crashRestartable: Bool = false, + independentSessions: Bool, + maxClientSessions: Int, + protocolRaw: Bool = true, + protocolStream: Bool = true, + queryCancel: Bool = true, + backupRestore: Bool = true, + backupFormats: [OliphauntBackupFormat] = [.physicalArchive], + restoreFormats: [OliphauntBackupFormat] = [.physicalArchive], + simpleQuery: Bool = true, + extensions: Bool = true, + connectionString: String? = nil + ) { + self.mode = mode + self.processIsolated = processIsolated + self.multiRoot = multiRoot + let effectiveReopenable = reopenable ?? processIsolated + self.reopenable = effectiveReopenable + self.sameRootLogicalReopen = sameRootLogicalReopen ?? (!processIsolated && effectiveReopenable) + self.rootSwitchable = rootSwitchable ?? processIsolated + self.crashRestartable = crashRestartable + self.independentSessions = independentSessions + self.maxClientSessions = maxClientSessions + self.protocolRaw = protocolRaw + self.protocolStream = protocolStream + self.queryCancel = queryCancel + self.backupRestore = backupRestore + self.backupFormats = backupFormats + self.restoreFormats = restoreFormats + self.simpleQuery = simpleQuery + self.extensions = extensions + self.connectionString = connectionString + } + + public func supportsBackupFormat(_ format: OliphauntBackupFormat) -> Bool { + backupRestore && backupFormats.contains(format) + } + + public func supportsRestoreFormat(_ format: OliphauntBackupFormat) -> Bool { + backupRestore && restoreFormats.contains(format) + } +} + +public struct OliphauntEngineModeSupport: Equatable, Sendable { + public var mode: OliphauntEngineMode + public var available: Bool + public var capabilities: OliphauntCapabilities + public var unavailableReason: String? + + public init( + mode: OliphauntEngineMode, + available: Bool, + capabilities: OliphauntCapabilities, + unavailableReason: String? = nil + ) { + self.mode = mode + self.available = available + self.capabilities = capabilities + self.unavailableReason = unavailableReason + } +} + +public enum OliphauntSDKSupport { + public static let allModes: [OliphauntEngineMode] = [ + .nativeDirect, + .nativeBroker, + .nativeServer, + ] + + public static func capabilities(for mode: OliphauntEngineMode) -> OliphauntCapabilities { + switch mode { + case .nativeDirect: + OliphauntCapabilities( + mode: mode, + processIsolated: false, + reopenable: true, + sameRootLogicalReopen: true, + rootSwitchable: false, + crashRestartable: false, + independentSessions: false, + maxClientSessions: 1 + ) + case .nativeBroker: + OliphauntCapabilities( + mode: mode, + processIsolated: true, + multiRoot: true, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: true, + independentSessions: false, + maxClientSessions: 1 + ) + case .nativeServer: + OliphauntCapabilities( + mode: mode, + processIsolated: true, + reopenable: true, + sameRootLogicalReopen: false, + rootSwitchable: true, + crashRestartable: false, + independentSessions: true, + maxClientSessions: 32, + backupFormats: [.sql, .physicalArchive] + ) + } + } + + public static func nativeDirectOnly( + brokerReason: String, + serverReason: String + ) -> [OliphauntEngineModeSupport] { + [ + OliphauntEngineModeSupport( + mode: .nativeDirect, + available: true, + capabilities: capabilities(for: .nativeDirect) + ), + OliphauntEngineModeSupport( + mode: .nativeBroker, + available: false, + capabilities: capabilities(for: .nativeBroker), + unavailableReason: brokerReason + ), + OliphauntEngineModeSupport( + mode: .nativeServer, + available: false, + capabilities: capabilities(for: .nativeServer), + unavailableReason: serverReason + ), + ] + } + + public static func unavailable(reason: String) -> [OliphauntEngineModeSupport] { + allModes.map { mode in + OliphauntEngineModeSupport( + mode: mode, + available: false, + capabilities: capabilities(for: mode), + unavailableReason: reason + ) + } + } +} + +public struct OliphauntConfiguration: Equatable, Sendable { + public var mode: OliphauntEngineMode + public var root: URL? + public var durability: OliphauntDurability + public var runtimeFootprint: OliphauntRuntimeFootprintProfile + public var startupGUCs: [OliphauntStartupGUC] + public var username: String? + public var database: String? + public var extensions: [String] + + public init( + mode: OliphauntEngineMode = .nativeDirect, + root: URL? = nil, + durability: OliphauntDurability = .balanced, + runtimeFootprint: OliphauntRuntimeFootprintProfile = .balancedMobile, + startupGUCs: [OliphauntStartupGUC] = [], + username: String? = nil, + database: String? = nil, + extensions: [String] = [] + ) { + self.mode = mode + self.root = root + self.durability = durability + self.runtimeFootprint = runtimeFootprint + self.startupGUCs = startupGUCs + self.username = username + self.database = database + self.extensions = extensions + } +} + +func validateOliphauntStartupIdentity(_ value: String?, label: String) throws { + guard let value else { + return + } + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw OliphauntError.engine("\(label) must not be empty") + } + if value.utf8.contains(0) { + throw OliphauntError.engine("\(label) must not contain NUL bytes") + } +} + +func validateOliphauntStartupGUCs(_ gucs: [OliphauntStartupGUC]) throws { + for guc in gucs { + let name = guc.name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { + throw OliphauntError.engine("PostgreSQL startup GUC name must not be empty") + } + if name.utf8.contains(0) || guc.value.utf8.contains(0) { + throw OliphauntError.engine("PostgreSQL startup GUC must not contain NUL bytes") + } + if !name.utf8.allSatisfy({ byte in + (byte >= 65 && byte <= 90) || + (byte >= 97 && byte <= 122) || + (byte >= 48 && byte <= 57) || + byte == 95 || + byte == 46 + }) { + throw OliphauntError.engine( + "PostgreSQL startup GUC name '\(guc.name)' must contain only ASCII letters, digits, '_' or '.'" + ) + } + if guc.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw OliphauntError.engine("PostgreSQL startup GUC '\(guc.name)' value must not be empty") + } + } +} + +func validateOliphauntRoot(_ root: URL?, label: String) throws { + guard let root else { + return + } + guard root.isFileURL else { + throw OliphauntError.engine("\(label) must be a file URL") + } + if root.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw OliphauntError.engine("\(label) must not be empty") + } + if root.path.utf8.contains(0) || + root.absoluteString.range(of: "%00", options: .caseInsensitive) != nil { + throw OliphauntError.engine("\(label) must not contain NUL bytes") + } +} + +public enum OliphauntBackupFormat: String, Sendable { + case sql + case physicalArchive + case oliphauntArchive +} + +public struct OliphauntBackupRequest: Equatable, Sendable { + public var format: OliphauntBackupFormat + + public init(format: OliphauntBackupFormat = .physicalArchive) { + self.format = format + } +} + +public struct OliphauntBackupArtifact: Equatable, Sendable { + public var format: OliphauntBackupFormat + public var bytes: Data + + public init(format: OliphauntBackupFormat, bytes: Data) { + self.format = format + self.bytes = bytes + } +} + +public enum OliphauntRestoreTargetPolicy: String, Sendable { + case failIfExists + case replaceExisting +} + +public struct OliphauntRestoreRequest: Equatable, Sendable { + public var artifact: OliphauntBackupArtifact + public var root: URL + public var targetPolicy: OliphauntRestoreTargetPolicy + + public init( + artifact: OliphauntBackupArtifact, + root: URL, + targetPolicy: OliphauntRestoreTargetPolicy = .failIfExists + ) { + self.artifact = artifact + self.root = root + self.targetPolicy = targetPolicy + } + + public func replaceExisting() -> OliphauntRestoreRequest { + OliphauntRestoreRequest( + artifact: artifact, + root: root, + targetPolicy: .replaceExisting + ) + } +} + +public struct OliphauntBackgroundPreparationOptions: Equatable, Sendable { + public var cancelActiveWork: Bool + public var checkpointWhenIdle: Bool + + public init( + cancelActiveWork: Bool = true, + checkpointWhenIdle: Bool = true + ) { + self.cancelActiveWork = cancelActiveWork + self.checkpointWhenIdle = checkpointWhenIdle + } +} + +public enum OliphauntBackgroundCheckpointSkipReason: String, Equatable, Sendable { + case activeWork + case transactionActive +} + +public struct OliphauntBackgroundPreparationResult: Equatable, Sendable { + public var cancelledActiveWork: Bool + public var checkpointed: Bool + public var skippedCheckpointReason: OliphauntBackgroundCheckpointSkipReason? + + public init( + cancelledActiveWork: Bool, + checkpointed: Bool, + skippedCheckpointReason: OliphauntBackgroundCheckpointSkipReason? = nil + ) { + self.cancelledActiveWork = cancelledActiveWork + self.checkpointed = checkpointed + self.skippedCheckpointReason = skippedCheckpointReason + } +} + +public enum OliphauntError: Error, Equatable, Sendable, CustomStringConvertible { + case runtimeUnavailable(OliphauntEngineMode) + case databaseClosed + case engine(String) + case postgres(OliphauntPostgresError) + + public var description: String { + switch self { + case .runtimeUnavailable(let mode): + "no Oliphaunt runtime is linked for \(mode)" + case .databaseClosed: + "database is closed" + case .engine(let message): + message + case .postgres(let error): + error.description + } + } +} + +public protocol OliphauntEngine: Sendable { + func open(configuration: OliphauntConfiguration) async throws -> any OliphauntSession + func restore(_ request: OliphauntRestoreRequest) async throws -> URL +} + +public protocol OliphauntEngineSupportProvider: Sendable { + var supportedModes: [OliphauntEngineModeSupport] { get } +} + +public protocol OliphauntSession: Sendable { + func capabilities() async -> OliphauntCapabilities + func execProtocolRaw(_ bytes: Data) async throws -> Data + func execProtocolStream( + _ bytes: Data, + onChunk: @escaping @Sendable (Data) throws -> Void + ) async throws + func backup(_ request: OliphauntBackupRequest) async throws -> OliphauntBackupArtifact + func cancel() async throws + func close() async throws +} + +public extension OliphauntSession { + func execProtocolStream( + _ bytes: Data, + onChunk: @escaping @Sendable (Data) throws -> Void + ) async throws { + try onChunk(try await execProtocolRaw(bytes)) + } +} + +public struct RuntimeUnavailableEngine: OliphauntEngine, OliphauntEngineSupportProvider { + public init() {} + + public var supportedModes: [OliphauntEngineModeSupport] { + OliphauntSDKSupport.unavailable(reason: "no native Oliphaunt runtime is linked") + } + + public func open(configuration: OliphauntConfiguration) async throws -> any OliphauntSession { + throw OliphauntError.runtimeUnavailable(configuration.mode) + } + + public func restore(_ request: OliphauntRestoreRequest) async throws -> URL { + throw OliphauntError.engine( + "no native Oliphaunt restore runtime is linked for \(request.artifact.format.rawValue)" + ) + } +} + +public struct OliphauntDefaultEngine: OliphauntEngine, OliphauntEngineSupportProvider { + public static let brokerUnavailableReason = + "Swift broker mode requires a platform broker adapter; it is not aliased to direct mode" + public static let serverUnavailableReason = + "Swift server mode requires a platform server adapter; it is not aliased to direct mode" + + public init() {} + + public var supportedModes: [OliphauntEngineModeSupport] { + OliphauntSDKSupport.nativeDirectOnly( + brokerReason: Self.brokerUnavailableReason, + serverReason: Self.serverUnavailableReason + ) + } + + public func open(configuration: OliphauntConfiguration) async throws -> any OliphauntSession { + switch configuration.mode { + case .nativeDirect: + return try await OliphauntNativeDirectEngine().open(configuration: configuration) + case .nativeBroker, .nativeServer: + throw OliphauntError.runtimeUnavailable(configuration.mode) + } + } + + public func restore(_ request: OliphauntRestoreRequest) async throws -> URL { + try await OliphauntNativeDirectEngine().restore(request) + } +} + +private actor OliphauntAsyncSerialGate { + private var locked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !locked { + locked = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if waiters.isEmpty { + locked = false + } else { + waiters.removeFirst().resume() + } + } +} + +public actor OliphauntDatabase { + private var session: (any OliphauntSession)? + private var activeTransactionToken: UInt64? + private var nextTransactionToken: UInt64 = 1 + private var activeOperationCount: Int = 0 + private let operationGate = OliphauntAsyncSerialGate() + + private init(session: any OliphauntSession) { + self.session = session + } + + public static func open( + configuration: OliphauntConfiguration, + engine: any OliphauntEngine = OliphauntDefaultEngine() + ) async throws -> OliphauntDatabase { + try validateOliphauntRoot(configuration.root, label: "database root") + try validateOliphauntStartupIdentity(configuration.username, label: "username") + try validateOliphauntStartupIdentity(configuration.database, label: "database") + try validateOliphauntStartupGUCs(configuration.startupGUCs) + var normalized = configuration + normalized.extensions = try OliphauntRuntimeResources.normalizedExtensionIds( + configuration.extensions + ) + let session = try await engine.open(configuration: normalized) + return OliphauntDatabase(session: session) + } + + public static func restore( + _ request: OliphauntRestoreRequest, + engine: any OliphauntEngine = OliphauntDefaultEngine() + ) async throws -> URL { + try validateOliphauntRoot(request.root, label: "restore root") + guard request.artifact.format == .physicalArchive else { + throw OliphauntError.engine( + "restore currently requires a physicalArchive artifact, got \(request.artifact.format.rawValue)" + ) + } + return try await engine.restore(request) + } + + public static func supportedModes( + engine: any OliphauntEngine = OliphauntDefaultEngine() + ) -> [OliphauntEngineModeSupport] { + guard let supportProvider = engine as? any OliphauntEngineSupportProvider else { + return OliphauntSDKSupport.unavailable( + reason: "engine does not publish static mode support" + ) + } + return supportProvider.supportedModes + } + + public func capabilities() async throws -> OliphauntCapabilities { + try await runSessionOperation(allowDuringTransaction: true) { session in + await session.capabilities() + } + } + + public func connectionString() async throws -> String? { + try await capabilities().connectionString + } + + public func supportsBackupFormat(_ format: OliphauntBackupFormat) async throws -> Bool { + try await capabilities().supportsBackupFormat(format) + } + + public func supportsRestoreFormat(_ format: OliphauntBackupFormat) async throws -> Bool { + try await capabilities().supportsRestoreFormat(format) + } + + public func execProtocolRaw(_ bytes: Data) async throws -> Data { + try await execProtocolRaw(bytes, transactionToken: nil) + } + + public func execProtocolStream( + _ bytes: Data, + onChunk: @escaping @Sendable (Data) throws -> Void + ) async throws { + try await execProtocolStream(bytes, transactionToken: nil, onChunk: onChunk) + } + + public func backup(_ request: OliphauntBackupRequest = OliphauntBackupRequest()) async throws -> OliphauntBackupArtifact { + try validateTransactionAccess(token: nil) + return try await runSessionOperation { session in + let capabilities = await session.capabilities() + guard capabilities.supportsBackupFormat(request.format) else { + throw OliphauntError.engine( + "\(request.format.rawValue) backup is not supported by \(capabilities.mode.rawValue)" + ) + } + return try await session.backup(request) + } + } + + public func checkpoint() async throws { + _ = try await execProtocolRaw(try OliphauntProtocol.simpleQuery("CHECKPOINT"), transactionToken: nil) + } + + public func prepareForBackground( + _ options: OliphauntBackgroundPreparationOptions = OliphauntBackgroundPreparationOptions() + ) async throws -> OliphauntBackgroundPreparationResult { + let session = try liveSession() + let hadActiveWork = activeOperationCount > 0 + let cancelledActiveWork: Bool + if options.cancelActiveWork && hadActiveWork { + try await session.cancel() + cancelledActiveWork = true + } else { + cancelledActiveWork = false + } + + guard options.checkpointWhenIdle else { + return OliphauntBackgroundPreparationResult( + cancelledActiveWork: cancelledActiveWork, + checkpointed: false + ) + } + if activeTransactionToken != nil { + return OliphauntBackgroundPreparationResult( + cancelledActiveWork: cancelledActiveWork, + checkpointed: false, + skippedCheckpointReason: .transactionActive + ) + } + if hadActiveWork || activeOperationCount > 0 { + return OliphauntBackgroundPreparationResult( + cancelledActiveWork: cancelledActiveWork, + checkpointed: false, + skippedCheckpointReason: .activeWork + ) + } + + try await checkpoint() + return OliphauntBackgroundPreparationResult( + cancelledActiveWork: cancelledActiveWork, + checkpointed: true + ) + } + + public func resumeFromBackground() async throws { + _ = try await execProtocolRaw( + try OliphauntProtocol.simpleQuery("SELECT 1"), + transactionToken: nil + ) + } + + public func transaction( + _ body: @Sendable (OliphauntTransaction) async throws -> T + ) async throws -> T { + guard activeTransactionToken == nil else { + throw OliphauntError.engine(Self.sessionPinnedMessage) + } + let token = nextTransactionToken + nextTransactionToken = nextTransactionToken == UInt64.max ? 1 : nextTransactionToken + 1 + activeTransactionToken = token + let transaction = OliphauntTransaction(database: self, token: token) + + do { + _ = try await execProtocolRaw(try OliphauntProtocol.simpleQuery("BEGIN"), transactionToken: token) + let result = try await body(transaction) + _ = try await execProtocolRaw(try OliphauntProtocol.simpleQuery("COMMIT"), transactionToken: token) + activeTransactionToken = nil + return result + } catch { + do { + _ = try await execProtocolRaw(try OliphauntProtocol.simpleQuery("ROLLBACK"), transactionToken: token) + } catch { + // Preserve the original transaction failure; rollback is best-effort cleanup. + } + activeTransactionToken = nil + throw error + } + } + + public func cancel() async throws { + try await liveSession().cancel() + } + + public func close() async throws { + guard let closingSession = session else { + return + } + self.session = nil + activeTransactionToken = nil + await operationGate.acquire() + do { + try await closingSession.close() + await operationGate.release() + } catch { + await operationGate.release() + throw error + } + } + + private func liveSession() throws -> any OliphauntSession { + guard let session else { + throw OliphauntError.databaseClosed + } + return session + } + + fileprivate func execProtocolRaw(_ bytes: Data, transactionToken: UInt64?) async throws -> Data { + _ = try liveSession() + try validateTransactionAccess(token: transactionToken) + return try await runSessionOperation(transactionToken: transactionToken) { + try await $0.execProtocolRaw(bytes) + } + } + + fileprivate func execProtocolStream( + _ bytes: Data, + transactionToken: UInt64?, + onChunk: @escaping @Sendable (Data) throws -> Void + ) async throws { + _ = try liveSession() + try validateTransactionAccess(token: transactionToken) + try await runSessionOperation(transactionToken: transactionToken) { + try await $0.execProtocolStream(bytes, onChunk: onChunk) + } + } + + private func runSessionOperation( + transactionToken: UInt64? = nil, + allowDuringTransaction: Bool = false, + _ body: (any OliphauntSession) async throws -> T + ) async throws -> T { + if !allowDuringTransaction { + try validateTransactionAccess(token: transactionToken) + } + await operationGate.acquire() + activeOperationCount += 1 + do { + let session = try liveSession() + if !allowDuringTransaction { + try validateTransactionAccess(token: transactionToken) + } + let result = try await body(session) + activeOperationCount -= 1 + await operationGate.release() + return result + } catch { + activeOperationCount -= 1 + await operationGate.release() + throw error + } + } + + private func validateTransactionAccess(token: UInt64?) throws { + if let token { + guard activeTransactionToken == token else { + throw OliphauntError.engine("transaction is no longer active") + } + return + } + if activeTransactionToken != nil { + throw OliphauntError.engine(Self.sessionPinnedMessage) + } + } + + private static let sessionPinnedMessage = + "physical session is pinned; use the active OliphauntTransaction" +} + +public struct OliphauntTransaction: Sendable { + fileprivate let database: OliphauntDatabase + fileprivate let token: UInt64 + + public func execProtocolRaw(_ bytes: Data) async throws -> Data { + try await database.execProtocolRaw(bytes, transactionToken: token) + } + + public func execProtocolStream( + _ bytes: Data, + onChunk: @escaping @Sendable (Data) throws -> Void + ) async throws { + try await database.execProtocolStream(bytes, transactionToken: token, onChunk: onChunk) + } +} + + +extension OliphauntConfiguration { + func postgresStartupArgs() -> [String] { + var args = runtimeFootprint.postgresStartupArgs() + args.append(contentsOf: durability.postgresStartupArgs()) + for guc in startupGUCs { + args.append("-c") + args.append("\(guc.name.trimmingCharacters(in: .whitespacesAndNewlines))=\(guc.value)") + } + return args + } +} + +private extension OliphauntRuntimeFootprintProfile { + func postgresStartupArgs() -> [String] { + switch self { + case .throughput: + return [ + "-c", "shared_buffers=128MB", + "-c", "wal_buffers=4MB", + "-c", "min_wal_size=80MB" + ] + case .balancedMobile: + return [ + "-c", "max_connections=1", + "-c", "superuser_reserved_connections=0", + "-c", "reserved_connections=0", + "-c", "autovacuum_worker_slots=1", + "-c", "max_wal_senders=0", + "-c", "max_replication_slots=0", + "-c", "shared_buffers=32MB", + "-c", "wal_buffers=-1", + "-c", "min_wal_size=32MB", + "-c", "max_wal_size=64MB", + "-c", "io_method=sync", + "-c", "io_max_concurrency=1" + ] + case .smallMobile: + return [ + "-c", "max_connections=1", + "-c", "superuser_reserved_connections=0", + "-c", "reserved_connections=0", + "-c", "autovacuum_worker_slots=1", + "-c", "max_wal_senders=0", + "-c", "max_replication_slots=0", + "-c", "shared_buffers=8MB", + "-c", "wal_buffers=256kB", + "-c", "min_wal_size=32MB", + "-c", "max_wal_size=64MB", + "-c", "work_mem=1MB", + "-c", "maintenance_work_mem=16MB", + "-c", "io_method=sync", + "-c", "io_max_concurrency=1" + ] + } + } +} + +private extension OliphauntDurability { + func postgresStartupArgs() -> [String] { + switch self { + case .safe: + return [ + "-c", "fsync=on", + "-c", "full_page_writes=on", + "-c", "synchronous_commit=on" + ] + case .balanced: + return [ + "-c", "fsync=on", + "-c", "full_page_writes=on", + "-c", "synchronous_commit=off" + ] + case .fastDev: + return [ + "-c", "fsync=off", + "-c", "full_page_writes=off", + "-c", "synchronous_commit=off" + ] + } + } +} diff --git a/src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift b/src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift new file mode 100644 index 00000000..19c15e79 --- /dev/null +++ b/src/sdks/swift/Sources/Oliphaunt/OliphauntNativeDirect.swift @@ -0,0 +1,549 @@ +import Foundation +import COliphaunt + +public struct OliphauntNativeDirectEngine: OliphauntEngine, OliphauntEngineSupportProvider { + public var libraryURL: URL? + public var runtimeDirectory: URL? + public var runtimeResources: OliphauntRuntimeResources? + public var username: String + public var database: String + + public init( + libraryURL: URL? = nil, + runtimeDirectory: URL? = nil, + runtimeResources: OliphauntRuntimeResources? = nil, + username: String = "postgres", + database: String = "postgres" + ) { + self.libraryURL = libraryURL + self.runtimeDirectory = runtimeDirectory + self.runtimeResources = runtimeResources + self.username = username + self.database = database + } + + public var supportedModes: [OliphauntEngineModeSupport] { + OliphauntSDKSupport.nativeDirectOnly( + brokerReason: OliphauntDefaultEngine.brokerUnavailableReason, + serverReason: OliphauntDefaultEngine.serverUnavailableReason + ) + } + + public func open(configuration: OliphauntConfiguration) async throws -> any OliphauntSession { + guard configuration.mode == .nativeDirect else { + throw OliphauntError.engine( + "OliphauntNativeDirectEngine supports nativeDirect, got \(configuration.mode.rawValue)" + ) + } + try validateOliphauntRoot(configuration.root, label: "database root") + try validateOliphauntStartupIdentity(configuration.username ?? username, label: "username") + try validateOliphauntStartupIdentity(configuration.database ?? database, label: "database") + try validateOliphauntStartupGUCs(configuration.startupGUCs) + _ = try OliphauntRuntimeResources.validateExtensionIds(configuration.extensions) + let packagedRuntimeResources = try runtimeResources ?? OliphauntRuntimeResources.bundled( + containing: configuration.extensions + ) + let resolvedRuntimeDirectory = try resolveRuntimeDirectory( + extensions: configuration.extensions, + runtimeResources: packagedRuntimeResources + ) + + let root = try Self.resolveRoot(configuration.root) + let pgdata = root.appendingPathComponent("pgdata", isDirectory: true) + let preparedPgdata = try packagedRuntimeResources?.preparePgdata(at: pgdata) ?? false + let hasPgVersion = FileManager.default.fileExists( + atPath: pgdata.appendingPathComponent("PG_VERSION").path + ) + if !hasPgVersion { + try Self.requireHostInitdbSupport( + preparedPgdata: preparedPgdata, + temporaryRoot: configuration.root == nil, + root: root + ) + try FileManager.default.createDirectory( + at: pgdata, + withIntermediateDirectories: true + ) + } + + let username = configuration.username ?? self.username + let database = configuration.database ?? self.database + let startupArgs = configuration.postgresStartupArgs() + let libraryPath = libraryURL?.path + let runtimePath = resolvedRuntimeDirectory?.path ?? "" + var session: OpaquePointer? + let rc = withCStringArray(startupArgs) { startupArgPointers in + pgdata.path.withCString { pgdataCString in + runtimePath.withCString { runtimeCString in + username.withCString { usernameCString in + database.withCString { databaseCString in + libraryPath.withOptionalCString { libraryCString in + var config = OliphauntConfig( + abi_version: UInt32(OLIPHAUNT_ABI_VERSION), + pgdata: pgdataCString, + runtime_dir: runtimeCString, + username: usernameCString, + database: databaseCString, + reserved_flags: 0, + startup_args: startupArgPointers, + startup_arg_count: startupArgs.count + ) + return oliphaunt_swift_open(libraryCString, &config, &session) + } + } + } + } + } + } + guard rc == 0, let session else { + if configuration.root == nil { + try? FileManager.default.removeItem(at: root) + } + throw OliphauntError.engine(Self.lastError(nil)) + } + return NativeDirectSession( + session: session, + root: root, + deleteRootOnClose: configuration.root == nil + ) + } + + public func restore(_ request: OliphauntRestoreRequest) async throws -> URL { + try validateOliphauntRoot(request.root, label: "restore root") + guard request.artifact.format == .physicalArchive else { + throw OliphauntError.engine( + "Swift native restore currently requires physicalArchive, got \(request.artifact.format.rawValue)" + ) + } + let libraryPath = libraryURL?.path + let flags: UInt64 = request.targetPolicy == .replaceExisting + ? UInt64(OLIPHAUNT_RESTORE_REPLACE_EXISTING) + : 0 + let rc = request.root.path.withCString { rootCString in + libraryPath.withOptionalCString { libraryCString in + request.artifact.bytes.withUnsafeBytes { rawBuffer in + var options = OliphauntRestoreOptions( + abi_version: UInt32(OLIPHAUNT_ABI_VERSION), + root: rootCString, + format: UInt32(OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE), + data: rawBuffer.bindMemory(to: UInt8.self).baseAddress, + len: request.artifact.bytes.count, + flags: flags + ) + return oliphaunt_swift_restore(libraryCString, &options) + } + } + } + guard rc == 0 else { + throw OliphauntError.engine(Self.lastError(nil)) + } + return request.root + } + + private func resolveRuntimeDirectory( + extensions: [String], + runtimeResources: OliphauntRuntimeResources? + ) throws -> URL? { + if let runtimeDirectory { + return runtimeDirectory + } + if let runtimeResources { + return try runtimeResources.materializeRuntime(requestedExtensions: extensions) + } + if let environmentRuntimeDirectory = Self.environmentRuntimeDirectory() { + return environmentRuntimeDirectory + } + if !extensions.isEmpty { + throw OliphauntError.engine( + "Swift native-direct extensions require runtimeDirectory or packaged OliphauntRuntimeResources built with the selected extensions" + ) + } + return nil + } + + private static func environmentRuntimeDirectory() -> URL? { + let environment = ProcessInfo.processInfo.environment + for key in ["OLIPHAUNT_INSTALL_DIR", "OLIPHAUNT_RUNTIME_DIR"] { + guard let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + continue + } + return URL(fileURLWithPath: value, isDirectory: true) + } + return nil + } + + private static func requireHostInitdbSupport( + preparedPgdata: Bool, + temporaryRoot: Bool, + root: URL + ) throws { + if preparedPgdata { + return + } +#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + if temporaryRoot { + try? FileManager.default.removeItem(at: root) + } + throw OliphauntError.engine( + "Swift Oliphaunt native-direct requires packaged template PGDATA or an existing PGDATA root on Apple mobile platforms; initdb cannot be assumed executable from app storage" + ) +#else + _ = temporaryRoot + _ = root +#endif + } + + private static func resolveRoot(_ configuredRoot: URL?) throws -> URL { + if let configuredRoot { + try FileManager.default.createDirectory( + at: configuredRoot, + withIntermediateDirectories: true + ) + return configuredRoot + } + let root = processTemporaryRoot + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + return root + } + + private static let processTemporaryRoot: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent( + "liboliphaunt-swift-\(ProcessInfo.processInfo.processIdentifier)-\(UUID().uuidString)", + isDirectory: true + ) + }() + + fileprivate static func lastError(_ session: OpaquePointer?) -> String { + guard let pointer = oliphaunt_swift_last_error(session) else { + return "unknown liboliphaunt Swift runtime error" + } + let message = String(cString: pointer) + return message.isEmpty ? "unknown liboliphaunt Swift runtime error" : message + } + +} + +private actor NativeDirectSession: OliphauntSession { + private let box: NativeSessionBox + + init(session: OpaquePointer, root: URL, deleteRootOnClose: Bool) { + self.box = NativeSessionBox( + pointer: session, + root: root, + deleteRootOnClose: deleteRootOnClose + ) + } + + deinit { + box.closeBestEffort() + } + + func capabilities() async -> OliphauntCapabilities { + let flags = box.capabilityFlags() + return OliphauntCapabilities( + mode: .nativeDirect, + processIsolated: false, + multiRoot: flags & OLIPHAUNT_CAP_MULTI_INSTANCE != 0, + reopenable: flags & OLIPHAUNT_CAP_LOGICAL_REOPEN != 0, + sameRootLogicalReopen: flags & OLIPHAUNT_CAP_LOGICAL_REOPEN != 0, + rootSwitchable: false, + crashRestartable: false, + independentSessions: false, + maxClientSessions: 1, + protocolRaw: flags & OLIPHAUNT_CAP_PROTOCOL_RAW != 0, + protocolStream: flags & OLIPHAUNT_CAP_PROTOCOL_STREAM != 0, + queryCancel: flags & OLIPHAUNT_CAP_QUERY_CANCEL != 0, + backupRestore: flags & OLIPHAUNT_CAP_BACKUP_RESTORE != 0, + backupFormats: [.physicalArchive], + restoreFormats: [.physicalArchive], + simpleQuery: flags & OLIPHAUNT_CAP_SIMPLE_QUERY != 0, + extensions: flags & OLIPHAUNT_CAP_EXTENSIONS != 0 + ) + } + + func execProtocolRaw(_ bytes: Data) async throws -> Data { + try box.execProtocolRaw(bytes) + } + + func execProtocolStream( + _ bytes: Data, + onChunk: @escaping @Sendable (Data) throws -> Void + ) async throws { + try box.execProtocolStream(bytes, onChunk: onChunk) + } + + func backup(_ request: OliphauntBackupRequest) async throws -> OliphauntBackupArtifact { + try box.backup(request) + } + + nonisolated func cancel() async throws { + try box.cancel() + } + + nonisolated func close() async throws { + try box.close() + } +} + +private final class NativeSessionBox: @unchecked Sendable { + private let condition = NSCondition() + private var pointer: OpaquePointer? + private var closed = false + private var activeCalls = 0 + private let root: URL + private let deleteRootOnClose: Bool + + init(pointer: OpaquePointer, root: URL, deleteRootOnClose: Bool) { + self.pointer = pointer + self.root = root + self.deleteRootOnClose = deleteRootOnClose + } + + deinit { + closeBestEffort() + } + + func capabilityFlags() -> UInt64 { + guard let pointer = try? beginCall() else { + return 0 + } + defer { + endCall() + } + return oliphaunt_swift_capabilities(pointer) + } + + func execProtocolRaw(_ bytes: Data) throws -> Data { + let pointer = try beginCall() + defer { + endCall() + } + + var response = OliphauntResponse(data: nil, len: 0) + let rc = bytes.withUnsafeBytes { rawBuffer in + let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress + return oliphaunt_swift_exec_protocol(pointer, base, bytes.count, &response) + } + guard rc == 0 else { + throw OliphauntError.engine(OliphauntNativeDirectEngine.lastError(pointer)) + } + defer { + oliphaunt_swift_free_response(pointer, &response) + } + guard let data = response.data, response.len > 0 else { + return Data() + } + return Data(bytes: data, count: response.len) + } + + func execProtocolStream( + _ bytes: Data, + onChunk: @escaping @Sendable (Data) throws -> Void + ) throws { + let pointer = try beginCall() + defer { + endCall() + } + + let callbackBox = NativeStreamCallbackBox(onChunk: onChunk) + let context = Unmanaged.passUnretained(callbackBox).toOpaque() + let rc = bytes.withUnsafeBytes { rawBuffer in + let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress + return oliphaunt_swift_exec_protocol_stream( + pointer, + base, + bytes.count, + { context, data, len in + guard let context else { + return -1 + } + let callbackBox = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + do { + if let data, len > 0 { + try callbackBox.onChunk(Data(bytes: data, count: len)) + } else { + try callbackBox.onChunk(Data()) + } + return 0 + } catch { + callbackBox.error = error + return -1 + } + }, + context + ) + } + if let error = callbackBox.error { + throw error + } + guard rc == 0 else { + throw OliphauntError.engine(OliphauntNativeDirectEngine.lastError(pointer)) + } + } + + func backup(_ request: OliphauntBackupRequest) throws -> OliphauntBackupArtifact { + guard request.format == .physicalArchive else { + throw OliphauntError.engine( + "Swift native-direct backup currently supports physicalArchive, got \(request.format.rawValue)" + ) + } + let pointer = try beginCall() + defer { + endCall() + } + + var response = OliphauntResponse(data: nil, len: 0) + let rc = oliphaunt_swift_backup( + pointer, + UInt32(OLIPHAUNT_BACKUP_FORMAT_PHYSICAL_ARCHIVE), + &response + ) + guard rc == 0 else { + throw OliphauntError.engine(OliphauntNativeDirectEngine.lastError(pointer)) + } + defer { + oliphaunt_swift_free_response(pointer, &response) + } + guard let data = response.data, response.len > 0 else { + return OliphauntBackupArtifact(format: .physicalArchive, bytes: Data()) + } + return OliphauntBackupArtifact( + format: .physicalArchive, + bytes: Data(bytes: data, count: response.len) + ) + } + + func cancel() throws { + condition.lock() + let pointer = self.pointer + let isClosed = closed + condition.unlock() + + guard let pointer, !isClosed else { + throw OliphauntError.databaseClosed + } + let rc = oliphaunt_swift_cancel(pointer) + guard rc == 0 else { + throw OliphauntError.engine(OliphauntNativeDirectEngine.lastError(pointer)) + } + } + + func close() throws { + let pointer = prepareClose() + guard let pointer else { + cleanupRoot() + return + } + let rc = oliphaunt_swift_close(pointer) + cleanupRoot() + guard rc == 0 else { + throw OliphauntError.engine(OliphauntNativeDirectEngine.lastError(nil)) + } + } + + func closeBestEffort() { + let pointer = prepareClose() + if let pointer { + _ = oliphaunt_swift_close(pointer) + } + cleanupRoot() + } + + private func beginCall() throws -> OpaquePointer { + condition.lock() + defer { + condition.unlock() + } + while !closed && activeCalls > 0 { + condition.wait() + } + guard let pointer, !closed else { + throw OliphauntError.databaseClosed + } + activeCalls += 1 + return pointer + } + + private func endCall() { + condition.lock() + activeCalls -= 1 + condition.broadcast() + condition.unlock() + } + + private func prepareClose() -> OpaquePointer? { + condition.lock() + if closed { + condition.unlock() + return nil + } + closed = true + let pointer = self.pointer + while activeCalls > 0 { + condition.wait() + } + self.pointer = nil + condition.unlock() + return pointer + } + + private func cleanupRoot() { + if deleteRootOnClose { + /* + Native direct close is a logical detach. The resident PostgreSQL + backend may still own PGDATA until process exit, so deleting a + temporary root here would corrupt the live runtime. + */ + _ = root + } + } +} + +private final class NativeStreamCallbackBox: @unchecked Sendable { + let onChunk: @Sendable (Data) throws -> Void + var error: Error? + + init(onChunk: @escaping @Sendable (Data) throws -> Void) { + self.onChunk = onChunk + } +} + +private func withCStringArray( + _ strings: [String], + _ body: (UnsafePointer?>?) throws -> T +) rethrows -> T { + let cStrings = strings.map { strdup($0) } + defer { + for cString in cStrings { + free(cString) + } + } + let pointers = cStrings.map { cString -> UnsafePointer? in + guard let cString else { + return nil + } + return UnsafePointer(cString) + } + return try pointers.withUnsafeBufferPointer { buffer in + try body(buffer.baseAddress) + } +} + +private extension Optional where Wrapped == String { + func withOptionalCString(_ body: (UnsafePointer?) throws -> T) rethrows -> T { + switch self { + case .some(let value): + return try value.withCString(body) + case .none: + return try body(nil) + } + } +} diff --git a/src/sdks/swift/Sources/Oliphaunt/OliphauntQuery.swift b/src/sdks/swift/Sources/Oliphaunt/OliphauntQuery.swift new file mode 100644 index 00000000..d1f3b2d9 --- /dev/null +++ b/src/sdks/swift/Sources/Oliphaunt/OliphauntQuery.swift @@ -0,0 +1,566 @@ +import Foundation + +public enum OliphauntQueryFormat: Equatable, Sendable { + case text + case binary + case other(Int16) + + fileprivate init(code: Int16) { + switch code { + case 0: + self = .text + case 1: + self = .binary + default: + self = .other(code) + } + } +} + +public enum OliphauntQueryParam: Equatable, Sendable { + case null + case text(String) + case binary(Data) + + public static func binary(_ bytes: [UInt8]) -> OliphauntQueryParam { + .binary(Data(bytes)) + } +} + +public struct OliphauntQueryField: Equatable, Sendable { + public var name: String + public var tableOID: UInt32 + public var tableAttribute: Int16 + public var typeOID: UInt32 + public var typeSize: Int16 + public var typeModifier: Int32 + public var format: OliphauntQueryFormat +} + +public struct OliphauntQueryRow: Equatable, Sendable { + public var values: [Data?] + + public func text(_ column: Int) throws -> String? { + guard values.indices.contains(column) else { + throw OliphauntError.engine("query row has no column at index \(column)") + } + guard let value = values[column] else { + return nil + } + guard let text = String(data: value, encoding: .utf8) else { + throw OliphauntError.engine("query value is not valid UTF-8") + } + return text + } +} + +public struct OliphauntQueryResult: Equatable, Sendable { + public var fields: [OliphauntQueryField] + public var rows: [OliphauntQueryRow] + public var commandTag: String? + + public var rowCount: Int { + rows.count + } + + public func fieldIndex(_ name: String) -> Int? { + fields.firstIndex { $0.name == name } + } + + public func getText(row: Int, column: String) throws -> String? { + guard let columnIndex = fieldIndex(column) else { + throw OliphauntError.engine("query result has no column named \(String(reflecting: column))") + } + guard rows.indices.contains(row) else { + throw OliphauntError.engine("query result has no row at index \(row)") + } + return try rows[row].text(columnIndex) + } +} + +public struct OliphauntPostgresErrorField: Equatable, Sendable { + public var code: UInt8 + public var value: String + + public init(code: UInt8, value: String) { + self.code = code + self.value = value + } +} + +public struct OliphauntPostgresError: Equatable, Sendable, CustomStringConvertible { + public var severity: String? + public var sqlstate: String? + public var message: String + public var detail: String? + public var hint: String? + public var position: String? + public var whereText: String? + public var schemaName: String? + public var tableName: String? + public var columnName: String? + public var dataTypeName: String? + public var constraintName: String? + public var fields: [OliphauntPostgresErrorField] + + public init(fields: [OliphauntPostgresErrorField]) { + self.fields = fields + self.severity = fieldValue(fields, 0x53) ?? fieldValue(fields, 0x56) + self.sqlstate = fieldValue(fields, 0x43) + self.message = fieldValue(fields, 0x4d) ?? "PostgreSQL ErrorResponse" + self.detail = fieldValue(fields, 0x44) + self.hint = fieldValue(fields, 0x48) + self.position = fieldValue(fields, 0x50) + self.whereText = fieldValue(fields, 0x57) + self.schemaName = fieldValue(fields, 0x73) + self.tableName = fieldValue(fields, 0x74) + self.columnName = fieldValue(fields, 0x63) + self.dataTypeName = fieldValue(fields, 0x64) + self.constraintName = fieldValue(fields, 0x6e) + } + + public static func fallback() -> OliphauntPostgresError { + OliphauntPostgresError(fields: [ + OliphauntPostgresErrorField(code: 0x4d, value: "PostgreSQL ErrorResponse") + ]) + } + + public var description: String { + switch (severity, sqlstate) { + case (.some(let severity), .some(let sqlstate)): + "\(severity) [\(sqlstate)]: \(message)" + case (.some(let severity), .none): + "\(severity): \(message)" + case (.none, .some(let sqlstate)): + "[\(sqlstate)]: \(message)" + case (.none, .none): + message + } + } +} + +public extension OliphauntDatabase { + func execute(_ sql: String) async throws -> Data { + try await execProtocolRaw(try OliphauntProtocol.simpleQuery(sql)) + } + + func query(_ sql: String) async throws -> OliphauntQueryResult { + try await parseOliphauntQueryResponse(execute(sql)) + } + + func query(_ sql: String, parameters: [OliphauntQueryParam]) async throws -> OliphauntQueryResult { + try await parseOliphauntQueryResponse( + execProtocolRaw(try OliphauntProtocol.extendedQuery(sql, parameters: parameters)) + ) + } +} + +public extension OliphauntTransaction { + func execute(_ sql: String) async throws -> Data { + try await execProtocolRaw(try OliphauntProtocol.simpleQuery(sql)) + } + + func query(_ sql: String) async throws -> OliphauntQueryResult { + try await parseOliphauntQueryResponse(execute(sql)) + } + + func query(_ sql: String, parameters: [OliphauntQueryParam]) async throws -> OliphauntQueryResult { + try await parseOliphauntQueryResponse( + execProtocolRaw(try OliphauntProtocol.extendedQuery(sql, parameters: parameters)) + ) + } +} + +public enum OliphauntProtocol { + public static func simpleQuery(_ sql: String) throws -> Data { + guard !sql.utf8.contains(0) else { + throw OliphauntError.engine("simple query SQL must not contain NUL bytes") + } + var body = Data(sql.utf8) + body.append(0) + let length = UInt32(body.count + 4) + var message = Data([0x51]) + message.append(UInt8((length >> 24) & 0xff)) + message.append(UInt8((length >> 16) & 0xff)) + message.append(UInt8((length >> 8) & 0xff)) + message.append(UInt8(length & 0xff)) + message.append(body) + return message + } + + public static func extendedQuery( + _ sql: String, + parameters: [OliphauntQueryParam] + ) throws -> Data { + guard parameters.count <= Int(Int16.max) else { + throw OliphauntError.engine( + "extended query supports at most \(Int16.max) parameters, got \(parameters.count)" + ) + } + guard !sql.utf8.contains(0) else { + throw OliphauntError.engine("extended query SQL must not contain NUL bytes") + } + + var packet = Data() + try appendParse(to: &packet, sql: sql) + try appendBind(to: &packet, parameters: parameters) + try appendDescribePortal(to: &packet) + try appendExecute(to: &packet) + appendFrontendMessage(to: &packet, tag: 0x53, body: Data()) + return packet + } + + private static func appendParse(to packet: inout Data, sql: String) throws { + var body = Data() + try appendCString(to: &body, "") + try appendCString(to: &body, sql) + appendInt16(to: &body, 0) + appendFrontendMessage(to: &packet, tag: 0x50, body: body) + } + + private static func appendBind(to packet: inout Data, parameters: [OliphauntQueryParam]) throws { + var body = Data() + try appendCString(to: &body, "") + try appendCString(to: &body, "") + + appendInt16(to: &body, Int16(parameters.count)) + for parameter in parameters { + switch parameter { + case .binary: + appendInt16(to: &body, 1) + case .null, .text: + appendInt16(to: &body, 0) + } + } + + appendInt16(to: &body, Int16(parameters.count)) + for parameter in parameters { + switch parameter { + case .null: + appendInt32(to: &body, -1) + case .text(let value): + try appendSizedValue(to: &body, Data(value.utf8)) + case .binary(let value): + try appendSizedValue(to: &body, value) + } + } + + appendInt16(to: &body, 1) + appendInt16(to: &body, 0) + appendFrontendMessage(to: &packet, tag: 0x42, body: body) + } + + private static func appendDescribePortal(to packet: inout Data) throws { + var body = Data([0x50]) + try appendCString(to: &body, "") + appendFrontendMessage(to: &packet, tag: 0x44, body: body) + } + + private static func appendExecute(to packet: inout Data) throws { + var body = Data() + try appendCString(to: &body, "") + appendInt32(to: &body, 0) + appendFrontendMessage(to: &packet, tag: 0x45, body: body) + } + + private static func appendFrontendMessage(to packet: inout Data, tag: UInt8, body: Data) { + packet.append(tag) + appendInt32(to: &packet, Int32(body.count + 4)) + packet.append(body) + } + + private static func appendCString(to data: inout Data, _ value: String) throws { + guard !value.utf8.contains(0) else { + throw OliphauntError.engine("frontend protocol string must not contain NUL bytes") + } + data.append(Data(value.utf8)) + data.append(0) + } + + private static func appendSizedValue(to data: inout Data, _ value: Data) throws { + guard value.count <= Int(Int32.max) else { + throw OliphauntError.engine("query parameter is too large") + } + appendInt32(to: &data, Int32(value.count)) + data.append(value) + } + + private static func appendInt32(to data: inout Data, _ value: Int32) { + let bits = UInt32(bitPattern: value) + data.append(UInt8((bits >> 24) & 0xff)) + data.append(UInt8((bits >> 16) & 0xff)) + data.append(UInt8((bits >> 8) & 0xff)) + data.append(UInt8(bits & 0xff)) + } + + private static func appendInt16(to data: inout Data, _ value: Int16) { + let bits = UInt16(bitPattern: value) + data.append(UInt8((bits >> 8) & 0xff)) + data.append(UInt8(bits & 0xff)) + } +} + +public func parseOliphauntQueryResponse(_ data: Data) throws -> OliphauntQueryResult { + var cursor = OliphauntByteCursor(data) + var fields: [OliphauntQueryField]? + var rows: [OliphauntQueryRow] = [] + var commandTag: String? + var sawReady = false + + while !cursor.isAtEnd { + let tag = try cursor.readUInt8(label: "backend message tag") + let length = try cursor.readInt32(label: "backend message length") + guard length >= 4 else { + throw OliphauntError.engine("invalid backend message length \(length)") + } + let body = try cursor.readData(count: Int(length - 4), label: "backend message body") + var bodyCursor = OliphauntByteCursor(body) + + switch tag { + case 0x54: + if fields != nil { + throw OliphauntError.engine( + "query() received multiple result sets; use execProtocolRaw for multi-statement row results" + ) + } + fields = try parseRowDescription(&bodyCursor) + try bodyCursor.requireEnd(label: "RowDescription") + case 0x44: + guard let activeFields = fields else { + throw OliphauntError.engine("DataRow arrived before RowDescription") + } + rows.append(try parseDataRow(&bodyCursor, expectedColumns: activeFields.count)) + try bodyCursor.requireEnd(label: "DataRow") + case 0x43: + commandTag = try bodyCursor.readCString(label: "CommandComplete tag") + try bodyCursor.requireEnd(label: "CommandComplete") + case 0x45: + throw OliphauntError.postgres(parseErrorResponse(&bodyCursor)) + case 0x47, 0x48, 0x57, 0x64, 0x63: + throw OliphauntError.engine( + "query() does not support COPY protocol responses; use execProtocolRaw for COPY traffic" + ) + case 0x5a: + try validateReadyForQuery(body) + sawReady = true + if !cursor.isAtEnd { + throw OliphauntError.engine("backend returned bytes after ReadyForQuery") + } + case 0x31: + try bodyCursor.requireEnd(label: "ParseComplete") + case 0x32: + try bodyCursor.requireEnd(label: "BindComplete") + case 0x33: + try bodyCursor.requireEnd(label: "CloseComplete") + case 0x49: + try bodyCursor.requireEnd(label: "EmptyQueryResponse") + case 0x6e: + try bodyCursor.requireEnd(label: "NoData") + case 0x53: + try validateParameterStatus(&bodyCursor) + case 0x4e: + try validateFieldResponse(&bodyCursor, label: "NoticeResponse") + case 0x41: + try validateNotificationResponse(&bodyCursor) + default: + throw OliphauntError.engine( + "query() received unexpected backend message tag \(hexBackendTag(tag))" + ) + } + } + + guard sawReady else { + throw OliphauntError.engine("query response ended before ReadyForQuery") + } + + return OliphauntQueryResult( + fields: fields ?? [], + rows: rows, + commandTag: commandTag + ) +} + +private func parseRowDescription(_ cursor: inout OliphauntByteCursor) throws -> [OliphauntQueryField] { + let count = try cursor.readInt16(label: "RowDescription field count") + guard count >= 0 else { + throw OliphauntError.engine("invalid RowDescription field count \(count)") + } + var fields: [OliphauntQueryField] = [] + fields.reserveCapacity(Int(count)) + for _ in 0.. OliphauntQueryRow { + let count = try cursor.readInt16(label: "DataRow column count") + guard count >= 0 else { + throw OliphauntError.engine("invalid DataRow column count \(count)") + } + guard Int(count) == expectedColumns else { + throw OliphauntError.engine( + "DataRow column count \(count) does not match RowDescription count \(expectedColumns)" + ) + } + var values: [Data?] = [] + values.reserveCapacity(Int(count)) + for _ in 0..= 0 else { + throw OliphauntError.engine("invalid DataRow value length \(length)") + } + values.append(try cursor.readData(count: Int(length), label: "DataRow value")) + } + return OliphauntQueryRow(values: values) +} + +private func parseErrorResponse(_ cursor: inout OliphauntByteCursor) -> OliphauntPostgresError { + var fields: [OliphauntPostgresErrorField] = [] + while !cursor.isAtEnd { + guard let code = try? cursor.readUInt8(label: "ErrorResponse field code") else { + return .fallback() + } + if code == 0 { + break + } + guard let value = try? cursor.readCString(label: "ErrorResponse field") else { + return .fallback() + } + fields.append(OliphauntPostgresErrorField(code: code, value: value)) + } + return OliphauntPostgresError(fields: fields) +} + +private func fieldValue(_ fields: [OliphauntPostgresErrorField], _ code: UInt8) -> String? { + fields.first { $0.code == code }?.value +} + +private func hexBackendTag(_ tag: UInt8) -> String { + let hex = String(tag, radix: 16, uppercase: false) + return "0x" + (hex.count == 1 ? "0\(hex)" : hex) +} + +private func validateReadyForQuery(_ body: Data) throws { + guard body.count == 1 else { + throw OliphauntError.engine("ReadyForQuery contained \(body.count) bytes, expected 1") + } + switch body[body.startIndex] { + case 0x49, 0x54, 0x45: + return + case let status: + throw OliphauntError.engine( + "ReadyForQuery contained invalid transaction status \(hexBackendTag(status))" + ) + } +} + +private func validateParameterStatus(_ cursor: inout OliphauntByteCursor) throws { + _ = try cursor.readCString(label: "ParameterStatus name") + _ = try cursor.readCString(label: "ParameterStatus value") + try cursor.requireEnd(label: "ParameterStatus") +} + +private func validateNotificationResponse(_ cursor: inout OliphauntByteCursor) throws { + _ = try cursor.readInt32(label: "NotificationResponse process id") + _ = try cursor.readCString(label: "NotificationResponse channel") + _ = try cursor.readCString(label: "NotificationResponse payload") + try cursor.requireEnd(label: "NotificationResponse") +} + +private func validateFieldResponse(_ cursor: inout OliphauntByteCursor, label: String) throws { + while true { + guard !cursor.isAtEnd else { + throw OliphauntError.engine("\(label) is missing terminator") + } + let code = try cursor.readUInt8(label: "\(label) field code") + if code == 0 { + try cursor.requireEnd(label: label) + return + } + _ = try cursor.readCString(label: "\(label) field") + } +} + +private struct OliphauntByteCursor { + private let bytes: [UInt8] + private var offset: Int = 0 + + init(_ data: Data) { + self.bytes = Array(data) + } + + var isAtEnd: Bool { + offset >= bytes.count + } + + mutating func requireEnd(label: String) throws { + if !isAtEnd { + throw OliphauntError.engine("\(label) contained trailing bytes") + } + } + + mutating func readUInt8(label: String) throws -> UInt8 { + try take(count: 1, label: label)[0] + } + + mutating func readUInt32(label: String) throws -> UInt32 { + let bytes = try take(count: 4, label: label) + return UInt32(bytes[0]) << 24 + | UInt32(bytes[1]) << 16 + | UInt32(bytes[2]) << 8 + | UInt32(bytes[3]) + } + + mutating func readInt32(label: String) throws -> Int32 { + Int32(bitPattern: try readUInt32(label: label)) + } + + mutating func readInt16(label: String) throws -> Int16 { + let bytes = try take(count: 2, label: label) + return Int16(bitPattern: UInt16(bytes[0]) << 8 | UInt16(bytes[1])) + } + + mutating func readData(count: Int, label: String) throws -> Data { + Data(try take(count: count, label: label)) + } + + mutating func readCString(label: String) throws -> String { + guard offset < bytes.count else { + throw OliphauntError.engine("\(label) is missing null terminator") + } + guard let end = bytes[offset.. [UInt8] { + guard count >= 0, offset + count <= bytes.count else { + throw OliphauntError.engine("truncated \(label)") + } + let start = offset + offset += count + return Array(bytes[start.. OliphauntExtensionArtifactResolution { + try Self.resolveNativeArtifacts( + requestedExtensions: requestedExtensions, + manifests: manifests, + target: target + ) + } + + public static func resolveNativeArtifacts( + requestedExtensions: [String], + manifests: [OliphauntExtensionReleaseManifest], + target: String + ) throws -> OliphauntExtensionArtifactResolution { + let requested = try OliphauntRuntimeResources.normalizedExtensionIds(requestedExtensions) + let target = try validateTarget(target) + let kind = try nativeArtifactKind(for: target) + var bySqlName: [String: OliphauntExtensionReleaseManifest] = [:] + for manifest in manifests { + if let existing = bySqlName[manifest.sqlName] { + throw OliphauntError.engine( + "Swift Oliphaunt extension manifests contain duplicate sqlName \(manifest.sqlName): \(existing.product) and \(manifest.product)" + ) + } + bySqlName[manifest.sqlName] = manifest + } + + var visiting = Set() + var visited = Set() + var ordered: [OliphauntExtensionReleaseManifest] = [] + + func visit(_ sqlName: String, requiredBy: String?) throws { + if visited.contains(sqlName) { + return + } + guard visiting.insert(sqlName).inserted else { + throw OliphauntError.engine( + "Swift Oliphaunt extension dependency cycle includes \(sqlName)" + ) + } + guard let manifest = bySqlName[sqlName] else { + if let requiredBy { + throw OliphauntError.engine( + "Swift Oliphaunt extension \(requiredBy) requires missing dependency \(sqlName)" + ) + } + throw OliphauntError.engine( + "Swift Oliphaunt requested extension \(sqlName) has no release manifest" + ) + } + for dependency in manifest.dependencies { + try visit(dependency, requiredBy: manifest.sqlName) + } + visiting.remove(sqlName) + visited.insert(sqlName) + ordered.append(manifest) + } + + for sqlName in requested { + try visit(sqlName, requiredBy: nil) + } + + var resolvedAssets: [OliphauntResolvedExtensionAsset] = [] + for manifest in ordered { + try validateReadiness(manifest, target: target) + resolvedAssets.append(OliphauntResolvedExtensionAsset( + sqlName: manifest.sqlName, + product: manifest.product, + version: manifest.version, + asset: try manifest.requiredAsset( + family: "native", + target: target, + kind: kind + ) + )) + } + + return OliphauntExtensionArtifactResolution( + requestedExtensions: requested, + resolvedExtensions: ordered.map(\.sqlName), + assets: resolvedAssets + ) + } + + private static func validateTarget(_ target: String) throws -> String { + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + guard OliphauntRuntimeResources.isPortableId(trimmed) else { + throw OliphauntError.engine( + "Swift Oliphaunt native extension target '\(target)' must contain only ASCII letters, digits, '.', '_' or '-'" + ) + } + return trimmed + } + + private static func nativeArtifactKind(for target: String) throws -> String { + if target == "ios-xcframework" { + return "ios-xcframework" + } + if target.hasPrefix("android-") { + return "android-static-archive" + } + if target.hasPrefix("macos-") || target.hasPrefix("linux-") || target.hasPrefix("windows-") { + return "runtime" + } + throw OliphauntError.engine( + "Swift Oliphaunt does not know the native extension artifact kind for target \(target)" + ) + } + + private static func validateReadiness( + _ manifest: OliphauntExtensionReleaseManifest, + target: String + ) throws { + if target == "ios-xcframework" || target.hasPrefix("android-") { + guard manifest.mobileReleaseReady else { + throw OliphauntError.engine( + "\(manifest.product) \(manifest.version) is not marked mobileReleaseReady for \(target)" + ) + } + return + } + guard manifest.desktopReleaseReady else { + throw OliphauntError.engine( + "\(manifest.product) \(manifest.version) is not marked desktopReleaseReady for \(target)" + ) + } + } +} + +public struct OliphauntExtensionReleaseManifest: Equatable, Sendable { + public var product: String + public var version: String + public var sqlName: String + public var dependencies: [String] + public var nativeModuleStem: String? + public var sharedPreloadLibraries: [String] + public var mobileReleaseReady: Bool + public var desktopReleaseReady: Bool + public var assets: [OliphauntExtensionReleaseAsset] + + public init(contentsOf url: URL) throws { + let values = try Self.readProperties(url) + try Self.require(values["schema"], equals: "oliphaunt-extension-release-manifest-v1", key: "schema", url: url) + let product = try Self.requiredPortableId(values["product"], key: "product", url: url) + guard product.hasPrefix("oliphaunt-extension-") else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) product must start with oliphaunt-extension-" + ) + } + self.product = product + self.version = try Self.requiredPortableId(values["version"], key: "version", url: url) + self.sqlName = try Self.requiredPortableId(values["sqlName"], key: "sqlName", url: url) + self.dependencies = try Self.csvPortableIds(values["dependencies"], key: "dependencies", url: url) + let stem = values["nativeModuleStem"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.nativeModuleStem = stem.isEmpty ? nil : try Self.portableId(stem, key: "nativeModuleStem", url: url) + self.sharedPreloadLibraries = try Self.csvPortableIds( + values["sharedPreloadLibraries"], + key: "sharedPreloadLibraries", + url: url + ) + self.mobileReleaseReady = try Self.requiredBool(values["mobileReleaseReady"], key: "mobileReleaseReady", url: url) + self.desktopReleaseReady = try Self.requiredBool( + values["desktopReleaseReady"], + key: "desktopReleaseReady", + url: url + ) + self.assets = try Self.assets(from: values, url: url) + } + + public func asset(family: String, target: String, kind: String) -> OliphauntExtensionReleaseAsset? { + assets.first { asset in + asset.family == family && asset.target == target && asset.kind == kind + } + } + + public func requiredAsset(family: String, target: String, kind: String) throws -> OliphauntExtensionReleaseAsset { + if let asset = asset(family: family, target: target, kind: kind) { + return asset + } + throw OliphauntError.engine( + "\(product) \(version) does not contain \(family)/\(target)/\(kind) extension asset" + ) + } + + private static func assets( + from values: [String: String], + url: URL + ) throws -> [OliphauntExtensionReleaseAsset] { + var assets: [OliphauntExtensionReleaseAsset] = [] + var seen = Set() + for key in values.keys.sorted() where key.hasPrefix("asset.") { + let parts = key.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 4 else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) asset key '\(key)' must be asset..." + ) + } + let family = try portableId(parts[1], key: key, url: url) + guard family == "native" || family == "wasix" else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) asset key '\(key)' has unsupported family '\(family)'" + ) + } + let target = try portableId(parts[2], key: key, url: url) + let kind = try portableId(parts[3], key: key, url: url) + let name = values[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !name.isEmpty, name == URL(fileURLWithPath: name).lastPathComponent, !name.contains("/") && !name.contains("\\") else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) asset '\(key)' must be a plain release asset file name" + ) + } + let identity = "\(family)\u{1f}\(target)\u{1f}\(kind)" + guard seen.insert(identity).inserted else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) repeats extension asset \(family)/\(target)/\(kind)" + ) + } + assets.append(OliphauntExtensionReleaseAsset( + family: family, + target: target, + kind: kind, + name: name + )) + } + guard !assets.isEmpty else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) does not declare any extension assets" + ) + } + return assets.sorted { left, right in + (left.family, left.target, left.kind, left.name) < (right.family, right.target, right.kind, right.name) + } + } + + private static func readProperties(_ url: URL) throws -> [String: String] { + let text = try String(contentsOf: url, encoding: .utf8) + var values: [String: String] = [:] + for rawLine in text.split(whereSeparator: { $0.isNewline }) { + let line = String(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { + continue + } + guard let separator = line.firstIndex(of: "=") else { + continue + } + let key = String(line[.." : actual)'; expected \(expected)" + ) + } + } + + private static func requiredBool(_ value: String?, key: String, url: URL) throws -> Bool { + switch value?.trimmingCharacters(in: .whitespacesAndNewlines) { + case "true": + return true + case "false": + return false + default: + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) \(key) must be true or false" + ) + } + } + + private static func requiredPortableId(_ value: String?, key: String, url: URL) throws -> String { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) is missing required \(key)" + ) + } + return try portableId(trimmed, key: key, url: url) + } + + private static func portableId(_ value: String, key: String, url: URL) throws -> String { + guard OliphauntRuntimeResources.isPortableId(value) else { + throw OliphauntError.engine( + "Oliphaunt extension release manifest \(url.path) \(key) value '\(value)' must contain only ASCII letters, digits, '.', '_' or '-'" + ) + } + return value + } + + private static func csvPortableIds(_ value: String?, key: String, url: URL) throws -> [String] { + let items = value?.split(separator: ",").map(String.init) ?? [] + return try items.map { item in + try portableId(item.trimmingCharacters(in: .whitespacesAndNewlines), key: key, url: url) + }.filter { !$0.isEmpty }.sorted() + } +} + +public struct OliphauntRuntimeResources: Sendable { + public var resourceRoot: URL + public var cacheRoot: URL + + public init(resourceRoot: URL, cacheRoot: URL = Self.defaultCacheRoot()) { + self.resourceRoot = resourceRoot + self.cacheRoot = cacheRoot + } + + public init(bundle: Bundle, cacheRoot: URL = Self.defaultCacheRoot()) throws { + guard let resourceURL = bundle.resourceURL else { + throw OliphauntError.engine("bundle has no resource URL for Oliphaunt resources") + } + self.init( + resourceRoot: resourceURL.appendingPathComponent("oliphaunt", isDirectory: true), + cacheRoot: cacheRoot + ) + } + + public static func bundled(cacheRoot: URL = Self.defaultCacheRoot()) -> OliphauntRuntimeResources? { + try? bundledResource( + inResourceDirectories: defaultBundleResourceURLs(), + containing: [], + cacheRoot: cacheRoot + ) + } + + public static func bundled( + containing requestedExtensions: [String], + cacheRoot: URL = Self.defaultCacheRoot() + ) throws -> OliphauntRuntimeResources? { + try bundledResource( + inResourceDirectories: defaultBundleResourceURLs(), + containing: requestedExtensions, + cacheRoot: cacheRoot + ) + } + + static func bundledResource( + inResourceDirectories resourceDirectories: [URL], + containing requestedExtensions: [String] = [], + cacheRoot: URL = Self.defaultCacheRoot() + ) throws -> OliphauntRuntimeResources? { + let requested = try validateExtensionIds(requestedExtensions) + for resourceDirectory in resourceDirectories { + let resources = OliphauntRuntimeResources( + resourceRoot: resourceDirectory.appendingPathComponent("oliphaunt", isDirectory: true), + cacheRoot: cacheRoot + ) + if try resources.hasPackagedResources(containing: requested) { + return resources + } + } + return nil + } + + public static func defaultCacheRoot() -> URL { + let base = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + return base.appendingPathComponent("oliphaunt/runtime-cache", isDirectory: true) + } + + public func packageSizeReport() throws -> OliphauntRuntimeResourceSizeReport? { + let url = resourceRoot.appendingPathComponent("package-size.tsv", isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + var report = try Self.parsePackageSizeReport( + String(contentsOf: url, encoding: .utf8), + source: url.path + ) + if let runtime = try optionalAssetPackage(kind: .runtime) { + report.mobileStaticRegistryState = runtime.mobileStaticRegistryState + report.mobileStaticRegistryRegistered = runtime.mobileStaticRegistryRegistered.sorted() + report.mobileStaticRegistryPending = runtime.mobileStaticRegistryPending.sorted() + report.nativeModuleStems = runtime.nativeModuleStems.sorted() + } + return report + } + + public func materializeRuntime(requestedExtensions: [String] = []) throws -> URL { + let requested = try Self.validateExtensionIds(requestedExtensions) + let runtime = try assetPackage(kind: .runtime) + try require(runtime: runtime, contains: requested) + let target = cacheRoot + .appendingPathComponent("runtime", isDirectory: true) + .appendingPathComponent(runtime.cacheKey, isDirectory: true) + try materialize(runtime, to: target) + return target + } + + func hasPackagedResources(containing requestedExtensions: Set = []) throws -> Bool { + guard FileManager.default.fileExists( + atPath: resourceRoot.appendingPathComponent("runtime/manifest.properties").path + ) || FileManager.default.fileExists( + atPath: resourceRoot.appendingPathComponent("template-pgdata/manifest.properties").path + ) else { + return false + } + guard !requestedExtensions.isEmpty else { + return true + } + guard let runtime = try optionalAssetPackage(kind: .runtime) else { + return false + } + return requestedExtensions.isSubset(of: runtime.extensions) + } + + @discardableResult + public func preparePgdata(at pgdata: URL) throws -> Bool { + if FileManager.default.fileExists( + atPath: pgdata.appendingPathComponent("PG_VERSION").path + ) { + try ensurePgdataDirectoryLayout(at: pgdata) + try hardenPgdataPermissions(at: pgdata) + return true + } + let template = try optionalAssetPackage(kind: .templatePgdata) + guard let template else { + return false + } + + if FileManager.default.fileExists(atPath: pgdata.path) { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: pgdata.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw OliphauntError.engine("PGDATA path exists but is not a directory: \(pgdata.path)") + } + let contents = try FileManager.default.contentsOfDirectory(atPath: pgdata.path) + if !contents.isEmpty { + throw OliphauntError.engine("PGDATA exists without PG_VERSION and is not empty: \(pgdata.path)") + } + } + + let parent = pgdata.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + let temp = parent.appendingPathComponent( + ".pgdata-template-\(template.cacheKey)-\(UUID().uuidString)", + isDirectory: true + ) + try? FileManager.default.removeItem(at: temp) + do { + try copyTree(from: template.filesURL, to: temp) + guard FileManager.default.fileExists( + atPath: temp.appendingPathComponent("PG_VERSION").path + ) else { + throw OliphauntError.engine( + "packaged liboliphaunt template PGDATA \(template.rootURL.path) does not contain PG_VERSION" + ) + } + if FileManager.default.fileExists(atPath: pgdata.path) { + try FileManager.default.removeItem(at: pgdata) + } + try FileManager.default.moveItem(at: temp, to: pgdata) + try ensurePgdataDirectoryLayout(at: pgdata) + try hardenPgdataPermissions(at: pgdata) + return true + } catch { + try? FileManager.default.removeItem(at: temp) + throw error + } + } + + private func materialize(_ package: AssetPackage, to target: URL) throws { + let stamp = target.appendingPathComponent(".liboliphaunt-asset-cache-key") + if FileManager.default.fileExists(atPath: target.path), + (try? String(contentsOf: stamp, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)) == package.cacheKey + { + return + } + + let parent = target.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + let temp = parent.appendingPathComponent( + ".\(target.lastPathComponent).tmp-\(UUID().uuidString)", + isDirectory: true + ) + try? FileManager.default.removeItem(at: temp) + do { + try copyTree(from: package.filesURL, to: temp) + try package.cacheKey.write( + to: temp.appendingPathComponent(".liboliphaunt-asset-cache-key"), + atomically: true, + encoding: .utf8 + ) + if FileManager.default.fileExists(atPath: target.path) { + try FileManager.default.removeItem(at: target) + } + try FileManager.default.moveItem(at: temp, to: target) + } catch { + try? FileManager.default.removeItem(at: temp) + throw error + } + } + + private func require(runtime: AssetPackage, contains requested: Set) throws { + let missing = requested.subtracting(runtime.extensions) + guard missing.isEmpty else { + let available = runtime.extensions.sorted().joined(separator: ",") + throw OliphauntError.engine( + "Swift Oliphaunt runtime resources \(runtime.rootURL.path) does not contain requested extension(s) \(missing.sorted().joined(separator: ",")); available extensions: \(available.isEmpty ? "" : available)" + ) + } + try requireExtensionInstallFiles(runtime: runtime, contains: requested) + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + guard requested.isEmpty || runtime.mobileStaticRegistryState != nil else { + throw OliphauntError.engine( + "Swift Oliphaunt runtime resources \(runtime.rootURL.path) does not declare mobileStaticRegistryState; rebuild it with the current oliphaunt runtime-resource generator" + ) + } + if runtime.mobileStaticRegistryState == "pending" { + let pending = runtime.mobileStaticRegistryPending.sorted().joined(separator: ",") + throw OliphauntError.engine( + "Swift Oliphaunt runtime resources \(runtime.rootURL.path) is not mobile static-registry ready for selected extension(s); pending extension(s): \(pending.isEmpty ? "" : pending)" + ) + } + #endif + } + + private func requireExtensionInstallFiles(runtime: AssetPackage, contains requested: Set) throws { + guard !requested.isEmpty else { + return + } + try Self.requireExtensionInstallFiles(runtime: runtime, contains: requested) + } + + private static func requireExtensionInstallFiles(runtime: AssetPackage, contains requested: Set) throws { + let extensionDirectory = runtime.filesURL + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("postgresql", isDirectory: true) + .appendingPathComponent("extension", isDirectory: true) + for extensionName in requested.sorted() { + let control = extensionDirectory + .appendingPathComponent("\(extensionName).control", isDirectory: false) + guard FileManager.default.fileExists(atPath: control.path) else { + throw OliphauntError.engine( + "Swift Oliphaunt runtime resources \(runtime.rootURL.path) declare extension \(extensionName) but are missing \(extensionName).control" + ) + } + let prefix = "\(extensionName)--" + let installScripts = try FileManager.default.contentsOfDirectory( + at: extensionDirectory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ).filter { url in + url.lastPathComponent.hasPrefix(prefix) && url.pathExtension == "sql" + } + guard !installScripts.isEmpty else { + throw OliphauntError.engine( + "Swift Oliphaunt runtime resources \(runtime.rootURL.path) declare extension \(extensionName) but are missing \(extensionName)--*.sql" + ) + } + } + } + + private func assetPackage(kind: AssetPackageKind) throws -> AssetPackage { + guard let package = try optionalAssetPackage(kind: kind) else { + throw OliphauntError.engine("missing packaged liboliphaunt \(kind.label) resources at \(kind.root(in: resourceRoot).path)") + } + return package + } + + private func optionalAssetPackage(kind: AssetPackageKind) throws -> AssetPackage? { + let rootURL = kind.root(in: resourceRoot) + let manifestURL = rootURL.appendingPathComponent("manifest.properties") + guard FileManager.default.fileExists(atPath: manifestURL.path) else { + return nil + } + let manifest = try readManifest(manifestURL) + let schema = manifest["schema"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard schema == oliphauntRuntimeResourcesSchema else { + throw OliphauntError.engine( + "liboliphaunt \(kind.label) manifest has unsupported runtime resource schema '\(schema.isEmpty ? "" : schema)'; expected \(oliphauntRuntimeResourcesSchema)" + ) + } + let layout = manifest["layout"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard layout == kind.expectedLayout else { + throw OliphauntError.engine( + "liboliphaunt \(kind.label) manifest has unsupported layout '\(layout.isEmpty ? "" : layout)'; expected \(kind.expectedLayout)" + ) + } + let cacheKey = manifest["cacheKey"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard Self.isPortableId(cacheKey) else { + throw OliphauntError.engine("liboliphaunt \(kind.label) manifest has invalid cacheKey '\(cacheKey)'") + } + let extensions = try Self.validateExtensionIds( + manifest["extensions"]?.split(separator: ",").map(String.init) ?? [] + ) + let mobileStaticRegistryState = try Self.validateMobileStaticRegistryState( + manifest["mobileStaticRegistryState"]?.trimmingCharacters(in: .whitespacesAndNewlines) + ) + let mobileStaticRegistryPending = try Self.validatePortableIds( + manifest["mobileStaticRegistryPending"]?.split(separator: ",").map(String.init) ?? [], + label: "mobile static registry extension" + ) + let mobileStaticRegistryRegistered = try Self.validatePortableIds( + manifest["mobileStaticRegistryRegistered"]?.split(separator: ",").map(String.init) ?? [], + label: "mobile static registry extension" + ) + let nativeModuleStems = try Self.validatePortableIds( + manifest["nativeModuleStems"]?.split(separator: ",").map(String.init) ?? [], + label: "native module stem" + ) + let sharedPreloadLibraries = try Self.validatePortableIds( + manifest["sharedPreloadLibraries"]?.split(separator: ",").map(String.init) ?? [], + label: "shared preload library" + ) + try Self.validateMobileStaticRegistryManifest( + state: mobileStaticRegistryState, + registered: mobileStaticRegistryRegistered, + pending: mobileStaticRegistryPending, + nativeModuleStems: nativeModuleStems + ) + let filesURL = rootURL.appendingPathComponent("files", isDirectory: true) + guard FileManager.default.fileExists(atPath: filesURL.path) else { + throw OliphauntError.engine("liboliphaunt \(kind.label) package is missing files directory at \(filesURL.path)") + } + return AssetPackage( + rootURL: rootURL, + filesURL: filesURL, + cacheKey: cacheKey, + extensions: extensions, + sharedPreloadLibraries: sharedPreloadLibraries, + mobileStaticRegistryState: mobileStaticRegistryState, + mobileStaticRegistryRegistered: mobileStaticRegistryRegistered, + mobileStaticRegistryPending: mobileStaticRegistryPending, + nativeModuleStems: nativeModuleStems + ) + } + + private func readManifest(_ url: URL) throws -> [String: String] { + let text = try String(contentsOf: url, encoding: .utf8) + var values: [String: String] = [:] + for rawLine in text.split(whereSeparator: { $0.isNewline }) { + let line = String(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { + continue + } + guard let separator = line.firstIndex(of: "=") else { + continue + } + let key = String(line[.. OliphauntRuntimeResourceSizeReport { + var packageBytes: UInt64? + var runtimeBytes: UInt64? + var templatePgdataBytes: UInt64? + var staticRegistryBytes: UInt64? + var selectedExtensionBytes: UInt64? + var extensionReports: [OliphauntExtensionSizeReport] = [] + var seenExtensionIds = Set() + + let lines = text.split(whereSeparator: \.isNewline).map(String.init) + guard lines.first == "kind\tid\textensions\tfiles\tbytes" else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) has unsupported header" + ) + } + for (index, line) in lines.dropFirst().enumerated() where !line.isEmpty { + let columns = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) + guard columns.count == 5 else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(index + 2) must have 5 tab-separated columns" + ) + } + let bytes = try Self.parseSizeReportUInt64( + columns[4], + source: source, + line: index + 2, + field: "bytes" + ) + switch (columns[0], columns[1]) { + case ("package", "total"): + try Self.setSizeReportValue( + &packageBytes, + bytes, + row: "package/total", + source: source, + line: index + 2 + ) + case ("package", "runtime"): + try Self.setSizeReportValue( + &runtimeBytes, + bytes, + row: "package/runtime", + source: source, + line: index + 2 + ) + case ("package", "template-pgdata"): + try Self.setSizeReportValue( + &templatePgdataBytes, + bytes, + row: "package/template-pgdata", + source: source, + line: index + 2 + ) + case ("package", "static-registry"): + try Self.setSizeReportValue( + &staticRegistryBytes, + bytes, + row: "package/static-registry", + source: source, + line: index + 2 + ) + case ("extensions", "selected"): + try Self.setSizeReportValue( + &selectedExtensionBytes, + bytes, + row: "extensions/selected", + source: source, + line: index + 2 + ) + case ("extension", let id): + guard Self.isPortableId(id) else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(index + 2) has invalid extension id '\(id)'" + ) + } + guard seenExtensionIds.insert(id).inserted else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(index + 2) repeats extension row '\(id)'" + ) + } + guard columns[2] == "-" else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(index + 2) extension rows must use '-' in the extensions column" + ) + } + let fileCount = try Self.parseSizeReportInt( + columns[3], + source: source, + line: index + 2, + field: "files" + ) + extensionReports.append(OliphauntExtensionSizeReport( + name: id, + fileCount: fileCount, + bytes: bytes + )) + default: + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(index + 2) has unknown row \(columns[0])/\(columns[1])" + ) + } + } + + return OliphauntRuntimeResourceSizeReport( + packageBytes: try Self.requireSizeReportValue(packageBytes, "package/total", source), + runtimeBytes: try Self.requireSizeReportValue(runtimeBytes, "package/runtime", source), + templatePgdataBytes: try Self.requireSizeReportValue( + templatePgdataBytes, + "package/template-pgdata", + source + ), + staticRegistryBytes: try Self.requireSizeReportValue( + staticRegistryBytes, + "package/static-registry", + source + ), + selectedExtensionBytes: try Self.requireSizeReportValue( + selectedExtensionBytes, + "extensions/selected", + source + ), + extensions: extensionReports.sorted { $0.name < $1.name } + ) + } + + private static func setSizeReportValue( + _ target: inout UInt64?, + _ value: UInt64, + row: String, + source: String, + line: Int + ) throws { + guard target == nil else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(line) repeats required row \(row)" + ) + } + target = value + } + + private static func requireSizeReportValue( + _ value: UInt64?, + _ row: String, + _ source: String + ) throws -> UInt64 { + guard let value else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) is missing required row \(row)" + ) + } + return value + } + + private static func parseSizeReportUInt64( + _ value: String, + source: String, + line: Int, + field: String + ) throws -> UInt64 { + guard let parsed = UInt64(value) else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(line) has invalid \(field) value '\(value)'" + ) + } + return parsed + } + + private static func parseSizeReportInt( + _ value: String, + source: String, + line: Int, + field: String + ) throws -> Int { + guard let parsed = Int(value), parsed >= 0 else { + throw OliphauntError.engine( + "Oliphaunt package size report \(source) line \(line) has invalid \(field) value '\(value)'" + ) + } + return parsed + } + + static func validateExtensionIds(_ values: [String]) throws -> Set { + Set(try normalizedExtensionIds(values)) + } + + static func normalizedExtensionIds(_ values: [String]) throws -> [String] { + try normalizedPortableIds(values, label: "extension id") + } + + static func validatePortableIds(_ values: [String], label: String) throws -> Set { + Set(try normalizedPortableIds(values, label: label)) + } + + static func normalizedPortableIds(_ values: [String], label: String) throws -> [String] { + var validated: [String] = [] + for value in values.map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) where !value.isEmpty { + guard isPortableId(value) else { + throw OliphauntError.engine( + "Swift Oliphaunt \(label) '\(value)' must contain only ASCII letters, digits, '.', '_' or '-'" + ) + } + validated.append(value) + } + return validated + } + + private static func validateMobileStaticRegistryState(_ state: String?) throws -> String? { + guard let state, !state.isEmpty else { + return nil + } + guard state == "not-required" || state == "complete" || state == "pending" else { + throw OliphauntError.engine( + "Swift Oliphaunt mobileStaticRegistryState '\(state)' must be one of not-required, complete, or pending" + ) + } + return state + } + + private static func validateMobileStaticRegistryManifest( + state: String?, + registered: Set, + pending: Set, + nativeModuleStems: Set + ) throws { + guard let state else { + throw OliphauntError.engine( + "Swift Oliphaunt mobile static-registry manifest omits mobileStaticRegistryState" + ) + } + guard registered.isDisjoint(with: pending) else { + throw OliphauntError.engine( + "Swift Oliphaunt mobile static-registry manifest lists the same extension as registered and pending" + ) + } + switch state { + case "not-required": + guard registered.isEmpty, pending.isEmpty, nativeModuleStems.isEmpty else { + throw OliphauntError.engine( + "Swift Oliphaunt mobileStaticRegistryState=not-required must not list registered, pending, or native module stems" + ) + } + case "pending": + guard !pending.isEmpty else { + throw OliphauntError.engine( + "Swift Oliphaunt mobileStaticRegistryState=pending must list mobileStaticRegistryPending" + ) + } + case "complete": + guard pending.isEmpty else { + throw OliphauntError.engine( + "Swift Oliphaunt mobileStaticRegistryState=complete must not list mobileStaticRegistryPending" + ) + } + guard !registered.isEmpty, !nativeModuleStems.isEmpty else { + throw OliphauntError.engine( + "Swift Oliphaunt mobileStaticRegistryState=complete must list mobileStaticRegistryRegistered and nativeModuleStems" + ) + } + default: + return + } + } + + static func isPortableId(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard !bytes.isEmpty, bytes.count <= 128 else { + return false + } + return bytes.allSatisfy { byte in + (byte >= 65 && byte <= 90) || + (byte >= 97 && byte <= 122) || + (byte >= 48 && byte <= 57) || + byte == 45 || + byte == 46 || + byte == 95 + } + } +} + +private func defaultBundleResourceURLs() -> [URL] { + let preferred = Bundle(identifier: "dev.oliphaunt.liboliphaunt").map { [$0] } ?? [] + let bundles = preferred + Bundle.allFrameworks + Bundle.allBundles + [Bundle.main] + var seen = Set() + var urls: [URL] = [] + for bundle in bundles { + guard let url = bundle.resourceURL else { + continue + } + let key = url.standardizedFileURL.path + if seen.insert(key).inserted { + urls.append(url) + } + } + return urls +} + +private enum AssetPackageKind { + case runtime + case templatePgdata + + var label: String { + switch self { + case .runtime: + return "runtime" + case .templatePgdata: + return "template-pgdata" + } + } + + var expectedLayout: String { + switch self { + case .runtime: + return oliphauntRuntimePackageLayout + case .templatePgdata: + return oliphauntTemplatePgdataPackageLayout + } + } + + func root(in resourceRoot: URL) -> URL { + resourceRoot.appendingPathComponent(label, isDirectory: true) + } +} + +private struct AssetPackage { + var rootURL: URL + var filesURL: URL + var cacheKey: String + var extensions: Set + var sharedPreloadLibraries: Set + var mobileStaticRegistryState: String? + var mobileStaticRegistryRegistered: Set + var mobileStaticRegistryPending: Set + var nativeModuleStems: Set +} + +private func copyTree(from source: URL, to destination: URL) throws { + let values = try source.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + if values.isSymbolicLink == true { + throw OliphauntError.engine("refusing to copy symbolic link in Oliphaunt resources: \(source.path)") + } + if values.isDirectory == true { + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + let children = try FileManager.default.contentsOfDirectory( + at: source, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) + for child in children { + try copyTree(from: child, to: destination.appendingPathComponent(child.lastPathComponent)) + } + } else { + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(at: source, to: destination) + } +} + +private func hardenPgdataPermissions(at pgdata: URL) throws { + let fileManager = FileManager.default + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: pgdata.path) + guard let enumerator = fileManager.enumerator( + at: pgdata, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) else { + return + } + + for case let url as URL in enumerator { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + if values.isSymbolicLink == true { + continue + } + let permissions = values.isDirectory == true ? 0o700 : 0o600 + try fileManager.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) + } +} + +private func ensurePgdataDirectoryLayout(at pgdata: URL) throws { + let requiredDirectories = [ + "base", + "global", + "pg_commit_ts", + "pg_dynshmem", + "pg_logical", + "pg_logical/mappings", + "pg_logical/snapshots", + "pg_multixact", + "pg_multixact/members", + "pg_multixact/offsets", + "pg_notify", + "pg_replslot", + "pg_serial", + "pg_snapshots", + "pg_stat", + "pg_stat_tmp", + "pg_subtrans", + "pg_tblspc", + "pg_twophase", + "pg_wal", + "pg_wal/archive_status", + "pg_wal/summaries", + "pg_xact", + ] + for relativePath in requiredDirectories { + try FileManager.default.createDirectory( + at: pgdata.appendingPathComponent(relativePath, isDirectory: true), + withIntermediateDirectories: true + ) + } +} diff --git a/src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift b/src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift new file mode 100644 index 00000000..76cd69fa --- /dev/null +++ b/src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift @@ -0,0 +1,2461 @@ +import Foundation +@testable import Oliphaunt +import Testing + +@Test +func opensAndExecutesThroughInjectedEngine() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: MockEngine(mode: .nativeDirect) + ) + + // OLIPHAUNT_DOCS_SNIPPET swift-quickstart + let response = try await database.execProtocolRaw(Data([0x51])) + #expect(response == Data([1, 0x51])) +} + +@Test +func queryParsesSimpleQueryResultsThroughInjectedEngine() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: MockEngine(mode: .nativeDirect) + ) + + let result = try await database.query("SELECT 1::text AS value, NULL AS empty") + + #expect(result.fields.map(\.name) == ["value", "empty"]) + #expect(result.fields[0].typeOID == 25) + #expect(result.rowCount == 1) + #expect(result.commandTag == "SELECT 1") + #expect(try result.getText(row: 0, column: "value") == "1") + #expect(try result.getText(row: 0, column: "empty") == nil) +} + +@Test +func queryParametersUseExtendedProtocolThroughInjectedEngine() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: MockEngine(mode: .nativeDirect) + ) + + let request = try OliphauntProtocol.extendedQuery( + "SELECT $1::text AS value, $2::text AS empty", + parameters: [.text("1"), .null] + ) + #expect(request.first == 0x50) + #expect(request.contains(0x42)) + #expect(request.contains(0x45)) + + let result = try await database.query( + "SELECT $1::text AS value, $2::text AS empty", + parameters: [.text("1"), .null] + ) + + #expect(try result.getText(row: 0, column: "value") == "1") + #expect(try result.getText(row: 0, column: "empty") == nil) +} + +@Test +func simpleQueryRejectsNulSQLBeforeBuildingProtocol() throws { + do { + _ = try OliphauntProtocol.simpleQuery("SELECT 1\0SELECT 2") + Issue.record("simple-query builder should reject NUL-containing SQL") + } catch OliphauntError.engine(let message) { + #expect(message == "simple query SQL must not contain NUL bytes") + } +} + +@Test +func extendedQueryRejectsInvalidFrontendInputsBeforeBuildingProtocol() throws { + do { + _ = try OliphauntProtocol.extendedQuery("SELECT \0", parameters: [.null]) + Issue.record("extended-query builder should reject NUL-containing SQL") + } catch OliphauntError.engine(let message) { + #expect(message == "extended query SQL must not contain NUL bytes") + } + + let tooMany = Array(repeating: OliphauntQueryParam.null, count: Int(Int16.max) + 1) + do { + _ = try OliphauntProtocol.extendedQuery("SELECT 1", parameters: tooMany) + Issue.record("extended-query builder should reject too many parameters") + } catch OliphauntError.engine(let message) { + #expect(message == "extended query supports at most \(Int16.max) parameters, got \(Int(Int16.max) + 1)") + } +} + +@Test +func transactionCommitsAndRejectsUnpinnedInterleaving() async throws { + let session = MockSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let value = try await database.transaction { transaction in + do { + _ = try await database.execute("SELECT outside_transaction") + Issue.record("database work should not interleave while a transaction is active") + } catch OliphauntError.engine(let message) { + #expect(message.contains("active OliphauntTransaction")) + } + do { + try await database.checkpoint() + Issue.record("checkpoint should not interleave while a transaction is active") + } catch OliphauntError.engine(let message) { + #expect(message.contains("active OliphauntTransaction")) + } + _ = try await transaction.execute("INSERT INTO swift_tx VALUES (1)") + let chunks = DataChunkAccumulator() + try await transaction.execProtocolStream(Data([0x52])) { chunk in + chunks.append(chunk) + } + #expect(chunks.chunks().map { Array($0) } == [[3, 0x52]]) + return 7 + } + + try await database.checkpoint() + #expect(value == 7) + let requests = await session.requestTexts() + #expect(requests.contains { $0.contains("BEGIN") }) + #expect(requests.contains { $0.contains("INSERT INTO swift_tx") }) + #expect(requests.contains { $0.contains("COMMIT") }) + #expect(requests.contains { $0.contains("CHECKPOINT") }) + #expect(!requests.contains { $0.contains("ROLLBACK") }) + + do { + let escaped = try await database.transaction { transaction in + transaction + } + _ = try await escaped.execute("SELECT after_commit") + Issue.record("escaped transaction should be inactive after commit") + } catch OliphauntError.engine(let message) { + #expect(message.contains("transaction is no longer active")) + } +} + +@Test +func transactionRollsBackWhenBodyThrows() async throws { + let session = MockSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let captured = TransactionCapture() + do { + try await database.transaction { transaction in + captured.store(transaction) + _ = try await transaction.execute("INSERT INTO swift_tx VALUES (2)") + throw OliphauntError.engine("boom") + } + Issue.record("transaction body error should escape") + } catch OliphauntError.engine(let message) { + #expect(message == "boom") + } + + let requests = await session.requestTexts() + #expect(requests.contains { $0.contains("BEGIN") }) + #expect(requests.contains { $0.contains("INSERT INTO swift_tx") }) + #expect(requests.contains { $0.contains("ROLLBACK") }) + + guard let rollbackTransaction = captured.load() else { + Issue.record("transaction body did not capture the rollback handle") + return + } + do { + _ = try await rollbackTransaction.execute("SELECT after_rollback") + Issue.record("captured transaction should be inactive after rollback") + } catch OliphauntError.engine(let message) { + #expect(message.contains("transaction is no longer active")) + } +} + +@Test +func closeDuringTransactionClosesSessionAndRejectsPinnedWork() async throws { + let session = MockSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + do { + try await database.transaction { transaction in + try await database.close() + _ = try await transaction.execute("SELECT after_close") + } + Issue.record("transaction should fail after close") + } catch OliphauntError.databaseClosed { + // Expected: close is a lifecycle boundary and no work runs afterward. + } + + do { + _ = try await database.execute("SELECT after_closed_database") + Issue.record("database work should fail after close") + } catch OliphauntError.databaseClosed { + // Expected. + } + + let requests = await session.requestTexts() + #expect(requests.contains { $0.contains("BEGIN") }) + #expect(!requests.contains { $0.contains("SELECT after_close") }) + #expect(!requests.contains { $0.contains("COMMIT") }) +} + +@Test +func rawProtocolStreamFallsBackToOwnedResponseThroughInjectedEngine() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: MockEngine(mode: .nativeDirect) + ) + + let chunks = DataChunkAccumulator() + try await database.execProtocolStream(Data([0x51])) { chunk in + chunks.append(chunk) + } + + #expect(chunks.chunks() == [Data([1, 0x51])]) +} + +@Test +func querySurfacesPostgresErrors() throws { + do { + _ = try parseOliphauntQueryResponse(backendErrorResponse("ERROR", "42P01", "relation does not exist")) + Issue.record("query parser should surface PostgreSQL ErrorResponse") + } catch OliphauntError.postgres(let error) { + #expect(error.severity == "ERROR") + #expect(error.sqlstate == "42P01") + #expect(error.message == "relation does not exist") + #expect(error.description == "ERROR [42P01]: relation does not exist") + } +} + +@Test +func queryNormalizesCancellationPostgresErrors() throws { + do { + _ = try parseOliphauntQueryResponse(backendErrorResponse( + "ERROR", + "57014", + "canceling statement due to user request" + )) + Issue.record("query parser should surface cancellation as a PostgreSQL ErrorResponse") + } catch OliphauntError.postgres(let error) { + #expect(error.severity == "ERROR") + #expect(error.sqlstate == "57014") + #expect(error.message == "canceling statement due to user request") + } +} + +@Test +func queryParserRejectsInvalidUTF8FieldNames() throws { + var response = Data() + appendRawRowDescription(&response, fields: [(Data([0xff]), UInt32(25))]) + appendReadyForQuery(&response) + + do { + _ = try parseOliphauntQueryResponse(response) + Issue.record("query parser should reject malformed UTF-8 field names") + } catch OliphauntError.engine(let message) { + #expect(message.contains("field name is not valid UTF-8")) + } +} + +@Test +func queryTextAccessorsRejectInvalidUTF8Values() throws { + var response = Data() + appendRowDescription(&response, fields: [("value", UInt32(25))]) + appendDataRow(&response, values: [Data([0xff])]) + appendCommandComplete(&response, "SELECT 1") + appendReadyForQuery(&response) + + let result = try parseOliphauntQueryResponse(response) + do { + _ = try result.getText(row: 0, column: "value") + Issue.record("query text accessor should reject malformed UTF-8 values") + } catch OliphauntError.engine(let message) { + #expect(message.contains("query value is not valid UTF-8")) + } +} + +@Test +func queryParserAcceptsExtendedQueryControlMessages() throws { + var response = Data() + appendBackendMessage(&response, tag: 0x31, body: Data()) + appendBackendMessage(&response, tag: 0x32, body: Data()) + appendBackendMessage(&response, tag: 0x6e, body: Data()) + appendCommandComplete(&response, "INSERT 0 0") + appendReadyForQuery(&response) + + let result = try parseOliphauntQueryResponse(response) + #expect(result.fields.isEmpty) + #expect(result.rows.isEmpty) + #expect(result.commandTag == "INSERT 0 0") +} + +@Test +func queryParserAcceptsAsyncBackendControlMessages() throws { + var response = Data() + appendParameterStatus(&response, name: "client_encoding", value: "UTF8") + appendNoticeResponse(&response, severity: "NOTICE", message: "hello") + appendNotificationResponse(&response, pid: 123, channel: "channel", payload: "payload") + appendCommandComplete(&response, "SELECT 0") + appendReadyForQuery(&response) + + let result = try parseOliphauntQueryResponse(response) + #expect(result.commandTag == "SELECT 0") +} + +@Test +func queryParserRejectsMalformedEmptyControlMessages() throws { + var response = Data() + appendBackendMessage(&response, tag: 0x31, body: Data([0])) + appendReadyForQuery(&response) + + do { + _ = try parseOliphauntQueryResponse(response) + Issue.record("query parser should reject malformed empty control messages") + } catch OliphauntError.engine(let message) { + #expect(message.contains("ParseComplete contained trailing bytes")) + } +} + +@Test +func queryParserRejectsMalformedAsyncBackendControlMessages() throws { + var malformedParameter = Data() + appendBackendMessage( + &malformedParameter, + tag: 0x53, + body: Data("client_encoding\u{0}".utf8) + ) + appendReadyForQuery(&malformedParameter) + do { + _ = try parseOliphauntQueryResponse(malformedParameter) + Issue.record("query parser should reject malformed ParameterStatus") + } catch OliphauntError.engine(let message) { + #expect(message.contains("ParameterStatus value is missing null terminator")) + } + + var malformedNotice = Data() + var noticeBody = Data([0x53]) + noticeBody.append(Data("NOTICE\u{0}".utf8)) + appendBackendMessage(&malformedNotice, tag: 0x4e, body: noticeBody) + appendReadyForQuery(&malformedNotice) + do { + _ = try parseOliphauntQueryResponse(malformedNotice) + Issue.record("query parser should reject malformed NoticeResponse") + } catch OliphauntError.engine(let message) { + #expect(message.contains("NoticeResponse is missing terminator")) + } + + var malformedNotification = Data() + var notificationBody = Data() + appendInt32(¬ificationBody, 123) + notificationBody.append(Data("channel".utf8)) + appendBackendMessage(&malformedNotification, tag: 0x41, body: notificationBody) + appendReadyForQuery(&malformedNotification) + do { + _ = try parseOliphauntQueryResponse(malformedNotification) + Issue.record("query parser should reject malformed NotificationResponse") + } catch OliphauntError.engine(let message) { + #expect(message.contains("NotificationResponse channel is missing null terminator")) + } +} + +@Test +func queryParserRejectsUnexpectedBackendMessageTags() throws { + var response = Data() + appendBackendMessage(&response, tag: 0x52, body: Data([0, 0, 0, 0])) + appendReadyForQuery(&response) + + do { + _ = try parseOliphauntQueryResponse(response) + Issue.record("query parser should reject unexpected backend message tags") + } catch OliphauntError.engine(let message) { + #expect(message.contains("unexpected backend message tag 0x52")) + } +} + +@Test +func queryParserAcceptsReadyForQueryTransactionStates() throws { + for status in [UInt8(0x49), UInt8(0x54), UInt8(0x45)] { + var response = Data() + appendCommandComplete(&response, "SELECT 0") + appendReadyForQuery(&response, status: status) + + let result = try parseOliphauntQueryResponse(response) + #expect(result.commandTag == "SELECT 0") + } +} + +@Test +func queryParserRejectsMalformedReadyForQueryStatus() throws { + var missing = Data() + appendBackendMessage(&missing, tag: 0x5a, body: Data()) + do { + _ = try parseOliphauntQueryResponse(missing) + Issue.record("query parser should reject ReadyForQuery without status") + } catch OliphauntError.engine(let message) { + #expect(message.contains("ReadyForQuery contained 0 bytes, expected 1")) + } + + var invalid = Data() + appendReadyForQuery(&invalid, status: 0) + do { + _ = try parseOliphauntQueryResponse(invalid) + Issue.record("query parser should reject invalid ReadyForQuery status") + } catch OliphauntError.engine(let message) { + #expect(message.contains("ReadyForQuery contained invalid transaction status 0x00")) + } +} + +@Test +func serverCapabilitiesExposeConnectionString() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeServer), + engine: MockEngine(mode: .nativeServer) + ) + + let capabilities = try await database.capabilities() + #expect(capabilities.independentSessions) + #expect(!capabilities.multiRoot) + #expect(capabilities.queryCancel) + #expect(capabilities.backupRestore) + #expect(capabilities.backupFormats == [.sql, .physicalArchive]) + #expect(capabilities.restoreFormats == [.physicalArchive]) + #expect(capabilities.supportsBackupFormat(.sql)) + #expect(capabilities.supportsBackupFormat(.physicalArchive)) + #expect(!capabilities.supportsBackupFormat(.oliphauntArchive)) + #expect(capabilities.supportsRestoreFormat(.physicalArchive)) + #expect(!capabilities.supportsRestoreFormat(.sql)) + #expect(try await database.supportsBackupFormat(.sql)) + #expect(try await database.supportsRestoreFormat(.physicalArchive)) + #expect(!(try await database.supportsRestoreFormat(.sql))) + #expect(capabilities.simpleQuery) + #expect(capabilities.connectionString == "postgres://postgres@127.0.0.1:55432/template1") +} + +@Test +func connectionStringIsOnlyPresentForServerCapabilities() async throws { + for mode in [OliphauntEngineMode.nativeDirect, .nativeBroker] { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: mode), + engine: MockEngine(mode: mode) + ) + #expect(try await database.connectionString() == nil) + #expect(!(try await database.capabilities()).independentSessions) + } + + let server = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeServer), + engine: MockEngine(mode: .nativeServer) + ) + #expect(try await server.connectionString() == "postgres://postgres@127.0.0.1:55432/template1") + #expect((try await server.capabilities()).independentSessions) +} + +@Test +func backupUsesCanonicalFormats() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeServer), + engine: MockEngine(mode: .nativeServer) + ) + + let artifact = try await database.backup(OliphauntBackupRequest(format: .sql)) + #expect(artifact.format == .sql) + #expect(artifact.bytes == Data("sql-backup".utf8)) +} + +@Test +func backupRejectsUnsupportedFormatsBeforeEngineCall() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: MockEngine(mode: .nativeDirect) + ) + + do { + _ = try await database.backup(OliphauntBackupRequest(format: .sql)) + Issue.record("nativeDirect SQL backup should be rejected by capabilities") + } catch OliphauntError.engine(let message) { + #expect(message.contains("sql backup is not supported by nativeDirect")) + } +} + +@Test +func openRejectsNonFileRootBeforeEngineCall() async throws { + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + root: URL(string: "https://example.invalid/liboliphaunt")! + ), + engine: MockEngine(mode: .nativeDirect) + ) + Issue.record("non-file database roots should be rejected before engine open") + } catch OliphauntError.engine(let message) { + #expect(message.contains("database root must be a file URL")) + } +} + +@Test +func openRejectsNulRootBeforeEngineCall() async throws { + let engine = CountingEngine() + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + root: URL(string: "file:///tmp/oliphaunt-swift%00root")! + ), + engine: engine + ) + Issue.record("NUL-containing database roots should be rejected before engine open") + } catch OliphauntError.engine(let message) { + #expect(message.contains("database root must not contain NUL bytes")) + } + #expect(await engine.openCallCount() == 0) +} + +@Test +func openValidatesExtensionIdsBeforeEngineCall() async throws { + let engine = CountingEngine() + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + extensions: ["mobile/vector"] + ), + engine: engine + ) + Issue.record("invalid extension ids should be rejected before engine open") + } catch OliphauntError.engine(let message) { + #expect(message.contains("extension id 'mobile/vector'")) + } + #expect(await engine.openCallCount() == 0) + + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + extensions: [" pg_trgm ", "", "vector", "hstore"] + ), + engine: engine + ) + #expect(await engine.openCallCount() == 1) + #expect(await engine.lastExtensions() == ["pg_trgm", "vector", "hstore"]) + try await database.close() +} + +@Test +func openForwardsFootprintAndStartupGUCsAndRejectsInvalidGUCsBeforeEngineCall() async throws { + let engine = CountingEngine() + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + startupGUCs: [OliphauntStartupGUC("shared-buffers", "16MB")] + ), + engine: engine + ) + Issue.record("invalid startup GUC names should be rejected before engine open") + } catch OliphauntError.engine(let message) { + #expect(message.contains("startup GUC name 'shared-buffers'")) + } + #expect(await engine.openCallCount() == 0) + + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + runtimeFootprint: .balancedMobile, + startupGUCs: [ + OliphauntStartupGUC("shared_buffers", "16MB"), + OliphauntStartupGUC("wal_buffers", "256kB"), + ] + ), + engine: engine + ) + #expect(await engine.openCallCount() == 1) + #expect(await engine.lastRuntimeFootprint() == .balancedMobile) + #expect(await engine.lastStartupGUCs() == [ + OliphauntStartupGUC("shared_buffers", "16MB"), + OliphauntStartupGUC("wal_buffers", "256kB"), + ]) + try await database.close() +} + +@Test +func runtimeFootprintProfilesBuildTheMobileStartupGUCContract() { + #expect( + startupAssignments( + OliphauntConfiguration( + durability: .balanced, + runtimeFootprint: .balancedMobile, + startupGUCs: [OliphauntStartupGUC(" shared_buffers ", "16MB")] + ).postgresStartupArgs() + ) == [ + "max_connections=1", + "superuser_reserved_connections=0", + "reserved_connections=0", + "autovacuum_worker_slots=1", + "max_wal_senders=0", + "max_replication_slots=0", + "shared_buffers=32MB", + "wal_buffers=-1", + "min_wal_size=32MB", + "max_wal_size=64MB", + "io_method=sync", + "io_max_concurrency=1", + "fsync=on", + "full_page_writes=on", + "synchronous_commit=off", + "shared_buffers=16MB", + ] + ) + #expect( + startupAssignments( + OliphauntConfiguration(runtimeFootprint: .smallMobile).postgresStartupArgs() + ) == [ + "max_connections=1", + "superuser_reserved_connections=0", + "reserved_connections=0", + "autovacuum_worker_slots=1", + "max_wal_senders=0", + "max_replication_slots=0", + "shared_buffers=8MB", + "wal_buffers=256kB", + "min_wal_size=32MB", + "max_wal_size=64MB", + "work_mem=1MB", + "maintenance_work_mem=16MB", + "io_method=sync", + "io_max_concurrency=1", + "fsync=on", + "full_page_writes=on", + "synchronous_commit=off", + ] + ) +} + +@Test +func openForwardsConnectionIdentityAndRejectsInvalidIdentityBeforeEngineCall() async throws { + let engine = CountingEngine() + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + username: " \n" + ), + engine: engine + ) + Issue.record("blank usernames should be rejected before engine open") + } catch OliphauntError.engine(let message) { + #expect(message.contains("username must not be empty")) + } + #expect(await engine.openCallCount() == 0) + + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + username: "app_user", + database: "app_db" + ), + engine: engine + ) + #expect(await engine.openCallCount() == 1) + #expect(await engine.lastUsername() == "app_user") + #expect(await engine.lastDatabase() == "app_db") + try await database.close() +} + +@Test +func restoreUsesCanonicalPhysicalArchiveShape() async throws { + let artifact = OliphauntBackupArtifact( + format: .physicalArchive, + bytes: Data("physical-backup".utf8) + ) + let root = URL(fileURLWithPath: "/tmp/oliphaunt-swift-restore") + let restored = try await OliphauntDatabase.restore( + OliphauntRestoreRequest(artifact: artifact, root: root).replaceExisting(), + engine: MockEngine(mode: .nativeDirect) + ) + + #expect(restored == root) +} + +@Test +func restoreRejectsUnsupportedFormatsBeforeEngineCall() async throws { + let request = OliphauntRestoreRequest( + artifact: OliphauntBackupArtifact(format: .sql, bytes: Data("sql-backup".utf8)), + root: URL(fileURLWithPath: "/tmp/oliphaunt-swift-restore-sql") + ) + + do { + _ = try await OliphauntDatabase.restore( + request, + engine: MockEngine(mode: .nativeDirect) + ) + Issue.record("SQL restore should be rejected before the engine call") + } catch OliphauntError.engine(let message) { + #expect(message.contains("restore currently requires a physicalArchive artifact, got sql")) + } +} + +@Test +func restoreRejectsNonFileRootBeforeEngineCall() async throws { + let request = OliphauntRestoreRequest( + artifact: OliphauntBackupArtifact( + format: .physicalArchive, + bytes: Data("physical-backup".utf8) + ), + root: URL(string: "https://example.invalid/liboliphaunt-restore")! + ) + + do { + _ = try await OliphauntDatabase.restore( + request, + engine: MockEngine(mode: .nativeDirect) + ) + Issue.record("non-file restore roots should be rejected before engine restore") + } catch OliphauntError.engine(let message) { + #expect(message.contains("restore root must be a file URL")) + } +} + +@Test +func restoreRejectsNulRootBeforeEngineCall() async throws { + let engine = CountingEngine() + let request = OliphauntRestoreRequest( + artifact: OliphauntBackupArtifact( + format: .physicalArchive, + bytes: Data("physical-backup".utf8) + ), + root: URL(string: "file:///tmp/oliphaunt-swift%00restore")! + ) + + do { + _ = try await OliphauntDatabase.restore(request, engine: engine) + Issue.record("NUL-containing restore roots should be rejected before engine restore") + } catch OliphauntError.engine(let message) { + #expect(message.contains("restore root must not contain NUL bytes")) + } + #expect(await engine.restoreCallCount() == 0) +} + +@Test +func closeIsIdempotentAndRejectsFurtherExecution() async throws { + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: MockEngine(mode: .nativeDirect) + ) + + try await database.close() + try await database.close() + + do { + _ = try await database.execProtocolRaw(Data()) + Issue.record("execution after close should fail") + } catch OliphauntError.databaseClosed { + } +} + +@Test +func closeWaitsForActiveExecutionBeforeClosing() async throws { + let session = BlockingSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let running = Task { + try await database.execProtocolRaw(Data("SELECT pg_sleep(5)".utf8)) + } + await session.waitUntilStarted() + + let closing = Task { + try await database.close() + } + await Task.yield() + + #expect(!(await session.wasClosed())) + await session.releaseExecution(with: Data("finished".utf8)) + let response = try await running.value + #expect(response == Data("finished".utf8)) + try await closing.value + #expect(!(await session.wasCancelled())) + #expect(await session.wasClosed()) + + do { + try await database.cancel() + Issue.record("cancel after close should fail") + } catch OliphauntError.databaseClosed { + } +} + +@Test +func sessionOperationsQueueFifoAcrossConcurrentTasks() async throws { + let session = BlockingSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let first = Task { + try await database.execProtocolRaw(Data([0x4c])) + } + await session.waitForRequestCount(1) + + let second = Task { + try await database.execProtocolRaw(Data([0x31])) + } + await Task.yield() + + #expect(await session.requestBytes() == [Data([0x4c])]) + + await session.releaseExecution(with: Data([0xf0])) + #expect(try await first.value == Data([0xf0])) + await session.waitForRequestCount(2) + + #expect(await session.requestBytes() == [Data([0x4c]), Data([0x31])]) + + await session.releaseExecution(with: Data([0xf1])) + #expect(try await second.value == Data([0xf1])) +} + +@Test +func closeRejectsQueuedWorkBeforeNativeSessionCall() async throws { + let session = BlockingSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let running = Task { + try await database.execProtocolRaw(Data("SELECT active".utf8)) + } + await session.waitForRequestCount(1) + + let queued = Task { + try await database.execProtocolRaw(Data("SELECT queued".utf8)) + } + await Task.yield() + + let closing = Task { + try await database.close() + } + await Task.yield() + + #expect(await session.requestTexts() == ["SELECT active"]) + + await session.releaseExecution(with: Data("active done".utf8)) + #expect(try await running.value == Data("active done".utf8)) + do { + _ = try await queued.value + Issue.record("queued work should be rejected after close detaches the database") + } catch OliphauntError.databaseClosed { + } + try await closing.value + + #expect(await session.wasClosed()) + #expect(await session.requestTexts() == ["SELECT active"]) +} + +@Test +func prepareForBackgroundCheckpointsWhenIdleAndResumeProbesSession() async throws { + let session = MockSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let prepared = try await database.prepareForBackground() + + #expect(prepared == OliphauntBackgroundPreparationResult( + cancelledActiveWork: false, + checkpointed: true + )) + try await database.resumeFromBackground() + + let requests = await session.requestTexts() + #expect(requests.contains { $0.contains("CHECKPOINT") }) + #expect(requests.contains { $0.contains("SELECT 1") }) +} + +@Test +func prepareForBackgroundCancelsActiveWorkAndSkipsCheckpoint() async throws { + let session = BlockingSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let running = Task { + try await database.execProtocolRaw(Data("SELECT pg_sleep(5)".utf8)) + } + await session.waitUntilStarted() + + let prepared = try await database.prepareForBackground() + + #expect(prepared == OliphauntBackgroundPreparationResult( + cancelledActiveWork: true, + checkpointed: false, + skippedCheckpointReason: .activeWork + )) + #expect(try await running.value == Data("cancelled".utf8)) + #expect(await session.wasCancelled()) +} + +@Test +func prepareForBackgroundSkipsCheckpointWhileTransactionIsActive() async throws { + let session = MockSession(mode: .nativeDirect) + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect), + engine: FixedSessionEngine(session: session) + ) + + let prepared = try await database.transaction { _ in + try await database.prepareForBackground() + } + + #expect(prepared == OliphauntBackgroundPreparationResult( + cancelledActiveWork: false, + checkpointed: false, + skippedCheckpointReason: .transactionActive + )) + let requests = await session.requestTexts() + #expect(!requests.contains { $0.contains("CHECKPOINT") }) +} + +@Test +func nativeDirectEngineReportsMissingLibrary() async throws { + let root = try makeExistingPgdataRoot() + defer { + try? FileManager.default.removeItem(at: root) + } + let engine = OliphauntNativeDirectEngine( + libraryURL: URL(fileURLWithPath: "/tmp/oliphaunt-swift-missing.dylib") + ) + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect, root: root), + engine: engine + ) + Issue.record("opening with a missing liboliphaunt library should fail") + } catch OliphauntError.engine(let message) { + #expect(message.contains("failed to load liboliphaunt")) + } +} + +@Test +func defaultEngineUsesNativeDirectRuntimeForNativeDirect() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["LIBOLIPHAUNT_PATH"] == nil else { + return + } + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect) + ) + Issue.record("default nativeDirect engine should fail while liboliphaunt is unavailable") + } catch OliphauntError.engine(let message) { + #expect(message.contains("oliphaunt")) + } +} + +@Test +func defaultEngineRejectsBrokerAndServerUntilThoseRuntimesAreLinked() async throws { + for mode in [OliphauntEngineMode.nativeBroker, .nativeServer] { + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: mode) + ) + Issue.record("default engine should reject \(mode.rawValue)") + } catch OliphauntError.runtimeUnavailable(let unavailableMode) { + #expect(unavailableMode == mode) + } + } +} + +@Test +func defaultEnginePublishesExplicitModeSupport() throws { + let support = OliphauntDatabase.supportedModes() + + #expect(support.map(\.mode) == [.nativeDirect, .nativeBroker, .nativeServer]) + #expect(support[0].available) + #expect(support[0].capabilities.maxClientSessions == 1) + #expect(support[0].capabilities.backupFormats == [.physicalArchive]) + #expect(support[0].capabilities.supportsBackupFormat(.physicalArchive)) + #expect(!support[0].capabilities.supportsBackupFormat(.sql)) + #expect(!support[0].capabilities.independentSessions) + #expect(!support[0].capabilities.multiRoot) + #expect(support[0].capabilities.reopenable) + #expect(support[0].capabilities.sameRootLogicalReopen) + #expect(!support[0].capabilities.rootSwitchable) + #expect(!support[0].capabilities.crashRestartable) + #expect(!support[1].available) + #expect(support[1].capabilities.processIsolated) + #expect(support[1].capabilities.multiRoot) + #expect(support[1].capabilities.reopenable) + #expect(!support[1].capabilities.sameRootLogicalReopen) + #expect(support[1].capabilities.rootSwitchable) + #expect(support[1].capabilities.crashRestartable) + #expect(support[1].unavailableReason?.contains("broker") == true) + #expect(!support[2].available) + #expect(support[2].capabilities.independentSessions) + #expect(!support[2].capabilities.multiRoot) + #expect(support[2].capabilities.reopenable) + #expect(!support[2].capabilities.sameRootLogicalReopen) + #expect(support[2].capabilities.rootSwitchable) + #expect(!support[2].capabilities.crashRestartable) + #expect(support[2].capabilities.backupFormats == [.sql, .physicalArchive]) + #expect(support[2].capabilities.supportsBackupFormat(.sql)) + #expect(support[2].capabilities.supportsRestoreFormat(.physicalArchive)) + #expect(support[2].unavailableReason?.contains("server") == true) +} + +@Test +func nativeDirectExtensionsRequireExplicitRuntimeDirectory() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["OLIPHAUNT_INSTALL_DIR"] == nil, + environment["OLIPHAUNT_RUNTIME_DIR"] == nil + else { + return + } + let engine = OliphauntNativeDirectEngine( + libraryURL: URL(fileURLWithPath: "/tmp/oliphaunt-swift-missing.dylib") + ) + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + extensions: ["vector"] + ), + engine: engine + ) + Issue.record("opening with extensions but no runtime directory should fail") + } catch OliphauntError.engine(let message) { + #expect(message.contains("extensions require runtimeDirectory")) + } +} + +@Test +func nativeDirectExtensionIdsArePortable() async throws { + let engine = OliphauntNativeDirectEngine( + libraryURL: URL(fileURLWithPath: "/tmp/oliphaunt-swift-missing.dylib"), + runtimeDirectory: URL(fileURLWithPath: "/tmp/oliphaunt-swift-runtime") + ) + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + extensions: ["mobile/vector"] + ), + engine: engine + ) + Issue.record("opening with a non-portable extension id should fail") + } catch OliphauntError.engine(let message) { + #expect(message.contains("must contain only ASCII")) + } +} + +@Test +func nativeDirectExtensionsUseExplicitRuntimeDirectory() async throws { + let root = try makeExistingPgdataRoot() + defer { + try? FileManager.default.removeItem(at: root) + } + let engine = OliphauntNativeDirectEngine( + libraryURL: URL(fileURLWithPath: "/tmp/oliphaunt-swift-missing.dylib"), + runtimeDirectory: URL(fileURLWithPath: "/tmp/oliphaunt-swift-runtime") + ) + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + root: root, + extensions: ["vector"] + ), + engine: engine + ) + Issue.record("missing liboliphaunt should fail after extension validation") + } catch OliphauntError.engine(let message) { + #expect(message.contains("failed to load liboliphaunt")) + } +} + +@Test +func runtimeResourcesMaterializeRuntimeAndPrepareTemplatePgdata() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + let runtime = try resources.materializeRuntime(requestedExtensions: ["vector"]) + #expect(FileManager.default.fileExists( + atPath: runtime.appendingPathComponent("share/postgresql/README.liboliphaunt-smoke").path + )) + #expect(FileManager.default.fileExists( + atPath: runtime.appendingPathComponent("share/postgresql/extension/vector.control").path + )) + #expect(FileManager.default.fileExists( + atPath: runtime.appendingPathComponent("share/postgresql/extension/vector--1.0.sql").path + )) + #expect(!FileManager.default.fileExists( + atPath: runtime.appendingPathComponent("share/postgresql/extension/hstore.control").path + )) + + let pgdata = fixture.root.appendingPathComponent("app-root/pgdata", isDirectory: true) + #expect(try resources.preparePgdata(at: pgdata)) + #expect(FileManager.default.fileExists(atPath: pgdata.appendingPathComponent("PG_VERSION").path)) + #expect(FileManager.default.fileExists(atPath: pgdata.appendingPathComponent("pg_notify").path)) + #expect(FileManager.default.fileExists(atPath: pgdata.appendingPathComponent("pg_wal/archive_status").path)) + #expect(try posixPermissions(pgdata) == 0o700) + #expect(try posixPermissions(pgdata.appendingPathComponent("PG_VERSION")) == 0o600) +} + +@Test +func runtimeResourcesDiscoverBundledResourceDirectoryCandidates() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + + let resources = try #require(try OliphauntRuntimeResources.bundledResource( + inResourceDirectories: [ + fixture.root.appendingPathComponent("empty-bundle-resources", isDirectory: true), + fixture.root.appendingPathComponent("resources", isDirectory: true), + ], + cacheRoot: fixture.cacheRoot + )) + #expect(resources.resourceRoot.standardizedFileURL == fixture.resourceRoot.standardizedFileURL) +} + +@Test +func runtimeResourcesDiscoveryPrefersBundleContainingRequestedExtensions() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + let baseOnlyResourceRoot = fixture.root.appendingPathComponent("base-bundle/oliphaunt", isDirectory: true) + try writeText( + baseOnlyResourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-base-v1 + extensions= + sharedPreloadLibraries= + mobileStaticRegistryState=not-required + mobileStaticRegistryRegistered= + mobileStaticRegistryPending= + nativeModuleStems= + """ + ) + try writeText( + baseOnlyResourceRoot.appendingPathComponent("runtime/files/share/postgresql/README.liboliphaunt-smoke"), + "base runtime smoke\n" + ) + + let baseFirst = try #require(try OliphauntRuntimeResources.bundledResource( + inResourceDirectories: [ + fixture.root.appendingPathComponent("base-bundle", isDirectory: true), + fixture.root.appendingPathComponent("resources", isDirectory: true), + ], + cacheRoot: fixture.cacheRoot + )) + #expect(baseFirst.resourceRoot.standardizedFileURL == baseOnlyResourceRoot.standardizedFileURL) + + let vectorResources = try #require(try OliphauntRuntimeResources.bundledResource( + inResourceDirectories: [ + fixture.root.appendingPathComponent("base-bundle", isDirectory: true), + fixture.root.appendingPathComponent("resources", isDirectory: true), + ], + containing: ["vector"], + cacheRoot: fixture.cacheRoot + )) + #expect(vectorResources.resourceRoot.standardizedFileURL == fixture.resourceRoot.standardizedFileURL) +} + +@Test +func runtimeResourcesExposePackageSizeReport() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + let report = try #require(try resources.packageSizeReport()) + #expect(report.packageBytes == 185) + #expect(report.runtimeBytes == 100) + #expect(report.templatePgdataBytes == 40) + #expect(report.staticRegistryBytes == 45) + #expect(report.selectedExtensionBytes == 30) + #expect(report.extensions == [ + OliphauntExtensionSizeReport( + name: "vector", + fileCount: 3, + bytes: 30 + ), + ]) +} + +@Test +func extensionReleaseManifestSelectsExactTargetAssets() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-manifest") + defer { + try? FileManager.default.removeItem(at: root) + } + let manifestURL = root.appendingPathComponent("manifest.properties") + try writeText( + manifestURL, + """ + schema=oliphaunt-extension-release-manifest-v1 + product=oliphaunt-extension-vector + version=0.1.0 + sqlName=vector + dependencies= + nativeModuleStem=vector + sharedPreloadLibraries= + mobileReleaseReady=true + desktopReleaseReady=true + asset.native.ios-xcframework.ios-xcframework=oliphaunt-extension-vector-0.1.0-native-ios-xcframework.zip + asset.native.macos-arm64.runtime=oliphaunt-extension-vector-0.1.0-native-macos-arm64-runtime.tar.gz + asset.wasix.wasix-portable.wasix-runtime=oliphaunt-extension-vector-0.1.0-wasix-portable.tar.zst + """ + ) + + let manifest = try OliphauntExtensionReleaseManifest(contentsOf: manifestURL) + + #expect(manifest.product == "oliphaunt-extension-vector") + #expect(manifest.sqlName == "vector") + #expect(manifest.nativeModuleStem == "vector") + #expect(manifest.mobileReleaseReady) + #expect(manifest.desktopReleaseReady) + #expect(try manifest.requiredAsset( + family: "native", + target: "ios-xcframework", + kind: "ios-xcframework" + ).name == "oliphaunt-extension-vector-0.1.0-native-ios-xcframework.zip") + #expect(manifest.asset(family: "native", target: "android-arm64-v8a", kind: "android-static-archive") == nil) +} + +@Test +func extensionArtifactResolverSelectsOnlyRequestedNativeAssets() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-resolver-exact") + defer { + try? FileManager.default.removeItem(at: root) + } + let vector = try writeExtensionReleaseManifest( + root: root, + sqlName: "vector", + nativeModuleStem: "vector", + assets: [ + "asset.native.ios-xcframework.ios-xcframework=vector-ios.zip", + "asset.native.macos-arm64.runtime=vector-macos.tar.gz", + "asset.wasix.wasix-portable.wasix-runtime=vector-wasix.tar.zst", + ] + ) + let pgtap = try writeExtensionReleaseManifest( + root: root, + sqlName: "pgtap", + nativeModuleStem: "pgtap", + assets: [ + "asset.native.ios-xcframework.ios-xcframework=pgtap-ios.zip", + "asset.native.macos-arm64.runtime=pgtap-macos.tar.gz", + ] + ) + + let resolution = try OliphauntExtensionArtifactResolver( + manifests: [vector, pgtap] + ).resolveNativeArtifacts( + requestedExtensions: ["vector"], + target: "ios-xcframework" + ) + + #expect(resolution.requestedExtensions == ["vector"]) + #expect(resolution.resolvedExtensions == ["vector"]) + #expect(resolution.assets.map(\.sqlName) == ["vector"]) + #expect(resolution.assets.map(\.asset.name) == ["vector-ios.zip"]) +} + +@Test +func extensionArtifactResolverIncludesDependencyClosureBeforeRequestedExtension() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-resolver-deps") + defer { + try? FileManager.default.removeItem(at: root) + } + let cube = try writeExtensionReleaseManifest( + root: root, + sqlName: "cube", + nativeModuleStem: "cube", + assets: ["asset.native.ios-xcframework.ios-xcframework=cube-ios.zip"] + ) + let earthdistance = try writeExtensionReleaseManifest( + root: root, + sqlName: "earthdistance", + dependencies: ["cube"], + nativeModuleStem: "earthdistance", + assets: ["asset.native.ios-xcframework.ios-xcframework=earthdistance-ios.zip"] + ) + + let resolution = try OliphauntExtensionArtifactResolver.resolveNativeArtifacts( + requestedExtensions: ["earthdistance"], + manifests: [earthdistance, cube], + target: "ios-xcframework" + ) + + #expect(resolution.requestedExtensions == ["earthdistance"]) + #expect(resolution.resolvedExtensions == ["cube", "earthdistance"]) + #expect(resolution.assets.map(\.asset.name) == ["cube-ios.zip", "earthdistance-ios.zip"]) +} + +@Test +func extensionArtifactResolverUsesDesktopRuntimeAssetsForMacTargets() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-resolver-desktop") + defer { + try? FileManager.default.removeItem(at: root) + } + let vector = try writeExtensionReleaseManifest( + root: root, + sqlName: "vector", + nativeModuleStem: "vector", + assets: [ + "asset.native.ios-xcframework.ios-xcframework=vector-ios.zip", + "asset.native.macos-arm64.runtime=vector-macos.tar.gz", + ] + ) + + let resolution = try OliphauntExtensionArtifactResolver.resolveNativeArtifacts( + requestedExtensions: ["vector"], + manifests: [vector], + target: "macos-arm64" + ) + + #expect(resolution.resolvedExtensions == ["vector"]) + #expect(resolution.assets.map(\.asset.name) == ["vector-macos.tar.gz"]) + #expect(resolution.assets.map(\.asset.kind) == ["runtime"]) +} + +@Test +func extensionArtifactResolverRejectsMissingDependencies() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-resolver-missing-dep") + defer { + try? FileManager.default.removeItem(at: root) + } + let earthdistance = try writeExtensionReleaseManifest( + root: root, + sqlName: "earthdistance", + dependencies: ["cube"], + nativeModuleStem: "earthdistance", + assets: ["asset.native.ios-xcframework.ios-xcframework=earthdistance-ios.zip"] + ) + + do { + _ = try OliphauntExtensionArtifactResolver.resolveNativeArtifacts( + requestedExtensions: ["earthdistance"], + manifests: [earthdistance], + target: "ios-xcframework" + ) + Issue.record("extension artifact resolver should reject missing dependencies") + } catch OliphauntError.engine(let message) { + #expect(message.contains("earthdistance requires missing dependency cube")) + } +} + +@Test +func extensionArtifactResolverRejectsMissingMobileStaticArtifacts() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-resolver-missing-mobile-asset") + defer { + try? FileManager.default.removeItem(at: root) + } + let vector = try writeExtensionReleaseManifest( + root: root, + sqlName: "vector", + nativeModuleStem: "vector", + assets: ["asset.native.macos-arm64.runtime=vector-macos.tar.gz"] + ) + + do { + _ = try OliphauntExtensionArtifactResolver.resolveNativeArtifacts( + requestedExtensions: ["vector"], + manifests: [vector], + target: "ios-xcframework" + ) + Issue.record("extension artifact resolver should reject missing iOS static artifacts") + } catch OliphauntError.engine(let message) { + #expect(message.contains("native/ios-xcframework/ios-xcframework")) + } +} + +@Test +func extensionArtifactResolverRejectsTargetsWithoutReleaseReadiness() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-resolver-readiness") + defer { + try? FileManager.default.removeItem(at: root) + } + let vector = try writeExtensionReleaseManifest( + root: root, + sqlName: "vector", + nativeModuleStem: "vector", + mobileReleaseReady: false, + assets: ["asset.native.ios-xcframework.ios-xcframework=vector-ios.zip"] + ) + + do { + _ = try OliphauntExtensionArtifactResolver.resolveNativeArtifacts( + requestedExtensions: ["vector"], + manifests: [vector], + target: "ios-xcframework" + ) + Issue.record("extension artifact resolver should reject mobile targets that are not release ready") + } catch OliphauntError.engine(let message) { + #expect(message.contains("not marked mobileReleaseReady")) + } +} + +@Test +func extensionReleaseManifestRejectsPathLikeAssetNames() throws { + let root = uniqueTempURL("liboliphaunt-swift-extension-manifest-path") + defer { + try? FileManager.default.removeItem(at: root) + } + let manifestURL = root.appendingPathComponent("manifest.properties") + try writeText( + manifestURL, + """ + schema=oliphaunt-extension-release-manifest-v1 + product=oliphaunt-extension-vector + version=0.1.0 + sqlName=vector + dependencies= + nativeModuleStem=vector + sharedPreloadLibraries= + mobileReleaseReady=true + desktopReleaseReady=true + asset.native.ios-xcframework.ios-xcframework=../vector.zip + """ + ) + + do { + _ = try OliphauntExtensionReleaseManifest(contentsOf: manifestURL) + Issue.record("extension release manifest should reject path-like asset names") + } catch OliphauntError.engine(let message) { + #expect(message.contains("plain release asset file name")) + } +} + +@Test +func runtimeResourcesRejectMalformedPackageSizeReport() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("package-size.tsv"), + """ + kind\tid\textensions\tfiles\tbytes + package\ttotal\t-\t-\tnot-bytes + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.packageSizeReport() + Issue.record("runtime resources should reject malformed package-size reports") + } catch OliphauntError.engine(let message) { + #expect(message.contains("invalid bytes value")) + } +} + +@Test +func runtimeResourcesRejectMissingExtension() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime(requestedExtensions: ["postgis"]) + Issue.record("runtime resources should reject extensions absent from the manifest") + } catch OliphauntError.engine(let message) { + #expect(message.contains("does not contain requested extension")) + } +} + +@Test +func runtimeResourcesRejectDeclaredExtensionMissingControlFile() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try FileManager.default.removeItem( + at: fixture.resourceRoot + .appendingPathComponent("runtime/files/share/postgresql/extension/vector.control") + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime(requestedExtensions: ["vector"]) + Issue.record("runtime resources should reject declared extensions missing control files") + } catch OliphauntError.engine(let message) { + #expect(message.contains("declare extension vector")) + #expect(message.contains("missing vector.control")) + } +} + +@Test +func runtimeResourcesRejectDeclaredExtensionMissingInstallScript() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try FileManager.default.removeItem( + at: fixture.resourceRoot + .appendingPathComponent("runtime/files/share/postgresql/extension/vector--1.0.sql") + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime(requestedExtensions: ["vector"]) + Issue.record("runtime resources should reject declared extensions missing install scripts") + } catch OliphauntError.engine(let message) { + #expect(message.contains("declare extension vector")) + #expect(message.contains("missing vector--*.sql")) + } +} + +@Test +func runtimeResourcesRejectMalformedSharedPreloadLibraryMetadata() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-v1 + extensions=vector + sharedPreloadLibraries=pg search + mobileStaticRegistryState=complete + mobileStaticRegistryRegistered=vector + mobileStaticRegistryPending= + nativeModuleStems=vector + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime(requestedExtensions: ["vector"]) + Issue.record("runtime resources should reject malformed shared preload library metadata") + } catch OliphauntError.engine(let message) { + #expect(message.contains("shared preload library")) + } +} + +@Test +func runtimeResourcesRejectUnsupportedSchema() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v0 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-v1 + extensions=vector + mobileStaticRegistryState=complete + mobileStaticRegistryRegistered= + mobileStaticRegistryPending= + nativeModuleStems= + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime(requestedExtensions: ["vector"]) + Issue.record("runtime resources should reject stale runtime-resource schemas") + } catch OliphauntError.engine(let message) { + #expect(message.contains("unsupported runtime resource schema")) + } +} + +func runtimeResourcesRejectUnsupportedPackageKindLayout() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("template-pgdata/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-template-v1 + extensions= + mobileStaticRegistryState=not-required + mobileStaticRegistryRegistered= + mobileStaticRegistryPending= + nativeModuleStems= + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + let pgdata = fixture.root.appendingPathComponent("app-root/pgdata", isDirectory: true) + + do { + _ = try resources.preparePgdata(at: pgdata) + Issue.record("runtime resources should reject manifests with the wrong package-kind layout") + } catch OliphauntError.engine(let message) { + #expect(message.contains("unsupported layout")) + } +} + +@Test +func runtimeResourcesRejectMissingMobileStaticRegistryState() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-v1 + extensions= + mobileStaticRegistryRegistered= + mobileStaticRegistryPending= + nativeModuleStems= + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime() + Issue.record("runtime resources should reject v1 manifests without mobileStaticRegistryState") + } catch OliphauntError.engine(let message) { + #expect(message.contains("omits mobileStaticRegistryState")) + } +} + +@Test +func runtimeResourcesRejectInconsistentCompleteMobileStaticRegistry() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-v1 + extensions=vector + mobileStaticRegistryState=complete + mobileStaticRegistryRegistered=vector + mobileStaticRegistryPending=vector + nativeModuleStems=vector + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime(requestedExtensions: ["vector"]) + Issue.record("runtime resources should reject conflicting complete mobile registry metadata") + } catch OliphauntError.engine(let message) { + #expect(message.contains("registered and pending")) + } +} + +@Test +func runtimeResourcesRejectNotRequiredMobileStaticRegistryWithModules() throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + try writeText( + fixture.resourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-v1 + extensions= + mobileStaticRegistryState=not-required + mobileStaticRegistryRegistered= + mobileStaticRegistryPending= + nativeModuleStems=vector + """ + ) + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + + do { + _ = try resources.materializeRuntime() + Issue.record("runtime resources should reject not-required mobile registry metadata that lists modules") + } catch OliphauntError.engine(let message) { + #expect(message.contains("not-required")) + } +} + +@Test +func nativeDirectCanUsePackagedRuntimeResourcesBeforeLibraryLoad() async throws { + let fixture = try makeRuntimeResourceFixture() + defer { + try? FileManager.default.removeItem(at: fixture.root) + } + let resources = OliphauntRuntimeResources( + resourceRoot: fixture.resourceRoot, + cacheRoot: fixture.cacheRoot + ) + let root = fixture.root.appendingPathComponent("database-root", isDirectory: true) + let engine = OliphauntNativeDirectEngine( + libraryURL: URL(fileURLWithPath: "/tmp/oliphaunt-swift-missing.dylib"), + runtimeResources: resources + ) + + do { + _ = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration( + mode: .nativeDirect, + root: root, + extensions: ["vector"] + ), + engine: engine + ) + Issue.record("missing liboliphaunt should fail after packaged resources are materialized") + } catch OliphauntError.engine(let message) { + #expect(message.contains("failed to load liboliphaunt")) + } + + #expect(FileManager.default.fileExists( + atPath: fixture.cacheRoot + .appendingPathComponent("runtime/test-runtime-v1/share/postgresql/README.liboliphaunt-smoke") + .path + )) + #expect(FileManager.default.fileExists( + atPath: root.appendingPathComponent("pgdata/PG_VERSION").path + )) +} + +@Test +func nativeDirectEngineExecutesAgainstLinkedLiboliphauntWhenAvailable() async throws { + let environment = ProcessInfo.processInfo.environment + guard + let library = environment["LIBOLIPHAUNT_PATH"], + let runtime = environment["OLIPHAUNT_INSTALL_DIR"] + else { + return + } + + let engine = OliphauntNativeDirectEngine( + libraryURL: URL(fileURLWithPath: library), + runtimeDirectory: URL(fileURLWithPath: runtime) + ) + // liboliphaunt-doc-example:swift-open-exec-close + let database = try await OliphauntDatabase.open( + configuration: OliphauntConfiguration(mode: .nativeDirect, durability: .fastDev), + engine: engine + ) + + let capabilities = try await database.capabilities() + #expect(capabilities.protocolRaw) + #expect(capabilities.protocolStream) + #expect(capabilities.queryCancel) + #expect(capabilities.backupRestore) + #expect(capabilities.simpleQuery) + #expect(!capabilities.multiRoot) + #expect(capabilities.sameRootLogicalReopen) + #expect(!capabilities.rootSwitchable) + #expect(!capabilities.crashRestartable) + + let response = try await database.execProtocolRaw(try OliphauntProtocol.simpleQuery("SELECT 1 AS value")) + #expect(response.contains(0x54)) + #expect(response.contains(0x44)) + #expect(response.contains(0x5A)) + + // liboliphaunt-doc-example:swift-streaming + let stream = DataChunkAccumulator() + try await database.execProtocolStream(try OliphauntProtocol.simpleQuery("SELECT 1 AS streamed_value")) { chunk in + stream.append(chunk) + } + let streamBytes = stream.joined() + #expect(streamBytes.contains(0x54)) + #expect(streamBytes.contains(0x44)) + #expect(streamBytes.contains(0x5A)) + + // liboliphaunt-doc-example:swift-typed-query + let typed = try await database.query("SELECT 1::text AS value") + #expect(try typed.getText(row: 0, column: "value") == "1") + + // liboliphaunt-doc-example:swift-parameterized-query + let parameterized = try await database.query( + "SELECT $1::text AS value", + parameters: [.text("1")] + ) + #expect(try parameterized.getText(row: 0, column: "value") == "1") + + let query = Task { + try await database.execProtocolRaw(try OliphauntProtocol.simpleQuery("SELECT pg_sleep(5) AS should_cancel")) + } + try await Task.sleep(nanoseconds: 100_000_000) + try await database.cancel() + let cancelResponse = try await query.value + #expect(cancelResponse.contains(0x45)) + #expect(cancelResponse.contains(0x5A)) + + _ = try await database.execProtocolRaw(try OliphauntProtocol.simpleQuery( + """ + CREATE TABLE IF NOT EXISTS swift_backup_smoke(value integer); + TRUNCATE swift_backup_smoke; + INSERT INTO swift_backup_smoke VALUES (42) + """ + )) + let archive = try await database.backup() + #expect(archive.format == .physicalArchive) + #expect(archive.bytes.range(of: Data("backup_label".utf8)) != nil) + + let restoredRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("liboliphaunt-swift-restore-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: restoredRoot) + } + let restored = try await OliphauntDatabase.restore( + OliphauntRestoreRequest(artifact: archive, root: restoredRoot).replaceExisting(), + engine: engine + ) + #expect(restored == restoredRoot) + #expect(FileManager.default.fileExists(atPath: restoredRoot.appendingPathComponent("pgdata/PG_VERSION").path)) + #expect(FileManager.default.fileExists(atPath: restoredRoot.appendingPathComponent("pgdata/backup_label").path)) + try await database.close() +} + +private struct FixedSessionEngine: OliphauntEngine { + let session: any OliphauntSession + + func open(configuration: OliphauntConfiguration) async throws -> any OliphauntSession { + session + } + + func restore(_ request: OliphauntRestoreRequest) async throws -> URL { + request.root + } +} + +private struct MockEngine: OliphauntEngine { + let mode: OliphauntEngineMode + + func open(configuration: OliphauntConfiguration) async throws -> any OliphauntSession { + #expect(configuration.mode == mode) + return MockSession(mode: mode) + } + + func restore(_ request: OliphauntRestoreRequest) async throws -> URL { + #expect(request.artifact.format == .physicalArchive) + #expect(request.targetPolicy == .replaceExisting) + return request.root + } +} + +private actor CountingEngine: OliphauntEngine { + private var calls = 0 + private var restores = 0 + private var extensions: [String] = [] + private var runtimeFootprint: OliphauntRuntimeFootprintProfile? + private var startupGUCs: [OliphauntStartupGUC] = [] + private var username: String? + private var database: String? + + func open(configuration: OliphauntConfiguration) async throws -> any OliphauntSession { + calls += 1 + extensions = configuration.extensions + runtimeFootprint = configuration.runtimeFootprint + startupGUCs = configuration.startupGUCs + username = configuration.username + database = configuration.database + return MockSession(mode: configuration.mode) + } + + func restore(_ request: OliphauntRestoreRequest) async throws -> URL { + restores += 1 + return request.root + } + + func openCallCount() -> Int { + calls + } + + func restoreCallCount() -> Int { + restores + } + + func lastExtensions() -> [String] { + extensions + } + + func lastRuntimeFootprint() -> OliphauntRuntimeFootprintProfile? { + runtimeFootprint + } + + func lastStartupGUCs() -> [OliphauntStartupGUC] { + startupGUCs + } + + func lastUsername() -> String? { + username + } + + func lastDatabase() -> String? { + database + } +} + +private actor MockSession: OliphauntSession { + let mode: OliphauntEngineMode + var calls = 0 + var requests: [Data] = [] + + init(mode: OliphauntEngineMode) { + self.mode = mode + } + + func capabilities() async -> OliphauntCapabilities { + switch mode { + case .nativeDirect: + return OliphauntCapabilities( + mode: mode, + processIsolated: false, + independentSessions: false, + maxClientSessions: 1 + ) + case .nativeBroker: + return OliphauntCapabilities( + mode: mode, + processIsolated: true, + independentSessions: false, + maxClientSessions: 1 + ) + case .nativeServer: + return OliphauntCapabilities( + mode: mode, + processIsolated: true, + independentSessions: true, + maxClientSessions: 32, + backupFormats: [.sql, .physicalArchive], + connectionString: "postgres://postgres@127.0.0.1:55432/template1" + ) + } + } + + func execProtocolRaw(_ bytes: Data) async throws -> Data { + calls += 1 + requests.append(bytes) + if bytes.count > 5, bytes.first == 0x51 || bytes.first == 0x50 { + return backendSelectResponse() + } + return Data([UInt8(calls)]) + bytes + } + + func requestTexts() -> [String] { + requests.map { String(decoding: $0, as: UTF8.self) } + } + + func backup(_ request: OliphauntBackupRequest) async throws -> OliphauntBackupArtifact { + switch request.format { + case .sql: + return OliphauntBackupArtifact(format: .sql, bytes: Data("sql-backup".utf8)) + case .physicalArchive: + return OliphauntBackupArtifact(format: .physicalArchive, bytes: Data("physical-backup".utf8)) + case .oliphauntArchive: + throw OliphauntError.engine("oliphauntArchive is not available") + } + } + + func cancel() async throws {} + + func close() async throws {} +} + +private actor BlockingSession: OliphauntSession { + let mode: OliphauntEngineMode + private var started = false + private var cancelled = false + private var closed = false + private var requests: [Data] = [] + private var startedContinuation: CheckedContinuation? + private var requestCountContinuations: [(minimum: Int, continuation: CheckedContinuation)] = [] + private var unblockContinuations: [CheckedContinuation] = [] + + init(mode: OliphauntEngineMode) { + self.mode = mode + } + + func capabilities() async -> OliphauntCapabilities { + OliphauntCapabilities( + mode: mode, + processIsolated: false, + independentSessions: false, + maxClientSessions: 1 + ) + } + + func execProtocolRaw(_ bytes: Data) async throws -> Data { + requests.append(bytes) + started = true + startedContinuation?.resume() + startedContinuation = nil + resumeRequestCountWaiters() + return await withCheckedContinuation { continuation in + if cancelled { + continuation.resume(returning: Data("cancelled".utf8)) + } else if closed { + continuation.resume(returning: Data("closed".utf8)) + } else { + unblockContinuations.append(continuation) + } + } + } + + func backup(_ request: OliphauntBackupRequest) async throws -> OliphauntBackupArtifact { + OliphauntBackupArtifact(format: request.format, bytes: Data()) + } + + func cancel() async throws { + cancelled = true + resumeAllExecutions(with: Data("cancelled".utf8)) + } + + func close() async throws { + closed = true + if !cancelled { + resumeAllExecutions(with: Data("closed".utf8)) + } + } + + func waitUntilStarted() async { + if started { + return + } + await withCheckedContinuation { continuation in + startedContinuation = continuation + } + } + + func waitForRequestCount(_ count: Int) async { + if requests.count >= count { + return + } + await withCheckedContinuation { continuation in + requestCountContinuations.append((minimum: count, continuation: continuation)) + } + } + + func requestBytes() async -> [Data] { + requests + } + + func requestTexts() async -> [String] { + requests.map { String(decoding: $0, as: UTF8.self) } + } + + func releaseExecution(with data: Data) { + guard !unblockContinuations.isEmpty else { + return + } + unblockContinuations.removeFirst().resume(returning: data) + } + + func wasCancelled() async -> Bool { + cancelled + } + + func wasClosed() async -> Bool { + closed + } + + private func resumeAllExecutions(with data: Data) { + let continuations = unblockContinuations + unblockContinuations.removeAll() + for continuation in continuations { + continuation.resume(returning: data) + } + } + + private func resumeRequestCountWaiters() { + var remaining: [(minimum: Int, continuation: CheckedContinuation)] = [] + for waiter in requestCountContinuations { + if requests.count >= waiter.minimum { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + requestCountContinuations = remaining + } +} + +private final class DataChunkAccumulator: @unchecked Sendable { + private let lock = NSLock() + private var values: [Data] = [] + + func append(_ value: Data) { + lock.lock() + values.append(value) + lock.unlock() + } + + func chunks() -> [Data] { + lock.lock() + defer { + lock.unlock() + } + return values + } + + func joined() -> Data { + lock.lock() + defer { + lock.unlock() + } + return values.reduce(into: Data()) { output, chunk in + output.append(chunk) + } + } +} + +private final class TransactionCapture: @unchecked Sendable { + private let lock = NSLock() + private var value: OliphauntTransaction? + + func store(_ transaction: OliphauntTransaction) { + lock.lock() + value = transaction + lock.unlock() + } + + func load() -> OliphauntTransaction? { + lock.lock() + defer { + lock.unlock() + } + return value + } +} + +private func makeExistingPgdataRoot() throws -> URL { + let root = uniqueTempURL("liboliphaunt-swift-existing-root") + try writeText(root.appendingPathComponent("pgdata/PG_VERSION"), "18\n") + return root +} + +private func makeRuntimeResourceFixture() throws -> ( + root: URL, + resourceRoot: URL, + cacheRoot: URL +) { + let root = uniqueTempURL("liboliphaunt-swift-resources") + let resourceRoot = root.appendingPathComponent("resources/oliphaunt", isDirectory: true) + let cacheRoot = root.appendingPathComponent("cache", isDirectory: true) + + try writeText( + resourceRoot.appendingPathComponent("runtime/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-runtime-files-v1 + cacheKey=test-runtime-v1 + extensions=vector + sharedPreloadLibraries= + mobileStaticRegistryState=complete + mobileStaticRegistryRegistered=vector + mobileStaticRegistryPending= + nativeModuleStems=vector + """ + ) + try writeText( + resourceRoot.appendingPathComponent("runtime/files/share/postgresql/README.liboliphaunt-smoke"), + "runtime smoke\n" + ) + try writeText( + resourceRoot.appendingPathComponent("runtime/files/share/postgresql/extension/vector.control"), + "comment = 'vector smoke control'\n" + ) + try writeText( + resourceRoot.appendingPathComponent("runtime/files/share/postgresql/extension/vector--1.0.sql"), + "select 'vector smoke sql';\n" + ) + try writeText( + resourceRoot.appendingPathComponent("template-pgdata/manifest.properties"), + """ + schema=oliphaunt-runtime-resources-v1 + layout=postgres-template-pgdata-v1 + cacheKey=test-template-v1 + extensions= + sharedPreloadLibraries= + mobileStaticRegistryState=not-required + mobileStaticRegistryRegistered= + mobileStaticRegistryPending= + nativeModuleStems= + """ + ) + try writeText( + resourceRoot.appendingPathComponent("template-pgdata/files/PG_VERSION"), + "18\n" + ) + try writeText( + resourceRoot.appendingPathComponent("package-size.tsv"), + """ + kind\tid\textensions\tfiles\tbytes + package\ttotal\t-\t-\t185 + package\truntime\t-\t-\t100 + package\ttemplate-pgdata\t-\t-\t40 + package\tstatic-registry\t-\t-\t45 + extensions\tselected\t-\t-\t30 + extension\tvector\t-\t3\t30 + """ + ) + + return (root: root, resourceRoot: resourceRoot, cacheRoot: cacheRoot) +} + +private func writeExtensionReleaseManifest( + root: URL, + sqlName: String, + dependencies: [String] = [], + nativeModuleStem: String? = nil, + mobileReleaseReady: Bool = true, + desktopReleaseReady: Bool = true, + assets: [String] +) throws -> OliphauntExtensionReleaseManifest { + let manifestURL = root + .appendingPathComponent(sqlName, isDirectory: true) + .appendingPathComponent("manifest.properties") + try writeText( + manifestURL, + """ + schema=oliphaunt-extension-release-manifest-v1 + product=oliphaunt-extension-\(sqlName) + version=0.1.0 + sqlName=\(sqlName) + dependencies=\(dependencies.joined(separator: ",")) + nativeModuleStem=\(nativeModuleStem ?? "") + sharedPreloadLibraries= + mobileReleaseReady=\(mobileReleaseReady ? "true" : "false") + desktopReleaseReady=\(desktopReleaseReady ? "true" : "false") + \(assets.joined(separator: "\n")) + """ + ) + return try OliphauntExtensionReleaseManifest(contentsOf: manifestURL) +} + +private func uniqueTempURL(_ prefix: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) +} + +private func writeText(_ url: URL, _ text: String) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try text.write(to: url, atomically: true, encoding: .utf8) +} + +private func posixPermissions(_ url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return try #require(attributes[.posixPermissions] as? Int) +} + +private func backendSelectResponse() -> Data { + var response = Data() + appendRowDescription(&response, fields: [("value", UInt32(25)), ("empty", UInt32(25))]) + appendDataRow(&response, values: [Data("1".utf8), nil]) + appendCommandComplete(&response, "SELECT 1") + appendReadyForQuery(&response) + return response +} + +private func backendErrorResponse(_ severity: String, _ sqlstate: String, _ message: String) -> Data { + var body = Data() + body.append(0x53) + body.append(Data(severity.utf8)) + body.append(0) + body.append(0x43) + body.append(Data(sqlstate.utf8)) + body.append(0) + body.append(0x4d) + body.append(Data(message.utf8)) + body.append(0) + body.append(0) + var response = Data() + appendBackendMessage(&response, tag: 0x45, body: body) + appendReadyForQuery(&response) + return response +} + +private func appendRowDescription( + _ response: inout Data, + fields: [(name: String, typeOID: UInt32)] +) { + appendRawRowDescription( + &response, + fields: fields.map { (Data($0.name.utf8), $0.typeOID) } + ) +} + +private func appendRawRowDescription( + _ response: inout Data, + fields: [(name: Data, typeOID: UInt32)] +) { + var body = Data() + appendInt16(&body, Int16(fields.count)) + for field in fields { + body.append(field.name) + body.append(0) + appendUInt32(&body, 0) + appendInt16(&body, 0) + appendUInt32(&body, field.typeOID) + appendInt16(&body, -1) + appendInt32(&body, -1) + appendInt16(&body, 0) + } + appendBackendMessage(&response, tag: 0x54, body: body) +} + +private func appendDataRow(_ response: inout Data, values: [Data?]) { + var body = Data() + appendInt16(&body, Int16(values.count)) + for value in values { + guard let value else { + appendInt32(&body, -1) + continue + } + appendInt32(&body, Int32(value.count)) + body.append(value) + } + appendBackendMessage(&response, tag: 0x44, body: body) +} + +private func appendCommandComplete(_ response: inout Data, _ tag: String) { + var body = Data(tag.utf8) + body.append(0) + appendBackendMessage(&response, tag: 0x43, body: body) +} + +private func appendNoticeResponse( + _ response: inout Data, + severity: String, + message: String +) { + var body = Data() + body.append(0x53) + body.append(Data(severity.utf8)) + body.append(0) + body.append(0x4d) + body.append(Data(message.utf8)) + body.append(0) + body.append(0) + appendBackendMessage(&response, tag: 0x4e, body: body) +} + +private func appendParameterStatus(_ response: inout Data, name: String, value: String) { + var body = Data(name.utf8) + body.append(0) + body.append(Data(value.utf8)) + body.append(0) + appendBackendMessage(&response, tag: 0x53, body: body) +} + +private func appendNotificationResponse( + _ response: inout Data, + pid: Int32, + channel: String, + payload: String +) { + var body = Data() + appendInt32(&body, pid) + body.append(Data(channel.utf8)) + body.append(0) + body.append(Data(payload.utf8)) + body.append(0) + appendBackendMessage(&response, tag: 0x41, body: body) +} + +private func appendReadyForQuery(_ response: inout Data, status: UInt8 = 0x49) { + appendBackendMessage(&response, tag: 0x5a, body: Data([status])) +} + +private func startupAssignments(_ args: [String]) -> [String] { + var assignments: [String] = [] + var index = 0 + while index < args.count { + precondition(args[index] == "-c", "unexpected startup flag \(args[index])") + precondition(index + 1 < args.count, "missing startup assignment after -c") + assignments.append(args[index + 1]) + index += 2 + } + return assignments +} + +private func appendBackendMessage(_ response: inout Data, tag: UInt8, body: Data) { + response.append(tag) + appendInt32(&response, Int32(body.count + 4)) + response.append(body) +} + +private func appendUInt32(_ data: inout Data, _ value: UInt32) { + data.append(UInt8((value >> 24) & 0xff)) + data.append(UInt8((value >> 16) & 0xff)) + data.append(UInt8((value >> 8) & 0xff)) + data.append(UInt8(value & 0xff)) +} + +private func appendInt32(_ data: inout Data, _ value: Int32) { + appendUInt32(&data, UInt32(bitPattern: value)) +} + +private func appendInt16(_ data: inout Data, _ value: Int16) { + let bits = UInt16(bitPattern: value) + data.append(UInt8((bits >> 8) & 0xff)) + data.append(UInt8(bits & 0xff)) +} diff --git a/src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift b/src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift new file mode 100644 index 00000000..23a1a611 --- /dev/null +++ b/src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift @@ -0,0 +1,185 @@ +import Foundation +@testable import Oliphaunt +import Testing + +@Test +func queryParserMatchesSharedProtocolFixtures() throws { + let fixtureURL = sharedProtocolFixtureURL() + guard FileManager.default.fileExists(atPath: fixtureURL.path) else { + return + } + + let corpus = try JSONDecoder().decode( + SharedProtocolFixtureCorpus.self, + from: Data(contentsOf: fixtureURL) + ) + #expect(corpus.schemaVersion == 1) + #expect(corpus.kind == "postgres-backend-query-response") + #expect(!corpus.cases.isEmpty) + + var names = Set() + for fixture in corpus.cases { + #expect(names.insert(fixture.name).inserted) + guard let expectation = fixture.queryExpectation else { + continue + } + let bytes = try sharedProtocolBytes(fixture.responseHex) + if let expected = expectation.ok { + try expectSharedProtocolOkFixture(fixture, expected: expected, bytes: bytes) + } else if let expected = expectation.postgresError { + expectSharedProtocolPostgresErrorFixture(fixture, expected: expected, bytes: bytes) + } else if let expected = expectation.engineErrorContains { + expectSharedProtocolEngineErrorFixture(fixture, expected: expected, bytes: bytes) + } else { + Issue.record("shared protocol fixture \(fixture.name) has no query expectation") + } + } +} + +private func expectSharedProtocolOkFixture( + _ fixture: SharedProtocolFixtureCase, + expected: SharedProtocolOkExpectation, + bytes: Data +) throws { + let result = try parseOliphauntQueryResponse(bytes) + #expect(result.rowCount == expected.rowCount) + #expect(result.commandTag == expected.commandTag) + #expect(result.fields.count == expected.fields.count) + #expect(result.rows.count == expected.rows.count) + + for (index, expectedField) in expected.fields.enumerated() { + guard result.fields.indices.contains(index) else { + Issue.record("shared protocol fixture \(fixture.name) is missing field \(index)") + continue + } + let actual = result.fields[index] + #expect(actual.name == expectedField.name) + #expect(actual.typeOID == expectedField.typeOid) + if expectedField.format == "text" { + #expect(actual.format == .text) + } + } + + for (rowIndex, expectedRow) in expected.rows.enumerated() { + #expect(expectedRow.count == expected.fields.count) + for (columnIndex, expectedValue) in expectedRow.enumerated() { + guard expected.fields.indices.contains(columnIndex) else { + Issue.record("shared protocol fixture \(fixture.name) is missing expected field \(columnIndex)") + continue + } + let field = expected.fields[columnIndex] + #expect(try result.getText(row: rowIndex, column: field.name) == expectedValue) + } + } +} + +private func expectSharedProtocolPostgresErrorFixture( + _ fixture: SharedProtocolFixtureCase, + expected: SharedProtocolPostgresErrorExpectation, + bytes: Data +) { + do { + _ = try parseOliphauntQueryResponse(bytes) + Issue.record("shared protocol fixture \(fixture.name) should have produced a PostgreSQL error") + } catch OliphauntError.postgres(let error) { + #expect(error.severity == expected.severity) + #expect(error.sqlstate == expected.sqlstate) + #expect(error.message == expected.message) + } catch { + Issue.record("shared protocol fixture \(fixture.name) produced unexpected error \(error)") + } +} + +private func expectSharedProtocolEngineErrorFixture( + _ fixture: SharedProtocolFixtureCase, + expected: String, + bytes: Data +) { + do { + _ = try parseOliphauntQueryResponse(bytes) + Issue.record("shared protocol fixture \(fixture.name) should have produced an engine error") + } catch OliphauntError.engine(let message) { + #expect(message.contains(expected)) + } catch { + Issue.record("shared protocol fixture \(fixture.name) produced unexpected error \(error)") + } +} + +private func sharedProtocolFixtureURL() -> URL { + if let fixtureRoot = ProcessInfo.processInfo.environment["OLIPHAUNT_SHARED_FIXTURES"] { + return URL(fileURLWithPath: fixtureRoot, isDirectory: true) + .appendingPathComponent("protocol") + .appendingPathComponent("query-response-cases.json") + } + + var root = URL(fileURLWithPath: #filePath) + for _ in 0..<5 { + root.deleteLastPathComponent() + } + return root + .appendingPathComponent("src") + .appendingPathComponent("shared") + .appendingPathComponent("fixtures") + .appendingPathComponent("protocol") + .appendingPathComponent("query-response-cases.json") +} + +private func sharedProtocolBytes(_ hex: String) throws -> Data { + let compact = hex.filter { !$0.isWhitespace } + guard compact.count.isMultiple(of: 2) else { + throw SharedProtocolFixtureError.invalidHex(hex) + } + + var bytes = Data() + var index = compact.startIndex + while index < compact.endIndex { + let next = compact.index(index, offsetBy: 2) + guard let byte = UInt8(String(compact[index..&2 + exit 1 +} +cd "$root" + +package_dir="src/sdks/swift" +scratch_base="${OLIPHAUNT_SDK_CHECK_SCRATCH:-$root/target/liboliphaunt-sdk-check/oliphaunt-swift}" +. "$root/tools/runtime/preflight.sh" + +mode="${1:-release-check}" + +case "$mode" in + check-static|test-unit|package-shape|smoke-runtime|regression|coverage|release-check) + ;; + "") + mode="release-check" + ;; + *) + echo "usage: src/sdks/swift/tools/check-sdk.sh [check-static|test-unit|package-shape|smoke-runtime|regression|coverage|release-check]" >&2 + exit 2 + ;; +esac + +scratch_root="$scratch_base/$mode" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +prepare_scratch_dir() { + dir="$scratch_root/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s\n' "$dir" +} + +require_archive_entry() { + archive_listing="$1" + entry="$2" + if ! grep -Fxq "package/$entry" "$archive_listing"; then + echo "Swift source archive did not include $entry" >&2 + exit 1 + fi +} + +reject_archive_entry_prefix() { + archive_listing="$1" + prefix="$2" + if grep -Eq "^package/$prefix" "$archive_listing"; then + echo "Swift source archive included generated or local-only files under $prefix" >&2 + exit 1 + fi +} + +check_ios_xcframework_if_available() { + if [ "$(uname -s)" != "Darwin" ]; then + return 0 + fi + if src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh --check-current; then + return 0 + fi + if [ -n "${OLIPHAUNT_SWIFT_REQUIRE_IOS_XCFRAMEWORK:-}" ]; then + exit 1 + fi + cat >&2 <&2 + exit 1 + } + [ -f "$asset_dir/liboliphaunt-$liboliphaunt_version-apple-spm-xcframework.zip" ] || { + echo "Swift release asset directory is missing liboliphaunt-$liboliphaunt_version-apple-spm-xcframework.zip" >&2 + exit 1 + } + else + echo "Swift package-shape requires OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR with the real Apple SwiftPM XCFramework asset" >&2 + exit 1 + fi + + run python3 tools/release/render_swiftpm_release_package.py \ + --asset-dir "$asset_dir" \ + --asset-base-url "$asset_base_url" \ + --output "$release_manifest" \ + --generated-tree "$generated_tree" + if ! grep -Fq ".binaryTarget(" "$release_manifest"; then + echo "SwiftPM release fixture manifest did not include a binary liboliphaunt target" >&2 + exit 1 + fi + if ! grep -Fq "$asset_base_url/liboliphaunt-$liboliphaunt_version-apple-spm-xcframework.zip" "$release_manifest"; then + echo "SwiftPM release fixture manifest did not resolve the release-shaped Apple XCFramework asset URL" >&2 + exit 1 + fi + if grep -Fq "liboliphaunt.xcframework" "$release_manifest"; then + echo "SwiftPM release fixture manifest must not point at a monorepo-local XCFramework path" >&2 + exit 1 + fi +} + +require swift +require python3 +require unzip + +if [ "$mode" = "coverage" ]; then + exec tools/coverage/run-product oliphaunt-swift +fi + +if [ "$mode" = "check-static" ]; then + swift_build_scratch="$(prepare_scratch_dir swift-build)" + run swift package --package-path "$package_dir" --scratch-path "$swift_build_scratch" describe + run swift build --package-path "$package_dir" --scratch-path "$swift_build_scratch" + swift_root_build_scratch="$(prepare_scratch_dir swift-root-build)" + run swift package --package-path "$root" --scratch-path "$swift_root_build_scratch" describe + run swift build --package-path "$root" --scratch-path "$swift_root_build_scratch" + exit 0 +fi + +if [ -z "${LIBOLIPHAUNT_PATH:-}" ] && [ -z "${OLIPHAUNT_INSTALL_DIR:-}" ]; then + if oliphaunt_runtime_native_host_ready basic; then + echo "using existing native Oliphaunt runtime at $(oliphaunt_runtime_native_host_work_root)" + else + echo "warning: native Oliphaunt runtime unavailable or incomplete; Swift native-direct tests will skip" >&2 + oliphaunt_runtime_native_host_diagnostics basic + fi +elif [ -n "${OLIPHAUNT_SWIFT_REQUIRE_NATIVE:-}" ]; then + if ! oliphaunt_runtime_native_host_ready basic; then + oliphaunt_runtime_native_host_diagnostics basic + exit 1 + fi +fi + +if [ "$mode" = "smoke-runtime" ] || [ "$mode" = "regression" ]; then + if ! oliphaunt_runtime_native_host_ready basic; then + oliphaunt_runtime_native_host_diagnostics basic + exit 1 + fi + if [ "$mode" = "smoke-runtime" ] && [ "$(uname -s)" = "Darwin" ]; then + run tools/runtime/preflight.sh ios-simulator + fi + liboliphaunt="$(oliphaunt_runtime_native_host_lib)" + install_dir="$(oliphaunt_runtime_native_host_install_dir)" + swift_build_scratch="$(prepare_scratch_dir swift-native-runtime)" + run env OLIPHAUNT_SWIFT_REQUIRE_NATIVE=1 \ + LIBOLIPHAUNT_PATH="$liboliphaunt" \ + OLIPHAUNT_INSTALL_DIR="$install_dir" \ + swift test --package-path "$package_dir" --scratch-path "$swift_build_scratch" + exit 0 +fi + +if [ "$mode" != "package-shape" ]; then + swift_build_scratch="$(prepare_scratch_dir swift-build)" + run swift package --package-path "$package_dir" --scratch-path "$swift_build_scratch" describe + run swift test --package-path "$package_dir" --scratch-path "$swift_build_scratch" + swift_root_build_scratch="$(prepare_scratch_dir swift-root-build)" + run swift package --package-path "$root" --scratch-path "$swift_root_build_scratch" describe + run swift test --package-path "$root" --scratch-path "$swift_root_build_scratch" + + if [ "$mode" = "test-unit" ]; then + exit 0 + fi +fi + +archive_work_dir="$(prepare_scratch_dir swift-source-archive)" +check_ios_xcframework_if_available +archive_package_dir="$archive_work_dir/package" +mkdir -p "$archive_package_dir" +cp -R "$package_dir/." "$archive_package_dir/" +rm -rf "$archive_package_dir/.build" "$archive_package_dir/.swiftpm" +swift_source_archive="$archive_work_dir/Oliphaunt-source.zip" +run swift package --package-path "$archive_package_dir" archive-source --output "$swift_source_archive" +archive_listing="$archive_work_dir/Oliphaunt-source-files.txt" +unzip -Z -1 "$swift_source_archive" >"$archive_listing" +for required in \ + Package.swift \ + README.md \ + Sources/COliphaunt/include/COliphaunt.h \ + Sources/COliphaunt/bridge.c \ + Sources/COliphaunt/empty.c \ + Sources/Oliphaunt/Oliphaunt.swift \ + Sources/Oliphaunt/OliphauntQuery.swift \ + Sources/Oliphaunt/OliphauntRuntimeResources.swift \ + Tests/OliphauntTests/OliphauntTests.swift \ + Tests/OliphauntTests/ProtocolFixtureTests.swift +do + require_archive_entry "$archive_listing" "$required" +done +reject_archive_entry_prefix "$archive_listing" "\\.build/" +reject_archive_entry_prefix "$archive_listing" "\\.swiftpm/" +reject_archive_entry_prefix "$archive_listing" "DerivedData/" +if [ "$mode" != "package-shape" ] || [ -n "${OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR:-}" ]; then + check_swiftpm_release_asset_manifest +fi + +if [ "$(uname -s)" = "Darwin" ] && command -v xcodebuild >/dev/null 2>&1; then + xcode_work_dir="$(prepare_scratch_dir swift-xcodebuild)" + xcode_package_dir="$xcode_work_dir/package" + mkdir -p "$xcode_package_dir" + cp -R "$package_dir/." "$xcode_package_dir/" + rm -rf "$xcode_package_dir/.build" "$xcode_package_dir/.swiftpm" + xcode_derived_data="$scratch_root/swift-xcode-derived-data" + xcode_source_packages="$scratch_root/swift-xcode-source-packages" + printf '\n==> (cd %s && xcodebuild -scheme Oliphaunt -destination generic/platform=iOS\\ Simulator -derivedDataPath %s -clonedSourcePackagesDirPath %s build)\n' "$xcode_package_dir" "$xcode_derived_data" "$xcode_source_packages" + ( + cd "$xcode_package_dir" + xcodebuild \ + -scheme Oliphaunt \ + -destination "generic/platform=iOS Simulator" \ + -derivedDataPath "$xcode_derived_data" \ + -clonedSourcePackagesDirPath "$xcode_source_packages" \ + -skipPackagePluginValidation \ + -quiet \ + build + ) +fi diff --git a/src/shared/contracts/moon.yml b/src/shared/contracts/moon.yml new file mode 100644 index 00000000..528b4c7c --- /dev/null +++ b/src/shared/contracts/moon.yml @@ -0,0 +1,27 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "shared-contracts" +language: "python" +layer: "tool" +stack: "infrastructure" +tags: ["shared", "contracts", "fixtures"] + +project: + title: "Shared Contracts" + description: "Cross-product test matrix and fixture contract definitions." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/shared/contracts/tools/check-test-matrix.py" + inputs: + - "/src/shared/contracts/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/shared/contracts/test-matrix.toml b/src/shared/contracts/test-matrix.toml new file mode 100644 index 00000000..b19bec04 --- /dev/null +++ b/src/shared/contracts/test-matrix.toml @@ -0,0 +1,250 @@ +schema_version = 1 + +[[fixtures]] +id = "protocol.query-response-cases" +path = "protocol/query-response-cases.json" +format = "json" +contract = "postgres backend response parsing" +proof_owner = "@oliphaunt/sdk" +ci_tier = "T1" +shared = true +consumers = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-react-native", + "oliphaunt-wasix-rust", +] +non_consumers = [ + "liboliphaunt-native", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-rust", kind = "fixture-file", path = "src/sdks/rust/tests/protocol_query_fixtures.rs", markers = ["query-response-cases.json"] }, + { consumer = "oliphaunt-swift", kind = "fixture-file", path = "src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift", markers = ["query-response-cases.json"] }, + { consumer = "oliphaunt-kotlin", kind = "fixture-file", path = "src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt", markers = ["query-response-cases.json"] }, + { consumer = "oliphaunt-js", kind = "fixture-file", path = "src/sdks/js/src/__tests__/protocol-fixtures.test.ts", markers = ["query-response-cases.json"] }, + { consumer = "oliphaunt-react-native", kind = "fixture-file", path = "src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts", markers = ["query-response-cases.json"] }, + { consumer = "oliphaunt-wasix-rust", kind = "fixture-file", path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs", markers = ["query-response-cases.json"] }, +] + +[[fixtures]] +id = "sdk-capabilities.mode-support" +path = "sdk-capabilities/mode-support.json" +format = "json" +contract = "SDK capability and mode support table" +proof_owner = "@oliphaunt/sdk" +ci_tier = "T1" +shared = true +consumers = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-react-native", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-wasix-rust", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-rust", kind = "fixture-file", path = "src/sdks/rust/tests/sdk_config_modes.rs", markers = ["sdk-capabilities/mode-support.json"] }, + { consumer = "oliphaunt-swift", kind = "semantic-contract", path = "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", markers = ["supportedModes", "maxClientSessions"] }, + { consumer = "oliphaunt-kotlin", kind = "semantic-contract", path = "src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt", markers = ["supportedModes", "maxClientSessions"] }, + { consumer = "oliphaunt-js", kind = "semantic-contract", path = "src/sdks/js/src/__tests__/client.test.ts", markers = ["supportedModes", "maxClientSessions"] }, + { consumer = "oliphaunt-react-native", kind = "semantic-contract", path = "src/sdks/react-native/src/__tests__/client.test.ts", markers = ["supportedModes", "maxClientSessions"] }, +] + +[[fixtures]] +id = "runtime-resources.manifest" +path = "runtime-resources/manifest.properties" +format = "properties" +contract = "runtime resource manifest shape" +proof_owner = "@oliphaunt/runtime" +ci_tier = "T2" +shared = true +consumers = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-rust", kind = "semantic-contract", path = "src/sdks/rust/tests/sdk_native_smoke.rs", markers = ["runtime/manifest.properties", "oliphaunt-runtime-resources-v1"] }, + { consumer = "oliphaunt-swift", kind = "semantic-contract", path = "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", markers = ["runtime/manifest.properties", "oliphaunt-runtime-resources-v1"] }, + { consumer = "oliphaunt-kotlin", kind = "semantic-contract", path = "src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt", markers = ["manifest.properties", "oliphaunt-runtime-resources-v1"] }, + { consumer = "oliphaunt-react-native", kind = "semantic-contract", path = "src/sdks/react-native/tools/check-sdk.sh", markers = ["manifest.properties", "oliphaunt-runtime-resources-v1"] }, +] + +[[fixtures]] +id = "runtime-resources.template-pgdata-manifest" +path = "runtime-resources/template-pgdata-manifest.properties" +format = "properties" +contract = "template PGDATA manifest shape" +proof_owner = "@oliphaunt/runtime" +ci_tier = "T2" +shared = true +consumers = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-rust", kind = "semantic-contract", path = "src/sdks/rust/tests/sdk_native_smoke.rs", markers = ["template-pgdata/manifest.properties", "oliphaunt-runtime-resources-v1"] }, + { consumer = "oliphaunt-swift", kind = "semantic-contract", path = "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", markers = ["template-pgdata/manifest.properties", "oliphaunt-runtime-resources-v1"] }, + { consumer = "oliphaunt-kotlin", kind = "semantic-contract", path = "src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt", markers = ["template-pgdata", "oliphaunt-runtime-resources-v1"] }, + { consumer = "oliphaunt-react-native", kind = "semantic-contract", path = "src/sdks/react-native/tools/check-sdk.sh", markers = ["template-pgdata", "oliphaunt-runtime-resources-v1"] }, +] + +[[fixtures]] +id = "runtime-resources.package-size" +path = "runtime-resources/package-size.tsv" +format = "tsv" +contract = "runtime package size report shape" +proof_owner = "@oliphaunt/runtime" +ci_tier = "T2" +shared = true +consumers = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-rust", kind = "semantic-contract", path = "src/sdks/rust/tests/sdk_native_smoke.rs", markers = ["package-size.tsv"] }, + { consumer = "oliphaunt-swift", kind = "semantic-contract", path = "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", markers = ["package-size.tsv"] }, + { consumer = "oliphaunt-kotlin", kind = "semantic-contract", path = "src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt", markers = ["package-size.tsv"] }, + { consumer = "oliphaunt-react-native", kind = "semantic-contract", path = "src/sdks/react-native/src/__tests__/client.test.ts", markers = ["testPackageSizeReportDelegatesToNativeSdk", "selectedExtensionBytes"] }, +] + +[[fixtures]] +id = "backup.physical-archive-manifest" +path = "backup/physical-archive-manifest.json" +format = "json" +contract = "physical backup archive manifest" +proof_owner = "@oliphaunt/sdk" +ci_tier = "T1" +shared = true +consumers = [ + "oliphaunt-rust", + "oliphaunt-js", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-wasix-rust", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-rust", kind = "semantic-contract", path = "src/sdks/rust/tests/sdk_native_smoke.rs", markers = ["backup-manifest.properties", "PhysicalArchive"] }, + { consumer = "oliphaunt-js", kind = "semantic-contract", path = "src/sdks/js/src/__tests__/physical-archive.test.ts", markers = ["createPhysicalArchive", "pg_backup_start"] }, +] + +[[fixtures]] +id = "lifecycle.session-lifecycle" +path = "lifecycle/session-lifecycle.json" +format = "json" +contract = "session lifecycle expectation model" +proof_owner = "@oliphaunt/sdk" +ci_tier = "T1" +shared = true +consumers = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-rust", kind = "semantic-contract", path = "src/sdks/rust/tests/sdk_shape.rs", markers = ["lifecycle_prepare_for_background_checkpoints_when_idle_and_resume_probes_session"] }, + { consumer = "oliphaunt-swift", kind = "semantic-contract", path = "src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift", markers = ["prepareForBackgroundCheckpointsWhenIdleAndResumeProbesSession"] }, + { consumer = "oliphaunt-kotlin", kind = "semantic-contract", path = "src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt", markers = ["prepareForBackgroundCheckpointsWhenIdleAndResumeProbesSession"] }, + { consumer = "oliphaunt-react-native", kind = "semantic-contract", path = "src/sdks/react-native/src/__tests__/client.test.ts", markers = ["testPrepareForBackgroundCheckpointsWhenIdleAndResumeProbesSession"] }, +] + +[[fixtures]] +id = "react-native-jsi.binary-transport" +path = "react-native-jsi/binary-transport.json" +format = "json" +contract = "React Native JSI binary transport boundary" +proof_owner = "@oliphaunt/sdk-react-native" +ci_tier = "T1" +shared = false +reason = "RN-specific boundary fixture is kept in the shared fixture catalog so policy checks can enforce no base64 fallback and no hidden binary-transport contract drift." +consumers = [ + "oliphaunt-react-native", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "policy-tools", + "release-tools", +] +evidence = [ + { consumer = "oliphaunt-react-native", kind = "fixture-file", path = "src/sdks/react-native/src/__tests__/client.test.ts", markers = ["react-native-jsi/binary-transport.json"] }, +] + +[[fixtures]] +id = "consumer-shape.products" +path = "consumer-shape/products.json" +format = "json" +contract = "release-shaped consumer package metadata fixture" +proof_owner = "@oliphaunt/core" +ci_tier = "T2" +shared = true +consumers = [ + "policy-tools", + "release-tools", +] +non_consumers = [ + "liboliphaunt-native", + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-react-native", + "oliphaunt-wasix-rust", +] +evidence = [ + { consumer = "policy-tools", kind = "fixture-file", path = "tools/policy/check-release-policy.py", markers = ["src/shared/fixtures/consumer-shape/products.json"] }, + { consumer = "release-tools", kind = "fixture-file", path = "tools/release/check_consumer_shape.py", markers = ["src/shared/fixtures/consumer-shape/products.json"] }, +] diff --git a/src/shared/contracts/tools/check-test-matrix.py b/src/shared/contracts/tools/check-test-matrix.py new file mode 100644 index 00000000..29230a77 --- /dev/null +++ b/src/shared/contracts/tools/check-test-matrix.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import json +import re +import sys +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[4] +CONTRACTS_ROOT = ROOT / "src/shared/contracts" +FIXTURES_ROOT = ROOT / "src/shared/fixtures" +MATRIX_PATH = CONTRACTS_ROOT / "test-matrix.toml" +GENERATED_MANIFEST = ROOT / "target/shared-fixtures/manifest.generated.json" +GENERATED_CONSUMPTION_REPORT = ROOT / "target/shared-fixtures/consumption-report.json" +ID_RE = re.compile(r"^[a-z0-9][a-z0-9.-]*[a-z0-9]$") +FORMATS = {"json", "properties", "tsv"} +EVIDENCE_KINDS = {"fixture-file", "semantic-contract"} +CONSUMPTION_SCAN_ROOTS = [ + "src/sdks/rust/tests", + "src/sdks/swift/Tests", + "src/sdks/kotlin/oliphaunt/src", + "src/sdks/js/src", + "src/sdks/react-native/src", + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src", + "tools/release", +] +CODE_SUFFIXES = { + ".bash", + ".c", + ".cjs", + ".cpp", + ".gradle", + ".h", + ".java", + ".js", + ".kt", + ".kts", + ".mjs", + ".mm", + ".py", + ".rs", + ".sh", + ".swift", + ".ts", + ".tsx", +} +IGNORED_DIR_NAMES = { + ".build", + ".gradle", + ".moon", + ".next", + "__pycache__", + "build", + "DerivedData", + "dist", + "lib", + "node_modules", + "target", +} +PROJECT_ROOTS = { + "src/runtimes/liboliphaunt/native": "liboliphaunt-native", + "src/sdks/rust": "oliphaunt-rust", + "src/sdks/swift": "oliphaunt-swift", + "src/sdks/kotlin": "oliphaunt-kotlin", + "src/sdks/js": "oliphaunt-js", + "src/sdks/react-native": "oliphaunt-react-native", + "src/bindings/wasix-rust": "oliphaunt-wasix-rust", + "tools/policy": "policy-tools", + "tools/release": "release-tools", +} + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def load_matrix() -> dict: + try: + with MATRIX_PATH.open("rb") as handle: + return tomllib.load(handle) + except tomllib.TOMLDecodeError as error: + fail(f"{MATRIX_PATH}: invalid TOML: {error}") + + +def validate_fixture_entry(entry: dict, seen: set[str]) -> dict: + fixture_id = require_string(entry, "id") + if not ID_RE.match(fixture_id): + fail(f"{MATRIX_PATH}: invalid fixture id {fixture_id!r}") + if fixture_id in seen: + fail(f"{MATRIX_PATH}: duplicate fixture id {fixture_id!r}") + seen.add(fixture_id) + + relative_path = require_string(entry, "path") + path = Path(relative_path) + if path.is_absolute() or ".." in path.parts: + fail(f"{MATRIX_PATH}: fixture {fixture_id} has unsafe path {relative_path!r}") + + fixture_format = require_string(entry, "format") + if fixture_format not in FORMATS: + fail(f"{MATRIX_PATH}: fixture {fixture_id} has unsupported format {fixture_format!r}") + + contract = require_string(entry, "contract") + proof_owner = require_string(entry, "proof_owner") + ci_tier = require_string(entry, "ci_tier") + if not re.match(r"^T[0-8]$", ci_tier): + fail(f"{MATRIX_PATH}: fixture {fixture_id} has invalid ci_tier {ci_tier!r}") + consumers = entry.get("consumers") + if not isinstance(consumers, list) or not consumers or not all(isinstance(item, str) and item for item in consumers): + fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare non-empty string consumers") + non_consumers = entry.get("non_consumers") + if not isinstance(non_consumers, list) or not all(isinstance(item, str) and item for item in non_consumers): + fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare string non_consumers") + overlap = set(consumers).intersection(non_consumers) + if overlap: + fail(f"{MATRIX_PATH}: fixture {fixture_id} declares consumers as non-consumers: {sorted(overlap)}") + + shared = entry.get("shared") + if not isinstance(shared, bool): + fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare shared = true/false") + if shared and len(set(consumers)) < 2: + fail(f"{MATRIX_PATH}: shared fixture {fixture_id} must have at least two consumers") + if not shared and not isinstance(entry.get("reason"), str): + fail(f"{MATRIX_PATH}: product-specific fixture {fixture_id} must explain why it is cataloged") + evidence = entry.get("evidence", []) + if not isinstance(evidence, list) or not evidence: + fail(f"{MATRIX_PATH}: fixture {fixture_id} must declare evidence for every consumer") + evidence_consumers: list[str] = [] + for item in evidence: + if not isinstance(item, dict): + fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence entries must be TOML tables") + consumer = require_string(item, "consumer") + if consumer not in consumers: + fail(f"{MATRIX_PATH}: fixture {fixture_id} has evidence for undeclared consumer {consumer!r}") + evidence_consumers.append(consumer) + kind = item.get("kind", "fixture-file") + if kind not in EVIDENCE_KINDS: + fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence for {consumer} has unsupported kind {kind!r}") + evidence_path = require_string(item, "path") + path = Path(evidence_path) + if path.is_absolute() or ".." in path.parts: + fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence for {consumer} has unsafe path {evidence_path!r}") + markers = item.get("markers") + if not isinstance(markers, list) or not markers or not all(isinstance(marker, str) and marker for marker in markers): + fail(f"{MATRIX_PATH}: fixture {fixture_id} evidence for {consumer} must declare non-empty string markers") + missing_evidence = sorted(set(consumers).difference(evidence_consumers)) + if missing_evidence: + fail(f"{MATRIX_PATH}: fixture {fixture_id} lacks evidence for consumers: {missing_evidence}") + + return { + "id": fixture_id, + "path": relative_path, + "format": fixture_format, + "contract": contract, + "proof_owner": proof_owner, + "ci_tier": ci_tier, + "shared": shared, + "consumers": consumers, + "non_consumers": non_consumers, + "evidence": evidence, + } + + +def require_string(entry: dict, key: str) -> str: + value = entry.get(key) + if not isinstance(value, str) or not value: + fail(f"{MATRIX_PATH}: fixture entry missing string {key!r}") + return value + + +def validate_fixture_file(entry: dict) -> dict: + relative_path = entry["path"] + fixture_path = FIXTURES_ROOT / relative_path + if not fixture_path.is_file(): + fail(f"missing shared fixture {fixture_path}") + + if entry["format"] == "json": + with fixture_path.open("r", encoding="utf-8") as handle: + parsed = json.load(handle) + if not isinstance(parsed, dict): + fail(f"{fixture_path}: JSON fixture must be an object") + elif entry["format"] == "properties": + validate_properties(fixture_path) + elif entry["format"] == "tsv": + validate_tsv(fixture_path) + + return { + "id": entry["id"], + "path": f"src/shared/fixtures/{relative_path}", + "format": entry["format"], + "proofOwner": entry["proof_owner"], + "ciTier": entry["ci_tier"], + "consumers": entry["consumers"], + "nonConsumers": entry["non_consumers"], + "shared": entry["shared"], + "evidence": [ + validate_evidence_file(entry, evidence) + for evidence in entry["evidence"] + ], + } + + +def validate_evidence_file(fixture: dict, evidence: dict) -> dict: + evidence_path = ROOT / evidence["path"] + if not evidence_path.is_file(): + fail(f"{MATRIX_PATH}: fixture {fixture['id']} evidence file does not exist: {evidence_path}") + text = evidence_path.read_text(encoding="utf-8") + for marker in evidence["markers"]: + if marker not in text: + fail( + f"{MATRIX_PATH}: fixture {fixture['id']} evidence file {evidence['path']} " + f"for {evidence['consumer']} lacks marker {marker!r}" + ) + return { + "consumer": evidence["consumer"], + "kind": evidence.get("kind", "fixture-file"), + "path": evidence["path"], + "markers": evidence["markers"], + } + + +def load_project_roots() -> dict[str, str]: + roots = dict(PROJECT_ROOTS) + for root, project_id in PROJECT_ROOTS.items(): + moon_file = ROOT / root / "moon.yml" + if not moon_file.is_file(): + fail(f"{MATRIX_PATH}: fixture matrix project root {root} is missing moon.yml") + match = re.search(r"(?m)^id:\s*[\"']?([^\"'\s#]+)", moon_file.read_text(encoding="utf-8")) + if not match: + fail(f"{MATRIX_PATH}: fixture matrix project root {root} moon.yml has no id") + actual_project_id = match.group(1) + if actual_project_id != project_id: + fail( + f"{MATRIX_PATH}: fixture matrix project root {root} expected id " + f"{project_id}, got {actual_project_id}" + ) + return roots + + +def project_for_path(path: Path, project_roots: dict[str, str]) -> str | None: + relative = path.relative_to(ROOT).as_posix() + best_root = "" + best_project: str | None = None + for root, project_id in project_roots.items(): + if relative == root or relative.startswith(f"{root}/"): + if len(root) > len(best_root): + best_root = root + best_project = project_id + return best_project + + +def validate_project_ids(entries: list[dict], project_roots: dict[str, str]) -> None: + known_ids = set(project_roots.values()) + for entry in entries: + ids = set(entry["consumers"]) | set(entry["non_consumers"]) + ids.update(evidence["consumer"] for evidence in entry["evidence"]) + unknown = sorted(ids.difference(known_ids)) + if unknown: + fail(f"{MATRIX_PATH}: fixture {entry['id']} references unknown Moon project ids: {unknown}") + + +def detect_fixture_references(entries: list[dict], project_roots: dict[str, str]) -> list[dict]: + by_pattern: dict[str, dict] = {} + for entry in entries: + relative_path = entry["path"] + by_pattern[f"src/shared/fixtures/{relative_path}"] = entry + by_pattern[relative_path] = entry + + detections: list[dict] = [] + seen: set[tuple[str, str, str]] = set() + for scan_root in CONSUMPTION_SCAN_ROOTS: + root = ROOT / scan_root + if not root.exists(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix not in CODE_SUFFIXES: + continue + relative_parts = path.relative_to(ROOT).parts + if any(part in IGNORED_DIR_NAMES for part in relative_parts): + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for pattern, entry in by_pattern.items(): + if pattern not in text: + continue + project_id = project_for_path(path, project_roots) + if project_id is None: + fail(f"{MATRIX_PATH}: fixture reference in unmanaged path {path.relative_to(ROOT)}") + if project_id in entry["non_consumers"] or project_id not in entry["consumers"]: + fail( + f"{MATRIX_PATH}: {project_id} references fixture {entry['id']} " + f"from {path.relative_to(ROOT)}, but allowed consumers are {entry['consumers']}" + ) + detection_key = (entry["id"], project_id, path.relative_to(ROOT).as_posix()) + if detection_key in seen: + continue + seen.add(detection_key) + detections.append( + { + "fixtureId": entry["id"], + "project": project_id, + "path": path.relative_to(ROOT).as_posix(), + "matched": pattern, + } + ) + return detections + + +def write_consumption_report(entries: list[dict], detections: list[dict]) -> None: + detections_by_fixture: dict[str, list[dict]] = {entry["id"]: [] for entry in entries} + for detection in detections: + detections_by_fixture.setdefault(detection["fixtureId"], []).append(detection) + + report = { + "schemaVersion": 1, + "fixtures": [ + { + "id": entry["id"], + "path": f"src/shared/fixtures/{entry['path']}", + "consumers": entry["consumers"], + "evidence": [ + { + "consumer": evidence["consumer"], + "kind": evidence.get("kind", "fixture-file"), + "path": evidence["path"], + } + for evidence in entry["evidence"] + ], + "detectedReferences": detections_by_fixture.get(entry["id"], []), + } + for entry in entries + ], + } + GENERATED_CONSUMPTION_REPORT.parent.mkdir(parents=True, exist_ok=True) + GENERATED_CONSUMPTION_REPORT.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def validate_properties(path: Path) -> None: + lines = path.read_text(encoding="utf-8").splitlines() + entries = [ + line + for line in lines + if line.strip() and not line.lstrip().startswith("#") + ] + if not entries: + fail(f"{path}: properties fixture is empty") + for line in entries: + if "=" not in line: + fail(f"{path}: properties line lacks '=': {line!r}") + + +def validate_tsv(path: Path) -> None: + with path.open("r", encoding="utf-8", newline="") as handle: + rows = list(csv.reader(handle, delimiter="\t")) + if len(rows) < 2: + fail(f"{path}: TSV fixture must contain a header and at least one data row") + width = len(rows[0]) + if width == 0: + fail(f"{path}: TSV fixture header is empty") + for index, row in enumerate(rows[1:], start=2): + if len(row) != width: + fail(f"{path}: row {index} has {len(row)} cells, expected {width}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--fixtures", + action="store_true", + help="also validate fixture files and emit the generated manifest", + ) + args = parser.parse_args() + + matrix = load_matrix() + if matrix.get("schema_version") != 1: + fail(f"{MATRIX_PATH}: schema_version must be 1") + raw_fixtures = matrix.get("fixtures") + if not isinstance(raw_fixtures, list) or not raw_fixtures: + fail(f"{MATRIX_PATH}: must declare at least one [[fixtures]] entry") + + seen: set[str] = set() + entries = [validate_fixture_entry(entry, seen) for entry in raw_fixtures] + + if args.fixtures: + project_roots = load_project_roots() + validate_project_ids(entries, project_roots) + detections = detect_fixture_references(entries, project_roots) + generated = { + "schemaVersion": 1, + "fixtures": [validate_fixture_file(entry) for entry in entries], + } + GENERATED_MANIFEST.parent.mkdir(parents=True, exist_ok=True) + GENERATED_MANIFEST.write_text(json.dumps(generated, indent=2, sort_keys=True) + "\n", encoding="utf-8") + write_consumption_report(entries, detections) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/shared/extension-runtime-contract/contract.toml b/src/shared/extension-runtime-contract/contract.toml new file mode 100644 index 00000000..7f70bb56 --- /dev/null +++ b/src/shared/extension-runtime-contract/contract.toml @@ -0,0 +1,15 @@ +schema = "oliphaunt-extension-runtime-contract-v1" + +[runtime] +resource_layout = "share/postgresql/extension" +dynamic_loader = "postgres-compatible" +static_registry_abi = 1 + +[selection] +unit = "sql-extension-name" +implicit_extensions = false +implicit_extension_groups = false + +[artifacts] +base_runtime_contains_optional_extensions = false +extension_artifacts_are_exact = true diff --git a/src/shared/extension-runtime-contract/moon.yml b/src/shared/extension-runtime-contract/moon.yml new file mode 100644 index 00000000..a632aa33 --- /dev/null +++ b/src/shared/extension-runtime-contract/moon.yml @@ -0,0 +1,27 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "extension-runtime-contract" +language: "python" +layer: "configuration" +stack: "systems" +tags: ["extensions", "contract", "runtime"] + +project: + title: "Extension Runtime Contract" + description: "Shared contract between base runtimes and exact extension artifacts." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/shared/extension-runtime-contract/tools/check-contract.py" + inputs: + - "/src/shared/extension-runtime-contract/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/shared/extension-runtime-contract/tools/check-contract.py b/src/shared/extension-runtime-contract/tools/check-contract.py new file mode 100644 index 00000000..97c256aa --- /dev/null +++ b/src/shared/extension-runtime-contract/tools/check-contract.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import pathlib +import sys +import tomllib + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "contract.toml" + + +def fail(message: str) -> None: + raise SystemExit(f"extension-runtime-contract: {message}") + + +def main() -> None: + try: + data = tomllib.loads(CONTRACT.read_text(encoding="utf-8")) + except Exception as error: + fail(f"cannot parse {CONTRACT}: {error}") + + if data.get("schema") != "oliphaunt-extension-runtime-contract-v1": + fail("contract.toml must use schema oliphaunt-extension-runtime-contract-v1") + runtime = data.get("runtime") + selection = data.get("selection") + artifacts = data.get("artifacts") + if not isinstance(runtime, dict) or not isinstance(selection, dict) or not isinstance(artifacts, dict): + fail("contract.toml must define runtime, selection, and artifacts tables") + if runtime.get("resource_layout") != "share/postgresql/extension": + fail("runtime.resource_layout must match PostgreSQL extension resources") + if runtime.get("dynamic_loader") != "postgres-compatible": + fail("runtime.dynamic_loader must stay PostgreSQL-compatible") + if runtime.get("static_registry_abi") != 1: + fail("runtime.static_registry_abi must be 1 until the C ABI changes") + if selection.get("unit") != "sql-extension-name": + fail("selection.unit must be exact SQL extension name") + for key in ("implicit_extensions", "implicit_extension_groups"): + if selection.get(key) is not False: + fail(f"selection.{key} must be false") + if artifacts.get("base_runtime_contains_optional_extensions") is not False: + fail("base runtime must not contain optional extension artifacts") + if artifacts.get("extension_artifacts_are_exact") is not True: + fail("extension artifacts must be exact-selected") + + +if __name__ == "__main__": + main() diff --git a/src/shared/fixtures/backup/physical-archive-manifest.json b/src/shared/fixtures/backup/physical-archive-manifest.json new file mode 100644 index 00000000..298edf64 --- /dev/null +++ b/src/shared/fixtures/backup/physical-archive-manifest.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "kind": "oliphaunt-physical-archive-metadata", + "format": "physicalArchive", + "requiredEntries": ["PG_VERSION", "global/pg_control", "base/"], + "restoreRules": { + "targetRootMustBeEmptyOrReplaceExisting": true, + "preservePgdataDirectoryName": true, + "rejectSqlArchive": true + } +} diff --git a/src/shared/fixtures/consumer-shape/products.json b/src/shared/fixtures/consumer-shape/products.json new file mode 100644 index 00000000..eff8ac1b --- /dev/null +++ b/src/shared/fixtures/consumer-shape/products.json @@ -0,0 +1,1089 @@ +{ + "schema": "oliphaunt-consumer-shape-v1", + "products": { + "liboliphaunt-native": { + "files": [ + "src/runtimes/liboliphaunt/native/VERSION", + "src/runtimes/liboliphaunt/native/include/oliphaunt.h", + "tools/release/package-liboliphaunt-assets.sh" + ], + "requiredText": { + "tools/release/package-liboliphaunt-assets.sh": [ + "stage_macos=\"$stage_root/liboliphaunt-${version}-macos-arm64\"", + "stage_ios=\"$stage_root/liboliphaunt-${version}-ios-xcframework\"", + "stage_android_arm64=\"$stage_root/liboliphaunt-${version}-android-arm64-v8a\"", + "stage_android_x86_64=\"$stage_root/liboliphaunt-${version}-android-x86_64\"" + ] + } + }, + "liboliphaunt-wasix": { + "files": [ + "src/runtimes/liboliphaunt/wasix/VERSION", + "src/runtimes/liboliphaunt/wasix/release.toml", + "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", + "src/runtimes/liboliphaunt/wasix/crates/assets/README.md" + ], + "requiredText": { + "src/runtimes/liboliphaunt/wasix/release.toml": [ + "kind = \"wasm-runtime\"", + "\"crates:oliphaunt-wasix-assets\"", + "\"github-release-assets\"" + ], + "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml": [ + "name = \"oliphaunt-wasix-assets\"", + "publish = true" + ] + } + }, + "oliphaunt-rust": { + "files": [ + "src/sdks/rust/Cargo.toml", + "src/sdks/rust/README.md" + ], + "requiredText": { + "src/sdks/rust/README.md": [ + "oliphaunt = \"0.1.0\"", + "## Compatibility", + "## Quickstart" + ] + } + }, + "oliphaunt-broker": { + "files": [ + "src/runtimes/broker/Cargo.toml", + "src/runtimes/broker/README.md", + "tools/release/package-broker-assets.sh" + ], + "requiredText": { + "src/runtimes/broker/Cargo.toml": [ + "name = \"oliphaunt-broker\"", + "path = \"src/main.rs\"" + ], + "tools/release/package-broker-assets.sh": [ + "oliphaunt-broker-${version}-${target_id}.${asset_extension}", + "target_id=\"windows-x64-msvc\"", + "oliphaunt-broker-${version}-release-assets.sha256" + ] + } + }, + "oliphaunt-node-direct": { + "files": [ + "src/runtimes/node-direct/package.json", + "src/runtimes/node-direct/README.md", + "src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc", + "src/runtimes/node-direct/tools/build-node-addon.sh", + "src/runtimes/node-direct/packages/darwin-arm64/package.json", + "src/runtimes/node-direct/packages/linux-x64-gnu/package.json", + "src/runtimes/node-direct/packages/linux-arm64-gnu/package.json", + "src/runtimes/node-direct/packages/win32-x64-msvc/package.json" + ], + "requiredText": { + "src/runtimes/node-direct/package.json": [ + "\"name\": \"@oliphaunt/node-direct\"", + "\"private\": true" + ], + "src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc": [ + "NAPI_MODULE" + ], + "src/runtimes/node-direct/tools/build-node-addon.sh": [ + "oliphaunt-node-direct-$version-$target", + "src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc" + ], + "src/runtimes/node-direct/packages/darwin-arm64/package.json": [ + "\"name\": \"@oliphaunt/node-direct-darwin-arm64\"", + "\"os\": [", + "\"cpu\": [" + ] + } + }, + "oliphaunt-extension-amcheck": { + "files": [ + "src/extensions/contrib/amcheck/VERSION", + "src/extensions/contrib/amcheck/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/amcheck/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/amcheck/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"amcheck\"" + ], + "src/extensions/contrib/amcheck/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"amcheck\"" + ] + } + }, + "oliphaunt-extension-auto-explain": { + "files": [ + "src/extensions/contrib/auto_explain/VERSION", + "src/extensions/contrib/auto_explain/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/auto_explain/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/auto_explain/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"auto_explain\"" + ], + "src/extensions/contrib/auto_explain/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"auto_explain\"" + ] + } + }, + "oliphaunt-extension-bloom": { + "files": [ + "src/extensions/contrib/bloom/VERSION", + "src/extensions/contrib/bloom/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/bloom/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/bloom/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"bloom\"" + ], + "src/extensions/contrib/bloom/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"bloom\"" + ] + } + }, + "oliphaunt-extension-btree-gin": { + "files": [ + "src/extensions/contrib/btree_gin/VERSION", + "src/extensions/contrib/btree_gin/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/btree_gin/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/btree_gin/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"btree_gin\"" + ], + "src/extensions/contrib/btree_gin/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"btree_gin\"" + ] + } + }, + "oliphaunt-extension-btree-gist": { + "files": [ + "src/extensions/contrib/btree_gist/VERSION", + "src/extensions/contrib/btree_gist/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/btree_gist/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/btree_gist/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"btree_gist\"" + ], + "src/extensions/contrib/btree_gist/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"btree_gist\"" + ] + } + }, + "oliphaunt-extension-citext": { + "files": [ + "src/extensions/contrib/citext/VERSION", + "src/extensions/contrib/citext/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/citext/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/citext/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"citext\"" + ], + "src/extensions/contrib/citext/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"citext\"" + ] + } + }, + "oliphaunt-extension-cube": { + "files": [ + "src/extensions/contrib/cube/VERSION", + "src/extensions/contrib/cube/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/cube/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/cube/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"cube\"" + ], + "src/extensions/contrib/cube/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"cube\"" + ] + } + }, + "oliphaunt-extension-dict-int": { + "files": [ + "src/extensions/contrib/dict_int/VERSION", + "src/extensions/contrib/dict_int/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/dict_int/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/dict_int/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"dict_int\"" + ], + "src/extensions/contrib/dict_int/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"dict_int\"" + ] + } + }, + "oliphaunt-extension-dict-xsyn": { + "files": [ + "src/extensions/contrib/dict_xsyn/VERSION", + "src/extensions/contrib/dict_xsyn/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/dict_xsyn/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/dict_xsyn/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"dict_xsyn\"" + ], + "src/extensions/contrib/dict_xsyn/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"dict_xsyn\"" + ] + } + }, + "oliphaunt-extension-earthdistance": { + "files": [ + "src/extensions/contrib/earthdistance/VERSION", + "src/extensions/contrib/earthdistance/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/earthdistance/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/earthdistance/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"earthdistance\"" + ], + "src/extensions/contrib/earthdistance/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"earthdistance\"" + ] + } + }, + "oliphaunt-extension-file-fdw": { + "files": [ + "src/extensions/contrib/file_fdw/VERSION", + "src/extensions/contrib/file_fdw/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/file_fdw/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/file_fdw/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"file_fdw\"" + ], + "src/extensions/contrib/file_fdw/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"file_fdw\"" + ] + } + }, + "oliphaunt-extension-fuzzystrmatch": { + "files": [ + "src/extensions/contrib/fuzzystrmatch/VERSION", + "src/extensions/contrib/fuzzystrmatch/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/fuzzystrmatch/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/fuzzystrmatch/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"fuzzystrmatch\"" + ], + "src/extensions/contrib/fuzzystrmatch/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"fuzzystrmatch\"" + ] + } + }, + "oliphaunt-extension-hstore": { + "files": [ + "src/extensions/contrib/hstore/VERSION", + "src/extensions/contrib/hstore/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/hstore/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/hstore/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"hstore\"" + ], + "src/extensions/contrib/hstore/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"hstore\"" + ] + } + }, + "oliphaunt-extension-intarray": { + "files": [ + "src/extensions/contrib/intarray/VERSION", + "src/extensions/contrib/intarray/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/intarray/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/intarray/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"intarray\"" + ], + "src/extensions/contrib/intarray/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"intarray\"" + ] + } + }, + "oliphaunt-extension-isn": { + "files": [ + "src/extensions/contrib/isn/VERSION", + "src/extensions/contrib/isn/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/isn/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/isn/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"isn\"" + ], + "src/extensions/contrib/isn/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"isn\"" + ] + } + }, + "oliphaunt-extension-lo": { + "files": [ + "src/extensions/contrib/lo/VERSION", + "src/extensions/contrib/lo/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/lo/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/lo/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"lo\"" + ], + "src/extensions/contrib/lo/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"lo\"" + ] + } + }, + "oliphaunt-extension-ltree": { + "files": [ + "src/extensions/contrib/ltree/VERSION", + "src/extensions/contrib/ltree/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/ltree/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/ltree/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"ltree\"" + ], + "src/extensions/contrib/ltree/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"ltree\"" + ] + } + }, + "oliphaunt-extension-pageinspect": { + "files": [ + "src/extensions/contrib/pageinspect/VERSION", + "src/extensions/contrib/pageinspect/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pageinspect/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pageinspect/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pageinspect\"" + ], + "src/extensions/contrib/pageinspect/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pageinspect\"" + ] + } + }, + "oliphaunt-extension-pg-buffercache": { + "files": [ + "src/extensions/contrib/pg_buffercache/VERSION", + "src/extensions/contrib/pg_buffercache/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pg_buffercache/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pg_buffercache/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_buffercache\"" + ], + "src/extensions/contrib/pg_buffercache/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pg_buffercache\"" + ] + } + }, + "oliphaunt-extension-pg-freespacemap": { + "files": [ + "src/extensions/contrib/pg_freespacemap/VERSION", + "src/extensions/contrib/pg_freespacemap/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pg_freespacemap/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pg_freespacemap/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_freespacemap\"" + ], + "src/extensions/contrib/pg_freespacemap/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pg_freespacemap\"" + ] + } + }, + "oliphaunt-extension-pg-surgery": { + "files": [ + "src/extensions/contrib/pg_surgery/VERSION", + "src/extensions/contrib/pg_surgery/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pg_surgery/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pg_surgery/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_surgery\"" + ], + "src/extensions/contrib/pg_surgery/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pg_surgery\"" + ] + } + }, + "oliphaunt-extension-pg-trgm": { + "files": [ + "src/extensions/contrib/pg_trgm/VERSION", + "src/extensions/contrib/pg_trgm/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pg_trgm/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pg_trgm/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_trgm\"" + ], + "src/extensions/contrib/pg_trgm/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pg_trgm\"" + ] + } + }, + "oliphaunt-extension-pg-visibility": { + "files": [ + "src/extensions/contrib/pg_visibility/VERSION", + "src/extensions/contrib/pg_visibility/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pg_visibility/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pg_visibility/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_visibility\"" + ], + "src/extensions/contrib/pg_visibility/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pg_visibility\"" + ] + } + }, + "oliphaunt-extension-pg-walinspect": { + "files": [ + "src/extensions/contrib/pg_walinspect/VERSION", + "src/extensions/contrib/pg_walinspect/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pg_walinspect/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pg_walinspect/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_walinspect\"" + ], + "src/extensions/contrib/pg_walinspect/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pg_walinspect\"" + ] + } + }, + "oliphaunt-extension-pgcrypto": { + "files": [ + "src/extensions/contrib/pgcrypto/VERSION", + "src/extensions/contrib/pgcrypto/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/pgcrypto/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/pgcrypto/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pgcrypto\"" + ], + "src/extensions/contrib/pgcrypto/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"pgcrypto\"" + ] + } + }, + "oliphaunt-extension-seg": { + "files": [ + "src/extensions/contrib/seg/VERSION", + "src/extensions/contrib/seg/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/seg/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/seg/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"seg\"" + ], + "src/extensions/contrib/seg/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"seg\"" + ] + } + }, + "oliphaunt-extension-tablefunc": { + "files": [ + "src/extensions/contrib/tablefunc/VERSION", + "src/extensions/contrib/tablefunc/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/tablefunc/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/tablefunc/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"tablefunc\"" + ], + "src/extensions/contrib/tablefunc/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"tablefunc\"" + ] + } + }, + "oliphaunt-extension-tcn": { + "files": [ + "src/extensions/contrib/tcn/VERSION", + "src/extensions/contrib/tcn/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/tcn/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/tcn/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"tcn\"" + ], + "src/extensions/contrib/tcn/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"tcn\"" + ] + } + }, + "oliphaunt-extension-tsm-system-rows": { + "files": [ + "src/extensions/contrib/tsm_system_rows/VERSION", + "src/extensions/contrib/tsm_system_rows/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/tsm_system_rows/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/tsm_system_rows/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"tsm_system_rows\"" + ], + "src/extensions/contrib/tsm_system_rows/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"tsm_system_rows\"" + ] + } + }, + "oliphaunt-extension-tsm-system-time": { + "files": [ + "src/extensions/contrib/tsm_system_time/VERSION", + "src/extensions/contrib/tsm_system_time/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/tsm_system_time/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/tsm_system_time/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"tsm_system_time\"" + ], + "src/extensions/contrib/tsm_system_time/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"tsm_system_time\"" + ] + } + }, + "oliphaunt-extension-unaccent": { + "files": [ + "src/extensions/contrib/unaccent/VERSION", + "src/extensions/contrib/unaccent/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/unaccent/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/unaccent/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"unaccent\"" + ], + "src/extensions/contrib/unaccent/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"unaccent\"" + ] + } + }, + "oliphaunt-extension-uuid-ossp": { + "files": [ + "src/extensions/contrib/uuid_ossp/VERSION", + "src/extensions/contrib/uuid_ossp/release.toml", + "src/extensions/contrib/postgres18.toml", + "src/extensions/contrib/uuid_ossp/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/contrib/uuid_ossp/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"uuid-ossp\"" + ], + "src/extensions/contrib/uuid_ossp/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ], + "src/extensions/contrib/postgres18.toml": [ + "sql-name = \"uuid-ossp\"" + ] + } + }, + "oliphaunt-extension-pg-hashids": { + "files": [ + "src/extensions/external/pg_hashids/VERSION", + "src/extensions/external/pg_hashids/release.toml", + "src/extensions/external/pg_hashids/source.toml", + "src/extensions/external/pg_hashids/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/external/pg_hashids/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_hashids\"" + ], + "src/extensions/external/pg_hashids/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ] + } + }, + "oliphaunt-extension-pg-ivm": { + "files": [ + "src/extensions/external/pg_ivm/VERSION", + "src/extensions/external/pg_ivm/release.toml", + "src/extensions/external/pg_ivm/source.toml", + "src/extensions/external/pg_ivm/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/external/pg_ivm/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_ivm\"" + ], + "src/extensions/external/pg_ivm/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ] + } + }, + "oliphaunt-extension-pg-textsearch": { + "files": [ + "src/extensions/external/pg_textsearch/VERSION", + "src/extensions/external/pg_textsearch/release.toml", + "src/extensions/external/pg_textsearch/source.toml", + "src/extensions/external/pg_textsearch/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/external/pg_textsearch/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_textsearch\"" + ], + "src/extensions/external/pg_textsearch/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ] + } + }, + "oliphaunt-extension-pg-uuidv7": { + "files": [ + "src/extensions/external/pg_uuidv7/VERSION", + "src/extensions/external/pg_uuidv7/release.toml", + "src/extensions/external/pg_uuidv7/source.toml", + "src/extensions/external/pg_uuidv7/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/external/pg_uuidv7/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pg_uuidv7\"" + ], + "src/extensions/external/pg_uuidv7/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ] + } + }, + "oliphaunt-extension-pgtap": { + "files": [ + "src/extensions/external/pgtap/VERSION", + "src/extensions/external/pgtap/release.toml", + "src/extensions/external/pgtap/source.toml", + "src/extensions/external/pgtap/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/external/pgtap/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"pgtap\"" + ], + "src/extensions/external/pgtap/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ] + } + }, + "oliphaunt-extension-postgis": { + "files": [ + "src/extensions/external/postgis/VERSION", + "src/extensions/external/postgis/release.toml", + "src/extensions/external/postgis/source.toml", + "src/extensions/external/postgis/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/external/postgis/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"postgis\"" + ], + "src/extensions/external/postgis/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ] + } + }, + "oliphaunt-extension-vector": { + "files": [ + "src/extensions/external/vector/VERSION", + "src/extensions/external/vector/release.toml", + "src/extensions/external/vector/source.toml", + "src/extensions/external/vector/targets/artifacts.toml" + ], + "requiredText": { + "src/extensions/external/vector/release.toml": [ + "kind = \"exact-extension-artifact\"", + "release_artifacts = [\"exact-extension-artifacts\"]", + "extension_sql_name = \"vector\"" + ], + "src/extensions/external/vector/targets/artifacts.toml": [ + "target = \"wasix-portable\"", + "target = \"ios-xcframework\"", + "target = \"android-arm64-v8a\"" + ] + } + }, + "oliphaunt-swift": { + "files": [ + "Package.swift", + "src/sdks/swift/README.md", + "tools/release/render_swiftpm_release_package.py", + "tools/release/publish_swiftpm_source_tag.py" + ], + "requiredText": { + "Package.swift": [ + "src/sdks/swift/Sources/Oliphaunt", + "src/sdks/swift/Sources/COliphaunt" + ], + "src/sdks/swift/README.md": [ + "SwiftPM", + "## Compatibility", + "## Quickstart" + ], + "tools/release/render_swiftpm_release_package.py": [ + "binaryTarget(", + "liboliphaunt-native-v" + ], + "tools/release/publish_swiftpm_source_tag.py": [ + "commit-tree", + "--manifest" + ] + } + }, + "oliphaunt-kotlin": { + "files": [ + "src/sdks/kotlin/gradle.properties", + "src/sdks/kotlin/README.md", + "src/sdks/kotlin/oliphaunt/build.gradle.kts", + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts" + ], + "requiredText": { + "src/sdks/kotlin/README.md": [ + "dev.oliphaunt", + "## Compatibility", + "## Quickstart" + ], + "src/sdks/kotlin/oliphaunt/build.gradle.kts": [ + "alias(libs.plugins.maven.publish)", + "mavenPublishing" + ], + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts": [ + "gradlePlugin", + "dev.oliphaunt.android" + ] + } + }, + "oliphaunt-react-native": { + "files": [ + "src/sdks/react-native/package.json", + "src/sdks/react-native/README.md", + "src/sdks/react-native/app.plugin.js", + "src/sdks/react-native/OliphauntReactNative.podspec" + ], + "requiredText": { + "src/sdks/react-native/README.md": [ + "New Architecture", + "## Compatibility", + "## Quickstart" + ], + "src/sdks/react-native/app.plugin.js": [ + "withDangerousMod", + "oliphauntExtensions", + "OliphauntExtensions.json" + ], + "src/sdks/react-native/OliphauntReactNative.podspec": [ + "OliphauntReactNative", + "source_files" + ] + } + }, + "oliphaunt-js": { + "files": [ + "src/sdks/js/package.json", + "src/sdks/js/jsr.json", + "src/sdks/js/README.md" + ], + "requiredText": { + "src/sdks/js/README.md": [ + "pnpm add @oliphaunt/ts", + "## Compatibility", + "## Quickstart" + ], + "src/sdks/js/package.json": [ + "\"./node\"", + "\"./bun\"", + "\"./deno\"" + ], + "src/sdks/js/jsr.json": [ + "\"./deno\"" + ] + } + }, + "oliphaunt-wasix-rust": { + "files": [ + "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml", + "src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md", + "src/bindings/wasix-rust/THIRD_PARTY_NOTICES.md" + ], + "requiredText": { + "src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md": [ + "oliphaunt-wasix", + "cargo add oliphaunt-wasix" + ], + "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml": [ + "oliphaunt-wasix-assets", + "oliphaunt-wasix-aot" + ] + } + } + } +} diff --git a/src/shared/fixtures/lifecycle/session-lifecycle.json b/src/shared/fixtures/lifecycle/session-lifecycle.json new file mode 100644 index 00000000..bd2e3f4e --- /dev/null +++ b/src/shared/fixtures/lifecycle/session-lifecycle.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "kind": "oliphaunt-session-lifecycle-expectations", + "expectations": [ + { + "name": "close-detaches-after-active-work", + "queuedWorkAfterClose": "databaseClosed", + "activeWork": "allowedToFinishUnlessCancelled" + }, + { + "name": "background-idle", + "prepareForBackground": ["checkpoint"], + "resumeFromBackground": ["sessionProbe"] + }, + { + "name": "background-active-work", + "prepareForBackground": ["cancel"], + "checkpoint": "skipped" + }, + { + "name": "background-pinned-transaction", + "prepareForBackground": ["noCheckpoint"], + "reason": "transaction owns the single physical session" + } + ] +} diff --git a/src/shared/fixtures/manifest.toml b/src/shared/fixtures/manifest.toml new file mode 100644 index 00000000..820b547a --- /dev/null +++ b/src/shared/fixtures/manifest.toml @@ -0,0 +1,2 @@ +schema_version = 1 +contract = "../contracts/test-matrix.toml" diff --git a/src/shared/fixtures/moon.yml b/src/shared/fixtures/moon.yml new file mode 100644 index 00000000..c8711b85 --- /dev/null +++ b/src/shared/fixtures/moon.yml @@ -0,0 +1,45 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "shared-fixtures" +language: "unknown" +layer: "tool" +stack: "infrastructure" +tags: ["shared", "fixtures", "tests"] + +project: + title: "Shared Fixtures" + description: "Cross-product fixture corpora consumed by SDK and runtime tests." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +dependsOn: + - "shared-contracts" + +tasks: + check: + tags: ["quality", "static"] + command: "python3 src/shared/contracts/tools/check-test-matrix.py --fixtures" + deps: + - "shared-contracts:check" + inputs: + - "/src/shared/contracts/**/*" + - "/src/shared/fixtures/**/*" + - "/src/sdks/rust/tests/**/*" + - "/src/sdks/swift/Tests/**/*" + - "/src/sdks/kotlin/oliphaunt/src/**/*" + - "/src/sdks/js/src/**/*" + - "/src/sdks/react-native/src/**/*" + - "/src/sdks/react-native/tools/**/*" + - "/src/bindings/wasix-rust/crates/oliphaunt-wasix/src/**/*" + - "/tools/release/**/*" + - "/tools/policy/**/*" + outputs: + - "/target/shared-fixtures/manifest.generated.json" + - "/target/shared-fixtures/consumption-report.json" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/shared/fixtures/protocol/query-response-cases.json b/src/shared/fixtures/protocol/query-response-cases.json new file mode 100644 index 00000000..bdddaa91 --- /dev/null +++ b/src/shared/fixtures/protocol/query-response-cases.json @@ -0,0 +1,134 @@ +{ + "schemaVersion": 1, + "kind": "postgres-backend-query-response", + "cases": [ + { + "name": "select_value_and_null", + "description": "One simple-query result row with a text value and SQL NULL.", + "responseHex": "5400000036000276616c75650000000000000000000019ffffffffffff0000656d7074790000000000000000000019ffffffffffff0000440000000f00020000000131ffffffff430000000d53454c4543542031005a0000000549", + "queryExpectation": { + "ok": { + "fields": [ + { + "name": "value", + "typeOid": 25, + "format": "text" + }, + { + "name": "empty", + "typeOid": 25, + "format": "text" + } + ], + "rows": [ + [ + "1", + null + ] + ], + "commandTag": "SELECT 1", + "rowCount": 1 + } + }, + "wireExpectation": { + "messageNames": [ + "rowDescription", + "dataRow", + "commandComplete", + "readyForQuery" + ] + } + }, + { + "name": "extended_controls_insert", + "description": "Extended-query control frames without row data.", + "responseHex": "310000000432000000046e00000004430000000f494e5345525420302030005a0000000549", + "queryExpectation": { + "ok": { + "fields": [], + "rows": [], + "commandTag": "INSERT 0 0", + "rowCount": 0 + } + }, + "wireExpectation": { + "messageNames": [ + "parseComplete", + "bindComplete", + "noData", + "commandComplete", + "readyForQuery" + ] + } + }, + { + "name": "async_controls_before_command", + "description": "Async backend control messages before a command completion.", + "responseHex": "5300000019636c69656e745f656e636f64696e670055544638004e00000014534e4f54494345004d68656c6c6f000041000000180000007b6368616e6e656c007061796c6f616400430000000d53454c4543542030005a0000000549", + "queryExpectation": { + "ok": { + "fields": [], + "rows": [], + "commandTag": "SELECT 0", + "rowCount": 0 + } + }, + "wireExpectation": { + "messageNames": [ + "parameterStatus", + "notice", + "notification", + "commandComplete", + "readyForQuery" + ] + } + }, + { + "name": "postgres_error_relation_missing", + "description": "Structured PostgreSQL ErrorResponse with SQLSTATE and message fields.", + "responseHex": "450000002c534552524f5200433432503031004d72656c6174696f6e20646f6573206e6f7420657869737400005a0000000549", + "queryExpectation": { + "postgresError": { + "severity": "ERROR", + "sqlstate": "42P01", + "message": "relation does not exist" + } + }, + "wireExpectation": { + "messageNames": [ + "error", + "readyForQuery" + ] + } + }, + { + "name": "bytes_after_ready_for_query", + "description": "Typed query parsers must reject trailing bytes after ReadyForQuery.", + "responseHex": "430000000d53454c4543542030005a000000054900", + "queryExpectation": { + "engineErrorContains": "backend returned bytes after ReadyForQuery" + } + }, + { + "name": "copy_response_rejected", + "description": "Typed query helpers reject COPY traffic and leave it to raw protocol APIs.", + "responseHex": "4700000007000000", + "queryExpectation": { + "engineErrorContains": "does not support COPY protocol responses" + }, + "wireExpectation": { + "messageNames": [ + "copyInResponse" + ] + } + }, + { + "name": "unexpected_backend_tag", + "description": "Typed query parsers reject unknown backend tags.", + "responseHex": "5200000008000000005a0000000549", + "queryExpectation": { + "engineErrorContains": "unexpected backend message tag 0x52" + } + } + ] +} diff --git a/src/shared/fixtures/react-native-jsi/binary-transport.json b/src/shared/fixtures/react-native-jsi/binary-transport.json new file mode 100644 index 00000000..4eb86d55 --- /dev/null +++ b/src/shared/fixtures/react-native-jsi/binary-transport.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "kind": "oliphaunt-react-native-jsi-binary-transport", + "cases": [ + { + "name": "array-buffer-request", + "requestKind": "ArrayBuffer", + "valid": true + }, + { + "name": "uint8array-offset", + "requestKind": "Uint8Array", + "byteOffset": 1, + "byteLength": 3, + "valid": true + }, + { + "name": "stream-chunks", + "chunks": ["aa", "bb"], + "requiresNativeChunkCallback": true + }, + { + "name": "base64-rejected", + "requestKind": "base64", + "valid": false + }, + { + "name": "unsafe-handle-rejected", + "handle": 9007199254740992, + "valid": false + } + ] +} diff --git a/src/shared/fixtures/runtime-resources/manifest.properties b/src/shared/fixtures/runtime-resources/manifest.properties new file mode 100644 index 00000000..f6ad3a67 --- /dev/null +++ b/src/shared/fixtures/runtime-resources/manifest.properties @@ -0,0 +1,10 @@ +schema=oliphaunt-runtime-resources-v1 +cacheKey=shared-fixture-runtime +layout=postgres-runtime-files-v1 +extensions=vector +sharedPreloadLibraries= +mobileStaticRegistryState=complete +mobileStaticRegistryRegistered=vector +mobileStaticRegistryPending= +nativeModuleStems=vector +mobileStaticRegistrySource=static-registry/oliphaunt_static_registry.c diff --git a/src/shared/fixtures/runtime-resources/package-size.tsv b/src/shared/fixtures/runtime-resources/package-size.tsv new file mode 100644 index 00000000..d1dd0664 --- /dev/null +++ b/src/shared/fixtures/runtime-resources/package-size.tsv @@ -0,0 +1,7 @@ +kind id extensions files bytes +package total - - 185 +package runtime - - 100 +package template-pgdata - - 40 +package static-registry - - 45 +extensions selected - - 30 +extension vector - 3 30 diff --git a/src/shared/fixtures/runtime-resources/template-pgdata-manifest.properties b/src/shared/fixtures/runtime-resources/template-pgdata-manifest.properties new file mode 100644 index 00000000..ac14d36c --- /dev/null +++ b/src/shared/fixtures/runtime-resources/template-pgdata-manifest.properties @@ -0,0 +1,10 @@ +schema=oliphaunt-runtime-resources-v1 +cacheKey=shared-fixture-template +layout=postgres-template-pgdata-v1 +extensions= +sharedPreloadLibraries= +mobileStaticRegistryState=not-required +mobileStaticRegistryRegistered= +mobileStaticRegistryPending= +nativeModuleStems= +mobileStaticRegistrySource= diff --git a/src/shared/fixtures/sdk-capabilities/mode-support.json b/src/shared/fixtures/sdk-capabilities/mode-support.json new file mode 100644 index 00000000..65c7caf3 --- /dev/null +++ b/src/shared/fixtures/sdk-capabilities/mode-support.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": 1, + "kind": "oliphaunt-sdk-capability-expectations", + "modes": [ + { + "engine": "nativeDirect", + "availableByDefault": { + "rust": true, + "swift": true, + "kotlin": true, + "typescript": true, + "reactNative": true, + "wasm": false + }, + "capabilities": { + "maxClientSessions": 1, + "independentSessions": false, + "processIsolated": false, + "backupFormats": ["physicalArchive"], + "restoreFormats": ["physicalArchive"] + } + }, + { + "engine": "nativeBroker", + "availableByDefault": { + "rust": true, + "swift": false, + "kotlin": false, + "typescript": true, + "reactNative": false, + "wasm": false + }, + "capabilities": { + "maxClientSessions": 1, + "independentSessions": false, + "processIsolated": true, + "backupFormats": ["physicalArchive"], + "restoreFormats": ["physicalArchive"] + } + }, + { + "engine": "nativeServer", + "availableByDefault": { + "rust": true, + "swift": false, + "kotlin": false, + "typescript": true, + "reactNative": false, + "wasm": false + }, + "capabilities": { + "maxClientSessions": null, + "independentSessions": true, + "processIsolated": true, + "backupFormats": ["sql", "physicalArchive"], + "restoreFormats": ["physicalArchive"] + } + } + ] +} diff --git a/src/shared/js-core/README.md b/src/shared/js-core/README.md new file mode 100644 index 00000000..6af993a6 --- /dev/null +++ b/src/shared/js-core/README.md @@ -0,0 +1,7 @@ +# Shared JavaScript Core + +Canonical TypeScript helpers shared by the JavaScript and React Native SDKs. + +The SDK packages keep mirrored copies of these files so published packages stay +self-contained. Run `moon run shared-js-core:check` to verify the mirrors +are byte-for-byte fresh. diff --git a/src/shared/js-core/moon.yml b/src/shared/js-core/moon.yml new file mode 100644 index 00000000..9798dfa7 --- /dev/null +++ b/src/shared/js-core/moon.yml @@ -0,0 +1,32 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "shared-js-core" +language: "typescript" +layer: "library" +stack: "frontend" +tags: ["shared", "typescript", "sdk"] + +project: + title: "Shared JavaScript Core" + description: "Canonical TypeScript helpers mirrored into the JavaScript and React Native SDK packages." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/sdk-js" + paths: + "**/*.ts": ["@oliphaunt/sdk-js", "@oliphaunt/sdk-react-native"] + "tools/**": ["@oliphaunt/sdk-js", "@oliphaunt/sdk-react-native"] + +tasks: + check: + tags: ["quality", "static"] + command: "node src/shared/js-core/tools/check-js-core.mjs" + inputs: + - "/src/shared/js-core/**/*" + - "/src/sdks/js/src/protocol.ts" + - "/src/sdks/js/src/query.ts" + - "/src/sdks/react-native/src/protocol.ts" + - "/src/sdks/react-native/src/query.ts" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/shared/js-core/src/protocol.ts b/src/shared/js-core/src/protocol.ts new file mode 100644 index 00000000..fe1ccd98 --- /dev/null +++ b/src/shared/js-core/src/protocol.ts @@ -0,0 +1,20 @@ +export function simpleQuery(sql: string): Uint8Array { + if (sql.includes('\0')) { + throw new Error('simple query SQL must not contain NUL bytes'); + } + const encoder = new TextEncoder(); + const body = encoder.encode(sql); + const packet = new Uint8Array(body.length + 6); + packet[0] = 'Q'.charCodeAt(0); + writeI32(packet, 1, body.length + 5); + packet.set(body, 5); + packet[packet.length - 1] = 0; + return packet; +} + +function writeI32(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = (value >>> 24) & 0xff; + bytes[offset + 1] = (value >>> 16) & 0xff; + bytes[offset + 2] = (value >>> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} diff --git a/src/shared/js-core/src/query.ts b/src/shared/js-core/src/query.ts new file mode 100644 index 00000000..7849f5d8 --- /dev/null +++ b/src/shared/js-core/src/query.ts @@ -0,0 +1,656 @@ +import { simpleQuery } from './protocol.js'; + +export type QueryBinaryInput = ArrayBuffer | ArrayBufferView | Uint8Array | ReadonlyArray; + +export type QueryParam = + | null + | string + | number + | boolean + | QueryBinaryInput + | { format: 'text'; value: string | number | boolean } + | { format: 'binary'; value: QueryBinaryInput }; + +export type QueryFormat = 'text' | 'binary' | { code: number; kind: 'other' }; + +export type QueryField = { + name: string; + tableOid: number; + tableAttribute: number; + typeOid: number; + typeSize: number; + typeModifier: number; + format: QueryFormat; +}; + +export type QueryRow = { + values: Array; + text(column: number): string | null; +}; + +export type QueryResult = { + fields: QueryField[]; + rows: QueryRow[]; + commandTag?: string; + rowCount: number; + fieldIndex(name: string): number | undefined; + getText(row: number, column: string): string | null; +}; + +export { simpleQuery }; + +export type PostgresErrorField = { + code: number; + value: string; +}; + +export class PostgresError extends Error { + readonly severity?: string; + readonly sqlstate?: string; + readonly detail?: string; + readonly hint?: string; + readonly position?: string; + readonly whereText?: string; + readonly schemaName?: string; + readonly tableName?: string; + readonly columnName?: string; + readonly dataTypeName?: string; + readonly constraintName?: string; + readonly fields: PostgresErrorField[]; + readonly postgresMessage: string; + + constructor(fields: PostgresErrorField[]) { + const severity = fieldValue(fields, 0x53) ?? fieldValue(fields, 0x56); + const sqlstate = fieldValue(fields, 0x43); + const postgresMessage = fieldValue(fields, 0x4d) ?? 'PostgreSQL ErrorResponse'; + super(formatPostgresError(severity, sqlstate, postgresMessage)); + this.name = 'PostgresError'; + this.severity = severity; + this.sqlstate = sqlstate; + this.postgresMessage = postgresMessage; + this.detail = fieldValue(fields, 0x44); + this.hint = fieldValue(fields, 0x48); + this.position = fieldValue(fields, 0x50); + this.whereText = fieldValue(fields, 0x57); + this.schemaName = fieldValue(fields, 0x73); + this.tableName = fieldValue(fields, 0x74); + this.columnName = fieldValue(fields, 0x63); + this.dataTypeName = fieldValue(fields, 0x64); + this.constraintName = fieldValue(fields, 0x6e); + this.fields = fields; + } + + static fallback(): PostgresError { + return new PostgresError([{ code: 0x4d, value: 'PostgreSQL ErrorResponse' }]); + } +} + +export function extendedQuery(sql: string, parameters: ReadonlyArray): Uint8Array { + if (parameters.length > 0x7fff) { + throw new Error( + `extended query supports at most ${0x7fff} parameters, got ${parameters.length}`, + ); + } + if (sql.includes('\0')) { + throw new Error('extended query SQL must not contain NUL bytes'); + } + + const packet: number[] = []; + pushParse(packet, sql); + pushBind(packet, parameters.map(normalizeQueryParam)); + pushDescribePortal(packet); + pushExecute(packet); + pushFrontendMessage(packet, 0x53, []); + return Uint8Array.from(packet); +} + +export function parseQueryResponse(bytes: Uint8Array): QueryResult { + const cursor = new ByteCursor(bytes); + let fields: QueryField[] | undefined; + const rows: QueryRow[] = []; + let commandTag: string | undefined; + let sawReady = false; + + while (!cursor.isAtEnd()) { + const tag = cursor.readU8('backend message tag'); + const length = cursor.readI32('backend message length'); + if (length < 4) { + throw new Error(`invalid backend message length ${length}`); + } + const body = new ByteCursor(cursor.readBytes(length - 4, 'backend message body')); + + switch (tag) { + case 0x54: + if (fields !== undefined) { + throw new Error( + 'query() received multiple result sets; use execProtocolRaw for multi-statement row results', + ); + } + fields = parseRowDescription(body); + body.requireEnd('RowDescription'); + break; + case 0x44: + if (fields === undefined) { + throw new Error('DataRow arrived before RowDescription'); + } + rows.push(parseDataRow(body, fields.length)); + body.requireEnd('DataRow'); + break; + case 0x43: + commandTag = body.readCString('CommandComplete tag'); + body.requireEnd('CommandComplete'); + break; + case 0x45: + throw parseErrorResponse(body); + case 0x47: + case 0x48: + case 0x57: + case 0x64: + case 0x63: + throw new Error( + 'query() does not support COPY protocol responses; use execProtocolRaw for COPY traffic', + ); + case 0x5a: + validateReadyForQuery(body); + sawReady = true; + if (!cursor.isAtEnd()) { + throw new Error('backend returned bytes after ReadyForQuery'); + } + break; + case 0x31: + body.requireEnd('ParseComplete'); + break; + case 0x32: + body.requireEnd('BindComplete'); + break; + case 0x33: + body.requireEnd('CloseComplete'); + break; + case 0x49: + body.requireEnd('EmptyQueryResponse'); + break; + case 0x6e: + body.requireEnd('NoData'); + break; + case 0x53: + validateParameterStatus(body); + break; + case 0x4e: + validateFieldResponse(body, 'NoticeResponse'); + break; + case 0x41: + validateNotificationResponse(body); + break; + default: + throw new Error(`query() received unexpected backend message tag ${hexBackendTag(tag)}`); + } + } + + if (!sawReady) { + throw new Error('query response ended before ReadyForQuery'); + } + + const resultFields = fields ?? []; + return { + fields: resultFields, + rows, + commandTag, + rowCount: rows.length, + fieldIndex(name: string): number | undefined { + const index = resultFields.findIndex((field) => field.name === name); + return index >= 0 ? index : undefined; + }, + getText(row: number, column: string): string | null { + const columnIndex = this.fieldIndex(column); + if (columnIndex === undefined) { + throw new Error(`query result has no column named ${JSON.stringify(column)}`); + } + const queryRow = rows[row]; + if (queryRow === undefined) { + throw new Error(`query result has no row at index ${row}`); + } + return queryRow.text(columnIndex); + }, + }; +} + +export function assertSuccessfulQueryResponse(bytes: Uint8Array): void { + const cursor = new ByteCursor(bytes); + let sawReady = false; + + while (!cursor.isAtEnd()) { + const tag = cursor.readU8('backend message tag'); + const length = cursor.readI32('backend message length'); + if (length < 4) { + throw new Error(`invalid backend message length ${length}`); + } + const body = new ByteCursor(cursor.readBytes(length - 4, 'backend message body')); + + switch (tag) { + case 0x45: + throw parseErrorResponse(body); + case 0x5a: + validateReadyForQuery(body); + sawReady = true; + if (!cursor.isAtEnd()) { + throw new Error('backend returned bytes after ReadyForQuery'); + } + break; + default: + break; + } + } + + if (!sawReady) { + throw new Error('query response ended before ReadyForQuery'); + } +} + +type NormalizedParam = + | { kind: 'null' } + | { kind: 'text'; value: Uint8Array } + | { kind: 'binary'; value: Uint8Array }; + +function normalizeQueryParam(parameter: QueryParam): NormalizedParam { + if (parameter === null) { + return { kind: 'null' }; + } + if ( + typeof parameter === 'string' || + typeof parameter === 'number' || + typeof parameter === 'boolean' + ) { + return { kind: 'text', value: new TextEncoder().encode(String(parameter)) }; + } + if (isQueryBinaryInput(parameter)) { + return { kind: 'binary', value: toUint8Array(parameter) }; + } + if (parameter.format === 'text') { + return { kind: 'text', value: new TextEncoder().encode(String(parameter.value)) }; + } + return { kind: 'binary', value: toUint8Array(parameter.value) }; +} + +function isQueryBinaryInput(value: unknown): value is QueryBinaryInput { + return value instanceof ArrayBuffer || ArrayBuffer.isView(value) || Array.isArray(value); +} + +function pushParse(out: number[], sql: string): void { + const body: number[] = []; + pushCString(body, ''); + pushCString(body, sql); + pushI16(body, 0); + pushFrontendMessage(out, 0x50, body); +} + +function pushBind(out: number[], parameters: NormalizedParam[]): void { + const body: number[] = []; + pushCString(body, ''); + pushCString(body, ''); + + pushI16(body, parameters.length); + for (const parameter of parameters) { + pushI16(body, parameter.kind === 'binary' ? 1 : 0); + } + + pushI16(body, parameters.length); + for (const parameter of parameters) { + if (parameter.kind === 'null') { + pushI32(body, -1); + } else { + pushSizedValue(body, parameter.value); + } + } + + pushI16(body, 1); + pushI16(body, 0); + pushFrontendMessage(out, 0x42, body); +} + +function pushDescribePortal(out: number[]): void { + const body: number[] = [0x50]; + pushCString(body, ''); + pushFrontendMessage(out, 0x44, body); +} + +function pushExecute(out: number[]): void { + const body: number[] = []; + pushCString(body, ''); + pushI32(body, 0); + pushFrontendMessage(out, 0x45, body); +} + +function pushFrontendMessage(out: number[], tag: number, body: ReadonlyArray): void { + out.push(tag); + pushI32(out, body.length + 4); + out.push(...body); +} + +function pushCString(out: number[], value: string): void { + if (value.includes('\0')) { + throw new Error('frontend protocol string must not contain NUL bytes'); + } + out.push(...new TextEncoder().encode(value), 0); +} + +function pushSizedValue(out: number[], value: Uint8Array): void { + pushI32(out, value.length); + out.push(...value); +} + +function pushI32(out: number[], value: number): void { + pushU32(out, value >>> 0); +} + +function pushU32(out: number[], value: number): void { + out.push((value >>> 24) & 0xff); + out.push((value >>> 16) & 0xff); + out.push((value >>> 8) & 0xff); + out.push(value & 0xff); +} + +function pushI16(out: number[], value: number): void { + const bits = value & 0xffff; + out.push((bits >>> 8) & 0xff); + out.push(bits & 0xff); +} + +export function toUint8Array(input: QueryBinaryInput): Uint8Array { + if (input instanceof Uint8Array) { + return input; + } + if (ArrayBuffer.isView(input)) { + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + return Uint8Array.from(input); +} + +function parseRowDescription(cursor: ByteCursor): QueryField[] { + const count = cursor.readI16('RowDescription field count'); + if (count < 0) { + throw new Error(`invalid RowDescription field count ${count}`); + } + const fields: QueryField[] = []; + for (let index = 0; index < count; index += 1) { + fields.push({ + name: cursor.readCString('field name'), + tableOid: cursor.readU32('field table oid'), + tableAttribute: cursor.readI16('field table attribute'), + typeOid: cursor.readU32('field type oid'), + typeSize: cursor.readI16('field type size'), + typeModifier: cursor.readI32('field type modifier'), + format: queryFormat(cursor.readI16('field format')), + }); + } + return fields; +} + +function parseDataRow(cursor: ByteCursor, expectedColumns: number): QueryRow { + const count = cursor.readI16('DataRow column count'); + if (count < 0) { + throw new Error(`invalid DataRow column count ${count}`); + } + if (count !== expectedColumns) { + throw new Error( + `DataRow column count ${count} does not match RowDescription count ${expectedColumns}`, + ); + } + const values: Array = []; + for (let index = 0; index < count; index += 1) { + const length = cursor.readI32('DataRow value length'); + if (length === -1) { + values.push(null); + } else if (length < 0) { + throw new Error(`invalid DataRow value length ${length}`); + } else { + values.push(cursor.readBytes(length, 'DataRow value')); + } + } + return { + values, + text(column: number): string | null { + if (column < 0 || column >= values.length) { + throw new Error(`query row has no column at index ${column}`); + } + const value = values[column]!; + return value === null ? null : decodeUtf8Strict(value, 'query value'); + }, + }; +} + +function parseErrorResponse(cursor: ByteCursor): PostgresError { + const fields: PostgresErrorField[] = []; + while (!cursor.isAtEnd()) { + let code: number; + try { + code = cursor.readU8('ErrorResponse field code'); + } catch { + return PostgresError.fallback(); + } + if (code === 0) { + break; + } + let value: string; + try { + value = cursor.readCString('ErrorResponse field'); + } catch { + return PostgresError.fallback(); + } + fields.push({ code, value }); + } + return new PostgresError(fields); +} + +function fieldValue(fields: ReadonlyArray, code: number): string | undefined { + return fields.find((field) => field.code === code)?.value; +} + +function formatPostgresError( + severity: string | undefined, + sqlstate: string | undefined, + message: string, +): string { + if (severity !== undefined && sqlstate !== undefined) { + return `${severity} [${sqlstate}]: ${message}`; + } + if (severity !== undefined) { + return `${severity}: ${message}`; + } + if (sqlstate !== undefined) { + return `[${sqlstate}]: ${message}`; + } + return message; +} + +function queryFormat(code: number): QueryFormat { + if (code === 0) { + return 'text'; + } + if (code === 1) { + return 'binary'; + } + return { code, kind: 'other' }; +} + +function hexBackendTag(tag: number): string { + return `0x${tag.toString(16).padStart(2, '0')}`; +} + +function validateReadyForQuery(body: ByteCursor): void { + const remaining = body.remainingBytes(); + if (remaining !== 1) { + throw new Error(`ReadyForQuery contained ${remaining} bytes, expected 1`); + } + const status = body.readU8('ReadyForQuery transaction status'); + if (status !== 0x49 && status !== 0x54 && status !== 0x45) { + throw new Error(`ReadyForQuery contained invalid transaction status ${hexBackendTag(status)}`); + } +} + +function validateParameterStatus(body: ByteCursor): void { + body.readCString('ParameterStatus name'); + body.readCString('ParameterStatus value'); + body.requireEnd('ParameterStatus'); +} + +function validateNotificationResponse(body: ByteCursor): void { + body.readI32('NotificationResponse process id'); + body.readCString('NotificationResponse channel'); + body.readCString('NotificationResponse payload'); + body.requireEnd('NotificationResponse'); +} + +function validateFieldResponse(body: ByteCursor, label: string): void { + for (;;) { + if (body.isAtEnd()) { + throw new Error(`${label} is missing terminator`); + } + const code = body.readU8(`${label} field code`); + if (code === 0) { + body.requireEnd(label); + return; + } + body.readCString(`${label} field`); + } +} + +class ByteCursor { + readonly #bytes: Uint8Array; + #offset = 0; + + constructor(bytes: Uint8Array) { + this.#bytes = bytes; + } + + isAtEnd(): boolean { + return this.#offset === this.#bytes.length; + } + + remainingBytes(): number { + return this.#bytes.length - this.#offset; + } + + requireEnd(label: string): void { + if (!this.isAtEnd()) { + throw new Error(`${label} contained trailing bytes`); + } + } + + readU8(label: string): number { + return this.readBytes(1, label)[0]!; + } + + readU32(label: string): number { + return ( + (this.readU8(label) * 0x1000000 + + (this.readU8(label) << 16) + + (this.readU8(label) << 8) + + this.readU8(label)) >>> + 0 + ); + } + + readI32(label: string): number { + const value = this.readU32(label); + return value > 0x7fffffff ? value - 0x100000000 : value; + } + + readI16(label: string): number { + const value = (this.readU8(label) << 8) | this.readU8(label); + return value > 0x7fff ? value - 0x10000 : value; + } + + readCString(label: string): string { + const end = this.#bytes.indexOf(0, this.#offset); + if (end < 0) { + throw new Error(`${label} is missing null terminator`); + } + const value = decodeUtf8Strict(this.#bytes.subarray(this.#offset, end), label); + this.#offset = end + 1; + return value; + } + + readBytes(count: number, label: string): Uint8Array { + if (count < 0 || this.#offset + count > this.#bytes.length) { + throw new Error(`truncated ${label}`); + } + const value = this.#bytes.slice(this.#offset, this.#offset + count); + this.#offset += count; + return value; + } +} + +function decodeUtf8Strict(bytes: Uint8Array, label: string): string { + validateUtf8(bytes, label); + return new TextDecoder().decode(bytes); +} + +function validateUtf8(bytes: Uint8Array, label: string): void { + let index = 0; + while (index < bytes.length) { + const first = bytes[index]!; + if (first <= 0x7f) { + index += 1; + } else if (first >= 0xc2 && first <= 0xdf) { + requireContinuation(bytes, index + 1, label); + index += 2; + } else if (first === 0xe0) { + requireRange(bytes, index + 1, 0xa0, 0xbf, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first >= 0xe1 && first <= 0xec) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first === 0xed) { + requireRange(bytes, index + 1, 0x80, 0x9f, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first >= 0xee && first <= 0xef) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + index += 3; + } else if (first === 0xf0) { + requireRange(bytes, index + 1, 0x90, 0xbf, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else if (first >= 0xf1 && first <= 0xf3) { + requireContinuation(bytes, index + 1, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else if (first === 0xf4) { + requireRange(bytes, index + 1, 0x80, 0x8f, label); + requireContinuation(bytes, index + 2, label); + requireContinuation(bytes, index + 3, label); + index += 4; + } else { + throw invalidUtf8(label, index); + } + } +} + +function requireContinuation(bytes: Uint8Array, index: number, label: string): void { + requireRange(bytes, index, 0x80, 0xbf, label); +} + +function requireRange( + bytes: Uint8Array, + index: number, + min: number, + max: number, + label: string, +): void { + const byte = bytes[index]; + if (byte === undefined || byte < min || byte > max) { + throw invalidUtf8(label, index); + } +} + +function invalidUtf8(label: string, index: number): Error { + return new Error(`${label} is not valid UTF-8 at byte ${index}`); +} diff --git a/src/shared/js-core/tools/check-js-core.mjs b/src/shared/js-core/tools/check-js-core.mjs new file mode 100644 index 00000000..44b13f95 --- /dev/null +++ b/src/shared/js-core/tools/check-js-core.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; + +function fail(message) { + throw new Error(message); +} + +function read(path) { + return readFileSync(path, 'utf8'); +} + +const mirrors = [ + ['src/shared/js-core/src/protocol.ts', 'src/sdks/js/src/protocol.ts'], + ['src/shared/js-core/src/protocol.ts', 'src/sdks/react-native/src/protocol.ts'], + ['src/shared/js-core/src/query.ts', 'src/sdks/js/src/query.ts'], + ['src/shared/js-core/src/query.ts', 'src/sdks/react-native/src/query.ts'], +]; + +for (const [canonicalPath, mirrorPath] of mirrors) { + const canonical = read(canonicalPath); + const mirror = read(mirrorPath); + if (canonical !== mirror) { + fail(`${mirrorPath} is not a fresh mirror of ${canonicalPath}`); + } +} + +console.log('shared JavaScript core mirrors are fresh'); diff --git a/src/sources/moon.yml b/src/sources/moon.yml new file mode 100644 index 00000000..a6a962ee --- /dev/null +++ b/src/sources/moon.yml @@ -0,0 +1,67 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "source-inputs" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["sources"] + +project: + title: "Source Inputs" + description: "Neutral source checkout materialization for PostgreSQL, runtime dependencies, toolchains, and extension-owned sources." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + source-fetch: + tags: ["source", "fetch"] + command: "bun tools/policy/fetch-sources.mjs all" + inputs: + - "/src/sources/**/*" + - "/src/extensions/external/**/source.toml" + - "/src/extensions/external/**/dependencies/**/source.toml" + - "/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile" + - "/tools/policy/fetch-sources.mjs" + options: + cache: false + runFromWorkspaceRoot: true + source-fetch-native-runtime: + tags: ["source", "fetch"] + command: "bun tools/policy/fetch-sources.mjs native-runtime" + inputs: + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/native/**/*" + - "/src/extensions/external/**/source.toml" + - "/src/extensions/external/**/dependencies/**/source.toml" + - "/tools/policy/fetch-sources.mjs" + options: + cache: false + runFromWorkspaceRoot: true + source-fetch-wasix-runtime: + tags: ["source", "fetch"] + command: "bun tools/policy/fetch-sources.mjs wasix-runtime" + inputs: + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/shared/**/*" + - "/src/sources/third-party/wasix/**/*" + - "/src/extensions/external/**/source.toml" + - "/src/extensions/external/**/dependencies/**/source.toml" + - "/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile" + - "/tools/policy/fetch-sources.mjs" + options: + cache: false + runFromWorkspaceRoot: true + source-fetch-extensions: + tags: ["source", "fetch"] + command: "bun tools/policy/fetch-sources.mjs extensions" + inputs: + - "/src/extensions/external/**/source.toml" + - "/src/extensions/external/**/dependencies/**/source.toml" + - "/tools/policy/fetch-sources.mjs" + options: + cache: false + runFromWorkspaceRoot: true diff --git a/src/sources/third-party/native/README.md b/src/sources/third-party/native/README.md new file mode 100644 index 00000000..846e141b --- /dev/null +++ b/src/sources/third-party/native/README.md @@ -0,0 +1,3 @@ +# Native Third-Party Sources + +Native-only third-party source pins live here when an upstream dependency is not shared with WASIX. diff --git a/src/sources/third-party/native/moon.yml b/src/sources/third-party/native/moon.yml new file mode 100644 index 00000000..53abd3a0 --- /dev/null +++ b/src/sources/third-party/native/moon.yml @@ -0,0 +1,28 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "third-party-native" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["third-party", "sources", "native"] + +project: + title: "Native Third-Party Sources" + description: "Pinned native-only third-party source metadata." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "node tools/policy/check-source-inputs.mjs third-party-native" + inputs: + - "/src/sources/third-party/native/**/*" + - "/tools/policy/check-source-inputs.mjs" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/sources/third-party/shared/icu.toml b/src/sources/third-party/shared/icu.toml new file mode 100644 index 00000000..f5197bc5 --- /dev/null +++ b/src/sources/third-party/shared/icu.toml @@ -0,0 +1,4 @@ +name = "icu" +url = "https://github.com/unicode-org/icu.git" +branch = "release-76-1" +commit = "8eca245c7484ac6cc179e3e5f7c1ea7680810f39" diff --git a/src/sources/third-party/shared/moon.yml b/src/sources/third-party/shared/moon.yml new file mode 100644 index 00000000..04489980 --- /dev/null +++ b/src/sources/third-party/shared/moon.yml @@ -0,0 +1,28 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "third-party-shared" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["third-party", "sources"] + +project: + title: "Shared Third-Party Sources" + description: "Pinned third-party source metadata shared by native and WASIX runtimes." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "node tools/policy/check-source-inputs.mjs third-party-shared" + inputs: + - "/src/sources/third-party/shared/**/*" + - "/tools/policy/check-source-inputs.mjs" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/sources/third-party/shared/openssl.toml b/src/sources/third-party/shared/openssl.toml new file mode 100644 index 00000000..386edf0b --- /dev/null +++ b/src/sources/third-party/shared/openssl.toml @@ -0,0 +1,4 @@ +name = "openssl" +url = "https://github.com/openssl/openssl.git" +branch = "openssl-3.5.6" +commit = "286ddeaac037533bbdce65b3c689e3f7ffebf0f6" diff --git a/src/sources/third-party/wasix/README.md b/src/sources/third-party/wasix/README.md new file mode 100644 index 00000000..1cfe5e80 --- /dev/null +++ b/src/sources/third-party/wasix/README.md @@ -0,0 +1,3 @@ +# WASIX Third-Party Sources + +WASIX-only third-party source pins live here when an upstream dependency is not shared with native runtimes. diff --git a/src/sources/third-party/wasix/moon.yml b/src/sources/third-party/wasix/moon.yml new file mode 100644 index 00000000..b9908d00 --- /dev/null +++ b/src/sources/third-party/wasix/moon.yml @@ -0,0 +1,28 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "third-party-wasix" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["third-party", "sources", "wasix"] + +project: + title: "WASIX Third-Party Sources" + description: "Pinned WASIX-only third-party source metadata." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "node tools/policy/check-source-inputs.mjs third-party-wasix" + inputs: + - "/src/sources/third-party/wasix/**/*" + - "/tools/policy/check-source-inputs.mjs" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/sources/toolchains/android-emulator-runner.toml b/src/sources/toolchains/android-emulator-runner.toml new file mode 100644 index 00000000..af0d61d3 --- /dev/null +++ b/src/sources/toolchains/android-emulator-runner.toml @@ -0,0 +1,12 @@ +[toolchain] +name = "ReactiveCircus Android Emulator Runner" +kind = "github-action" +repository = "ReactiveCircus/android-emulator-runner" +ref = "v2" +sha = "70f4dee990796918b78d040e3278474bdbd348a7" +license = "Apache-2.0" +cloud_required = false + +[usage] +scope = "React Native Android installed-app E2E on GitHub-hosted Ubuntu runners" +reason = "Creates, boots, waits for, and tears down the Android emulator around the product-owned Maestro flow." diff --git a/src/sources/toolchains/maestro.toml b/src/sources/toolchains/maestro.toml new file mode 100644 index 00000000..2f0b506e --- /dev/null +++ b/src/sources/toolchains/maestro.toml @@ -0,0 +1,14 @@ +[toolchain] +maestro = "2.6.0" +install_url = "https://get.maestro.mobile.dev" +license = "Apache-2.0" +cloud_required = false + +[decision] +status = "accepted" +date = "2026-06-08" +scope = "react-native-installed-app-e2e" +runner = "open-source-maestro-cli" +reopen_only_if = "Replacing the mobile E2E implementation or proving an actual requirement cannot be met by the pinned open-source CLI." +stop_rule = "Do not re-check Maestro, Detox, Appium, EAS-only flows, or hosted mobile E2E providers during routine implementation/review work." +default_path = "free, public-checkout reproducible, GitHub-hosted emulator/simulator E2E" diff --git a/src/sources/toolchains/moon.yml b/src/sources/toolchains/moon.yml new file mode 100644 index 00000000..4d3f0e1c --- /dev/null +++ b/src/sources/toolchains/moon.yml @@ -0,0 +1,28 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "source-toolchains" +language: "unknown" +layer: "configuration" +stack: "systems" +tags: ["sources", "toolchain"] + +project: + title: "Source Toolchains" + description: "Pinned compiler, runtime, and container metadata for source builds." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "node tools/policy/check-source-inputs.mjs toolchains" + inputs: + - "/src/sources/toolchains/**/*" + - "/tools/policy/check-source-inputs.mjs" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/src/sources/toolchains/wasix.toml b/src/sources/toolchains/wasix.toml new file mode 100644 index 00000000..657d92c5 --- /dev/null +++ b/src/sources/toolchains/wasix.toml @@ -0,0 +1,16 @@ +[toolchain] +wasmer = "7.2.0-alpha.3" +wasmer-wasix = "0.702.0-alpha.3" +wasixcc = "2026-03-02.1" +llvm = "22.1" +docker_image = "ghcr.io/f0rr0/oliphaunt-wasix-wasix-build" +docker_image_digest = "sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b" + +[build] +postgres_prefix = "/" +postgres_pkglibdir = "/lib/postgresql" +postgres_sharedir = "/share/postgresql" +main_flags = ["-fwasm-exceptions"] +extension_flags = ["-fwasm-exceptions", "-fPIC", "-Wl,-shared"] +archive_format = "tar.zst" +deterministic_archives = true diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs deleted file mode 100644 index d38fb260..00000000 --- a/tests/cli_smoke.rs +++ /dev/null @@ -1,82 +0,0 @@ -#![cfg(feature = "extensions")] - -use anyhow::{Context, Result}; -use pglite_oxide::{Pglite, capture_phase_timings}; -use sqlx::{Connection, Row}; -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; -use tokio::time::{Duration, timeout}; - -mod support; -use support::{ChildGuard, TestTrace, trace_step}; - -fn direct_open_diagnostic() -> String { - let (result, phases) = capture_phase_timings(|| Pglite::builder().temporary().open()); - let outcome = match result { - Ok(mut pg) => match pg.close() { - Ok(()) => "direct temporary Pglite open succeeded".to_owned(), - Err(err) => format!("direct temporary Pglite open succeeded, close failed: {err:#}"), - }, - Err(err) => format!("direct temporary Pglite open failed: {err:#}"), - }; - format!("{outcome}\nphases:\n{phases:#?}") -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pglite_proxy_print_uri_accepts_sqlx_connection() -> Result<()> { - let _trace = TestTrace::new("pglite_proxy_print_uri_accepts_sqlx_connection"); - let process = Command::new(env!("CARGO_BIN_EXE_pglite-proxy")) - .args(["--temporary", "--tcp", "127.0.0.1:0", "--print-uri"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("spawn pglite-proxy")?; - let mut child = ChildGuard::new(process, "pglite-proxy")?; - - let stdout = child - .child_mut() - .stdout - .take() - .context("pglite-proxy stdout pipe")?; - let mut reader = BufReader::new(stdout); - let mut uri = String::new(); - let bytes = reader - .read_line(&mut uri) - .context("read pglite-proxy printed URI")?; - if bytes == 0 { - let stderr = child.collect_stderr(); - anyhow::bail!("pglite-proxy exited before printing URI\n\nstderr:\n{stderr}"); - } - let uri = uri.trim(); - assert!( - uri.starts_with("postgresql://") || uri.starts_with("postgres://"), - "unexpected URI: {uri}" - ); - trace_step("pglite_proxy printed URI"); - - let mut conn = match timeout(Duration::from_secs(30), sqlx::PgConnection::connect(uri)).await { - Ok(Ok(conn)) => conn, - Ok(Err(err)) => { - let stderr = child.collect_stderr(); - let direct = direct_open_diagnostic(); - anyhow::bail!( - "connect to pglite-proxy failed: {err:#}\n\nstderr:\n{stderr}\n\ndirect backend diagnostic:\n{direct}" - ); - } - Err(err) => { - let stderr = child.collect_stderr(); - let direct = direct_open_diagnostic(); - anyhow::bail!( - "timed out connecting to pglite-proxy: {err}\n\nstderr:\n{stderr}\n\ndirect backend diagnostic:\n{direct}" - ); - } - }; - let row = sqlx::query("SELECT $1::int4 + 1 AS answer") - .bind(41_i32) - .fetch_one(&mut conn) - .await?; - assert_eq!(row.try_get::("answer")?, 42); - - conn.close().await?; - Ok(()) -} diff --git a/tools/coverage/check-product b/tools/coverage/check-product new file mode 100755 index 00000000..478e6544 --- /dev/null +++ b/tools/coverage/check-product @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +exec "$(dirname "$0")/coverage.py" check-product "$@" diff --git a/tools/coverage/coverage.py b/tools/coverage/coverage.py new file mode 100755 index 00000000..306bf775 --- /dev/null +++ b/tools/coverage/coverage.py @@ -0,0 +1,805 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tomllib +import xml.etree.ElementTree as ET +from functools import lru_cache +from pathlib import Path +from typing import Any + + +PRODUCTS = ( + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-react-native", + "oliphaunt-wasix-rust", +) + +PRODUCT_SOURCE_ROOTS = { + "oliphaunt-rust": "src/sdks/rust", + "oliphaunt-swift": "src/sdks/swift", + "oliphaunt-kotlin": "src/sdks/kotlin", + "oliphaunt-js": "src/sdks/js", + "oliphaunt-react-native": "src/sdks/react-native", + "oliphaunt-wasix-rust": "src/bindings/wasix-rust/crates/oliphaunt-wasix", +} + +FORBIDDEN_PATH_PARTS = ( + "/node_modules/", + "/target/", + "/.build/", + "/DerivedData/", + "/build/", + "/.cxx/", + "/generated/", + "/vendor/", +) + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +ROOT = repo_root() +BASELINE = ROOT / "coverage" / "baseline.toml" +COVERAGE_ROOT = ROOT / "target" / "coverage" + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def run(command: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> None: + print(f"\n==> {' '.join(command)}", flush=True) + subprocess.run(command, cwd=cwd, env=env, check=True) + + +def capture(command: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> str: + print(f"\n==> {' '.join(command)}", flush=True) + result = subprocess.run( + command, + cwd=cwd, + env=env, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print(result.stdout, end="") + return result.stdout + + +def optional_capture(command: list[str], *, cwd: Path = ROOT) -> str | None: + try: + result = subprocess.run( + command, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except FileNotFoundError: + return None + if result.returncode != 0: + return None + value = result.stdout.strip() + return value or None + + +def require_tool(name: str, install_hint: str) -> None: + if shutil.which(name) is None: + fail(f"missing required coverage tool: {name}\n\nInstall with:\n {install_hint}") + + +def load_baseline() -> dict[str, Any]: + if not BASELINE.is_file(): + fail(f"missing coverage baseline: {BASELINE.relative_to(ROOT)}") + with BASELINE.open("rb") as handle: + data = tomllib.load(handle) + products = data.get("products") + if not isinstance(products, dict): + fail("coverage baseline must define [products.] tables") + return data + + +def product_config(product: str) -> dict[str, Any]: + data = load_baseline() + config = data["products"].get(product) + if not isinstance(config, dict): + fail(f"coverage baseline does not define product {product!r}") + return config + + +def output_dir(product: str) -> Path: + return COVERAGE_ROOT / product + + +def product_source_root(product: str) -> Path: + source = PRODUCT_SOURCE_ROOTS.get(product) + if source is None: + fail(f"missing source root mapping for coverage product {product}") + return ROOT / source + + +def product_source_prefix(product: str) -> str: + return product_source_root(product).relative_to(ROOT).as_posix() + + +def reset_output(product: str) -> Path: + out = output_dir(product) + shutil.rmtree(out, ignore_errors=True) + out.mkdir(parents=True, exist_ok=True) + return out + + +def rel_path(path: str | Path) -> str: + raw = Path(path) + try: + return raw.resolve().relative_to(ROOT).as_posix() + except (OSError, ValueError): + return raw.as_posix() + + +@lru_cache(maxsize=512) +def repo_glob_regex(pattern: str) -> re.Pattern[str]: + normalized = pattern.replace(os.sep, "/") + parts: list[str] = ["^"] + index = 0 + while index < len(normalized): + char = normalized[index] + if char == "*": + if index + 1 < len(normalized) and normalized[index + 1] == "*": + index += 2 + if index < len(normalized) and normalized[index] == "/": + index += 1 + parts.append("(?:.*/)?") + else: + parts.append(".*") + continue + parts.append("[^/]*") + elif char == "?": + parts.append("[^/]") + else: + parts.append(re.escape(char)) + index += 1 + parts.append("$") + return re.compile("".join(parts)) + + +def matches_any(path: str, patterns: list[str]) -> bool: + normalized = path.replace(os.sep, "/") + return any(repo_glob_regex(pattern).match(normalized) is not None for pattern in patterns) + + +def source_globs(config: dict[str, Any]) -> list[str]: + globs = config.get("source_globs") + if not isinstance(globs, list) or not all(isinstance(item, str) for item in globs) or not globs: + fail("coverage product config must define non-empty source_globs") + return globs + + +def exclude_globs(config: dict[str, Any]) -> list[str]: + globs = config.get("exclude_globs") or [] + if not isinstance(globs, list) or not all(isinstance(item, str) for item in globs): + fail("coverage product config exclude_globs must be a list of strings") + return globs + + +def waiver_entries(config: dict[str, Any]) -> list[dict[str, str]]: + entries = config.get("waivers") or [] + if not isinstance(entries, list): + fail("coverage waivers must be an array of tables") + normalized = [] + for entry in entries: + if not isinstance(entry, dict): + fail("coverage waiver entries must be tables") + path = entry.get("path") + pattern = entry.get("glob") + reason = entry.get("reason") + evidence = entry.get("evidence") + owner = entry.get("owner") + expires = entry.get("expires") + if (path is None) == (pattern is None): + fail("coverage waiver must define exactly one of path or glob") + if ( + not isinstance(path or pattern, str) + or not isinstance(reason, str) + or not isinstance(evidence, str) + or not isinstance(owner, str) + or not isinstance(expires, str) + ): + fail("coverage waiver path/glob, reason, evidence, owner, and expires must be strings") + if not reason.strip() or not evidence.strip() or not owner.strip() or not expires.strip(): + fail("coverage waiver reason, evidence, owner, and expires must be non-empty") + normalized.append( + { + "path": path or "", + "glob": pattern or "", + "reason": reason, + "evidence": evidence, + "owner": owner, + "expires": expires, + } + ) + return normalized + + +def waiver_patterns(config: dict[str, Any]) -> list[str]: + patterns: list[str] = [] + for waiver in waiver_entries(config): + patterns.append(waiver["path"] or waiver["glob"]) + return patterns + + +def is_waived(path: str | Path, config: dict[str, Any]) -> bool: + relative = rel_path(path) + for waiver in waiver_entries(config): + exact = waiver["path"] + pattern = waiver["glob"] + if exact and relative == exact: + return True + if pattern and matches_any(relative, [pattern]): + return True + return False + + +def allowed_file(path: str | Path, config: dict[str, Any]) -> bool: + relative = rel_path(path) + normalized = f"/{relative}" + if not matches_any(relative, source_globs(config)): + return False + if matches_any(relative, exclude_globs(config)): + return False + if is_waived(relative, config): + return False + return not any(part in normalized for part in FORBIDDEN_PATH_PARTS) + + +def tracked_or_local_source_files(config: dict[str, Any]) -> list[str]: + files: set[str] = set() + for pattern in source_globs(config): + for candidate in ROOT.glob(pattern): + if candidate.is_file(): + files.add(rel_path(candidate)) + return sorted(files) + + +def validate_waivers(config: dict[str, Any]) -> list[dict[str, str]]: + files = tracked_or_local_source_files(config) + for waiver in waiver_entries(config): + exact = waiver["path"] + pattern = waiver["glob"] + matched = [file for file in files if (exact and file == exact) or (pattern and matches_any(file, [pattern]))] + if not matched: + target = exact or pattern + fail(f"coverage waiver does not match an owned source file: {target}") + return waiver_entries(config) + + +def owned_unwaived_source_files(config: dict[str, Any]) -> list[str]: + validate_waivers(config) + owned = [] + for file in tracked_or_local_source_files(config): + normalized = f"/{file}" + if matches_any(file, exclude_globs(config)): + continue + if is_waived(file, config): + continue + if any(part in normalized for part in FORBIDDEN_PATH_PARTS): + continue + owned.append(file) + return sorted(owned) + + +def percent(covered: int, total: int) -> float: + if total <= 0: + return 0.0 + return round((covered / total) * 100.0, 2) + + +def parse_lcov(path: Path, config: dict[str, Any]) -> tuple[int, int, list[dict[str, Any]]]: + files: list[dict[str, Any]] = [] + current_file: str | None = None + current_lines: dict[int, int] = {} + + def flush() -> None: + nonlocal current_file, current_lines + if current_file is None: + return + if allowed_file(current_file, config): + total = len(current_lines) + covered = sum(1 for count in current_lines.values() if count > 0) + if total > 0: + files.append({"path": rel_path(current_file), "covered_lines": covered, "total_lines": total}) + current_file = None + current_lines = {} + + with path.open("r", encoding="utf-8", errors="replace") as handle: + for raw_line in handle: + line = raw_line.rstrip("\n") + if line.startswith("SF:"): + flush() + current_file = line[3:] + elif line.startswith("DA:") and current_file is not None: + line_no, count, *_ = line[3:].split(",") + current_lines[int(line_no)] = int(count) + elif line == "end_of_record": + flush() + flush() + covered = sum(file["covered_lines"] for file in files) + total = sum(file["total_lines"] for file in files) + return covered, total, files + + +def normalize_javascript_report_path(product: str, raw_path: str) -> str: + path = Path(raw_path) + if path.is_absolute(): + return raw_path + source_prefix = product_source_prefix(product) + if raw_path.startswith(f"{source_prefix}/"): + return raw_path + return f"{source_prefix}/{raw_path}" + + +def parse_javascript_summary( + path: Path, + product: str, + config: dict[str, Any], +) -> tuple[int, int, list[dict[str, Any]]]: + data = json.loads(path.read_text()) + files: list[dict[str, Any]] = [] + for raw_path, entry in data.items(): + source_path = normalize_javascript_report_path(product, raw_path) + if raw_path == "total" or not allowed_file(source_path, config): + continue + lines = entry.get("lines") or {} + total = int(lines.get("total") or 0) + covered = int(lines.get("covered") or 0) + if total > 0: + files.append({"path": rel_path(source_path), "covered_lines": covered, "total_lines": total}) + covered = sum(file["covered_lines"] for file in files) + total = sum(file["total_lines"] for file in files) + return covered, total, files + + +def resolve_kover_source_path(package_name: str, sourcefile_name: str) -> str: + package_path = package_name.replace(".", "/") + source_root = product_source_root("oliphaunt-kotlin") / "oliphaunt" / "src" + candidates = sorted(source_root.glob(f"**/{package_path}/{sourcefile_name}")) + source_candidates = [candidate for candidate in candidates if "Test" not in candidate.parts] + if source_candidates: + return rel_path(source_candidates[0]) + if candidates: + return rel_path(candidates[0]) + return f"src/sdks/kotlin/oliphaunt/src/{package_path}/{sourcefile_name}" + + +def parse_kover_xml(path: Path, config: dict[str, Any]) -> tuple[int, int, list[dict[str, Any]]]: + root = ET.parse(path).getroot() + files: list[dict[str, Any]] = [] + for package in root.findall(".//package"): + package_name = package.attrib.get("name", "") + for sourcefile in package.findall("sourcefile"): + name = sourcefile.attrib.get("name", "") + source_path = resolve_kover_source_path(package_name, name) + if not allowed_file(source_path, config): + continue + lines = sourcefile.findall("line") + total = len(lines) + covered = 0 + for line in lines: + covered_instructions = int(line.attrib.get("ci", "0")) + if covered_instructions > 0: + covered += 1 + if total > 0: + files.append( + { + "path": source_path, + "covered_lines": covered, + "total_lines": total, + } + ) + covered = sum(file["covered_lines"] for file in files) + total = sum(file["total_lines"] for file in files) + return covered, total, files + + +def parse_swift_json(path: Path, config: dict[str, Any]) -> tuple[int, int, list[dict[str, Any]]]: + data = json.loads(path.read_text()) + files: list[dict[str, Any]] = [] + for report in data.get("data", []): + for file_entry in report.get("files", []): + filename = file_entry.get("filename") or file_entry.get("name") + if not filename or not allowed_file(filename, config): + continue + summary = file_entry.get("summary") or {} + lines = summary.get("lines") or {} + total = int(lines.get("count") or lines.get("total") or 0) + covered = int(lines.get("covered") or 0) + if total > 0: + files.append({"path": rel_path(filename), "covered_lines": covered, "total_lines": total}) + covered = sum(file["covered_lines"] for file in files) + total = sum(file["total_lines"] for file in files) + return covered, total, files + + +def write_summary( + product: str, + tool: str, + covered_lines: int, + total_lines: int, + files: list[dict[str, Any]], + reports: list[Path], +) -> Path: + out = output_dir(product) + config = product_config(product) + files = sorted(files, key=lambda item: item["path"]) + summary = { + "schema": "oliphaunt-coverage-summary-v1", + "product": product, + "tool": tool, + "line_coverage": percent(covered_lines, total_lines), + "line_threshold": float(config["line_threshold"]), + "covered_lines": covered_lines, + "total_lines": total_lines, + "files": files, + "reports": [rel_path(path) for path in reports], + "source_globs": source_globs(config), + "exclude_globs": exclude_globs(config), + "waived_files": [ + { + "path": waiver["path"] or waiver["glob"], + "reason": waiver["reason"], + "evidence": waiver["evidence"], + "owner": waiver["owner"], + "expires": waiver["expires"], + } + for waiver in waiver_entries(config) + ], + } + path = out / "summary.json" + path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + return path + + +def check_summary(product: str) -> dict[str, Any]: + config = product_config(product) + summary_path = ROOT / config["summary"] + if not summary_path.is_file(): + fail(f"{product}: missing measured coverage summary {summary_path.relative_to(ROOT)}") + summary = json.loads(summary_path.read_text()) + if summary.get("product") != product: + fail(f"{product}: coverage summary product mismatch") + total = int(summary.get("total_lines") or 0) + covered = int(summary.get("covered_lines") or 0) + if total <= 0 or covered <= 0: + fail(f"{product}: coverage summary is unmeasured: covered={covered} total={total}") + files = summary.get("files", []) + if not isinstance(files, list) or not files: + fail(f"{product}: coverage summary contains no measured source files") + measured = float(summary.get("line_coverage") or 0.0) + threshold = float(config["line_threshold"]) + committed_measured = float(config.get("measured_line_coverage", 0.0)) + if committed_measured < threshold: + fail(f"{product}: committed measured_line_coverage is below line_threshold") + if measured + 0.005 < threshold: + fail(f"{product}: line coverage {measured:.2f}% is below threshold {threshold:.2f}%") + summary_reports = set(summary.get("reports", [])) + for report in config.get("reports", []): + if report not in summary_reports: + fail(f"{product}: coverage summary is missing expected report {report}") + for report in summary_reports: + report_path = ROOT / report + if not report_path.is_file() or report_path.stat().st_size == 0: + fail(f"{product}: missing or empty coverage report {report}") + for file in files: + source_path = file.get("path", "") + path = f"/{source_path}" + if any(part in path for part in FORBIDDEN_PATH_PARTS): + fail(f"{product}: coverage includes generated/vendor/build path {source_path}") + if not allowed_file(source_path, config): + fail(f"{product}: coverage includes a source path outside the baseline scope: {source_path}") + per_file_threshold = float(config.get("per_file_line_threshold", 0.0)) + if per_file_threshold > 0.0: + for file in files: + source_path = file.get("path", "") + file_total = int(file.get("total_lines") or 0) + file_covered = int(file.get("covered_lines") or 0) + file_percent = percent(file_covered, file_total) + if file_percent + 0.005 < per_file_threshold: + fail( + f"{product}: {source_path} line coverage {file_percent:.2f}% " + f"is below per-file threshold {per_file_threshold:.2f}%" + ) + measured_paths = {file.get("path", "") for file in files} + missing_owned = sorted(set(owned_unwaived_source_files(config)) - measured_paths) + if missing_owned: + fail( + f"{product}: owned source files are neither measured nor waived: " + + ", ".join(missing_owned[:20]) + + (" ..." if len(missing_owned) > 20 else "") + ) + return summary + + +def run_rust(product: str) -> None: + package = "oliphaunt" if product == "oliphaunt-rust" else "oliphaunt-wasix" + out = reset_output(product) + lcov = out / "lcov.info" + require_tool("cargo", "rustup toolchain install 1.93") + if subprocess.run(["cargo", "llvm-cov", "--version"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + fail("missing required coverage tool: cargo-llvm-cov\n\nInstall with:\n cargo install cargo-llvm-cov") + if subprocess.run(["cargo", "nextest", "--version"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + fail("missing required coverage tool: cargo-nextest\n\nInstall with:\n cargo install cargo-nextest --locked") + env = os.environ.copy() + if "LLVM_COV" not in env: + llvm_cov = shutil.which("llvm-cov") or optional_capture(["xcrun", "--find", "llvm-cov"]) + if llvm_cov: + env["LLVM_COV"] = llvm_cov + if "LLVM_PROFDATA" not in env: + llvm_profdata = shutil.which("llvm-profdata") or optional_capture(["xcrun", "--find", "llvm-profdata"]) + if llvm_profdata: + env["LLVM_PROFDATA"] = llvm_profdata + feature_args = ["--no-default-features"] if product == "oliphaunt-wasix-rust" else [] + target_args = ["--lib"] if product == "oliphaunt-wasix-rust" else [] + run(["cargo", "llvm-cov", "clean", "--profraw-only"], env=env) + run( + [ + "cargo", + "llvm-cov", + "nextest", + "--package", + package, + *target_args, + *feature_args, + "--locked", + "--profile", + "ci", + "--no-tests=fail", + "--test-threads=1", + "--no-report", + ], + env=env, + ) + run( + [ + "cargo", + "test", + "--doc", + "--package", + package, + "--locked", + ], + env=env, + ) + run(["cargo", "llvm-cov", "report", "--lcov", "--output-path", str(lcov)], env=env) + covered, total, files = parse_lcov(lcov, product_config(product)) + write_summary(product, "cargo-llvm-cov", covered, total, files, [lcov]) + check_summary(product) + + +def run_swift() -> None: + out = reset_output("oliphaunt-swift") + scratch = ROOT / "target" / "coverage-build" / "oliphaunt-swift" + shutil.rmtree(scratch, ignore_errors=True) + require_tool("swift", "Install Xcode or the Swift toolchain") + run( + [ + "swift", + "test", + "--package-path", + str(ROOT), + "--scratch-path", + str(scratch), + "--enable-code-coverage", + ] + ) + output = capture( + [ + "swift", + "test", + "--package-path", + str(ROOT), + "--scratch-path", + str(scratch), + "--show-codecov-path", + ] + ) + candidates = [ + Path(line.strip()) + for line in output.splitlines() + if line.strip().endswith(".json") and Path(line.strip()).is_file() + ] + if not candidates: + candidates = list(scratch.rglob("*.json")) + if not candidates: + fail("oliphaunt-swift: swift test did not emit a code coverage JSON path") + report = out / "swift-coverage.json" + shutil.copyfile(candidates[-1], report) + covered, total, files = parse_swift_json(report, product_config("oliphaunt-swift")) + write_summary("oliphaunt-swift", "swift test --enable-code-coverage", covered, total, files, [report]) + check_summary("oliphaunt-swift") + + +def run_kotlin() -> None: + out = reset_output("oliphaunt-kotlin") + require_tool("java", "Install JDK 17") + package_dir = product_source_root("oliphaunt-kotlin") + gradle = package_dir / "gradlew" + build_root = ROOT / "target" / "coverage-build" / "oliphaunt-kotlin" / "gradle" + cxx_build_root = ROOT / "target" / "coverage-build" / "oliphaunt-kotlin" / "cxx" + project_cache = ROOT / "target" / "coverage-build" / "oliphaunt-kotlin" / "gradle-cache" + shutil.rmtree(build_root, ignore_errors=True) + shutil.rmtree(cxx_build_root, ignore_errors=True) + run( + [ + str(gradle), + "-p", + str(package_dir.relative_to(ROOT)), + ":oliphaunt:koverXmlReport", + ":oliphaunt:koverVerify", + "--no-daemon", + f"-PoliphauntBuildRoot={build_root}", + f"-PoliphauntCxxBuildRoot={cxx_build_root}", + "--project-cache-dir", + str(project_cache), + ] + ) + reports = sorted(build_root.rglob("reports/kover/**/*.xml")) + if not reports: + reports = sorted(package_dir.rglob("build/reports/kover/**/*.xml")) + if not reports: + fail("oliphaunt-kotlin: Kover did not emit an XML report") + report = out / "kover.xml" + shutil.copyfile(reports[-1], report) + covered, total, files = parse_kover_xml(report, product_config("oliphaunt-kotlin")) + write_summary("oliphaunt-kotlin", "kover", covered, total, files, [report]) + check_summary("oliphaunt-kotlin") + + +def run_javascript(product: str) -> None: + out = reset_output(product) + package_dir = product_source_root(product) + require_tool("pnpm", "corepack enable && corepack prepare pnpm@11.5.0 --activate") + config = product_config(product) + threshold = str(int(float(config["line_threshold"]))) + include_patterns: list[str] = [] + for pattern in source_globs(config): + prefix = f"{product_source_prefix(product)}/" + include_patterns.append(pattern.removeprefix(prefix)) + exclude_patterns: list[str] = [] + for pattern in [*exclude_globs(config), *waiver_patterns(config)]: + prefix = f"{product_source_prefix(product)}/" + exclude_patterns.append(pattern.removeprefix(prefix)) + env = os.environ.copy() + env.update( + { + "OLIPHAUNT_VITEST_COVERAGE": "1", + "OLIPHAUNT_VITEST_COVERAGE_DIR": str(out), + "OLIPHAUNT_VITEST_COVERAGE_INCLUDE": json.dumps(include_patterns), + "OLIPHAUNT_VITEST_COVERAGE_EXCLUDE": json.dumps(exclude_patterns), + "OLIPHAUNT_VITEST_COVERAGE_LINES": threshold, + } + ) + run(["pnpm", "--dir", str(package_dir), "test"], env=env) + summary_report = out / "coverage-summary.json" + if not summary_report.is_file(): + fail(f"{product}: Vitest did not emit {summary_report.relative_to(ROOT)}") + covered, total, files = parse_javascript_summary(summary_report, product, config) + reports = [summary_report] + lcov = out / "lcov.info" + if lcov.is_file(): + reports.append(lcov) + write_summary(product, "vitest-v8", covered, total, files, reports) + check_summary(product) + + +def run_product(product: str) -> None: + if product not in PRODUCTS: + fail(f"unknown product {product!r}; expected one of {', '.join(PRODUCTS)}") + if product in ("oliphaunt-rust", "oliphaunt-wasix-rust"): + run_rust(product) + elif product == "oliphaunt-swift": + run_swift() + elif product == "oliphaunt-kotlin": + run_kotlin() + elif product in ("oliphaunt-js", "oliphaunt-react-native"): + run_javascript(product) + else: + fail(f"unhandled coverage product {product}") + + +def parse_products_json(value: str | None) -> list[str]: + if value is None or not value.strip(): + return list(PRODUCTS) + try: + parsed = json.loads(value) + except json.JSONDecodeError as error: + fail(f"coverage products JSON is invalid: {error}") + if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): + fail("coverage products JSON must be a string array") + unknown = sorted(set(parsed) - set(PRODUCTS)) + if unknown: + fail("unknown coverage product(s): " + ", ".join(unknown)) + return sorted(set(parsed), key=PRODUCTS.index) + + +def summarize(*, allow_missing: bool = False, products_json: str | None = None) -> None: + data = load_baseline() + products = data["products"] + selected_products = parse_products_json(products_json) + rows = [] + all_summaries = [] + for product in selected_products: + if product not in products: + if data.get("policy", {}).get("fail_on_unmeasured_product", True): + fail(f"missing coverage baseline for {product}") + continue + summary_path = ROOT / products[product]["summary"] + if allow_missing and not summary_path.is_file(): + continue + if not summary_path.is_file(): + fail(f"missing required coverage summary: {summary_path.relative_to(ROOT)}") + summary = check_summary(product) + all_summaries.append(summary) + rows.append( + "| {product} | {tool} | {line_coverage:.2f}% | {line_threshold:.2f}% | {covered_lines}/{total_lines} |".format( + **summary + ) + ) + COVERAGE_ROOT.mkdir(parents=True, exist_ok=True) + aggregate = { + "schema": "oliphaunt-coverage-aggregate-v1", + "products": all_summaries, + } + (COVERAGE_ROOT / "summary.json").write_text(json.dumps(aggregate, indent=2, sort_keys=True) + "\n") + markdown = "\n".join( + [ + "| Product | Tool | Lines | Threshold | Covered |", + "| --- | --- | ---: | ---: | ---: |", + *rows, + "", + ] + ) + (COVERAGE_ROOT / "summary.md").write_text(markdown) + print(markdown) + + +def main(argv: list[str]) -> None: + parser = argparse.ArgumentParser(description="Oliphaunt coverage runner") + subparsers = parser.add_subparsers(dest="command", required=True) + run_parser = subparsers.add_parser("run-product") + run_parser.add_argument("product", choices=PRODUCTS) + check_parser = subparsers.add_parser("check-product") + check_parser.add_argument("product", choices=PRODUCTS) + summarize_parser = subparsers.add_parser("summarize") + summarize_parser.add_argument( + "--allow-missing", + action="store_true", + help="summarize only measured product reports that are present", + ) + summarize_parser.add_argument( + "--products-json", + help="JSON string array of product reports that must be present", + ) + args = parser.parse_args(argv) + if args.command == "run-product": + run_product(args.product) + elif args.command == "check-product": + summary = check_summary(args.product) + print(f"{args.product}: {summary['line_coverage']:.2f}% line coverage") + elif args.command == "summarize": + summarize(allow_missing=args.allow_missing, products_json=args.products_json) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tools/coverage/moon.yml b/tools/coverage/moon.yml new file mode 100644 index 00000000..cc64491e --- /dev/null +++ b/tools/coverage/moon.yml @@ -0,0 +1,27 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "coverage-tools" +language: "python" +layer: "tool" +stack: "infrastructure" +tags: ["tools", "coverage", "repo-hygiene"] + +project: + title: "Coverage Tools" + description: "Measured polyglot coverage runners, parsers, thresholds, and aggregate summaries." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "python3 -m py_compile tools/coverage/coverage.py" + inputs: + - "/tools/coverage/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/tools/coverage/run-product b/tools/coverage/run-product new file mode 100755 index 00000000..fbb05058 --- /dev/null +++ b/tools/coverage/run-product @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +exec "$(dirname "$0")/coverage.py" run-product "$@" diff --git a/tools/coverage/summarize b/tools/coverage/summarize new file mode 100755 index 00000000..ce71196a --- /dev/null +++ b/tools/coverage/summarize @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +exec "$(dirname "$0")/coverage.py" summarize "$@" diff --git a/scripts/bootstrap-tools.sh b/tools/dev/bootstrap-tools.sh similarity index 52% rename from scripts/bootstrap-tools.sh rename to tools/dev/bootstrap-tools.sh index 6c8fcca6..d4dcd73b 100755 --- a/scripts/bootstrap-tools.sh +++ b/tools/dev/bootstrap-tools.sh @@ -1,14 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -PREK_VERSION="${PREK_VERSION:-0.3.10}" +PREK_VERSION="${PREK_VERSION:-0.4.3}" CARGO_BINSTALL_VERSION="${CARGO_BINSTALL_VERSION:-1.19.1}" -CARGO_DENY_VERSION="${CARGO_DENY_VERSION:-0.19.4}" +CARGO_DENY_VERSION="${CARGO_DENY_VERSION:-0.19.8}" CARGO_HACK_VERSION="${CARGO_HACK_VERSION:-0.6.44}" +CARGO_NEXTEST_VERSION="${CARGO_NEXTEST_VERSION:-0.9.137}" CARGO_SEMVER_CHECKS_VERSION="${CARGO_SEMVER_CHECKS_VERSION:-0.47.0}" -ZIZMOR_VERSION="${ZIZMOR_VERSION:-1.24.1}" -ACTIONLINT_VERSION="${ACTIONLINT_VERSION:-1.7.12}" - +DPRINT_VERSION="${DPRINT_VERSION:-0.54.0}" +LYCHEE_VERSION="${LYCHEE_VERSION:-0.24.2}" +TAPLO_VERSION="${TAPLO_VERSION:-0.10.0}" +TYPOS_VERSION="${TYPOS_VERSION:-1.47.0}" +ZIZMOR_VERSION="${ZIZMOR_VERSION:-1.25.2}" +RIPGREP_VERSION="${RIPGREP_VERSION:-15.1.0}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cargo_bin_dir="${CARGO_HOME:-$HOME/.cargo}/bin" mkdir -p "$cargo_bin_dir" PATH="$cargo_bin_dir:$PATH" @@ -20,9 +26,9 @@ has_command() { installed_tool_version() { binary="$1" - case "$binary" in - cargo-hack) cargo hack --version 2>/dev/null || true ;; - cargo-semver-checks) cargo semver-checks --version 2>/dev/null || true ;; + case "$(basename "$binary")" in + cargo-hack) PATH="$(dirname "$binary"):$PATH" cargo hack --version 2>/dev/null || true ;; + cargo-semver-checks) PATH="$(dirname "$binary"):$PATH" cargo semver-checks --version 2>/dev/null || true ;; *) "$binary" --version 2>/dev/null || true ;; esac } @@ -46,8 +52,8 @@ Expected: $version Found: $output -Remove or update the existing $binary so scripts/bootstrap-tools.sh can provide -the pinned toolchain. +Re-run tools/dev/bootstrap-tools.sh so the pinned local toolchain can replace +or override the mismatched binary. MSG exit 1 fi @@ -65,31 +71,45 @@ install_cargo_tool() { package="$1" binary="$2" version="$3" - if has_command "$binary"; then - echo "$binary already installed: $(installed_pinned_tool_version "$binary" "$version")" - return + local_binary="$cargo_bin_dir/$binary" + if [ -x "$local_binary" ]; then + output="$(installed_tool_version "$local_binary")" + if version_output_matches "$output" "$version"; then + echo "$binary already installed: $output" + return + fi + printf '%s\n' "replacing $local_binary with pinned $package@$version (found: $output)" + elif has_command "$binary"; then + printf '%s\n' "installing pinned $package@$version; ignoring non-local $binary at $(command -v "$binary")" + fi + + binstall_args="--no-confirm --disable-telemetry --force --strategies crate-meta-data,quick-install" + if ! cargo binstall --help 2>/dev/null | grep -q -- '--force'; then + binstall_args="--no-confirm --disable-telemetry --strategies crate-meta-data,quick-install" fi if has_command cargo-binstall; then - if cargo binstall \ - --no-confirm \ - --disable-telemetry \ - --strategies crate-meta-data,quick-install \ - "$package@$version"; then - installed_pinned_tool_version "$binary" "$version" >/dev/null + # shellcheck disable=SC2086 + if cargo binstall $binstall_args "$package@$version"; then + installed_pinned_tool_version "$local_binary" "$version" >/dev/null return fi echo "cargo-binstall could not install $package@$version from a binary; falling back to cargo install" >&2 fi - cargo install "$package" --version "$version" --locked - installed_pinned_tool_version "$binary" "$version" >/dev/null + cargo install "$package" --version "$version" --locked --force + installed_pinned_tool_version "$local_binary" "$version" >/dev/null } install_cargo_binstall() { - if has_command cargo-binstall; then - output="$(cargo-binstall -V 2>/dev/null || true)" - require_pinned_version cargo-binstall "$CARGO_BINSTALL_VERSION" "$output" - echo "cargo-binstall already installed: $output" - return + local_binary="$cargo_bin_dir/cargo-binstall" + if [ -x "$local_binary" ]; then + output="$("$local_binary" -V 2>/dev/null || true)" + if version_output_matches "$output" "$CARGO_BINSTALL_VERSION"; then + echo "cargo-binstall already installed: $output" + return + fi + printf '%s\n' "replacing $local_binary with pinned cargo-binstall@$CARGO_BINSTALL_VERSION (found: $output)" + elif has_command cargo-binstall; then + printf '%s\n' "installing pinned cargo-binstall@$CARGO_BINSTALL_VERSION; ignoring non-local cargo-binstall at $(command -v cargo-binstall)" fi os="$(uname -s | tr '[:upper:]' '[:lower:]')" @@ -130,53 +150,25 @@ PY find "$tmp" -maxdepth 3 -type f -print >&2 return 1 fi - install "$binstall_bin" "$cargo_bin_dir/cargo-binstall" + install "$binstall_bin" "$local_binary" rm -rf "$tmp" - output="$(cargo-binstall -V 2>/dev/null || true)" + output="$("$local_binary" -V 2>/dev/null || true)" require_pinned_version cargo-binstall "$CARGO_BINSTALL_VERSION" "$output" } -install_actionlint() { - if has_command actionlint; then - output="$(actionlint -version 2>/dev/null || true)" - require_pinned_version actionlint "$ACTIONLINT_VERSION" "$output" - echo "actionlint already installed: $output" - return - fi - - os="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch="$(uname -m)" - case "$os:$arch" in - darwin:arm64) asset_os=darwin; asset_arch=arm64 ;; - darwin:x86_64) asset_os=darwin; asset_arch=amd64 ;; - linux:aarch64 | linux:arm64) asset_os=linux; asset_arch=arm64 ;; - linux:x86_64) asset_os=linux; asset_arch=amd64 ;; - *) - echo "unsupported actionlint platform: $os/$arch" >&2 - echo "install actionlint manually from https://github.com/rhysd/actionlint/releases" >&2 - return 1 - ;; - esac - - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT - archive="$tmp/actionlint.tar.gz" - curl -L --fail --retry 3 \ - --output "$archive" \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${asset_os}_${asset_arch}.tar.gz" - tar -xzf "$archive" -C "$tmp" - install "$tmp/actionlint" "$cargo_bin_dir/actionlint" - output="$(actionlint -version 2>/dev/null || true)" - require_pinned_version actionlint "$ACTIONLINT_VERSION" "$output" -} - install_cargo_binstall install_cargo_tool prek prek "$PREK_VERSION" install_cargo_tool cargo-deny cargo-deny "$CARGO_DENY_VERSION" install_cargo_tool cargo-hack cargo-hack "$CARGO_HACK_VERSION" +install_cargo_tool cargo-nextest cargo-nextest "$CARGO_NEXTEST_VERSION" install_cargo_tool cargo-semver-checks cargo-semver-checks "$CARGO_SEMVER_CHECKS_VERSION" +install_cargo_tool dprint dprint "$DPRINT_VERSION" +install_cargo_tool lychee lychee "$LYCHEE_VERSION" +install_cargo_tool taplo-cli taplo "$TAPLO_VERSION" +install_cargo_tool typos-cli typos "$TYPOS_VERSION" install_cargo_tool zizmor zizmor "$ZIZMOR_VERSION" -install_actionlint +install_cargo_tool ripgrep rg "$RIPGREP_VERSION" +"$script_dir/install-actionlint.sh" echo echo "Tool bootstrap complete. Ensure $cargo_bin_dir is on PATH." diff --git a/tools/dev/bun.sh b/tools/dev/bun.sh new file mode 100755 index 00000000..28a2f79c --- /dev/null +++ b/tools/dev/bun.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "$1" >&2 + exit 1 +} + +proto_version() { + local tool="$1" + awk -F '=' -v tool="$tool" ' + $1 ~ "^[[:space:]]*" tool "[[:space:]]*$" { + value=$2 + gsub(/^[[:space:]\"]+|[[:space:]\"]+$/, "", value) + print value + found=1 + } + END { if (!found) exit 1 } + ' .prototools +} + +version="$(proto_version bun)" +if command -v bun >/dev/null 2>&1; then + installed_version="$(bun --version 2>/dev/null || true)" + if [[ "$installed_version" == "$version" ]]; then + exec bun "$@" + fi +fi + +case "$(uname -s)" in + Darwin) + case "$(uname -m)" in + arm64|aarch64) target="darwin-aarch64" ;; + x86_64) target="darwin-x64" ;; + *) fail "unsupported Bun host architecture: $(uname -m)" ;; + esac + exe_name="bun" + ;; + Linux) + case "$(uname -m)" in + arm64|aarch64) target="linux-aarch64" ;; + x86_64) target="linux-x64" ;; + *) fail "unsupported Bun host architecture: $(uname -m)" ;; + esac + exe_name="bun" + ;; + MINGW*|MSYS*|CYGWIN*) + case "$(uname -m)" in + x86_64|AMD64) target="windows-x64" ;; + *) fail "unsupported Bun host architecture: $(uname -m)" ;; + esac + exe_name="bun.exe" + ;; + *) + fail "unsupported Bun host operating system: $(uname -s)" + ;; +esac + +asset="bun-$target.zip" +install_dir="$root/target/oliphaunt-tools/bun/v$version/$target" +bun_bin="$install_dir/$exe_name" +if [[ ! -x "$bun_bin" ]]; then + command -v curl >/dev/null 2>&1 || fail "missing required command: curl" + command -v python3 >/dev/null 2>&1 || fail "missing required command: python3" + mkdir -p "$install_dir" + archive="$install_dir/bun.zip" + url="https://github.com/oven-sh/bun/releases/download/bun-v$version/$asset" + tmp_dir="$install_dir.tmp.$$" + rm -rf "$tmp_dir" + mkdir -p "$tmp_dir" + curl --fail --location --retry 3 --retry-delay 2 --output "$archive" "$url" + extracted_bin="$(python3 - "$archive" "$tmp_dir" "$exe_name" <<'PY' +import sys +import zipfile +from pathlib import Path + +archive = Path(sys.argv[1]) +target = Path(sys.argv[2]) +exe_name = sys.argv[3] +with zipfile.ZipFile(archive) as zf: + zf.extractall(target) +matches = [path for path in target.rglob(exe_name) if path.is_file()] +if len(matches) != 1: + print(f"Bun archive must contain exactly one {exe_name}, found {len(matches)}", file=sys.stderr) + for match in matches: + print(match, file=sys.stderr) + sys.exit(1) +print(matches[0]) +PY +)" + mv "$extracted_bin" "$bun_bin" + chmod +x "$bun_bin" + rm -rf "$tmp_dir" "$archive" +fi + +exec "$bun_bin" "$@" diff --git a/tools/dev/deno.sh b/tools/dev/deno.sh new file mode 100755 index 00000000..0e21c2e8 --- /dev/null +++ b/tools/dev/deno.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "$1" >&2 + exit 1 +} + +proto_version() { + local tool="$1" + awk -F '=' -v tool="$tool" ' + $1 ~ "^[[:space:]]*" tool "[[:space:]]*$" { + value=$2 + gsub(/^[[:space:]\"]+|[[:space:]\"]+$/, "", value) + print value + found=1 + } + END { if (!found) exit 1 } + ' .prototools +} + +version="$(proto_version deno)" +if command -v deno >/dev/null 2>&1; then + installed_version="$(deno --version 2>/dev/null | awk 'NR == 1 { print $2 }')" + if [[ "$installed_version" == "$version" ]]; then + exec deno "$@" + fi +fi + +case "$(uname -s)" in + Darwin) + case "$(uname -m)" in + arm64|aarch64) target="aarch64-apple-darwin" ;; + x86_64) target="x86_64-apple-darwin" ;; + *) fail "unsupported Deno host architecture: $(uname -m)" ;; + esac + exe_name="deno" + ;; + Linux) + case "$(uname -m)" in + arm64|aarch64) target="aarch64-unknown-linux-gnu" ;; + x86_64) target="x86_64-unknown-linux-gnu" ;; + *) fail "unsupported Deno host architecture: $(uname -m)" ;; + esac + exe_name="deno" + ;; + MINGW*|MSYS*|CYGWIN*) + case "$(uname -m)" in + x86_64|AMD64) target="x86_64-pc-windows-msvc" ;; + *) fail "unsupported Deno host architecture: $(uname -m)" ;; + esac + exe_name="deno.exe" + ;; + *) + fail "unsupported Deno host operating system: $(uname -s)" + ;; +esac + +install_dir="$root/target/oliphaunt-tools/deno/v$version/$target" +deno_bin="$install_dir/$exe_name" +if [[ ! -x "$deno_bin" ]]; then + command -v curl >/dev/null 2>&1 || fail "missing required command: curl" + command -v python3 >/dev/null 2>&1 || fail "missing required command: python3" + mkdir -p "$install_dir" + url="https://github.com/denoland/deno/releases/download/v$version/deno-$target.zip" + tmp_dir="$install_dir.tmp.$$" + rm -rf "$tmp_dir" + mkdir -p "$tmp_dir" + archive="$tmp_dir/deno.zip" + curl \ + --fail \ + --location \ + --retry 8 \ + --retry-all-errors \ + --retry-delay 5 \ + --connect-timeout 20 \ + --output "$archive" \ + "$url" + python3 - "$archive" "$tmp_dir" <<'PY' +import sys +import zipfile +from pathlib import Path + +archive = Path(sys.argv[1]) +target = Path(sys.argv[2]) +with zipfile.ZipFile(archive) as zf: + zf.extractall(target) +PY + if [[ ! -f "$tmp_dir/$exe_name" ]]; then + rm -rf "$tmp_dir" + fail "Deno archive did not contain $exe_name: $url" + fi + mv "$tmp_dir/$exe_name" "$deno_bin" + chmod +x "$deno_bin" + rm -rf "$tmp_dir" +fi + +exec "$deno_bin" "$@" diff --git a/tools/dev/doctor.sh b/tools/dev/doctor.sh new file mode 100755 index 00000000..f892d63b --- /dev/null +++ b/tools/dev/doctor.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +strict=0 +if [[ "${1:-}" == "--strict" ]]; then + strict=1 +fi + +failures=0 +warnings=0 + +expected() { + local tool="$1" + awk -F '"' -v tool="$tool" '$1 ~ "^" tool " = " { print $2 }' .prototools +} + +check_command() { + local command="$1" + local message="$2" + if ! command -v "$command" >/dev/null 2>&1; then + echo "missing $command: $message" >&2 + failures=$((failures + 1)) + return 1 + fi +} + +check_version() { + local command="$1" + local expected_version="$2" + local actual="$3" + local severity="${4:-error}" + if [[ "$actual" != *"$expected_version"* ]]; then + echo "$command version mismatch: expected $expected_version, got $actual" >&2 + if [[ "$severity" == "warning" ]]; then + warnings=$((warnings + 1)) + else + failures=$((failures + 1)) + fi + else + echo "$command ok: $actual" + fi +} + +proto_version="$(awk -F '"' '/^[[:space:]]+version: / { print $2; exit }' .moon/toolchains.yml)" +moon_version="$(expected moon)" +node_version="$(expected node)" +pnpm_version="$(expected pnpm)" +bun_version="$(expected bun)" +deno_version="$(expected deno)" +proto_bin="$(command -v proto 2>/dev/null || true)" +if [[ -z "$proto_bin" && -x "$HOME/.proto/bin/proto" ]]; then + proto_bin="$HOME/.proto/bin/proto" +fi + +check_command git "required for workspace root and affected checks" || true +check_command cargo "install Rust from rustup; CI uses Rust 1.93" || true +check_command pnpm "run corepack enable && corepack prepare pnpm@$pnpm_version --activate" || true +check_command node "install Node $node_version through proto, mise, or another pinned runtime manager" || true +if [[ ! -x tools/dev/bun.sh ]]; then + echo "missing tools/dev/bun.sh: TypeScript SDK checks need the pinned Bun launcher" >&2 + failures=$((failures + 1)) +fi + +if command -v pnpm >/dev/null 2>&1; then + check_version pnpm "$pnpm_version" "$(pnpm --version 2>/dev/null || true)" +fi +if command -v bun >/dev/null 2>&1; then + check_version bun "$bun_version" "$(bun --version 2>/dev/null || true)" warning +else + echo "missing optional bun: TypeScript package checks will use tools/dev/bun.sh to download pinned Bun $bun_version on demand" >&2 +fi +if command -v node >/dev/null 2>&1; then + proto_node="$HOME/.proto/tools/node/$node_version/bin/node" + if [[ -x "$proto_node" ]]; then + echo "node ok: $node_version via proto toolchain" + shell_node="$(node --version 2>/dev/null | sed 's/^v//')" + if [[ "$shell_node" != "$node_version" ]]; then + echo "node shell version differs from pinned toolchain: shell $shell_node, proto $node_version" + fi + else + check_version node "$node_version" "$(node --version 2>/dev/null | sed 's/^v//')" warning + fi +fi + +if [[ -n "$proto_bin" ]]; then + check_version proto "$proto_version" "$("$proto_bin" --version 2>/dev/null || true)" +else + echo "proto is not on PATH; moon will manage proto $proto_version through its pinned setup" +fi + +if command -v moon >/dev/null 2>&1; then + check_version moon "$moon_version" "$(moon --version 2>/dev/null || true)" +else + echo "missing moon: install the pinned toolchain with 'proto install moon' after adding ~/.proto/bin to PATH" >&2 + failures=$((failures + 1)) +fi + +if command -v deno >/dev/null 2>&1; then + check_version deno "$deno_version" "$(deno --version 2>/dev/null | head -n 1)" warning +else + echo "missing optional deno: TypeScript package checks will use tools/dev/deno.sh to download pinned Deno $deno_version on demand" >&2 + [[ "$strict" -eq 0 ]] || failures=$((failures + 1)) +fi + +for optional in \ + actionlint \ + autoconf \ + aclocal \ + cargo-deny \ + cargo-hack \ + cargo-nextest \ + cargo-semver-checks \ + ccache \ + dprint \ + glibtoolize \ + lychee \ + prek \ + rg \ + shellcheck \ + shfmt \ + swift-format \ + swiftlint \ + taplo \ + typos \ + zizmor +do + if command -v "$optional" >/dev/null 2>&1; then + echo "$optional ok: $(command -v "$optional")" + else + echo "missing optional $optional: run tools/dev/bootstrap-tools.sh for maintainer gates" >&2 + [[ "$strict" -eq 0 ]] || failures=$((failures + 1)) + fi +done + +if [[ "$failures" -ne 0 ]]; then + echo "doctor found $failures tooling issue(s)" >&2 + exit 1 +fi + +if [[ "$warnings" -ne 0 ]]; then + echo "doctor completed with $warnings advisory warning(s)" >&2 +fi + +echo "doctor passed" diff --git a/tools/dev/install-actionlint.sh b/tools/dev/install-actionlint.sh new file mode 100755 index 00000000..165e1682 --- /dev/null +++ b/tools/dev/install-actionlint.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTIONLINT_VERSION="${ACTIONLINT_VERSION:-1.7.12}" + +cargo_bin_dir="${CARGO_HOME:-$HOME/.cargo}/bin" +mkdir -p "$cargo_bin_dir" +PATH="$cargo_bin_dir:$PATH" +export PATH + +version_output_matches() { + output="$1" + version="$2" + escaped_version="$(printf '%s' "$version" | sed 's/[][\\.^$*+?{}|()]/\\&/g')" + printf '%s\n' "$output" | grep -Eq "(^|[^0-9.])${escaped_version}([^0-9.]|$)" +} + +require_pinned_version() { + binary="$1" + version="$2" + output="$3" + if ! version_output_matches "$output" "$version"; then + cat >&2 </dev/null || true)" + if version_output_matches "$output" "$ACTIONLINT_VERSION"; then + echo "actionlint already installed: $output" + exit 0 + fi + printf '%s\n' "replacing $local_binary with pinned actionlint@$ACTIONLINT_VERSION (found: $output)" +elif command -v actionlint >/dev/null 2>&1; then + printf '%s\n' "installing pinned actionlint@$ACTIONLINT_VERSION; ignoring non-local actionlint at $(command -v actionlint)" +fi + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" +case "$os:$arch" in + darwin:arm64) asset_os=darwin; asset_arch=arm64 ;; + darwin:x86_64) asset_os=darwin; asset_arch=amd64 ;; + linux:aarch64 | linux:arm64) asset_os=linux; asset_arch=arm64 ;; + linux:x86_64) asset_os=linux; asset_arch=amd64 ;; + *) + echo "unsupported actionlint platform: $os/$arch" >&2 + echo "install actionlint manually from https://github.com/rhysd/actionlint/releases" >&2 + exit 1 + ;; +esac + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +archive="$tmp/actionlint.tar.gz" +if curl -L --fail --retry 8 --retry-all-errors --retry-delay 5 --connect-timeout 20 \ + --output "$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${asset_os}_${asset_arch}.tar.gz"; then + tar -xzf "$archive" -C "$tmp" + install "$tmp/actionlint" "$local_binary" +elif command -v go >/dev/null 2>&1; then + printf '%s\n' "actionlint release asset download failed; falling back to go install" + mkdir -p "$tmp/gobin" + GOBIN="$tmp/gobin" go install "github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}" + install "$tmp/gobin/actionlint" "$local_binary" +else + echo "failed to download actionlint and Go is not available for source fallback" >&2 + exit 1 +fi +output="$("$local_binary" -version 2>/dev/null || true)" +require_pinned_version actionlint "$ACTIONLINT_VERSION" "$output" diff --git a/scripts/install-hooks.sh b/tools/dev/install-hooks.sh similarity index 100% rename from scripts/install-hooks.sh rename to tools/dev/install-hooks.sh diff --git a/tools/dev/moon.yml b/tools/dev/moon.yml new file mode 100644 index 00000000..c2323ee3 --- /dev/null +++ b/tools/dev/moon.yml @@ -0,0 +1,47 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "dev-tools" +language: "bash" +layer: "tool" +stack: "infrastructure" +tags: ["tools", "developer-experience"] + +project: + title: "Developer Tools" + description: "Local bootstrap, doctor, hook installation, and smoke helpers." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + doctor: + tags: ["diagnostics", "developer-experience"] + command: "bash tools/dev/doctor.sh" + inputs: + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/.prototools" + - "/package.json" + - "/pnpm-lock.yaml" + - "/tools/dev/**/*" + options: + cache: false + runFromWorkspaceRoot: true + check: + tags: ["quality", "static"] + command: "bash tools/policy/check-tooling-stack.sh" + inputs: + - "/.github/actions/**/*" + - "/.github/workflows/**/*" + - "/.moon/*.{yml,yaml,jsonc,json,pkl,hcl,toml}" + - "/docs/maintainers/tooling.md" + - "/package.json" + - "/pnpm-lock.yaml" + - "/tools/dev/**/*" + - "/tools/policy/check-tooling-stack.sh" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/tools/dev/setup-android-sdk.sh b/tools/dev/setup-android-sdk.sh new file mode 100755 index 00000000..ab6d5e1c --- /dev/null +++ b/tools/dev/setup-android-sdk.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "setup-android-sdk.sh: $*" >&2 + exit 1 +} + +require() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +usage() { + cat <<'EOF' +usage: tools/dev/setup-android-sdk.sh [options] + +Provision a minimal Android SDK for Oliphaunt Android builder jobs. + +Options: + --sdk-root Android SDK root. Defaults to ANDROID_HOME, + ANDROID_SDK_ROOT, or $HOME/android-sdk. + --ndk-version Android NDK side-by-side version. + --cmake-version Android CMake package version. + --compile-sdk Android platform API level. + --cmdline-tools-version Android command-line tools build id. + --cmdline-tools-url Override command-line tools zip URL. + --cmdline-tools-sha1 Override command-line tools SHA-1 checksum. + --cmdline-tools-sha256 Optional command-line tools SHA-256 checksum. + -h, --help Show this help. +EOF +} + +sdk_root="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/android-sdk}}" +ndk_version="${ANDROID_NDK_VERSION:-27.0.12077973}" +cmake_version="${ANDROID_CMAKE_VERSION:-3.22.1}" +compile_sdk="${ANDROID_COMPILE_SDK:-36}" +cmdline_tools_version="${ANDROID_CMDLINE_TOOLS_VERSION:-14742923}" +cmdline_tools_url="${ANDROID_CMDLINE_TOOLS_URL:-}" +cmdline_tools_sha1="${ANDROID_CMDLINE_TOOLS_SHA1:-}" +cmdline_tools_sha256="${ANDROID_CMDLINE_TOOLS_SHA256:-}" +sdkmanager_install_attempts="${ANDROID_SDKMANAGER_INSTALL_ATTEMPTS:-4}" +sdkmanager_retry_delay="${ANDROID_SDKMANAGER_RETRY_DELAY:-5}" +tmp_dir="" +trap '[ -z "${tmp_dir:-}" ] || rm -rf "$tmp_dir"' EXIT + +while [ "$#" -gt 0 ]; do + case "$1" in + --sdk-root) + [ "$#" -ge 2 ] || fail "--sdk-root requires a value" + sdk_root="$2" + shift 2 + ;; + --ndk-version) + [ "$#" -ge 2 ] || fail "--ndk-version requires a value" + ndk_version="$2" + shift 2 + ;; + --cmake-version) + [ "$#" -ge 2 ] || fail "--cmake-version requires a value" + cmake_version="$2" + shift 2 + ;; + --compile-sdk) + [ "$#" -ge 2 ] || fail "--compile-sdk requires a value" + compile_sdk="$2" + shift 2 + ;; + --cmdline-tools-version) + [ "$#" -ge 2 ] || fail "--cmdline-tools-version requires a value" + cmdline_tools_version="$2" + shift 2 + ;; + --cmdline-tools-url) + [ "$#" -ge 2 ] || fail "--cmdline-tools-url requires a value" + cmdline_tools_url="$2" + shift 2 + ;; + --cmdline-tools-sha1) + [ "$#" -ge 2 ] || fail "--cmdline-tools-sha1 requires a value" + cmdline_tools_sha1="$2" + shift 2 + ;; + --cmdline-tools-sha256) + [ "$#" -ge 2 ] || fail "--cmdline-tools-sha256 requires a value" + cmdline_tools_sha256="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +[ -n "$sdk_root" ] || fail "Android SDK root is empty" +[ -n "$ndk_version" ] || fail "Android NDK version is empty" +[ -n "$cmake_version" ] || fail "Android CMake version is empty" +[ -n "$compile_sdk" ] || fail "Android compile SDK is empty" + +case "$compile_sdk" in + *[!0-9]* | "") fail "compile SDK must be a numeric API level, got: $compile_sdk" ;; +esac +case "$sdkmanager_install_attempts" in + *[!0-9]* | "") fail "ANDROID_SDKMANAGER_INSTALL_ATTEMPTS must be a positive integer, got: $sdkmanager_install_attempts" ;; +esac +case "$sdkmanager_retry_delay" in + *[!0-9]* | "") fail "ANDROID_SDKMANAGER_RETRY_DELAY must be a non-negative integer, got: $sdkmanager_retry_delay" ;; +esac +[ "$sdkmanager_install_attempts" -ge 1 ] || + fail "ANDROID_SDKMANAGER_INSTALL_ATTEMPTS must be at least 1" + +os_name="$(uname -s)" +case "$os_name" in + Linux) + host_tag="linux" + default_cmdline_tools_sha1="48833c34b761c10cb20bcd16582129395d121b27" + ;; + Darwin) + host_tag="mac" + default_cmdline_tools_sha1="cc27cca4b84bfdbc7df17e3d0a01d0c640d8ee71" + ;; + *) + fail "unsupported host OS for Android SDK bootstrap: $os_name" + ;; +esac + +if [ -z "$cmdline_tools_url" ]; then + cmdline_tools_url="https://dl.google.com/android/repository/commandlinetools-${host_tag}-${cmdline_tools_version}_latest.zip" +fi +cmdline_tools_urls="$cmdline_tools_url" +if [ "${ANDROID_CMDLINE_TOOLS_URL:-}" = "" ]; then + cmdline_tools_urls="$cmdline_tools_urls https://edgedl.me.gvt1.com/edgedl/android/repository/commandlinetools-${host_tag}-${cmdline_tools_version}_latest.zip" +fi +if [ -z "$cmdline_tools_sha1" ]; then + cmdline_tools_sha1="$default_cmdline_tools_sha1" +fi + +hash_file() { + local algorithm="$1" + local path="$2" + case "$algorithm" in + sha1) + if command -v sha1sum >/dev/null 2>&1; then + sha1sum "$path" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 1 "$path" | awk '{ print $1 }' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha1 "$path" | awk '{ print $NF }' + else + fail "cannot verify SHA-1 checksum; install sha1sum, shasum, or openssl" + fi + ;; + sha256) + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{ print $1 }' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | awk '{ print $NF }' + else + fail "cannot verify SHA-256 checksum; install sha256sum, shasum, or openssl" + fi + ;; + *) fail "unsupported checksum algorithm: $algorithm" ;; + esac +} + +verify_checksum() { + local algorithm="$1" + local expected="$2" + local path="$3" + [ -n "$expected" ] || return 0 + local actual + actual="$(hash_file "$algorithm" "$path")" + if [ "$actual" != "$expected" ]; then + fail "Android command-line tools $algorithm mismatch for $path: expected $expected, got $actual" + fi +} + +install_cmdline_tools() { + require curl + require unzip + + local archive extracted_tools url downloaded + tmp_dir="$(mktemp -d)" + archive="$tmp_dir/commandline-tools.zip" + + downloaded=0 + for url in $cmdline_tools_urls; do + echo "Downloading Android command-line tools: $url" + if curl -L --fail --retry 3 --retry-delay 2 --output "$archive" "$url"; then + downloaded=1 + break + fi + done + [ "$downloaded" -eq 1 ] || fail "could not download Android command-line tools from configured URLs" + + verify_checksum sha1 "$cmdline_tools_sha1" "$archive" + verify_checksum sha256 "$cmdline_tools_sha256" "$archive" + + unzip -q "$archive" -d "$tmp_dir/unpacked" + extracted_tools="$tmp_dir/unpacked/cmdline-tools" + [ -d "$extracted_tools/bin" ] || fail "Android command-line tools archive did not contain cmdline-tools/bin" + + mkdir -p "$sdk_root/cmdline-tools" + rm -rf "$sdk_root/cmdline-tools/latest" + mkdir -p "$sdk_root/cmdline-tools/latest" + cp -R "$extracted_tools"/. "$sdk_root/cmdline-tools/latest/" +} + +cleanup_partial_sdk_packages() { + rm -rf \ + "$sdk_root/.temp" \ + "$sdk_root/build-tools/${compile_sdk}.0.0" \ + "$sdk_root/cmake/$cmake_version" \ + "$sdk_root/ndk/$ndk_version" \ + "$sdk_root/platforms/android-${compile_sdk}" +} + +install_sdk_packages() { + local attempt + attempt=1 + while [ "$attempt" -le "$sdkmanager_install_attempts" ]; do + echo "Installing Android SDK packages into $sdk_root (attempt $attempt/$sdkmanager_install_attempts)" + if "$sdkmanager_bin" --sdk_root="$sdk_root" --install \ + "platform-tools" \ + "platforms;android-${compile_sdk}" \ + "build-tools;${compile_sdk}.0.0" \ + "cmake;${cmake_version}" \ + "ndk;${ndk_version}"; then + return 0 + fi + if [ "$attempt" -eq "$sdkmanager_install_attempts" ]; then + break + fi + echo "Android SDK package install failed; removing partial packages before retry" >&2 + cleanup_partial_sdk_packages + sleep "$sdkmanager_retry_delay" + attempt=$((attempt + 1)) + done + fail "could not install Android SDK packages after $sdkmanager_install_attempts attempts" +} + +mkdir -p "$sdk_root" +sdkmanager_bin="$sdk_root/cmdline-tools/latest/bin/sdkmanager" +if [ ! -x "$sdkmanager_bin" ]; then + install_cmdline_tools +fi +[ -x "$sdkmanager_bin" ] || fail "sdkmanager is not executable at $sdkmanager_bin" + +require java +mkdir -p "$HOME/.android" +touch "$HOME/.android/repositories.cfg" + +echo "Accepting Android SDK licenses" +yes | "$sdkmanager_bin" --sdk_root="$sdk_root" --licenses >/dev/null || true + +install_sdk_packages + +[ -d "$sdk_root/ndk/$ndk_version" ] || + fail "Android NDK $ndk_version was not installed under $sdk_root/ndk" +[ -d "$sdk_root/cmake/$cmake_version" ] || + fail "Android CMake $cmake_version was not installed under $sdk_root/cmake" +[ -x "$sdk_root/platform-tools/adb" ] || + fail "Android platform-tools adb was not installed under $sdk_root/platform-tools" + +echo "ANDROID_HOME=$sdk_root" +echo "ANDROID_NDK_HOME=$sdk_root/ndk/$ndk_version" diff --git a/tools/dev/setup-maestro.sh b/tools/dev/setup-maestro.sh new file mode 100755 index 00000000..03f9ba35 --- /dev/null +++ b/tools/dev/setup-maestro.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "missing required command: $1" >&2 + exit 1 + } +} + +need_cmd curl +need_cmd java + +export MAESTRO_CLI_NO_ANALYTICS=true +export MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED=true +maestro_bin="$HOME/.maestro/bin/maestro" + +version="$(sed -n 's/^[[:space:]]*maestro[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' src/sources/toolchains/maestro.toml | head -n 1)" +[ -n "$version" ] || { + echo "missing maestro version in src/sources/toolchains/maestro.toml" >&2 + exit 1 +} + +if command -v maestro >/dev/null 2>&1 || [ -x "$maestro_bin" ]; then + if command -v maestro >/dev/null 2>&1; then + current="$(maestro --version 2>/dev/null | awk '{print $NF}' | sed 's/^cli-//')" + else + current="$("$maestro_bin" --version 2>/dev/null | awk '{print $NF}' | sed 's/^cli-//')" + fi + if [ "$current" = "$version" ] || [ "cli-$current" = "$version" ]; then + if command -v maestro >/dev/null 2>&1; then + maestro --version + else + "$maestro_bin" --version + fi + if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "$HOME/.maestro/bin" >>"$GITHUB_PATH" + fi + exit 0 + fi +fi + +export MAESTRO_VERSION="$version" +curl -fsSL "https://get.maestro.mobile.dev" | bash + +[ -x "$maestro_bin" ] || { + echo "maestro install did not produce $maestro_bin" >&2 + exit 1 +} + +if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "$HOME/.maestro/bin" >>"$GITHUB_PATH" +fi + +"$maestro_bin" --version diff --git a/tools/dev/start-android-emulator-ci.sh b/tools/dev/start-android-emulator-ci.sh new file mode 100755 index 00000000..05c03c05 --- /dev/null +++ b/tools/dev/start-android-emulator-ci.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + echo "error: $*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +[ -n "${ANDROID_HOME:-}" ] || fail "ANDROID_HOME is not set" + +api="${OLIPHAUNT_ANDROID_EMULATOR_API:-${OLIPHAUNT_ANDROID_COMPILE_SDK:-36}}" +name="${OLIPHAUNT_ANDROID_EMULATOR_NAME:-oliphaunt-ci}" +target="${OLIPHAUNT_ANDROID_EMULATOR_TARGET:-google_atd}" +timeout_seconds="${OLIPHAUNT_ANDROID_EMULATOR_TIMEOUT_SECONDS:-900}" +host_arch="$(uname -m)" +case "$host_arch" in + arm64|aarch64) abi="arm64-v8a" ;; + x86_64|amd64) abi="x86_64" ;; + *) fail "unsupported Android emulator host architecture: $host_arch" ;; +esac +image="${OLIPHAUNT_ANDROID_EMULATOR_IMAGE:-system-images;android-${api};${target};${abi}}" + +export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" +need_cmd sdkmanager +need_cmd avdmanager +need_cmd adb +need_cmd emulator + +yes | sdkmanager --licenses >/dev/null || true +sdkmanager --install "emulator" "$image" + +if ! emulator -list-avds | grep -Fxq "$name"; then + echo "no" | avdmanager create avd --force --name "$name" --package "$image" --device "pixel_5" +fi + +adb kill-server >/dev/null 2>&1 || true +adb start-server >/dev/null + +log_dir="${RUNNER_TEMP:-/tmp}" +mkdir -p "$log_dir" +log_file="$log_dir/oliphaunt-android-emulator.log" +echo "Starting Android emulator $name with image $image and ${timeout_seconds}s boot timeout" +emulator -avd "$name" \ + -no-window \ + -no-audio \ + -no-boot-anim \ + -no-snapshot \ + -no-snapshot-load \ + -no-snapshot-save \ + -wipe-data \ + -no-metrics \ + -accel on \ + -gpu swiftshader_indirect \ + >"$log_file" 2>&1 & +emulator_pid="$!" + +cleanup_on_error() { + status=$? + if [ "$status" -ne 0 ]; then + tail -200 "$log_file" >&2 || true + kill "$emulator_pid" >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup_on_error EXIT + +booted=0 +deadline=$((SECONDS + timeout_seconds)) +while [ "$SECONDS" -lt "$deadline" ]; do + if ! kill -0 "$emulator_pid" >/dev/null 2>&1; then + tail -200 "$log_file" >&2 || true + fail "Android emulator process exited before boot completed" + fi + if ! adb devices | grep -Eq 'device$'; then + sleep 2 + continue + fi + boot_completed="$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)" + if [ "$boot_completed" = "1" ]; then + booted=1 + break + fi + sleep 2 +done +[ "$booted" = "1" ] || fail "Android emulator did not finish booting within ${timeout_seconds}s" + +adb shell input keyevent 82 >/dev/null 2>&1 || true +adb shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true +adb shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true +adb shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "abi=$abi" + echo "avd=$name" + } >>"$GITHUB_OUTPUT" +fi + +echo "Android emulator $name is ready for ABI $abi" diff --git a/tools/graph/affected.py b/tools/graph/affected.py new file mode 100755 index 00000000..f72b618d --- /dev/null +++ b/tools/graph/affected.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Shared Moon affectedness helpers for local and GitHub CI planners.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def moon_bin() -> str: + if configured := os.environ.get("MOON_BIN"): + return configured + proto_moon = Path.home() / ".proto" / "bin" / "moon" + return str(proto_moon) if proto_moon.exists() else "moon" + + +def moon(args: list[str]) -> dict[str, object]: + command = [moon_bin(), *args] + env = dict(os.environ) + output = subprocess.check_output(command, cwd=ROOT, env=env, text=True) + return json.loads(output) + + +def names(value: object) -> set[str]: + if isinstance(value, dict): + return {str(key) for key in value} + if isinstance(value, list): + result: set[str] = set() + for item in value: + if isinstance(item, str): + result.add(item) + elif isinstance(item, dict): + identifier = item.get("id") or item.get("target") + if identifier: + result.add(str(identifier)) + return result + return set() + + +def affected_projects_and_tasks() -> tuple[set[str], set[str], set[str]]: + direct = moon(["query", "affected", "--upstream", "none", "--downstream", "none"]) + downstream = moon(["query", "affected", "--upstream", "none", "--downstream", "deep"]) + direct_projects = names(direct.get("projects")) + projects = names(downstream.get("projects")) + tasks = names(downstream.get("tasks")) + return direct_projects, projects, tasks + + +def project_task_targets(projects: set[str], task_name: str) -> list[str]: + queried = moon(["query", "tasks"]) + tasks_by_project = queried.get("tasks") + if not isinstance(tasks_by_project, dict): + raise RuntimeError("moon query tasks did not return a tasks object") + + targets: list[str] = [] + for project in sorted(projects): + project_tasks = tasks_by_project.get(project) + if isinstance(project_tasks, dict) and task_name in project_tasks: + targets.append(f"{project}:{task_name}") + return targets diff --git a/tools/graph/cache-witness.py b/tools/graph/cache-witness.py new file mode 100755 index 00000000..6101c852 --- /dev/null +++ b/tools/graph/cache-witness.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Exercise Moon's local output cache with a deterministic tiny fixture.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import uuid +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +WITNESS_ROOT = ROOT / "target" / "graph" / "cache-witness" +INPUT = WITNESS_ROOT / "input.txt" +OUTPUT = WITNESS_ROOT / "output.txt" +RUNS = WITNESS_ROOT / "runs.txt" + + +def fail(message: str) -> None: + raise SystemExit(f"cache-witness.py: {message}") + + +def read_text(path: Path) -> str: + if not path.is_file(): + fail(f"missing expected file: {path.relative_to(ROOT)}") + return path.read_text(encoding="utf-8") + + +def fixture() -> int: + value = read_text(INPUT).strip() + WITNESS_ROOT.mkdir(parents=True, exist_ok=True) + runs = 0 + if RUNS.is_file(): + runs = int(RUNS.read_text(encoding="utf-8").strip()) + runs += 1 + RUNS.write_text(f"{runs}\n", encoding="utf-8") + OUTPUT.write_text(f"moon-cache-witness:{value}\n", encoding="utf-8") + return 0 + + +def run_moon_fixture() -> str: + completed = subprocess.run( + ["moon", "run", "graph-tools:cache-witness-fixture"], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + return completed.stdout + + +def assert_cache() -> int: + WITNESS_ROOT.mkdir(parents=True, exist_ok=True) + token = uuid.uuid4().hex + INPUT.write_text(f"{token}\n", encoding="utf-8") + for path in (OUTPUT, RUNS): + path.unlink(missing_ok=True) + + first_log = run_moon_fixture() + expected = f"moon-cache-witness:{token}\n" + if read_text(OUTPUT) != expected: + fail("first run did not write the expected fixture output") + if read_text(RUNS) != "1\n": + fail("first run did not execute the fixture exactly once") + + OUTPUT.unlink() + second_log = run_moon_fixture() + if read_text(OUTPUT) != expected: + fail("second run did not restore the expected fixture output") + if read_text(RUNS) != "1\n": + fail( + "Moon reran the fixture instead of hydrating the declared output from cache " + "(runs counter changed)" + ) + + print("Moon cache witness passed") + print("first run:") + print(first_log.rstrip()) + print("second run:") + print(second_log.rstrip()) + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("fixture") + subparsers.add_parser("assert") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.command == "fixture": + return fixture() + if args.command == "assert": + return assert_cache() + fail(f"unsupported command {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/graph/ci_plan.py b/tools/graph/ci_plan.py new file mode 100644 index 00000000..52a90789 --- /dev/null +++ b/tools/graph/ci_plan.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python3 +"""Map Moon affected tasks onto stable GitHub Actions jobs. + +Moon is the only project/task graph. Stable GitHub job names are selected from +Moon task tags named ``ci-``. GitHub Actions still owns platform matrix +fan-out because runner OS, native target triples, and simulator/device targets +are CI execution details, not source projects. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools" / "release")) + +import artifact_target_matrix # noqa: E402 +from affected import affected_projects_and_tasks # noqa: E402 + + +BASE_JOBS = {"affected"} +ALWAYS_JOBS = set(BASE_JOBS) +BUILDER_JOBS = { + "broker-runtime", + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "js-sdk-package", + "kotlin-sdk-package", + "liboliphaunt-native-android", + "liboliphaunt-native-desktop", + "liboliphaunt-native-ios", + "liboliphaunt-native-release-assets", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", + "liboliphaunt-wasix-runtime", + "mobile-build-android", + "mobile-build-ios", + "mobile-extension-packages", + "node-direct", + "react-native-sdk-package", + "rust-sdk-package", + "swift-sdk-package", + "wasix-rust-package", +} +NATIVE_RUNTIME_JOBS = { + "liboliphaunt-native-android", + "liboliphaunt-native-desktop", + "liboliphaunt-native-ios", +} +NATIVE_RUNTIME_TASKS = { + "liboliphaunt-native:release-runtime", + "liboliphaunt-native:release-runtime-desktop", + "liboliphaunt-native:release-runtime-mobile-target", +} +WASM_RUNTIME_JOBS = { + "liboliphaunt-wasix-runtime", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", +} +AGGREGATE_ARTIFACT_JOBS = {"liboliphaunt-native-release-assets"} +WASM_RUNTIME_PORTABLE_TASK = "liboliphaunt-wasix:runtime-portable" +WASM_RUNTIME_AOT_TASK = "liboliphaunt-wasix:runtime-aot" +MOBILE_JOB_SURFACES = { + "mobile-build-android": "react-native-android", + "mobile-build-ios": "react-native-ios", +} +ANDROID_MOBILE_JOBS = {"mobile-build-android"} +IOS_MOBILE_JOBS = {"mobile-build-ios"} +EXTENSION_ARTIFACT_CONSUMER_JOBS = { + "extension-packages", + "mobile-extension-packages", +} +WASIX_EXTENSION_ARTIFACT_PORTABLE_CONSUMER_JOBS = { + "extension-packages", + "extension-artifacts-wasix", +} +MOBILE_SMOKE_EXTENSION_PRODUCTS = {"oliphaunt-extension-vector"} + + +def moon_bin() -> str: + if configured := os.environ.get("MOON_BIN"): + return configured + for candidate in ( + Path.home() / ".proto" / "shims" / "moon", + Path.home() / ".proto" / "bin" / "moon", + ): + if candidate.exists(): + return str(candidate) + return "moon" + + +def moon(args: list[str]) -> dict[str, object]: + output = subprocess.check_output([moon_bin(), *args], cwd=ROOT, text=True) + return json.loads(output) + + +def moon_ci_job_targets() -> dict[str, list[str]]: + queried = moon(["query", "tasks"]) + tasks_by_project = queried.get("tasks") + if not isinstance(tasks_by_project, dict): + raise RuntimeError("moon query tasks did not return a tasks object") + + jobs: dict[str, set[str]] = {} + for project_id, project_tasks in tasks_by_project.items(): + if not isinstance(project_tasks, dict): + continue + for task_id, task in project_tasks.items(): + if not isinstance(task, dict): + continue + target = task.get("target") or f"{project_id}:{task_id}" + tags = task.get("tags", []) + if not isinstance(tags, list): + continue + for tag in tags: + if isinstance(tag, str) and tag.startswith("ci-"): + job = tag.removeprefix("ci-") + jobs.setdefault(job, set()).add(str(target)) + return {job: sorted(targets) for job, targets in sorted(jobs.items())} + + +CI_JOB_TARGETS: dict[str, list[str]] = moon_ci_job_targets() +ALL_BUILDER_JOBS = (set(BUILDER_JOBS) | WASM_RUNTIME_JOBS | AGGREGATE_ARTIFACT_JOBS) - ALWAYS_JOBS +COVERAGE_JOB_PRODUCTS = { + job: targets[0].split(":", 1)[0] + for job, targets in CI_JOB_TARGETS.items() + if any(target.endswith(":coverage") for target in targets) +} +CI_JOBS_CONFIG = { + "always_jobs": sorted(ALWAYS_JOBS), + "ci_job_targets": CI_JOB_TARGETS, + "coverage_job_products": COVERAGE_JOB_PRODUCTS, + "wasm_runtime_jobs": sorted(WASM_RUNTIME_JOBS), +} + + +def job_targets_for_jobs(jobs: set[str]) -> dict[str, list[str]]: + return { + job: CI_JOB_TARGETS[job] + for job in sorted(jobs) + if job in CI_JOB_TARGETS + } + + +def empty_matrix() -> dict[str, list[dict[str, str]]]: + return {"include": []} + + +def jobs_for_targets(targets: set[str], *, allowed_jobs: set[str] | None = None) -> set[str]: + jobs: set[str] = set() + target_set = set(targets) + for job, job_targets in CI_JOB_TARGETS.items(): + if allowed_jobs is not None and job not in allowed_jobs: + continue + if target_set & set(job_targets): + jobs.add(job) + return jobs + + +def add_implied_jobs(jobs: set[str], tasks: set[str]) -> None: + if jobs & { + "liboliphaunt-wasix-runtime", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", + } or {WASM_RUNTIME_PORTABLE_TASK, WASM_RUNTIME_AOT_TASK} & tasks: + jobs.update(WASM_RUNTIME_JOBS) + + if jobs & set(MOBILE_JOB_SURFACES): + jobs.add("mobile-extension-packages") + jobs.add("react-native-sdk-package") + + if jobs & ANDROID_MOBILE_JOBS: + jobs.add("liboliphaunt-native-android") + jobs.add("kotlin-sdk-package") + + if jobs & IOS_MOBILE_JOBS: + jobs.add("liboliphaunt-native-ios") + jobs.add("swift-sdk-package") + + if "swift-sdk-package" in jobs: + jobs.add("liboliphaunt-native-ios") + + if "liboliphaunt-native-release-assets" in jobs: + jobs.update(NATIVE_RUNTIME_JOBS) + + if jobs & EXTENSION_ARTIFACT_CONSUMER_JOBS: + jobs.add("extension-artifacts-native") + + if jobs & WASIX_EXTENSION_ARTIFACT_PORTABLE_CONSUMER_JOBS: + jobs.add("extension-artifacts-wasix") + jobs.add("liboliphaunt-wasix-runtime") + + +def plan_jobs_for_affected( + direct_projects: set[str], + tasks: set[str], +) -> set[str]: + jobs = set(ALWAYS_JOBS) + jobs.update(jobs_for_targets(tasks, allowed_jobs=ALL_BUILDER_JOBS)) + if "react-native-sdk-package" in jobs: + jobs.update(ANDROID_MOBILE_JOBS) + jobs.update(IOS_MOBILE_JOBS) + if "ci-workflows" in direct_projects: + jobs.update(ALL_BUILDER_JOBS) + add_implied_jobs(jobs, tasks) + if tasks & NATIVE_RUNTIME_TASKS: + jobs.add("liboliphaunt-native-release-assets") + jobs.update(NATIVE_RUNTIME_JOBS) + return jobs + + +def native_target_subset_for_jobs(jobs: set[str], tasks: set[str]) -> set[str] | None: + if not (jobs & NATIVE_RUNTIME_JOBS): + return None + if "liboliphaunt-native-release-assets" in jobs: + return None + if tasks & NATIVE_RUNTIME_TASKS: + return None + + targets = mobile_native_targets_for_jobs(jobs) + if "swift-sdk-package" in jobs: + targets.add("ios-xcframework") + if "kotlin-sdk-package" in jobs: + targets.update(artifact_target_matrix.liboliphaunt_native_runtime_targets_for_surface("maven")) + return targets or None + + +def mobile_native_targets_for_jobs(jobs: set[str]) -> set[str]: + targets: set[str] = set() + for job, surface in MOBILE_JOB_SURFACES.items(): + if job in jobs: + targets.update(artifact_target_matrix.liboliphaunt_native_runtime_targets_for_surface(surface)) + return targets + + +def mobile_extension_package_native_targets(jobs: set[str], selected_targets: set[str] | None) -> list[str]: + if "mobile-extension-packages" not in jobs: + return [] + if selected_targets is not None: + return sorted(selected_targets) + return sorted(mobile_native_targets_for_jobs(jobs)) + + +def focused_mobile_native_targets( + mobile_target: str, + native_target: str, + focused_mobile_jobs: set[str], +) -> set[str]: + targets = mobile_native_targets_for_jobs(focused_mobile_jobs) + if native_target == "all": + return targets + if mobile_target == "both": + raise RuntimeError("focused mobile_target=both requires native_target=all") + if native_target not in targets: + valid_targets = ", ".join(sorted(targets)) + raise RuntimeError( + f"native_target={native_target} is not valid for mobile_target={mobile_target}; " + f"expected one of: all, {valid_targets}" + ) + return {native_target} + + +def plan_for_pull_request() -> tuple[set[str], set[str], set[str], str, set[str] | None]: + base = os.environ.get("MOON_BASE") + head = os.environ.get("MOON_HEAD") + if not base or not head: + raise RuntimeError("MOON_BASE and MOON_HEAD are required for pull_request CI planning") + + direct_projects, projects, tasks = affected_projects_and_tasks() + jobs = plan_jobs_for_affected(direct_projects, tasks) + selected_native_targets = native_target_subset_for_jobs(jobs, tasks) + reason = ( + f"direct affected projects: {', '.join(sorted(direct_projects)) or '(none)'}; " + f"downstream affected projects: {', '.join(sorted(projects)) or '(none)'}; " + f"affected tasks: {', '.join(sorted(tasks)) or '(none)'}" + ) + return jobs, projects, tasks, reason, selected_native_targets + + +def liboliphaunt_native_desktop_runtime_matrix( + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return artifact_target_matrix.liboliphaunt_native_desktop_runtime_matrix( + native_target=native_target, + selected_targets=selected_targets, + ) + + +def liboliphaunt_native_android_runtime_matrix( + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return artifact_target_matrix.liboliphaunt_native_android_runtime_matrix( + native_target=native_target, + selected_targets=selected_targets, + ) + + +def liboliphaunt_native_ios_runtime_matrix( + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return artifact_target_matrix.liboliphaunt_native_ios_runtime_matrix( + native_target=native_target, + selected_targets=selected_targets, + ) + + +def react_native_android_mobile_app_matrix( + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return artifact_target_matrix.react_native_android_mobile_app_matrix( + native_target=native_target, + selected_targets=selected_targets, + ) + + +def broker_runtime_matrix(native_target: str = "all") -> dict[str, list[dict[str, str]]]: + matrix = artifact_target_matrix.broker_runtime_matrix() + if native_target == "all": + return matrix + include = [target for target in matrix["include"] if target["target"] == native_target] + if not include: + valid_targets = ", ".join(target["target"] for target in matrix["include"]) + raise RuntimeError(f"unknown broker target {native_target}; expected one of: all, {valid_targets}") + return {"include": include} + + +def node_direct_runtime_matrix(native_target: str = "all") -> dict[str, list[dict[str, str]]]: + matrix = artifact_target_matrix.node_direct_runtime_matrix() + if native_target == "all": + return matrix + include = [target for target in matrix["include"] if target["target"] == native_target] + if not include: + valid_targets = ", ".join(target["target"] for target in matrix["include"]) + raise RuntimeError(f"unknown Node direct target {native_target}; expected one of: all, {valid_targets}") + return {"include": include} + + +def extension_artifacts_wasix_matrix( + wasm_target: str = "all", + selected_products: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return artifact_target_matrix.extension_artifacts_wasix_matrix(wasm_target, selected_products) + + +def liboliphaunt_wasix_aot_runtime_matrix(wasm_target: str = "all") -> dict[str, list[dict[str, str]]]: + return artifact_target_matrix.liboliphaunt_wasix_aot_runtime_matrix(wasm_target) + + +def extension_artifacts_native_matrix( + native_target: str = "all", + selected_targets: set[str] | None = None, + selected_products: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return artifact_target_matrix.extension_artifacts_native_matrix(native_target, selected_targets, selected_products) + + +def targets_for_jobs(jobs: set[str]) -> set[str]: + targets: set[str] = set() + for job in jobs: + targets.update(CI_JOB_TARGETS.get(job, [])) + return targets + + +def selected_extension_products_for_plan( + direct_projects: set[str], + tasks: set[str], + jobs: set[str], +) -> set[str] | None: + exact_products = set(artifact_target_matrix.exact_extension_products()) + selected = (direct_projects & exact_products) | { + target.split(":", 1)[0] + for target in tasks + if target.split(":", 1)[0] in exact_products + } + broad_extension_inputs = { + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-contrib-postgres18", + "extension-model", + "extension-packages", + "extensions", + "liboliphaunt-native", + "liboliphaunt-wasix", + "postgres18", + "source-inputs", + "third-party-native", + "third-party-shared", + "third-party-wasix", + } + if direct_projects & broad_extension_inputs: + return exact_products + if "extension-packages:assemble-release" in tasks and not selected: + return exact_products + if "extension-packages" in jobs and not selected: + return exact_products + if jobs & set(MOBILE_JOB_SURFACES): + selected.update(MOBILE_SMOKE_EXTENSION_PRODUCTS) + if jobs & {"extension-artifacts-native", "extension-artifacts-wasix"} and not selected: + return exact_products + if "extension-packages:assemble-mobile" in tasks and not selected: + return exact_products + if not selected: + return None + return selected + + +def plan_for_full_run( + wasm_target: str = "all", + native_target: str = "all", + mobile_target: str = "all", +) -> tuple[set[str], set[str], set[str], str, set[str] | None]: + if mobile_target != "all": + mobile_jobs_by_target = { + "android": {"mobile-build-android"}, + "ios": {"mobile-build-ios"}, + "both": {"mobile-build-android", "mobile-build-ios"}, + } + focused_mobile_jobs = mobile_jobs_by_target.get(mobile_target) + if focused_mobile_jobs is None: + raise RuntimeError(f"unknown mobile target {mobile_target}; expected one of: all, android, ios, both") + focused_jobs = set(BASE_JOBS) | focused_mobile_jobs + add_implied_jobs(focused_jobs, set()) + focused_native_targets = focused_mobile_native_targets(mobile_target, native_target, focused_mobile_jobs) + return ( + focused_jobs, + {"liboliphaunt-native", "oliphaunt-react-native"}, + targets_for_jobs(focused_mobile_jobs), + f"manual focused mobile CI run for {mobile_target}", + focused_native_targets, + ) + + if native_target != "all": + if native_target.startswith("android-") or native_target == "ios-xcframework": + focused_jobs = set(BASE_JOBS) | { + "liboliphaunt-native-android" if native_target.startswith("android-") else "liboliphaunt-native-ios" + } + focused_projects = {"liboliphaunt-native"} + else: + focused_jobs = set(BASE_JOBS) | {"liboliphaunt-native-desktop", "broker-runtime", "node-direct"} + focused_projects = {"liboliphaunt-native", "oliphaunt-broker", "oliphaunt-node-direct"} + add_implied_jobs(focused_jobs, set()) + return ( + focused_jobs, + focused_projects, + targets_for_jobs(focused_jobs), + f"manual focused native runtime CI run for {native_target}", + None, + ) + + if wasm_target != "all": + focused_jobs = set(BASE_JOBS) | { + "liboliphaunt-wasix-runtime", + "liboliphaunt-wasix-aot", + } + return ( + focused_jobs, + {"liboliphaunt-wasix"}, + targets_for_jobs(focused_jobs), + f"manual focused WASIX runtime CI run for {wasm_target}", + None, + ) + + jobs = set(BASE_JOBS) | BUILDER_JOBS | WASM_RUNTIME_JOBS + add_implied_jobs(jobs, targets_for_jobs(jobs)) + return jobs, set(), targets_for_jobs(jobs), "non-PR full CI/runtime run", None + + +def output(name: str, value: object) -> None: + if isinstance(value, str): + rendered = value + else: + rendered = json.dumps(value, sort_keys=True, separators=(",", ":")) + path = os.environ.get("GITHUB_OUTPUT") + if path: + with Path(path).open("a", encoding="utf-8") as handle: + print(f"{name}={rendered}", file=handle) + print(f"{name}={rendered}") + + +def write_plan_artifact(plan: dict[str, object]) -> None: + path = ROOT / "target" / "graph" / "ci-plan.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{json.dumps(plan, indent=2, sort_keys=True)}\n", encoding="utf-8") + + +def emit_github_outputs() -> int: + try: + if os.environ.get("GITHUB_EVENT_NAME") == "pull_request": + jobs, projects, tasks, reason, selected_native_targets = plan_for_pull_request() + else: + jobs, projects, tasks, reason, selected_native_targets = plan_for_full_run( + os.environ.get("WASM_TARGET", "all"), + os.environ.get("NATIVE_TARGET", "all"), + os.environ.get("MOBILE_TARGET", "all"), + ) + except Exception as error: + print(f"affected planning failed: {error}", file=sys.stderr) + return 2 + direct_projects: set[str] = set() + if os.environ.get("GITHUB_EVENT_NAME") == "pull_request": + try: + direct_projects, _, _ = affected_projects_and_tasks() + except Exception: + direct_projects = set() + selected_extension_products = selected_extension_products_for_plan(direct_projects, tasks, jobs) + + plan = { + "jobs": sorted(jobs), + "builder_jobs": sorted(jobs & BUILDER_JOBS), + "job_targets": job_targets_for_jobs(jobs), + "projects": sorted(projects), + "tasks": sorted(tasks), + "liboliphaunt_native_desktop_runtime_matrix": ( + liboliphaunt_native_desktop_runtime_matrix( + os.environ.get("NATIVE_TARGET", "all"), + selected_native_targets, + ) + if "liboliphaunt-native-desktop" in jobs + else empty_matrix() + ), + "liboliphaunt_native_android_runtime_matrix": ( + liboliphaunt_native_android_runtime_matrix( + os.environ.get("NATIVE_TARGET", "all"), + selected_native_targets, + ) + if "liboliphaunt-native-android" in jobs + else empty_matrix() + ), + "liboliphaunt_native_ios_runtime_matrix": ( + liboliphaunt_native_ios_runtime_matrix( + os.environ.get("NATIVE_TARGET", "all"), + selected_native_targets, + ) + if "liboliphaunt-native-ios" in jobs + else empty_matrix() + ), + "extension_artifacts_native_matrix": ( + extension_artifacts_native_matrix( + os.environ.get("NATIVE_TARGET", "all"), + selected_native_targets if "extension-packages" not in jobs else None, + selected_extension_products, + ) + if "extension-artifacts-native" in jobs + else empty_matrix() + ), + "extension_artifacts_wasix_matrix": ( + extension_artifacts_wasix_matrix("all", selected_extension_products) + if "extension-artifacts-wasix" in jobs + else empty_matrix() + ), + "liboliphaunt_wasix_aot_runtime_matrix": ( + liboliphaunt_wasix_aot_runtime_matrix(os.environ.get("WASM_TARGET", "all")) + if "liboliphaunt-wasix-aot" in jobs + else empty_matrix() + ), + "extension_package_products": sorted(selected_extension_products or []), + "extension_package_products_csv": ",".join(sorted(selected_extension_products or [])), + "mobile_extension_package_native_targets": mobile_extension_package_native_targets(jobs, selected_native_targets), + "mobile_extension_package_native_targets_csv": ",".join( + mobile_extension_package_native_targets(jobs, selected_native_targets) + ), + "react_native_android_mobile_app_matrix": ( + react_native_android_mobile_app_matrix( + os.environ.get("NATIVE_TARGET", "all"), + selected_native_targets, + ) + if "mobile-build-android" in jobs + else empty_matrix() + ), + "broker_runtime_matrix": ( + broker_runtime_matrix(os.environ.get("NATIVE_TARGET", "all")) + if "broker-runtime" in jobs + else empty_matrix() + ), + "node_direct_runtime_matrix": ( + node_direct_runtime_matrix(os.environ.get("NATIVE_TARGET", "all")) + if "node-direct" in jobs + else empty_matrix() + ), + "reason": reason, + } + write_plan_artifact(plan) + for name, value in plan.items(): + output(name, value) + return 0 + + +if __name__ == "__main__": + raise SystemExit(emit_github_outputs()) diff --git a/tools/graph/graph.py b/tools/graph/graph.py new file mode 100755 index 00000000..d39a47a6 --- /dev/null +++ b/tools/graph/graph.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python3 +"""Generate and explain Oliphaunt product/task/release metadata data.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tomllib +from collections import deque +from pathlib import Path +from typing import Any, NoReturn + + +ROOT = Path(__file__).resolve().parents[2] +GRAPH_ROOT = ROOT / "target" / "graph" +COVERAGE_BASELINE_PATH = ROOT / "coverage" / "baseline.toml" +SYNTHETIC_ROOT = ROOT / "tools" / "graph" / "synthetic" +GENERATED_PATH_PARTS = { + ".build", + ".cxx", + ".expo", + ".gradle", + ".kotlin", + ".moon", + ".next", + ".source", + "DerivedData", + "Pods", + "__pycache__", + "dist", + "lib", + "node_modules", + "out", + "target", +} + +sys.path.insert(0, str(ROOT / "tools" / "release")) +sys.path.insert(0, str(ROOT / "tools" / "graph")) +import release_plan # noqa: E402 +from ci_plan import CI_JOB_TARGETS, CI_JOBS_CONFIG # noqa: E402 + + +def fail(message: str) -> NoReturn: + raise SystemExit(f"graph.py: {message}") + + +def moon_bin() -> str: + if configured := os.environ.get("MOON_BIN"): + return configured + proto_moon = Path.home() / ".proto" / "bin" / "moon" + return str(proto_moon) if proto_moon.exists() else "moon" + + +def rel(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def read_toml(path: Path) -> dict[str, Any]: + if not path.is_file(): + fail(f"missing TOML input: {rel(path)}") + with path.open("rb") as handle: + return tomllib.load(handle) + + +def run_moon(args: list[str]) -> dict[str, Any]: + command = [moon_bin(), *args] + env = dict(os.environ) + output = subprocess.check_output(command, cwd=ROOT, env=env, text=True) + return json.loads(output) + + +def moon_projects() -> list[dict[str, Any]]: + data = run_moon(["query", "projects"]) + projects = data.get("projects") + if not isinstance(projects, list): + fail("moon query projects did not return a projects array") + return projects + + +def moon_tasks() -> dict[str, Any]: + data = run_moon(["query", "tasks"]) + tasks = data.get("tasks") + if not isinstance(tasks, dict): + fail("moon query tasks did not return a tasks object") + return tasks + + +def normalize_project(project: dict[str, Any]) -> dict[str, Any]: + config = project.get("config") if isinstance(project.get("config"), dict) else {} + raw_deps = project.get("dependencies") or config.get("dependsOn") or [] + if not isinstance(raw_deps, list): + fail(f"Moon project {project.get('id')} has non-list dependsOn") + deps: dict[str, str] = {} + for dependency in raw_deps: + if isinstance(dependency, str): + deps[dependency] = "production" + elif isinstance(dependency, dict) and isinstance(dependency.get("id"), str): + deps[dependency["id"]] = str(dependency.get("scope") or "production") + else: + fail(f"Moon project {project.get('id')} has unsupported dependency entry {dependency!r}") + return { + "id": project["id"], + "source": project.get("source") or config.get("source") or "", + "language": project.get("language") or config.get("language"), + "layer": project.get("layer") or config.get("layer"), + "stack": project.get("stack") or config.get("stack"), + "tags": sorted(config.get("tags") or []), + "dependsOn": sorted(deps), + "dependencyScopes": dict(sorted(deps.items())), + "project": config.get("project") if isinstance(config.get("project"), dict) else {}, + "tasks": sorted((project.get("tasks") or {}).keys()), + } + + +def normalize_task(task: dict[str, Any]) -> dict[str, Any]: + inputs = sorted( + { + *task.get("inputFiles", {}).keys(), + *task.get("inputGlobs", {}).keys(), + *[ + item.get("file") or item.get("glob") + for item in task.get("inputs", []) + if isinstance(item, dict) and (item.get("file") or item.get("glob")) + ], + } + ) + outputs = sorted( + { + *task.get("outputFiles", {}).keys(), + *task.get("outputGlobs", {}).keys(), + *[ + item.get("file") or item.get("glob") or item + for item in task.get("outputs", []) + if isinstance(item, (dict, str)) + ], + } + ) + deps = sorted( + ( + { + "target": dep.get("target"), + "cacheStrategy": dep.get("cacheStrategy"), + } + if isinstance(dep, dict) + else {"target": dep, "cacheStrategy": None} + for dep in task.get("deps", []) + ), + key=lambda dep: (dep.get("target") or "", dep.get("cacheStrategy") or ""), + ) + command = " ".join([task.get("command") or "", *(task.get("args") or [])]).strip() + return { + "command": command, + "deps": deps, + "tags": sorted(task.get("tags") or []), + "inputs": inputs, + "outputs": outputs, + "cache": (task.get("options") or {}).get("cache"), + "runInCI": (task.get("options") or {}).get("runInCI", True), + } + + +def release_products(release_metadata: dict[str, Any]) -> dict[str, dict[str, Any]]: + products = release_metadata.get("products") + if not isinstance(products, dict): + fail("release metadata must define [products.] tables") + return products + + +def dependents_by_project(projects: dict[str, dict[str, Any]]) -> dict[str, list[str]]: + dependents: dict[str, set[str]] = {project: set() for project in projects} + for project, config in projects.items(): + for dependency in config["dependsOn"]: + dependents.setdefault(dependency, set()).add(project) + return {project: sorted(values) for project, values in sorted(dependents.items())} + + +def downstream_closure(project: str, dependents: dict[str, list[str]]) -> list[str]: + seen = {project} + queue: deque[str] = deque([project]) + while queue: + current = queue.popleft() + for dependent in dependents.get(current, []): + if dependent not in seen: + seen.add(dependent) + queue.append(dependent) + return sorted(seen) + + +def owner_project_for_path(projects: dict[str, dict[str, Any]], path: str) -> str | None: + if is_generated_local_state(path): + return None + matches = [ + project + for project in projects.values() + if project["source"] == "." or path == project["source"] or path.startswith(f"{project['source']}/") + ] + matches.sort(key=lambda project: len(project["source"]), reverse=True) + return matches[0]["id"] if matches else None + + +def is_generated_local_state(path: str) -> bool: + if path.startswith("target/"): + return True + return any(part in GENERATED_PATH_PARTS for part in Path(path).parts) + + +def coverage_expectations( + coverage_baseline: dict[str, Any], + tasks: dict[str, Any], +) -> dict[str, Any]: + products = coverage_baseline.get("products") + if not isinstance(products, dict): + fail("coverage baseline must define [products.] tables") + expectations: dict[str, Any] = {} + for product, config in sorted(products.items()): + product_tasks = tasks.get(product, {}) + expectations[product] = { + "tool": config.get("tool"), + "lineThreshold": config.get("line_threshold"), + "measuredLineCoverage": config.get("measured_line_coverage"), + "summary": config.get("summary"), + "reports": config.get("reports", []), + "includeGlobs": config.get("source_globs", config.get("include_globs", [])), + "excludeGlobs": config.get("exclude_globs", []), + "moonCoverageTask": "coverage" in product_tasks, + } + return expectations + + +def ci_matrix(tasks: dict[str, Any]) -> dict[str, Any]: + jobs: dict[str, Any] = {} + missing: dict[str, list[str]] = {} + for job, targets in CI_JOB_TARGETS.items(): + missing_targets: list[str] = [] + for target in targets: + project, task = target.split(":", 1) + if task not in tasks.get(project, {}): + missing_targets.append(target) + jobs[job] = { + "targets": targets, + "allTargetsExist": not missing_targets, + } + if missing_targets: + missing[job] = missing_targets + return { + "metadata": { + "alwaysJobs": sorted(CI_JOBS_CONFIG["always_jobs"]), + "coverageJobProducts": dict(sorted(CI_JOBS_CONFIG["coverage_job_products"].items())), + "wasmRuntimeJobs": sorted(CI_JOBS_CONFIG["wasm_runtime_jobs"]), + "source": "Moon task tags ci-", + }, + "jobs": jobs, + "requiredJobs": sorted(CI_JOB_TARGETS), + "missingTargets": missing, + } + + +def build_graph() -> dict[str, Any]: + release_metadata = release_plan.load_graph() + coverage_baseline = read_toml(COVERAGE_BASELINE_PATH) + projects = {project["id"]: normalize_project(project) for project in moon_projects()} + tasks_raw = moon_tasks() + tasks = { + project: {task_id: normalize_task(task) for task_id, task in sorted(project_tasks.items())} + for project, project_tasks in sorted(tasks_raw.items()) + } + products = release_products(release_metadata) + product_ids = list(products) + dependents = dependents_by_project(projects) + return { + "moonProjects": projects, + "moonTasks": tasks, + "moonDependents": dependents, + "releaseProducts": { + product: { + "owner": config.get("owner"), + "kind": config.get("kind"), + "moonProject": release_plan.release_product_project_id(product, products, projects), + "tagPrefix": config.get("tag_prefix"), + "publishTargets": config.get("publish_targets", []), + "releaseArtifacts": config.get("release_artifacts", []), + "moonProjectExists": release_plan.release_product_project_id(product, products, projects) in projects, + } + for product, config in products.items() + }, + "releaseOrder": release_plan.release_order(products, projects, product_ids), + "coverageExpectations": coverage_expectations(coverage_baseline, tasks_raw), + "ciMatrix": ci_matrix(tasks_raw), + "productIds": product_ids, + "policy": release_metadata.get("policy", {}), + } + + +def explain_paths(paths: list[str], graph: dict[str, Any]) -> dict[str, Any]: + projects = graph["moonProjects"] + dependents = graph["moonDependents"] + normalized_paths = normalize_explain_paths(paths) + release_metadata = release_plan.load_graph() + release_impact = release_plan.build_plan( + release_metadata, + release_plan.normalize_files(normalized_paths), + ) + explanations = [] + for path in normalized_paths: + owner = owner_project_for_path(projects, path) + explanations.append( + { + "path": path, + "ownerProject": owner, + "moonAffectedProjects": downstream_closure(owner, dependents) if owner else [], + "coverageProducts": coverage_products_for_path(path, graph), + } + ) + return { + "paths": explanations, + "releasePlan": release_impact, + } + + +def normalize_explain_paths(paths: Iterable[str]) -> list[str]: + normalized: set[str] = set() + for path in paths: + value = path.strip().replace("\\", "/") + if value.startswith("./"): + value = value[2:] + if value: + normalized.add(value) + return sorted(normalized) + + +def coverage_products_for_path(path: str, graph: dict[str, Any]) -> list[str]: + if is_generated_local_state(path): + return [] + products: list[str] = [] + for product, config in graph["coverageExpectations"].items(): + includes = config.get("includeGlobs", []) + excludes = config.get("excludeGlobs", []) + if release_plan.product_matches(path, includes) and not release_plan.product_matches( + path, excludes + ): + products.append(product) + return sorted(products) + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{json.dumps(value, indent=2, sort_keys=True)}\n", encoding="utf-8") + + +def write_graph(graph: dict[str, Any]) -> None: + GRAPH_ROOT.mkdir(parents=True, exist_ok=True) + write_json( + GRAPH_ROOT / "products.json", + { + "moonProjects": graph["moonProjects"], + "moonDependents": graph["moonDependents"], + "releaseProducts": graph["releaseProducts"], + "releaseOrder": graph["releaseOrder"], + "productIds": graph["productIds"], + }, + ) + write_json(GRAPH_ROOT / "tasks.json", graph["moonTasks"]) + write_json(GRAPH_ROOT / "ci-matrix.json", graph["ciMatrix"]) + write_json(GRAPH_ROOT / "coverage-expectations.json", graph["coverageExpectations"]) + write_json( + GRAPH_ROOT / "explain.json", + { + "usage": "tools/graph/graph.py explain --path ", + "syntheticCases": { + contract: synthetic_contract_cases(contract).get("cases", {}) + for contract in ("affected", "release", "coverage") + }, + }, + ) + + +def synthetic_contract_cases(contract: str) -> dict[str, Any]: + path = SYNTHETIC_ROOT / f"{contract}.toml" + if not path.is_file(): + fail(f"missing synthetic graph fixture: {rel(path)}") + return read_toml(path) + + +def assert_equal_list(label: str, actual: list[str], expected: list[str]) -> None: + if sorted(actual) != sorted(expected): + fail(f"{label}: expected {sorted(expected)}, got {sorted(actual)}") + + +def task(graph: dict[str, Any], project: str, task_id: str) -> dict[str, Any]: + try: + return graph["moonTasks"][project][task_id] + except KeyError: + fail(f"missing Moon task {project}:{task_id}") + + +def assert_task_tags(graph: dict[str, Any], project: str, task_id: str, expected: list[str]) -> None: + actual = task(graph, project, task_id).get("tags", []) + missing = sorted(set(expected) - set(actual)) + if missing: + fail(f"{project}:{task_id} tags: missing {missing}, got {sorted(actual)}") + + +def assert_dep_cache_strategy( + graph: dict[str, Any], + project: str, + task_id: str, + target: str, + expected: str, +) -> None: + deps = task(graph, project, task_id).get("deps", []) + for dep in deps: + if dep.get("target") == target: + if dep.get("cacheStrategy") != expected: + fail( + f"{project}:{task_id} dependency {target}: expected cacheStrategy={expected}, " + f"got {dep.get('cacheStrategy')}" + ) + return + fail(f"{project}:{task_id} is missing dependency {target}") + + +def check_graph(graph: dict[str, Any]) -> None: + projects = graph["moonProjects"] + release_products_config = release_products(release_plan.load_graph()) + for product, config in release_products_config.items(): + project_id = release_plan.release_product_project_id(product, release_products_config, projects) + project = projects.get(project_id) + if project is None: + fail(f"release product {product} does not have an owning Moon project") + if "release-product" not in project.get("tags", []): + fail(f"release product {product} Moon project {project_id} must be tagged release-product") + metadata = project.get("project", {}).get("metadata", {}) + release = metadata.get("release") if isinstance(metadata, dict) else None + if not isinstance(release, dict): + release = project.get("project", {}).get("release") + if not isinstance(release, dict): + fail(f"release product {product} Moon project {project_id} must declare project.release metadata") + if release.get("component") != product: + fail(f"release product {product} Moon metadata component mismatch: {release.get('component')}") + if release.get("packagePath") != config.get("path"): + fail(f"release product {product} Moon metadata packagePath mismatch: {release.get('packagePath')}") + + missing_ci_targets = graph["ciMatrix"]["missingTargets"] + if missing_ci_targets: + fail(f"CI matrix references missing Moon targets: {missing_ci_targets}") + + for project, project_tasks in graph["moonTasks"].items(): + for task_id, config in project_tasks.items(): + if not config.get("tags"): + fail(f"{project}:{task_id} must declare Moon task tags") + + for project in graph["moonProjects"]: + for task_id in ("check", "test"): + if task_id in graph["moonTasks"].get(project, {}): + if task_id == "check": + expected_tags = ["quality", "static"] + elif project == "liboliphaunt-native": + expected_tags = ["quality", "runtime"] + else: + expected_tags = ["quality", "unit"] + assert_task_tags(graph, project, task_id, expected_tags) + + for project in ( + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + ): + assert_task_tags(graph, project, "coverage", ["coverage", "quality"]) + assert_task_tags(graph, project, "bench-run", ["bench", "measured"]) + + for target in ( + "oliphaunt-rust:coverage", + "oliphaunt-swift:coverage", + "oliphaunt-kotlin:coverage", + "oliphaunt-js:coverage", + "oliphaunt-react-native:coverage", + "oliphaunt-wasix-rust:coverage", + ): + assert_dep_cache_strategy(graph, "repo", "coverage", target, "outputs") + assert_dep_cache_strategy(graph, "docs", "smoke", "docs:build", "outputs") + assert_dep_cache_strategy(graph, "docs", "release-check", "docs:build", "outputs") + + for product, config in graph["coverageExpectations"].items(): + if not config["moonCoverageTask"]: + fail(f"coverage baseline product {product} has no Moon coverage task") + if config["lineThreshold"] is None or config["measuredLineCoverage"] is None: + fail(f"coverage baseline product {product} is missing measured threshold data") + + affected_cases = synthetic_contract_cases("affected").get("cases") + if not isinstance(affected_cases, dict): + fail("tools/graph/synthetic/affected.toml must define [cases.] tables") + for case_id, case in affected_cases.items(): + path = case.get("path") + if not isinstance(path, str): + fail(f"synthetic affected case {case_id} is missing path") + explanation = explain_paths([path], graph) + moon_projects = explanation["paths"][0]["moonAffectedProjects"] + assert_equal_list(f"{case_id} Moon affected projects", moon_projects, case.get("moon_projects", [])) + + release_cases = synthetic_contract_cases("release").get("cases") + if not isinstance(release_cases, dict): + fail("tools/graph/synthetic/release.toml must define [cases.] tables") + for case_id, case in release_cases.items(): + path = case.get("path") + if not isinstance(path, str): + fail(f"synthetic release case {case_id} is missing path") + release_impact = release_plan.build_plan( + release_plan.load_graph(), + release_plan.normalize_files([path]), + ) + planned_release_products = release_impact["releaseProducts"] + assert_equal_list( + f"{case_id} direct release products", + release_impact["directProducts"], + case.get("direct_products", []), + ) + assert_equal_list( + f"{case_id} release products", + planned_release_products, + case.get("release_products", []), + ) + if "docs_only" in case and release_impact.get("docsOnly") is not case["docs_only"]: + fail( + f"{case_id} docsOnly: expected {case['docs_only']}, " + f"got {release_impact.get('docsOnly')}" + ) + + coverage_cases = synthetic_contract_cases("coverage").get("cases") + if not isinstance(coverage_cases, dict): + fail("tools/graph/synthetic/coverage.toml must define [cases.] tables") + for case_id, case in coverage_cases.items(): + path = case.get("path") + if not isinstance(path, str): + fail(f"synthetic coverage case {case_id} is missing path") + explanation = explain_paths([path], graph) + assert_equal_list( + f"{case_id} coverage products", + explanation["paths"][0]["coverageProducts"], + case.get("coverage_products", []), + ) + + for project, task_id, expected_cache, expected_output in [ + ("graph-tools", "cache-witness", False, None), + ("graph-tools", "cache-witness-fixture", True, "/target/graph/cache-witness/output.txt"), + ]: + config = task(graph, project, task_id) + if config.get("cache") is not expected_cache: + fail( + f"{project}:{task_id} cache: expected {expected_cache}, " + f"got {config.get('cache')}" + ) + if expected_output is not None and expected_output not in config.get("outputs", []): + fail(f"{project}:{task_id} must declare output {expected_output}") + + +def print_explanation(explanation: dict[str, Any], fmt: str) -> None: + if fmt == "json": + print(json.dumps(explanation, indent=2, sort_keys=True)) + return + for path in explanation["paths"]: + print(f"{path['path']}") + print(f" owner project: {path['ownerProject'] or '(none)'}") + print(" Moon affected: " + (", ".join(path["moonAffectedProjects"]) or "(none)")) + print(" coverage: " + (", ".join(path["coverageProducts"]) or "(none)")) + plan = explanation["releasePlan"] + print("Release direct products: " + (", ".join(plan["directProducts"]) or "(none)")) + print("Release products: " + (", ".join(plan["releaseProducts"]) or "(none)")) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("generate") + subparsers.add_parser("check") + explain = subparsers.add_parser("explain") + explain.add_argument("--path", action="append", required=True, help="repo-relative path") + explain.add_argument("--format", choices=["text", "json"], default="text") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + graph = build_graph() + if args.command == "generate": + write_graph(graph) + print(f"generated graph data in {rel(GRAPH_ROOT)}") + elif args.command == "check": + write_graph(graph) + check_graph(graph) + print(f"graph checks passed ({len(graph['moonProjects'])} Moon projects, {len(graph['productIds'])} release products)") + elif args.command == "explain": + write_graph(graph) + print_explanation(explain_paths(args.path, graph), args.format) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/graph/moon.yml b/tools/graph/moon.yml new file mode 100644 index 00000000..cb5ca74d --- /dev/null +++ b/tools/graph/moon.yml @@ -0,0 +1,96 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "graph-tools" +language: "python" +layer: "tool" +stack: "infrastructure" +tags: ["tools", "graph", "repo-hygiene"] + +project: + title: "Graph Tools" + description: "Generated Moon, release, coverage, CI, and explain graph data." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "tools/graph/graph.py check" + inputs: + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/.github/moon.yml" + - "/benchmarks/moon.yml" + - "/coverage/baseline.toml" + - "/.release-please-manifest.json" + - "/examples/moon.yml" + - "/moon.yml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/release-please-config.json" + - "/src/**/release.toml" + - "/src/**/moon.yml" + - "/tools/**/moon.yml" + - "/tools/graph/**/*" + - "/tools/release/release_plan.py" + outputs: + - "/target/graph/**/*" + options: + cache: true + runFromWorkspaceRoot: true + generate: + tags: ["generated", "graph"] + command: "tools/graph/graph.py generate" + inputs: + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/.github/moon.yml" + - "/benchmarks/moon.yml" + - "/coverage/baseline.toml" + - "/.release-please-manifest.json" + - "/examples/moon.yml" + - "/moon.yml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/release-please-config.json" + - "/src/**/release.toml" + - "/src/**/moon.yml" + - "/tools/**/moon.yml" + - "/tools/graph/**/*" + - "/tools/release/release_plan.py" + outputs: + - "/target/graph/**/*" + options: + cache: true + runFromWorkspaceRoot: true + cache-witness: + tags: ["cache", "witness"] + command: "tools/graph/cache-witness.py assert" + inputs: + - "/.moon/workspace.yml" + - "/.moon/toolchains.yml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/tools/graph/cache-witness.py" + - "/tools/graph/moon.yml" + options: + cache: false + runFromWorkspaceRoot: true + runInCI: false + cache-witness-fixture: + tags: ["cache", "witness", "generated"] + command: "tools/graph/cache-witness.py fixture" + inputs: + - "/target/graph/cache-witness/input.txt" + - "/tools/graph/cache-witness.py" + - "/tools/graph/moon.yml" + outputs: + - "/target/graph/cache-witness/output.txt" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false diff --git a/tools/graph/synthetic/affected.toml b/tools/graph/synthetic/affected.toml new file mode 100644 index 00000000..15d96119 --- /dev/null +++ b/tools/graph/synthetic/affected.toml @@ -0,0 +1,394 @@ +# Moon affectedness fixtures. These cases assert path owner and downstream project closure only. + +[cases.postgres18_source] +path = "src/postgres/versions/18/source.toml" +moon_projects = [ + "postgres18", + "extension-contrib-postgres18", + "oliphaunt-extension-amcheck", + "oliphaunt-extension-auto-explain", + "oliphaunt-extension-bloom", + "oliphaunt-extension-btree-gin", + "oliphaunt-extension-btree-gist", + "oliphaunt-extension-citext", + "oliphaunt-extension-cube", + "oliphaunt-extension-dict-int", + "oliphaunt-extension-dict-xsyn", + "oliphaunt-extension-earthdistance", + "oliphaunt-extension-file-fdw", + "oliphaunt-extension-fuzzystrmatch", + "oliphaunt-extension-hstore", + "oliphaunt-extension-intarray", + "oliphaunt-extension-isn", + "oliphaunt-extension-lo", + "oliphaunt-extension-ltree", + "oliphaunt-extension-pageinspect", + "oliphaunt-extension-pg-buffercache", + "oliphaunt-extension-pg-freespacemap", + "oliphaunt-extension-pg-surgery", + "oliphaunt-extension-pg-trgm", + "oliphaunt-extension-pg-visibility", + "oliphaunt-extension-pg-walinspect", + "oliphaunt-extension-pgcrypto", + "oliphaunt-extension-seg", + "oliphaunt-extension-tablefunc", + "oliphaunt-extension-tcn", + "oliphaunt-extension-tsm-system-rows", + "oliphaunt-extension-tsm-system-time", + "oliphaunt-extension-unaccent", + "oliphaunt-extension-uuid-ossp", + "extensions", + "extension-model", + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.shared_third_party_source] +path = "src/sources/third-party/shared/openssl.toml" +moon_projects = [ + "third-party-shared", + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.native_third_party_source] +path = "src/sources/third-party/native/geos.toml" +moon_projects = [ + "third-party-native", + "extension-artifacts-native", + "extension-packages", + "liboliphaunt-native", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "docs", + "release-tools", + "repo" + ] + +[cases.wasm_third_party_source] +path = "src/sources/third-party/wasix/geos.toml" +moon_projects = [ + "third-party-wasix", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-wasix", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.wasix_toolchain] +path = "src/sources/toolchains/wasix.toml" +moon_projects = [ + "source-toolchains", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-wasix", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.native_patch] +path = "src/runtimes/liboliphaunt/native/postgres18/patches/embedded.diff" +moon_projects = [ + "liboliphaunt-native", + "extension-artifacts-native", + "extension-packages", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "docs", + "release-tools", + "repo" + ] + +[cases.wasm_patch] +path = "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch" +moon_projects = [ + "liboliphaunt-wasix", + "extension-artifacts-wasix", + "extension-packages", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.extension_catalog] +path = "src/extensions/catalog/vector.toml" +moon_projects = [ + "extensions", + "extension-model", + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.native_extension_recipe] +path = "src/extensions/external/postgis/targets/native.toml" +moon_projects = [ + "oliphaunt-extension-postgis", + "extensions", + "extension-model", + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.wasm_extension_recipe] +path = "src/extensions/external/postgis/targets/wasix.toml" +moon_projects = [ + "oliphaunt-extension-postgis", + "extensions", + "extension-model", + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.shared_protocol_fixture] +path = "src/shared/fixtures/protocol/query-response-cases.json" +moon_projects = [ + "shared-fixtures", + "extension-artifacts-wasix", + "extension-packages", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.swift_sdk_source] +path = "src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift" +moon_projects = [ + "oliphaunt-swift", + "oliphaunt-react-native", + "docs", + "release-tools", + "repo" + ] + +[cases.kotlin_sdk_source] +path = "src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt" +moon_projects = [ + "oliphaunt-kotlin", + "oliphaunt-react-native", + "docs", + "release-tools", + "repo" + ] + +[cases.react_native_sdk_source] +path = "src/sdks/react-native/src/index.ts" +moon_projects = [ + "oliphaunt-react-native", + "docs", + "release-tools", + "repo" + ] + +[cases.shared_js_core_source] +path = "src/shared/js-core/src/query.ts" +moon_projects = [ + "shared-js-core", + "oliphaunt-js", + "oliphaunt-react-native", + "docs", + "release-tools", + "repo" + ] + +[cases.rust_sdk_source] +path = "src/sdks/rust/src/lib.rs" +moon_projects = [ + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-js", + "docs", + "release-tools", + "repo" + ] + +[cases.rust_broker_helper_source] +path = "src/runtimes/broker/src/main.rs" +moon_projects = [ + "oliphaunt-broker", + "oliphaunt-js", + "docs", + "release-tools", + "repo" + ] + +[cases.typescript_sdk_source] +path = "src/sdks/js/src/client.ts" +moon_projects = [ + "oliphaunt-js", + "docs", + "release-tools", + "repo" + ] + +[cases.wasm_sdk_source] +path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/types.rs" +moon_projects = [ + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.root_docs_only] +path = "docs/architecture/native-liboliphaunt.md" +moon_projects = [ + "repo" + ] + +[cases.docs_product_only] +path = "src/docs/content/sdk/rust/index.md" +moon_projects = [ + "docs" + ] + +[cases.package_visible_rust_docs] +path = "src/sdks/rust/README.md" +moon_projects = [ + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-js", + "docs", + "release-tools", + "repo" + ] + +[cases.package_visible_swift_docs] +path = "src/sdks/swift/README.md" +moon_projects = [ + "oliphaunt-swift", + "oliphaunt-react-native", + "docs", + "release-tools", + "repo" + ] + +[cases.package_visible_kotlin_docs] +path = "src/sdks/kotlin/README.md" +moon_projects = [ + "oliphaunt-kotlin", + "oliphaunt-react-native", + "docs", + "release-tools", + "repo" + ] + +[cases.package_visible_react_native_docs] +path = "src/sdks/react-native/README.md" +moon_projects = [ + "oliphaunt-react-native", + "docs", + "release-tools", + "repo" + ] + +[cases.package_visible_typescript_docs] +path = "src/sdks/js/README.md" +moon_projects = [ + "oliphaunt-js", + "docs", + "release-tools", + "repo" + ] + +[cases.package_visible_wasm_docs] +path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md" +moon_projects = [ + "oliphaunt-wasix-rust", + "docs", + "release-tools", + "repo" + ] + +[cases.benchmark_fixture] +path = "benchmarks/native/sql/benchmark1.sql" +moon_projects = [ + "benchmarks", + "perf-tools" + ] + +[cases.generated_coverage_output] +path = "target/coverage/oliphaunt-rust/summary.json" +moon_projects = [] + +[cases.generated_docs_output] +path = "src/docs/.next/server/app/page.js" +moon_projects = [] diff --git a/tools/graph/synthetic/coverage.toml b/tools/graph/synthetic/coverage.toml new file mode 100644 index 00000000..d2853551 --- /dev/null +++ b/tools/graph/synthetic/coverage.toml @@ -0,0 +1,133 @@ +# Coverage routing fixtures. These cases assert measured coverage product routing only. + +[cases.postgres18_source] +path = "src/postgres/versions/18/source.toml" +coverage_products = [] + +[cases.shared_third_party_source] +path = "src/sources/third-party/shared/openssl.toml" +coverage_products = [] + +[cases.native_third_party_source] +path = "src/sources/third-party/native/geos.toml" +coverage_products = [] + +[cases.wasm_third_party_source] +path = "src/sources/third-party/wasix/geos.toml" +coverage_products = [] + +[cases.wasix_toolchain] +path = "src/sources/toolchains/wasix.toml" +coverage_products = [] + +[cases.native_patch] +path = "src/runtimes/liboliphaunt/native/postgres18/patches/embedded.diff" +coverage_products = [] + +[cases.wasm_patch] +path = "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch" +coverage_products = [] + +[cases.extension_catalog] +path = "src/extensions/catalog/vector.toml" +coverage_products = [] + +[cases.native_extension_recipe] +path = "src/extensions/external/postgis/targets/native.toml" +coverage_products = [] + +[cases.wasm_extension_recipe] +path = "src/extensions/external/postgis/targets/wasix.toml" +coverage_products = [] + +[cases.shared_protocol_fixture] +path = "src/shared/fixtures/protocol/query-response-cases.json" +coverage_products = [] + +[cases.swift_sdk_source] +path = "src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift" +coverage_products = [ + "oliphaunt-swift" + ] + +[cases.kotlin_sdk_source] +path = "src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt" +coverage_products = [ + "oliphaunt-kotlin" + ] + +[cases.react_native_sdk_source] +path = "src/sdks/react-native/src/index.ts" +coverage_products = [ + "oliphaunt-react-native" + ] + +[cases.shared_js_core_source] +path = "src/shared/js-core/src/query.ts" +coverage_products = [] + +[cases.rust_sdk_source] +path = "src/sdks/rust/src/lib.rs" +coverage_products = [ + "oliphaunt-rust" + ] + +[cases.rust_broker_helper_source] +path = "src/runtimes/broker/src/main.rs" +coverage_products = [] + +[cases.typescript_sdk_source] +path = "src/sdks/js/src/client.ts" +coverage_products = [ + "oliphaunt-js" + ] + +[cases.wasm_sdk_source] +path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/types.rs" +coverage_products = [ + "oliphaunt-wasix-rust" + ] + +[cases.root_docs_only] +path = "docs/architecture/native-liboliphaunt.md" +coverage_products = [] + +[cases.docs_product_only] +path = "src/docs/content/sdk/rust/index.md" +coverage_products = [] + +[cases.package_visible_rust_docs] +path = "src/sdks/rust/README.md" +coverage_products = [] + +[cases.package_visible_swift_docs] +path = "src/sdks/swift/README.md" +coverage_products = [] + +[cases.package_visible_kotlin_docs] +path = "src/sdks/kotlin/README.md" +coverage_products = [] + +[cases.package_visible_react_native_docs] +path = "src/sdks/react-native/README.md" +coverage_products = [] + +[cases.package_visible_typescript_docs] +path = "src/sdks/js/README.md" +coverage_products = [] + +[cases.package_visible_wasm_docs] +path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md" +coverage_products = [] + +[cases.benchmark_fixture] +path = "benchmarks/native/sql/benchmark1.sql" +coverage_products = [] + +[cases.generated_coverage_output] +path = "target/coverage/oliphaunt-rust/summary.json" +coverage_products = [] + +[cases.generated_docs_output] +path = "src/docs/.next/server/app/page.js" +coverage_products = [] diff --git a/tools/graph/synthetic/release.toml b/tools/graph/synthetic/release.toml new file mode 100644 index 00000000..9363e656 --- /dev/null +++ b/tools/graph/synthetic/release.toml @@ -0,0 +1,378 @@ +# Release planning fixtures. These cases assert release products and docs-only classification only. + +[cases.postgres18_source] +path = "src/postgres/versions/18/source.toml" +direct_products = [] +release_products = [ + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-broker", + "oliphaunt-extension-amcheck", + "oliphaunt-extension-auto-explain", + "oliphaunt-extension-bloom", + "oliphaunt-extension-btree-gin", + "oliphaunt-extension-btree-gist", + "oliphaunt-extension-citext", + "oliphaunt-extension-cube", + "oliphaunt-extension-dict-int", + "oliphaunt-extension-dict-xsyn", + "oliphaunt-extension-earthdistance", + "oliphaunt-extension-file-fdw", + "oliphaunt-extension-fuzzystrmatch", + "oliphaunt-extension-hstore", + "oliphaunt-extension-intarray", + "oliphaunt-extension-isn", + "oliphaunt-extension-lo", + "oliphaunt-extension-ltree", + "oliphaunt-extension-pageinspect", + "oliphaunt-extension-pg-buffercache", + "oliphaunt-extension-pg-freespacemap", + "oliphaunt-extension-pg-surgery", + "oliphaunt-extension-pg-trgm", + "oliphaunt-extension-pg-visibility", + "oliphaunt-extension-pg-walinspect", + "oliphaunt-extension-pgcrypto", + "oliphaunt-extension-seg", + "oliphaunt-extension-tablefunc", + "oliphaunt-extension-tcn", + "oliphaunt-extension-tsm-system-rows", + "oliphaunt-extension-tsm-system-time", + "oliphaunt-extension-unaccent", + "oliphaunt-extension-uuid-ossp", + "oliphaunt-js", + "oliphaunt-kotlin", + "oliphaunt-node-direct", + "oliphaunt-react-native", + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-wasix-rust" + ] +docs_only = false + +[cases.shared_third_party_source] +path = "src/sources/third-party/shared/openssl.toml" +direct_products = [] +release_products = [ + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust" + ] +docs_only = false + +[cases.native_third_party_source] +path = "src/sources/third-party/native/geos.toml" +direct_products = [] +release_products = [ + "liboliphaunt-native", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js" + ] +docs_only = false + +[cases.wasm_third_party_source] +path = "src/sources/third-party/wasix/geos.toml" +direct_products = [] +release_products = [ + "liboliphaunt-wasix", + "oliphaunt-wasix-rust" + ] +docs_only = false + +[cases.wasix_toolchain] +path = "src/sources/toolchains/wasix.toml" +direct_products = [] +release_products = [ + "liboliphaunt-wasix", + "oliphaunt-wasix-rust" + ] +docs_only = false + +[cases.native_patch] +path = "src/runtimes/liboliphaunt/native/postgres18/patches/embedded.diff" +direct_products = [ + "liboliphaunt-native" + ] +release_products = [ + "liboliphaunt-native", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js" + ] +docs_only = false + +[cases.wasm_patch] +path = "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch" +direct_products = [ + "liboliphaunt-wasix" + ] +release_products = [ + "liboliphaunt-wasix", + "oliphaunt-wasix-rust" + ] +docs_only = false + +[cases.contrib_manifest] +path = "src/extensions/contrib/postgres18.toml" +direct_products = [] +release_products = [ + "oliphaunt-extension-amcheck", + "oliphaunt-extension-auto-explain", + "oliphaunt-extension-bloom", + "oliphaunt-extension-btree-gin", + "oliphaunt-extension-btree-gist", + "oliphaunt-extension-citext", + "oliphaunt-extension-cube", + "oliphaunt-extension-dict-int", + "oliphaunt-extension-dict-xsyn", + "oliphaunt-extension-earthdistance", + "oliphaunt-extension-file-fdw", + "oliphaunt-extension-fuzzystrmatch", + "oliphaunt-extension-hstore", + "oliphaunt-extension-intarray", + "oliphaunt-extension-isn", + "oliphaunt-extension-lo", + "oliphaunt-extension-ltree", + "oliphaunt-extension-pageinspect", + "oliphaunt-extension-pg-buffercache", + "oliphaunt-extension-pg-freespacemap", + "oliphaunt-extension-pg-surgery", + "oliphaunt-extension-pg-trgm", + "oliphaunt-extension-pg-visibility", + "oliphaunt-extension-pg-walinspect", + "oliphaunt-extension-pgcrypto", + "oliphaunt-extension-seg", + "oliphaunt-extension-tablefunc", + "oliphaunt-extension-tcn", + "oliphaunt-extension-tsm-system-rows", + "oliphaunt-extension-tsm-system-time", + "oliphaunt-extension-unaccent", + "oliphaunt-extension-uuid-ossp", + ] +docs_only = false + + +[cases.extension_catalog] +path = "src/extensions/catalog/vector.toml" +direct_products = [] +release_products = [] +docs_only = false + +[cases.native_extension_recipe] +path = "src/extensions/external/postgis/targets/native.toml" +direct_products = [ + "oliphaunt-extension-postgis" + ] +release_products = [ + "oliphaunt-extension-postgis" + ] +docs_only = false + +[cases.wasm_extension_recipe] +path = "src/extensions/external/postgis/targets/wasix.toml" +direct_products = [ + "oliphaunt-extension-postgis" + ] +release_products = [ + "oliphaunt-extension-postgis" + ] +docs_only = false + +[cases.shared_protocol_fixture] +path = "src/shared/fixtures/protocol/query-response-cases.json" +direct_products = [] +release_products = [] +docs_only = false + +[cases.swift_sdk_source] +path = "src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift" +direct_products = [ + "oliphaunt-swift" + ] +release_products = [ + "oliphaunt-swift", + "oliphaunt-react-native" + ] +docs_only = false + +[cases.kotlin_sdk_source] +path = "src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt" +direct_products = [ + "oliphaunt-kotlin" + ] +release_products = [ + "oliphaunt-kotlin", + "oliphaunt-react-native" + ] +docs_only = false + +[cases.react_native_sdk_source] +path = "src/sdks/react-native/src/index.ts" +direct_products = [ + "oliphaunt-react-native" + ] +release_products = [ + "oliphaunt-react-native" + ] +docs_only = false + +[cases.shared_js_core_source] +path = "src/shared/js-core/src/query.ts" +direct_products = [] +release_products = [ + "oliphaunt-js", + "oliphaunt-react-native" + ] +docs_only = false + +[cases.rust_sdk_source] +path = "src/sdks/rust/src/lib.rs" +direct_products = [ + "oliphaunt-rust" + ] +release_products = [ + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-js" + ] +docs_only = false + +[cases.rust_broker_helper_source] +path = "src/runtimes/broker/src/main.rs" +direct_products = [ + "oliphaunt-broker" + ] +release_products = [ + "oliphaunt-broker", + "oliphaunt-js" + ] +docs_only = false + +[cases.typescript_sdk_source] +path = "src/sdks/js/src/client.ts" +direct_products = [ + "oliphaunt-js" + ] +release_products = [ + "oliphaunt-js" + ] +docs_only = false + +[cases.wasm_sdk_source] +path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/types.rs" +direct_products = [ + "oliphaunt-wasix-rust" + ] +release_products = [ + "oliphaunt-wasix-rust" + ] +docs_only = false + +[cases.root_docs_only] +path = "docs/architecture/native-liboliphaunt.md" +direct_products = [] +release_products = [] +docs_only = true + +[cases.docs_product_only] +path = "src/docs/content/sdk/rust/index.md" +direct_products = [] +release_products = [] +docs_only = true + +[cases.package_visible_rust_docs] +path = "src/sdks/rust/README.md" +direct_products = [ + "oliphaunt-rust" + ] +release_products = [ + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-js" + ] +docs_only = false + +[cases.package_visible_swift_docs] +path = "src/sdks/swift/README.md" +direct_products = [ + "oliphaunt-swift" + ] +release_products = [ + "oliphaunt-swift", + "oliphaunt-react-native" + ] +docs_only = false + +[cases.package_visible_kotlin_docs] +path = "src/sdks/kotlin/README.md" +direct_products = [ + "oliphaunt-kotlin" + ] +release_products = [ + "oliphaunt-kotlin", + "oliphaunt-react-native" + ] +docs_only = false + +[cases.package_visible_react_native_docs] +path = "src/sdks/react-native/README.md" +direct_products = [ + "oliphaunt-react-native" + ] +release_products = [ + "oliphaunt-react-native" + ] +docs_only = false + +[cases.package_visible_typescript_docs] +path = "src/sdks/js/README.md" +direct_products = [ + "oliphaunt-js" + ] +release_products = [ + "oliphaunt-js" + ] +docs_only = false + +[cases.package_visible_wasm_docs] +path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md" +direct_products = [ + "oliphaunt-wasix-rust" + ] +release_products = [ + "oliphaunt-wasix-rust" + ] +docs_only = false + +[cases.benchmark_fixture] +path = "benchmarks/native/sql/benchmark1.sql" +direct_products = [] +release_products = [] +docs_only = false + +[cases.generated_coverage_output] +path = "target/coverage/oliphaunt-rust/summary.json" +direct_products = [] +release_products = [] +docs_only = false + +[cases.generated_docs_output] +path = "src/docs/.next/server/app/page.js" +direct_products = [] +release_products = [] +docs_only = false diff --git a/tools/perf/bench-react-native-expo-android.sh b/tools/perf/bench-react-native-expo-android.sh new file mode 100755 index 00000000..39436ad3 --- /dev/null +++ b/tools/perf/bench-react-native-expo-android.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export OLIPHAUNT_EXPO_ANDROID_RUNNER="${OLIPHAUNT_EXPO_ANDROID_RUNNER:-benchmark}" +export OLIPHAUNT_EXPO_ANDROID_TIMEOUT_SECONDS="${OLIPHAUNT_EXPO_ANDROID_TIMEOUT_SECONDS:-360}" +exec "$script_dir/../../src/sdks/react-native/tools/mobile-drill.sh" android benchmark "$@" diff --git a/tools/perf/bench-react-native-expo-ios.sh b/tools/perf/bench-react-native-expo-ios.sh new file mode 100755 index 00000000..11f88e46 --- /dev/null +++ b/tools/perf/bench-react-native-expo-ios.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export OLIPHAUNT_EXPO_IOS_RUNNER="${OLIPHAUNT_EXPO_IOS_RUNNER:-benchmark}" +export OLIPHAUNT_EXPO_IOS_TIMEOUT_SECONDS="${OLIPHAUNT_EXPO_IOS_TIMEOUT_SECONDS:-360}" +exec "$script_dir/../../src/sdks/react-native/tools/mobile-drill.sh" ios benchmark "$@" diff --git a/tools/perf/check-native-perf-harness.sh b/tools/perf/check-native-perf-harness.sh new file mode 100755 index 00000000..e6a1484f --- /dev/null +++ b/tools/perf/check-native-perf-harness.sh @@ -0,0 +1,1244 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +. "$root/tools/runtime/preflight.sh" + +if ! command -v rg >/dev/null 2>&1; then + echo "missing required command: rg" >&2 + exit 1 +fi +if ! command -v node >/dev/null 2>&1; then + echo "missing required command: node" >&2 + exit 1 +fi + +release_plan="$(tools/perf/matrix/run_native_oliphaunt_matrix.sh --plan-only --skip-build)" +quick_plan="$(tools/perf/matrix/run_native_oliphaunt_matrix.sh --plan-only --quick --skip-build)" +focused_plan="$(tools/perf/matrix/run_native_oliphaunt_matrix.sh --plan-only --quick --skip-build --engines broker --suites streaming --skip-sqlite --skip-prepared)" + +require_plan_text() { + plan="$1" + text="$2" + message="$3" + plan_tmp="$(mktemp)" + printf '%s\n' "$plan" >"$plan_tmp" + if ! rg -q --fixed-strings -- "$text" "$plan_tmp"; then + rm -f "$plan_tmp" + echo "$message" >&2 + echo "missing plan text: $text" >&2 + exit 1 + fi + rm -f "$plan_tmp" +} + +reject_plan_text() { + plan="$1" + text="$2" + message="$3" + plan_tmp="$(mktemp)" + printf '%s\n' "$plan" >"$plan_tmp" + if rg -q --fixed-strings -- "$text" "$plan_tmp"; then + rm -f "$plan_tmp" + echo "$message" >&2 + echo "unexpected plan text: $text" >&2 + exit 1 + fi + rm -f "$plan_tmp" +} + +reject_text() { + pattern="$1" + file="$2" + message="$3" + if rg -q --fixed-strings -- "$pattern" "$file"; then + echo "$message" >&2 + echo "unexpected text '$pattern' in $file" >&2 + exit 1 + fi +} + +require_text() { + pattern="$1" + file="$2" + message="$3" + if ! rg -q --fixed-strings -- "$pattern" "$file"; then + echo "$message" >&2 + echo "missing text '$pattern' in $file" >&2 + exit 1 + fi +} + +extension_probe_root="" +provenance_probe_root="" +mobile_probe_root="" +cleanup_extension_probe() { + if [ -n "$extension_probe_root" ]; then + rm -rf "$extension_probe_root" + fi + if [ -n "$provenance_probe_root" ]; then + rm -rf "$provenance_probe_root" + fi + if [ -n "$mobile_probe_root" ]; then + rm -rf "$mobile_probe_root" + fi +} +trap cleanup_extension_probe EXIT HUP INT TERM + +assert_extension_no_build_guard() { + if [ "$(uname -s)" != "Darwin" ]; then + echo "skipping Darwin-only extension artifact no-build probe on $(uname -s)" + return 0 + fi + + extension_probe_root="$(mktemp -d)" + mkdir -p "$extension_probe_root/out" "$extension_probe_root/install/bin" + printf 'fake liboliphaunt for readiness probe\n' > "$extension_probe_root/out/$(oliphaunt_runtime_host_library_name)" + for tool in initdb postgres; do + printf '#!/bin/sh\nexit 0\n' > "$extension_probe_root/install/bin/$tool" + chmod +x "$extension_probe_root/install/bin/$tool" + done + + set +e + probe_output="$( + OLIPHAUNT_WORK_ROOT="$extension_probe_root" \ + OLIPHAUNT_TRACK_BUILD=never \ + OLIPHAUNT_TRACK_SKIP_HARNESS_GUARD=1 \ + OLIPHAUNT_TRACK_SKIP_CURRENT_GUARD=1 \ + src/runtimes/liboliphaunt/native/tools/check-track.sh extensions 2>&1 + )" + probe_status=$? + set -e + + if [ "$probe_status" -eq 0 ]; then + echo "extension/full validation accepted a core-only native runtime under no-build policy" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + if ! printf '%s\n' "$probe_output" | + rg -q --fixed-strings "missing native extension artifacts for the liboliphaunt extension matrix"; then + echo "extension/full validation failed for the wrong reason under no-build policy" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + + rm -rf "$extension_probe_root" + extension_probe_root="" +} + +assert_extension_current_check_is_no_build() { + if [ "$(uname -s)" != "Darwin" ]; then + echo "skipping Darwin-only extension freshness no-build probe on $(uname -s)" + return 0 + fi + + extension_probe_root="$(mktemp -d)" + + set +e + probe_output="$( + OLIPHAUNT_WORK_ROOT="$extension_probe_root" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --check-extension-artifacts-current 2>&1 + )" + probe_status=$? + set -e + + if [ "$probe_status" -eq 0 ]; then + echo "extension freshness check accepted an empty work root" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + if [ -d "$extension_probe_root/source" ] || [ -d "$extension_probe_root/out" ]; then + echo "extension freshness check created build directories instead of staying no-build" >&2 + find "$extension_probe_root" -maxdepth 2 -print >&2 + exit 1 + fi + if ! printf '%s\n' "$probe_output" | + rg -q --fixed-strings "native extension artifacts are missing or stale"; then + echo "extension freshness check failed with an unexpected diagnostic" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + + rm -rf "$extension_probe_root" + extension_probe_root="" +} + +assert_runtime_no_build_guard() { + extension_probe_root="$(mktemp -d)" + + set +e + probe_output="$( + OLIPHAUNT_WORK_ROOT="$extension_probe_root" \ + OLIPHAUNT_TRACK_BUILD=never \ + OLIPHAUNT_TRACK_SKIP_HARNESS_GUARD=1 \ + src/runtimes/liboliphaunt/native/tools/check-track.sh host-smoke 2>&1 + )" + probe_status=$? + set -e + + if [ "$probe_status" -eq 0 ]; then + echo "host C smoke accepted a missing native runtime under no-build policy" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + if [ -d "$extension_probe_root/source" ] || [ -d "$extension_probe_root/out" ]; then + echo "host C smoke no-build validation created build directories" >&2 + find "$extension_probe_root" -maxdepth 2 -print >&2 + exit 1 + fi + if ! printf '%s\n' "$probe_output" | + rg -q --fixed-strings "native Oliphaunt runtime is missing or stale"; then + echo "rust no-build validation failed with an unexpected diagnostic" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + + rm -rf "$extension_probe_root" + extension_probe_root="" +} + +assert_rust_sdk_extension_readiness_guard() { + extension_probe_root="$(mktemp -d)" + mkdir -p "$extension_probe_root/out" "$extension_probe_root/install/bin" "$extension_probe_root/install/include" + printf 'fake liboliphaunt for Rust SDK readiness probe\n' > "$extension_probe_root/out/$(oliphaunt_runtime_host_library_name)" + for tool in initdb postgres; do + printf '#!/bin/sh\nexit 0\n' > "$extension_probe_root/install/bin/$tool" + chmod +x "$extension_probe_root/install/bin/$tool" + done + cat > "$extension_probe_root/install/bin/pg_config" <<'SH' +#!/bin/sh +if [ "${1:-}" = "--configure" ]; then + printf '%s\n' "'--with-icu'" +fi +SH + chmod +x "$extension_probe_root/install/bin/pg_config" + printf '#define USE_ICU 1\n' > "$extension_probe_root/install/include/pg_config.h" + + set +e + probe_output="$( + OLIPHAUNT_WORK_ROOT="$extension_probe_root" \ + OLIPHAUNT_REQUIRE_NATIVE=1 \ + src/sdks/rust/tools/check-sdk.sh 2>&1 + )" + probe_status=$? + set -e + + if [ "$probe_status" -eq 0 ]; then + echo "Rust SDK validation accepted a core-only native runtime with missing extension artifacts" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + if [ -d "$extension_probe_root/source" ]; then + echo "Rust SDK readiness validation entered the native build path" >&2 + find "$extension_probe_root" -maxdepth 2 -print >&2 + exit 1 + fi + if ! printf '%s\n' "$probe_output" | + rg -q --fixed-strings "native Oliphaunt runtime is incomplete: extension artifacts are missing"; then + echo "Rust SDK extension readiness guard failed with an unexpected diagnostic" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + + rm -rf "$extension_probe_root" + extension_probe_root="" +} + +assert_mobile_footprint_summary_smoke() { + mobile_probe_root="$(mktemp -d)" + mobile_case_dir="$mobile_probe_root/cases/android-safe-shared-32MB-wal--1-minwal-32MB" + mkdir -p "$mobile_case_dir/scratch/reports" "$mobile_case_dir/crash-scratch/reports" + cat >"$mobile_case_dir/case.json" <<'JSON' +{ + "id": "android-safe-shared-32MB-wal--1-minwal-32MB", + "platform": "android", + "durability": "safe", + "runtimeFootprint": "balancedMobile", + "startupGUCs": "shared_buffers=32MB,wal_buffers=-1,min_wal_size=32MB", + "gucs": { + "shared_buffers": "32MB", + "wal_buffers": "-1", + "min_wal_size": "32MB", + "max_wal_size": "64MB", + "wal_segment_size_mb": "4" + }, + "status": "passed" +} +JSON + cat >"$mobile_case_dir/scratch/reports/benchmark-report.json" <<'JSON' +{ + "schemaVersion": 1, + "openMs": 12.5, + "closeMs": 1.5, + "elapsedMs": 250, + "packageSizeReport": { + "packageBytes": 10485760 + }, + "postgresSettings": { + "shared_buffers": "32MB", + "wal_buffers": "-1", + "wal_segment_size": "4MB", + "min_wal_size": "32MB", + "max_wal_size": "64MB", + "synchronous_commit": "off", + "io_method": "sync" + }, + "jsTimerTicks": 42, + "workloads": [ + { + "id": "raw_simple_query_rtt", + "latency": {"p50Ms": 1, "p90Ms": 1.5, "p95Ms": 2, "p99Ms": 3} + }, + { + "id": "typed_select_rtt", + "latency": {"p50Ms": 4, "p90Ms": 4.5, "p95Ms": 5, "p99Ms": 6} + }, + { + "id": "parameterized_select_rtt", + "latency": {"p50Ms": 7, "p90Ms": 7.5, "p95Ms": 8, "p99Ms": 9} + }, + { + "id": "transaction_insert", + "throughput": {"rows": 1000, "totalMs": 100, "rowsPerSecond": 10000} + }, + { + "id": "indexed_lookup", + "latency": {"p50Ms": 9.1, "p90Ms": 9.2, "p95Ms": 9.3, "p99Ms": 9.4} + }, + { + "id": "indexed_aggregate", + "latency": {"p50Ms": 9.5, "p90Ms": 9.6, "p95Ms": 9.7, "p99Ms": 9.8} + }, + { + "id": "indexed_update", + "latency": {"p50Ms": 10, "p90Ms": 10.5, "p95Ms": 11, "p99Ms": 12} + }, + { + "id": "background_checkpoint", + "latency": {"p50Ms": 13, "p90Ms": 13.5, "p95Ms": 14, "p99Ms": 15} + }, + { + "id": "large_result_raw", + "latency": {"p50Ms": 16, "p90Ms": 16.5, "p95Ms": 17, "p99Ms": 18} + } + ], + "sqliteBenchmark": { + "schemaVersion": 1, + "engine": "expo-sqlite", + "openMs": 0.8, + "workloads": [ + { + "id": "sqlite_simple_select_rtt", + "latency": {"p50Ms": 0.15, "p90Ms": 0.25, "p95Ms": 0.35, "p99Ms": 0.45} + }, + { + "id": "sqlite_parameterized_select_rtt", + "latency": {"p50Ms": 0.2, "p90Ms": 0.3, "p95Ms": 0.4, "p99Ms": 0.5} + }, + { + "id": "sqlite_transaction_insert", + "throughput": {"rows": 1000, "totalMs": 50, "rowsPerSecond": 20000} + }, + { + "id": "sqlite_indexed_lookup", + "latency": {"p50Ms": 0.51, "p90Ms": 0.52, "p95Ms": 0.53, "p99Ms": 0.54} + }, + { + "id": "sqlite_indexed_aggregate", + "latency": {"p50Ms": 0.55, "p90Ms": 0.56, "p95Ms": 0.57, "p99Ms": 0.58} + }, + { + "id": "sqlite_indexed_update", + "latency": {"p50Ms": 0.6, "p90Ms": 0.7, "p95Ms": 0.8, "p99Ms": 0.9} + }, + { + "id": "sqlite_wal_checkpoint", + "latency": {"p50Ms": 1.1, "p90Ms": 1.2, "p95Ms": 1.3, "p99Ms": 1.4} + }, + { + "id": "sqlite_large_result", + "latency": {"p50Ms": 1.5, "p90Ms": 1.6, "p95Ms": 1.7, "p99Ms": 1.8} + } + ] + } +} +JSON + cat >"$mobile_case_dir/crash-scratch/reports/crash-report.json" <<'JSON' +{ + "elapsedMs": 88, + "openMs": 21 +} +JSON + cat >"$mobile_case_dir/scratch/reports/benchmark-meminfo.txt" <<'TEXT' +TOTAL PSS: 65536 +TOTAL RSS: 98304 +TEXT + cat >"$mobile_case_dir/scratch/reports/benchmark-process.tsv" <<'TEXT' +pid rss cpu +123 131072 4.5 +TEXT + cat >"$mobile_case_dir/scratch/reports/benchmark-package-sizes.json" <<'JSON' +{ + "apkBytes": 209715200, + "iosAppBytes": 73400320, + "rnPackageBytes": 65536 +} +JSON + + tools/perf/matrix/run_mobile_footprint_matrix.sh \ + --summarize-only \ + --run-id mobile-probe \ + --output-dir "$mobile_probe_root" >/dev/null + + require_text '"rawP50Ms": 1' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include raw query p50 latency" + require_text '"rawP90Ms": 1.5' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include raw query p90 latency" + require_text '"typedP95Ms": 5' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include typed query p95 latency" + require_text '"parameterizedP99Ms": 9' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include parameterized query p99 latency" + require_text '"lookupP90Ms": 9.2' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include indexed lookup latency" + require_text '"aggregateP95Ms": 9.7' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include indexed aggregate latency" + require_text '"max_wal_size": "64MB"' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include WAL maximum tuning" + require_text '"wal_segment_size_mb": "4"' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include template WAL segment size" + require_text '"wal_segment_size": "4MB"' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must preserve effective WAL segment size" + require_text '"androidRssKb": 98304' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include Android RSS" + require_text '"packageBytes": 10485760' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include package bytes" + require_text '"androidApkBytes": 209715200' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include Android APK bytes" + require_text '"iosAppBytes": 73400320' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include iOS app bytes" + require_text '"rnPackageBytes": 65536' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include React Native package bytes" + require_text '"postgresSettings": {' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include effective PostgreSQL settings" + require_text '"shared_buffers": "32MB"' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must preserve effective shared_buffers" + require_text '"sqliteOpenMs": 0.8' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include native-device SQLite open latency" + require_text '"sqliteSimpleP90Ms": 0.25' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include native-device SQLite simple-query latency" + require_text '"sqliteParameterizedP90Ms": 0.3' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include native-device SQLite query latency" + require_text '"sqliteLookupP90Ms": 0.52' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include native-device SQLite lookup latency" + require_text '"sqliteAggregateP95Ms": 0.57' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include native-device SQLite aggregate latency" + require_text '"sqliteCheckpointP90Ms": 1.2' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include native-device SQLite checkpoint latency" + require_text '"sqliteInsertRowsPerSecond": 20000' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include native-device SQLite insert throughput" + require_text '"crashRecoveryOpenMs": 21' "$mobile_probe_root/summary.json" \ + "mobile footprint summary JSON must include crash-recovery reopen latency" + require_text 'Durability | Runtime footprint | Benchmark preset | shared_buffers' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose runtime footprint and benchmark preset next to durability and GUCs" + require_text 'Effective GUCs | Open ms' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose effective PostgreSQL settings" + require_text 'min_wal_size | max_wal_size | WAL segment MB | Effective GUCs | Open ms' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose WAL min/max and template segment tuning columns" + require_text 'Raw p50 ms | Raw p90 ms | Raw p95 ms | Raw p99 ms' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose warm query p50/p90/p95/p99" + require_text 'Crash recovery ms | Crash recovery open ms | Insert rows/s' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose process-death recovery total and reopen latency" + require_text 'SQLite open ms | SQLite simple p50 ms | SQLite simple p90 ms | SQLite simple p95 ms | SQLite simple p99 ms' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose same-device SQLite comparison columns" + require_text 'SQLite lookup p50 ms | SQLite lookup p90 ms | SQLite lookup p95 ms | SQLite lookup p99 ms' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose same-device SQLite indexed lookup columns" + require_text 'SQLite aggregate p50 ms | SQLite aggregate p90 ms | SQLite aggregate p95 ms | SQLite aggregate p99 ms' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose same-device SQLite indexed aggregate columns" + require_text 'Oliphaunt payload MB | Android APK MB | iOS app MB | RN package KB | Android PSS MB' "$mobile_probe_root/summary.md" \ + "mobile footprint summary markdown must expose package and platform memory evidence" + + rm -rf "$mobile_probe_root" + mobile_probe_root="" +} + +assert_mobile_footprint_plan_guard() { + mobile_plan="$( + tools/perf/matrix/run_mobile_footprint_matrix.sh \ + --plan-only \ + --platform android \ + --run-id mobile-plan-probe + )" + mobile_profile_plan="$( + tools/perf/matrix/run_mobile_footprint_matrix.sh \ + --plan-only \ + --quick \ + --platform android \ + --runtime-footprint all \ + --shared-buffers 32MB \ + --wal-buffers -1 \ + --min-wal-size 32MB \ + --max-wal-size default \ + --durability balanced \ + --crash-recovery off \ + --run-id mobile-profile-plan-probe + )" + mobile_filtered_plan="$( + tools/perf/matrix/run_mobile_footprint_matrix.sh \ + --plan-only \ + --quick \ + --platform android \ + --shared-buffers 8MB,32MB \ + --wal-buffers -1 \ + --min-wal-size 32MB \ + --max-wal-size default \ + --durability balanced \ + --crash-recovery off \ + --run-id mobile-filtered-plan-probe + )" + mobile_walseg_plan="$( + tools/perf/matrix/run_mobile_footprint_matrix.sh \ + --plan-only \ + --quick \ + --platform android \ + --shared-buffers 32MB \ + --wal-buffers -1 \ + --min-wal-size 8MB,16MB \ + --max-wal-size 32MB \ + --durability balanced \ + --wal-segsize 4 \ + --crash-recovery off \ + --run-id mobile-walseg-plan-probe + )" + require_plan_text "$mobile_plan" "planned=160" \ + "mobile footprint matrix plan must count only runnable Android cases once" + require_plan_text "$mobile_plan" "runtimeFootprint=balancedMobile" \ + "mobile footprint matrix plan must expose the runtime footprint profile for each case" + require_plan_text "$mobile_plan" "skippedInvalidForWalSegment=240" \ + "mobile footprint matrix plan must report invalid 8MB/16MB WAL-minimum cases" + require_plan_text "$mobile_plan" "skippedInvalidWalRange=80" \ + "mobile footprint matrix plan must report max_wal_size below min_wal_size cases" + require_plan_text "$mobile_plan" "crashCommand=skipped durability=balanced reason=synchronous_commit_off_does_not_guarantee_last_commit" \ + "mobile footprint matrix plan must not advertise balanced durability as crash-recovery evidence" + require_plan_text "$mobile_profile_plan" "planned=3" \ + "mobile footprint matrix all-profile probe must expand a filtered slice across three profiles" + require_plan_text "$mobile_profile_plan" "runtimeFootprint=throughput" \ + "mobile footprint matrix all-profile plan must include the throughput profile" + require_plan_text "$mobile_profile_plan" "runtimeFootprint=smallMobile" \ + "mobile footprint matrix all-profile plan must include the small mobile profile" + require_plan_text "$mobile_filtered_plan" "planned=2" \ + "mobile footprint matrix filters must shrink Android plan counts to the selected GUC slice" + require_plan_text "$mobile_filtered_plan" "benchmarkPreset=quick" \ + "mobile footprint matrix filters must preserve quick preset reporting" + require_plan_text "$mobile_filtered_plan" "shared_buffers=8MB" \ + "mobile footprint matrix filters must include selected shared_buffers values" + require_plan_text "$mobile_filtered_plan" "shared_buffers=32MB" \ + "mobile footprint matrix filters must include every selected shared_buffers value" + require_plan_text "$mobile_filtered_plan" "skippedInvalidForWalSegment=0" \ + "mobile footprint matrix filters must not report skipped WAL-min cases when only valid minima are selected" + require_plan_text "$mobile_walseg_plan" "planned=2" \ + "mobile footprint matrix must run 8MB/16MB minima with a matching smaller WAL segment template" + require_plan_text "$mobile_walseg_plan" "walSegmentSizeMB=4" \ + "mobile footprint matrix plan must report the selected template WAL segment size" + require_plan_text "$mobile_walseg_plan" "OLIPHAUNT_EXPO_MOBILE_WAL_SEGSIZE_MB=4" \ + "mobile footprint matrix plan must pass the template WAL segment size to the Expo harness" + require_plan_text "$mobile_walseg_plan" "min_wal_size=8MB" \ + "mobile footprint matrix plan must include the requested 8MB WAL minimum when segment size makes it valid" +} + +write_release_probe_outputs() { + PROBE_RUN_DIR="$1" PROBE_DIRECT_REGRESSION="${2:-0}" node <<'NODE' +const fs = require('node:fs') +const path = require('node:path') + +const runDir = process.env.PROBE_RUN_DIR +const directRegression = process.env.PROBE_DIRECT_REGRESSION === '1' + +function ensureParent(file) { + fs.mkdirSync(path.dirname(file), { recursive: true }) +} + +function writeJson(name, value) { + const jsonFile = path.join(runDir, `${name}.json`) + const resourceFile = path.join(runDir, `${name}.resource.txt`) + ensureParent(jsonFile) + fs.writeFileSync(jsonFile, `${JSON.stringify(value, null, 2)}\n`) + fs.writeFileSync(resourceFile, '0.01 real\n0.01 user\n0.00 sys\n1024 maximum resident set size\n') +} + +function benchTest(id, elapsedMicros = 1) { + return { + id, + label: 'probe', + unit: 'milliseconds', + operationCount: 1, + sampleCount: 1, + trimmedSampleCount: 1, + elapsedMicros, + averageMicros: elapsedMicros, + minMicros: 1, + p50Micros: elapsedMicros, + p90Micros: elapsedMicros, + p95Micros: elapsedMicros, + p99Micros: elapsedMicros, + } +} + +function benchRun(suite, mode) { + const elapsedMicros = directRegression && suite === 'speed' && mode === 'native_liboliphaunt_direct' + ? 10 + : 1 + const testId = suite === 'speed' ? 'probe_speed_case' : `${suite}_probe` + return { + suite, + mode, + description: 'probe', + openMicros: 1, + connectMicros: null, + setupMicros: 1, + observedServerPeakRssBytes: 1024, + tests: [benchTest(testId, elapsedMicros)], + } +} + +function benchReport(runs) { + return { + wasmerVersion: 'probe', + wasmerWasixVersion: 'probe', + sourceModel: 'probe', + measurementModel: 'probe', + rttIterations: 100, + speedScale: 1, + preloadMicros: 0, + runs, + } +} + +function writeBench(name, runs) { + writeJson(name, benchReport(runs.map(([suite, mode]) => benchRun(suite, mode)))) +} + +function preparedTest(id) { + return { + id, + label: id, + openMicros: 1, + connectMicros: 1, + setupMicros: 1, + prepareMicros: 1, + elapsedMicros: 1, + operationCount: 1, + averageMicros: 1, + } +} + +function preparedReport(modes) { + return { + sourceModel: 'probe', + measurementModel: 'probe', + gateModel: null, + rows: 25000, + runs: modes.map((mode) => ({ + mode, + description: 'probe', + protocolStats: null, + tests: [preparedTest('numeric'), preparedTest('text')], + })), + } +} + +function writePrepared(name, modes) { + writeJson(name, preparedReport(modes)) +} + +function nativeMode(engine) { + return `native_liboliphaunt_${engine}` +} + +function nativeCase(engine, suite) { + if (engine === 'direct') { + return { + rtt: 'native-liboliphaunt-rtt', + speed: 'native-liboliphaunt-speed', + streaming: 'native-liboliphaunt-streaming', + prepared: 'native-liboliphaunt-prepared-direct', + backup: 'native-liboliphaunt-backup', + }[suite] + } + return { + rtt: `native-liboliphaunt-${engine}-rtt`, + speed: `native-liboliphaunt-${engine}-speed`, + streaming: `native-liboliphaunt-${engine}-streaming`, + prepared: `native-liboliphaunt-prepared-${engine}`, + backup: `native-liboliphaunt-${engine}-backup`, + }[suite] +} + +function nativePreparedModes(engine) { + const mode = nativeMode(engine) + return [`${mode}_prepared`, `${mode}_pipelined_prepared`] +} + +function repeat(index, count) { + return String(index).padStart(String(count).length, '0') +} + +const engines = ['direct', 'broker', 'server'] + +fs.writeFileSync( + path.join(runDir, 'artifact-sizes.json'), + `${JSON.stringify({ + artifacts: [ + { name: 'liboliphaunt-native', path: '/probe/liboliphaunt.dylib', bytes: 1 }, + { name: 'embedded-modules', path: '/probe/modules', bytes: 1 }, + { name: 'native-postgres-install', path: '/probe/install', bytes: 1 }, + ], + }, null, 2)}\n`, +) +fs.writeFileSync(path.join(runDir, 'report.md'), '# Probe native performance report\n') + +for (const engine of engines) { + for (const suite of ['rtt', 'speed', 'streaming']) { + writeBench(nativeCase(engine, suite), [[suite, nativeMode(engine)]]) + } + writeBench(nativeCase(engine, 'backup'), [['backup-restore', nativeMode(engine)]]) + writePrepared(nativeCase(engine, 'prepared'), nativePreparedModes(engine)) +} + +writeBench('native-postgres-tokio-all', [ + ['rtt', 'native_postgres'], + ['speed', 'native_postgres'], +]) +writeBench('native-postgres-sqlx-all', [ + ['rtt', 'native_postgres_sqlx'], + ['speed', 'native_postgres_sqlx'], +]) +writeBench('native-postgres-streaming', [['streaming', 'native_postgres_raw']]) +writeBench('native-postgres-backup', [ + ['backup-restore', 'native_postgres'], + ['backup-restore', 'native_postgres_physical'], +]) +writeBench('sqlite-speed', [['speed', 'sqlite']]) +writeBench('sqlite-backup', [['backup-restore', 'sqlite']]) +writePrepared('native-postgres-prepared', [ + 'native_postgres_tokio_prepared', + 'native_postgres_tokio_pipelined_prepared', +]) + +for (let index = 1; index <= 10; index += 1) { + const suffix = repeat(index, 10) + for (const engine of engines) { + writeBench(`repeats/${nativeCase(engine, 'rtt')}-${suffix}`, [['rtt', nativeMode(engine)]]) + writeBench(`repeats/${nativeCase(engine, 'backup')}-${suffix}`, [ + ['backup-restore', nativeMode(engine)], + ]) + writePrepared( + `repeats/${nativeCase(engine, 'prepared')}-${suffix}`, + nativePreparedModes(engine), + ) + } + writeBench(`repeats/native-postgres-tokio-rtt-${suffix}`, [['rtt', 'native_postgres']]) + writeBench(`repeats/native-postgres-backup-${suffix}`, [ + ['backup-restore', 'native_postgres'], + ['backup-restore', 'native_postgres_physical'], + ]) + writeBench(`repeats/sqlite-backup-${suffix}`, [['backup-restore', 'sqlite']]) + writePrepared(`repeats/native-postgres-prepared-${suffix}`, [ + 'native_postgres_tokio_prepared', + 'native_postgres_tokio_pipelined_prepared', + ]) +} + +for (let index = 1; index <= 20; index += 1) { + const suffix = repeat(index, 20) + for (const engine of engines) { + writeBench(`repeats/${nativeCase(engine, 'speed')}-${suffix}`, [ + ['speed', nativeMode(engine)], + ]) + } + writeBench(`repeats/native-postgres-tokio-speed-${suffix}`, [['speed', 'native_postgres']]) + writeBench(`repeats/sqlite-speed-${suffix}`, [['speed', 'sqlite']]) +} +NODE +} + +assert_provenance_release_gate() { + provenance_probe_root="$(mktemp -d)" + + node tools/perf/matrix/native_oliphaunt_provenance.mjs write \ + --run-dir "$provenance_probe_root/release" \ + --repo-root "$root" \ + --run-id perf-release-probe \ + --native-engines direct,broker,server \ + --suites rtt,speed,streaming,prepared,backup \ + --durability safe \ + --rtt-iterations 100 \ + --rtt-repeats 10 \ + --prepared-rows 25000 \ + --prepared-repeats 10 \ + --speed-repeats 20 \ + --backup-repeats 10 \ + --run-sqlite 1 \ + --run-prepared 1 \ + --release-evidence 1 \ + --partial-report 0 \ + --diagnostic-run 0 \ + --release-min-rtt-iterations 100 \ + --release-min-rtt-repeats 10 \ + --release-min-prepared-rows 25000 \ + --release-min-prepared-repeats 10 \ + --release-min-speed-repeats 20 \ + --release-min-backup-repeats 10 \ + >/dev/null + write_release_probe_outputs "$provenance_probe_root/release" + node tools/perf/matrix/native_oliphaunt_provenance.mjs verify \ + --run-dir "$provenance_probe_root/release" \ + --require-release-evidence \ + >/dev/null + + node tools/perf/matrix/native_oliphaunt_provenance.mjs write \ + --run-dir "$provenance_probe_root/diagnostic" \ + --repo-root "$root" \ + --run-id perf-diagnostic-probe \ + --native-engines broker \ + --suites streaming \ + --durability safe \ + --rtt-iterations 10 \ + --rtt-repeats 1 \ + --prepared-rows 1000 \ + --prepared-repeats 1 \ + --speed-repeats 1 \ + --backup-repeats 1 \ + --run-sqlite 0 \ + --run-prepared 0 \ + --release-evidence 0 \ + --partial-report 1 \ + --diagnostic-run 1 \ + --release-min-rtt-iterations 100 \ + --release-min-rtt-repeats 10 \ + --release-min-prepared-rows 25000 \ + --release-min-prepared-repeats 10 \ + --release-min-speed-repeats 20 \ + --release-min-backup-repeats 10 \ + >/dev/null + + set +e + probe_output="$( + node tools/perf/matrix/native_oliphaunt_provenance.mjs verify \ + --run-dir "$provenance_probe_root/diagnostic" \ + --require-release-evidence 2>&1 + )" + probe_status=$? + set -e + + if [ "$probe_status" -eq 0 ]; then + echo "release-evidence provenance gate accepted a diagnostic perf report" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + if ! printf '%s\n' "$probe_output" | + rg -q --fixed-strings "benchmark provenance is not marked as releaseEvidence=true"; then + echo "release-evidence provenance gate failed with an unexpected diagnostic" >&2 + printf '%s\n' "$probe_output" >&2 + exit 1 + fi + + node tools/perf/matrix/native_oliphaunt_provenance.mjs verify \ + --run-dir "$provenance_probe_root/diagnostic" \ + >/dev/null + + summary_output="$( + node tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + --run-dir "$provenance_probe_root/release" \ + --run-id perf-release-probe \ + --postgres-version "postgres (PostgreSQL) 18.4" \ + --durability safe \ + --runtime-footprint throughput + )" + require_plan_text "$summary_output" "throughput p50 ops/s" \ + "native perf summary must render speed throughput columns" + require_plan_text "$summary_output" "tail throughput p10 MB/s" \ + "native perf summary must render backup throughput columns" + require_plan_text "$summary_output" "payload p50 MB" \ + "native perf summary must render backup payload-size columns" + require_plan_text "$summary_output" "Speed tail throughput p10" \ + "native perf summary must render throughput parity gate" + require_plan_text "$summary_output" "Backup/restore physical total p90" \ + "native perf summary must gate backup totals against the native PostgreSQL physical control" + require_plan_text "$summary_output" "Native Postgres physical archive" \ + "native perf summary must render the native PostgreSQL physical backup control" + + node tools/perf/matrix/native_oliphaunt_provenance.mjs write \ + --run-dir "$provenance_probe_root/regression" \ + --repo-root "$root" \ + --run-id perf-regression-probe \ + --native-engines direct,broker,server \ + --suites rtt,speed,streaming,prepared,backup \ + --durability safe \ + --rtt-iterations 100 \ + --rtt-repeats 10 \ + --prepared-rows 25000 \ + --prepared-repeats 10 \ + --speed-repeats 20 \ + --backup-repeats 10 \ + --run-sqlite 1 \ + --run-prepared 1 \ + --release-evidence 1 \ + --partial-report 0 \ + --diagnostic-run 0 \ + --release-min-rtt-iterations 100 \ + --release-min-rtt-repeats 10 \ + --release-min-prepared-rows 25000 \ + --release-min-prepared-repeats 10 \ + --release-min-speed-repeats 20 \ + --release-min-backup-repeats 10 \ + >/dev/null + write_release_probe_outputs "$provenance_probe_root/regression" 1 + node tools/perf/matrix/native_oliphaunt_provenance.mjs verify \ + --run-dir "$provenance_probe_root/regression" \ + --require-release-evidence \ + >/dev/null + regression_summary="$( + node tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + --run-dir "$provenance_probe_root/regression" \ + --run-id perf-regression-probe \ + --postgres-version "postgres (PostgreSQL) 18.4" \ + --durability safe \ + --runtime-footprint throughput + )" + require_plan_text "$regression_summary" "## Native Direct Regression Diagnostics" \ + "native perf summary must render diagnostic section when native-direct gates miss" + require_plan_text "$regression_summary" "Speed-case diagnostic commands:" \ + "native perf summary must render speed-case diagnostic commands when speed cases miss" + require_plan_text "$regression_summary" "tools/perf/matrix/run_native_speed_diagnostics.sh --ids probe_speed_case --repeats 10 --skip-build" \ + "native perf summary must render the repeated speed diagnostic command" + require_plan_text "$regression_summary" "cargo run --release -p oliphaunt-perf -- diagnose-speed-cases --engine native-liboliphaunt --ids probe_speed_case" \ + "native perf summary must render the liboliphaunt speed-case diagnostic command" + require_plan_text "$regression_summary" "cargo run --release -p oliphaunt-perf -- diagnose-speed-cases --engine native-postgres --ids probe_speed_case" \ + "native perf summary must render the native PostgreSQL speed-case diagnostic command" + require_plan_text "$regression_summary" 'Run `oliphaunt-perf diagnose-speed-cases` for the missed case ids below' \ + "native perf summary must explain the missed speed-suite diagnostic path" + + rm -rf "$provenance_probe_root" + provenance_probe_root="" +} + +require_plan_text "$release_plan" "nativeOnly=true" "native performance plan must declare native-only scope" +require_plan_text "$release_plan" "legacyWasixControls=false" "native performance plan must not include WASIX controls" +require_plan_text "$release_plan" "nativeEngines=direct,broker,server" "native performance full plan must cover all native engines by default" +require_plan_text "$release_plan" "suites=rtt,speed,streaming,prepared,backup" "native performance full plan must cover all suites by default" +require_plan_text "$release_plan" "releaseEvidence=1" "native performance default plan must be classified as release evidence" +require_plan_text "$release_plan" "partialReport=0" "native performance default plan must not be classified as partial" +require_plan_text "$release_plan" "diagnosticRun=0" "native performance default plan must not be classified as diagnostic" +require_plan_text "$release_plan" "releaseMinRttIterations=100" "native performance plan must publish release RTT sample minimum" +require_plan_text "$release_plan" "releaseMinRttRepeats=10" "native performance plan must publish release RTT repeat minimum" +require_plan_text "$release_plan" "releaseMinPreparedRows=25000" "native performance plan must publish release prepared-row minimum" +require_plan_text "$release_plan" "releaseMinPreparedRepeats=10" "native performance plan must publish release prepared repeat minimum" +require_plan_text "$release_plan" "releaseMinSpeedRepeats=20" "native performance plan must publish release speed repeat minimum" +require_plan_text "$release_plan" "releaseMinBackupRepeats=10" "native performance plan must publish release backup/restore repeat minimum" +require_plan_text "$release_plan" "rttIterations=100" "native performance default plan must meet release RTT sample minimum" +require_plan_text "$release_plan" "rttRepeats=10" "native performance default plan must meet release RTT repeat minimum" +require_plan_text "$release_plan" "preparedRows=25000" "native performance default plan must meet release prepared-row minimum" +require_plan_text "$release_plan" "preparedRepeats=10" "native performance default plan must meet release prepared repeat minimum" +require_plan_text "$release_plan" "speedRepeats=20" "native performance default plan must meet release speed repeat minimum" +require_plan_text "$release_plan" "backupRepeats=10" "native performance default plan must meet release backup/restore repeat minimum" +require_plan_text "$release_plan" "perfRunnerBuildCommand=cargo build --release -p oliphaunt-perf -p oliphaunt --bins" \ + "native performance plan must opt in to oliphaunt-perf support explicitly" +require_plan_text "$release_plan" "case=native-liboliphaunt-rtt" "native performance plan must cover direct RTT" +require_plan_text "$release_plan" "case=native-liboliphaunt-broker-rtt" "native performance plan must cover broker RTT" +require_plan_text "$release_plan" "case=native-liboliphaunt-server-rtt" "native performance plan must cover server RTT" +require_plan_text "$release_plan" "case=native-liboliphaunt-backup" "native performance plan must cover direct backup/restore" +require_plan_text "$release_plan" "case=native-liboliphaunt-broker-backup" "native performance plan must cover broker backup/restore" +require_plan_text "$release_plan" "case=native-liboliphaunt-server-backup" "native performance plan must cover server backup/restore" +require_plan_text "$release_plan" "case=native-postgres-tokio-all" "native performance plan must include native PostgreSQL control" +require_plan_text "$release_plan" "case=native-postgres-backup" "native performance plan must include native PostgreSQL backup/restore control" +require_plan_text "$release_plan" "case=sqlite-backup" "native performance plan must include SQLite backup/restore control" + +require_plan_text "$quick_plan" "nativeEngines=direct,broker,server" "quick native performance plan must retain all engines" +require_plan_text "$quick_plan" "suites=rtt,speed,streaming,prepared,backup" "quick native performance plan must retain all suites" +require_plan_text "$quick_plan" "releaseEvidence=0" "quick native performance plan must not be classified as release evidence" +require_plan_text "$quick_plan" "partialReport=0" "quick native performance plan must remain full coverage" +require_plan_text "$quick_plan" "diagnosticRun=1" "quick native performance plan must be classified as diagnostic" + +require_plan_text "$focused_plan" "nativeEngines=broker" "focused native performance plan must preserve selected engine" +require_plan_text "$focused_plan" "suites=streaming" "focused native performance plan must preserve selected suite" +require_plan_text "$focused_plan" "releaseEvidence=0" "focused native performance plan must not be classified as release evidence" +require_plan_text "$focused_plan" "partialReport=1" "focused native performance plan must be classified as partial" +require_plan_text "$focused_plan" "diagnosticRun=1" "focused native performance plan must be classified as diagnostic" +require_plan_text "$focused_plan" "runSqlite=0" "focused streaming plan must not include SQLite" +require_plan_text "$focused_plan" "runPrepared=0" "focused streaming plan must not include prepared updates" +require_plan_text "$focused_plan" "case=native-liboliphaunt-broker-streaming" "focused native performance plan must include selected broker streaming case" +require_plan_text "$focused_plan" "case=native-postgres-streaming" "focused native performance plan must include native PostgreSQL streaming control" +reject_plan_text "$focused_plan" "case=native-liboliphaunt-rtt" "focused native performance plan must not include direct RTT" +reject_plan_text "$focused_plan" "case=native-liboliphaunt-server-streaming" "focused native performance plan must not include unselected server engine" +reject_plan_text "$focused_plan" "case=sqlite-speed" "focused native performance plan must not include SQLite speed" +reject_plan_text "$focused_plan" "case=native-liboliphaunt-prepared-broker" "focused native performance plan must not include prepared updates" + +reject_text "WASIX controls enabled by default" README.md \ + "README must not describe native perf as a WASIX-control matrix" +reject_text "--skip-wasix" README.md \ + "README must not require a skip-WASIX flag for native perf" +reject_text "--skip-wasix" src/docs/content/reference/performance.mdx \ + "performance docs must not require a skip-WASIX flag for native perf" +require_text 'command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh quick"' src/runtimes/liboliphaunt/native/moon.yml \ + "liboliphaunt smoke must delegate to the reusable native-only product harness" +require_text 'command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh host-smoke"' src/runtimes/liboliphaunt/native/moon.yml \ + "liboliphaunt test must expose a fast cross-platform host C ABI smoke lane" +require_text 'OLIPHAUNT_TRACK_BUILD: "never"' src/runtimes/liboliphaunt/native/moon.yml \ + "liboliphaunt test must fail fast instead of entering the native build path" +require_text 'cargo test -p oliphaunt --locked \' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native Rust track validation must run selected Rust targets through one cargo invocation" +require_text '--test native_sql_regression \' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native Rust track validation must include native SQL regression in the combined invocation" +reject_text 'cargo test -p oliphaunt --test sdk_shape --locked' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native Rust track validation must not split sdk_shape into a separate cargo invocation" +require_text 'cargo test -p oliphaunt --locked \' src/sdks/rust/tools/check-sdk.sh \ + "Rust SDK validation must run selected non-doc targets through one cargo invocation" +reject_text 'cargo test -p oliphaunt --test native_root_locking --locked' src/sdks/rust/tools/check-sdk.sh \ + "Rust SDK validation must not split native_root_locking into a separate cargo invocation" +require_text '--print-required-extension-artifacts' tools/runtime/preflight.sh \ + "shared runtime preflight must use the native build script's complete extension artifact inventory" +require_text 'oliphaunt_runtime_native_host_extensions_ready()' tools/runtime/preflight.sh \ + "shared runtime preflight must treat native extension artifacts as part of runtime readiness" +require_text 'fcntl.flock' tools/runtime/with-native-runtime-lock.py \ + "shared native runtime probes must use an OS-level lock instead of ad hoc task-ordering" +require_text 'msvcrt.locking' tools/runtime/with-native-runtime-lock.py \ + "shared native runtime probes must use an OS-level lock on Windows runners" +require_text 'native_runtime_lock cargo test -p oliphaunt --locked \' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "liboliphaunt native Rust probes must be serialized across parallel Moon release lanes" +require_text 'native_runtime_lock node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "liboliphaunt host C ABI smoke must be serialized across parallel Moon release lanes" +require_text 'command: "bash src/extensions/artifacts/native/tools/check-release-artifacts.sh"' src/extensions/artifacts/native/moon.yml \ + "native extension artifact release-check must validate the native extension matrix through extension-owned tooling" +require_text "env.OLIPHAUNT_BUILD_EXTENSIONS ??= '0'" src/runtimes/liboliphaunt/native/tools/build-release-runtime.mjs \ + "liboliphaunt core release-runtime producer must not build optional extension artifacts by default" +require_text 'export OLIPHAUNT_BUILD_EXTENSIONS="${OLIPHAUNT_BUILD_EXTENSIONS:-1}"' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "liboliphaunt extension validation must opt into native extension artifacts explicitly" +reject_text 'command: "src/runtimes/liboliphaunt/native/tools/check-track.sh full"' src/runtimes/liboliphaunt/native/moon.yml \ + "liboliphaunt release-check must not use the legacy full aggregate now that SDK release checks are first-class Moon product tasks" +require_text '- "liboliphaunt-native:release-runtime"' src/sdks/rust/moon.yml \ + "Rust SDK release-check must consume the liboliphaunt runtime producer" +require_text 'native_runtime_lock cargo nextest run -p oliphaunt --locked --profile ci' src/sdks/rust/tools/check-sdk.sh \ + "Rust SDK native-capable nextest runs must serialize with liboliphaunt native probes" +require_text '- "liboliphaunt-native:release-runtime"' src/sdks/swift/moon.yml \ + "Swift SDK release-check must consume the liboliphaunt runtime producer" +require_text '- "liboliphaunt-native:release-runtime"' src/sdks/kotlin/moon.yml \ + "Kotlin SDK release-check must consume the liboliphaunt runtime producer" +require_text 'run_native_backlog_guard' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native track validation must guard the maintainer backlog against legacy runtime drift" +require_text 'Native Product Backlog' docs/internal/TODO.md \ + "maintainer TODO must describe the native product backlog" +reject_text "route native product work back to WASIX" docs/internal/TODO.md \ + "maintainer TODO must not route native product work back to WASIX" +reject_text "WASIX fallback" docs/internal/TODO.md \ + "maintainer TODO must not make native product readiness depend on a WASIX fallback" +reject_text "--skip-wasix" docs/internal/TODO.md \ + "maintainer TODO must not describe native validation as a skip-WASIX matrix" +reject_text "Wasmer" docs/internal/TODO.md \ + "maintainer TODO must not route native product work back to Wasmer" +require_text 'moon run liboliphaunt-native:test' README.md \ + "README must advertise the no-build native product inner loop" +require_text 'moon run liboliphaunt-native:test' docs/maintainers/development.md \ + "development docs must advertise the no-build native product inner loop" +require_text 'normal extension files, and embedded' docs/maintainers/development.md \ + "development docs must state that rust-sdk native reuse requires complete extension artifacts" +require_text '--require-release-evidence' tools/perf/check-native-perf-report.sh \ + "native perf report validation must require release-evidence provenance by default" +require_text 'OLIPHAUNT_PERF_ALLOW_DIAGNOSTIC' tools/perf/check-native-perf-report.sh \ + "native perf report validation must require an explicit diagnostic override" +require_text 'benchmarkReleaseOutputFailures' tools/perf/matrix/native_oliphaunt_provenance.mjs \ + "native perf provenance validation must verify release raw benchmark outputs" +require_text 'p99_micros' tools/perf/runner/src/report.rs \ + "native benchmark JSON must include p99 tail latency" +require_text 'median p99 us' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report p99 RTT tail latency" +require_text 'suite p99 s' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report p99 speed-suite tail latency" +require_text 'total p99 s' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report p99 backup/restore tail latency" +require_text 'p99 observed server RSS MB' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report p99 broker/server child RSS" +require_text 'p99 command CPU s' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report p99 prepared-update CPU" +require_text 'throughput p50 ops/s' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report speed throughput" +require_text 'tail throughput p10 MB/s' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report backup/restore tail throughput" +require_text 'payload p50 MB' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native benchmark summary must report backup/restore payload size" +require_text 'Speed tail throughput p10' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native direct gate must include throughput parity" +require_text 'Backup/restore physical total p90' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native direct gate must compare physical backup totals against a physical native PostgreSQL control" +require_text 'native_postgres_physical' tools/perf/runner/src/native_postgres.rs \ + "native Postgres benchmark must expose a physical backup/restore control" +require_text 'native_postgres_physical' tools/perf/matrix/native_oliphaunt_provenance.mjs \ + "native perf provenance must require the native PostgreSQL physical backup/restore control" +require_text 'Native Direct Regression Diagnostics' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native perf report must include diagnostics for native-direct gate misses" +require_text 'oliphaunt-perf diagnose-speed-cases' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native perf report must include liboliphaunt speed-case diagnostic commands" +require_text 'cargo run --release -p oliphaunt-perf -- diagnose-speed-cases --engine native-postgres' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native perf report must include native PostgreSQL speed-case diagnostic commands" +require_text 'run_native_speed_diagnostics.sh' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native perf report must include repeated speed diagnostic commands" +require_text "oliphaunt.native-speed-diagnostics.v1" tools/perf/matrix/summarize_native_speed_diagnostics.mjs \ + "native speed diagnostic summary must write a versioned schema" +require_text 'NativeDirect diagnostics run one fresh process per case/repeat' tools/perf/matrix/summarize_native_speed_diagnostics.mjs \ + "native speed diagnostic summary must document direct process-lifetime semantics" +require_text 'p50/p90/p95/p99 latency, suite totals, throughput' src/docs/content/reference/performance.mdx \ + "performance docs must document p99 tail latency and throughput reporting" +require_text 'Native Direct Regression Diagnostics' src/docs/content/reference/performance.mdx \ + "performance docs must document native-direct regression diagnostics" +require_text '--runtime-footprint' tools/perf/runner/src/main.rs \ + "native perf runner must expose runtime footprint profile sweeps" +require_text '--startup-guc' tools/perf/runner/src/main.rs \ + "native perf runner must expose explicit PostgreSQL startup GUC sweeps" +require_text 'runtimeFootprint' tools/perf/matrix/run_native_oliphaunt_matrix.sh \ + "native perf matrix plan must record runtime footprint profile" +require_text 'startupGucs' tools/perf/matrix/run_native_oliphaunt_matrix.sh \ + "native perf matrix plan must record startup GUC overrides" +require_text 'runtimeFootprint' tools/perf/matrix/native_oliphaunt_provenance.mjs \ + "native perf provenance must record runtime footprint profile" +require_text 'Native runtime footprint profile' tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs \ + "native perf report must state the runtime footprint profile" +require_text 'min_wal_size=8MB' src/docs/content/reference/performance.mdx \ + "performance docs must explain invalid below-segment WAL-size experiments" +require_text '#!/usr/bin/env bash' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix runner must exist" +require_text 'shared_buffers=(8MB 16MB 32MB 64MB 128MB)' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must sweep requested shared_buffers values" +require_text 'wal_buffers=(-1 256kB 1MB 4MB)' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must sweep requested wal_buffers values" +require_text 'min_wal_sizes=(8MB 16MB 32MB 80MB)' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must sweep requested min_wal_size values" +require_text 'max_wal_sizes=(32MB 64MB default)' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must sweep requested max_wal_size values and preserve the default baseline" +require_text 'skippedInvalidWalRange' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must reject impossible max_wal_size below min_wal_size cases" +require_text 'durabilities=(safe balanced)' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must sweep Safe and Balanced durability" +require_text '--shared-buffers VALUES' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must expose shared_buffers filters for measured tuning slices" +require_text '--wal-buffers VALUES' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must expose wal_buffers filters for measured tuning slices" +require_text '--min-wal-size VALUES' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must expose min_wal_size filters for measured tuning slices" +require_text '--max-wal-size VALUES' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must expose max_wal_size filters for measured tuning slices" +require_text '--wal-segsize MB' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must expose template WAL segment size for 8/16MB min_wal_size experiments" +require_text '--durability VALUES' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must expose durability filters for measured tuning slices" +require_text 'OLIPHAUNT_EXPO_MOBILE_BENCHMARK_PRESET=$benchmark_preset' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix quick mode must propagate an installed-app benchmark preset" +require_text '--runtime-footprint PROFILE' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must expose runtime footprint selection" +require_text 'normalize_runtime_footprints "$runtime_footprints_raw"' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix must validate runtime footprint selections" +require_text "'Runtime footprint'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose the runtime footprint profile and benchmark preset" +require_text "'Benchmark preset'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose the installed-app benchmark preset" +require_text "'Effective GUCs'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose effective PostgreSQL settings" +require_text 'runtimeFootprint=%s' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix plan must expose the runtime footprint profile" +require_text "'WAL segment MB'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose WAL min/max and segment tuning columns" +require_text "'Raw p50 ms'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose warm query p50/p90/p95/p99 columns" +require_text "'Raw p99 ms'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose warm query tail-latency columns" +require_text 'Lookup p50 ms' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose indexed lookup latency columns" +require_text 'Aggregate p50 ms' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose indexed aggregate latency columns" +require_text 'SQLite lookup p50 ms' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose SQLite indexed lookup comparison columns" +require_text 'SQLite aggregate p50 ms' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose SQLite indexed aggregate comparison columns" +require_text 'SQLite large result p99 ms' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose full SQLite large-result latency distribution" +require_text 'Crash recovery open ms' tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose crash-recovery reopen latency" +require_text "'Oliphaunt payload MB'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose package and platform memory columns" +require_text "'Android APK MB'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose Android package size columns" +require_text "'iOS app MB'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose iOS app size columns" +require_text "'Android PSS MB'" tools/perf/matrix/run_mobile_footprint_matrix.sh \ + "mobile footprint matrix summary must expose Android PSS columns" +require_text 'liboliphauntBenchmarkPreset' src/sdks/react-native/tools/expo-runner-ios-installed-app.sh \ + "iOS Expo benchmark harness must pass the benchmark preset into the installed app" +require_text 'iPhoneOS builds require a local Apple Development signing identity' src/sdks/react-native/tools/expo-runner-ios-device.sh \ + "iOS physical-device benchmark harness must fail fast when signing is not configured" +require_text 'is_physical_ios_launch' src/sdks/react-native/tools/expo-ios-runner.sh \ + "iOS harness must separate physical install/launch preflight from iPhoneOS build-only validation" +require_text 'printf '"'"'id=%s\n'"'"' "$(select_ios_physical_device_id)"' src/sdks/react-native/tools/expo-runner-ios-device.sh \ + "iOS physical-device benchmark harness must build for the selected device so Xcode can register/provision it" +require_text 'OLIPHAUNT_EXPO_IOS_CODE_SIGNING_ALLOWED=NO for install/launch benchmarks' src/sdks/react-native/tools/expo-runner-ios-device.sh \ + "iOS physical-device benchmark harness must reject unsigned install/launch runs" +require_text 'physical iOS runs require Developer Mode enabled' src/sdks/react-native/tools/expo-runner-ios-device.sh \ + "iOS physical-device benchmark harness must fail fast when Developer Mode is disabled" +require_text 'physical iOS runs require Developer Disk Image services' src/sdks/react-native/tools/expo-runner-ios-device.sh \ + "iOS physical-device benchmark harness must fail fast when Developer Disk Image services are unavailable" +require_text '-allowProvisioningUpdates' src/sdks/react-native/tools/expo-ios-runner.sh \ + "iOS physical-device benchmark harness must support explicit automatic provisioning" +require_text 'crash_root_suffix' src/sdks/react-native/tools/expo-ios-runner.sh \ + "iOS crash recovery roots must be isolated per scratch run" +require_text 'oliphaunt-crash-recovery-root-$crash_root_suffix' src/sdks/react-native/tools/expo-ios-runner.sh \ + "iOS crash recovery must avoid stale persistent roots across benchmark runs" +require_text 'rm -rf "$root_path"' src/sdks/react-native/tools/expo-runner-ios-installed-app.sh \ + "iOS simulator crash recovery must clear the per-run root before the write phase" +require_text 'liboliphauntBenchmarkPreset' src/sdks/react-native/tools/expo-runner-android-device.sh \ + "Android Expo benchmark harness must pass the benchmark preset into the installed app" +require_text 'crash_root_suffix' src/sdks/react-native/tools/expo-android-runner.sh \ + "Android crash recovery roots must be isolated per scratch run" +require_text 'oliphaunt-crash-recovery-root-$crash_root_suffix' src/sdks/react-native/tools/expo-android-runner.sh \ + "Android crash recovery must avoid stale persistent roots across benchmark runs" +require_text 'rm -rf "$crash_root"' src/sdks/react-native/tools/expo-runner-android-device.sh \ + "Android crash recovery must clear the per-run root before the write phase" +require_text 'benchmarkOptionsForPreset' src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx \ + "Expo benchmark app must size benchmark workloads from a named preset" +require_text 'refreshing native extension artifacts through fingerprinted build script' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native extension/full validation must refresh extension artifacts through the fingerprinted build script" +require_text '--check-extension-artifacts-current' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native extension/full validation must check extension freshness before entering the build path" +require_text '--check-oliphaunt-current' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native validation must check liboliphaunt dylib freshness before trusting no-build artifacts" +require_text '--check-oliphaunt-current' src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh \ + "native build script must expose a no-build liboliphaunt dylib freshness check" +require_text 'missing native extension artifacts for the liboliphaunt extension matrix' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native extension/full validation must fail fast when extension artifacts are absent under no-build policy" +require_text 'OLIPHAUNT_TRACK_SKIP_HARNESS_GUARD' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native track harness must expose an internal recursion guard for fast harness self-tests" +require_text 'OLIPHAUNT_TRACK_SKIP_CURRENT_GUARD' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native track harness must expose an internal current-artifact guard bypass for focused harness self-tests" +require_text '--print-required-extension-artifacts' src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh \ + "native build script must expose required extension artifact metadata without rebuilding" +require_text '--check-extension-artifacts-current' src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh \ + "native build script must expose a no-build native extension freshness check" +require_text '--print-required-extension-artifacts' src/runtimes/liboliphaunt/native/tools/check-track.sh \ + "native extension/full validation must use the build script's complete extension artifact inventory" +required_extension_artifacts="$(src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh --print-required-extension-artifacts)" +require_plan_text "$required_extension_artifacts" "control:pgtap" \ + "extension artifact inventory must include SQL-only pgtap control assets" +require_plan_text "$required_extension_artifacts" "module:_int" \ + "extension artifact inventory must include module stems whose names differ from extension ids" +require_plan_text "$required_extension_artifacts" "module:vector" \ + "extension artifact inventory must include vector native module assets" +assert_extension_current_check_is_no_build +assert_runtime_no_build_guard +assert_rust_sdk_extension_readiness_guard +assert_extension_no_build_guard +assert_mobile_footprint_plan_guard +assert_mobile_footprint_summary_smoke +assert_provenance_release_gate + +printf '\nNative performance and validation harness checks passed.\n' diff --git a/tools/perf/check-native-perf-report.sh b/tools/perf/check-native-perf-report.sh new file mode 100755 index 00000000..30f352e0 --- /dev/null +++ b/tools/perf/check-native-perf-report.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +if [ -z "${OLIPHAUNT_PERF_RUN_DIR:-}" ]; then + echo "OLIPHAUNT_PERF_RUN_DIR must point at a target/perf/native-liboliphaunt-* run directory" >&2 + exit 2 +fi + +args=(verify --run-dir "$OLIPHAUNT_PERF_RUN_DIR") +if [ "${OLIPHAUNT_PERF_ALLOW_DIAGNOSTIC:-0}" = "1" ]; then + node tools/perf/matrix/native_oliphaunt_provenance.mjs "${args[@]}" +else + node tools/perf/matrix/native_oliphaunt_provenance.mjs "${args[@]}" --require-release-evidence +fi diff --git a/scripts/perf/build_bench_matrix.mjs b/tools/perf/matrix/build_bench_matrix.mjs similarity index 77% rename from scripts/perf/build_bench_matrix.mjs rename to tools/perf/matrix/build_bench_matrix.mjs index e97e7538..92003fa3 100644 --- a/scripts/perf/build_bench_matrix.mjs +++ b/tools/perf/matrix/build_bench_matrix.mjs @@ -114,8 +114,8 @@ async function main() { const oxideSpeedSqlx = collectRun(oxide, 'speed', 'server_sqlx') const nativeRttSqlx = collectRun(native, 'rtt', 'native_postgres_sqlx') const nativeSpeedSqlx = collectRun(native, 'speed', 'native_postgres_sqlx') - const nodeRttNodefsSqlx = collectRun(node, 'rtt', 'pglite_nodefs_sqlx') - const nodeSpeedNodefsSqlx = collectRun(node, 'speed', 'pglite_nodefs_sqlx') + const legacyRttSqlx = collectRun(node, 'rtt', 'legacy_wasix_sqlx') + const legacySpeedSqlx = collectRun(node, 'speed', 'legacy_wasix_sqlx') const headlineModes = [ { @@ -127,7 +127,7 @@ async function main() { setupMicros: nativeRttSqlx.setupMicros, }, { - label: 'pglite-oxide + SQLx', + label: 'oliphaunt-wasix + SQLx', rttRun: oxideRttSqlx, speedRun: oxideSpeedSqlx, openMicros: oxideRttSqlx.openMicros, @@ -135,19 +135,19 @@ async function main() { setupMicros: oxideRttSqlx.setupMicros, }, { - label: 'vanilla PGlite + SQLx', - rttRun: nodeRttNodefsSqlx, - speedRun: nodeSpeedNodefsSqlx, - openMicros: nodeRttNodefsSqlx.openMicros, - connectMicros: nodeRttNodefsSqlx.connectMicros, - setupMicros: nodeRttNodefsSqlx.setupMicros, + label: 'legacy WASIX SQLx', + rttRun: legacyRttSqlx, + speedRun: legacySpeedSqlx, + openMicros: legacyRttSqlx.openMicros, + connectMicros: legacyRttSqlx.connectMicros, + setupMicros: legacyRttSqlx.setupMicros, }, ] const speedMaps = { oxideSqlx: indexTestsById(oxideSpeedSqlx), nativeSqlx: indexTestsById(nativeSpeedSqlx), - nodeNodefsSqlx: indexTestsById(nodeSpeedNodefsSqlx), + legacySqlx: indexTestsById(legacySpeedSqlx), } const lines = [] @@ -163,17 +163,17 @@ async function main() { lines.push(`- Logical cores: \`${machineCores}\``) lines.push(`- Node: \`${nodeServer.node}\``) lines.push( - `- npm packages: \`${nodeServer.package}@${nodeServer.version}\`, \`${nodeServer.socketPackage}@${nodeServer.socketVersion}\``, + `- legacy control packages: \`${nodeServer.package}@${nodeServer.version}\`, \`${nodeServer.socketPackage}@${nodeServer.socketVersion}\``, ) lines.push(`- Native Postgres: \`${nativeVersion}\``) lines.push(`- Oxide Wasmer: \`${oxide.wasmerVersion}\``) lines.push(`- Oxide Wasmer WASIX: \`${oxide.wasmerWasixVersion}\``) lines.push(`- RTT iterations: \`${oxide.rttIterations}\``) - lines.push(`- Speed source: exact upstream SQL from \`assets/checkouts/pglite/packages/benchmark/src\``) + lines.push(`- Speed source: exact upstream SQL from \`benchmarks/native/sql\``) lines.push('') lines.push('## Headline') lines.push('') - lines.push('| Metric | native pg + SQLx | pglite-oxide + SQLx | vanilla PGlite + SQLx |') + lines.push('| Metric | native pg + SQLx | oliphaunt-wasix + SQLx | legacy WASIX SQLx |') lines.push('|---|---:|---:|---:|') lines.push( @@ -203,31 +203,31 @@ async function main() { lines.push('') lines.push('## Relative view') lines.push('') - lines.push(`- pglite-oxide + SQLx RTT vs vanilla PGlite + SQLx: ${formatRatio(rttAverageMicros(oxideRttSqlx), rttAverageMicros(nodeRttNodefsSqlx))}`) - lines.push(`- pglite-oxide + SQLx RTT vs native pg + SQLx: ${formatRatio(rttAverageMicros(oxideRttSqlx), rttAverageMicros(nativeRttSqlx))}`) - lines.push(`- pglite-oxide + SQLx speed total vs vanilla PGlite + SQLx: ${formatRatio(speedTotalMicros(oxideSpeedSqlx), speedTotalMicros(nodeSpeedNodefsSqlx))}`) - lines.push(`- pglite-oxide + SQLx speed total vs native pg + SQLx: ${formatRatio(speedTotalMicros(oxideSpeedSqlx), speedTotalMicros(nativeSpeedSqlx))}`) + lines.push(`- oliphaunt-wasix + SQLx RTT vs legacy WASIX SQLx: ${formatRatio(rttAverageMicros(oxideRttSqlx), rttAverageMicros(legacyRttSqlx))}`) + lines.push(`- oliphaunt-wasix + SQLx RTT vs native pg + SQLx: ${formatRatio(rttAverageMicros(oxideRttSqlx), rttAverageMicros(nativeRttSqlx))}`) + lines.push(`- oliphaunt-wasix + SQLx speed total vs legacy WASIX SQLx: ${formatRatio(speedTotalMicros(oxideSpeedSqlx), speedTotalMicros(legacySpeedSqlx))}`) + lines.push(`- oliphaunt-wasix + SQLx speed total vs native pg + SQLx: ${formatRatio(speedTotalMicros(oxideSpeedSqlx), speedTotalMicros(nativeSpeedSqlx))}`) lines.push('') lines.push('## Speed Suite') lines.push('') - lines.push('| ID | Test | native pg + SQLx | pglite-oxide + SQLx | vanilla PGlite + SQLx |') + lines.push('| ID | Test | native pg + SQLx | oliphaunt-wasix + SQLx | legacy WASIX SQLx |') lines.push('|---|---|---:|---:|---:|') for (const test of oxideSpeedSqlx.tests) { const oxideSqlx = speedMaps.oxideSqlx.get(test.id).elapsedMicros const nativeSqlx = speedMaps.nativeSqlx.get(test.id).elapsedMicros - const nodeNodefsSqlx = speedMaps.nodeNodefsSqlx.get(test.id).elapsedMicros + const legacySqlx = speedMaps.legacySqlx.get(test.id).elapsedMicros lines.push( - `| ${test.id} | ${test.label} | ${formatMillis(nativeSqlx / 1000)} | ${formatMillis(oxideSqlx / 1000)} | ${formatMillis(nodeNodefsSqlx / 1000)} |`, + `| ${test.id} | ${test.label} | ${formatMillis(nativeSqlx / 1000)} | ${formatMillis(oxideSqlx / 1000)} | ${formatMillis(legacySqlx / 1000)} |`, ) } lines.push('') lines.push('## Notes') lines.push('') - lines.push('- This matrix is meant for local reproducibility, not universal absolute claims. Different CPUs, filesystems, Node versions, and native Postgres builds will move the numbers.') + lines.push('- This matrix is meant for local reproducibility, not universal absolute claims. Different CPUs, filesystems, runtime versions, and native Postgres builds will move the numbers.') lines.push('- The serial runner intentionally avoids parallel execution so disk caches, CPU scheduling, and memory pressure stay isolated by mode.') - lines.push('- The SQLx-to-SQLx comparison to focus on in product docs is `native pg + SQLx` vs `pglite-oxide + SQLx` vs `vanilla PGlite + SQLx`.') + lines.push('- The SQLx-to-SQLx comparison to focus on in product docs is `native pg + SQLx` vs `oliphaunt-wasix + SQLx` vs `legacy WASIX SQLx`.') lines.push('') await fs.writeFile(output, `${lines.join('\n')}\n`) diff --git a/tools/perf/matrix/native_oliphaunt_provenance.mjs b/tools/perf/matrix/native_oliphaunt_provenance.mjs new file mode 100644 index 00000000..cf8e997a --- /dev/null +++ b/tools/perf/matrix/native_oliphaunt_provenance.mjs @@ -0,0 +1,935 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +const SOURCE_INPUTS = [ + { type: 'file', path: 'Cargo.lock' }, + { type: 'file', path: 'Cargo.toml' }, + { type: 'file', path: 'src/sdks/rust/Cargo.toml' }, + { type: 'dir', path: 'src/sdks/rust/src', extensions: ['.rs'] }, + { type: 'dir', path: 'src/sdks/rust/tests', extensions: ['.rs'] }, + { type: 'dir', path: 'src/runtimes/liboliphaunt/native/bin', extensions: ['.sh'] }, + { type: 'dir', path: 'src/runtimes/liboliphaunt/native/include', extensions: ['.h'] }, + { type: 'dir', path: 'src/runtimes/liboliphaunt/native/patches', extensions: ['.patch'] }, + { type: 'dir', path: 'src/runtimes/liboliphaunt/native/postgres18', extensions: ['.toml'] }, + { type: 'dir', path: 'src/runtimes/liboliphaunt/native/src', extensions: ['.c', '.h'] }, + { type: 'file', path: 'tools/perf/runner/Cargo.toml' }, + { type: 'dir', path: 'tools/perf/runner/src', extensions: ['.rs'] }, + { type: 'dir', path: 'tools/perf/matrix', extensions: ['.mjs', '.sh'] }, +] + +const BENCHMARK_ENV_KEYS = [ + 'OLIPHAUNT_PGDATA_COPY_MODE', + 'OLIPHAUNT_RUNTIME_CACHE_DIR', + 'OLIPHAUNT_PERF_DURABILITY', + 'OLIPHAUNT_PERF_RUNTIME_FOOTPRINT', + 'OLIPHAUNT_PERF_STARTUP_GUCS', + 'OLIPHAUNT_STREAM_QUEUE_MAX_BYTES', + 'OLIPHAUNT_TIMEOUT_MS', + 'OLIPHAUNT_STACK_BYTES', +] + +function usage() { + console.error(`usage: + native_oliphaunt_provenance.mjs write --run-dir DIR --repo-root DIR [options] + native_oliphaunt_provenance.mjs verify --run-dir DIR [--repo-root DIR] [--require-release-evidence] + +write options: + --run-id ID + --native-engines LIST + --suites LIST + --durability PROFILE + --runtime-footprint PROFILE + --startup-gucs LIST + --rtt-iterations N + --rtt-repeats N + --prepared-rows N + --prepared-repeats N + --speed-repeats N + --backup-repeats N + --run-sqlite 0|1 + --run-prepared 0|1 + --release-evidence 0|1 + --partial-report 0|1 + --diagnostic-run 0|1 + --release-min-rtt-iterations N + --release-min-rtt-repeats N + --release-min-prepared-rows N + --release-min-prepared-repeats N + --release-min-speed-repeats N + --release-min-backup-repeats N + --pgdata-copy-mode MODE + --liboliphaunt PATH + --postgres-bin PATH + --initdb-bin PATH + --perf-runner PATH`) +} + +function parseArgs(argv) { + const command = argv[0] + const args = {} + for (let index = 1; index < argv.length; index += 1) { + const key = argv[index] + if (!key.startsWith('--')) { + throw new Error(`unexpected argument: ${key}`) + } + const hasValue = index + 1 < argv.length + const value = argv[index + 1] + if (hasValue && !value.startsWith('--')) { + args[key] = value + index += 1 + } else { + args[key] = 'true' + } + } + return { command, args } +} + +function requireArg(args, key) { + const value = args[key] + if (!value) { + throw new Error(`${key} is required`) + } + return value +} + +function optionalPath(value) { + return value && value !== 'true' ? path.resolve(value) : null +} + +function numberArg(args, key) { + const value = args[key] + if (value === undefined) { + return null + } + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`${key} must be numeric`) + } + return parsed +} + +function flagArg(args, key) { + const value = args[key] + if (value === undefined) { + return null + } + return value === '1' || value === 'true' +} + +function stringListArg(args, key) { + const value = args[key] + if (value === undefined || value === 'true') { + return [] + } + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + +function sameStringList(actual, expected) { + return ( + Array.isArray(actual) && + actual.length === expected.length && + actual.every((value, index) => value === expected[index]) + ) +} + +function finiteNumber(value) { + return typeof value === 'number' && Number.isFinite(value) +} + +function repeatIndex(index, repeatCount) { + return String(index).padStart(String(repeatCount).length, '0') +} + +function benchmarkEnvironment(args) { + const env = {} + for (const key of BENCHMARK_ENV_KEYS) { + env[key] = process.env[key] ?? null + } + env.OLIPHAUNT_PGDATA_COPY_MODE = + args['--pgdata-copy-mode'] ?? env.OLIPHAUNT_PGDATA_COPY_MODE + return env +} + +function posixRelative(root, target) { + return path.relative(root, target).split(path.sep).join('/') +} + +async function fileSha256(file) { + return createHash('sha256').update(await fs.readFile(file)).digest('hex') +} + +function digestEntries(entries) { + const hash = createHash('sha256') + for (const entry of entries) { + hash.update(entry.path) + hash.update('\0') + hash.update(entry.sha256) + hash.update('\n') + } + return hash.digest('hex') +} + +async function walkFiles(root, extensions) { + const output = [] + + async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const absolute = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(absolute) + } else if (entry.isFile() || entry.isSymbolicLink()) { + if (!extensions || extensions.includes(path.extname(entry.name))) { + output.push(absolute) + } + } + } + } + + await walk(root) + return output.sort() +} + +async function collectSource(repoRoot) { + const paths = new Set() + for (const input of SOURCE_INPUTS) { + const absolute = path.join(repoRoot, input.path) + const stat = await fs.stat(absolute) + if (input.type === 'file') { + if (!stat.isFile()) { + throw new Error(`source input is not a file: ${input.path}`) + } + paths.add(input.path) + } else { + if (!stat.isDirectory()) { + throw new Error(`source input is not a directory: ${input.path}`) + } + const files = await walkFiles(absolute, input.extensions) + for (const file of files) { + paths.add(posixRelative(repoRoot, file)) + } + } + } + + const entries = [] + for (const relativePath of [...paths].sort()) { + const absolute = path.join(repoRoot, relativePath) + const stat = await fs.stat(absolute) + entries.push({ + path: relativePath, + bytes: stat.size, + sha256: await fileSha256(absolute), + }) + } + + return { + inputs: SOURCE_INPUTS, + sourceSetSha256: digestEntries(entries), + entries, + } +} + +async function hashDirectory(root) { + const files = await walkFiles(root) + const entries = [] + let bytes = 0 + for (const file of files) { + const stat = await fs.lstat(file) + const relativeFile = posixRelative(root, file) + if (stat.isSymbolicLink()) { + const target = await fs.readlink(file) + const targetBytes = Buffer.byteLength(target) + bytes += targetBytes + entries.push({ + path: relativeFile, + kind: 'symlink', + bytes: targetBytes, + sha256: createHash('sha256').update(`symlink\0${target}`).digest('hex'), + }) + continue + } + bytes += stat.size + entries.push({ + path: relativeFile, + kind: 'file', + bytes: stat.size, + sha256: await fileSha256(file), + }) + } + return { + kind: 'directory', + bytes, + fileCount: entries.length, + sha256: digestEntries(entries), + } +} + +async function hashArtifact(name, artifactPath) { + const stat = await fs.stat(artifactPath) + if (stat.isDirectory()) { + const directory = await hashDirectory(artifactPath) + return { + name, + path: artifactPath, + ...directory, + } + } + if (!stat.isFile()) { + throw new Error(`artifact is neither a file nor directory: ${artifactPath}`) + } + return { + name, + path: artifactPath, + kind: 'file', + bytes: stat.size, + sha256: await fileSha256(artifactPath), + } +} + +async function collectArtifacts(args) { + const liboliphaunt = optionalPath(args['--liboliphaunt']) + const postgresBin = optionalPath(args['--postgres-bin']) + const initdbBin = optionalPath(args['--initdb-bin']) + const perfRunner = optionalPath(args['--perf-runner']) + const candidates = [ + ['liboliphaunt-native', liboliphaunt], + ['postgres', postgresBin], + ['initdb', initdbBin], + ['oliphaunt-perf', perfRunner], + ] + + if (liboliphaunt) { + candidates.push(['embedded-modules', path.join(path.dirname(liboliphaunt), 'modules')]) + } + if (postgresBin) { + candidates.push(['native-postgres-install', path.dirname(path.dirname(postgresBin))]) + } + + const artifacts = [] + for (const [name, artifactPath] of candidates) { + if (!artifactPath) { + continue + } + try { + artifacts.push(await hashArtifact(name, artifactPath)) + } catch (error) { + if (error && error.code === 'ENOENT') { + artifacts.push({ + name, + path: artifactPath, + missing: true, + sha256: null, + }) + } else { + throw error + } + } + } + return artifacts +} + +function runGit(repoRoot, args) { + const result = spawnSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (result.status !== 0) { + return null + } + return result.stdout.trim() +} + +function gitMetadata(repoRoot) { + const status = runGit(repoRoot, ['status', '--porcelain', '--untracked-files=no']) ?? '' + return { + root: repoRoot, + commit: runGit(repoRoot, ['rev-parse', 'HEAD']), + branch: runGit(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']), + dirtyTracked: status.length > 0, + trackedStatusLineCount: status ? status.split('\n').length : 0, + } +} + +async function writeProvenance(args) { + const runDir = path.resolve(requireArg(args, '--run-dir')) + const repoRoot = path.resolve(args['--repo-root'] ?? process.cwd()) + const source = await collectSource(repoRoot) + const artifacts = await collectArtifacts(args) + const provenance = { + schema: 'oliphaunt.native-perf.provenance.v1', + generatedAt: new Date().toISOString(), + runId: args['--run-id'] ?? path.basename(runDir), + repo: gitMetadata(repoRoot), + environment: { + platform: process.platform, + arch: process.arch, + node: process.version, + osRelease: os.release(), + host: os.hostname(), + }, + benchmark: { + nativeEngines: stringListArg(args, '--native-engines'), + suites: stringListArg(args, '--suites'), + durability: args['--durability'] ?? null, + runtimeFootprint: args['--runtime-footprint'] ?? null, + startupGucs: stringListArg(args, '--startup-gucs'), + rttIterations: numberArg(args, '--rtt-iterations'), + rttRepeats: numberArg(args, '--rtt-repeats'), + preparedRows: numberArg(args, '--prepared-rows'), + preparedRepeats: numberArg(args, '--prepared-repeats'), + speedRepeats: numberArg(args, '--speed-repeats'), + backupRepeats: numberArg(args, '--backup-repeats'), + pgdataCopyMode: args['--pgdata-copy-mode'] ?? null, + environment: benchmarkEnvironment(args), + includes: { + sqlite: flagArg(args, '--run-sqlite'), + preparedUpdates: flagArg(args, '--run-prepared'), + }, + quality: { + releaseEvidence: flagArg(args, '--release-evidence'), + partialReport: flagArg(args, '--partial-report'), + diagnosticRun: flagArg(args, '--diagnostic-run'), + releaseMinimums: { + rttIterations: numberArg(args, '--release-min-rtt-iterations'), + rttRepeats: numberArg(args, '--release-min-rtt-repeats'), + preparedRows: numberArg(args, '--release-min-prepared-rows'), + preparedRepeats: numberArg(args, '--release-min-prepared-repeats'), + speedRepeats: numberArg(args, '--release-min-speed-repeats'), + backupRepeats: numberArg(args, '--release-min-backup-repeats'), + }, + }, + }, + source, + artifacts, + } + await fs.mkdir(runDir, { recursive: true }) + const file = path.join(runDir, 'provenance.json') + await fs.writeFile(file, `${JSON.stringify(provenance, null, 2)}\n`) + console.log(file) +} + +async function readJson(file) { + return JSON.parse(await fs.readFile(file, 'utf8')) +} + +async function readRequiredJson(runDir, relativeFile, failures) { + const file = path.join(runDir, relativeFile) + try { + return JSON.parse(await fs.readFile(file, 'utf8')) + } catch (error) { + if (error && error.code === 'ENOENT') { + failures.push(`missing benchmark output: ${relativeFile}`) + return null + } + failures.push(`invalid benchmark JSON output ${relativeFile}: ${error.message}`) + return null + } +} + +async function requireNonEmptyFile(runDir, relativeFile, failures) { + const file = path.join(runDir, relativeFile) + try { + const stat = await fs.stat(file) + if (!stat.isFile()) { + failures.push(`benchmark output is not a file: ${relativeFile}`) + } else if (stat.size === 0) { + failures.push(`benchmark output is empty: ${relativeFile}`) + } + } catch (error) { + if (error && error.code === 'ENOENT') { + failures.push(`missing benchmark output: ${relativeFile}`) + } else { + throw error + } + } +} + +function compareSource(expected, actual) { + const failures = [] + if (expected.sourceSetSha256 !== actual.sourceSetSha256) { + failures.push( + `source set changed: expected ${expected.sourceSetSha256}, got ${actual.sourceSetSha256}`, + ) + } + + const expectedByPath = new Map(expected.entries.map((entry) => [entry.path, entry])) + const actualByPath = new Map(actual.entries.map((entry) => [entry.path, entry])) + for (const [entryPath, entry] of expectedByPath) { + const current = actualByPath.get(entryPath) + if (!current) { + failures.push(`source missing: ${entryPath}`) + } else if (current.sha256 !== entry.sha256) { + failures.push(`source changed: ${entryPath}`) + } + } + for (const entryPath of actualByPath.keys()) { + if (!expectedByPath.has(entryPath)) { + failures.push(`source added: ${entryPath}`) + } + } + return failures +} + +async function compareArtifacts(expectedArtifacts) { + const failures = [] + const checks = [] + for (const expected of expectedArtifacts ?? []) { + if (expected.missing) { + checks.push(`artifact skipped because original was missing: ${expected.name}`) + continue + } + try { + const actual = await hashArtifact(expected.name, expected.path) + if (actual.sha256 === expected.sha256) { + checks.push(`artifact ok: ${expected.name}`) + } else { + failures.push( + `artifact changed: ${expected.name} expected ${expected.sha256}, got ${actual.sha256}`, + ) + } + } catch (error) { + if (error && error.code === 'ENOENT') { + failures.push(`artifact missing: ${expected.name} (${expected.path})`) + } else { + throw error + } + } + } + return { failures, checks } +} + +function findBenchmarkRun(report, suite, mode) { + if (!Array.isArray(report?.runs)) { + return null + } + return report.runs.find((run) => run.suite === suite && run.mode === mode) ?? null +} + +function validateBenchmarkRun(name, run, suite, mode, failures) { + if (!run) { + failures.push(`benchmark output ${name}.json is missing run suite=${suite} mode=${mode}`) + return + } + if (!Array.isArray(run.tests) || run.tests.length === 0) { + failures.push(`benchmark output ${name}.json run ${suite}/${mode} has no tests`) + return + } + for (const test of run.tests) { + const id = test?.id ?? '' + for (const field of ['elapsedMicros', 'p50Micros', 'p90Micros', 'p95Micros', 'p99Micros']) { + if (!finiteNumber(test[field])) { + failures.push(`benchmark output ${name}.json ${suite}/${mode}/${id} is missing ${field}`) + } + } + if (!finiteNumber(test.sampleCount) || test.sampleCount < 1) { + failures.push(`benchmark output ${name}.json ${suite}/${mode}/${id} has invalid sampleCount`) + } + } +} + +async function benchmarkReportFailures(runDir, name, expectedRuns) { + const failures = [] + const report = await readRequiredJson(runDir, `${name}.json`, failures) + await requireNonEmptyFile(runDir, `${name}.resource.txt`, failures) + if (!report) { + return failures + } + if (!Array.isArray(report.runs)) { + failures.push(`benchmark output ${name}.json does not contain a runs array`) + return failures + } + for (const expected of expectedRuns) { + validateBenchmarkRun( + name, + findBenchmarkRun(report, expected.suite, expected.mode), + expected.suite, + expected.mode, + failures, + ) + } + return failures +} + +function validatePreparedRun(name, run, mode, failures) { + if (!run) { + failures.push(`benchmark output ${name}.json is missing prepared-update mode=${mode}`) + return + } + if (!Array.isArray(run.tests) || run.tests.length === 0) { + failures.push(`benchmark output ${name}.json prepared mode ${mode} has no tests`) + return + } + for (const test of run.tests) { + const id = test?.id ?? '' + for (const field of [ + 'openMicros', + 'connectMicros', + 'setupMicros', + 'elapsedMicros', + 'operationCount', + 'averageMicros', + ]) { + if (!finiteNumber(test[field])) { + failures.push(`benchmark output ${name}.json ${mode}/${id} is missing ${field}`) + } + } + } +} + +async function preparedReportFailures(runDir, name, expectedModes) { + const failures = [] + const report = await readRequiredJson(runDir, `${name}.json`, failures) + await requireNonEmptyFile(runDir, `${name}.resource.txt`, failures) + if (!report) { + return failures + } + if (!Array.isArray(report.runs)) { + failures.push(`benchmark output ${name}.json does not contain a runs array`) + return failures + } + for (const mode of expectedModes) { + const run = report.runs.find((entry) => entry.mode === mode) ?? null + validatePreparedRun(name, run, mode, failures) + } + return failures +} + +async function artifactSizesFailures(runDir) { + const failures = [] + const report = await readRequiredJson(runDir, 'artifact-sizes.json', failures) + if (!report) { + return failures + } + if (!Array.isArray(report.artifacts)) { + failures.push('artifact-sizes.json does not contain an artifacts array') + return failures + } + const artifacts = new Map(report.artifacts.map((entry) => [entry.name, entry])) + for (const name of ['liboliphaunt-native', 'embedded-modules', 'native-postgres-install']) { + const artifact = artifacts.get(name) + if (!artifact) { + failures.push(`artifact-sizes.json is missing artifact ${name}`) + } else if (!finiteNumber(artifact.bytes) || artifact.bytes < 0) { + failures.push(`artifact-sizes.json artifact ${name} has invalid bytes`) + } + } + return failures +} + +function nativeBenchmarkMode(engine) { + return `native_liboliphaunt_${engine}` +} + +function nativeCaseName(engine, suite) { + const directNames = { + rtt: 'native-liboliphaunt-rtt', + speed: 'native-liboliphaunt-speed', + streaming: 'native-liboliphaunt-streaming', + prepared: 'native-liboliphaunt-prepared-direct', + backup: 'native-liboliphaunt-backup', + } + const prefixedNames = { + rtt: `native-liboliphaunt-${engine}-rtt`, + speed: `native-liboliphaunt-${engine}-speed`, + streaming: `native-liboliphaunt-${engine}-streaming`, + prepared: `native-liboliphaunt-prepared-${engine}`, + backup: `native-liboliphaunt-${engine}-backup`, + } + return engine === 'direct' ? directNames[suite] : prefixedNames[suite] +} + +function nativePreparedModes(engine) { + const mode = nativeBenchmarkMode(engine) + return [`${mode}_prepared`, `${mode}_pipelined_prepared`] +} + +async function benchmarkReleaseOutputFailures(runDir, provenance) { + const benchmark = provenance.benchmark ?? {} + const failures = [] + failures.push(...(await artifactSizesFailures(runDir))) + await requireNonEmptyFile(runDir, 'report.md', failures) + + for (const engine of ['direct', 'broker', 'server']) { + const mode = nativeBenchmarkMode(engine) + for (const suite of ['rtt', 'speed', 'streaming']) { + failures.push( + ...(await benchmarkReportFailures(runDir, nativeCaseName(engine, suite), [ + { suite, mode }, + ])), + ) + } + failures.push( + ...(await benchmarkReportFailures(runDir, nativeCaseName(engine, 'backup'), [ + { suite: 'backup-restore', mode }, + ])), + ) + failures.push( + ...(await preparedReportFailures( + runDir, + nativeCaseName(engine, 'prepared'), + nativePreparedModes(engine), + )), + ) + } + + failures.push( + ...(await benchmarkReportFailures(runDir, 'native-postgres-tokio-all', [ + { suite: 'rtt', mode: 'native_postgres' }, + { suite: 'speed', mode: 'native_postgres' }, + ])), + ) + failures.push( + ...(await benchmarkReportFailures(runDir, 'native-postgres-sqlx-all', [ + { suite: 'rtt', mode: 'native_postgres_sqlx' }, + { suite: 'speed', mode: 'native_postgres_sqlx' }, + ])), + ) + failures.push( + ...(await benchmarkReportFailures(runDir, 'native-postgres-streaming', [ + { suite: 'streaming', mode: 'native_postgres_raw' }, + ])), + ) + failures.push( + ...(await benchmarkReportFailures(runDir, 'sqlite-speed', [ + { suite: 'speed', mode: 'sqlite' }, + ])), + ) + failures.push( + ...(await benchmarkReportFailures(runDir, 'native-postgres-backup', [ + { suite: 'backup-restore', mode: 'native_postgres' }, + { suite: 'backup-restore', mode: 'native_postgres_physical' }, + ])), + ) + failures.push( + ...(await benchmarkReportFailures(runDir, 'sqlite-backup', [ + { suite: 'backup-restore', mode: 'sqlite' }, + ])), + ) + failures.push( + ...(await preparedReportFailures(runDir, 'native-postgres-prepared', [ + 'native_postgres_tokio_prepared', + 'native_postgres_tokio_pipelined_prepared', + ])), + ) + + for (let index = 1; index <= benchmark.rttRepeats; index += 1) { + const repeat = repeatIndex(index, benchmark.rttRepeats) + for (const engine of ['direct', 'broker', 'server']) { + failures.push( + ...(await benchmarkReportFailures( + runDir, + `repeats/${nativeCaseName(engine, 'rtt')}-${repeat}`, + [{ suite: 'rtt', mode: nativeBenchmarkMode(engine) }], + )), + ) + } + failures.push( + ...(await benchmarkReportFailures(runDir, `repeats/native-postgres-tokio-rtt-${repeat}`, [ + { suite: 'rtt', mode: 'native_postgres' }, + ])), + ) + } + + for (let index = 1; index <= benchmark.speedRepeats; index += 1) { + const repeat = repeatIndex(index, benchmark.speedRepeats) + for (const engine of ['direct', 'broker', 'server']) { + failures.push( + ...(await benchmarkReportFailures( + runDir, + `repeats/${nativeCaseName(engine, 'speed')}-${repeat}`, + [{ suite: 'speed', mode: nativeBenchmarkMode(engine) }], + )), + ) + } + failures.push( + ...(await benchmarkReportFailures(runDir, `repeats/native-postgres-tokio-speed-${repeat}`, [ + { suite: 'speed', mode: 'native_postgres' }, + ])), + ) + failures.push( + ...(await benchmarkReportFailures(runDir, `repeats/sqlite-speed-${repeat}`, [ + { suite: 'speed', mode: 'sqlite' }, + ])), + ) + } + + for (let index = 1; index <= benchmark.preparedRepeats; index += 1) { + const repeat = repeatIndex(index, benchmark.preparedRepeats) + failures.push( + ...(await preparedReportFailures(runDir, `repeats/native-postgres-prepared-${repeat}`, [ + 'native_postgres_tokio_prepared', + 'native_postgres_tokio_pipelined_prepared', + ])), + ) + for (const engine of ['direct', 'broker', 'server']) { + failures.push( + ...(await preparedReportFailures( + runDir, + `repeats/${nativeCaseName(engine, 'prepared')}-${repeat}`, + nativePreparedModes(engine), + )), + ) + } + } + + for (let index = 1; index <= benchmark.backupRepeats; index += 1) { + const repeat = repeatIndex(index, benchmark.backupRepeats) + for (const engine of ['direct', 'broker', 'server']) { + failures.push( + ...(await benchmarkReportFailures( + runDir, + `repeats/${nativeCaseName(engine, 'backup')}-${repeat}`, + [{ suite: 'backup-restore', mode: nativeBenchmarkMode(engine) }], + )), + ) + } + failures.push( + ...(await benchmarkReportFailures(runDir, `repeats/native-postgres-backup-${repeat}`, [ + { suite: 'backup-restore', mode: 'native_postgres' }, + { suite: 'backup-restore', mode: 'native_postgres_physical' }, + ])), + ) + failures.push( + ...(await benchmarkReportFailures(runDir, `repeats/sqlite-backup-${repeat}`, [ + { suite: 'backup-restore', mode: 'sqlite' }, + ])), + ) + } + + return failures +} + +function benchmarkReleaseFailures(provenance) { + const benchmark = provenance.benchmark ?? {} + const quality = benchmark.quality ?? {} + const minimums = quality.releaseMinimums ?? { + rttIterations: 100, + rttRepeats: 10, + preparedRows: 25000, + preparedRepeats: 10, + speedRepeats: 20, + backupRepeats: 10, + } + const failures = [] + + if (quality.releaseEvidence !== true) { + failures.push('benchmark provenance is not marked as releaseEvidence=true') + } + if (quality.partialReport !== false) { + failures.push('benchmark provenance is partial; release evidence must cover the default matrix') + } + if (quality.diagnosticRun !== false) { + failures.push('benchmark provenance is diagnostic; release evidence must come from the default matrix') + } + if (!sameStringList(benchmark.nativeEngines, ['direct', 'broker', 'server'])) { + failures.push( + `benchmark native engines are ${JSON.stringify(benchmark.nativeEngines)}, expected ["direct","broker","server"]`, + ) + } + if (!sameStringList(benchmark.suites, ['rtt', 'speed', 'streaming', 'prepared', 'backup'])) { + failures.push( + `benchmark suites are ${JSON.stringify(benchmark.suites)}, expected ["rtt","speed","streaming","prepared","backup"]`, + ) + } + if (benchmark.includes?.sqlite !== true) { + failures.push('benchmark provenance does not include the SQLite embedded control') + } + if (benchmark.includes?.preparedUpdates !== true) { + failures.push('benchmark provenance does not include prepared-update suites') + } + + const numericChecks = [ + ['rttIterations', 'RTT samples'], + ['rttRepeats', 'RTT repeats'], + ['preparedRows', 'prepared-update rows'], + ['preparedRepeats', 'prepared-update repeats'], + ['speedRepeats', 'speed repeats'], + ['backupRepeats', 'backup/restore repeats'], + ] + for (const [key, label] of numericChecks) { + const actual = benchmark[key] + const minimum = minimums[key] + if (!Number.isFinite(actual) || !Number.isFinite(minimum) || actual < minimum) { + failures.push(`${label} ${actual ?? 'missing'} is below release minimum ${minimum ?? 'missing'}`) + } + } + + return failures +} + +async function verifyProvenance(args) { + const runDir = path.resolve(requireArg(args, '--run-dir')) + const provenance = await readJson(path.join(runDir, 'provenance.json')) + const repoRoot = path.resolve(args['--repo-root'] ?? provenance.repo?.root ?? process.cwd()) + const source = await collectSource(repoRoot) + const sourceFailures = compareSource(provenance.source, source) + const artifactResult = await compareArtifacts(provenance.artifacts) + const requireReleaseEvidence = flagArg(args, '--require-release-evidence') === true + const releaseFailures = requireReleaseEvidence ? benchmarkReleaseFailures(provenance) : [] + const releaseOutputFailures = requireReleaseEvidence + ? await benchmarkReleaseOutputFailures(runDir, provenance) + : [] + const failures = [ + ...sourceFailures, + ...artifactResult.failures, + ...releaseFailures, + ...releaseOutputFailures, + ] + + console.log(`run: ${provenance.runId}`) + console.log(`generated: ${provenance.generatedAt}`) + console.log(`repo commit: ${provenance.repo?.commit ?? 'n/a'}`) + console.log(`source set: ${provenance.source?.sourceSetSha256 ?? 'n/a'}`) + console.log( + `release evidence: ${provenance.benchmark?.quality?.releaseEvidence === true ? 'yes' : 'no'}`, + ) + console.log( + `partial report: ${provenance.benchmark?.quality?.partialReport === true ? 'yes' : 'no'}`, + ) + console.log( + `diagnostic run: ${provenance.benchmark?.quality?.diagnosticRun === true ? 'yes' : 'no'}`, + ) + for (const check of artifactResult.checks) { + console.log(check) + } + + if (failures.length > 0) { + console.error('\nprovenance verification failed:') + for (const failure of failures.slice(0, 40)) { + console.error(`- ${failure}`) + } + if (failures.length > 40) { + console.error(`- ... ${failures.length - 40} more`) + } + process.exitCode = 1 + return + } + + console.log('provenance verification passed') +} + +const { command, args } = parseArgs(process.argv.slice(2)) + +try { + if (command === 'write') { + await writeProvenance(args) + } else if (command === 'verify') { + await verifyProvenance(args) + } else { + usage() + process.exitCode = 2 + } +} catch (error) { + console.error(error) + usage() + process.exitCode = 1 +} diff --git a/tools/perf/matrix/run_bench_matrix.sh b/tools/perf/matrix/run_bench_matrix.sh new file mode 100755 index 00000000..4e991638 --- /dev/null +++ b/tools/perf/matrix/run_bench_matrix.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" + +cat >&2 <<'MSG' +tools/perf/matrix/run_bench_matrix.sh is a retired compatibility entrypoint. +Use tools/perf/matrix/run_native_oliphaunt_matrix.sh for native direct, +broker, server, PostgreSQL, SQLite, and WASIX comparison plans. +MSG + +exec "$script_dir/run_native_oliphaunt_matrix.sh" "$@" diff --git a/tools/perf/matrix/run_mobile_footprint_matrix.sh b/tools/perf/matrix/run_mobile_footprint_matrix.sh new file mode 100755 index 00000000..9ac39284 --- /dev/null +++ b/tools/perf/matrix/run_mobile_footprint_matrix.sh @@ -0,0 +1,999 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +if repo_root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + repo_root="$(cd "$script_dir/../../.." && pwd)" +fi +example_dir="$repo_root/src/sdks/react-native/examples/expo" + +platform="both" +plan_only=0 +include_invalid_wal_min=0 +quick=0 +keep_going=0 +summarize_only=0 +crash_recovery="${OLIPHAUNT_MOBILE_FOOTPRINT_CRASH_RECOVERY:-per-case}" +runtime_footprints_raw="${OLIPHAUNT_MOBILE_FOOTPRINT_RUNTIME_FOOTPRINTS:-balancedMobile}" +shared_buffers_raw="${OLIPHAUNT_MOBILE_FOOTPRINT_SHARED_BUFFERS:-all}" +wal_buffers_raw="${OLIPHAUNT_MOBILE_FOOTPRINT_WAL_BUFFERS:-all}" +min_wal_sizes_raw="${OLIPHAUNT_MOBILE_FOOTPRINT_MIN_WAL_SIZES:-all}" +max_wal_sizes_raw="${OLIPHAUNT_MOBILE_FOOTPRINT_MAX_WAL_SIZES:-all}" +durabilities_raw="${OLIPHAUNT_MOBILE_FOOTPRINT_DURABILITIES:-all}" +wal_segsize_mb="${OLIPHAUNT_MOBILE_FOOTPRINT_WAL_SEGSIZE_MB:-16}" +run_id="${OLIPHAUNT_MOBILE_FOOTPRINT_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}" +output_dir="${OLIPHAUNT_MOBILE_FOOTPRINT_OUTPUT_DIR:-$repo_root/target/perf/mobile-footprint-$run_id}" +output_dir_explicit=0 +run_id_explicit=0 + +usage() { + cat >&2 <<'USAGE' +usage: tools/perf/matrix/run_mobile_footprint_matrix.sh [options] + +Options: + --platform android|ios|both Platform benchmark harness to run. Default: both. + --plan-only Print concrete benchmark commands without running them. + --include-invalid-wal-min Include min_wal_size combinations smaller than two WAL segments. + Use only for negative validation. + --wal-segsize MB Template-cluster WAL segment size in megabytes. Default: 16. + Pass 4 to make min_wal_size=8MB and 16MB valid. + --run-id ID Stable run id for the report directory. + --output-dir DIR Matrix output directory. Default: target/perf/mobile-footprint-. + --keep-going Continue after a failed case and summarize failures. + --runtime-footprint PROFILE Runtime footprint profile to use for each case: + throughput, balancedMobile, smallMobile, all, or a + comma-separated list. Default: balancedMobile. + --shared-buffers VALUES shared_buffers values to run: all or a comma-separated + subset of 8MB,16MB,32MB,64MB,128MB. + --wal-buffers VALUES wal_buffers values to run: all or a comma-separated + subset of -1,256kB,1MB,4MB. + --min-wal-size VALUES min_wal_size values to run: all or a comma-separated + subset of 8MB,16MB,32MB,80MB. + --max-wal-size VALUES max_wal_size values to run: all or a comma-separated + subset of 32MB,64MB,default. + --durability VALUES Durability profiles to run: all, safe, balanced, or a + comma-separated subset. Default: all. + --crash-recovery off|per-case Run process-death recovery evidence for safe + durability cases. Balanced keeps + synchronous_commit=off and is not a last-commit + survival gate. Default: per-case. + --summarize-only Rebuild summary.json and summary.md from an existing output dir. + --quick Forward quick benchmark sizing to the Expo benchmark harness when + supported by local overrides. + -h, --help Show this help. + +The matrix sweeps: + runtime footprint: balancedMobile by default; pass --runtime-footprint all + to compare throughput/balancedMobile/smallMobile under the same GUC axes + shared_buffers: 8/16/32/64/128MB + wal_buffers: -1/256kB/1MB/4MB + min_wal_size: 8/16/32/80MB + WAL segment size: 16MB by default; pass --wal-segsize 4 to run the 8/16MB + min_wal_size mobile experiments against a matching template cluster + max_wal_size: 32/64MB plus default + durability: safe/balanced +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) + platform="${2:?--platform requires a value}" + shift 2 + ;; + --plan-only) + plan_only=1 + shift + ;; + --include-invalid-wal-min) + include_invalid_wal_min=1 + shift + ;; + --quick) + quick=1 + shift + ;; + --wal-segsize|--wal-segsize-mb) + wal_segsize_mb="${2:?$1 requires a value}" + wal_segsize_mb="${wal_segsize_mb%MB}" + shift 2 + ;; + --keep-going) + keep_going=1 + shift + ;; + --runtime-footprint|--runtime-footprints) + runtime_footprints_raw="${2:?$1 requires a value}" + shift 2 + ;; + --shared-buffers|--shared-buffer) + shared_buffers_raw="${2:?$1 requires a value}" + shift 2 + ;; + --wal-buffers|--wal-buffer) + wal_buffers_raw="${2:?$1 requires a value}" + shift 2 + ;; + --min-wal-size|--min-wal-sizes) + min_wal_sizes_raw="${2:?$1 requires a value}" + shift 2 + ;; + --max-wal-size|--max-wal-sizes) + max_wal_sizes_raw="${2:?$1 requires a value}" + shift 2 + ;; + --durability|--durabilities) + durabilities_raw="${2:?$1 requires a value}" + shift 2 + ;; + --crash-recovery) + crash_recovery="${2:?--crash-recovery requires a value}" + shift 2 + ;; + --summarize-only) + summarize_only=1 + shift + ;; + --run-id) + run_id="${2:?--run-id requires a value}" + run_id_explicit=1 + if [[ "$output_dir_explicit" -eq 0 ]]; then + output_dir="${OLIPHAUNT_MOBILE_FOOTPRINT_OUTPUT_DIR:-$repo_root/target/perf/mobile-footprint-$run_id}" + fi + shift 2 + ;; + --output-dir) + output_dir="${2:?--output-dir requires a value}" + output_dir_explicit=1 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +case "$platform" in + android|ios|both) ;; + *) + echo "unknown platform: $platform" >&2 + exit 2 + ;; +esac +case "$crash_recovery" in + off|per-case) ;; + *) + echo "unknown --crash-recovery value: $crash_recovery" >&2 + exit 2 + ;; +esac +case "$wal_segsize_mb" in + ''|*[!0-9]*) + echo "--wal-segsize must be a positive integer number of megabytes" >&2 + exit 2 + ;; +esac +if [[ "$wal_segsize_mb" -le 0 ]]; then + echo "--wal-segsize must be greater than zero" >&2 + exit 2 +fi +if [[ "$summarize_only" -eq 1 && "$output_dir_explicit" -eq 1 && "$run_id_explicit" -eq 0 ]]; then + output_basename="$(basename "$output_dir")" + run_id="${output_basename#mobile-footprint-}" +fi + +shared_buffers=(8MB 16MB 32MB 64MB 128MB) +wal_buffers=(-1 256kB 1MB 4MB) +min_wal_sizes=(8MB 16MB 32MB 80MB) +max_wal_sizes=(32MB 64MB default) +durabilities=(safe balanced) +runtime_footprints=() + +value_in() { + local wanted="$1" + shift + local value + for value in "$@"; do + if [[ "$value" = "$wanted" ]]; then + return 0 + fi + done + return 1 +} + +print_axis_values() { + local label="$1" + local raw="$2" + shift 2 + local allowed=("$@") + local selected=() + local old_ifs values value existing + old_ifs="$IFS" + IFS="," + # shellcheck disable=SC2206 + values=($raw) + IFS="$old_ifs" + + if [[ "${#values[@]}" -eq 0 ]]; then + echo "$label list must not be empty" >&2 + exit 2 + fi + + for value in "${values[@]}"; do + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [[ -n "$value" ]] || continue + if [[ "$value" = "all" ]]; then + selected=("${allowed[@]}") + break + fi + if ! value_in "$value" "${allowed[@]}"; then + echo "unknown $label value: $value" >&2 + echo "expected all or one of: ${allowed[*]}" >&2 + exit 2 + fi + if [[ "${#selected[@]}" -gt 0 ]]; then + for existing in "${selected[@]}"; do + if [[ "$existing" = "$value" ]]; then + value="" + break + fi + done + fi + [[ -n "$value" ]] && selected+=("$value") + done + + if [[ "${#selected[@]}" -eq 0 ]]; then + echo "$label list must not be empty" >&2 + exit 2 + fi + printf '%s\n' "${selected[@]}" +} + +add_runtime_footprint() { + local profile="$1" + local existing + if [[ "${#runtime_footprints[@]}" -gt 0 ]]; then + for existing in "${runtime_footprints[@]}"; do + if [[ "$existing" = "$profile" ]]; then + return + fi + done + fi + runtime_footprints+=("$profile") +} + +normalize_runtime_footprints() { + local raw="$1" + local old_ifs value + old_ifs="$IFS" + IFS="," + # shellcheck disable=SC2206 + local values=($raw) + IFS="$old_ifs" + + if [[ "${#values[@]}" -eq 0 ]]; then + echo "runtime footprint profile list must not be empty" >&2 + exit 2 + fi + + for value in "${values[@]}"; do + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [[ -n "$value" ]] || continue + case "$value" in + all) + add_runtime_footprint throughput + add_runtime_footprint balancedMobile + add_runtime_footprint smallMobile + ;; + throughput|balancedMobile|smallMobile) + add_runtime_footprint "$value" + ;; + *) + echo "unknown runtime footprint profile: $value" >&2 + echo "expected throughput, balancedMobile, smallMobile, all, or a comma-separated list" >&2 + exit 2 + ;; + esac + done + + if [[ "${#runtime_footprints[@]}" -eq 0 ]]; then + echo "runtime footprint profile list must not be empty" >&2 + exit 2 + fi +} + +normalize_runtime_footprints "$runtime_footprints_raw" +shared_buffers=($(print_axis_values shared_buffers "$shared_buffers_raw" "${shared_buffers[@]}")) +wal_buffers=($(print_axis_values wal_buffers "$wal_buffers_raw" "${wal_buffers[@]}")) +min_wal_sizes=($(print_axis_values min_wal_size "$min_wal_sizes_raw" "${min_wal_sizes[@]}")) +max_wal_sizes=($(print_axis_values max_wal_size "$max_wal_sizes_raw" "${max_wal_sizes[@]}")) +durabilities=($(print_axis_values durability "$durabilities_raw" "${durabilities[@]}")) + +platforms=() +case "$platform" in + android) platforms=(android) ;; + ios) platforms=(ios) ;; + both) platforms=(android ios) ;; +esac + +size_mb() { + case "$1" in + *MB) printf '%s\n' "${1%MB}" ;; + default) printf '1048576\n' ;; + *) + echo "unsupported size value: $1" >&2 + exit 2 + ;; + esac +} + +is_valid_wal_min_for_segment() { + local min_wal="$1" + local segment_mb="$2" + [[ "$(size_mb "$min_wal")" -ge $((segment_mb * 2)) ]] +} + +is_valid_wal_range() { + local min_wal="$1" + local max_wal="$2" + if [[ "$max_wal" = "default" ]]; then + return 0 + fi + [[ "$(size_mb "$max_wal")" -ge "$(size_mb "$min_wal")" ]] +} + +shell_quote() { + printf '%q' "$1" +} + +case_slug() { + printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' +} + +write_case_metadata() { + local case_dir="$1" + local case_id="$2" + local target_platform="$3" + local durability="$4" + local runtime_footprint="$5" + local shared="$6" + local wal="$7" + local min_wal="$8" + local max_wal="$9" + local wal_segment_mb="${10}" + local startup_gucs="${11}" + local status="${12}" + CASE_ID="$case_id" \ + CASE_PLATFORM="$target_platform" \ + CASE_DURABILITY="$durability" \ + CASE_RUNTIME_FOOTPRINT="$runtime_footprint" \ + CASE_SHARED_BUFFERS="$shared" \ + CASE_WAL_BUFFERS="$wal" \ + CASE_MIN_WAL_SIZE="$min_wal" \ + CASE_MAX_WAL_SIZE="$max_wal" \ + CASE_WAL_SEGMENT_SIZE_MB="$wal_segment_mb" \ + CASE_STARTUP_GUCS="$startup_gucs" \ + CASE_STATUS="$status" \ + node <<'NODE' >"$case_dir/case.json" +const data = { + id: process.env.CASE_ID, + platform: process.env.CASE_PLATFORM, + durability: process.env.CASE_DURABILITY, + runtimeFootprint: process.env.CASE_RUNTIME_FOOTPRINT, + startupGUCs: process.env.CASE_STARTUP_GUCS, + gucs: { + shared_buffers: process.env.CASE_SHARED_BUFFERS, + wal_buffers: process.env.CASE_WAL_BUFFERS, + min_wal_size: process.env.CASE_MIN_WAL_SIZE, + max_wal_size: process.env.CASE_MAX_WAL_SIZE, + wal_segment_size_mb: process.env.CASE_WAL_SEGMENT_SIZE_MB, + }, + status: process.env.CASE_STATUS, +}; +process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); +NODE +} + +summarize_matrix() { + MATRIX_OUTPUT_DIR="$output_dir" MATRIX_RUN_ID="$run_id" node <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); + +const outputDir = process.env.MATRIX_OUTPUT_DIR; +const casesDir = path.join(outputDir, 'cases'); +const rows = []; + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +function readText(file) { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + return ''; + } +} + +function workload(report, id) { + return report?.workloads?.find(entry => entry.id === id); +} + +function latency(report, id, field) { + const value = workload(report, id)?.latency?.[field]; + return typeof value === 'number' ? value : null; +} + +function throughput(report, id) { + const value = workload(report, id)?.throughput?.rowsPerSecond; + return typeof value === 'number' ? value : null; +} + +function parseAndroidMemory(text) { + const pss = text.match(/TOTAL PSS:\s*([0-9]+)/); + const rss = text.match(/TOTAL RSS:\s*([0-9]+)/); + return { + androidPssKb: pss ? Number(pss[1]) : null, + androidRssKb: rss ? Number(rss[1]) : null, + }; +} + +function parseIosProcess(text) { + const [, line] = text.trim().split(/\r?\n/); + if (!line) { + return { iosResidentKb: null, iosCpuPercent: null }; + } + const [pid, rss, cpu] = line.split('\t'); + const rssValue = typeof rss === 'string' && rss.trim() !== '' ? Number(rss) : null; + const cpuValue = typeof cpu === 'string' && cpu.trim() !== '' ? Number(cpu) : null; + return { + iosResidentKb: pid && Number.isFinite(rssValue) ? rssValue : null, + iosCpuPercent: pid && Number.isFinite(cpuValue) ? cpuValue : null, + }; +} + +function bytesToKb(value) { + return typeof value === 'number' && Number.isFinite(value) ? value / 1024 : null; +} + +function parseProcessMemoryReport(report) { + const memory = report?.processMemoryReport; + if (!memory || typeof memory !== 'object') { + return { + processMemorySource: null, + processResidentKb: null, + processPhysicalFootprintKb: null, + processVirtualKb: null, + processPeakResidentKb: null, + processTotalPssKb: null, + processPrivateDirtyKb: null, + processSharedDirtyKb: null, + }; + } + return { + processMemorySource: typeof memory.source === 'string' ? memory.source : null, + processResidentKb: bytesToKb(memory.residentBytes), + processPhysicalFootprintKb: bytesToKb(memory.physicalFootprintBytes), + processVirtualKb: bytesToKb(memory.virtualBytes), + processPeakResidentKb: bytesToKb(memory.peakResidentBytes), + processTotalPssKb: typeof memory.totalPssKb === 'number' && Number.isFinite(memory.totalPssKb) + ? memory.totalPssKb + : null, + processPrivateDirtyKb: typeof memory.totalPrivateDirtyKb === 'number' && Number.isFinite(memory.totalPrivateDirtyKb) + ? memory.totalPrivateDirtyKb + : null, + processSharedDirtyKb: typeof memory.totalSharedDirtyKb === 'number' && Number.isFinite(memory.totalSharedDirtyKb) + ? memory.totalSharedDirtyKb + : null, + }; +} + +if (fs.existsSync(casesDir)) { + for (const name of fs.readdirSync(casesDir).sort()) { + const caseDir = path.join(casesDir, name); + const stat = fs.statSync(caseDir); + if (!stat.isDirectory()) { + continue; + } + const meta = readJson(path.join(caseDir, 'case.json')); + if (!meta) { + continue; + } + const report = readJson(path.join(caseDir, 'scratch', 'reports', 'benchmark-report.json')); + const crashReport = readJson(path.join(caseDir, 'crash-scratch', 'reports', 'crash-report.json')); + const androidMemory = parseAndroidMemory( + readText(path.join(caseDir, 'scratch', 'reports', 'benchmark-meminfo.txt')), + ); + const iosProcess = parseIosProcess( + readText(path.join(caseDir, 'scratch', 'reports', 'benchmark-process.tsv')), + ); + const processMemory = parseProcessMemoryReport(report); + const packageSizes = readJson( + path.join(caseDir, 'scratch', 'reports', 'benchmark-package-sizes.json'), + ); + rows.push({ + ...meta, + reportPath: report ? path.relative(outputDir, path.join(caseDir, 'scratch', 'reports', 'benchmark-report.json')) : null, + benchmarkPreset: typeof report?.metadata?.benchmarkPreset === 'string' ? report.metadata.benchmarkPreset : null, + postgresSettings: report?.postgresSettings && typeof report.postgresSettings === 'object' + ? report.postgresSettings + : null, + openMs: typeof report?.openMs === 'number' ? report.openMs : null, + closeMs: typeof report?.closeMs === 'number' ? report.closeMs : null, + elapsedMs: typeof report?.elapsedMs === 'number' ? report.elapsedMs : null, + rawP50Ms: latency(report, 'raw_simple_query_rtt', 'p50Ms'), + rawP90Ms: latency(report, 'raw_simple_query_rtt', 'p90Ms'), + rawP95Ms: latency(report, 'raw_simple_query_rtt', 'p95Ms'), + rawP99Ms: latency(report, 'raw_simple_query_rtt', 'p99Ms'), + typedP50Ms: latency(report, 'typed_select_rtt', 'p50Ms'), + typedP90Ms: latency(report, 'typed_select_rtt', 'p90Ms'), + typedP95Ms: latency(report, 'typed_select_rtt', 'p95Ms'), + typedP99Ms: latency(report, 'typed_select_rtt', 'p99Ms'), + parameterizedP50Ms: latency(report, 'parameterized_select_rtt', 'p50Ms'), + parameterizedP90Ms: latency(report, 'parameterized_select_rtt', 'p90Ms'), + parameterizedP95Ms: latency(report, 'parameterized_select_rtt', 'p95Ms'), + parameterizedP99Ms: latency(report, 'parameterized_select_rtt', 'p99Ms'), + lookupP50Ms: latency(report, 'indexed_lookup', 'p50Ms'), + lookupP90Ms: latency(report, 'indexed_lookup', 'p90Ms'), + lookupP95Ms: latency(report, 'indexed_lookup', 'p95Ms'), + lookupP99Ms: latency(report, 'indexed_lookup', 'p99Ms'), + aggregateP50Ms: latency(report, 'indexed_aggregate', 'p50Ms'), + aggregateP90Ms: latency(report, 'indexed_aggregate', 'p90Ms'), + aggregateP95Ms: latency(report, 'indexed_aggregate', 'p95Ms'), + aggregateP99Ms: latency(report, 'indexed_aggregate', 'p99Ms'), + updateP50Ms: latency(report, 'indexed_update', 'p50Ms'), + updateP90Ms: latency(report, 'indexed_update', 'p90Ms'), + updateP95Ms: latency(report, 'indexed_update', 'p95Ms'), + updateP99Ms: latency(report, 'indexed_update', 'p99Ms'), + backgroundCheckpointP50Ms: latency(report, 'background_checkpoint', 'p50Ms'), + backgroundCheckpointP90Ms: latency(report, 'background_checkpoint', 'p90Ms'), + backgroundCheckpointP95Ms: latency(report, 'background_checkpoint', 'p95Ms'), + backgroundCheckpointP99Ms: latency(report, 'background_checkpoint', 'p99Ms'), + largeResultP50Ms: latency(report, 'large_result_raw', 'p50Ms'), + largeResultP90Ms: latency(report, 'large_result_raw', 'p90Ms'), + largeResultP95Ms: latency(report, 'large_result_raw', 'p95Ms'), + largeResultP99Ms: latency(report, 'large_result_raw', 'p99Ms'), + sqliteOpenMs: typeof report?.sqliteBenchmark?.openMs === 'number' ? report.sqliteBenchmark.openMs : null, + sqliteSimpleP50Ms: latency(report?.sqliteBenchmark, 'sqlite_simple_select_rtt', 'p50Ms'), + sqliteSimpleP90Ms: latency(report?.sqliteBenchmark, 'sqlite_simple_select_rtt', 'p90Ms'), + sqliteSimpleP95Ms: latency(report?.sqliteBenchmark, 'sqlite_simple_select_rtt', 'p95Ms'), + sqliteSimpleP99Ms: latency(report?.sqliteBenchmark, 'sqlite_simple_select_rtt', 'p99Ms'), + sqliteParameterizedP50Ms: latency(report?.sqliteBenchmark, 'sqlite_parameterized_select_rtt', 'p50Ms'), + sqliteParameterizedP90Ms: latency(report?.sqliteBenchmark, 'sqlite_parameterized_select_rtt', 'p90Ms'), + sqliteParameterizedP95Ms: latency(report?.sqliteBenchmark, 'sqlite_parameterized_select_rtt', 'p95Ms'), + sqliteParameterizedP99Ms: latency(report?.sqliteBenchmark, 'sqlite_parameterized_select_rtt', 'p99Ms'), + sqliteLookupP50Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_lookup', 'p50Ms'), + sqliteLookupP90Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_lookup', 'p90Ms'), + sqliteLookupP95Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_lookup', 'p95Ms'), + sqliteLookupP99Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_lookup', 'p99Ms'), + sqliteAggregateP50Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_aggregate', 'p50Ms'), + sqliteAggregateP90Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_aggregate', 'p90Ms'), + sqliteAggregateP95Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_aggregate', 'p95Ms'), + sqliteAggregateP99Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_aggregate', 'p99Ms'), + sqliteUpdateP50Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_update', 'p50Ms'), + sqliteUpdateP90Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_update', 'p90Ms'), + sqliteUpdateP95Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_update', 'p95Ms'), + sqliteUpdateP99Ms: latency(report?.sqliteBenchmark, 'sqlite_indexed_update', 'p99Ms'), + sqliteCheckpointP50Ms: latency(report?.sqliteBenchmark, 'sqlite_wal_checkpoint', 'p50Ms'), + sqliteCheckpointP90Ms: latency(report?.sqliteBenchmark, 'sqlite_wal_checkpoint', 'p90Ms'), + sqliteCheckpointP95Ms: latency(report?.sqliteBenchmark, 'sqlite_wal_checkpoint', 'p95Ms'), + sqliteCheckpointP99Ms: latency(report?.sqliteBenchmark, 'sqlite_wal_checkpoint', 'p99Ms'), + sqliteLargeResultP50Ms: latency(report?.sqliteBenchmark, 'sqlite_large_result', 'p50Ms'), + sqliteLargeResultP90Ms: latency(report?.sqliteBenchmark, 'sqlite_large_result', 'p90Ms'), + sqliteLargeResultP95Ms: latency(report?.sqliteBenchmark, 'sqlite_large_result', 'p95Ms'), + sqliteLargeResultP99Ms: latency(report?.sqliteBenchmark, 'sqlite_large_result', 'p99Ms'), + sqliteInsertRowsPerSecond: throughput(report?.sqliteBenchmark, 'sqlite_transaction_insert'), + crashRecoveryElapsedMs: typeof crashReport?.elapsedMs === 'number' ? crashReport.elapsedMs : null, + crashRecoveryOpenMs: typeof crashReport?.openMs === 'number' ? crashReport.openMs : null, + insertRowsPerSecond: throughput(report, 'transaction_insert'), + packageBytes: typeof report?.packageSizeReport?.packageBytes === 'number' + ? report.packageSizeReport.packageBytes + : (typeof report?.packageBytes === 'number' ? report.packageBytes : null), + androidApkBytes: typeof packageSizes?.apkBytes === 'number' ? packageSizes.apkBytes : null, + iosAppBytes: typeof packageSizes?.iosAppBytes === 'number' ? packageSizes.iosAppBytes : null, + rnPackageBytes: typeof packageSizes?.rnPackageBytes === 'number' ? packageSizes.rnPackageBytes : null, + jsTimerTicks: typeof report?.jsTimerTicks === 'number' ? report.jsTimerTicks : null, + androidPssKb: processMemory.processTotalPssKb ?? androidMemory.androidPssKb, + androidRssKb: androidMemory.androidRssKb, + iosResidentKb: processMemory.processResidentKb ?? iosProcess.iosResidentKb, + iosPhysicalFootprintKb: processMemory.processPhysicalFootprintKb, + iosVirtualKb: processMemory.processVirtualKb, + iosPeakResidentKb: processMemory.processPeakResidentKb, + iosCpuPercent: iosProcess.iosCpuPercent, + processPrivateDirtyKb: processMemory.processPrivateDirtyKb, + processSharedDirtyKb: processMemory.processSharedDirtyKb, + processMemorySource: processMemory.processMemorySource, + }); + } +} + +const summary = { + schemaVersion: 1, + runId: process.env.MATRIX_RUN_ID, + outputDir, + generatedAt: new Date().toISOString(), + caseCount: rows.length, + passed: rows.filter(row => row.status === 'passed').length, + failed: rows.filter(row => row.status === 'failed').length, + rows, +}; +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(path.join(outputDir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`); + +function fmt(value, digits = 2) { + return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : ''; +} + +function fmtMb(kb) { + return typeof kb === 'number' && Number.isFinite(kb) ? (kb / 1024).toFixed(1) : ''; +} + +function fmtBytesMb(bytes) { + return typeof bytes === 'number' && Number.isFinite(bytes) ? (bytes / 1024 / 1024).toFixed(1) : ''; +} + +function effectiveGucSummary(settings) { + if (!settings || typeof settings !== 'object') { + return ''; + } + return [ + 'shared_buffers', + 'wal_buffers', + 'wal_segment_size', + 'min_wal_size', + 'max_wal_size', + 'synchronous_commit', + 'io_method', + ] + .map(name => { + const value = settings[name]; + return typeof value === 'string' ? `${name}=${value}` : null; + }) + .filter(Boolean) + .join(', '); +} + +const lines = []; +lines.push(`# Mobile Footprint Matrix ${summary.runId}`); +lines.push(''); +lines.push(`- Generated: ${summary.generatedAt}`); +lines.push(`- Cases: ${summary.caseCount}; passed: ${summary.passed}; failed: ${summary.failed}`); +lines.push(''); +const summaryColumns = [ + 'Case', 'Platform', 'Durability', 'Runtime footprint', 'Benchmark preset', + 'shared_buffers', 'wal_buffers', 'min_wal_size', 'max_wal_size', + 'WAL segment MB', 'Effective GUCs', 'Open ms', + 'Raw p50 ms', 'Raw p90 ms', 'Raw p95 ms', 'Raw p99 ms', + 'Typed p50 ms', 'Typed p90 ms', 'Typed p95 ms', 'Typed p99 ms', + 'Param p50 ms', 'Param p90 ms', 'Param p95 ms', 'Param p99 ms', + 'Lookup p50 ms', 'Lookup p90 ms', 'Lookup p95 ms', 'Lookup p99 ms', + 'Aggregate p50 ms', 'Aggregate p90 ms', 'Aggregate p95 ms', 'Aggregate p99 ms', + 'Update p50 ms', 'Update p90 ms', 'Update p95 ms', 'Update p99 ms', + 'Background checkpoint p50 ms', 'Background checkpoint p90 ms', + 'Background checkpoint p95 ms', 'Background checkpoint p99 ms', + 'Large result p50 ms', 'Large result p90 ms', 'Large result p95 ms', + 'Large result p99 ms', 'Crash recovery ms', 'Crash recovery open ms', + 'Insert rows/s', + 'SQLite open ms', 'SQLite simple p50 ms', 'SQLite simple p90 ms', + 'SQLite simple p95 ms', 'SQLite simple p99 ms', 'SQLite param p50 ms', + 'SQLite param p90 ms', 'SQLite param p95 ms', 'SQLite param p99 ms', + 'SQLite lookup p50 ms', 'SQLite lookup p90 ms', 'SQLite lookup p95 ms', + 'SQLite lookup p99 ms', 'SQLite aggregate p50 ms', 'SQLite aggregate p90 ms', + 'SQLite aggregate p95 ms', 'SQLite aggregate p99 ms', 'SQLite update p50 ms', + 'SQLite update p90 ms', 'SQLite update p95 ms', 'SQLite update p99 ms', + 'SQLite checkpoint p50 ms', 'SQLite checkpoint p90 ms', + 'SQLite checkpoint p95 ms', 'SQLite checkpoint p99 ms', + 'SQLite large result p50 ms', 'SQLite large result p90 ms', + 'SQLite large result p95 ms', 'SQLite large result p99 ms', + 'SQLite insert rows/s', 'Oliphaunt payload MB', 'Android APK MB', + 'iOS app MB', 'RN package KB', 'Android PSS MB', 'Android RSS MB', + 'iOS RSS MB', 'iOS footprint MB', 'iOS CPU %', 'Memory source', 'Report', +]; + +function markdownRow(cells) { + return `| ${cells.join(' | ')} |`; +} + +lines.push(markdownRow(summaryColumns)); +lines.push(markdownRow(summaryColumns.map(() => '---'))); +for (const row of rows) { + lines.push(markdownRow([ + row.status === 'passed' ? row.id : `${row.id} (${row.status})`, + row.platform, + row.durability, + row.runtimeFootprint ?? '', + row.benchmarkPreset ?? '', + row.gucs?.shared_buffers ?? '', + row.gucs?.wal_buffers ?? '', + row.gucs?.min_wal_size ?? '', + row.gucs?.max_wal_size ?? '', + row.gucs?.wal_segment_size_mb ?? '', + effectiveGucSummary(row.postgresSettings), + fmt(row.openMs), + fmt(row.rawP50Ms), + fmt(row.rawP90Ms), + fmt(row.rawP95Ms), + fmt(row.rawP99Ms), + fmt(row.typedP50Ms), + fmt(row.typedP90Ms), + fmt(row.typedP95Ms), + fmt(row.typedP99Ms), + fmt(row.parameterizedP50Ms), + fmt(row.parameterizedP90Ms), + fmt(row.parameterizedP95Ms), + fmt(row.parameterizedP99Ms), + fmt(row.lookupP50Ms), + fmt(row.lookupP90Ms), + fmt(row.lookupP95Ms), + fmt(row.lookupP99Ms), + fmt(row.aggregateP50Ms), + fmt(row.aggregateP90Ms), + fmt(row.aggregateP95Ms), + fmt(row.aggregateP99Ms), + fmt(row.updateP50Ms), + fmt(row.updateP90Ms), + fmt(row.updateP95Ms), + fmt(row.updateP99Ms), + fmt(row.backgroundCheckpointP50Ms), + fmt(row.backgroundCheckpointP90Ms), + fmt(row.backgroundCheckpointP95Ms), + fmt(row.backgroundCheckpointP99Ms), + fmt(row.largeResultP50Ms), + fmt(row.largeResultP90Ms), + fmt(row.largeResultP95Ms), + fmt(row.largeResultP99Ms), + fmt(row.crashRecoveryElapsedMs), + fmt(row.crashRecoveryOpenMs), + fmt(row.insertRowsPerSecond, 0), + fmt(row.sqliteOpenMs), + fmt(row.sqliteSimpleP50Ms), + fmt(row.sqliteSimpleP90Ms), + fmt(row.sqliteSimpleP95Ms), + fmt(row.sqliteSimpleP99Ms), + fmt(row.sqliteParameterizedP50Ms), + fmt(row.sqliteParameterizedP90Ms), + fmt(row.sqliteParameterizedP95Ms), + fmt(row.sqliteParameterizedP99Ms), + fmt(row.sqliteLookupP50Ms), + fmt(row.sqliteLookupP90Ms), + fmt(row.sqliteLookupP95Ms), + fmt(row.sqliteLookupP99Ms), + fmt(row.sqliteAggregateP50Ms), + fmt(row.sqliteAggregateP90Ms), + fmt(row.sqliteAggregateP95Ms), + fmt(row.sqliteAggregateP99Ms), + fmt(row.sqliteUpdateP50Ms), + fmt(row.sqliteUpdateP90Ms), + fmt(row.sqliteUpdateP95Ms), + fmt(row.sqliteUpdateP99Ms), + fmt(row.sqliteCheckpointP50Ms), + fmt(row.sqliteCheckpointP90Ms), + fmt(row.sqliteCheckpointP95Ms), + fmt(row.sqliteCheckpointP99Ms), + fmt(row.sqliteLargeResultP50Ms), + fmt(row.sqliteLargeResultP90Ms), + fmt(row.sqliteLargeResultP95Ms), + fmt(row.sqliteLargeResultP99Ms), + fmt(row.sqliteInsertRowsPerSecond, 0), + fmtBytesMb(row.packageBytes), + fmtBytesMb(row.androidApkBytes), + fmtBytesMb(row.iosAppBytes), + typeof row.rnPackageBytes === 'number' && Number.isFinite(row.rnPackageBytes) + ? (row.rnPackageBytes / 1024).toFixed(1) + : '', + fmtMb(row.androidPssKb), + fmtMb(row.androidRssKb), + fmtMb(row.iosResidentKb), + fmtMb(row.iosPhysicalFootprintKb), + fmt(row.iosCpuPercent), + row.processMemorySource ?? '', + row.reportPath ? `\`${row.reportPath}\`` : '', + ])); +} +lines.push(''); +fs.writeFileSync(path.join(outputDir, 'summary.md'), `${lines.join('\n')}\n`); +NODE +} + +print_or_run() { + local target_platform="$1" + local runtime_footprint="$2" + local durability="$3" + local shared="$4" + local wal="$5" + local min_wal="$6" + local max_wal="$7" + local startup_gucs="shared_buffers=$shared,wal_buffers=$wal,min_wal_size=$min_wal" + if [[ "$max_wal" != "default" ]]; then + startup_gucs="$startup_gucs,max_wal_size=$max_wal" + fi + local script="bench:$target_platform" + local crash_script="crash:$target_platform" + local raw_case_id="$target_platform-profile-$runtime_footprint-$durability-shared-$shared-wal-$wal-minwal-$min_wal-maxwal-$max_wal-walseg-${wal_segsize_mb}MB" + local case_id + case_id="$(case_slug "$raw_case_id")" + local case_dir="$output_dir/cases/$case_id" + local scratch="$case_dir/scratch" + local crash_scratch="$case_dir/crash-scratch" + local benchmark_preset=full + if [[ "$quick" -eq 1 ]]; then + benchmark_preset=quick + fi + local base_prefix=( + env + "OLIPHAUNT_EXPO_MOBILE_DURABILITY=$durability" + "OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT=$runtime_footprint" + "OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS=$startup_gucs" + "OLIPHAUNT_EXPO_MOBILE_WAL_SEGSIZE_MB=$wal_segsize_mb" + "OLIPHAUNT_EXPO_MOBILE_BENCHMARK_PRESET=$benchmark_preset" + ) + local prefix=("${base_prefix[@]}") + local crash_prefix=("${base_prefix[@]}") + local run_crash=0 + if [[ "$crash_recovery" = "per-case" && "$durability" = "safe" ]]; then + run_crash=1 + fi + + case "$target_platform" in + android) prefix+=("OLIPHAUNT_EXPO_ANDROID_SCRATCH=$scratch") ;; + ios) prefix+=("OLIPHAUNT_EXPO_IOS_SCRATCH=$scratch") ;; + esac + if [[ "$run_crash" -eq 1 ]]; then + case "$target_platform" in + android) crash_prefix+=("OLIPHAUNT_EXPO_ANDROID_SCRATCH=$crash_scratch") ;; + ios) crash_prefix+=("OLIPHAUNT_EXPO_IOS_SCRATCH=$crash_scratch") ;; + esac + fi + + if [[ "$quick" -eq 1 ]]; then + case "$target_platform" in + android) + prefix+=("OLIPHAUNT_EXPO_ANDROID_TIMEOUT_SECONDS=240") + crash_prefix+=("OLIPHAUNT_EXPO_ANDROID_TIMEOUT_SECONDS=240") + ;; + ios) + prefix+=("OLIPHAUNT_EXPO_IOS_TIMEOUT_SECONDS=240") + crash_prefix+=("OLIPHAUNT_EXPO_IOS_TIMEOUT_SECONDS=240") + ;; + esac + fi + + if [[ "$plan_only" -eq 1 ]]; then + printf 'case platform=%s durability=%s runtimeFootprint=%s shared_buffers=%s wal_buffers=%s min_wal_size=%s max_wal_size=%s wal_segment_size_mb=%s\n' \ + "$target_platform" "$durability" "$runtime_footprint" "$shared" "$wal" "$min_wal" "$max_wal" "$wal_segsize_mb" + printf 'benchmarkPreset=%s\n' "$benchmark_preset" + printf 'caseId=%s\n' "$case_id" + printf 'caseOutputDir=%s\n' "$case_dir" + printf 'command=' + for part in "${prefix[@]}"; do + printf '%s ' "$(shell_quote "$part")" + done + printf 'pnpm --dir %s run %s\n' "$(shell_quote "$example_dir")" "$(shell_quote "$script")" + if [[ "$run_crash" -eq 1 ]]; then + printf 'crashCommand=' + for part in "${crash_prefix[@]}"; do + printf '%s ' "$(shell_quote "$part")" + done + printf 'pnpm --dir %s run %s\n' "$(shell_quote "$example_dir")" "$(shell_quote "$crash_script")" + elif [[ "$crash_recovery" = "per-case" ]]; then + printf 'crashCommand=skipped durability=%s reason=synchronous_commit_off_does_not_guarantee_last_commit\n' "$durability" + fi + return + fi + + mkdir -p "$case_dir" + write_case_metadata \ + "$case_dir" \ + "$case_id" \ + "$target_platform" \ + "$durability" \ + "$runtime_footprint" \ + "$shared" \ + "$wal" \ + "$min_wal" \ + "$max_wal" \ + "$wal_segsize_mb" \ + "$startup_gucs" \ + "running" + + echo "==> mobile footprint case $case_id" + set +e + "${prefix[@]}" pnpm --dir "$example_dir" run "$script" 2>&1 | tee "$case_dir/harness.log" + local status="${PIPESTATUS[0]}" + if [[ "$status" -eq 0 && "$run_crash" -eq 1 ]]; then + "${crash_prefix[@]}" pnpm --dir "$example_dir" run "$crash_script" 2>&1 | tee "$case_dir/crash-harness.log" + status="${PIPESTATUS[0]}" + fi + set -e + if [[ "$status" -eq 0 ]]; then + write_case_metadata \ + "$case_dir" \ + "$case_id" \ + "$target_platform" \ + "$durability" \ + "$runtime_footprint" \ + "$shared" \ + "$wal" \ + "$min_wal" \ + "$max_wal" \ + "$wal_segsize_mb" \ + "$startup_gucs" \ + "passed" + else + write_case_metadata \ + "$case_dir" \ + "$case_id" \ + "$target_platform" \ + "$durability" \ + "$runtime_footprint" \ + "$shared" \ + "$wal" \ + "$min_wal" \ + "$max_wal" \ + "$wal_segsize_mb" \ + "$startup_gucs" \ + "failed" + if [[ "$keep_going" -ne 1 ]]; then + summarize_matrix + exit "$status" + fi + fi +} + +if [[ "$summarize_only" -eq 1 ]]; then + summarize_matrix + printf 'mobile footprint summary: %s\n' "$output_dir/summary.md" + exit 0 +fi + +planned=0 +skipped=0 +skipped_wal_range=0 +for target_platform in "${platforms[@]}"; do + for runtime_footprint in "${runtime_footprints[@]}"; do + for durability in "${durabilities[@]}"; do + for shared in "${shared_buffers[@]}"; do + for wal in "${wal_buffers[@]}"; do + for min_wal in "${min_wal_sizes[@]}"; do + for max_wal in "${max_wal_sizes[@]}"; do + if ! is_valid_wal_min_for_segment "$min_wal" "$wal_segsize_mb" && [[ "$include_invalid_wal_min" -ne 1 ]]; then + skipped=$((skipped + 1)) + continue + fi + if ! is_valid_wal_range "$min_wal" "$max_wal"; then + skipped_wal_range=$((skipped_wal_range + 1)) + continue + fi + planned=$((planned + 1)) + print_or_run "$target_platform" "$runtime_footprint" "$durability" "$shared" "$wal" "$min_wal" "$max_wal" + done + done + done + done + done + done +done + +if [[ "$plan_only" -eq 1 ]]; then + printf 'runId=%s\n' "$run_id" + printf 'outputDir=%s\n' "$output_dir" + printf 'planned=%s\n' "$planned" + printf 'walSegmentSizeMB=%s\n' "$wal_segsize_mb" + printf 'skippedInvalidForWalSegment=%s\n' "$skipped" + printf 'skippedInvalidWalRange=%s\n' "$skipped_wal_range" +else + summarize_matrix + printf 'mobile footprint summary: %s\n' "$output_dir/summary.md" +fi diff --git a/tools/perf/matrix/run_native_oliphaunt_matrix.sh b/tools/perf/matrix/run_native_oliphaunt_matrix.sh new file mode 100755 index 00000000..d8e60e84 --- /dev/null +++ b/tools/perf/matrix/run_native_oliphaunt_matrix.sh @@ -0,0 +1,778 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +fi +TARGET_ROOT="$REPO_ROOT/target/perf" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +RELEASE_MIN_RTT_ITERATIONS=100 +RELEASE_MIN_RTT_REPEATS=10 +RELEASE_MIN_PREPARED_ROWS=25000 +RELEASE_MIN_PREPARED_REPEATS=10 +RELEASE_MIN_SPEED_REPEATS=20 +RELEASE_MIN_BACKUP_REPEATS=10 +RTT_ITERATIONS="$RELEASE_MIN_RTT_ITERATIONS" +RTT_REPEATS="$RELEASE_MIN_RTT_REPEATS" +PREPARED_ROWS="$RELEASE_MIN_PREPARED_ROWS" +PREPARED_REPEATS="$RELEASE_MIN_PREPARED_REPEATS" +SPEED_REPEATS="$RELEASE_MIN_SPEED_REPEATS" +BACKUP_REPEATS="$RELEASE_MIN_BACKUP_REPEATS" +RUN_SQLITE=1 +RUN_PREPARED=1 +BUILD_PERF_RUNNER="${OLIPHAUNT_PERF_BUILD_RUNNER:-1}" +PLAN_ONLY=0 +DURABILITY="${OLIPHAUNT_PERF_DURABILITY:-safe}" +RUNTIME_FOOTPRINT="${OLIPHAUNT_PERF_RUNTIME_FOOTPRINT:-throughput}" +PGDATA_COPY_MODE="${OLIPHAUNT_PGDATA_COPY_MODE:-copy}" +NATIVE_ENGINES="${OLIPHAUNT_PERF_ENGINES:-direct,broker,server}" +SUITES="${OLIPHAUNT_PERF_SUITES:-rtt,speed,streaming,prepared,backup}" +STARTUP_GUCS=() +if [[ -n "${OLIPHAUNT_PERF_STARTUP_GUCS:-}" ]]; then + IFS=',' read -r -a STARTUP_GUCS <<< "${OLIPHAUNT_PERF_STARTUP_GUCS//[[:space:]]/}" +fi + +usage() { + cat >&2 <<'USAGE' +usage: tools/perf/matrix/run_native_oliphaunt_matrix.sh [options] + +Options: + --run-id ID Output run id. Defaults to current UTC timestamp. + --rtt-iterations N RTT samples per case. Default: 100. + --rtt-repeats N Fresh-process RTT repeats for release-grade gating. Default: 10. + --prepared-rows N Prepared-update rows. Default: 25000. + --prepared-repeats N Fresh-process prepared-update repeats for p90/p95. Default: 10. + --speed-repeats N Fresh-process speed-suite repeats for p50/p90/p95. Default: 20. + --backup-repeats N Fresh-process backup/restore repeats for p50/p90/p95. Default: 10. + --durability PROFILE Native durability profile: safe, balanced, or fast-dev. Default: safe. + --runtime-footprint PROFILE + Native runtime footprint: throughput, balanced-mobile, or small-mobile. + Default: throughput. + --startup-guc NAME=VALUE + PostgreSQL startup GUC override. Repeatable; applied after footprint + and durability defaults. + --pgdata-copy-mode MODE PGDATA template hydration: copy or prefer-clone. Default: copy. + --engines LIST Comma-separated native engines: direct, broker, server, or all. + Default: direct,broker,server. + --suite LIST Alias for --suites. + --suites LIST Comma-separated suites: rtt, speed, streaming, prepared, backup, or all. + Default: rtt,speed,streaming,prepared,backup. + --quick Fast plumbing preset: 10 RTT samples, one repeat, 1000 prepared rows. + --plan-only Print the native-only benchmark plan and exit without artifact checks. + --skip-sqlite Skip SQLite embedded control. + --skip-prepared Skip prepared-update suites. + --skip-build Reuse target/release/oliphaunt-perf without rebuilding it. + -h, --help Show this help. + +Environment: + LIBOLIPHAUNT_PATH Required path to liboliphaunt.dylib/.so. + OLIPHAUNT_POSTGRES Path to matching postgres binary. + OLIPHAUNT_INITDB Path to matching initdb binary. + OLIPHAUNT_PERF_STARTUP_GUCS + Comma-separated NAME=VALUE startup GUC overrides. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --run-id) + RUN_ID="${2:?--run-id requires a value}" + shift 2 + ;; + --rtt-iterations) + RTT_ITERATIONS="${2:?--rtt-iterations requires a value}" + shift 2 + ;; + --rtt-repeats) + RTT_REPEATS="${2:?--rtt-repeats requires a value}" + shift 2 + ;; + --prepared-rows) + PREPARED_ROWS="${2:?--prepared-rows requires a value}" + shift 2 + ;; + --prepared-repeats) + PREPARED_REPEATS="${2:?--prepared-repeats requires a value}" + shift 2 + ;; + --speed-repeats) + SPEED_REPEATS="${2:?--speed-repeats requires a value}" + shift 2 + ;; + --backup-repeats) + BACKUP_REPEATS="${2:?--backup-repeats requires a value}" + shift 2 + ;; + --durability) + DURABILITY="${2:?--durability requires a value}" + shift 2 + ;; + --runtime-footprint) + RUNTIME_FOOTPRINT="${2:?--runtime-footprint requires a value}" + shift 2 + ;; + --startup-guc) + STARTUP_GUCS+=("${2:?--startup-guc requires a value}") + shift 2 + ;; + --pgdata-copy-mode) + PGDATA_COPY_MODE="${2:?--pgdata-copy-mode requires a value}" + shift 2 + ;; + --engines) + NATIVE_ENGINES="${2:?--engines requires a value}" + shift 2 + ;; + --suite|--suites) + SUITES="${2:?--suites requires a value}" + shift 2 + ;; + --quick) + RTT_ITERATIONS=10 + RTT_REPEATS=1 + PREPARED_ROWS=1000 + PREPARED_REPEATS=1 + SPEED_REPEATS=1 + BACKUP_REPEATS=1 + shift + ;; + --plan-only) + PLAN_ONLY=1 + shift + ;; + --skip-sqlite) + RUN_SQLITE=0 + shift + ;; + --skip-prepared) + RUN_PREPARED=0 + shift + ;; + --skip-build) + BUILD_PERF_RUNNER=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +csv_has() { + local csv="$1" + local value="$2" + [[ ",$csv," == *",$value,"* ]] +} + +normalize_csv_arg() { + local name="$1" + local raw="${2//[[:space:]]/}" + local allowed="$3" + local default_value="$4" + local item + local output="" + + if [[ -z "$raw" || "$raw" == "all" ]]; then + printf '%s\n' "$default_value" + return + fi + + IFS=',' read -r -a items <<< "$raw" + for item in "${items[@]}"; do + if [[ -z "$item" ]]; then + echo "$name must not contain empty entries" >&2 + exit 2 + fi + if [[ "$item" == "all" ]]; then + printf '%s\n' "$default_value" + return + fi + if ! csv_has "$allowed" "$item"; then + echo "unknown $name value: $item" >&2 + exit 2 + fi + if ! csv_has "$output" "$item"; then + output="${output:+$output,}$item" + fi + done + + if [[ -z "$output" ]]; then + echo "$name must not be empty" >&2 + exit 2 + fi + printf '%s\n' "$output" +} + +NATIVE_ENGINES="$(normalize_csv_arg "--engines" "$NATIVE_ENGINES" "direct,broker,server" "direct,broker,server")" +SUITES="$(normalize_csv_arg "--suites" "$SUITES" "rtt,speed,streaming,prepared,backup" "rtt,speed,streaming,prepared,backup")" +if ! csv_has "$SUITES" speed; then + RUN_SQLITE=0 +fi +if ! csv_has "$SUITES" prepared; then + RUN_PREPARED=0 +fi + +if [[ "$RTT_ITERATIONS" -le 0 || "$RTT_REPEATS" -le 0 || "$PREPARED_ROWS" -le 0 || "$PREPARED_REPEATS" -le 0 || "$SPEED_REPEATS" -le 0 || "$BACKUP_REPEATS" -le 0 ]]; then + echo "iteration, row, and repeat counts must be positive" >&2 + exit 2 +fi + +case "$DURABILITY" in + safe|balanced|fast-dev) ;; + *) + echo "unknown durability profile: $DURABILITY" >&2 + exit 2 + ;; +esac + +case "$RUNTIME_FOOTPRINT" in + throughput|balanced-mobile|small-mobile) ;; + *) + echo "unknown runtime footprint profile: $RUNTIME_FOOTPRINT" >&2 + exit 2 + ;; +esac + +STARTUP_GUC_COUNT="${#STARTUP_GUCS[@]}" +if [[ "$STARTUP_GUC_COUNT" -gt 0 ]]; then + for startup_guc in "${STARTUP_GUCS[@]}"; do + case "$startup_guc" in + *=?*) ;; + *) + echo "startup GUC must be formatted as name=value: $startup_guc" >&2 + exit 2 + ;; + esac + done +fi + +TUNING_ARGS=(--runtime-footprint "$RUNTIME_FOOTPRINT") +if [[ "$STARTUP_GUC_COUNT" -gt 0 ]]; then + for startup_guc in "${STARTUP_GUCS[@]}"; do + TUNING_ARGS+=(--startup-guc "$startup_guc") + done +fi + +join_csv() { + local IFS=, + printf '%s\n' "$*" +} +STARTUP_GUCS_CSV="" +if [[ "$STARTUP_GUC_COUNT" -gt 0 ]]; then + STARTUP_GUCS_CSV="$(join_csv "${STARTUP_GUCS[@]}")" +fi + +case "$PGDATA_COPY_MODE" in + prefer-clone|clone|copy|byte-copy|byte_copy|physical-copy|physical_copy) ;; + *) + echo "unknown PGDATA copy mode: $PGDATA_COPY_MODE" >&2 + exit 2 + ;; +esac +export OLIPHAUNT_PGDATA_COPY_MODE="$PGDATA_COPY_MODE" + +PARTIAL_REPORT=0 +if [[ "$NATIVE_ENGINES" != "direct,broker,server" || "$SUITES" != "rtt,speed,streaming,prepared,backup" || "$RUN_SQLITE" -ne 1 || "$RUN_PREPARED" -ne 1 ]]; then + PARTIAL_REPORT=1 +fi + +RELEASE_EVIDENCE=0 +if [[ "$PARTIAL_REPORT" -eq 0 && + "$RTT_ITERATIONS" -ge "$RELEASE_MIN_RTT_ITERATIONS" && + "$RTT_REPEATS" -ge "$RELEASE_MIN_RTT_REPEATS" && + "$PREPARED_ROWS" -ge "$RELEASE_MIN_PREPARED_ROWS" && + "$PREPARED_REPEATS" -ge "$RELEASE_MIN_PREPARED_REPEATS" && + "$SPEED_REPEATS" -ge "$RELEASE_MIN_SPEED_REPEATS" && + "$BACKUP_REPEATS" -ge "$RELEASE_MIN_BACKUP_REPEATS" ]]; then + RELEASE_EVIDENCE=1 +fi + +DIAGNOSTIC_RUN=0 +if [[ "$RELEASE_EVIDENCE" -ne 1 ]]; then + DIAGNOSTIC_RUN=1 +fi + +native_case_name() { + local engine="$1" + local suite="$2" + case "$engine:$suite" in + direct:rtt) echo "native-liboliphaunt-rtt" ;; + direct:speed) echo "native-liboliphaunt-speed" ;; + direct:streaming) echo "native-liboliphaunt-streaming" ;; + direct:prepared) echo "native-liboliphaunt-prepared-direct" ;; + direct:backup) echo "native-liboliphaunt-backup" ;; + broker:rtt) echo "native-liboliphaunt-broker-rtt" ;; + broker:speed) echo "native-liboliphaunt-broker-speed" ;; + broker:streaming) echo "native-liboliphaunt-broker-streaming" ;; + broker:prepared) echo "native-liboliphaunt-prepared-broker" ;; + broker:backup) echo "native-liboliphaunt-broker-backup" ;; + server:rtt) echo "native-liboliphaunt-server-rtt" ;; + server:speed) echo "native-liboliphaunt-server-speed" ;; + server:streaming) echo "native-liboliphaunt-server-streaming" ;; + server:prepared) echo "native-liboliphaunt-prepared-server" ;; + server:backup) echo "native-liboliphaunt-server-backup" ;; + *) + echo "unsupported native case: $engine $suite" >&2 + exit 2 + ;; + esac +} + +print_native_plan_cases() { + local suite + local engine + for suite in rtt speed streaming prepared backup; do + if ! csv_has "$SUITES" "$suite"; then + continue + fi + if [[ "$suite" == "prepared" && "$RUN_PREPARED" -ne 1 ]]; then + continue + fi + for engine in direct broker server; do + if csv_has "$NATIVE_ENGINES" "$engine"; then + echo "case=$(native_case_name "$engine" "$suite")" + fi + done + done +} + +print_native_postgres_plan_cases() { + if csv_has "$SUITES" rtt && csv_has "$SUITES" speed; then + echo "case=native-postgres-tokio-all" + echo "case=native-postgres-sqlx-all" + else + if csv_has "$SUITES" rtt; then + echo "case=native-postgres-tokio-rtt" + echo "case=native-postgres-sqlx-rtt" + fi + if csv_has "$SUITES" speed; then + echo "case=native-postgres-tokio-speed" + echo "case=native-postgres-sqlx-speed" + fi + fi + if csv_has "$SUITES" streaming; then + echo "case=native-postgres-streaming" + fi + if csv_has "$SUITES" speed && [[ "$RUN_SQLITE" -eq 1 ]]; then + echo "case=sqlite-speed" + fi + if csv_has "$SUITES" prepared && [[ "$RUN_PREPARED" -eq 1 ]]; then + echo "case=native-postgres-prepared" + fi + if csv_has "$SUITES" backup; then + echo "case=native-postgres-backup" + if [[ "$RUN_SQLITE" -eq 1 ]]; then + echo "case=sqlite-backup" + fi + fi +} + +print_plan() { + cat <&2 + exit 1 +fi +if [[ ! -x "$POSTGRES_BIN" ]]; then + echo "missing native postgres binary: $POSTGRES_BIN" >&2 + exit 1 +fi +if [[ ! -x "$INITDB_BIN" ]]; then + echo "missing native initdb binary: $INITDB_BIN" >&2 + exit 1 +fi + +export LIBOLIPHAUNT_PATH="$OLIPHAUNT" +export OLIPHAUNT_POSTGRES="$POSTGRES_BIN" +export OLIPHAUNT_INITDB="$INITDB_BIN" + +RUN_DIR="$TARGET_ROOT/native-liboliphaunt-$RUN_ID" +mkdir -p "$RUN_DIR" + +PERF_RUNNER="$REPO_ROOT/target/release/oliphaunt-perf" + +RUN_DIR="$RUN_DIR" \ +OLIPHAUNT_PATH="$OLIPHAUNT" \ +POSTGRES_BIN_PATH="$POSTGRES_BIN" \ +INITDB_BIN_PATH="$INITDB_BIN" \ +node <<'NODE' > "$RUN_DIR/artifact-sizes.json" +const fs = require('node:fs') +const path = require('node:path') + +function sizeBytes(target) { + if (!target || !fs.existsSync(target)) return null + const stat = fs.lstatSync(target) + if (stat.isFile() || stat.isSymbolicLink()) return stat.size + if (!stat.isDirectory()) return 0 + let total = 0 + for (const entry of fs.readdirSync(target)) { + total += sizeBytes(path.join(target, entry)) ?? 0 + } + return total +} + +const liboliphaunt = process.env.OLIPHAUNT_PATH +const installDir = path.dirname(path.dirname(process.env.POSTGRES_BIN_PATH)) +const embeddedModules = path.join(path.dirname(liboliphaunt), 'modules') +const artifacts = [ + ['liboliphaunt-native', liboliphaunt], + ['embedded-modules', embeddedModules], + ['native-postgres-install', installDir], +] +console.log(JSON.stringify({ + artifacts: artifacts.map(([name, filePath]) => ({ + name, + path: filePath, + bytes: sizeBytes(filePath), + })), +}, null, 2)) +NODE + +if [[ "$BUILD_PERF_RUNNER" -eq 1 ]]; then + echo "Building native-only release oliphaunt-perf and native broker helper..." + cargo build --release -p oliphaunt-perf -p oliphaunt --bins +elif [[ ! -x "$PERF_RUNNER" ]]; then + echo "missing release oliphaunt-perf: $PERF_RUNNER" >&2 + echo "run without --skip-build first" >&2 + exit 1 +else + echo "Reusing existing release oliphaunt-perf: $PERF_RUNNER" +fi + +node "$SCRIPT_DIR/native_oliphaunt_provenance.mjs" write \ + --run-dir "$RUN_DIR" \ + --repo-root "$REPO_ROOT" \ + --run-id "$RUN_ID" \ + --native-engines "$NATIVE_ENGINES" \ + --suites "$SUITES" \ + --durability "$DURABILITY" \ + --runtime-footprint "$RUNTIME_FOOTPRINT" \ + --startup-gucs "$STARTUP_GUCS_CSV" \ + --rtt-iterations "$RTT_ITERATIONS" \ + --rtt-repeats "$RTT_REPEATS" \ + --prepared-rows "$PREPARED_ROWS" \ + --prepared-repeats "$PREPARED_REPEATS" \ + --speed-repeats "$SPEED_REPEATS" \ + --backup-repeats "$BACKUP_REPEATS" \ + --pgdata-copy-mode "$PGDATA_COPY_MODE" \ + --run-sqlite "$RUN_SQLITE" \ + --run-prepared "$RUN_PREPARED" \ + --release-evidence "$RELEASE_EVIDENCE" \ + --partial-report "$PARTIAL_REPORT" \ + --diagnostic-run "$DIAGNOSTIC_RUN" \ + --release-min-rtt-iterations "$RELEASE_MIN_RTT_ITERATIONS" \ + --release-min-rtt-repeats "$RELEASE_MIN_RTT_REPEATS" \ + --release-min-prepared-rows "$RELEASE_MIN_PREPARED_ROWS" \ + --release-min-prepared-repeats "$RELEASE_MIN_PREPARED_REPEATS" \ + --release-min-speed-repeats "$RELEASE_MIN_SPEED_REPEATS" \ + --release-min-backup-repeats "$RELEASE_MIN_BACKUP_REPEATS" \ + --liboliphaunt "$OLIPHAUNT" \ + --postgres-bin "$POSTGRES_BIN" \ + --initdb-bin "$INITDB_BIN" \ + --perf-runner "$PERF_RUNNER" \ + > "$RUN_DIR/provenance.path" + +run_timed_json() { + local name="$1" + shift + local json="$RUN_DIR/$name.json" + local resource="$RUN_DIR/$name.resource.txt" + + echo "Running $name..." + if [[ "$(uname -s)" == "Darwin" ]]; then + /usr/bin/time -l -o "$resource" "$@" > "$json" + elif /usr/bin/time -v true >/dev/null 2>&1; then + /usr/bin/time -v -o "$resource" "$@" > "$json" + else + /usr/bin/time -p -o "$resource" "$@" > "$json" + fi +} + +run_native_liboliphaunt_case() { + local engine="$1" + local suite="$2" + local name="${3:-}" + if [[ -z "$name" ]]; then + name="$(native_case_name "$engine" "$suite")" + fi + case "$suite" in + rtt) + run_timed_json "$name" \ + "$PERF_RUNNER" native-liboliphaunt \ + --engine "$engine" \ + --suite rtt \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" \ + --iterations "$RTT_ITERATIONS" + ;; + speed) + run_timed_json "$name" \ + "$PERF_RUNNER" native-liboliphaunt \ + --engine "$engine" \ + --suite speed \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" \ + --speed-source oliphaunt + ;; + streaming) + run_timed_json "$name" \ + "$PERF_RUNNER" native-liboliphaunt \ + --engine "$engine" \ + --suite streaming \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" + ;; + prepared) + run_timed_json "$name" \ + "$PERF_RUNNER" native-liboliphaunt \ + --engine "$engine" \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" \ + --suite prepared-updates \ + --rows "$PREPARED_ROWS" + ;; + backup) + run_timed_json "$name" \ + "$PERF_RUNNER" native-liboliphaunt \ + --engine "$engine" \ + --suite backup-restore \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" + ;; + esac +} + +run_native_postgres_case() { + local name="$1" + local client="$2" + local suite="$3" + local args=( + "$PERF_RUNNER" native-postgres + --suite "$suite" + --durability "$DURABILITY" + "${TUNING_ARGS[@]}" + --postgres-bin "$POSTGRES_BIN" + --initdb-bin "$INITDB_BIN" + ) + if [[ "$suite" == "all" || "$suite" == "rtt" ]]; then + args+=(--iterations "$RTT_ITERATIONS") + fi + if [[ "$suite" == "all" || "$suite" == "speed" ]]; then + args+=(--speed-source oliphaunt) + fi + if [[ -n "$client" ]]; then + args+=(--client "$client") + fi + run_timed_json "$name" "${args[@]}" +} + +run_native_postgres_prepared() { + local name="$1" + run_timed_json "$name" \ + "$PERF_RUNNER" native-postgres \ + --suite prepared-updates \ + --rows "$PREPARED_ROWS" \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" \ + --postgres-bin "$POSTGRES_BIN" \ + --initdb-bin "$INITDB_BIN" +} + +for suite in rtt speed streaming prepared backup; do + if ! csv_has "$SUITES" "$suite"; then + continue + fi + if [[ "$suite" == "prepared" && "$RUN_PREPARED" -ne 1 ]]; then + continue + fi + for engine in direct broker server; do + if csv_has "$NATIVE_ENGINES" "$engine"; then + run_native_liboliphaunt_case "$engine" "$suite" + fi + done +done + +if csv_has "$SUITES" rtt && csv_has "$SUITES" speed; then + run_native_postgres_case native-postgres-tokio-all tokio-postgres-simple all + run_native_postgres_case native-postgres-sqlx-all sqlx all +else + if csv_has "$SUITES" rtt; then + run_native_postgres_case native-postgres-tokio-rtt tokio-postgres-simple rtt + run_native_postgres_case native-postgres-sqlx-rtt sqlx rtt + fi + if csv_has "$SUITES" speed; then + run_native_postgres_case native-postgres-tokio-speed tokio-postgres-simple speed + run_native_postgres_case native-postgres-sqlx-speed sqlx speed + fi +fi + +if csv_has "$SUITES" streaming; then + run_native_postgres_case native-postgres-streaming "" streaming +fi + +if csv_has "$SUITES" speed && [[ "$RUN_SQLITE" -eq 1 ]]; then + run_timed_json sqlite-speed \ + "$PERF_RUNNER" sqlite \ + --suite speed \ + --durability "$DURABILITY" \ + --speed-source oliphaunt +fi + +if csv_has "$SUITES" prepared && [[ "$RUN_PREPARED" -eq 1 ]]; then + run_native_postgres_prepared native-postgres-prepared +fi + +if csv_has "$SUITES" backup; then + run_native_postgres_case native-postgres-backup tokio-postgres-simple backup-restore + if [[ "$RUN_SQLITE" -eq 1 ]]; then + run_timed_json sqlite-backup \ + "$PERF_RUNNER" sqlite \ + --suite backup-restore \ + --durability "$DURABILITY" + fi +fi + +if csv_has "$SUITES" prepared && [[ "$RUN_PREPARED" -eq 1 && "$PREPARED_REPEATS" -gt 1 ]]; then + mkdir -p "$RUN_DIR/repeats" + for index in $(seq -w 1 "$PREPARED_REPEATS"); do + run_native_postgres_prepared "repeats/native-postgres-prepared-$index" + for engine in direct broker server; do + if csv_has "$NATIVE_ENGINES" "$engine"; then + run_native_liboliphaunt_case "$engine" prepared "repeats/$(native_case_name "$engine" prepared)-$index" + fi + done + done +fi + +if csv_has "$SUITES" rtt && [[ "$RTT_REPEATS" -gt 1 ]]; then + mkdir -p "$RUN_DIR/repeats" + for index in $(seq -w 1 "$RTT_REPEATS"); do + for engine in direct broker server; do + if csv_has "$NATIVE_ENGINES" "$engine"; then + run_timed_json "repeats/$(native_case_name "$engine" rtt)-$index" \ + "$PERF_RUNNER" native-liboliphaunt \ + --engine "$engine" \ + --suite rtt \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" \ + --iterations "$RTT_ITERATIONS" + fi + done + run_native_postgres_case "repeats/native-postgres-tokio-rtt-$index" tokio-postgres-simple rtt + done +fi + +if csv_has "$SUITES" speed && [[ "$SPEED_REPEATS" -gt 1 ]]; then + mkdir -p "$RUN_DIR/repeats" + for index in $(seq -w 1 "$SPEED_REPEATS"); do + for engine in direct broker server; do + if csv_has "$NATIVE_ENGINES" "$engine"; then + run_timed_json "repeats/$(native_case_name "$engine" speed)-$index" \ + "$PERF_RUNNER" native-liboliphaunt \ + --engine "$engine" \ + --suite speed \ + --durability "$DURABILITY" \ + "${TUNING_ARGS[@]}" \ + --speed-source oliphaunt + fi + done + run_native_postgres_case "repeats/native-postgres-tokio-speed-$index" tokio-postgres-simple speed + if [[ "$RUN_SQLITE" -eq 1 ]]; then + run_timed_json "repeats/sqlite-speed-$index" \ + "$PERF_RUNNER" sqlite \ + --suite speed \ + --durability "$DURABILITY" \ + --speed-source oliphaunt + fi + done +fi + +if csv_has "$SUITES" backup && [[ "$BACKUP_REPEATS" -gt 1 ]]; then + mkdir -p "$RUN_DIR/repeats" + for index in $(seq -w 1 "$BACKUP_REPEATS"); do + for engine in direct broker server; do + if csv_has "$NATIVE_ENGINES" "$engine"; then + run_native_liboliphaunt_case "$engine" backup "repeats/$(native_case_name "$engine" backup)-$index" + fi + done + run_native_postgres_case "repeats/native-postgres-backup-$index" tokio-postgres-simple backup-restore + if [[ "$RUN_SQLITE" -eq 1 ]]; then + run_timed_json "repeats/sqlite-backup-$index" \ + "$PERF_RUNNER" sqlite \ + --suite backup-restore \ + --durability "$DURABILITY" + fi + done +fi + +node "$SCRIPT_DIR/summarize_native_oliphaunt_matrix.mjs" \ + --run-dir "$RUN_DIR" \ + --run-id "$RUN_ID" \ + --postgres-version "$("$POSTGRES_BIN" --version)" \ + --native-engines "$NATIVE_ENGINES" \ + --suites "$SUITES" \ + --durability "$DURABILITY" \ + --runtime-footprint "$RUNTIME_FOOTPRINT" \ + --startup-gucs "$STARTUP_GUCS_CSV" \ + --pgdata-copy-mode "$PGDATA_COPY_MODE" \ + --release-evidence "$RELEASE_EVIDENCE" \ + --partial-report "$PARTIAL_REPORT" \ + --rtt-repeats "$RTT_REPEATS" \ + --prepared-repeats "$PREPARED_REPEATS" \ + --speed-repeats "$SPEED_REPEATS" \ + --backup-repeats "$BACKUP_REPEATS" \ + > "$RUN_DIR/report.md" + +echo "$RUN_DIR/report.md" diff --git a/tools/perf/matrix/run_native_speed_diagnostics.sh b/tools/perf/matrix/run_native_speed_diagnostics.sh new file mode 100755 index 00000000..d8e3a15c --- /dev/null +++ b/tools/perf/matrix/run_native_speed_diagnostics.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + : +else + REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +fi + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +IDS="" +REPEATS=10 +DURABILITY="${OLIPHAUNT_PERF_DURABILITY:-safe}" +BUILD_PERF_RUNNER=1 + +usage() { + cat >&2 <<'USAGE' +usage: tools/perf/matrix/run_native_speed_diagnostics.sh --ids LIST [options] + +Options: + --ids LIST Comma-separated Oliphaunt fixture speed case ids. + --repeats N Fresh-process repeats per case. Default: 10. + --run-id ID Output run id. Defaults to current UTC timestamp. + --durability PROFILE + Native durability profile: safe, balanced, or fast-dev. + --skip-build Reuse target/release/oliphaunt-perf. + -h, --help Show this help. + +Environment: + LIBOLIPHAUNT_PATH Path to liboliphaunt.dylib/.so. Defaults to target artifact. + OLIPHAUNT_POSTGRES Path to matching postgres binary. Defaults to target artifact. + OLIPHAUNT_INITDB Path to matching initdb binary. Defaults to target artifact. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --ids) + IDS="${2:?--ids requires a value}" + shift 2 + ;; + --ids=*) + IDS="${1#--ids=}" + shift + ;; + --repeats) + REPEATS="${2:?--repeats requires a value}" + shift 2 + ;; + --run-id) + RUN_ID="${2:?--run-id requires a value}" + shift 2 + ;; + --durability) + DURABILITY="${2:?--durability requires a value}" + shift 2 + ;; + --skip-build) + BUILD_PERF_RUNNER=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "$IDS" ]]; then + echo "--ids is required" >&2 + usage + exit 2 +fi +if [[ "$REPEATS" -le 0 ]]; then + echo "--repeats must be positive" >&2 + exit 2 +fi +case "$DURABILITY" in + safe|balanced|fast-dev) ;; + *) + echo "unknown durability profile: $DURABILITY" >&2 + exit 2 + ;; +esac + +OLIPHAUNT="${LIBOLIPHAUNT_PATH:-$REPO_ROOT/target/liboliphaunt-pg18/out/liboliphaunt.dylib}" +POSTGRES_BIN="${OLIPHAUNT_POSTGRES:-$REPO_ROOT/target/liboliphaunt-pg18/install/bin/postgres}" +INITDB_BIN="${OLIPHAUNT_INITDB:-$REPO_ROOT/target/liboliphaunt-pg18/install/bin/initdb}" +PERF_RUNNER="$REPO_ROOT/target/release/oliphaunt-perf" + +if [[ ! -f "$OLIPHAUNT" ]]; then + echo "missing native liboliphaunt-native: $OLIPHAUNT" >&2 + exit 1 +fi +if [[ ! -x "$POSTGRES_BIN" ]]; then + echo "missing native postgres binary: $POSTGRES_BIN" >&2 + exit 1 +fi +if [[ ! -x "$INITDB_BIN" ]]; then + echo "missing native initdb binary: $INITDB_BIN" >&2 + exit 1 +fi + +if [[ "$BUILD_PERF_RUNNER" -eq 1 ]]; then + cargo build --release -p oliphaunt-perf -p oliphaunt --bins +elif [[ ! -x "$PERF_RUNNER" ]]; then + echo "missing release oliphaunt-perf: $PERF_RUNNER" >&2 + exit 1 +fi + +RUN_DIR="$REPO_ROOT/target/perf/native-speed-diagnostics-$RUN_ID" +mkdir -p "$RUN_DIR/direct" "$RUN_DIR/native-postgres" + +IFS=',' read -r -a ID_LIST <<< "${IDS//[[:space:]]/}" +for id in "${ID_LIST[@]}"; do + if [[ -z "$id" ]]; then + echo "--ids must not contain empty entries" >&2 + exit 2 + fi +done + +safe_id() { + printf '%s\n' "${1//./_}" +} + +for repeat in $(seq -w 1 "$REPEATS"); do + echo "Running native-postgres speed diagnostics repeat $repeat..." + "$PERF_RUNNER" diagnose-speed-cases \ + --engine native-postgres \ + --ids "$IDS" \ + --durability "$DURABILITY" \ + --postgres-bin "$POSTGRES_BIN" \ + --initdb-bin "$INITDB_BIN" \ + > "$RUN_DIR/native-postgres/native-postgres-speed-cases-$repeat.json" \ + 2> "$RUN_DIR/native-postgres/native-postgres-speed-cases-$repeat.err" + + for id in "${ID_LIST[@]}"; do + id_file="$(safe_id "$id")" + echo "Running native-liboliphaunt speed diagnostic case $id repeat $repeat..." + LIBOLIPHAUNT_PATH="$OLIPHAUNT" \ + OLIPHAUNT_INSTALL_DIR="$(dirname "$(dirname "$POSTGRES_BIN")")" \ + "$PERF_RUNNER" diagnose-speed-cases \ + --engine native-liboliphaunt \ + --ids "$id" \ + --durability "$DURABILITY" \ + > "$RUN_DIR/direct/native-liboliphaunt-speed-case-$id_file-$repeat.json" \ + 2> "$RUN_DIR/direct/native-liboliphaunt-speed-case-$id_file-$repeat.err" + done +done + +node "$SCRIPT_DIR/summarize_native_speed_diagnostics.mjs" \ + --run-dir "$RUN_DIR" \ + --ids "$IDS" \ + --repeats "$REPEATS" + +echo "$RUN_DIR/summary.md" diff --git a/tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs b/tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs new file mode 100644 index 00000000..2abccf88 --- /dev/null +++ b/tools/perf/matrix/summarize_native_oliphaunt_matrix.mjs @@ -0,0 +1,1337 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +function parseArgs(argv) { + const args = {} + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index] + if (!key.startsWith('--')) { + continue + } + const hasValue = index + 1 < argv.length + const value = argv[index + 1] + if (hasValue && !value.startsWith('--')) { + args[key] = value + index += 1 + } else { + args[key] = 'true' + } + } + return args +} + +function requireArg(args, key) { + const value = args[key] + if (!value) { + throw new Error(`${key} is required`) + } + return value +} + +function boolValue(value) { + if (value === undefined || value === null) { + return null + } + if (typeof value === 'boolean') { + return value + } + return value === '1' || value === 'true' +} + +async function readJsonIfExists(file) { + try { + return JSON.parse(await fs.readFile(file, 'utf8')) + } catch (error) { + if (error && error.code === 'ENOENT') { + return null + } + throw error + } +} + +async function readTextIfExists(file) { + try { + return await fs.readFile(file, 'utf8') + } catch (error) { + if (error && error.code === 'ENOENT') { + return '' + } + throw error + } +} + +function collectRun(report, suite, mode) { + if (!report) { + return null + } + return report.runs.find((entry) => entry.suite === suite && entry.mode === mode) ?? null +} + +function sum(values) { + return values.reduce((total, value) => total + value, 0) +} + +function mean(values) { + return values.length === 0 ? null : sum(values) / values.length +} + +function percentile(values, ratio) { + if (values.length === 0) { + return null + } + const sorted = [...values].sort((a, b) => a - b) + const index = Math.round((sorted.length - 1) * ratio) + return sorted[index] +} + +function round(value, digits = 2) { + if (value === null || value === undefined || Number.isNaN(value)) { + return null + } + return Number(value.toFixed(digits)) +} + +function fmtMsFromMicros(value) { + return value === null || value === undefined ? 'n/a' : `${round(value / 1000, 2)}` +} + +function fmtSecFromMicros(value) { + return value === null || value === undefined ? 'n/a' : `${round(value / 1_000_000, 3)}` +} + +function fmtMb(value) { + return value === null || value === undefined ? 'n/a' : `${round(value, 1)}` +} + +function fmtSec(value) { + return value === null || value === undefined ? 'n/a' : `${round(value, 2)}` +} + +function fmtBytes(value) { + if (value === null || value === undefined) { + return 'n/a' + } + if (value >= 1024 * 1024 * 1024) { + return `${round(value / 1024 / 1024 / 1024, 2)} GB` + } + if (value >= 1024 * 1024) { + return `${round(value / 1024 / 1024, 2)} MB` + } + if (value >= 1024) { + return `${round(value / 1024, 2)} KB` + } + return `${value} B` +} + +function shortSha(value) { + return value ? value.slice(0, 12) : 'n/a' +} + +function fmtRatio(value, baseline) { + if (!Number.isFinite(value) || !Number.isFinite(baseline) || baseline === 0) { + return 'n/a' + } + return `${round(value / baseline, 3)}x` +} + +function fmtRate(value) { + return value === null || value === undefined ? 'n/a' : `${round(value, 1)}` +} + +function fmtMbPerSec(value) { + return value === null || value === undefined ? 'n/a' : `${round(value / 1024 / 1024, 1)}` +} + +function fmtMbFromBytes(value) { + return value === null || value === undefined ? 'n/a' : `${round(value / 1024 / 1024, 2)}` +} + +function ratioNumber(value, baseline) { + if (!Number.isFinite(value) || !Number.isFinite(baseline) || baseline === 0) { + return null + } + return value / baseline +} + +function gateStatus(value, baseline, tolerance = 0.05) { + if (!Number.isFinite(value) || !Number.isFinite(baseline)) { + return 'n/a' + } + return value <= baseline * (1 + tolerance) ? 'pass' : 'miss' +} + +function gateStatusHigher(value, baseline, tolerance = 0.05) { + if (!Number.isFinite(value) || !Number.isFinite(baseline)) { + return 'n/a' + } + return value >= baseline * (1 - tolerance) ? 'pass' : 'miss' +} + +function speedTotalMicros(run) { + return run ? sum(run.tests.map((test) => test.elapsedMicros)) : null +} + +function benchmarkRunOperationCount(run) { + return run ? sum(run.tests.map((test) => test.operationCount ?? 0)) : null +} + +function benchmarkRunThroughputPerSecond(run) { + const totalMicros = speedTotalMicros(run) + const operationCount = benchmarkRunOperationCount(run) + if (!Number.isFinite(totalMicros) || !Number.isFinite(operationCount) || totalMicros <= 0) { + return null + } + return operationCount / (totalMicros / 1_000_000) +} + +function bytesToMb(value) { + return value === null || value === undefined ? null : value / 1024 / 1024 +} + +function rttSummary(run) { + if (!run) { + return null + } + const p50s = run.tests.map((test) => test.p50Micros).filter(Number.isFinite) + const p90s = run.tests.map((test) => test.p90Micros).filter(Number.isFinite) + const p95s = run.tests.map((test) => test.p95Micros).filter(Number.isFinite) + const p99s = run.tests.map((test) => test.p99Micros).filter(Number.isFinite) + return { + openMicros: run.openMicros, + connectMicros: run.connectMicros, + setupMicros: run.setupMicros, + medianP50Us: percentile(p50s, 0.5), + medianP90Us: percentile(p90s, 0.5), + medianP95Us: percentile(p95s, 0.5), + medianP99Us: percentile(p99s, 0.5), + maxP90Us: p90s.length ? Math.max(...p90s) : null, + maxP99Us: p99s.length ? Math.max(...p99s) : null, + observedServerPeakRssMb: bytesToMb(run.observedServerPeakRssBytes), + } +} + +function parseResource(text) { + const resource = { + realSec: null, + userSec: null, + sysSec: null, + cpuSec: null, + peakRssMb: null, + peakFootprintMb: null, + } + for (const rawLine of text.split('\n')) { + const line = rawLine.trim() + let darwinMatch = line.match( + /^([0-9.]+)\s+real\s+([0-9.]+)\s+user\s+([0-9.]+)\s+sys$/, + ) + if (darwinMatch) { + resource.realSec = Number(darwinMatch[1]) + resource.userSec = Number(darwinMatch[2]) + resource.sysSec = Number(darwinMatch[3]) + continue + } + let match = line.match(/^([0-9.]+)\s+real$/) + if (match) { + resource.realSec = Number(match[1]) + continue + } + match = line.match(/^([0-9.]+)\s+user$/) + if (match) { + resource.userSec = Number(match[1]) + continue + } + match = line.match(/^([0-9.]+)\s+sys$/) + if (match) { + resource.sysSec = Number(match[1]) + continue + } + match = line.match(/^([0-9]+)\s+maximum resident set size$/) + if (match) { + resource.peakRssMb = Number(match[1]) / 1024 / 1024 + continue + } + match = line.match(/^([0-9]+)\s+peak memory footprint$/) + if (match) { + resource.peakFootprintMb = Number(match[1]) / 1024 / 1024 + continue + } + match = line.match(/^Maximum resident set size .*:\s*([0-9]+)$/) + if (match) { + resource.peakRssMb = Number(match[1]) / 1024 + } + } + if (resource.userSec !== null || resource.sysSec !== null) { + resource.cpuSec = (resource.userSec ?? 0) + (resource.sysSec ?? 0) + } + return resource +} + +async function loadMeasuredRun(runDir, name) { + const report = await readJsonIfExists(path.join(runDir, `${name}.json`)) + const resource = parseResource(await readTextIfExists(path.join(runDir, `${name}.resource.txt`))) + return { report, resource } +} + +async function loadFirstMeasuredRun(runDir, names) { + let first = null + for (const name of names) { + const measurement = await loadMeasuredRun(runDir, name) + if (!first) { + first = measurement + } + if (measurement.report) { + return measurement + } + } + return first ?? { report: null, resource: parseResource('') } +} + +async function loadRttRepeatMeasurements(runDir, prefix, mode) { + const repeatDir = path.join(runDir, 'repeats') + let entries = [] + try { + entries = await fs.readdir(repeatDir) + } catch (error) { + if (error && error.code === 'ENOENT') { + return [] + } + throw error + } + const files = entries + .filter((entry) => entry.startsWith(prefix) && entry.endsWith('.json')) + .sort() + const measurements = [] + for (const file of files) { + const jsonPath = path.join(repeatDir, file) + const report = await readJsonIfExists(jsonPath) + const run = report?.runs?.find((entry) => entry.suite === 'rtt' && entry.mode === mode) + if (run) { + const resourcePath = jsonPath.replace(/\.json$/, '.resource.txt') + const resource = parseResource(await readTextIfExists(resourcePath)) + measurements.push({ file, run, resource }) + } + } + return measurements +} + +async function loadSpeedRepeatMeasurements(runDir, prefix) { + return loadBenchmarkRepeatMeasurements(runDir, prefix, 'speed') +} + +async function loadBackupRepeatMeasurements(runDir, prefix, mode = null) { + return loadBenchmarkRepeatMeasurements(runDir, prefix, 'backup-restore', mode) +} + +async function loadBenchmarkRepeatMeasurements(runDir, prefix, suite, mode = null) { + const repeatDir = path.join(runDir, 'repeats') + let entries = [] + try { + entries = await fs.readdir(repeatDir) + } catch (error) { + if (error && error.code === 'ENOENT') { + return [] + } + throw error + } + const files = entries + .filter((entry) => entry.startsWith(prefix) && entry.endsWith('.json')) + .sort() + const measurements = [] + for (const file of files) { + const jsonPath = path.join(repeatDir, file) + const report = await readJsonIfExists(jsonPath) + const run = report?.runs?.find( + (entry) => entry.suite === suite && (mode === null || entry.mode === mode), + ) + if (run) { + const resourcePath = jsonPath.replace(/\.json$/, '.resource.txt') + const resource = parseResource(await readTextIfExists(resourcePath)) + measurements.push({ file, run, resource }) + } + } + return measurements +} + +async function loadPreparedRepeatMeasurements(runDir, prefix) { + const repeatDir = path.join(runDir, 'repeats') + let entries = [] + try { + entries = await fs.readdir(repeatDir) + } catch (error) { + if (error && error.code === 'ENOENT') { + return [] + } + throw error + } + const files = entries + .filter((entry) => entry.startsWith(prefix) && entry.endsWith('.json')) + .sort() + const measurements = [] + for (const file of files) { + const jsonPath = path.join(repeatDir, file) + const report = await readJsonIfExists(jsonPath) + if (report?.runs?.length) { + const resourcePath = jsonPath.replace(/\.json$/, '.resource.txt') + const resource = parseResource(await readTextIfExists(resourcePath)) + measurements.push({ file, report, resource }) + } + } + return measurements +} + +function repeatedRttSummary(primaryRun, primaryResource, repeatMeasurements) { + const runs = repeatMeasurements.length + ? repeatMeasurements.map((measurement) => measurement.run) + : primaryRun + ? [primaryRun] + : [] + const resources = repeatMeasurements.length + ? repeatMeasurements.map((measurement) => measurement.resource) + : primaryResource + ? [primaryResource] + : [] + const summaries = runs.map(rttSummary).filter(Boolean) + const opens = summaries.map((summary) => summary.openMicros).filter(Number.isFinite) + const connects = summaries.map((summary) => summary.connectMicros).filter(Number.isFinite) + const medianP50s = summaries.map((summary) => summary.medianP50Us).filter(Number.isFinite) + const medianP90s = summaries.map((summary) => summary.medianP90Us).filter(Number.isFinite) + const medianP95s = summaries.map((summary) => summary.medianP95Us).filter(Number.isFinite) + const medianP99s = summaries.map((summary) => summary.medianP99Us).filter(Number.isFinite) + const maxP90s = summaries.map((summary) => summary.maxP90Us).filter(Number.isFinite) + const maxP99s = summaries.map((summary) => summary.maxP99Us).filter(Number.isFinite) + const rss = resources.map((resource) => resource.peakRssMb).filter(Number.isFinite) + const cpus = resources.map((resource) => resource.cpuSec).filter(Number.isFinite) + const observedServerRss = summaries + .map((summary) => summary.observedServerPeakRssMb) + .filter(Number.isFinite) + return { + n: runs.length, + openMicros: percentile(opens, 0.5), + openP90Micros: percentile(opens, 0.9), + connectMicros: percentile(connects, 0.5), + medianP50Us: percentile(medianP50s, 0.5), + medianP90Us: percentile(medianP90s, 0.5), + medianP95Us: percentile(medianP95s, 0.5), + medianP99Us: percentile(medianP99s, 0.5), + gateMedianP90Us: percentile(medianP90s, runs.length >= 10 ? 0.9 : 0.5), + maxP90Us: maxP90s.length ? Math.max(...maxP90s) : null, + maxP99Us: maxP99s.length ? Math.max(...maxP99s) : null, + peakRssMb: percentile(rss, 0.9), + observedServerPeakRssMb: percentile(observedServerRss, 0.9), + cpuSec: percentile(cpus, 0.9), + } +} + +function repeatedSpeedSummary(primaryRun, primaryResource, repeatMeasurements) { + const runs = repeatMeasurements.length + ? repeatMeasurements.map((measurement) => measurement.run) + : primaryRun + ? [primaryRun] + : [] + const resources = repeatMeasurements.length + ? repeatMeasurements.map((measurement) => measurement.resource) + : primaryResource + ? [primaryResource] + : [] + const totals = runs.map(speedTotalMicros) + const finiteTotals = totals.filter(Number.isFinite) + const throughputs = runs.map(benchmarkRunThroughputPerSecond).filter(Number.isFinite) + const operationCounts = runs.map(benchmarkRunOperationCount).filter(Number.isFinite) + const opens = runs.map((run) => run.openMicros).filter(Number.isFinite) + const rss = resources.map((resource) => resource.peakRssMb).filter(Number.isFinite) + const footprints = resources + .map((resource) => resource.peakFootprintMb) + .filter(Number.isFinite) + const cpus = resources.map((resource) => resource.cpuSec).filter(Number.isFinite) + const observedServerRss = runs + .map((run) => bytesToMb(run.observedServerPeakRssBytes)) + .filter(Number.isFinite) + const p90RssMb = percentile(rss, 0.9) + const p90ObservedServerRssMb = percentile(observedServerRss, 0.9) + const p99RssMb = percentile(rss, 0.99) + const p99ObservedServerRssMb = percentile(observedServerRss, 0.99) + return { + n: runs.length, + minTotalMicros: finiteTotals.length ? Math.min(...finiteTotals) : null, + maxTotalMicros: finiteTotals.length ? Math.max(...finiteTotals) : null, + p50TotalMicros: percentile(finiteTotals, 0.5), + p90TotalMicros: percentile(finiteTotals, 0.9), + p95TotalMicros: percentile(finiteTotals, 0.95), + p99TotalMicros: percentile(finiteTotals, 0.99), + p50OperationCount: percentile(operationCounts, 0.5), + p90OperationCount: percentile(operationCounts, 0.9), + p50ThroughputPerSecond: percentile(throughputs, 0.5), + tailP10ThroughputPerSecond: percentile(throughputs, 0.1), + p50OpenMicros: percentile(opens, 0.5), + p90OpenMicros: percentile(opens, 0.9), + p99OpenMicros: percentile(opens, 0.99), + p90RssMb, + p90ObservedServerRssMb, + p90MemoryBaselineRssMb: Math.max(p90RssMb ?? 0, p90ObservedServerRssMb ?? 0) || null, + p99RssMb, + p99ObservedServerRssMb, + p99MemoryBaselineRssMb: Math.max(p99RssMb ?? 0, p99ObservedServerRssMb ?? 0) || null, + p90FootprintMb: percentile(footprints, 0.9), + p99FootprintMb: percentile(footprints, 0.99), + p90CpuSec: percentile(cpus, 0.9), + p99CpuSec: percentile(cpus, 0.99), + } +} + +function runQuality(summary) { + if (!summary || summary.n === 0 || !Number.isFinite(summary.p50TotalMicros)) { + return { status: 'n/a', reason: 'missing speed measurements' } + } + if (summary.n < 10) { + return { status: 'insufficient', reason: 'fewer than ten fresh-process repeats' } + } + if (summary.n < 20) { + return { status: 'insufficient', reason: 'fewer than twenty repeats; tail quality is not release-grade' } + } + const p90ToP50 = ratioNumber(summary.p90TotalMicros, summary.p50TotalMicros) + const p95ToP50 = ratioNumber(summary.p95TotalMicros, summary.p50TotalMicros) + const p99ToP50 = ratioNumber(summary.p99TotalMicros, summary.p50TotalMicros) + if ((p90ToP50 ?? 0) > 1.2 || (p95ToP50 ?? 0) > 1.3 || (p99ToP50 ?? 0) > 1.5) { + return { status: 'noisy', reason: 'tail spread is too high for release parity claims' } + } + if ((p90ToP50 ?? 0) > 1.12 || (p95ToP50 ?? 0) > 1.2 || (p99ToP50 ?? 0) > 1.35) { + return { status: 'watch', reason: 'tail spread is elevated; repeat on an idle host' } + } + return { status: 'stable', reason: 'tail spread is within release-evidence bounds' } +} + +function speedCaseRows(modes) { + const base = modes.find((mode) => mode.run)?.run + if (!base) { + return [] + } + return base.tests.map((test) => { + const values = modes.map((mode) => { + if (mode.repeats.length > 0) { + const repeatedValues = mode.repeats + .map((measurement) => + measurement.run.tests.find((candidate) => candidate.id === test.id)?.elapsedMicros, + ) + .filter(Number.isFinite) + return fmtMsFromMicros(percentile(repeatedValues, 0.9)) + } + const match = mode.run?.tests.find((candidate) => candidate.id === test.id) + return fmtMsFromMicros(match?.elapsedMicros) + }) + return `| ${test.id} | ${test.label} | ${values.join(' | ')} |` + }) +} + +function speedCaseMicros(mode, testId) { + if (mode.repeats.length > 0) { + const repeatedValues = mode.repeats + .map((measurement) => + measurement.run.tests.find((candidate) => candidate.id === testId)?.elapsedMicros, + ) + .filter(Number.isFinite) + return percentile(repeatedValues, 0.9) + } + return mode.run?.tests.find((candidate) => candidate.id === testId)?.elapsedMicros ?? null +} + +function speedCaseGateMisses(nativeMode, baselineMode, tolerance = 0.05) { + if (!nativeMode?.run || !baselineMode?.run) { + return [] + } + const misses = [] + for (const test of nativeMode.run.tests) { + const nativeMicros = speedCaseMicros(nativeMode, test.id) + const baselineMicros = speedCaseMicros(baselineMode, test.id) + if (gateStatus(nativeMicros, baselineMicros, tolerance) === 'miss') { + misses.push({ + id: test.id, + label: test.label, + nativeMicros, + baselineMicros, + }) + } + } + return misses +} + +function slowestRepeatRows(modes, count = 3) { + const rows = [] + for (const mode of modes) { + if (!mode.run) { + continue + } + const measurements = mode.repeats.length + ? mode.repeats + : [{ file: 'primary', run: mode.run, resource: mode.resource }] + const summary = repeatedSpeedSummary(mode.run, mode.resource, mode.repeats) + const totals = measurements + .map((measurement) => ({ + file: measurement.file ?? 'primary', + totalMicros: speedTotalMicros(measurement.run), + openMicros: measurement.run.openMicros, + })) + .filter((entry) => Number.isFinite(entry.totalMicros)) + .sort((a, b) => b.totalMicros - a.totalMicros) + .slice(0, count) + for (const entry of totals) { + rows.push( + `| ${mode.label} | \`${entry.file}\` | ${fmtSecFromMicros(entry.totalMicros)} | ${fmtRatio(entry.totalMicros, summary.p50TotalMicros)} | ${fmtMsFromMicros(entry.openMicros)} |`, + ) + } + } + return rows +} + +function preparedTest(run, id) { + return run?.tests?.find((test) => test.id === id) ?? null +} + +function preparedBaselineMode(mode) { + return mode.includes('pipelined') + ? 'native_postgres_tokio_pipelined_prepared' + : 'native_postgres_tokio_prepared' +} + +function repeatedPreparedSummary(primaryMeasurement, repeatMeasurements, mode) { + const measurements = repeatMeasurements.length + ? repeatMeasurements + : primaryMeasurement.report + ? [primaryMeasurement] + : [] + const matched = measurements + .map((measurement) => ({ + run: measurement.report?.runs?.find((entry) => entry.mode === mode) ?? null, + resource: measurement.resource, + })) + .filter((measurement) => measurement.run) + const runs = matched.map((measurement) => measurement.run) + const resources = matched.map((measurement) => measurement.resource) + const numeric = runs + .map((run) => preparedTest(run, 'numeric_indexed')?.elapsedMicros) + .filter(Number.isFinite) + const text = runs + .map((run) => preparedTest(run, 'text_indexed')?.elapsedMicros) + .filter(Number.isFinite) + const rss = resources.map((resource) => resource.peakRssMb).filter(Number.isFinite) + const footprints = resources + .map((resource) => resource.peakFootprintMb) + .filter(Number.isFinite) + const cpus = resources.map((resource) => resource.cpuSec).filter(Number.isFinite) + const reals = resources.map((resource) => resource.realSec).filter(Number.isFinite) + return { + n: runs.length, + numericP50Micros: percentile(numeric, 0.5), + numericP90Micros: percentile(numeric, 0.9), + numericP95Micros: percentile(numeric, 0.95), + numericP99Micros: percentile(numeric, 0.99), + textP50Micros: percentile(text, 0.5), + textP90Micros: percentile(text, 0.9), + textP95Micros: percentile(text, 0.95), + textP99Micros: percentile(text, 0.99), + p90RssMb: percentile(rss, 0.9), + p99RssMb: percentile(rss, 0.99), + p90FootprintMb: percentile(footprints, 0.9), + p99FootprintMb: percentile(footprints, 0.99), + p90CpuSec: percentile(cpus, 0.9), + p99CpuSec: percentile(cpus, 0.99), + p90RealSec: percentile(reals, 0.9), + p99RealSec: percentile(reals, 0.99), + } +} + +function preparedRows(measurement, repeatMeasurements, baselineMeasurement, baselineRepeatMeasurements) { + if (!measurement.report && repeatMeasurements.length === 0) { + return [] + } + const modes = new Set() + for (const run of measurement.report?.runs ?? []) { + modes.add(run.mode) + } + for (const repeat of repeatMeasurements) { + for (const run of repeat.report?.runs ?? []) { + modes.add(run.mode) + } + } + return [...modes].map((mode) => { + const summary = repeatedPreparedSummary(measurement, repeatMeasurements, mode) + const baseline = repeatedPreparedSummary( + baselineMeasurement, + baselineRepeatMeasurements, + preparedBaselineMode(mode), + ) + return `| ${mode} | ${summary.n} | ${fmtSecFromMicros(summary.numericP50Micros)} | ${fmtSecFromMicros(summary.numericP90Micros)} | ${fmtSecFromMicros(summary.numericP95Micros)} | ${fmtSecFromMicros(summary.numericP99Micros)} | ${fmtRatio(summary.numericP90Micros, baseline.numericP90Micros)} | ${fmtSecFromMicros(summary.textP50Micros)} | ${fmtSecFromMicros(summary.textP90Micros)} | ${fmtSecFromMicros(summary.textP95Micros)} | ${fmtSecFromMicros(summary.textP99Micros)} | ${fmtRatio(summary.textP90Micros, baseline.textP90Micros)} | ${fmtMb(summary.p90RssMb)} | ${fmtMb(summary.p99RssMb)} | ${fmtMb(summary.p90FootprintMb)} | ${fmtMb(summary.p99FootprintMb)} | ${fmtSec(summary.p90CpuSec)} | ${fmtSec(summary.p99CpuSec)} | ${fmtSec(summary.p90RealSec)} | ${fmtSec(summary.p99RealSec)} |` + }) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const runDir = requireArg(args, '--run-dir') + const runId = requireArg(args, '--run-id') + const postgresVersion = requireArg(args, '--postgres-version') + const durability = args['--durability'] ?? 'safe' + const runtimeFootprint = args['--runtime-footprint'] ?? 'throughput' + const startupGucs = args['--startup-gucs'] ?? '' + + const nativeLibRtt = await loadMeasuredRun(runDir, 'native-liboliphaunt-rtt') + const nativeLibSpeed = await loadMeasuredRun(runDir, 'native-liboliphaunt-speed') + const nativeLibStreaming = await loadMeasuredRun(runDir, 'native-liboliphaunt-streaming') + const nativeLibBackup = await loadMeasuredRun(runDir, 'native-liboliphaunt-backup') + const nativeBrokerRtt = await loadMeasuredRun(runDir, 'native-liboliphaunt-broker-rtt') + const nativeBrokerSpeed = await loadMeasuredRun(runDir, 'native-liboliphaunt-broker-speed') + const nativeBrokerStreaming = await loadMeasuredRun(runDir, 'native-liboliphaunt-broker-streaming') + const nativeBrokerBackup = await loadMeasuredRun(runDir, 'native-liboliphaunt-broker-backup') + const nativeServerRtt = await loadMeasuredRun(runDir, 'native-liboliphaunt-server-rtt') + const nativeServerSpeed = await loadMeasuredRun(runDir, 'native-liboliphaunt-server-speed') + const nativeServerStreaming = await loadMeasuredRun(runDir, 'native-liboliphaunt-server-streaming') + const nativeServerBackup = await loadMeasuredRun(runDir, 'native-liboliphaunt-server-backup') + const nativeTokioRtt = await loadFirstMeasuredRun(runDir, [ + 'native-postgres-tokio-all', + 'native-postgres-tokio-rtt', + ]) + const nativeTokioSpeed = await loadFirstMeasuredRun(runDir, [ + 'native-postgres-tokio-all', + 'native-postgres-tokio-speed', + ]) + const nativeSqlxRtt = await loadFirstMeasuredRun(runDir, [ + 'native-postgres-sqlx-all', + 'native-postgres-sqlx-rtt', + ]) + const nativeSqlxSpeed = await loadFirstMeasuredRun(runDir, [ + 'native-postgres-sqlx-all', + 'native-postgres-sqlx-speed', + ]) + const nativePostgresStreaming = await loadMeasuredRun(runDir, 'native-postgres-streaming') + const nativePostgresBackup = await loadMeasuredRun(runDir, 'native-postgres-backup') + const sqliteSpeed = await loadMeasuredRun(runDir, 'sqlite-speed') + const sqliteBackup = await loadMeasuredRun(runDir, 'sqlite-backup') + const artifactSizes = await readJsonIfExists(path.join(runDir, 'artifact-sizes.json')) + const provenance = await readJsonIfExists(path.join(runDir, 'provenance.json')) + const pgdataCopyMode = args['--pgdata-copy-mode'] ?? provenance?.benchmark?.pgdataCopyMode ?? 'n/a' + const selectedNativeEngines = args['--native-engines'] ?? provenance?.benchmark?.nativeEngines?.join(',') ?? 'direct,broker,server' + const selectedSuites = args['--suites'] ?? provenance?.benchmark?.suites?.join(',') ?? 'rtt,speed,streaming,prepared,backup' + const isPartialCoverage = + boolValue(args['--partial-report']) ?? + provenance?.benchmark?.quality?.partialReport ?? + (selectedNativeEngines !== 'direct,broker,server' || + selectedSuites !== 'rtt,speed,streaming,prepared,backup') + const releaseMinimums = provenance?.benchmark?.quality?.releaseMinimums ?? { + rttIterations: 100, + rttRepeats: 10, + preparedRows: 25000, + preparedRepeats: 10, + speedRepeats: 20, + backupRepeats: 10, + } + const rttRepeats = Number(args['--rtt-repeats'] ?? provenance?.benchmark?.rttRepeats ?? '1') + const speedRepeats = Number(args['--speed-repeats'] ?? provenance?.benchmark?.speedRepeats ?? '1') + const backupRepeats = Number(args['--backup-repeats'] ?? provenance?.benchmark?.backupRepeats ?? '1') + const preparedRepeats = Number(args['--prepared-repeats'] ?? provenance?.benchmark?.preparedRepeats ?? '1') + const releaseEvidenceInput = + boolValue(args['--release-evidence']) ?? provenance?.benchmark?.quality?.releaseEvidence ?? null + const releaseEvidence = + releaseEvidenceInput ?? + (!isPartialCoverage && + Number(args['--rtt-iterations'] ?? provenance?.benchmark?.rttIterations ?? '0') >= + releaseMinimums.rttIterations && + rttRepeats >= releaseMinimums.rttRepeats && + Number(args['--prepared-rows'] ?? provenance?.benchmark?.preparedRows ?? '0') >= + releaseMinimums.preparedRows && + preparedRepeats >= releaseMinimums.preparedRepeats && + speedRepeats >= releaseMinimums.speedRepeats && + backupRepeats >= (releaseMinimums.backupRepeats ?? 10)) + const nativePostgresPrepared = await loadMeasuredRun(runDir, 'native-postgres-prepared') + const nativePreparedDirect = await loadMeasuredRun(runDir, 'native-liboliphaunt-prepared-direct') + const nativePreparedBroker = await loadMeasuredRun(runDir, 'native-liboliphaunt-prepared-broker') + const nativePreparedServer = await loadMeasuredRun(runDir, 'native-liboliphaunt-prepared-server') + const nativeBackupDirectRepeats = await loadBackupRepeatMeasurements(runDir, 'native-liboliphaunt-backup-') + const nativeBackupBrokerRepeats = await loadBackupRepeatMeasurements(runDir, 'native-liboliphaunt-broker-backup-') + const nativeBackupServerRepeats = await loadBackupRepeatMeasurements(runDir, 'native-liboliphaunt-server-backup-') + const nativePostgresBackupRepeats = await loadBackupRepeatMeasurements(runDir, 'native-postgres-backup-', 'native_postgres') + const nativePostgresPhysicalBackupRepeats = await loadBackupRepeatMeasurements(runDir, 'native-postgres-backup-', 'native_postgres_physical') + const sqliteBackupRepeats = await loadBackupRepeatMeasurements(runDir, 'sqlite-backup-') + const nativePostgresPreparedRepeats = await loadPreparedRepeatMeasurements(runDir, 'native-postgres-prepared-') + const nativePreparedDirectRepeats = await loadPreparedRepeatMeasurements(runDir, 'native-liboliphaunt-prepared-direct-') + const nativePreparedBrokerRepeats = await loadPreparedRepeatMeasurements(runDir, 'native-liboliphaunt-prepared-broker-') + const nativePreparedServerRepeats = await loadPreparedRepeatMeasurements(runDir, 'native-liboliphaunt-prepared-server-') + + const rttModes = [ + { + label: 'Native liboliphaunt direct', + run: collectRun(nativeLibRtt.report, 'rtt', 'native_liboliphaunt_direct'), + resource: nativeLibRtt.resource, + repeats: await loadRttRepeatMeasurements(runDir, 'native-liboliphaunt-rtt-', 'native_liboliphaunt_direct'), + }, + { + label: 'Native liboliphaunt broker', + run: collectRun(nativeBrokerRtt.report, 'rtt', 'native_liboliphaunt_broker'), + resource: nativeBrokerRtt.resource, + repeats: await loadRttRepeatMeasurements(runDir, 'native-liboliphaunt-broker-rtt-', 'native_liboliphaunt_broker'), + }, + { + label: 'Native liboliphaunt server', + run: collectRun(nativeServerRtt.report, 'rtt', 'native_liboliphaunt_server'), + resource: nativeServerRtt.resource, + repeats: await loadRttRepeatMeasurements(runDir, 'native-liboliphaunt-server-rtt-', 'native_liboliphaunt_server'), + }, + { + label: 'Native Postgres tokio simple', + run: collectRun(nativeTokioRtt.report, 'rtt', 'native_postgres'), + resource: nativeTokioRtt.resource, + repeats: await loadRttRepeatMeasurements(runDir, 'native-postgres-tokio-rtt-', 'native_postgres'), + }, + { + label: 'Native Postgres SQLx', + run: collectRun(nativeSqlxRtt.report, 'rtt', 'native_postgres_sqlx'), + resource: nativeSqlxRtt.resource, + repeats: [], + }, + ] + + const speedModes = [ + { + label: 'Native liboliphaunt direct', + run: collectRun(nativeLibSpeed.report, 'speed', 'native_liboliphaunt_direct'), + resource: nativeLibSpeed.resource, + repeats: await loadSpeedRepeatMeasurements(runDir, 'native-liboliphaunt-speed-'), + }, + { + label: 'Native liboliphaunt broker', + run: collectRun(nativeBrokerSpeed.report, 'speed', 'native_liboliphaunt_broker'), + resource: nativeBrokerSpeed.resource, + repeats: await loadSpeedRepeatMeasurements(runDir, 'native-liboliphaunt-broker-speed-'), + }, + { + label: 'Native liboliphaunt server', + run: collectRun(nativeServerSpeed.report, 'speed', 'native_liboliphaunt_server'), + resource: nativeServerSpeed.resource, + repeats: await loadSpeedRepeatMeasurements(runDir, 'native-liboliphaunt-server-speed-'), + }, + { + label: 'Native Postgres tokio simple', + run: collectRun(nativeTokioSpeed.report, 'speed', 'native_postgres'), + resource: nativeTokioSpeed.resource, + repeats: await loadSpeedRepeatMeasurements(runDir, 'native-postgres-tokio-speed-'), + }, + { + label: 'Native Postgres SQLx', + run: collectRun(nativeSqlxSpeed.report, 'speed', 'native_postgres_sqlx'), + resource: nativeSqlxSpeed.resource, + repeats: [], + }, + { + label: 'SQLite embedded', + run: collectRun(sqliteSpeed.report, 'speed', 'sqlite'), + resource: sqliteSpeed.resource, + repeats: await loadSpeedRepeatMeasurements(runDir, 'sqlite-speed-'), + }, + ] + const activeSpeedModes = speedModes.filter((mode) => mode.run) + const streamingModes = [ + ['Native liboliphaunt direct', collectRun(nativeLibStreaming.report, 'streaming', 'native_liboliphaunt_direct'), nativeLibStreaming.resource], + ['Native liboliphaunt broker', collectRun(nativeBrokerStreaming.report, 'streaming', 'native_liboliphaunt_broker'), nativeBrokerStreaming.resource], + ['Native liboliphaunt server', collectRun(nativeServerStreaming.report, 'streaming', 'native_liboliphaunt_server'), nativeServerStreaming.resource], + ['Native Postgres raw', collectRun(nativePostgresStreaming.report, 'streaming', 'native_postgres_raw'), nativePostgresStreaming.resource], + ] + const backupModes = [ + { + label: 'Native liboliphaunt direct', + run: collectRun(nativeLibBackup.report, 'backup-restore', 'native_liboliphaunt_direct'), + resource: nativeLibBackup.resource, + repeats: nativeBackupDirectRepeats, + }, + { + label: 'Native liboliphaunt broker', + run: collectRun(nativeBrokerBackup.report, 'backup-restore', 'native_liboliphaunt_broker'), + resource: nativeBrokerBackup.resource, + repeats: nativeBackupBrokerRepeats, + }, + { + label: 'Native liboliphaunt server', + run: collectRun(nativeServerBackup.report, 'backup-restore', 'native_liboliphaunt_server'), + resource: nativeServerBackup.resource, + repeats: nativeBackupServerRepeats, + }, + { + label: 'Native Postgres physical archive', + run: collectRun(nativePostgresBackup.report, 'backup-restore', 'native_postgres_physical'), + resource: nativePostgresBackup.resource, + repeats: nativePostgresPhysicalBackupRepeats, + }, + { + label: 'Native Postgres pg_dump/pg_restore', + run: collectRun(nativePostgresBackup.report, 'backup-restore', 'native_postgres'), + resource: nativePostgresBackup.resource, + repeats: nativePostgresBackupRepeats, + }, + { + label: 'SQLite VACUUM/file restore', + run: collectRun(sqliteBackup.report, 'backup-restore', 'sqlite'), + resource: sqliteBackup.resource, + repeats: sqliteBackupRepeats, + }, + ] + const nativeDirectSpeed = speedModes[0] + const nativePostgresSpeed = speedModes.find( + (mode) => mode.label === 'Native Postgres tokio simple', + ) + const nativeDirectSpeedSummary = repeatedSpeedSummary( + nativeDirectSpeed.run, + nativeDirectSpeed.resource, + nativeDirectSpeed.repeats, + ) + const nativePostgresSpeedSummary = repeatedSpeedSummary( + nativePostgresSpeed.run, + nativePostgresSpeed.resource, + nativePostgresSpeed.repeats, + ) + const sqliteEmbeddedSpeed = speedModes.find((mode) => mode.label === 'SQLite embedded') + const sqliteEmbeddedSpeedSummary = repeatedSpeedSummary( + sqliteEmbeddedSpeed.run, + sqliteEmbeddedSpeed.resource, + sqliteEmbeddedSpeed.repeats, + ) + const nativeDirectRtt = rttModes[0] + const nativePostgresRtt = rttModes.find( + (mode) => mode.label === 'Native Postgres tokio simple', + ) + const nativeDirectRttSummary = repeatedRttSummary( + nativeDirectRtt.run, + nativeDirectRtt.resource, + nativeDirectRtt.repeats, + ) + const nativePostgresRttSummary = repeatedRttSummary( + nativePostgresRtt.run, + nativePostgresRtt.resource, + nativePostgresRtt.repeats, + ) + const nativeDirectBackupSummary = repeatedSpeedSummary( + backupModes[0].run, + backupModes[0].resource, + backupModes[0].repeats, + ) + const nativePostgresBackupSummary = repeatedSpeedSummary( + backupModes[3].run, + backupModes[3].resource, + backupModes[3].repeats, + ) + const nativeDirectGateRows = [ + { + metric: 'RTT repeat p90 median-p90', + nativeDisplay: `${nativeDirectRttSummary?.gateMedianP90Us ?? 'n/a'} us`, + baselineDisplay: `${nativePostgresRttSummary?.gateMedianP90Us ?? 'n/a'} us`, + ratio: fmtRatio(nativeDirectRttSummary?.gateMedianP90Us, nativePostgresRttSummary?.gateMedianP90Us), + status: gateStatus(nativeDirectRttSummary?.gateMedianP90Us, nativePostgresRttSummary?.gateMedianP90Us), + diagnostic: 'Run focused RTT repeats for direct and native PostgreSQL to confirm the transport tail before changing code.', + }, + { + metric: 'Speed suite p90', + nativeDisplay: `${fmtSecFromMicros(nativeDirectSpeedSummary.p90TotalMicros)} s`, + baselineDisplay: `${fmtSecFromMicros(nativePostgresSpeedSummary.p90TotalMicros)} s`, + ratio: fmtRatio(nativeDirectSpeedSummary.p90TotalMicros, nativePostgresSpeedSummary.p90TotalMicros), + status: gateStatus(nativeDirectSpeedSummary.p90TotalMicros, nativePostgresSpeedSummary.p90TotalMicros), + diagnostic: 'Run `oliphaunt-perf diagnose-speed-cases` for the missed case ids below, then compare with the native PostgreSQL diagnostic engine.', + }, + { + metric: 'Speed tail throughput p10', + nativeDisplay: `${fmtRate(nativeDirectSpeedSummary.tailP10ThroughputPerSecond)} ops/s`, + baselineDisplay: `${fmtRate(nativePostgresSpeedSummary.tailP10ThroughputPerSecond)} ops/s`, + ratio: fmtRatio(nativeDirectSpeedSummary.tailP10ThroughputPerSecond, nativePostgresSpeedSummary.tailP10ThroughputPerSecond), + status: gateStatusHigher(nativeDirectSpeedSummary.tailP10ThroughputPerSecond, nativePostgresSpeedSummary.tailP10ThroughputPerSecond), + diagnostic: 'Run speed-case diagnostics; throughput misses usually need the same per-SQL investigation as speed-suite p90 misses.', + }, + { + metric: 'Speed open p90', + nativeDisplay: `${fmtMsFromMicros(nativeDirectSpeedSummary.p90OpenMicros)} ms`, + baselineDisplay: `${fmtMsFromMicros(nativePostgresSpeedSummary.p90OpenMicros)} ms`, + ratio: fmtRatio(nativeDirectSpeedSummary.p90OpenMicros, nativePostgresSpeedSummary.p90OpenMicros), + status: gateStatus(nativeDirectSpeedSummary.p90OpenMicros, nativePostgresSpeedSummary.p90OpenMicros), + diagnostic: 'Compare runtime-footprint and startup-GUC sweeps; cold open is expected to differ from SQLite but should not regress against native PostgreSQL controls.', + }, + { + metric: 'Speed p90 RSS', + nativeDisplay: `${fmtMb(nativeDirectSpeedSummary.p90RssMb)} MB`, + baselineDisplay: `${fmtMb(nativePostgresSpeedSummary.p90MemoryBaselineRssMb)} MB`, + ratio: fmtRatio(nativeDirectSpeedSummary.p90RssMb, nativePostgresSpeedSummary.p90MemoryBaselineRssMb), + status: gateStatus(nativeDirectSpeedSummary.p90RssMb, nativePostgresSpeedSummary.p90MemoryBaselineRssMb), + diagnostic: 'Run the mobile/runtime-footprint matrix before source cuts; RSS misses should be attributed to specific GUCs first.', + }, + { + metric: 'Backup/restore physical total p90', + nativeDisplay: `${fmtSecFromMicros(nativeDirectBackupSummary.p90TotalMicros)} s`, + baselineDisplay: `${fmtSecFromMicros(nativePostgresBackupSummary.p90TotalMicros)} s`, + ratio: fmtRatio(nativeDirectBackupSummary.p90TotalMicros, nativePostgresBackupSummary.p90TotalMicros), + status: gateStatus(nativeDirectBackupSummary.p90TotalMicros, nativePostgresBackupSummary.p90TotalMicros), + diagnostic: 'Run the backup suite in isolation and inspect physical archive bytes, PGDATA copy mode, and restore verification timings.', + }, + { + metric: 'Backup/restore tail throughput p10', + nativeDisplay: `${fmtMbPerSec(nativeDirectBackupSummary.tailP10ThroughputPerSecond)} MB/s`, + baselineDisplay: `${fmtMbPerSec(nativePostgresBackupSummary.tailP10ThroughputPerSecond)} MB/s`, + ratio: fmtRatio(nativeDirectBackupSummary.tailP10ThroughputPerSecond, nativePostgresBackupSummary.tailP10ThroughputPerSecond), + status: gateStatusHigher(nativeDirectBackupSummary.tailP10ThroughputPerSecond, nativePostgresBackupSummary.tailP10ThroughputPerSecond), + diagnostic: 'Run backup suite isolation; tail throughput misses are usually archive/copy-mode issues rather than SQL execution issues.', + }, + ] + const nativeDirectGateMisses = nativeDirectGateRows.filter((row) => row.status === 'miss') + const firstRttReport = [ + nativeLibRtt, + nativeBrokerRtt, + nativeServerRtt, + nativeTokioRtt, + nativeSqlxRtt, + ].find((measurement) => measurement.report)?.report + const selectedEngineSet = new Set( + selectedNativeEngines.split(',').filter((engine) => engine.length > 0), + ) + const coverageStatus = (measured, selected, detail) => { + if (measured) { + return `measured via ${detail}` + } + return selected ? `selected but missing; expected via ${detail}` : 'not selected' + } + const nativeDirectMeasured = Boolean( + nativeLibRtt.report || + nativeLibSpeed.report || + nativeLibStreaming.report || + nativeLibBackup.report || + nativePreparedDirect.report || + nativeBackupDirectRepeats.length || + nativePreparedDirectRepeats.length, + ) + const nativeBrokerMeasured = Boolean( + nativeBrokerRtt.report || + nativeBrokerSpeed.report || + nativeBrokerStreaming.report || + nativeBrokerBackup.report || + nativePreparedBroker.report || + nativeBackupBrokerRepeats.length || + nativePreparedBrokerRepeats.length, + ) + const nativeServerMeasured = Boolean( + nativeServerRtt.report || + nativeServerSpeed.report || + nativeServerStreaming.report || + nativeServerBackup.report || + nativePreparedServer.report || + nativeBackupServerRepeats.length || + nativePreparedServerRepeats.length, + ) + + const lines = [] + lines.push(`# Native liboliphaunt Perf Matrix ${runId}`) + lines.push('') + lines.push(`Run directory: \`${runDir}\``) + lines.push('') + lines.push('## Method') + lines.push('') + lines.push('- Release binary: `target/release/oliphaunt-perf`; Cargo build time is excluded from benchmark timings.') + lines.push(`- Native control: \`${postgresVersion}\`.`) + lines.push('- Native direct: `oliphaunt` with one embedded PostgreSQL backend per benchmark process.') + lines.push('- Native broker: `oliphaunt` helper-process mode with local IPC to one embedded PostgreSQL backend.') + lines.push('- Native server: `oliphaunt` true local PostgreSQL server mode.') + lines.push(`- Native durability profile: \`${durability}\`.`) + lines.push(`- Native runtime footprint profile: \`${runtimeFootprint}\`.`) + if (startupGucs.length > 0) { + lines.push(`- Native startup GUC overrides: \`${startupGucs}\`.`) + } + lines.push(`- PGDATA template hydration: \`${pgdataCopyMode}\`.`) + lines.push(`- Selected native engines: \`${selectedNativeEngines}\`.`) + lines.push(`- Selected suites: \`${selectedSuites}\`.`) + lines.push( + `- Run classification: ${ + releaseEvidence === true + ? 'release evidence' + : 'diagnostic; do not use for release claims without a default release-evidence matrix' + }.`, + ) + if (isPartialCoverage) { + lines.push('- Coverage scope: partial focused run; use the default all-engine/all-suite matrix for release evidence.') + } + lines.push('- Speed source: exact Oliphaunt fixture SQL files from `benchmarks/native/sql`.') + lines.push(`- RTT samples per case: ${firstRttReport?.rttIterations ?? 'n/a'}.`) + lines.push(`- RTT repeats: ${rttRepeats}. When repeats are present, RTT summary columns report p50 across fresh-process run summaries and the native direct gate uses p90 across repeated median-p90 RTT summaries.`) + lines.push(`- Prepared-update repeats: ${preparedRepeats}. Prepared rows report p50/p90/p95/p99 across fresh-process prepared-update suite runs when repeats are present.`) + lines.push(`- Speed repeats: ${speedRepeats}. p50/p90/p95/p99 collapse fresh-process suite totals when repeats are present; speed case rows use per-case p90 when repeats are present.`) + lines.push(`- Backup/restore repeats: ${backupRepeats}. Backup rows report p50/p90/p95/p99 across fresh-process physical archive or control backup/restore runs when repeats are present.`) + lines.push( + `- Release-evidence minimums: ${releaseMinimums.rttIterations} RTT samples, ${releaseMinimums.rttRepeats} RTT repeats, ${releaseMinimums.preparedRows} prepared rows, ${releaseMinimums.preparedRepeats} prepared repeats, ${releaseMinimums.speedRepeats} speed repeats, and ${releaseMinimums.backupRepeats ?? 10} backup/restore repeats across the default all-engine/all-suite matrix.`, + ) + lines.push('- Resource metrics come from `/usr/bin/time`; RSS and peak footprint are process-level values. Native broker/server `observed server RSS` is sampled separately from child process trees during xtask execution.') + if (provenance) { + lines.push(`- Provenance: \`provenance.json\` records source/artifact SHA-256s. Verify with \`node tools/perf/matrix/native_oliphaunt_provenance.mjs verify --run-dir ${runDir}\`.`) + } else { + lines.push('- Provenance: no `provenance.json` was found; rerun the matrix with the current harness before using this report as release evidence.') + } + lines.push('') + lines.push('## Coverage') + lines.push('') + lines.push('| Mode | Status |') + lines.push('| --- | --- |') + lines.push(`| NativeDirect | ${coverageStatus(nativeDirectMeasured, selectedEngineSet.has('direct'), 'native liboliphaunt')} |`) + lines.push(`| NativeBroker | ${coverageStatus(nativeBrokerMeasured, selectedEngineSet.has('broker'), 'oliphaunt broker helper process')} |`) + lines.push(`| NativeServer | ${coverageStatus(nativeServerMeasured, selectedEngineSet.has('server'), 'oliphaunt local PostgreSQL server mode; native PostgreSQL control remains the baseline')} |`) + if (sqliteSpeed.report) { + lines.push('| SQLite embedded | measured through rusqlite |') + } + lines.push('') + lines.push('## RTT Summary') + lines.push('') + lines.push('| Mode | n | open p50 ms | open p90 ms | connect p50 ms | median p50 us | median p90 us | gate p90 us | median p95 us | median p99 us | max p90 us | max p99 us | peak RSS MB | observed server RSS MB | CPU s |') + lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + for (const mode of rttModes) { + if (!mode.run) { + continue + } + const summary = repeatedRttSummary(mode.run, mode.resource, mode.repeats) + lines.push( + `| ${mode.label} | ${summary.n} | ${fmtMsFromMicros(summary.openMicros)} | ${fmtMsFromMicros(summary.openP90Micros)} | ${fmtMsFromMicros(summary.connectMicros)} | ${summary.medianP50Us ?? 'n/a'} | ${summary.medianP90Us ?? 'n/a'} | ${summary.gateMedianP90Us ?? 'n/a'} | ${summary.medianP95Us ?? 'n/a'} | ${summary.medianP99Us ?? 'n/a'} | ${summary.maxP90Us ?? 'n/a'} | ${summary.maxP99Us ?? 'n/a'} | ${fmtMb(summary.peakRssMb)} | ${fmtMb(summary.observedServerPeakRssMb)} | ${fmtSec(summary.cpuSec)} |`, + ) + } + lines.push('') + lines.push('## Speed Summary') + lines.push('') + lines.push('| Mode | n | suite p50 s | suite p90 s | suite p95 s | suite p99 s | throughput p50 ops/s | tail throughput p10 ops/s | open p50 ms | open p90 ms | open p99 ms | p90 RSS MB | p99 RSS MB | p90 observed server RSS MB | p99 observed server RSS MB | p90 footprint MB | p99 footprint MB | p90 CPU s | p99 CPU s |') + lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + for (const mode of speedModes) { + if (!mode.run) { + continue + } + const summary = repeatedSpeedSummary(mode.run, mode.resource, mode.repeats) + lines.push( + `| ${mode.label} | ${summary.n} | ${fmtSecFromMicros(summary.p50TotalMicros)} | ${fmtSecFromMicros(summary.p90TotalMicros)} | ${fmtSecFromMicros(summary.p95TotalMicros)} | ${fmtSecFromMicros(summary.p99TotalMicros)} | ${fmtRate(summary.p50ThroughputPerSecond)} | ${fmtRate(summary.tailP10ThroughputPerSecond)} | ${fmtMsFromMicros(summary.p50OpenMicros)} | ${fmtMsFromMicros(summary.p90OpenMicros)} | ${fmtMsFromMicros(summary.p99OpenMicros)} | ${fmtMb(summary.p90RssMb)} | ${fmtMb(summary.p99RssMb)} | ${fmtMb(summary.p90ObservedServerRssMb)} | ${fmtMb(summary.p99ObservedServerRssMb)} | ${fmtMb(summary.p90FootprintMb)} | ${fmtMb(summary.p99FootprintMb)} | ${fmtSec(summary.p90CpuSec)} | ${fmtSec(summary.p99CpuSec)} |`, + ) + } + lines.push('') + lines.push('## Backup/Restore Summary') + lines.push('') + lines.push('| Mode | n | total p50 s | total p90 s | total p95 s | total p99 s | payload p50 MB | throughput p50 MB/s | tail throughput p10 MB/s | open p50 ms | open p90 ms | open p99 ms | p90 RSS MB | p99 RSS MB | p90 observed server RSS MB | p99 observed server RSS MB | p90 footprint MB | p99 footprint MB | p90 CPU s | p99 CPU s |') + lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + for (const mode of backupModes) { + if (!mode.run) { + continue + } + const summary = repeatedSpeedSummary(mode.run, mode.resource, mode.repeats) + const payloadBytes = Number.isFinite(summary.p50OperationCount) + ? summary.p50OperationCount / 2 + : null + lines.push( + `| ${mode.label} | ${summary.n} | ${fmtSecFromMicros(summary.p50TotalMicros)} | ${fmtSecFromMicros(summary.p90TotalMicros)} | ${fmtSecFromMicros(summary.p95TotalMicros)} | ${fmtSecFromMicros(summary.p99TotalMicros)} | ${fmtMbFromBytes(payloadBytes)} | ${fmtMbPerSec(summary.p50ThroughputPerSecond)} | ${fmtMbPerSec(summary.tailP10ThroughputPerSecond)} | ${fmtMsFromMicros(summary.p50OpenMicros)} | ${fmtMsFromMicros(summary.p90OpenMicros)} | ${fmtMsFromMicros(summary.p99OpenMicros)} | ${fmtMb(summary.p90RssMb)} | ${fmtMb(summary.p99RssMb)} | ${fmtMb(summary.p90ObservedServerRssMb)} | ${fmtMb(summary.p99ObservedServerRssMb)} | ${fmtMb(summary.p90FootprintMb)} | ${fmtMb(summary.p99FootprintMb)} | ${fmtSec(summary.p90CpuSec)} | ${fmtSec(summary.p99CpuSec)} |`, + ) + } + lines.push('') + lines.push('## Run Quality') + lines.push('') + lines.push('| Mode | n | min s | p50 s | p90/p50 | p95/p50 | p99/p50 | max s | status | reason |') + lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- |') + for (const mode of speedModes) { + if (!mode.run) { + continue + } + const summary = repeatedSpeedSummary(mode.run, mode.resource, mode.repeats) + const quality = runQuality(summary) + lines.push( + `| ${mode.label} | ${summary.n} | ${fmtSecFromMicros(summary.minTotalMicros)} | ${fmtSecFromMicros(summary.p50TotalMicros)} | ${fmtRatio(summary.p90TotalMicros, summary.p50TotalMicros)} | ${fmtRatio(summary.p95TotalMicros, summary.p50TotalMicros)} | ${fmtRatio(summary.p99TotalMicros, summary.p50TotalMicros)} | ${fmtSecFromMicros(summary.maxTotalMicros)} | ${quality.status} | ${quality.reason} |`, + ) + } + lines.push('') + lines.push('## Slowest Speed Repeats') + lines.push('') + lines.push('Use this table to distinguish host-wide pauses from engine-specific tail events. Repeated indices that recur across engines usually indicate host noise; isolated rows point at an engine path that needs focused diagnostics.') + lines.push('') + lines.push('| Mode | Repeat | suite s | ratio vs mode p50 | open ms |') + lines.push('| --- | --- | ---: | ---: | ---: |') + lines.push(...slowestRepeatRows(speedModes)) + lines.push('') + if (artifactSizes?.artifacts?.length) { + lines.push('## Artifact Sizes') + lines.push('') + lines.push('| Artifact | Size | Path |') + lines.push('| --- | ---: | --- |') + for (const artifact of artifactSizes.artifacts) { + lines.push(`| ${artifact.name} | ${fmtBytes(artifact.bytes)} | \`${artifact.path}\` |`) + } + lines.push('') + } + if (provenance) { + lines.push('## Provenance') + lines.push('') + lines.push('| Item | Value |') + lines.push('| --- | --- |') + lines.push(`| Generated | ${provenance.generatedAt ?? 'n/a'} |`) + lines.push(`| Git commit | \`${shortSha(provenance.repo?.commit)}\` |`) + lines.push(`| Tracked dirty | ${provenance.repo?.dirtyTracked ? 'yes' : 'no'} |`) + lines.push(`| Source set SHA-256 | \`${shortSha(provenance.source?.sourceSetSha256)}\` |`) + lines.push(`| Source files | ${provenance.source?.entries?.length ?? 'n/a'} |`) + lines.push(`| PGDATA copy mode | \`${provenance.benchmark?.pgdataCopyMode ?? 'n/a'}\` |`) + lines.push('') + if (provenance.artifacts?.length) { + lines.push('| Artifact | SHA-256 | Path |') + lines.push('| --- | --- | --- |') + for (const artifact of provenance.artifacts) { + lines.push( + `| ${artifact.name} | \`${shortSha(artifact.sha256)}\` | \`${artifact.path}\` |`, + ) + } + lines.push('') + } + } + lines.push('## Native Direct Gate') + lines.push('') + lines.push('| Metric | Native liboliphaunt direct | Native Postgres control | Ratio | Status |') + lines.push('| --- | ---: | ---: | ---: | --- |') + for (const row of nativeDirectGateRows) { + lines.push( + `| ${row.metric} | ${row.nativeDisplay} | ${row.baselineDisplay} | ${row.ratio} | ${row.status} |`, + ) + } + lines.push('') + if (sqliteEmbeddedSpeed?.run) { + lines.push('## SQLite Comparison') + lines.push('') + lines.push('| Metric | Native liboliphaunt direct | SQLite embedded | Ratio |') + lines.push('| --- | ---: | ---: | ---: |') + lines.push( + `| Speed suite p90 | ${fmtSecFromMicros(nativeDirectSpeedSummary.p90TotalMicros)} s | ${fmtSecFromMicros(sqliteEmbeddedSpeedSummary.p90TotalMicros)} s | ${fmtRatio(nativeDirectSpeedSummary.p90TotalMicros, sqliteEmbeddedSpeedSummary.p90TotalMicros)} |`, + ) + lines.push( + `| Speed tail throughput p10 | ${fmtRate(nativeDirectSpeedSummary.tailP10ThroughputPerSecond)} ops/s | ${fmtRate(sqliteEmbeddedSpeedSummary.tailP10ThroughputPerSecond)} ops/s | ${fmtRatio(nativeDirectSpeedSummary.tailP10ThroughputPerSecond, sqliteEmbeddedSpeedSummary.tailP10ThroughputPerSecond)} |`, + ) + lines.push( + `| Speed open p90 | ${fmtMsFromMicros(nativeDirectSpeedSummary.p90OpenMicros)} ms | ${fmtMsFromMicros(sqliteEmbeddedSpeedSummary.p90OpenMicros)} ms | ${fmtRatio(nativeDirectSpeedSummary.p90OpenMicros, sqliteEmbeddedSpeedSummary.p90OpenMicros)} |`, + ) + lines.push( + `| Speed p90 RSS | ${fmtMb(nativeDirectSpeedSummary.p90RssMb)} MB | ${fmtMb(sqliteEmbeddedSpeedSummary.p90RssMb)} MB | ${fmtRatio(nativeDirectSpeedSummary.p90RssMb, sqliteEmbeddedSpeedSummary.p90RssMb)} |`, + ) + lines.push('') + } + const speedGateMissDetails = speedCaseGateMisses(nativeDirectSpeed, nativePostgresSpeed) + const gateMisses = speedGateMissDetails.map( + (miss) => + `| ${miss.id} | ${miss.label} | ${fmtMsFromMicros(miss.nativeMicros)} | ${fmtMsFromMicros(miss.baselineMicros)} | ${fmtRatio(miss.nativeMicros, miss.baselineMicros)} |`, + ) + if (gateMisses.length === 0) { + lines.push('- No speed case misses above the 5% native PostgreSQL tolerance.') + } else { + lines.push('Speed case misses above the 5% native PostgreSQL tolerance:') + lines.push('') + lines.push('| ID | Test | Native liboliphaunt direct p90 ms | Native Postgres tokio simple p90 ms | Ratio |') + lines.push('| --- | --- | ---: | ---: | ---: |') + lines.push(...gateMisses) + } + lines.push('') + if (nativeDirectGateMisses.length || speedGateMissDetails.length) { + lines.push('## Native Direct Regression Diagnostics') + lines.push('') + lines.push( + 'Run these diagnostics before changing PostgreSQL patches or source/build flags. They keep direct-mode regressions tied to a measured suite, case id, or runtime GUC instead of broad speculation.', + ) + lines.push('') + if (nativeDirectGateMisses.length) { + lines.push('| Missed gate | Diagnostic action |') + lines.push('| --- | --- |') + for (const miss of nativeDirectGateMisses) { + lines.push(`| ${miss.metric} | ${miss.diagnostic} |`) + } + lines.push('') + } + if (speedGateMissDetails.length) { + const ids = speedGateMissDetails.map((miss) => miss.id).join(',') + lines.push('Speed-case diagnostic commands:') + lines.push('') + lines.push('```sh') + lines.push( + `tools/perf/matrix/run_native_speed_diagnostics.sh --ids ${ids} --repeats 10 --skip-build`, + ) + lines.push( + `cargo run --release -p oliphaunt-perf -- diagnose-speed-cases --engine native-liboliphaunt --ids ${ids}`, + ) + lines.push( + `cargo run --release -p oliphaunt-perf -- diagnose-speed-cases --engine native-postgres --ids ${ids}`, + ) + lines.push('```') + lines.push('') + } + if (nativeDirectGateMisses.some((miss) => miss.metric.includes('RTT'))) { + lines.push('RTT tail diagnostic command:') + lines.push('') + lines.push('```sh') + lines.push( + 'tools/perf/matrix/run_native_oliphaunt_matrix.sh --quick --engines direct --suites rtt --skip-sqlite --skip-prepared', + ) + lines.push('```') + lines.push('') + } + if (nativeDirectGateMisses.some((miss) => miss.metric.includes('RSS') || miss.metric.includes('open'))) { + lines.push('Runtime-footprint diagnostic command:') + lines.push('') + lines.push('```sh') + lines.push( + 'tools/perf/matrix/run_native_oliphaunt_matrix.sh --quick --engines direct --suites speed --runtime-footprint balanced-mobile --startup-guc shared_buffers=32MB --startup-guc wal_buffers=-1 --skip-sqlite --skip-prepared', + ) + lines.push('```') + lines.push('') + } + if (nativeDirectGateMisses.some((miss) => miss.metric.includes('Backup/restore'))) { + lines.push('Backup/restore diagnostic command:') + lines.push('') + lines.push('```sh') + lines.push( + 'tools/perf/matrix/run_native_oliphaunt_matrix.sh --quick --engines direct --suites backup --skip-sqlite --skip-prepared', + ) + lines.push('```') + lines.push('') + } + } + lines.push('') + lines.push('## Speed Cases') + lines.push('') + if (activeSpeedModes.length) { + lines.push( + `| ID | Test | ${activeSpeedModes.map((mode) => `${mode.label} p90 ms`).join(' | ')} |`, + ) + lines.push(`| --- | --- | ${activeSpeedModes.map(() => '---:').join(' | ')} |`) + lines.push(...speedCaseRows(activeSpeedModes)) + } else { + lines.push('No speed suite measurements were selected for this run.') + } + lines.push('') + lines.push('## Streaming') + lines.push('') + lines.push('| Mode | open ms | case | elapsed ms | bytes | peak RSS MB | observed server RSS MB | CPU s |') + lines.push('| --- | ---: | --- | ---: | ---: | ---: | ---: | ---: |') + for (const [label, run, resource] of streamingModes) { + if (!run) { + continue + } + for (const test of run.tests) { + lines.push( + `| ${label} | ${fmtMsFromMicros(run.openMicros)} | ${test.id} | ${fmtMsFromMicros(test.elapsedMicros)} | ${test.operationCount ?? 'n/a'} | ${fmtMb(resource.peakRssMb)} | ${fmtMb(run.observedServerPeakRssBytes ? run.observedServerPeakRssBytes / 1024 / 1024 : undefined)} | ${fmtSec(resource.cpuSec)} |`, + ) + } + } + lines.push('') + lines.push('## Prepared Updates') + lines.push('') + lines.push('| Mode | n | numeric p50 s | numeric p90 s | numeric p95 s | numeric p99 s | numeric p90/native | text p50 s | text p90 s | text p95 s | text p99 s | text p90/native | p90 command RSS MB | p99 command RSS MB | p90 command footprint MB | p99 command footprint MB | p90 command CPU s | p99 command CPU s | p90 command wall s | p99 command wall s |') + lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + lines.push(...preparedRows(nativePostgresPrepared, nativePostgresPreparedRepeats, nativePostgresPrepared, nativePostgresPreparedRepeats)) + lines.push(...preparedRows(nativePreparedDirect, nativePreparedDirectRepeats, nativePostgresPrepared, nativePostgresPreparedRepeats)) + lines.push(...preparedRows(nativePreparedBroker, nativePreparedBrokerRepeats, nativePostgresPrepared, nativePostgresPreparedRepeats)) + lines.push(...preparedRows(nativePreparedServer, nativePreparedServerRepeats, nativePostgresPrepared, nativePostgresPreparedRepeats)) + lines.push('') + lines.push('## Notes') + lines.push('') + lines.push('- Native liboliphaunt v1 is deliberately process-lifetime scoped; same-process reopen is not measured as a supported path.') + lines.push('- Native broker and native server are measured as their own SDK modes. No direct-mode multiplexing is counted as broker or server performance.') + lines.push('- Native PostgreSQL `observed server RSS` is sampled from the live server process tree during each suite. It is reported separately from `/usr/bin/time` process RSS because the control server runs out of process.') + lines.push('- SQLite embedded uses the same durability label mapped to explicit SQLite PRAGMAs inside xtask; it is a product comparison baseline, not the release gate for PostgreSQL execution parity.') + lines.push('- Compare direct mode with native PostgreSQL simple-query controls for backend execution parity; SQLx rows include client abstraction overhead.') + lines.push('') + + console.log(lines.join('\n')) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/tools/perf/matrix/summarize_native_speed_diagnostics.mjs b/tools/perf/matrix/summarize_native_speed_diagnostics.mjs new file mode 100644 index 00000000..d51ec2f7 --- /dev/null +++ b/tools/perf/matrix/summarize_native_speed_diagnostics.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +function usage() { + console.error(`usage: + summarize_native_speed_diagnostics.mjs --run-dir DIR --ids LIST --repeats N`) +} + +function parseArgs(argv) { + const args = {} + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index] + if (!key.startsWith('--')) { + throw new Error(`unexpected argument: ${key}`) + } + const value = argv[index + 1] + if (index + 1 < argv.length && !value.startsWith('--')) { + args[key] = value + index += 1 + } else { + args[key] = 'true' + } + } + return args +} + +function requireArg(args, key) { + const value = args[key] + if (!value || value === 'true') { + throw new Error(`${key} is required`) + } + return value +} + +function parseIds(value) { + const ids = value + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + if (ids.length === 0) { + throw new Error('--ids must contain at least one speed case id') + } + return ids +} + +function parsePositiveInt(value, label) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`) + } + return parsed +} + +function safeId(id) { + return id.replaceAll('.', '_') +} + +function percentile(values, p) { + if (values.length === 0) { + return null + } + const sorted = [...values].sort((a, b) => a - b) + const index = Math.round((sorted.length - 1) * p) + return sorted[index] +} + +function stats(values) { + return { + n: values.length, + minMicros: percentile(values, 0), + p50Micros: percentile(values, 0.5), + p90Micros: percentile(values, 0.9), + p95Micros: percentile(values, 0.95), + p99Micros: percentile(values, 0.99), + maxMicros: percentile(values, 1), + } +} + +function fmtMs(micros) { + if (micros === null || micros === undefined) { + return 'n/a' + } + return `${(micros / 1000).toFixed(3)}` +} + +function fmtRatio(a, b) { + if (!Number.isFinite(a) || !Number.isFinite(b) || b === 0) { + return 'n/a' + } + return `${(a / b).toFixed(3)}x` +} + +async function readJson(file) { + return JSON.parse(await fs.readFile(file, 'utf8')) +} + +function firstCase(report, id, engine) { + const found = report.cases?.find((item) => item.id === id) + if (!found) { + throw new Error(`${engine} diagnostic report missing case ${id}`) + } + return found +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const runDir = path.resolve(requireArg(args, '--run-dir')) + const ids = parseIds(requireArg(args, '--ids')) + const repeats = parsePositiveInt(requireArg(args, '--repeats'), '--repeats') + + const cases = [] + for (const id of ids) { + const direct = [] + const nativePostgres = [] + for (let repeat = 1; repeat <= repeats; repeat += 1) { + const index = String(repeat).padStart(String(repeats).length, '0') + const directReport = await readJson( + path.join(runDir, 'direct', `native-liboliphaunt-speed-case-${safeId(id)}-${index}.json`), + ) + const pgReport = await readJson( + path.join(runDir, 'native-postgres', `native-postgres-speed-cases-${index}.json`), + ) + direct.push(firstCase(directReport, id, 'native-liboliphaunt')) + nativePostgres.push(firstCase(pgReport, id, 'native-postgres')) + } + + const directElapsed = stats(direct.map((item) => item.elapsed_micros)) + const pgElapsed = stats(nativePostgres.map((item) => item.elapsed_micros)) + const directOpen = stats(direct.map((item) => item.open_micros).filter((item) => item !== null)) + const pgOpen = stats(nativePostgres.map((item) => item.open_micros).filter((item) => item !== null)) + const directSetup = stats(direct.map((item) => item.setup_micros)) + const pgSetup = stats(nativePostgres.map((item) => item.setup_micros)) + const directRss = stats( + direct + .map((item) => item.observed_server_peak_rss_bytes) + .filter((item) => Number.isFinite(item)) + .map((item) => Math.round(item / 1024 / 1024 * 1000)), + ) + const pgRss = stats( + nativePostgres + .map((item) => item.observed_server_peak_rss_bytes) + .filter((item) => Number.isFinite(item)) + .map((item) => Math.round(item / 1024 / 1024 * 1000)), + ) + + cases.push({ + id, + label: direct[0].label, + repeats, + operationCount: direct[0].operation_count, + direct: { + elapsed: directElapsed, + open: directOpen, + setup: directSetup, + observedServerRssMbTimes1000: directRss, + settings: direct[0].settings, + }, + nativePostgres: { + elapsed: pgElapsed, + open: pgOpen, + setup: pgSetup, + observedServerRssMbTimes1000: pgRss, + settings: nativePostgres[0].settings, + }, + ratios: { + elapsedP90: directElapsed.p90Micros / pgElapsed.p90Micros, + elapsedP99: directElapsed.p99Micros / pgElapsed.p99Micros, + }, + }) + } + + const summary = { + schema: 'oliphaunt.native-speed-diagnostics.v1', + runDir, + ids, + repeats, + cases, + } + await fs.writeFile(path.join(runDir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`) + + const lines = [] + lines.push(`# Native Speed Diagnostics`) + lines.push('') + lines.push(`Run directory: \`${runDir}\``) + lines.push('') + lines.push('| ID | Test | n | Direct p50 ms | Direct p90 ms | Direct p99 ms | Native PG p50 ms | Native PG p90 ms | Native PG p99 ms | p90 ratio | p99 ratio | Direct open p90 ms | Native PG open p90 ms |') + lines.push('| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + for (const item of cases) { + lines.push( + `| ${item.id} | ${item.label} | ${item.repeats} | ${fmtMs(item.direct.elapsed.p50Micros)} | ${fmtMs(item.direct.elapsed.p90Micros)} | ${fmtMs(item.direct.elapsed.p99Micros)} | ${fmtMs(item.nativePostgres.elapsed.p50Micros)} | ${fmtMs(item.nativePostgres.elapsed.p90Micros)} | ${fmtMs(item.nativePostgres.elapsed.p99Micros)} | ${fmtRatio(item.direct.elapsed.p90Micros, item.nativePostgres.elapsed.p90Micros)} | ${fmtRatio(item.direct.elapsed.p99Micros, item.nativePostgres.elapsed.p99Micros)} | ${fmtMs(item.direct.open.p90Micros)} | ${fmtMs(item.nativePostgres.open.p90Micros)} |`, + ) + } + lines.push('') + lines.push('## Setup And RSS') + lines.push('') + lines.push('| ID | Direct setup p90 ms | Native PG setup p90 ms | Direct observed RSS p90 MB | Native PG observed RSS p90 MB |') + lines.push('| --- | ---: | ---: | ---: | ---: |') + for (const item of cases) { + lines.push( + `| ${item.id} | ${fmtMs(item.direct.setup.p90Micros)} | ${fmtMs(item.nativePostgres.setup.p90Micros)} | ${fmtMs(item.direct.observedServerRssMbTimes1000.p90Micros)} | ${fmtMs(item.nativePostgres.observedServerRssMbTimes1000.p90Micros)} |`, + ) + } + lines.push('') + lines.push('NativeDirect diagnostics run one fresh process per case/repeat because direct mode owns one process-lifetime embedded backend.') + await fs.writeFile(path.join(runDir, 'summary.md'), `${lines.join('\n')}\n`) +} + +main().catch((error) => { + usage() + console.error(error) + process.exit(1) +}) diff --git a/tools/perf/moon.yml b/tools/perf/moon.yml new file mode 100644 index 00000000..f645775c --- /dev/null +++ b/tools/perf/moon.yml @@ -0,0 +1,66 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "perf-tools" +language: "rust" +layer: "tool" +stack: "systems" +tags: ["tools", "performance", "bench"] +dependsOn: + - "benchmarks" + +project: + title: "Performance Tools" + description: "Native, WASM, mobile, and SQLite benchmark orchestration and reporting." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/perf" + paths: + "**/*": ["@oliphaunt/perf"] + +tasks: + check: + tags: ["quality", "static"] + command: "bash tools/perf/check-native-perf-harness.sh" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/src/bindings/wasix-rust/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/**/*" + - "/src/extensions/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/rust-toolchain.toml" + options: + cache: true + runFromWorkspaceRoot: true + bench: + tags: ["bench", "plan"] + command: "bash tools/perf/matrix/run_native_oliphaunt_matrix.sh --plan-only" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/sdks/rust/**/*" + - "/src/bindings/wasix-rust/**/*" + - "/src/postgres/versions/18/**/*" + - "/src/sources/toolchains/**/*" + - "/src/sources/third-party/**/*" + - "/src/extensions/**/*" + - "/tools/perf/**/*" + - "/benchmarks/**/*" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/release.toml" + - "/rust-toolchain.toml" + options: + cache: true + runFromWorkspaceRoot: true + runInCI: false diff --git a/tools/perf/runner/Cargo.toml b/tools/perf/runner/Cargo.toml new file mode 100644 index 00000000..87957675 --- /dev/null +++ b/tools/perf/runner/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "oliphaunt-perf" +version = "0.0.0" +edition = "2024" +rust-version = "1.93" +license.workspace = true +publish = false + +[features] +default = [] +legacy-oliphaunt = ["dep:directories", "dep:oliphaunt-wasix"] + +[dependencies] +anyhow = "1" +directories = { version = "6", optional = true } +futures-util = "0.3" +oliphaunt = { version = "0.1.0", path = "../../../src/sdks/rust" } +oliphaunt-wasix = { version = "0.6.0", path = "../../../src/bindings/wasix-rust/crates/oliphaunt-wasix", features = ["extensions"], optional = true } +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", default-features = false, features = [ + "postgres", + "runtime-tokio", +] } +tar = "0.4" +tokio = { version = "1", features = ["rt-multi-thread"] } +tokio-postgres = "0.7" diff --git a/tools/perf/runner/src/benchmarks.rs b/tools/perf/runner/src/benchmarks.rs new file mode 100644 index 00000000..4b6842f4 --- /dev/null +++ b/tools/perf/runner/src/benchmarks.rs @@ -0,0 +1,807 @@ +use super::*; + +pub(super) fn read_oliphaunt_benchmark_sql(id: &str) -> Result { + let path = Path::new(OLIPHAUNT_BENCHMARK_SQL_DIR).join(format!("benchmark{id}.sql")); + fs::read_to_string(&path) + .with_context(|| format!("read Oliphaunt benchmark SQL {}", path.display())) +} + +pub(super) struct RttCase { + pub(super) id: &'static str, + pub(super) label: &'static str, + pub(super) sql: String, +} + +pub(super) struct SpeedCase { + pub(super) id: &'static str, + pub(super) label: String, + pub(super) sql: String, + pub(super) operation_count: usize, +} + +pub(super) struct StreamingCase { + pub(super) id: &'static str, + pub(super) label: &'static str, + pub(super) sql: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SpeedSqlSource { + Generated, + OliphauntFixture, +} + +impl SpeedSqlSource { + pub(super) fn source_model(self) -> &'static str { + match self { + SpeedSqlSource::Generated => { + "Mirrors the two Oliphaunt benchmark families documented at https://oliphaunt.dev/benchmarks: trimmed-average CRUD round-trip microbenchmarks and a SQLite speedtest-style SQL suite. The speed suite is generated locally instead of vendoring Oliphaunt's generated SQL files." + } + SpeedSqlSource::OliphauntFixture => { + "Mirrors the two Oliphaunt benchmark families documented at https://oliphaunt.dev/benchmarks: trimmed-average CRUD round-trip microbenchmarks and the exact SQL files from benchmarks/native/sql." + } + } + } +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_rtt_direct_benchmark(iterations: usize) -> Result { + let open_started = Instant::now(); + let mut db = Oliphaunt::builder().temporary().open()?; + let open_micros = open_started.elapsed().as_micros(); + + let setup_started = Instant::now(); + db.exec(rtt_setup_sql(), None)?; + let setup_micros = setup_started.elapsed().as_micros(); + + let mut tests = Vec::new(); + for case in rtt_cases() { + tests.push(run_rtt_case(iterations, &case, |sql| { + db.exec(sql, None)?; + Ok(()) + })?); + } + db.close()?; + + Ok(BenchmarkRun { + suite: "rtt", + mode: "direct", + description: "Oliphaunt direct Rust API, matching Oliphaunt's in-process exec-style benchmark shape.", + open_micros, + connect_micros: None, + setup_micros, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_rtt_server_sqlx_benchmark(iterations: usize) -> Result { + let open_started = Instant::now(); + let server = benchmark_oliphaunt_server()?; + let open_micros = open_started.elapsed().as_micros(); + let uri = server.database_url(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create benchmark Tokio runtime")?; + + let (connect_micros, setup_micros, tests) = runtime.block_on(async { + let connect_started = Instant::now(); + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx benchmark client")?; + let connect_micros = connect_started.elapsed().as_micros(); + + let setup_started = Instant::now(); + conn.execute(rtt_setup_sql()) + .await + .context("execute RTT setup over SQLx")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let mut tests = Vec::new(); + for case in rtt_cases() { + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let started = Instant::now(); + conn.execute(case.sql.as_str()) + .await + .with_context(|| format!("execute RTT benchmark {} over SQLx", case.id))?; + samples.push(started.elapsed().as_micros()); + } + tests.push(samples_result( + case.id, + format!("Test {}: {}", case.id, case.label), + "milliseconds", + iterations, + samples, + )); + } + conn.close().await.context("close SQLx benchmark client")?; + Ok::<_, anyhow::Error>((connect_micros, setup_micros, tests)) + })?; + server.shutdown()?; + + Ok(BenchmarkRun { + suite: "rtt", + mode: "server_sqlx", + description: "OliphauntServer over the Postgres wire protocol using one long-lived SQLx connection.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_rtt_server_tokio_postgres_simple_benchmark( + iterations: usize, +) -> Result { + let open_started = Instant::now(); + let server = benchmark_oliphaunt_server()?; + let open_micros = open_started.elapsed().as_micros(); + let uri = server.database_url(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create tokio-postgres simple RTT runtime")?; + + let (connect_micros, setup_micros, tests) = runtime.block_on(async { + let connect_started = Instant::now(); + let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls) + .await + .context("connect tokio-postgres simple RTT client")?; + let connection_handle = tokio::spawn(connection); + let connect_micros = connect_started.elapsed().as_micros(); + + let setup_started = Instant::now(); + client + .batch_execute(rtt_setup_sql()) + .await + .context("execute RTT setup over tokio-postgres simple-query protocol")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let mut tests = Vec::new(); + for case in rtt_cases() { + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let started = Instant::now(); + client.batch_execute(&case.sql).await.with_context(|| { + format!( + "execute RTT benchmark {} over tokio-postgres simple-query protocol", + case.id + ) + })?; + samples.push(started.elapsed().as_micros()); + } + tests.push(samples_result( + case.id, + format!("Test {}: {}", case.id, case.label), + "milliseconds", + iterations, + samples, + )); + } + + drop(client); + connection_handle + .await + .context("join tokio-postgres simple RTT connection task")? + .context("tokio-postgres simple RTT connection task")?; + Ok::<_, anyhow::Error>((connect_micros, setup_micros, tests)) + })?; + server.shutdown()?; + + Ok(BenchmarkRun { + suite: "rtt", + mode: "server_tokio_postgres_simple", + description: "OliphauntServer over the Postgres wire protocol using one long-lived tokio-postgres connection and the simple-query protocol without SQLx.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_speed_direct_benchmark( + scale: f64, + sql_source: SpeedSqlSource, +) -> Result { + let open_started = Instant::now(); + let mut db = Oliphaunt::builder().temporary().open()?; + let open_micros = open_started.elapsed().as_micros(); + + let mut tests = Vec::new(); + for case in speed_cases(scale, sql_source)? { + let started = Instant::now(); + db.exec(&case.sql, None) + .with_context(|| format!("execute speed benchmark {}", case.id))?; + tests.push(single_sample_result( + case.id, + case.label, + "seconds", + case.operation_count, + started.elapsed(), + )); + } + db.close()?; + + Ok(BenchmarkRun { + suite: "speed", + mode: "direct", + description: "Generated SQLite speedtest-style SQL suite through Oliphaunt direct Rust API.", + open_micros, + connect_micros: None, + setup_micros: 0, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_speed_server_sqlx_benchmark( + scale: f64, + sql_source: SpeedSqlSource, +) -> Result { + let open_started = Instant::now(); + let server = benchmark_oliphaunt_server()?; + let open_micros = open_started.elapsed().as_micros(); + let uri = server.database_url(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create benchmark Tokio runtime")?; + + let (connect_micros, tests) = runtime.block_on(async { + let connect_started = Instant::now(); + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx speed benchmark client")?; + let connect_micros = connect_started.elapsed().as_micros(); + + let mut tests = Vec::new(); + for case in speed_cases(scale, sql_source)? { + let started = Instant::now(); + conn.execute(case.sql.as_str()) + .await + .with_context(|| format!("execute speed benchmark {} over SQLx", case.id))?; + tests.push(single_sample_result( + case.id, + case.label, + "seconds", + case.operation_count, + started.elapsed(), + )); + } + conn.close() + .await + .context("close SQLx speed benchmark client")?; + Ok::<_, anyhow::Error>((connect_micros, tests)) + })?; + server.shutdown()?; + + Ok(BenchmarkRun { + suite: "speed", + mode: "server_sqlx", + description: "Generated SQLite speedtest-style SQL suite through one SQLx connection to OliphauntServer.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros: 0, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn benchmark_oliphaunt_server() -> Result { + OliphauntServer::builder() + .temporary() + .database("postgres") + .start() +} + +pub(super) fn rtt_setup_sql() -> &'static str { + "\ +CREATE TABLE t1 (id SERIAL PRIMARY KEY NOT NULL, a INTEGER); +CREATE TABLE t2 (id SERIAL PRIMARY KEY NOT NULL, a TEXT); +" +} + +pub(super) fn rtt_cases() -> Vec { + vec![ + RttCase { + id: "1", + label: "insert small row", + sql: "INSERT INTO t1 (a) VALUES (1);".to_owned(), + }, + RttCase { + id: "2", + label: "select small row", + sql: "SELECT * FROM t1 WHERE id = 333;".to_owned(), + }, + RttCase { + id: "3", + label: "update small row", + sql: "UPDATE t1 SET a = 2 WHERE id = 666;".to_owned(), + }, + RttCase { + id: "4", + label: "delete small row", + sql: "DELETE FROM t1 WHERE id IN (SELECT id FROM t1 LIMIT 1);".to_owned(), + }, + RttCase { + id: "5", + label: "insert 1kb row", + sql: format!("INSERT INTO t2 (a) VALUES ('{}');", "a".repeat(1_000)), + }, + RttCase { + id: "6", + label: "select 1kb row", + sql: "SELECT * FROM t2 WHERE id IN (SELECT id FROM t2 LIMIT 1);".to_owned(), + }, + RttCase { + id: "7", + label: "update 1kb row", + sql: format!("UPDATE t2 SET a = '{}' WHERE id = 1;", "a".repeat(1_000)), + }, + RttCase { + id: "8", + label: "delete 1kb row", + sql: "DELETE FROM t2 WHERE id IN (SELECT id FROM t2 LIMIT 1);".to_owned(), + }, + RttCase { + id: "9", + label: "insert 10kb row", + sql: format!("INSERT INTO t2 (a) VALUES ('{}');", "a".repeat(10_000)), + }, + RttCase { + id: "10", + label: "select 10kb row", + sql: "SELECT * FROM t2 WHERE id IN (SELECT id FROM t2 LIMIT 1);".to_owned(), + }, + RttCase { + id: "11", + label: "update 10kb row", + sql: format!("UPDATE t2 SET a = '{}' WHERE id = 1;", "a".repeat(10_000)), + }, + RttCase { + id: "12", + label: "delete 10kb row", + sql: "DELETE FROM t2 WHERE id IN (SELECT id FROM t2 LIMIT 1);".to_owned(), + }, + ] +} + +pub(super) fn streaming_cases() -> &'static [StreamingCase] { + &[ + StreamingCase { + id: "large_text_8mb", + label: "Large text result, approximately 8 MiB of row payload", + sql: "SELECT i, repeat('x', 1024) AS payload FROM generate_series(1, 8192) AS i", + }, + StreamingCase { + id: "wide_text_16mb", + label: "Wide text result, approximately 16 MiB of row payload", + sql: "SELECT repeat('y', 1048576) AS payload FROM generate_series(1, 16)", + }, + StreamingCase { + id: "copy_out_8mb", + label: "COPY TO STDOUT result, approximately 8 MiB of CopyData payload", + sql: "COPY (SELECT i, repeat('c', 1024) AS payload FROM generate_series(1, 8192) AS i) TO STDOUT", + }, + ] +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_rtt_case( + iterations: usize, + case: &RttCase, + mut execute: impl FnMut(&str) -> Result<()>, +) -> Result { + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let started = Instant::now(); + execute(&case.sql).with_context(|| format!("execute RTT benchmark {}", case.id))?; + samples.push(started.elapsed().as_micros()); + } + Ok(samples_result( + case.id, + format!("Test {}: {}", case.id, case.label), + "milliseconds", + iterations, + samples, + )) +} + +pub(super) fn samples_result( + id: &'static str, + label: String, + unit: &'static str, + operation_count: usize, + samples: Vec, +) -> BenchmarkTestResult { + let elapsed_micros = samples.iter().sum(); + let mut sorted = samples; + sorted.sort_unstable(); + let trim = if sorted.len() >= 10 { + sorted.len() / 10 + } else { + 0 + }; + let trimmed = &sorted[trim..sorted.len() - trim]; + let average = trimmed.iter().sum::() as f64 / trimmed.len() as f64; + let p50 = percentile_sorted(&sorted, 0.50); + let p90 = percentile_sorted(&sorted, 0.90); + let p95 = percentile_sorted(&sorted, 0.95); + let p99 = percentile_sorted(&sorted, 0.99); + BenchmarkTestResult { + id, + label, + unit, + operation_count, + sample_count: sorted.len(), + trimmed_sample_count: trimmed.len(), + elapsed_micros, + average_micros: Some(average), + min_micros: sorted.first().copied(), + p50_micros: p50, + p90_micros: p90, + p95_micros: p95, + p99_micros: p99, + } +} + +pub(super) fn single_sample_result( + id: &'static str, + label: String, + unit: &'static str, + operation_count: usize, + elapsed: Duration, +) -> BenchmarkTestResult { + let elapsed_micros = elapsed.as_micros(); + BenchmarkTestResult { + id, + label, + unit, + operation_count, + sample_count: 1, + trimmed_sample_count: 1, + elapsed_micros, + average_micros: None, + min_micros: Some(elapsed_micros), + p50_micros: Some(elapsed_micros), + p90_micros: Some(elapsed_micros), + p95_micros: Some(elapsed_micros), + p99_micros: Some(elapsed_micros), + } +} + +fn percentile_sorted(sorted: &[u128], percentile: f64) -> Option { + if sorted.is_empty() { + return None; + } + let idx = ((sorted.len() - 1) as f64 * percentile).round() as usize; + sorted.get(idx).copied() +} + +pub(super) fn speed_cases(scale: f64, sql_source: SpeedSqlSource) -> Result> { + let insert_1k = scaled_count(1_000, scale); + let insert_25k = scaled_count(25_000, scale); + let select_100 = scaled_count(100, scale); + let select_5k = scaled_count(5_000, scale); + let update_1k = scaled_count(1_000, scale); + let update_25k = scaled_count(25_000, scale); + let refill_12k = scaled_count(12_000, scale); + let mut cases = vec![ + SpeedCase { + id: "1", + label: format!("Test 1: {insert_1k} INSERTs"), + sql: speed_create_and_insert("t1", insert_1k, false, false), + operation_count: insert_1k, + }, + SpeedCase { + id: "2", + label: format!("Test 2: {insert_25k} INSERTs in a transaction"), + sql: speed_create_and_insert("t2", insert_25k, true, false), + operation_count: insert_25k, + }, + SpeedCase { + id: "2.1", + label: format!("Test 2.1: {insert_25k} INSERTs in single statement"), + sql: speed_create_and_insert("t2_1", insert_25k, true, true), + operation_count: insert_25k, + }, + SpeedCase { + id: "3", + label: format!("Test 3: {insert_25k} INSERTs into an indexed table"), + sql: speed_indexed_create_and_insert("t3", "i3", insert_25k, false), + operation_count: insert_25k, + }, + SpeedCase { + id: "3.1", + label: format!("Test 3.1: {insert_25k} INSERTs into an indexed table in single statement"), + sql: speed_indexed_create_and_insert("t3_1", "i3_1", insert_25k, true), + operation_count: insert_25k, + }, + SpeedCase { + id: "4", + label: format!("Test 4: {select_100} SELECTs without an index"), + sql: speed_select_range("t2", select_100, 100), + operation_count: select_100, + }, + SpeedCase { + id: "5", + label: format!("Test 5: {select_100} SELECTs on a string comparison"), + sql: speed_select_like("t2", select_100), + operation_count: select_100, + }, + SpeedCase { + id: "6", + label: "Test 6: Creating indexes".to_owned(), + sql: "CREATE INDEX i2a ON t2(a);\nCREATE INDEX i2b ON t2(b);\n".to_owned(), + operation_count: 2, + }, + SpeedCase { + id: "7", + label: format!("Test 7: {select_5k} SELECTs with an index"), + sql: speed_select_range("t2", select_5k, 100), + operation_count: select_5k, + }, + SpeedCase { + id: "8", + label: format!("Test 8: {update_1k} UPDATEs without an index"), + sql: speed_update_t1(update_1k), + operation_count: update_1k, + }, + SpeedCase { + id: "9", + label: format!("Test 9: {update_25k} UPDATEs with an index"), + sql: speed_update_t2_numeric(update_25k), + operation_count: update_25k, + }, + SpeedCase { + id: "10", + label: format!("Test 10: {update_25k} text UPDATEs with an index"), + sql: speed_update_t2_text(update_25k), + operation_count: update_25k, + }, + SpeedCase { + id: "11", + label: "Test 11: INSERTs from a SELECT".to_owned(), + sql: "BEGIN;\nINSERT INTO t1 SELECT b,a,c FROM t2;\nINSERT INTO t2 SELECT b,a,c FROM t1;\nCOMMIT;\n".to_owned(), + operation_count: 2, + }, + SpeedCase { + id: "12", + label: "Test 12: DELETE without an index".to_owned(), + sql: "DELETE FROM t2 WHERE c LIKE '%fifty%';\n".to_owned(), + operation_count: 1, + }, + SpeedCase { + id: "13", + label: "Test 13: DELETE with an index".to_owned(), + sql: "DELETE FROM t2 WHERE a > 10 AND a < 20000;\n".to_owned(), + operation_count: 1, + }, + SpeedCase { + id: "14", + label: "Test 14: A big INSERT after a big DELETE".to_owned(), + sql: "INSERT INTO t2 SELECT * FROM t1;\n".to_owned(), + operation_count: 1, + }, + SpeedCase { + id: "15", + label: format!("Test 15: A big DELETE followed by {refill_12k} small INSERTs"), + sql: speed_delete_and_refill_t1(refill_12k), + operation_count: refill_12k + 1, + }, + SpeedCase { + id: "16", + label: "Test 16: DROP TABLE".to_owned(), + sql: "DROP TABLE t1;\nDROP TABLE t2;\nDROP TABLE t3;\nDROP TABLE t2_1;\nDROP TABLE t3_1;\n".to_owned(), + operation_count: 5, + }, + ]; + + if sql_source == SpeedSqlSource::OliphauntFixture { + let benchmark_dir = Path::new(OLIPHAUNT_BENCHMARK_SQL_DIR); + for case in &mut cases { + let path = benchmark_dir.join(format!("benchmark{}.sql", case.id)); + case.sql = fs::read_to_string(&path) + .with_context(|| format!("read Oliphaunt benchmark SQL {}", path.display()))?; + } + } + + Ok(cases) +} + +fn scaled_count(base: usize, scale: f64) -> usize { + ((base as f64 * scale).round() as usize).max(1) +} + +fn speed_create_and_insert( + table: &str, + rows: usize, + transaction: bool, + single_statement: bool, +) -> String { + let mut sql = String::new(); + if transaction { + sql.push_str("BEGIN;\n"); + } + sql.push_str(&format!( + "CREATE TABLE {table}(a INTEGER, b INTEGER, c VARCHAR(100));\n" + )); + if single_statement { + sql.push_str(&format!("INSERT INTO {table} VALUES\n")); + for row in 1..=rows { + if row > 1 { + sql.push_str(",\n"); + } + sql.push_str(&speed_row_values(row, row)); + } + sql.push_str(";\n"); + } else { + append_insert_rows(&mut sql, table, rows, 0); + } + if transaction { + sql.push_str("COMMIT;\n"); + } + sql +} + +fn speed_indexed_create_and_insert( + table: &str, + index: &str, + rows: usize, + single_statement: bool, +) -> String { + let mut sql = String::new(); + sql.push_str("BEGIN;\n"); + sql.push_str(&format!( + "CREATE TABLE {table}(a INTEGER, b INTEGER, c VARCHAR(100));\n" + )); + sql.push_str(&format!("CREATE INDEX {index} ON {table}(c);\n")); + if single_statement { + sql.push_str(&format!("INSERT INTO {table} VALUES\n")); + for row in 1..=rows { + if row > 1 { + sql.push_str(",\n"); + } + sql.push_str(&speed_row_values(row, row + 17)); + } + sql.push_str(";\n"); + } else { + append_insert_rows(&mut sql, table, rows, 17); + } + sql.push_str("COMMIT;\n"); + sql +} + +fn append_insert_rows(sql: &mut String, table: &str, rows: usize, seed_offset: usize) { + for row in 1..=rows { + sql.push_str(&format!( + "INSERT INTO {table} VALUES{};\n", + speed_row_values(row, row + seed_offset) + )); + } +} + +fn speed_row_values(row: usize, seed: usize) -> String { + let value = deterministic_benchmark_value(seed); + format!("({row}, {value}, '{}')", synthetic_benchmark_text(value)) +} + +fn speed_select_range(table: &str, count: usize, width: usize) -> String { + let mut sql = String::from("BEGIN;\n"); + for step in 0..count { + let low = step * width; + let high = low + width; + sql.push_str(&format!( + "SELECT count(*), avg(b) FROM {table} WHERE b >= {low} AND b < {high};\n" + )); + } + sql.push_str("COMMIT;\n"); + sql +} + +fn speed_select_like(table: &str, count: usize) -> String { + const WORDS: &[&str] = &[ + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", + "twenty", + ]; + let mut sql = String::from("BEGIN;\n"); + for step in 0..count { + let word = WORDS[step % WORDS.len()]; + sql.push_str(&format!( + "SELECT count(*), avg(b) FROM {table} WHERE c LIKE '%{word}%';\n" + )); + } + sql.push_str("COMMIT;\n"); + sql +} + +fn speed_update_t1(count: usize) -> String { + let mut sql = String::from("BEGIN;\n"); + for step in 0..count { + let low = step * 10; + let high = low + 10; + sql.push_str(&format!( + "UPDATE t1 SET b = b * 2 WHERE a >= {low} AND a < {high};\n" + )); + } + sql.push_str("COMMIT;\n"); + sql +} + +fn speed_update_t2_numeric(count: usize) -> String { + let mut sql = String::from("BEGIN;\n"); + for row in 1..=count { + let value = deterministic_benchmark_value(row + 101); + sql.push_str(&format!("UPDATE t2 SET b = {value} WHERE a = {row};\n")); + } + sql.push_str("COMMIT;\n"); + sql +} + +fn speed_update_t2_text(count: usize) -> String { + let mut sql = String::from("BEGIN;\n"); + for row in 1..=count { + let value = deterministic_benchmark_value(row + 202); + sql.push_str(&format!( + "UPDATE t2 SET c = '{}' WHERE a = {row};\n", + synthetic_benchmark_text(value) + )); + } + sql.push_str("COMMIT;\n"); + sql +} + +fn speed_delete_and_refill_t1(count: usize) -> String { + let mut sql = String::from("BEGIN;\nDELETE FROM t1;\n"); + append_insert_rows(&mut sql, "t1", count, 303); + sql.push_str("COMMIT;\n"); + sql +} + +fn deterministic_benchmark_value(seed: usize) -> usize { + ((seed as u64) + .wrapping_mul(1_103_515_245) + .wrapping_add(12_345) + % 100_000) as usize +} + +fn synthetic_benchmark_text(value: usize) -> String { + const WORDS: &[&str] = &[ + "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", + "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety", + ]; + format!( + "{} {} {} {}", + WORDS[value % WORDS.len()], + WORDS[(value / 7) % WORDS.len()], + WORDS[(value / 97) % WORDS.len()], + value + ) +} diff --git a/tools/perf/runner/src/diagnostics.rs b/tools/perf/runner/src/diagnostics.rs new file mode 100644 index 00000000..df5bcbd9 --- /dev/null +++ b/tools/perf/runner/src/diagnostics.rs @@ -0,0 +1,802 @@ +use super::*; + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn perf_diagnose_indexed_update() -> Result<()> { + Oliphaunt::preload()?; + + let benchmark2 = read_oliphaunt_benchmark_sql("2")?; + let benchmark6 = read_oliphaunt_benchmark_sql("6")?; + let benchmark9 = read_oliphaunt_benchmark_sql("9")?; + let benchmark10 = read_oliphaunt_benchmark_sql("10")?; + let unlogged_benchmark2 = benchmark2.replace("CREATE TABLE", "CREATE UNLOGGED TABLE"); + let lookup_index_only = "CREATE INDEX i2a ON t2(a);\n"; + + let cases = vec![ + run_indexed_update_diagnostic_case( + "exact_numeric_indexed", + "Oliphaunt benchmark2 + benchmark6, then exact benchmark9 numeric updates", + &[benchmark2.as_str(), benchmark6.as_str()], + &benchmark9, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "exact_text_indexed", + "Oliphaunt benchmark2 + benchmark6, then exact benchmark10 text updates", + &[benchmark2.as_str(), benchmark6.as_str()], + &benchmark10, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "numeric_lookup_index_only", + "Oliphaunt benchmark2 + index on lookup column a only, then exact benchmark9 numeric updates", + &[benchmark2.as_str(), lookup_index_only], + &benchmark9, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "text_lookup_index_only", + "Oliphaunt benchmark2 + index on lookup column a only, then exact benchmark10 text updates", + &[benchmark2.as_str(), lookup_index_only], + &benchmark10, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "numeric_unlogged_indexed", + "Oliphaunt benchmark2 rewritten to UNLOGGED + benchmark6, then exact benchmark9 numeric updates", + &[unlogged_benchmark2.as_str(), benchmark6.as_str()], + &benchmark9, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "text_unlogged_indexed", + "Oliphaunt benchmark2 rewritten to UNLOGGED + benchmark6, then exact benchmark10 text updates", + &[unlogged_benchmark2.as_str(), benchmark6.as_str()], + &benchmark10, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "text_after_numeric_indexed", + "Oliphaunt benchmark2 + benchmark6 + exact benchmark9 numeric updates, then exact benchmark10 text updates", + &[ + benchmark2.as_str(), + benchmark6.as_str(), + benchmark9.as_str(), + ], + &benchmark10, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "text_after_numeric_vacuumed", + "Oliphaunt benchmark2 + benchmark6 + exact benchmark9 numeric updates + VACUUM t2, then exact benchmark10 text updates", + &[ + benchmark2.as_str(), + benchmark6.as_str(), + benchmark9.as_str(), + "VACUUM t2;\n", + ], + &benchmark10, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "text_after_numeric_vacuum_full", + "Oliphaunt benchmark2 + benchmark6 + exact benchmark9 numeric updates + VACUUM FULL t2, then exact benchmark10 text updates", + &[ + benchmark2.as_str(), + benchmark6.as_str(), + benchmark9.as_str(), + "VACUUM FULL t2;\n", + ], + &benchmark10, + 25_000, + )?, + run_indexed_update_diagnostic_case( + "set_based_numeric_indexed", + "Oliphaunt benchmark2 + benchmark6, then one set-based numeric update that changes every row", + &[benchmark2.as_str(), benchmark6.as_str()], + "BEGIN;\nUPDATE t2 SET b = b + 1;\nCOMMIT;\n", + 1, + )?, + run_indexed_update_diagnostic_case( + "set_based_text_indexed", + "Oliphaunt benchmark2 + benchmark6, then one set-based text update that changes every row", + &[benchmark2.as_str(), benchmark6.as_str()], + "BEGIN;\nUPDATE t2 SET c = c || ' updated';\nCOMMIT;\n", + 1, + )?, + ]; + + let report = IndexedUpdateDiagnosticReport { + source_model: "Exact Oliphaunt fixture benchmark SQL files from benchmarks/native/sql plus controlled variants.", + measurement_model: "Each case opens a fresh temporary database, runs setup outside the measured section, then records the measured update SQL and internal Rust/WASIX phase timings.", + wasix_runtime_assets: wasix_runtime_asset_report()?, + cases, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +pub(super) fn perf_diagnose_indexed_update() -> Result<()> { + legacy_oliphaunt_unavailable("perf diagnose-indexed-update") +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn perf_diagnose_speed_hotspots() -> Result<()> { + let options = SpeedDiagnosticOptions { + engine: DiagnosticEngine::WasixLegacy, + postgres_bin: default_native_postgres_tool("postgres", &["OLIPHAUNT_POSTGRES"]), + initdb_bin: default_native_postgres_tool("initdb", &["OLIPHAUNT_INITDB"]), + durability: NativeDurabilityProfile::Safe, + }; + perf_diagnose_speed_ids(&["9", "10", "11", "14"], &options) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +pub(super) fn perf_diagnose_speed_hotspots() -> Result<()> { + legacy_oliphaunt_unavailable("perf diagnose-speed-hotspots") +} + +pub(super) fn perf_diagnose_speed_cases(args: &[String]) -> Result<()> { + let mut ids: Option> = None; + let mut engine = DiagnosticEngine::WasixLegacy; + let mut postgres_bin = default_native_postgres_tool("postgres", &["OLIPHAUNT_POSTGRES"]); + let mut initdb_bin = default_native_postgres_tool("initdb", &["OLIPHAUNT_INITDB"]); + let mut durability = NativeDurabilityProfile::Safe; + let mut cursor = 0usize; + while cursor < args.len() { + let arg = &args[cursor]; + if let Some(raw_ids) = arg.strip_prefix("--ids=") { + let parsed = raw_ids + .split(',') + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if parsed.is_empty() { + bail!("--ids must contain at least one speed benchmark id"); + } + ids = Some(parsed); + } else if arg == "--ids" { + cursor += 1; + let raw_ids = args + .get(cursor) + .ok_or_else(|| anyhow!("--ids requires a value"))?; + let parsed = raw_ids + .split(',') + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if parsed.is_empty() { + bail!("--ids must contain at least one speed benchmark id"); + } + ids = Some(parsed); + } else if let Some(raw_engine) = arg.strip_prefix("--engine=") { + engine = DiagnosticEngine::parse(raw_engine)?; + } else if arg == "--engine" { + cursor += 1; + let raw_engine = args + .get(cursor) + .ok_or_else(|| anyhow!("--engine requires a value"))?; + engine = DiagnosticEngine::parse(raw_engine)?; + } else if arg == "--postgres-bin" { + cursor += 1; + postgres_bin = PathBuf::from( + args.get(cursor) + .ok_or_else(|| anyhow!("--postgres-bin requires a value"))?, + ); + } else if arg == "--initdb-bin" { + cursor += 1; + initdb_bin = PathBuf::from( + args.get(cursor) + .ok_or_else(|| anyhow!("--initdb-bin requires a value"))?, + ); + } else if arg == "--durability" { + cursor += 1; + durability = parse_native_durability( + args.get(cursor) + .ok_or_else(|| anyhow!("--durability requires a value"))?, + )?; + } else { + bail!("unknown perf diagnose-speed-cases flag: {arg}"); + } + cursor += 1; + } + + let cases = speed_cases(1.0, SpeedSqlSource::OliphauntFixture)?; + let selected_ids = match ids { + Some(ids) => ids, + None => cases.iter().map(|case| case.id.to_owned()).collect(), + }; + let selected_refs = selected_ids.iter().map(String::as_str).collect::>(); + let options = SpeedDiagnosticOptions { + engine, + postgres_bin, + initdb_bin, + durability, + }; + perf_diagnose_speed_ids(&selected_refs, &options) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DiagnosticEngine { + WasixLegacy, + NativeOliphaunt, + NativePostgres, +} + +impl DiagnosticEngine { + fn parse(value: &str) -> Result { + match value { + "wasix" | "wasix-legacy" | "legacy" => Ok(Self::WasixLegacy), + "native" | "native-liboliphaunt" | "liboliphaunt" => Ok(Self::NativeOliphaunt), + "native-postgres" | "postgres" | "pg" => Ok(Self::NativePostgres), + other => bail!( + "unknown diagnostic engine {other:?}; use wasix, native-liboliphaunt, or native-postgres" + ), + } + } + + pub(super) fn label(self) -> &'static str { + match self { + Self::WasixLegacy => "wasix_legacy", + Self::NativeOliphaunt => "native_liboliphaunt", + Self::NativePostgres => "native_postgres", + } + } +} + +pub(super) struct SpeedDiagnosticOptions { + pub(super) engine: DiagnosticEngine, + pub(super) postgres_bin: PathBuf, + pub(super) initdb_bin: PathBuf, + pub(super) durability: NativeDurabilityProfile, +} + +fn perf_diagnose_speed_ids(ids: &[&str], options: &SpeedDiagnosticOptions) -> Result<()> { + if options.engine == DiagnosticEngine::WasixLegacy { + #[cfg(feature = "legacy-oliphaunt")] + Oliphaunt::preload()?; + #[cfg(not(feature = "legacy-oliphaunt"))] + legacy_oliphaunt_unavailable("perf diagnose-speed-cases --engine wasix")?; + } else if options.engine == DiagnosticEngine::NativeOliphaunt { + ensure!( + ids.len() == 1, + "native liboliphaunt direct diagnostics can run one case per process; pass a single --ids value" + ); + } + let cases = speed_cases(1.0, SpeedSqlSource::OliphauntFixture)?; + let mut diagnostics = Vec::new(); + for id in ids { + diagnostics.push(run_speed_hotspot_diagnostic_case(&cases, id, options)?); + } + + let report = SpeedHotspotDiagnosticReport { + source_model: "Exact Oliphaunt fixture benchmark SQL files from benchmarks/native/sql.", + measurement_model: "Each case opens a fresh temporary database, runs all earlier Oliphaunt speed tests outside the measured section, then records the selected speed-test SQL. WASIX diagnostics include FS trace and internal Rust phase timings. Native direct diagnostics run one case per process. Native PostgreSQL diagnostics start a fresh temporary cluster per case and use the same database target as liboliphaunt.", + wasix_runtime_assets: (options.engine == DiagnosticEngine::WasixLegacy) + .then(wasix_runtime_asset_report) + .transpose()?, + cases: diagnostics, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn perf_diagnose_buffer_cache() -> Result<()> { + Oliphaunt::preload()?; + let cases = speed_cases(1.0, SpeedSqlSource::OliphauntFixture)?; + let diagnostics = vec![ + run_buffer_cache_diagnostic_case( + &cases, + "11", + &[ + "BEGIN", + "INSERT INTO t1 SELECT b,a,c FROM t2", + "INSERT INTO t2 SELECT b,a,c FROM t1", + "COMMIT", + ], + )?, + run_buffer_cache_diagnostic_case(&cases, "14", &["INSERT INTO t2 SELECT * FROM t1"])?, + ]; + + let report = BufferCacheDiagnosticReport { + source_model: "Exact Oliphaunt fixture benchmark SQL files from benchmarks/native/sql.", + measurement_model: "Each case opens a fresh temporary database, runs all earlier Oliphaunt speed tests outside the measured section, then executes EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) for the target data-moving statements.", + wasix_runtime_assets: wasix_runtime_asset_report()?, + cases: diagnostics, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +pub(super) fn perf_diagnose_buffer_cache() -> Result<()> { + legacy_oliphaunt_unavailable("perf diagnose-buffer-cache") +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_buffer_cache_diagnostic_case( + cases: &[SpeedCase], + id: &str, + statements: &[&str], +) -> Result { + let target_index = cases + .iter() + .position(|case| case.id == id) + .ok_or_else(|| anyhow!("unknown speed hotspot case {id}"))?; + let target = &cases[target_index]; + + let mut builder = Oliphaunt::builder().temporary(); + if let Some(config) = perf_postgres_config_from_env()? { + builder = builder.postgres_configs(config); + } + let mut db = builder + .open() + .with_context(|| format!("open buffer-cache diagnostic database for {}", target.id))?; + + let setup_started = Instant::now(); + for setup_case in &cases[..target_index] { + db.exec(&setup_case.sql, None) + .with_context(|| format!("run buffer-cache setup case {}", setup_case.id))?; + } + let setup_micros = setup_started.elapsed().as_micros(); + + let settings = exec_rows_json( + &mut db, + "SELECT current_setting('shared_buffers') AS shared_buffers, current_setting('fsync') AS fsync, current_setting('full_page_writes') AS full_page_writes, current_setting('synchronous_commit') AS synchronous_commit, current_setting('wal_buffers') AS wal_buffers, current_setting('work_mem') AS work_mem", + )?; + let relation_sizes = exec_rows_json( + &mut db, + "SELECT relname, pg_relation_size(oid)::bigint AS bytes FROM pg_class WHERE relname IN ('t1', 't2', 'i2a', 'i2b') ORDER BY relname", + )?; + + let mut explained = Vec::new(); + for statement in statements { + if matches!(*statement, "BEGIN" | "COMMIT") { + let (result, phases) = capture_phase_timings(|| { + let started = Instant::now(); + let result = db.exec(statement, None); + (result, started.elapsed()) + }); + let (result, elapsed) = result; + result.with_context(|| format!("run transaction control statement {statement}"))?; + explained.push(BufferCacheDiagnosticStatement { + sql: (*statement).to_owned(), + elapsed_micros: elapsed.as_micros(), + explain_rows: serde_json::Value::Null, + fs_trace: serde_json::Value::Null, + wal_state: buffer_cache_wal_state_json(&mut db)?, + phases, + }); + continue; + } + + reset_fs_trace(); + let explain_sql = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {statement}"); + let (result, phases) = capture_phase_timings(|| { + let started = Instant::now(); + let result = db.exec(&explain_sql, None); + (result, started.elapsed()) + }); + let (result, elapsed) = result; + let result = result.with_context(|| format!("run buffer-cache explain for {statement}"))?; + let fs_trace = serde_json::to_value(fs_trace_snapshot())?; + explained.push(BufferCacheDiagnosticStatement { + sql: (*statement).to_owned(), + elapsed_micros: elapsed.as_micros(), + explain_rows: results_to_json(result), + fs_trace, + wal_state: buffer_cache_wal_state_json(&mut db)?, + phases, + }); + } + + db.close() + .with_context(|| format!("close buffer-cache diagnostic database for {}", target.id))?; + + Ok(BufferCacheDiagnosticCase { + id: target.id.to_owned(), + label: target.label.clone(), + setup_micros, + settings, + relation_sizes, + statements: explained, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn buffer_cache_wal_state_json(db: &mut Oliphaunt) -> Result { + exec_rows_json( + db, + "SELECT pg_current_wal_insert_lsn()::text AS insert_lsn, pg_current_wal_lsn()::text AS write_lsn, pg_current_wal_flush_lsn()::text AS flush_lsn, pg_wal_lsn_diff(pg_current_wal_insert_lsn(), pg_current_wal_flush_lsn())::bigint AS insert_flush_bytes", + ) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn perf_postgres_config_from_env() -> Result>> { + let mut config = Vec::new(); + for (env_name, guc_name) in [ + ("OLIPHAUNT_WASM_PERF_WAL_BUFFERS", "wal_buffers"), + ( + "OLIPHAUNT_WASM_PERF_SYNCHRONOUS_COMMIT", + "synchronous_commit", + ), + ("OLIPHAUNT_WASM_PERF_FULL_PAGE_WRITES", "full_page_writes"), + ] { + let Ok(value) = env::var(env_name) else { + continue; + }; + ensure!( + !value.contains('\0') && !value.trim().is_empty(), + "{env_name} must be a non-empty PostgreSQL GUC value without NUL bytes" + ); + config.push((guc_name.to_owned(), value)); + } + Ok((!config.is_empty()).then_some(config)) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn exec_rows_json(db: &mut Oliphaunt, sql: &str) -> Result { + let results = db.exec(sql, None)?; + Ok(results_to_json(results)) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn results_to_json(results: Vec) -> serde_json::Value { + serde_json::Value::Array( + results + .into_iter() + .map(|result| { + serde_json::json!({ + "fields": result + .fields + .into_iter() + .map(|field| { + serde_json::json!({ + "name": field.name, + "dataTypeId": field.data_type_id, + }) + }) + .collect::>(), + "rows": result.rows, + "affectedRows": result.affected_rows, + }) + }) + .collect(), + ) +} + +fn run_speed_hotspot_diagnostic_case( + cases: &[SpeedCase], + id: &str, + options: &SpeedDiagnosticOptions, +) -> Result { + let target_index = cases + .iter() + .position(|case| case.id == id) + .ok_or_else(|| anyhow!("unknown speed hotspot case {id}"))?; + + if options.engine == DiagnosticEngine::NativeOliphaunt { + return run_native_liboliphaunt_speed_hotspot_diagnostic_case(cases, target_index, options); + } + if options.engine == DiagnosticEngine::NativePostgres { + return run_native_postgres_speed_hotspot_diagnostic_case(cases, target_index, options); + } + + #[cfg(feature = "legacy-oliphaunt")] + return run_wasix_speed_hotspot_diagnostic_case(cases, target_index, options); + + #[cfg(not(feature = "legacy-oliphaunt"))] + legacy_oliphaunt_unavailable("perf diagnose-speed-cases --engine wasix") +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_wasix_speed_hotspot_diagnostic_case( + cases: &[SpeedCase], + target_index: usize, + options: &SpeedDiagnosticOptions, +) -> Result { + let target = &cases[target_index]; + let mut db = Oliphaunt::builder() + .temporary() + .open() + .with_context(|| format!("open speed hotspot diagnostic database for {}", target.id))?; + + let setup_started = Instant::now(); + for setup_case in &cases[..target_index] { + db.exec(&setup_case.sql, None) + .with_context(|| format!("run speed hotspot setup case {}", setup_case.id))?; + } + let setup_micros = setup_started.elapsed().as_micros(); + + reset_fs_trace(); + let (result, phases) = capture_phase_timings(|| { + let started = Instant::now(); + let result = db.exec(&target.sql, None); + (result, started.elapsed()) + }); + let (result, elapsed) = result; + result.with_context(|| format!("run speed hotspot measured case {}", target.id))?; + let fs_trace = serde_json::to_value(fs_trace_snapshot())?; + db.close() + .with_context(|| format!("close speed hotspot diagnostic database for {}", target.id))?; + + Ok(SpeedHotspotDiagnosticCase { + engine: options.engine.label(), + process_model: "wasix_legacy_embedded_runtime", + id: target.id.to_owned(), + label: target.label.clone(), + open_micros: None, + connect_micros: None, + setup_micros, + elapsed_micros: elapsed.as_micros(), + operation_count: target.operation_count, + settings: serde_json::Value::Null, + observed_server_peak_rss_bytes: None, + fs_trace, + phases, + }) +} + +fn run_native_postgres_speed_hotspot_diagnostic_case( + cases: &[SpeedCase], + target_index: usize, + options: &SpeedDiagnosticOptions, +) -> Result { + let target = &cases[target_index]; + let open_started = Instant::now(); + let tuning = NativeBenchmarkTuning { + durability: options.durability, + ..NativeBenchmarkTuning::default() + }; + let native = NativePostgres::start(&options.postgres_bin, &options.initdb_bin, &tuning) + .with_context(|| { + format!( + "start native Postgres diagnostic database for {}", + target.id + ) + })?; + let open_micros = open_started.elapsed().as_micros(); + let server_pid = native.child.id(); + let mut server_rss = ProcessTreeRssSampler::new(server_pid); + server_rss.sample(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create native Postgres speed diagnostic Tokio runtime")?; + + let diagnostic = runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, &native); + let connect_started = Instant::now(); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect native Postgres speed diagnostic client")?; + let connection_task = tokio::spawn(async move { + if let Err(err) = connection.await { + eprintln!("native Postgres diagnostic connection error: {err}"); + } + }); + let connect_micros = connect_started.elapsed().as_micros(); + + let setup_started = Instant::now(); + for setup_case in &cases[..target_index] { + client + .simple_query(&setup_case.sql) + .await + .with_context(|| { + format!( + "run native Postgres diagnostic setup case {}", + setup_case.id + ) + })?; + server_rss.sample(); + } + let setup_micros = setup_started.elapsed().as_micros(); + + let started = Instant::now(); + client.simple_query(&target.sql).await.with_context(|| { + format!("run native Postgres diagnostic measured case {}", target.id) + })?; + let elapsed_micros = started.elapsed().as_micros(); + server_rss.sample(); + let settings = client + .simple_query(speed_diagnostic_settings_sql()) + .await + .map(|messages| diagnostic_settings_from_simple_query_messages(&messages)) + .unwrap_or_else(|error| serde_json::json!({ "error": error.to_string() })); + + drop(client); + connection_task.await.ok(); + + Ok::<_, anyhow::Error>(SpeedHotspotDiagnosticCase { + engine: DiagnosticEngine::NativePostgres.label(), + process_model: "native_postgres_postmaster_control", + id: target.id.to_owned(), + label: target.label.clone(), + open_micros: Some(open_micros), + connect_micros: Some(connect_micros), + setup_micros, + elapsed_micros, + operation_count: target.operation_count, + settings, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + fs_trace: serde_json::Value::Null, + phases: Vec::new(), + }) + })?; + + drop(native); + Ok(diagnostic) +} + +pub(super) fn speed_diagnostic_settings_sql() -> &'static str { + "SELECT json_build_object(\ + 'server_version', current_setting('server_version'),\ + 'shared_buffers', current_setting('shared_buffers'),\ + 'fsync', current_setting('fsync'),\ + 'full_page_writes', current_setting('full_page_writes'),\ + 'synchronous_commit', current_setting('synchronous_commit'),\ + 'wal_buffers', current_setting('wal_buffers'),\ + 'work_mem', current_setting('work_mem'),\ + 'max_worker_processes', current_setting('max_worker_processes'),\ + 'max_parallel_workers', current_setting('max_parallel_workers'),\ + 'max_parallel_workers_per_gather', current_setting('max_parallel_workers_per_gather'),\ + 'autovacuum', current_setting('autovacuum'),\ + 'data_directory', current_setting('data_directory')\ + )::text" +} + +pub(super) fn diagnostic_settings_from_protocol_response(bytes: &[u8]) -> serde_json::Value { + match first_protocol_data_row_text_values(bytes).first() { + Some(json) => serde_json::from_str(json) + .unwrap_or_else(|error| serde_json::json!({ "error": error.to_string(), "raw": json })), + None => serde_json::json!({ "error": "settings query did not return a DataRow" }), + } +} + +fn diagnostic_settings_from_simple_query_messages( + messages: &[tokio_postgres::SimpleQueryMessage], +) -> serde_json::Value { + for message in messages { + if let tokio_postgres::SimpleQueryMessage::Row(row) = message { + let Some(json) = row.get(0) else { + return serde_json::json!({ "error": "settings row had no first column" }); + }; + return serde_json::from_str(json).unwrap_or_else( + |error| serde_json::json!({ "error": error.to_string(), "raw": json }), + ); + } + } + serde_json::json!({ "error": "settings query did not return a row" }) +} + +fn first_protocol_data_row_text_values(mut bytes: &[u8]) -> Vec { + while bytes.len() >= 5 { + let tag = bytes[0]; + let len = i32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]); + if len < 4 { + break; + } + let total = 1 + len as usize; + if bytes.len() < total { + break; + } + if tag == b'D' { + return parse_protocol_data_row_text_values(&bytes[5..total]); + } + bytes = &bytes[total..]; + } + Vec::new() +} + +fn parse_protocol_data_row_text_values(payload: &[u8]) -> Vec { + if payload.len() < 2 { + return Vec::new(); + } + let columns = i16::from_be_bytes([payload[0], payload[1]]); + if columns < 0 { + return Vec::new(); + } + let mut offset = 2; + let mut values = Vec::with_capacity(columns as usize); + for _ in 0..columns { + if payload.len().saturating_sub(offset) < 4 { + return Vec::new(); + } + let len = i32::from_be_bytes([ + payload[offset], + payload[offset + 1], + payload[offset + 2], + payload[offset + 3], + ]); + offset += 4; + if len == -1 { + values.push("NULL".to_owned()); + continue; + } + if len < 0 { + return Vec::new(); + } + let len = len as usize; + if payload.len().saturating_sub(offset) < len { + return Vec::new(); + } + values.push(String::from_utf8_lossy(&payload[offset..offset + len]).into_owned()); + offset += len; + } + values +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_indexed_update_diagnostic_case( + name: &'static str, + description: &'static str, + setup_sql: &[&str], + measured_sql: &str, + operation_count: usize, +) -> Result { + let mut db = Oliphaunt::builder() + .temporary() + .open() + .with_context(|| format!("open diagnostic database for {name}"))?; + + let setup_started = Instant::now(); + for sql in setup_sql { + db.exec(sql, None) + .with_context(|| format!("run diagnostic setup for {name}"))?; + } + let setup_micros = setup_started.elapsed().as_micros(); + let stats_before = indexed_update_stats(&mut db) + .with_context(|| format!("collect diagnostic pre-stats for {name}"))?; + + reset_fs_trace(); + let (result, phases) = capture_phase_timings(|| { + let started = Instant::now(); + let result = db.exec(measured_sql, None); + (result, started.elapsed()) + }); + let (result, elapsed) = result; + result.with_context(|| format!("run diagnostic measured SQL for {name}"))?; + let fs_trace = serde_json::to_value(fs_trace_snapshot())?; + let stats_after = indexed_update_stats(&mut db) + .with_context(|| format!("collect diagnostic post-stats for {name}"))?; + db.close() + .with_context(|| format!("close diagnostic database for {name}"))?; + + Ok(IndexedUpdateDiagnosticCase { + name, + description, + setup_micros, + elapsed_micros: elapsed.as_micros(), + operation_count, + stats_before, + stats_after, + fs_trace, + phases, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn indexed_update_stats(db: &mut Oliphaunt) -> Result { + let result = db.query( + "SELECT \ + pg_relation_size('t2'::regclass)::text AS t2_size, \ + pg_relation_size('i2a'::regclass)::text AS i2a_size, \ + coalesce(pg_relation_size(to_regclass('i2b')), 0)::text AS i2b_size, \ + coalesce((SELECT n_tup_upd FROM pg_stat_user_tables WHERE relname = 't2'), 0)::text AS n_tup_upd, \ + coalesce((SELECT n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 't2'), 0)::text AS n_tup_hot_upd, \ + coalesce((SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 't2'), 0)::text AS n_dead_tup", + &[], + None, + )?; + Ok(result + .rows + .into_iter() + .next() + .unwrap_or(serde_json::Value::Null)) +} diff --git a/tools/perf/runner/src/legacy_wasix.rs b/tools/perf/runner/src/legacy_wasix.rs new file mode 100644 index 00000000..e9f912ff --- /dev/null +++ b/tools/perf/runner/src/legacy_wasix.rs @@ -0,0 +1,412 @@ +use super::*; + +#[allow(clippy::too_many_arguments)] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn capture_operation( + name: &'static str, + description: &'static str, + cache_state_before: impl Into, + process_state_before: &'static str, + root_state: &'static str, + query_state: &'static str, + workload: &'static str, + primary_latency_phase: &'static str, + operation: impl FnOnce() -> Result<()>, +) -> Result { + let started = Instant::now(); + let (result, phases) = capture_phase_timings(operation); + let elapsed_micros = started.elapsed().as_micros(); + result?; + let primary_latency_micros = phases + .iter() + .rev() + .find(|phase| phase.name == primary_latency_phase) + .map(|phase| phase.elapsed_micros) + .unwrap_or(elapsed_micros); + Ok(PerfOperation { + name, + description, + cache_state_before: cache_state_before.into(), + process_state_before, + root_state, + query_state, + workload, + primary_latency_phase, + primary_latency_micros, + elapsed_micros, + correct: true, + phases, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn oliphaunt_wasix_cache_dir() -> Result { + ProjectDirs::from("dev", "oliphaunt-wasix", "oliphaunt-wasix") + .context("could not resolve oliphaunt-wasix cache directory") + .map(|dirs| dirs.cache_dir().to_path_buf()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_direct_select_one() -> Result<()> { + let visible_started = Instant::now(); + let mut db = Oliphaunt::builder().temporary().open()?; + let result = db.query( + "SELECT $1::int4 + 1 AS answer", + &[serde_json::json!(41)], + None, + )?; + ensure_json_int(&result.rows[0]["answer"], 42)?; + record_phase_timing( + "visible.direct_open_to_first_query", + visible_started.elapsed(), + ); + measure_phase("operation.close", || db.close()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_direct_vector_query() -> Result<()> { + let visible_started = Instant::now(); + let mut db = Oliphaunt::builder() + .temporary() + .extension(extensions::VECTOR) + .open()?; + let result = db.query( + "SELECT '[1,2,3]'::vector <-> '[1,2,4]'::vector AS distance", + &[], + None, + )?; + if result.rows[0]["distance"].as_f64().is_none() { + bail!("extension-backed query did not return a float distance"); + } + record_phase_timing( + "visible.direct_open_to_first_query", + visible_started.elapsed(), + ); + measure_phase("operation.close", || db.close()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_server_sqlx_select_one() -> Result<()> { + let visible_started = Instant::now(); + let server = measure_phase("server.start", OliphauntServer::temporary_tcp)?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let started = Instant::now(); + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx to OliphauntServer")?; + record_phase_timing("client.sqlx_connect", started.elapsed()); + let started = Instant::now(); + let row = sqlx::query("SELECT $1::int4 + 1 AS answer") + .bind(41_i32) + .fetch_one(&mut conn) + .await + .context("run first SQLx query")?; + record_phase_timing("client.sqlx_first_query", started.elapsed()); + let answer: i32 = row.try_get("answer").context("read SQLx answer")?; + if answer != 42 { + bail!("SQLx server query returned {answer}, expected 42"); + } + conn.close().await.context("close SQLx connection")?; + Ok::<_, anyhow::Error>(()) + })?; + record_phase_timing( + "visible.server_start_to_first_sqlx_query", + visible_started.elapsed(), + ); + measure_phase("operation.shutdown", || server.shutdown()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_direct_repeated_selects(iterations: usize) -> Result<()> { + let mut db = Oliphaunt::builder().temporary().open()?; + run_direct_scalar_query(&mut db, 41)?; + let started = Instant::now(); + for value in 0..iterations { + run_direct_scalar_query(&mut db, value as i32)?; + } + record_total_and_average( + "warm.direct_repeated_scalar_queries.total", + "warm.direct_repeated_scalar_queries.avg", + started.elapsed(), + iterations, + ); + measure_phase("operation.close", || db.close()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_direct_transaction_batch(iterations: usize) -> Result<()> { + let mut db = Oliphaunt::builder().temporary().open()?; + run_direct_scalar_query(&mut db, 41)?; + let started = Instant::now(); + db.transaction(|tx| { + for value in 0..iterations { + let result = tx.query( + "SELECT $1::int4 + 1 AS answer", + &[serde_json::json!(value as i32)], + None, + )?; + ensure_json_int(&result.rows[0]["answer"], value as i64 + 1)?; + } + Ok(()) + })?; + record_total_and_average( + "warm.direct_transaction_batch.total", + "warm.direct_transaction_batch.avg", + started.elapsed(), + iterations, + ); + measure_phase("operation.close", || db.close()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_direct_repeated_vector_queries(iterations: usize) -> Result<()> { + let mut db = Oliphaunt::builder() + .temporary() + .extension(extensions::VECTOR) + .open()?; + run_direct_vector_distance_query(&mut db)?; + let started = Instant::now(); + for _ in 0..iterations { + run_direct_vector_distance_query(&mut db)?; + } + record_total_and_average( + "warm.direct_repeated_vector_queries.total", + "warm.direct_repeated_vector_queries.avg", + started.elapsed(), + iterations, + ); + measure_phase("operation.close", || db.close()) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_direct_scalar_query(db: &mut Oliphaunt, value: i32) -> Result<()> { + let result = db.query( + "SELECT $1::int4 + 1 AS answer", + &[serde_json::json!(value)], + None, + )?; + ensure_json_int(&result.rows[0]["answer"], value as i64 + 1) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_direct_vector_distance_query(db: &mut Oliphaunt) -> Result<()> { + let result = db.query( + "SELECT '[1,2,3]'::vector <-> '[1,2,4]'::vector AS distance", + &[], + None, + )?; + if result.rows[0]["distance"].as_f64().is_none() { + bail!("extension-backed query did not return a float distance"); + } + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_server_sqlx_single_connection_repeated_queries(iterations: usize) -> Result<()> { + let server = measure_phase("server.start", OliphauntServer::temporary_tcp)?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx to OliphauntServer")?; + run_sqlx_scalar_query(&mut conn, 41).await?; + let started = Instant::now(); + for value in 0..iterations { + run_sqlx_scalar_query(&mut conn, value as i32).await?; + } + record_total_and_average( + "warm.server_sqlx_single_connection_repeated_queries.total", + "warm.server_sqlx_single_connection_repeated_queries.avg", + started.elapsed(), + iterations, + ); + conn.close().await.context("close SQLx connection")?; + Ok::<_, anyhow::Error>(()) + })?; + measure_phase("operation.shutdown", || server.shutdown()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_server_sqlx_repeated_connections(iterations: usize) -> Result<()> { + let server = measure_phase("server.start", OliphauntServer::temporary_tcp)?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let started = Instant::now(); + for value in 0..iterations { + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx to OliphauntServer")?; + run_sqlx_scalar_query(&mut conn, value as i32).await?; + conn.close().await.context("close SQLx connection")?; + } + record_total_and_average( + "warm.server_sqlx_repeated_connections.total", + "warm.server_sqlx_repeated_connections.avg", + started.elapsed(), + iterations, + ); + Ok::<_, anyhow::Error>(()) + })?; + measure_phase("operation.shutdown", || server.shutdown()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_server_sqlx_vector_single_connection_repeated_queries( + iterations: usize, +) -> Result<()> { + let server = measure_phase("server.start", || { + OliphauntServer::builder() + .temporary() + .extension(extensions::VECTOR) + .start() + })?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx to extension-enabled OliphauntServer")?; + run_sqlx_vector_query(&mut conn).await?; + let started = Instant::now(); + for _ in 0..iterations { + run_sqlx_vector_query(&mut conn).await?; + } + record_total_and_average( + "warm.server_sqlx_vector_single_connection_repeated_queries.total", + "warm.server_sqlx_vector_single_connection_repeated_queries.avg", + started.elapsed(), + iterations, + ); + conn.close().await.context("close SQLx connection")?; + Ok::<_, anyhow::Error>(()) + })?; + measure_phase("operation.shutdown", || server.shutdown()) +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn run_server_tokio_postgres_single_connection_repeated_queries( + iterations: usize, +) -> Result<()> { + let server = measure_phase("server.start", OliphauntServer::temporary_tcp)?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls) + .await + .context("connect tokio-postgres to OliphauntServer")?; + let connection_handle = tokio::spawn(connection); + run_tokio_postgres_scalar_query(&client, 41).await?; + let started = Instant::now(); + for value in 0..iterations { + run_tokio_postgres_scalar_query(&client, value as i32).await?; + } + record_total_and_average( + "warm.server_tokio_postgres_single_connection_repeated_queries.total", + "warm.server_tokio_postgres_single_connection_repeated_queries.avg", + started.elapsed(), + iterations, + ); + drop(client); + connection_handle + .await + .context("join tokio-postgres connection task")? + .context("tokio-postgres connection task")?; + Ok::<_, anyhow::Error>(()) + })?; + measure_phase("operation.shutdown", || server.shutdown()) +} + +#[cfg(feature = "legacy-oliphaunt")] +async fn run_sqlx_scalar_query(conn: &mut sqlx::PgConnection, value: i32) -> Result<()> { + let row = sqlx::query("SELECT $1::int4 + 1 AS answer") + .bind(value) + .fetch_one(conn) + .await + .context("run SQLx scalar query")?; + let answer: i32 = row.try_get("answer").context("read SQLx answer")?; + ensure!(answer == value + 1, "SQLx query returned {answer}"); + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +async fn run_sqlx_vector_query(conn: &mut sqlx::PgConnection) -> Result<()> { + let row = sqlx::query("SELECT '[1,2,3]'::vector <-> '[1,2,4]'::vector AS distance") + .fetch_one(conn) + .await + .context("run SQLx vector query")?; + let distance: f64 = row.try_get("distance").context("read vector distance")?; + ensure!(distance == 1.0, "SQLx vector query returned {distance}"); + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +async fn run_tokio_postgres_scalar_query( + client: &tokio_postgres::Client, + value: i32, +) -> Result<()> { + let row = client + .query_one("SELECT $1::int4 + 1 AS answer", &[&value]) + .await + .context("run tokio-postgres scalar query")?; + let answer: i32 = row.get("answer"); + ensure!( + answer == value + 1, + "tokio-postgres query returned {answer}" + ); + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn record_total_and_average( + total_name: &'static str, + average_name: &'static str, + elapsed: Duration, + iterations: usize, +) { + record_phase_timing(total_name, elapsed); + let average = elapsed.as_micros() / iterations as u128; + record_phase_timing( + average_name, + Duration::from_micros(average.try_into().unwrap_or(u64::MAX)), + ); +} + +#[cfg(feature = "legacy-oliphaunt")] +fn ensure_json_int(value: &serde_json::Value, expected: i64) -> Result<()> { + let Some(actual) = value.as_i64() else { + bail!("expected integer JSON value {expected}, got {value}"); + }; + if actual != expected { + bail!("expected integer JSON value {expected}, got {actual}"); + } + Ok(()) +} diff --git a/tools/perf/runner/src/main.rs b/tools/perf/runner/src/main.rs new file mode 100644 index 00000000..eb94d6f4 --- /dev/null +++ b/tools/perf/runner/src/main.rs @@ -0,0 +1,1670 @@ +use std::env; +use std::fs; +use std::io::{BufReader, Cursor, Read, Write}; +use std::net::TcpListener; +#[cfg(not(unix))] +use std::net::TcpStream; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +#[cfg(feature = "legacy-oliphaunt")] +use directories::ProjectDirs; +use futures_util::future::try_join_all; +use oliphaunt::{ + DurabilityProfile as NativeDurabilityProfile, PostgresStartupGuc, RuntimeFootprintProfile, +}; +#[cfg(feature = "legacy-oliphaunt")] +use oliphaunt_wasix::{ + ExecProtocolOptions, Oliphaunt, OliphauntServer, PhaseTiming, ProtocolStatsSnapshot, + capture_phase_timings, disable_protocol_stats, extensions, fs_trace_snapshot, measure_phase, + protocol_stats_snapshot, record_phase_timing, reset_fs_trace, reset_protocol_stats, +}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "legacy-oliphaunt")] +use sqlx::Row; +use sqlx::postgres::{PgConnectOptions, PgSslMode}; +use sqlx::{Connection, Executor}; +use tar::{Archive, Builder as TarBuilder, Header as TarHeader}; + +use crate::process_rss::ProcessTreeRssSampler; + +mod benchmarks; +mod diagnostics; +#[cfg(feature = "legacy-oliphaunt")] +mod legacy_wasix; +mod native_liboliphaunt; +mod native_postgres; +mod prepared_updates; +mod process_rss; +mod report; +mod shared; +mod sqlite; + +use benchmarks::*; +use diagnostics::*; +#[cfg(feature = "legacy-oliphaunt")] +use legacy_wasix::*; +use native_liboliphaunt::*; +use native_postgres::*; +use prepared_updates::*; +use report::*; +use shared::*; +use sqlite::*; + +const NATIVE_BENCHMARK_DATABASE: &str = "template1"; +const OLIPHAUNT_BENCHMARK_SQL_DIR: &str = "benchmarks/native/sql"; + +#[cfg(not(feature = "legacy-oliphaunt"))] +type PhaseTiming = serde_json::Value; + +#[cfg(not(feature = "legacy-oliphaunt"))] +type ProtocolStatsSnapshot = serde_json::Value; + +fn main() -> Result<()> { + perf(env::args().skip(1).collect()) +} + +pub(crate) fn perf(args: Vec) -> Result<()> { + match args.first().map(String::as_str) { + Some("cold") => perf_cold(&args[1..]), + Some("warm") => perf_warm(&args[1..]), + Some("bench") => perf_bench(&args[1..]), + Some("prepared-updates") => perf_prepared_updates(&args[1..]), + Some("diagnose-indexed-update") => perf_diagnose_indexed_update(), + Some("diagnose-speed-hotspots") => perf_diagnose_speed_hotspots(), + Some("diagnose-speed-cases") => perf_diagnose_speed_cases(&args[1..]), + Some("diagnose-buffer-cache") => perf_diagnose_buffer_cache(), + Some("native-postgres") => perf_native_postgres(&args[1..]), + Some("native-liboliphaunt") => perf_native_liboliphaunt(&args[1..]), + Some("native-liboliphaunt-prepared-child") => { + perf_native_liboliphaunt_prepared_child(&args[1..]) + } + Some("native-liboliphaunt-restore-verify-child") => { + perf_native_liboliphaunt_restore_verify_child(&args[1..]) + } + Some("sqlite") => perf_sqlite(&args[1..]), + Some("legacy-wasix-sqlx") => perf_legacy_wasix_sqlx(&args[1..]), + Some("smoke") => run( + "cargo", + &[ + "test", + "--workspace", + "--locked", + "preload", + "--", + "--nocapture", + ], + ), + Some(other) => bail!("unknown perf subcommand: {other}"), + None => bail!( + "usage: cargo run -p oliphaunt-perf -- [--reset-cache]" + ), + } +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +fn legacy_oliphaunt_unavailable(command: &str) -> Result { + bail!( + "{command} requires oliphaunt-perf feature `legacy-oliphaunt`; enable it explicitly or avoid this legacy WASIX/Oliphaunt control command" + ) +} + +fn now_micros() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_micros()) +} + +fn run(command: &str, args: &[&str]) -> Result<()> { + let mut command = command_for_host(command); + command.args(args); + run_command(&mut command) +} + +fn command_for_host(command: &str) -> Command { + if cfg!(windows) + && Path::new(command) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("sh")) + { + let mut shell = Command::new(windows_bash_path()); + shell.arg("--noprofile").arg("--norc"); + shell.arg(command); + return shell; + } + Command::new(command) +} + +#[cfg(windows)] +fn windows_bash_path() -> PathBuf { + for path in [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + ] { + let path = PathBuf::from(path); + if path.is_file() { + return path; + } + } + PathBuf::from("bash") +} + +#[cfg(not(windows))] +fn windows_bash_path() -> &'static str { + "bash" +} + +fn run_command(command: &mut Command) -> Result<()> { + let status = command + .status() + .map_err(|err| anyhow!("failed to spawn command: {err}"))?; + if !status.success() { + bail!("command failed with {status}"); + } + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn perf_cold(args: &[String]) -> Result<()> { + let reset_cache = args.iter().any(|arg| arg == "--reset-cache"); + for arg in args { + if arg != "--reset-cache" { + bail!("unknown perf cold flag: {arg}"); + } + } + + let cache_dir = oliphaunt_wasix_cache_dir()?; + let cache_state_at_start = if reset_cache { + if cache_dir.exists() { + fs::remove_dir_all(&cache_dir) + .with_context(|| format!("reset oliphaunt-wasix cache {}", cache_dir.display()))?; + } + "cold_absent_after_reset" + } else if cache_dir.exists() { + "existing" + } else { + "cold_absent" + }; + + let mut operations = Vec::new(); + + operations.push(capture_operation( + "process_cold_runtime_preload", + "First explicit runtime preload in this xtask process. With --reset-cache, this includes first-install cache bootstrap.", + cache_state_at_start, + "cold", + "internal_preload_temp_root", + "not_a_query", + "runtime_preload", + "operation.total", + Oliphaunt::preload, + )?); + operations.push(capture_operation( + "process_warm_new_temp_direct_first_query", + "First direct query for a newly opened temporary database after runtime preload in the same process.", + "warm_after_runtime_preload", + "warm", + "new_temporary_root", + "first_query_after_open", + "direct_select_with_bind", + "visible.direct_open_to_first_query", + run_direct_select_one, + )?); + operations.push(capture_operation( + "process_warm_second_new_temp_direct_first_query", + "Repeat first direct query for a second newly opened temporary database in the same warm process.", + "warm_after_runtime_preload", + "warm", + "second_new_temporary_root", + "first_query_after_open", + "direct_select_with_bind", + "visible.direct_open_to_first_query", + run_direct_select_one, + )?); + operations.push(capture_operation( + "process_warm_vector_preload", + "Explicit preload of the representative extension artifact after runtime preload.", + "warm_after_runtime_preload", + "warm", + "internal_preload_temp_root", + "not_a_query", + "vector_extension_preload", + "operation.total", + || Oliphaunt::preload_extensions([extensions::VECTOR]), + )?); + operations.push(capture_operation( + "process_warm_new_temp_direct_vector_first_query", + "First vector-backed direct query for a newly opened temporary database after vector preload.", + "warm_after_vector_preload", + "warm", + "new_temporary_root_with_requested_vector", + "first_extension_backed_query_after_open", + "direct_vector_distance", + "visible.direct_open_to_first_query", + run_direct_vector_query, + )?); + operations.push(capture_operation( + "process_warm_new_temp_server_tokio_postgres_first_query", + "First tokio-postgres query against a new temporary OliphauntServer in the warm process.", + "warm_after_runtime_preload", + "warm", + "new_temporary_server_root", + "first_client_query_after_server_start", + "tokio_postgres_select_with_bind", + "visible.server_start_to_first_tokio_postgres_query", + || { + let visible_started = Instant::now(); + let server = measure_phase("server.start", OliphauntServer::temporary_tcp)?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let started = Instant::now(); + let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls) + .await + .context("connect tokio-postgres to OliphauntServer")?; + record_phase_timing("client.tokio_postgres_connect", started.elapsed()); + let connection_handle = tokio::spawn(connection); + let started = Instant::now(); + let row = client + .query_one("SELECT $1::int4 + 1 AS answer", &[&41_i32]) + .await + .context("run first tokio-postgres query")?; + record_phase_timing("client.tokio_postgres_first_query", started.elapsed()); + let answer: i32 = row.get("answer"); + if answer != 42 { + bail!("server query returned {answer}, expected 42"); + } + drop(client); + connection_handle + .await + .context("join tokio-postgres connection task")? + .context("tokio-postgres connection task")?; + Ok::<_, anyhow::Error>(()) + })?; + record_phase_timing( + "visible.server_start_to_first_tokio_postgres_query", + visible_started.elapsed(), + ); + measure_phase("operation.shutdown", || server.shutdown()) + }, + )?); + operations.push(capture_operation( + "process_warm_new_temp_server_sqlx_first_query", + "First SQLx query against a new temporary OliphauntServer in the warm process.", + "warm_after_runtime_preload", + "warm", + "new_temporary_server_root", + "first_client_query_after_server_start", + "sqlx_select_with_bind", + "visible.server_start_to_first_sqlx_query", + run_server_sqlx_select_one, + )?); + operations.push(capture_operation( + "process_warm_new_temp_server_sqlx_vector_first_query", + "First vector-backed SQLx query against a new extension-enabled temporary OliphauntServer.", + "warm_after_vector_preload", + "warm", + "new_temporary_server_root_with_requested_vector", + "first_extension_backed_client_query_after_server_start", + "sqlx_vector_distance", + "visible.server_start_to_first_sqlx_query", + || { + let visible_started = Instant::now(); + let server = measure_phase("server.start", || { + OliphauntServer::builder() + .temporary() + .extension(extensions::VECTOR) + .start() + })?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let started = Instant::now(); + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx to extension-enabled OliphauntServer")?; + record_phase_timing("client.sqlx_extension_connect", started.elapsed()); + let started = Instant::now(); + let row = sqlx::query("SELECT '[1,2,3]'::vector <-> '[1,2,4]'::vector AS distance") + .fetch_one(&mut conn) + .await + .context("run first SQLx extension-backed query")?; + record_phase_timing("client.sqlx_extension_first_query", started.elapsed()); + let distance: f64 = row.try_get("distance").context("read vector distance")?; + if distance != 1.0 { + bail!("SQLx vector query returned {distance}, expected 1.0"); + } + conn.close().await.context("close SQLx connection")?; + Ok::<_, anyhow::Error>(()) + })?; + record_phase_timing( + "visible.server_start_to_first_sqlx_query", + visible_started.elapsed(), + ); + measure_phase("operation.shutdown", || server.shutdown()) + }, + )?); + let preinstalled_extension_root = unique_perf_root("server-sqlx-preinstalled-extension")?; + { + let mut db = Oliphaunt::builder() + .path(&preinstalled_extension_root) + .extension(extensions::VECTOR) + .open() + .context("prepare preinstalled extension perf root")?; + db.close() + .context("close preinstalled extension perf root")?; + } + operations.push(capture_operation( + "process_warm_existing_persistent_server_sqlx_vector_first_query", + "Diagnostic first vector-backed SQLx query against an existing persistent root where vector was already installed.", + "warm_after_vector_preload", + "warm", + "existing_persistent_root_with_preinstalled_vector", + "first_client_query_after_server_start", + "sqlx_vector_distance", + "visible.server_start_to_first_sqlx_query", + || { + let visible_started = Instant::now(); + let server = measure_phase("server.start", || { + OliphauntServer::builder() + .path(&preinstalled_extension_root) + .extension(extensions::VECTOR) + .start() + })?; + let uri = server.database_url(); + let runtime = measure_phase("client.tokio_runtime_create", || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create perf tokio runtime") + })?; + runtime.block_on(async move { + let started = Instant::now(); + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx to preinstalled-extension OliphauntServer")?; + record_phase_timing("client.sqlx_extension_connect", started.elapsed()); + let started = Instant::now(); + let row = sqlx::query("SELECT '[1,2,3]'::vector <-> '[1,2,4]'::vector AS distance") + .fetch_one(&mut conn) + .await + .context("run first SQLx preinstalled-extension query")?; + record_phase_timing("client.sqlx_extension_first_query", started.elapsed()); + let distance: f64 = row.try_get("distance").context("read vector distance")?; + if distance != 1.0 { + bail!("SQLx vector query returned {distance}, expected 1.0"); + } + conn.close().await.context("close SQLx connection")?; + Ok::<_, anyhow::Error>(()) + })?; + record_phase_timing( + "visible.server_start_to_first_sqlx_query", + visible_started.elapsed(), + ); + measure_phase("operation.shutdown", || server.shutdown()) + }, + )?); + let _ = fs::remove_dir_all(&preinstalled_extension_root); + + let report = ColdPerfReport { + wasmer_version: "7.2.0-alpha.3", + wasmer_wasix_version: "0.702.0-alpha.3", + wasix_runtime_assets: wasix_runtime_asset_report()?, + cache_reset_requested: reset_cache, + cache_dir: cache_dir.display().to_string(), + cache_state_at_start, + measurement_model: "Operations run sequentially in one xtask process. 'Warm' means process/runtime/module caches have been warmed by earlier operations; 'first query' means first query after opening that operation's new database root or server.", + operations, + experiments: vec![ + ColdPerfExperiment { + name: "wasmer_webassembly_exceptions", + status: "production_invariant", + implementation_risk: "medium", + artifact_size_impact: "required", + notes: "the runtime and WASIX build require WebAssembly exception handling; no non-EH fallback or opt-out is supported", + }, + ColdPerfExperiment { + name: "wasix_dynamic_linking_flags", + status: "production_invariant", + implementation_risk: "medium", + artifact_size_impact: "required", + notes: "main modules use dynamic-main flags and extension/tool side modules use PIC shared-module flags from the same configured tree", + }, + ColdPerfExperiment { + name: "process_wide_headless_engine_and_module_cache", + status: "implemented", + implementation_risk: "low", + artifact_size_impact: "none", + notes: "main and side modules are cached by artifact hash inside the process", + }, + ColdPerfExperiment { + name: "persistent_raw_aot_cache", + status: "implemented", + implementation_risk: "low", + artifact_size_impact: "none", + notes: "compressed AOT artifacts expand once to a manifest raw-SHA-keyed cache path; subsequent processes use fast receipt verification before mmap/native deserialization; full content hashing is only enabled with OLIPHAUNT_WASM_AOT_VERIFY=full", + }, + ColdPerfExperiment { + name: "mmap_native_deserialization", + status: "mainline_measured_in_this_run", + implementation_risk: "medium", + artifact_size_impact: "none", + notes: "runtime uses Wasmer native mmapped deserialization as the only production AOT loading path", + }, + ColdPerfExperiment { + name: "shared_wasix_runtime_and_module_cache", + status: "implemented", + implementation_risk: "medium", + artifact_size_impact: "none", + notes: "runtime infrastructure is shared while Store, Instance, WASI env, mounts, and protocol state remain per database", + }, + ColdPerfExperiment { + name: "template_clone_hardlink_reflink_copy", + status: "implemented", + implementation_risk: "medium", + artifact_size_impact: "none", + notes: "immutable runtime files hardlink first; mutable PGDATA uses archive install by default, with per-file reflink available through OLIPHAUNT_WASM_TEMPLATE_REFLINK", + }, + ColdPerfExperiment { + name: "eager_pgdata_template_overlay", + status: "mainline_measured_in_this_run", + implementation_risk: "medium", + artifact_size_impact: "none", + notes: "mounts the cached initialized PGDATA template as lower /base and copies individual files into the per-instance upper only before mutating opens", + }, + ColdPerfExperiment { + name: "mountfs_overlay_runtime_root", + status: "mainline_measured_in_this_run", + implementation_risk: "medium", + artifact_size_impact: "none", + notes: "serves immutable runtime files from the shared cached lower root and keeps only mutable state plus requested extension assets in the per-root upper root", + }, + ColdPerfExperiment { + name: "snapshot_journaling", + status: "scouted_not_promoted", + implementation_risk: "high", + artifact_size_impact: "unknown", + notes: "Wasmer 7.2 exposes WASIX journal and process snapshot APIs, while StoreSnapshot captures store globals only; promotion requires an isolated restore correctness suite for direct protocol, server mode, extensions, PGDATA, fd state, and mount state", + }, + ColdPerfExperiment { + name: "asyncify", + status: "production_excluded", + implementation_risk: "high", + artifact_size_impact: "unknown", + notes: "not used in production artifacts; only an isolated snapshot/journaling experiment may enable it if Wasm EH plus WASIX journaling cannot support the required control-flow restore path", + }, + ], + }; + + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +fn perf_cold(args: &[String]) -> Result<()> { + let _ = args; + legacy_oliphaunt_unavailable("perf cold") +} + +#[cfg(feature = "legacy-oliphaunt")] +fn perf_warm(args: &[String]) -> Result<()> { + let mut query_iterations = 100usize; + let mut connection_iterations = 20usize; + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--iterations" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--iterations requires a value"))?; + query_iterations = value + .parse() + .with_context(|| format!("parse --iterations value {value:?}"))?; + } + "--connections" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--connections requires a value"))?; + connection_iterations = value + .parse() + .with_context(|| format!("parse --connections value {value:?}"))?; + } + other => bail!("unknown perf warm flag: {other}"), + } + cursor += 1; + } + if query_iterations == 0 { + bail!("--iterations must be greater than zero"); + } + if connection_iterations == 0 { + bail!("--connections must be greater than zero"); + } + + let mut operations = Vec::new(); + operations.push(capture_operation( + "warm_process_preload", + "Warm runtime and representative extension artifacts before steady-state workloads.", + "existing", + "warm", + "process_cache", + "not_a_query", + "runtime_and_extension_preload", + "operation.total", + || { + Oliphaunt::preload()?; + Oliphaunt::preload_extensions([extensions::VECTOR]) + }, + )?); + operations.push(capture_operation( + "warm_direct_repeated_scalar_queries", + "Repeated direct API scalar extended-protocol queries on one already-open temporary database.", + "warm_after_preload", + "warm", + "long_lived_temporary_direct_root", + "steady_state_queries", + "direct_select_with_bind", + "warm.direct_repeated_scalar_queries.total", + || run_direct_repeated_selects(query_iterations), + )?); + operations.push(capture_operation( + "warm_direct_transaction_batch", + "Repeated direct API scalar queries inside one transaction on an already-open temporary database.", + "warm_after_preload", + "warm", + "long_lived_temporary_direct_root", + "steady_state_transaction_batch", + "direct_transaction_select_with_bind", + "warm.direct_transaction_batch.total", + || run_direct_transaction_batch(query_iterations), + )?); + operations.push(capture_operation( + "warm_direct_repeated_vector_queries", + "Repeated direct API extension-backed queries on one already-open extension-enabled temporary database.", + "warm_after_vector_preload", + "warm", + "long_lived_temporary_direct_root_with_vector", + "steady_state_extension_queries", + "direct_vector_distance", + "warm.direct_repeated_vector_queries.total", + || run_direct_repeated_vector_queries(query_iterations), + )?); + operations.push(capture_operation( + "warm_server_sqlx_single_connection_repeated_queries", + "Repeated SQLx queries over one connection to one long-lived temporary server.", + "warm_after_preload", + "warm", + "long_lived_temporary_server_root", + "steady_state_single_connection_queries", + "sqlx_select_with_bind", + "warm.server_sqlx_single_connection_repeated_queries.total", + || run_server_sqlx_single_connection_repeated_queries(query_iterations), + )?); + operations.push(capture_operation( + "warm_server_sqlx_repeated_connections", + "Repeated SQLx connect-query-close cycles against one long-lived temporary server.", + "warm_after_preload", + "warm", + "long_lived_temporary_server_root", + "steady_state_repeated_connections", + "sqlx_connect_query_close", + "warm.server_sqlx_repeated_connections.total", + || run_server_sqlx_repeated_connections(connection_iterations), + )?); + operations.push(capture_operation( + "warm_server_sqlx_vector_single_connection_repeated_queries", + "Repeated SQLx extension-backed queries over one connection to one long-lived extension-enabled temporary server.", + "warm_after_vector_preload", + "warm", + "long_lived_temporary_server_root_with_vector", + "steady_state_extension_queries", + "sqlx_vector_distance", + "warm.server_sqlx_vector_single_connection_repeated_queries.total", + || run_server_sqlx_vector_single_connection_repeated_queries(query_iterations), + )?); + operations.push(capture_operation( + "warm_server_tokio_postgres_single_connection_repeated_queries", + "Repeated tokio-postgres queries over one connection to one long-lived temporary server.", + "warm_after_preload", + "warm", + "long_lived_temporary_server_root", + "steady_state_single_connection_queries", + "tokio_postgres_select_with_bind", + "warm.server_tokio_postgres_single_connection_repeated_queries.total", + || run_server_tokio_postgres_single_connection_repeated_queries(query_iterations), + )?); + + let report = WarmPerfReport { + wasmer_version: "7.2.0-alpha.3", + wasmer_wasix_version: "0.702.0-alpha.3", + wasix_runtime_assets: wasix_runtime_asset_report()?, + query_iterations, + connection_iterations, + measurement_model: "Operations run after explicit process preload. Each workload opens one database/server, performs one warmup query where relevant, then records only the repeated steady-state section as the primary latency phase. Open and shutdown phases remain in the phase list for context.", + operations, + }; + + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +fn perf_warm(args: &[String]) -> Result<()> { + let _ = args; + legacy_oliphaunt_unavailable("perf warm") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BenchmarkSuiteFilter { + All, + Rtt, + Speed, + Streaming, + PreparedUpdates, + BackupRestore, +} + +#[cfg(feature = "legacy-oliphaunt")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BenchmarkModeFilter { + All, + Direct, + ServerSqlx, + ServerTokioPostgresSimple, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativePostgresClientMode { + TokioPostgresSimple, + Sqlx, +} + +impl BenchmarkSuiteFilter { + fn includes(self, suite: &'static str) -> bool { + matches!( + (self, suite), + (Self::All, "rtt" | "speed") + | (Self::Rtt, "rtt") + | (Self::Speed, "speed") + | (Self::Streaming, "streaming") + | (Self::PreparedUpdates, "prepared-updates") + | (Self::BackupRestore, "backup-restore") + ) + } +} + +#[cfg(feature = "legacy-oliphaunt")] +impl BenchmarkModeFilter { + fn includes(self, mode: &'static str) -> bool { + matches!( + (self, mode), + (Self::All, _) + | (Self::Direct, "direct") + | (Self::ServerSqlx, "server_sqlx") + | ( + Self::ServerTokioPostgresSimple, + "server_tokio_postgres_simple" + ) + ) + } +} + +#[cfg(feature = "legacy-oliphaunt")] +fn perf_bench(args: &[String]) -> Result<()> { + let mut suite = BenchmarkSuiteFilter::All; + let mut mode = BenchmarkModeFilter::All; + let mut rtt_iterations = 100usize; + let mut speed_scale = 1.0f64; + let mut speed_sql_source = SpeedSqlSource::Generated; + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--suite" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--suite requires a value"))?; + suite = match value.as_str() { + "all" => BenchmarkSuiteFilter::All, + "rtt" | "roundtrip" | "round-trip" => BenchmarkSuiteFilter::Rtt, + "speed" | "sqlite" | "sqlite-suite" => BenchmarkSuiteFilter::Speed, + other => bail!("unknown --suite value {other:?}; use all, rtt, or speed"), + }; + } + "--mode" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--mode requires a value"))?; + mode = match value.as_str() { + "all" => BenchmarkModeFilter::All, + "direct" => BenchmarkModeFilter::Direct, + "server-sqlx" | "server_sqlx" | "sqlx" | "server" => { + BenchmarkModeFilter::ServerSqlx + } + "server-tokio-postgres-simple" + | "server_tokio_postgres_simple" + | "tokio-postgres-simple" + | "tokio_postgres_simple" + | "tokio-postgres" + | "tokio_postgres" => BenchmarkModeFilter::ServerTokioPostgresSimple, + other => { + bail!( + "unknown --mode value {other:?}; use all, direct, server-sqlx, or server-tokio-postgres-simple" + ) + } + }; + } + "--iterations" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--iterations requires a value"))?; + rtt_iterations = value + .parse() + .with_context(|| format!("parse --iterations value {value:?}"))?; + } + "--scale" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--scale requires a value"))?; + speed_scale = value + .parse() + .with_context(|| format!("parse --scale value {value:?}"))?; + } + "--speed-source" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--speed-source requires a value"))?; + speed_sql_source = match value.as_str() { + "generated" | "local" => SpeedSqlSource::Generated, + "oliphaunt" | "fixture" | "oliphaunt-fixture" => { + SpeedSqlSource::OliphauntFixture + } + other => { + bail!("unknown --speed-source value {other:?}; use generated or oliphaunt") + } + }; + } + other => bail!("unknown perf bench flag: {other}"), + } + cursor += 1; + } + if rtt_iterations == 0 { + bail!("--iterations must be greater than zero"); + } + if !speed_scale.is_finite() || speed_scale <= 0.0 { + bail!("--scale must be a finite positive number"); + } + if speed_sql_source == SpeedSqlSource::OliphauntFixture + && (speed_scale - 1.0).abs() > f64::EPSILON + { + bail!("--speed-source oliphaunt uses fixed upstream SQL files and requires --scale 1"); + } + + let preload_started = Instant::now(); + Oliphaunt::preload()?; + let preload_micros = preload_started.elapsed().as_micros(); + + let mut runs = Vec::new(); + if suite.includes("rtt") && mode.includes("direct") { + runs.push(run_rtt_direct_benchmark(rtt_iterations)?); + } + if suite.includes("rtt") && mode.includes("server_sqlx") { + runs.push(run_rtt_server_sqlx_benchmark(rtt_iterations)?); + } + if suite.includes("rtt") && mode.includes("server_tokio_postgres_simple") { + runs.push(run_rtt_server_tokio_postgres_simple_benchmark( + rtt_iterations, + )?); + } + if suite.includes("speed") && mode.includes("direct") { + runs.push(run_speed_direct_benchmark(speed_scale, speed_sql_source)?); + } + if suite.includes("speed") && mode.includes("server_sqlx") { + runs.push(run_speed_server_sqlx_benchmark( + speed_scale, + speed_sql_source, + )?); + } + ensure!( + !runs.is_empty(), + "selected benchmark filter produced no runs" + ); + + let report = BenchmarkReport { + wasmer_version: "7.2.0-alpha.3", + wasmer_wasix_version: "0.702.0-alpha.3", + wasix_runtime_assets: Some(wasix_runtime_asset_report()?), + source_model: speed_sql_source.source_model(), + measurement_model: "Database/server open and setup are measured separately. Test timings start immediately before each SQL execution call and end after that execution completes. RTT tests sort samples, discard the lowest and highest 10% when possible, and report trimmed averages in microseconds.", + native_tuning: None, + rtt_iterations, + speed_scale, + preload_micros, + runs, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +fn perf_bench(args: &[String]) -> Result<()> { + let _ = args; + legacy_oliphaunt_unavailable("perf bench") +} + +fn default_native_postgres_tool(tool: &str, env_names: &[&str]) -> PathBuf { + for env_name in env_names { + if let Ok(value) = env::var(env_name) + && !value.is_empty() + { + return PathBuf::from(value); + } + } + if let Ok(root) = env::current_dir() { + let repo_pinned = root + .join("target") + .join("liboliphaunt-pg18") + .join("install") + .join("bin") + .join(tool); + if repo_pinned.is_file() { + return repo_pinned; + } + } + PathBuf::from(tool) +} + +fn perf_native_postgres(args: &[String]) -> Result<()> { + let mut postgres_bin = default_native_postgres_tool("postgres", &["OLIPHAUNT_POSTGRES"]); + let mut initdb_bin = default_native_postgres_tool("initdb", &["OLIPHAUNT_INITDB"]); + let mut suite = BenchmarkSuiteFilter::Speed; + let mut speed_sql_source = SpeedSqlSource::OliphauntFixture; + let mut rtt_iterations = 100usize; + let mut prepared_rows = 25_000usize; + let mut client_mode = NativePostgresClientMode::TokioPostgresSimple; + let mut tuning = NativeBenchmarkTuning::default(); + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--postgres-bin" => { + cursor += 1; + postgres_bin = PathBuf::from( + args.get(cursor) + .ok_or_else(|| anyhow!("--postgres-bin requires a value"))?, + ); + } + "--initdb-bin" => { + cursor += 1; + initdb_bin = PathBuf::from( + args.get(cursor) + .ok_or_else(|| anyhow!("--initdb-bin requires a value"))?, + ); + } + "--suite" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--suite requires a value"))?; + suite = match value.as_str() { + "all" => BenchmarkSuiteFilter::All, + "rtt" | "roundtrip" | "round-trip" => BenchmarkSuiteFilter::Rtt, + "speed" | "sqlite" | "sqlite-suite" => BenchmarkSuiteFilter::Speed, + "stream" | "streaming" | "large-results" => BenchmarkSuiteFilter::Streaming, + "prepared" | "prepared-updates" => BenchmarkSuiteFilter::PreparedUpdates, + "backup" | "backup-restore" | "backup_restore" => { + BenchmarkSuiteFilter::BackupRestore + } + other => { + bail!( + "unknown --suite value {other:?}; use all, rtt, speed, streaming, prepared-updates, or backup-restore" + ) + } + }; + } + "--iterations" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--iterations requires a value"))?; + rtt_iterations = value + .parse() + .with_context(|| format!("parse --iterations value {value:?}"))?; + } + "--rows" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--rows requires a value"))?; + prepared_rows = value + .parse() + .with_context(|| format!("parse --rows value {value:?}"))?; + } + "--speed-source" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--speed-source requires a value"))?; + speed_sql_source = match value.as_str() { + "generated" | "local" => SpeedSqlSource::Generated, + "oliphaunt" | "oliphaunt-vendored" | "upstream" => { + SpeedSqlSource::OliphauntFixture + } + other => { + bail!("unknown --speed-source value {other:?}; use generated or oliphaunt") + } + }; + } + "--client" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--client requires a value"))?; + client_mode = match value.as_str() { + "tokio-postgres-simple" + | "tokio_postgres_simple" + | "tokio-postgres" + | "tokio_postgres" + | "simple" + | "simple-query" => NativePostgresClientMode::TokioPostgresSimple, + "sqlx" => NativePostgresClientMode::Sqlx, + other => { + bail!("unknown --client value {other:?}; use tokio-postgres-simple or sqlx") + } + }; + } + "--durability" => { + cursor += 1; + tuning.durability = parse_native_durability( + args.get(cursor) + .ok_or_else(|| anyhow!("--durability requires a value"))?, + )?; + } + "--runtime-footprint" => { + cursor += 1; + tuning.runtime_footprint = parse_runtime_footprint( + args.get(cursor) + .ok_or_else(|| anyhow!("--runtime-footprint requires a value"))?, + )?; + } + "--startup-guc" => { + cursor += 1; + tuning.startup_gucs.push(parse_startup_guc( + args.get(cursor) + .ok_or_else(|| anyhow!("--startup-guc requires a value"))?, + )?); + } + other => bail!("unknown perf native-postgres flag: {other}"), + } + cursor += 1; + } + ensure!(rtt_iterations > 0, "--iterations must be greater than zero"); + ensure!(prepared_rows > 0, "--rows must be greater than zero"); + + if suite == BenchmarkSuiteFilter::PreparedUpdates { + return perf_native_postgres_prepared_updates( + &postgres_bin, + &initdb_bin, + prepared_rows, + tuning, + ); + } + + let native_open_started = Instant::now(); + let native = NativePostgres::start(&postgres_bin, &initdb_bin, &tuning)?; + let native_open_micros = native_open_started.elapsed().as_micros(); + let mut runs = Vec::new(); + if suite.includes("rtt") || suite.includes("speed") { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create native Postgres benchmark Tokio runtime")?; + let mut client_runs = runtime.block_on(async { + match client_mode { + NativePostgresClientMode::TokioPostgresSimple => { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, &native); + let connect_started = Instant::now(); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect to native Postgres benchmark cluster")?; + let connection_task = tokio::spawn(async move { + if let Err(err) = connection.await { + eprintln!("native Postgres benchmark connection error: {err}"); + } + }); + let connect_micros = connect_started.elapsed().as_micros(); + let server_pid = native.child.id(); + + let mut runs = Vec::new(); + if suite.includes("rtt") { + let mut sampler = ProcessTreeRssSampler::new(server_pid); + runs.push( + run_native_postgres_rtt_benchmark( + &client, + rtt_iterations, + native_open_micros, + connect_micros, + &mut sampler, + ) + .await?, + ); + } + if suite.includes("speed") { + let mut sampler = ProcessTreeRssSampler::new(server_pid); + runs.push( + run_native_postgres_speed_benchmark( + &client, + speed_sql_source, + native_open_micros, + connect_micros, + &mut sampler, + ) + .await?, + ); + } + drop(client); + connection_task.await.ok(); + Ok::<_, anyhow::Error>(runs) + } + NativePostgresClientMode::Sqlx => { + let connect_started = Instant::now(); + let mut conn = + sqlx::PgConnection::connect_with(&native_postgres_sqlx_options(&native)) + .await + .context("connect SQLx native Postgres benchmark client")?; + let connect_micros = connect_started.elapsed().as_micros(); + let server_pid = native.child.id(); + + let mut runs = Vec::new(); + if suite.includes("rtt") { + let mut sampler = ProcessTreeRssSampler::new(server_pid); + runs.push( + run_native_postgres_rtt_sqlx_benchmark( + &mut conn, + rtt_iterations, + native_open_micros, + connect_micros, + &mut sampler, + ) + .await?, + ); + } + if suite.includes("speed") { + let mut sampler = ProcessTreeRssSampler::new(server_pid); + runs.push( + run_native_postgres_speed_sqlx_benchmark( + &mut conn, + speed_sql_source, + native_open_micros, + connect_micros, + &mut sampler, + ) + .await?, + ); + } + conn.close() + .await + .context("close SQLx native Postgres benchmark client")?; + Ok::<_, anyhow::Error>(runs) + } + } + })?; + runs.append(&mut client_runs); + } + if suite.includes("streaming") { + let mut sampler = ProcessTreeRssSampler::new(native.child.id()); + runs.push(run_native_postgres_streaming_benchmark( + &native, + native_open_micros, + &mut sampler, + )?); + } + if suite.includes("backup-restore") { + let mut sampler = ProcessTreeRssSampler::new(native.child.id()); + runs.push(run_native_postgres_physical_backup_restore_benchmark( + &native, + &postgres_bin, + native_open_micros, + &mut sampler, + &tuning, + )?); + runs.push(run_native_postgres_backup_restore_benchmark( + &native, + &postgres_bin, + native_open_micros, + &mut sampler, + )?); + } + ensure!( + !runs.is_empty(), + "selected native Postgres suite produced no runs" + ); + + let report = BenchmarkReport { + wasmer_version: "native-postgres", + wasmer_wasix_version: "native-postgres", + wasix_runtime_assets: None, + source_model: speed_sql_source.source_model(), + measurement_model: match client_mode { + NativePostgresClientMode::TokioPostgresSimple => { + "Native Postgres control. xtask starts a temporary local cluster with the selected durability profile and Oliphaunt-parity startup GUCs, connects to the same template1 database target used by liboliphaunt, then sends each benchmark SQL file as one simple-query buffer through tokio-postgres simple_query. This intentionally avoids psql -f because psql splits files client-side." + } + NativePostgresClientMode::Sqlx => { + "Native Postgres control. xtask starts a temporary local cluster with the selected durability profile and Oliphaunt-parity startup GUCs, connects to the same template1 database target used by liboliphaunt, then runs the benchmark SQL through one long-lived SQLx connection." + } + }, + native_tuning: Some(tuning.report()), + rtt_iterations, + speed_scale: 1.0, + preload_micros: 0, + runs, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn perf_native_postgres_prepared_updates( + postgres_bin: &Path, + initdb_bin: &Path, + rows: usize, + tuning: NativeBenchmarkTuning, +) -> Result<()> { + let numeric_updates = parsed_numeric_updates(rows)?; + let text_updates = parsed_text_updates(rows)?; + let runs = vec![ + PreparedUpdateRun { + mode: "native_postgres_tokio_prepared".to_owned(), + description: "Native PostgreSQL control using tokio-postgres with one prepared statement and one Execute await per update.".to_owned(), + protocol_stats: None, + tests: run_native_prepared_update_tests( + postgres_bin, + initdb_bin, + &tuning, + &numeric_updates, + &text_updates, + PreparedExecution::Sequential, + )?, + }, + PreparedUpdateRun { + mode: "native_postgres_tokio_pipelined_prepared".to_owned(), + description: "Native PostgreSQL control using tokio-postgres with one prepared statement and pipelined Execute futures inside one transaction.".to_owned(), + protocol_stats: None, + tests: run_native_prepared_update_tests( + postgres_bin, + initdb_bin, + &tuning, + &numeric_updates, + &text_updates, + PreparedExecution::Pipelined, + )?, + }, + ]; + + let report = PreparedUpdateReport { + source_model: "Exact Oliphaunt fixture benchmark2/benchmark6 setup plus update values parsed from benchmark9 and benchmark10.", + measurement_model: "Native PostgreSQL prepared-update control. Each test starts a fresh temporary local PostgreSQL cluster with the selected durability profile and Oliphaunt-parity startup GUCs, connects through tokio-postgres, prepares one statement, then executes N updates inside one transaction.", + gate_model: None, + wasix_runtime_assets: None, + native_tuning: Some(tuning.report()), + rows, + runs, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +async fn run_native_postgres_rtt_benchmark( + client: &tokio_postgres::Client, + iterations: usize, + open_micros: u128, + connect_micros: u128, + server_rss: &mut ProcessTreeRssSampler, +) -> Result { + let setup_started = Instant::now(); + client + .simple_query(rtt_setup_sql()) + .await + .context("execute native Postgres RTT setup")?; + let setup_micros = setup_started.elapsed().as_micros(); + server_rss.sample(); + + let mut tests = Vec::new(); + for case in rtt_cases() { + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let started = Instant::now(); + client + .simple_query(&case.sql) + .await + .with_context(|| format!("execute native Postgres RTT benchmark {}", case.id))?; + samples.push(started.elapsed().as_micros()); + } + tests.push(samples_result( + case.id, + format!("Test {}: {}", case.id, case.label), + "milliseconds", + iterations, + samples, + )); + server_rss.sample(); + } + + Ok(BenchmarkRun { + suite: "rtt", + mode: "native_postgres", + description: "Native Postgres over Unix socket using tokio-postgres simple_query against the liboliphaunt-matched template1 database target.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + tests, + }) +} + +async fn run_native_postgres_speed_benchmark( + client: &tokio_postgres::Client, + sql_source: SpeedSqlSource, + open_micros: u128, + connect_micros: u128, + server_rss: &mut ProcessTreeRssSampler, +) -> Result { + client + .simple_query( + "DROP TABLE IF EXISTS t1 CASCADE;\ + DROP TABLE IF EXISTS t2 CASCADE;\ + DROP TABLE IF EXISTS t2_1 CASCADE;\ + DROP TABLE IF EXISTS t3 CASCADE;\ + DROP TABLE IF EXISTS t3_1 CASCADE;", + ) + .await + .context("clear native Postgres speed benchmark tables")?; + server_rss.sample(); + + let mut tests = Vec::new(); + for case in speed_cases(1.0, sql_source)? { + let started = Instant::now(); + client + .simple_query(&case.sql) + .await + .with_context(|| format!("execute native Postgres speed benchmark {}", case.id))?; + tests.push(single_sample_result( + case.id, + case.label, + "seconds", + case.operation_count, + started.elapsed(), + )); + server_rss.sample(); + } + Ok(BenchmarkRun { + suite: "speed", + mode: "native_postgres", + description: "Native Postgres speed suite over Unix socket using tokio-postgres simple_query against the liboliphaunt-matched template1 database target.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros: 0, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn perf_legacy_wasix_sqlx(args: &[String]) -> Result<()> { + let mut database_url: Option = None; + let mut suite = BenchmarkSuiteFilter::Speed; + let mut speed_sql_source = SpeedSqlSource::OliphauntFixture; + let mut rtt_iterations = 100usize; + let mut open_micros = 0u128; + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--database-url" => { + cursor += 1; + database_url = Some( + args.get(cursor) + .ok_or_else(|| anyhow!("--database-url requires a value"))? + .to_owned(), + ); + } + "--open-micros" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--open-micros requires a value"))?; + open_micros = value + .parse() + .with_context(|| format!("parse --open-micros value {value:?}"))?; + } + "--suite" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--suite requires a value"))?; + suite = match value.as_str() { + "all" => BenchmarkSuiteFilter::All, + "rtt" | "roundtrip" | "round-trip" => BenchmarkSuiteFilter::Rtt, + "speed" | "sqlite" | "sqlite-suite" => BenchmarkSuiteFilter::Speed, + other => bail!("unknown --suite value {other:?}; use all, rtt, or speed"), + }; + } + "--iterations" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--iterations requires a value"))?; + rtt_iterations = value + .parse() + .with_context(|| format!("parse --iterations value {value:?}"))?; + } + "--speed-source" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--speed-source requires a value"))?; + speed_sql_source = match value.as_str() { + "generated" | "local" => SpeedSqlSource::Generated, + "oliphaunt" | "oliphaunt-vendored" | "upstream" => { + SpeedSqlSource::OliphauntFixture + } + other => { + bail!("unknown --speed-source value {other:?}; use generated or oliphaunt") + } + }; + } + other => bail!("unknown perf legacy-wasix-sqlx flag: {other}"), + } + cursor += 1; + } + ensure!(rtt_iterations > 0, "--iterations must be greater than zero"); + let database_url = database_url.ok_or_else(|| anyhow!("--database-url is required"))?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create legacy WASIX Oliphaunt SQLx benchmark Tokio runtime")?; + let runs = runtime.block_on(async { + let connect_started = Instant::now(); + let mut conn = sqlx::PgConnection::connect(&database_url) + .await + .context("connect SQLx client to legacy WASIX Oliphaunt socket server")?; + let connect_micros = connect_started.elapsed().as_micros(); + + let mut runs = Vec::new(); + if suite.includes("rtt") { + runs.push( + run_legacy_wasix_rtt_sqlx_benchmark( + &mut conn, + rtt_iterations, + open_micros, + connect_micros, + ) + .await?, + ); + } + if suite.includes("speed") { + runs.push( + run_legacy_wasix_speed_sqlx_benchmark( + &mut conn, + speed_sql_source, + open_micros, + connect_micros, + ) + .await?, + ); + } + conn.close() + .await + .context("close SQLx legacy WASIX Oliphaunt benchmark client")?; + Ok::<_, anyhow::Error>(runs) + })?; + + let report = BenchmarkReport { + wasmer_version: "node-oliphaunt", + wasmer_wasix_version: "node-oliphaunt", + wasix_runtime_assets: None, + source_model: speed_sql_source.source_model(), + measurement_model: "Oliphaunt fixture control. A caller supplies a PostgreSQL-compatible database URL, then xtask runs the benchmark SQL through one long-lived SQLx connection.", + native_tuning: None, + rtt_iterations, + speed_scale: 1.0, + preload_micros: 0, + runs, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +fn perf_legacy_wasix_sqlx(args: &[String]) -> Result<()> { + let _ = args; + legacy_oliphaunt_unavailable("perf legacy-wasix-sqlx") +} + +async fn run_native_postgres_rtt_sqlx_benchmark( + conn: &mut sqlx::PgConnection, + iterations: usize, + open_micros: u128, + connect_micros: u128, + server_rss: &mut ProcessTreeRssSampler, +) -> Result { + let setup_started = Instant::now(); + conn.execute(rtt_setup_sql()) + .await + .context("execute native Postgres RTT setup over SQLx")?; + let setup_micros = setup_started.elapsed().as_micros(); + server_rss.sample(); + + let mut tests = Vec::new(); + for case in rtt_cases() { + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let started = Instant::now(); + conn.execute(case.sql.as_str()).await.with_context(|| { + format!( + "execute native Postgres RTT benchmark {} over SQLx", + case.id + ) + })?; + samples.push(started.elapsed().as_micros()); + } + tests.push(samples_result( + case.id, + format!("Test {}: {}", case.id, case.label), + "milliseconds", + iterations, + samples, + )); + server_rss.sample(); + } + + Ok(BenchmarkRun { + suite: "rtt", + mode: "native_postgres_sqlx", + description: "Native Postgres over TCP using one long-lived SQLx connection against the liboliphaunt-matched template1 database target.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +async fn run_legacy_wasix_rtt_sqlx_benchmark( + conn: &mut sqlx::PgConnection, + iterations: usize, + open_micros: u128, + connect_micros: u128, +) -> Result { + let setup_started = Instant::now(); + conn.execute(rtt_setup_sql()) + .await + .context("execute legacy WASIX Oliphaunt RTT setup over SQLx")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let mut tests = Vec::new(); + for case in rtt_cases() { + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let started = Instant::now(); + conn.execute(case.sql.as_str()).await.with_context(|| { + format!( + "execute legacy WASIX Oliphaunt RTT benchmark {} over SQLx", + case.id + ) + })?; + samples.push(started.elapsed().as_micros()); + } + tests.push(samples_result( + case.id, + format!("Test {}: {}", case.id, case.label), + "milliseconds", + iterations, + samples, + )); + } + + Ok(BenchmarkRun { + suite: "rtt", + mode: "legacy_wasix_sqlx", + description: "legacy WASIX Oliphaunt over the Postgres wire protocol using one long-lived SQLx connection.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +async fn run_native_postgres_speed_sqlx_benchmark( + conn: &mut sqlx::PgConnection, + sql_source: SpeedSqlSource, + open_micros: u128, + connect_micros: u128, + server_rss: &mut ProcessTreeRssSampler, +) -> Result { + conn.execute( + "DROP TABLE IF EXISTS t1 CASCADE;\ + DROP TABLE IF EXISTS t2 CASCADE;\ + DROP TABLE IF EXISTS t2_1 CASCADE;\ + DROP TABLE IF EXISTS t3 CASCADE;\ + DROP TABLE IF EXISTS t3_1 CASCADE;", + ) + .await + .context("clear native Postgres speed benchmark tables over SQLx")?; + server_rss.sample(); + + let mut tests = Vec::new(); + for case in speed_cases(1.0, sql_source)? { + let started = Instant::now(); + conn.execute(case.sql.as_str()).await.with_context(|| { + format!( + "execute native Postgres speed benchmark {} over SQLx", + case.id + ) + })?; + tests.push(single_sample_result( + case.id, + case.label, + "seconds", + case.operation_count, + started.elapsed(), + )); + server_rss.sample(); + } + Ok(BenchmarkRun { + suite: "speed", + mode: "native_postgres_sqlx", + description: "Native Postgres speed suite over TCP using one SQLx connection against the liboliphaunt-matched template1 database target.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros: 0, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +async fn run_legacy_wasix_speed_sqlx_benchmark( + conn: &mut sqlx::PgConnection, + sql_source: SpeedSqlSource, + open_micros: u128, + connect_micros: u128, +) -> Result { + conn.execute( + "DROP TABLE IF EXISTS t1 CASCADE;\ + DROP TABLE IF EXISTS t2 CASCADE;\ + DROP TABLE IF EXISTS t2_1 CASCADE;\ + DROP TABLE IF EXISTS t3 CASCADE;\ + DROP TABLE IF EXISTS t3_1 CASCADE;", + ) + .await + .context("clear legacy WASIX Oliphaunt speed benchmark tables over SQLx")?; + + let mut tests = Vec::new(); + for case in speed_cases(1.0, sql_source)? { + let started = Instant::now(); + conn.execute(case.sql.as_str()).await.with_context(|| { + format!( + "execute legacy WASIX Oliphaunt speed benchmark {} over SQLx", + case.id + ) + })?; + tests.push(single_sample_result( + case.id, + case.label, + "seconds", + case.operation_count, + started.elapsed(), + )); + } + Ok(BenchmarkRun { + suite: "speed", + mode: "legacy_wasix_sqlx", + description: "legacy WASIX Oliphaunt speed suite over TCP using one SQLx connection.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros: 0, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +fn unique_perf_root(name: &str) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("read system clock for perf root")? + .as_nanos(); + let root = env::temp_dir().join(format!( + "oliphaunt-wasix-{name}-{}-{now}", + std::process::id() + )); + if root.exists() { + fs::remove_dir_all(&root) + .with_context(|| format!("remove stale perf root {}", root.display()))?; + } + fs::create_dir_all(&root).with_context(|| format!("create perf root {}", root.display()))?; + Ok(root) +} diff --git a/tools/perf/runner/src/native_liboliphaunt.rs b/tools/perf/runner/src/native_liboliphaunt.rs new file mode 100644 index 00000000..7ec4746f --- /dev/null +++ b/tools/perf/runner/src/native_liboliphaunt.rs @@ -0,0 +1,1236 @@ +use super::*; + +use crate::process_rss::NativeLiboliphauntChildRssSampler; +use oliphaunt::{ + BackupRequest as NativeBackupRequest, Oliphaunt as NativeOliphaunt, + OliphauntBuilder as NativeOliphauntBuilder, ProtocolRequest as NativeProtocolRequest, + RestoreRequest as NativeRestoreRequest, +}; + +pub(super) fn perf_native_liboliphaunt(args: &[String]) -> Result<()> { + let mut suite = NativeLiboliphauntSuiteFilter::Rtt; + let mut engine = NativeLiboliphauntEngineMode::Direct; + let mut speed_sql_source = SpeedSqlSource::OliphauntFixture; + let mut rtt_iterations = 100usize; + let mut prepared_rows = 25_000usize; + let mut tuning = NativeBenchmarkTuning::default(); + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--engine" | "--mode" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--engine requires a value"))?; + engine = NativeLiboliphauntEngineMode::parse(value)?; + } + "--suite" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--suite requires a value"))?; + suite = match value.as_str() { + "rtt" | "roundtrip" | "round-trip" => NativeLiboliphauntSuiteFilter::Rtt, + "speed" | "sqlite" | "sqlite-suite" => NativeLiboliphauntSuiteFilter::Speed, + "stream" | "streaming" | "large-results" => { + NativeLiboliphauntSuiteFilter::Streaming + } + "prepared-updates" | "prepared" => { + NativeLiboliphauntSuiteFilter::PreparedUpdates + } + "backup" | "backup-restore" | "backup_restore" => { + NativeLiboliphauntSuiteFilter::BackupRestore + } + "all" => bail!( + "native-liboliphaunt v1 can only open once per process; run --suite rtt, speed, streaming, prepared-updates, and backup-restore in separate commands" + ), + other => { + bail!( + "unknown --suite value {other:?}; use rtt, speed, streaming, prepared-updates, or backup-restore" + ) + } + }; + } + "--iterations" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--iterations requires a value"))?; + rtt_iterations = value + .parse() + .with_context(|| format!("parse --iterations value {value:?}"))?; + } + "--speed-source" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--speed-source requires a value"))?; + speed_sql_source = match value.as_str() { + "generated" | "local" => SpeedSqlSource::Generated, + "oliphaunt" | "oliphaunt-vendored" | "upstream" => { + SpeedSqlSource::OliphauntFixture + } + other => { + bail!("unknown --speed-source value {other:?}; use generated or oliphaunt") + } + }; + } + "--rows" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--rows requires a value"))?; + prepared_rows = value + .parse() + .with_context(|| format!("parse --rows value {value:?}"))?; + } + "--durability" => { + cursor += 1; + tuning.durability = parse_native_durability( + args.get(cursor) + .ok_or_else(|| anyhow!("--durability requires a value"))?, + )?; + } + "--runtime-footprint" => { + cursor += 1; + tuning.runtime_footprint = parse_runtime_footprint( + args.get(cursor) + .ok_or_else(|| anyhow!("--runtime-footprint requires a value"))?, + )?; + } + "--startup-guc" => { + cursor += 1; + tuning.startup_gucs.push(parse_startup_guc( + args.get(cursor) + .ok_or_else(|| anyhow!("--startup-guc requires a value"))?, + )?); + } + other => bail!("unknown perf native-liboliphaunt flag: {other}"), + } + cursor += 1; + } + ensure!(rtt_iterations > 0, "--iterations must be greater than zero"); + ensure!(prepared_rows > 0, "--rows must be greater than zero"); + + if suite == NativeLiboliphauntSuiteFilter::PreparedUpdates { + return perf_native_liboliphaunt_prepared_updates(engine, prepared_rows, tuning); + } + + let run = match suite { + NativeLiboliphauntSuiteFilter::Rtt => { + run_native_liboliphaunt_rtt_benchmark(engine, rtt_iterations, &tuning)? + } + NativeLiboliphauntSuiteFilter::Speed => { + run_native_liboliphaunt_speed_benchmark(engine, speed_sql_source, &tuning)? + } + NativeLiboliphauntSuiteFilter::Streaming => { + run_native_liboliphaunt_streaming_benchmark(engine, &tuning)? + } + NativeLiboliphauntSuiteFilter::BackupRestore => { + run_native_liboliphaunt_backup_restore_benchmark(engine, &tuning)? + } + NativeLiboliphauntSuiteFilter::PreparedUpdates => { + unreachable!("prepared-updates returns before benchmark report construction") + } + }; + let report = BenchmarkReport { + wasmer_version: "native-liboliphaunt", + wasmer_wasix_version: "native-liboliphaunt", + wasix_runtime_assets: None, + source_model: speed_sql_source.source_model(), + measurement_model: engine.measurement_model(), + native_tuning: Some(tuning.report()), + rtt_iterations, + speed_scale: 1.0, + preload_micros: 0, + runs: vec![run], + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativeLiboliphauntSuiteFilter { + Rtt, + Speed, + Streaming, + PreparedUpdates, + BackupRestore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativeLiboliphauntEngineMode { + Direct, + Broker, + Server, +} + +impl NativeLiboliphauntEngineMode { + fn parse(value: &str) -> Result { + match value { + "direct" | "native-direct" | "native_direct" => Ok(Self::Direct), + "broker" | "native-broker" | "native_broker" => Ok(Self::Broker), + "server" | "native-server" | "native_server" => Ok(Self::Server), + other => { + bail!("unknown native-liboliphaunt engine {other:?}; use direct, broker, or server") + } + } + } + + fn label(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Broker => "broker", + Self::Server => "server", + } + } + + fn benchmark_mode(self) -> &'static str { + match self { + Self::Direct => "native_liboliphaunt_direct", + Self::Broker => "native_liboliphaunt_broker", + Self::Server => "native_liboliphaunt_server", + } + } + + fn description(self, suite: &'static str) -> &'static str { + match (self, suite) { + (Self::Direct, "rtt") => "Native liboliphaunt in-process direct Rust API.", + (Self::Direct, "speed") => { + "Native liboliphaunt speed suite through the in-process direct Rust API." + } + (Self::Direct, "streaming") => { + "Native liboliphaunt large-result streaming through the in-process direct Rust API." + } + (Self::Direct, "backup-restore") => { + "Native liboliphaunt physical archive backup and restore through the in-process direct Rust API." + } + (Self::Broker, "rtt") => { + "Native liboliphaunt broker mode through a helper process and local IPC." + } + (Self::Broker, "speed") => { + "Native liboliphaunt speed suite through broker helper-process IPC." + } + (Self::Broker, "streaming") => { + "Native liboliphaunt large-result streaming through broker helper-process IPC." + } + (Self::Broker, "backup-restore") => { + "Native liboliphaunt physical archive backup and restore through broker helper-process IPC." + } + (Self::Server, "rtt") => { + "Native liboliphaunt server mode through a real local PostgreSQL server process." + } + (Self::Server, "speed") => { + "Native liboliphaunt speed suite through a real local PostgreSQL server process." + } + (Self::Server, "streaming") => { + "Native liboliphaunt large-result streaming through a real local PostgreSQL server process." + } + (Self::Server, "backup-restore") => { + "Native liboliphaunt physical archive backup and restore through a real local PostgreSQL server process." + } + _ => "Native liboliphaunt benchmark.", + } + } + + fn measurement_model(self) -> &'static str { + match self { + Self::Direct => { + "Native liboliphaunt direct-mode control. xtask opens one embedded native PostgreSQL backend in-process through the oliphaunt Rust SDK. RTT sample loops run inside one Tokio runtime, sort samples, discard the lowest and highest 10% when possible, and report trimmed averages plus percentile latencies. Speed tests run each Oliphaunt fixture SQL file as one simple-query buffer." + } + Self::Broker => { + "Native liboliphaunt broker-mode control. xtask opens oliphaunt in broker mode, where a helper process owns the direct native backend and the Rust client sends raw protocol/control frames over local IPC. RTT sample loops run inside one Tokio runtime, sort samples, discard the lowest and highest 10% when possible, and report trimmed averages plus percentile latencies. Speed tests run each Oliphaunt fixture SQL file as one simple-query buffer." + } + Self::Server => { + "Native liboliphaunt server-mode control. xtask opens oliphaunt in server mode, which starts a real local PostgreSQL server process and sends raw PostgreSQL protocol frames through the SDK's server client. RTT sample loops run inside one Tokio runtime, sort samples, discard the lowest and highest 10% when possible, and report trimmed averages plus percentile latencies. Speed tests run each Oliphaunt fixture SQL file as one simple-query buffer." + } + } + } +} + +fn run_native_liboliphaunt_rtt_benchmark( + engine: NativeLiboliphauntEngineMode, + iterations: usize, + tuning: &NativeBenchmarkTuning, +) -> Result { + let root = native_liboliphaunt_benchmark_root(engine.label(), "rtt")?; + let runtime = native_liboliphaunt_runtime()?; + let open_started = Instant::now(); + let db = runtime + .block_on(native_liboliphaunt_builder(&root, engine, tuning).open()) + .with_context(|| format!("open native liboliphaunt {} RTT database", engine.label()))?; + let open_micros = open_started.elapsed().as_micros(); + let mut child_rss = NativeLiboliphauntChildRssSampler::new(); + child_rss.sample(); + + let setup_started = Instant::now(); + runtime + .block_on(db.execute(rtt_setup_sql())) + .with_context(|| format!("execute native liboliphaunt {} RTT setup", engine.label()))?; + let setup_micros = setup_started.elapsed().as_micros(); + child_rss.sample(); + + let mut tests = Vec::new(); + for case in rtt_cases() { + let test = runtime.block_on(async { + let mut samples = Vec::with_capacity(iterations); + for _ in 0..iterations { + let started = Instant::now(); + db.execute(&case.sql) + .await + .with_context(|| format!("execute RTT benchmark {}", case.id))?; + samples.push(started.elapsed().as_micros()); + } + Ok::<_, anyhow::Error>(samples_result( + case.id, + format!("Test {}: {}", case.id, case.label), + "milliseconds", + iterations, + samples, + )) + })?; + tests.push(test); + child_rss.sample(); + } + runtime.block_on(db.close())?; + cleanup_native_liboliphaunt_benchmark_root(engine, &root, "RTT")?; + + Ok(BenchmarkRun { + suite: "rtt", + mode: engine.benchmark_mode(), + description: engine.description("rtt"), + open_micros, + connect_micros: None, + setup_micros, + observed_server_peak_rss_bytes: child_rss.peak_bytes(), + tests, + }) +} + +fn run_native_liboliphaunt_speed_benchmark( + engine: NativeLiboliphauntEngineMode, + sql_source: SpeedSqlSource, + tuning: &NativeBenchmarkTuning, +) -> Result { + let cases = speed_cases(1.0, sql_source)?; + let root = native_liboliphaunt_benchmark_root(engine.label(), "speed")?; + let runtime = native_liboliphaunt_runtime()?; + let open_started = Instant::now(); + let db = runtime + .block_on(native_liboliphaunt_builder(&root, engine, tuning).open()) + .with_context(|| format!("open native liboliphaunt {} speed database", engine.label()))?; + let open_micros = open_started.elapsed().as_micros(); + let mut child_rss = NativeLiboliphauntChildRssSampler::new(); + child_rss.sample(); + + let mut tests = Vec::new(); + for case in cases { + let started = Instant::now(); + runtime + .block_on(db.execute(&case.sql)) + .with_context(|| format!("execute native liboliphaunt speed benchmark {}", case.id))?; + tests.push(single_sample_result( + case.id, + case.label, + "seconds", + case.operation_count, + started.elapsed(), + )); + child_rss.sample(); + } + runtime.block_on(db.close())?; + cleanup_native_liboliphaunt_benchmark_root(engine, &root, "speed")?; + + Ok(BenchmarkRun { + suite: "speed", + mode: engine.benchmark_mode(), + description: engine.description("speed"), + open_micros, + connect_micros: None, + setup_micros: 0, + observed_server_peak_rss_bytes: child_rss.peak_bytes(), + tests, + }) +} + +fn run_native_liboliphaunt_streaming_benchmark( + engine: NativeLiboliphauntEngineMode, + tuning: &NativeBenchmarkTuning, +) -> Result { + let root = native_liboliphaunt_benchmark_root(engine.label(), "streaming")?; + let runtime = native_liboliphaunt_runtime()?; + let open_started = Instant::now(); + let db = runtime + .block_on(native_liboliphaunt_builder(&root, engine, tuning).open()) + .with_context(|| { + format!( + "open native liboliphaunt {} streaming database", + engine.label() + ) + })?; + let open_micros = open_started.elapsed().as_micros(); + let mut child_rss = NativeLiboliphauntChildRssSampler::new(); + child_rss.sample(); + + let mut tests = Vec::new(); + for case in streaming_cases() { + let counters = std::sync::Arc::new(std::sync::Mutex::new((0usize, 0usize))); + let counters_for_callback = std::sync::Arc::clone(&counters); + let started = Instant::now(); + runtime + .block_on( + db.exec_protocol_raw_stream(pg_query(case.sql), move |chunk| { + let mut counters = counters_for_callback.lock().map_err(|_| { + oliphaunt::Error::Engine( + "streaming benchmark counter lock poisoned".to_owned(), + ) + })?; + counters.0 = counters.0.saturating_add(chunk.len()); + counters.1 = counters.1.saturating_add(1); + Ok(()) + }), + ) + .with_context(|| { + format!( + "execute native liboliphaunt {} streaming benchmark {}", + engine.label(), + case.id + ) + })?; + let (bytes, chunks) = *counters + .lock() + .map_err(|_| anyhow!("streaming benchmark counter lock poisoned"))?; + tests.push(single_sample_result( + case.id, + format!( + "{}; streamed {bytes} bytes across {chunks} chunk(s)", + case.label + ), + "seconds", + bytes, + started.elapsed(), + )); + child_rss.sample(); + } + runtime.block_on(db.close())?; + cleanup_native_liboliphaunt_benchmark_root(engine, &root, "streaming")?; + + Ok(BenchmarkRun { + suite: "streaming", + mode: engine.benchmark_mode(), + description: engine.description("streaming"), + open_micros, + connect_micros: None, + setup_micros: 0, + observed_server_peak_rss_bytes: child_rss.peak_bytes(), + tests, + }) +} + +fn run_native_liboliphaunt_backup_restore_benchmark( + engine: NativeLiboliphauntEngineMode, + tuning: &NativeBenchmarkTuning, +) -> Result { + let root = native_liboliphaunt_benchmark_root(engine.label(), "backup")?; + let restore_root = native_liboliphaunt_benchmark_root(engine.label(), "restore")?; + let runtime = native_liboliphaunt_runtime()?; + let open_started = Instant::now(); + let db = runtime + .block_on(native_liboliphaunt_builder(&root, engine, tuning).open()) + .with_context(|| { + format!( + "open native liboliphaunt {} backup/restore database", + engine.label() + ) + })?; + let open_micros = open_started.elapsed().as_micros(); + let mut child_rss = NativeLiboliphauntChildRssSampler::new(); + child_rss.sample(); + + let setup_started = Instant::now(); + let setup_sql = backup_restore_setup_sql(); + runtime.block_on(db.execute(&setup_sql)).with_context(|| { + format!( + "execute native liboliphaunt {} backup/restore setup", + engine.label() + ) + })?; + let setup_micros = setup_started.elapsed().as_micros(); + child_rss.sample(); + + let backup_started = Instant::now(); + let artifact = runtime + .block_on(db.backup(NativeBackupRequest::physical_archive())) + .with_context(|| format!("backup native liboliphaunt {} root", engine.label()))?; + let backup_elapsed = backup_started.elapsed(); + ensure!( + !artifact.bytes.is_empty(), + "native liboliphaunt {} backup returned an empty archive", + engine.label() + ); + let archive_bytes = artifact.bytes.len(); + child_rss.sample(); + + runtime.block_on(db.close())?; + + let restore_started = Instant::now(); + runtime + .block_on(NativeOliphaunt::restore( + NativeRestoreRequest::physical_archive(&restore_root, artifact), + )) + .with_context(|| { + format!( + "restore native liboliphaunt {} physical archive", + engine.label() + ) + })?; + let restore_elapsed = restore_started.elapsed(); + + verify_native_liboliphaunt_restored_root(engine, &restore_root, tuning)?; + + cleanup_native_liboliphaunt_benchmark_root(engine, &root, "backup")?; + cleanup_native_liboliphaunt_benchmark_root(engine, &restore_root, "restore")?; + + Ok(BenchmarkRun { + suite: "backup-restore", + mode: engine.benchmark_mode(), + description: engine.description("backup-restore"), + open_micros, + connect_micros: None, + setup_micros, + observed_server_peak_rss_bytes: child_rss.peak_bytes(), + tests: vec![ + single_sample_result( + "physical_archive_backup", + format!( + "Physical archive backup; archive size {}", + fmt_bytes_label(archive_bytes) + ), + "seconds", + archive_bytes, + backup_elapsed, + ), + single_sample_result( + "physical_archive_restore", + format!( + "Physical archive restore; archive size {}", + fmt_bytes_label(archive_bytes) + ), + "seconds", + archive_bytes, + restore_elapsed, + ), + ], + }) +} + +fn verify_native_liboliphaunt_restored_root( + engine: NativeLiboliphauntEngineMode, + root: &Path, + tuning: &NativeBenchmarkTuning, +) -> Result<()> { + let mut args = vec![ + "perf".to_owned(), + "native-liboliphaunt-restore-verify-child".to_owned(), + "--engine".to_owned(), + engine.label().to_owned(), + "--root".to_owned(), + root.display().to_string(), + "--expected-rows".to_owned(), + BACKUP_RESTORE_EXPECTED_ROWS.to_string(), + "--durability".to_owned(), + native_durability_arg(tuning.durability).to_owned(), + "--runtime-footprint".to_owned(), + tuning.runtime_footprint.to_string(), + ]; + for guc in &tuning.startup_gucs { + args.push("--startup-guc".to_owned()); + args.push(format!("{}={}", guc.name.trim(), guc.value)); + } + + let output = Command::new(env::current_exe().context("resolve current xtask executable")?) + .args(args) + .output() + .with_context(|| { + format!( + "run native-liboliphaunt {} restore verification child", + engine.label() + ) + })?; + ensure!( + output.status.success(), + "native-liboliphaunt restore verification child failed for {}:\nstdout:\n{}\nstderr:\n{}", + engine.label(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +pub(super) fn perf_native_liboliphaunt_restore_verify_child(args: &[String]) -> Result<()> { + let mut engine = NativeLiboliphauntEngineMode::Direct; + let mut root = None; + let mut expected_rows = BACKUP_RESTORE_EXPECTED_ROWS; + let mut tuning = NativeBenchmarkTuning::default(); + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--engine" | "--mode" => { + cursor += 1; + engine = NativeLiboliphauntEngineMode::parse( + args.get(cursor) + .ok_or_else(|| anyhow!("--engine requires a value"))?, + )?; + } + "--root" => { + cursor += 1; + root = Some(PathBuf::from( + args.get(cursor) + .ok_or_else(|| anyhow!("--root requires a value"))?, + )); + } + "--expected-rows" => { + cursor += 1; + expected_rows = args + .get(cursor) + .ok_or_else(|| anyhow!("--expected-rows requires a value"))? + .parse() + .context("parse --expected-rows")?; + } + "--durability" => { + cursor += 1; + tuning.durability = parse_native_durability( + args.get(cursor) + .ok_or_else(|| anyhow!("--durability requires a value"))?, + )?; + } + "--runtime-footprint" => { + cursor += 1; + tuning.runtime_footprint = parse_runtime_footprint( + args.get(cursor) + .ok_or_else(|| anyhow!("--runtime-footprint requires a value"))?, + )?; + } + "--startup-guc" => { + cursor += 1; + tuning.startup_gucs.push(parse_startup_guc( + args.get(cursor) + .ok_or_else(|| anyhow!("--startup-guc requires a value"))?, + )?); + } + other => bail!("unknown native-liboliphaunt restore verification child flag: {other}"), + } + cursor += 1; + } + let root = root.context("--root is required")?; + let runtime = native_liboliphaunt_runtime()?; + let db = runtime + .block_on( + native_liboliphaunt_builder(&root, engine, &tuning) + .existing_only() + .open(), + ) + .with_context(|| { + format!( + "open restored native-liboliphaunt {} root {}", + engine.label(), + root.display() + ) + })?; + let result = runtime + .block_on(db.query("SELECT count(*)::text AS count FROM backup_restore_items")) + .context("query restored backup_restore_items count")?; + let count = result + .get_text(0, "count") + .context("read restored count column")? + .context("restored count was NULL")?; + ensure!( + count == expected_rows.to_string(), + "restored row count mismatch: got {count}, expected {expected_rows}" + ); + runtime.block_on(db.close())?; + println!("verified restored rows: {count}"); + Ok(()) +} + +fn perf_native_liboliphaunt_prepared_updates( + engine: NativeLiboliphauntEngineMode, + rows: usize, + tuning: NativeBenchmarkTuning, +) -> Result<()> { + let sequential_mode = format!("{}_prepared", engine.benchmark_mode()); + let pipelined_mode = format!("{}_pipelined_prepared", engine.benchmark_mode()); + let sequential_description = format!( + "Native liboliphaunt {} mode using one named prepared statement and one Bind/Execute/Sync round trip per update.", + engine.label() + ); + let pipelined_description = format!( + "Native liboliphaunt {} mode using one named prepared statement and one pipelined Bind/Execute batch inside one transaction.", + engine.label() + ); + let runs = vec![ + PreparedUpdateRun { + mode: sequential_mode, + description: sequential_description, + protocol_stats: None, + tests: run_native_liboliphaunt_prepared_update_tests( + engine, + rows, + &tuning, + PreparedExecution::Sequential, + )?, + }, + PreparedUpdateRun { + mode: pipelined_mode, + description: pipelined_description, + protocol_stats: None, + tests: run_native_liboliphaunt_prepared_update_tests( + engine, + rows, + &tuning, + PreparedExecution::Pipelined, + )?, + }, + ]; + + let report = PreparedUpdateReport { + source_model: "Exact Oliphaunt fixture benchmark2/benchmark6 setup plus update values parsed from benchmark9 and benchmark10.", + measurement_model: "Each native-liboliphaunt prepared-update test runs in a fresh xtask child process. The child opens the selected native SDK mode, prepares one named statement over the raw frontend/backend protocol, then executes N updates inside one transaction.", + gate_model: None, + wasix_runtime_assets: None, + native_tuning: Some(tuning.report()), + rows, + runs, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_native_liboliphaunt_prepared_update_tests( + engine: NativeLiboliphauntEngineMode, + rows: usize, + tuning: &NativeBenchmarkTuning, + execution: PreparedExecution, +) -> Result> { + Ok(vec![ + run_native_liboliphaunt_prepared_update_child( + engine, + NativeLiboliphauntPreparedCase::Numeric, + execution, + rows, + tuning, + )?, + run_native_liboliphaunt_prepared_update_child( + engine, + NativeLiboliphauntPreparedCase::Text, + execution, + rows, + tuning, + )?, + ]) +} + +fn run_native_liboliphaunt_prepared_update_child( + engine: NativeLiboliphauntEngineMode, + case: NativeLiboliphauntPreparedCase, + execution: PreparedExecution, + rows: usize, + tuning: &NativeBenchmarkTuning, +) -> Result { + let rows_arg = rows.to_string(); + let mut child_args = vec![ + "perf".to_owned(), + "native-liboliphaunt-prepared-child".to_owned(), + "--engine".to_owned(), + engine.label().to_owned(), + "--case".to_owned(), + case.arg().to_owned(), + "--execution".to_owned(), + execution.arg().to_owned(), + "--rows".to_owned(), + rows_arg, + "--durability".to_owned(), + native_durability_arg(tuning.durability).to_owned(), + "--runtime-footprint".to_owned(), + tuning.runtime_footprint.to_string(), + ]; + for guc in &tuning.startup_gucs { + child_args.push("--startup-guc".to_owned()); + child_args.push(format!("{}={}", guc.name.trim(), guc.value)); + } + let output = Command::new(env::current_exe().context("resolve current xtask executable")?) + .args(child_args) + .output() + .with_context(|| format!("run native-liboliphaunt prepared child for {}", case.arg()))?; + + if !output.status.success() { + bail!( + "native-liboliphaunt prepared child failed for {} {}:\nstdout:\n{}\nstderr:\n{}", + case.arg(), + execution.arg(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let metrics: PreparedUpdateChildMetrics = + serde_json::from_slice(&output.stdout).with_context(|| { + format!( + "parse native-liboliphaunt prepared child JSON for {} {}", + case.arg(), + execution.arg() + ) + })?; + Ok(metrics.into_test(case)) +} + +pub(super) fn perf_native_liboliphaunt_prepared_child(args: &[String]) -> Result<()> { + let mut engine = NativeLiboliphauntEngineMode::Direct; + let mut case = None; + let mut execution = None; + let mut rows = 25_000usize; + let mut tuning = NativeBenchmarkTuning::default(); + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--engine" | "--mode" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--engine requires a value"))?; + engine = NativeLiboliphauntEngineMode::parse(value)?; + } + "--case" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--case requires a value"))?; + case = Some(NativeLiboliphauntPreparedCase::parse(value)?); + } + "--execution" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--execution requires a value"))?; + execution = Some(parse_prepared_execution(value)?); + } + "--rows" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--rows requires a value"))?; + rows = value + .parse() + .with_context(|| format!("parse --rows value {value:?}"))?; + } + "--durability" => { + cursor += 1; + tuning.durability = parse_native_durability( + args.get(cursor) + .ok_or_else(|| anyhow!("--durability requires a value"))?, + )?; + } + "--runtime-footprint" => { + cursor += 1; + tuning.runtime_footprint = parse_runtime_footprint( + args.get(cursor) + .ok_or_else(|| anyhow!("--runtime-footprint requires a value"))?, + )?; + } + "--startup-guc" => { + cursor += 1; + tuning.startup_gucs.push(parse_startup_guc( + args.get(cursor) + .ok_or_else(|| anyhow!("--startup-guc requires a value"))?, + )?); + } + other => bail!("unknown native-liboliphaunt prepared child flag: {other}"), + } + cursor += 1; + } + ensure!(rows > 0, "--rows must be greater than zero"); + let case = case.context("--case is required")?; + let execution = execution.context("--execution is required")?; + + let metrics = + run_native_liboliphaunt_prepared_update_case(engine, case, execution, rows, &tuning)?; + println!("{}", serde_json::to_string_pretty(&metrics)?); + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +enum NativeLiboliphauntPreparedCase { + Numeric, + Text, +} + +impl NativeLiboliphauntPreparedCase { + fn parse(value: &str) -> Result { + match value { + "numeric" | "numeric-indexed" => Ok(Self::Numeric), + "text" | "text-indexed" => Ok(Self::Text), + other => bail!("unknown native-liboliphaunt prepared case {other:?}"), + } + } + + fn arg(self) -> &'static str { + match self { + Self::Numeric => "numeric", + Self::Text => "text", + } + } + + fn id(self) -> &'static str { + match self { + Self::Numeric => "numeric_indexed", + Self::Text => "text_indexed", + } + } + + fn label(self) -> &'static str { + match self { + Self::Numeric => { + "Parameterized numeric UPDATEs with indexes on lookup and updated columns" + } + Self::Text => "Parameterized text UPDATEs with indexes on lookup and numeric column", + } + } +} + +fn parse_prepared_execution(value: &str) -> Result { + match value { + "sequential" => Ok(PreparedExecution::Sequential), + "pipelined" | "pipeline" => Ok(PreparedExecution::Pipelined), + other => bail!("unknown prepared execution {other:?}"), + } +} + +impl PreparedExecution { + fn arg(self) -> &'static str { + match self { + Self::Sequential => "sequential", + Self::Pipelined => "pipelined", + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreparedUpdateChildMetrics { + open_micros: u128, + connect_micros: u128, + setup_micros: u128, + prepare_micros: Option, + elapsed_micros: u128, + operation_count: usize, + average_micros: f64, +} + +impl PreparedUpdateChildMetrics { + fn into_test(self, case: NativeLiboliphauntPreparedCase) -> PreparedUpdateTest { + PreparedUpdateTest { + id: case.id(), + label: case.label(), + open_micros: self.open_micros, + connect_micros: self.connect_micros, + setup_micros: self.setup_micros, + prepare_micros: self.prepare_micros, + elapsed_micros: self.elapsed_micros, + operation_count: self.operation_count, + average_micros: self.average_micros, + } + } +} + +fn run_native_liboliphaunt_prepared_update_case( + engine: NativeLiboliphauntEngineMode, + case: NativeLiboliphauntPreparedCase, + execution: PreparedExecution, + rows: usize, + tuning: &NativeBenchmarkTuning, +) -> Result { + let setup_benchmark2 = read_oliphaunt_benchmark_sql("2")?; + let setup_benchmark6 = read_oliphaunt_benchmark_sql("6")?; + let update_values = match case { + NativeLiboliphauntPreparedCase::Numeric => { + NativeLiboliphauntPreparedValues::Numeric(parsed_numeric_updates(rows)?) + } + NativeLiboliphauntPreparedCase::Text => { + NativeLiboliphauntPreparedValues::Text(parsed_text_updates(rows)?) + } + }; + + let root = native_liboliphaunt_benchmark_root(engine.label(), "prepared")?; + let runtime = native_liboliphaunt_runtime()?; + let open_started = Instant::now(); + let builder = native_liboliphaunt_builder(&root, engine, tuning); + let db = runtime + .block_on(builder.open()) + .context("open native-liboliphaunt prepared database")?; + let open_micros = open_started.elapsed().as_micros(); + + let setup_started = Instant::now(); + runtime + .block_on(db.execute(&setup_benchmark2)) + .context("execute native-liboliphaunt prepared setup benchmark2")?; + runtime + .block_on(db.execute(&setup_benchmark6)) + .context("execute native-liboliphaunt prepared setup benchmark6")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let statement_name = "oliphaunt_bench_update"; + let (sql, param_oids) = match case { + NativeLiboliphauntPreparedCase::Numeric => ("UPDATE t2 SET b=$1 WHERE a=$2", &[23, 23][..]), + NativeLiboliphauntPreparedCase::Text => ("UPDATE t2 SET c=$1 WHERE a=$2", &[25, 23][..]), + }; + let mut prepare = Vec::new(); + prepare.extend(pg_parse(Some(statement_name), sql, param_oids)); + prepare.extend(pg_describe(b'S', Some(statement_name))); + prepare.extend(pg_sync()); + let prepare_started = Instant::now(); + exec_raw_checked( + &runtime, + &db, + &prepare, + "prepare native-liboliphaunt statement", + )?; + let prepare_micros = prepare_started.elapsed().as_micros(); + + let started = Instant::now(); + exec_raw_checked( + &runtime, + &db, + &pg_query("BEGIN"), + "begin prepared-update transaction", + )?; + let operation_count = match update_values { + NativeLiboliphauntPreparedValues::Numeric(updates) => { + execute_native_liboliphaunt_prepared_updates( + &runtime, + &db, + statement_name, + execution, + updates + .iter() + .map(|(lookup, value)| [value.to_string(), lookup.to_string()]), + )?; + updates.len() + } + NativeLiboliphauntPreparedValues::Text(updates) => { + execute_native_liboliphaunt_prepared_updates( + &runtime, + &db, + statement_name, + execution, + updates + .iter() + .map(|(lookup, value)| [value.clone(), lookup.to_string()]), + )?; + updates.len() + } + }; + exec_raw_checked( + &runtime, + &db, + &pg_query("COMMIT"), + "commit prepared-update transaction", + )?; + let elapsed = started.elapsed(); + + runtime + .block_on(db.close()) + .context("close native-liboliphaunt prepared-update database")?; + cleanup_native_liboliphaunt_benchmark_root(engine, &root, "prepared-update")?; + + Ok(PreparedUpdateChildMetrics { + open_micros, + connect_micros: 0, + setup_micros, + prepare_micros: Some(prepare_micros), + elapsed_micros: elapsed.as_micros(), + operation_count, + average_micros: elapsed.as_micros() as f64 / operation_count as f64, + }) +} + +fn native_liboliphaunt_builder( + root: &Path, + engine: NativeLiboliphauntEngineMode, + tuning: &NativeBenchmarkTuning, +) -> NativeOliphauntBuilder { + let builder = NativeOliphaunt::builder() + .path(root) + .durability(tuning.durability) + .runtime_footprint(tuning.runtime_footprint) + .startup_gucs(tuning.startup_gucs.clone()); + match engine { + NativeLiboliphauntEngineMode::Direct => builder.native_direct(), + NativeLiboliphauntEngineMode::Broker => builder.native_broker(), + NativeLiboliphauntEngineMode::Server => builder.native_server(), + } +} + +fn native_liboliphaunt_benchmark_root(engine: &str, label: &str) -> Result { + let root = env::current_dir() + .context("read current directory")? + .join("target/perf") + .join(format!( + "native-liboliphaunt-{engine}-{label}-{}-{}", + std::process::id(), + now_micros()? + )); + if root.exists() { + fs::remove_dir_all(&root) + .with_context(|| format!("remove stale native liboliphaunt root {}", root.display()))?; + } + fs::create_dir_all(&root) + .with_context(|| format!("create native liboliphaunt root {}", root.display()))?; + Ok(root) +} + +fn cleanup_native_liboliphaunt_benchmark_root( + engine: NativeLiboliphauntEngineMode, + root: &Path, + label: &str, +) -> Result<()> { + if engine == NativeLiboliphauntEngineMode::Direct { + return Ok(()); + } + fs::remove_dir_all(root) + .with_context(|| format!("remove native liboliphaunt {label} root {}", root.display())) +} + +fn native_liboliphaunt_runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build native liboliphaunt benchmark runtime") +} + +enum NativeLiboliphauntPreparedValues { + Numeric(Vec<(i32, i32)>), + Text(Vec<(i32, String)>), +} + +fn execute_native_liboliphaunt_prepared_updates( + runtime: &tokio::runtime::Runtime, + db: &NativeOliphaunt, + statement_name: &str, + execution: PreparedExecution, + values: I, +) -> Result<()> +where + I: IntoIterator, +{ + match execution { + PreparedExecution::Sequential => { + for value_pair in values { + let mut batch = Vec::new(); + batch.extend(pg_bind(None, statement_name, &value_pair)); + batch.extend(pg_execute(None)); + batch.extend(pg_sync()); + exec_raw_checked( + runtime, + db, + &batch, + "execute sequential native-liboliphaunt prepared update", + )?; + } + } + PreparedExecution::Pipelined => { + let mut batch = Vec::new(); + for (idx, value_pair) in values.into_iter().enumerate() { + let portal = format!("p{idx}"); + batch.extend(pg_bind(Some(&portal), statement_name, &value_pair)); + batch.extend(pg_execute(Some(&portal))); + batch.extend(pg_close(b'P', Some(&portal))); + } + batch.extend(pg_sync()); + exec_raw_checked( + runtime, + db, + &batch, + "execute pipelined native-liboliphaunt prepared updates", + )?; + } + } + Ok(()) +} + +fn exec_raw_checked( + runtime: &tokio::runtime::Runtime, + db: &NativeOliphaunt, + message: &[u8], + context: &'static str, +) -> Result<()> { + let response = runtime + .block_on(db.exec_protocol_raw(NativeProtocolRequest::new(message.to_vec()))) + .with_context(|| context)?; + ensure_protocol_response_ok(response.as_bytes()).with_context(|| context) +} + +pub(super) fn run_native_liboliphaunt_speed_hotspot_diagnostic_case( + cases: &[SpeedCase], + target_index: usize, + options: &SpeedDiagnosticOptions, +) -> Result { + let target = &cases[target_index]; + let root = native_liboliphaunt_benchmark_root("direct", "diagnose-speed")?; + let runtime = native_liboliphaunt_runtime()?; + let open_started = Instant::now(); + let db = runtime + .block_on( + NativeOliphaunt::builder() + .path(&root) + .native_direct() + .durability(options.durability) + .open(), + ) + .with_context(|| { + format!( + "open native liboliphaunt diagnostic database for {}", + target.id + ) + })?; + let open_micros = open_started.elapsed().as_micros(); + let mut child_rss = NativeLiboliphauntChildRssSampler::new(); + child_rss.sample(); + + let setup_started = Instant::now(); + for setup_case in &cases[..target_index] { + runtime + .block_on(db.execute(&setup_case.sql)) + .with_context(|| format!("run native liboliphaunt setup case {}", setup_case.id))?; + child_rss.sample(); + } + let setup_micros = setup_started.elapsed().as_micros(); + + let started = Instant::now(); + runtime + .block_on(db.execute(&target.sql)) + .with_context(|| format!("run native liboliphaunt measured case {}", target.id))?; + let elapsed_micros = started.elapsed().as_micros(); + child_rss.sample(); + let settings = runtime + .block_on(db.execute(speed_diagnostic_settings_sql())) + .map(|response| diagnostic_settings_from_protocol_response(response.as_bytes())) + .unwrap_or_else(|error| serde_json::json!({ "error": error.to_string() })); + + runtime.block_on(db.close())?; + + Ok(SpeedHotspotDiagnosticCase { + engine: DiagnosticEngine::NativeOliphaunt.label(), + process_model: "native_liboliphaunt_in_process_standalone_backend", + id: target.id.to_owned(), + label: target.label.clone(), + open_micros: Some(open_micros), + connect_micros: None, + setup_micros, + elapsed_micros, + operation_count: target.operation_count, + settings, + observed_server_peak_rss_bytes: child_rss.peak_bytes(), + fs_trace: serde_json::Value::Null, + phases: Vec::new(), + }) +} diff --git a/tools/perf/runner/src/native_postgres.rs b/tools/perf/runner/src/native_postgres.rs new file mode 100644 index 00000000..5b78566f --- /dev/null +++ b/tools/perf/runner/src/native_postgres.rs @@ -0,0 +1,1014 @@ +use super::*; + +pub(super) fn run_native_postgres_streaming_benchmark( + native: &NativePostgres, + open_micros: u128, + server_rss: &mut ProcessTreeRssSampler, +) -> Result { + let connect_started = Instant::now(); + let mut client = NativePostgresRawClient::connect(native)?; + let connect_micros = connect_started.elapsed().as_micros(); + server_rss.sample(); + + let mut tests = Vec::new(); + for case in streaming_cases() { + let mut bytes = 0usize; + let mut chunks = 0usize; + let started = Instant::now(); + client.exec_streaming(case.sql, |chunk| { + bytes = bytes.saturating_add(chunk.len()); + chunks = chunks.saturating_add(1); + Ok(()) + })?; + tests.push(single_sample_result( + case.id, + format!( + "{}; streamed {bytes} bytes across {chunks} protocol frame(s)", + case.label + ), + "seconds", + bytes, + started.elapsed(), + )); + server_rss.sample(); + } + client.terminate()?; + + Ok(BenchmarkRun { + suite: "streaming", + mode: "native_postgres_raw", + description: "Native Postgres streaming control over the raw PostgreSQL wire protocol against the liboliphaunt-matched template1 database target.", + open_micros, + connect_micros: Some(connect_micros), + setup_micros: 0, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + tests, + }) +} + +pub(super) fn run_native_postgres_backup_restore_benchmark( + native: &NativePostgres, + postgres_bin: &Path, + open_micros: u128, + server_rss: &mut ProcessTreeRssSampler, +) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create native Postgres backup/restore Tokio runtime")?; + let setup_started = Instant::now(); + runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, native); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect native Postgres backup setup client")?; + let connection_task = tokio::spawn(async move { + let _ = connection.await; + }); + client + .simple_query(&backup_restore_setup_sql()) + .await + .context("execute native Postgres backup/restore setup")?; + drop(client); + let _ = connection_task.await; + Ok::<_, anyhow::Error>(()) + })?; + let setup_micros = setup_started.elapsed().as_micros(); + server_rss.sample(); + + let backup_path = native.root.join("backup.dump"); + let backup_started = Instant::now(); + run_native_postgres_tool( + native, + native_postgres_sibling_tool(postgres_bin, "pg_dump"), + [ + "-d".to_owned(), + NATIVE_BENCHMARK_DATABASE.to_owned(), + "-Fc".to_owned(), + "-f".to_owned(), + backup_path.display().to_string(), + ], + ) + .context("run native Postgres pg_dump backup")?; + let backup_elapsed = backup_started.elapsed(); + let backup_bytes = fs::metadata(&backup_path) + .with_context(|| format!("stat native Postgres backup {}", backup_path.display()))? + .len() as usize; + ensure!(backup_bytes > 0, "native Postgres backup was empty"); + server_rss.sample(); + + let restore_started = Instant::now(); + runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, native); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect native Postgres restore control client")?; + let connection_task = tokio::spawn(async move { + let _ = connection.await; + }); + client + .simple_query("DROP DATABASE IF EXISTS oliphaunt_restore") + .await + .context("drop native Postgres restore database")?; + client + .simple_query("CREATE DATABASE oliphaunt_restore TEMPLATE template0") + .await + .context("create native Postgres restore database")?; + drop(client); + let _ = connection_task.await; + Ok::<_, anyhow::Error>(()) + })?; + run_native_postgres_tool( + native, + native_postgres_sibling_tool(postgres_bin, "pg_restore"), + [ + "-d".to_owned(), + "oliphaunt_restore".to_owned(), + backup_path.display().to_string(), + ], + ) + .context("run native Postgres pg_restore")?; + runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client_db(&mut config, native, "oliphaunt_restore"); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect restored native Postgres database")?; + let connection_task = tokio::spawn(async move { + let _ = connection.await; + }); + let row = client + .query_one("SELECT count(*)::int8 FROM backup_restore_items", &[]) + .await + .context("query restored native Postgres row count")?; + let count: i64 = row.get(0); + ensure!( + count == BACKUP_RESTORE_EXPECTED_ROWS as i64, + "native Postgres restored row count mismatch: got {count}, expected {BACKUP_RESTORE_EXPECTED_ROWS}" + ); + drop(client); + let _ = connection_task.await; + Ok::<_, anyhow::Error>(()) + })?; + let restore_elapsed = restore_started.elapsed(); + server_rss.sample(); + + Ok(BenchmarkRun { + suite: "backup-restore", + mode: "native_postgres", + description: "Native Postgres backup/restore control using pg_dump -Fc and pg_restore against the liboliphaunt-matched temporary cluster.", + open_micros, + connect_micros: None, + setup_micros, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + tests: vec![ + single_sample_result( + "pg_dump_custom_backup", + format!( + "pg_dump custom-format backup; backup size {}", + fmt_bytes_label(backup_bytes) + ), + "seconds", + backup_bytes, + backup_elapsed, + ), + single_sample_result( + "pg_restore_custom_backup", + format!( + "pg_restore custom-format restore; backup size {}", + fmt_bytes_label(backup_bytes) + ), + "seconds", + backup_bytes, + restore_elapsed, + ), + ], + }) +} + +pub(super) fn run_native_postgres_physical_backup_restore_benchmark( + native: &NativePostgres, + postgres_bin: &Path, + open_micros: u128, + server_rss: &mut ProcessTreeRssSampler, + tuning: &NativeBenchmarkTuning, +) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create native Postgres physical backup/restore Tokio runtime")?; + let setup_started = Instant::now(); + runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, native); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect native Postgres physical backup setup client")?; + let connection_task = tokio::spawn(async move { + let _ = connection.await; + }); + client + .simple_query(&backup_restore_setup_sql()) + .await + .context("execute native Postgres physical backup setup")?; + drop(client); + let _ = connection_task.await; + Ok::<_, anyhow::Error>(()) + })?; + let setup_micros = setup_started.elapsed().as_micros(); + server_rss.sample(); + + let backup_started = Instant::now(); + let archive_bytes = native_postgres_physical_archive(&runtime, native) + .context("create native Postgres physical archive")?; + let backup_elapsed = backup_started.elapsed(); + let archive_len = archive_bytes.len(); + ensure!(archive_len > 0, "native Postgres physical backup was empty"); + server_rss.sample(); + + let restore_root = env::current_dir() + .context("read current directory")? + .join("target/perf") + .join(format!("npg-r-{}-{}", std::process::id(), now_micros()?)); + let restore_started = Instant::now(); + native_postgres_restore_physical_archive(&archive_bytes, &restore_root).with_context(|| { + format!( + "restore native Postgres physical archive into {}", + restore_root.display() + ) + })?; + let restore_elapsed = restore_started.elapsed(); + + let restore_data_dir = restore_root.join("pgdata"); + let mut restored = NativePostgres::start_existing( + postgres_bin, + restore_root.clone(), + restore_data_dir, + tuning, + ) + .context("start restored native Postgres physical backup")?; + runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, &restored); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect restored native Postgres physical backup")?; + let connection_task = tokio::spawn(async move { + let _ = connection.await; + }); + let row = client + .query_one("SELECT count(*)::int8 FROM backup_restore_items", &[]) + .await + .context("query restored native Postgres physical backup row count")?; + let count: i64 = row.get(0); + ensure!( + count == BACKUP_RESTORE_EXPECTED_ROWS as i64, + "native Postgres physical restored row count mismatch: got {count}, expected {BACKUP_RESTORE_EXPECTED_ROWS}" + ); + drop(client); + let _ = connection_task.await; + Ok::<_, anyhow::Error>(()) + })?; + server_rss.sample(); + terminate_child_gracefully(&mut restored.child); + + Ok(BenchmarkRun { + suite: "backup-restore", + mode: "native_postgres_physical", + description: "Native Postgres physical backup/restore control using pg_backup_start/pg_backup_stop and the same filtered PGDATA tar archive semantics as liboliphaunt.", + open_micros, + connect_micros: None, + setup_micros, + observed_server_peak_rss_bytes: server_rss.peak_bytes(), + tests: vec![ + single_sample_result( + "physical_archive_backup", + format!( + "Native Postgres physical archive backup; archive size {}", + fmt_bytes_label(archive_len) + ), + "seconds", + archive_len, + backup_elapsed, + ), + single_sample_result( + "physical_archive_restore", + format!( + "Native Postgres physical archive restore; archive size {}", + fmt_bytes_label(archive_len) + ), + "seconds", + archive_len, + restore_elapsed, + ), + ], + }) +} + +fn native_postgres_physical_archive( + runtime: &tokio::runtime::Runtime, + native: &NativePostgres, +) -> Result> { + let (client, connection_task) = runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, native); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect native Postgres physical backup client")?; + let connection_task = tokio::spawn(async move { + let _ = connection.await; + }); + client + .query_one( + "SELECT pg_backup_start(label => $1, fast => true)", + &[&NATIVE_POSTGRES_PHYSICAL_BACKUP_LABEL], + ) + .await + .context("start native Postgres physical backup")?; + Ok::<_, anyhow::Error>((client, connection_task)) + })?; + + let mut backup_stopped = false; + let archive_result = (|| -> Result> { + let mut bytes = Vec::new(); + { + let mut archive = TarBuilder::new(&mut bytes); + native_postgres_append_pgdata_tree(&mut archive, &native.data_dir)?; + let stop_files = runtime.block_on(native_postgres_stop_physical_backup(&client))?; + backup_stopped = true; + native_postgres_append_pg_wal_tree(&mut archive, &native.data_dir)?; + native_postgres_append_generated_file( + &mut archive, + "pgdata/backup_label", + stop_files.backup_label, + )?; + if let Some(tablespace_map) = stop_files.tablespace_map + && !tablespace_map.is_empty() + { + native_postgres_append_generated_file( + &mut archive, + "pgdata/tablespace_map", + tablespace_map, + )?; + } + archive + .finish() + .context("finish native Postgres physical archive")?; + } + Ok(bytes) + })(); + + if let Err(error) = &archive_result + && !backup_stopped + { + let stop_error = runtime + .block_on(native_postgres_stop_physical_backup(&client)) + .err(); + if let Some(stop_error) = stop_error { + drop(client); + runtime.block_on(async { + let _ = connection_task.await; + }); + bail!( + "{error}; also failed to leave native Postgres backup mode cleanly: {stop_error}" + ); + } + } + drop(client); + runtime.block_on(async { + let _ = connection_task.await; + }); + archive_result +} + +async fn native_postgres_stop_physical_backup( + client: &tokio_postgres::Client, +) -> Result { + let row = client + .query_one( + "SELECT labelfile, spcmapfile FROM pg_backup_stop(wait_for_archive => false)", + &[], + ) + .await + .context("stop native Postgres physical backup")?; + Ok(NativePostgresBackupStopFiles { + backup_label: row.get::(0), + tablespace_map: row.get::>(1), + }) +} + +struct NativePostgresBackupStopFiles { + backup_label: String, + tablespace_map: Option, +} + +fn native_postgres_restore_physical_archive(bytes: &[u8], restore_root: &Path) -> Result<()> { + fs::remove_dir_all(restore_root) + .or_else(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(err) + } + }) + .with_context(|| { + format!( + "remove old native Postgres restore root {}", + restore_root.display() + ) + })?; + fs::create_dir_all(restore_root).with_context(|| { + format!( + "create native Postgres restore root {}", + restore_root.display() + ) + })?; + let mut archive = Archive::new(Cursor::new(bytes)); + archive.unpack(restore_root).with_context(|| { + format!( + "unpack native Postgres physical archive into {}", + restore_root.display() + ) + })?; + ensure!( + restore_root.join("pgdata/PG_VERSION").is_file(), + "native Postgres physical archive did not restore pgdata/PG_VERSION" + ); + Ok(()) +} + +fn native_postgres_append_pgdata_tree( + archive: &mut TarBuilder<&mut Vec>, + pgdata: &Path, +) -> Result<()> { + native_postgres_append_directory(archive, pgdata, Path::new("pgdata"))?; + for entry in native_postgres_sorted_read_dir(pgdata)? { + native_postgres_append_pgdata_entry(archive, pgdata, &entry.path(), false)?; + } + Ok(()) +} + +fn native_postgres_append_pg_wal_tree( + archive: &mut TarBuilder<&mut Vec>, + pgdata: &Path, +) -> Result<()> { + let pg_wal = pgdata.join("pg_wal"); + if !pg_wal.is_dir() { + return Ok(()); + } + for entry in native_postgres_sorted_read_dir(&pg_wal)? { + native_postgres_append_pgdata_entry(archive, pgdata, &entry.path(), true)?; + } + Ok(()) +} + +fn native_postgres_append_pgdata_entry( + archive: &mut TarBuilder<&mut Vec>, + pgdata: &Path, + source: &Path, + include_wal_contents: bool, +) -> Result<()> { + let relative = source.strip_prefix(pgdata).with_context(|| { + format!( + "strip PGDATA prefix {} from {}", + pgdata.display(), + source.display() + ) + })?; + if native_postgres_should_skip_pgdata_entry(relative, include_wal_contents) { + return Ok(()); + } + + let archive_path = Path::new("pgdata").join(relative); + let metadata = fs::symlink_metadata(source).with_context(|| { + format!( + "stat {} for native Postgres physical backup", + source.display() + ) + })?; + let file_type = metadata.file_type(); + if file_type.is_dir() { + native_postgres_append_directory(archive, source, &archive_path)?; + for entry in native_postgres_sorted_read_dir(source)? { + native_postgres_append_pgdata_entry( + archive, + pgdata, + &entry.path(), + include_wal_contents, + )?; + } + } else if file_type.is_file() { + let mut file = fs::File::open(source).with_context(|| { + format!( + "open {} for native Postgres physical backup", + source.display() + ) + })?; + archive + .append_file(&archive_path, &mut file) + .with_context(|| format!("archive native Postgres file {}", source.display()))?; + } else if file_type.is_symlink() { + bail!( + "native Postgres physical benchmark archive does not support symlinked PGDATA entry {}", + archive_path.display() + ); + } else { + bail!( + "native Postgres physical benchmark archive does not support non-regular PGDATA entry {}", + archive_path.display() + ); + } + Ok(()) +} + +fn native_postgres_should_skip_pgdata_entry(relative: &Path, include_wal_contents: bool) -> bool { + if relative == Path::new("postmaster.pid") || relative == Path::new("postmaster.opts") { + return true; + } + if relative + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "pg_internal.init" || name.starts_with("pgsql_tmp")) + { + return true; + } + let mut components = relative.components(); + let Some(Component::Normal(first)) = components.next() else { + return false; + }; + let has_child = components.next().is_some(); + if !has_child { + return false; + } + first.to_str().is_some_and(|name| { + NATIVE_POSTGRES_PHYSICAL_TRANSIENT_CONTENT_DIRS.contains(&name) + || (name == "pg_wal" && !include_wal_contents) + }) +} + +fn native_postgres_append_directory( + archive: &mut TarBuilder<&mut Vec>, + source: &Path, + archive_path: &Path, +) -> Result<()> { + archive + .append_dir(archive_path, source) + .with_context(|| format!("archive native Postgres directory {}", source.display())) +} + +fn native_postgres_append_generated_file( + archive: &mut TarBuilder<&mut Vec>, + archive_path: &str, + contents: String, +) -> Result<()> { + let bytes = contents.into_bytes(); + let mut header = TarHeader::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o600); + header.set_cksum(); + archive + .append_data(&mut header, archive_path, Cursor::new(bytes)) + .with_context(|| format!("archive native Postgres generated file {archive_path}")) +} + +fn native_postgres_sorted_read_dir(path: &Path) -> Result> { + let mut entries = fs::read_dir(path) + .with_context(|| format!("read native Postgres directory {}", path.display()))? + .collect::>>() + .with_context(|| format!("read native Postgres directory entry in {}", path.display()))?; + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries) +} + +fn native_postgres_sibling_tool(postgres_bin: &Path, tool: &str) -> PathBuf { + postgres_bin + .parent() + .map(|dir| dir.join(tool)) + .unwrap_or_else(|| PathBuf::from(tool)) +} + +fn run_native_postgres_tool(native: &NativePostgres, tool: PathBuf, extra_args: I) -> Result<()> +where + I: IntoIterator, +{ + let mut command = Command::new(&tool); + #[cfg(unix)] + command.arg("-h").arg(&native.socket_dir); + #[cfg(not(unix))] + command.arg("-h").arg("127.0.0.1"); + command + .arg("-p") + .arg(native.port.to_string()) + .arg("-U") + .arg("postgres"); + for arg in extra_args { + command.arg(arg); + } + let output = command + .output() + .with_context(|| format!("spawn native Postgres tool {}", tool.display()))?; + ensure!( + output.status.success(), + "native Postgres tool {} failed with {}:\nstdout:\n{}\nstderr:\n{}", + tool.display(), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +trait NativePostgresRawStream: Read + Write + Send {} + +impl NativePostgresRawStream for T where T: Read + Write + Send {} + +struct NativePostgresRawClient { + stream: BufReader>, +} + +impl NativePostgresRawClient { + fn connect(native: &NativePostgres) -> Result { + let mut stream = BufReader::with_capacity(64 * 1024, native_postgres_raw_stream(native)?); + write_native_postgres_startup(stream.get_mut().as_mut())?; + read_native_postgres_until_ready(&mut stream, true, &mut |_| Ok(()))?; + Ok(Self { stream }) + } + + fn exec_streaming( + &mut self, + sql: &str, + mut on_chunk: impl FnMut(&[u8]) -> Result<()>, + ) -> Result<()> { + let stream = self.stream.get_mut(); + stream + .write_all(&pg_query(sql)) + .and_then(|()| stream.flush()) + .context("write native Postgres streaming query")?; + read_native_postgres_until_ready(&mut self.stream, false, &mut on_chunk) + } + + fn terminate(&mut self) -> Result<()> { + self.stream + .get_mut() + .write_all(&[b'X', 0, 0, 0, 4]) + .and_then(|()| self.stream.get_mut().flush()) + .context("terminate native Postgres raw streaming client") + } +} + +fn native_postgres_raw_stream(native: &NativePostgres) -> Result> { + #[cfg(unix)] + { + let socket_path = native.socket_dir.join(format!(".s.PGSQL.{}", native.port)); + let stream = UnixStream::connect(&socket_path) + .with_context(|| format!("connect native Postgres socket {}", socket_path.display()))?; + stream + .set_read_timeout(Some(Duration::from_secs(120))) + .context("set native Postgres raw socket read timeout")?; + stream + .set_write_timeout(Some(Duration::from_secs(30))) + .context("set native Postgres raw socket write timeout")?; + Ok(Box::new(stream)) + } + #[cfg(not(unix))] + { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], native.port)); + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(15)) + .with_context(|| format!("connect native Postgres TCP socket {addr}"))?; + stream + .set_nodelay(true) + .context("set TCP_NODELAY on native Postgres raw socket")?; + stream + .set_read_timeout(Some(Duration::from_secs(120))) + .context("set native Postgres raw socket read timeout")?; + stream + .set_write_timeout(Some(Duration::from_secs(30))) + .context("set native Postgres raw socket write timeout")?; + Ok(Box::new(stream)) + } +} + +fn write_native_postgres_startup(stream: &mut dyn Write) -> Result<()> { + let mut body = Vec::new(); + body.extend_from_slice(&196_608_i32.to_be_bytes()); + push_cstr(&mut body, "user"); + push_cstr(&mut body, "postgres"); + push_cstr(&mut body, "database"); + push_cstr(&mut body, NATIVE_BENCHMARK_DATABASE); + body.push(0); + + let total_len = i32::try_from(body.len() + 4) + .map_err(|_| anyhow!("native Postgres startup message is too large"))?; + stream + .write_all(&total_len.to_be_bytes()) + .and_then(|()| stream.write_all(&body)) + .and_then(|()| stream.flush()) + .context("write native Postgres startup message") +} + +fn read_native_postgres_until_ready( + stream: &mut dyn Read, + startup: bool, + on_chunk: &mut dyn FnMut(&[u8]) -> Result<()>, +) -> Result<()> { + let mut callback_error = None; + let mut frame = Vec::with_capacity(8192); + loop { + frame.resize(5, 0); + stream + .read_exact(&mut frame[..5]) + .context("read native Postgres protocol header")?; + let tag = frame[0]; + let len = i32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]]); + ensure!( + len >= 4, + "native Postgres returned invalid frame length {len}" + ); + let body_len = (len as usize) - 4; + frame.resize(5 + body_len, 0); + stream + .read_exact(&mut frame[5..]) + .context("read native Postgres protocol body")?; + if callback_error.is_none() + && let Err(error) = on_chunk(&frame) + { + callback_error = Some(error); + } + match tag { + b'R' if startup => validate_native_postgres_authentication(&frame[5..])?, + b'E' => bail!("{}", parse_native_postgres_error_response(&frame[5..])), + b'Z' => return callback_error.map_or(Ok(()), Err), + _ => {} + } + } +} + +fn validate_native_postgres_authentication(body: &[u8]) -> Result<()> { + ensure!( + body.len() >= 4, + "native Postgres returned truncated authentication frame" + ); + let method = i32::from_be_bytes([body[0], body[1], body[2], body[3]]); + ensure!( + method == 0, + "native Postgres requested unsupported authentication method {method}" + ); + Ok(()) +} + +fn parse_native_postgres_error_response(body: &[u8]) -> String { + for field in body + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + { + if field[0] == b'M' { + return String::from_utf8_lossy(&field[1..]).into_owned(); + } + } + "native Postgres returned ErrorResponse".to_owned() +} + +pub(super) fn native_postgres_sqlx_options(native: &NativePostgres) -> PgConnectOptions { + PgConnectOptions::new_without_pgpass() + .host("127.0.0.1") + .port(native.port) + .username("postgres") + .database(NATIVE_BENCHMARK_DATABASE) + .ssl_mode(PgSslMode::Disable) +} + +pub(super) struct NativePostgres { + pub(super) child: Child, + pub(super) root: PathBuf, + pub(super) data_dir: PathBuf, + pub(super) socket_dir: PathBuf, + pub(super) port: u16, +} + +impl NativePostgres { + pub(super) fn start( + postgres_bin: &Path, + initdb_bin: &Path, + tuning: &NativeBenchmarkTuning, + ) -> Result { + let root = env::current_dir() + .context("read current directory")? + .join("target/perf") + .join(format!( + "native-postgres-{}-{}", + std::process::id(), + now_micros()? + )); + let data_dir = root.join("data"); + let socket_dir = root.join("socket"); + fs::create_dir_all(&data_dir).with_context(|| format!("create {}", data_dir.display()))?; + fs::create_dir_all(&socket_dir) + .with_context(|| format!("create {}", socket_dir.display()))?; + + let init_status = Command::new(initdb_bin) + .arg("-D") + .arg(&data_dir) + .args([ + "-A", + "trust", + "-U", + "postgres", + "--encoding=UTF8", + "--no-instructions", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .status() + .with_context(|| format!("spawn native initdb {}", initdb_bin.display()))?; + ensure!( + init_status.success(), + "native initdb failed with {init_status}" + ); + + Self::start_existing(postgres_bin, root, data_dir, tuning) + } + + pub(super) fn start_existing( + postgres_bin: &Path, + root: PathBuf, + data_dir: PathBuf, + tuning: &NativeBenchmarkTuning, + ) -> Result { + let socket_dir = root.join("socket"); + fs::create_dir_all(&socket_dir) + .with_context(|| format!("create {}", socket_dir.display()))?; + ensure!( + data_dir.join("PG_VERSION").is_file(), + "native postgres data directory is missing PG_VERSION: {}", + data_dir.display() + ); + + let port = reserve_loopback_port()?; + let log_path = root.join("postgres.log"); + let log = fs::File::create(&log_path) + .with_context(|| format!("create native Postgres log {}", log_path.display()))?; + let mut command = Command::new(postgres_bin); + command.arg("-D").arg(&data_dir); + #[cfg(unix)] + { + command + .arg("-h") + .arg("127.0.0.1") + .arg("-k") + .arg(&socket_dir); + } + #[cfg(not(unix))] + { + command.arg("-h").arg("127.0.0.1"); + } + command.arg("-p").arg(port.to_string()); + for assignment in tuning.native_postgres_control_assignments() { + command.arg("-c").arg(assignment); + } + let child = command + .stdout(Stdio::null()) + .stderr(Stdio::from(log)) + .spawn() + .with_context(|| format!("spawn native postgres {}", postgres_bin.display()))?; + + let mut native = Self { + child, + root, + data_dir, + socket_dir, + port, + }; + native.wait_ready(&log_path)?; + Ok(native) + } + + fn wait_ready(&mut self, log_path: &Path) -> Result<()> { + #[cfg(unix)] + let socket_path = self.socket_dir.join(format!(".s.PGSQL.{}", self.port)); + let start = Instant::now(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create native Postgres readiness Tokio runtime")?; + let mut last_probe_error = None; + while start.elapsed() < Duration::from_secs(15) { + if let Some(status) = self.child.try_wait().context("poll native postgres")? { + let log = fs::read_to_string(log_path).unwrap_or_default(); + bail!("native postgres exited early with {status}; log:\n{log}"); + } + #[cfg(unix)] + let transport_ready = socket_path.exists(); + #[cfg(not(unix))] + let transport_ready = true; + if transport_ready { + match runtime.block_on(self.probe_ready()) { + Ok(()) => return Ok(()), + Err(err) => last_probe_error = Some(err), + } + } + std::thread::sleep(Duration::from_millis(25)); + } + let log = fs::read_to_string(log_path).unwrap_or_default(); + let probe = last_probe_error + .map(|err| format!("last readiness probe error: {err}\n")) + .unwrap_or_default(); + bail!("native postgres did not become ready; {probe}log:\n{log}"); + } + + async fn probe_ready(&self) -> Result<()> { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, self); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect readiness probe")?; + let connection_task = tokio::spawn(async move { + let _ = connection.await; + }); + let query_result = client + .simple_query("SELECT 1") + .await + .context("run readiness probe query"); + drop(client); + let _ = connection_task.await; + query_result.map(|_| ()) + } +} + +fn reserve_loopback_port() -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .context("reserve loopback port for native Postgres benchmark")?; + let port = listener + .local_addr() + .context("read reserved native Postgres benchmark port")? + .port(); + drop(listener); + Ok(port) +} + +pub(super) fn configure_native_postgres_client( + config: &mut tokio_postgres::Config, + native: &NativePostgres, +) { + configure_native_postgres_client_db(config, native, NATIVE_BENCHMARK_DATABASE) +} + +pub(super) fn configure_native_postgres_client_db( + config: &mut tokio_postgres::Config, + native: &NativePostgres, + database: &str, +) { + config.user("postgres").dbname(database).port(native.port); + #[cfg(unix)] + { + config.host_path(&native.socket_dir); + } + #[cfg(not(unix))] + { + config.host("127.0.0.1"); + } +} + +impl Drop for NativePostgres { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + terminate_child_gracefully(&mut self.child); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + } + let _ = self.child.wait(); + } + let _ = fs::remove_dir_all(&self.root); + } +} + +fn terminate_child_gracefully(child: &mut Child) { + #[cfg(unix)] + { + let _ = Command::new("kill") + .arg("-TERM") + .arg(child.id().to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(5) { + if child.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(25)); + } + } + #[cfg(not(unix))] + { + let _ = child; + } +} diff --git a/tools/perf/runner/src/prepared_updates.rs b/tools/perf/runner/src/prepared_updates.rs new file mode 100644 index 00000000..c856b411 --- /dev/null +++ b/tools/perf/runner/src/prepared_updates.rs @@ -0,0 +1,1019 @@ +use super::*; + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn perf_prepared_updates(args: &[String]) -> Result<()> { + let mut rows = 25_000usize; + let mut skip_native = false; + let mut gate = false; + let mut only_sqlx = false; + let mut only_direct_raw = false; + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--skip-native" => { + skip_native = true; + } + "--gate" => { + gate = true; + } + "--only-sqlx" => { + only_sqlx = true; + skip_native = true; + } + "--only-direct-raw" => { + only_direct_raw = true; + skip_native = true; + } + "--rows" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--rows requires a value"))?; + rows = value + .parse() + .with_context(|| format!("parse --rows value {value:?}"))?; + } + other => bail!( + "unknown perf prepared-updates flag: {other}; use --skip-native, --gate, --rows, --only-sqlx, or --only-direct-raw" + ), + } + cursor += 1; + } + ensure!(rows > 0, "--rows must be greater than zero"); + ensure!( + !(only_sqlx && only_direct_raw), + "--only-sqlx and --only-direct-raw are mutually exclusive" + ); + + Oliphaunt::preload()?; + let numeric_updates = parsed_numeric_updates(rows)?; + let text_updates = parsed_text_updates(rows)?; + ensure!( + numeric_updates.len() == rows && text_updates.len() == rows, + "prepared update parser returned fewer rows than requested" + ); + + let mut runs = Vec::new(); + if !only_direct_raw { + runs.push(oliphaunt_prepared_update_run( + "oliphaunt_server_sqlx", + "OliphauntServer over TCP using SQLx parameterized queries and SQLx statement cache.", + || run_oliphaunt_sqlx_prepared_update_tests(&numeric_updates, &text_updates), + )?); + } + if only_direct_raw { + runs.push(oliphaunt_prepared_update_run( + "oliphaunt_direct_raw_pipelined_prepared", + "Direct embedded Oliphaunt raw frontend/backend protocol with one prepared statement and one pipelined Bind/Execute batch per test.", + || { + run_oliphaunt_direct_raw_prepared_update_tests( + &numeric_updates, + &text_updates, + PreparedExecution::Pipelined, + ) + }, + )?); + } + if !only_sqlx && !only_direct_raw { + runs.push(oliphaunt_prepared_update_run( + "oliphaunt_server_tcp_tokio_postgres_prepared", + "OliphauntServer over TCP using tokio-postgres explicit prepared statements.", + || { + run_oliphaunt_tokio_prepared_update_tests( + &numeric_updates, + &text_updates, + OliphauntPreparedEndpoint::Tcp, + PreparedExecution::Sequential, + ) + }, + )?); + runs.push(oliphaunt_prepared_update_run( + "oliphaunt_server_tcp_tokio_postgres_pipelined_prepared", + "OliphauntServer over TCP using tokio-postgres explicit prepared statements with all update futures pipelined inside one transaction.", + || { + run_oliphaunt_tokio_prepared_update_tests( + &numeric_updates, + &text_updates, + OliphauntPreparedEndpoint::Tcp, + PreparedExecution::Pipelined, + ) + }, + )?); + } + #[cfg(unix)] + if !only_sqlx && !only_direct_raw { + runs.push(oliphaunt_prepared_update_run( + "oliphaunt_server_unix_tokio_postgres_prepared", + "OliphauntServer over Unix socket using tokio-postgres explicit prepared statements.", + || { + run_oliphaunt_tokio_prepared_update_tests( + &numeric_updates, + &text_updates, + OliphauntPreparedEndpoint::Unix, + PreparedExecution::Sequential, + ) + }, + )?); + runs.push(oliphaunt_prepared_update_run( + "oliphaunt_server_unix_tokio_postgres_pipelined_prepared", + "OliphauntServer over Unix socket using tokio-postgres explicit prepared statements with all update futures pipelined inside one transaction.", + || run_oliphaunt_tokio_prepared_update_tests( + &numeric_updates, + &text_updates, + OliphauntPreparedEndpoint::Unix, + PreparedExecution::Pipelined, + ), + )?); + } + let mut native_tuning_report = None; + if !skip_native { + let native_postgres = env::var("OLIPHAUNT_POSTGRES") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("postgres")); + let native_initdb = env::var("OLIPHAUNT_INITDB") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("initdb")); + let native_tuning = NativeBenchmarkTuning::default(); + runs.push(PreparedUpdateRun { + mode: "native_tokio_postgres_prepared".to_owned(), + description: + "Native Postgres over Unix socket using tokio-postgres explicit prepared statements." + .to_owned(), + protocol_stats: None, + tests: run_native_prepared_update_tests( + &native_postgres, + &native_initdb, + &native_tuning, + &numeric_updates, + &text_updates, + PreparedExecution::Sequential, + )?, + }); + runs.push(PreparedUpdateRun { + mode: "native_tokio_postgres_pipelined_prepared".to_owned(), + description: "Native Postgres over Unix socket using tokio-postgres explicit prepared statements with all update futures pipelined inside one transaction.".to_owned(), + protocol_stats: None, + tests: run_native_prepared_update_tests( + &native_postgres, + &native_initdb, + &native_tuning, + &numeric_updates, + &text_updates, + PreparedExecution::Pipelined, + )?, + }); + native_tuning_report = Some(native_tuning.report()); + } + + let report = PreparedUpdateReport { + source_model: "Exact Oliphaunt fixture benchmark2/benchmark6 setup plus update values parsed from benchmark9 and benchmark10.", + measurement_model: "Each test uses a fresh database, creates the same indexed t2 table, prepares one parameterized UPDATE statement, then executes N updates inside one transaction. Oliphaunt server runs use one local server per test; native Postgres uses a temporary Unix-socket cluster with the same benchmark GUCs as perf native-postgres.", + gate_model: gate.then_some("Optional local regression gate for oliphaunt-wasix server prepared-update transport: SQLx and sequential tokio-postgres must stay below 5s per 25k rows, pipelined tokio-postgres must stay below 1.5s per 25k rows, non-COPY prepared traffic must not use streaming handoff, and pipelined prepared traffic must stay batched. Thresholds scale linearly with --rows."), + wasix_runtime_assets: Some(wasix_runtime_asset_report()?), + native_tuning: native_tuning_report, + rows, + runs, + }; + + println!("{}", serde_json::to_string_pretty(&report)?); + if gate { + validate_prepared_update_gate(&report)?; + } + Ok(()) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +pub(super) fn perf_prepared_updates(args: &[String]) -> Result<()> { + let _ = args; + legacy_oliphaunt_unavailable("perf prepared-updates") +} + +#[cfg(feature = "legacy-oliphaunt")] +fn oliphaunt_prepared_update_run( + mode: &'static str, + description: &'static str, + run: impl FnOnce() -> Result>, +) -> Result { + reset_protocol_stats(); + let tests = match run() { + Ok(tests) => tests, + Err(err) => { + disable_protocol_stats(); + return Err(err); + } + }; + let protocol_stats = Some(protocol_stats_snapshot()); + disable_protocol_stats(); + Ok(PreparedUpdateRun { + mode: mode.to_owned(), + description: description.to_owned(), + protocol_stats, + tests, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn validate_prepared_update_gate(report: &PreparedUpdateReport) -> Result<()> { + let scale = report.rows as f64 / 25_000_f64; + for run in &report.runs { + let Some(base_limit_micros) = prepared_update_limit_micros(&run.mode) else { + continue; + }; + let limit = (base_limit_micros as f64 * scale).ceil() as u128; + for test in &run.tests { + ensure!( + test.elapsed_micros <= limit, + "prepared-update gate failed for {} {}: {:.3}ms > {:.3}ms", + run.mode, + test.id, + test.elapsed_micros as f64 / 1_000.0, + limit as f64 / 1_000.0 + ); + } + if let Some(stats) = run.protocol_stats.as_ref() { + ensure!( + stats.streaming_copy_handoffs == 0, + "prepared-update gate failed for {}: non-COPY traffic used streaming handoff", + run.mode + ); + } + if run.mode.contains("pipelined") { + let stats = run + .protocol_stats + .as_ref() + .context("missing protocol stats for pipelined prepared-update run")?; + ensure!( + stats.protocol_batches < 1_000, + "prepared-update gate failed for {}: pipelined traffic was not batched ({} protocol batches)", + run.mode, + stats.protocol_batches + ); + } + } + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn prepared_update_limit_micros(mode: &str) -> Option { + if mode.starts_with("native_") { + return None; + } + if mode.contains("pipelined") { + Some(1_500_000) + } else { + Some(5_000_000) + } +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_oliphaunt_sqlx_prepared_update_tests( + numeric_updates: &[(i32, i32)], + text_updates: &[(i32, String)], +) -> Result> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create prepared-update SQLx Tokio runtime")?; + + let numeric = run_oliphaunt_sqlx_prepared_update_case( + &runtime, + "numeric_indexed", + "Parameterized numeric UPDATEs with indexes on lookup and updated columns", + "UPDATE t2 SET b=$1 WHERE a=$2", + PreparedUpdateValues::Numeric(numeric_updates), + )?; + let text = run_oliphaunt_sqlx_prepared_update_case( + &runtime, + "text_indexed", + "Parameterized text UPDATEs with indexes on lookup and numeric column", + "UPDATE t2 SET c=$1 WHERE a=$2", + PreparedUpdateValues::Text(text_updates), + )?; + Ok(vec![numeric, text]) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_oliphaunt_direct_raw_prepared_update_tests( + numeric_updates: &[(i32, i32)], + text_updates: &[(i32, String)], + execution: PreparedExecution, +) -> Result> { + Ok(vec![ + run_oliphaunt_direct_raw_prepared_update_case( + "numeric_indexed", + "Parameterized numeric UPDATEs with indexes on lookup and updated columns", + "UPDATE t2 SET b=$1 WHERE a=$2", + &[23, 23], + DirectRawPreparedValues::Numeric(numeric_updates), + execution, + )?, + run_oliphaunt_direct_raw_prepared_update_case( + "text_indexed", + "Parameterized text UPDATEs with indexes on lookup and numeric column", + "UPDATE t2 SET c=$1 WHERE a=$2", + &[25, 23], + DirectRawPreparedValues::Text(text_updates), + execution, + )?, + ]) +} + +#[cfg(feature = "legacy-oliphaunt")] +enum DirectRawPreparedValues<'a> { + Numeric(&'a [(i32, i32)]), + Text(&'a [(i32, String)]), +} + +#[cfg(feature = "legacy-oliphaunt")] +impl DirectRawPreparedValues<'_> { + fn len(&self) -> usize { + match self { + Self::Numeric(values) => values.len(), + Self::Text(values) => values.len(), + } + } +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_oliphaunt_direct_raw_prepared_update_case( + id: &'static str, + label: &'static str, + sql: &'static str, + param_oids: &[i32], + values: DirectRawPreparedValues<'_>, + execution: PreparedExecution, +) -> Result { + let open_started = Instant::now(); + let mut db = Oliphaunt::builder() + .temporary() + .open() + .context("open direct raw prepared-update database")?; + let open_micros = open_started.elapsed().as_micros(); + let operation_count = values.len(); + + let setup_started = Instant::now(); + db.exec(&read_oliphaunt_benchmark_sql("2")?, None) + .context("execute direct raw prepared-update setup benchmark2")?; + db.exec(&read_oliphaunt_benchmark_sql("6")?, None) + .context("execute direct raw prepared-update setup benchmark6")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let statement_name = "oliphaunt_bench_update"; + let mut prepare = Vec::new(); + prepare.extend(pg_parse(Some(statement_name), sql, param_oids)); + prepare.extend(pg_describe(b'S', Some(statement_name))); + prepare.extend(pg_sync()); + let prepare_started = Instant::now(); + exec_wasix_raw_checked( + &mut db, + &prepare, + "prepare direct raw prepared-update statement", + )?; + let prepare_micros = prepare_started.elapsed().as_micros(); + + let started = Instant::now(); + exec_wasix_raw_checked(&mut db, &pg_query("BEGIN"), "begin direct raw transaction")?; + match values { + DirectRawPreparedValues::Numeric(updates) => execute_wasix_direct_raw_prepared_updates( + &mut db, + statement_name, + execution, + updates + .iter() + .map(|(lookup, value)| [value.to_string(), lookup.to_string()]), + )?, + DirectRawPreparedValues::Text(updates) => execute_wasix_direct_raw_prepared_updates( + &mut db, + statement_name, + execution, + updates + .iter() + .map(|(lookup, value)| [value.clone(), lookup.to_string()]), + )?, + } + exec_wasix_raw_checked( + &mut db, + &pg_query("COMMIT"), + "commit direct raw transaction", + )?; + let elapsed = started.elapsed(); + + db.close() + .context("close direct raw prepared-update database")?; + Ok(PreparedUpdateTest { + id, + label, + open_micros, + connect_micros: 0, + setup_micros, + prepare_micros: Some(prepare_micros), + elapsed_micros: elapsed.as_micros(), + operation_count, + average_micros: elapsed.as_micros() as f64 / operation_count as f64, + }) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn execute_wasix_direct_raw_prepared_updates( + db: &mut Oliphaunt, + statement_name: &str, + execution: PreparedExecution, + values: I, +) -> Result<()> +where + I: IntoIterator, +{ + match execution { + PreparedExecution::Sequential => { + for value_pair in values { + let mut batch = Vec::new(); + batch.extend(pg_bind(None, statement_name, &value_pair)); + batch.extend(pg_execute(None)); + batch.extend(pg_sync()); + exec_wasix_raw_checked( + db, + &batch, + "execute sequential direct raw prepared update", + )?; + } + } + PreparedExecution::Pipelined => { + let mut batch = Vec::new(); + for (idx, value_pair) in values.into_iter().enumerate() { + let portal = format!("p{idx}"); + batch.extend(pg_bind(Some(&portal), statement_name, &value_pair)); + batch.extend(pg_execute(Some(&portal))); + batch.extend(pg_close(b'P', Some(&portal))); + } + batch.extend(pg_sync()); + exec_wasix_raw_checked(db, &batch, "execute pipelined direct raw prepared updates")?; + } + } + Ok(()) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn exec_wasix_raw_checked(db: &mut Oliphaunt, message: &[u8], context: &'static str) -> Result<()> { + let response = db + .exec_protocol_raw(message, ExecProtocolOptions::no_sync()) + .with_context(|| context)?; + ensure_protocol_response_ok(&response).with_context(|| context) +} + +#[cfg(feature = "legacy-oliphaunt")] +enum PreparedUpdateValues<'a> { + Numeric(&'a [(i32, i32)]), + Text(&'a [(i32, String)]), +} + +#[cfg(feature = "legacy-oliphaunt")] +impl PreparedUpdateValues<'_> { + fn len(&self) -> usize { + match self { + Self::Numeric(values) => values.len(), + Self::Text(values) => values.len(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PreparedExecution { + Sequential, + Pipelined, +} + +#[cfg(feature = "legacy-oliphaunt")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OliphauntPreparedEndpoint { + Tcp, + #[cfg(unix)] + Unix, +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_oliphaunt_sqlx_prepared_update_case( + runtime: &tokio::runtime::Runtime, + id: &'static str, + label: &'static str, + sql: &'static str, + values: PreparedUpdateValues<'_>, +) -> Result { + let open_started = Instant::now(); + let server = OliphauntServer::temporary_tcp()?; + let open_micros = open_started.elapsed().as_micros(); + let uri = server.database_url(); + let operation_count = values.len(); + + let test = runtime.block_on(async { + let connect_started = Instant::now(); + let mut conn = sqlx::PgConnection::connect(&uri) + .await + .context("connect SQLx prepared-update client")?; + let connect_micros = connect_started.elapsed().as_micros(); + + let setup_started = Instant::now(); + conn.execute(read_oliphaunt_benchmark_sql("2")?.as_str()) + .await + .context("execute prepared-update SQLx setup benchmark2")?; + conn.execute(read_oliphaunt_benchmark_sql("6")?.as_str()) + .await + .context("execute prepared-update SQLx setup benchmark6")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let prepare_started = Instant::now(); + let _statement = conn + .prepare(sql) + .await + .with_context(|| format!("prepare SQLx statement {sql}"))?; + let prepare_micros = prepare_started.elapsed().as_micros(); + + let elapsed = measure_async_transaction_sqlx(&mut conn, sql, values).await?; + conn.close() + .await + .context("close SQLx prepared-update client")?; + + Ok::<_, anyhow::Error>(PreparedUpdateTest { + id, + label, + open_micros, + connect_micros, + setup_micros, + prepare_micros: Some(prepare_micros), + elapsed_micros: elapsed.as_micros(), + operation_count, + average_micros: elapsed.as_micros() as f64 / operation_count as f64, + }) + })?; + server.shutdown()?; + Ok(test) +} + +#[cfg(feature = "legacy-oliphaunt")] +async fn measure_async_transaction_sqlx( + conn: &mut sqlx::PgConnection, + sql: &'static str, + values: PreparedUpdateValues<'_>, +) -> Result { + let started = Instant::now(); + conn.execute("BEGIN") + .await + .context("begin SQLx transaction")?; + match values { + PreparedUpdateValues::Numeric(values) => { + for (lookup, value) in values { + sqlx::query(sql) + .bind(*value) + .bind(*lookup) + .execute(&mut *conn) + .await + .context("execute SQLx prepared numeric update")?; + } + } + PreparedUpdateValues::Text(values) => { + for (lookup, value) in values { + sqlx::query(sql) + .bind(value.as_str()) + .bind(*lookup) + .execute(&mut *conn) + .await + .context("execute SQLx prepared text update")?; + } + } + } + conn.execute("COMMIT") + .await + .context("commit SQLx transaction")?; + Ok(started.elapsed()) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn run_oliphaunt_tokio_prepared_update_tests( + numeric_updates: &[(i32, i32)], + text_updates: &[(i32, String)], + endpoint: OliphauntPreparedEndpoint, + execution: PreparedExecution, +) -> Result> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create prepared-update tokio-postgres runtime")?; + + Ok(vec![ + run_oliphaunt_tokio_prepared_update_case( + &runtime, + "numeric_indexed", + "Parameterized numeric UPDATEs with indexes on lookup and updated columns", + "UPDATE t2 SET b=$1 WHERE a=$2", + numeric_updates, + None, + endpoint, + execution, + )?, + run_oliphaunt_tokio_prepared_update_case( + &runtime, + "text_indexed", + "Parameterized text UPDATEs with indexes on lookup and numeric column", + "UPDATE t2 SET c=$1 WHERE a=$2", + &[], + Some(text_updates), + endpoint, + execution, + )?, + ]) +} + +#[cfg(feature = "legacy-oliphaunt")] +#[allow(clippy::too_many_arguments)] +fn run_oliphaunt_tokio_prepared_update_case( + runtime: &tokio::runtime::Runtime, + id: &'static str, + label: &'static str, + sql: &'static str, + numeric_updates: &[(i32, i32)], + text_updates: Option<&[(i32, String)]>, + endpoint: OliphauntPreparedEndpoint, + execution: PreparedExecution, +) -> Result { + let open_started = Instant::now(); + let server = start_prepared_update_oliphaunt_server(endpoint)?; + let open_micros = open_started.elapsed().as_micros(); + let connection = oliphaunt_prepared_update_connection(&server, endpoint)?; + #[cfg(unix)] + let cleanup_socket_dir = match &connection { + PreparedOliphauntConnection::Tcp(_) => None, + PreparedOliphauntConnection::Unix { socket_dir, .. } => Some(socket_dir.clone()), + }; + + let test = runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + config.user("postgres").dbname("template1"); + match &connection { + PreparedOliphauntConnection::Tcp(addr) => { + config.host(addr.ip().to_string()).port(addr.port()); + } + #[cfg(unix)] + PreparedOliphauntConnection::Unix { socket_dir, port } => { + config.host_path(socket_dir).port(*port); + } + } + let connect_started = Instant::now(); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect tokio-postgres prepared-update client")?; + let connection_task = tokio::spawn(async move { + if let Err(err) = connection.await { + eprintln!("prepared-update oliphaunt connection error: {err}"); + } + }); + let connect_micros = connect_started.elapsed().as_micros(); + + let result = run_tokio_prepared_update_case_on_client( + &client, + id, + label, + sql, + numeric_updates, + text_updates, + execution, + open_micros, + connect_micros, + ) + .await; + drop(client); + let _ = connection_task.await; + result + })?; + server.shutdown()?; + #[cfg(unix)] + if let Some(socket_dir) = cleanup_socket_dir { + let _ = fs::remove_dir_all(socket_dir); + } + Ok(test) +} + +#[cfg(feature = "legacy-oliphaunt")] +fn start_prepared_update_oliphaunt_server( + endpoint: OliphauntPreparedEndpoint, +) -> Result { + match endpoint { + OliphauntPreparedEndpoint::Tcp => OliphauntServer::temporary_tcp(), + #[cfg(unix)] + OliphauntPreparedEndpoint::Unix => { + let socket_dir = env::current_dir() + .context("read current directory")? + .join("target/perf") + .join(format!( + "oliphaunt-prepared-unix-{}-{}", + std::process::id(), + now_micros()? + )); + let port = 5432; + let socket_path = socket_dir.join(format!(".s.PGSQL.{port}")); + OliphauntServer::builder() + .temporary() + .unix(socket_path) + .start() + } + } +} + +#[cfg(feature = "legacy-oliphaunt")] +enum PreparedOliphauntConnection { + Tcp(std::net::SocketAddr), + #[cfg(unix)] + Unix { + socket_dir: PathBuf, + port: u16, + }, +} + +#[cfg(feature = "legacy-oliphaunt")] +fn oliphaunt_prepared_update_connection( + server: &OliphauntServer, + endpoint: OliphauntPreparedEndpoint, +) -> Result { + match endpoint { + OliphauntPreparedEndpoint::Tcp => { + let addr = server + .tcp_addr() + .ok_or_else(|| anyhow!("prepared-update OliphauntServer did not bind TCP"))?; + Ok(PreparedOliphauntConnection::Tcp(addr)) + } + #[cfg(unix)] + OliphauntPreparedEndpoint::Unix => { + let socket_path = server.socket_path().ok_or_else(|| { + anyhow!("prepared-update OliphauntServer did not bind Unix socket") + })?; + let socket_dir = socket_path + .parent() + .ok_or_else(|| anyhow!("prepared-update Unix socket has no parent directory"))? + .to_path_buf(); + let port = socket_path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix(".s.PGSQL.")) + .ok_or_else(|| { + anyhow!( + "prepared-update Unix socket path is not libpq-shaped: {}", + socket_path.display() + ) + })? + .parse() + .context("parse prepared-update Unix socket port")?; + Ok(PreparedOliphauntConnection::Unix { socket_dir, port }) + } + } +} + +pub(super) fn run_native_prepared_update_tests( + postgres_bin: &Path, + initdb_bin: &Path, + tuning: &NativeBenchmarkTuning, + numeric_updates: &[(i32, i32)], + text_updates: &[(i32, String)], + execution: PreparedExecution, +) -> Result> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create native prepared-update Tokio runtime")?; + + Ok(vec![ + run_native_prepared_update_case( + &runtime, + postgres_bin, + initdb_bin, + tuning, + "numeric_indexed", + "Parameterized numeric UPDATEs with indexes on lookup and updated columns", + "UPDATE t2 SET b=$1 WHERE a=$2", + numeric_updates, + None, + execution, + )?, + run_native_prepared_update_case( + &runtime, + postgres_bin, + initdb_bin, + tuning, + "text_indexed", + "Parameterized text UPDATEs with indexes on lookup and numeric column", + "UPDATE t2 SET c=$1 WHERE a=$2", + &[], + Some(text_updates), + execution, + )?, + ]) +} + +#[allow(clippy::too_many_arguments)] +fn run_native_prepared_update_case( + runtime: &tokio::runtime::Runtime, + postgres_bin: &Path, + initdb_bin: &Path, + tuning: &NativeBenchmarkTuning, + id: &'static str, + label: &'static str, + sql: &'static str, + numeric_updates: &[(i32, i32)], + text_updates: Option<&[(i32, String)]>, + execution: PreparedExecution, +) -> Result { + let open_started = Instant::now(); + let native = NativePostgres::start(postgres_bin, initdb_bin, tuning)?; + let open_micros = open_started.elapsed().as_micros(); + + runtime.block_on(async { + let mut config = tokio_postgres::Config::new(); + configure_native_postgres_client(&mut config, &native); + let connect_started = Instant::now(); + let (client, connection) = config + .connect(tokio_postgres::NoTls) + .await + .context("connect native prepared-update client")?; + let connection_task = tokio::spawn(async move { + if let Err(err) = connection.await { + eprintln!("native prepared-update connection error: {err}"); + } + }); + let connect_micros = connect_started.elapsed().as_micros(); + + let result = run_tokio_prepared_update_case_on_client( + &client, + id, + label, + sql, + numeric_updates, + text_updates, + execution, + open_micros, + connect_micros, + ) + .await; + drop(client); + let _ = connection_task.await; + result + }) +} + +#[allow(clippy::too_many_arguments)] +async fn run_tokio_prepared_update_case_on_client( + client: &tokio_postgres::Client, + id: &'static str, + label: &'static str, + sql: &'static str, + numeric_updates: &[(i32, i32)], + text_updates: Option<&[(i32, String)]>, + execution: PreparedExecution, + open_micros: u128, + connect_micros: u128, +) -> Result { + let setup_started = Instant::now(); + client + .simple_query(&read_oliphaunt_benchmark_sql("2")?) + .await + .context("execute prepared-update setup benchmark2")?; + client + .simple_query(&read_oliphaunt_benchmark_sql("6")?) + .await + .context("execute prepared-update setup benchmark6")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let prepare_started = Instant::now(); + let statement = client + .prepare(sql) + .await + .with_context(|| format!("prepare tokio-postgres statement {sql}"))?; + let prepare_micros = prepare_started.elapsed().as_micros(); + + let started = Instant::now(); + client + .simple_query("BEGIN") + .await + .context("begin tokio-postgres prepared-update transaction")?; + let operation_count = if let Some(text_updates) = text_updates { + match execution { + PreparedExecution::Sequential => { + for (lookup, value) in text_updates { + let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [value, lookup]; + client + .execute(&statement, ¶ms) + .await + .context("execute tokio-postgres prepared text update")?; + } + } + PreparedExecution::Pipelined => { + let updates = text_updates.iter().map(|(lookup, value)| { + let statement = &statement; + async move { + let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = + [value, lookup]; + client.execute(statement, ¶ms).await + } + }); + try_join_all(updates) + .await + .context("execute pipelined tokio-postgres prepared text updates")?; + } + } + text_updates.len() + } else { + match execution { + PreparedExecution::Sequential => { + for (lookup, value) in numeric_updates { + let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [value, lookup]; + client + .execute(&statement, ¶ms) + .await + .context("execute tokio-postgres prepared numeric update")?; + } + } + PreparedExecution::Pipelined => { + let updates = numeric_updates.iter().map(|(lookup, value)| { + let statement = &statement; + async move { + let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = + [value, lookup]; + client.execute(statement, ¶ms).await + } + }); + try_join_all(updates) + .await + .context("execute pipelined tokio-postgres prepared numeric updates")?; + } + } + numeric_updates.len() + }; + client + .simple_query("COMMIT") + .await + .context("commit tokio-postgres prepared-update transaction")?; + let elapsed = started.elapsed(); + + Ok(PreparedUpdateTest { + id, + label, + open_micros, + connect_micros, + setup_micros, + prepare_micros: Some(prepare_micros), + elapsed_micros: elapsed.as_micros(), + operation_count, + average_micros: elapsed.as_micros() as f64 / operation_count as f64, + }) +} + +pub(super) fn parsed_numeric_updates(limit: usize) -> Result> { + let sql = read_oliphaunt_benchmark_sql("9")?; + let mut updates = Vec::with_capacity(limit); + for line in sql.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("UPDATE t2 SET b=") else { + continue; + }; + let rest = rest + .strip_suffix(';') + .ok_or_else(|| anyhow!("numeric update line is missing semicolon: {line}"))?; + let (value, lookup) = rest + .split_once(" WHERE a=") + .ok_or_else(|| anyhow!("numeric update line has unexpected shape: {line}"))?; + updates.push((lookup.parse()?, value.parse()?)); + if updates.len() == limit { + break; + } + } + ensure!( + updates.len() == limit, + "benchmark9 only contained {} update rows; requested {limit}", + updates.len() + ); + Ok(updates) +} + +pub(super) fn parsed_text_updates(limit: usize) -> Result> { + let sql = read_oliphaunt_benchmark_sql("10")?; + let mut updates = Vec::with_capacity(limit); + for line in sql.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("UPDATE t2 SET c='") else { + continue; + }; + let rest = rest + .strip_suffix(';') + .ok_or_else(|| anyhow!("text update line is missing semicolon: {line}"))?; + let (value, lookup) = rest + .split_once("' WHERE a=") + .ok_or_else(|| anyhow!("text update line has unexpected shape: {line}"))?; + updates.push((lookup.parse()?, value.to_owned())); + if updates.len() == limit { + break; + } + } + ensure!( + updates.len() == limit, + "benchmark10 only contained {} update rows; requested {limit}", + updates.len() + ); + Ok(updates) +} diff --git a/tools/perf/runner/src/process_rss.rs b/tools/perf/runner/src/process_rss.rs new file mode 100644 index 00000000..62865358 --- /dev/null +++ b/tools/perf/runner/src/process_rss.rs @@ -0,0 +1,189 @@ +use std::collections::{HashMap, HashSet}; +use std::process::Command; + +use anyhow::{Context, Result}; + +pub(crate) struct ProcessTreeRssSampler { + root_pid: u32, + peak_bytes: u64, + warned: bool, +} + +impl ProcessTreeRssSampler { + pub(crate) fn new(root_pid: u32) -> Self { + Self { + root_pid, + peak_bytes: 0, + warned: false, + } + } + + pub(crate) fn sample(&mut self) { + match process_tree_rss_bytes(self.root_pid) { + Ok(Some(bytes)) => { + self.peak_bytes = self.peak_bytes.max(bytes); + } + Ok(None) => {} + Err(err) => { + if !self.warned { + eprintln!( + "warning: failed to sample native Postgres server RSS for pid {}: {err}", + self.root_pid + ); + self.warned = true; + } + } + } + } + + pub(crate) fn peak_bytes(&self) -> Option { + (self.peak_bytes > 0).then_some(self.peak_bytes) + } +} + +pub(crate) struct NativeLiboliphauntChildRssSampler { + parent_pid: u32, + peak_bytes: u64, + warned: bool, +} + +impl NativeLiboliphauntChildRssSampler { + pub(crate) fn new() -> Self { + Self { + parent_pid: std::process::id(), + peak_bytes: 0, + warned: false, + } + } + + pub(crate) fn sample(&mut self) { + match process_descendants_rss_bytes(self.parent_pid) { + Ok(Some(bytes)) => { + self.peak_bytes = self.peak_bytes.max(bytes); + } + Ok(None) => {} + Err(err) => { + if !self.warned { + eprintln!( + "warning: failed to sample native liboliphaunt child RSS for parent pid {}: {err}", + self.parent_pid + ); + self.warned = true; + } + } + } + } + + pub(crate) fn peak_bytes(&self) -> Option { + (self.peak_bytes > 0).then_some(self.peak_bytes) + } +} + +fn process_tree_rss_bytes(root_pid: u32) -> Result> { + let process_table = sample_process_table()?; + if !process_table.rss_by_pid.contains_key(&root_pid) { + return Ok(None); + } + + Ok(Some(sum_process_tree_rss(&process_table, vec![root_pid]))) +} + +fn process_descendants_rss_bytes(parent_pid: u32) -> Result> { + let process_table = sample_process_table()?; + let Some(children) = process_table.children_by_parent.get(&parent_pid) else { + return Ok(None); + }; + + let total = sum_process_tree_rss(&process_table, children.clone()); + Ok((total > 0).then_some(total)) +} + +fn sum_process_tree_rss(process_table: &ProcessTable, roots: Vec) -> u64 { + let mut total = 0u64; + let mut stack = roots; + let mut seen = HashSet::new(); + while let Some(pid) = stack.pop() { + if !seen.insert(pid) { + continue; + } + total = total.saturating_add( + process_table + .rss_by_pid + .get(&pid) + .copied() + .unwrap_or_default(), + ); + if let Some(children) = process_table.children_by_parent.get(&pid) { + stack.extend(children.iter().copied()); + } + } + total +} + +struct ProcessTable { + rss_by_pid: HashMap, + children_by_parent: HashMap>, +} + +fn sample_process_table() -> Result { + let output = Command::new("ps") + .args(["-axo", "pid=,ppid=,rss="]) + .output() + .context("sample process RSS with ps")?; + if !output.status.success() { + return Ok(ProcessTable { + rss_by_pid: HashMap::new(), + children_by_parent: HashMap::new(), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut rss_by_pid = HashMap::::new(); + let mut children_by_parent = HashMap::>::new(); + for line in stdout.lines() { + let mut parts = line.split_whitespace(); + let (Some(pid), Some(parent_pid), Some(rss_kb)) = + (parts.next(), parts.next(), parts.next()) + else { + continue; + }; + let (Ok(pid), Ok(parent_pid), Ok(rss_kb)) = ( + pid.parse::(), + parent_pid.parse::(), + rss_kb.parse::(), + ) else { + continue; + }; + rss_by_pid.insert(pid, rss_kb.saturating_mul(1024)); + children_by_parent.entry(parent_pid).or_default().push(pid); + } + Ok(ProcessTable { + rss_by_pid, + children_by_parent, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sums_root_process_and_descendants() { + let process_table = ProcessTable { + rss_by_pid: HashMap::from([(1, 100), (2, 20), (3, 30), (4, 4), (9, 900)]), + children_by_parent: HashMap::from([(1, vec![2, 3]), (3, vec![4]), (9, vec![])]), + }; + + assert_eq!(sum_process_tree_rss(&process_table, vec![1]), 154); + } + + #[test] + fn sums_multiple_roots_without_double_counting_cycles() { + let process_table = ProcessTable { + rss_by_pid: HashMap::from([(1, 10), (2, 20), (3, 30)]), + children_by_parent: HashMap::from([(1, vec![2, 3]), (2, vec![3]), (3, vec![1])]), + }; + + assert_eq!(sum_process_tree_rss(&process_table, vec![1, 2]), 60); + } +} diff --git a/tools/perf/runner/src/report.rs b/tools/perf/runner/src/report.rs new file mode 100644 index 00000000..bf67fb44 --- /dev/null +++ b/tools/perf/runner/src/report.rs @@ -0,0 +1,347 @@ +use super::*; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct ColdPerfReport { + pub(super) wasmer_version: &'static str, + pub(super) wasmer_wasix_version: &'static str, + pub(super) wasix_runtime_assets: WasixRuntimeAssetReport, + pub(super) cache_reset_requested: bool, + pub(super) cache_dir: String, + pub(super) cache_state_at_start: &'static str, + pub(super) measurement_model: &'static str, + pub(super) operations: Vec, + pub(super) experiments: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct PerfOperation { + pub(super) name: &'static str, + pub(super) description: &'static str, + pub(super) cache_state_before: String, + pub(super) process_state_before: &'static str, + pub(super) root_state: &'static str, + pub(super) query_state: &'static str, + pub(super) workload: &'static str, + pub(super) primary_latency_phase: &'static str, + pub(super) primary_latency_micros: u128, + pub(super) elapsed_micros: u128, + pub(super) correct: bool, + pub(super) phases: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct WarmPerfReport { + pub(super) wasmer_version: &'static str, + pub(super) wasmer_wasix_version: &'static str, + pub(super) wasix_runtime_assets: WasixRuntimeAssetReport, + pub(super) query_iterations: usize, + pub(super) connection_iterations: usize, + pub(super) measurement_model: &'static str, + pub(super) operations: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct BenchmarkReport { + pub(super) wasmer_version: &'static str, + pub(super) wasmer_wasix_version: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) wasix_runtime_assets: Option, + pub(super) source_model: &'static str, + pub(super) measurement_model: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) native_tuning: Option, + pub(super) rtt_iterations: usize, + pub(super) speed_scale: f64, + pub(super) preload_micros: u128, + pub(super) runs: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct BenchmarkRun { + pub(super) suite: &'static str, + pub(super) mode: &'static str, + pub(super) description: &'static str, + pub(super) open_micros: u128, + pub(super) connect_micros: Option, + pub(super) setup_micros: u128, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) observed_server_peak_rss_bytes: Option, + pub(super) tests: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct BenchmarkTestResult { + pub(super) id: &'static str, + pub(super) label: String, + pub(super) unit: &'static str, + pub(super) operation_count: usize, + pub(super) sample_count: usize, + pub(super) trimmed_sample_count: usize, + pub(super) elapsed_micros: u128, + pub(super) average_micros: Option, + pub(super) min_micros: Option, + pub(super) p50_micros: Option, + pub(super) p90_micros: Option, + pub(super) p95_micros: Option, + pub(super) p99_micros: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PreparedUpdateReport { + pub(super) source_model: &'static str, + pub(super) measurement_model: &'static str, + pub(super) gate_model: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) wasix_runtime_assets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) native_tuning: Option, + pub(super) rows: usize, + pub(super) runs: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PreparedUpdateRun { + pub(super) mode: String, + pub(super) description: String, + pub(super) protocol_stats: Option, + pub(super) tests: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PreparedUpdateTest { + pub(super) id: &'static str, + pub(super) label: &'static str, + pub(super) open_micros: u128, + pub(super) connect_micros: u128, + pub(super) setup_micros: u128, + pub(super) prepare_micros: Option, + pub(super) elapsed_micros: u128, + pub(super) operation_count: usize, + pub(super) average_micros: f64, +} + +#[derive(Debug, Clone)] +pub(super) struct NativeBenchmarkTuning { + pub(super) durability: NativeDurabilityProfile, + pub(super) runtime_footprint: RuntimeFootprintProfile, + pub(super) startup_gucs: Vec, +} + +impl Default for NativeBenchmarkTuning { + fn default() -> Self { + Self { + durability: NativeDurabilityProfile::Safe, + runtime_footprint: RuntimeFootprintProfile::Throughput, + startup_gucs: Vec::new(), + } + } +} + +impl NativeBenchmarkTuning { + fn postgres_startup_assignments(&self) -> Vec { + let mut assignments = Vec::new(); + for (name, value) in self.runtime_footprint.postgres_gucs() { + assignments.push(format!("{name}={value}")); + } + for (name, value) in self.durability.postgres_gucs() { + assignments.push(format!("{name}={value}")); + } + for guc in &self.startup_gucs { + assignments.push(format!("{}={}", guc.name.trim(), guc.value)); + } + assignments + } + + pub(super) fn native_postgres_control_assignments(&self) -> Vec { + let mut assignments = Vec::new(); + for (name, value) in self.runtime_footprint.postgres_gucs() { + assignments.push(format!("{name}={value}")); + } + for (name, value) in self.durability.postgres_gucs() { + assignments.push(format!("{name}={value}")); + } + assignments.extend( + [ + "max_worker_processes=0", + "max_parallel_workers=0", + "max_parallel_workers_per_gather=0", + "autovacuum=off", + "log_checkpoints=off", + ] + .into_iter() + .map(str::to_owned), + ); + for guc in &self.startup_gucs { + assignments.push(format!("{}={}", guc.name.trim(), guc.value)); + } + assignments + } + + pub(super) fn report(&self) -> NativeBenchmarkTuningReport { + NativeBenchmarkTuningReport { + durability: native_durability_arg(self.durability).to_owned(), + runtime_footprint: self.runtime_footprint.to_string(), + startup_gucs: self + .startup_gucs + .iter() + .map(|guc| format!("{}={}", guc.name.trim(), guc.value)) + .collect(), + postgres_startup_assignments: self.postgres_startup_assignments(), + native_postgres_control_assignments: self.native_postgres_control_assignments(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct NativeBenchmarkTuningReport { + pub(super) durability: String, + pub(super) runtime_footprint: String, + pub(super) startup_gucs: Vec, + pub(super) postgres_startup_assignments: Vec, + pub(super) native_postgres_control_assignments: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct WasixRuntimeAssetReport { + pub(super) source_lane: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) source_fingerprint: Option, + pub(super) postgres_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pgdata_template_source_lane: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pgdata_template_source_fingerprint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pgdata_template_postgres_version: Option, +} + +#[cfg(feature = "legacy-oliphaunt")] +pub(super) fn wasix_runtime_asset_report() -> Result { + let metadata = + oliphaunt_wasix::asset_manifest_metadata().context("read bundled WASIX asset manifest")?; + Ok(WasixRuntimeAssetReport { + source_lane: metadata + .source_lane + .unwrap_or_else(|| "oliphaunt-wasix".to_owned()), + source_fingerprint: metadata.source_fingerprint, + postgres_version: metadata.postgres_version, + pgdata_template_source_lane: metadata.pgdata_template_source_lane, + pgdata_template_source_fingerprint: metadata.pgdata_template_source_fingerprint, + pgdata_template_postgres_version: metadata.pgdata_template_postgres_version, + }) +} + +#[cfg(not(feature = "legacy-oliphaunt"))] +pub(super) fn wasix_runtime_asset_report() -> Result { + legacy_oliphaunt_unavailable("WASIX runtime asset provenance") +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct IndexedUpdateDiagnosticReport { + pub(super) source_model: &'static str, + pub(super) measurement_model: &'static str, + pub(super) wasix_runtime_assets: WasixRuntimeAssetReport, + pub(super) cases: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct IndexedUpdateDiagnosticCase { + pub(super) name: &'static str, + pub(super) description: &'static str, + pub(super) setup_micros: u128, + pub(super) elapsed_micros: u128, + pub(super) operation_count: usize, + pub(super) stats_before: serde_json::Value, + pub(super) stats_after: serde_json::Value, + pub(super) fs_trace: serde_json::Value, + pub(super) phases: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct SpeedHotspotDiagnosticReport { + pub(super) source_model: &'static str, + pub(super) measurement_model: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) wasix_runtime_assets: Option, + pub(super) cases: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct SpeedHotspotDiagnosticCase { + pub(super) engine: &'static str, + pub(super) process_model: &'static str, + pub(super) id: String, + pub(super) label: String, + pub(super) open_micros: Option, + pub(super) connect_micros: Option, + pub(super) setup_micros: u128, + pub(super) elapsed_micros: u128, + pub(super) operation_count: usize, + pub(super) settings: serde_json::Value, + pub(super) observed_server_peak_rss_bytes: Option, + pub(super) fs_trace: serde_json::Value, + pub(super) phases: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct BufferCacheDiagnosticReport { + pub(super) source_model: &'static str, + pub(super) measurement_model: &'static str, + pub(super) wasix_runtime_assets: WasixRuntimeAssetReport, + pub(super) cases: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct BufferCacheDiagnosticCase { + pub(super) id: String, + pub(super) label: String, + pub(super) setup_micros: u128, + pub(super) settings: serde_json::Value, + pub(super) relation_sizes: serde_json::Value, + pub(super) statements: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct BufferCacheDiagnosticStatement { + pub(super) sql: String, + pub(super) elapsed_micros: u128, + pub(super) explain_rows: serde_json::Value, + pub(super) fs_trace: serde_json::Value, + pub(super) wal_state: serde_json::Value, + pub(super) phases: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg(feature = "legacy-oliphaunt")] +pub(super) struct ColdPerfExperiment { + pub(super) name: &'static str, + pub(super) status: &'static str, + pub(super) implementation_risk: &'static str, + pub(super) artifact_size_impact: &'static str, + pub(super) notes: &'static str, +} diff --git a/tools/perf/runner/src/shared.rs b/tools/perf/runner/src/shared.rs new file mode 100644 index 00000000..d1b4c561 --- /dev/null +++ b/tools/perf/runner/src/shared.rs @@ -0,0 +1,208 @@ +use super::*; + +pub(super) fn parse_native_durability(value: &str) -> Result { + match value { + "safe" => Ok(NativeDurabilityProfile::Safe), + "balanced" => Ok(NativeDurabilityProfile::Balanced), + "fast-dev" | "fast_dev" | "fast" => Ok(NativeDurabilityProfile::FastDev), + other => bail!("unknown durability profile {other:?}; use safe, balanced, or fast-dev"), + } +} + +pub(super) fn parse_runtime_footprint(value: &str) -> Result { + match value { + "throughput" => Ok(RuntimeFootprintProfile::Throughput), + "balanced-mobile" | "balanced_mobile" | "balancedMobile" => { + Ok(RuntimeFootprintProfile::BalancedMobile) + } + "small-mobile" | "small_mobile" | "smallMobile" => Ok(RuntimeFootprintProfile::SmallMobile), + other => bail!( + "unknown runtime footprint profile {other:?}; use throughput, balanced-mobile, or small-mobile" + ), + } +} + +pub(super) fn parse_startup_guc(value: &str) -> Result { + let (name, guc_value) = value + .split_once('=') + .ok_or_else(|| anyhow!("startup GUC must be formatted as name=value"))?; + let name = name.trim(); + ensure!(!name.is_empty(), "startup GUC name must not be empty"); + ensure!( + name.bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')), + "startup GUC name {name:?} must contain only ASCII letters, digits, '_' or '.'" + ); + ensure!( + !guc_value.trim().is_empty(), + "startup GUC {name:?} value must not be empty" + ); + ensure!( + !name.as_bytes().contains(&0) && !guc_value.as_bytes().contains(&0), + "startup GUC must not contain NUL bytes" + ); + Ok(PostgresStartupGuc::new(name, guc_value)) +} + +pub(super) fn native_durability_arg(durability: NativeDurabilityProfile) -> &'static str { + match durability { + NativeDurabilityProfile::Safe => "safe", + NativeDurabilityProfile::Balanced => "balanced", + NativeDurabilityProfile::FastDev => "fast-dev", + } +} + +pub(super) const BACKUP_RESTORE_EXPECTED_ROWS: usize = 5_000; +pub(super) const NATIVE_POSTGRES_PHYSICAL_BACKUP_LABEL: &str = + "oliphaunt native postgres physical control"; +pub(super) const NATIVE_POSTGRES_PHYSICAL_TRANSIENT_CONTENT_DIRS: &[&str] = &[ + "pg_dynshmem", + "pg_notify", + "pg_serial", + "pg_snapshots", + "pg_stat_tmp", + "pg_subtrans", +]; + +pub(super) fn backup_restore_setup_sql() -> String { + format!( + "DROP TABLE IF EXISTS backup_restore_items;\ + CREATE TABLE backup_restore_items(id integer PRIMARY KEY, payload text NOT NULL);\ + INSERT INTO backup_restore_items \ + SELECT i, repeat(md5(i::text), 8) FROM generate_series(1, {BACKUP_RESTORE_EXPECTED_ROWS}) AS i" + ) +} + +pub(super) fn sqlite_backup_restore_setup_sql() -> String { + let mut sql = String::from( + "DROP TABLE IF EXISTS backup_restore_items;\ + CREATE TABLE backup_restore_items(id integer PRIMARY KEY, payload text NOT NULL);\ + BEGIN;", + ); + for id in 1..=BACKUP_RESTORE_EXPECTED_ROWS { + sql.push_str("INSERT INTO backup_restore_items(id, payload) VALUES ("); + sql.push_str(&id.to_string()); + sql.push_str(", '"); + sql.push_str(&format!("{id:032x}").repeat(8)); + sql.push_str("');"); + } + sql.push_str("COMMIT;"); + sql +} + +pub(super) fn fmt_bytes_label(bytes: usize) -> String { + if bytes >= 1024 * 1024 { + format!("{:.2} MiB", bytes as f64 / 1024.0 / 1024.0) + } else if bytes >= 1024 { + format!("{:.2} KiB", bytes as f64 / 1024.0) + } else { + format!("{bytes} B") + } +} + +pub(super) fn ensure_protocol_response_ok(response: &[u8]) -> Result<()> { + let mut off = 0usize; + let mut ready = false; + while off + 5 <= response.len() { + let tag = response[off]; + let len = u32::from_be_bytes([ + response[off + 1], + response[off + 2], + response[off + 3], + response[off + 4], + ]) as usize; + ensure!(len >= 4, "invalid backend message length {len}"); + let frame_len = 1 + len; + ensure!( + frame_len <= response.len() - off, + "truncated backend message tag {} length {len}", + tag as char + ); + ensure!(tag != b'E', "backend returned ErrorResponse"); + ready |= tag == b'Z'; + off += frame_len; + } + ensure!(off == response.len(), "trailing bytes in backend response"); + ensure!(ready, "backend response did not include ReadyForQuery"); + Ok(()) +} + +pub(super) fn pg_query(sql: &str) -> Vec { + let mut body = Vec::new(); + push_cstr(&mut body, sql); + pg_frame(b'Q', &body) +} + +pub(super) fn pg_parse(name: Option<&str>, sql: &str, types: &[i32]) -> Vec { + let mut body = Vec::new(); + push_cstr(&mut body, name.unwrap_or("")); + push_cstr(&mut body, sql); + push_i16(&mut body, types.len() as i16); + for oid in types { + push_i32(&mut body, *oid); + } + pg_frame(b'P', &body) +} + +pub(super) fn pg_bind(portal: Option<&str>, statement: &str, values: &[String; 2]) -> Vec { + let mut body = Vec::new(); + push_cstr(&mut body, portal.unwrap_or("")); + push_cstr(&mut body, statement); + push_i16(&mut body, values.len() as i16); + for _ in values { + push_i16(&mut body, 0); + } + push_i16(&mut body, values.len() as i16); + for value in values { + push_i32(&mut body, value.len() as i32); + body.extend_from_slice(value.as_bytes()); + } + push_i16(&mut body, 0); + pg_frame(b'B', &body) +} + +pub(super) fn pg_execute(portal: Option<&str>) -> Vec { + let mut body = Vec::new(); + push_cstr(&mut body, portal.unwrap_or("")); + push_i32(&mut body, 0); + pg_frame(b'E', &body) +} + +pub(super) fn pg_describe(target_type: u8, name: Option<&str>) -> Vec { + let mut body = Vec::new(); + body.push(target_type); + push_cstr(&mut body, name.unwrap_or("")); + pg_frame(b'D', &body) +} + +pub(super) fn pg_close(target_type: u8, name: Option<&str>) -> Vec { + let mut body = Vec::new(); + body.push(target_type); + push_cstr(&mut body, name.unwrap_or("")); + pg_frame(b'C', &body) +} + +pub(super) fn pg_sync() -> Vec { + pg_frame(b'S', &[]) +} + +fn pg_frame(tag: u8, body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(1 + 4 + body.len()); + out.push(tag); + out.extend_from_slice(&((body.len() + 4) as i32).to_be_bytes()); + out.extend_from_slice(body); + out +} + +pub(super) fn push_cstr(out: &mut Vec, value: &str) { + out.extend_from_slice(value.as_bytes()); + out.push(0); +} + +fn push_i16(out: &mut Vec, value: i16) { + out.extend_from_slice(&value.to_be_bytes()); +} + +fn push_i32(out: &mut Vec, value: i32) { + out.extend_from_slice(&value.to_be_bytes()); +} diff --git a/tools/perf/runner/src/sqlite.rs b/tools/perf/runner/src/sqlite.rs new file mode 100644 index 00000000..3f0f9791 --- /dev/null +++ b/tools/perf/runner/src/sqlite.rs @@ -0,0 +1,248 @@ +use super::*; + +pub(super) fn perf_sqlite(args: &[String]) -> Result<()> { + let mut suite = BenchmarkSuiteFilter::Speed; + let mut speed_sql_source = SpeedSqlSource::OliphauntFixture; + let mut speed_scale = 1.0_f64; + let mut durability = NativeDurabilityProfile::Safe; + let mut cursor = 0usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--suite" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--suite requires a value"))?; + suite = match value.as_str() { + "speed" | "sqlite" | "sqlite-suite" => BenchmarkSuiteFilter::Speed, + "backup" | "backup-restore" | "backup_restore" => { + BenchmarkSuiteFilter::BackupRestore + } + other => { + bail!( + "unknown --suite value {other:?}; sqlite currently supports speed or backup-restore" + ) + } + }; + } + "--speed-source" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--speed-source requires a value"))?; + speed_sql_source = match value.as_str() { + "generated" | "local" => SpeedSqlSource::Generated, + "oliphaunt" | "oliphaunt-vendored" | "upstream" => { + SpeedSqlSource::OliphauntFixture + } + other => { + bail!("unknown --speed-source value {other:?}; use generated or oliphaunt") + } + }; + } + "--scale" => { + cursor += 1; + let value = args + .get(cursor) + .ok_or_else(|| anyhow!("--scale requires a value"))?; + speed_scale = value + .parse() + .with_context(|| format!("parse --scale value {value:?}"))?; + } + "--durability" => { + cursor += 1; + durability = parse_native_durability( + args.get(cursor) + .ok_or_else(|| anyhow!("--durability requires a value"))?, + )?; + } + other => bail!("unknown perf sqlite flag: {other}"), + } + cursor += 1; + } + ensure!(speed_scale > 0.0, "--scale must be greater than zero"); + + let mut runs = Vec::new(); + if suite.includes("speed") { + runs.push(run_sqlite_speed_benchmark( + speed_scale, + speed_sql_source, + durability, + )?); + } + if suite.includes("backup-restore") { + runs.push(run_sqlite_backup_restore_benchmark(durability)?); + } + let report = BenchmarkReport { + wasmer_version: "sqlite", + wasmer_wasix_version: "sqlite", + wasix_runtime_assets: None, + source_model: speed_sql_source.source_model(), + measurement_model: "SQLite control. xtask opens one temporary file-backed SQLite database in-process through rusqlite, applies an explicit durability profile through PRAGMA settings, then executes the selected speed or backup/restore suite.", + native_tuning: None, + rtt_iterations: 0, + speed_scale, + preload_micros: 0, + runs, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +fn run_sqlite_speed_benchmark( + scale: f64, + sql_source: SpeedSqlSource, + durability: NativeDurabilityProfile, +) -> Result { + let root = unique_perf_root("sqlite-speed")?; + let database_path = root.join("benchmark.sqlite3"); + let open_started = Instant::now(); + let conn = rusqlite::Connection::open(&database_path) + .with_context(|| format!("open SQLite benchmark database {}", database_path.display()))?; + apply_sqlite_durability(&conn, durability)?; + let open_micros = open_started.elapsed().as_micros(); + + let mut tests = Vec::new(); + let run_result = (|| -> Result<()> { + for case in speed_cases(scale, sql_source)? { + let started = Instant::now(); + conn.execute_batch(&case.sql) + .with_context(|| format!("execute SQLite speed benchmark {}", case.id))?; + tests.push(single_sample_result( + case.id, + case.label, + "seconds", + case.operation_count, + started.elapsed(), + )); + } + Ok(()) + })(); + let cleanup_result = fs::remove_dir_all(&root) + .with_context(|| format!("remove SQLite benchmark root {}", root.display())); + run_result?; + cleanup_result?; + + Ok(BenchmarkRun { + suite: "speed", + mode: "sqlite", + description: "File-backed SQLite control using rusqlite and the same speed SQL batches as the native matrix.", + open_micros, + connect_micros: None, + setup_micros: 0, + observed_server_peak_rss_bytes: None, + tests, + }) +} + +fn run_sqlite_backup_restore_benchmark( + durability: NativeDurabilityProfile, +) -> Result { + let root = unique_perf_root("sqlite-backup")?; + let database_path = root.join("backup.sqlite3"); + let backup_path = root.join("backup-copy.sqlite3"); + let restore_path = root.join("restored.sqlite3"); + + let open_started = Instant::now(); + let conn = rusqlite::Connection::open(&database_path) + .with_context(|| format!("open SQLite backup database {}", database_path.display()))?; + apply_sqlite_durability(&conn, durability)?; + let open_micros = open_started.elapsed().as_micros(); + + let setup_started = Instant::now(); + conn.execute_batch(&sqlite_backup_restore_setup_sql()) + .context("execute SQLite backup/restore setup")?; + let setup_micros = setup_started.elapsed().as_micros(); + + let backup_started = Instant::now(); + conn.execute_batch(&format!( + "VACUUM INTO {};", + sqlite_string_literal(&backup_path.display().to_string()) + )) + .context("run SQLite VACUUM INTO backup")?; + let backup_elapsed = backup_started.elapsed(); + let backup_bytes = fs::metadata(&backup_path) + .with_context(|| format!("stat SQLite backup {}", backup_path.display()))? + .len() as usize; + + let restore_started = Instant::now(); + fs::copy(&backup_path, &restore_path).with_context(|| { + format!( + "copy SQLite backup {} to restore target {}", + backup_path.display(), + restore_path.display() + ) + })?; + let restored = rusqlite::Connection::open(&restore_path) + .with_context(|| format!("open restored SQLite database {}", restore_path.display()))?; + let count: i64 = restored + .query_row("SELECT count(*) FROM backup_restore_items", [], |row| { + row.get(0) + }) + .context("verify SQLite restored row count")?; + ensure!( + count == BACKUP_RESTORE_EXPECTED_ROWS as i64, + "SQLite restored row count mismatch: got {count}, expected {BACKUP_RESTORE_EXPECTED_ROWS}" + ); + let restore_elapsed = restore_started.elapsed(); + + drop(restored); + drop(conn); + fs::remove_dir_all(&root) + .with_context(|| format!("remove SQLite backup root {}", root.display()))?; + + Ok(BenchmarkRun { + suite: "backup-restore", + mode: "sqlite", + description: "File-backed SQLite backup/restore control using VACUUM INTO for a consistent backup image and file-copy restore.", + open_micros, + connect_micros: None, + setup_micros, + observed_server_peak_rss_bytes: None, + tests: vec![ + single_sample_result( + "sqlite_vacuum_into_backup", + format!( + "SQLite VACUUM INTO backup; backup size {}", + fmt_bytes_label(backup_bytes) + ), + "seconds", + backup_bytes, + backup_elapsed, + ), + single_sample_result( + "sqlite_file_restore", + format!( + "SQLite file restore; backup size {}", + fmt_bytes_label(backup_bytes) + ), + "seconds", + backup_bytes, + restore_elapsed, + ), + ], + }) +} + +fn sqlite_string_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn apply_sqlite_durability( + conn: &rusqlite::Connection, + durability: NativeDurabilityProfile, +) -> Result<()> { + let pragma_sql = match durability { + NativeDurabilityProfile::Safe => { + "PRAGMA journal_mode=WAL;\nPRAGMA synchronous=FULL;\nPRAGMA temp_store=MEMORY;\n" + } + NativeDurabilityProfile::Balanced => { + "PRAGMA journal_mode=WAL;\nPRAGMA synchronous=NORMAL;\nPRAGMA temp_store=MEMORY;\n" + } + NativeDurabilityProfile::FastDev => { + "PRAGMA journal_mode=MEMORY;\nPRAGMA synchronous=OFF;\nPRAGMA temp_store=MEMORY;\n" + } + }; + conn.execute_batch(pragma_sql) + .context("apply SQLite benchmark durability PRAGMAs") +} diff --git a/tools/policy/check-coverage.sh b/tools/policy/check-coverage.sh new file mode 100755 index 00000000..4827b42c --- /dev/null +++ b/tools/policy/check-coverage.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +product="${1:-all}" +baseline="coverage/baseline.toml" + +fail() { + echo "$1" >&2 + exit 1 +} + +require_file() { + [ -f "$1" ] || fail "missing coverage policy file: $1" +} + +require_text() { + file="$1" + text="$2" + grep -Fq -- "$text" "$file" || fail "expected '$text' in $file" +} + +reject_text() { + file="$1" + text="$2" + if grep -Fq -- "$text" "$file"; then + fail "unexpected '$text' in $file" + fi +} + +require_file "$baseline" +require_text "$baseline" "fail_on_unmeasured_product = true" +require_text "$baseline" "minimum_new_sdk_line_coverage = 80.0" +require_text "$baseline" "target_sdk_line_coverage = 85.0" +reject_text "$baseline" "include_globs" +require_text "moon.yml" "coverage-policy:" +require_text "moon.yml" "tools/coverage/summarize" +require_text "moon.yml" "tools/policy/check-coverage.sh all" + +products="oliphaunt-rust oliphaunt-swift oliphaunt-kotlin oliphaunt-js oliphaunt-react-native oliphaunt-wasix-rust" + +product_moon_yml() { + case "$1" in + oliphaunt-rust) + printf '%s\n' "src/sdks/rust/moon.yml" + ;; + oliphaunt-swift) + printf '%s\n' "src/sdks/swift/moon.yml" + ;; + oliphaunt-kotlin) + printf '%s\n' "src/sdks/kotlin/moon.yml" + ;; + oliphaunt-js) + printf '%s\n' "src/sdks/js/moon.yml" + ;; + oliphaunt-react-native) + printf '%s\n' "src/sdks/react-native/moon.yml" + ;; + oliphaunt-wasix-rust) + printf '%s\n' "src/bindings/wasix-rust/moon.yml" + ;; + esac +} + +case "$product" in + all) + for item in $products; do + moon_yml="$(product_moon_yml "$item")" + require_text "$baseline" "[products.$item]" + require_text "$baseline" "summary = \"target/coverage/$item/summary.json\"" + require_text "$baseline" "line_threshold = 80.0" + require_text "$moon_yml" "coverage:" + require_text "$moon_yml" "tools/coverage/run-product $item" + require_text "$moon_yml" "/target/coverage/$item/**/*" + done + ;; + oliphaunt-rust|oliphaunt-swift|oliphaunt-kotlin|oliphaunt-js|oliphaunt-react-native|oliphaunt-wasix-rust) + moon_yml="$(product_moon_yml "$product")" + require_text "$baseline" "[products.$product]" + require_text "$baseline" "summary = \"target/coverage/$product/summary.json\"" + require_text "$moon_yml" "coverage:" + require_text "$moon_yml" "tools/coverage/run-product $product" + require_text "$moon_yml" "/target/coverage/$product/**/*" + ;; + *) + fail "unknown coverage product '$product'" + ;; +esac + +python3 - "$product" <<'PY' +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + +selected = sys.argv[1] +expected = [ + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-react-native", + "oliphaunt-wasix-rust", +] +with Path("coverage/baseline.toml").open("rb") as handle: + baseline = tomllib.load(handle) +products = baseline.get("products", {}) +targets = expected if selected == "all" else [selected] +for product in targets: + config = products.get(product) + if not isinstance(config, dict): + raise SystemExit(f"missing coverage product config: {product}") + if "include_globs" in config: + raise SystemExit(f"{product}: coverage must use source_globs, not include_globs") + source_globs = config.get("source_globs") + if not isinstance(source_globs, list) or not source_globs or not all(isinstance(item, str) for item in source_globs): + raise SystemExit(f"{product}: source_globs must be a non-empty string array") + if float(config.get("line_threshold", 0.0)) < 80.0: + raise SystemExit(f"{product}: aggregate line_threshold must stay at or above 80") + if float(config.get("per_file_line_threshold", 0.0)) < 50.0: + raise SystemExit(f"{product}: per_file_line_threshold must stay at or above 50") + if float(config.get("measured_line_coverage", 0.0)) < float(config.get("line_threshold", 0.0)): + raise SystemExit(f"{product}: measured_line_coverage audit snapshot is below the aggregate threshold") + waivers = config.get("waivers", []) + if not isinstance(waivers, list) or not waivers: + raise SystemExit(f"{product}: coverage waivers must be explicit even when the list is short") + for waiver in waivers: + if not isinstance(waiver, dict): + raise SystemExit(f"{product}: waiver must be a TOML table") + has_path = isinstance(waiver.get("path"), str) + has_glob = isinstance(waiver.get("glob"), str) + if has_path == has_glob: + raise SystemExit(f"{product}: waiver must define exactly one of path or glob") + for key in ("reason", "evidence", "owner", "expires"): + value = waiver.get(key) + if not isinstance(value, str) or not value.strip(): + raise SystemExit(f"{product}: waiver {key} must be a non-empty string") +PY + +printf 'measured coverage policy is modeled for %s\n' "$product" diff --git a/tools/policy/check-crate-package.sh b/tools/policy/check-crate-package.sh new file mode 100755 index 00000000..8d17c3a8 --- /dev/null +++ b/tools/policy/check-crate-package.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +allow_dirty=() +packages=() +while [ "$#" -gt 0 ]; do + case "$1" in + --allow-dirty) + allow_dirty=(--allow-dirty) + shift + ;; + --package|-p) + if [ -z "${2:-}" ]; then + echo "--package requires a package name" >&2 + exit 2 + fi + packages+=("$2") + shift 2 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +rm -f target/package/*.crate +if [ "${#packages[@]}" -eq 0 ]; then + cargo package --workspace --exclude xtask --locked --no-verify "${allow_dirty[@]}" +else + for package in "${packages[@]}"; do + cargo package -p "$package" --locked --no-verify "${allow_dirty[@]}" + done +fi +tools/policy/check-crate-size.sh --enforce diff --git a/scripts/check-crate-size.sh b/tools/policy/check-crate-size.sh similarity index 100% rename from scripts/check-crate-size.sh rename to tools/policy/check-crate-size.sh diff --git a/scripts/check-dependency-invariants.sh b/tools/policy/check-dependency-invariants.sh similarity index 59% rename from scripts/check-dependency-invariants.sh rename to tools/policy/check-dependency-invariants.sh index 225f2331..a6da526a 100755 --- a/scripts/check-dependency-invariants.sh +++ b/tools/policy/check-dependency-invariants.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} cd "$root" python3 <<'PY' @@ -10,10 +13,10 @@ import sys import tomllib root = pathlib.Path.cwd() -root_manifest_path = root / "Cargo.toml" -root_manifest = tomllib.loads(root_manifest_path.read_text(encoding="utf-8")) -root_version = root_manifest["package"]["version"] -expected_req = f"={root_version}" +product_manifest_path = root / "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml" +product_manifest = tomllib.loads(product_manifest_path.read_text(encoding="utf-8")) +product_version = product_manifest["package"]["version"] +expected_req = f"={product_version}" def dependency_tables(manifest): @@ -43,22 +46,22 @@ def dependency_path(spec): def is_internal_payload_crate(name): - return name == "pglite-oxide-assets" or name.startswith("pglite-oxide-aot-") + return name == "oliphaunt-wasix-assets" or name.startswith("oliphaunt-wasix-aot-") errors = [] -root_deps = {} -for table_name, deps in dependency_tables(root_manifest): +product_deps = {} +for table_name, deps in dependency_tables(product_manifest): for dep_key, spec in deps.items(): name = dependency_name(dep_key, spec) if not is_internal_payload_crate(name): continue - if name in root_deps: - errors.append(f"{name} is declared more than once in root dependencies") - root_deps[name] = (table_name, spec) + if name in product_deps: + errors.append(f"{name} is declared more than once in oliphaunt-wasix dependencies") + product_deps[name] = (table_name, spec) -internal_manifest_paths = [root / "crates/assets/Cargo.toml"] -internal_manifest_paths.extend(sorted((root / "crates/aot").glob("*/Cargo.toml"))) +internal_manifest_paths = [root / "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml"] +internal_manifest_paths.extend(sorted((root / "src/runtimes/liboliphaunt/wasix/crates/aot").glob("*/Cargo.toml"))) for manifest_path in internal_manifest_paths: manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) @@ -68,35 +71,35 @@ for manifest_path in internal_manifest_paths: if not is_internal_payload_crate(name): errors.append(f"{manifest_path}: unexpected internal crate name {name!r}") continue - if version != root_version: + if version != product_version: errors.append( - f"{manifest_path}: {name} version {version} does not match root version {root_version}" + f"{manifest_path}: {name} version {version} does not match oliphaunt-wasix version {product_version}" ) - dep = root_deps.get(name) + dep = product_deps.get(name) if dep is None: - errors.append(f"root Cargo.toml does not depend on internal crate {name}") + errors.append(f"src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml does not depend on internal crate {name}") continue table_name, spec = dep version_req = dependency_version(spec) if version_req != expected_req: errors.append( - f"root Cargo.toml {table_name}.{name} uses version {version_req!r}; expected {expected_req!r}" + f"src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml {table_name}.{name} uses version {version_req!r}; expected {expected_req!r}" ) path = dependency_path(spec) if path is None: - errors.append(f"root Cargo.toml {table_name}.{name} must keep a path dependency") + errors.append(f"src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml {table_name}.{name} must keep a path dependency") continue expected_path = manifest_path.parent.resolve() - actual_path = (root / path).resolve() + actual_path = (product_manifest_path.parent / path).resolve() if actual_path != expected_path: errors.append( - f"root Cargo.toml {table_name}.{name} points at {path!r}; expected {manifest_path.parent}" + f"src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml {table_name}.{name} points at {path!r}; expected {manifest_path.parent}" ) -extra_deps = sorted(set(root_deps) - {tomllib.loads(path.read_text(encoding="utf-8"))["package"]["name"] for path in internal_manifest_paths}) +extra_deps = sorted(set(product_deps) - {tomllib.loads(path.read_text(encoding="utf-8"))["package"]["name"] for path in internal_manifest_paths}) for name in extra_deps: - errors.append(f"root Cargo.toml depends on unknown internal crate {name}") + errors.append(f"src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml depends on unknown internal crate {name}") if errors: print("release version invariant violations:", file=sys.stderr) @@ -109,7 +112,7 @@ PY blocked='wasm''time|wasm''time-wasi|wasmer-compiler-(llvm|cranelift|singlepass)|llvm-sys|cranelift-|singlepass' -if cargo tree -p pglite-oxide --features extensions --locked | rg -n "$blocked"; then +if cargo tree -p oliphaunt-wasix --features extensions --locked | rg -n "$blocked"; then cat >&2 <<'MSG' blocked runtime dependency found in the normal user dependency tree. diff --git a/tools/policy/check-docs.sh b/tools/policy/check-docs.sh new file mode 100755 index 00000000..70da8355 --- /dev/null +++ b/tools/policy/check-docs.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "$1" >&2 + exit 1 +} + +for required in \ + docs/README.md \ + docs/architecture/ios.md \ + docs/architecture/native-liboliphaunt.md \ + docs/maintainers/assets.md \ + docs/maintainers/development.md \ + docs/maintainers/tooling.md \ + docs/maintainers/repo-structure.md \ + docs/maintainers/release.md \ + docs/maintainers/release-setup.md \ + docs/maintainers/testing.md \ + docs/maintainers/extension-packaging-policy.md \ + docs/maintainers/rust-sdk-policy.md \ + docs/maintainers/sdk-api-surface.md \ + docs/maintainers/sdk-parity-policy.md \ + docs/maintainers/wasm-usage-legacy.md \ + docs/internal/PHYSICAL_ARCHIVE_FORMAT.md \ + docs/internal/OLIPHAUNT_TRACK_REVIEW.md \ + docs/internal/OLIPHAUNT_PATCH_STACK.md \ + docs/internal/WASIX_PATCH_STACK.md \ + docs/internal/PERFORMANCE.md \ + src/docs/docs-manifest.toml \ + src/docs/content/learn/native-runtime.mdx \ + src/docs/content/learn/mobile-stability.mdx \ + src/docs/content/learn/tauri.mdx \ + src/docs/content/reference/extensions.mdx \ + src/docs/content/reference/performance.mdx \ + tools/policy/sdk-manifest.toml \ + src/docs/content/reference/capabilities.mdx \ + src/docs/content/reference/sdk-products.mdx \ + src/docs/reference/doxygen/Doxyfile \ + src/docs/tools/generate-api-reference.mjs \ + src/docs/tools/run-docs-task.mjs \ + src/docs/content/sdk/react-native/architecture.mdx \ + src/docs/content/sdk/wasm/dump-restore.mdx \ + src/docs/content/sdk/wasm/runtime.mdx +do + [[ -f "$required" ]] || fail "missing required maintainer/product doc: $required" +done + +for docs_task in generate check test build release-check; do + grep -Fq "\"$docs_task\": \"node tools/run-docs-task.mjs $docs_task\"" src/docs/package.json || + fail "docs package task $docs_task must use the lock-aware docs task runner" +done + +grep -Fq "const lockDir = path.join(generatedRoot, '.docs-task.lock')" src/docs/tools/run-docs-task.mjs || + fail "docs task runner must serialize generated Fumadocs/Next writes" + +top_level_docs="$(git ls-files docs | grep -E '^docs/[^/]+\.md$' | grep -v '^docs/README\.md$' || true)" +if [[ -n "$top_level_docs" ]]; then + echo "$top_level_docs" >&2 + fail "root docs/*.md is retired except docs/README.md; use docs/architecture, docs/maintainers, docs/internal, or src/docs" +fi + +if git ls-files docs/products | grep -q .; then + git ls-files docs/products >&2 + fail "consumer product docs must live under src/docs/content, not docs/products" +fi + +product_local_docs="$(git ls-files 'src/*/docs/**' | grep -E '\.(md|mdx)$' | grep -v '^src/docs/' || true)" +if [[ -n "$product_local_docs" ]]; then + echo "$product_local_docs" >&2 + fail "public SDK docs must be centralized under src/docs/content; product-local docs require an explicit package-shipped exception" +fi + +pnpm --dir src/docs run check + +if find docs -maxdepth 1 -type f -iname '*internal*' | grep -q .; then + find docs -maxdepth 1 -type f -iname '*internal*' >&2 + fail "internal docs must live under docs/internal/" +fi + +if [[ -f docs/OLIPHAUNT_TRACK_REVIEW.md ]]; then + fail "track review and release blocker audits must live under docs/internal/" +fi + +if grep -Fq '[DONE.md](DONE.md)' docs/maintainers/development.md || + grep -Fq '[TODO.md](TODO.md)' docs/maintainers/development.md; then + fail "public development docs must link maintainer progress notes through docs/internal/" +fi + +retired_docs_grep=( + 'docs/ASSETS.md' + 'docs/DEVELOPMENT.md' + 'docs/EXTENSIONS.md' + 'docs/IOS_ARCHITECTURE.md' + 'docs/MOBILE_STABILITY.md' + 'docs/NATIVE_OLIPHAUNT.md' + 'docs/PERFORMANCE.md' + 'docs/PG_DUMP.md' + 'docs/REACT_NATIVE.md' + 'docs/RELEASE.md' + 'docs/RELEASE_SETUP.md' + 'docs/REPO_STRUCTURE.md' + 'docs/RUNTIME.md' + 'docs/SDK_API_SURFACE.md' + 'docs/SDK_PARITY.md' + 'docs/TAURI.md' + 'docs/TESTING.md' + 'docs/TOOLING.md' + 'docs/USAGE.md' + 'docs/WASIX_RUNTIME.md' +) +retired_docs_args=() +for retired_doc in "${retired_docs_grep[@]}"; do + retired_docs_args+=(-e "$retired_doc") +done +if git grep -n -F "${retired_docs_args[@]}" -- README.md docs src tools .github .moon | + grep -v '^tools/policy/check-docs\.sh:' >/tmp/docs-retired-grep.$$ 2>/dev/null; then + cat /tmp/docs-retired-grep.$$ >&2 + rm -f /tmp/docs-retired-grep.$$ + fail "retired root docs paths remain referenced" +fi +rm -f /tmp/docs-retired-grep.$$ + +if git grep -n \ + -e 'f0rr0/oliphaunt-oxide' \ + -e 'github.com/f0rr0/oliphaunt-oxide' \ + -- docs README.md src/*/README.md src/docs src/sdks/react-native/examples/expo/README.md src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md | + grep -v '^docs/internal/' >/tmp/docs-stale-grep.$$ 2>/dev/null; then + cat /tmp/docs-stale-grep.$$ >&2 + rm -f /tmp/docs-stale-grep.$$ + fail "stale oliphaunt-oxide repository identity remains in public docs" +fi +rm -f /tmp/docs-stale-grep.$$ + +if git grep -n -E \ + -e '(^|[^[:alnum:]_-])npm --prefix' \ + -e '(^|[^[:alnum:]_-])npm run' \ + -e '(^|[^[:alnum:]_-])npm pack([[:space:];|&]|$)' \ + -e '(^|[^[:alnum:]_-])npm start' \ + -- README.md docs src/docs src/sdks/react-native/README.md src/sdks/react-native/examples/expo/README.md src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md | + grep -v '^docs/internal/' >/tmp/docs-npm-grep.$$ 2>/dev/null; then + cat /tmp/docs-npm-grep.$$ >&2 + rm -f /tmp/docs-npm-grep.$$ + fail "public JavaScript docs must use pnpm workspace commands" +fi +rm -f /tmp/docs-npm-grep.$$ + +if git grep -n -E 'pnpm run moon --|pnpm run [[:alnum:]:-]+ -- --affected' \ + -- README.md docs src/*/README.md src/docs src/sdks/react-native/examples/expo/README.md src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/README.md >/tmp/docs-pnpm-args-grep.$$ 2>/dev/null; then + cat /tmp/docs-pnpm-args-grep.$$ >&2 + rm -f /tmp/docs-pnpm-args-grep.$$ + fail "public pnpm script docs must not pass moon flags through an extra -- separator" +fi +rm -f /tmp/docs-pnpm-args-grep.$$ + +if git grep -n -E 'oliphaunt-wasix = (\{ version = )?"0\.4"' \ + -- docs src/docs src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md >/tmp/docs-wasm-version-grep.$$ 2>/dev/null; then + cat /tmp/docs-wasm-version-grep.$$ >&2 + rm -f /tmp/docs-wasm-version-grep.$$ + fail "public oliphaunt-wasix install snippets must use the current crate version" +fi +rm -f /tmp/docs-wasm-version-grep.$$ + +if git grep -n 'docs/assets/oliphaunt-wasix.png' \ + -- docs src/docs src/bindings/wasix-rust/crates/oliphaunt-wasix/README.md >/tmp/docs-wasm-image-grep.$$ 2>/dev/null; then + cat /tmp/docs-wasm-image-grep.$$ >&2 + rm -f /tmp/docs-wasm-image-grep.$$ + fail "oliphaunt-wasix docs must not reference nonexistent local image assets" +fi +rm -f /tmp/docs-wasm-image-grep.$$ + +echo "documentation checks passed" diff --git a/tools/policy/check-feature-powerset.sh b/tools/policy/check-feature-powerset.sh new file mode 100755 index 00000000..1466a194 --- /dev/null +++ b/tools/policy/check-feature-powerset.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +cargo hack check --workspace --feature-powerset --no-dev-deps --exclude-features aot-serializer,template-runner diff --git a/tools/policy/check-final-source-architecture.py b/tools/policy/check-final-source-architecture.py new file mode 100755 index 00000000..90da31a3 --- /dev/null +++ b/tools/policy/check-final-source-architecture.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python3 +"""Validate Oliphaunt's target source architecture invariants. + +This is a source architecture guard. It rejects retired product aliases and +validates the structured source/extension metadata that current products rely +on. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any, NoReturn + + +ROOT = Path(__file__).resolve().parents[2] +EXTENSION_ID = re.compile(r"^[a-z][a-z0-9_]{0,127}$") +SQL_EXTENSION_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,127}$") + +CURRENT_SOURCE_DOMAINS = { + "src/postgres/versions/18", + "src/sources", + "src/extensions", + "src/shared", +} + +CURRENT_SOURCE_DOMAIN_PROJECTS = { + "src/postgres/versions/18", + "src/sources/third-party/shared", + "src/sources/third-party/native", + "src/sources/third-party/wasix", + "src/sources/toolchains", + "src/extensions", + "src/shared/js-core", +} + +TARGET_SOURCE_DOMAINS = { + "src/postgres", + "src/sources", + "src/extensions", + "src/runtimes", + "src/shared", + "src/sdks", + "src/bindings", + "src/docs", +} + +CURRENT_PRODUCT_ROOTS = { + "src/runtimes/liboliphaunt/native": "liboliphaunt-native", + "src/sdks/rust": "oliphaunt-rust", + "src/sdks/swift": "oliphaunt-swift", + "src/sdks/kotlin": "oliphaunt-kotlin", + "src/sdks/react-native": "oliphaunt-react-native", + "src/sdks/js": "oliphaunt-js", + "src/bindings/wasix-rust": "oliphaunt-wasix-rust", + "src/docs": "docs", +} + +ALLOWED_SRC_TOP_LEVEL = { + *(path.removeprefix("src/") for path in CURRENT_SOURCE_DOMAINS), + *(path.removeprefix("src/") for path in TARGET_SOURCE_DOMAINS), + *(path.removeprefix("src/") for path in CURRENT_PRODUCT_ROOTS), +} + +RETIRED_ROOTS = { + "assets", + "crates", + "fixtures", + "liboliphaunt-native", + "sdks", +} + +FORBIDDEN_PRODUCT_IDENTITIES = { + "@oliphaunt/sdk-apple", + "apple-sdk", + "oliphaunt-apple", +} + +FORBIDDEN_RETIRED_RELEASE_TOOL_TEXT = { + "release-plz", + "git-cliff", +} + +SDK_RUNTIME_SOURCE_PREFIXES = ( + "src/sdks/rust/src/", + "src/sdks/swift/Sources/", + "src/sdks/kotlin/oliphaunt/src/commonMain/", + "src/sdks/kotlin/oliphaunt/src/androidMain/", + "src/sdks/kotlin/oliphaunt/src/nativeMain/", + "src/sdks/react-native/src/", + "src/sdks/react-native/ios/", + "src/sdks/react-native/android/src/main/", + "src/sdks/js/src/", +) + +TRANSITIONAL_EXTENSION_RULE_ALLOWLIST = { + ( + "src/sdks/js/src/config.ts", + "if (extension === 'pg_search')", + ), + ( + "src/sdks/js/src/config.ts", + "libraries.add('pg_search')", + ), +} + +TRANSITIONAL_EXTENSION_RULE_FILES = { + # Replaced by generated SDK extension metadata in checklist item 8. + "src/sdks/rust/src/extension.rs", + "src/sdks/rust/src/runtime_resources.rs", + # Copied native ABI headers currently include one example module stem. + "src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h", + "src/sdks/kotlin/oliphaunt/src/androidMain/cpp/include/oliphaunt.h", + "src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h", +} + +PROMOTED_CATALOG = ROOT / "src/extensions/catalog/extensions.promoted.toml" +SMOKE_CATALOG = ROOT / "src/extensions/catalog/extensions.smoke.toml" +GENERATED_CATALOG = ROOT / "src/extensions/generated/extensions.catalog.json" +GENERATED_BUILD_PLAN = ROOT / "src/extensions/generated/extensions.build-plan.json" +GENERATED_EXTENSION_DOCS = ROOT / "src/extensions/generated/docs/extensions.json" +GENERATED_EXTENSION_EVIDENCE = ROOT / "src/extensions/generated/docs/extension-evidence.json" +EVIDENCE_MATRIX = ROOT / "src/extensions/evidence/matrix.toml" +EVIDENCE_RUN_SCHEMA = ROOT / "src/extensions/evidence/schemas/run.schema.json" +EVIDENCE_MATRIX_SCHEMA = ROOT / "src/extensions/evidence/schemas/matrix.schema.json" +EVIDENCE_RUNS = ROOT / "src/extensions/evidence/runs" +GENERATED_SDK_METADATA = [ + ROOT / "src/extensions/generated/sdk/rust.json", + ROOT / "src/extensions/generated/sdk/swift.json", + ROOT / "src/extensions/generated/sdk/kotlin.json", + ROOT / "src/extensions/generated/sdk/js.json", + ROOT / "src/extensions/generated/sdk/react-native.json", +] +GENERATED_SDK_PACKAGE_METADATA = [ + ROOT / "src/sdks/js/src/generated/extensions.ts", + ROOT / "src/sdks/kotlin/oliphaunt/src/generated/extensions.json", + ROOT / "src/sdks/react-native/src/generated/extensions.ts", + ROOT / "src/sdks/react-native/src/generated/extensions.json", +] +GENERATED_MOBILE_REGISTRY = ROOT / "src/extensions/generated/mobile/static-registry.json" +GENERATED_WASIX_METADATA = ROOT / "src/extensions/generated/wasix/extensions.json" +GENERATED_TSV = [ + ROOT / "src/extensions/generated/contrib-build.tsv", + ROOT / "src/extensions/generated/pgxs-build.tsv", +] + + +def fail(message: str) -> NoReturn: + raise SystemExit(f"check-final-source-architecture.py: {message}") + + +def rel(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def require_file(path: Path) -> None: + if not path.is_file(): + fail(f"missing required file: {rel(path)}") + + +def require_dir(path: Path) -> None: + if not path.is_dir(): + fail(f"missing required directory: {rel(path)}") + + +def tracked_files(*paths: str) -> list[str]: + command = ["git", "ls-files", "-z", "--", *paths] + output = subprocess.check_output(command, cwd=ROOT) + return sorted(path for path in output.decode("utf-8").split("\0") if path) + + +def read_toml(path: Path) -> dict[str, Any]: + require_file(path) + with path.open("rb") as handle: + return tomllib.load(handle) + + +def read_json(path: Path) -> dict[str, Any]: + require_file(path) + with path.open(encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + fail(f"{rel(path)} must contain a JSON object") + return value + + +def validate_extension_id(value: object, context: str) -> str: + if not isinstance(value, str) or not EXTENSION_ID.fullmatch(value): + fail(f"{context} has invalid exact SQL extension id {value!r}") + return value + + +def validate_sql_extension_name(value: object, context: str) -> str: + if not isinstance(value, str) or not SQL_EXTENSION_NAME.fullmatch(value): + fail(f"{context} has invalid exact SQL extension name {value!r}") + return value + + +def validate_unique_ids(ids: list[str], context: str) -> None: + seen: set[str] = set() + duplicates: set[str] = set() + for extension_id in ids: + if extension_id in seen: + duplicates.add(extension_id) + seen.add(extension_id) + if duplicates: + fail(f"{context} has duplicate extension ids: {sorted(duplicates)}") + + +def extension_rows(path: Path) -> list[dict[str, Any]]: + value = read_toml(path).get("extensions") + if not isinstance(value, list): + fail(f"{rel(path)} must define [[extensions]] rows") + rows: list[dict[str, Any]] = [] + for index, row in enumerate(value): + if not isinstance(row, dict): + fail(f"{rel(path)} extensions[{index}] must be a table") + rows.append(row) + return rows + + +def check_source_domains() -> None: + for source_domain in CURRENT_SOURCE_DOMAINS: + require_dir(ROOT / source_domain) + for source_domain in CURRENT_SOURCE_DOMAIN_PROJECTS: + require_file(ROOT / source_domain / "moon.yml") + require_file(ROOT / "src/shared/contracts/moon.yml") + require_file(ROOT / "src/shared/fixtures/moon.yml") + for retired in RETIRED_ROOTS: + files = tracked_files(retired) + if files: + fail(f"retired root source alias {retired}/ still has tracked files: {files[:8]}") + + src_children = { + path.split("/", 2)[1] + for path in tracked_files("src") + if path.count("/") >= 1 + } + unexpected = sorted(src_children - ALLOWED_SRC_TOP_LEVEL) + if unexpected: + fail(f"unexpected top-level source domains under src/: {unexpected}") + + +def check_source_spine_policy() -> None: + path = ROOT / "tools/xtask/src/source_spine.rs" + source_spine = path.read_text(encoding="utf-8") + if "Path::new(SOURCE_CHECKOUT_ROOT).join(name)" not in source_spine: + fail(f"{rel(path)} must derive source checkout paths from SOURCE_CHECKOUT_ROOT and source name") + for forbidden in [ + '"pgtap" =>', + '"postgis" =>', + '"pgvector" =>', + "target/oliphaunt-sources/checkouts/pgtap", + "target/oliphaunt-sources/checkouts/postgis", + "target/oliphaunt-sources/checkouts/pgvector", + ]: + if forbidden in source_spine: + fail(f"{rel(path)} must not hardcode source checkout mapping {forbidden!r}") + + +def check_xtask_extension_policy() -> None: + postgres_guard = ROOT / "tools/xtask/src/postgres_guard.rs" + postgres_guard_text = postgres_guard.read_text(encoding="utf-8") + if 'extension.build_kind == "postgis"' in postgres_guard_text: + fail( + f"{rel(postgres_guard)} must not key PostGIS source-shape checks off " + "the reusable build-kind family" + ) + if 'extension.source_kind == "postgis"' not in postgres_guard_text: + fail( + f"{rel(postgres_guard)} must keep PostGIS source-shape checks keyed " + "to source_kind" + ) + + +def check_product_roots() -> None: + for product_root, project_id in CURRENT_PRODUCT_ROOTS.items(): + moon_yml = ROOT / product_root / "moon.yml" + require_file(moon_yml) + text = moon_yml.read_text(encoding="utf-8") + if f'id: "{project_id}"' not in text: + fail(f"{product_root}/moon.yml must declare id {project_id!r}") + + for forbidden in ("src/apple-sdk", "src/oliphaunt-apple", "src/apple"): + files = tracked_files(forbidden) + if files: + fail(f"forbidden Swift SDK alias has tracked files: {files[:8]}") + + +def check_forbidden_product_identity_text() -> None: + scan_files = tracked_files( + "src", + ".github", + "tools/release", + "Cargo.toml", + "Package.swift", + "package.json", + "pnpm-workspace.yaml", + ) + offenders: list[str] = [] + for path in scan_files: + if path.startswith("src/postgres/versions/18/"): + continue + full_path = ROOT / path + if not full_path.exists(): + continue + try: + text = full_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + lowered = text.lower() + for identity in FORBIDDEN_PRODUCT_IDENTITIES: + if identity in lowered: + offenders.append(f"{path}: contains {identity}") + if offenders: + fail("forbidden product identity text found:\n" + "\n".join(offenders[:20])) + + +def check_forbidden_retired_release_tool_text() -> None: + scan_files = tracked_files( + "src", + ".github", + "tools/release", + "Cargo.toml", + "Package.swift", + "package.json", + "pnpm-workspace.yaml", + "release-please-config.json", + ".release-please-manifest.json", + ) + offenders: list[str] = [] + for path in scan_files: + if path.startswith("src/postgres/versions/18/"): + continue + full_path = ROOT / path + if not full_path.exists(): + continue + try: + text = full_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + lowered = text.lower() + for name in FORBIDDEN_RETIRED_RELEASE_TOOL_TEXT: + if name in lowered: + offenders.append(f"{path}: contains retired release tool reference {name}") + if offenders: + fail("retired release tool text found on active product/release surfaces:\n" + "\n".join(offenders[:20])) + + +def check_extension_catalogs() -> None: + promoted_rows = extension_rows(PROMOTED_CATALOG) + smoke_rows = extension_rows(SMOKE_CATALOG) + promoted_ids = [validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") for row in promoted_rows] + smoke_ids = [validate_extension_id(row.get("id"), f"{rel(SMOKE_CATALOG)} row") for row in smoke_rows] + validate_unique_ids(promoted_ids, rel(PROMOTED_CATALOG)) + validate_unique_ids(smoke_ids, rel(SMOKE_CATALOG)) + unknown_smoke = sorted(set(smoke_ids) - set(promoted_ids)) + if unknown_smoke: + fail(f"{rel(SMOKE_CATALOG)} references extensions not in promoted catalog: {unknown_smoke}") + + for row in promoted_rows: + unexpected_pack_keys = sorted(key for key in row if "pack" in key or "bundle" in key or "alias" in key) + if unexpected_pack_keys: + fail(f"extension row {row.get('id')} must not use pack/bundle/alias keys: {unexpected_pack_keys}") + if row.get("stable") is False and not row.get("blocker"): + fail(f"candidate extension {row.get('id')} must explain its blocker") + + +def check_generated_extension_metadata() -> None: + catalog = read_json(GENERATED_CATALOG) + build_plan = read_json(GENERATED_BUILD_PLAN) + docs_table = read_json(GENERATED_EXTENSION_DOCS) + evidence_table = read_json(GENERATED_EXTENSION_EVIDENCE) + if catalog.get("format-version") != 1: + fail(f"{rel(GENERATED_CATALOG)} must use format-version 1") + if build_plan.get("format-version") != 1: + fail(f"{rel(GENERATED_BUILD_PLAN)} must use format-version 1") + if docs_table.get("format-version") != 1: + fail(f"{rel(GENERATED_EXTENSION_DOCS)} must use format-version 1") + if evidence_table.get("format-version") != 1: + fail(f"{rel(GENERATED_EXTENSION_EVIDENCE)} must use format-version 1") + for path in [*GENERATED_SDK_METADATA, GENERATED_MOBILE_REGISTRY, GENERATED_WASIX_METADATA]: + value = read_json(path) + if value.get("format-version") != 1: + fail(f"{rel(path)} must use format-version 1") + for path in GENERATED_SDK_PACKAGE_METADATA: + require_file(path) + + promoted_ids = {validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") for row in extension_rows(PROMOTED_CATALOG)} + catalog_extensions = catalog.get("extensions") + build_extensions = build_plan.get("extensions") + if not isinstance(catalog_extensions, list) or not catalog_extensions: + fail(f"{rel(GENERATED_CATALOG)} must define non-empty extensions") + if not isinstance(build_extensions, list) or not build_extensions: + fail(f"{rel(GENERATED_BUILD_PLAN)} must define non-empty extensions") + + catalog_ids = [validate_extension_id(row.get("id"), f"{rel(GENERATED_CATALOG)} row") for row in catalog_extensions] + build_ids = [validate_extension_id(row.get("id"), f"{rel(GENERATED_BUILD_PLAN)} row") for row in build_extensions] + validate_unique_ids(catalog_ids, rel(GENERATED_CATALOG)) + validate_unique_ids(build_ids, rel(GENERATED_BUILD_PLAN)) + unknown_catalog = sorted(set(catalog_ids) - promoted_ids) + unknown_build = sorted(set(build_ids) - promoted_ids) + if unknown_catalog: + fail(f"{rel(GENERATED_CATALOG)} has ids not declared in promoted catalog: {unknown_catalog}") + if unknown_build: + fail(f"{rel(GENERATED_BUILD_PLAN)} has ids not declared in promoted catalog: {unknown_build}") + + for row in build_extensions: + extension_id = validate_extension_id(row.get("id"), f"{rel(GENERATED_BUILD_PLAN)} row") + sql_name = validate_sql_extension_name(row.get("sql-name", extension_id), f"{rel(GENERATED_BUILD_PLAN)} row") + build_kind = row.get("build-kind") + if build_kind not in {"postgres-contrib", "pgxs-external", "pgxs-sql-only", "autotools"}: + fail( + f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} has unsupported " + f"build-kind {build_kind!r}" + ) + if build_kind == sql_name: + fail( + f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} uses extension-specific " + f"build-kind {build_kind!r}; build-kind must be a reusable build family" + ) + archive = row.get("archive") + if not isinstance(archive, str) or archive != f"extensions/{sql_name}.tar.zst": + fail(f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} has invalid exact-extension archive {archive!r}") + if any(key in row for key in ("pack", "packs", "bundle", "alias", "aliases")): + fail(f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} must not use pack/bundle/alias metadata") + if build_kind == "autotools": + build_script = row.get("build-script") + if not isinstance(build_script, str) or not build_script: + fail( + f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} " + "must declare build-script for recipe-staged autotools builds" + ) + for field in ("required-build-files", "required-build-globs"): + values = row.get(field) + if not isinstance(values, list) or not values or not all(isinstance(value, str) and value for value in values): + fail( + f"{rel(GENERATED_BUILD_PLAN)} extension {extension_id} " + f"must declare non-empty {field} for recipe-staged autotools builds" + ) + + for path in GENERATED_TSV: + require_file(path) + text = path.read_text(encoding="utf-8") + if "pack" in text.lower() or "bundle" in text.lower(): + fail(f"{rel(path)} must not contain extension pack/bundle metadata") + + +def check_extension_evidence() -> None: + require_file(EVIDENCE_MATRIX) + require_file(EVIDENCE_RUN_SCHEMA) + require_file(EVIDENCE_MATRIX_SCHEMA) + require_dir(EVIDENCE_RUNS) + if not list(EVIDENCE_RUNS.glob("*.json")): + fail(f"{rel(EVIDENCE_RUNS)} must contain extension evidence run files") + + matrix = read_toml(EVIDENCE_MATRIX) + if matrix.get("format-version") != 1: + fail(f"{rel(EVIDENCE_MATRIX)} must use format-version 1") + claims = matrix.get("claims") + if not isinstance(claims, list) or not claims: + fail(f"{rel(EVIDENCE_MATRIX)} must declare [[claims]]") + + public_ids = { + validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") + for row in extension_rows(PROMOTED_CATALOG) + if row.get("stable") is True and row.get("build") is not False + } + claim_ids = { + validate_extension_id(claim.get("extension"), f"{rel(EVIDENCE_MATRIX)} claim") + for claim in claims + if isinstance(claim, dict) and claim.get("public") is True + } + missing = sorted(public_ids - claim_ids) + extra = sorted(claim_ids - public_ids) + if missing: + fail(f"{rel(EVIDENCE_MATRIX)} is missing public claims for stable catalog rows: {missing}") + if extra: + fail(f"{rel(EVIDENCE_MATRIX)} claims public support for non-stable catalog rows: {extra}") + + +def check_extension_recipes() -> None: + retired_recipes_root = ROOT / "src/extensions/recipes" + if retired_recipes_root.exists(): + fail(f"{rel(retired_recipes_root)} is retired; external extension definitions live under src/extensions/external") + external_root = ROOT / "src/extensions/external" + if not external_root.exists(): + fail(f"{rel(external_root)} must exist") + recipe_files = sorted(external_root.glob("*/recipe.toml")) + for recipe in recipe_files: + data = read_toml(recipe) + if data.get("schema") != "oliphaunt-extension-recipe-v1": + fail(f"{rel(recipe)} must use schema = oliphaunt-extension-recipe-v1") + sql_name = validate_sql_extension_name(data.get("sql_name"), f"{rel(recipe)} recipe") + kind = data.get("kind") + if kind not in {"external-simple-pgxs", "external-complex"}: + fail(f"{rel(recipe)} must declare an external recipe kind") + if recipe.parent.name != sql_name: + fail(f"{rel(recipe)} directory must match exact SQL extension name") + for section in ("lifecycle", "artifacts", "support"): + if not isinstance(data.get(section), dict): + fail(f"{rel(recipe)} must declare [{section}]") + recipe_dir = recipe.parent + require_file(recipe_dir / "tests" / "smoke.sql") + targets = recipe_dir / "targets" + if not targets.is_dir() or not any(targets.glob("*.toml")): + fail(f"{rel(recipe)} must declare at least one target TOML under targets/") + if kind == "external-complex": + require_file(recipe_dir / "deps.toml") + require_file(recipe_dir / "tests" / "upstream.toml") + require_file(recipe_dir / "patches" / "README.md") + require_file(recipe_dir / "blockers.toml") + + +def check_sdk_local_extension_rules() -> None: + catalog_ids = { + validate_extension_id(row.get("id"), f"{rel(PROMOTED_CATALOG)} row") + for row in extension_rows(PROMOTED_CATALOG) + } + complex_ids = catalog_ids & {"age", "graph", "pg_search", "pg_textsearch", "postgis", "vector"} + offenders: list[str] = [] + for path in tracked_files("src/sdks/rust", "src/sdks/swift", "src/sdks/kotlin", "src/sdks/react-native", "src/sdks/js"): + if not path.startswith(SDK_RUNTIME_SOURCE_PREFIXES): + continue + if path in TRANSITIONAL_EXTENSION_RULE_FILES or "/generated/" in path: + continue + if "/tests/" in path or "/Tests/" in path or "/__tests__/" in path: + continue + try: + lines = (ROOT / path).read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + continue + for line_number, line in enumerate(lines, start=1): + stripped = line.strip() + if (path, stripped) in TRANSITIONAL_EXTENSION_RULE_ALLOWLIST: + continue + for extension_id in complex_ids: + if re.search(rf"['\"`]({re.escape(extension_id)})['\"`]", stripped): + offenders.append(f"{path}:{line_number}: hardcodes extension {extension_id!r}: {stripped}") + if offenders: + fail( + "SDK runtime source must not hardcode complex extension rules outside generated metadata; " + "known transitional exceptions must be explicit:\n" + "\n".join(offenders[:20]) + ) + + +def self_test() -> None: + try: + validate_extension_id("bad-name", "self-test") + except SystemExit: + pass + else: + fail("self-test expected invalid extension id to fail") + + try: + validate_unique_ids(["vector", "vector"], "self-test") + except SystemExit: + pass + else: + fail("self-test expected duplicate extension ids to fail") + + +def check_live_repo() -> None: + check_source_domains() + check_source_spine_policy() + check_xtask_extension_policy() + check_product_roots() + check_forbidden_product_identity_text() + check_forbidden_retired_release_tool_text() + check_extension_catalogs() + check_generated_extension_metadata() + check_extension_evidence() + check_extension_recipes() + check_sdk_local_extension_rules() + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true", help="run embedded failure-case checks") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.self_test: + self_test() + check_live_repo() + print("final source architecture policy checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/policy/check-mobile-extension-artifacts.sh b/tools/policy/check-mobile-extension-artifacts.sh new file mode 100755 index 00000000..5e115758 --- /dev/null +++ b/tools/policy/check-mobile-extension-artifacts.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +if [ -z "${ANDROID_HOME:-}" ] && [ -d "$HOME/Library/Android/sdk" ]; then + export ANDROID_HOME="$HOME/Library/Android/sdk" +fi +if [ -n "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then + export ANDROID_SDK_ROOT="$ANDROID_HOME" +fi + +source src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh + +selected_raw="${OLIPHAUNT_MOBILE_EXTENSION_CHECK_EXTENSIONS:-vector}" +scratch_root="${OLIPHAUNT_MOBILE_EXTENSION_CHECK_SCRATCH:-$root/target/liboliphaunt-mobile-extension-check}" +resource_output="$scratch_root/resources" +jni_root="$scratch_root/android-jni" +kotlin_build_root="$scratch_root/kotlin-gradle" +kotlin_cxx_root="$scratch_root/kotlin-cxx" +kotlin_cache_root="$scratch_root/kotlin-gradle-cache" +rn_build_root="$scratch_root/react-native-gradle" +rn_cxx_root="$scratch_root/react-native-cxx" +rn_cache_root="$scratch_root/react-native-gradle-cache" +native_resource_work_root="$scratch_root/native-resource-runtime" + +selected_extensions=() +selected_module_extensions=() +selected_stems=() +selected_ios_dependencies=() +native_resource_env=() +mobile_catalog_cache="" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +mobile_catalog() { + if [ -z "$mobile_catalog_cache" ]; then + mobile_catalog_cache="$(cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions)" + fi + printf '%s\n' "$mobile_catalog_cache" +} + +mobile_prebuilt_extensions() { + mobile_catalog | awk -F '\t' 'NR > 1 && $8 == "yes" { print $1 }' +} + +mobile_catalog_native_module_stem() { + local extension="$1" + mobile_catalog | awk -F '\t' -v extension="$extension" ' + NR > 1 && $1 == extension && $8 == "yes" { + print $4 + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' +} + +array_contains() { + local value="$1" + shift || true + case " $* " in + *" $value "*) ;; + *) return 1 ;; + esac +} + +add_ios_dependency() { + local dependency="$1" + [ -n "$dependency" ] || return 0 + array_contains "$dependency" "${selected_ios_dependencies[@]}" || selected_ios_dependencies+=("$dependency") +} + +add_extension() { + local requested="$1" + local extension stem catalog_stem + requested="$(printf '%s\n' "$requested" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [ -n "$requested" ] || return 0 + if oliphaunt_mobile_static_extension_spec "$requested" >/dev/null; then + extension="$(oliphaunt_mobile_static_extension_sql_name "$requested")" + stem="$(oliphaunt_mobile_static_extension_module_stem "$requested")" + array_contains "$extension" "${selected_extensions[@]}" || selected_extensions+=("$extension") + array_contains "$extension" "${selected_module_extensions[@]}" || selected_module_extensions+=("$extension") + array_contains "$stem" "${selected_stems[@]}" || selected_stems+=("$stem") + local dependency + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + add_ios_dependency "$dependency" + done < <(oliphaunt_mobile_static_extension_dependencies_for_target "$extension" ios || true) + return 0 + fi + + if ! catalog_stem="$(mobile_catalog_native_module_stem "$requested")"; then + echo "unsupported mobile extension artifact check extension: $requested" >&2 + printf 'supported mobile-prebuilt exact extensions: ' >&2 + mobile_prebuilt_extensions | paste -sd ',' - >&2 + exit 2 + fi + + if [ "$catalog_stem" != "-" ]; then + echo "mobile-prebuilt extension $requested is missing a mobile static build spec for native module $catalog_stem" >&2 + exit 2 + fi + + array_contains "$requested" "${selected_extensions[@]}" || selected_extensions+=("$requested") +} + +join_csv() { + local old_ifs="$IFS" + IFS="," + printf '%s' "$*" + IFS="$old_ifs" +} + +join_sorted_csv() { + if [ "$#" -eq 0 ]; then + return 0 + fi + printf '%s\n' "$@" | LC_ALL=C sort -u | paste -sd ',' - +} + +prepare_native_resource_runtime() { + native_resource_env=() + if ! array_contains postgis "${selected_extensions[@]}"; then + return 0 + fi + local native_resource_log="$scratch_root/native-resource-runtime.log" + printf '\n==> env OLIPHAUNT_WORK_ROOT=%s OLIPHAUNT_BUILD_EXTENSIONS=1 OLIPHAUNT_POSTGIS_USE_PINNED_DEPS=1 src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh (log: %s)\n' \ + "$native_resource_work_root" \ + "$native_resource_log" + if ! env \ + OLIPHAUNT_WORK_ROOT="$native_resource_work_root" \ + OLIPHAUNT_BUILD_EXTENSIONS=1 \ + OLIPHAUNT_POSTGIS_USE_PINNED_DEPS=1 \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh > "$native_resource_log" 2>&1; then + echo "native PostGIS resource runtime build failed; tail of $native_resource_log:" >&2 + tail -n 120 "$native_resource_log" >&2 || true + exit 1 + fi + native_resource_env=( + "OLIPHAUNT_INSTALL_DIR=$native_resource_work_root/install" + "LIBOLIPHAUNT_PATH=$native_resource_work_root/out/liboliphaunt.dylib" + ) +} + +require_text() { + local file="$1" + local text="$2" + local message="$3" + if ! grep -Fq "$text" "$file"; then + echo "$message" >&2 + echo "expected '$text' in $file" >&2 + exit 1 + fi +} + +require_manifest_line() { + local file="$1" + local line="$2" + local message="$3" + if ! grep -Fxq "$line" "$file"; then + echo "$message" >&2 + echo "expected exact line '$line' in $file" >&2 + exit 1 + fi +} + +reject_zip_entry() { + local archive="$1" + local pattern="$2" + local message="$3" + local entries + entries="$(unzip -Z1 "$archive")" + if grep -Eq "$pattern" <<< "$entries"; then + echo "$message" >&2 + echo "unexpected pattern '$pattern' in $archive" >&2 + exit 1 + fi +} + +require_zip_entry() { + local archive="$1" + local pattern="$2" + local message="$3" + local entries + entries="$(unzip -Z1 "$archive")" + if ! grep -Eq "$pattern" <<< "$entries"; then + echo "$message" >&2 + echo "expected pattern '$pattern' in $archive" >&2 + exit 1 + fi +} + +regex_escape() { + printf '%s' "$1" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g' +} + +require_selected_extension_controls() { + local archive="$1" + local label="$2" + local extension escaped + for extension in "${selected_extensions[@]}"; do + if [ "$extension" = "auto_explain" ]; then + continue + fi + escaped="$(regex_escape "$extension")" + require_zip_entry "$archive" "assets/oliphaunt/runtime/files/share/postgresql/extension/$escaped\\.control$" \ + "$label must include selected $extension extension assets" + done +} + +reject_unselected_extension_controls() { + local archive="$1" + local label="$2" + local extension escaped + while IFS= read -r extension; do + [ -n "$extension" ] || continue + [ "$extension" != "auto_explain" ] || continue + array_contains "$extension" "${selected_extensions[@]}" && continue + escaped="$(regex_escape "$extension")" + reject_zip_entry "$archive" "assets/oliphaunt/runtime/files/share/postgresql/extension/$escaped\\.control$" \ + "$label must not leak unselected $extension assets" + done < <(mobile_prebuilt_extensions) +} + +require_library_symbol() { + local library="$1" + local symbol="$2" + local symbols + if ! symbols="$(nm -D --defined-only "$library" 2>/dev/null)"; then + echo "could not inspect symbols in $library" >&2 + exit 1 + fi + if ! grep -Eq "[[:space:]]$symbol$" <<< "$symbols"; then + echo "missing required symbol $symbol in $library" >&2 + exit 1 + fi +} + +require cargo +require unzip +require nm +require xcodebuild + +IFS="," +read -r -a requested_extensions <<< "$selected_raw" +IFS=$' \t\n' +for requested in "${requested_extensions[@]}"; do + case "$(printf '%s\n' "$requested" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" in + all-mobile) + while IFS= read -r extension; do + add_extension "$extension" + done < <(mobile_prebuilt_extensions) + ;; + all-mobile-modules) + while IFS= read -r extension; do + add_extension "$extension" + done < <(oliphaunt_mobile_static_supported_extensions) + ;; + *) + add_extension "$requested" + ;; + esac +done + +if [ "${#selected_extensions[@]}" -eq 0 ]; then + echo "no mobile extension artifact check extensions selected" >&2 + exit 2 +fi + +selected_csv="$(join_csv "${selected_extensions[@]}")" +module_extensions_csv="$(join_csv "${selected_module_extensions[@]}")" +stems_csv="$(join_csv "${selected_stems[@]}")" +manifest_stems_csv="$(join_sorted_csv "${selected_stems[@]}")" + +printf 'checking mobile extension artifacts for extensions=%s stems=%s\n' "$selected_csv" "$stems_csv" + +rm -rf \ + "$resource_output" \ + "$jni_root" \ + "$kotlin_build_root" \ + "$kotlin_cxx_root" \ + "$rn_build_root" \ + "$rn_cxx_root" + +mobile_static_args=() +for stem in "${selected_stems[@]}"; do + mobile_static_args+=(--mobile-static-module "$stem") +done + +run env OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$module_extensions_csv" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh +run env OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$module_extensions_csv" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh +run env OLIPHAUNT_MOBILE_STATIC_EXTENSIONS="$module_extensions_csv" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh + +prepare_native_resource_runtime + +run env "${native_resource_env[@]}" cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ + --output "$resource_output" \ + --extension "$selected_csv" \ + "${mobile_static_args[@]}" \ + --require-mobile-static-registry \ + --force + +runtime_manifest="$resource_output/oliphaunt/runtime/manifest.properties" +static_registry_source="$resource_output/oliphaunt/static-registry/oliphaunt_static_registry.c" +require_manifest_line "$runtime_manifest" "extensions=$selected_csv" \ + "Rust runtime resources must record exact selected extensions" +require_manifest_line "$runtime_manifest" "nativeModuleStems=$manifest_stems_csv" \ + "Rust runtime resources must record selected native module stems" +if [ "${#selected_stems[@]}" -eq 0 ]; then + require_manifest_line "$runtime_manifest" "mobileStaticRegistryState=not-required" \ + "SQL-only mobile runtime resources must not invent a static registry" +else + require_manifest_line "$runtime_manifest" "mobileStaticRegistryState=complete" \ + "Rust runtime resources must prove mobile static registry completion" + require_text "$static_registry_source" "liboliphaunt_selected_static_extensions" \ + "Rust runtime resources must emit static extension registry glue" + for stem in "${selected_stems[@]}"; do + require_text "$static_registry_source" "$(oliphaunt_static_symbol_prefix "$stem")_Pg_magic_func" \ + "Rust runtime resources must strongly reference selected extension magic symbols" + done + case " ${selected_extensions[*]} " in + *" vector "*) + require_text "$static_registry_source" "vector_in" \ + "Rust runtime resources must strongly reference selected vector SQL symbols" + require_text "$static_registry_source" "pg_finfo_vector_in" \ + "Rust runtime resources must strongly reference selected vector SQL finfo symbols" + ;; + esac +fi + +run src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh \ + --runtime-resources "$resource_output" +run src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh \ + --check-current \ + --runtime-resources "$resource_output" + +for index in "${!selected_module_extensions[@]}"; do + extension="${selected_module_extensions[$index]}" + stem="${selected_stems[$index]}" + xcframework="target/liboliphaunt-ios-extension-xcframeworks/out/$stem/liboliphaunt_extension_$stem.xcframework" + [ -d "$xcframework" ] || { + echo "missing iOS extension XCFramework for $extension: $xcframework" >&2 + exit 1 + } + plutil -extract AvailableLibraries raw "$xcframework/Info.plist" >/dev/null +done + +for dependency in "${selected_ios_dependencies[@]}"; do + xcframework="target/liboliphaunt-ios-extension-xcframeworks/out/dependencies/$dependency/liboliphaunt_dependency_$dependency.xcframework" + [ -d "$xcframework" ] || { + echo "missing iOS dependency XCFramework for $dependency: $xcframework" >&2 + exit 1 + } + plutil -extract AvailableLibraries raw "$xcframework/Info.plist" >/dev/null +done + +mkdir -p "$jni_root/arm64-v8a" +cp target/liboliphaunt-pg18-android-arm64/out/liboliphaunt.so "$jni_root/arm64-v8a/liboliphaunt.so" + +kotlin_gradle="src/sdks/kotlin/gradlew" +run "$kotlin_gradle" -p src/sdks/kotlin :oliphaunt:bundleReleaseAar \ + -PoliphauntRuntimeResourcesDir="$resource_output" \ + -PoliphauntAndroidJniLibsDir="$jni_root" \ + -PoliphauntAndroidExtensionArchivesDir="$root/target/liboliphaunt-pg18-android-arm64/out" \ + -PoliphauntAndroidAbiFilters=arm64-v8a \ + -PoliphauntBuildRoot="$kotlin_build_root" \ + -PoliphauntCxxBuildRoot="$kotlin_cxx_root" \ + --project-cache-dir "$kotlin_cache_root" \ + --no-configuration-cache + +kotlin_aar="$kotlin_build_root/oliphaunt/outputs/aar/oliphaunt-release.aar" +if [ "${#selected_stems[@]}" -gt 0 ]; then + require_zip_entry "$kotlin_aar" 'jni/arm64-v8a/liboliphaunt_extensions\.so$' \ + "Kotlin Android release AAR must include selected-extension support library" +fi +require_selected_extension_controls "$kotlin_aar" "Kotlin Android release AAR" +reject_unselected_extension_controls "$kotlin_aar" "Kotlin Android release AAR" +reject_zip_entry "$kotlin_aar" 'assets/oliphaunt/static-registry/archives/' \ + "Kotlin Android release AAR must not ship build-only static extension archives" + +if [ "${#selected_stems[@]}" -gt 0 ]; then + kotlin_extension_library="$kotlin_build_root/oliphaunt/intermediates/cxx/RelWithDebInfo" + kotlin_extension_library="$(find "$kotlin_extension_library" -path '*/obj/arm64-v8a/liboliphaunt_extensions.so' -print -quit)" + require_library_symbol "$kotlin_extension_library" liboliphaunt_selected_static_extensions +fi + +run "$kotlin_gradle" -p src/sdks/react-native/android assembleDebug \ + -PoliphauntReactNativePackageRuntime=true \ + -PoliphauntRuntimeResourcesDir="$resource_output" \ + -PoliphauntAndroidJniLibsDir="$jni_root" \ + -PoliphauntAndroidExtensionArchivesDir="$root/target/liboliphaunt-pg18-android-arm64/out" \ + -PoliphauntAndroidAbiFilters=arm64-v8a \ + -PoliphauntKotlinSdkDir="$root/src/sdks/kotlin/oliphaunt" \ + -PoliphauntBuildRoot="$rn_build_root" \ + -PoliphauntCxxBuildRoot="$rn_cxx_root" \ + --project-cache-dir "$rn_cache_root" \ + --no-configuration-cache + +rn_aar="$rn_build_root/root/outputs/aar/oliphaunt-react-native-android-debug.aar" +require_selected_extension_controls "$rn_aar" "React Native Android AAR" +reject_unselected_extension_controls "$rn_aar" "React Native Android AAR" +if [ "${#selected_stems[@]}" -gt 0 ]; then + require_zip_entry "$rn_aar" 'jni/arm64-v8a/liboliphaunt_extensions\.so$' \ + "React Native Android AAR must include selected-extension support library" + reject_zip_entry "$rn_aar" 'assets/oliphaunt/static-registry/archives/' \ + "React Native Android AAR must not ship build-only static extension archives" + rn_extension_library="$rn_build_root/root/intermediates/cxx/Debug" + rn_extension_library="$(find "$rn_extension_library" -path '*/obj/arm64-v8a/liboliphaunt_extensions.so' -print -quit)" + require_library_symbol "$rn_extension_library" liboliphaunt_selected_static_extensions +fi + +printf '\nmobile extension artifact checks passed for %s\n' "$selected_csv" diff --git a/tools/policy/check-moon-product-graph.mjs b/tools/policy/check-moon-product-graph.mjs new file mode 100755 index 00000000..107914f5 --- /dev/null +++ b/tools/policy/check-moon-product-graph.mjs @@ -0,0 +1,1114 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import { runMoon } from './moon.mjs'; + +const releasePleaseConfig = JSON.parse(fs.readFileSync('release-please-config.json', 'utf8')); +const releasePackages = releasePleaseConfig.packages ?? {}; +const releaseProductIds = Object.values(releasePackages).map((config) => config.component).filter(Boolean); +const releasePackagePathByProduct = new Map( + Object.entries(releasePackages).map(([packagePath, config]) => [config.component, packagePath]), +); +const generatedSdkExtensions = JSON.parse(fs.readFileSync('src/extensions/generated/sdk/rust.json', 'utf8')).extensions ?? []; +const exactExtensionProducts = generatedSdkExtensions + .map((extension) => `oliphaunt-extension-${extension['sql-name'].replaceAll('_', '-').toLowerCase()}`) + .sort(); +const contribExtensionProducts = exactExtensionProducts.filter((product) => + releasePackagePathByProduct.get(product)?.startsWith('src/extensions/contrib/'), +); +const externalExtensionProducts = exactExtensionProducts.filter((product) => + releasePackagePathByProduct.get(product)?.startsWith('src/extensions/external/'), +); + +function parseProjects() { + const output = runMoon(['query', 'projects']); + const parsed = JSON.parse(output); + if (!Array.isArray(parsed.projects)) { + throw new Error('moon query projects did not return a projects array'); + } + return parsed.projects; +} + +function parseTasks() { + const output = runMoon(['query', 'tasks']); + const parsed = JSON.parse(output); + if (!parsed.tasks || typeof parsed.tasks !== 'object') { + throw new Error('moon query tasks did not return a tasks object'); + } + return parsed.tasks; +} + +function assertEqualSet(label, actual, expected) { + const actualSorted = [...actual].sort(); + const expectedSorted = [...expected].sort(); + if ( + actualSorted.length !== expectedSorted.length || + actualSorted.some((value, index) => value !== expectedSorted[index]) + ) { + throw new Error( + `${label}: expected [${expectedSorted.join(', ')}], got [${actualSorted.join(', ')}]`, + ); + } +} + +function downstreamClosure(projectId, dependentsByProject) { + const seen = new Set([projectId]); + const queue = [projectId]; + while (queue.length > 0) { + const current = queue.shift(); + for (const dependent of dependentsByProject.get(current) ?? []) { + if (!seen.has(dependent)) { + seen.add(dependent); + queue.push(dependent); + } + } + } + return seen; +} + +function ownerProjectForPath(projects, path) { + const matches = projects + .filter( + (project) => + project.source === '.' || + path === project.source || + path.startsWith(`${project.source}/`), + ) + .sort((left, right) => right.source.length - left.source.length); + return matches[0]?.id ?? null; +} + +function assertTaskCommand(tasks, projectId, taskId, expectedCommand) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + const actual = [task.command, ...(task.args ?? [])].join(' '); + if (expectedCommand.includes('.sh') && !expectedCommand.startsWith('bash ')) { + expectedCommand = `bash ${expectedCommand}`; + } + if (actual !== expectedCommand) { + throw new Error(`${projectId}:${taskId}: expected command '${expectedCommand}', got '${actual}'`); + } +} + +function assertShellTasksUseBash(tasks) { + for (const [projectId, projectTasks] of Object.entries(tasks)) { + for (const [taskId, task] of Object.entries(projectTasks ?? {})) { + const command = [task.command, ...(task.args ?? [])].join(' '); + if (command.includes('.sh') && !command.startsWith('bash ')) { + throw new Error(`${projectId}:${taskId}: shell script commands must start with 'bash', got '${command}'`); + } + } + } +} + +function assertTaskInput(tasks, projectId, taskId, expectedInput) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + const inputs = [ + ...Object.keys(task.inputFiles ?? {}), + ...Object.keys(task.inputGlobs ?? {}), + ...(task.inputs ?? []).map((input) => input.file ?? input.glob).filter(Boolean), + ]; + if (!inputs.includes(expectedInput)) { + throw new Error( + `${projectId}:${taskId}: expected input '${expectedInput}', got [${inputs.sort().join(', ')}]`, + ); + } +} + +function assertTaskEnv(tasks, projectId, taskId, expectedName, expectedValue) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + if ((task.env ?? {})[expectedName] !== expectedValue) { + throw new Error( + `${projectId}:${taskId}: expected env ${expectedName}='${expectedValue}', got '${(task.env ?? {})[expectedName]}'`, + ); + } +} + +function assertTaskCargoTargetDir(tasks, projectId, taskId) { + assertTaskEnv(tasks, projectId, taskId, 'CARGO_TARGET_DIR', `target/moon/${projectId}/${taskId}`); +} + +function assertTaskCache(tasks, projectId, taskId, expected) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + if (task.options?.cache !== expected) { + throw new Error( + `${projectId}:${taskId}: expected cache=${JSON.stringify(expected)}, got ${JSON.stringify(task.options?.cache)}`, + ); + } +} + +function assertTaskRunsInCI(tasks, projectId, taskId) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + if (task.options?.runInCI === false) { + throw new Error(`${projectId}:${taskId}: task is invoked by CI and must not set runInCI=false`); + } +} + +function assertTaskRunsOutsideCI(tasks, projectId, taskId) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + if (task.options?.runInCI !== false) { + throw new Error(`${projectId}:${taskId}: task is not invoked by normal CI and must set runInCI=false`); + } +} + +function assertTaskSkippedByBroadCI(tasks, projectId, taskId) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + if (task.options?.runInCI !== 'skip') { + throw new Error(`${projectId}:${taskId}: expected runInCI=skip so broad Moon CI does not start it`); + } +} + +function assertTaskDependency(tasks, projectId, taskId, expectedTarget) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + const deps = (task.deps ?? []).map((dep) => dep.target ?? dep); + if (!deps.includes(expectedTarget)) { + throw new Error( + `${projectId}:${taskId}: expected dependency '${expectedTarget}', got [${deps.sort().join(', ')}]`, + ); + } +} + +function assertTaskDependencyCacheStrategy(tasks, projectId, taskId, expectedTarget, expectedStrategy) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + const dep = (task.deps ?? []).find((entry) => (entry.target ?? entry) === expectedTarget); + if (!dep) { + throw new Error(`${projectId}:${taskId}: expected dependency '${expectedTarget}'`); + } + if (dep.cacheStrategy !== expectedStrategy) { + throw new Error( + `${projectId}:${taskId}: dependency '${expectedTarget}' expected cacheStrategy='${expectedStrategy}', got '${dep.cacheStrategy}'`, + ); + } +} + +function assertTaskTags(tasks, projectId, taskId, expectedTags) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + const actual = new Set(task.tags ?? []); + const missing = expectedTags.filter((tag) => !actual.has(tag)); + if (missing.length > 0) { + throw new Error( + `${projectId}:${taskId} tags missing [${missing.join(', ')}], got [${[...actual].sort().join(', ')}]`, + ); + } +} + +function assertCiTagTargets(tasks, expectedTargetsByTag) { + const actualTargetsByTag = new Map(); + for (const [projectId, projectTasks] of Object.entries(tasks)) { + for (const [taskId, task] of Object.entries(projectTasks ?? {})) { + const target = task.target ?? `${projectId}:${taskId}`; + for (const tag of task.tags ?? []) { + if (typeof tag === 'string' && tag.startsWith('ci-')) { + const targets = actualTargetsByTag.get(tag) ?? new Set(); + targets.add(target); + actualTargetsByTag.set(tag, targets); + } + } + } + } + for (const [tag, expectedTargets] of expectedTargetsByTag.entries()) { + assertEqualSet(`Moon CI tag ${tag}`, actualTargetsByTag.get(tag) ?? new Set(), new Set(expectedTargets)); + } + assertEqualSet( + 'Moon CI tag set', + new Set(actualTargetsByTag.keys()), + new Set(expectedTargetsByTag.keys()), + ); +} + +function assertNoDefaultInputs(tasks, projectId, taskId) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + if (task.state?.defaultInputs) { + throw new Error(`${projectId}:${taskId}: must declare explicit inputs; default **/* inputs are not allowed`); + } +} + +function rejectTaskInput(tasks, projectId, taskId, rejectedInput) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + const inputs = [ + ...Object.keys(task.inputFiles ?? {}), + ...Object.keys(task.inputGlobs ?? {}), + ...(task.inputs ?? []).map((input) => input.file ?? input.glob).filter(Boolean), + ]; + if (inputs.includes(rejectedInput)) { + throw new Error(`${projectId}:${taskId}: input '${rejectedInput}' would include generated moon cache state`); + } +} + +function assertTaskOutput(tasks, projectId, taskId, expectedOutput) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + throw new Error(`missing moon task ${projectId}:${taskId}`); + } + const outputs = [ + ...Object.keys(task.outputFiles ?? {}), + ...Object.keys(task.outputGlobs ?? {}), + ...(task.outputs ?? []).map((output) => output.file ?? output.glob ?? output).filter(Boolean), + ]; + if (!outputs.includes(expectedOutput)) { + throw new Error( + `${projectId}:${taskId}: expected output '${expectedOutput}', got [${outputs.sort().join(', ')}]`, + ); + } +} + +const projects = parseProjects(); +const tasks = parseTasks(); +assertShellTasksUseBash(tasks); +const requiredProjects = new Set([ + 'repo', + 'ci-workflows', + 'docs', + 'benchmarks', + 'integration-examples', + 'postgres18', + 'source-inputs', + 'source-toolchains', + 'third-party-shared', + 'third-party-native', + 'third-party-wasix', + 'extension-runtime-contract', + 'extension-model', + 'extension-artifacts-native', + 'extension-artifacts-wasix', + 'extension-packages', + 'extension-contrib-postgres18', + 'extension-age', + 'extensions', + 'shared-contracts', + 'shared-fixtures', + 'shared-js-core', + ...releaseProductIds, + 'dev-tools', + 'coverage-tools', + 'perf-tools', + 'policy-tools', + 'release-tools', + 'test-tools', + 'xtask', +]); +assertEqualSet( + 'moon projects', + new Set(projects.map((project) => project.id).filter((id) => requiredProjects.has(id))), + requiredProjects, +); + +const byId = new Map(projects.map((project) => [project.id, project])); +const dependentsByProject = new Map(); +for (const project of projects) { + for (const dependency of project.dependencies ?? []) { + if (!dependentsByProject.has(dependency.id)) { + dependentsByProject.set(dependency.id, new Set()); + } + dependentsByProject.get(dependency.id).add(project.id); + } +} + +const expectedDirectDependencies = new Map([ + [ + 'repo', + [ + 'oliphaunt-kotlin', + 'oliphaunt-react-native', + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-js', + 'oliphaunt-wasix-rust', + ], + ], + ['ci-workflows', []], + ['benchmarks', []], + [ + 'docs', + [ + 'extensions', + 'liboliphaunt-native', + 'liboliphaunt-wasix', + 'oliphaunt-kotlin', + 'oliphaunt-react-native', + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-js', + 'oliphaunt-wasix-rust', + 'postgres18', + 'source-toolchains', + 'third-party-shared', + 'third-party-native', + 'third-party-wasix', + ], + ], + ['integration-examples', []], + ['postgres18', []], + ['source-inputs', []], + ['source-toolchains', []], + ['third-party-shared', []], + ['third-party-native', []], + ['third-party-wasix', []], + [ + 'extensions', + [ + 'extension-age', + 'extension-contrib-postgres18', + 'extension-runtime-contract', + ...exactExtensionProducts, + 'postgres18', + ], + ], + ['extension-runtime-contract', []], + ['extension-contrib-postgres18', ['extension-runtime-contract', 'postgres18']], + ['extension-age', ['extension-runtime-contract']], + ...contribExtensionProducts.map((product) => [ + product, + ['extension-contrib-postgres18', 'extension-runtime-contract'], + ]), + ...externalExtensionProducts.map((product) => [product, ['extension-runtime-contract']]), + ['extension-model', ['extensions']], + ['extension-artifacts-native', ['extension-model', 'extensions', 'liboliphaunt-native', 'source-inputs']], + ['extension-artifacts-wasix', ['extension-model', 'extensions', 'liboliphaunt-wasix', 'source-inputs']], + ['extension-packages', ['extension-artifacts-native', 'extension-artifacts-wasix', 'extensions']], + ['shared-contracts', []], + ['shared-fixtures', ['shared-contracts']], + ['shared-js-core', []], + [ + 'liboliphaunt-native', + [ + 'extension-runtime-contract', + 'postgres18', + 'source-inputs', + 'third-party-native', + 'third-party-shared', + ], + ], + [ + 'liboliphaunt-wasix', + [ + 'extension-model', + 'extension-runtime-contract', + 'postgres18', + 'shared-fixtures', + 'source-toolchains', + 'third-party-shared', + 'third-party-wasix', + ], + ], + ['oliphaunt-rust', ['extension-artifacts-native', 'liboliphaunt-native', 'shared-contracts', 'shared-fixtures']], + ['oliphaunt-swift', ['liboliphaunt-native', 'shared-contracts', 'shared-fixtures']], + ['oliphaunt-kotlin', ['liboliphaunt-native', 'shared-contracts', 'shared-fixtures']], + [ + 'oliphaunt-react-native', + [ + 'oliphaunt-kotlin', + 'oliphaunt-swift', + 'shared-contracts', + 'shared-fixtures', + 'shared-js-core', + ], + ], + [ + 'oliphaunt-js', + [ + 'liboliphaunt-native', + 'oliphaunt-broker', + 'oliphaunt-node-direct', + 'oliphaunt-rust', + 'shared-contracts', + 'shared-fixtures', + 'shared-js-core', + ], + ], + ['oliphaunt-wasix-rust', ['liboliphaunt-wasix', 'shared-fixtures']], + ['oliphaunt-broker', ['liboliphaunt-native', 'oliphaunt-rust']], + ['oliphaunt-node-direct', ['liboliphaunt-native']], + ['dev-tools', []], + ['coverage-tools', []], + ['perf-tools', ['benchmarks']], + ['policy-tools', []], + ['test-tools', []], + [ + 'release-tools', + [ + 'liboliphaunt-native', + 'liboliphaunt-wasix', + 'extensions', + 'postgres18', + 'source-toolchains', + 'third-party-shared', + 'third-party-native', + 'third-party-wasix', + 'oliphaunt-kotlin', + 'oliphaunt-react-native', + 'oliphaunt-js', + 'oliphaunt-broker', + 'oliphaunt-node-direct', + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-wasix-rust', + ], + ], + ['xtask', []], +]); +for (const [projectId, expected] of expectedDirectDependencies) { + const project = byId.get(projectId); + if (!project) { + throw new Error(`missing moon project ${projectId}`); + } + assertEqualSet( + `${projectId} dependencies`, + new Set((project.dependencies ?? []).map((dependency) => dependency.id)), + new Set(expected), + ); +} + +const expectedRepoTasks = new Set([ + 'check', + 'structure', + 'tooling', + 'docs-policy', + 'release-policy', + 'release-metadata', + 'moon-graph', + 'prek', + 'test', + 'smoke', + 'regression', + 'package', + 'coverage', + 'coverage-policy', + 'release-check', + 'bench', + 'bench-run', +]); +assertEqualSet('repo tasks', new Set(Object.keys(byId.get('repo')?.tasks ?? {})), expectedRepoTasks); + +const expectedSourceInputTasks = new Set(['check']); +for (const projectId of [ + 'postgres18', + 'source-toolchains', + 'extension-runtime-contract', + 'extension-contrib-postgres18', + 'extension-age', + 'extensions', + 'shared-js-core', +]) { + const project = byId.get(projectId); + assertEqualSet(`${projectId} tasks`, new Set(Object.keys(project.tasks ?? {})), expectedSourceInputTasks); +} +for (const projectId of exactExtensionProducts) { + const project = byId.get(projectId); + assertEqualSet(`${projectId} tasks`, new Set(Object.keys(project.tasks ?? {})), new Set(['check', 'assemble-release'])); +} +assertEqualSet( + 'source-inputs tasks', + new Set(Object.keys(byId.get('source-inputs')?.tasks ?? {})), + new Set(['source-fetch', 'source-fetch-native-runtime', 'source-fetch-wasix-runtime', 'source-fetch-extensions']), +); +assertEqualSet( + 'third-party-shared tasks', + new Set(Object.keys(byId.get('third-party-shared')?.tasks ?? {})), + expectedSourceInputTasks, +); +for (const projectId of ['third-party-native', 'third-party-wasix']) { + const project = byId.get(projectId); + assertEqualSet(`${projectId} tasks`, new Set(Object.keys(project.tasks ?? {})), expectedSourceInputTasks); +} +assertEqualSet( + 'liboliphaunt tasks', + new Set(Object.keys(byId.get('liboliphaunt-native')?.tasks ?? {})), + new Set([ + 'check', + 'test', + 'smoke', + 'build-ios-xcframework', + 'release-check', + 'bench', + 'release-runtime', + 'release-runtime-desktop', + 'release-runtime-mobile-target', + 'release-assets', + ]), +); +assertEqualSet( + 'liboliphaunt-wasix tasks', + new Set(Object.keys(byId.get('liboliphaunt-wasix')?.tasks ?? {})), + new Set(['check', 'release-check', 'runtime-portable', 'runtime-aot', 'release-assets', 'smoke', 'regression']), +); +assertEqualSet( + 'extension-model tasks', + new Set(Object.keys(byId.get('extension-model')?.tasks ?? {})), + new Set(['check']), +); +assertEqualSet( + 'extension-artifacts-native tasks', + new Set(Object.keys(byId.get('extension-artifacts-native')?.tasks ?? {})), + new Set(['check', 'release-check', 'build-target']), +); +assertEqualSet( + 'extension-artifacts-wasix tasks', + new Set(Object.keys(byId.get('extension-artifacts-wasix')?.tasks ?? {})), + new Set(['check', 'build-target']), +); +assertEqualSet( + 'extension-packages tasks', + new Set(Object.keys(byId.get('extension-packages')?.tasks ?? {})), + new Set(['assemble-release', 'assemble-mobile']), +); + +const expectedSdkTasks = new Set([ + 'check', + 'test', + 'smoke', + 'package', + 'package-artifacts', + 'release-check', + 'bench', + 'regression', + 'coverage', + 'bench-run', +]); +for (const projectId of [ + 'oliphaunt-swift', + 'oliphaunt-kotlin', + 'oliphaunt-js', +]) { + const project = byId.get(projectId); + assertEqualSet(`${projectId} tasks`, new Set(Object.keys(project.tasks ?? {})), expectedSdkTasks); +} +assertEqualSet( + 'oliphaunt-rust tasks', + new Set(Object.keys(byId.get('oliphaunt-rust')?.tasks ?? {})), + new Set([...expectedSdkTasks, 'extension-regression']), +); +assertEqualSet( + 'oliphaunt-wasix-rust tasks', + new Set(Object.keys(byId.get('oliphaunt-wasix-rust')?.tasks ?? {})), + new Set(['check', 'test', 'package', 'package-artifacts', 'release-check', 'example-check', 'bench', 'coverage', 'bench-run']), +); +assertEqualSet( + 'oliphaunt-react-native tasks', + new Set(Object.keys(byId.get('oliphaunt-react-native')?.tasks ?? {})), + new Set([ + ...expectedSdkTasks, + 'build-android-bridge', + 'build-ios-bridge', + 'smoke-android', + 'smoke-ios', + 'smoke-mobile', + 'e2e', + 'mobile-build-android', + 'mobile-e2e-android', + 'mobile-drill-android', + 'mobile-build-ios', + 'mobile-e2e-ios', + 'mobile-drill-ios', + ]), +); +assertEqualSet( + 'docs tasks', + new Set(Object.keys(byId.get('docs')?.tasks ?? {})), + new Set(['dev', 'check', 'test', 'build', 'smoke', 'release-check']), +); +assertEqualSet( + 'oliphaunt-broker tasks', + new Set(Object.keys(byId.get('oliphaunt-broker')?.tasks ?? {})), + new Set(['check', 'test', 'package', 'release-check', 'release-assets']), +); +assertEqualSet( + 'oliphaunt-node-direct tasks', + new Set(Object.keys(byId.get('oliphaunt-node-direct')?.tasks ?? {})), + new Set(['check', 'test', 'package', 'release-check', 'release-assets']), +); + +assertTaskCommand(tasks, 'repo', 'check', 'true'); +for (const dependency of [ + 'repo:structure', + 'repo:tooling', + 'repo:docs-policy', + 'repo:release-policy', + 'repo:release-metadata', + 'repo:moon-graph', + 'repo:prek', +]) { + assertTaskDependency(tasks, 'repo', 'check', dependency); +} +assertTaskCommand(tasks, 'repo', 'coverage', 'tools/coverage/summarize'); +assertTaskCommand(tasks, 'repo', 'coverage-policy', 'tools/policy/check-coverage.sh all'); +assertTaskCommand(tasks, 'policy-tools', 'check', 'tools/policy/check-policy-tools.sh'); +assertTaskCommand(tasks, 'shared-js-core', 'check', 'node src/shared/js-core/tools/check-js-core.mjs'); +assertTaskInput(tasks, 'shared-js-core', 'check', '/src/shared/js-core/**/*'); +assertTaskInput(tasks, 'shared-js-core', 'check', '/src/sdks/js/src/protocol.ts'); +assertTaskInput(tasks, 'shared-js-core', 'check', '/src/sdks/js/src/query.ts'); +assertTaskInput(tasks, 'shared-js-core', 'check', '/src/sdks/react-native/src/protocol.ts'); +assertTaskInput(tasks, 'shared-js-core', 'check', '/src/sdks/react-native/src/query.ts'); +assertTaskCache(tasks, 'shared-js-core', 'check', true); +assertTaskCommand(tasks, 'docs', 'check', 'pnpm --dir src/docs run check'); +assertTaskCommand(tasks, 'docs', 'test', 'pnpm --dir src/docs run test'); +for (const [projectId, taskId] of [ + ['oliphaunt-rust', 'regression'], + ['oliphaunt-js', 'regression'], + ['liboliphaunt-wasix', 'regression'], +]) { + assertTaskRunsInCI(tasks, projectId, taskId); +} +assertTaskCommand(tasks, 'docs', 'build', 'pnpm --dir src/docs run build'); +assertTaskCommand(tasks, 'docs', 'smoke', 'pnpm --dir src/docs run smoke'); +assertTaskCommand(tasks, 'docs', 'release-check', 'pnpm --dir src/docs run release-check'); +assertTaskCommand(tasks, 'oliphaunt-rust', 'check', 'src/sdks/rust/tools/check-sdk.sh check-static'); +assertTaskCommand(tasks, 'oliphaunt-rust', 'test', 'src/sdks/rust/tools/check-sdk.sh test-unit'); +assertTaskCommand(tasks, 'oliphaunt-rust', 'regression', 'src/sdks/rust/tools/check-sdk.sh regression'); +assertTaskCommand(tasks, 'oliphaunt-rust', 'extension-regression', 'src/sdks/rust/tools/check-sdk.sh extension-regression'); +assertTaskCommand(tasks, 'oliphaunt-swift', 'check', 'src/sdks/swift/tools/check-sdk.sh check-static'); +assertTaskCommand(tasks, 'oliphaunt-swift', 'test', 'src/sdks/swift/tools/check-sdk.sh test-unit'); +assertTaskCommand(tasks, 'oliphaunt-kotlin', 'check', 'src/sdks/kotlin/tools/check-sdk.sh check-static'); +assertTaskCommand(tasks, 'oliphaunt-kotlin', 'test', 'src/sdks/kotlin/tools/check-sdk.sh test-unit'); +assertTaskCommand(tasks, 'oliphaunt-rust', 'package', 'src/sdks/rust/tools/check-sdk.sh package-shape'); +assertTaskCommand(tasks, 'oliphaunt-rust', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-rust'); +assertTaskCommand(tasks, 'oliphaunt-swift', 'package', 'src/sdks/swift/tools/check-sdk.sh package-shape'); +assertTaskCommand(tasks, 'oliphaunt-swift', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-swift'); +assertTaskCommand(tasks, 'oliphaunt-kotlin', 'package', 'src/sdks/kotlin/tools/check-sdk.sh package-shape'); +assertTaskCommand(tasks, 'oliphaunt-kotlin', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-kotlin'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'check', 'src/sdks/react-native/tools/check-sdk.sh check-static'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'build-android-bridge', 'src/sdks/react-native/tools/check-sdk.sh build-android-bridge'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'build-ios-bridge', 'src/sdks/react-native/tools/check-sdk.sh build-ios-bridge'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'test', 'src/sdks/react-native/tools/check-sdk.sh test-unit'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'package', 'src/sdks/react-native/tools/check-sdk.sh package-shape'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-react-native'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'smoke-android', 'pnpm --dir src/sdks/react-native/examples/expo run smoke:android'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'smoke-ios', 'pnpm --dir src/sdks/react-native/examples/expo run smoke:ios'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'smoke-mobile', 'pnpm --dir src/sdks/react-native/examples/expo run smoke'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'mobile-build-android', 'pnpm --dir src/sdks/react-native/examples/expo run mobile-build:android'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'mobile-e2e-android', 'pnpm --dir src/sdks/react-native/examples/expo run mobile-e2e:android'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'mobile-drill-android', 'pnpm --dir src/sdks/react-native/examples/expo run mobile-drill:android'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'mobile-build-ios', 'pnpm --dir src/sdks/react-native/examples/expo run mobile-build:ios'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'mobile-e2e-ios', 'pnpm --dir src/sdks/react-native/examples/expo run mobile-e2e:ios'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'mobile-drill-ios', 'pnpm --dir src/sdks/react-native/examples/expo run mobile-drill:ios'); +assertTaskCommand(tasks, 'oliphaunt-js', 'check', 'src/sdks/js/tools/check-sdk.sh check-static'); +assertTaskCommand(tasks, 'oliphaunt-js', 'test', 'src/sdks/js/tools/check-sdk.sh test-unit'); +assertTaskCommand(tasks, 'oliphaunt-js', 'package', 'src/sdks/js/tools/check-sdk.sh package-shape'); +assertTaskCommand(tasks, 'oliphaunt-js', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-js'); +assertTaskCommand(tasks, 'extension-artifacts-native', 'build-target', 'src/extensions/artifacts/native/tools/package-release-assets.sh'); +assertTaskDependency(tasks, 'extension-artifacts-native', 'release-check', 'source-inputs:source-fetch-native-runtime'); +assertTaskDependency(tasks, 'extension-artifacts-native', 'build-target', 'source-inputs:source-fetch-native-runtime'); +rejectTaskInput(tasks, 'extension-artifacts-native', 'release-check', '/src/sdks/rust/src/bin/package_resources.rs'); +rejectTaskInput(tasks, 'extension-artifacts-native', 'release-check', '/src/sdks/rust/src/bin/extension_artifact.rs'); +rejectTaskInput(tasks, 'extension-artifacts-native', 'release-check', '/src/sdks/rust/src/runtime_resources/**/*'); +assertTaskInput(tasks, 'extension-artifacts-native', 'build-target', '/src/sources/third-party/shared/**/*'); +assertTaskInput(tasks, 'extension-artifacts-native', 'build-target', '/src/sources/third-party/native/**/*'); +rejectTaskInput(tasks, 'extension-artifacts-native', 'build-target', '/src/sdks/rust/src/bin/package_resources.rs'); +rejectTaskInput(tasks, 'extension-artifacts-native', 'build-target', '/src/sdks/rust/src/bin/extension_artifact.rs'); +rejectTaskInput(tasks, 'extension-artifacts-native', 'build-target', '/src/sdks/rust/src/runtime_resources/**/*'); +assertTaskOutput(tasks, 'extension-artifacts-native', 'build-target', 'target/extensions/native/release-assets/**/*'); +assertTaskCommand(tasks, 'extension-artifacts-wasix', 'build-target', 'src/extensions/artifacts/wasix/tools/package-release-assets.sh'); +assertTaskDependency(tasks, 'extension-artifacts-wasix', 'build-target', 'liboliphaunt-wasix:runtime-portable'); +assertTaskOutput(tasks, 'extension-artifacts-wasix', 'build-target', 'target/extensions/wasix/release-assets/**/*'); +assertTaskCommand(tasks, 'extension-packages', 'assemble-release', 'python3 tools/release/build-extension-ci-artifacts.py --all --require-native --require-wasix'); +assertTaskOutput(tasks, 'extension-packages', 'assemble-release', 'target/extension-artifacts/**/*'); +assertTaskCommand(tasks, 'extension-packages', 'assemble-mobile', 'src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh'); +assertTaskOutput(tasks, 'extension-packages', 'assemble-mobile', 'target/extension-artifacts/**/*'); +for (const projectId of exactExtensionProducts) { + assertTaskCommand(tasks, projectId, 'assemble-release', `python3 tools/release/build-extension-ci-artifacts.py ${projectId} --require-native --require-wasix`); + assertTaskOutput(tasks, projectId, 'assemble-release', `target/extension-artifacts/${projectId}/**/*`); + assertTaskCache(tasks, projectId, 'assemble-release', false); +} +assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'test', 'src/bindings/wasix-rust/tools/check-unit.sh'); +assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'example-check', 'src/bindings/wasix-rust/tools/check-examples.sh'); +assertTaskCommand(tasks, 'oliphaunt-broker', 'release-check', 'src/runtimes/broker/tools/check-package.sh'); +assertTaskCommand(tasks, 'oliphaunt-broker', 'release-assets', 'tools/release/package-broker-assets.sh'); +assertTaskTags(tasks, 'oliphaunt-broker', 'release-assets', ['artifact', 'release']); +assertTaskCache(tasks, 'oliphaunt-broker', 'release-assets', false); +assertTaskOutput(tasks, 'oliphaunt-broker', 'release-assets', 'target/oliphaunt-broker/release-assets/**/*'); +assertTaskCommand(tasks, 'oliphaunt-node-direct', 'release-check', 'src/runtimes/node-direct/tools/check-package.sh package-shape'); +assertTaskCommand(tasks, 'oliphaunt-node-direct', 'release-assets', 'src/runtimes/node-direct/tools/build-node-addon.sh'); +assertTaskTags(tasks, 'oliphaunt-node-direct', 'release-assets', ['artifact', 'release']); +assertTaskCache(tasks, 'oliphaunt-node-direct', 'release-assets', false); +assertTaskOutput(tasks, 'oliphaunt-node-direct', 'release-assets', 'target/oliphaunt-node-direct/release-assets/**/*'); +assertTaskOutput(tasks, 'oliphaunt-node-direct', 'release-assets', 'target/oliphaunt-node-direct/npm-packages/**/*'); +assertTaskCommand(tasks, 'liboliphaunt-wasix', 'regression', 'src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh regression'); +assertTaskCommand(tasks, 'liboliphaunt-wasix', 'runtime-portable', 'src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh'); +assertTaskCommand(tasks, 'liboliphaunt-wasix', 'runtime-aot', 'src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh'); +assertTaskCommand(tasks, 'liboliphaunt-wasix', 'release-assets', 'cargo run -p xtask -- release package-assets'); +assertTaskTags(tasks, 'liboliphaunt-wasix', 'runtime-portable', ['artifact', 'runtime']); +assertTaskTags(tasks, 'liboliphaunt-wasix', 'runtime-aot', ['artifact', 'runtime']); +assertTaskTags(tasks, 'liboliphaunt-wasix', 'release-assets', ['artifact', 'release']); +assertTaskCache(tasks, 'liboliphaunt-wasix', 'runtime-portable', false); +assertTaskCache(tasks, 'liboliphaunt-wasix', 'runtime-aot', false); +assertTaskCache(tasks, 'liboliphaunt-wasix', 'release-assets', false); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/.github/workflows/ci.yml'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/src/runtimes/liboliphaunt/wasix/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/src/postgres/versions/18/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/src/sources/toolchains/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/src/sources/third-party/shared/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/src/sources/third-party/wasix/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/src/shared/extension-runtime-contract/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-portable', '/tools/xtask/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/.github/workflows/ci.yml'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/src/runtimes/liboliphaunt/wasix/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/src/postgres/versions/18/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/src/sources/toolchains/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/src/sources/third-party/shared/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/src/sources/third-party/wasix/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/src/shared/extension-runtime-contract/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/tools/runtime/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'runtime-aot', '/tools/xtask/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'release-assets', '/target/oliphaunt-wasix/wasix-build/build/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'release-assets', '/target/oliphaunt-wasix/assets/**/*'); +assertTaskInput(tasks, 'liboliphaunt-wasix', 'release-assets', '/target/oliphaunt-wasix/aot/**/*'); +assertTaskOutput(tasks, 'liboliphaunt-wasix', 'release-assets', 'target/oliphaunt-wasix/release-assets/**/*'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'smoke-mobile', 'oliphaunt-swift:smoke'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'smoke-mobile', 'oliphaunt-kotlin:smoke'); +assertTaskCommand(tasks, 'oliphaunt-react-native', 'e2e', 'pnpm --dir src/sdks/react-native/examples/expo run mobile-e2e'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'e2e', 'oliphaunt-react-native:mobile-build-android'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'e2e', 'oliphaunt-react-native:mobile-e2e-android'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'e2e', 'oliphaunt-react-native:mobile-build-ios'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'e2e', 'oliphaunt-react-native:mobile-e2e-ios'); +assertTaskCache(tasks, 'oliphaunt-react-native', 'e2e', false); +assertTaskRunsOutsideCI(tasks, 'oliphaunt-react-native', 'e2e'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'mobile-build-android', 'oliphaunt-kotlin:package-artifacts'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'mobile-build-android', 'oliphaunt-react-native:package-artifacts'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'mobile-build-ios', 'oliphaunt-swift:package-artifacts'); +assertTaskDependency(tasks, 'oliphaunt-react-native', 'mobile-build-ios', 'oliphaunt-react-native:package-artifacts'); +assertTaskInput(tasks, 'oliphaunt-react-native', 'mobile-build-android', '/target/sdk-artifacts/oliphaunt-kotlin/**/*'); +assertTaskInput(tasks, 'oliphaunt-react-native', 'mobile-build-android', '/target/sdk-artifacts/oliphaunt-react-native/**/*'); +assertTaskInput(tasks, 'oliphaunt-react-native', 'mobile-build-ios', '/target/sdk-artifacts/oliphaunt-react-native/**/*'); +assertTaskInput(tasks, 'oliphaunt-react-native', 'mobile-build-ios', '/target/sdk-artifacts/oliphaunt-swift/**/*'); +assertTaskRunsInCI(tasks, 'oliphaunt-react-native', 'mobile-build-android'); +assertTaskRunsInCI(tasks, 'oliphaunt-react-native', 'mobile-build-ios'); +assertTaskSkippedByBroadCI(tasks, 'oliphaunt-react-native', 'mobile-e2e-android'); +assertTaskSkippedByBroadCI(tasks, 'oliphaunt-react-native', 'mobile-e2e-ios'); +assertTaskRunsOutsideCI(tasks, 'oliphaunt-react-native', 'mobile-drill-android'); +assertTaskRunsOutsideCI(tasks, 'oliphaunt-react-native', 'mobile-drill-ios'); +for (const taskId of ['mobile-e2e-android', 'mobile-e2e-ios']) { + assertTaskInput(tasks, 'oliphaunt-react-native', taskId, '/src/sdks/react-native/examples/expo/maestro/**/*'); + assertTaskInput(tasks, 'oliphaunt-react-native', taskId, '/src/sources/toolchains/maestro.toml'); + assertTaskInput(tasks, 'oliphaunt-react-native', taskId, '/tools/dev/setup-maestro.sh'); +} +assertTaskInput( + tasks, + 'oliphaunt-react-native', + 'mobile-e2e-android', + '/src/sources/toolchains/android-emulator-runner.toml', +); +assertTaskDependency(tasks, 'liboliphaunt-native', 'test', 'liboliphaunt-native:release-runtime'); +assertTaskDependency(tasks, 'liboliphaunt-native', 'release-runtime', 'source-inputs:source-fetch-native-runtime'); +assertTaskDependency(tasks, 'liboliphaunt-native', 'release-runtime-desktop', 'source-inputs:source-fetch-native-runtime'); +assertTaskDependency( + tasks, + 'liboliphaunt-native', + 'release-runtime-mobile-target', + 'source-inputs:source-fetch-native-runtime', +); +assertTaskDependency(tasks, 'liboliphaunt-native', 'release-check', 'liboliphaunt-native:release-runtime'); +assertTaskEnv(tasks, 'liboliphaunt-native', 'release-check', 'OLIPHAUNT_TRACK_BUILD', 'never'); +assertTaskDependency(tasks, 'oliphaunt-rust', 'regression', 'liboliphaunt-native:test'); +assertTaskDependency(tasks, 'oliphaunt-rust', 'extension-regression', 'extension-artifacts-native:release-check'); +assertTaskRunsOutsideCI(tasks, 'oliphaunt-rust', 'extension-regression'); +assertTaskTags(tasks, 'liboliphaunt-native', 'release-runtime', ['runtime', 'release']); +assertTaskTags(tasks, 'liboliphaunt-native', 'release-runtime-desktop', [ + 'runtime', + 'release', + 'ci-liboliphaunt-native-desktop', +]); +assertTaskTags(tasks, 'liboliphaunt-native', 'release-runtime-mobile-target', [ + 'runtime', + 'release', + 'ci-liboliphaunt-native-android', + 'ci-liboliphaunt-native-ios', +]); +assertTaskCache(tasks, 'liboliphaunt-native', 'release-runtime', false); +assertTaskCache(tasks, 'liboliphaunt-native', 'release-runtime-desktop', false); +assertTaskCache(tasks, 'liboliphaunt-native', 'release-runtime-mobile-target', false); +for (const sourceFetchTask of [ + 'source-fetch', + 'source-fetch-native-runtime', + 'source-fetch-wasix-runtime', + 'source-fetch-extensions', +]) { + assertTaskTags(tasks, 'source-inputs', sourceFetchTask, ['source', 'fetch']); + assertTaskCache(tasks, 'source-inputs', sourceFetchTask, false); +} +for (const requiredFetchInput of [ + '/src/sources/**/*', + '/src/extensions/external/**/source.toml', + '/src/extensions/external/**/dependencies/**/source.toml', + '/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile', + '/tools/policy/fetch-sources.mjs', +]) { + assertTaskInput(tasks, 'source-inputs', 'source-fetch', requiredFetchInput); +} +for (const sourceFetchTask of [ + 'source-fetch', + 'source-fetch-native-runtime', + 'source-fetch-wasix-runtime', + 'source-fetch-extensions', +]) { + for (const rustFetchInput of ['/Cargo.lock', '/Cargo.toml', '/rust-toolchain.toml', '/tools/xtask/**/*']) { + rejectTaskInput(tasks, 'source-inputs', sourceFetchTask, rustFetchInput); + } +} +for (const rejectedNativeFetchInput of [ + '/src/extensions/**/*', + '/src/sources/toolchains/**/*', + '/src/sources/third-party/wasix/**/*', + '/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile', +]) { + rejectTaskInput(tasks, 'source-inputs', 'source-fetch-native-runtime', rejectedNativeFetchInput); +} +assertTaskCommand(tasks, 'source-inputs', 'source-fetch', 'bun tools/policy/fetch-sources.mjs all'); +assertTaskCommand(tasks, 'source-inputs', 'source-fetch-native-runtime', 'bun tools/policy/fetch-sources.mjs native-runtime'); +assertTaskCommand(tasks, 'source-inputs', 'source-fetch-wasix-runtime', 'bun tools/policy/fetch-sources.mjs wasix-runtime'); +assertTaskCommand(tasks, 'source-inputs', 'source-fetch-extensions', 'bun tools/policy/fetch-sources.mjs extensions'); +for (const requiredNativeRuntimeSourceInput of [ + '/src/sources/third-party/shared/**/*', + '/src/sources/third-party/native/**/*', + '/src/extensions/external/**/source.toml', + '/src/extensions/external/**/dependencies/**/source.toml', +]) { + assertTaskInput(tasks, 'source-inputs', 'source-fetch-native-runtime', requiredNativeRuntimeSourceInput); +} +for (const requiredWasixRuntimeSourceInput of [ + '/src/sources/toolchains/**/*', + '/src/sources/third-party/shared/**/*', + '/src/sources/third-party/wasix/**/*', + '/src/extensions/external/**/source.toml', + '/src/extensions/external/**/dependencies/**/source.toml', + '/src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile', +]) { + assertTaskInput(tasks, 'source-inputs', 'source-fetch-wasix-runtime', requiredWasixRuntimeSourceInput); +} +assertTaskInput(tasks, 'source-inputs', 'source-fetch-extensions', '/src/extensions/external/**/source.toml'); +assertTaskInput(tasks, 'source-inputs', 'source-fetch-extensions', '/src/extensions/external/**/dependencies/**/source.toml'); +for (const requiredRuntimeInput of [ + '/src/postgres/versions/18/**/*', + '/src/sources/third-party/shared/**/*', + '/src/sources/third-party/native/**/*', + '/src/shared/extension-runtime-contract/**/*', + '/src/runtimes/liboliphaunt/native/**/*', + '/tools/xtask/**/*', + '/Cargo.lock', + '/Cargo.toml', + '/rust-toolchain.toml', +]) { + assertTaskInput(tasks, 'liboliphaunt-native', 'release-runtime', requiredRuntimeInput); +} +for (const sdkProjectId of ['oliphaunt-rust', 'oliphaunt-swift', 'oliphaunt-kotlin']) { + assertTaskDependency(tasks, sdkProjectId, 'release-check', 'liboliphaunt-native:release-runtime'); +} +for (const [projectId, taskIds] of new Map([ + ['oliphaunt-rust', ['check', 'test', 'smoke', 'package', 'release-check', 'regression']], + ['oliphaunt-swift', ['check', 'test', 'smoke', 'package', 'release-check', 'regression']], + ['oliphaunt-kotlin', ['check', 'test', 'smoke', 'package', 'release-check', 'regression']], + ['oliphaunt-js', ['check', 'test', 'smoke', 'package', 'release-check', 'regression']], + ['liboliphaunt-wasix', ['smoke', 'regression']], +])) { + for (const taskId of taskIds) { + assertTaskInput(tasks, projectId, taskId, '/tools/runtime/**/*'); + } +} +for (const target of [ + 'oliphaunt-rust:coverage', + 'oliphaunt-swift:coverage', + 'oliphaunt-kotlin:coverage', + 'oliphaunt-js:coverage', + 'oliphaunt-react-native:coverage', + 'oliphaunt-wasix-rust:coverage', +]) { + assertTaskDependencyCacheStrategy(tasks, 'repo', 'coverage', target, 'outputs'); +} +assertTaskDependencyCacheStrategy(tasks, 'docs', 'smoke', 'docs:build', 'outputs'); +assertTaskDependencyCacheStrategy(tasks, 'docs', 'release-check', 'docs:build', 'outputs'); +for (const [taskId, requiredInput] of [ + ['structure', 'README.md'], + ['structure', 'package.json'], + ['structure', 'pnpm-lock.yaml'], + ['structure', 'src/shared/fixtures/**/*'], + ['tooling', 'tools/**/*'], + ['tooling', '.moon/workspace.yml'], + ['tooling', '.moon/toolchains.yml'], + ['docs-policy', 'docs/**/*'], + ['release-policy', 'src/**/*'], + ['release-policy', 'tools/release/**/*'], + ['moon-graph', 'coverage/**/*'], + ['prek', 'prek.toml'], +]) { + assertTaskInput(tasks, 'repo', taskId, requiredInput); +} +assertTaskCache(tasks, 'repo', 'check', true); +assertTaskCache(tasks, 'repo', 'test', true); +assertTaskCache(tasks, 'repo', 'bench', true); +assertTaskCache(tasks, 'repo', 'bench-run', false); +assertTaskTags(tasks, 'benchmarks', 'check', ['quality', 'static']); +assertTaskInput(tasks, 'benchmarks', 'check', 'benchmarks/**/*'); +for (const requiredDocsInput of [ + 'README.md', + 'docs/**/*', + 'src/docs/**/*', + 'src/extensions/**/*', + '.release-please-manifest.json', + 'release-please-config.json', + 'src/**/release.toml', +]) { + assertTaskInput(tasks, 'docs', 'check', requiredDocsInput); +} +assertTaskCache(tasks, 'docs', 'dev', false); +assertTaskCache(tasks, 'docs', 'check', true); +assertTaskCache(tasks, 'docs', 'test', true); +assertTaskCache(tasks, 'docs', 'build', 'local'); +assertTaskCache(tasks, 'docs', 'smoke', 'local'); +assertTaskCache(tasks, 'docs', 'release-check', 'local'); +for (const [projectId, taskIds] of new Map([ + ['oliphaunt-rust', [ + 'check', + 'test', + 'smoke', + 'package', + 'release-check', + 'bench', + 'regression', + 'extension-regression', + 'coverage', + 'bench-run', + ]], + ['oliphaunt-broker', ['check', 'test', 'package', 'release-check', 'release-assets']], + ['oliphaunt-wasix-rust', [ + 'check', + 'test', + 'package', + 'release-check', + 'bench', + 'coverage', + 'bench-run', + ]], + ['xtask', ['check', 'test', 'release-check']], + ['oliphaunt-js', ['smoke']], +])) { + for (const taskId of taskIds) { + assertTaskCargoTargetDir(tasks, projectId, taskId); + } +} +assertTaskCache(tasks, 'oliphaunt-wasix-rust', 'example-check', 'local'); +assertTaskInput(tasks, 'oliphaunt-wasix-rust', 'example-check', 'src/bindings/wasix-rust/examples/**/*'); +assertTaskInput(tasks, 'oliphaunt-wasix-rust', 'example-check', 'src/bindings/wasix-rust/tools/check-examples.sh'); +for (const project of projects) { + for (const taskId of Object.keys(project.tasks ?? {})) { + assertNoDefaultInputs(tasks, project.id, taskId); + if ((tasks[project.id]?.[taskId]?.tags ?? []).length === 0) { + throw new Error(`${project.id}:${taskId}: task must declare first-class Moon tags`); + } + } +} +for (const project of projects) { + if (tasks[project.id]?.check) { + assertTaskTags(tasks, project.id, 'check', ['quality', 'static']); + } + if (tasks[project.id]?.test) { + const expectedTestTags = project.id === 'liboliphaunt-native' ? ['quality', 'runtime'] : ['quality', 'unit']; + assertTaskTags(tasks, project.id, 'test', expectedTestTags); + } +} +for (const projectId of [ + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-kotlin', + 'oliphaunt-react-native', + 'oliphaunt-js', + 'oliphaunt-wasix-rust', +]) { + for (const taskId of ['check', 'test', 'coverage', 'bench']) { + assertTaskCache(tasks, projectId, taskId, true); + } + assertTaskTags(tasks, projectId, 'coverage', ['coverage', 'quality']); + assertTaskTags(tasks, projectId, 'bench-run', ['bench', 'measured']); + assertTaskInput(tasks, projectId, 'bench', 'benchmarks/**/*'); + assertTaskInput(tasks, projectId, 'bench-run', 'benchmarks/**/*'); + assertTaskCommand(tasks, projectId, 'coverage', `tools/coverage/run-product ${projectId}`); + assertTaskInput(tasks, projectId, 'coverage', 'coverage/baseline.toml'); + assertTaskInput(tasks, projectId, 'coverage', 'tools/coverage/**/*'); + assertTaskOutput(tasks, projectId, 'coverage', `target/coverage/${projectId}/**/*`); + assertTaskOutput(tasks, projectId, 'package-artifacts', `target/sdk-artifacts/${projectId}/**/*`); + assertTaskCache(tasks, projectId, 'bench-run', false); +} +assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'package', 'src/bindings/wasix-rust/tools/check-package.sh'); +assertTaskCommand(tasks, 'oliphaunt-wasix-rust', 'package-artifacts', 'tools/release/build-sdk-ci-artifacts.sh oliphaunt-wasix-rust'); +assertTaskOutput(tasks, 'oliphaunt-wasix-rust', 'package-artifacts', 'target/sdk-artifacts/oliphaunt-wasix-rust/**/*'); +for (const projectId of [ + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-kotlin', + 'oliphaunt-react-native', + 'oliphaunt-js', + 'liboliphaunt-wasix', +]) { + assertTaskCache(tasks, projectId, 'smoke', 'local'); +} +for (const projectId of ['oliphaunt-rust', 'oliphaunt-react-native', 'oliphaunt-js', 'oliphaunt-wasix-rust']) { + assertTaskCache(tasks, projectId, 'package', true); + assertTaskCache(tasks, projectId, 'package-artifacts', true); +} +for (const projectId of ['oliphaunt-swift', 'oliphaunt-kotlin']) { + assertTaskCache(tasks, projectId, 'package', 'local'); + assertTaskCache(tasks, projectId, 'package-artifacts', 'local'); +} +assertTaskInput(tasks, 'oliphaunt-js', 'smoke', 'src/shared/fixtures/**/*'); +assertTaskInput(tasks, 'oliphaunt-js', 'smoke', 'src/sdks/js/**/*'); +for (const projectId of ['oliphaunt-js', 'oliphaunt-react-native']) { + assertTaskInput(tasks, projectId, 'test', 'tools/test/**/*'); + assertTaskInput(tasks, projectId, 'coverage', 'tools/test/**/*'); +} +assertCiTagTargets(tasks, new Map([ + ['ci-broker-runtime', ['oliphaunt-broker:release-assets']], + ['ci-extension-artifacts-native', ['extension-artifacts-native:build-target']], + ['ci-extension-artifacts-wasix', ['extension-artifacts-wasix:build-target']], + ['ci-extension-packages', ['extension-packages:assemble-release']], + ['ci-mobile-extension-packages', ['extension-packages:assemble-mobile']], + ['ci-js-sdk-package', ['oliphaunt-js:package-artifacts']], + ['ci-kotlin-sdk-package', ['oliphaunt-kotlin:package-artifacts']], + ['ci-liboliphaunt-native-android', ['liboliphaunt-native:release-runtime-mobile-target']], + ['ci-liboliphaunt-native-desktop', ['liboliphaunt-native:release-runtime-desktop']], + ['ci-liboliphaunt-native-ios', ['liboliphaunt-native:release-runtime-mobile-target']], + ['ci-liboliphaunt-native-release-assets', ['liboliphaunt-native:release-assets']], + ['ci-liboliphaunt-wasix-aot', ['liboliphaunt-wasix:runtime-aot']], + ['ci-liboliphaunt-wasix-release-assets', ['liboliphaunt-wasix:release-assets']], + ['ci-liboliphaunt-wasix-runtime', ['liboliphaunt-wasix:runtime-portable']], + ['ci-mobile-build-android', ['oliphaunt-react-native:mobile-build-android']], + ['ci-mobile-build-ios', ['oliphaunt-react-native:mobile-build-ios']], + ['ci-node-direct', ['oliphaunt-node-direct:release-assets']], + ['ci-react-native-sdk-package', ['oliphaunt-react-native:package-artifacts']], + ['ci-rust-sdk-package', ['oliphaunt-rust:package-artifacts']], + ['ci-swift-sdk-package', ['oliphaunt-swift:package-artifacts']], + ['ci-wasix-rust-package', ['oliphaunt-wasix-rust:package-artifacts']], +])); + +console.log('moon product graph checks passed'); diff --git a/tools/policy/check-native-boundaries.sh b/tools/policy/check-native-boundaries.sh new file mode 100755 index 00000000..8c344463 --- /dev/null +++ b/tools/policy/check-native-boundaries.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +python3 <<'PY' +import json +import pathlib +import re +import sys +import tomllib + +root = pathlib.Path.cwd() +errors: list[str] = [] + +legacy_package_names = { + "oliphaunt-wasix", + "oliphaunt-wasix-assets", +} +legacy_name_prefixes = ( + "oliphaunt-wasix-aot-", +) +legacy_runtime_names = { + "wasmer", + "wasmer-wasix", + "wasmer-vfs", + "wasmer-types", + "wasmer-headless", +} +legacy_path_fragments = ( + "src/bindings/wasix-rust/crates/oliphaunt-wasix", + "src/runtimes/liboliphaunt/wasix/crates/assets", + "src/runtimes/liboliphaunt/wasix/crates/aot", +) + + +def rel(path: pathlib.Path) -> str: + return path.relative_to(root).as_posix() + + +def read_toml(relative_path: str) -> dict: + path = root / relative_path + return tomllib.loads(path.read_text(encoding="utf-8")) + + +def dependency_tables(manifest: dict): + for table_name in ("dependencies", "dev-dependencies", "build-dependencies"): + yield table_name, manifest.get(table_name, {}) + for cfg, table in manifest.get("target", {}).items(): + for table_name in ("dependencies", "dev-dependencies", "build-dependencies"): + yield f"target.{cfg}.{table_name}", table.get(table_name, {}) + + +def dependency_name(dep_key: str, spec) -> str: + if isinstance(spec, dict): + return spec.get("package", dep_key) + return dep_key + + +def dependency_path(spec): + if isinstance(spec, dict): + return spec.get("path") + return None + + +def is_blocked_rust_dependency(name: str) -> bool: + return ( + name in legacy_package_names + or name in legacy_runtime_names + or any(name.startswith(prefix) for prefix in legacy_name_prefixes) + ) + + +def check_native_rust_manifest(relative_path: str) -> None: + manifest_path = root / relative_path + manifest = read_toml(relative_path) + for table_name, deps in dependency_tables(manifest): + for dep_key, spec in deps.items(): + name = dependency_name(dep_key, spec) + if is_blocked_rust_dependency(name): + errors.append( + f"{relative_path} {table_name}.{dep_key} depends on legacy runtime resources {name!r}" + ) + path_value = dependency_path(spec) + if path_value is None: + continue + dependency_target = (manifest_path.parent / path_value).resolve() + dependency_target_rel = dependency_target.relative_to(root).as_posix() + if any( + dependency_target_rel == fragment + or dependency_target_rel.startswith(f"{fragment}/") + for fragment in legacy_path_fragments + ): + errors.append( + f"{relative_path} {table_name}.{dep_key} points at legacy path {dependency_target_rel}" + ) + + +def check_json_manifest(relative_path: str) -> None: + manifest = json.loads((root / relative_path).read_text(encoding="utf-8")) + for table_name in ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ): + deps = manifest.get(table_name, {}) + for name in deps: + if name in legacy_package_names or any( + name.startswith(prefix) for prefix in legacy_name_prefixes + ): + errors.append( + f"{relative_path} {table_name}.{name} depends on legacy WASIX package" + ) + + +def require_text(relative_path: str, text: str, message: str) -> None: + if text not in (root / relative_path).read_text(encoding="utf-8"): + errors.append(f"{relative_path}: {message}; expected {text!r}") + + +def check_tool_crate_boundaries() -> None: + manifest = read_toml("tools/xtask/Cargo.toml") + features = manifest.get("features", {}) + dependencies = manifest.get("dependencies", {}) + + if features.get("default") != []: + errors.append( + "tools/xtask/Cargo.toml must keep the default feature set empty" + ) + for removed_feature in ("perf", "legacy-oliphaunt"): + if removed_feature in features: + errors.append( + f"tools/xtask/Cargo.toml must not define product-aware feature {removed_feature!r}; use tools/perf/runner" + ) + + forbidden_xtask_dependencies = ( + "directories", + "futures-util", + "oliphaunt", + "oliphaunt-wasix", + "rusqlite", + "sqlx", + "tokio-postgres", + ) + for dep_name in forbidden_xtask_dependencies: + if dep_name in dependencies: + errors.append( + f"tools/xtask/Cargo.toml must not depend on product/perf crate {dep_name!r}; use tools/perf/runner" + ) + + for dep_name in ("wasmer", "wasmer-types", "wasmer-wasix", "webc", "tokio"): + spec = dependencies.get(dep_name) + if not isinstance(spec, dict) or spec.get("optional") is not True: + errors.append( + f"tools/xtask/Cargo.toml dependency {dep_name!r} must stay optional so default xtask builds do not compile template/AOT runtime support" + ) + + perf_manifest = read_toml("tools/perf/runner/Cargo.toml") + perf_features = perf_manifest.get("features", {}) + perf_dependencies = perf_manifest.get("dependencies", {}) + if perf_features.get("default") != []: + errors.append( + "tools/perf/runner/Cargo.toml must keep the default feature set empty" + ) + legacy_feature = set(perf_features.get("legacy-oliphaunt", [])) + for dep_name in ("dep:directories", "dep:oliphaunt-wasix"): + if dep_name not in legacy_feature: + errors.append( + f"tools/perf/runner/Cargo.toml legacy-oliphaunt feature must gate {dep_name}" + ) + for dep_name in ("oliphaunt", "rusqlite", "sqlx", "tokio-postgres"): + if dep_name not in perf_dependencies: + errors.append( + f"tools/perf/runner/Cargo.toml must own benchmark dependency {dep_name!r}" + ) + + wasix_runner = set(features.get("wasix-runner", [])) + for dep_name in ("dep:wasmer", "dep:wasmer-wasix", "dep:webc"): + if dep_name not in wasix_runner: + errors.append( + f"tools/xtask/Cargo.toml wasix-runner feature must explicitly gate {dep_name}" + ) + + aot_serializer = set(features.get("aot-serializer", [])) + if "dep:wasmer-types" not in aot_serializer: + errors.append( + "tools/xtask/Cargo.toml aot-serializer feature must explicitly gate dep:wasmer-types" + ) + + +def check_native_script_boundary() -> None: + require_text( + "tools/perf/matrix/run_native_oliphaunt_matrix.sh", + "cargo build --release -p oliphaunt-perf -p oliphaunt --bins", + "native perf matrix must build the dedicated perf runner and native broker helper", + ) + require_text( + "tools/perf/matrix/run_native_oliphaunt_matrix.sh", + "legacyWasixControls=false", + "native perf matrix plan must classify itself as native-only", + ) + require_text( + "src/runtimes/liboliphaunt/native/tools/check-track.sh", + "run src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs --check", + "native track validation must keep the PostgreSQL patch-stack audit in the native lane", + ) + require_text( + "src/runtimes/liboliphaunt/native/moon.yml", + 'command: "bash src/runtimes/liboliphaunt/native/tools/check-track.sh host-smoke"', + "liboliphaunt test validation must run the host C ABI smoke rather than workspace legacy validation", + ) + reject_manifest_text( + "tools/policy/check-policy-tools.sh", + [ + ( + "tools/policy/check-sdk-parity.sh", + "policy-tools must stay a thin repository-policy aggregator; SDK parity evidence belongs to dedicated SDK/contract tasks", + ), + ], + ) + + +def reject_manifest_text(relative_path: str, patterns: list[tuple[str, str]]) -> None: + path = root / relative_path + text = path.read_text(encoding="utf-8") + for label, pattern in patterns: + if re.search(pattern, text, flags=re.IGNORECASE): + errors.append(f"{relative_path} contains blocked native-boundary reference: {label}") + + +def walk_files(relative_roots: list[str], suffixes: tuple[str, ...]): + for relative_root in relative_roots: + path = root / relative_root + if not path.exists(): + errors.append(f"missing expected native boundary path: {relative_root}") + continue + for file_path in path.rglob("*"): + if file_path.is_file() and file_path.suffix in suffixes: + yield file_path + + +check_native_rust_manifest("src/sdks/rust/Cargo.toml") +check_json_manifest("src/sdks/react-native/package.json") +check_json_manifest("src/sdks/react-native/examples/expo/package.json") +check_tool_crate_boundaries() +check_native_script_boundary() + +manifest_text_patterns = [ + ("oliphaunt-wasix package", r"\boliphaunt-wasix\b"), + ("WASIX runtime", r"\bwasix\b"), + ("Wasmer runtime", r"\bwasmer\b"), +] +for manifest_path in ( + "src/sdks/swift/Package.swift", + "src/sdks/react-native/OliphauntReactNative.podspec", + "src/sdks/kotlin/build.gradle.kts", + "src/sdks/kotlin/oliphaunt/build.gradle.kts", + "src/sdks/react-native/android/build.gradle", + "src/sdks/react-native/android/settings.gradle", +): + reject_manifest_text(manifest_path, manifest_text_patterns) + +source_patterns = [ + ("Rust import of legacy crate", r"\b(use|extern\s+crate)\s+oliphaunt_wasix\b"), + ("Rust path to legacy crate", r"\boliphaunt_wasix::"), + ("JavaScript import of legacy package", r"\b(import|require)\s*(?:.+?\s+from\s*)?['\"]oliphaunt-wasix['\"]"), + ("Swift/Kotlin legacy module import", r"\bimport\s+OliphauntWasm\b"), +] +for file_path in walk_files( + [ + "src/sdks/rust/src", + "src/sdks/rust/tests", + "src/runtimes/liboliphaunt/native/include", + "src/runtimes/liboliphaunt/native/src", + "src/sdks/swift/Sources", + "src/sdks/swift/Tests", + "src/sdks/kotlin/oliphaunt/src", + "src/sdks/react-native/src", + "src/sdks/react-native/ios", + "src/sdks/react-native/android/src", + ], + (".rs", ".c", ".h", ".swift", ".kt", ".java", ".ts", ".tsx", ".m", ".mm", ".cpp"), +): + text = file_path.read_text(encoding="utf-8", errors="ignore") + for label, pattern in source_patterns: + if re.search(pattern, text): + errors.append(f"{rel(file_path)} contains blocked native-boundary code reference: {label}") + +sdk_manifest = read_toml("tools/policy/sdk-manifest.toml") +expected_paths = { + "rust": "src/sdks/rust", + "swift": "src/sdks/swift", + "kotlin": "src/sdks/kotlin", + "react-native": "src/sdks/react-native", +} +seen_paths: dict[str, str] = {} +for sdk, expected_path in expected_paths.items(): + section = sdk_manifest.get("sdks", {}).get(sdk) + if section is None: + errors.append(f"tools/policy/sdk-manifest.toml is missing [sdks.{sdk}]") + continue + actual_path = section.get("implementation_path") + if actual_path != expected_path: + errors.append( + f"tools/policy/sdk-manifest.toml [sdks.{sdk}].implementation_path is {actual_path!r}; expected {expected_path!r}" + ) + if actual_path in seen_paths: + errors.append( + f"tools/policy/sdk-manifest.toml shares implementation_path {actual_path!r} between {seen_paths[actual_path]} and {sdk}" + ) + seen_paths[actual_path] = sdk + +react_native = sdk_manifest.get("sdks", {}).get("react-native", {}) +if react_native.get("runtime_owner") is not False: + errors.append("React Native SDK must stay a delegating adapter with runtime_owner = false") +if react_native.get("delegates_apple_to") != "swift": + errors.append("React Native Apple runtime delegation must point at the Swift SDK") +if react_native.get("delegates_android_to") != "kotlin": + errors.append("React Native Android runtime delegation must point at the Kotlin SDK") + +if errors: + print("native product boundary violations:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + sys.exit(1) + +print("native product boundaries ok") +PY diff --git a/tools/policy/check-policy-tools.sh b/tools/policy/check-policy-tools.sh new file mode 100755 index 00000000..08ca93a5 --- /dev/null +++ b/tools/policy/check-policy-tools.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +run tools/policy/check-tooling-stack.sh +run python3 tools/policy/check-final-source-architecture.py --self-test +run python3 tools/policy/check-release-policy.py +run python3 tools/release/check_release_please_config.py +run python3 src/shared/contracts/tools/check-test-matrix.py --fixtures +run tools/release/check_release_metadata.py +run tools/release/release.py consumer-shape --format json --require-ready +run tools/release/release.py consumer-shape --format json --require-ready --products-json '["oliphaunt-react-native"]' +run tools/graph/graph.py check +run tools/policy/check-moon-product-graph.mjs +run tools/policy/check-test-strategy.mjs diff --git a/tools/policy/check-prek.sh b/tools/policy/check-prek.sh new file mode 100755 index 00000000..c001947c --- /dev/null +++ b/tools/policy/check-prek.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" +PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +export PATH + +if ! command -v prek >/dev/null 2>&1; then + echo "missing required command: prek" >&2 + echo "run tools/dev/bootstrap-tools.sh to install pinned maintainer tools" >&2 + exit 1 +fi + +printf '\n==> prek validate-config prek.toml\n' +prek validate-config prek.toml + +printf '\n==> prek run --tracked-files --stage pre-commit\n' +git ls-files | + while IFS= read -r file; do + [ -e "$file" ] && printf '%s\0' "$file" + done | + xargs -0 prek run --stage pre-commit --files diff --git a/tools/policy/check-react-native-boundary.sh b/tools/policy/check-react-native-boundary.sh new file mode 100755 index 00000000..759eddd2 --- /dev/null +++ b/tools/policy/check-react-native-boundary.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$script_dir/sdk-check-lib.sh" + +reject_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "AndroidNativeDirectEngine" \ + "React Native Android must use the Kotlin SDK facade instead of constructing the native-direct engine itself" + +unexpected_rn_android_cpp="$( + find src/sdks/react-native/android/src/main/cpp -type f 2>/dev/null | + sed 's#^src/sdks/react-native/android/##' | + grep -Ev '^(src/main/cpp/CMakeLists\.txt|src/main/cpp/OliphauntJsiBindings\.cpp|src/main/cpp/include/oliphaunt\.h)$' || true +)" +if [ -n "$unexpected_rn_android_cpp" ]; then + echo "React Native Android must only carry the JSI installer and must delegate the native C/C++ database runtime to the Kotlin SDK" >&2 + echo "$unexpected_rn_android_cpp" >&2 + exit 1 +fi +require_no_files_under src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/runtime \ + "React Native Android must not grow a private runtime resources; delegate to the Kotlin SDK" +printf '\nReact Native boundary checks passed.\n' diff --git a/tools/policy/check-release-policy.py b/tools/policy/check-release-policy.py new file mode 100644 index 00000000..895f014b --- /dev/null +++ b/tools/policy/check-release-policy.py @@ -0,0 +1,1070 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +import os +import pathlib +import subprocess +import sys +import tomllib + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/release")) +sys.path.insert(0, str(ROOT / "tools/graph")) + +import ci_plan # noqa: E402 +import artifact_targets # noqa: E402 +import product_metadata # noqa: E402 +import release_plan # noqa: E402 + + +BASE_PRODUCTS = { + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-broker", + "oliphaunt-node-direct", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", +} +CONSUMER_SHAPE_PRODUCTS_FIXTURE = "src/shared/fixtures/consumer-shape/products.json" + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def read_text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def read_toml(path: pathlib.Path) -> dict: + with path.open("rb") as handle: + return tomllib.load(handle) + + +def extension_product_id(sql_name: str) -> str: + return "oliphaunt-extension-" + sql_name.replace("_", "-").lower() + + +def expected_extension_products_from_sdk_catalog() -> set[str]: + data = json.loads(read_text("src/extensions/generated/sdk/rust.json")) + rows = data.get("extensions") + if not isinstance(rows, list) or not rows: + fail("generated Rust extension catalog must define public extensions") + products = set() + for row in rows: + if not isinstance(row, dict): + fail("generated Rust extension catalog rows must be objects") + sql_name = row.get("sql-name") + if not isinstance(sql_name, str) or not sql_name: + fail("generated Rust extension catalog rows must declare sql-name") + products.add(extension_product_id(sql_name)) + return products + + +def expected_contrib_extension_products_from_manifest() -> set[str]: + data = read_toml(ROOT / "src/extensions/contrib/postgres18.toml") + rows = data.get("extensions") + if not isinstance(rows, list) or not rows: + fail("PostgreSQL contrib extension manifest must define extension rows") + products = set() + for row in rows: + if not isinstance(row, dict): + fail("PostgreSQL contrib extension manifest rows must be tables") + sql_name = row.get("sql-name") + if not isinstance(sql_name, str) or not sql_name: + fail("PostgreSQL contrib extension manifest rows must declare sql-name") + products.add(extension_product_id(sql_name)) + return products + + +def expected_products() -> set[str]: + return BASE_PRODUCTS | expected_extension_products_from_sdk_catalog() + + +def moon_projects() -> dict[str, dict]: + moon_bin = os.environ.get("MOON_BIN") + if moon_bin is None: + proto_moon = pathlib.Path.home() / ".proto/bin/moon" + moon_bin = str(proto_moon) if proto_moon.exists() else "moon" + output = subprocess.check_output( + [moon_bin, "query", "projects"], + cwd=ROOT, + text=True, + ) + projects = json.loads(output).get("projects") + if not isinstance(projects, list): + fail("moon query projects did not return a projects array") + return {project["id"]: project for project in projects} + + +def project_release_metadata(project: dict) -> dict | None: + config = project.get("config") if isinstance(project.get("config"), dict) else {} + project_config = config.get("project") if isinstance(config.get("project"), dict) else {} + metadata = project_config.get("metadata") if isinstance(project_config.get("metadata"), dict) else {} + release = metadata.get("release") if isinstance(metadata, dict) else None + if isinstance(release, dict): + return release + release = project_config.get("release") + return release if isinstance(release, dict) else None + + +def assert_no_file(path: str) -> None: + if (ROOT / path).exists(): + fail(f"{path} must not exist; Moon is the only dependency/affectedness graph") + + +def assert_contains(path: str, snippet: str, message: str) -> None: + if snippet not in read_text(path): + fail(message) + + +def workflow_job_blocks(path: str) -> dict[str, str]: + text = read_text(path) + jobs_section = text.split("\njobs:\n", 1)[1] if "\njobs:\n" in text else "" + if not jobs_section: + fail(f"{path} must declare a jobs section") + matches = list(re.finditer(r"^ ([A-Za-z0-9_-]+):\n", jobs_section, flags=re.MULTILINE)) + if not matches: + fail(f"{path} parser found no jobs") + blocks: dict[str, str] = {} + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(jobs_section) + blocks[match.group(1)] = jobs_section[match.start():end] + return blocks + + +def workflow_step_blocks(job_block: str) -> dict[str, str]: + matches = list(re.finditer(r"^ - name: (.+)\n", job_block, flags=re.MULTILINE)) + blocks: dict[str, str] = {} + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(job_block) + name = match.group(1).strip() + blocks[name] = job_block[match.start():end] + return blocks + + +def workflow_job_needs(blocks: dict[str, str], job: str) -> set[str]: + block = blocks.get(job) + if block is None: + fail(f"CI workflow is missing job {job}") + match = re.search(r"(?ms)^ needs:\n(?P(?: - [A-Za-z0-9_-]+\n)+)", block) + if match is None: + return set() + return { + line.removeprefix(" - ").strip() + for line in match.group("body").splitlines() + if line.strip() + } + + +def assert_job_contains(blocks: dict[str, str], job: str, snippet: str, message: str) -> None: + block = blocks.get(job) + if block is None: + fail(f"CI workflow is missing job {job}") + if snippet not in block: + fail(message) + + +def assert_step_contains(steps: dict[str, str], step: str, snippet: str, message: str) -> None: + block = steps.get(step) + if block is None: + fail(f"workflow is missing step {step!r}") + if snippet not in block: + fail(message) + + +def assert_step_if_contains_publish_guard(steps: dict[str, str], step: str) -> None: + block = steps.get(step) + if block is None: + fail(f"workflow is missing step {step!r}") + if "inputs.operation == 'publish'" not in block: + fail(f"{step!r} must be guarded by inputs.operation == 'publish'") + + +def normalized_shell(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def assert_text_order(text: str, snippets: list[str], message: str) -> None: + index = -1 + for snippet in snippets: + next_index = text.find(snippet, index + 1) + if next_index == -1: + fail(f"{message}: missing {snippet!r}") + index = next_index + + +def check_release_metadata(graph: dict) -> None: + products = graph.get("products") + if not isinstance(products, dict): + fail("release metadata must define products") + if set(products) != expected_products(): + fail(f"release product set mismatch: expected {sorted(expected_products())}, got {sorted(products)}") + modeled_extension_products = { + product + for product in product_metadata.product_ids(graph) + if product_metadata.product_config(product, graph).get("kind") == "exact-extension-artifact" + } + expected_extension_products = expected_extension_products_from_sdk_catalog() + if modeled_extension_products != expected_extension_products: + fail( + "exact-extension release products must match the public generated extension catalog: " + f"expected {sorted(expected_extension_products)}, got {sorted(modeled_extension_products)}" + ) + + projects = moon_projects() + for product, config in products.items(): + release_path = ROOT / config["path"] / "release.toml" + raw = read_toml(release_path) + for forbidden in ("depends_on", "source_globs", "package_visible_globs"): + if forbidden in raw: + fail(f"{release_path.relative_to(ROOT)} must not declare {forbidden}; Moon owns graph shape") + for key in ("id", "owner", "kind", "publish_targets", "release_artifacts"): + if key not in raw: + fail(f"{release_path.relative_to(ROOT)} must declare {key}") + if not config.get("tag_prefix") or not config.get("version_files") or not config.get("changelog_path"): + fail(f"{product} must have release-please tag/version/changelog metadata") + + project_id = release_plan.release_product_project_id(product, products, graph["moon_projects"]) + project = projects.get(project_id) + if project is None: + fail(f"{product} has no owning Moon project") + tags = set(project.get("config", {}).get("tags", [])) + if "release-product" not in tags: + fail(f"{project_id} must be tagged release-product") + release = project_release_metadata(project) + if release is None: + fail(f"{project_id} must declare project.release metadata") + if release.get("component") != product: + fail(f"{project_id} release component expected {product}, got {release.get('component')}") + if release.get("packagePath") != config.get("path"): + fail(f"{project_id} packagePath expected {config.get('path')}, got {release.get('packagePath')}") + + +def check_release_planning(graph: dict) -> None: + contains_cases = { + "src/shared/js-core/src/query.ts": {"oliphaunt-js", "oliphaunt-react-native"}, + "src/postgres/versions/18/source.toml": { + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + }, + "src/extensions/contrib/postgres18.toml": expected_contrib_extension_products_from_manifest(), + } + for path, expected in contains_cases.items(): + plan = release_plan.build_plan(graph, [path]) + actual = set(plan.get("releaseProducts", [])) + if not expected <= actual: + fail(f"{path} release plan expected at least {sorted(expected)}, got {sorted(actual)}") + + exact_cases = { + "src/extensions/contrib/amcheck/release.toml": {"oliphaunt-extension-amcheck"}, + "src/extensions/external/vector/source.toml": {"oliphaunt-extension-vector"}, + "src/shared/fixtures/protocol/query-response-cases.json": set(), + "docs/maintainers/release.md": set(), + } + for path, expected in exact_cases.items(): + plan = release_plan.build_plan(graph, [path]) + actual = set(plan.get("releaseProducts", [])) + if actual != expected: + fail(f"{path} release plan expected exactly {sorted(expected)}, got {sorted(actual)}") + + +def check_ci_policy() -> None: + assert_no_file("tools/graph/jobs.toml") + assert_no_file("tools/release/release-inputs.toml") + ci = read_text(".github/workflows/ci.yml") + for forbidden in ("targets=(", "tools/graph/jobs.toml", "tools/release/release-inputs.toml"): + if forbidden in ci: + fail(f"CI workflow must not contain {forbidden}") + assert_contains("tools/graph/ci_plan.py", "moon([\"query\", \"tasks\"])", "CI planner must read Moon task tags") + assert_contains("tools/graph/ci_plan.py", "ci-", "CI planner must document ci-* task tags") + assert_contains( + "tools/graph/ci_plan.py", + "extension_package_products_csv", + "CI planner must emit selected exact-extension products for artifact package builders", + ) + assert_contains( + ".github/workflows/ci.yml", + "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", + "CI extension package builders must consume selected exact-extension products from the affected plan", + ) + assert_contains( + "tools/release/build-extension-ci-artifacts.py", + "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", + "exact-extension package builder must support selected product subsets", + ) + assert_contains( + ".github/scripts/run-planned-moon-job.sh", + "OLIPHAUNT_CI_JOB_TARGETS_JSON", + "CI product jobs must consume planned Moon targets", + ) + if not ci_plan.CI_JOB_TARGETS: + fail("CI planner found no Moon ci-* task tags") + if "liboliphaunt-wasix-aot-targets" in ci_plan.BUILDER_JOBS: + fail("builder_jobs must contain artifact-producing jobs, not the WASIX AOT target planner") + + workflow_blocks = workflow_job_blocks(".github/workflows/ci.yml") + workflow_jobs = set(workflow_blocks) + if not workflow_jobs: + fail("CI workflow parser found no jobs") + moon_jobs = set(ci_plan.CI_JOB_TARGETS) + builder_moon_jobs = moon_jobs & ci_plan.BUILDER_JOBS + no_moon_target_jobs = { + "affected", + "builders", + "required", + } + allowed_workflow_jobs = builder_moon_jobs | no_moon_target_jobs + missing_workflow_jobs = sorted(ci_plan.BUILDER_JOBS - workflow_jobs) + if missing_workflow_jobs: + fail(f"builder Moon ci-* tags have no Builds workflow job: {missing_workflow_jobs}") + untagged_workflow_jobs = sorted(workflow_jobs - allowed_workflow_jobs) + if untagged_workflow_jobs: + fail(f"Builds workflow must only define builder jobs and aggregate exceptions: {untagged_workflow_jobs}") + non_builder_workflow_jobs = sorted((moon_jobs - ci_plan.BUILDER_JOBS) & workflow_jobs) + if non_builder_workflow_jobs: + fail(f"Builds workflow must not define non-builder Moon jobs: {non_builder_workflow_jobs}") + + required_match = re.search(r"(?ms)^ required:\n.*?^ needs:\n(?P(?: - [A-Za-z0-9_-]+\n)+)", ci) + if required_match is None: + fail("CI workflow required job must declare a static needs list") + required_needs = { + line.removeprefix(" - ").strip() + for line in required_match.group("body").splitlines() + if line.strip() + } + if required_needs != {"affected", "builders"}: + fail(f"required.needs must be the builder gate only: ['affected', 'builders']; got {sorted(required_needs)}") + + builders_match = re.search(r"(?ms)^ builders:\n.*?^ needs:\n(?P(?: - [A-Za-z0-9_-]+\n)+)", ci) + if builders_match is None: + fail("CI workflow builders job must declare a static needs list") + builders_needs = { + line.removeprefix(" - ").strip() + for line in builders_match.group("body").splitlines() + if line.strip() + } + missing_builders = sorted(ci_plan.BUILDER_JOBS - builders_needs) + if missing_builders: + fail(f"builders.needs is missing builder jobs: {missing_builders}") + + planned_job_invocations = set( + match.group(1) + for match in re.finditer(r"run-planned-moon-job[.]sh ([A-Za-z0-9_-]+)", ci) + ) + missing_planned_invocations = sorted(builder_moon_jobs - planned_job_invocations) + if missing_planned_invocations: + fail(f"builder workflow jobs do not consume planned Moon targets: {missing_planned_invocations}") + for line_number, line in enumerate(ci.splitlines(), start=1): + match = re.search(r"run-planned-moon-job[.]sh ([A-Za-z0-9_-]+)", line) + if match is None: + continue + job = match.group(1) + if job in ci_plan.BUILDER_JOBS and "MOON_CACHE=off" not in line: + fail(f"builder job {job} must disable Moon cache in CI at .github/workflows/ci.yml:{line_number}") + if job in ci_plan.BUILDER_JOBS and "OLIPHAUNT_MOON_UPSTREAM=none" not in line: + fail( + f"builder job {job} must not run upstream Moon checks in CI " + f"at .github/workflows/ci.yml:{line_number}" + ) + + expected_mobile_build_needs = { + "mobile-build-android": { + "affected", + "mobile-extension-packages", + "liboliphaunt-native-android", + "kotlin-sdk-package", + "react-native-sdk-package", + }, + "mobile-build-ios": { + "affected", + "mobile-extension-packages", + "liboliphaunt-native-ios", + "react-native-sdk-package", + "swift-sdk-package", + }, + } + for job, expected in expected_mobile_build_needs.items(): + actual = workflow_job_needs(workflow_blocks, job) + if actual != expected: + fail(f"{job}.needs must consume staged runtime, SDK, and exact-extension builders: expected {sorted(expected)}, got {sorted(actual)}") + for snippet in ( + "OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS: \"0\"", + "OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS: \"1\"", + "OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS: \"1\"", + "OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT:", + "oliphaunt-mobile-extension-package-artifacts", + "--require-mobile-prebuilt-extensions", + ): + assert_job_contains(workflow_blocks, job, snippet, f"{job} must use staged SDK/runtime/exact-extension artifacts and reject source-build fallbacks") + assert_job_contains( + workflow_blocks, + "mobile-build-android", + "OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release", + "Android mobile app builder must publish the same release-mode artifact that installed-app E2E consumes", + ) + assert_job_contains( + workflow_blocks, + "mobile-build-ios", + "OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release", + "iOS mobile app builder must publish the same release-mode artifact that installed-app E2E consumes", + ) + assert_job_contains( + workflow_blocks, + "mobile-build-ios", + "OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator", + "iOS mobile app builder must publish a simulator artifact for free installed-app E2E", + ) + + android_build = workflow_blocks["mobile-build-android"] + for snippet in ( + "matrix: ${{ fromJson(needs.affected.outputs.react_native_android_mobile_app_matrix) }}", + "liboliphaunt-native-target-${{ matrix.target }}", + "OLIPHAUNT_EXPO_ANDROID_ABI: ${{ matrix.abi }}", + "oliphaunt-kotlin-sdk-package-artifacts", + "oliphaunt-react-native-sdk-package-artifacts", + "react-native-mobile-android-app-${{ matrix.target }}", + ): + if snippet not in android_build: + fail(f"mobile-build-android must download/upload {snippet}") + for path, snippet, message in ( + ( + "src/sdks/react-native/android/build.gradle", + "OLIPHAUNT_ANDROID_LINK_EVIDENCE_FILE", + "React Native Android Gradle packaging must pass static-extension link evidence into CMake", + ), + ( + "src/sdks/react-native/android/src/main/cpp/CMakeLists.txt", + "oliphaunt-android-static-extension-link-v1", + "React Native Android CMake packaging must emit deterministic static-extension link evidence", + ), + ( + "src/sdks/react-native/tools/expo-android-runner.sh", + "androidLinkEvidence", + "React Native Android mobile build reports must include static-extension link evidence", + ), + ( + "tools/release/check_staged_artifacts.py", + "check_android_prebuilt_extension_linkage", + "staged mobile artifact checks must validate Android static-extension link evidence", + ), + ): + if snippet not in read_text(path): + fail(message) + + ios_build = workflow_blocks["mobile-build-ios"] + for snippet in ( + "liboliphaunt-native-target-ios-xcframework", + "oliphaunt-swift-sdk-package-artifacts", + "oliphaunt-react-native-sdk-package-artifacts", + "react-native-mobile-ios-app", + ): + if snippet not in ios_build: + fail(f"mobile-build-ios must download/upload {snippet}") + + wasix_extension_packager = read_text("src/extensions/artifacts/wasix/tools/package-release-assets.sh") + if "--strict-generated" in wasix_extension_packager: + fail("WASIX exact-extension packaging must consume portable runtime outputs; strict generation checks belong to the portable runtime builder") + + mobile_e2e = read_text(".github/workflows/mobile-e2e.yml") + for snippet in ( + 'name: Mobile E2E', + 'workflows: ["Builds"]', + 'artifact_builders_succeeded', + 'if event_name == "workflow_run":', + 'matched = any(available.values())', + 'matched = all(', + 'react-native-mobile-android-app-android-x86_64', + 'react-native-mobile-ios-app', + 'uses: ./.github/actions/setup-maestro', + 'tools/dev/start-android-emulator-ci.sh', + 'bash src/sdks/react-native/tools/mobile-e2e.sh android', + 'bash src/sdks/react-native/tools/mobile-e2e.sh ios', + 'OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE: release', + 'OLIPHAUNT_EXPO_IOS_CONFIGURATION: Release', + 'OLIPHAUNT_EXPO_IOS_SDK: iphonesimulator', + ): + if snippet not in mobile_e2e: + fail(f"Mobile E2E workflow must consume built app artifacts with pinned installed-app tooling: missing {snippet}") + for forbidden in ( + "run-planned-moon-job.sh", + "mobile-build:android", + "mobile-build:ios", + "tools/mobile-build.sh", + "OLIPHAUNT_EXPO_ALLOW_NATIVE_BUILDS", + ): + if forbidden in mobile_e2e: + fail(f"Mobile E2E workflow must not rebuild source artifacts or invoke builder tasks: {forbidden}") + + release_workflow_blocks = workflow_job_blocks(".github/workflows/release.yml") + release_tool_patterns = ("tools/release/release.py", "tools/release/artifact_target_matrix.py") + missing_moon_setup = sorted( + job + for job, block in release_workflow_blocks.items() + if any(pattern in block for pattern in release_tool_patterns) + and "./.github/actions/setup-moon" not in block + ) + if missing_moon_setup: + fail(f"release workflow jobs invoke release metadata without setup-moon: {missing_moon_setup}") + + if not (ROOT / CONSUMER_SHAPE_PRODUCTS_FIXTURE).is_file(): + fail(f"missing consumer shape fixture: {CONSUMER_SHAPE_PRODUCTS_FIXTURE}") + + +def check_release_workflow_policy() -> None: + release_blocks = workflow_job_blocks(".github/workflows/release.yml") + publish_block = release_blocks.get("publish") + if publish_block is None: + fail("Release workflow must define a publish job") + publish_steps = workflow_step_blocks(publish_block) + + for permission in ( + "actions: read", + "attestations: write", + "contents: write", + "id-token: write", + ): + if permission not in publish_block: + fail(f"Release publish job must declare {permission}") + + assert_text_order( + publish_block, + [ + "Require same-SHA Builds artifact builder gate", + "Download WASIX runtime build artifacts", + "Download WASIX release assets", + "Download exact-extension package artifacts", + "Download SDK package artifacts", + "Download liboliphaunt release assets", + "Download native helper release assets", + "Download Node direct optional npm packages", + "Validate selected release product dry-runs", + ], + "Release dry-run must validate same-SHA builder outputs before product dry-runs", + ) + + for snippet in ( + "id: builds_artifact_gate", + 'require-workflow-success.sh Builds "$GITHUB_SHA" 7200 --job artifact-builders', + "BUILDS_RUN_ID: ${{ steps.builds_artifact_gate.outputs.run_id }}", + "--run-id \"$BUILDS_RUN_ID\"", + "--run-id \"${BUILDS_RUN_ID}\"", + "--job artifact-builders", + "--artifact liboliphaunt-wasix-release-assets", + "--artifact oliphaunt-extension-package-artifacts", + "--artifact liboliphaunt-native-release-assets", + "--artifact \"$artifact\"", + "download_sdk_artifact oliphaunt-rust oliphaunt-rust-sdk-package-artifacts", + "download_sdk_artifact oliphaunt-swift oliphaunt-swift-sdk-package-artifacts", + "download_sdk_artifact oliphaunt-kotlin oliphaunt-kotlin-sdk-package-artifacts", + "download_sdk_artifact oliphaunt-react-native oliphaunt-react-native-sdk-package-artifacts", + "download_sdk_artifact oliphaunt-js oliphaunt-js-sdk-package-artifacts", + "download_sdk_artifact oliphaunt-wasix-rust oliphaunt-wasix-rust-package-artifacts", + "--artifact oliphaunt-node-direct-npm-package-macos-arm64", + "target/oliphaunt-broker/release-assets", + "target/oliphaunt-node-direct/release-assets", + "tools/release/release.py publish-dry-run --products-json", + ): + if snippet not in publish_block: + fail(f"Release workflow dry-run handoff is missing {snippet!r}") + if "target/release-assets/native" in publish_block: + fail("Release workflow must download native helper artifacts into product-owned release asset roots") + + download_calls = list(re.finditer(r"[.]github/scripts/download-build-artifacts[.]sh", publish_block)) + if not download_calls: + fail("Release workflow must download staged builder artifacts from the Builds workflow") + for index, call in enumerate(download_calls): + next_call = download_calls[index + 1].start() if index + 1 < len(download_calls) else -1 + next_step = publish_block.find("\n - name:", call.end()) + end_candidates = [candidate for candidate in (next_call, next_step) if candidate != -1] + end = min(end_candidates) if end_candidates else len(publish_block) + call_text = normalized_shell(publish_block[call.start():end]) + # Every release artifact download must come from the same-SHA Builds + # workflow and the artifact-builders aggregate, even when wrapped in shell + # helper functions. + for required in ("Builds", '"$GITHUB_SHA"', "--run-id", "--job artifact-builders", "--artifact"): + if required not in call_text: + fail(f"Release artifact download must require {required}: {call_text[:240]}") + + build_artifact_script = read_text(".github/scripts/download-build-artifacts.sh") + for snippet in ( + "--run-id", + "selected_run_id", + 'required_job_success "$run_id"', + 'artifact_present "$run_id" "$artifact"', + ): + if snippet not in build_artifact_script: + fail(f"shared Builds artifact downloader must support and verify pinned run ids: missing {snippet!r}") + + require_workflow_script = read_text(".github/scripts/require-workflow-success.sh") + for snippet in ("--run-id", "GITHUB_OUTPUT", "run_id=", 'emit_run_id "$run_id"'): + if snippet not in require_workflow_script: + fail(f"Builds artifact gate must emit and validate selected run ids: missing {snippet!r}") + + wasix_download_script = read_text(".github/scripts/download-wasix-runtime-build-artifacts.sh") + for snippet in ("BUILDS_RUN_ID", '--run-id "$BUILDS_RUN_ID"', "--required-job artifact-builders"): + if snippet not in wasix_download_script: + fail(f"WASIX runtime artifact handoff must consume the selected Builds run id: missing {snippet!r}") + + guarded_publish_steps = { + "Create release-please GitHub releases", + "Publish liboliphaunt GitHub release assets", + "Publish selected extension GitHub release assets", + "Attest selected extension release assets", + "Attest liboliphaunt release assets", + "Publish Swift SDK GitHub release and SwiftPM tags", + "Publish Kotlin SDK to Maven Central", + "Publish React Native package to npm", + "Publish WASIX runtime crates to crates.io", + "Publish WASIX Rust binding to crates.io", + "Publish Rust SDK to crates.io", + "Publish broker GitHub release assets", + "Attest broker release assets", + "Publish Node direct GitHub release assets", + "Attest Node direct release assets", + "Publish Node direct optional packages to npm", + "Publish TypeScript packages to npm and JSR", + "Upload WASIX GitHub release assets", + "Attest WASIX release assets", + "Verify published release", + "Run consumer shape gates", + } + for step in guarded_publish_steps: + assert_step_if_contains_publish_guard(publish_steps, step) + + attestation_requirements = { + "Attest selected extension release assets": [ + "actions/attest-build-provenance@", + "target/extension-artifacts/*/release-assets/*.tar.gz", + "target/extension-artifacts/*/release-assets/*.tar.zst", + "target/extension-artifacts/*/release-assets/*.zip", + "target/extension-artifacts/*/release-assets/*.json", + "target/extension-artifacts/*/release-assets/*.properties", + "target/extension-artifacts/*/release-assets/*.sha256", + ], + "Attest liboliphaunt release assets": [ + "actions/attest-build-provenance@", + "target/liboliphaunt/release-assets/*.tar.gz", + "target/liboliphaunt/release-assets/*.tar.zst", + "target/liboliphaunt/release-assets/*.zip", + "target/liboliphaunt/release-assets/*.tsv", + "target/liboliphaunt/release-assets/*.sha256", + ], + "Attest broker release assets": [ + "actions/attest-build-provenance@", + "target/oliphaunt-broker/release-assets/*.tar.gz", + "target/oliphaunt-broker/release-assets/*.zip", + "target/oliphaunt-broker/release-assets/*.sha256", + ], + "Attest Node direct release assets": [ + "actions/attest-build-provenance@", + "target/oliphaunt-node-direct/release-assets/*.tar.gz", + "target/oliphaunt-node-direct/release-assets/*.zip", + "target/oliphaunt-node-direct/release-assets/*.sha256", + ], + "Attest WASIX release assets": [ + "actions/attest-build-provenance@", + "target/oliphaunt-wasix/release-assets/*.tar.zst", + "target/oliphaunt-wasix/release-assets/*.sha256", + ], + } + for step, snippets in attestation_requirements.items(): + for snippet in snippets: + assert_step_contains(publish_steps, step, snippet, f"{step} must attest {snippet}") + + assert_step_contains( + publish_steps, + "Verify published release", + "tools/release/release.py verify-release --products-json", + "Release workflow must verify published products through the release CLI", + ) + assert_contains( + "tools/release/release.py", + "tools/release/verify_github_release_attestations.py", + "release.py verify-release must verify GitHub artifact attestations", + ) + for snippet in ( + "--signer-workflow", + ".github/workflows/release.yml", + "--source-ref", + "refs/heads/main", + "--deny-self-hosted-runners", + ): + assert_contains( + "tools/release/verify_github_release_attestations.py", + snippet, + "Release attestation verification must pin signer workflow, source ref, and runner trust", + ) + + +def extension_native_targets(jobs: set[str], tasks: set[str]) -> set[str]: + selected_targets = ci_plan.native_target_subset_for_jobs(jobs, tasks) + matrix = ci_plan.extension_artifacts_native_matrix("all", selected_targets) + include = matrix.get("include") + if not isinstance(include, list): + fail("native extension artifact matrix must declare include rows") + targets = {row.get("target") for row in include if isinstance(row, dict)} + if not all(isinstance(target, str) for target in targets): + fail("native extension artifact matrix rows must declare string target") + return set(targets) + + +def assert_single_extension_matrix_selection(product: str) -> None: + jobs = ci_plan.plan_jobs_for_affected( + {product}, + {f"{product}:assemble-release"}, + ) + selection = ci_plan.selected_extension_products_for_plan( + {product}, + {f"{product}:assemble-release"}, + jobs, + ) + if selection != {product}: + fail(f"single exact-extension changes must narrow extension artifact matrices, got {sorted(selection or [])}") + native_matrix = ci_plan.extension_artifacts_native_matrix( + "all", + None, + selection, + ) + matrix_products = { + item + for row in native_matrix.get("include", []) + if isinstance(row, dict) + for item in str(row.get("extensions_csv", "")).split(",") + if item + } + if matrix_products != {product}: + fail(f"single exact-extension native matrix must include only {product}, got {sorted(matrix_products)}") + + aggregate_tasks = { + f"{product}:assemble-release", + "extension-artifacts-native:build-target", + "extension-artifacts-wasix:build-target", + "extension-packages:assemble-release", + } + aggregate_jobs = ci_plan.plan_jobs_for_affected({product}, aggregate_tasks) + aggregate_selection = ci_plan.selected_extension_products_for_plan( + {product}, + aggregate_tasks, + aggregate_jobs, + ) + if aggregate_selection != {product}: + fail( + "single exact-extension changes must stay product-scoped even when aggregate artifact/package tasks are selected, " + f"got {sorted(aggregate_selection or [])}" + ) + aggregate_native_products = { + item + for row in ci_plan.extension_artifacts_native_matrix("all", None, aggregate_selection).get("include", []) + if isinstance(row, dict) + for item in str(row.get("extensions_csv", "")).split(",") + if item + } + if aggregate_native_products != {product}: + fail( + f"single exact-extension aggregate native matrix must include only {product}, got {sorted(aggregate_native_products)}" + ) + aggregate_wasix_products = { + item + for row in ci_plan.extension_artifacts_wasix_matrix("all", aggregate_selection).get("include", []) + if isinstance(row, dict) + for item in str(row.get("extensions_csv", "")).split(",") + if item + } + if aggregate_wasix_products != {product}: + fail( + f"single exact-extension aggregate WASIX matrix must include only {product}, got {sorted(aggregate_wasix_products)}" + ) + + +def check_ci_builder_planning() -> None: + full_jobs, _projects, _tasks, _reason, _selected_targets = ci_plan.plan_for_full_run() + allowed_full_non_builders = ci_plan.BASE_JOBS + unexpected_full_jobs = sorted(full_jobs - ci_plan.BUILDER_JOBS - allowed_full_non_builders) + if unexpected_full_jobs: + fail( + "full non-PR Builds runs must select artifact-producing builder jobs only; " + f"unexpected jobs: {unexpected_full_jobs}" + ) + forbidden_full_jobs = sorted( + full_jobs + & { + "coverage-summary", + "docs", + "js-regression", + "mobile-e2e-android", + "mobile-e2e-ios", + "release-intent", + "release-readiness", + "repo", + "rust-regression", + "wasm-regression", + } + ) + if forbidden_full_jobs: + fail(f"full non-PR Builds runs must not select check/regression/policy jobs: {forbidden_full_jobs}") + + focused_wasix_jobs, _projects, _tasks, _reason, _targets = ci_plan.plan_for_full_run( + wasm_target="linux-x64-gnu", + ) + expected_focused_wasix_jobs = { + "affected", + "liboliphaunt-wasix-runtime", + "liboliphaunt-wasix-aot", + } + if focused_wasix_jobs != expected_focused_wasix_jobs: + fail( + "focused WASIX target Builds runs must build only the portable runtime and requested AOT target, " + f"got {sorted(focused_wasix_jobs)}" + ) + + focused_mobile_expectations = { + "android": { + "affected", + "extension-artifacts-native", + "kotlin-sdk-package", + "liboliphaunt-native-android", + "mobile-build-android", + "mobile-extension-packages", + "react-native-sdk-package", + }, + "ios": { + "affected", + "extension-artifacts-native", + "liboliphaunt-native-ios", + "mobile-build-ios", + "mobile-extension-packages", + "react-native-sdk-package", + "swift-sdk-package", + }, + } + for target, expected_jobs in focused_mobile_expectations.items(): + focused_jobs, *_ = ci_plan.plan_for_full_run(mobile_target=target) + if not expected_jobs <= focused_jobs: + fail( + f"focused {target} Builds run is missing builder jobs: " + f"expected at least {sorted(expected_jobs)}, got {sorted(focused_jobs)}" + ) + focused_forbidden = focused_jobs & {"mobile-e2e-android", "mobile-e2e-ios"} + if focused_forbidden: + fail( + f"focused {target} Builds run must build app artifacts only, not E2E jobs: " + f"{sorted(focused_forbidden)}" + ) + + android_arm_jobs, _projects, _tasks, _reason, android_arm_targets = ci_plan.plan_for_full_run( + native_target="android-arm64-v8a", + mobile_target="android", + ) + if android_arm_targets != {"android-arm64-v8a"}: + fail( + "focused Android mobile Builds run with native_target=android-arm64-v8a must narrow every " + f"target-scoped builder to android-arm64-v8a, got {sorted(android_arm_targets or [])}" + ) + if ci_plan.mobile_extension_package_native_targets(android_arm_jobs, android_arm_targets) != ["android-arm64-v8a"]: + fail("focused Android mobile extension package targets must match the selected Android native target") + + ios_focused_jobs, _projects, _tasks, _reason, ios_focused_targets = ci_plan.plan_for_full_run( + native_target="ios-xcframework", + mobile_target="ios", + ) + if ios_focused_targets != {"ios-xcframework"}: + fail( + "focused iOS mobile Builds run with native_target=ios-xcframework must narrow every " + f"target-scoped builder to ios-xcframework, got {sorted(ios_focused_targets or [])}" + ) + if ci_plan.mobile_extension_package_native_targets(ios_focused_jobs, ios_focused_targets) != ["ios-xcframework"]: + fail("focused iOS mobile extension package targets must match the selected iOS native target") + + try: + ci_plan.plan_for_full_run(native_target="ios-xcframework", mobile_target="android") + except RuntimeError as error: + if "not valid for mobile_target=android" not in str(error): + fail(f"focused Android/iOS target mismatch failed with an unclear error: {error}") + else: + fail("focused Android mobile Builds run must reject native_target=ios-xcframework") + + try: + ci_plan.plan_for_full_run(native_target="android-arm64-v8a", mobile_target="both") + except RuntimeError as error: + if "mobile_target=both requires native_target=all" not in str(error): + fail(f"focused mobile_target=both mismatch failed with an unclear error: {error}") + else: + fail("focused mobile_target=both must reject a single native target") + + react_native_jobs = ci_plan.plan_jobs_for_affected( + set(), + {"oliphaunt-react-native:package-artifacts"}, + ) + react_native_expected_jobs = { + "extension-artifacts-native", + "kotlin-sdk-package", + "liboliphaunt-native-android", + "liboliphaunt-native-ios", + "mobile-build-android", + "mobile-build-ios", + "mobile-extension-packages", + "react-native-sdk-package", + "swift-sdk-package", + } + if not react_native_expected_jobs <= react_native_jobs: + fail( + "React Native SDK package changes must build both mobile app artifacts from staged SDK/runtime/extension inputs; " + f"missing {sorted(react_native_expected_jobs - react_native_jobs)} from {sorted(react_native_jobs)}" + ) + react_native_targets = ci_plan.native_target_subset_for_jobs( + react_native_jobs, + {"oliphaunt-react-native:package-artifacts"}, + ) + expected_react_native_targets = {"android-arm64-v8a", "android-x86_64", "ios-xcframework"} + if react_native_targets != expected_react_native_targets: + fail( + "React Native SDK package changes must request Android and iOS native runtime targets, " + f"got {sorted(react_native_targets or [])}" + ) + + assert_single_extension_matrix_selection("oliphaunt-extension-vector") + assert_single_extension_matrix_selection("oliphaunt-extension-amcheck") + broad_selection = ci_plan.selected_extension_products_for_plan( + {"extensions"}, + {"extension-packages:assemble-release"}, + {"extension-packages", "extension-artifacts-native", "extension-artifacts-wasix"}, + ) + all_extension_products = expected_extension_products_from_sdk_catalog() + if broad_selection != all_extension_products: + fail( + "broad extension catalog changes must select the full exact-extension product set, " + f"got {sorted(broad_selection or [])}" + ) + + full_builder_selection = ci_plan.selected_extension_products_for_plan( + set(), + { + "extension-packages:assemble-release", + "extension-packages:assemble-mobile", + "oliphaunt-react-native:mobile-build-android", + "oliphaunt-react-native:mobile-build-ios", + }, + { + "extension-artifacts-native", + "extension-artifacts-wasix", + "extension-packages", + "mobile-build-android", + "mobile-build-ios", + "mobile-extension-packages", + }, + ) + if full_builder_selection != all_extension_products: + fail( + "full builder runs must select the full exact-extension product set, " + f"got {sorted(full_builder_selection or [])}" + ) + + mobile_focused_selection = ci_plan.selected_extension_products_for_plan( + set(), + {"oliphaunt-react-native:mobile-build-android"}, + {"mobile-build-android", "mobile-extension-packages", "extension-artifacts-native"}, + ) + if mobile_focused_selection != {"oliphaunt-extension-vector"}: + fail( + "focused mobile builder runs must build only the selected smoke extension, " + f"got {sorted(mobile_focused_selection or [])}" + ) + + android_tasks = {"oliphaunt-react-native:mobile-build-android"} + android_jobs = ci_plan.plan_jobs_for_affected(set(), android_tasks) + if "extension-artifacts-native" not in android_jobs: + fail("Android mobile build must build selected native extension artifacts") + android_targets = extension_native_targets(android_jobs, android_tasks) + if android_targets != {"android-arm64-v8a", "android-x86_64"}: + fail(f"Android mobile build must only request Android extension artifacts, got {sorted(android_targets)}") + + android_e2e_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-react-native:mobile-e2e-android"}) + if android_e2e_jobs != ci_plan.BASE_JOBS: + fail(f"Builds CI must not select Android E2E jobs; got {sorted(android_e2e_jobs)}") + + ios_tasks = {"oliphaunt-react-native:mobile-build-ios"} + ios_jobs = ci_plan.plan_jobs_for_affected(set(), ios_tasks) + if "extension-artifacts-native" not in ios_jobs: + fail("iOS mobile build must build selected native extension artifacts") + ios_targets = extension_native_targets(ios_jobs, ios_tasks) + if ios_targets != {"ios-xcframework"}: + fail(f"iOS mobile build must only request iOS extension artifacts, got {sorted(ios_targets)}") + + ios_e2e_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-react-native:mobile-e2e-ios"}) + if ios_e2e_jobs != ci_plan.BASE_JOBS: + fail(f"Builds CI must not select iOS E2E jobs; got {sorted(ios_e2e_jobs)}") + + extension_tasks = {"extension-packages:assemble-release"} + extension_jobs = ci_plan.plan_jobs_for_affected(set(), extension_tasks) + full_targets = extension_native_targets(extension_jobs, extension_tasks) + expected_full_targets = { + target.target + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + published_only=True, + ) + if target.extension_artifacts + } + if full_targets != expected_full_targets: + fail(f"extension package build must request all supported native extension artifacts, got {sorted(full_targets)}") + + swift_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-swift:package-artifacts"}) + if "liboliphaunt-native-ios" not in swift_jobs: + fail("Swift SDK package build must build the Apple liboliphaunt XCFramework") + swift_targets = ci_plan.native_target_subset_for_jobs(swift_jobs, {"oliphaunt-swift:package-artifacts"}) + if swift_targets != {"ios-xcframework"}: + fail(f"Swift SDK package build must only request the Apple XCFramework runtime target, got {sorted(swift_targets or [])}") + + kotlin_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-kotlin:package-artifacts"}) + if kotlin_jobs != ci_plan.BASE_JOBS | {"kotlin-sdk-package"}: + fail(f"Kotlin SDK package build must only package the Kotlin SDK, got {sorted(kotlin_jobs)}") + + rust_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-rust:package-artifacts"}) + if rust_jobs != ci_plan.BASE_JOBS | {"rust-sdk-package"}: + fail(f"Rust SDK package build must only package the Rust SDK, got {sorted(rust_jobs)}") + + js_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-js:package-artifacts"}) + if js_jobs != ci_plan.BASE_JOBS | {"js-sdk-package"}: + fail(f"TypeScript SDK package build must only package the TypeScript SDK, got {sorted(js_jobs)}") + + wasix_rust_jobs = ci_plan.plan_jobs_for_affected(set(), {"oliphaunt-wasix-rust:package-artifacts"}) + if wasix_rust_jobs != ci_plan.BASE_JOBS | {"wasix-rust-package"}: + fail(f"WASIX Rust binding package build must only package the binding crate, got {sorted(wasix_rust_jobs)}") + + +def main() -> int: + graph = release_plan.load_graph() + policy = graph.get("policy") + if not isinstance(policy, dict): + fail("release metadata must define policy") + if policy.get("repository") != "f0rr0/oliphaunt": + fail("release policy repository must be f0rr0/oliphaunt") + if policy.get("versioning") != "independent": + fail("release policy must use independent versioning") + + check_release_metadata(graph) + check_release_planning(graph) + check_ci_policy() + check_release_workflow_policy() + check_ci_builder_planning() + print("release policy checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/policy/check-repo-structure.sh b/tools/policy/check-repo-structure.sh new file mode 100755 index 00000000..e9b17283 --- /dev/null +++ b/tools/policy/check-repo-structure.sh @@ -0,0 +1,596 @@ +#!/usr/bin/env sh +set -eu + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +canonical_release_repo="f0rr0/oliphaunt" +canonical_release_url="https://github.com/$canonical_release_repo" + +fail() { + echo "$1" >&2 + exit 1 +} + +require_file() { + [ -f "$1" ] || fail "missing required repository structure file: $1" +} + +proto_version() { + tool="$1" + awk -F '=' -v tool="$tool" ' + $1 ~ "^[[:space:]]*" tool "[[:space:]]*$" { + value=$2 + gsub(/^[[:space:]\"]+|[[:space:]\"]+$/, "", value) + print value + found=1 + } + END { if (!found) exit 1 } + ' .prototools +} + +require_dir() { + [ -d "$1" ] || fail "missing required repository structure directory: $1" +} + +reject_tracked_under() { + path="$1" + tracked_existing="$( + git ls-files -- "$path" | while IFS= read -r tracked_path; do + [ ! -e "$tracked_path" ] || printf '%s\n' "$tracked_path" + done + )" + if [ -n "$tracked_existing" ]; then + echo "tracked files remain under retired path: $path" >&2 + printf '%s\n' "$tracked_existing" >&2 + exit 1 + fi +} + +reject_existing_path() { + path="$1" + tracked_existing="$( + git ls-files -- "$path" | while IFS= read -r tracked_path; do + [ ! -e "$tracked_path" ] || printf '%s\n' "$tracked_path" + done + )" + if [ -n "$tracked_existing" ]; then + echo "generated path must not be tracked under the source tree: $path" >&2 + printf '%s\n' "$tracked_existing" >&2 + exit 1 + fi +} + +reject_path() { + path="$1" + if [ -e "$path" ]; then + echo "retired repository path must not exist: $path" >&2 + exit 1 + fi +} + +require_text() { + file="$1" + text="$2" + if ! grep -Fq -- "$text" "$file"; then + echo "expected '$text' in $file" >&2 + exit 1 + fi +} + +reject_text() { + file="$1" + text="$2" + if grep -Fq -- "$text" "$file"; then + echo "unexpected '$text' in $file" >&2 + exit 1 + fi +} + +for path in \ + liboliphaunt \ + crates \ + sdks \ + assets \ + fixtures \ + assets/wasix-build \ + examples/react-native-oliphaunt-expo \ + examples/tauri-sqlx-vanilla \ + examples/build_pgdata_template.rs \ + src/extensions/recipes \ + src/liboliphaunt \ + src/oliphaunt-docs \ + src/oliphaunt-kotlin \ + src/oliphaunt-react-native \ + src/oliphaunt-rust \ + src/oliphaunt-swift \ + src/oliphaunt-ts \ + src/oliphaunt-wasix \ + src/third-party +do + reject_tracked_under "$path" +done + +for path in \ + tools/dev/smoke-react-native-expo-android.sh \ + tools/dev/smoke-react-native-expo-ios.sh \ + tools/dev/mobile-extension-runtime.sh +do + reject_path "$path" +done + +for path in \ + release-plz.toml \ + tools/ci/validate.sh \ + tools/graph/run-affected.py \ + tools/release/check_clean_consumer_installs.py \ + tools/release/check_consumer_install_readiness.py \ + tools/release/check_product_changelogs.py \ + tools/release/cliff.toml \ + tools/release/ensure_swiftpm_version_tag.py \ + tools/release/plan.py \ + tools/release/prepare_products.py \ + tools/release/product_release_notes.py \ + tools/release/product_version.py \ + tools/release/product_versions_from_ref.py \ + tools/release/release-graph.toml +do + reject_tracked_under "$path" +done + +for path in \ + src/target \ + src/runtimes/liboliphaunt/wasix/assets/build/build \ + src/runtimes/liboliphaunt/wasix/assets/build/work \ + src/sdks/swift/.build \ + src/sdks/kotlin/.gradle \ + src/sdks/kotlin/.kotlin \ + src/sdks/kotlin/build \ + src/sdks/kotlin/liboliphaunt-kotlin \ + src/sdks/kotlin/oliphaunt/.cxx \ + src/sdks/kotlin/oliphaunt/build \ + src/sdks/js/lib \ + src/sdks/js/node_modules \ + src/docs/node_modules \ + src/sdks/react-native/.build \ + src/sdks/react-native/lib \ + src/sdks/react-native/node_modules \ + src/sdks/react-native/android/.cxx \ + src/sdks/react-native/android/.gradle \ + src/sdks/react-native/android/build \ + src/sdks/react-native/examples/expo/.expo \ + src/sdks/react-native/examples/expo/node_modules \ + src/sdks/react-native/examples/expo/android \ + src/sdks/react-native/examples/expo/ios \ + src/docs/.docusaurus \ + src/docs/.next \ + src/docs/.source \ + src/docs/out \ + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/dist \ + src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/gen +do + reject_existing_path "$path" +done + +for product in \ + src/runtimes/liboliphaunt/native \ + src/sdks/rust \ + src/sdks/swift \ + src/sdks/kotlin \ + src/sdks/react-native \ + src/sdks/js \ + src/bindings/wasix-rust \ + src/docs +do + require_dir "$product" + require_file "$product/moon.yml" +done + +require_file .moon/workspace.yml +require_file .moon/toolchains.yml +require_file .prototools +require_file .config/nextest.toml +require_file .lychee.toml +require_file .markdownlint-cli2.jsonc +require_file .typos.toml +require_file biome.json +require_file renovate.json +require_file THIRD_PARTY_NOTICES.md +require_file package.json +require_file pnpm-lock.yaml +require_file pnpm-workspace.yaml +require_file release-please-config.json +require_file .release-please-manifest.json +require_file tools/release/release.py +require_file tools/dev/bun.sh +require_file tools/dev/doctor.sh +require_file tools/policy/check-policy-tools.sh +require_file tools/policy/check-final-source-architecture.py +require_file tools/graph/moon.yml +require_file tools/graph/graph.py +reject_path tools/graph/synthetic-paths.toml +require_file tools/graph/synthetic/affected.toml +require_file tools/graph/synthetic/release.toml +require_file tools/graph/synthetic/coverage.toml +require_file src/shared/contracts/moon.yml +require_file src/shared/contracts/test-matrix.toml +require_file src/shared/contracts/tools/check-test-matrix.py +require_file src/shared/fixtures/moon.yml +require_file src/shared/fixtures/manifest.toml +require_file .github/scripts/plan-affected.py +require_file .github/scripts/run-moon-targets.sh +require_file src/runtimes/liboliphaunt/native/tools/check-patch-stack.mjs +require_file src/runtimes/liboliphaunt/native/THIRD_PARTY_NOTICES.md +require_file src/runtimes/liboliphaunt/wasix/tools/check-patch-stack.mjs +require_file src/bindings/wasix-rust/THIRD_PARTY_NOTICES.md +require_file tools/policy/check-react-native-boundary.sh +require_file tools/policy/check-sdk-mobile-extension-surface.sh +require_file tools/policy/check-test-strategy.mjs +require_file tools/policy/check-coverage.sh +require_file tools/policy/sdk-check-lib.sh +require_file tools/test/moon.yml +require_file tools/test/run-js-tests.mjs +require_file src/docs/package.json +require_file src/docs/next.config.mjs +require_file src/docs/source.config.ts +require_file src/docs/src/app/layout.tsx +require_file 'src/docs/src/app/(home)/page.tsx' +require_file src/docs/src/app/global.css +require_file src/docs/src/app/docs/layout.tsx +require_file 'src/docs/src/app/docs/[[...slug]]/page.tsx' +require_file src/docs/src/lib/source.ts +require_file src/docs/src/components/mdx.tsx +require_file src/docs/docs-manifest.toml +require_file tools/policy/sdk-manifest.toml +require_file src/docs/content/reference/sdk-products.mdx +require_file src/docs/reference/doxygen/Doxyfile +require_file src/docs/tools/generate-api-reference.mjs +require_file src/docs/tools/generate-content.mjs +require_file src/docs/tools/check-docs-product.mjs +require_file src/docs/tools/publish-next-export.mjs +require_file src/docs/tools/smoke-built-site.mjs +require_file tools/xtask/src/asset_manifest.rs +require_file tools/xtask/src/asset_checks.rs +require_file tools/xtask/src/asset_io.rs +require_file tools/xtask/src/asset_pipeline.rs +require_file tools/xtask/src/aot_serializer.rs +require_file tools/xtask/src/fs_utils.rs +require_file tools/perf/runner/Cargo.toml +require_file tools/perf/runner/src/benchmarks.rs +require_file tools/perf/runner/src/diagnostics.rs +require_file tools/perf/runner/src/legacy_wasix.rs +require_file tools/perf/runner/src/native_liboliphaunt.rs +require_file tools/perf/runner/src/native_postgres.rs +require_file tools/perf/runner/src/prepared_updates.rs +require_file tools/perf/runner/src/report.rs +require_file tools/perf/runner/src/shared.rs +require_file tools/perf/runner/src/sqlite.rs +require_file tools/xtask/src/postgres_guard.rs +require_file tools/xtask/src/release_workspace.rs +require_file tools/xtask/src/source_spine.rs +require_file tools/xtask/src/template_runner.rs +require_file src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs +require_file src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs +require_file src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs +require_file src/sdks/rust/src/runtime_resources/extension_artifact.rs +require_file src/sdks/rust/src/runtime_resources/extension_index.rs +require_file src/sdks/rust/src/runtime_resources/manifest.rs +require_file src/sdks/rust/src/runtime_resources/package.rs +require_file src/sdks/rust/src/runtime_resources/static_registry.rs +require_file src/extensions/contrib/postgres18.toml +require_file src/extensions/external/README.md +require_file src/extensions/external/vector/source.toml +require_file src/extensions/external/postgis/source.toml +require_file src/extensions/external/postgis/dependencies/geos/source.toml +require_file src/extensions/external/postgis/dependencies/proj/source.toml +require_file src/extensions/external/postgis/dependencies/sqlite/source.toml +require_file src/extensions/external/postgis/dependencies/libxml2/source.toml +require_file src/extensions/external/postgis/dependencies/json-c/source.toml +require_file src/extensions/external/postgis/dependencies/libiconv/source.toml +require_file src/extensions/schemas/recipe.schema.json +require_file src/extensions/schemas/support-table.schema.json +require_file src/extensions/evidence/matrix.toml +require_file src/extensions/evidence/schemas/matrix.schema.json +require_file src/extensions/evidence/schemas/run.schema.json +require_file src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json +require_file src/extensions/generated/docs/extensions.json +require_file src/extensions/generated/docs/extension-evidence.json +require_file src/extensions/generated/sdk/rust.json +require_file src/extensions/generated/sdk/swift.json +require_file src/extensions/generated/sdk/kotlin.json +require_file src/extensions/generated/sdk/js.json +require_file src/extensions/generated/sdk/react-native.json +require_file src/sdks/rust/src/generated/extensions.rs +require_file src/extensions/generated/mobile/static-registry.json +require_file src/extensions/generated/mobile/static-extensions.tsv +require_file src/extensions/generated/wasix/extensions.json +require_file src/extensions/tools/check-extension-model.py + +require_dir src/sdks/rust/tests +require_dir src/sdks/swift/Tests +require_dir src/sdks/kotlin/oliphaunt/src/commonTest +require_dir src/sdks/kotlin/oliphaunt/src/androidUnitTest +require_dir src/sdks/kotlin/oliphaunt/src/nativeTest +require_dir src/sdks/react-native/src/__tests__ +require_dir src/sdks/js/src/__tests__ +require_dir src/bindings/wasix-rust/crates/oliphaunt-wasix/tests +require_file benchmarks/README.md +require_dir src/shared/fixtures/protocol +require_file src/shared/fixtures/protocol/query-response-cases.json +require_file src/shared/fixtures/sdk-capabilities/mode-support.json +require_file src/shared/fixtures/runtime-resources/manifest.properties +require_file src/shared/fixtures/runtime-resources/template-pgdata-manifest.properties +require_file src/shared/fixtures/runtime-resources/package-size.tsv +require_file src/shared/fixtures/backup/physical-archive-manifest.json +require_file src/shared/fixtures/lifecycle/session-lifecycle.json +require_file src/shared/fixtures/react-native-jsi/binary-transport.json +require_file src/shared/fixtures/consumer-shape/products.json +require_file coverage/baseline.toml + +require_text .gitignore '/.moon/cache/' +pnpm_version="$(proto_version pnpm)" +require_text package.json "\"packageManager\": \"pnpm@$pnpm_version\"" +require_text package.json '"node": ">=22.13 <25"' +require_text package.json "\"pnpm\": \"$pnpm_version\"" +require_text pnpm-workspace.yaml 'nodeLinker: hoisted' +require_text pnpm-workspace.yaml 'confirmModulesPurge: false' +require_text pnpm-workspace.yaml 'updateNotifier: false' +require_text pnpm-workspace.yaml 'saveWorkspaceProtocol: rolling' +require_text pnpm-workspace.yaml 'verifyDepsBeforeRun: false' +node -e ' +const fs = require("node:fs"); +const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); +const scripts = Object.keys(pkg.scripts ?? {}); +if (scripts.length !== 0) { + console.error(`root package.json scripts must be empty; use moon directly, got ${scripts.join(", ")}`); + process.exit(1); +} +' +require_text .github/actions/setup-moon/action.yml 'moonrepo/setup-toolchain' +require_text .github/actions/setup-moon/action.yml 'auto-install: true' +require_text .github/actions/setup-moon/action.yml 'moon --version' +require_text .github/actions/setup-moon/action.yml 'moon query projects' +reject_text .github/actions/setup-moon/action.yml 'pnpm moon' +reject_text .github/actions/setup-moon/action.yml 'node-version:' +reject_text .github/actions/setup-moon/action.yml 'pnpm-version:' +reject_tracked_under tools/graph/moon.mjs +reject_tracked_under tools/graph/tool-versions.mjs +reject_tracked_under tools/graph/tool_versions.py +reject_tracked_under tools/graph/run-affected-task.py +require_text src/postgres/versions/18/moon.yml "node tools/policy/check-source-inputs.mjs postgres18" +require_text src/sources/moon.yml 'id: "source-inputs"' +require_text src/sources/moon.yml "bun tools/policy/fetch-sources.mjs" +require_text src/sources/toolchains/moon.yml "node tools/policy/check-source-inputs.mjs toolchains" +reject_text package.json 'pnpm moon' +reject_text package.json 'tools/graph/run-affected-task.py' +reject_text package.json '"docs:' +reject_text package.json '"check"' +reject_text package.json '"test"' +reject_text package.json '"coverage"' +reject_text package.json 'tools/graph/run-affected.py' +reject_text package.json '--affected --downstream deep' +reject_text package.json 'tools/dev/doctor.sh' +reject_text package.json 'tools/policy/format.sh' +reject_text package.json '"validate":' +reject_text package.json '"native:' +reject_text package.json '"rn:' +reject_text package.json '"wasix:' +reject_tracked_under tools/graph/run-affected.py +require_text .moon/workspace.yml 'moon.yml' +require_text .moon/workspace.yml 'sources:' +require_text .moon/workspace.yml 'ci-workflows: ".github"' +reject_text .moon/workspace.yml 'docs/moon.yml' +require_text .moon/workspace.yml 'examples/moon.yml' +require_text .moon/workspace.yml 'src/*/moon.yml' +require_text .moon/workspace.yml 'src/sources/*/moon.yml' +require_text .moon/workspace.yml 'src/sources/third-party/*/moon.yml' +require_text .moon/workspace.yml 'src/shared/*/moon.yml' +require_text .moon/workspace.yml 'tools/*/moon.yml' +require_text src/shared/contracts/moon.yml 'id: "shared-contracts"' +require_text src/shared/fixtures/moon.yml 'id: "shared-fixtures"' +require_text src/shared/fixtures/moon.yml 'target/shared-fixtures/manifest.generated.json' +require_text tools/policy/moon.yml 'tools/policy/check-policy-tools.sh' +require_text tools/policy/moon.yml '/tools/graph/**/*' +require_text tools/graph/moon.yml 'id: "graph-tools"' +require_text tools/graph/moon.yml 'tools/graph/graph.py check' +require_file tools/graph/cache-witness.py +require_text tools/graph/moon.yml 'cache-witness-fixture:' +require_text moon.yml 'cacheStrategy: "outputs"' +require_text src/docs/moon.yml 'cacheStrategy: "outputs"' +require_text tools/policy/moon.yml '/tools/test/**/*' +require_text src/sdks/rust/moon.yml 'dependsOn:' +require_text src/sdks/rust/moon.yml '- "liboliphaunt-native"' +require_text src/sdks/swift/moon.yml '- "liboliphaunt-native"' +require_text src/sdks/kotlin/moon.yml '- "liboliphaunt-native"' +require_text src/sdks/react-native/moon.yml '- "oliphaunt-swift"' +require_text src/sdks/react-native/moon.yml '- "oliphaunt-kotlin"' +require_text src/bindings/wasix-rust/moon.yml 'dependsOn:' +require_text src/bindings/wasix-rust/moon.yml '- "liboliphaunt-wasix"' +require_text src/runtimes/liboliphaunt/wasix/moon.yml 'id: "liboliphaunt-wasix"' +require_text src/runtimes/liboliphaunt/wasix/moon.yml '- "postgres18"' +require_text src/runtimes/liboliphaunt/wasix/moon.yml '- "source-toolchains"' +require_text src/runtimes/liboliphaunt/wasix/moon.yml '- "third-party-shared"' +require_text src/runtimes/liboliphaunt/wasix/moon.yml '- "third-party-wasix"' +require_text src/runtimes/liboliphaunt/wasix/moon.yml '- "extension-runtime-contract"' +require_text src/postgres/versions/18/moon.yml 'id: "postgres18"' +require_text src/sources/toolchains/moon.yml 'id: "source-toolchains"' +require_text src/sources/third-party/shared/moon.yml 'id: "third-party-shared"' +require_text src/sources/third-party/native/moon.yml 'id: "third-party-native"' +require_text src/sources/third-party/wasix/moon.yml 'id: "third-party-wasix"' +require_text src/extensions/moon.yml 'id: "extensions"' +require_text src/docs/moon.yml 'id: "docs"' +require_text src/docs/moon.yml 'pnpm --dir src/docs run check' + +if git ls-files docs/products | grep -q .; then + git ls-files docs/products >&2 + fail "root docs/products must stay retired; colocate product docs under src//docs" +fi + +require_text Cargo.toml 'src/sdks/rust' +require_text Cargo.toml 'src/bindings/wasix-rust/crates/oliphaunt-wasix' +require_text Cargo.toml 'src/runtimes/liboliphaunt/wasix/crates/assets' +require_text Cargo.toml 'tools/perf/runner' +reject_text Cargo.toml '"crates/' +reject_text Cargo.toml '"sdks/' +require_text moon.yml '/tools/perf/runner/**/*.rs' +require_text moon.yml '/tools/perf/runner/Cargo.toml' +require_text tools/perf/moon.yml 'language: "rust"' +require_text tools/perf/moon.yml '/tools/perf/**/*' +require_text tools/xtask/moon.yml 'source-policy checks' +reject_text tools/xtask/moon.yml 'benchmark utilities' + +require_text Cargo.toml "repository = \"$canonical_release_url\"" +require_text .github/workflows/release.yml "CANONICAL_RELEASE_REPOSITORY: $canonical_release_repo" +require_text docs/maintainers/release.md "repository \`$canonical_release_repo\`" +reject_tracked_under .github/dependabot.yml +reject_tracked_under .github/workflows/conventional-commits.yml +reject_tracked_under .github/scripts/check-release-changelog.sh +require_text src/bindings/wasix-rust/crates/oliphaunt-wasix/release.toml 'id = "oliphaunt-wasix-rust"' +require_text release-please-config.json '"component": "oliphaunt-wasix-rust"' +reject_text .github/workflows/release.yml "f0rr0/oliphaunt-wasix" +reject_text .github/workflows/release.yml "Conventional Commits" +reject_text docs/maintainers/release.md "f0rr0/oliphaunt-wasix" + +if git grep -n 'repository = "https://github.com/f0rr0/' -- '*.toml' | + grep -Fv "repository = \"$canonical_release_url\"" >/tmp/oliphaunt-cargo-repo-url-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-cargo-repo-url-grep.$$ >&2 + rm -f /tmp/oliphaunt-cargo-repo-url-grep.$$ + fail "Cargo package repository URLs must point at $canonical_release_url" +fi +rm -f /tmp/oliphaunt-cargo-repo-url-grep.$$ + +if git grep -n 's.source = { :git => "https://github.com/f0rr0/' -- '*.podspec' | + grep -Fv "https://github.com/$canonical_release_repo.git" >/tmp/oliphaunt-podspec-repo-url-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-podspec-repo-url-grep.$$ >&2 + rm -f /tmp/oliphaunt-podspec-repo-url-grep.$$ + fail "podspec source URLs must point at https://github.com/$canonical_release_repo.git" +fi +rm -f /tmp/oliphaunt-podspec-repo-url-grep.$$ + +reject_tracked_under .github/workflows/assets.yml +require_text .github/workflows/ci.yml 'src/runtimes/liboliphaunt/wasix/assets/build/**' +require_text .github/workflows/ci.yml 'src/postgres/versions/18/**' +require_text .github/workflows/ci.yml 'src/sources/third-party/**' +require_text .github/workflows/ci.yml 'src/sources/toolchains/**' +require_text .github/workflows/ci.yml 'src/shared/extension-runtime-contract/**' +require_text .github/workflows/ci.yml 'target/oliphaunt-wasix/wasix-build/build/**' +require_text .github/workflows/ci.yml 'name: Builds' +require_text .github/workflows/ci.yml 'name: build-native-runtime-desktop (${{ matrix.target }})' +require_text .github/workflows/ci.yml 'name: build-native-runtime-android (${{ matrix.target }})' +require_text .github/workflows/ci.yml 'name: build-native-runtime-ios (${{ matrix.target }})' +require_text .github/workflows/ci.yml 'name: build-liboliphaunt-wasix-runtime' +require_text .github/workflows/ci.yml 'name: build-liboliphaunt-wasix-aot (${{ matrix.target_id }})' +require_text .github/workflows/ci.yml 'python3 .github/scripts/plan-affected.py' +require_text .github/workflows/ci.yml 'name: build-plan' +require_text .github/workflows/ci.yml 'path: target/graph/ci-plan.json' +require_text .github/workflows/ci.yml 'job_targets: ${{ steps.plan.outputs.job_targets }}' +require_text .github/workflows/ci.yml 'liboliphaunt_wasix_aot_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_wasix_aot_runtime_matrix }}' +require_text .github/workflows/ci.yml 'matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_wasix_aot_runtime_matrix' +require_text .github/workflows/ci.yml "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-runtime')" +require_text .github/workflows/ci.yml "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-aot')" +require_text .github/workflows/ci.yml "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-release-assets')" +reject_text .github/workflows/ci.yml 'liboliphaunt-wasix-aot-targets' +require_text .github/workflows/ci.yml '.github/scripts/run-planned-moon-job.sh wasix-rust-package' +require_text .github/workflows/ci.yml 'liboliphaunt-wasix-runtime-portable' +require_text .github/workflows/ci.yml 'liboliphaunt-wasix-runtime-aot-${{ matrix.target_id }}' +require_text .github/scripts/run-planned-moon-job.sh 'OLIPHAUNT_CI_JOB_TARGETS_JSON' +require_text .github/scripts/run-planned-moon-job.sh 'exec .github/scripts/run-moon-targets.sh' +require_text .github/scripts/run-moon-targets.sh 'exec "$moon_bin" run "$@"' +reject_text .github/scripts/run-moon-targets.sh 'pnpm moon' +require_text .github/scripts/plan-affected.py 'ci_plan.emit_github_outputs()' +require_text tools/graph/affected.py 'moon(["query", "affected", "--upstream", "none", "--downstream", "none"])' +require_text tools/graph/affected.py 'moon(["query", "affected", "--upstream", "none", "--downstream", "deep"])' +reject_path tools/graph/jobs.toml +reject_path tools/release/release-inputs.toml +require_text tools/graph/ci_plan.py 'moon_ci_job_targets' +require_text tools/graph/ci_plan.py 'ci-' +require_text tools/graph/ci_plan.py 'job_targets_for_jobs' +reject_text tools/graph/ci_plan.py 'import plan as release_plan' +require_text tools/graph/graph.py 'import release_plan' +reject_text tools/graph/graph.py 'import plan as release_plan' +require_text tools/graph/ci_plan.py 'WASM_RUNTIME_PORTABLE_TASK' +require_text tools/graph/ci_plan.py 'WASM_RUNTIME_JOBS' +reject_text tools/graph/ci_plan.py 'PROJECT_JOBS = {' +reject_text tools/graph/ci_plan.py 'CI_JOB_TARGETS: dict[str, list[str]] = {' +reject_text tools/graph/ci_plan.py 'MOBILE_ANDROID_PATTERNS = [' +reject_text tools/graph/ci_plan.py 'RN_IOS_PLATFORM_PATTERNS = [' +require_text src/runtimes/liboliphaunt/wasix/moon.yml 'runtime-portable:' +reject_text tools/graph/ci_plan.py 'PRODUCER_PROJECTS' +reject_text tools/graph/ci_plan.py 'PRODUCER_TASKS' +reject_text .github/workflows/ci.yml 'producer_required' +reject_text .github/workflows/ci.yml 'asset-plan' +reject_text .github/workflows/ci.yml 'plan-wasix-assets.py' +reject_text .github/workflows/ci.yml '- "assets/**"' +reject_text .github/workflows/ci.yml 'src/runtimes/liboliphaunt/wasix/assets/build/build/**' +python3 - <<'PY' +from pathlib import Path + +text = Path(".github/workflows/ci.yml").read_text() +head = text.split("push:", 1)[0] +if "paths:" in head: + raise SystemExit("Builds pull_request trigger must not use path filters; Moon affected is the source of truth") +if ( + "liboliphaunt-wasix-runtime:" not in text + or "liboliphaunt-wasix-aot:" not in text +): + raise SystemExit("Builds workflow must keep separate liboliphaunt-wasix runtime and AOT builder jobs") +PY +require_text tools/xtask/src/main.rs 'target/oliphaunt-wasix/wasix-build/build/outputs.json' +require_text docs/maintainers/testing.md 'Product-native tests stay in product-native test roots' +require_text docs/maintainers/testing.md 'src/shared/fixtures/protocol/query-response-cases.json' +require_text docs/maintainers/testing.md 'src/shared/fixtures/sdk-capabilities/mode-support.json' +require_text docs/maintainers/testing.md 'src/shared/fixtures/react-native-jsi/binary-transport.json' +require_text docs/maintainers/testing.md 'coverage/baseline.toml' +require_text docs/maintainers/repo-structure.md 'Shared fixture corpora consumed by at least two product-native test suites' +require_text src/sdks/rust/tests/protocol_query_fixtures.rs 'query-response-cases.json' +require_text src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift 'query-response-cases.json' +require_text src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt 'query-response-cases.json' +require_text src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts 'query-response-cases.json' +require_text src/sdks/react-native/package.json 'node ../../../tools/test/run-js-tests.mjs src/__tests__' +require_text src/sdks/react-native/src/__tests__/client.test.ts 'react-native-jsi/binary-transport.json' +require_text src/sdks/js/src/__tests__/protocol-fixtures.test.ts 'query-response-cases.json' +require_text src/sdks/js/package.json 'node ../../../tools/test/run-js-tests.mjs src/__tests__' +require_text src/sdks/js/tools/check-sdk.sh 'jsr publish --dry-run' +require_text src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs 'query-response-cases.json' +require_file benchmarks/native/sql/benchmark1.sql +require_file benchmarks/native/baselines/README.md +require_file benchmarks/wasix/README.md +require_file benchmarks/mobile/README.md +require_file benchmarks/reports/README.md +reject_tracked_under tools/perf/fixtures +reject_text tools/perf/matrix/run_bench_matrix.sh 'node-bench' +reject_text tools/perf/matrix/run_bench_matrix.sh 'bench-oxide' +reject_text tools/perf/matrix/run_bench_matrix.sh 'nodefs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/template_runner.rs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/asset_checks.rs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/asset_manifest.rs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/asset_io.rs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/asset_pipeline.rs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/fs_utils.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/benchmarks.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/diagnostics.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/legacy_wasix.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/native_liboliphaunt.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/native_postgres.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/prepared_updates.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/report.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/shared.rs' +require_text docs/maintainers/tooling.md 'tools/perf/runner/src/sqlite.rs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/postgres_guard.rs' +require_text docs/maintainers/tooling.md 'tools/xtask/src/source_spine.rs' +require_text docs/maintainers/tooling.md 'src/sdks/rust/src/runtime_resources/extension_artifact.rs' +require_text docs/maintainers/tooling.md 'src/sdks/rust/src/runtime_resources/extension_index.rs' +require_text docs/maintainers/tooling.md 'src/sdks/rust/src/runtime_resources/manifest.rs' +require_text docs/maintainers/tooling.md 'src/sdks/rust/src/runtime_resources/package.rs' +require_text docs/maintainers/tooling.md 'src/sdks/rust/src/runtime_resources/static_registry.rs' +require_text docs/maintainers/tooling.md 'src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base/template_clone.rs' +require_text docs/maintainers/tooling.md 'src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/stdio.rs' +require_text docs/maintainers/tooling.md 'src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod/wasix_fs.rs' +require_text docs/maintainers/tooling.md 'tools/policy/check-sdk-mobile-extension-surface.sh' +require_text src/bindings/wasix-rust/tools/check-examples.sh '--target-dir target/oliphaunt-wasix-rust/examples/tauri-sqlx-vanilla/src-tauri' +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh 'oliphaunt_resolve_repo_root' +require_text src/runtimes/liboliphaunt/native/bin/common.sh 'git -C "$script_dir" rev-parse --show-toplevel' +python3 tools/policy/check-final-source-architecture.py --self-test diff --git a/tools/policy/check-repo.sh b/tools/policy/check-repo.sh new file mode 100755 index 00000000..5e7c71f0 --- /dev/null +++ b/tools/policy/check-repo.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" +PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +export PATH + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + echo "run tools/dev/bootstrap-tools.sh to install pinned maintainer tools" >&2 + exit 1 + fi +} + +run tools/policy/check-repo-structure.sh +run tools/policy/check-tooling-stack.sh +run tools/policy/check-docs.sh +run tools/policy/check-release-policy.py +run tools/release/check_release_metadata.py +run tools/policy/check-moon-product-graph.mjs +run tools/policy/check-prek.sh diff --git a/tools/policy/check-rust-lint.sh b/tools/policy/check-rust-lint.sh new file mode 100755 index 00000000..b83217e0 --- /dev/null +++ b/tools/policy/check-rust-lint.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +run tools/policy/check-dependency-invariants.sh +run cargo clippy --workspace --all-targets --locked -- -D warnings diff --git a/tools/policy/check-rust-test-topology.sh b/tools/policy/check-rust-test-topology.sh new file mode 100755 index 00000000..22a418ab --- /dev/null +++ b/tools/policy/check-rust-test-topology.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + printf 'rust test topology check failed: %s\n' "$1" >&2 + exit 1 +} + +require_text() { + local file="$1" + local text="$2" + local message="$3" + + [ -f "$file" ] || fail "missing required file: $file" + grep -Fq "$text" "$file" || fail "$message" +} + +reject_text() { + local file="$1" + local text="$2" + local message="$3" + + [ -f "$file" ] || fail "missing required file: $file" + if grep -Fq "$text" "$file"; then + fail "$message" + fi +} + +require_text .config/nextest.toml '[profile.ci]' \ + "cargo-nextest CI profile must remain configured centrally" +require_text src/sdks/rust/tools/check-sdk.sh 'cargo test -p oliphaunt --doc --locked' \ + "Rust SDK doctests must run in the Rust SDK product test task" +require_text src/sdks/rust/tools/check-sdk.sh 'native_runtime_lock cargo nextest run -p oliphaunt --locked --profile ci --no-tests=fail --test-threads=1' \ + "Rust SDK executable tests must run through native-runtime-locked cargo-nextest" +require_text src/bindings/wasix-rust/tools/check-unit.sh 'cargo test -p oliphaunt-wasix --doc --locked' \ + "WASIX Rust doctests must run in the WASIX Rust product test task" +require_text src/bindings/wasix-rust/tools/check-unit.sh 'cargo nextest run -p oliphaunt-wasix --locked --profile ci --no-default-features --lib --no-tests=fail --test-threads=1' \ + "WASIX Rust unit tests must run through cargo-nextest in the WASIX Rust product test task" +require_text src/runtimes/broker/moon.yml 'command: "cargo test -p oliphaunt-broker --locked"' \ + "Broker runtime tests must be owned by the broker runtime product task" +require_text tools/xtask/moon.yml 'command: "cargo check -p xtask --features template-runner --locked"' \ + "xtask template-runner validation must stay in xtask:test" + +require_text moon.yml 'check-rust-test-topology.sh' \ + "repo:test must run the topology policy script" +reject_text moon.yml 'command: "tools/policy/check-rust-tests.sh"' \ + "repo:test must not call the retired broad Cargo test wrapper" +reject_text moon.yml 'cargo test --doc --workspace' \ + "root Moon tasks must not run all workspace doctests inside :test" +reject_text moon.yml 'cargo check --workspace --no-default-features' \ + "root Moon tasks must not run broad workspace Cargo checks inside :test" + +printf 'rust test topology checks passed\n' diff --git a/tools/policy/check-sdk-doc-examples.mjs b/tools/policy/check-sdk-doc-examples.mjs new file mode 100755 index 00000000..352d277a --- /dev/null +++ b/tools/policy/check-sdk-doc-examples.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); + +const readmes = [ + { + sdk: 'rust', + path: 'src/sdks/rust/README.md', + languages: new Set(['rust']), + }, + { + sdk: 'swift', + path: 'src/sdks/swift/README.md', + languages: new Set(['swift']), + }, + { + sdk: 'kotlin', + path: 'src/sdks/kotlin/README.md', + languages: new Set(['kotlin']), + }, + { + sdk: 'react-native', + path: 'src/sdks/react-native/README.md', + languages: new Set(['ts', 'typescript']), + }, +]; + +const coverageRoots = [ + 'src/sdks/rust/tests', + 'src/sdks/swift/Tests', + 'src/sdks/kotlin/oliphaunt/src', + 'src/sdks/react-native/src/__tests__', +]; + +const markerPattern = /liboliphaunt-doc-example:([a-z0-9][a-z0-9_.-]*)/g; + +function readFile(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +function lineNumberAt(source, offset) { + return source.slice(0, offset).split('\n').length; +} + +function previousNonEmptyLine(lines, index) { + for (let i = index - 1; i >= 0; i -= 1) { + if (lines[i].trim().length > 0) { + return {line: lines[i], index: i}; + } + } + return null; +} + +function collectReadmeExamples(spec) { + const source = readFile(spec.path); + const lines = source.split('\n'); + const examples = []; + let inFence = false; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/); + if (!fence) { + continue; + } + if (inFence) { + inFence = false; + continue; + } + inFence = true; + const language = fence[1].toLowerCase(); + if (!spec.languages.has(language)) { + continue; + } + const previous = previousNonEmptyLine(lines, i); + const marker = previous?.line.match(/liboliphaunt-doc-example:([a-z0-9][a-z0-9_.-]*)/); + if (!marker) { + throw new Error( + `${spec.path}:${i + 1} ${language} example must be preceded by a liboliphaunt-doc-example marker`, + ); + } + examples.push({ + id: marker[1], + file: spec.path, + line: i + 1, + language, + }); + } + + const markerIds = new Set(); + for (const match of source.matchAll(markerPattern)) { + const id = match[1]; + markerIds.add(id); + const line = lineNumberAt(source, match.index ?? 0); + const following = lines.slice(line).find(entry => entry.trim().length > 0); + if (!following?.startsWith('```')) { + throw new Error( + `${spec.path}:${line} doc-example marker ${id} must be immediately followed by a fenced code block`, + ); + } + } + + for (const id of markerIds) { + if (!examples.some(example => example.id === id)) { + throw new Error(`${spec.path} marker ${id} did not attach to a tracked code example`); + } + } + + return examples; +} + +function listFiles(dir) { + const fullDir = path.join(root, dir); + if (!fs.existsSync(fullDir)) { + return []; + } + const files = []; + for (const entry of fs.readdirSync(fullDir, {withFileTypes: true})) { + const relative = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(relative)); + } else if (entry.isFile()) { + files.push(relative); + } + } + return files; +} + +function collectCoverageMarkers() { + const markers = new Map(); + for (const file of coverageRoots.flatMap(listFiles)) { + const source = readFile(file); + for (const match of source.matchAll(markerPattern)) { + const id = match[1]; + const entries = markers.get(id) ?? []; + entries.push({ + file, + line: lineNumberAt(source, match.index ?? 0), + }); + markers.set(id, entries); + } + } + return markers; +} + +const examples = readmes.flatMap(collectReadmeExamples); +const seen = new Map(); +for (const example of examples) { + const previous = seen.get(example.id); + if (previous) { + throw new Error( + `duplicate doc-example id ${example.id}: ${previous.file}:${previous.line} and ${example.file}:${example.line}`, + ); + } + seen.set(example.id, example); +} + +const coverage = collectCoverageMarkers(); +for (const example of examples) { + if (!coverage.has(example.id)) { + throw new Error( + `${example.file}:${example.line} doc-example ${example.id} has no SDK test/source coverage marker`, + ); + } +} + +for (const [id, entries] of coverage) { + if (!seen.has(id)) { + const first = entries[0]; + throw new Error(`${first.file}:${first.line} stale SDK doc-example coverage marker ${id}`); + } +} + +console.log(`SDK README example coverage checks passed (${examples.length} examples).`); diff --git a/tools/policy/check-sdk-mobile-extension-surface.sh b/tools/policy/check-sdk-mobile-extension-surface.sh new file mode 100755 index 00000000..ca57f918 --- /dev/null +++ b/tools/policy/check-sdk-mobile-extension-surface.sh @@ -0,0 +1,619 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$script_dir/sdk-check-lib.sh" + +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "schema=oliphaunt-runtime-resources-v1" \ + "Kotlin Android Gradle packaging must emit the shared runtime-resource schema" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "validateRuntimeResourcesSchema" \ + "Kotlin Android Gradle packaging must reject stale runtime-resource schemas before copying app assets" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "mobileStaticRegistryPending=" \ + "Kotlin Android Gradle packaging must emit mobile static-registry metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "sharedPreloadLibraries=" \ + "Kotlin Android Gradle packaging must emit shared-preload metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "nativeModuleStems=" \ + "Kotlin Android Gradle packaging must emit expected native module stems" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "generatedExtensionMetadata.from(layout.projectDirectory.file(\"src/generated/extensions.json\"))" \ + "Kotlin Android Gradle packaging must consume package-local generated extension metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "mobile-release-ready" \ + "Kotlin Android Gradle packaging must reject extensions without release-ready mobile artifacts" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "generatedNativeModuleStem(extension)" \ + "Kotlin Android Gradle packaging must derive native module stems from generated extension metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "cannot select unknown extension" \ + "Kotlin Android split runtime packaging must reject extensions absent from generated metadata" +reject_text src/sdks/kotlin/oliphaunt/build.gradle.kts "?: return extension" \ + "Kotlin Android Gradle packaging must not infer native module stems for unknown extensions" +reject_text src/sdks/kotlin/oliphaunt/build.gradle.kts '"postgis" -> "postgis-3"' \ + "Kotlin Android Gradle packaging must not hardcode PostGIS native module stems" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "mobileStaticRegistrySource=" \ + "Kotlin Android Gradle packaging must emit mobile static-registry source metadata" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "oliphauntAndroidExtensionArchivesDir" \ + "Kotlin Android Gradle packaging must accept prebuilt per-extension archive roots" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "static-registry/archives" \ + "Kotlin Android Gradle packaging must default to selected archives carried by runtime resources" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts 'requireExtensionAsset(manifest, product, target, "android-static-archive", sqlName)' \ + "Kotlin Android release-asset resolver must download target static archives for selected native-module extensions" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "validateEquivalentAndroidRuntimeAssets" \ + "Kotlin Android release-asset resolver must verify Android runtime extension payloads are ABI-independent" +require_text src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java '"android-static-archive"' \ + "Kotlin Android public Gradle plugin must download target static archives for selected native-module extensions" +require_text src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java "validateEquivalentAndroidRuntimeAssets" \ + "Kotlin Android public Gradle plugin must verify Android runtime extension payloads are ABI-independent" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt "add_library(oliphaunt_extensions SHARED" \ + "Kotlin Android CMake must link a support library from prebuilt static extension archives" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/cpp/CMakeLists.txt "oliphaunt_dependency_archives" \ + "Kotlin Android CMake must link selected mobile static dependency archives" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/cpp/oliphaunt_android_bridge.cpp "liboliphaunt_extensions.so" \ + "Kotlin Android bridge must discover the prebuilt extension support library" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/cpp/oliphaunt_android_bridge.cpp "liboliphaunt_selected_static_extensions" \ + "Kotlin Android native bridge must register generated static extension rows before open" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "resolveExtensionSelection" \ + "Kotlin Android Gradle packaging must resolve exact extension selections" +require_text src/sdks/kotlin/README.md "Maven Central artifact is the Android SDK and JNI adapter" \ + "Kotlin docs must state that Maven does not implicitly ship liboliphaunt/runtime/extension assets" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt "Available extensions" \ + "Kotlin Android resource parser must validate exact extension availability" +require_text src/sdks/react-native/android/build.gradle "schema=oliphaunt-runtime-resources-v1" \ + "React Native Android Gradle packaging must emit the shared runtime-resource schema for the Kotlin SDK" +require_text src/sdks/react-native/android/build.gradle "validateRuntimeResourcesSchema" \ + "React Native Android Gradle packaging must reject stale runtime-resource schemas before copying app assets" +require_text src/sdks/react-native/android/build.gradle "mobileStaticRegistryPending=" \ + "React Native Android Gradle packaging must emit mobile static-registry metadata" +require_text src/sdks/react-native/android/build.gradle "sharedPreloadLibraries=" \ + "React Native Android Gradle packaging must emit shared-preload metadata" +require_text src/sdks/react-native/android/build.gradle "nativeModuleStems=" \ + "React Native Android Gradle packaging must emit expected native module stems" +require_text src/sdks/react-native/android/build.gradle "generatedExtensionMetadata.from(file(\"../src/generated/extensions.json\"))" \ + "React Native Android Gradle packaging must consume package-local generated extension metadata" +require_text src/sdks/react-native/android/build.gradle "mobile-release-ready" \ + "React Native Android Gradle packaging must reject extensions without release-ready mobile artifacts" +require_text src/sdks/react-native/app.plugin.js "MOBILE_RELEASE_READY_EXTENSION_SQL_NAMES" \ + "React Native config plugin must reject extensions without release-ready mobile artifacts" +require_text src/sdks/react-native/android/build.gradle "generatedNativeModuleStem(extension, metadataBySqlName)" \ + "React Native Android Gradle packaging must derive native module stems from generated extension metadata" +require_text src/sdks/react-native/android/build.gradle "cannot select unknown extension" \ + "React Native Android split runtime packaging must reject extensions absent from generated metadata" +reject_text src/sdks/react-native/android/build.gradle " return extension" \ + "React Native Android Gradle packaging must not infer native module stems for unknown extensions" +reject_text src/sdks/react-native/android/build.gradle "return \"postgis-3\"" \ + "React Native Android Gradle packaging must not hardcode PostGIS native module stems" +require_text src/sdks/react-native/android/build.gradle "mobileStaticRegistrySource=" \ + "React Native Android Gradle packaging must emit mobile static-registry source metadata" +require_text src/sdks/react-native/android/build.gradle "oliphauntAndroidExtensionArchivesDir" \ + "React Native Android Gradle packaging must accept prebuilt per-extension archive roots" +require_text src/sdks/react-native/android/build.gradle "static-registry/archives" \ + "React Native Android Gradle packaging must default to selected archives carried by runtime resources" +require_text src/sdks/react-native/android/src/main/cpp/CMakeLists.txt "add_library(oliphaunt_extensions SHARED" \ + "React Native Android CMake must link a support library from prebuilt static extension archives" +require_text src/sdks/react-native/android/src/main/cpp/CMakeLists.txt "oliphaunt_dependency_archives" \ + "React Native Android CMake must link selected mobile static dependency archives" +require_text src/sdks/react-native/android/build.gradle "resolveExtensionSelection" \ + "React Native Android Gradle packaging must resolve exact extension selections" +require_text src/sdks/react-native/README.md "published React Native artifact does not carry base \`liboliphaunt\`" \ + "React Native docs must state that the JS package does not implicitly ship native runtime or extension assets" +require_text src/sdks/react-native/app.plugin.js "pod 'Oliphaunt', :podspec => File.join(oliphaunt_podspecs_path, 'Oliphaunt.podspec')" \ + "React Native iOS config plugin must resolve the Swift SDK through npm-shipped podspec shims" +if [ -f src/sdks/swift/COliphaunt.podspec ] || [ -f src/sdks/swift/Oliphaunt.podspec ]; then + echo "Swift SDK must be SwiftPM-only; React Native podspec shims live in the RN npm package" >&2 + exit 1 +fi +require_file src/sdks/react-native/ios/podspecs/COliphaunt.podspec +require_file src/sdks/react-native/ios/podspecs/Oliphaunt.podspec +require_text src/sdks/react-native/ios/podspecs/COliphaunt.podspec "src/sdks/swift/Sources/COliphaunt" \ + "React Native C podspec shim must resolve the released Swift SDK C bridge source" +require_text src/sdks/react-native/ios/podspecs/Oliphaunt.podspec "s.dependency \"COliphaunt\", swift_sdk_version" \ + "React Native Swift podspec shim must depend on the exact C bridge version" +reject_text src/sdks/react-native/package.json "prepare-apple-vendor" \ + "React Native npm package must not generate a vendored Swift SDK source slice" +require_text Package.swift "SwiftPM is the public Apple SDK entrypoint" \ + "SwiftPM must be the public Apple SDK entrypoint" +require_text src/sdks/swift/README.md "CocoaPods trunk is not a release path" \ + "Swift docs must not depend on CocoaPods trunk for public Apple SDK releases" +require_text src/sdks/swift/README.md "liboliphaunt-native-v" \ + "Swift docs must pair SwiftPM source tags with compatible liboliphaunt release assets" +require_text src/sdks/swift/README.md "Optional extension" \ + "Swift docs must keep optional extension XCFrameworks exact-selected instead of bundled by default" +require_text src/sdks/react-native/OliphauntReactNative.podspec "ios/generated/static-registry/*.c" \ + "React Native iOS CocoaPods packaging must compile generated exact-extension static registry glue" +require_text src/sdks/react-native/tools/mobile-extension-runtime.sh "liboliphaunt_extension_*.xcframework" \ + "React Native iOS prebuilt extension unpacking must inspect exact extension XCFramework inputs" +require_text src/sdks/react-native/tools/mobile-extension-runtime.sh 'comm -23 "$expected_file" "$actual_file"' \ + "React Native iOS prebuilt extension unpacking must fail when a selected XCFramework is missing" +require_text src/sdks/react-native/tools/mobile-extension-runtime.sh "unpacked unselected XCFrameworks" \ + "React Native iOS prebuilt extension unpacking must reject unselected extension XCFrameworks" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "validate_ios_static_extension_linkage" \ + "React Native iOS build runner must prove selected static extension frameworks were linked" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "build-only static-registry source" \ + "React Native iOS build runner must reject build-only static-registry source in app resources" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "liboliphaunt_extension_[A-Za-z0-9_]+" \ + "React Native iOS build runner must inspect selected extension framework link inputs" +require_text tools/release/check_staged_artifacts.py "check_ios_prebuilt_extension_linkage" \ + "staged mobile artifact checks must verify iOS selected extension link evidence" +require_text tools/release/check_staged_artifacts.py "static-registry/oliphaunt_static_registry.c" \ + "staged mobile artifact checks must reject build-only static-registry source in iOS app resources" +require_text tools/release/check_staged_artifacts.py "liboliphaunt_extension_[A-Za-z0-9_]+" \ + "staged mobile artifact checks must reject unselected iOS extension framework link inputs" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift "available extensions" \ + "Swift resource parser must validate exact extension availability" +require_text src/sdks/swift/Sources/COliphaunt/bridge.c "liboliphaunt_selected_static_extensions" \ + "Swift native bridge must register generated static extension rows before open" +require_text src/sdks/rust/src/runtime_resources.rs "oliphaunt-static-registry-v1" \ + "Rust runtime-resource code must generate the platform static-registry artifact schema" +require_text src/sdks/rust/src/runtime_resources/static_registry.rs "OLIPHAUNT_STATIC_OPTIONAL" \ + "Rust runtime-resource static registry must keep optional extension init hooks distinct from required entry points" +reject_text src/sdks/rust/src/runtime_resources/static_registry.rs "OLIPHAUNT_STATIC_WEAK" \ + "Rust runtime-resource static registry must not make selected extension entry points weak" +require_text src/sdks/rust/src/extension.rs "RELEASE_READY_PG18_SUPPORTED" \ + "Rust SDK must expose the release-ready exact extension catalog" +require_text src/sdks/rust/src/extension.rs "MOBILE_RELEASE_READY_PG18_SUPPORTED" \ + "Rust SDK must expose the release-ready mobile extension catalog" +require_text src/sdks/rust/src/extension.rs "by_release_ready_sql_name" \ + "Rust runtime-resource CLI must resolve public exact extension selections through the release-ready catalog" +require_text src/sdks/rust/src/extension.rs "mod generated_extensions" \ + "Rust SDK extension metadata must be generated from the shared extension catalog" +require_text src/sdks/rust/src/extension.rs "pub use generated_extensions::Extension" \ + "Rust SDK must expose the generated typed Extension enum" +require_text src/sdks/rust/src/extension.rs "generated_extensions::RELEASE_READY_PG18_SUPPORTED" \ + "Rust SDK release-ready extension catalog must delegate to generated metadata" +require_text src/sdks/rust/src/extension.rs "generated_extensions::NATIVE_EXTENSION_MANIFEST" \ + "Rust SDK native extension manifest must delegate to generated metadata" +require_text src/sdks/rust/src/extension.rs "generated_extensions::extension_data_files" \ + "Rust SDK extension data files must delegate to generated metadata" +require_text src/sdks/rust/src/generated/extensions.rs "@generated by src/extensions/tools/check-extension-model.py" \ + "Rust SDK generated extension metadata must record its generator" +require_text src/sdks/rust/src/generated/extensions.rs "pub enum Extension" \ + "Rust SDK generated extension metadata must own the public Extension enum" +require_text src/extensions/generated/sdk/rust.json "\"mobile-release-ready\"" \ + "generated SDK metadata must expose mobile artifact readiness" +require_text src/extensions/generated/sdk/rust.json "\"target-status\"" \ + "generated SDK metadata must expose target-family status" +require_text src/sdks/rust/src/generated/extensions.rs "Extension::Earthdistance => &[Extension::Cube]" \ + "Rust SDK generated extension metadata must carry exact extension dependencies" +require_text src/sdks/rust/src/generated/extensions.rs "Extension::PgSearch => Some(\"pg_search\")" \ + "Rust SDK generated extension metadata must carry shared-preload requirements" +require_text src/sdks/rust/src/generated/extensions.rs "Extension::PgSearch => ExtensionArtifactPolicy::External" \ + "Rust SDK generated extension metadata must carry external artifact policies" +require_text src/sdks/rust/src/generated/extensions.rs "contrib/postgis-3.6/spatial_ref_sys.sql" \ + "Rust SDK generated extension metadata must carry complex extension data files" +require_text src/extensions/external/postgis/recipe.toml "extension_sql_file_prefixes" \ + "PostGIS helper SQL file ownership must live in recipe metadata" +require_text src/extensions/external/postgis/recipe.toml "[[runtime_environment]]" \ + "PostGIS runtime environment ownership must live in recipe metadata" +require_text src/extensions/external/pgtap/recipe.toml "extension_sql_file_prefixes" \ + "pgTAP helper SQL file ownership must live in recipe metadata" +require_text src/sdks/rust/src/generated/extensions.rs "\"postgis_comments\"" \ + "Rust SDK generated extension metadata must carry PostGIS helper SQL prefixes" +require_text src/sdks/rust/src/generated/extensions.rs "\"postgis_proc_set_search_path\"" \ + "Rust SDK generated extension metadata must carry PostGIS helper SQL prefixes" +require_text src/sdks/rust/src/generated/extensions.rs "\"rtpostgis\"" \ + "Rust SDK generated extension metadata must carry PostGIS helper SQL prefixes" +require_text src/sdks/rust/src/generated/extensions.rs "\"pgtap-core\"" \ + "Rust SDK generated extension metadata must carry pgTAP helper SQL prefixes" +require_text src/sdks/rust/src/generated/extensions.rs "\"pgtap-schema\"" \ + "Rust SDK generated extension metadata must carry pgTAP helper SQL prefixes" +require_text src/sdks/rust/src/generated/extensions.rs "\"uninstall_pgtap.sql\"" \ + "Rust SDK generated extension metadata must carry pgTAP helper SQL filenames" +require_text src/sdks/rust/src/generated/extensions.rs "Extension::Postgis => &[" \ + "Rust SDK generated extension metadata must carry PostGIS runtime environment" +require_text src/sdks/rust/src/generated/extensions.rs "name: \"PROJ_DATA\"" \ + "Rust SDK generated extension metadata must carry PostGIS PROJ_DATA environment" +require_text src/sdks/rust/src/generated/extensions.rs "relative_path: \"share/postgresql/proj\"" \ + "Rust SDK generated extension metadata must carry the PostGIS PROJ data path" +require_text src/sdks/rust/src/generated/extensions.rs "required_file: \"proj.db\"" \ + "Rust SDK generated extension metadata must gate PostGIS PROJ_DATA on proj.db" +require_text src/sdks/rust/src/server.rs "configure_extension_runtime_env" \ + "Rust native server must configure selected extension runtime environment generically" +reject_text src/sdks/rust/src/extension.rs "Self::Earthdistance => &[Self::Cube]" \ + "Rust SDK must not reintroduce hand-written extension dependency tables" +reject_text src/sdks/rust/src/extension.rs "Self::PgSearch => Some(\"pg_search\")" \ + "Rust SDK must not reintroduce hand-written shared-preload tables" +reject_text src/sdks/rust/src/extension.rs "Self::Graph => ExtensionArtifactPolicy::External" \ + "Rust SDK must not reintroduce hand-written external artifact policies" +reject_text src/sdks/rust/src/extension.rs "contrib/postgis-3.6/legacy.sql" \ + "Rust SDK must not reintroduce hand-written complex extension data files" +reject_text src/sdks/rust/src/extension.rs "postgis_comments" \ + "Rust SDK must not reintroduce hand-written PostGIS helper SQL prefixes" +reject_text src/sdks/rust/src/extension.rs "postgis_proc_set_search_path" \ + "Rust SDK must not reintroduce hand-written PostGIS helper SQL prefixes" +reject_text src/sdks/rust/src/extension.rs "rtpostgis" \ + "Rust SDK must not reintroduce hand-written PostGIS helper SQL prefixes" +reject_text src/sdks/rust/src/extension.rs "uninstall_postgis" \ + "Rust SDK must not reintroduce hand-written PostGIS helper SQL filenames" +reject_text src/sdks/rust/src/extension.rs "pgtap-core" \ + "Rust SDK must not reintroduce hand-written pgTAP helper SQL prefixes" +reject_text src/sdks/rust/src/extension.rs "pgtap-schema" \ + "Rust SDK must not reintroduce hand-written pgTAP helper SQL prefixes" +reject_text src/sdks/rust/src/extension.rs "uninstall_pgtap" \ + "Rust SDK must not reintroduce hand-written pgTAP helper SQL filenames" +reject_text src/sdks/rust/src/server.rs "configure_postgis_proj_data_env" \ + "Rust native server must not reintroduce PostGIS-specific environment wiring" +reject_text src/sdks/rust/src/extension.rs "pub const NATIVE_EXTENSION_MANIFEST: &[ExtensionManifestEntry] = &[" \ + "Rust SDK must not reintroduce a hand-written native extension manifest" +reject_text src/sdks/rust/src/extension.rs "pub enum Extension {" \ + "Rust SDK must not reintroduce a hand-written public Extension enum" +require_text src/sdks/rust/src/bin/package_resources.rs "--list-extensions" \ + "Rust runtime-resource CLI must list the prebuilt exact extension catalog without requiring a native build" +require_text src/sdks/rust/src/bin/package_resources.rs "--prebuilt-extension" \ + "Rust runtime-resource CLI must accept exact prebuilt third-party extension artifacts" +require_text src/sdks/rust/src/bin/extension_artifact.rs "oliphaunt-extension-artifact" \ + "Rust SDK must expose a producer CLI for exact prebuilt extension artifacts" +require_text src/sdks/rust/src/bin/extension_artifact.rs "--native-module-file" \ + "Prebuilt extension artifact producer must accept target-specific native module filenames" +require_text src/sdks/rust/src/runtime_resources/extension_artifact.rs "create_prebuilt_extension_artifact" \ + "Rust runtime-resource code must create exact prebuilt extension artifacts from built runtime files" +require_text src/sdks/rust/src/runtime_resources/extension_artifact.rs "nativeModuleFile" \ + "Prebuilt extension artifacts must record target-specific native module filenames" +require_text src/sdks/rust/src/runtime_resources.rs "oliphaunt-extension-artifact-index-v1" \ + "Rust runtime-resource code must define the exact prebuilt extension artifact index schema" +require_text src/sdks/rust/src/runtime_resources/extension_index.rs "resolve_prebuilt_extension_artifacts_from_indexes" \ + "Rust runtime-resource code must resolve exact external extension names through verified artifact indexes" +require_text src/sdks/rust/src/runtime_resources/extension_index.rs "create_prebuilt_extension_artifact_index" \ + "Rust runtime-resource code must create exact prebuilt extension artifact indexes from validated artifacts" +require_text src/sdks/rust/src/runtime_resources/extension_index.rs "has sha256" \ + "Prebuilt extension artifact indexes must verify SHA-256 before consumption" +require_text src/sdks/rust/src/runtime_resources/extension_index.rs "artifact_cache_dir" \ + "Prebuilt extension artifact indexes must support verified artifact caches for release URL rows" +require_text src/sdks/rust/src/runtime_resources/extension_index.rs "extension-download" \ + "Prebuilt extension artifact HTTPS downloads must stay an explicit packaging-tool feature" +require_text src/sdks/rust/src/runtime_resources.rs "oliphaunt-extension-artifact-index-signature-v1" \ + "Prebuilt extension artifact indexes must have a signed release consumption path" +require_text src/sdks/rust/src/runtime_resources.rs "NativeExtensionArtifactIndexTrustRoot" \ + "Prebuilt extension artifact indexes must verify against explicit trusted publisher keys" +require_text src/sdks/rust/src/runtime_resources/extension_index.rs "list_prebuilt_extension_artifact_index_catalog" \ + "Prebuilt extension artifact indexes must expose exact external catalog discovery" +require_text src/sdks/rust/src/runtime_resources.rs "NativeExtensionArtifactIndexCatalogEntry" \ + "Prebuilt extension artifact indexes must model exact external catalog metadata" +require_text src/sdks/rust/src/runtime_resources/extension_index.rs "extension-signing" \ + "Prebuilt extension artifact index signing must stay an explicit packaging-tool feature" +require_text src/sdks/rust/src/bin/extension_index.rs "oliphaunt-extension-index" \ + "Rust SDK must expose a producer CLI for exact prebuilt extension artifact indexes" +require_text src/sdks/rust/src/bin/extension_index.rs "--base-url" \ + "Rust SDK extension index producer must record release artifact URLs without changing exact selection semantics" +require_text src/sdks/rust/src/bin/extension_index.rs "--signing-key-file" \ + "Rust SDK extension index producer must sign release indexes from an explicit key file" +require_text src/sdks/rust/src/bin/package_resources.rs "--extension-index" \ + "Rust runtime-resource CLI must resolve external exact extension names through artifact indexes" +require_text src/sdks/rust/src/bin/package_resources.rs "--extension-cache" \ + "Rust runtime-resource CLI must download URL-backed indexed artifacts into an explicit verified cache" +require_text src/sdks/rust/src/bin/package_resources.rs "--trusted-extension-index-key-file" \ + "Rust runtime-resource CLI must require signed external extension indexes when a trust root is supplied" +require_text src/sdks/rust/src/bin/package_resources.rs "signed external index metadata is listed" \ + "Rust runtime-resource CLI must list external exact-extension index metadata without artifact downloads" +require_text src/sdks/rust/src/bin/package_resources.rs "mobile_static_archive_targets" \ + "Rust runtime-resource CLI must expose carried mobile static archive targets for exact external extensions" +require_text src/sdks/rust/src/bin/package_resources.rs ".tar.zst" \ + "Rust runtime-resource CLI help must advertise portable compressed prebuilt extension archives" +require_text src/sdks/rust/src/runtime_resources.rs "oliphaunt-extension-artifact-v1" \ + "Rust runtime-resource code must define the exact prebuilt extension artifact schema" +require_text src/sdks/rust/src/runtime_resources/extension_artifact.rs "mobileStaticArchives" \ + "Rust extension artifact schema must carry selected mobile static archives inside exact artifacts" +require_text src/sdks/rust/src/runtime_resources.rs "static-registry/archives" \ + "Rust runtime resources must copy selected prebuilt mobile static archives into platform SDK resources" +require_text src/sdks/rust/src/runtime_resources/extension_artifact.rs "archive_is_tar_zst" \ + "Rust runtime-resource code must accept .tar.zst prebuilt extension artifacts" +require_text src/sdks/rust/src/runtime_resources/extension_artifact.rs "must be a regular file or directory" \ + "Rust runtime-resource code must reject unsafe prebuilt extension archive entry types" +require_text src/sdks/rust/src/runtime_resources.rs "prebuilt_extension_artifact_can_override_builtin_artifact_payload" \ + "Concrete prebuilt extension artifacts must be able to supply the exact release payload for built-in exact-extension names" +require_text src/sdks/rust/src/runtime_resources.rs "unselected files inside a prebuilt extension artifact must not leak" \ + "Rust runtime-resource tests must prove extra files inside prebuilt extension artifacts do not leak" +require_text src/sdks/rust/src/runtime_resources/static_registry.rs "does not have release-ready iOS/Android static artifacts" \ + "Rust runtime-resource code must reject mobile static completion claims for extensions without prebuilt mobile artifacts" +require_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "static-extensions.tsv" \ + "liboliphaunt mobile static extension specs must be read from generated extension metadata" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'sql-name\tnative-module-stem\tsource-kind\tsource-rel\tmobile-static-dependencies\tios-static-dependencies\tandroid-static-dependencies\tinclude-dependencies\tinclude-dirs\tcflags\thash-source-dependencies\tios-hash-source-dependencies\tandroid-hash-source-dependencies\thash-dirs\tsource-files\tsource-recursive-dirs')" \ + "generated mobile static extension specs must expose dependency/include/cflag/hash metadata columns" +require_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "oliphaunt_mobile_static_extension_dependency_field" \ + "liboliphaunt mobile static extension dependency selection must read generated dependency fields" +require_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "oliphaunt_mobile_static_extension_hash_source_dependencies" \ + "liboliphaunt mobile static extension hashing must read generated hash dependency fields" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'hstore\thstore\tcontrib\tcontrib/hstore')" \ + "generated mobile static extension specs must include release-ready hstore artifacts" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'pg_ivm\tpg_ivm\texternal\ttarget/oliphaunt-sources/checkouts/pg_ivm')" \ + "generated mobile static extension specs must include pg_ivm build metadata without claiming mobile release readiness" +require_text src/extensions/generated/mobile/static-extensions.tsv "createas.c,matview.c,pg_ivm.c,ruleutils.c,subselect.c" \ + "generated mobile static extension specs must record pg_ivm's explicit source file set" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "pg_ivm)" \ + "liboliphaunt mobile static extension specs must not hardcode pg_ivm source files" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'pg_hashids\tpg_hashids\texternal\ttarget/oliphaunt-sources/checkouts/pg_hashids')" \ + "generated mobile static extension specs must include release-ready pg_hashids artifacts" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'pgcrypto\tpgcrypto\tcontrib\tcontrib/pgcrypto\topenssl\topenssl\topenssl\topenssl\t\t\topenssl\topenssl\topenssl')" \ + "generated mobile static extension specs must record pgcrypto's OpenSSL link/include/hash metadata" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "pgcrypto) printf '%s\\n' openssl" \ + "liboliphaunt mobile static extension specs must not hardcode pgcrypto dependency selection" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "openssl/include" \ + "liboliphaunt mobile static extension specs must not hardcode pgcrypto include paths" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "checkouts/openssl" \ + "liboliphaunt mobile static extension specs must not hardcode pgcrypto hash source paths" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'uuid-ossp\tuuid-ossp\tcontrib\tcontrib/uuid-ossp')" \ + "generated mobile static extension specs must include uuid-ossp's buildable module path" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'uuid-ossp\tuuid-ossp\tcontrib\tcontrib/uuid-ossp\tuuid\tuuid\tuuid\t\tsrc/runtimes/liboliphaunt/native/portable-uuid/include\t-DHAVE_UUID_E2FS=1,-DHAVE_UUID_UUID_H=1\t\t\t\tsrc/runtimes/liboliphaunt/native/portable-uuid')" \ + "generated mobile static extension specs must record uuid-ossp's portable UUID link/include/cflag/hash metadata" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "uuid-ossp) printf '%s\\n' uuid" \ + "liboliphaunt mobile static extension specs must not hardcode uuid-ossp dependency selection" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "HAVE_UUID_E2FS" \ + "liboliphaunt mobile static extension specs must not hardcode uuid-ossp C flags" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'postgis\tpostgis-3\texternal\ttarget/oliphaunt-sources/checkouts/postgis\tgeos,geos-c,json-c,libcharset,libiconv,libxml2,proj,sqlite\tgeos,geos-c,json-c,libxml2,proj,sqlite\tgeos,geos-c,json-c,libcharset,libiconv,libxml2,proj,sqlite\t\t\t\tgeos,json-c,libiconv,libxml2,proj,sqlite\tgeos,json-c,libxml2,proj,sqlite\tgeos,json-c,libiconv,libxml2,proj,sqlite')" \ + "generated mobile static extension specs must record PostGIS generic/iOS/Android static and hash dependency sets" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "for dependency_dir in geos proj sqlite json-c libxml2 libiconv" \ + "liboliphaunt mobile static extension specs must not hardcode PostGIS hash dependency sets" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 '"libxml2s.lib", "libxml2.lib", "xml2.lib"' \ + "Windows PostGIS dependency probes must accept libxml2's static CMake import library name" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh 'pg_extension_cflags="$native_cflags $icu_cflags"' \ + "iOS simulator mobile static extension compiles must inherit PostgreSQL ICU include flags" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh 'pg_extension_cflags="$native_cflags $icu_cflags"' \ + "iOS device mobile static extension compiles must inherit PostgreSQL ICU include flags" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh 'pg_extension_cflags="$native_cflags $postgres_cppflags $icu_cflags"' \ + "Android mobile static extension compiles must inherit PostgreSQL ICU include flags" +require_text src/runtimes/liboliphaunt/native/bin/mobile-postgis-extensions.sh "pg_finfo_%s_difference" \ + "PostGIS mobile static builds must map token-pasted legacy pg_finfo aliases" +require_text src/extensions/artifacts/native/tools/package-release-assets.sh "pg_finfo_\${static_prefix}_difference" \ + "PostGIS release artifacts must publish token-pasted legacy pg_finfo aliases" +require_text src/extensions/artifacts/native/tools/package-release-assets.sh 'bun "$packager" list-catalog' \ + "Native extension release packaging must derive the exact extension catalog through neutral Bun tooling" +require_text src/extensions/artifacts/native/tools/package-release-assets.sh '"$packager" create-artifact' \ + "Native extension release packaging must create artifacts through neutral Bun tooling" +require_text src/extensions/artifacts/native/tools/extension-artifact-packager.mjs "packageLayout=oliphaunt-extension-artifact-v1" \ + "Native extension Bun packaging must emit the shared exact-extension artifact schema" +reject_text src/extensions/artifacts/native/tools/package-release-assets.sh "cargo run -p oliphaunt" \ + "Native extension release packaging must not piggyback on Rust SDK resource binaries" +require_text src/extensions/artifacts/native/tools/package-release-assets.sh 'mobile_dependency_args[@]+"${mobile_dependency_args[@]}"' \ + "Native extension release packaging must guard empty mobile dependency arrays under Bash strict mode" +require_text src/extensions/artifacts/native/tools/package-release-assets.sh 'extra_args[@]+"${extra_args[@]}"' \ + "Native extension release packaging must guard empty mobile artifact argument arrays under Bash strict mode" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh 'selected_dependencies[@]+"${selected_dependencies[@]}"' \ + "iOS extension XCFramework packaging must guard empty dependency arrays under Bash strict mode" +require_text src/sdks/react-native/tools/expo-runner-runtime-resources.sh 'optional_data_excludes[@]+"${optional_data_excludes[@]}"' \ + "React Native mobile runtime resource packaging must guard empty optional data exclude arrays under Bash strict mode" +require_text src/sdks/react-native/tools/expo-runner-workspace.sh 'react_native_package_extra_excludes[@]+"${react_native_package_extra_excludes[@]}"' \ + "React Native package workspace staging must guard empty extra exclude arrays under Bash strict mode" +reject_text src/sdks/react-native/tools/expo-ios-runner.sh "mapfile" \ + "iOS mobile app runner must stay compatible with macOS Bash 3.2" +reject_text src/sdks/react-native/tools/expo-ios-runner.sh "readarray" \ + "iOS mobile app runner must stay compatible with macOS Bash 3.2" +require_text src/sdks/react-native/tools/expo-runner-workspace.sh "ensure_mobile_runtime_tool_permissions" \ + "React Native mobile app runners must repair executable bits on artifact-downloaded runtime tools before invoking initdb" +require_text src/sdks/react-native/tools/expo-android-runner.sh 'ensure_mobile_runtime_tool_permissions "$runtime_source"' \ + "Android mobile app runner must repair executable bits on downloaded native runtime tools" +require_text src/sdks/react-native/tools/expo-ios-runner.sh 'ensure_mobile_runtime_tool_permissions "$runtime_source"' \ + "iOS mobile app runner must repair executable bits on downloaded native runtime tools" +require_text src/sdks/rust/src/liboliphaunt/root/runtime/install.rs "ensure_runtime_tool_executable" \ + "Rust runtime resource installer must not preserve non-executable artifact modes for PostgreSQL tools" +require_text src/sdks/react-native/tools/expo-android-runner.sh 'return 0 # exact-extension packages may provide the static registry source.' \ + "Android mobile static registry lookup must not abort when exact-extension packages provide the registry source" +require_text src/sdks/react-native/tools/expo-ios-runner.sh 'return 0 # exact-extension packages may provide the static registry source.' \ + "iOS mobile static registry lookup must not abort when exact-extension packages provide the registry source" +uuid_ossp_wasix_contrib_row="$(printf 'uuid_ossp\tuuid-ossp\tuuid-ossp\tuuid-ossp.so\textensions/uuid-ossp.tar.zst\ttrue')" +require_text src/extensions/generated/contrib-build.tsv "$uuid_ossp_wasix_contrib_row" \ + "WASIX contrib build plan must build stable uuid-ossp after smoke evidence exists" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'pg_textsearch\tpg_textsearch\texternal\ttarget/oliphaunt-sources/checkouts/pg_textsearch')" \ + "generated mobile static extension specs must include pg_textsearch build metadata without claiming mobile release readiness" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'pg_textsearch\tpg_textsearch\texternal\ttarget/oliphaunt-sources/checkouts/pg_textsearch\t\t\t\t\tsource:src\t\t\t\t\t\t\tsrc')" \ + "generated mobile static extension specs must record pg_textsearch recursive source and include layout" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "pg_textsearch)" \ + "liboliphaunt mobile static extension specs must not hardcode pg_textsearch source layout" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'pg_uuidv7\tpg_uuidv7\texternal\ttarget/oliphaunt-sources/checkouts/pg_uuidv7')" \ + "generated mobile static extension specs must include release-ready pg_uuidv7 artifacts" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 "oliphaunt_pg_uuidv7_clock_gettime" \ + "Windows native exact-extension producer must shim pg_uuidv7 CLOCK_REALTIME through PostgreSQL portable timestamps" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 "extern PGDLLEXPORT Datum tp_handler(PG_FUNCTION_ARGS);" \ + "Windows native exact-extension producer must align pg_textsearch tp_handler linkage with PG_FUNCTION_INFO_V1" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 "USE_CRT_DLL=1 NO_TCL=1 LDFLAGS=" \ + "Windows PostGIS SQLite dependency build must avoid stale SQLite MSVC CRT linker suppression" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 "cmake @cmakeArgs" \ + "Windows PostGIS CMake dependency wrapper must not splat PowerShell's automatic args variable" +require_text src/extensions/generated/mobile/static-extensions.tsv "$(printf 'vector\tvector\texternal\ttarget/oliphaunt-sources/checkouts/pgvector')" \ + "generated mobile static extension specs must resolve pgvector sources by checkout name, not SQL name" +require_text src/extensions/generated/pgxs-build.tsv "$(printf 'vector\tvector\ttarget/oliphaunt-sources/checkouts/pgvector\tvector.so')" \ + "native PGXS build plan must map exact vector artifact builds to the pgvector checkout" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh "pgxs_extension_source_rel" \ + "macOS native PGXS builder must resolve external source checkouts from generated build-plan metadata" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh 'BE_DLLLIBS=$be_dllibs -lm' \ + "macOS native PGXS builder must keep libm extensions on the Darwin bundle-loader link path" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh "pgxs_extension_source_rel" \ + "Linux native PGXS builder must resolve external source checkouts from generated build-plan metadata" +reject_text src/runtimes/liboliphaunt/native/bin/mobile-static-extensions.sh "hstore|hstore|contrib" \ + "liboliphaunt mobile static extension specs must not reintroduce a handwritten shell case table" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh "mobile-static-extensions.sh" \ + "iOS simulator build must use the shared mobile static extension artifact specs" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh 'object="$object_dir/${source_rel%.c}.o"' \ + "iOS simulator mobile static extension builds must preserve nested source paths instead of colliding basenames" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh "liboliphaunt_extension_\$stem.a" \ + "iOS simulator build must emit per-extension static archives for selected mobile modules" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh "build_openssl_dependency" \ + "iOS simulator build must build selected mobile static dependency archives" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh "build_uuid_dependency" \ + "iOS simulator build must build uuid-ossp's portable UUID dependency archive" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh "mobile-static-extensions.sh" \ + "iOS device build must use the shared mobile static extension artifact specs" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh 'object="$object_dir/${source_rel%.c}.o"' \ + "iOS device mobile static extension builds must preserve nested source paths instead of colliding basenames" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh "liboliphaunt_extension_\$stem.a" \ + "iOS device build must emit per-extension static archives for selected mobile modules" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh "build_openssl_dependency" \ + "iOS device build must build selected mobile static dependency archives" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh "build_uuid_dependency" \ + "iOS device build must build uuid-ossp's portable UUID dependency archive" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "OLIPHAUNT_MOBILE_STATIC_EXTENSIONS" \ + "iOS extension XCFramework packaging must consume exact extension selections" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "--runtime-resources" \ + "iOS extension XCFramework packaging must derive selected extensions from Rust runtime resources" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "nativeModuleStems" \ + "iOS extension XCFramework packaging must use exact selected native module stems" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "static_registry_manifest_value" \ + "iOS extension XCFramework packaging must map custom prebuilt module stems back to exact extension names" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "static-registry/archives" \ + "iOS extension XCFramework packaging must prefer selected mobile static archives carried by runtime resources" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "for custom prebuilt extensions, pass --runtime-resources" \ + "iOS extension XCFramework packaging must allow custom prebuilt stems through runtime-resource manifests" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "oliphaunt-ios-extension-xcframeworks-v1" \ + "iOS extension XCFramework packaging must emit an auditable selected-extension manifest" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "liboliphaunt_extension_\$stem.xcframework" \ + "iOS extension XCFramework packaging must emit one framework per selected extension archive" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "dependencyArchives" \ + "iOS extension XCFramework packaging must discover selected mobile dependency archives from runtime resources" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh "liboliphaunt_dependency_\$dependency.xcframework" \ + "iOS extension XCFramework packaging must emit one framework per selected dependency archive" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh "mobile-static-extensions.sh" \ + "Android build must use the shared mobile static extension artifact specs" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh 'object="$object_dir/${source_rel%.c}.o"' \ + "Android mobile static extension builds must preserve nested source paths instead of colliding basenames" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh "liboliphaunt_extension_\$stem.a" \ + "Android build must emit per-extension static archives for selected mobile modules" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh "build_openssl_dependency" \ + "Android build must build selected mobile static dependency archives" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh "build_uuid_dependency" \ + "Android build must build uuid-ossp's portable UUID dependency archive" +require_text tools/policy/check-mobile-extension-artifacts.sh "OLIPHAUNT_MOBILE_EXTENSION_CHECK_EXTENSIONS" \ + "product checks must expose a configurable mobile exact-extension artifact proof" +require_text tools/policy/check-mobile-extension-artifacts.sh "all-mobile" \ + "product checks must validate the full mobile-prebuilt exact-extension catalog by exact SQL name" +require_text tools/policy/check-mobile-extension-artifacts.sh "mobile_prebuilt" \ + "product checks must derive full mobile coverage from the exact extension catalog" +require_text tools/policy/check-mobile-extension-artifacts.sh "liboliphaunt_extension_\$stem.xcframework" \ + "mobile exact-extension artifact proof must validate iOS selected-extension XCFrameworks" +require_text tools/policy/check-mobile-extension-artifacts.sh "liboliphaunt_extensions.so" \ + "mobile exact-extension artifact proof must validate Android selected-extension support libraries" +require_text tools/policy/check-mobile-extension-artifacts.sh "reject_unselected_extension_controls" \ + "mobile exact-extension artifact proof must reject unselected extension assets" +require_text tools/policy/check-mobile-extension-artifacts.sh "mobile_prebuilt_extensions" \ + "mobile exact-extension artifact proof must derive unselected checks from the catalog" +require_text docs/maintainers/extension-packaging-policy.md "The release invariant is strict" \ + "extension docs must state the selected-only app-bundle invariant" +require_text docs/maintainers/extension-packaging-policy.md "The manifest records exact extension names only" \ + "extension docs must make exact extension selection the only extension selection model" +require_text docs/maintainers/extension-packaging-policy.md "release readiness is target-specific" \ + "extension docs must document target-specific release readiness" +require_text docs/maintainers/extension-packaging-policy.md "public selection surface may advertise only the exact extensions" \ + "extension docs must document the target-specific public selection invariant" +require_text docs/maintainers/extension-packaging-policy.md "Apache AGE" \ + "extension docs must record Oliphaunt-listed PG18 compatibility blockers separately from release-ready parity" +require_text docs/maintainers/extension-packaging-policy.md "The only current" \ + "extension docs must make the Oliphaunt-listed PG18 blocker set explicit" +require_text docs/maintainers/extension-packaging-policy.md "\`--with-uuid=e2fs\`" \ + "extension docs must record uuid-ossp's PostgreSQL UUID library requirement" +require_text docs/maintainers/extension-packaging-policy.md "src/runtimes/liboliphaunt/native/portable-uuid" \ + "extension docs must record uuid-ossp's portable UUID dependency source" +require_text docs/maintainers/extension-packaging-policy.md "\`uuid-ossp\` is stable in the" \ + "extension docs must record uuid-ossp release-ready promotion" +require_text docs/maintainers/extension-packaging-policy.md "WASIX side-module builds and packages with matching archive" \ + "extension docs must reflect the verified uuid-ossp WASIX package state" +require_text src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh 'PGSRC/src/include' \ + "WASIX contrib build must compile uuid-ossp's portable UUID dependency with prepared source headers" +require_text src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh "OLIPHAUNT_WASM_SKIP_IMAGE_BUILD" \ + "WASIX runtime support Docker wrapper must be able to reuse a prebuilt image" +require_text src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh "OLIPHAUNT_WASM_SKIP_IMAGE_BUILD" \ + "WASIX initdb Docker wrapper must be able to reuse a prebuilt image" +require_text src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh "OLIPHAUNT_WASM_SKIP_IMAGE_BUILD" \ + "WASIX PGXS Docker wrapper must be able to reuse a prebuilt image" +require_text src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh "OLIPHAUNT_WASM_SKIP_IMAGE_BUILD" \ + "WASIX pg_dump Docker wrapper must be able to reuse a prebuilt image" +require_text src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh "oliphaunt_wasix_run_extension_build_in_docker_if_needed" \ + "WASIX extension build helpers must provide generic Docker delegation" +require_text src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh "OLIPHAUNT_WASM_EXTENSION_BUILD_IN_DOCKER" \ + "generic WASIX extension Docker delegation must set an in-container sentinel" +require_text src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh "oliphaunt_wasix_extension_build_outputs_exist" \ + "WASIX extension build helpers must validate recipe-owned build outputs" +require_text src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh "required_build_files" \ + "WASIX extension build helpers must read required output files from the target recipe" +require_text src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh "required_build_globs" \ + "WASIX extension build helpers must read required output globs from the target recipe" +require_text src/extensions/external/postgis/tools/build_wasix.sh "oliphaunt_wasix_run_extension_build_in_docker_if_needed" \ + "PostGIS WASIX build must delegate through the generic extension Docker helper when the WASIX compiler is unavailable" +require_text src/extensions/external/postgis/tools/build_wasix.sh "oliphaunt_wasix_extension_build_outputs_exist" \ + "PostGIS WASIX build must validate outputs through recipe-owned helper metadata" +require_text src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh "oliphaunt_wasix_apply_wasix_profile configure" \ + "libiconv WASIX configure probes must run without build-time wasm-opt" +require_text src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh "oliphaunt_wasix_apply_wasix_profile build" \ + "libiconv WASIX compilation must restore the selected build profile before make" +require_text docs/maintainers/extension-packaging-policy.md "OpenSSL for \`pgcrypto\`" \ + "extension docs must keep the pgcrypto mobile dependency explicit" +require_text docs/maintainers/extension-packaging-policy.md "PostGIS mobile metadata" \ + "extension docs must mention the PostGIS mobile metadata boundary" +require_text docs/maintainers/extension-packaging-policy.md "remains candidate until the selected iOS and Android static" \ + "extension docs must keep the PostGIS mobile candidate boundary explicit" +require_text src/docs/content/reference/sdk-products.mdx "Extension selection is exact-name only" \ + "SDK docs must present exact extension selection without product-level grouping" +require_text docs/maintainers/extension-packaging-policy.md "no selector expansion, alias, shorthand" \ + "extension docs must reject multi-extension selector expansion" +require_text docs/maintainers/extension-packaging-policy.md "\`mobile_prebuilt=no\` is a hard release" \ + "extension docs must state that app developers should not build missing mobile artifacts from source" +require_text docs/maintainers/extension-packaging-policy.md "--prebuilt-extension vendor/acme_ext.tar.zst" \ + "extension docs must document exact prebuilt third-party extension artifacts" +require_text docs/maintainers/extension-packaging-policy.md "oliphaunt-extension-artifact" \ + "extension docs must document producer tooling for exact prebuilt extension artifacts" +require_text docs/maintainers/extension-packaging-policy.md "oliphaunt-extension-artifact-index-v1" \ + "extension docs must document exact prebuilt extension artifact indexes" +require_text docs/maintainers/extension-packaging-policy.md "oliphaunt-extension-index" \ + "extension docs must document producer tooling for exact prebuilt extension artifact indexes" +require_text docs/maintainers/extension-packaging-policy.md "mobile_prebuilt = true" \ + "extension docs must document mobile-prebuilt metadata in exact extension indexes" +require_text docs/maintainers/extension-packaging-policy.md "mobile_static_archive_targets" \ + "extension docs must document external exact mobile static archive target metadata" +require_text docs/maintainers/extension-packaging-policy.md "mobileStaticArchives=" \ + "extension docs must document carried mobile static archives in exact extension artifacts" +require_text docs/maintainers/extension-packaging-policy.md "--list-extensions" \ + "extension docs must show exact external extension discovery from artifact indexes" +require_text docs/maintainers/extension-packaging-policy.md "--extension-index vendor/oliphaunt-extensions.toml" \ + "extension docs must show selecting external extensions through artifact indexes" +require_text docs/maintainers/extension-packaging-policy.md "--extension-cache ~/.cache/oliphaunt/extensions" \ + "extension docs must show verified cache consumption for URL-backed exact extension artifacts" +require_text docs/maintainers/extension-packaging-policy.md "\`extension-download\`" \ + "extension docs must keep HTTP/TLS artifact downloads out of the default embedded SDK dependency story" +require_text docs/maintainers/extension-packaging-policy.md "--trusted-extension-index-key-file acme-release-2026q2:keys/acme-extension-index.ed25519.pub" \ + "extension docs must show signed release index verification through an explicit trust root" +require_text docs/maintainers/extension-packaging-policy.md "\`extension-signing\`" \ + "extension docs must keep signed-index verification out of the default embedded SDK dependency story" +require_text docs/maintainers/extension-packaging-policy.md "nativeModuleFile=acme_ext.so" \ + "extension docs must document target-specific native module files in prebuilt artifacts" +require_text docs/maintainers/extension-packaging-policy.md "\`.tar.zst\`" \ + "extension docs must document portable compressed prebuilt extension artifacts" +require_text docs/maintainers/extension-packaging-policy.md "packageLayout=oliphaunt-extension-artifact-v1" \ + "extension docs must document the prebuilt extension artifact manifest schema" +require_text docs/maintainers/extension-packaging-policy.md "does not compile PostgreSQL or extension source in the app project" \ + "extension docs must state that Android app builds link prebuilt extension artifacts only" +require_text docs/maintainers/extension-packaging-policy.md "strong references for selected" \ + "extension docs must state selected extension artifacts are build/link requirements" +require_text docs/maintainers/extension-packaging-policy.md "--runtime-resources " \ + "extension docs must state iOS extension packaging derives selection from runtime resources" +require_text src/sdks/swift/README.md "The resolver fetches only those extension" \ + "Swift SDK docs must document selected-extension iOS XCFramework packaging" +require_text src/sdks/swift/README.md "selected their exact PostgreSQL extension name" \ + "Swift SDK docs must document deriving iOS selected-extension artifacts from exact SQL extension selections" +require_text src/sdks/swift/README.md "strongly references selected" \ + "Swift SDK docs must describe selected extension link-time failure semantics" +require_text src/sdks/react-native/README.md "build-ios-extension-xcframeworks.sh" \ + "React Native SDK docs must document selected-extension iOS XCFramework packaging" +require_text src/sdks/react-native/README.md "--runtime-resources " \ + "React Native SDK docs must document deriving iOS selected-extension artifacts from runtime resources" +require_text src/sdks/react-native/README.md "strongly references selected" \ + "React Native SDK docs must describe selected extension link-time failure semantics" +ios_extension_smoke_root="$(mktemp -d "${TMPDIR:-/tmp}/oliphaunt-ios-extension-xcframework-check.XXXXXX")" +mkdir -p "$ios_extension_smoke_root/resources/oliphaunt/runtime" +cat >"$ios_extension_smoke_root/resources/oliphaunt/runtime/manifest.properties" <<'EOF' +schema=oliphaunt-runtime-resources-v1 +layout=postgres-runtime-files-v1 +extensions= +mobileStaticRegistryState=not-required +nativeModuleStems= +EOF +OLIPHAUNT_IOS_EXTENSION_XCFRAMEWORK_ROOT="$ios_extension_smoke_root/out" \ + src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh \ + --runtime-resources "$ios_extension_smoke_root/resources" >/dev/null +require_text "$ios_extension_smoke_root/out/out/manifest.properties" "packageLayout=oliphaunt-ios-extension-xcframeworks-v1" \ + "iOS extension XCFramework builder must emit a selected-extension manifest" +OLIPHAUNT_IOS_EXTENSION_XCFRAMEWORK_ROOT="$ios_extension_smoke_root/out" \ + src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh \ + --check-current \ + --runtime-resources "$ios_extension_smoke_root/resources" >/dev/null +rm -rf "$ios_extension_smoke_root" +require_text src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh "--check-current" \ + "liboliphaunt must expose a no-build currentness gate for opt-in external pgrx artifacts" +require_text src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh "--refresh-current-stamps" \ + "liboliphaunt must expose a no-build restamp path for valid external pgrx artifacts" +require_text src/runtimes/liboliphaunt/native/bin/build-external-pgrx-extensions-macos.sh "build_fingerprint_schema" \ + "external pgrx artifact fingerprints must be schema-versioned instead of hashing incidental harness text" +require_text src/runtimes/liboliphaunt/native/postgres18/external-extensions.toml "pgrx_version = \"0.18.0\"" \ + "external pgrx extension candidates must be pinned to a cargo-pgrx version" +printf '\nSDK mobile extension surface checks passed.\n' diff --git a/tools/policy/check-sdk-parity.sh b/tools/policy/check-sdk-parity.sh new file mode 100755 index 00000000..5dad756c --- /dev/null +++ b/tools/policy/check-sdk-parity.sh @@ -0,0 +1,1166 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$script_dir/sdk-check-lib.sh" + +require_command node + +require_file README.md +require_file src/docs/content/reference/sdk-products.mdx +require_file docs/maintainers/sdk-products-policy.md +require_file tools/policy/sdk-manifest.toml +require_file docs/maintainers/rust-sdk-policy.md +require_file src/sdks/swift/README.md +require_file src/sdks/kotlin/README.md +require_file src/sdks/react-native/README.md +require_file src/sdks/js/README.md +require_file docs/maintainers/repo-structure.md +require_file src/docs/content/learn/native-runtime.mdx +require_file src/docs/content/sdk/wasm/runtime.mdx +require_file docs/maintainers/wasm-usage-legacy.md +require_file docs/maintainers/sdk-parity-policy.md +require_file docs/maintainers/sdk-api-surface.md +require_file src/shared/fixtures/protocol/query-response-cases.json +require_file src/docs/content/sdk/react-native/architecture.mdx +require_file src/docs/content/learn/mobile-stability.mdx +require_file tools/policy/check-native-boundaries.sh +require_file tools/policy/check-mobile-extension-artifacts.sh +require_file src/sdks/react-native/OliphauntReactNative.podspec +require_file src/sdks/react-native/android/settings.gradle +require_file src/sdks/react-native/android/build.gradle +require_file src/sdks/react-native/ios/OliphauntAdapter.swift +require_file src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt +require_file src/sdks/swift/Sources/COliphaunt/include/module.modulemap +require_file src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h +require_file src/sdks/react-native/examples/expo/package.json +require_file src/sdks/react-native/examples/expo/eas.json +require_file src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx +require_file src/sdks/react-native/examples/expo/src/sqlite-benchmark.ts +require_file src/sdks/react-native/tools/expo-android-runner.sh +require_file src/sdks/react-native/tools/expo-ios-runner.sh +require_file src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch +require_file src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0008-liboliphaunt-clean-embedded-symbols.patch +require_file src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh +require_file src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh +require_file src/runtimes/liboliphaunt/native/bin/build-ios-extension-xcframeworks.sh +require_file src/sdks/rust/src/config.rs +require_file src/sdks/rust/src/builder.rs +require_file src/sdks/rust/tests/sdk_config_modes.rs +require_file src/sdks/rust/tests/sdk_shape.rs +require_file src/sdks/rust/tests/sdk_native_smoke.rs +require_file src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt +require_file src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidDefaultEngineTest.kt +require_file src/sdks/rust/src/error.rs +require_file src/sdks/rust/src/query.rs +require_file src/sdks/rust/tests/protocol_query_fixtures.rs +require_file src/sdks/rust/tests/sdk_extensions.rs +require_file src/sdks/swift/Sources/Oliphaunt/OliphauntQuery.swift +require_file src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift +require_file src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift +require_file src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift +require_file src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Query.kt +require_file src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt +require_file src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt +require_file src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt +require_file src/sdks/react-native/src/query.ts +require_file src/sdks/react-native/src/client.ts +require_file src/sdks/react-native/src/specs/NativeOliphaunt.ts +require_file src/sdks/react-native/src/__tests__/client.test.ts +require_file src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts +require_file src/sdks/react-native/src/index.ts +require_file src/sdks/js/package.json +require_file src/sdks/js/jsr.json +require_file src/sdks/js/src/index.ts +require_file src/sdks/js/src/client.ts +require_file src/sdks/js/src/query.ts +require_file src/sdks/js/src/runtime/broker.ts +require_file src/sdks/js/src/__tests__/client.test.ts +require_file src/sdks/js/src/__tests__/protocol-fixtures.test.ts +require_text src/sdks/swift/tools/check-sdk.sh 'ProtocolFixtureTests.swift' \ + "Swift SDK packaging check must include the shared protocol fixture test file" + +node tools/policy/generate-sdk-api-surface.mjs --check +node tools/policy/check-sdk-doc-examples.mjs +tools/policy/check-native-boundaries.sh + +if ! cmp -s src/runtimes/liboliphaunt/native/include/oliphaunt.h src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h; then + echo "Swift COliphaunt packaged C ABI header must match src/runtimes/liboliphaunt/native/include/oliphaunt.h" >&2 + exit 1 +fi +if ! cmp -s src/runtimes/liboliphaunt/native/include/oliphaunt.h src/sdks/react-native/android/src/main/cpp/include/oliphaunt.h; then + echo "React Native Android packaged C ABI header must match src/runtimes/liboliphaunt/native/include/oliphaunt.h" >&2 + exit 1 +fi + +require_text README.md 'and `src/sdks/js/`: platform and runtime SDKs.' \ + "root README must classify Rust as an SDK peer" +require_text README.md '- `src/runtimes/liboliphaunt/native/`: C ABI, PostgreSQL 18 source pin, patch stack, native build and' \ + "root README must use the canonical liboliphaunt directory name" +require_text README.md '- `tools/policy/sdk-manifest.toml`: SDK ownership registry used by parity checks.' \ + "root README must mention the SDK ownership registry" +require_manifest_text rust 'classification = "sdk"' \ + "SDK manifest must classify Rust as a product SDK" +require_manifest_text rust 'implementation_path = "src/sdks/rust"' \ + "SDK manifest must point Rust SDK ownership at the Rust crate" +require_manifest_text rust 'primary_targets = ["tauri", "rust-desktop"]' \ + "SDK manifest must classify Rust as the Tauri/Rust desktop SDK" +require_manifest_text rust 'available_modes = ["native-direct", "native-broker", "native-server"]' \ + "SDK manifest must declare Rust mode availability" +require_manifest_text swift 'classification = "sdk"' \ + "SDK manifest must classify Swift as a product SDK" +require_manifest_text swift 'primary_targets = ["ios", "macos"]' \ + "SDK manifest must classify Swift as the iOS/macOS SDK" +require_manifest_text swift 'runtime_boundary = "Oliphaunt"' \ + "SDK manifest must classify Swift as the iOS/macOS runtime boundary" +require_manifest_text swift 'available_modes = ["native-direct"]' \ + "SDK manifest must declare current Swift mode availability" +require_manifest_text swift 'unsupported_modes = ["native-broker", "native-server"]' \ + "SDK manifest must declare current Swift unsupported modes" +require_manifest_text kotlin 'classification = "sdk"' \ + "SDK manifest must classify Kotlin as a product SDK" +require_manifest_text kotlin 'primary_targets = ["android"]' \ + "SDK manifest must classify Kotlin as the Android SDK" +require_manifest_text kotlin 'runtime_boundary = "OliphauntAndroid"' \ + "SDK manifest must classify the Kotlin Android facade as the runtime boundary" +require_manifest_text kotlin 'available_modes = ["native-direct"]' \ + "SDK manifest must declare current Kotlin mode availability" +require_manifest_text kotlin 'unsupported_modes = ["native-broker", "native-server"]' \ + "SDK manifest must declare current Kotlin unsupported modes" +require_manifest_text react-native 'classification = "sdk"' \ + "SDK manifest must classify React Native as an SDK" +require_manifest_text react-native 'runtime_owner = false' \ + "SDK manifest must prevent React Native from owning a separate database runtime" +require_manifest_text react-native 'delegates_apple_to = "swift"' \ + "SDK manifest must route React Native Apple runtime behavior through Swift" +require_manifest_text react-native 'delegates_android_to = "kotlin"' \ + "SDK manifest must route React Native Android runtime behavior through Kotlin" +require_manifest_text react-native 'available_modes = ["native-direct"]' \ + "SDK manifest must declare current React Native delegated mode availability" +require_manifest_text react-native 'unsupported_modes = ["native-broker", "native-server"]' \ + "SDK manifest must declare current React Native unsupported modes" +require_manifest_text typescript 'classification = "sdk"' \ + "SDK manifest must classify TypeScript as an SDK" +require_manifest_text typescript 'package_name = "@oliphaunt/ts"' \ + "SDK manifest must name the TypeScript registry package" +require_manifest_text typescript 'primary_targets = ["node", "bun", "deno", "tauri-javascript"]' \ + "SDK manifest must classify TypeScript as the desktop JavaScript SDK" +require_manifest_text typescript 'available_modes = ["native-direct", "native-broker", "native-server"]' \ + "SDK manifest must declare TypeScript mode availability" +require_manifest_text typescript 'depends_on_rust_broker_helper = true' \ + "SDK manifest must make the TypeScript broker helper dependency explicit" +require_text docs/maintainers/sdk-products-policy.md "These are product SDKs, not auxiliary bindings." \ + "SDK maintainer policy must frame Rust/Swift/Kotlin/RN as product SDKs" +require_text docs/maintainers/sdk-products-policy.md '`tools/policy/sdk-manifest.toml` is the repo-level SDK registry kept for' \ + "SDK maintainer policy must identify the SDK ownership registry" +require_text docs/maintainers/sdk-products-policy.md 'The canonical product graph now lives' \ + "SDK maintainer policy must identify moon project manifests as the canonical product graph" +require_text src/docs/content/reference/sdk-products.mdx "Rust is the SDK for Tauri and Rust desktop apps." \ + "SDK README must state the Rust SDK target" +require_text src/docs/content/reference/sdk-products.mdx "React Native is the TypeScript/TurboModule SDK over the Swift and Kotlin SDKs." \ + "SDK README must state RN is layered over Swift/Kotlin" +require_text src/docs/content/reference/sdk-products.mdx "TypeScript is the SDK for Node.js, Bun, Deno, and Tauri JavaScript apps." \ + "SDK README must state the TypeScript SDK target" +require_text src/docs/content/reference/sdk-products.mdx "TypeScript broker mode uses a published broker helper" \ + "SDK README must make TypeScript broker helper ownership explicit" +require_text src/docs/content/reference/sdk-products.mdx 'Android calls flow through the Kotlin SDK' \ + "SDK README must route React Native Android through the Kotlin SDK" +require_text docs/maintainers/sdk-products-policy.md "Silent drift between SDKs" \ + "SDK README must require justified SDK parity gaps" +require_text docs/maintainers/rust-sdk-policy.md "The Rust SDK is a peer product SDK for Tauri and Rust desktop apps." \ + "Rust SDK README must identify Rust as a peer product SDK" +require_text docs/maintainers/rust-sdk-policy.md "Tauri desktop apps" \ + "Rust SDK README must state Tauri as the primary Rust SDK app target" +require_text src/sdks/rust/README.md "Rust is a product SDK" \ + "Rust crate README must classify Rust as a product SDK" +require_text src/sdks/rust/README.md "Swift owns iOS and macOS runtime behavior" \ + "Rust crate README must state Swift owns Apple runtime behavior" +require_text src/sdks/rust/README.md "Kotlin owns Android runtime behavior" \ + "Rust crate README must state Kotlin owns Android runtime behavior" +require_text src/sdks/rust/README.md "React Native owns the TypeScript and" \ + "Rust crate README must state React Native owns JS/TurboModule DX" +require_text src/sdks/rust/README.md "runtime behavior to those platform SDKs" \ + "Rust crate README must state React Native delegates runtime behavior" +require_text src/docs/content/learn/native-runtime.mdx "# Native Runtime" \ + "runtime docs must describe the native liboliphaunt runtime by default" +require_text src/docs/content/learn/native-runtime.mdx '| `NativeDirect` | in-process | one serialized physical session | one resident root per process | same-root logical reopen; WAL recovery after process relaunch |' \ + "runtime docs must make direct lifecycle semantics explicit" +require_text src/docs/content/learn/native-runtime.mdx "Use server mode" \ + "runtime docs must make Rust clone/executor semantics explicit" +require_text src/docs/content/learn/native-runtime.mdx 'Direct and broker mode reject `max_client_sessions` values other' \ + "runtime docs must reject fake direct/broker pool semantics" +reject_text src/docs/content/learn/native-runtime.mdx "oliphaunt-wasix" \ + "native runtime docs must not describe the legacy WASIX package" +reject_text src/docs/content/learn/native-runtime.mdx "OliphauntServer" \ + "native runtime docs must not use legacy OliphauntServer terminology" +require_text src/docs/content/sdk/wasm/runtime.mdx "# WASIX Runtime Guide" \ + "WASIX runtime docs must identify themselves as WASIX-specific" +require_text docs/maintainers/wasm-usage-legacy.md "# WASIX Usage Guide" \ + "usage docs must identify themselves as WASIX-specific" +require_text docs/maintainers/repo-structure.md "Rust-native SDK for Tauri and Rust desktop" \ + "repo structure docs must classify Rust as the Tauri/Rust desktop SDK" +require_text docs/maintainers/repo-structure.md 'tools/policy/check-native-boundaries.sh' \ + "repo structure docs must document the native/legacy boundary guard" +require_text docs/maintainers/repo-structure.md 'default feature set is intentionally' \ + "repo structure docs must document the lean xtask default feature boundary" +require_text docs/maintainers/repo-structure.md 'explicit feature flags' \ + "repo structure docs must document that legacy xtask capabilities are opt-in" +require_text docs/maintainers/repo-structure.md 'src/runtimes/liboliphaunt/native/` for C, PostgreSQL patches, and platform build scripts' \ + "repo structure docs must use the canonical liboliphaunt directory name" +require_text docs/maintainers/repo-structure.md "Swift package for iOS and macOS apps" \ + "repo structure docs must classify Swift as the iOS/macOS SDK" +require_text docs/maintainers/repo-structure.md "Kotlin Multiplatform build for" \ + "repo structure docs must classify Kotlin as the Android SDK" +require_text docs/maintainers/repo-structure.md "React Native native code should be" \ + "repo structure docs must keep React Native as platform SDK adapter glue" +require_text docs/maintainers/repo-structure.md "tools/policy/sdk-manifest.toml" \ + "repo structure docs must mention the SDK ownership registry" +require_text docs/maintainers/repo-structure.md "parity wherever the target platform can support" \ + "repo structure docs must require justified SDK parity" +require_text docs/maintainers/sdk-parity-policy.md "Rust: SDK for Tauri and Rust desktop apps;" \ + "SDK parity docs must define Rust as the Tauri/Rust SDK" +require_text docs/maintainers/sdk-parity-policy.md "Swift: Apple SDK for iOS and macOS apps;" \ + "SDK parity docs must define Swift target platforms" +require_text docs/maintainers/sdk-parity-policy.md "Kotlin: Android SDK;" \ + "SDK parity docs must define Kotlin target platforms" +require_text docs/maintainers/sdk-parity-policy.md "React Native: TypeScript/TurboModule SDK over Swift and Kotlin." \ + "SDK parity docs must define React Native ownership" +require_text docs/maintainers/sdk-parity-policy.md '`tools/policy/sdk-manifest.toml`' \ + "SDK parity docs must link the machine-checked SDK registry" +require_text docs/maintainers/sdk-parity-policy.md '[`sdk-api-surface.md`](sdk-api-surface.md)' \ + "SDK parity docs must link the generated SDK API surface inventory" +require_text docs/maintainers/sdk-parity-policy.md "WASM are peer products with ecosystem" \ + "SDK parity docs must classify SDKs as peer products" +require_text docs/maintainers/sdk-parity-policy.md 'src/shared/fixtures/protocol/query-response-cases.json' \ + "SDK parity docs must document the shared protocol fixture corpus" +require_text docs/maintainers/sdk-parity-policy.md "React Native is not a fifth runtime." \ + "SDK parity docs must forbid an independent React Native runtime" +require_text docs/maintainers/sdk-parity-policy.md "Cloned Rust \`Oliphaunt\` handles share one SDK executor" \ + "SDK parity docs must make cloned Rust handle/executor semantics explicit" +require_text docs/maintainers/sdk-parity-policy.md "FIFO async serial gate" \ + "SDK parity docs must make Swift session serialization explicit" +require_text docs/maintainers/sdk-parity-policy.md "delegate ordering to the platform serial session" \ + "SDK parity docs must make React Native delegated ordering explicit" +require_text docs/maintainers/sdk-parity-policy.md "Runtime footprint profiles" \ + "SDK parity docs must include the shared runtime-footprint profile contract" +require_text docs/maintainers/sdk-parity-policy.md "Startup GUC overrides" \ + "SDK parity docs must include the shared startup-GUC override contract" +require_text src/docs/content/learn/mobile-stability.mdx "- one resident backend per app process;" \ + "mobile stability docs must make direct mode's resident backend contract explicit" +require_text src/docs/content/learn/mobile-stability.mdx "- one physical session;" \ + "mobile stability docs must make direct mode's one-session contract explicit" +require_text src/docs/content/learn/mobile-stability.mdx "- serialized requests;" \ + "mobile stability docs must make direct mode's serialized-session contract explicit" +require_text src/docs/content/learn/mobile-stability.mdx "- same-root logical reopen only;" \ + "mobile stability docs must make direct mode's same-root logical reopen contract explicit" +require_text src/docs/content/learn/mobile-stability.mdx "- app-process ownership;" \ + "mobile stability docs must make direct mode's app-process ownership explicit" +require_text src/docs/content/learn/mobile-stability.mdx "prepareForBackground" \ + "mobile stability docs must document foreground/background lifecycle APIs" +require_text src/docs/content/learn/mobile-stability.mdx "resumeFromBackground" \ + "mobile stability docs must document foreground/background lifecycle APIs" +require_text src/sdks/rust/src/config.rs "pub enum RuntimeFootprintProfile" \ + "Rust SDK must expose runtime footprint profiles" +require_text src/sdks/rust/src/config.rs "BalancedMobile" \ + "Rust SDK must expose the balanced mobile footprint profile" +require_text src/sdks/rust/src/config.rs "SmallMobile" \ + "Rust SDK must expose the small mobile footprint profile" +require_text src/sdks/rust/src/config.rs '("shared_buffers", "32MB")' \ + "Rust balanced mobile profile must lower shared_buffers" +require_text src/sdks/rust/src/config.rs '("shared_buffers", "8MB")' \ + "Rust small mobile profile must use the lowest supported shared_buffers profile" +require_text src/sdks/rust/src/config.rs '("wal_buffers", "-1")' \ + "Rust balanced mobile profile must let WAL buffers autotune" +require_text src/sdks/rust/src/config.rs '("io_method", "sync")' \ + "Rust mobile profiles must disable the PG18 worker AIO path" +require_text src/sdks/rust/src/config.rs '("io_max_concurrency", "1")' \ + "Rust mobile profiles must cap PG18 IO concurrency" +require_text src/sdks/rust/src/builder.rs "pub fn runtime_footprint" \ + "Rust builder must expose runtime footprint selection" +require_text src/sdks/rust/src/builder.rs "pub fn startup_guc" \ + "Rust builder must expose startup GUC overrides" +require_text src/sdks/rust/src/builder.rs "pub fn startup_gucs" \ + "Rust builder must expose bulk startup GUC overrides" +require_text src/sdks/rust/tests/sdk_config_modes.rs "runtime_footprint_profiles_define_the_mobile_pg18_startup_contract" \ + "Rust SDK shape tests must lock the mobile PG18 startup GUC contract" +require_text src/sdks/rust/tests/sdk_config_modes.rs "direct_broker_server_lifecycle_capabilities_are_honest" \ + "Rust SDK shape tests must lock direct/broker/server lifecycle capability semantics" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public enum OliphauntRuntimeFootprintProfile" \ + "Swift SDK must expose runtime footprint profiles" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public var runtimeFootprint" \ + "Swift configuration must expose runtime footprint selection" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public var startupGUCs" \ + "Swift configuration must expose startup GUC overrides" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift '"shared_buffers=32MB"' \ + "Swift balanced mobile profile must mirror the Rust shared_buffers contract" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift '"shared_buffers=8MB"' \ + "Swift small mobile profile must mirror the Rust low-memory shared_buffers contract" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift '"wal_buffers=-1"' \ + "Swift balanced mobile profile must mirror the Rust WAL buffer contract" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift '"io_max_concurrency=1"' \ + "Swift mobile profiles must mirror the Rust PG18 IO concurrency contract" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "runtimeFootprintProfilesBuildTheMobileStartupGUCContract" \ + "Swift tests must lock the mobile PG18 startup GUC contract" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public enum class RuntimeFootprintProfile" \ + "Kotlin SDK must expose runtime footprint profiles" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "val runtimeFootprint" \ + "Kotlin configuration must expose runtime footprint selection" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "val startupGucs" \ + "Kotlin configuration must expose startup GUC overrides" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt '"shared_buffers=32MB"' \ + "Kotlin balanced mobile profile must mirror the Rust shared_buffers contract" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt '"shared_buffers=8MB"' \ + "Kotlin small mobile profile must mirror the Rust low-memory shared_buffers contract" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt '"wal_buffers=-1"' \ + "Kotlin balanced mobile profile must mirror the Rust WAL buffer contract" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt '"io_max_concurrency=1"' \ + "Kotlin mobile profiles must mirror the Rust PG18 IO concurrency contract" +require_text src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt "runtimeFootprintProfilesBuildTheMobileStartupGucContract" \ + "Kotlin tests must lock the mobile PG18 startup GUC contract" +require_text src/sdks/react-native/src/client.ts "export type RuntimeFootprintProfile" \ + "React Native SDK must expose runtime footprint profiles" +require_text src/sdks/react-native/src/client.ts "runtimeFootprint?: RuntimeFootprintProfile" \ + "React Native OpenConfig must expose runtime footprint selection" +require_text src/sdks/react-native/src/client.ts "startupGUCs?: ReadonlyArray" \ + "React Native OpenConfig must expose startup GUC overrides" +require_text src/sdks/react-native/src/client.ts "function normalizeRuntimeFootprint" \ + "React Native SDK must validate runtime footprint profiles before native calls" +require_text src/sdks/react-native/src/client.ts "function validateStartupGUCs" \ + "React Native SDK must validate startup GUC overrides before native calls" +require_text src/sdks/react-native/src/specs/NativeOliphaunt.ts "runtimeFootprint?: string" \ + "React Native Codegen config must forward runtime footprint selection" +require_text src/sdks/react-native/src/specs/NativeOliphaunt.ts "startupGUCs?: Array" \ + "React Native Codegen config must forward startup GUC overrides" +require_text src/sdks/react-native/src/client.ts "config.runtimeFootprint ?? 'balancedMobile'" \ + "React Native SDK default opens must use the mobile runtime footprint profile" +require_text src/sdks/react-native/src/client.ts "durability: config.durability ?? 'balanced'" \ + "React Native SDK default opens must use the SQLite-like balanced durability profile" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "durability: OliphauntDurability = .balanced" \ + "Swift SDK default opens must use the SQLite-like balanced durability profile" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "runtimeFootprint: OliphauntRuntimeFootprintProfile = .balancedMobile" \ + "Swift SDK default opens must use the mobile runtime footprint profile" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "val durability: DurabilityProfile = DurabilityProfile.Balanced" \ + "Kotlin SDK default opens must use the SQLite-like balanced durability profile" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "val runtimeFootprint: RuntimeFootprintProfile = RuntimeFootprintProfile.BalancedMobile" \ + "Kotlin SDK default opens must use the mobile runtime footprint profile" +require_text src/sdks/react-native/src/__tests__/client.test.ts "testOpenForwardsNativeRuntimeOverrides" \ + "React Native tests must prove runtime footprint and startup GUC forwarding" +require_text src/sdks/react-native/src/__tests__/client.test.ts "testOpenValidatesStartupGUCsBeforeNativeCall" \ + "React Native tests must prove startup GUC validation happens before native calls" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "parseRuntimeFootprint" \ + "React Native iOS adapter must parse runtime footprint profiles through Swift" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "startupGUCs(config, \"startupGUCs\")" \ + "React Native iOS adapter must forward startup GUC overrides through Swift" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "parseRuntimeFootprint" \ + "React Native Android adapter must parse runtime footprint profiles through Kotlin" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "startupGucs(\"startupGUCs\")" \ + "React Native Android adapter must forward startup GUC overrides through Kotlin" +require_text docs/maintainers/sdk-parity-policy.md "src/sdks/react-native/tools/expo-android-runner.sh" \ + "React Native maintainer docs must identify the real Android app smoke harness" +require_text docs/maintainers/sdk-parity-policy.md "src/sdks/react-native/tools/expo-ios-runner.sh" \ + "React Native maintainer docs must identify the iOS app smoke harness" +require_text docs/maintainers/sdk-parity-policy.md "moon run oliphaunt-react-native:smoke-mobile" \ + "React Native maintainer docs must identify the default Expo dev-client installed-app harness" +require_text docs/maintainers/sdk-parity-policy.md "EXPO_UNSTABLE_MCP_SERVER=1" \ + "React Native maintainer docs must document Expo local MCP validation" +require_text src/sdks/react-native/README.md "src/sdks/react-native/examples/expo" \ + "React Native README must identify the Expo installed-app example" +require_text src/sdks/react-native/README.md "moon run oliphaunt-react-native:smoke-mobile" \ + "React Native README must identify the default Expo dev-client installed-app validation lane" +require_text src/sdks/react-native/examples/expo/README.md "pnpm run smoke" \ + "Expo example README must document the default combined dev-client smoke harness" +require_text src/sdks/react-native/examples/expo/package.json '"smoke:android"' \ + "Expo example must expose the Android installed-app smoke command" +require_text src/sdks/react-native/examples/expo/package.json '"smoke:ios"' \ + "Expo example must expose the iOS installed-app smoke command" +require_text src/sdks/react-native/examples/expo/package.json '"smoke": "pnpm run smoke:android && pnpm run smoke:ios"' \ + "Expo example must expose the default combined dev-client smoke command" +require_text src/sdks/react-native/tools/check-sdk.sh 'rn_headers="$(prepare_scratch_dir react-native-ios-headers)"' \ + "React Native iOS syntax checks must stage synthetic headers in the task scratch dir, not shared /tmp" +require_text src/sdks/react-native/examples/expo/eas.json '"developmentClient": true' \ + "Expo example EAS development profile must build a development client" +require_text src/sdks/react-native/examples/expo/app.json '"expo-dev-client"' \ + "Expo example app config must declare the development-client plugin" +require_text src/sdks/react-native/examples/expo/app.json '"launchMode": "most-recent"' \ + "Expo example development-client plugin must launch the most recent project by default" +require_text src/sdks/react-native/examples/expo/package.json '"expo-sqlite"' \ + "Expo example must include native SQLite so mobile benchmarks can report same-device SQLite comparison data" +require_text src/sdks/react-native/examples/expo/app.json '"expo-sqlite"' \ + "Expo example app config must include the native SQLite plugin" +require_text src/sdks/react-native/examples/expo/package.json '"crash:android"' \ + "Expo example must expose the Android process-death recovery command" +require_text src/sdks/react-native/examples/expo/package.json '"crash:ios"' \ + "Expo example must expose the iOS process-death recovery command" +require_text src/sdks/react-native/examples/expo/package.json '"start": "EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client"' \ + "Expo example must default Metro to the development-client harness with local Expo MCP capabilities, not Expo Go" +require_text src/sdks/react-native/examples/expo/package.json '"android:start": "EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client --android"' \ + "Expo example Android start command must use the development-client harness with local Expo MCP capabilities" +require_text src/sdks/react-native/examples/expo/package.json '"ios:start": "EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client --ios"' \ + "Expo example iOS start command must use the development-client harness with local Expo MCP capabilities" +require_text src/sdks/react-native/examples/expo/package.json '"mcp:start"' \ + "Expo example must expose the Expo local MCP dev-server command" +require_text src/sdks/react-native/examples/expo/scripts/reset-project.js "EXPO_UNSTABLE_MCP_SERVER=1 npx expo start --dev-client" \ + "Expo reset helper must keep the development-client server with local MCP capabilities as the default harness" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "EXPO_PUBLIC_OLIPHAUNT_RUNTIME_FOOTPRINT" \ + "Expo example must forward runtime footprint tuning into installed-app smoke and benchmark runs" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "EXPO_PUBLIC_OLIPHAUNT_STARTUP_GUCS" \ + "Expo example must forward startup GUC tuning into installed-app smoke and benchmark runs" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "EXPO_PUBLIC_OLIPHAUNT_DURABILITY" \ + "Expo example must forward durability tuning into installed-app smoke and benchmark runs" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "background_checkpoint" \ + "Expo benchmark dashboard must display background checkpoint latency" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "sqliteBenchmark" \ + "Expo benchmark dashboard must include native-device SQLite comparison evidence" +require_text src/sdks/react-native/examples/expo/src/sqlite-benchmark.ts "PRAGMA journal_mode = WAL" \ + "Expo SQLite benchmark must use an explicit WAL durability model" +require_text src/sdks/react-native/examples/expo/src/sqlite-benchmark.ts "PRAGMA synchronous = \${synchronous}" \ + "Expo SQLite benchmark must map safe/balanced/fastDev to explicit synchronous settings" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "OLIPHAUNT_EXPO_CRASH_RECOVERY_PASS" \ + "Expo example must emit a machine-readable process-death recovery pass signal" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "liboliphauntRoot" \ + "Expo example must accept a persistent root for process-death recovery" +require_text src/sdks/react-native/src/benchmark.ts "id: 'background_checkpoint'" \ + "React Native benchmark must measure background checkpoint latency" +require_text tools/perf/matrix/run_mobile_footprint_matrix.sh "summary.json" \ + "Mobile footprint matrix must persist machine-readable summary reports" +require_text tools/perf/matrix/run_mobile_footprint_matrix.sh "crashCommand=" \ + "Mobile footprint matrix must plan process-death recovery evidence" +require_text tools/perf/matrix/run_mobile_footprint_matrix.sh "crashRecoveryElapsedMs" \ + "Mobile footprint matrix summary must include process-death recovery timing" +require_text tools/perf/matrix/run_mobile_footprint_matrix.sh "OLIPHAUNT_EXPO_ANDROID_SCRATCH" \ + "Mobile footprint matrix must isolate Android case scratch directories" +require_text tools/perf/matrix/run_mobile_footprint_matrix.sh "OLIPHAUNT_EXPO_IOS_SCRATCH" \ + "Mobile footprint matrix must isolate iOS case scratch directories" +require_text src/sdks/react-native/tools/expo-android-runner.sh "OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS" \ + "Expo Android harness must expose mobile startup GUC benchmark tuning" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "OLIPHAUNT_EXPO_MOBILE_STARTUP_GUCS" \ + "Expo iOS harness must expose mobile startup GUC benchmark tuning" +require_text src/sdks/react-native/tools/expo-android-runner.sh "OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT" \ + "Expo Android harness must expose mobile runtime footprint benchmark tuning" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "OLIPHAUNT_EXPO_MOBILE_RUNTIME_FOOTPRINT" \ + "Expo iOS harness must expose mobile runtime footprint benchmark tuning" +require_text src/sdks/react-native/tools/expo-android-runner.sh "EXPO_PUBLIC_OLIPHAUNT_ROOT" \ + "Expo Android harness must pass persistent roots through the controlled dev-client Metro environment" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "EXPO_PUBLIC_OLIPHAUNT_ROOT" \ + "Expo iOS harness must pass persistent roots through the controlled dev-client Metro environment" +require_text src/sdks/react-native/tools/expo-runner-android-device.sh "start_metro_if_needed crash-write" \ + "Expo Android crash recovery must run a phase-specific dev-client bundle for the write phase" +require_text src/sdks/react-native/tools/expo-runner-ios-installed-app.sh "start_metro_if_needed crash-write" \ + "Expo iOS crash recovery must run a phase-specific dev-client bundle for the write phase" +require_text src/sdks/react-native/tools/expo-runner-android-device.sh "start_metro_if_needed crash-verify" \ + "Expo Android crash recovery must run a phase-specific dev-client bundle for the verify phase" +require_text src/sdks/react-native/tools/expo-runner-ios-installed-app.sh "start_metro_if_needed crash-verify" \ + "Expo iOS crash recovery must run a phase-specific dev-client bundle for the verify phase" +require_text src/sdks/react-native/tools/expo-android-runner.sh "[ \"\$runner\" = \"crash\" ] && default_durability_profile=safe" \ + "Expo Android crash recovery must default to safe durability" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "[ \"\$runner\" = \"crash\" ] && default_durability_profile=safe" \ + "Expo iOS crash recovery must default to safe durability" +require_text tools/perf/matrix/run_mobile_footprint_matrix.sh "synchronous_commit_off_does_not_guarantee_last_commit" \ + "Mobile footprint matrix must not treat balanced durability as last-commit crash evidence" +require_text src/sdks/react-native/tools/expo-runner-android-device.sh "OLIPHAUNT_EXPO_CRASH_WRITE_READY" \ + "Expo Android harness must run the process-death recovery write phase" +require_text src/sdks/react-native/tools/expo-runner-ios-installed-app.sh "OLIPHAUNT_EXPO_CRASH_WRITE_READY" \ + "Expo iOS harness must run the process-death recovery write phase" +require_text src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx "OLIPHAUNT_EXPO_SMOKE_PASS" \ + "Expo example must emit a machine-readable installed-app smoke pass signal" +require_text src/sdks/react-native/tools/expo-android-runner.sh "expo prebuild --platform android" \ + "Expo Android smoke must be reproducible from a checkout without a committed android/ directory" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "expo prebuild --platform ios --no-install" \ + "Expo iOS smoke must be reproducible from a checkout without a committed ios/ directory" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "refusing macOS liboliphaunt.dylib" \ + "Expo iOS smoke must reject macOS liboliphaunt artifacts" +require_text src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c "OLIPHAUNT_CAN_EXEC_INITDB 0" \ + "liboliphaunt bootstrap must compile out initdb process execution on Apple mobile targets" +require_text src/runtimes/liboliphaunt/native/src/liboliphaunt_bootstrap.c "hydrate the root from packaged template PGDATA before oliphaunt_init" \ + "Oliphaunt mobile bootstrap must fail with an actionable template-PGDATA error" +require_text src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch "OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS" \ + "PostgreSQL mobile patch must compile shell command execution out of embedded Apple mobile builds" +require_text src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0007-liboliphaunt-disable-shell-commands-on-apple-mobile.patch "TARGET_OS_VISION" \ + "PostgreSQL mobile patch must cover visionOS along with iOS, tvOS, and watchOS" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh "OLIPHAUNT_EMBEDDED_NO_SHELL_COMMANDS" \ + "PostgreSQL patch verification must include the Apple mobile shell-command guard" +require_text src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs "iOS liboliphaunt C source syntax check passed" \ + "C ABI conformance must include the fast iOS simulator C-source syntax guard" +require_text src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh "PostgreSQL 18 iOS simulator embedded probe passed" \ + "liboliphaunt must expose a fast PostgreSQL iOS simulator embedded patch probe" +require_text src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh "oliphaunt_static_extension_magic(file_scanner->static_extension)" \ + "PostgreSQL iOS simulator probe must cover static extension magic lookup" +require_text src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh "--with-icu" \ + "PostgreSQL iOS simulator probe must exercise the ICU-enabled embedded configuration" +require_text src/runtimes/liboliphaunt/native/bin/icu.sh "U_STATIC_IMPLEMENTATION" \ + "liboliphaunt ICU helper must compile PostgreSQL as a static ICU consumer" +require_text src/runtimes/liboliphaunt/native/bin/icu.sh "oliphaunt_icu_static_data_ready" \ + "liboliphaunt ICU helper must reject stubdata-only ICU static archives" +require_text src/runtimes/liboliphaunt/native/bin/icu.sh "oliphaunt_icu_artifacts_ready" \ + "liboliphaunt ICU helper must provide one reusable archive/header readiness gate" +require_text src/runtimes/liboliphaunt/native/bin/icu.sh "oliphaunt_icu_linked_symbols_ready" \ + "liboliphaunt ICU helper must verify static ICU data and registration symbols in final binaries" +require_text src/runtimes/liboliphaunt/native/bin/icu.sh "packagedata" \ + "liboliphaunt ICU helper must explicitly build real ICU resource data" +require_text src/runtimes/liboliphaunt/native/bin/icu.sh "install-local" \ + "liboliphaunt ICU helper must install the real static ICU data archive" +require_text src/runtimes/liboliphaunt/native/patches/postgresql-18.4/0013-liboliphaunt-register-static-icu-data.patch "udata_setCommonData" \ + "PostgreSQL static ICU patch must register linked ICU common data" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh "platform IOSSIMULATOR" \ + "iOS simulator liboliphaunt artifact build must reject non-simulator dylibs" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh "--with-icu" \ + "iOS simulator liboliphaunt artifact build must enable PostgreSQL ICU support" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-simulator.sh "oliphaunt_icu_linked_symbols_ready" \ + "iOS simulator artifact gate must prove static ICU is linked into the final dylib" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh "platform IOS" \ + "iOS device liboliphaunt artifact build must reject non-device dylibs" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh "--with-icu" \ + "iOS device liboliphaunt artifact build must enable PostgreSQL ICU support" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-ios-device.sh "oliphaunt_icu_linked_symbols_ready" \ + "iOS device artifact gate must prove static ICU is linked into the final dylib" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh "--with-icu" \ + "Android liboliphaunt artifact build must enable PostgreSQL ICU support" +require_text src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh "oliphaunt_icu_linked_symbols_ready" \ + "Android artifact gate must prove static ICU is linked into the final shared library" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh "xcodebuild -create-xcframework" \ + "iOS packaging must produce a first-class liboliphaunt XCFramework" +require_text src/runtimes/liboliphaunt/native/postgres18/source.toml '"0008-liboliphaunt-clean-embedded-symbols.patch"' \ + "PostgreSQL source manifest must include all native patch files" +require_text src/sdks/react-native/moon.yml 'command: "pnpm --dir src/sdks/react-native/examples/expo run smoke"' \ + "React Native Moon smoke task must expose the default Expo dev-client app smoke lane" +require_text src/sdks/swift/moon.yml 'command: "bash src/sdks/swift/tools/check-sdk.sh smoke-runtime"' \ + "Swift Moon smoke task must route through the SDK-owned runtime smoke" +require_text src/sdks/swift/tools/check-sdk.sh "tools/runtime/preflight.sh ios-simulator" \ + "Swift runtime smoke must include the shared PostgreSQL iOS simulator preflight" +require_text src/sdks/swift/moon.yml 'command: "bash tools/release/build-sdk-ci-artifacts.sh oliphaunt-swift"' \ + "Swift Moon package task must stage release-shaped SDK artifacts" +require_text src/sdks/swift/tools/check-sdk.sh "build-ios-xcframework.sh --check-current" \ + "Swift package shape must expose the iOS liboliphaunt artifact check" +require_text src/sdks/kotlin/moon.yml 'command: "bash src/sdks/kotlin/tools/check-sdk.sh smoke-runtime"' \ + "Kotlin Moon smoke task must route through the SDK-owned Android runtime smoke" +require_text src/sdks/kotlin/tools/check-sdk.sh "run_android_runtime_smoke" \ + "Kotlin runtime smoke must run the SDK-owned Android packaging smoke" +require_text src/sdks/kotlin/tools/check-sdk.sh "Kotlin Android smoke AAR must include the explicitly supplied liboliphaunt runtime" \ + "Kotlin runtime smoke must prove explicit Android liboliphaunt packaging" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "build-postgres18-ios-simulator.sh" \ + "Expo iOS smoke must be able to build or locate the native simulator dylib" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "oliphaunt_capture_build_artifact_path" \ + "Expo iOS smoke must parse native build output instead of treating build logs as artifact paths" +require_text src/sdks/react-native/tools/expo-ios-runner.sh 'node_modules/@oliphaunt/react-native/ios/podspecs' \ + "Expo iOS smoke must patch CocoaPods to the installed React Native podspec shims" +reject_text src/sdks/react-native/tools/expo-ios-runner.sh 'OLIPHAUNT_SWIFT_SDK_SOURCE_DIR="$root/src/sdks/swift" pnpm pack' \ + "Expo iOS smoke must not vendor Swift SDK source into the React Native package" +reject_text src/sdks/react-native/tools/expo-ios-runner.sh 'xcodebuild -create-xcframework' \ + "Expo iOS smoke must consume liboliphaunt XCFramework artifacts built by the native runtime builder instead of creating frameworks locally" +require_text src/sdks/react-native/tools/expo-ios-runner.sh 'liboliphaunt_pod_mode="vendored-framework"' \ + "Expo iOS smoke must default to linked liboliphaunt XCFramework artifacts" +require_text src/sdks/react-native/OliphauntReactNative.podspec "s.vendored_frameworks = vendored_frameworks" \ + "React Native iOS podspec must link selected liboliphaunt and extension XCFramework artifacts" +require_text src/sdks/react-native/tools/expo-ios-runner.sh 'OLIPHAUNT_LIBOLIPHAUNT_POD_MODE="$liboliphaunt_pod_mode"' \ + "Expo iOS smoke must pass the selected liboliphaunt pod mode into CocoaPods" +reject_text src/sdks/react-native/tools/expo-ios-runner.sh '-library "$artifact"' \ + "Expo iOS smoke must not wrap simulator dylib artifacts into dynamic-library XCFrameworks for static CocoaPods builds" +require_text src/sdks/react-native/tools/expo-android-runner.sh "oliphaunt_capture_build_artifact_path" \ + "Expo Android smoke must parse native build output instead of treating build logs as artifact paths" +reject_text src/sdks/react-native/tools/expo-android-runner.sh 'OLIPHAUNT_SWIFT_SDK_SOURCE_DIR="$root/src/sdks/swift" pnpm pack' \ + "Expo Android smoke must not vendor Swift SDK source into the React Native package" +require_text src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh "oliphaunt_capture_build_artifact_path" \ + "iOS XCFramework packaging must parse native build output instead of treating build logs as artifact paths" +require_text docs/maintainers/sdk-parity-policy.md "Unsupported does not mean undefined." \ + "SDK parity docs must require explicit unsupported behavior" +require_text docs/maintainers/sdk-parity-policy.md "Mode support is part of the public contract, not tribal knowledge." \ + "SDK parity docs must require explicit mode support discovery" +require_text docs/maintainers/sdk-parity-policy.md "Rust is classified as an SDK, not an internal implementation detail." \ + "SDK parity docs must explicitly classify Rust as an SDK" +require_text docs/maintainers/sdk-parity-policy.md "The Rust SDK owns the runtime-resource producer contract." \ + "SDK parity docs must make Rust the runtime-resource producer" +require_text docs/maintainers/sdk-parity-policy.md "schema=oliphaunt-runtime-resources-v1" \ + "SDK parity docs must document the shared runtime-resource schema" +require_text docs/maintainers/sdk-parity-policy.md "Package-size evidence" \ + "SDK parity docs must track package-size evidence across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "OliphauntRuntimeResources.packageSizeReport()" \ + "SDK parity docs must document Swift package-size report parity" +require_text docs/maintainers/sdk-parity-policy.md "OliphauntAndroid.packageSizeReport(context)" \ + "SDK parity docs must document Kotlin Android package-size report parity" +require_text docs/maintainers/sdk-parity-policy.md "Oliphaunt.packageSizeReport(...)" \ + "SDK parity docs must document React Native package-size report parity" +require_text docs/maintainers/extension-packaging-policy.md "The Rust SDK owns the runtime-resource CLI and manifest contract." \ + "Extension docs must make Rust the SDK-owned runtime-resource producer" +require_text docs/maintainers/extension-packaging-policy.md "OliphauntRuntimeResources.packageSizeReport()" \ + "Extension docs must document Swift package-size report consumption" +require_text docs/maintainers/extension-packaging-policy.md "OliphauntAndroid.packageSizeReport(context)" \ + "Extension docs must document Kotlin package-size report consumption" +require_text docs/maintainers/extension-packaging-policy.md "Oliphaunt.packageSizeReport(...)" \ + "Extension docs must document React Native package-size report consumption" +require_text src/sdks/rust/src/runtime_resources.rs "Runtime resources generated by the Rust SDK" \ + "Rust runtime-resource code must identify Rust SDK ownership" +require_text src/sdks/rust/src/bin/package_resources.rs "runtime resources from the Rust SDK for Swift, Kotlin, and React Native" \ + "Rust runtime-resource CLI help must identify Rust SDK ownership" +require_text docs/maintainers/sdk-parity-policy.md "| Typed query helpers | yes | yes, simple and parameterized result parser | yes, simple and parameterized result parser | yes, JS simple and parameterized result parser |" \ + "SDK parity docs must classify typed query helper coverage across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Simple-query SQL validation | simple-query builders reject NUL-containing SQL before frontend frame construction | simple-query builders reject NUL-containing SQL before frontend frame construction | simple-query builders reject NUL-containing SQL before frontend frame construction | simple-query builders reject NUL-containing SQL before frontend frame construction |" \ + "SDK parity docs must classify simple-query SQL validation across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Extended-query input validation | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol \`Int16\` limit before frontend frame construction | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol \`Int16\` limit before frontend frame construction | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol \`Int16\` limit before frontend frame construction | extended-query builders reject NUL-containing SQL and parameter lists above the PostgreSQL protocol \`Int16\` limit before frontend frame construction |" \ + "SDK parity docs must classify extended-query input validation across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Backend UTF-8 parsing | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding | backend C-strings and text accessors reject malformed UTF-8 instead of replacement decoding |" \ + "SDK parity docs must classify strict backend UTF-8 parsing across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Backend response validation | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and \`ReadyForQuery\` transaction status, and reject unexpected backend tags instead of ignoring them | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and \`ReadyForQuery\` transaction status, and reject unexpected backend tags instead of ignoring them | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and \`ReadyForQuery\` transaction status, and reject unexpected backend tags instead of ignoring them | typed query parsers accept known simple/extended-query control tags, validate async backend control-message framing and \`ReadyForQuery\` transaction status, and reject unexpected backend tags instead of ignoring them |" \ + "SDK parity docs must classify backend response validation across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Transaction helper | \`transaction()\` returns an explicit pinned handle; \`with_transaction(...)\` commits or rolls back an async closure; unpinned work is rejected | \`transaction {}\` uses the actor-owned session for raw and streaming work and rejects database work outside the active transaction handle | \`transaction {}\` uses the serialized session for raw and streaming work and rejects database work outside the active transaction handle | \`transaction(async tx => ...)\` preserves the platform session boundary for raw and streaming work and rejects database work outside the active transaction handle |" \ + "SDK parity docs must classify transaction helper coverage across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Structured PostgreSQL errors | \`Error::Postgres(PostgresError)\` with SQLSTATE and raw ErrorResponse fields | \`OliphauntError.postgres(OliphauntPostgresError)\` with SQLSTATE and raw ErrorResponse fields | \`PostgresException(PostgresError)\` with SQLSTATE and raw ErrorResponse fields | \`PostgresError\` with SQLSTATE and raw ErrorResponse fields |" \ + "SDK parity docs must classify structured PostgreSQL errors across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Streaming protocol API | \`exec_protocol_raw_stream\` | \`execProtocolStream\` | \`execProtocolStream\` | \`execProtocolStream\` over the selected raw transport; New Architecture builds use \`jsi-array-buffer\` |" \ + "SDK parity docs must classify streaming protocol coverage across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Capability reporting | raw, stream, cancel, backup/restore, simple query, extensions, session model, multi-root support | same C ABI capability bits surfaced as Swift properties, including \`multiRoot\` | same C ABI capability bits surfaced as Kotlin properties, including \`multiRoot\` | same capability fields delegated from Swift/Kotlin, including \`multiRoot\` |" \ + "SDK parity docs must classify capability reporting across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Backup/restore format discovery | direct/broker: physical archive; server: SQL and physical archive backup; restore: physical archive; capability and handle \`supports_backup_format\`/\`supports_restore_format\` helpers | \`backupFormats\`, \`restoreFormats\`, and capability/database \`supportsBackupFormat\`/\`supportsRestoreFormat\` helpers | \`backupFormats\`, \`restoreFormats\`, and capability/database \`supportsBackupFormat\`/\`supportsRestoreFormat\` helpers | delegated \`backupFormats\` and \`restoreFormats\` capability fields plus TypeScript \`supportsBackupFormat\`/\`supportsRestoreFormat\` helpers and matching database methods |" \ + "SDK parity docs must classify backup/restore format discovery across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Backup format enforcement | \`EngineExecutor::backup\` rejects unsupported formats before the owner queue | \`OliphauntDatabase.backup\` rejects unsupported formats before the native session call | \`OliphauntDatabase.backup\` rejects unsupported formats before the platform session call | \`OliphauntDatabase.backup\` rejects unsupported formats before the TurboModule backup call |" \ + "SDK parity docs must classify backup format enforcement across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Checkpoint | \`checkpoint()\` sends PostgreSQL \`CHECKPOINT\` through the opened engine and rejects while a session pin is active | \`checkpoint()\` sends PostgreSQL \`CHECKPOINT\` through the actor-owned session and rejects while a transaction is active | \`checkpoint()\` sends PostgreSQL \`CHECKPOINT\` through the serialized session and rejects while a transaction is active | \`checkpoint()\` sends PostgreSQL \`CHECKPOINT\` through the delegated platform session and rejects while a transaction is active |" \ + "SDK parity docs must classify checkpoint coverage across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Restore format enforcement | \`Oliphaunt::restore\` rejects non-physical artifacts before target materialization | \`OliphauntDatabase.restore\` rejects non-physical artifacts before the engine call | \`OliphauntDatabase.restore\` rejects non-physical artifacts before the platform engine call | \`Oliphaunt.restore\` rejects non-physical artifacts before the TurboModule restore call |" \ + "SDK parity docs must classify restore format enforcement across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Root validation | persistent roots are rejected when empty or NUL-containing before runtime selection; restore targets are rejected before materialization | roots must be file URLs and are rejected when empty or NUL-containing before engine calls | blank or NUL-containing open and restore roots are rejected before platform engine calls | blank or NUL-containing open and restore roots are rejected before TurboModule calls |" \ + "SDK parity docs must classify root validation across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Mode support discovery | \`EngineCapabilities::rust_sdk_support()\` | \`OliphauntDatabase.supportedModes()\` | \`OliphauntDatabase.supportedModes()\` and \`OliphauntAndroid.supportedModes()\` | \`Oliphaunt.supportedModes()\` delegated from Swift/Kotlin |" \ + "SDK parity docs must classify mode support discovery across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Handle/executor ownership | Cloned Rust \`Oliphaunt\` handles share one SDK executor, FIFO owner queue, session pin, cancel handle, and close state in direct, broker, and server modes; cloning is not a connection pool | Swift database values are actor-owned session handles guarded by a FIFO async serial gate; additional references share the same actor/session and server-mode independent clients must use server support when implemented | Kotlin database values are coroutine session handles guarded by \`executionMutex\`; additional references share the same coroutine/session boundary and server-mode independent clients must use server support when implemented | React Native \`OliphauntDatabase\` objects wrap the delegated Swift/Kotlin session handle and delegate ordering to the platform serial session; JS references do not create independent sessions |" \ + "SDK parity docs must classify shared-handle FIFO executor ownership across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Connection identity | \`Oliphaunt::builder().username(...).database(...)\` feeds direct, broker, and server startup identity; invalid empty/NUL values are rejected before runtime open | \`OliphauntConfiguration(username:database:)\` feeds native-direct startup identity and rejects invalid empty/NUL values before engine open | \`OliphauntConfig(username, database)\` feeds native-direct startup identity and rejects invalid empty/NUL values before engine open | \`open({ username, database })\` forwards the same identity through Swift/Kotlin and rejects invalid empty/NUL values before the TurboModule call |" \ + "SDK parity docs must classify startup connection identity across SDKs" +require_text docs/maintainers/sdk-parity-policy.md "| Close behavior | \`Oliphaunt::close\` rejects queued work, waits for active work, then closes/detaches; use \`cancel()\` explicitly to interrupt SQL | \`OliphauntDatabase.close\` rejects queued work, waits for active work, then detaches; use \`cancel()\` explicitly to interrupt SQL | \`OliphauntDatabase.close\` rejects queued work, waits for active work, then detaches; use \`cancel()\` explicitly to interrupt SQL | \`OliphauntDatabase.close\` delegates the same wait-and-detach behavior through Swift/Kotlin |" \ + "SDK parity docs must classify close behavior across SDKs" +require_text docs/maintainers/sdk-parity-policy.md '| Rust | Tauri and Rust desktop apps | `oliphaunt` | direct, broker, server | none for the core SDK contract |' \ + "SDK parity docs must state Rust SDK target and complete mode ownership" +require_text docs/maintainers/sdk-parity-policy.md '| Swift | iOS and macOS apps | `Oliphaunt` | direct | broker/server are explicit unsupported errors until platform runtimes exist; they must not be faked through direct mode |' \ + "SDK parity docs must justify current Swift broker/server non-parity" +require_text docs/maintainers/sdk-parity-policy.md '| Kotlin | Android apps | `oliphaunt` | Android direct plus Kotlin/Native direct | Android common defaults require the `OliphauntAndroid` Context facade; JVM runtime is explicitly unavailable; Android broker/server must be separate platform adapters, not direct-mode aliases |' \ + "SDK parity docs must justify current Kotlin platform non-parity" +require_text docs/maintainers/sdk-parity-policy.md "| React Native | React Native apps | Swift on Apple, Kotlin on Android | delegated direct | New Architecture JSI ArrayBuffer transport is required for protocol, backup, and restore bytes |" \ + "SDK parity docs must justify current React Native transport/runtime stance" +require_text docs/maintainers/sdk-parity-policy.md "any future RN macOS target must use the same Swift SDK boundary" \ + "SDK parity docs must route future React Native Apple targets through Swift" +require_text docs/maintainers/sdk-parity-policy.md "RN Android delegates the same operations to the Kotlin SDK through the" \ + "SDK parity docs must route React Native Android through the Kotlin SDK facade" +require_text src/docs/content/sdk/react-native/architecture.mdx "through the Android \`dev.oliphaunt.OliphauntAndroid\` facade" \ + "React Native architecture docs must route Android through the Kotlin SDK facade" +require_text src/docs/content/sdk/react-native/architecture.mdx "returning the Kotlin SDK \`OliphauntDatabase\` handle" \ + "React Native architecture docs must keep OliphauntDatabase as the Kotlin SDK handle, not the runtime boundary" +require_text src/docs/content/sdk/react-native/architecture.mdx "\`supportedModes()\` is delegated too" \ + "React Native docs must state mode support is delegated" +require_text src/docs/content/sdk/react-native/architecture.mdx "\`Oliphaunt.restore({ libraryPath, ... })\` forwards the same native library" \ + "React Native docs must document restore library-path override forwarding" + +require_text src/sdks/swift/README.md "iOS and macOS apps" \ + "Swift README must state Apple app targets" +require_text src/sdks/swift/README.md 'use the typed simple-query helper' \ + "Swift README must document typed query helper DX" +require_text src/sdks/swift/README.md 'Pass `parameters:` for PostgreSQL extended-protocol parameters' \ + "Swift README must document parameterized query helper DX" +require_text src/sdks/swift/README.md 'execProtocolStream' \ + "Swift README must document streaming raw-protocol DX" +require_text src/sdks/swift/README.md 'Capabilities report the same product contract as Rust' \ + "Swift README must document SDK capability parity" +require_text src/sdks/kotlin/README.md "Android includes a native-direct runtime over JNI" \ + "Kotlin README must state Android runtime ownership" +require_text src/sdks/kotlin/README.md 'use the typed simple-query helper' \ + "Kotlin README must document typed query helper DX" +require_text src/sdks/kotlin/README.md 'Pass a `List` for PostgreSQL extended-protocol parameters' \ + "Kotlin README must document parameterized query helper DX" +require_text src/sdks/kotlin/README.md 'execProtocolStream' \ + "Kotlin README must document streaming raw-protocol DX" +require_text src/sdks/kotlin/README.md 'Capabilities report the same product contract as Rust' \ + "Kotlin README must document SDK capability parity" +require_text src/sdks/react-native/README.md "RN Android delegates to the Kotlin SDK" \ + "React Native README must state Android delegates to Kotlin" +require_text src/sdks/react-native/README.md 'through `OliphauntAndroid`' \ + "React Native README must state Android uses the Kotlin SDK facade" +require_text src/sdks/react-native/README.md 'stores the returned `OliphauntDatabase` handle' \ + "React Native README must describe OliphauntDatabase as the SDK handle returned by the Kotlin facade" +require_text src/sdks/react-native/README.md '`query(sql)` parses normal PostgreSQL backend protocol frames' \ + "React Native README must document typed query helper DX" +require_text src/sdks/react-native/README.md 'Pass query parameters as the second argument' \ + "React Native README must document parameterized query helper DX" +require_text src/sdks/react-native/README.md 'execProtocolStream' \ + "React Native README must document streaming raw-protocol DX" +require_text src/sdks/react-native/README.md 'Capabilities are delegated from the platform SDK' \ + "React Native README must document delegated capability parity" +require_text src/sdks/react-native/README.md 'Restore forwards `libraryPath` to' \ + "React Native README must document restore library-path override forwarding" +require_text src/sdks/react-native/README.md 'RN iOS delegates to `Oliphaunt`' \ + "React Native README must state iOS delegates to Swift/Oliphaunt" +require_text src/sdks/react-native/README.md "any future React Native macOS target must use the same" \ + "React Native README must route future macOS support through Swift/Oliphaunt" +require_text src/docs/content/reference/sdk-products.mdx "structured PostgreSQL error" \ + "SDK README must include structured PostgreSQL errors in the shared SDK contract" +require_text docs/maintainers/rust-sdk-policy.md "structured PostgreSQL errors with SQLSTATE" \ + "Rust SDK README must document structured PostgreSQL error parity" +require_text docs/maintainers/rust-sdk-policy.md "transaction helpers that keep one physical session pinned" \ + "Rust SDK README must document transaction helper parity" +require_text docs/maintainers/rust-sdk-policy.md "pinned raw and streaming protocol calls" \ + "Rust SDK README must document pinned transaction streaming parity" +require_text docs/maintainers/rust-sdk-policy.md "\`with_transaction(async |tx| { ... })\`" \ + "Rust SDK README must document closure transaction helper parity" +require_text docs/maintainers/rust-sdk-policy.md "\`checkpoint()\` for explicit PostgreSQL checkpoint requests" \ + "Rust SDK README must document checkpoint parity" +require_text docs/maintainers/rust-sdk-policy.md "SDK-owned executable/tooling paths" \ + "Rust SDK README must document executable/tooling path validation" +require_text docs/maintainers/rust-sdk-policy.md "\`EngineCapabilities::rust_sdk_support()\`" \ + "Rust SDK README must document mode support discovery" +require_text docs/maintainers/rust-sdk-policy.md "concrete backup and restore format support" \ + "Rust SDK README must document backup/restore format support discovery" +require_text docs/maintainers/rust-sdk-policy.md "capability and opened-handle \`supports_backup_format\` and" \ + "Rust SDK README must document backup/restore helper APIs" +require_text docs/maintainers/rust-sdk-policy.md "direct and broker mode reject values other than \`1\`" \ + "Rust SDK README must document honest max_client_sessions semantics" +require_text docs/maintainers/rust-sdk-policy.md "SDK-boundary rejection for unsupported backup formats" \ + "Rust SDK README must document backup format enforcement" +require_text docs/maintainers/rust-sdk-policy.md "unsupported restore formats before a target" \ + "Rust SDK README must document restore format enforcement" +require_text src/sdks/swift/README.md "OliphauntError.postgres(OliphauntPostgresError)" \ + "Swift README must document structured PostgreSQL errors" +require_text src/sdks/swift/README.md "Use \`transaction {}\` for multi-step work" \ + "Swift README must document transaction helper DX" +require_text src/sdks/swift/README.md "Use \`checkpoint()\` to request a PostgreSQL checkpoint" \ + "Swift README must document checkpoint DX" +require_text src/sdks/swift/README.md "\`OliphauntDatabase.supportedModes()\`" \ + "Swift README must document mode support discovery" +require_text src/sdks/swift/README.md "concrete backup/restore formats" \ + "Swift README must document backup/restore format support discovery" +require_text src/sdks/swift/README.md "on either \`OliphauntCapabilities\` or \`OliphauntDatabase\`" \ + "Swift README must document backup/restore helper APIs" +require_text src/sdks/swift/README.md "\`backup(_:)\` enforces" \ + "Swift README must document backup format enforcement" +require_text src/sdks/swift/README.md "\`OliphauntDatabase.restore\` rejects unsupported restore artifact formats" \ + "Swift README must document restore format enforcement" +require_text src/sdks/kotlin/README.md "PostgresException(PostgresError)" \ + "Kotlin README must document structured PostgreSQL errors" +require_text src/sdks/kotlin/README.md "Use \`database.transaction { tx -> ... }\`" \ + "Kotlin README must document transaction helper DX" +require_text src/sdks/kotlin/README.md "Use \`database.checkpoint()\` to request a PostgreSQL checkpoint" \ + "Kotlin README must document checkpoint DX" +require_text src/sdks/kotlin/README.md "\`OliphauntAndroid.supportedModes()\` reports the same Android facade contract" \ + "Kotlin README must document Android mode support discovery" +require_text src/sdks/kotlin/README.md "concrete backup/restore formats" \ + "Kotlin README must document backup/restore format support discovery" +require_text src/sdks/kotlin/README.md "on either \`EngineCapabilities\` or \`OliphauntDatabase\`" \ + "Kotlin README must document backup/restore helper APIs" +require_text src/sdks/kotlin/README.md "\`backup(...)\` enforces" \ + "Kotlin README must document backup format enforcement" +require_text src/sdks/kotlin/README.md "\`OliphauntDatabase.restore(...)\` rejects unsupported restore artifact formats" \ + "Kotlin README must document restore format enforcement" +require_text src/sdks/react-native/README.md "structured PostgreSQL errors through" \ + "React Native README must document structured PostgreSQL errors" +require_text src/sdks/react-native/README.md "\`OliphauntDatabase.transaction(async tx => ...)\`" \ + "React Native README must document transaction helper DX" +require_text src/sdks/react-native/README.md "\`OliphauntDatabase.checkpoint()\`" \ + "React Native README must document checkpoint DX" +require_text src/sdks/react-native/README.md "\`Oliphaunt.supportedModes()\`" \ + "React Native README must document mode support discovery" +require_text src/sdks/react-native/README.md "\`backupFormats\` and \`restoreFormats\`" \ + "React Native README must document backup/restore format support discovery" +require_text src/sdks/react-native/README.md "\`OliphauntDatabase.supportsBackupFormat\` and" \ + "React Native README must document backup/restore helper APIs" +require_text src/sdks/react-native/README.md "\`OliphauntDatabase.backup\` enforces" \ + "React Native README must document backup format enforcement" +require_text src/sdks/react-native/README.md "\`Oliphaunt.restore\` rejects unsupported restore artifact formats" \ + "React Native README must document restore format enforcement" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "try await transaction.execProtocolStream" \ + "Swift SDK tests must prove transaction-scoped streaming" +require_text src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt "transaction.execProtocolStream" \ + "Kotlin SDK tests must prove transaction-scoped streaming" +require_text src/sdks/react-native/src/__tests__/client.test.ts "await tx.execProtocolStream" \ + "React Native SDK tests must prove transaction-scoped streaming" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "SELECT after_rollback" \ + "Swift SDK tests must prove captured transaction handles are inactive after rollback" +require_text src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt "SELECT after_rollback" \ + "Kotlin SDK tests must prove captured transaction handles are inactive after rollback" +require_text src/sdks/react-native/src/__tests__/client.test.ts "SELECT after_rollback" \ + "React Native SDK tests must prove captured transaction handles are inactive after rollback" +require_text src/sdks/rust/tests/sdk_shape.rs "close_during_transaction_stops_session_and_rejects_pinned_work" \ + "Rust SDK tests must prove close during a transaction is a lifecycle boundary" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "closeDuringTransactionClosesSessionAndRejectsPinnedWork" \ + "Swift SDK tests must prove close during a transaction is a lifecycle boundary" +require_text src/sdks/kotlin/oliphaunt/src/commonTest/kotlin/dev/oliphaunt/OliphauntDatabaseTest.kt "closeDuringTransactionClosesSessionAndRejectsPinnedWork" \ + "Kotlin SDK tests must prove close during a transaction is a lifecycle boundary" +require_text src/sdks/react-native/src/__tests__/client.test.ts "testCloseDuringTransactionClosesSessionAndRejectsPinnedWork" \ + "React Native SDK tests must prove close during a transaction is a lifecycle boundary" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "private actor OliphauntAsyncSerialGate" \ + "Swift SDK must enforce an explicit FIFO gate instead of relying on actor non-reentrancy" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "sessionOperationsQueueFifoAcrossConcurrentTasks" \ + "Swift SDK tests must prove concurrent database calls use FIFO session ordering" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "closeRejectsQueuedWorkBeforeNativeSessionCall" \ + "Swift SDK tests must prove close rejects queued work before it reaches the native session" + +require_text src/sdks/react-native/OliphauntReactNative.podspec 's.dependency "Oliphaunt"' \ + "React Native podspec must depend on the Swift SDK" +require_text src/sdks/react-native/app.plugin.js "ios/podspecs" \ + "React Native Expo config plugin must resolve Swift SDK pods through npm-shipped podspec shims" +require_text src/sdks/react-native/app.plugin.js "pod 'COliphaunt', :podspec => File.join(oliphaunt_podspecs_path, 'COliphaunt.podspec')" \ + "React Native Expo config plugin must inject the C bridge podspec shim instead of requiring CocoaPods trunk" +require_text src/sdks/react-native/ios/podspecs/Oliphaunt.podspec "src/sdks/swift/Sources/Oliphaunt/**/*.swift" \ + "React Native package must point CocoaPods at the released Swift SDK source instead of vendoring it" +require_text src/sdks/react-native/ios/podspecs/COliphaunt.podspec "src/sdks/swift/Sources/COliphaunt/include/oliphaunt.h" \ + "React Native package must point CocoaPods at the released C ABI header instead of vendoring it" +require_text src/sdks/react-native/OliphauntReactNative.podspec 's.source_files = "ios/*.{h,m,mm,swift}", "ios/generated/static-registry/*.c"' \ + "React Native podspec must compile bridge sources and the generated mobile static-extension registry only" +require_file src/sdks/react-native/ios/OliphauntReactNative.h +reject_file src/sdks/react-native/ios/Oliphaunt.h \ + "React Native Objective-C headers must not case-collide with the lowercase liboliphaunt C ABI header" +require_text src/sdks/react-native/ios/Oliphaunt.mm '#import "OliphauntReactNative.h"' \ + "React Native implementation must import the package-specific Objective-C header name" +require_text src/sdks/react-native/OliphauntReactNative.podspec 's.resources = resource_bundle' \ + "React Native podspec must copy the prebuilt Oliphaunt resource bundle as an app resource" +require_text src/sdks/react-native/OliphauntReactNative.podspec 'ios/extension-frameworks/**/*.xcframework' \ + "React Native podspec must treat selected iOS extension XCFrameworks as link inputs instead of app resources" +require_text src/sdks/react-native/ios/podspecs/COliphaunt.podspec 's.module_map = "src/sdks/swift/Sources/COliphaunt/include/module.modulemap"' \ + "React Native C bridge podspec shim must expose a module map for CocoaPods integration" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "stamp_expo_modules_jsi_prebuilt" \ + "Expo iOS smoke must keep local Xcode beta validation on Expo's prebuilt JSI xcframework path" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "patch_expo_modules_jsi_for_host_toolchain" \ + "Expo iOS smoke must adapt ExpoModulesJSI source builds to the local Swift beta compiler when needed" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "OLIPHAUNT_EXPO_IOS_USE_PRECOMPILED_MODULES:-true" \ + "Expo iOS smoke must default to Expo precompiled modules for fast local and CI validation" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "pod install --repo-update" \ + "Expo iOS smoke must refresh CocoaPods specs when source-built Expo modules require pods outside the local cache" +require_text src/sdks/react-native/tools/expo-ios-runner.sh '-clonedSourcePackagesDirPath "$xcode_source_packages"' \ + "Expo iOS smoke must isolate Xcode SwiftPM source packages inside the smoke scratch directory" +require_text src/sdks/react-native/tools/expo-ios-runner.sh '-packageCachePath "$xcode_package_cache"' \ + "Expo iOS smoke must isolate Xcode SwiftPM package cache inside the smoke scratch directory" +require_text src/sdks/react-native/tools/expo-ios-runner.sh "-skipPackageUpdates" \ + "Expo iOS smoke must not update SwiftPM packages during deterministic CI builds" +require_text src/sdks/react-native/tools/expo-ios-runner.sh ":modular_headers => true" \ + "Expo iOS smoke must integrate the local C bridge as a modular header pod" +require_text src/sdks/react-native/android/settings.gradle 'project(":oliphaunt").projectDir = localKotlinSdkDir' \ + "React Native Android settings must include the local Kotlin SDK project" +require_text src/sdks/react-native/android/build.gradle "implementation localKotlinSdkProject" \ + "React Native Android must depend on the Kotlin SDK when built locally" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "import Oliphaunt" \ + "React Native iOS adapter must import the Swift SDK" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "import dev.oliphaunt.OliphauntAndroid" \ + "React Native Android module must import the Kotlin SDK facade" +require_text src/sdks/rust/src/engine.rs "pub struct EngineModeSupport" \ + "Rust SDK must expose an explicit mode support contract" +require_text src/sdks/rust/src/database.rs "pub async fn transaction(&self) -> Result" \ + "Rust SDK must expose a transaction helper" +require_text src/sdks/rust/src/database.rs "pub async fn with_transaction" \ + "Rust SDK must expose a closure transaction helper" +require_text src/sdks/rust/src/database.rs "pub async fn exec_protocol_raw_stream" \ + "Rust SDK session pins and transactions must expose pinned streaming protocol calls" +require_text src/sdks/rust/src/database.rs "pub async fn checkpoint(&self) -> Result<()>" \ + "Rust SDK must expose checkpoint" +require_text src/sdks/rust/src/engine.rs "pub multi_root: bool" \ + "Rust SDK capabilities must expose multi-root support" +require_text src/sdks/rust/src/engine.rs "pub backup_formats: Vec" \ + "Rust SDK capabilities must expose supported backup formats" +require_text src/sdks/rust/src/engine.rs "pub restore_formats: Vec" \ + "Rust SDK capabilities must expose supported restore formats" +require_text src/sdks/rust/src/engine.rs "pub fn supports_backup_format(&self, format: BackupFormat) -> bool" \ + "Rust SDK capabilities must expose backup format helper" +require_text src/sdks/rust/src/engine.rs "pub fn supports_restore_format(&self, format: BackupFormat) -> bool" \ + "Rust SDK capabilities must expose restore format helper" +require_text src/sdks/rust/src/database.rs "pub fn supports_backup_format(&self, format: BackupFormat) -> bool" \ + "Rust SDK opened handle must expose backup format helper" +require_text src/sdks/rust/src/database.rs "pub fn supports_restore_format(&self, format: BackupFormat) -> bool" \ + "Rust SDK opened handle must expose restore format helper" +require_text src/sdks/rust/src/executor.rs "if !self.capabilities.supports_backup_format(request.format)" \ + "Rust SDK backup must reject unsupported formats before engine execution" +require_text src/sdks/rust/src/executor.rs "Command::Backup { request, reply } =>" \ + "Rust SDK backup must route through the owner executor" +require_text src/sdks/rust/src/executor.rs "Err(Error::SessionPinned)" \ + "Rust SDK owner executor must reject unpinned work while a session pin is active" +require_text src/sdks/rust/src/backup.rs "restore currently requires a physical archive artifact" \ + "Rust SDK restore must reject unsupported formats before target materialization" +require_text src/sdks/rust/src/config.rs "database root must not be empty" \ + "Rust SDK open config must reject empty persistent roots before runtime selection" +require_text src/sdks/rust/src/config.rs "database root must not contain NUL bytes" \ + "Rust SDK open config must reject NUL-containing persistent roots before runtime selection" +require_text src/sdks/rust/src/config.rs "native broker max_client_sessions must be exactly 1" \ + "Rust SDK broker mode must reject fake multi-session pools before helper startup" +require_text src/sdks/rust/src/config.rs "validate_config_path(\"initdb path\", initdb)" \ + "Rust SDK initdb tooling path must reject malformed paths before startup" +require_text src/sdks/rust/src/config.rs "validate_config_path(\"native broker executable path\", executable)" \ + "Rust SDK broker executable path must reject malformed paths before helper startup" +require_text src/sdks/rust/src/config.rs "validate_config_path(\"native server executable path\", executable)" \ + "Rust SDK server executable path must reject malformed paths before process startup" +require_text src/sdks/rust/src/backup.rs "restore target root must not contain NUL bytes" \ + "Rust SDK restore must reject NUL-containing target roots before archive unpack" +require_text src/sdks/rust/src/database.rs "call \`cancel()\` explicitly when a" \ + "Rust SDK close docs must require explicit cancellation rather than implicit close-time cancel" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public struct OliphauntEngineModeSupport" \ + "Swift SDK must expose an explicit mode support contract" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public func transaction" \ + "Swift SDK must expose a transaction helper" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public func checkpoint() async throws" \ + "Swift SDK must expose checkpoint" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public var multiRoot: Bool" \ + "Swift SDK capabilities must expose multi-root support" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public var backupFormats: [OliphauntBackupFormat]" \ + "Swift SDK capabilities must expose supported backup formats" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public var restoreFormats: [OliphauntBackupFormat]" \ + "Swift SDK capabilities must expose supported restore formats" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public func supportsBackupFormat(_ format: OliphauntBackupFormat) -> Bool" \ + "Swift SDK capabilities must expose backup format helper" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public func supportsRestoreFormat(_ format: OliphauntBackupFormat) -> Bool" \ + "Swift SDK capabilities must expose restore format helper" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public func supportsBackupFormat(_ format: OliphauntBackupFormat) async throws -> Bool" \ + "Swift SDK opened database must expose backup format helper" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "public func supportsRestoreFormat(_ format: OliphauntBackupFormat) async throws -> Bool" \ + "Swift SDK opened database must expose restore format helper" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "guard capabilities.supportsBackupFormat(request.format) else" \ + "Swift SDK backup must reject unsupported formats before native session calls" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "guard request.artifact.format == .physicalArchive else" \ + "Swift SDK restore must reject unsupported formats before engine calls" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "must be a file URL" \ + "Swift SDK must reject non-file roots before engine calls" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "validateOliphauntRoot(configuration.root, label: \"database root\")" \ + "Swift SDK open must validate roots before engine calls" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "validateOliphauntRoot(request.root, label: \"restore root\")" \ + "Swift SDK restore must validate roots before engine calls" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "must not contain NUL bytes" \ + "Swift SDK root validation must reject NUL-containing roots before C ABI calls" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "try await closingSession.close()" \ + "Swift SDK close must wait on session close without issuing implicit cancel" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public data class EngineModeSupport" \ + "Kotlin SDK must expose an explicit mode support contract" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public suspend fun transaction" \ + "Kotlin SDK must expose a transaction helper" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public suspend fun checkpoint()" \ + "Kotlin SDK must expose checkpoint" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "val multiRoot: Boolean" \ + "Kotlin SDK capabilities must expose multi-root support" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "val backupFormats: List" \ + "Kotlin SDK capabilities must expose supported backup formats" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "val restoreFormats: List" \ + "Kotlin SDK capabilities must expose supported restore formats" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public fun supportsBackupFormat(format: BackupFormat): Boolean" \ + "Kotlin SDK capabilities must expose backup format helper" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public fun supportsRestoreFormat(format: BackupFormat): Boolean" \ + "Kotlin SDK capabilities must expose restore format helper" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public suspend fun supportsBackupFormat(format: BackupFormat): Boolean" \ + "Kotlin SDK opened database must expose backup format helper" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public suspend fun supportsRestoreFormat(format: BackupFormat): Boolean" \ + "Kotlin SDK opened database must expose restore format helper" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "if (!capabilities.supportsBackupFormat(request.format))" \ + "Kotlin SDK backup must reject unsupported formats before platform session calls" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "if (request.artifact.format != BackupFormat.PhysicalArchive)" \ + "Kotlin SDK restore must reject unsupported formats before platform engine calls" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "validateRootPath(config.root, \"database root\")" \ + "Kotlin SDK open must reject malformed roots before platform engine calls" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "validateRootPath(request.root, \"restore root\")" \ + "Kotlin SDK restore must reject malformed roots before platform engine calls" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "must not contain NUL bytes" \ + "Kotlin SDK root validation must reject NUL-containing roots before platform engine calls" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "executionMutex.withLock {" \ + "Kotlin SDK close must wait on serialized session close without issuing implicit cancel" +require_text src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt "oliphaunt_kotlin_close(current)" \ + "Kotlin/Native direct session close must detach through the native close bridge" +require_text src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt "OliphauntConfig as NativeOliphauntConfig" \ + "Kotlin/Native direct engine must not let the C ABI config shadow the public SDK config" +require_text src/sdks/kotlin/oliphaunt/src/nativeMain/kotlin/dev/oliphaunt/NativeDirectEngine.kt "alloc" \ + "Kotlin/Native direct engine must allocate the aliased C ABI config explicitly" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "defaultOliphauntEngine(EngineMode.NativeDirect)" \ + "Kotlin SDK common restore/support defaults must use the platform default native-direct engine" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/DefaultEngine.kt "use OliphauntAndroid.open(context, config)" \ + "Kotlin Android common open default must point apps to the Context facade" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/DefaultEngine.kt "use OliphauntAndroid.restore(context, request)" \ + "Kotlin Android common restore default must point apps to the Context facade" +require_text src/sdks/react-native/src/client.ts "supportedModes(): Promise" \ + "React Native SDK must expose mode support discovery" +require_text src/sdks/react-native/src/client.ts "async transaction" \ + "React Native SDK must expose a transaction helper" +require_text src/sdks/react-native/src/client.ts "async checkpoint(): Promise" \ + "React Native SDK must expose checkpoint" +require_text src/sdks/react-native/src/client.ts "multiRoot: boolean" \ + "React Native SDK capabilities must expose multi-root support" +require_text src/sdks/react-native/src/client.ts "backupFormats: BackupFormat[]" \ + "React Native SDK capabilities must expose supported backup formats" +require_text src/sdks/react-native/src/client.ts "restoreFormats: BackupFormat[]" \ + "React Native SDK capabilities must expose supported restore formats" +require_text src/sdks/react-native/src/client.ts "export function supportsBackupFormat" \ + "React Native SDK must expose a backup format helper" +require_text src/sdks/react-native/src/client.ts "export function supportsRestoreFormat" \ + "React Native SDK must expose a restore format helper" +require_text src/sdks/react-native/src/client.ts "if (!supportsBackupFormat(capabilities, format))" \ + "React Native SDK backup must reject unsupported formats before TurboModule calls" +require_text src/sdks/react-native/src/client.ts "if (artifact.format !== 'physicalArchive')" \ + "React Native SDK restore must reject unsupported formats before TurboModule calls" +require_text src/sdks/react-native/src/client.ts "database root must not be empty" \ + "React Native SDK open must reject blank roots before TurboModule calls" +require_text src/sdks/react-native/src/client.ts "restore root must not be empty" \ + "React Native SDK restore must reject blank roots before TurboModule calls" +require_text src/sdks/react-native/src/client.ts "database root must not contain NUL bytes" \ + "React Native SDK open must reject NUL-containing roots before TurboModule calls" +require_text src/sdks/react-native/src/client.ts "restore root must not contain NUL bytes" \ + "React Native SDK restore must reject NUL-containing roots before TurboModule calls" +require_text src/sdks/react-native/src/client.ts "libraryPath must not be empty" \ + "React Native SDK must reject blank native library overrides before TurboModule calls" +require_text src/sdks/react-native/src/client.ts "runtimeDirectory must not be empty" \ + "React Native SDK must reject blank native runtime-directory overrides before TurboModule calls" +require_text src/sdks/react-native/src/__tests__/client.test.ts "libraryPath must not contain NUL bytes" \ + "React Native SDK tests must prove malformed native override paths stay before the bridge" +require_text src/sdks/react-native/src/client.ts "await this.#native.close(this.#handle);" \ + "React Native SDK close must delegate close without issuing implicit cancel" +require_text src/sdks/react-native/src/client.ts "libraryPath?: string;" \ + "React Native SDK restore options must accept native library override" +require_text src/sdks/react-native/src/client.ts "libraryPath ?? null" \ + "React Native SDK restore must forward native library override to JSI transport" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt 'putBoolean("multiRoot", multiRoot)' \ + "React Native Android must delegate multi-root capability from Kotlin" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift 'values["multiRoot"] = capabilities.multiRoot' \ + "React Native iOS must delegate multi-root capability from Swift" +require_text src/sdks/react-native/src/jsiTransport.ts "libraryPath: string | null" \ + "React Native JSI transport must carry restore native library override" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "OliphauntDatabase.supportedModes()" \ + "React Native iOS must delegate mode support to the Swift SDK" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "OliphauntAndroid.supportedModes()" \ + "React Native Android must delegate mode support to the Kotlin SDK" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "libraryPath: String?" \ + "React Native iOS restore must accept native library override" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "libraryPath = reactNativeLibraryPath(validatePathOverride(libraryPath, \"libraryPath\"))" \ + "React Native Android restore must forward native library override to Kotlin SDK" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "execProtocolStream" \ + "Swift SDK must expose streaming raw-protocol execution" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "queryCancel" \ + "Swift SDK must expose query-cancel capability reporting" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "backupRestore" \ + "Swift SDK must expose backup/restore capability reporting" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "simpleQuery" \ + "Swift SDK must expose simple-query capability reporting" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "execProtocolStream" \ + "Kotlin SDK must expose streaming raw-protocol execution" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "queryCancel" \ + "Kotlin SDK must expose query-cancel capability reporting" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "backupRestore" \ + "Kotlin SDK must expose backup/restore capability reporting" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "simpleQuery" \ + "Kotlin SDK must expose simple-query capability reporting" +require_text src/sdks/react-native/src/client.ts "execProtocolStream" \ + "React Native SDK must expose streaming raw-protocol execution" +require_text src/sdks/react-native/src/client.ts "queryCancel" \ + "React Native SDK must expose query-cancel capability reporting" +require_text src/sdks/react-native/src/client.ts "backupRestore" \ + "React Native SDK must expose backup/restore capability reporting" +require_text src/sdks/react-native/src/client.ts "simpleQuery" \ + "React Native SDK must expose simple-query capability reporting" +require_text src/sdks/rust/src/error.rs "pub struct PostgresError" \ + "Rust SDK must expose structured PostgreSQL errors" +require_text src/sdks/rust/src/query.rs "parse_postgres_error_response" \ + "Rust SDK query parser must preserve PostgreSQL ErrorResponse fields" +require_text src/sdks/swift/Sources/Oliphaunt/Oliphaunt.swift "case postgres(OliphauntPostgresError)" \ + "Swift SDK must expose structured PostgreSQL errors" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntQuery.swift "public struct OliphauntPostgresError" \ + "Swift SDK query parser must preserve PostgreSQL ErrorResponse fields" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Oliphaunt.kt "public class PostgresException" \ + "Kotlin SDK must expose structured PostgreSQL errors" +require_text src/sdks/kotlin/oliphaunt/src/commonMain/kotlin/dev/oliphaunt/Query.kt "public data class PostgresError" \ + "Kotlin SDK query parser must preserve PostgreSQL ErrorResponse fields" +require_text src/sdks/react-native/src/query.ts "export class PostgresError extends Error" \ + "React Native SDK must expose structured PostgreSQL errors" +require_text src/sdks/react-native/src/index.ts "PostgresError" \ + "React Native SDK must re-export structured PostgreSQL errors" +require_text src/sdks/react-native/src/client.ts "validateExtensionIds" \ + "React Native SDK must validate extension identifiers before crossing the bridge" +require_text src/sdks/react-native/src/__tests__/client.test.ts "mobile/vector" \ + "React Native SDK must test malformed extension identifiers before native open" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "extensions must be an array of strings" \ + "React Native iOS adapter must reject malformed extension arrays before Swift SDK open" +reject_text src/sdks/react-native/ios/OliphauntAdapter.swift 'compactMap { $0 as? String }' \ + "React Native iOS adapter must not silently drop malformed extension entries" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "startupIdentity" \ + "React Native iOS adapter must validate startup identity before Swift SDK open" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "username must not contain NUL bytes" \ + "React Native iOS adapter must reject malformed startup identity before Swift SDK open" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "resourceRoot must not be empty" \ + "React Native iOS adapter must reject blank resource roots before Swift SDK open" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "must be a string" \ + "React Native iOS adapter must reject malformed scalar config values before Swift SDK open" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "libraryPath must not be empty" \ + "React Native iOS adapter must reject blank native library overrides before Swift SDK open/restore" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "runtimeDirectory must not be empty" \ + "React Native iOS adapter must reject blank runtime-directory overrides before Swift SDK open" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "return try nonBlankValue(try string(dictionary, key), key, emptyMessage: emptyMessage)" \ + "React Native iOS adapter path helper must reject NUL-containing roots and native override paths" +reject_text src/sdks/react-native/ios/OliphauntAdapter.swift 'username: string(config, "username")' \ + "React Native iOS adapter must not drop empty startup identity values before Swift SDK open" +reject_text src/sdks/react-native/ios/OliphauntAdapter.swift '(value as? String)?.isEmpty == false' \ + "React Native iOS adapter must not silently drop malformed scalar config values" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "extensions must be an array of strings" \ + "React Native Android adapter must reject malformed extension arrays before Kotlin SDK open" +reject_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt 'getString(index)?.let(::add)' \ + "React Native Android adapter must not silently drop malformed extension entries" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "startupIdentity" \ + "React Native Android adapter must validate startup identity before Kotlin SDK open" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "username must not contain NUL bytes" \ + "React Native Android adapter must reject malformed startup identity before Kotlin SDK open" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt '$name must be a string' \ + "React Native Android adapter must reject malformed scalar config values before Kotlin SDK open" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "pathOverride" \ + "React Native Android adapter must validate native override paths before Kotlin SDK open/restore" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "libraryPath must not be empty" \ + "React Native Android adapter must reject blank native library overrides before Kotlin SDK open/restore" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "validateRootPath(root, \"restore root\")" \ + "React Native Android adapter must reject malformed restore roots before Kotlin SDK restore" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "validateRootPath(it, \"database root\")" \ + "React Native Android adapter must reject malformed open roots before Kotlin SDK open" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "extension/vector.control" \ + "Swift SDK tests must prove selected mobile extension assets materialize" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "extension/hstore.control" \ + "Swift SDK tests must prove unselected mobile extension assets stay invisible" +require_text src/sdks/kotlin/tools/check-sdk.sh "selected vector extension control file" \ + "Kotlin SDK check must prove selected Android extension assets are packaged" +require_text src/sdks/kotlin/tools/check-sdk.sh "unselected hstore extension control file" \ + "Kotlin SDK check must prove unselected Android extension assets stay invisible" +require_text src/sdks/react-native/tools/check-sdk.sh "unselected hstore extension control file" \ + "React Native SDK check must prove Android AAR extension asset boundaries through Kotlin" +require_text src/sdks/kotlin/tools/check-sdk.sh "package-size.tsv" \ + "Kotlin SDK check must prove Android resource packaging preserves package-size reports" +require_text src/sdks/react-native/tools/check-sdk.sh "package-size.tsv" \ + "React Native SDK check must prove Android AAR packaging preserves package-size reports" +require_text src/sdks/rust/tools/check-sdk.sh "cargo package -p oliphaunt --locked --allow-dirty --list" \ + "Rust SDK check must inspect the cargo package file list before release" +require_text src/sdks/swift/tools/check-sdk.sh "archive-source --output" \ + "Swift SDK check must inspect the SwiftPM source archive before release" +require_text src/sdks/swift/tools/check-sdk.sh "reject_archive_entry_prefix" \ + "Swift SDK check must reject generated build directories from the SwiftPM source archive" +require_text src/sdks/react-native/tools/check-sdk.sh "ios/podspecs/Oliphaunt.podspec" \ + "React Native SDK check must prove the packed artifact includes the Swift SDK podspec shim needed by iOS autolinking" +require_text src/sdks/kotlin/tools/check-sdk.sh ":oliphaunt:bundleReleaseAar" \ + "Kotlin SDK check must assemble the Android release AAR package surface" +require_text src/sdks/kotlin/tools/check-sdk.sh 'kotlin_version="$(kotlin_package_version)"' \ + "Kotlin SDK check must derive package artifact names from Gradle release metadata" +require_text src/sdks/kotlin/tools/check-sdk.sh 'oliphaunt-metadata-$kotlin_version-sources.jar' \ + "Kotlin SDK check must inspect Kotlin Multiplatform source artifacts" +require_text src/sdks/kotlin/tools/check-sdk.sh 'oliphaunt-metadata-$kotlin_version.jar' \ + "Kotlin SDK check must inspect Kotlin Multiplatform metadata artifacts" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts 'it.name.startsWith("cinteropOliphaunt")' \ + "Kotlin/Native cinterop tasks must build the local static bridge before interop" +require_text src/sdks/kotlin/oliphaunt/build.gradle.kts "dependsOn(buildNativeBridge)" \ + "Kotlin/Native cinterop tasks must depend on the generated bridge archive" +require_text src/sdks/react-native/tools/check-sdk.sh "pack --dry-run --json" \ + "React Native SDK check must inspect the Node package file list before release" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift "oliphaunt-runtime-resources-v1" \ + "Swift SDK must validate the shared runtime-resource schema" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssets.kt "oliphaunt-runtime-resources-v1" \ + "Kotlin Android SDK must validate the shared runtime-resource schema" +require_text src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt "unsupported runtime resource schema" \ + "Kotlin Android SDK must test stale runtime-resource schema rejection" +require_text src/sdks/swift/Sources/Oliphaunt/OliphauntRuntimeResources.swift "OliphauntRuntimeResourceSizeReport" \ + "Swift SDK must expose the shared package-size report" +require_text src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift "runtimeResourcesExposePackageSizeReport" \ + "Swift SDK tests must prove package-size report parsing" +require_text src/sdks/kotlin/oliphaunt/src/androidMain/kotlin/dev/oliphaunt/AndroidNativeDirectEngine.kt "packageSizeReport" \ + "Kotlin Android SDK must expose package-size report parsing" +require_text src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt "parsesPackageSizeReportFromResourceRoot" \ + "Kotlin Android SDK tests must prove local resource-root package-size parsing" +require_text src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidRuntimeAssetsTest.kt "parsesPackageSizeReport" \ + "Kotlin Android SDK tests must prove package-size report parsing" +require_text src/sdks/react-native/src/client.ts "packageSizeReport" \ + "React Native SDK must expose package-size report parsing" +require_text src/sdks/react-native/src/__tests__/client.test.ts "testPackageSizeReportDelegatesToNativeSdk" \ + "React Native SDK tests must prove package-size report delegation" +require_text src/sdks/react-native/android/src/main/java/dev/oliphaunt/reactnative/OliphauntModule.kt "OliphauntAndroid.packageSizeReport" \ + "React Native Android must delegate package-size reports to the Kotlin SDK" +require_text src/sdks/react-native/ios/OliphauntAdapter.swift "packageSizeReportWithConfig" \ + "React Native iOS must delegate package-size reports to the Swift SDK" +tools/policy/check-sdk-mobile-extension-surface.sh +tools/policy/check-react-native-boundary.sh + +printf '\nSDK parity ownership checks passed.\n' diff --git a/tools/policy/check-semver.sh b/tools/policy/check-semver.sh new file mode 100755 index 00000000..88a726a7 --- /dev/null +++ b/tools/policy/check-semver.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +cargo semver-checks check-release --package oliphaunt-wasix --manifest-path src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml diff --git a/tools/policy/check-source-inputs.mjs b/tools/policy/check-source-inputs.mjs new file mode 100755 index 00000000..210bde03 --- /dev/null +++ b/tools/policy/check-source-inputs.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +import {existsSync, readFileSync} from 'node:fs'; +import {spawnSync} from 'node:child_process'; +import process from 'node:process'; + +function run(command, args, options = {}) { + return spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); +} + +function workspaceRoot() { + const result = run('git', ['rev-parse', '--show-toplevel']); + if (result.status === 0) { + return result.stdout.trim(); + } + return process.cwd(); +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +function requireFile(path) { + if (!existsSync(path)) { + fail(`missing required file: ${path}`); + } +} + +function requireText(path, text) { + requireFile(path); + const contents = readFileSync(path, 'utf8'); + if (!contents.includes(text)) { + fail(`${path} must contain ${text}`); + } +} + +function checkPostgres18() { + requireText('src/postgres/versions/18/source.toml', 'version = "18.4"'); + requireText('src/postgres/versions/18/source.toml', 'postgresql-18.4.tar.bz2'); + requireText('src/postgres/versions/18/source.toml', 'sha256 = "'); +} + +function checkThirdParty() { + checkThirdPartyShared(); + checkThirdPartyNative(); + checkThirdPartyWasix(); +} + +function checkThirdPartyShared() { + for (const path of [ + 'src/sources/third-party/shared/icu.toml', + 'src/sources/third-party/shared/openssl.toml', + ]) { + requireText(path, 'name = "'); + requireText(path, 'commit = "'); + } +} + +function checkThirdPartyNative() { + requireFile('src/sources/third-party/native/README.md'); +} + +function checkThirdPartyWasix() { + requireFile('src/sources/third-party/wasix/README.md'); +} + +function checkToolchains() { + requireText('src/sources/toolchains/wasix.toml', '[toolchain]'); + requireText('src/sources/toolchains/wasix.toml', '[build]'); + requireText('src/sources/toolchains/maestro.toml', '[toolchain]'); + requireText('src/sources/toolchains/maestro.toml', 'cloud_required = false'); + requireText('src/sources/toolchains/android-emulator-runner.toml', 'repository = "ReactiveCircus/android-emulator-runner"'); + requireText('src/sources/toolchains/android-emulator-runner.toml', 'sha = "70f4dee990796918b78d040e3278474bdbd348a7"'); + requireText('src/sources/toolchains/android-emulator-runner.toml', 'cloud_required = false'); +} + +function checkExtensions() { + for (const path of [ + 'src/extensions/catalog/extensions.promoted.toml', + 'src/extensions/catalog/extensions.smoke.toml', + 'src/extensions/contrib/postgres18.toml', + 'src/extensions/external/README.md', + 'src/extensions/external/vector/source.toml', + 'src/extensions/external/postgis/source.toml', + 'src/extensions/external/postgis/dependencies/geos/source.toml', + 'src/extensions/external/postgis/dependencies/proj/source.toml', + 'src/extensions/external/postgis/dependencies/sqlite/source.toml', + 'src/extensions/external/postgis/dependencies/libxml2/source.toml', + 'src/extensions/external/postgis/dependencies/json-c/source.toml', + 'src/extensions/external/postgis/dependencies/libiconv/source.toml', + 'src/extensions/schemas/recipe.schema.json', + 'src/extensions/schemas/support-table.schema.json', + 'src/extensions/evidence/matrix.toml', + 'src/extensions/evidence/schemas/matrix.schema.json', + 'src/extensions/evidence/schemas/run.schema.json', + 'src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json', + 'src/extensions/generated/extensions.catalog.json', + 'src/extensions/generated/extensions.build-plan.json', + 'src/extensions/generated/contrib-build.tsv', + 'src/extensions/generated/pgxs-build.tsv', + 'src/extensions/generated/docs/extensions.json', + 'src/extensions/generated/docs/extension-evidence.json', + 'src/extensions/generated/sdk/rust.json', + 'src/extensions/generated/sdk/swift.json', + 'src/extensions/generated/sdk/kotlin.json', + 'src/extensions/generated/sdk/js.json', + 'src/extensions/generated/sdk/react-native.json', + 'src/sdks/rust/src/generated/extensions.rs', + 'src/sdks/js/src/generated/extensions.ts', + 'src/sdks/kotlin/oliphaunt/src/generated/extensions.json', + 'src/sdks/react-native/src/generated/extensions.ts', + 'src/sdks/react-native/src/generated/extensions.json', + 'src/extensions/generated/mobile/static-registry.json', + 'src/extensions/generated/mobile/static-extensions.tsv', + 'src/extensions/generated/wasix/extensions.json', + 'src/extensions/tools/check-extension-model.py', + ]) { + requireFile(path); + } + + const result = spawnSync('python3', ['src/extensions/tools/check-extension-model.py', '--check'], { + stdio: 'inherit', + }); + if (result.error !== undefined) { + fail(result.error.message); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function checkRepoPolicy() { + const assets = run('git', ['ls-files', 'assets']); + if (assets.status !== 0) { + process.exit(assets.status ?? 1); + } + if (assets.stdout.trim().length > 0) { + fail(`root assets/ must not contain tracked files:\n${assets.stdout.trim()}`); + } + const retiredThirdParty = run('git', ['ls-files', 'src/third-party']); + if (retiredThirdParty.status !== 0) { + process.exit(retiredThirdParty.status ?? 1); + } + if (retiredThirdParty.stdout.trim().length > 0) { + fail(`src/third-party must not contain tracked files:\n${retiredThirdParty.stdout.trim()}`); + } + + const removedName = 'pg' + 'lite'; + const grep = run('git', [ + 'grep', + '-I', + '-i', + '-n', + '-e', + `@electric-sql/${removedName}`, + '-e', + `@electric-sql/${removedName}-socket`, + '-e', + `electric-sql/${removedName}`, + '-e', + `postgres-${removedName}`, + '-e', + `${removedName}-build`, + '-e', + `${removedName}-bindings`, + '-e', + `REL_17_5-${removedName}`, + '-e', + 'pgl_startPG' + 'lite', + '-e', + 'PG' + 'Lite', + '-e', + removedName, + '--', + ':!target/**', + ':!node_modules/**', + ]); + if (grep.status === 0) { + console.error(grep.stdout); + fail('removed upstream identifiers remain in tracked source'); + } + if (grep.status !== 1) { + process.exit(grep.status ?? 1); + } +} + +process.chdir(workspaceRoot()); + +const scope = process.argv[2] ?? 'all'; +switch (scope) { + case 'postgres18': + checkPostgres18(); + break; + case 'third-party': + checkThirdParty(); + break; + case 'third-party-shared': + checkThirdPartyShared(); + break; + case 'third-party-native': + checkThirdPartyNative(); + break; + case 'third-party-wasix': + checkThirdPartyWasix(); + break; + case 'toolchains': + checkToolchains(); + break; + case 'extensions': + checkPostgres18(); + checkThirdParty(); + checkExtensions(); + break; + case 'all': + checkPostgres18(); + checkThirdParty(); + checkToolchains(); + checkExtensions(); + checkRepoPolicy(); + break; + default: + fail('usage: check-source-inputs.mjs [postgres18|third-party|third-party-shared|third-party-native|third-party-wasix|toolchains|extensions|all]'); +} diff --git a/tools/policy/check-source-inputs.sh b/tools/policy/check-source-inputs.sh new file mode 100755 index 00000000..a9474aaf --- /dev/null +++ b/tools/policy/check-source-inputs.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +scope="${1:-all}" + +check_postgres18() { + test -f src/postgres/versions/18/source.toml + grep -Fq 'version = "18.4"' src/postgres/versions/18/source.toml + grep -Fq 'postgresql-18.4.tar.bz2' src/postgres/versions/18/source.toml + grep -Fq 'sha256 = "' src/postgres/versions/18/source.toml +} + +check_third_party() { + check_third_party_shared + check_third_party_native + check_third_party_wasix +} + +check_third_party_shared() { + for source_pin in \ + src/sources/third-party/shared/icu.toml \ + src/sources/third-party/shared/openssl.toml; do + test -f "$source_pin" + grep -Fq 'name = "' "$source_pin" + grep -Fq 'commit = "' "$source_pin" + done +} + +check_third_party_native() { + test -f src/sources/third-party/native/README.md +} + +check_third_party_wasix() { + test -f src/sources/third-party/wasix/README.md +} + +check_toolchains() { + test -f src/sources/toolchains/wasix.toml + grep -Fq '[toolchain]' src/sources/toolchains/wasix.toml + grep -Fq '[build]' src/sources/toolchains/wasix.toml + test -f src/sources/toolchains/maestro.toml + grep -Fq '[toolchain]' src/sources/toolchains/maestro.toml + grep -Fq 'cloud_required = false' src/sources/toolchains/maestro.toml + test -f src/sources/toolchains/android-emulator-runner.toml + grep -Fq 'repository = "ReactiveCircus/android-emulator-runner"' src/sources/toolchains/android-emulator-runner.toml + grep -Fq 'sha = "70f4dee990796918b78d040e3278474bdbd348a7"' src/sources/toolchains/android-emulator-runner.toml + grep -Fq 'cloud_required = false' src/sources/toolchains/android-emulator-runner.toml +} + +check_extensions() { + test -f src/extensions/catalog/extensions.promoted.toml + test -f src/extensions/catalog/extensions.smoke.toml + test -f src/extensions/contrib/postgres18.toml + test -f src/extensions/external/README.md + test -f src/extensions/external/vector/source.toml + test -f src/extensions/external/postgis/source.toml + test -f src/extensions/external/postgis/dependencies/geos/source.toml + test -f src/extensions/external/postgis/dependencies/proj/source.toml + test -f src/extensions/external/postgis/dependencies/sqlite/source.toml + test -f src/extensions/external/postgis/dependencies/libxml2/source.toml + test -f src/extensions/external/postgis/dependencies/json-c/source.toml + test -f src/extensions/external/postgis/dependencies/libiconv/source.toml + test -f src/extensions/schemas/recipe.schema.json + test -f src/extensions/schemas/support-table.schema.json + test -f src/extensions/evidence/matrix.toml + test -f src/extensions/evidence/schemas/matrix.schema.json + test -f src/extensions/evidence/schemas/run.schema.json + test -f src/extensions/evidence/runs/2026-06-07-transitional-catalog-smoke.json + test -f src/extensions/generated/extensions.catalog.json + test -f src/extensions/generated/extensions.build-plan.json + test -f src/extensions/generated/contrib-build.tsv + test -f src/extensions/generated/pgxs-build.tsv + test -f src/extensions/generated/docs/extensions.json + test -f src/extensions/generated/docs/extension-evidence.json + test -f src/extensions/generated/sdk/rust.json + test -f src/extensions/generated/sdk/swift.json + test -f src/extensions/generated/sdk/kotlin.json + test -f src/extensions/generated/sdk/js.json + test -f src/extensions/generated/sdk/react-native.json + test -f src/sdks/rust/src/generated/extensions.rs + test -f src/extensions/generated/mobile/static-registry.json + test -f src/extensions/generated/mobile/static-extensions.tsv + test -f src/extensions/generated/wasix/extensions.json + test -f src/extensions/tools/check-extension-model.py + python3 src/extensions/tools/check-extension-model.py --check +} + +check_repo_policy() { + if tracked="$(git ls-files assets)" && [ -n "$tracked" ]; then + printf 'root assets/ must not contain tracked files:\n%s\n' "$tracked" >&2 + exit 1 + fi + if tracked="$(git ls-files src/third-party)" && [ -n "$tracked" ]; then + printf 'src/third-party must not contain tracked files:\n%s\n' "$tracked" >&2 + exit 1 + fi + removed_name="pg""lite" + if git grep -I -i -n \ + -e "@electric-sql/${removed_name}" \ + -e "@electric-sql/${removed_name}-socket" \ + -e "electric-sql/${removed_name}" \ + -e "postgres-${removed_name}" \ + -e "${removed_name}-build" \ + -e "${removed_name}-bindings" \ + -e "REL_17_5-${removed_name}" \ + -e "pgl_startPG""lite" \ + -e "PG""Lite" \ + -e "${removed_name}" \ + -- ':!target/**' ':!node_modules/**'; then + echo "removed upstream identifiers remain in tracked source" >&2 + exit 1 + fi +} + +case "$scope" in + postgres18) + check_postgres18 + ;; + third-party) + check_third_party + ;; + third-party-shared) + check_third_party_shared + ;; + third-party-native) + check_third_party_native + ;; + third-party-wasix) + check_third_party_wasix + ;; + toolchains) + check_toolchains + ;; + extensions) + check_postgres18 + check_third_party + check_extensions + ;; + all) + check_postgres18 + check_third_party + check_toolchains + check_extensions + check_repo_policy + ;; + *) + echo "usage: $0 [postgres18|third-party|third-party-shared|third-party-native|third-party-wasix|toolchains|extensions|all]" >&2 + exit 2 + ;; +esac diff --git a/tools/policy/check-supply-chain.sh b/tools/policy/check-supply-chain.sh new file mode 100755 index 00000000..85f56d21 --- /dev/null +++ b/tools/policy/check-supply-chain.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +cargo deny check diff --git a/tools/policy/check-test-strategy.mjs b/tools/policy/check-test-strategy.mjs new file mode 100755 index 00000000..29b8d35d --- /dev/null +++ b/tools/policy/check-test-strategy.mjs @@ -0,0 +1,603 @@ +#!/usr/bin/env node +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { runMoon } from './moon.mjs'; + +function fail(message) { + throw new Error(message); +} + +function requireFile(path) { + if (!existsSync(path)) { + fail(`missing test strategy file: ${path}`); + } +} + +function requireText(path, text) { + const source = readFileSync(path, 'utf8'); + if (!source.includes(text)) { + fail(`expected '${text}' in ${path}`); + } +} + +function rejectText(path, text) { + const source = readFileSync(path, 'utf8'); + if (source.includes(text)) { + fail(`unexpected '${text}' in ${path}`); + } +} + +function posixRelative(from, to) { + return path.relative(from, to).split(path.sep).join('/'); +} + +function expectedJsTestScript(packagePath) { + const packageDir = path.dirname(packagePath); + const runner = posixRelative(packageDir, 'tools/test/run-js-tests.mjs'); + return `node ${runner} src/__tests__`; +} + +function parseTasks() { + const parsed = JSON.parse(runMoon(['query', 'tasks'])); + if (!parsed.tasks || typeof parsed.tasks !== 'object') { + fail('moon query tasks did not return a tasks object'); + } + return parsed.tasks; +} + +function taskCommand(tasks, projectId, taskId) { + const task = tasks[projectId]?.[taskId]; + if (!task) { + fail(`missing moon task ${projectId}:${taskId}`); + } + return [task.command, ...(task.args ?? [])].join(' ').trim(); +} + +function configuredTask(tasks, projectId, taskId) { + const configured = tasks[projectId]?.[taskId]; + if (!configured) { + fail(`missing moon task ${projectId}:${taskId}`); + } + return configured; +} + +function taskDeps(tasks, projectId, taskId) { + return (configuredTask(tasks, projectId, taskId).deps ?? []).map((dep) => dep.target ?? dep); +} + +function requireTaskDependency(tasks, projectId, taskId, dependency) { + const deps = taskDeps(tasks, projectId, taskId); + if (!deps.includes(dependency)) { + fail(`${projectId}:${taskId} must depend on ${dependency}; got [${deps.join(', ')}]`); + } +} + +function requireDistinctTaskCommands(tasks, projectId, left, right) { + const leftCommand = taskCommand(tasks, projectId, left); + const rightCommand = taskCommand(tasks, projectId, right); + if (leftCommand === rightCommand) { + fail(`${projectId}:${left} and ${projectId}:${right} must not call the same command`); + } +} + +for (const path of [ + 'src/shared/fixtures/protocol/query-response-cases.json', + 'src/shared/fixtures/sdk-capabilities/mode-support.json', + 'src/shared/fixtures/runtime-resources/manifest.properties', + 'src/shared/fixtures/runtime-resources/template-pgdata-manifest.properties', + 'src/shared/fixtures/runtime-resources/package-size.tsv', + 'src/shared/fixtures/backup/physical-archive-manifest.json', + 'src/shared/fixtures/lifecycle/session-lifecycle.json', + 'src/shared/fixtures/react-native-jsi/binary-transport.json', + 'coverage/baseline.toml', + 'tools/runtime/preflight.sh', + 'tools/test/run-js-tests.mjs', +]) { + requireFile(path); +} + +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); +const rootScripts = Object.keys(packageJson.scripts ?? {}); +if (rootScripts.length !== 0) { + fail(`root package.json scripts must be empty; use moon directly, got ${rootScripts.join(', ')}`); +} + +const tasks = parseTasks(); + +for (const [projectId, projectTasks] of Object.entries(tasks)) { + if (projectTasks.check && projectTasks.test) { + requireDistinctTaskCommands(tasks, projectId, 'check', 'test'); + } +} + +const peerProducts = [ + 'oliphaunt-rust', + 'oliphaunt-swift', + 'oliphaunt-kotlin', + 'oliphaunt-js', + 'oliphaunt-react-native', + 'oliphaunt-wasix-rust', +]; + +for (const product of peerProducts) { + const requiredTaskIds = + product === 'oliphaunt-wasix-rust' + ? ['check', 'test', 'package', 'example-check', 'coverage', 'bench'] + : ['check', 'test', 'package', 'smoke', 'regression', 'coverage', 'bench']; + for (const taskId of requiredTaskIds) { + taskCommand(tasks, product, taskId); + if (configuredTask(tasks, product, taskId).state?.defaultInputs) { + fail(`${product}:${taskId} must declare explicit Moon inputs instead of default **/* inputs`); + } + } + requireDistinctTaskCommands(tasks, product, 'check', 'test'); + requireDistinctTaskCommands(tasks, product, 'test', 'package'); + if (product === 'oliphaunt-wasix-rust') { + requireDistinctTaskCommands(tasks, product, 'check', 'example-check'); + } + for (const cacheableTask of ['check', 'test', 'coverage', 'bench']) { + if (configuredTask(tasks, product, cacheableTask).options?.cache !== true) { + fail(`${product}:${cacheableTask} must be cacheable; it is deterministic product validation`); + } + } + if (requiredTaskIds.includes('smoke') && configuredTask(tasks, product, 'smoke').options?.cache !== 'local') { + fail(`${product}:smoke must use local-only caching for developer runtime probes`); + } + if (product === 'oliphaunt-wasix-rust' && configuredTask(tasks, product, 'example-check').options?.cache !== 'local') { + fail('oliphaunt-wasix-rust:example-check must use local-only caching for product-local example validation'); + } + if (!taskCommand(tasks, product, 'test').match(/(test-unit|check-unit\.sh|cargo nextest|swift test|gradle .*test|pnpm .* test|runtime-smoke)/)) { + fail(`${product}:test must run product-native tests, not only metadata checks`); + } + if (taskCommand(tasks, product, 'test').includes('--no-run')) { + fail(`${product}:test must execute deterministic tests; compile-only belongs in check/package`); + } + const coverageCommand = taskCommand(tasks, product, 'coverage'); + if (!coverageCommand.includes(`tools/coverage/run-product ${product}`)) { + fail(`${product}:coverage must run measured product coverage through tools/coverage/run-product`); + } + if (coverageCommand.includes('tools/policy/check-coverage.sh')) { + fail(`${product}:coverage must not be a metadata-only policy check`); + } + const coverageTask = configuredTask(tasks, product, 'coverage'); + const outputs = [ + ...Object.keys(coverageTask.outputFiles ?? {}), + ...Object.keys(coverageTask.outputGlobs ?? {}), + ...(coverageTask.outputs ?? []).map((output) => output.file ?? output.glob ?? output).filter(Boolean), + ]; + if (!outputs.includes(`target/coverage/${product}/**/*`)) { + fail(`${product}:coverage must declare target/coverage/${product}/**/* as a Moon output`); + } +} + +for (const taskId of ['check', 'release-check', 'runtime-portable', 'runtime-aot', 'smoke', 'regression']) { + taskCommand(tasks, 'liboliphaunt-wasix', taskId); + if (configuredTask(tasks, 'liboliphaunt-wasix', taskId).state?.defaultInputs) { + fail(`liboliphaunt-wasix:${taskId} must declare explicit Moon inputs instead of default **/* inputs`); + } +} +if (configuredTask(tasks, 'liboliphaunt-wasix', 'smoke').options?.cache !== 'local') { + fail('liboliphaunt-wasix:smoke must use local-only caching for developer runtime probes'); +} + +for (const task of ['smoke-android', 'smoke-ios', 'smoke-mobile']) { + taskCommand(tasks, 'oliphaunt-react-native', task); + if (tasks['oliphaunt-react-native'][task].options?.cache !== 'local') { + fail(`oliphaunt-react-native:${task} must use local-only caching`); + } +} +if (taskCommand(tasks, 'oliphaunt-react-native', 'smoke') !== taskCommand(tasks, 'oliphaunt-react-native', 'smoke-mobile')) { + fail('oliphaunt-react-native:smoke must be the explicit mobile smoke aggregate'); +} +requireTaskDependency(tasks, 'oliphaunt-react-native', 'smoke-mobile', 'oliphaunt-swift:smoke'); +requireTaskDependency(tasks, 'oliphaunt-react-native', 'smoke-mobile', 'oliphaunt-kotlin:smoke'); +for (const task of ['smoke', 'smoke-android', 'smoke-ios', 'smoke-mobile']) { + if (configuredTask(tasks, 'oliphaunt-react-native', task).options?.runInCI === false) { + fail(`oliphaunt-react-native:${task} is an explicit mobile smoke lane and must not set runInCI=false`); + } +} +if (taskCommand(tasks, 'oliphaunt-react-native', 'e2e') !== 'pnpm --dir src/sdks/react-native/examples/expo run mobile-e2e') { + fail('oliphaunt-react-native:e2e must be the explicit installed-app E2E aggregate'); +} +for (const task of [ + 'mobile-build-android', + 'mobile-e2e-android', + 'mobile-build-ios', + 'mobile-e2e-ios', +]) { + requireTaskDependency(tasks, 'oliphaunt-react-native', 'e2e', `oliphaunt-react-native:${task}`); +} +if (configuredTask(tasks, 'oliphaunt-react-native', 'e2e').options?.cache !== false) { + fail('oliphaunt-react-native:e2e must not use Moon cache; installed-app E2E is runtime evidence'); +} +if (configuredTask(tasks, 'oliphaunt-react-native', 'e2e').options?.runInCI !== false) { + fail('oliphaunt-react-native:e2e aggregate must not run in default Moon CI; CI selects platform E2E lanes explicitly'); +} +for (const task of [ + 'mobile-build-android', + 'mobile-e2e-android', + 'mobile-build-ios', + 'mobile-e2e-ios', +]) { + taskCommand(tasks, 'oliphaunt-react-native', task); + if (configuredTask(tasks, 'oliphaunt-react-native', task).options?.cache !== false) { + fail(`oliphaunt-react-native:${task} must not use Moon cache; mobile app build/e2e state is runtime evidence`); + } + const runInCI = configuredTask(tasks, 'oliphaunt-react-native', task).options?.runInCI; + if (task.startsWith('mobile-e2e-')) { + if (runInCI !== 'skip') { + fail(`oliphaunt-react-native:${task} must use runInCI=skip so broad Moon CI does not start installed-app E2E`); + } + } else if (runInCI === false) { + fail(`oliphaunt-react-native:${task} is a selected mobile CI lane and must not set runInCI=false`); + } +} +for (const task of ['mobile-drill-android', 'mobile-drill-ios']) { + taskCommand(tasks, 'oliphaunt-react-native', task); + if (configuredTask(tasks, 'oliphaunt-react-native', task).options?.cache !== false) { + fail(`oliphaunt-react-native:${task} must not use Moon cache; lifecycle/crash drills are runtime evidence`); + } + if (configuredTask(tasks, 'oliphaunt-react-native', task).options?.runInCI !== false) { + fail(`oliphaunt-react-native:${task} must stay out of default CI; schedule it in nightly/release/manual lanes`); + } +} +requireText('src/sdks/react-native/tools/mobile-build.sh', 'OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE="${OLIPHAUNT_EXPO_ANDROID_BUILD_TYPE:-release}"'); +requireText('src/sdks/react-native/tools/mobile-build.sh', 'OLIPHAUNT_EXPO_IOS_CONFIGURATION="${OLIPHAUNT_EXPO_IOS_CONFIGURATION:-Release}"'); +requireText('src/sdks/react-native/tools/mobile-e2e.sh', 'OLIPHAUNT_EXPO_ANDROID_E2E_ONLY=1'); +requireText('src/sdks/react-native/tools/mobile-e2e.sh', 'OLIPHAUNT_EXPO_IOS_E2E_ONLY=1'); +requireText('src/sdks/react-native/tools/mobile-e2e.sh', 'OLIPHAUNT_EXPO_IOS_CONFIGURATION="${OLIPHAUNT_EXPO_IOS_CONFIGURATION:-Release}"'); +requireText('src/sdks/react-native/tools/mobile-e2e.sh', 'OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER="${OLIPHAUNT_MOBILE_E2E_ASSERTION_RUNNER:-maestro}"'); +requireText('src/sdks/react-native/tools/mobile-e2e.sh', 'OLIPHAUNT_EXPO_ANDROID_LIFECYCLE_SMOKE="${OLIPHAUNT_EXPO_ANDROID_LIFECYCLE_SMOKE:-0}"'); +requireText('src/sdks/react-native/tools/mobile-e2e.sh', 'OLIPHAUNT_EXPO_IOS_LIFECYCLE_SMOKE="${OLIPHAUNT_EXPO_IOS_LIFECYCLE_SMOKE:-0}"'); +requireText('src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx', 'liboliphaunt-smoke-status-${state}'); +requireText('src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml', 'id: liboliphaunt-smoke-status-passed'); +requireText('src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml', 'id: liboliphaunt-smoke-result'); +requireText('src/sdks/react-native/tools/expo-runner-common.sh', 'maestro_binary()'); +requireText('src/sdks/react-native/tools/expo-runner-common.sh', 'file_from_offset()'); +requireText('src/sdks/react-native/tools/expo-runner-common.sh', 'urlencode()'); +requireText('src/sdks/react-native/tools/expo-runner-metro.sh', 'reserve_metro_port()'); +requireText('src/sdks/react-native/tools/expo-runner-metro.sh', 'stop_owned_metro()'); +requireText('src/sdks/react-native/tools/expo-runner-metro.sh', 'cleanup()'); +requireText('src/sdks/react-native/tools/expo-runner-reporting.sh', 'write_runner_report()'); +requireText('src/sdks/react-native/tools/expo-runner-reporting.sh', 'write_maestro_runner_report()'); +requireText('src/sdks/react-native/tools/expo-runner-reporting.sh', 'write_mobile_package_size_report()'); +requireText('src/sdks/react-native/tools/expo-runner-reporting.sh', 'write_mobile_build_artifact_report_json()'); +requireText('src/sdks/react-native/tools/expo-runner-reporting.sh', 'OLIPHAUNT_EXPO_LOG_TAG'); +requireText('src/sdks/react-native/tools/expo-runner-reporting.sh', "schema: 'oliphaunt-react-native-mobile-build-v1'"); +requireText('src/sdks/react-native/tools/expo-runner-runtime-resources.sh', 'prepare_mobile_runtime_resource_package()'); +requireText('src/sdks/react-native/tools/expo-runner-runtime-resources.sh', 'copy_mobile_runtime_files()'); +requireText('src/sdks/react-native/tools/expo-runner-runtime-resources.sh', 'schema=oliphaunt-runtime-resources-v1'); +requireText('src/sdks/react-native/tools/expo-runner-runtime-resources.sh', 'kind\tid\textensions\tfiles\tbytes'); +requireText('src/sdks/react-native/tools/expo-runner-runtime-resources.sh', 'oliphaunt_dev_assert_runtime_data_files "$runtime_dest" "$selected_extensions" "$platform"'); +requireText('src/sdks/react-native/tools/expo-runner-runtime-resources.sh', 'src/extensions/generated/mobile/static-registry.json'); +requireText('src/sdks/react-native/tools/expo-runner-workspace.sh', 'prepare_expo_example_workspace()'); +requireText('src/sdks/react-native/tools/expo-runner-workspace.sh', 'prepare_react_native_package_worktree()'); +requireText('src/sdks/react-native/tools/expo-runner-workspace.sh', 'prepare_mobile_template_pgdata()'); +requireText('src/sdks/react-native/tools/expo-runner-workspace.sh', 'find_latest_mobile_pgdata()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-device.sh', 'select_ios_simulator_udid()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-device.sh', 'select_ios_physical_device_id()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-device.sh', 'configure_iphoneos_signing()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-device.sh', 'preflight_physical_ios_device()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-device.sh', 'resolve_xcode_destination()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-device.sh', 'boot_ios_simulator()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'latest_metro_runner_pass()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'run_maestro_installed_smoke()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'write_ios_process_metrics()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'resolve_prebuilt_ios_app()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'write_ios_device_process_metrics()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'exercise_ios_lifecycle()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'exercise_ios_device_lifecycle()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'exercise_ios_crash_recovery()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'exercise_ios_device_crash_recovery()'); +requireText('src/sdks/react-native/tools/expo-runner-ios-installed-app.sh', 'install_and_launch()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'should_use_maestro_e2e()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'run_maestro_installed_smoke()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'write_android_process_metrics()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'write_android_e2e_diagnostics()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'exercise_android_lifecycle()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'wake_android_device()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'exercise_android_crash_recovery()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'install_and_launch()'); +requireText('src/sdks/react-native/tools/expo-runner-android-device.sh', 'dismiss_expo_dev_menu_onboarding()'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/expo-runner-common.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/expo-runner-metro.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/expo-runner-reporting.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/expo-runner-workspace.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/mobile-extension-runtime.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/expo-runner-runtime-resources.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/expo-runner-ios-device.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'src/sdks/react-native/tools/expo-runner-ios-installed-app.sh'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'prepare_mobile_runtime_resource_package \\'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'iOS \\'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'oliphaunt_dev_assert_runtime_file_list "$selected_extensions" "iOS"'); +requireText('src/sdks/react-native/tools/expo-ios-runner.sh', 'iOS app is missing OliphauntReactNativeResources.bundle/oliphaunt resource root'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'src/sdks/react-native/tools/expo-runner-common.sh'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'src/sdks/react-native/tools/expo-runner-metro.sh'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'src/sdks/react-native/tools/expo-runner-reporting.sh'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'src/sdks/react-native/tools/expo-runner-workspace.sh'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'src/sdks/react-native/tools/mobile-extension-runtime.sh'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'src/sdks/react-native/tools/expo-runner-runtime-resources.sh'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'src/sdks/react-native/tools/expo-runner-android-device.sh'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'prepare_mobile_runtime_resource_package \\'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'Android \\'); +requireText('src/sdks/react-native/tools/expo-android-runner.sh', 'oliphaunt_dev_assert_runtime_file_list "$selected_extensions" "Android"'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nrun() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nmaestro_binary() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nfile_from_offset() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nprepare_expo_example_workspace() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nprepare_react_native_package_worktree() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nprepare_mobile_template_pgdata() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nfind_latest_pgdata() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nport_is_listening() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nreserve_metro_port() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nkill_process_tree() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nstop_owned_metro() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\ncleanup() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nwrite_runner_report() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nwrite_maestro_runner_report() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\ncopy_mobile_runtime_files() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nselect_ios_simulator_udid() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nselect_ios_physical_device_id() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nconfigure_iphoneos_signing() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\npreflight_physical_ios_device() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nresolve_xcode_destination() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nboot_ios_simulator() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nlatest_metro_runner_pass() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nlatest_metro_tag() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nlatest_metro_runner_failure() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nshould_use_maestro_e2e() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nrun_maestro_installed_smoke() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nwrite_ios_process_metrics() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nresolve_prebuilt_ios_app() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nextract_devicectl_pid() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nwrite_ios_device_process_metrics() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nlogs_have_lifecycle_ready() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nexercise_ios_lifecycle() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nexercise_ios_device_lifecycle() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nios_metro_url() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nios_runner_url() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nwait_for_ios_tag() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nexercise_ios_crash_recovery() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nlaunch_ios_device_runner() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nwait_for_ios_device_runner() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nexercise_ios_device_crash_recovery() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\nwait_for_ios_tag_from_metro() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', '\ninstall_and_launch() {'); +rejectText('src/sdks/react-native/tools/expo-ios-runner.sh', 'cat >"$reports_dir/$runner-package-sizes.json" <"$package_root/oliphaunt/runtime/manifest.properties" <"$package_root/oliphaunt/package-size.tsv" <"$reports_dir/$runner-package-sizes.json" <"$package_root/oliphaunt/runtime/manifest.properties" <"$package_root/oliphaunt/package-size.tsv" < file.endsWith('.test.ts')); + if (testFiles.length === 0) { + fail(`${productDir} must contain discoverable *.test.ts tests`); + } +} + +const rustCheckSdk = readFileSync('src/sdks/rust/tools/check-sdk.sh', 'utf8'); +if (!rustCheckSdk.includes('tools/runtime/preflight.sh')) { + fail('Rust SDK runtime lanes must source the shared runtime preflight helper'); +} +if (!rustCheckSdk.includes('oliphaunt_runtime_native_host_ready extensions')) { + fail('Rust SDK smoke/regression must require native host runtime plus extension artifacts'); +} +if (!rustCheckSdk.includes('cargo test -p oliphaunt --doc --locked')) { + fail('Rust SDK test-unit lane must keep doctests through cargo test --doc'); +} +if (!rustCheckSdk.includes('cargo nextest run -p oliphaunt --locked --profile ci --no-tests=fail --test-threads=1')) { + fail('Rust SDK test-unit lane must use nextest discovery for compiled tests'); +} +for (const manuallyListed of ['--test sdk_config_modes', '--test sdk_shape', '--test protocol_parser_fuzz']) { + if (rustCheckSdk.includes(manuallyListed)) { + fail(`Rust SDK test-unit lane must not handpick ${manuallyListed}; use nextest discovery`); + } +} + +requireText('src/sdks/swift/tools/check-sdk.sh', 'tools/runtime/preflight.sh'); +requireText('src/sdks/swift/tools/check-sdk.sh', 'oliphaunt_runtime_native_host_ready basic'); +requireText('src/sdks/swift/tools/check-sdk.sh', 'tools/runtime/preflight.sh ios-simulator'); +requireText('src/sdks/kotlin/tools/check-sdk.sh', 'tools/runtime/preflight.sh'); +requireText('src/sdks/kotlin/tools/check-sdk.sh', 'run_android_runtime_smoke'); +requireText('src/sdks/kotlin/tools/check-sdk.sh', 'static_tasks='); +requireText('src/sdks/kotlin/tools/check-sdk.sh', 'unit_tasks='); +requireText('src/sdks/kotlin/tools/check-sdk.sh', 'run_without_linked_native_runtime'); +requireText('src/sdks/kotlin/tools/check-sdk.sh', 'if [ "$mode" = "regression" ] || [ "$mode" = "release-check" ]; then'); +rejectText('src/sdks/kotlin/tools/check-sdk.sh', 'if [ "$mode" != "package-shape" ]; then'); +requireText('src/sdks/kotlin/tools/check-sdk.sh', 'Kotlin Android smoke AAR must include the explicitly supplied liboliphaunt runtime'); +requireText('src/sdks/js/tools/check-sdk.sh', 'tools/runtime/preflight.sh'); +requireText('src/sdks/js/tools/check-sdk.sh', 'oliphaunt_runtime_native_host_require basic'); + +requireText('src/sdks/react-native/src/__tests__/client.test.ts', 'testJsiArrayBufferTransportIsRequiredAndUsedForBinaryCalls'); +requireText('src/sdks/react-native/src/__tests__/client.test.ts', 'testJsiStreamTransportAdvertisesAndUsesNativeChunks'); +requireText('src/sdks/react-native/tools/check-sdk.sh', 'base64_runtime_hits'); +requireText('src/sdks/react-native/tools/check-sdk.sh', 'Codegen spec must stay lifecycle/control-only'); +requireText('src/sdks/react-native/tools/check-sdk.sh', 'ios/podspecs/COliphaunt.podspec'); +requireText('src/sdks/react-native/tools/check-sdk.sh', 'ios/vendor/oliphaunt-swift'); +rejectText('src/sdks/react-native/package.json', 'prepare-apple-vendor'); +requireText('src/sdks/react-native/src/__tests__/client.test.ts', 'react-native-jsi/binary-transport.json'); +rejectText('src/sdks/react-native/src/specs/NativeOliphaunt.ts', 'base64'); +rejectText('src/sdks/react-native/tools/check-sdk.sh', 'pnpm --dir "$root" install'); +rejectText('src/sdks/react-native/tools/check-sdk.sh', '$root/src/sdks/react-native/node_modules'); +rejectText('src/sdks/react-native/tools/check-sdk.sh', '$root/src/sdks/js/node_modules'); +rejectText('src/sdks/js/tools/check-sdk.sh', '$source_package_dir/node_modules'); +requireText('src/sdks/react-native/tools/check-sdk.sh', 'core-js: false'); +requireText('src/sdks/js/tools/check-sdk.sh', 'core-js: false'); +requireText('src/sdks/react-native/tools/check-sdk.sh', "--glob '!**/__tests__/**'"); +requireText('src/sdks/js/tools/check-sdk.sh', "--glob '!**/__tests__/**'"); + +const sharedConsumers = new Map([ + ['src/sdks/rust/tests/protocol_query_fixtures.rs', 'query-response-cases.json'], + ['src/sdks/swift/Tests/OliphauntTests/ProtocolFixtureTests.swift', 'query-response-cases.json'], + ['src/sdks/kotlin/oliphaunt/src/jvmTest/kotlin/dev/oliphaunt/SharedProtocolFixtureTest.kt', 'query-response-cases.json'], + ['src/sdks/js/src/__tests__/protocol-fixtures.test.ts', 'query-response-cases.json'], + ['src/sdks/react-native/src/__tests__/protocol-fixtures.test.ts', 'query-response-cases.json'], + ['src/bindings/wasix-rust/crates/oliphaunt-wasix/src/protocol/shared_fixture_tests.rs', 'query-response-cases.json'], +]); +for (const [file, marker] of sharedConsumers) { + requireText(file, marker); +} + +for (const file of [ + 'src/sdks/rust/tests/sdk_config_modes.rs', + 'src/sdks/swift/Tests/OliphauntTests/OliphauntTests.swift', + 'src/sdks/kotlin/oliphaunt/src/androidUnitTest/kotlin/dev/oliphaunt/OliphauntAndroidDefaultEngineTest.kt', + 'src/sdks/js/src/__tests__/client.test.ts', + 'src/sdks/react-native/src/__tests__/client.test.ts', +]) { + requireText(file, 'supportedModes'); +} + +for (const file of [ + 'tools/perf/matrix/run_bench_matrix.sh', + 'src/docs/content/reference/performance.mdx', +]) { + rejectText(file, 'node-bench'); + rejectText(file, 'bench-oxide'); + rejectText(file, 'nodefs'); +} + +console.log('peer SDK test strategy checks passed'); diff --git a/tools/policy/check-tooling-stack.sh b/tools/policy/check-tooling-stack.sh new file mode 100755 index 00000000..2fa5fa37 --- /dev/null +++ b/tools/policy/check-tooling-stack.sh @@ -0,0 +1,619 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "$1" >&2 + exit 1 +} + +require_file() { + [[ -f "$1" ]] || fail "missing required tooling file: $1" +} + +require_file package.json +require_file .prototools +require_file .gitignore +require_file pnpm-workspace.yaml +require_file pnpm-lock.yaml +require_file biome.json +require_file renovate.json +require_file .markdownlint-cli2.jsonc +require_file .typos.toml +require_file .lychee.toml +require_file .config/nextest.toml +require_file src/sdks/swift/.swift-format +require_file src/sdks/swift/.swiftlint.yml +require_file src/runtimes/liboliphaunt/native/bin/common.sh +require_file .github/moon.yml +require_file .github/workflows/ci.yml +require_file .github/scripts/setup-native-build-tools.sh +require_file .moon/workspace.yml +require_file docs/maintainers/tooling.md +require_file tools/test/moon.yml +require_file tools/test/run-js-tests.mjs +require_file tools/graph/cache-witness.py +require_file tools/runtime/preflight.sh +require_file tools/dev/bun.sh +require_file tools/dev/deno.sh +require_file tools/dev/install-actionlint.sh +require_file tools/dev/setup-android-sdk.sh +require_file .github/actions/setup-wasmer-llvm/action.yml + +while IFS= read -r tracked_patch_input; do + eol_attr="$(git check-attr eol -- "$tracked_patch_input" | awk -F': ' '{print $3}')" + [[ "$eol_attr" == "lf" ]] || + fail "$tracked_patch_input must be covered by .gitattributes with eol=lf; Windows checkouts corrupt PostgreSQL patch application without it" +done < <(git ls-files -- '*.patch' '*.diff' ':(glob)src/**/patches/series') + +proto_version() { + local tool="$1" + awk -F '=' -v tool="$tool" ' + $1 ~ "^[[:space:]]*" tool "[[:space:]]*$" { + value=$2 + gsub(/^[[:space:]\"]+|[[:space:]\"]+$/, "", value) + print value + found=1 + } + END { if (!found) exit 1 } + ' .prototools +} + +MOON_VERSION="$(proto_version moon)" +NODE_VERSION="$(proto_version node)" +PNPM_VERSION="$(proto_version pnpm)" +BUN_VERSION="$(proto_version bun)" +DENO_VERSION="$(proto_version deno)" +DENO_VERSION_WITH_PREFIX="v$DENO_VERSION" + +grep -Fq "\"packageManager\": \"pnpm@$PNPM_VERSION\"" package.json || + fail "root package.json must pin pnpm through packageManager" +grep -Fq '"node": ">=22.13 <25"' package.json || + fail "root package.json must declare the supported Node runtime band" +grep -Fq "\"pnpm\": \"$PNPM_VERSION\"" package.json || + fail "root package.json must declare the exact supported pnpm version" +grep -Fq "default: \"$NODE_VERSION\"" .github/actions/setup-node-pnpm/action.yml || + fail "setup-node-pnpm must default to the pinned Node version from .prototools" +if grep -Fq 'cache: pnpm' .github/actions/setup-node-pnpm/action.yml; then + fail "setup-node-pnpm must not use actions/setup-node pnpm cache before pnpm is installed" +fi +grep -Fq 'Resolve pnpm store' .github/actions/setup-node-pnpm/action.yml || + fail "setup-node-pnpm must resolve the pnpm store after enabling pinned pnpm" +grep -Fq 'key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-node-${{ inputs.node-version }}-pnpm-${{ inputs.pnpm-version }}-${{ hashFiles('\''pnpm-lock.yaml'\'') }}' .github/actions/setup-node-pnpm/action.yml || + fail "setup-node-pnpm pnpm store cache key must include runner, Node, pnpm, and lockfile" +grep -Fq 'moonrepo/setup-toolchain' .github/actions/setup-moon/action.yml || + fail "setup-moon must install the pinned proto/Moon toolchain through moonrepo/setup-toolchain" +grep -Fq 'auto-install: true' .github/actions/setup-moon/action.yml || + fail "setup-moon must allow proto to auto-install pinned tools from .prototools" +grep -Fq 'path: ~/.moon/plugins' .github/actions/setup-moon/action.yml || + fail "setup-moon must cache Moon toolchain plugins to avoid live plugin downloads in every CI job" +grep -Fq "key: moon-plugins-\${{ runner.os }}-\${{ runner.arch }}-\${{ hashFiles('.moon/toolchains.yml', '.moon/workspace.yml', '.prototools') }}" .github/actions/setup-moon/action.yml || + fail "setup-moon Moon plugin cache key must include Moon/proto toolchain pins" +grep -Fq 'Hydrate Moon plugins' .github/actions/setup-moon/action.yml || + fail "setup-moon must hydrate Moon plugins with retries before product jobs run" +grep -Fq 'for attempt in 1 2 3 4 5 6 7 8 9 10 11 12' .github/actions/setup-moon/action.yml || + fail "setup-moon Moon plugin hydration must retry transient GitHub release download failures" +grep -Fq -- '--retry-all-errors' .github/actions/setup-wasmer-llvm/action.yml || + fail "setup-wasmer-llvm must retry transient LLVM archive download failures" +grep -Fq -- '--connect-timeout 20' .github/actions/setup-wasmer-llvm/action.yml || + fail "setup-wasmer-llvm must bound LLVM archive download connection stalls" +if grep -Eq 'node-version:|pnpm-version:|pnpm moon' .github/actions/setup-moon/action.yml; then + fail "setup-moon must not expose stale Node/pnpm inputs or launch Moon through pnpm" +fi +grep -Fq "NODE_VERSION: $NODE_VERSION" .github/workflows/ci.yml || + fail "CI must expose the pinned Node version explicitly" +grep -Fq 'ACTIONLINT_VERSION: 1.7.12' .github/workflows/ci.yml || + fail "CI must expose the pinned actionlint version explicitly" +grep -Fq "NODE_VERSION: $NODE_VERSION" .github/workflows/release.yml || + fail "release workflow must expose the pinned Node version explicitly" +grep -Fq 'NPM_VERSION: 11.5.1' .github/workflows/release.yml || + fail "release workflow must pin npm for trusted publishing" +grep -Fq 'npm install --global "npm@${{ env.NPM_VERSION }}"' .github/workflows/release.yml || + fail "release workflow must install the pinned npm CLI before trusted publishing checks" +if grep -Fq 'node-version: 24' .github/workflows/release.yml; then + fail "release workflow must not drift to a separate Node 24 publishing runtime" +fi +for tool_name in moon node pnpm bun deno; do + proto_version "$tool_name" >/dev/null || + fail ".prototools must pin $tool_name" +done +for moon_experiment in \ + 'asyncAffectedTracking: true' \ + 'asyncGraphBuilding: true' \ + 'casOutputsCache: true' \ + 'nativeFileHashing: true' +do + grep -Fq "$moon_experiment" .moon/workspace.yml || + fail ".moon/workspace.yml must enable Moon v2.3 graph/cache experiment: $moon_experiment" +done +if grep -Fq 'MOON_CONCURRENCY=1' package.json; then + fail "root command-card scripts must not force single-threaded Moon execution; use MOON_CONCURRENCY=1 only as an ad-hoc debug override" +fi +root_fallback_hits="$( + grep -R --exclude=check-tooling-stack.sh --exclude-dir=target --exclude-dir=node_modules \ + -F 'git rev-parse --show-toplevel 2>/dev/null || pwd' tools src examples .github || true +)" +if [[ -n "$root_fallback_hits" ]]; then + echo "$root_fallback_hits" >&2 + fail "repo scripts must fail closed when not run inside the Oliphaunt git checkout; do not fall back to pwd" +fi +node -e ' +const fs = require("node:fs"); +const scripts = Object.keys(JSON.parse(fs.readFileSync("package.json", "utf8")).scripts ?? {}); +if (scripts.length !== 0) { + console.error(`root package.json scripts must be empty; use moon directly, got ${scripts.join(", ")}`); + process.exit(1); +} +' +for retired_moon_helper in tools/graph/moon.mjs tools/graph/tool-versions.mjs tools/graph/tool_versions.py tools/graph/run-affected-task.py; do + if [ -e "$retired_moon_helper" ]; then + fail "retired Moon helper must not exist: $retired_moon_helper" + fi +done +for catalog_dep in '@vitest/coverage-v8' 'tsx' 'typedoc' 'typescript' 'vitest'; do + grep -Eq "^[[:space:]]+\"?$catalog_dep\"?:" pnpm-workspace.yaml || + fail "pnpm-workspace.yaml must catalog shared JS test/build tool $catalog_dep" +done +for package_file in src/sdks/js/package.json src/sdks/react-native/package.json; do + for catalog_dep in '@vitest/coverage-v8' 'tsx' 'typedoc' 'typescript' 'vitest'; do + grep -Fq "\"$catalog_dep\": \"catalog:\"" "$package_file" || + fail "$package_file must consume shared JS test/build tool $catalog_dep through pnpm catalog:" + done +done +grep -Fq "node tools/policy/check-source-inputs.mjs postgres18" src/postgres/versions/18/moon.yml || + fail "source input checks must use cross-platform Node tasks" +grep -Fq "bun tools/policy/fetch-sources.mjs" src/sources/moon.yml || + fail "source fetch task must use cross-platform Bun" +grep -Fq "node tools/policy/check-source-inputs.mjs toolchains" src/sources/toolchains/moon.yml || + fail "toolchain source checks must use cross-platform Node" +if grep -Fq -- '--affected --downstream deep' package.json; then + fail "root package scripts must not carry affected Moon aliases" +fi +grep -Fq 'moon(["query", "affected", "--upstream", "none", "--downstream", "none"])' tools/graph/affected.py || + fail "affected runner must get direct affected projects from Moon" +grep -Fq 'moon(["query", "affected", "--upstream", "none", "--downstream", "deep"])' tools/graph/affected.py || + fail "affected runner must get downstream affected projects from Moon" +grep -Fq 'moon(["query", "tasks"])' tools/graph/affected.py || + fail "affected runner must discover task availability from Moon" +grep -Fq 'tools/dev/bun.sh' tools/dev/doctor.sh || + fail "pnpm doctor must report the pinned Bun launcher used by TypeScript SDK checks" +grep -Fq 'https://github.com/oven-sh/bun/releases/download/bun-v$version/$asset' tools/dev/bun.sh || + fail "repo Bun launcher must use official pinned Bun release binaries" +grep -Fq 'tools/dev/bun.sh" "$package_dir/.oliphaunt-bun-smoke.ts"' src/sdks/js/tools/check-sdk.sh || + fail "TypeScript SDK package checks must run Bun smoke through the pinned repo Bun launcher" +grep -Fq 'missing optional deno' tools/dev/doctor.sh || + fail "pnpm doctor must report the pinned Deno runtime needed by strict JSR consumer gates" +grep -Fq 'https://github.com/denoland/deno/releases/download/v$version/deno-$target.zip' tools/dev/deno.sh || + fail "repo Deno launcher must use official pinned Deno release binaries" +grep -Fq 'tools/dev/deno.sh" run --allow-read --allow-env' src/sdks/js/tools/check-sdk.sh || + fail "TypeScript SDK package checks must run Deno smoke through the pinned repo Deno launcher" +grep -Fq 'RIPGREP_VERSION="${RIPGREP_VERSION:-15.1.0}"' tools/dev/bootstrap-tools.sh || + fail "local tool bootstrap must pin ripgrep" +grep -Fq 'install_cargo_tool ripgrep rg "$RIPGREP_VERSION"' tools/dev/bootstrap-tools.sh || + fail "local tool bootstrap must install the pinned ripgrep binary" +grep -Fq 'cargo install ripgrep --version 15.1.0 --locked' .github/actions/setup-rust-tools/action.yml || + fail "shared CI Rust setup must install pinned ripgrep for repo policy and native probes" +grep -Fq '"$script_dir/install-actionlint.sh"' tools/dev/bootstrap-tools.sh || + fail "local tool bootstrap must install actionlint through the shared actionlint installer" +grep -Fq 'ACTIONLINT_VERSION="${ACTIONLINT_VERSION:-1.7.12}"' tools/dev/install-actionlint.sh || + fail "shared actionlint installer must pin actionlint 1.7.12" +grep -Fq 'require_brew_tool autoconf autoconf' .github/scripts/setup-native-build-tools.sh || + fail "native CI setup must install autoconf for PostGIS autogen builds" +grep -Fq 'require_brew_tool aclocal automake' .github/scripts/setup-native-build-tools.sh || + fail "native CI setup must install automake/aclocal for PostGIS autogen builds" +grep -Fq 'require_brew_tool glibtoolize libtool' .github/scripts/setup-native-build-tools.sh || + fail "native CI setup must install GNU libtool for PostGIS autogen builds" +grep -Fq 'install_linux_tools()' .github/scripts/setup-native-build-tools.sh || + fail "native CI setup must install Linux build tools for liboliphaunt Linux release targets" +grep -Fq 'sudo apt-get install -y --no-install-recommends' .github/scripts/setup-native-build-tools.sh || + fail "native CI setup must use a minimal apt install for Linux native build tools" +grep -Fq 'ripgrep \' .github/scripts/setup-native-build-tools.sh || + fail "native CI setup must install ripgrep for Linux native build probes" +grep -Fq '.github/scripts/setup-native-build-tools.sh 2G' .github/workflows/ci.yml || + fail "CI liboliphaunt native lanes must use the shared native build tool setup" +grep -Fq 'tools/dev/setup-android-sdk.sh \' .github/actions/setup-android/action.yml || + fail "setup-android action must provision Android SDK packages through the shared setup-android-sdk tool" +if grep -Fq 'sdkmanager is required for Android SDK provisioning' .github/actions/setup-android/action.yml; then + fail "setup-android action must bootstrap Android command-line tools on clean Linux builders instead of requiring a preinstalled sdkmanager" +fi +grep -Fq 'commandlinetools-${host_tag}-${cmdline_tools_version}_latest.zip' tools/dev/setup-android-sdk.sh || + fail "Android SDK setup must derive command-line tools URLs from the pinned host/version metadata" +grep -Fq '"ndk;${ndk_version}"' tools/dev/setup-android-sdk.sh || + fail "Android SDK setup must install the pinned NDK side-by-side package through sdkmanager" +grep -Fq '"cmake;${cmake_version}"' tools/dev/setup-android-sdk.sh || + fail "Android SDK setup must install the pinned Android CMake package through sdkmanager" +grep -Fq 'ANDROID_SDKMANAGER_INSTALL_ATTEMPTS' tools/dev/setup-android-sdk.sh || + fail "Android SDK setup must retry sdkmanager package installation for transient/corrupt downloads" +grep -Fq 'cleanup_partial_sdk_packages' tools/dev/setup-android-sdk.sh || + fail "Android SDK setup must clean partial sdkmanager package directories before retrying" +grep -Fq 'python3 .github/scripts/plan-affected.py' .github/workflows/ci.yml || + fail "CI must derive product job startup from the Moon affected planner" +grep -Fq "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-runtime')" .github/workflows/ci.yml || + fail "CI must gate expensive WASIX runtime work from the Moon affected job list" +grep -Fq "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-aot')" .github/workflows/ci.yml || + fail "CI must gate expensive WASIX AOT work from the Moon affected job list" +grep -Fq "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-release-assets')" .github/workflows/ci.yml || + fail "CI must gate WASIX release asset aggregation from the Moon affected job list" +if [[ -e .github/workflows/assets.yml ]]; then + fail "WASM runtime jobs must live in the main Builds workflow, not a standalone assets workflow" +fi +grep -Fq 'exec "$moon_bin" run "$@"' .github/scripts/run-moon-targets.sh || + fail "shared CI Moon helper must run selected targets through canonical moon run" +grep -Fq 'OLIPHAUNT_CI_JOB_TARGETS_JSON' .github/scripts/run-planned-moon-job.sh || + fail "planned CI Moon helper must consume the affected planner target map" +if grep -Fq 'pnpm moon' .github/scripts/run-moon-targets.sh; then + fail "shared CI Moon helper must not launch Moon through pnpm" +fi +grep -Fq 'Download liboliphaunt release assets' .github/workflows/release.yml || + fail "release workflow must download staged liboliphaunt assets instead of rebuilding native runtime artifacts" +grep -Fq 'Download native helper release assets' .github/workflows/release.yml || + fail "release workflow must download staged native helper assets instead of rebuilding helper artifacts" +grep -Fq ' rg \' tools/dev/doctor.sh || + fail "pnpm doctor must report the pinned ripgrep binary used by maintainer gates" +grep -Fq 'minimumReleaseAge: 1440' pnpm-workspace.yaml || + fail "pnpm workspace must retain a release-age delay for new registry versions" +grep -Fq 'saveWorkspaceProtocol: rolling' pnpm-workspace.yaml || + fail "pnpm workspace must preserve workspace:* when adding local package dependencies" +grep -Fq 'autoInstallPeers: false' pnpm-workspace.yaml || + fail "pnpm workspace must not auto-install peer dependencies into SDK library package locks" +grep -Fq 'updateNotifier: false' pnpm-workspace.yaml || + fail "pnpm workspace must suppress update-notifier output for quiet scripted installs" +grep -Fq 'verifyDepsBeforeRun: false' pnpm-workspace.yaml || + fail "pnpm run must not auto-install before command-card scripts; install is an explicit developer action" +grep -Fxq '/.moon/cache/' .gitignore || + fail ".moon/cache must remain ignored; Moon cache state is local generated data" +grep -Fq ' core-js: false' pnpm-workspace.yaml || + fail "pnpm workspace must explicitly review and ignore the core-js postinstall script" +for allowed_build in esbuild msgpackr-extract sharp unrs-resolver; do + grep -Fq " $allowed_build: true" pnpm-workspace.yaml || + fail "pnpm workspace must explicitly review and allow required install scripts from $allowed_build" + grep -Fq " $allowed_build: true" src/bindings/wasix-rust/tools/check-examples.sh || + fail "example scratch workspace must mirror required allowed install script from $allowed_build" + grep -Fq " $allowed_build: true" src/sdks/react-native/tools/check-sdk.sh || + fail "React Native SDK scratch workspace must mirror required allowed install script from $allowed_build" + grep -Fq " $allowed_build: true" src/sdks/js/tools/check-sdk.sh || + fail "TypeScript SDK scratch workspace must mirror required allowed install script from $allowed_build" +done +grep -Fq ' core-js: false' src/bindings/wasix-rust/tools/check-examples.sh || + fail "example scratch workspace must mirror the reviewed core-js postinstall decision" +grep -Fq ' core-js: false' src/sdks/react-native/tools/check-sdk.sh || + fail "React Native SDK scratch workspace must mirror the reviewed core-js postinstall decision" +grep -Fq ' core-js: false' src/sdks/js/tools/check-sdk.sh || + fail "TypeScript SDK scratch workspace must mirror the reviewed core-js postinstall decision" +grep -Fq '/tools/test/**/*' tools/policy/moon.yml || + fail "policy-tools Moon inputs must include shared test tooling" +grep -Fq 'target/liboliphaunt-sdk-check/oliphaunt-react-native' src/sdks/react-native/tools/check-sdk.sh || + fail "React Native SDK checks must use an isolated scratch root so Moon can run SDK checks in parallel" +grep -Fq 'target/liboliphaunt-sdk-check/oliphaunt-js' src/sdks/js/tools/check-sdk.sh || + fail "TypeScript SDK checks must use an isolated scratch root so Moon can run SDK checks in parallel" +grep -Fq 'cache-witness-fixture:' tools/graph/moon.yml || + fail "graph-tools must keep a cache witness fixture task" +grep -Fq 'cacheStrategy: "outputs"' moon.yml || + fail "repo coverage aggregate must use Moon dependency cacheStrategy=outputs" +grep -Fq 'cacheStrategy: "outputs"' src/docs/moon.yml || + fail "docs generated-site consumers must use Moon dependency cacheStrategy=outputs" + +for workspace in \ + 'src/docs' \ + 'src/sdks/react-native' \ + 'src/sdks/js' \ + 'src/sdks/react-native/examples/expo' \ + 'src/bindings/wasix-rust/examples/tauri-sqlx-vanilla' +do + grep -Fq "\"$workspace\"" pnpm-workspace.yaml || + fail "pnpm workspace is missing $workspace" +done + +for biome_include in \ + '"src/sdks/react-native/typedoc.json"' \ + '"src/sdks/js/package.json"' \ + '"src/sdks/js/typedoc.json"' \ + '"src/sdks/js/jsr.json"' \ + '"src/sdks/js/src/**/*.ts"' \ + '"tools/test/**/*.mjs"' +do + grep -Fq "$biome_include" biome.json || + fail "biome.json must include formatter/linter surface $biome_include" +done + +node -e ' +const fs = require("node:fs"); +const root = JSON.parse(fs.readFileSync("package.json", "utf8")); +const actualScripts = Object.keys(root.scripts ?? {}); +if (actualScripts.length !== 0) { + console.error(`root package.json scripts must be empty; use moon directly, got ${actualScripts.join(", ")}`); + process.exit(1); +} +const pkg = JSON.parse(fs.readFileSync("src/sdks/react-native/examples/expo/package.json", "utf8")); +if (pkg.dependencies?.["@oliphaunt/react-native"] !== "workspace:*") { + console.error("Expo source example must depend on @oliphaunt/react-native with workspace:*; installed-package smoke scripts patch scratch copies to tarballs."); + process.exit(1); +} +' + +if git grep -n -E 'target/react-native-oliphaunt-expo|file:.*oliphaunt-react-native-[^[:space:]]*\.tgz' \ + -- package.json pnpm-lock.yaml src/sdks/react-native | + grep -v '^tools/policy/check-tooling-stack.sh:' >/tmp/oliphaunt-rn-tarball-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-rn-tarball-grep.$$ >&2 + rm -f /tmp/oliphaunt-rn-tarball-grep.$$ + fail "generated React Native package tarballs must not be referenced by checked-in pnpm manifests or lockfiles" +fi +rm -f /tmp/oliphaunt-rn-tarball-grep.$$ + +if git grep -n -- '--no-lockfile' -- .github tools src/sdks/react-native src/sdks/js src/bindings/wasix-rust/examples/tauri-sqlx-vanilla | + grep -v '^tools/policy/check-tooling-stack.sh:' >/tmp/oliphaunt-no-lockfile-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-no-lockfile-grep.$$ >&2 + rm -f /tmp/oliphaunt-no-lockfile-grep.$$ + fail "pnpm installs in tooling must use the root lockfile or a scratch lockfile, not --no-lockfile" +fi +rm -f /tmp/oliphaunt-no-lockfile-grep.$$ + +tracked_lockfiles="$(git ls-files '*package-lock.json' '*yarn.lock' '*bun.lockb' | while IFS= read -r path; do + [ ! -e "$path" ] || printf '%s\n' "$path" +done)" +if [ -n "$tracked_lockfiles" ]; then + printf '%s\n' "$tracked_lockfiles" >&2 + fail "JavaScript workspaces must use the root pnpm-lock.yaml" +fi + +declare -a npm_policy_paths=() +while IFS= read -r path; do + [ -n "$path" ] && npm_policy_paths+=("$path") +done < <( + git ls-files .github tools src/sdks/react-native src/sdks/js src/bindings/wasix-rust/examples/tauri-sqlx-vanilla package.json pnpm-workspace.yaml | + grep -E '(^|/)(package\.json|pnpm-workspace\.yaml)$|\.(sh|bash|zsh|mjs|cjs|js|ts|tsx|json|ya?ml)$' +) +if (( ${#npm_policy_paths[@]} > 0 )) && + git grep -n -E '(^|[^[:alnum:]_-])npm --prefix([[:space:]]|$)|(^|[^[:alnum:]_-])npm ci([[:space:]]|$)|(^|[^[:alnum:]_-])npm run([[:space:]]|$)|(^|[^[:alnum:]_-])npm pack([[:space:]]|$)|cache: npm|package-lock\.json' \ + -- "${npm_policy_paths[@]}" | + grep -v '^tools/policy/check-tooling-stack.sh:' | + grep -v '^tools/policy/check-docs.sh:' | + grep -v '^tools/policy/check-repo-structure.sh:' >/tmp/oliphaunt-npm-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-npm-grep.$$ >&2 + rm -f /tmp/oliphaunt-npm-grep.$$ + fail "executable JavaScript tooling must use pnpm" +fi +rm -f /tmp/oliphaunt-npm-grep.$$ + +if git grep -n -E 'dirname "?\$\{BASH_SOURCE\[0\]\}"?.*/\.\./\.\.' -- src/runtimes/liboliphaunt/native/bin | + grep -v '^src/runtimes/liboliphaunt/native/bin/common.sh:' >/tmp/oliphaunt-root-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-root-grep.$$ >&2 + rm -f /tmp/oliphaunt-root-grep.$$ + fail "native scripts must use src/runtimes/liboliphaunt/native/bin/common.sh for repo root resolution" +fi +rm -f /tmp/oliphaunt-root-grep.$$ + +python3 - <<'PY' +from pathlib import Path +import re +import subprocess +import sys + + +def fail(message: str) -> None: + print(message, file=sys.stderr) + sys.exit(1) + + +def task_block(path: str, task: str) -> str: + text = Path(path).read_text() + match = re.search(rf"^ {re.escape(task)}:\n", text, flags=re.MULTILINE) + if not match: + fail(f"{path} is missing task {task}") + next_task = re.search(r"^ [A-Za-z0-9_-]+:\n", text[match.end() :], flags=re.MULTILINE) + end = match.end() + next_task.start() if next_task else len(text) + return text[match.start() : end] + + +for task in ("check", "release-check"): + block = task_block(".github/moon.yml", task) + if "cache: true" not in block: + fail(f"ci-workflows:{task} must be cacheable; workflow lint/security checks are deterministic") + for required_input in ( + "/.github/actions/**/*", + "/.github/workflows/**/*", + "/.github/zizmor.yml", + "/tools/policy/check-workflows.sh", + ): + if required_input not in block: + fail(f"ci-workflows:{task} must include {required_input} in its Moon inputs") + +smoke_projects = ( + "moon.yml", + "src/runtimes/liboliphaunt/native/moon.yml", + "src/sdks/rust/moon.yml", + "src/sdks/swift/moon.yml", + "src/sdks/kotlin/moon.yml", + "src/sdks/react-native/moon.yml", + "src/sdks/js/moon.yml", + "src/runtimes/liboliphaunt/wasix/moon.yml", +) +for path in smoke_projects: + block = task_block(path, "smoke") + if "cache: local" not in block: + fail(f"{path} smoke task must use local-only Moon caching") + if "inputs:" not in block: + fail(f"{path} smoke task must declare explicit inputs for cache correctness") + +for path in ( + "moon.yml", + "tools/perf/moon.yml", + "src/runtimes/liboliphaunt/native/moon.yml", + "src/sdks/rust/moon.yml", + "src/sdks/swift/moon.yml", + "src/sdks/kotlin/moon.yml", + "src/sdks/react-native/moon.yml", + "src/sdks/js/moon.yml", + "src/bindings/wasix-rust/moon.yml", +): + block = task_block(path, "bench") + if "cache: true" not in block: + fail(f"{path} bench task must cache benchmark plan/harness validation") + if "inputs:" not in block: + fail(f"{path} bench task must declare explicit benchmark plan inputs") + if '"/benchmarks/**/*"' not in block: + fail(f"{path} bench task must include benchmark specs in Moon inputs") + if "run_mobile_footprint_matrix.sh" in block and "--plan-only" not in block: + fail(f"{path} mobile bench task must be plan-only; measured mobile benchmarks belong in bench-run") + if "run_native_oliphaunt_matrix.sh" in block and "--plan-only" not in block: + fail(f"{path} native benchmark matrix bench task must be plan-only; measured benchmarks belong in bench-run") + +for path in ( + "moon.yml", + "src/sdks/rust/moon.yml", + "src/sdks/swift/moon.yml", + "src/sdks/kotlin/moon.yml", + "src/sdks/react-native/moon.yml", + "src/sdks/js/moon.yml", + "src/bindings/wasix-rust/moon.yml", +): + block = task_block(path, "bench-run") + if "cache: false" not in block: + fail(f"{path} bench-run task must stay uncached because it measures the current runtime") + if "runInCI: false" not in block: + fail(f"{path} bench-run task must not run in default CI lanes") + if '"/benchmarks/**/*"' not in block: + fail(f"{path} bench-run task must include benchmark specs in Moon inputs") + +src_generated_excludes = ( + ' - "!/src/**/node_modules/**"', + ' - "!/src/**/.build/**"', + ' - "!/src/**/.gradle/**"', + ' - "!/src/**/.cxx/**"', + ' - "!/src/**/.next/**"', + ' - "!/src/**/.source/**"', + ' - "!/src/**/build/**"', + ' - "!/src/**/out/**"', + ' - "!/src/**/Pods/**"', + ' - "!/src/**/DerivedData/**"', +) +tracked_moon_files = subprocess.check_output( + ["git", "ls-files", "*moon.yml"], + text=True, +).splitlines() +for tracked_path in tracked_moon_files: + path = Path(tracked_path) + text = path.read_text() + if ' - "/src/**/*"' in text: + for excluded in src_generated_excludes: + if excluded not in text: + fail(f"{path}: broad /src/**/* inputs must exclude generated local state with {excluded}") + if ' - "/src/sdks/react-native/**/*"' in text: + for excluded in ( + ' - "!/src/sdks/react-native/**/node_modules"', + ' - "!/src/sdks/react-native/**/node_modules/**"', + ): + if excluded not in text: + fail(f"{path}: React Native inputs must exclude nested Expo node_modules with {excluded}") + +ci = Path(".github/workflows/ci.yml").read_text() +for tag in ( + "mobile-build-android", + "mobile-build-ios", +): + expected = f"MOON_CACHE=off .github/scripts/run-planned-moon-job.sh {tag}" + if expected not in ci: + fail(f"{tag} CI mobile lane must force live execution through the planned Moon wrapper") +for target in ( + "oliphaunt-react-native:mobile-build-android", + "oliphaunt-react-native:mobile-build-ios", +): + forbidden = f"pnpm moon run {target} --cache off" + if forbidden in ci: + fail(f"{target} CI mobile lane must not bypass .github/scripts/run-moon-targets.sh") + if "pnpm moon run liboliphaunt-wasix:regression --cache off" in ci: + fail("CI WASM regression must not bypass .github/scripts/run-moon-targets.sh") + +tooling_docs = Path("docs/maintainers/tooling.md").read_text() +for required_text in ( + "Use `cache: local` for developer smoke tasks", + "Force live execution for CI/mobile/device proof with `MOON_CACHE=off`", + "Cache benchmark plan checks, never measured benchmark runs", +): + if required_text not in tooling_docs: + fail(f"docs/maintainers/tooling.md must document Moon cache policy: {required_text}") +PY + +while IFS= read -r script; do + case "$(head -n 1 "$script")" in + '#!/usr/bin/env bash') + bash -n "$script" + ;; + '#!/usr/bin/env sh') + sh -n "$script" + ;; + esac +done < <( + find .github tools src/runtimes/liboliphaunt/native/bin src/runtimes/node-direct/tools \ + \( -path './.github/actions/*/node_modules' \) -prune -o \ + -type f -name '*.sh' -print | + LC_ALL=C sort +) + +if git ls-files | + grep -E '(^|/)(node_modules|\.build|\.gradle|\.kotlin|\.cxx|\.next|\.source|\.expo|build|out|dist|web-build|Pods|DerivedData|__pycache__)/' | + grep -v '^src/runtimes/liboliphaunt/wasix/assets/build/' >/tmp/oliphaunt-generated-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-generated-grep.$$ >&2 + rm -f /tmp/oliphaunt-generated-grep.$$ + fail "generated build/dependency directories must not be tracked" +fi +rm -f /tmp/oliphaunt-generated-grep.$$ + +if git ls-files tools/ci tools/product | grep -q .; then + git ls-files tools/ci tools/product >&2 + fail "retired tools/ci and tools/product entrypoints must not be tracked" +fi + +if git ls-files | + grep -E '(^crates/|^sdks/|^liboliphaunt/|^assets/wasix-build/)' >/tmp/oliphaunt-root-product-alias-grep.$$ 2>/dev/null; then + cat /tmp/oliphaunt-root-product-alias-grep.$$ >&2 + rm -f /tmp/oliphaunt-root-product-alias-grep.$$ + fail "root product aliases are retired; product source must live under src/" +fi +rm -f /tmp/oliphaunt-root-product-alias-grep.$$ + +while IFS= read -r executable; do + case "$executable" in + .github/scripts/*.sh | \ + examples/tools/* | \ + src/runtimes/liboliphaunt/native/bin/* | \ + src/runtimes/liboliphaunt/native/tools/* | \ + src/runtimes/broker/tools/* | \ + src/runtimes/node-direct/tools/* | \ + src/extensions/tools/* | \ + src/extensions/external/*/tools/* | \ + src/extensions/artifacts/native/tools/* | \ + src/extensions/artifacts/packages/tools/* | \ + src/extensions/artifacts/wasix/tools/* | \ + src/sdks/kotlin/gradlew | \ + src/sdks/kotlin/tools/* | \ + src/sdks/react-native/tools/* | \ + src/sdks/js/tools/* | \ + src/bindings/wasix-rust/tools/* | \ + src/sdks/rust/tools/* | \ + src/sdks/swift/tools/* | \ + src/runtimes/liboliphaunt/wasix/assets/build/*.sh | \ + src/runtimes/liboliphaunt/wasix/tools/* | \ + tools/coverage/* | \ + tools/dev/* | \ + tools/graph/* | \ + tools/perf/* | \ + tools/perf/matrix/* | \ + tools/policy/* | \ + tools/runtime/* | \ + tools/test/* | \ + tools/release/*) + ;; + *) + echo "$executable" >&2 + fail "tracked executable is outside an allowed ownership bucket" + ;; + esac +done < <(git ls-files -s | awk '$1 ~ /^1007/ { print $4 }' | LC_ALL=C sort) + +echo "tooling stack checks passed" diff --git a/tools/policy/check-wasm-artifacts.sh b/tools/policy/check-wasm-artifacts.sh new file mode 100755 index 00000000..e239dc91 --- /dev/null +++ b/tools/policy/check-wasm-artifacts.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +if [ "${CI:-}" = "true" ]; then + cargo run -p xtask -- assets fetch +fi +"$root/src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh" >/dev/null +cargo run -p xtask -- assets verify-committed diff --git a/tools/policy/check-workflows.sh b/tools/policy/check-workflows.sh new file mode 100755 index 00000000..b3760596 --- /dev/null +++ b/tools/policy/check-workflows.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" +PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" +export PATH + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + echo "run tools/dev/bootstrap-tools.sh to install pinned maintainer tools" >&2 + exit 1 + fi +} + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +require actionlint +require zizmor +if grep -R --line-number --fixed-strings 'pnpm moon run' .github/workflows; then + echo "GitHub workflows must invoke Moon through .github/scripts/run-moon-targets.sh" >&2 + exit 1 +fi +run actionlint +run zizmor --config .github/zizmor.yml --min-severity medium --persona auditor .github/workflows .github/actions diff --git a/tools/policy/fetch-sources.mjs b/tools/policy/fetch-sources.mjs new file mode 100755 index 00000000..2b50e093 --- /dev/null +++ b/tools/policy/fetch-sources.mjs @@ -0,0 +1,541 @@ +#!/usr/bin/env bun +import {spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import {dirname, join, relative, resolve, sep} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +process.chdir(workspaceRoot); + +const sourceCheckoutRoot = join(workspaceRoot, 'target', 'oliphaunt-sources', 'checkouts'); +const sourceArchiveRoot = join(workspaceRoot, 'target', 'oliphaunt-sources', 'archives'); +const sourceOrigins = { + sharedThirdParty: 'shared-third-party', + nativeThirdParty: 'native-third-party', + wasixThirdParty: 'wasix-third-party', + extension: 'extension', +}; +const allowedScopes = new Set(['all', 'native-runtime', 'wasix-runtime', 'extensions']); + +const {scope, force} = parseArgs(process.argv.slice(2)); +if (!allowedScopes.has(scope)) { + fail(`unsupported source fetch scope '${scope}'; expected one of: ${[...allowedScopes].join(', ')}`, 2); +} + +if (!force && process.env.CI !== 'true' && process.env.OLIPHAUNT_FETCH_SOURCES !== '1') { + console.log( + `source checkout fetch skipped outside CI for scope '${scope}'; set OLIPHAUNT_FETCH_SOURCES=1 or pass --force to refresh pinned checkouts with Bun`, + ); + process.exit(0); +} + +try { + const manifest = loadSourcesManifest(scope); + validateSourcesManifest(manifest, scope); + await fetchManifestSources(manifest, scope); +} catch (error) { + fail(error instanceof Error ? error.message : String(error)); +} + +function parseArgs(args) { + let selectedScope = 'all'; + let sawScope = false; + let forceFetch = false; + for (const arg of args) { + if (arg === '--force') { + forceFetch = true; + continue; + } + if (arg === '--help' || arg === '-h') { + console.log('usage: bun tools/policy/fetch-sources.mjs [all|native-runtime|wasix-runtime|extensions] [--force]'); + process.exit(0); + } + if (sawScope) { + fail(`unexpected argument '${arg}'`, 2); + } + selectedScope = arg; + sawScope = true; + } + return {scope: selectedScope, force: forceFetch}; +} + +function loadSourcesManifest(selectedScope) { + const sources = []; + const names = new Set(); + const thirdPartyRoot = join(workspaceRoot, 'src', 'sources', 'third-party'); + for (const [domain, origin] of sourceDomainsForScope(selectedScope)) { + const domainDir = join(thirdPartyRoot, domain); + if (!existsSync(domainDir)) { + continue; + } + for (const file of readdirSync(domainDir).sort()) { + if (!file.endsWith('.toml')) { + continue; + } + pushSourcePin(sources, names, join(domainDir, file), origin); + } + } + for (const sourcePath of extensionSourcePinPaths()) { + pushSourcePin(sources, names, sourcePath, sourceOrigins.extension); + } + return {sources, ...(scopeIncludesWasix(selectedScope) ? readToml('src/sources/toolchains/wasix.toml') : {})}; +} + +function sourceDomainsForScope(selectedScope) { + const domains = []; + if (selectedScope === 'all' || selectedScope === 'native-runtime' || selectedScope === 'wasix-runtime') { + domains.push(['shared', sourceOrigins.sharedThirdParty]); + } + if (selectedScope === 'all' || selectedScope === 'native-runtime') { + domains.push(['native', sourceOrigins.nativeThirdParty]); + } + if (selectedScope === 'all' || selectedScope === 'wasix-runtime') { + domains.push(['wasix', sourceOrigins.wasixThirdParty]); + } + return domains; +} + +function scopeIncludesWasix(selectedScope) { + return selectedScope === 'all' || selectedScope === 'wasix-runtime'; +} + +function extensionSourcePinPaths() { + const root = join(workspaceRoot, 'src', 'extensions', 'external'); + const paths = []; + collectSourcePins(root, paths); + return paths.sort(); +} + +function collectSourcePins(dir, paths) { + if (!existsSync(dir)) { + return; + } + for (const entry of readdirSync(dir, {withFileTypes: true}).sort((left, right) => + left.name.localeCompare(right.name), + )) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + collectSourcePins(path, paths); + } else if (entry.name === 'source.toml') { + paths.push(path); + } + } +} + +function pushSourcePin(sources, names, path, origin) { + const raw = readToml(path); + const source = { + name: stringField(raw, 'name', path), + kind: raw.kind ?? 'git', + url: stringField(raw, 'url', path), + branch: stringField(raw, 'branch', path), + commit: stringField(raw, 'commit', path), + sha256: optionalStringField(raw, 'sha256', path), + stripPrefix: optionalStringField(raw, 'strip_prefix', path) ?? optionalStringField(raw, 'strip-prefix', path), + origin, + }; + if (names.has(source.name)) { + throw new Error(`duplicate source pin '${source.name}' in source metadata`); + } + names.add(source.name); + sources.push(source); +} + +function readToml(path) { + const text = readFileSync(path, 'utf8'); + try { + return Bun.TOML.parse(text); + } catch (error) { + throw new Error(`parse ${path}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function stringField(object, field, path) { + const value = object[field]; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${path} must set non-empty string field '${field}'`); + } + return value; +} + +function optionalStringField(object, field, path) { + const value = object[field]; + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error(`${path} field '${field}' must be a string`); + } + return value; +} + +function validateSourcesManifest(manifest, selectedScope) { + if (!Array.isArray(manifest.sources) || manifest.sources.length === 0) { + throw new Error('source metadata must contain at least one source pin'); + } + if (scopeIncludesWasix(selectedScope)) { + validateWasixToolchain(manifest); + } + for (const source of manifest.sources) { + validateSourcePin(source); + } +} + +function validateWasixToolchain(manifest) { + assertEquals(manifest.toolchain?.wasmer, '7.2.0-alpha.3', 'toolchain.wasmer'); + assertEquals(manifest.toolchain?.['wasmer-wasix'], '0.702.0-alpha.3', 'toolchain.wasmer-wasix'); + const digest = manifest.toolchain?.docker_image_digest; + if (typeof digest !== 'string' || !/^sha256:[0-9a-fA-F]{64}$/.test(digest)) { + throw new Error(`toolchain.docker_image_digest must pin a concrete sha256 digest, got ${digest}`); + } + const dockerfile = readFileSync( + join(workspaceRoot, 'src', 'runtimes', 'liboliphaunt', 'wasix', 'assets', 'build', 'docker', 'Dockerfile'), + 'utf8', + ); + if (!dockerfile.includes(`FROM ubuntu:24.04@${digest}`)) { + throw new Error( + 'WASIX build Dockerfile must pin the same base image digest as src/sources/toolchains/wasix.toml', + ); + } + assertEquals(manifest.build?.postgres_prefix, '/', 'build.postgres_prefix'); + assertEquals(manifest.build?.postgres_pkglibdir, '/lib/postgresql', 'build.postgres_pkglibdir'); + assertEquals(manifest.build?.postgres_sharedir, '/share/postgresql', 'build.postgres_sharedir'); + assertIncludes(manifest.build?.main_flags, '-fwasm-exceptions', 'build.main_flags'); + assertNoFlagContains(manifest.build?.main_flags, 'asyncify', 'build.main_flags'); + assertIncludes(manifest.build?.extension_flags, '-fwasm-exceptions', 'build.extension_flags'); + assertNoFlagContains(manifest.build?.extension_flags, 'asyncify', 'build.extension_flags'); + assertIncludes(manifest.build?.extension_flags, '-fPIC', 'build.extension_flags'); + assertIncludes(manifest.build?.extension_flags, '-Wl,-shared', 'build.extension_flags'); + assertEquals(manifest.build?.archive_format, 'tar.zst', 'build.archive_format'); + if (manifest.build?.deterministic_archives !== true) { + throw new Error('build.deterministic_archives must be true'); + } +} + +function validateSourcePin(source) { + if (!validSourceNameComponent(source.name) || source.url.trim() === '' || source.branch.trim() === '') { + throw new Error(`invalid source pin in source metadata: ${JSON.stringify(source)}`); + } + if (source.commit.length < 40) { + throw new Error(`source '${source.name}' commit must be a full pinned revision`); + } + if (!['git', 'archive'].includes(source.kind)) { + throw new Error(`source '${source.name}' has unsupported kind '${source.kind}'`); + } + if (source.kind === 'git') { + if (source.sha256 !== undefined || source.stripPrefix !== undefined) { + throw new Error(`git source '${source.name}' must not set sha256 or strip-prefix`); + } + return; + } + const sha256 = archiveSha256(source); + archiveStripPrefix(source); + assertEquals(source.commit, sha256, `${source.name} archive commit must equal archive sha256`); + if (!source.url.endsWith('.tar.gz') && !source.url.endsWith('.tgz')) { + throw new Error(`archive source '${source.name}' must point at a .tar.gz or .tgz URL`); + } +} + +async function fetchManifestSources(manifest, selectedScope) { + for (const source of manifest.sources) { + if (!scopeIncludes(selectedScope, source.origin)) { + console.error(`skipping source '${source.name}' for selected source lane`); + continue; + } + const checkoutPath = sourceCheckoutPath(source.name); + if (checkoutPath === undefined) { + console.error(`warning: source '${source.name}' has no configured checkout path; skipping fetch`); + continue; + } + if (source.kind === 'archive') { + fetchArchiveSource(source, checkoutPath); + continue; + } + if (!existsSync(checkoutPath) || !existsSync(join(checkoutPath, '.git'))) { + initSourceCheckout(source, checkoutPath); + } + ensureCleanCheckout(source, checkoutPath); + ensureSourceRemote(source, checkoutPath); + await fetchGitSourceWithRetries(source, checkoutPath); + run('git', ['checkout', '-B', source.branch, source.commit], { + cwd: checkoutPath, + label: `checkout ${source.name} at ${source.commit} in ${checkoutPath}`, + }); + } +} + +function scopeIncludes(selectedScope, origin) { + if (selectedScope === 'all') { + return true; + } + if (selectedScope === 'native-runtime') { + return [ + sourceOrigins.sharedThirdParty, + sourceOrigins.nativeThirdParty, + sourceOrigins.extension, + ].includes(origin); + } + if (selectedScope === 'wasix-runtime') { + return [ + sourceOrigins.sharedThirdParty, + sourceOrigins.wasixThirdParty, + sourceOrigins.extension, + ].includes(origin); + } + return origin === sourceOrigins.extension; +} + +function initSourceCheckout(source, path) { + if (existsSync(path) && !existsSync(join(path, '.git'))) { + if (readdirSync(path).length === 0) { + rmSync(path, {recursive: true, force: true}); + } else { + throw new Error(`source checkout path ${path} exists but is not a git checkout; remove it or move it aside`); + } + } + mkdirSync(dirname(path), {recursive: true}); + run('git', ['init', path], {label: `initialize source checkout ${path}`}); + ensureSourceRemote(source, path); +} + +function ensureSourceRemote(source, path) { + const remotes = commandOutput('git', ['remote'], path); + const args = remotes.split(/\r?\n/).includes('origin') + ? ['remote', 'set-url', 'origin', source.url] + : ['remote', 'add', 'origin', source.url]; + run('git', args, {cwd: path, label: `configure origin remote for ${source.name} at ${path}`}); +} + +async function fetchGitSourceWithRetries(source, path) { + const attempts = 5; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + run('git', ['fetch', '--no-tags', '--depth', '1', 'origin', source.commit], { + cwd: path, + label: `fetch ${source.name}`, + }); + return; + } catch (error) { + if (attempt === attempts) { + throw error; + } + const delaySeconds = attempt * 5; + console.error( + `fetch ${source.name} failed on attempt ${attempt}/${attempts}: ${ + error instanceof Error ? error.message : String(error) + }; retrying in ${delaySeconds}s`, + ); + await Bun.sleep(delaySeconds * 1000); + } + } +} + +function fetchArchiveSource(source, path) { + if (archiveSourceReady(source, path)) { + return; + } + if (existsSync(path)) { + if (existsSync(join(path, '.git'))) { + const status = sourceCheckoutStatusForSource(source, path); + if (status.trim() !== '') { + throw new Error( + `archive source path ${path} (${source.name}) is a dirty git checkout; preserve it before replacing it with an archive source`, + ); + } + } + rmSync(path, {recursive: true, force: true}); + } + const archive = fetchSourceArchive(source); + const extractRoot = join(dirname(path), `.${source.name}-extracting`); + rmSync(extractRoot, {recursive: true, force: true}); + mkdirSync(extractRoot, {recursive: true}); + run('tar', ['-xzf', commandPath(archive), '-C', commandPath(extractRoot)], {label: `extract ${archive}`}); + const extracted = join(extractRoot, archiveStripPrefix(source)); + if (!isDirectory(extracted)) { + throw new Error(`archive source '${source.name}' did not contain expected root ${extracted}`); + } + mkdirSync(dirname(path), {recursive: true}); + renameSync(extracted, path); + rmSync(extractRoot, {recursive: true, force: true}); + writeFileSync(archiveSourceStampPath(path), archiveStamp(source)); +} + +function fetchSourceArchive(source) { + const sha256 = archiveSha256(source); + mkdirSync(sourceArchiveRoot, {recursive: true}); + const archive = join(sourceArchiveRoot, `${source.name}-${sha256}.tar.gz`); + if (existsSync(archive)) { + const actual = sha256File(archive); + if (actual === sha256) { + return archive; + } + rmSync(archive, {force: true}); + } + const tmpArchive = `${archive}.tmp`; + rmSync(tmpArchive, {force: true}); + run( + 'curl', + [ + '--fail', + '--location', + '--silent', + '--show-error', + '--retry', + '8', + '--retry-all-errors', + '--retry-delay', + '5', + '--connect-timeout', + '20', + source.url, + '-o', + tmpArchive, + ], + {label: `download ${source.name}`}, + ); + assertEquals(sha256File(tmpArchive), sha256, `${source.name} archive sha256`); + renameSync(tmpArchive, archive); + return archive; +} + +function archiveSourceReady(source, path) { + const stampPath = archiveSourceStampPath(path); + return isDirectory(path) && existsSync(stampPath) && readFileSync(stampPath, 'utf8') === archiveStamp(source); +} + +function archiveSourceStampPath(path) { + return join(path, '.oliphaunt-source-pin'); +} + +function archiveStamp(source) { + return `name=${source.name}\nkind=archive\nurl=${source.url}\nbranch=${source.branch}\ncommit=${source.commit}\nsha256=${ + source.sha256 ?? '' + }\nstrip-prefix=${source.stripPrefix ?? ''}\n`; +} + +function archiveSha256(source) { + if (source.sha256 === undefined || !/^[0-9a-fA-F]{64}$/.test(source.sha256)) { + throw new Error(`archive source '${source.name}' has invalid sha256 ${source.sha256}`); + } + return source.sha256; +} + +function archiveStripPrefix(source) { + if ( + source.stripPrefix === undefined || + source.stripPrefix === '' || + source.stripPrefix.includes('..') || + source.stripPrefix.startsWith('/') + ) { + throw new Error(`archive source '${source.name}' has invalid strip-prefix`); + } + return source.stripPrefix; +} + +function sourceCheckoutPath(name) { + return validSourceNameComponent(name) ? join(sourceCheckoutRoot, name) : undefined; +} + +function validSourceNameComponent(name) { + return ( + typeof name === 'string' && + name !== '' && + !name.includes('..') && + !name.includes('/') && + !name.includes('\\') && + /^[A-Za-z0-9._-]+$/.test(name) + ); +} + +function ensureCleanCheckout(source, path) { + if (!existsSync(path)) { + throw new Error(`source checkout is missing: ${path}`); + } + const status = sourceCheckoutStatusForSource(source, path); + if (status.trim() !== '') { + throw new Error(`source checkout ${path} (${source.name}) has uncommitted changes; preserve them before fetching pins`); + } +} + +function sourceCheckoutStatusForSource(source, path) { + return commandOutput('git', ['status', '--porcelain'], path, `read status for ${path} (${source.name})`); +} + +function commandOutput(command, args, cwd, label = `${command} ${args.join(' ')}`) { + const result = spawnSync(command, args, {cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}); + if (result.error !== undefined) { + throw new Error(`${label}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`${label}: ${result.stderr.trim() || `exit code ${result.status}`}`); + } + return result.stdout; +} + +function run(command, args, {cwd = workspaceRoot, label = `${command} ${args.join(' ')}`} = {}) { + const result = spawnSync(command, args, {cwd, stdio: 'inherit'}); + if (result.error !== undefined) { + throw new Error(`${label}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`${label}: exit code ${result.status}`); + } +} + +function sha256File(path) { + const hash = createHash('sha256'); + hash.update(readFileSync(path)); + return hash.digest('hex'); +} + +function isDirectory(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function commandPath(path) { + const relativePath = relative(workspaceRoot, resolve(path)); + if (!relativePath.startsWith('..') && !relativePath.includes(':')) { + return relativePath.split(sep).join('/'); + } + return path.split(sep).join('/'); +} + +function assertEquals(actual, expected, name) { + if (actual !== expected) { + throw new Error(`${name}: expected ${expected}, got ${actual}`); + } +} + +function assertIncludes(values, expected, name) { + if (!Array.isArray(values) || !values.includes(expected)) { + throw new Error(`${name} must contain ${expected}`); + } +} + +function assertNoFlagContains(values, needle, name) { + if (!Array.isArray(values)) { + throw new Error(`${name} must be an array`); + } + if (values.some((value) => typeof value === 'string' && value.includes(needle))) { + throw new Error(`${name} must not contain ${needle}`); + } +} + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} diff --git a/tools/policy/format.sh b/tools/policy/format.sh new file mode 100755 index 00000000..0e3412de --- /dev/null +++ b/tools/policy/format.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +mode="${1:---check}" +case "$mode" in + --check) biome_args=(format); cargo_fmt_args=(--check) ;; + --write) biome_args=(format --write); cargo_fmt_args=() ;; + *) echo "usage: tools/policy/format.sh [--check|--write]" >&2; exit 2 ;; +esac + +cargo fmt "${cargo_fmt_args[@]}" + +# Biome owns JS/TS/JSON/CSS formatting. Other language-native formatters are +# wired through their product build files to avoid overlapping format engines. +pnpm --package=@biomejs/biome@2.4.16 dlx biome "${biome_args[@]}" \ + package.json \ + biome.json \ + renovate.json \ + .markdownlint-cli2.jsonc \ + src/docs/package.json \ + src/docs/next.config.mjs \ + src/docs/postcss.config.mjs \ + src/docs/proxy.ts \ + src/docs/source.config.ts \ + src/docs/src \ + src/docs/tools \ + src/sdks/react-native/package.json \ + src/sdks/react-native/typedoc.json \ + src/sdks/react-native/react-native.config.js \ + src/sdks/react-native/src \ + src/sdks/js/package.json \ + src/sdks/js/typedoc.json \ + src/sdks/js/jsr.json \ + src/sdks/js/src \ + tools/perf/matrix \ + tools/test diff --git a/tools/policy/generate-sdk-api-surface.mjs b/tools/policy/generate-sdk-api-surface.mjs new file mode 100755 index 00000000..08908e43 --- /dev/null +++ b/tools/policy/generate-sdk-api-surface.mjs @@ -0,0 +1,480 @@ +#!/usr/bin/env node +import {execFileSync} from 'node:child_process'; +import {existsSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; + +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', +}).trim(); +const outputPath = path.join(root, 'docs/maintainers/sdk-api-surface.md'); +const mode = process.argv[2] ?? '--check'; + +if (!['--check', '--write'].includes(mode)) { + console.error('usage: tools/policy/generate-sdk-api-surface.mjs [--check|--write]'); + process.exit(2); +} + +function readRelative(relativePath) { + return readFileSync(path.join(root, relativePath), 'utf8'); +} + +function listFiles(relativeDir, extension) { + const absoluteDir = path.join(root, relativeDir); + if (!existsSync(absoluteDir)) { + return []; + } + return readdirSync(absoluteDir, {withFileTypes: true}) + .flatMap(entry => { + const child = path.join(relativeDir, entry.name); + if (entry.isDirectory()) { + return listFiles(child, extension); + } + return entry.isFile() && child.endsWith(extension) ? [child] : []; + }) + .sort(); +} + +function splitNames(raw) { + return raw + .split(',') + .map(name => name.trim()) + .filter(Boolean) + .map(name => name.replace(/\s+as\s+.*/u, '').trim()) + .filter(Boolean); +} + +function sorted(values) { + return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); +} + +function extractRustSurface() { + const lines = readRelative('src/sdks/rust/src/lib.rs').split('\n'); + const symbols = []; + let skipDocHidden = false; + + for (let index = 0; index < lines.length; index += 1) { + const trimmed = lines[index].trim(); + if (trimmed === '#[doc(hidden)]') { + skipDocHidden = true; + continue; + } + if (!trimmed.startsWith('pub use ')) { + if (trimmed.length > 0 && !trimmed.startsWith('#[')) { + skipDocHidden = false; + } + continue; + } + + let block = trimmed; + while (!block.includes(';') && index + 1 < lines.length) { + index += 1; + block += ` ${lines[index].trim()}`; + } + if (skipDocHidden) { + skipDocHidden = false; + continue; + } + + const spec = block + .replace(/^pub use\s+/u, '') + .replace(/;$/u, '') + .replace(/\s+/gu, ' ') + .trim(); + const grouped = spec.match(/^(.*)::\{(.*)\}$/u); + if (grouped) { + for (const name of splitNames(grouped[2])) { + symbols.push(`oliphaunt::${name}`); + } + } else { + const name = spec.split('::').pop(); + if (name) { + symbols.push(`oliphaunt::${name}`); + } + } + skipDocHidden = false; + } + + return sorted(symbols); +} + +function countBraces(line) { + let opens = 0; + let closes = 0; + for (const char of line) { + if (char === '{') opens += 1; + if (char === '}') closes += 1; + } + return {opens, closes}; +} + +function multilineDeclarationStillOpen(line) { + return !line.includes('{') && !line.includes(')') && !line.includes('='); +} + +function swiftMemberName(line) { + if (/\binit\s*\(/u.test(line)) { + return 'init'; + } + const functionMatch = line.match(/\bfunc\s+([A-Za-z_][A-Za-z0-9_]*)/u); + if (functionMatch) { + return `${functionMatch[1]}()`; + } + const valueMatch = line.match(/\b(?:var|let)\s+([A-Za-z_][A-Za-z0-9_]*)/u); + if (valueMatch) { + return valueMatch[1]; + } + return null; +} + +function extractSwiftSurface() { + const files = listFiles('src/sdks/swift/Sources/Oliphaunt', '.swift'); + const symbols = []; + + for (const file of files) { + let depth = 0; + const stack = []; + let awaitingContext = null; + + for (const line of readRelative(file).split('\n')) { + while (stack.length > 0 && depth < stack[stack.length - 1].depth) { + stack.pop(); + } + + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('//')) { + const braces = countBraces(line); + depth += braces.opens - braces.closes; + continue; + } + + const active = stack[stack.length - 1] ?? awaitingContext; + let pendingContext = null; + const typeMatch = trimmed.match( + /^public\s+(?:final\s+)?(enum|struct|actor|protocol|class)\s+([A-Za-z_][A-Za-z0-9_]*)/u, + ); + const extensionMatch = trimmed.match( + /^public\s+extension\s+([A-Za-z_][A-Za-z0-9_.]*)/u, + ); + + if (typeMatch) { + const name = active ? `${active.name}.${typeMatch[2]}` : typeMatch[2]; + symbols.push(`${typeMatch[1]} ${name}`); + pendingContext = {name, depth: depth + 1}; + } else if (extensionMatch) { + symbols.push(`extension ${extensionMatch[1]}`); + pendingContext = {name: extensionMatch[1], depth: depth + 1, extension: true}; + } else { + const inPublicExtension = active?.extension === true; + const isPublicMember = /^public\s+(?:static\s+)?(?:func|var|let|init)\b/u.test(trimmed); + const isExtensionMember = + inPublicExtension && /^(?:static\s+)?(?:func|var|let|init)\b/u.test(trimmed); + if (isPublicMember || isExtensionMember) { + const member = swiftMemberName(trimmed); + if (member) { + symbols.push(active ? `${active.name}.${member}` : member); + } + } + } + + const braces = countBraces(line); + depth += braces.opens - braces.closes; + if (pendingContext && braces.opens > braces.closes) { + pendingContext.depth = depth; + stack.push(pendingContext); + awaitingContext = null; + } else if (pendingContext && multilineDeclarationStillOpen(trimmed)) { + awaitingContext = pendingContext; + } else if (awaitingContext && braces.opens > braces.closes) { + awaitingContext.depth = depth; + stack.push(awaitingContext); + awaitingContext = null; + } else if (awaitingContext && trimmed.startsWith(')')) { + awaitingContext = null; + } + } + } + + return sorted(symbols); +} + +function kotlinMemberName(line) { + const functionMatch = line.match( + /\bfun\s+(?:<[^>]+>\s*)?(?:(?:[A-Za-z_][A-Za-z0-9_]*\.)+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(/u, + ); + if (functionMatch) { + const receiverMatch = line.match( + /\bfun\s+(?:<[^>]+>\s*)?((?:[A-Za-z_][A-Za-z0-9_]*\.)+)[A-Za-z_][A-Za-z0-9_]*\s*\(/u, + ); + return { + name: `${functionMatch[1]}()`, + receiver: receiverMatch ? receiverMatch[1].replace(/\.$/u, '') : null, + }; + } + const valueMatch = line.match(/\b(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)/u); + if (valueMatch) { + return {name: valueMatch[1], receiver: null}; + } + return null; +} + +function extractKotlinSurface() { + const sourceSets = ['commonMain', 'androidMain', 'jvmMain', 'nativeMain']; + const sections = []; + + for (const sourceSet of sourceSets) { + const files = listFiles( + `src/sdks/kotlin/oliphaunt/src/${sourceSet}/kotlin/dev/oliphaunt`, + '.kt', + ); + const symbols = []; + + for (const file of files) { + let depth = 0; + const stack = []; + let awaitingContext = null; + + for (const line of readRelative(file).split('\n')) { + while (stack.length > 0 && depth < stack[stack.length - 1].depth) { + stack.pop(); + } + + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('//')) { + const braces = countBraces(line); + depth += braces.opens - braces.closes; + continue; + } + + const active = stack[stack.length - 1] ?? awaitingContext; + let pendingContext = null; + const typeMatch = trimmed.match( + /^public\s+(?:(?:data|sealed|open)\s+)*(enum\s+class|data\s+class|sealed\s+class|open\s+class|class|object|interface)\s+([A-Za-z_][A-Za-z0-9_]*)/u, + ); + + if (typeMatch) { + const name = active ? `${active.name}.${typeMatch[2]}` : typeMatch[2]; + symbols.push(`${typeMatch[1]} ${name}`); + pendingContext = {name, depth: depth + 1}; + } else if (/^public\s+(?:expect\s+|actual\s+)?(?:suspend\s+)?fun\b/u.test(trimmed)) { + const member = kotlinMemberName(trimmed); + if (member) { + const owner = member.receiver ?? active?.name; + symbols.push(owner ? `${owner}.${member.name}` : member.name); + } + } else if (/^public\s+(?:val|var)\b/u.test(trimmed)) { + const member = kotlinMemberName(trimmed); + if (member) { + symbols.push(active ? `${active.name}.${member.name}` : member.name); + } + } + + const braces = countBraces(line); + depth += braces.opens - braces.closes; + if (pendingContext && braces.opens > braces.closes) { + pendingContext.depth = depth; + stack.push(pendingContext); + awaitingContext = null; + } else if (pendingContext && multilineDeclarationStillOpen(trimmed)) { + awaitingContext = pendingContext; + } else if (awaitingContext && braces.opens > braces.closes) { + awaitingContext.depth = depth; + stack.push(awaitingContext); + awaitingContext = null; + } else if (awaitingContext && trimmed.startsWith(')')) { + awaitingContext = null; + } + } + } + + sections.push({sourceSet, symbols: sorted(symbols)}); + } + + return sections; +} + +function extractTypeScriptSurface(indexFile, memberFiles) { + const text = readRelative(indexFile); + const types = []; + const values = []; + + for (const match of text.matchAll(/export\s+type\s+\{([\s\S]*?)\}\s+from/gu)) { + types.push(...splitNames(match[1])); + } + for (const match of text.matchAll(/export\s+\{([\s\S]*?)\}\s+from/gu)) { + values.push(...splitNames(match[1])); + } + for (const match of text.matchAll(/export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)/gu)) { + values.push(match[1]); + } + + const exportedTypes = new Set(types); + const exportedValues = new Set(values); + const members = extractTypeScriptMembers(exportedTypes, exportedValues, memberFiles); + + return { + types: sorted(types), + values: sorted(values), + members, + }; +} + +function extractReactNativeSurface() { + return extractTypeScriptSurface('src/sdks/react-native/src/index.ts', [ + 'src/sdks/react-native/src/client.ts', + 'src/sdks/react-native/src/protocol.ts', + 'src/sdks/react-native/src/query.ts', + ]); +} + +function extractOliphauntTsSurface() { + return extractTypeScriptSurface('src/sdks/js/src/index.ts', [ + 'src/sdks/js/src/client.ts', + 'src/sdks/js/src/protocol.ts', + 'src/sdks/js/src/query.ts', + 'src/sdks/js/src/types.ts', + ]); +} + +function typeScriptMemberName(line) { + const getterMatch = line.match(/^get\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/u); + if (getterMatch) { + return getterMatch[1]; + } + const methodMatch = line.match(/^(?:async\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(/u); + if (methodMatch) { + return `${methodMatch[1]}()`; + } + const propertyMatch = line.includes(';') + ? line.match(/^([A-Za-z_][A-Za-z0-9_]*)\??:/u) + : null; + if (propertyMatch) { + return propertyMatch[1]; + } + return null; +} + +function extractTypeScriptMembers(exportedTypes, exportedValues, files) { + const members = []; + + for (const file of files) { + let depth = 0; + const stack = []; + let awaitingContext = null; + for (const line of readRelative(file).split('\n')) { + while (stack.length > 0 && depth < stack[stack.length - 1].depth) { + stack.pop(); + } + + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('//')) { + const braces = countBraces(line); + depth += braces.opens - braces.closes; + continue; + } + + const active = stack[stack.length - 1] ?? awaitingContext; + let pendingContext = null; + const typeMatch = trimmed.match(/^export\s+type\s+([A-Za-z_][A-Za-z0-9_]*)/u); + const classMatch = trimmed.match(/^export\s+class\s+([A-Za-z_][A-Za-z0-9_]*)/u); + const functionMatch = trimmed.match(/^export\s+function\s+([A-Za-z_][A-Za-z0-9_]*)/u); + const constMatch = trimmed.match(/^export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)/u); + + if (typeMatch) { + if (exportedTypes.has(typeMatch[1])) { + pendingContext = {name: typeMatch[1], depth: depth + 1}; + } + } else if (classMatch) { + if (exportedValues.has(classMatch[1])) { + pendingContext = {name: classMatch[1], depth: depth + 1}; + } + } else if (functionMatch) { + if (exportedValues.has(functionMatch[1])) { + members.push(`${functionMatch[1]}()`); + } + } else if (constMatch) { + if (exportedValues.has(constMatch[1])) { + members.push(constMatch[1]); + } + } else if (active && depth === active.depth && !trimmed.startsWith('#')) { + const member = typeScriptMemberName(trimmed); + if (member && !['constructor'].includes(member.replace(/\(\)$/u, ''))) { + members.push(`${active.name}.${member}`); + } + } + + const braces = countBraces(line); + depth += braces.opens - braces.closes; + if (pendingContext && braces.opens > braces.closes) { + pendingContext.depth = depth; + stack.push(pendingContext); + awaitingContext = null; + } else if (pendingContext && multilineDeclarationStillOpen(trimmed)) { + awaitingContext = pendingContext; + } else if (awaitingContext && braces.opens > braces.closes) { + awaitingContext.depth = depth; + stack.push(awaitingContext); + awaitingContext = null; + } else if (awaitingContext && trimmed.startsWith('}')) { + awaitingContext = null; + } + } + } + + return sorted(members); +} + +function markdownList(items) { + if (items.length === 0) { + return '- none\n'; + } + return `${items.map(item => `- \`${item}\``).join('\n')}\n`; +} + +function render() { + const kotlin = extractKotlinSurface(); + const rn = extractReactNativeSurface(); + const ts = extractOliphauntTsSurface(); + let output = `\n`; + output += `# SDK API Surface Inventory\n\n`; + output += `This no-build inventory makes public SDK drift visible in review. It is a symbol-level guard, not a replacement for full language reference documentation.\n\n`; + output += `Regenerate with:\n\n`; + output += `\`\`\`sh\n`; + output += `node tools/policy/generate-sdk-api-surface.mjs --write\n`; + output += `\`\`\`\n\n`; + output += `## Rust: oliphaunt\n\n`; + output += markdownList(extractRustSurface()); + output += `\n## Swift: Oliphaunt\n\n`; + output += markdownList(extractSwiftSurface()); + output += `\n## Kotlin: oliphaunt\n\n`; + for (const section of kotlin) { + output += `### ${section.sourceSet}\n\n`; + output += markdownList(section.symbols); + output += `\n`; + } + output += `## React Native: @oliphaunt/react-native\n\n`; + output += `### Types\n\n`; + output += markdownList(rn.types); + output += `\n### Values\n\n`; + output += markdownList(rn.values); + output += `\n### Members\n\n`; + output += markdownList(rn.members); + output += `\n## TypeScript: @oliphaunt/ts\n\n`; + output += `### Types\n\n`; + output += markdownList(ts.types); + output += `\n### Values\n\n`; + output += markdownList(ts.values); + output += `\n### Members\n\n`; + output += markdownList(ts.members); + return output; +} + +const generated = render(); +if (mode === '--write') { + writeFileSync(outputPath, generated); +} else { + const current = existsSync(outputPath) ? readFileSync(outputPath, 'utf8') : ''; + if (current !== generated) { + console.error('docs/maintainers/sdk-api-surface.md is stale; run node tools/policy/generate-sdk-api-surface.mjs --write'); + process.exit(1); + } +} diff --git a/tools/policy/moon.mjs b/tools/policy/moon.mjs new file mode 100644 index 00000000..15769f86 --- /dev/null +++ b/tools/policy/moon.mjs @@ -0,0 +1,30 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +export function moonBin() { + if (process.env.MOON_BIN) { + return process.env.MOON_BIN; + } + + for (const candidate of [ + path.join(homedir(), '.proto/bin/moon'), + path.join(homedir(), '.proto/shims/moon'), + ]) { + if (existsSync(candidate)) { + return candidate; + } + } + + return 'moon'; +} + +export function runMoon(args, options = {}) { + return execFileSync(moonBin(), args, { + encoding: 'utf8', + env: {...process.env}, + maxBuffer: 32 * 1024 * 1024, + ...options, + }); +} diff --git a/tools/policy/moon.yml b/tools/policy/moon.yml new file mode 100644 index 00000000..1e92e8f2 --- /dev/null +++ b/tools/policy/moon.yml @@ -0,0 +1,122 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "policy-tools" +language: "javascript" +layer: "tool" +stack: "infrastructure" +tags: ["tools", "policy", "repo-hygiene"] + +project: + title: "Policy Checks" + description: "Cross-product repository policy, product graph, SDK parity, and package-boundary checks." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + fmt: + tags: ["format"] + command: "bash tools/policy/format.sh --write" + inputs: + - "/Cargo.toml" + - "/Cargo.lock" + - "/rust-toolchain.toml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/biome.json" + - "/renovate.json" + - "/.markdownlint-cli2.jsonc" + - "/src/docs/**/*" + - "!/src/docs/node_modules" + - "!/src/docs/node_modules/**" + - "!/src/docs/.next/**" + - "!/src/docs/.source/**" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/node_modules" + - "!/src/sdks/react-native/node_modules/**" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/lib/**" + - "/src/sdks/js/**/*" + - "!/src/sdks/js/node_modules" + - "!/src/sdks/js/node_modules/**" + - "!/src/sdks/js/lib/**" + - "/tools/policy/format.sh" + - "/tools/perf/matrix/**/*" + - "/tools/test/**/*" + options: + cache: false + runFromWorkspaceRoot: true + fmt-check: + tags: ["format", "quality", "static"] + command: "bash tools/policy/format.sh --check" + inputs: + - "/Cargo.toml" + - "/Cargo.lock" + - "/rust-toolchain.toml" + - "/package.json" + - "/pnpm-lock.yaml" + - "/biome.json" + - "/renovate.json" + - "/.markdownlint-cli2.jsonc" + - "/src/docs/**/*" + - "!/src/docs/node_modules" + - "!/src/docs/node_modules/**" + - "!/src/docs/.next/**" + - "!/src/docs/.source/**" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/node_modules" + - "!/src/sdks/react-native/node_modules/**" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "!/src/sdks/react-native/lib/**" + - "/src/sdks/js/**/*" + - "!/src/sdks/js/node_modules" + - "!/src/sdks/js/node_modules/**" + - "!/src/sdks/js/lib/**" + - "/tools/policy/format.sh" + - "/tools/perf/matrix/**/*" + - "/tools/test/**/*" + options: + cache: true + runFromWorkspaceRoot: true + check: + tags: ["quality", "static"] + command: "bash tools/policy/check-policy-tools.sh" + inputs: + - "/.github/**/*" + - "/.moon/*.{yml,yaml,jsonc,json,pkl,hcl,toml}" + - "/Cargo.lock" + - "/Cargo.toml" + - "/README.md" + - "/coverage/**/*" + - "/docs/**/*" + - "/src/shared/fixtures/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/graph/**/*" + - "/tools/policy/**/*" + - "/tools/release/**/*" + - "/tools/perf/**/*" + - "/tools/test/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/tools/policy/sdk-check-lib.sh b/tools/policy/sdk-check-lib.sh new file mode 100755 index 00000000..3aef2175 --- /dev/null +++ b/tools/policy/sdk-check-lib.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env sh + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +require_command() { + command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "missing required command: $command_name" >&2 + exit 1 + fi +} + +require_command rg + +require_file() { + file="$1" + if [ ! -f "$file" ]; then + echo "missing required SDK parity file: $file" >&2 + exit 1 + fi +} + +reject_file() { + file="$1" + message="$2" + if [ -f "$file" ]; then + echo "$message" >&2 + echo "unexpected SDK parity file: $file" >&2 + exit 1 + fi +} + +require_text() { + file="$1" + text="$2" + message="$3" + if ! rg -q --fixed-strings -- "$text" "$file"; then + echo "$message" >&2 + echo "expected '$text' in $file" >&2 + exit 1 + fi +} + +require_manifest_text() { + sdk="$1" + text="$2" + message="$3" + if ! awk -v section="[sdks.$sdk]" -v expected="$text" ' + $0 == section { + in_section = 1 + next + } + /^\[sdks\./ && in_section { + exit + } + in_section && index($0, expected) > 0 { + found = 1 + exit + } + END { + if (found) { + exit 0 + } + exit 1 + } + ' tools/policy/sdk-manifest.toml; then + echo "$message" >&2 + echo "expected '$text' in [sdks.$sdk] of tools/policy/sdk-manifest.toml" >&2 + exit 1 + fi +} + +require_no_files_under() { + path="$1" + message="$2" + if [ -d "$path" ] && find "$path" -type f | grep -q .; then + echo "$message" >&2 + find "$path" -type f >&2 + exit 1 + fi +} + +reject_text() { + file="$1" + text="$2" + message="$3" + if rg -q --fixed-strings -- "$text" "$file"; then + echo "$message" >&2 + echo "unexpected '$text' in $file" >&2 + exit 1 + fi +} diff --git a/tools/policy/sdk-manifest.toml b/tools/policy/sdk-manifest.toml new file mode 100644 index 00000000..82877bb5 --- /dev/null +++ b/tools/policy/sdk-manifest.toml @@ -0,0 +1,75 @@ +# Product SDK taxonomy. +# +# This file is intentionally small and reviewed with SDK changes. It is the +# repo-level registry for SDK ownership, target platforms, and delegation rules; +# `tools/policy/check-sdk-parity.sh` keeps the docs and package boundaries +# aligned with it. + +schema_version = 1 + +[sdks.rust] +classification = "sdk" +package_name = "oliphaunt" +implementation_path = "src/sdks/rust" +documentation_path = "src/docs/content/sdk/rust" +primary_targets = ["tauri", "rust-desktop"] +runtime_owner = true +runtime_boundary = "oliphaunt" +parity_role = "canonical" +available_modes = ["native-direct", "native-broker", "native-server"] +unsupported_modes = [] + +[sdks.swift] +classification = "sdk" +package_name = "Oliphaunt" +implementation_path = "src/sdks/swift" +documentation_path = "src/docs/content/sdk/swift" +primary_targets = ["ios", "macos"] +runtime_owner = true +runtime_boundary = "Oliphaunt" +parity_role = "platform-peer" +available_modes = ["native-direct"] +unsupported_modes = ["native-broker", "native-server"] +unsupported_mode_reason = "platform broker/server adapters are not implemented yet; direct mode remains a single-session runtime" + +[sdks.kotlin] +classification = "sdk" +package_name = "oliphaunt" +implementation_path = "src/sdks/kotlin" +documentation_path = "src/docs/content/sdk/kotlin" +primary_targets = ["android"] +runtime_owner = true +runtime_boundary = "OliphauntAndroid" +parity_role = "platform-peer" +available_modes = ["native-direct"] +unsupported_modes = ["native-broker", "native-server"] +unsupported_mode_reason = "Android broker/server adapters are not implemented yet; direct mode remains a single-session runtime" + +[sdks.react-native] +classification = "sdk" +package_name = "@oliphaunt/react-native" +implementation_path = "src/sdks/react-native" +documentation_path = "src/docs/content/sdk/react-native" +primary_targets = ["react-native-ios", "react-native-android", "future-react-native-macos"] +runtime_owner = false +runtime_boundary = "TurboModule adapter" +delegates_apple_to = "swift" +delegates_android_to = "kotlin" +parity_role = "delegating-platform-peer" +available_modes = ["native-direct"] +unsupported_modes = ["native-broker", "native-server"] +unsupported_mode_reason = "runtime availability is delegated to Swift and Kotlin supportedModes" + +[sdks.typescript] +classification = "sdk" +package_name = "@oliphaunt/ts" +implementation_path = "src/sdks/js" +documentation_path = "src/docs/content/sdk/typescript" +primary_targets = ["node", "bun", "deno", "tauri-javascript"] +runtime_owner = true +runtime_boundary = "@oliphaunt/ts" +parity_role = "desktop-javascript-peer" +available_modes = ["native-direct", "native-broker", "native-server"] +unsupported_modes = [] +depends_on_rust_broker_helper = true +broker_helper_product = "oliphaunt-rust" diff --git a/tools/release/archive_dir.py b/tools/release/archive_dir.py new file mode 100755 index 00000000..99fe5b8b --- /dev/null +++ b/tools/release/archive_dir.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Create a deterministic tar.gz or zip archive from a directory.""" + +from __future__ import annotations + +import gzip +import os +import stat +import sys +import tarfile +import zipfile +from pathlib import Path +from typing import NoReturn + + +def fail(message: str) -> "NoReturn": + print(f"archive_dir.py: {message}", file=sys.stderr) + raise SystemExit(2) + + +def normalized_mode(path: Path) -> int: + mode = path.stat().st_mode + if path.is_dir(): + return stat.S_IFDIR | 0o755 + executable = bool(mode & stat.S_IXUSR) + return stat.S_IFREG | (0o755 if executable else 0o644) + + +def add_path(archive: tarfile.TarFile, root: Path, path: Path) -> None: + relative = path.relative_to(root) + name = "." if str(relative) == "." else relative.as_posix() + info = tarfile.TarInfo(name) + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + info.mode = normalized_mode(path) & 0o777 + if path.is_dir(): + info.type = tarfile.DIRTYPE + archive.addfile(info) + return + if not path.is_file(): + fail(f"unsupported archive entry type: {path}") + info.size = path.stat().st_size + with path.open("rb") as file: + archive.addfile(info, file) + + +def add_zip_path(archive: zipfile.ZipFile, root: Path, path: Path) -> None: + relative = path.relative_to(root) + name = "." if str(relative) == "." else relative.as_posix() + if path.is_dir() and name != ".": + name = f"{name}/" + info = zipfile.ZipInfo(name) + info.date_time = (1980, 1, 1, 0, 0, 0) + info.create_system = 3 + info.external_attr = (normalized_mode(path) & 0o777) << 16 + if path.is_dir(): + info.external_attr |= 0x10 + archive.writestr(info, b"") + return + if not path.is_file(): + fail(f"unsupported archive entry type: {path}") + info.compress_type = zipfile.ZIP_DEFLATED + with path.open("rb") as file: + archive.writestr(info, file.read()) + + +def write_tar_gz(source: Path, output: Path) -> None: + with output.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gzip_file: + with tarfile.open(fileobj=gzip_file, mode="w") as archive: + add_path(archive, source, source) + for directory, dirnames, filenames in os.walk(source): + dirnames.sort() + filenames.sort() + for dirname in dirnames: + add_path(archive, source, Path(directory) / dirname) + for filename in filenames: + add_path(archive, source, Path(directory) / filename) + + +def write_zip(source: Path, output: Path) -> None: + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + add_zip_path(archive, source, source) + for directory, dirnames, filenames in os.walk(source): + dirnames.sort() + filenames.sort() + for dirname in dirnames: + add_zip_path(archive, source, Path(directory) / dirname) + for filename in filenames: + add_zip_path(archive, source, Path(directory) / filename) + + +def main(argv: list[str]) -> int: + if len(argv) != 3: + fail("usage: tools/release/archive_dir.py ") + source = Path(argv[1]).resolve() + output = Path(argv[2]).resolve() + if not source.is_dir(): + fail(f"source is not a directory: {source}") + output.parent.mkdir(parents=True, exist_ok=True) + if output.name.endswith(".tar.gz"): + write_tar_gz(source, output) + elif output.suffix == ".zip": + write_zip(source, output) + else: + fail(f"unsupported archive extension: {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tools/release/artifact_target_matrix.py b/tools/release/artifact_target_matrix.py new file mode 100755 index 00000000..c2358f4b --- /dev/null +++ b/tools/release/artifact_target_matrix.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Emit GitHub Actions matrices derived from release artifact targets.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +import json +import os +from pathlib import Path +from typing import Iterable + +import artifact_targets +import extension_artifact_targets +import product_metadata + + +@dataclass +class ExtensionTargetGroup: + target: str + runner: str + extensions: set[str] = field(default_factory=set) + sql_names: set[str] = field(default_factory=set) + build_root: str | None = None + ci_artifact_root: str | None = None + runtime_kind: str | None = None + triple: str | None = None + + +def build_root_for_liboliphaunt_target(target_id: str) -> str: + if target_id == "macos-arm64": + return "target/liboliphaunt-pg18" + if target_id == "android-arm64-v8a": + return "target/liboliphaunt-pg18-android-arm64" + if target_id == "ios-xcframework": + return "target/liboliphaunt-ios-xcframework" + return f"target/liboliphaunt-pg18-{target_id}" + + +def ci_artifact_root_for_liboliphaunt_target(target_id: str) -> str: + return f"target/liboliphaunt-native-ci/{target_id}" + + +def liboliphaunt_native_runtime_matrix() -> dict[str, list[dict[str, str]]]: + include: list[dict[str, str]] = [] + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + published_only=True, + ): + if target.runner is None: + product_metadata.fail(f"{target.id} must declare runner") + include.append( + { + "target": target.target, + "runner": target.runner, + "build-root": build_root_for_liboliphaunt_target(target.target), + "ci-artifact-root": ci_artifact_root_for_liboliphaunt_target(target.target), + } + ) + if not include: + product_metadata.fail("no published liboliphaunt-native native-runtime targets") + return {"include": include} + + +def _filtered_liboliphaunt_native_runtime_matrix( + predicate, + *, + native_target: str = "all", + selected_targets: set[str] | None = None, + label: str, +) -> dict[str, list[dict[str, str]]]: + include = [ + item + for item in liboliphaunt_native_runtime_matrix()["include"] + if predicate(item["target"]) + ] + if native_target != "all": + include = [item for item in include if item["target"] == native_target] + if selected_targets is not None: + include = [item for item in include if item["target"] in selected_targets] + if not include: + product_metadata.fail(f"no published liboliphaunt-native {label} targets matched the selected CI plan") + return {"include": include} + + +def liboliphaunt_native_desktop_runtime_matrix( + *, + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return _filtered_liboliphaunt_native_runtime_matrix( + lambda target: target.startswith(("linux-", "macos-", "windows-")), + native_target=native_target, + selected_targets=selected_targets, + label="desktop", + ) + + +def liboliphaunt_native_android_runtime_matrix( + *, + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return _filtered_liboliphaunt_native_runtime_matrix( + lambda target: target.startswith("android-"), + native_target=native_target, + selected_targets=selected_targets, + label="Android", + ) + + +def liboliphaunt_native_ios_runtime_matrix( + *, + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + return _filtered_liboliphaunt_native_runtime_matrix( + lambda target: target == "ios-xcframework", + native_target=native_target, + selected_targets=selected_targets, + label="iOS", + ) + + +def extension_artifacts_native_matrix( + native_target: str = "all", + selected_targets: set[str] | None = None, + selected_products: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + by_target: dict[str, ExtensionTargetGroup] = {} + runtime_targets = { + target.target: target + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + published_only=True, + ) + if target.extension_artifacts + } + for extension_target in extension_artifact_targets.artifact_targets( + family="native", + published_only=True, + ): + if selected_products is not None and extension_target.product not in selected_products: + continue + target_id = extension_target.target + if native_target != "all" and target_id != native_target: + continue + if selected_targets is not None and target_id not in selected_targets: + continue + runtime_target = runtime_targets.get(target_id) + if runtime_target is None: + product_metadata.fail(f"{extension_target.product} declares native extension target {target_id}, but liboliphaunt-native does not publish it") + if runtime_target.runner is None: + product_metadata.fail(f"{runtime_target.id} must declare runner") + grouped = by_target.setdefault( + target_id, + ExtensionTargetGroup( + target=target_id, + runner=runtime_target.runner, + build_root=build_root_for_liboliphaunt_target(target_id), + ci_artifact_root=ci_artifact_root_for_liboliphaunt_target(target_id), + ), + ) + grouped.extensions.add(extension_target.product) + grouped.sql_names.add(extension_target.sql_name) + include: list[dict[str, str]] = [] + for item in by_target.values(): + extensions = sorted(item.extensions) + sql_names = sorted(item.sql_names) + if item.build_root is None or item.ci_artifact_root is None: + raise AssertionError(f"native extension group {item.target} is missing native build metadata") + include.append( + { + "extensions_csv": ",".join(extensions), + "sql_names_csv": ",".join(sql_names), + "extension_count": str(len(extensions)), + "target": item.target, + "runner": item.runner, + "build-root": item.build_root, + "ci-artifact-root": item.ci_artifact_root, + } + ) + if not include: + valid_targets = ", ".join(extension_artifact_targets.published_target_ids(family="native")) + product_metadata.fail(f"unknown native extension artifact target {native_target}; expected one of: all, {valid_targets}") + include.sort(key=lambda item: item["target"]) + return {"include": include} + + +def liboliphaunt_native_runtime_targets_for_surface(surface: str) -> list[str]: + targets = [ + target.target + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + surface=surface, + published_only=True, + ) + ] + if not targets: + product_metadata.fail(f"no published liboliphaunt-native native-runtime targets for surface {surface}") + return sorted(targets) + + +def react_native_android_mobile_app_matrix( + *, + native_target: str = "all", + selected_targets: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + include: list[dict[str, str]] = [] + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + surface="react-native-android", + published_only=True, + ): + if native_target != "all" and target.target != native_target: + continue + if selected_targets is not None and target.target not in selected_targets: + continue + if target.target == "android-arm64-v8a": + abi = "arm64-v8a" + elif target.target == "android-x86_64": + abi = "x86_64" + else: + product_metadata.fail(f"unsupported React Native Android runtime target {target.target}") + include.append( + { + "target": target.target, + "abi": abi, + "build-root": build_root_for_liboliphaunt_target(target.target), + } + ) + if not include: + valid_targets = ", ".join(liboliphaunt_native_runtime_targets_for_surface("react-native-android")) + product_metadata.fail(f"no React Native Android app targets matched; expected one of: all, {valid_targets}") + include.sort(key=lambda item: item["target"]) + return {"include": include} + + +def extension_artifacts_wasix_matrix( + wasm_target: str = "all", + selected_products: set[str] | None = None, +) -> dict[str, list[dict[str, str]]]: + by_target: dict[str, ExtensionTargetGroup] = {} + extension_targets = extension_artifact_targets.artifact_targets(family="wasix", published_only=True) + for target in artifact_targets.artifact_targets( + product="liboliphaunt-wasix", + published_only=True, + ): + if target.kind != "wasix-runtime": + continue + extension_target = "wasix-portable" if target.target == "portable" else target.target + if wasm_target != "all" and target.target != wasm_target: + continue + for declared in extension_targets: + if selected_products is not None and declared.product not in selected_products: + continue + if declared.target != extension_target: + continue + grouped = by_target.setdefault( + declared.target, + ExtensionTargetGroup( + target=declared.target, + runner=target.runner or "ubuntu-latest", + runtime_kind=target.kind, + triple=target.triple or "", + ), + ) + grouped.extensions.add(declared.product) + grouped.sql_names.add(declared.sql_name) + include: list[dict[str, str]] = [] + for item in by_target.values(): + extensions = sorted(item.extensions) + sql_names = sorted(item.sql_names) + if item.runtime_kind is None or item.triple is None: + raise AssertionError(f"WASIX extension group {item.target} is missing runtime metadata") + include.append( + { + "extensions_csv": ",".join(extensions), + "sql_names_csv": ",".join(sql_names), + "extension_count": str(len(extensions)), + "target": item.target, + "runner": item.runner, + "runtime-kind": item.runtime_kind, + "triple": item.triple, + } + ) + if not include: + valid_targets = ", ".join( + target.target + for target in artifact_targets.artifact_targets( + product="liboliphaunt-wasix", + published_only=True, + ) + if target.kind == "wasix-runtime" + ) + product_metadata.fail(f"unknown WASIX extension artifact target {wasm_target}; expected one of: all, {valid_targets}") + include.sort(key=lambda item: item["target"]) + return {"include": include} + + +def liboliphaunt_wasix_aot_runtime_matrix(wasm_target: str = "all") -> dict[str, list[dict[str, str]]]: + include: list[dict[str, str]] = [] + for target in artifact_targets.artifact_targets( + product="liboliphaunt-wasix", + kind="wasix-aot-runtime", + published_only=True, + ): + if wasm_target != "all" and wasm_target not in {target.target, target.triple}: + continue + if target.runner is None: + product_metadata.fail(f"{target.id} must declare runner") + if target.triple is None: + product_metadata.fail(f"{target.id} must declare triple") + if target.llvm_url is None: + product_metadata.fail(f"{target.id} must declare llvm_url") + include.append( + { + "os": target.runner, + "target": target.triple, + "target_id": target.target, + "package": f"oliphaunt-wasix-aot-{target.triple}", + "artifact": f"liboliphaunt-wasix-runtime-aot-{target.target}", + "llvm_url": target.llvm_url, + } + ) + if not include: + valid_targets = ", ".join( + target.target + for target in artifact_targets.artifact_targets( + product="liboliphaunt-wasix", + kind="wasix-aot-runtime", + published_only=True, + ) + ) + product_metadata.fail(f"unknown WASIX AOT runtime target {wasm_target}; expected one of: all, {valid_targets}") + include.sort(key=lambda item: item["target_id"]) + return {"include": include} + + +def exact_extension_products() -> list[str]: + return sorted({target.product for target in extension_artifact_targets.artifact_targets()}) + + +def broker_runtime_matrix() -> dict[str, list[dict[str, str]]]: + include: list[dict[str, str]] = [] + for target in artifact_targets.artifact_targets( + product="oliphaunt-broker", + kind="broker-helper", + published_only=True, + ): + if target.runner is None: + product_metadata.fail(f"{target.id} must declare runner") + include.append( + { + "target": target.target, + "runner": target.runner, + } + ) + if not include: + product_metadata.fail("no published oliphaunt-broker helper targets") + return {"include": include} + + +def node_direct_runtime_matrix() -> dict[str, list[dict[str, str]]]: + include: list[dict[str, str]] = [] + for target in artifact_targets.artifact_targets( + product="oliphaunt-node-direct", + kind="node-direct-addon", + published_only=True, + ): + if target.runner is None: + product_metadata.fail(f"{target.id} must declare runner") + include.append( + { + "target": target.target, + "runner": target.runner, + } + ) + if not include: + product_metadata.fail("no published oliphaunt-node-direct targets") + return {"include": include} + + +def emit_github_output(name: str, value: object) -> None: + rendered = json.dumps(value, sort_keys=True, separators=(",", ":")) + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path: + with Path(output_path).open("a", encoding="utf-8") as handle: + print(f"{name}={rendered}", file=handle) + print(f"{name}={rendered}") + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "matrix", + choices=[ + "liboliphaunt-native-runtime", + "liboliphaunt-native-desktop-runtime", + "liboliphaunt-native-android-runtime", + "liboliphaunt-native-ios-runtime", + "react-native-android-mobile-app", + "extension-artifacts-native", + "extension-artifacts-wasix", + "liboliphaunt-wasix-aot-runtime", + "broker-runtime", + "node-direct-runtime", + ], + help="matrix shape to emit", + ) + parser.add_argument("--github-output", action="store_true", help="write matrix=... to $GITHUB_OUTPUT") + args = parser.parse_args(list(argv) if argv is not None else None) + + product_metadata.load_graph() + match args.matrix: + case "liboliphaunt-native-runtime": + matrix = liboliphaunt_native_runtime_matrix() + case "liboliphaunt-native-desktop-runtime": + matrix = liboliphaunt_native_desktop_runtime_matrix() + case "liboliphaunt-native-android-runtime": + matrix = liboliphaunt_native_android_runtime_matrix() + case "liboliphaunt-native-ios-runtime": + matrix = liboliphaunt_native_ios_runtime_matrix() + case "react-native-android-mobile-app": + matrix = react_native_android_mobile_app_matrix() + case "extension-artifacts-native": + matrix = extension_artifacts_native_matrix() + case "extension-artifacts-wasix": + matrix = extension_artifacts_wasix_matrix() + case "liboliphaunt-wasix-aot-runtime": + matrix = liboliphaunt_wasix_aot_runtime_matrix() + case "broker-runtime": + matrix = broker_runtime_matrix() + case "node-direct-runtime": + matrix = node_direct_runtime_matrix() + case _: + raise AssertionError(args.matrix) + + if args.github_output: + emit_github_output("matrix", matrix) + else: + print(json.dumps(matrix, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/artifact_targets.py b/tools/release/artifact_targets.py new file mode 100644 index 00000000..752a88b8 --- /dev/null +++ b/tools/release/artifact_targets.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Release artifact target metadata from legacy graph and product-local files.""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import product_metadata + +ROOT = Path(__file__).resolve().parents[2] + +PRODUCT_LOCAL_TARGET_DIRS = ( + ROOT / "src/runtimes/liboliphaunt/native/targets", + ROOT / "src/runtimes/liboliphaunt/wasix/targets", + ROOT / "src/runtimes/broker/targets", + ROOT / "src/runtimes/node-direct/targets", +) + + +@dataclass(frozen=True) +class ArtifactTarget: + id: str + product: str + kind: str + target: str + asset: str + published: bool + surfaces: tuple[str, ...] + triple: str | None = None + runner: str | None = None + library_relative_path: str | None = None + executable_relative_path: str | None = None + npm_package: str | None = None + llvm_url: str | None = None + extension_artifacts: bool = True + + def asset_name(self, version: str) -> str: + return self.asset.format(version=version) + + +def _string(value: object, key: str, target_id: str, required: bool = True) -> str | None: + if isinstance(value, str) and value: + return value + if required: + product_metadata.fail(f"artifact target {target_id}.{key} must be a non-empty string") + if value is not None: + product_metadata.fail(f"artifact target {target_id}.{key} must be a string") + return None + + +def _surfaces(value: object, target_id: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value): + product_metadata.fail(f"artifact target {target_id}.surfaces must be a non-empty string list") + return tuple(value) + + +def _published(value: object, target_id: str) -> bool: + if isinstance(value, bool): + return value + product_metadata.fail(f"artifact target {target_id}.published must be true or false") + + +def _optional_bool(value: object, key: str, target_id: str, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + product_metadata.fail(f"artifact target {target_id}.{key} must be true or false") + + +def _local_target_tables() -> list[dict]: + tables: list[dict] = [] + for directory in PRODUCT_LOCAL_TARGET_DIRS: + if not directory.exists(): + continue + for path in sorted(directory.glob("*.toml")): + data = tomllib.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + product_metadata.fail(f"{path.relative_to(ROOT)} must contain one target table") + table = dict(data) + table["_source_file"] = str(path.relative_to(ROOT)) + tables.append(table) + return tables + + +def raw_artifact_target_tables(graph: dict | None = None) -> list[dict]: + """Return artifact target tables from product-local metadata.""" + + data = graph if graph is not None else product_metadata.load_graph() + graph_targets = data.get("artifact_targets", []) + if not isinstance(graph_targets, list): + product_metadata.fail("compatibility artifact_targets must be an array of tables") + tables: list[dict] = [] + for raw in graph_targets: + if not isinstance(raw, dict): + product_metadata.fail("compatibility artifact_targets entries must be tables") + table = dict(raw) + table.setdefault("_source_file", "product metadata compatibility graph") + tables.append(table) + tables.extend(_local_target_tables()) + return tables + + +def artifact_targets( + graph: dict | None = None, + *, + product: str | None = None, + kind: str | None = None, + surface: str | None = None, + published_only: bool = False, +) -> list[ArtifactTarget]: + data = graph if graph is not None else product_metadata.load_graph() + raw_targets = raw_artifact_target_tables(data) + + products = product_metadata.graph_products(data) + parsed: list[ArtifactTarget] = [] + seen: set[str] = set() + for raw in raw_targets: + target_id = _string(raw.get("id"), "id", "") + assert target_id is not None + if target_id in seen: + source_file = raw.get("_source_file", "unknown source") + product_metadata.fail(f"duplicate artifact target id {target_id} in {source_file}") + seen.add(target_id) + + target_product = _string(raw.get("product"), "product", target_id) + assert target_product is not None + if target_product not in products: + product_metadata.fail(f"artifact target {target_id} references unknown product {target_product}") + + parsed_target = ArtifactTarget( + id=target_id, + product=target_product, + kind=_string(raw.get("kind"), "kind", target_id) or "", + target=_string(raw.get("target"), "target", target_id) or "", + asset=_string(raw.get("asset"), "asset", target_id) or "", + published=_published(raw.get("published"), target_id), + surfaces=_surfaces(raw.get("surfaces"), target_id), + triple=_string(raw.get("triple"), "triple", target_id, required=False), + runner=_string(raw.get("runner"), "runner", target_id, required=False), + library_relative_path=_string(raw.get("library_relative_path"), "library_relative_path", target_id, required=False), + executable_relative_path=_string(raw.get("executable_relative_path"), "executable_relative_path", target_id, required=False), + npm_package=_string(raw.get("npm_package"), "npm_package", target_id, required=False), + llvm_url=_string(raw.get("llvm_url"), "llvm_url", target_id, required=False), + extension_artifacts=_optional_bool(raw.get("extension_artifacts"), "extension_artifacts", target_id, True), + ) + if product is not None and parsed_target.product != product: + continue + if kind is not None and parsed_target.kind != kind: + continue + if surface is not None and surface not in parsed_target.surfaces: + continue + if published_only and not parsed_target.published: + continue + parsed.append(parsed_target) + + return parsed + + +def expected_assets( + product: str, + version: str, + *, + surface: str = "github-release", + published_only: bool = True, + kinds: Iterable[str] | None = None, +) -> list[str]: + allowed_kinds = set(kinds) if kinds is not None else None + assets = [ + target.asset_name(version) + for target in artifact_targets( + product=product, + surface=surface, + published_only=published_only, + ) + if allowed_kinds is None or target.kind in allowed_kinds + ] + if not assets: + product_metadata.fail(f"{product} has no artifact targets for surface {surface}") + return sorted(assets) diff --git a/tools/release/build-extension-ci-artifacts.py b/tools/release/build-extension-ci-artifacts.py new file mode 100755 index 00000000..9fbd4999 --- /dev/null +++ b/tools/release/build-extension-ci-artifacts.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""Stage publishable exact-extension artifacts from built runtime outputs.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import shutil +import sys +from pathlib import Path +from typing import NoReturn + +import product_metadata +import extension_artifact_targets + + +ROOT = Path(__file__).resolve().parents[2] + + +def fail(message: str) -> NoReturn: + print(f"build-extension-ci-artifacts.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def extension_products() -> list[str]: + products = [] + for product in product_metadata.product_ids(): + config = product_metadata.product_config(product) + if config.get("kind") == "exact-extension-artifact": + products.append(product) + return sorted(products) + + +def extension_sql_name(product: str) -> str: + config = product_metadata.product_config(product) + value = config.get("extension_sql_name") + if not isinstance(value, str) or not value: + fail(f"{product} release metadata must declare extension_sql_name") + return value + + +def generated_extension_row(sql_name: str) -> dict[str, object]: + metadata = ROOT / "src/extensions/generated/sdk/kotlin.json" + with metadata.open("r", encoding="utf-8") as handle: + data = json.load(handle) + for row in data.get("extensions", []): + if isinstance(row, dict) and row.get("sql-name") == sql_name: + return row + fail(f"generated extension metadata has no row for {sql_name}") + + +def string_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return sorted(str(item) for item in value if str(item)) + + +def properties_csv(values: list[str]) -> str: + return ",".join(values) + + +def resolve_repo_path(value: str, *, label: str) -> Path: + path = Path(value) + if not path.is_absolute(): + path = ROOT / path + try: + path.relative_to(ROOT) + except ValueError: + fail(f"{label} must be inside the repository: {path}") + return path + + +def native_release_asset_root() -> Path: + return resolve_repo_path( + os.environ.get("OLIPHAUNT_NATIVE_EXTENSION_RELEASE_ASSET_ROOT", "target/extensions/native/release-assets"), + label="native extension release asset root", + ) + + +def wasix_release_asset_root() -> Path: + return resolve_repo_path( + os.environ.get("OLIPHAUNT_WASIX_EXTENSION_RELEASE_ASSET_ROOT", "target/extensions/wasix/release-assets"), + label="WASIX extension release asset root", + ) + + +def index_contains_sql_name(index: Path, sql_name: str) -> bool: + with index.open("r", encoding="utf-8", newline="") as handle: + return any(row.get("sql_name") == sql_name for row in csv.DictReader(handle, delimiter="\t")) + + +def native_extension_asset_indexes(sql_name: str, product: str | None = None) -> list[Path]: + version = product_metadata.read_current_version("liboliphaunt-native") + root = native_release_asset_root() + indexes: list[Path] = [] + for target in extension_artifact_targets.published_target_ids(family="native"): + target_root = root / target + if product is not None: + product_index = target_root / product / f"liboliphaunt-{version}-native-extension-assets.tsv" + if product_index.is_file() and index_contains_sql_name(product_index, sql_name): + indexes.append(product_index) + continue + direct_index = target_root / f"liboliphaunt-{version}-native-extension-assets.tsv" + if direct_index.is_file(): + indexes.append(direct_index) + return sorted(indexes) + + +def native_assets_from_target_indexes( + sql_name: str, + *, + product: str | None = None, + required: bool, +) -> list[tuple[Path, str, str]]: + indexes = native_extension_asset_indexes(sql_name, product) + if not indexes: + return [] + + assets: list[tuple[Path, str, str]] = [] + seen: set[tuple[str, str]] = set() + for index in indexes: + with index.open("r", encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle, delimiter="\t")) + for row in rows: + if row.get("sql_name") != sql_name: + continue + target = row.get("target") + kind = row.get("kind") + artifact = row.get("artifact") + if not target or not kind or not artifact: + fail(f"{index.relative_to(ROOT)} has an incomplete native asset row for {sql_name}") + dedupe_key = (target, kind) + if dedupe_key in seen: + fail(f"duplicate native extension asset row for {sql_name} target={target} kind={kind}") + seen.add(dedupe_key) + path = index.parent / artifact + if not path.is_file(): + fail(f"{index.relative_to(ROOT)} references missing native asset {path.relative_to(ROOT)}") + assets.append((path, target, kind)) + + if required and not assets: + fail(f"{sql_name} has no native extension assets in native target asset indexes") + return assets + + +def native_assets_for(sql_name: str, *, product: str | None = None, required: bool) -> list[tuple[Path, str, str]]: + indexed = native_assets_from_target_indexes(sql_name, product=product, required=False) + if indexed: + return indexed + if required: + product_hint = f" for {product}" if product else "" + fail(f"{sql_name}{product_hint} has no native extension assets in native target asset indexes") + return [] + + +def wasix_archive_for(sql_name: str, *, product: str | None = None, required: bool) -> Path | None: + version = product_metadata.read_current_version("liboliphaunt-wasix") + root = wasix_release_asset_root() + indexes: list[Path] = [] + for target in extension_artifact_targets.published_target_ids(family="wasix"): + target_root = root / target + if product is not None: + product_index = target_root / product / f"liboliphaunt-wasix-{version}-wasix-extension-assets.tsv" + if product_index.is_file(): + indexes.append(product_index) + continue + direct_index = target_root / f"liboliphaunt-wasix-{version}-wasix-extension-assets.tsv" + if direct_index.is_file(): + indexes.append(direct_index) + assets: list[Path] = [] + for index in indexes: + with index.open("r", encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle, delimiter="\t")) + for row in rows: + if row.get("sql_name") != sql_name: + continue + target = row.get("target") + kind = row.get("kind") + artifact = row.get("artifact") + if target != "wasix-portable" or kind != "wasix-runtime" or not artifact: + fail(f"{index.relative_to(ROOT)} has an invalid WASIX asset row for {sql_name}") + path = index.parent / artifact + if not path.is_file(): + fail(f"{index.relative_to(ROOT)} references missing WASIX asset {path.relative_to(ROOT)}") + assets.append(path) + if len(assets) > 1: + fail(f"{sql_name} has duplicate WASIX extension assets: {', '.join(str(path.relative_to(ROOT)) for path in assets)}") + if assets: + return assets[0] + + if required: + fail( + f"{sql_name} has no WASIX extension assets in " + "target/extensions/wasix/release-assets target indexes" + ) + return None + + +def copy_asset(source: Path, destination_dir: Path, *, name: str) -> dict[str, object]: + destination_dir.mkdir(parents=True, exist_ok=True) + destination = destination_dir / name + shutil.copy2(source, destination) + return { + "name": destination.name, + "path": str(destination.relative_to(ROOT)), + "source": str(source.relative_to(ROOT)), + "sha256": sha256(destination), + "bytes": destination.stat().st_size, + } + + +def native_asset_name(product: str, version: str, target: str, kind: str, source: Path) -> str: + suffix = archive_suffix(source) + if target == "macos-arm64": + return f"{product}-{version}-native-macos-arm64-runtime{suffix}" + if target.startswith("linux-"): + return f"{product}-{version}-native-{target}-runtime{suffix}" + if target.startswith("windows-"): + return f"{product}-{version}-native-{target}-runtime{suffix}" + if target == "ios-xcframework": + if kind == "runtime": + return f"{product}-{version}-native-ios-runtime{suffix}" + if kind == "ios-xcframework": + return f"{product}-{version}-native-ios-xcframework{suffix}" + fail(f"unsupported iOS extension artifact kind {kind} for {source.name}") + if target.startswith("android-"): + if kind == "runtime": + return f"{product}-{version}-native-{target}-runtime{suffix}" + if kind == "android-static-archive": + return f"{product}-{version}-native-{target}-static{suffix}" + fail(f"unsupported Android extension artifact kind {kind} for {source.name}") + fail(f"unsupported native extension artifact target {target} for {source.name}") + + +def archive_suffix(source: Path) -> str: + for suffix in (".tar.gz", ".tar.zst", ".zip"): + if source.name.endswith(suffix): + return suffix + fail(f"native extension asset {source.name} must use .tar.gz, .tar.zst, or .zip") + + +def validate_staged_targets( + product: str, + assets: list[dict[str, object]], + *, + require_native: bool, + require_wasix: bool, + require_native_targets: set[str], +) -> None: + declared_native_targets = { + target.target + for target in extension_artifact_targets.artifact_targets( + product=product, + family="native", + published_only=True, + ) + } + declared_wasix_targets = { + target.target + for target in extension_artifact_targets.artifact_targets( + product=product, + family="wasix", + published_only=True, + ) + } + staged_native_targets = { + str(asset["target"]) + for asset in assets + if asset.get("family") == "native" + } + staged_wasix_targets = { + str(asset["target"]) + for asset in assets + if asset.get("family") == "wasix" + } + + extra_native = staged_native_targets - declared_native_targets + extra_wasix = staged_wasix_targets - declared_wasix_targets + if extra_native: + fail(f"{product} staged undeclared native extension targets: {', '.join(sorted(extra_native))}") + if extra_wasix: + fail(f"{product} staged undeclared WASIX extension targets: {', '.join(sorted(extra_wasix))}") + + if require_native_targets: + unknown_required = require_native_targets - declared_native_targets + if unknown_required: + fail(f"{product} was asked to require undeclared native targets: {', '.join(sorted(unknown_required))}") + missing_native = require_native_targets - staged_native_targets + if missing_native: + fail(f"{product} is missing native extension artifacts for: {', '.join(sorted(missing_native))}") + elif require_native: + missing_native = declared_native_targets - staged_native_targets + if missing_native: + fail(f"{product} is missing native extension artifacts for: {', '.join(sorted(missing_native))}") + if require_wasix: + missing_wasix = declared_wasix_targets - staged_wasix_targets + if missing_wasix: + fail(f"{product} is missing WASIX extension artifacts for: {', '.join(sorted(missing_wasix))}") + + +def resolve_output_root(value: str) -> Path: + return resolve_repo_path(value, label="output root") + + +def stage_product( + product: str, + *, + output_root: Path, + require_native: bool, + require_wasix: bool, + require_native_targets: set[str], +) -> None: + known = set(extension_products()) + if product not in known: + fail(f"unknown exact-extension product {product}; expected one of: {', '.join(sorted(known))}") + + sql_name = extension_sql_name(product) + extension_row = generated_extension_row(sql_name) + version = product_metadata.read_current_version(product) + product_root = output_root / product + asset_dir = product_root / "release-assets" + if product_root.exists(): + shutil.rmtree(product_root) + asset_dir.mkdir(parents=True, exist_ok=True) + + assets: list[dict[str, object]] = [] + for native_asset, target, kind in native_assets_for(sql_name, product=product, required=require_native): + if require_native_targets and target not in require_native_targets: + continue + metadata = copy_asset( + native_asset, + asset_dir, + name=native_asset_name(product, version, target, kind, native_asset), + ) + metadata["family"] = "native" + metadata["kind"] = kind + metadata["target"] = target + assets.append(metadata) + + wasix_archive = wasix_archive_for(sql_name, product=product, required=require_wasix) + if wasix_archive is not None: + wasix_name = f"{product}-{version}-wasix-portable.tar.zst" + metadata = copy_asset(wasix_archive, asset_dir, name=wasix_name) + metadata["family"] = "wasix" + metadata["kind"] = "wasix-runtime" + metadata["target"] = "wasix-portable" + assets.append(metadata) + + validate_staged_targets( + product, + assets, + require_native=require_native, + require_wasix=require_wasix, + require_native_targets=require_native_targets, + ) + if not assets: + fail(f"{product} produced no extension artifacts") + + manifest = { + "schema": "oliphaunt-extension-ci-artifacts-v1", + "product": product, + "version": version, + "sqlName": sql_name, + "dependencies": string_list(extension_row.get("selected-extension-dependencies")), + "nativeModuleStem": extension_row.get("native-module-stem"), + "sharedPreloadLibraries": string_list(extension_row.get("shared-preload-libraries")), + "mobileReleaseReady": extension_row.get("mobile-release-ready") is True, + "desktopReleaseReady": extension_row.get("desktop-release-ready") is True, + "assets": assets, + } + (product_root / "extension-artifacts.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + release_manifest = asset_dir / f"{product}-{version}-manifest.json" + release_manifest.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + properties_manifest = asset_dir / f"{product}-{version}-manifest.properties" + properties_lines = [ + "schema=oliphaunt-extension-release-manifest-v1\n", + f"product={product}\n", + f"version={version}\n", + f"sqlName={sql_name}\n", + f"dependencies={properties_csv(manifest['dependencies'])}\n", + f"nativeModuleStem={manifest['nativeModuleStem'] or ''}\n", + f"sharedPreloadLibraries={properties_csv(manifest['sharedPreloadLibraries'])}\n", + f"mobileReleaseReady={'true' if manifest['mobileReleaseReady'] else 'false'}\n", + f"desktopReleaseReady={'true' if manifest['desktopReleaseReady'] else 'false'}\n", + ] + for asset in sorted(assets, key=lambda value: (str(value["family"]), str(value["target"]), str(value["kind"]))): + key = f"asset.{asset['family']}.{asset['target']}.{asset['kind']}" + properties_lines.append(f"{key}={asset['name']}\n") + properties_manifest.write_text("".join(properties_lines), encoding="utf-8") + checksum_manifest = asset_dir / f"{product}-{version}-release-assets.sha256" + checksum_lines = [] + for asset in sorted(path for path in asset_dir.iterdir() if path.is_file() and path != checksum_manifest): + checksum_lines.append(f"{sha256(asset)} ./{asset.name}\n") + checksum_manifest.write_text("".join(checksum_lines), encoding="utf-8") + (product_root / "artifacts.txt").write_text( + "".join( + [ + *(f"{asset['path']}\n" for asset in assets), + f"{release_manifest.relative_to(ROOT)}\n", + f"{properties_manifest.relative_to(ROOT)}\n", + f"{checksum_manifest.relative_to(ROOT)}\n", + ] + ), + encoding="utf-8", + ) + print(f"{product}: staged {len(assets)} exact-extension artifact(s) in {product_root.relative_to(ROOT)}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("products", nargs="*", help="exact-extension product id(s)") + parser.add_argument("--all", action="store_true", help="stage every exact-extension product") + parser.add_argument( + "--output-root", + default="target/extension-artifacts", + help="repository-relative staging root for package-shaped extension artifacts", + ) + parser.add_argument("--require-native", action="store_true", help="fail if native extension assets are missing") + parser.add_argument( + "--require-native-target", + action="append", + default=[], + help="fail if the named native extension target is missing; may be passed more than once", + ) + parser.add_argument("--require-wasix", action="store_true", help="fail if WASIX extension archives are missing") + return parser.parse_args(argv) + + +def selected_products_from_env() -> list[str]: + raw = os.environ.get("OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS", "") + products = sorted({item.strip() for item in raw.split(",") if item.strip()}) + if not products: + return [] + known = set(extension_products()) + unknown = sorted(set(products) - known) + if unknown: + fail(f"OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS contains unknown exact-extension product(s): {', '.join(unknown)}") + return products + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + products = selected_products_from_env() or (extension_products() if args.all else args.products) + if not products: + fail("pass --all or at least one exact-extension product id") + output_root = resolve_output_root(args.output_root) + require_native_targets = set(args.require_native_target) + for product in products: + stage_product( + product, + output_root=output_root, + require_native=args.require_native, + require_wasix=args.require_wasix, + require_native_targets=require_native_targets, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/build-sdk-ci-artifacts.sh b/tools/release/build-sdk-ci-artifacts.sh new file mode 100755 index 00000000..6b967b09 --- /dev/null +++ b/tools/release/build-sdk-ci-artifacts.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "build-sdk-ci-artifacts.sh: $*" >&2 + exit 1 +} + +require() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +stage_glob() { + local glob="$1" + local destination="$2" + local matched=0 + mkdir -p "$destination" + shopt -s nullglob + for artifact in $glob; do + matched=1 + cp -R "$artifact" "$destination/" + done + shopt -u nullglob + [ "$matched" -eq 1 ] || fail "no artifacts matched $glob" +} + +rust_crate_name() { + local manifest="$1" + python3 - "$manifest" <<'PY' +from pathlib import Path +import sys +import tomllib + +data = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +package = data["package"] +print(f"{package['name']}-{package['version']}.crate") +PY +} + +cargo_package_dir() { + local target_dir="${CARGO_TARGET_DIR:-$root/target}" + if [[ "$target_dir" != /* ]]; then + target_dir="$root/$target_dir" + fi + printf '%s/package\n' "$target_dir" +} + +cargo_workspace_excludes_except() { + python3 - "$@" <<'PY' +import json +import subprocess +import sys + +wanted = set(sys.argv[1:]) +metadata = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], + text=True, + ) +) +for package in metadata["packages"]: + name = package["name"] + if name not in wanted: + print(name) +PY +} + +package_npm_workspace() { + local package_dir="$1" + local destination="$2" + require pnpm + mkdir -p "$destination" + local pack_json pack_file + pack_json="$(pnpm --dir "$package_dir" pack --pack-destination "$destination" --json)" + printf '%s\n' "$pack_json" >"$destination/pnpm-pack.json" + pack_file="$( + PACK_JSON="$pack_json" PACK_DIR="$destination" node -e " +const manifest = JSON.parse(process.env.PACK_JSON || '{}'); +if (!manifest.filename || !manifest.filename.endsWith('.tgz')) { + throw new Error('pnpm pack did not report a .tgz filename'); +} +const path = require('node:path'); +console.log(path.isAbsolute(manifest.filename) ? manifest.filename : path.join(process.env.PACK_DIR || '', manifest.filename)); +" + )" + [ -f "$pack_file" ] || fail "pnpm pack did not create $pack_file" +} + +stage_jsr_source_workspace() { + local package_dir="$1" + local destination="$2" + rm -rf "$destination" + mkdir -p "$destination" + ( + cd "$package_dir" + tar \ + --exclude='./node_modules' \ + --exclude='./node_modules/*' \ + --exclude='./lib' \ + --exclude='./lib/*' \ + --exclude='./.turbo' \ + --exclude='./.turbo/*' \ + -cf - . + ) | ( + cd "$destination" + tar -xf - + ) + [ -f "$destination/jsr.json" ] || fail "JSR source workspace is missing jsr.json" + [ -f "$destination/package.json" ] || fail "JSR source workspace is missing package.json" + [ -d "$destination/src" ] || fail "JSR source workspace is missing src/" +} + +product="${1:-}" +[ -n "$product" ] || fail "usage: tools/release/build-sdk-ci-artifacts.sh " + +artifact_root="$root/target/sdk-artifacts/$product" +work_root="$root/target/sdk-artifacts-work/$product" +rm -rf "$artifact_root" "$work_root" +mkdir -p "$artifact_root" "$work_root" + +case "$product" in + oliphaunt-rust) + require cargo + require python3 + env OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check" \ + src/sdks/rust/tools/check-sdk.sh package-shape + cargo package -p oliphaunt --locked --allow-dirty + crate_name="$(rust_crate_name "$root/src/sdks/rust/Cargo.toml")" + package_dir="$(cargo_package_dir)" + [ -f "$package_dir/$crate_name" ] || fail "cargo package did not create $package_dir/$crate_name" + cp "$package_dir/$crate_name" "$artifact_root/$crate_name" + cp "$root/target/liboliphaunt-sdk-check/rust-cargo-package-list.txt" \ + "$artifact_root/cargo-package-files.txt" + ;; + oliphaunt-swift) + require swift + env OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check" \ + src/sdks/swift/tools/check-sdk.sh package-shape + stage_glob "$work_root/check/package-shape/swift-source-archive/Oliphaunt-source.zip" "$artifact_root" + [ -n "${OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR:-}" ] || + fail "oliphaunt-swift package artifacts require OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR" + python3 tools/release/render_swiftpm_release_package.py \ + --asset-dir "$OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR" \ + --output "$artifact_root/Package.swift.release" \ + --generated-tree "$work_root/swiftpm-release-tree" + grep -Fq "liboliphaunt-native-v" "$artifact_root/Package.swift.release" || + fail "staged SwiftPM release manifest must use the public liboliphaunt GitHub release URL" + if grep -Fq "file://" "$artifact_root/Package.swift.release"; then + fail "staged SwiftPM release manifest must not contain local file URLs" + fi + ;; + oliphaunt-kotlin) + env OLIPHAUNT_KOTLIN_ANDROID_ABI_FILTERS=arm64-v8a,x86_64 \ + OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check" \ + src/sdks/kotlin/tools/check-sdk.sh package-shape + kotlin_maven_repo="$work_root/maven-local" + kotlin_build_root="$work_root/gradle-build" + kotlin_cxx_root="$work_root/cxx-build" + kotlin_cache_root="$work_root/gradle-cache" + kotlin_version="$(sed -n 's/^VERSION_NAME=//p' "$root/src/sdks/kotlin/gradle.properties" | tail -n 1)" + [ -n "$kotlin_version" ] || fail "missing VERSION_NAME in src/sdks/kotlin/gradle.properties" + "$root/src/sdks/kotlin/gradlew" -p "$root/src/sdks/kotlin" \ + :oliphaunt:publishAndroidReleasePublicationToMavenLocal \ + :oliphaunt-android-gradle-plugin:publishToMavenLocal \ + "-Dmaven.repo.local=$kotlin_maven_repo" \ + "-PoliphauntAndroidAbiFilters=arm64-v8a,x86_64" \ + "-PoliphauntBuildRoot=$kotlin_build_root" \ + "-PoliphauntCxxBuildRoot=$kotlin_cxx_root" \ + --project-cache-dir "$kotlin_cache_root" \ + --no-configuration-cache + [ -f "$kotlin_maven_repo/dev/oliphaunt/oliphaunt-android/$kotlin_version/oliphaunt-android-$kotlin_version.aar" ] || + fail "Kotlin SDK Maven artifact did not publish oliphaunt-android" + [ -f "$kotlin_maven_repo/dev/oliphaunt/oliphaunt-android-gradle-plugin/$kotlin_version/oliphaunt-android-gradle-plugin-$kotlin_version.jar" ] || + fail "Kotlin SDK Maven artifact did not publish the Android Gradle plugin" + mkdir -p "$artifact_root/maven" + cp -R "$kotlin_maven_repo/." "$artifact_root/maven/" + ;; + oliphaunt-js) + require node + env OLIPHAUNT_JS_SKIP_REGISTRY_DRY_RUN=1 \ + OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check" \ + src/sdks/js/tools/check-sdk.sh package-shape + package_npm_workspace "$work_root/check/package-shape/src/sdks/js" "$artifact_root" + stage_jsr_source_workspace "$work_root/check/package-shape/src/sdks/js" "$artifact_root/jsr-source" + ;; + oliphaunt-react-native) + require node + env OLIPHAUNT_SDK_CHECK_SCRATCH="$work_root/check" \ + src/sdks/react-native/tools/check-sdk.sh package-shape + package_npm_workspace "$work_root/check/package-shape/src/sdks/react-native" "$artifact_root" + ;; + oliphaunt-wasix-rust) + require cargo + require python3 + env OLIPHAUNT_WASM_PACKAGE_OUT="$artifact_root" \ + src/bindings/wasix-rust/tools/check-package.sh + # Cargo cannot verify a root crate that depends on same-release internal + # crates until the runtime asset/AOT crates have been published. The + # liboliphaunt-wasix release lane owns and validates those internal crates; + # this builder stages only the binding crate payload. + mapfile -t wasix_internal_packages < <(cargo run --quiet -p xtask -- assets internal-packages) + wasix_packages=("${wasix_internal_packages[@]}" "oliphaunt-wasix") + package_args=(--workspace --locked --allow-dirty --no-verify) + while IFS= read -r excluded_package; do + [ -n "$excluded_package" ] || continue + package_args+=(--exclude "$excluded_package") + done < <(cargo_workspace_excludes_except "${wasix_packages[@]}") + cargo package "${package_args[@]}" + crate_name="$(rust_crate_name "$root/src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml")" + package_dir="$(cargo_package_dir)" + [ -f "$package_dir/$crate_name" ] || fail "cargo package did not create $package_dir/$crate_name" + cp "$package_dir/$crate_name" "$artifact_root/$crate_name" + cp "$root/target/oliphaunt-wasix-rust/package/oliphaunt-wasix.package-files.txt" \ + "$artifact_root/cargo-package-files.txt" + ;; + *) + fail "unsupported SDK product: $product" + ;; +esac + +find "$artifact_root" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -print | sort >"$artifact_root/artifacts.txt" +[ -s "$artifact_root/artifacts.txt" ] || fail "no SDK artifacts were staged for $product" +python3 tools/release/check_staged_artifacts.py --require-sdk-product "$product" +printf 'Staged %s SDK artifacts under %s\n' "$product" "$artifact_root" diff --git a/tools/release/check_artifact_targets.py b/tools/release/check_artifact_targets.py new file mode 100644 index 00000000..8dcd062e --- /dev/null +++ b/tools/release/check_artifact_targets.py @@ -0,0 +1,1279 @@ +#!/usr/bin/env python3 +"""Validate native and helper artifact target metadata.""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path +from typing import NoReturn + +import artifact_target_matrix +import artifact_targets +import extension_artifact_targets +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools" / "graph")) + +import ci_plan # noqa: E402 + + +def fail(message: str) -> NoReturn: + print(f"check_artifact_targets.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def read_text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def read_toml(path: Path) -> dict: + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except tomllib.TOMLDecodeError as error: + fail(f"{path.relative_to(ROOT)} is invalid TOML: {error}") + if not isinstance(data, dict): + fail(f"{path.relative_to(ROOT)} must contain a TOML table") + return data + + +def ts_template(asset: str) -> str: + return asset.replace("{version}", "${version}") + + +def require_text(path: str, text: str, message: str) -> None: + if text not in read_text(path): + fail(message) + + +def reject_text(path: str, text: str, message: str) -> None: + if text in read_text(path): + fail(message) + + +def validate_target_shape() -> None: + targets = artifact_targets.artifact_targets() + if not targets: + fail("artifact target metadata must define targets") + raw_targets = { + raw.get("id"): raw + for raw in artifact_targets.raw_artifact_target_tables(product_metadata.load_graph()) + if isinstance(raw, dict) and isinstance(raw.get("id"), str) + } + + seen_assets: dict[tuple[str, str], str] = {} + for target in targets: + raw_target = raw_targets.get(target.id, {}) + if "{version}" not in target.asset: + fail(f"{target.id} asset template must contain {{version}}") + if target.published and "github-release" not in target.surfaces: + fail(f"{target.id} is published but is not a GitHub release asset") + if not target.published: + if raw_target.get("tier") != "planned": + fail(f"{target.id} is unpublished and must declare tier = \"planned\"") + reason = raw_target.get("unsupported_reason") + if not isinstance(reason, str) or len(reason.strip()) < 40: + fail(f"{target.id} is unpublished and must declare a concrete unsupported_reason") + if target.kind in {"native-runtime", "broker-helper", "node-direct-addon"}: + if target.triple is None: + fail(f"{target.id} must declare a target triple") + if target.runner is None: + fail(f"{target.id} must declare the CI/release runner") + if target.kind == "wasix-aot-runtime": + if target.triple is None: + fail(f"{target.id} must declare a target triple") + if target.runner is None: + fail(f"{target.id} must declare the CI/release runner") + if target.llvm_url is None: + fail(f"{target.id} must declare llvm_url for AOT generation") + if target.kind in {"native-runtime", "node-direct-addon"}: + if target.library_relative_path is None: + fail(f"{target.id} must declare library_relative_path") + if target.kind == "native-runtime" and target.target.startswith("android-"): + expected_prefix = f"jni/{target.target.removeprefix('android-')}/" + if target.library_relative_path is None or not target.library_relative_path.startswith(expected_prefix): + fail( + f"{target.id} library_relative_path must describe the Android release archive " + f"layout under {expected_prefix}, got {target.library_relative_path}" + ) + if target.kind == "broker-helper" and target.executable_relative_path is None: + fail(f"{target.id} must declare executable_relative_path") + dedupe_key = (target.product, target.asset) + previous = seen_assets.get(dedupe_key) + if previous is not None: + fail(f"{target.id} and {previous} use the same asset template {target.asset}") + seen_assets[dedupe_key] = target.id + + +def validate_product_local_targets() -> None: + graph_targets = product_metadata.load_graph().get("artifact_targets", []) + if not isinstance(graph_targets, list): + fail("release metadata artifact_targets must be an array of tables") + central_targets = [ + raw.get("id") + for raw in graph_targets + if isinstance(raw, dict) + ] + if central_targets: + fail( + "artifact targets must live under product-local targets directories, " + f"not central release metadata: {central_targets}" + ) + + expected_runtime_files = { + "liboliphaunt-native": ( + "src/runtimes/liboliphaunt/native/targets", + { + "android-arm64-v8a.toml", + "android-x86_64.toml", + "apple-spm-xcframework.toml", + "checksums.toml", + "ios-xcframework.toml", + "linux-arm64-gnu.toml", + "linux-x64-gnu.toml", + "macos-arm64.toml", + "macos-x64.toml", + "package-size.toml", + "runtime-resources.toml", + "windows-x64-msvc.toml", + }, + ), + "liboliphaunt-wasix": ( + "src/runtimes/liboliphaunt/wasix/targets", + { + "checksums.toml", + "linux-arm64-gnu.toml", + "linux-x64-gnu.toml", + "macos-arm64.toml", + "wasix-runtime.toml", + "windows-x64-msvc.toml", + }, + ), + "oliphaunt-broker": ( + "src/runtimes/broker/targets", + { + "checksums.toml", + "linux-arm64-gnu.toml", + "linux-x64-gnu.toml", + "macos-arm64.toml", + "windows-x64-msvc.toml", + }, + ), + "oliphaunt-node-direct": ( + "src/runtimes/node-direct/targets", + { + "checksums.toml", + "linux-arm64-gnu.toml", + "linux-x64-gnu.toml", + "macos-arm64.toml", + "windows-x64-msvc.toml", + }, + ), + } + for product, (directory, expected_files) in expected_runtime_files.items(): + actual_files = {path.name for path in (ROOT / directory).glob("*.toml")} + if actual_files != expected_files: + fail( + f"{product} target metadata files must be exact and explicit: " + f"{sorted(actual_files)} vs {sorted(expected_files)}" + ) + + +def wasm_extension_target_id(runtime_target: str) -> str: + if runtime_target == "portable": + return "wasix-portable" + return runtime_target + + +def validate_extension_artifact_targets() -> None: + extension_products = [ + product + for product in product_metadata.product_ids() + if product_metadata.product_config(product).get("kind") == "exact-extension-artifact" + ] + if not extension_products: + fail("exact-extension release products must be modeled as release products") + + expected_native_targets = { + target.target + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + published_only=True, + ) + if target.extension_artifacts + } + expected_wasix_targets = { + wasm_extension_target_id(target.target) + for target in artifact_targets.artifact_targets( + product="liboliphaunt-wasix", + published_only=True, + ) + if target.kind == "wasix-runtime" + } + if not expected_native_targets: + fail("published native runtime targets are required before extension artifacts can be published") + if not expected_wasix_targets: + fail("published WASIX runtime targets are required before extension artifacts can be published") + + for product in extension_products: + path = extension_artifact_targets.artifact_target_file(product) + if not path.is_file(): + fail(f"{product} must declare exact-extension artifact targets at {path.relative_to(ROOT)}") + rows = extension_artifact_targets.artifact_targets(product=product) + published_native_targets = { + target.target for target in rows if target.family == "native" and target.published + } + declared_native_targets = { + target.target for target in rows if target.family == "native" + } + published_wasix_targets = { + target.target for target in rows if target.family == "wasix" and target.published + } + if declared_native_targets != expected_native_targets: + fail( + f"{product} native extension target rows must cover published liboliphaunt native runtimes, " + f"including explicit unpublished opt-outs: {sorted(declared_native_targets)} vs {sorted(expected_native_targets)}" + ) + if not published_native_targets: + fail(f"{product} must publish at least one native extension artifact target") + if not published_native_targets <= expected_native_targets: + fail( + f"{product} published native extension targets must be published liboliphaunt native runtimes: " + f"{sorted(published_native_targets)} vs {sorted(expected_native_targets)}" + ) + if published_wasix_targets != expected_wasix_targets: + fail( + f"{product} published WASIX extension targets must match published liboliphaunt WASIX runtimes: " + f"{sorted(published_wasix_targets)} vs {sorted(expected_wasix_targets)}" + ) + for row in rows: + if row.family == "native": + expected_kind = ( + "native-static-registry" + if row.target == "ios-xcframework" or row.target.startswith("android-") + else "native-dynamic" + ) + if row.kind != expected_kind: + fail(f"{product} {row.target} must use extension artifact kind {expected_kind}, got {row.kind}") + if row.published and row.kind == "native-static-registry": + static_recipe = ROOT / product_metadata.package_path(product) / "targets" / "native-static-registry.toml" + if static_recipe.is_file(): + static_data = read_toml(static_recipe) + status = static_data.get("status") + if status != "supported": + fail( + f"{product} publishes {row.target} native static-registry artifacts, " + f"but {static_recipe.relative_to(ROOT)} declares status={status!r}" + ) + if row.family == "wasix" and row.kind != "wasix-runtime": + fail(f"{product} {row.target} must use wasix-runtime extension artifacts") + + +def validate_github_asset_helpers() -> None: + require_text( + "tools/release/package-liboliphaunt-macos-assets.sh", + "liboliphaunt-${version}-${target_id}.tar.gz", + "macOS liboliphaunt target packager must emit the release-shaped macOS archive", + ) + require_text( + "tools/release/package-liboliphaunt-macos-assets.sh", + "target/liboliphaunt/release-assets", + "macOS liboliphaunt target packager must write into the release asset directory", + ) + require_text( + "tools/release/check_github_release_assets.py", + "artifact_targets.expected_assets", + "GitHub release asset checks must derive product assets from product-local artifact targets", + ) + require_text( + "tools/release/check_liboliphaunt_release_assets.py", + "artifact_targets.expected_assets", + "liboliphaunt release asset checks must derive required assets from product-local artifact targets", + ) + require_text( + "tools/release/check_broker_release_assets.py", + "artifact_targets.expected_assets", + "Rust broker release asset checks must derive required assets from product-local artifact targets", + ) + require_text( + "src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs", + "OLIPHAUNT_SMOKE_BIN_DIR", + "liboliphaunt C ABI smoke runner must support staged-release smoke binaries outside release layouts", + ) + for packager in ( + "tools/release/package-liboliphaunt-macos-assets.sh", + "tools/release/package-liboliphaunt-linux-assets.sh", + "tools/release/package-liboliphaunt-windows-assets.ps1", + ): + require_text( + packager, + "OLIPHAUNT_SMOKE_BIN_DIR", + f"{packager} must smoke the staged release layout without writing smoke binaries into the archive", + ) + require_text( + packager, + "run-host-c-smoke.mjs", + f"{packager} must run the liboliphaunt C ABI smoke against the staged release layout", + ) + + +def validate_ci_release_artifacts() -> None: + ci = read_text(".github/workflows/ci.yml") + release = read_text(".github/workflows/release.yml") + required_ci_snippets = { + "Package liboliphaunt macOS release asset": "CI must build a release-shaped liboliphaunt macOS target archive", + "tools/release/package-liboliphaunt-macos-assets.sh": "CI must use the macOS liboliphaunt target packager", + "Package liboliphaunt Linux release asset": "CI must build release-shaped liboliphaunt Linux target archives", + "tools/release/package-liboliphaunt-linux-assets.sh": "CI must use the Linux liboliphaunt target packager", + "Package liboliphaunt Windows release asset": "CI must build a release-shaped liboliphaunt Windows target archive", + "package-liboliphaunt-windows-assets.ps1": "CI must use the Windows liboliphaunt target packager", + "Package liboliphaunt Android release asset": "CI must package release-shaped liboliphaunt Android target archives", + "Package liboliphaunt iOS release asset": "CI must package release-shaped liboliphaunt iOS target archives", + "tools/release/package-liboliphaunt-mobile-assets.sh": "CI must use the mobile liboliphaunt target packager", + "liboliphaunt-native-release-assets-${{ matrix.target }}": "CI must upload liboliphaunt release-shaped artifacts per target", + "liboliphaunt-native-release-assets:": "CI must aggregate complete public liboliphaunt release assets", + "Download liboliphaunt target release assets": "CI must aggregate liboliphaunt target archive outputs", + ".github/scripts/run-planned-moon-job.sh liboliphaunt-native-release-assets": ( + "CI must aggregate liboliphaunt native release assets through the Moon-modeled builder" + ), + "Upload aggregate liboliphaunt release assets": "CI must upload complete liboliphaunt release assets for release consumption", + "Download Apple liboliphaunt release assets": "Swift SDK package artifacts must consume the Apple SwiftPM liboliphaunt release asset", + "liboliphaunt-native-release-assets-ios-xcframework": "Swift SDK package artifacts must download the Apple target release asset directly", + "OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR": "Swift SDK package artifacts must render Package.swift.release from real liboliphaunt release assets in CI", + ".github/scripts/run-planned-moon-job.sh broker-runtime": "CI must invoke the planned broker Moon job that includes release-shaped helper artifacts", + "oliphaunt-broker-release-assets-${{ matrix.target }}": "CI must upload broker helper release-shaped artifacts per target", + ".github/scripts/run-planned-moon-job.sh node-direct": "CI must invoke the planned Node direct Moon job that includes release-shaped addon artifacts", + "oliphaunt-node-direct-release-assets-${{ matrix.target }}": "CI must upload Node direct release-shaped artifacts per target", + "oliphaunt-node-direct-npm-package-${{ matrix.target }}": "CI must upload Node direct optional npm package artifacts per target", + "oliphaunt-rust-sdk-package-artifacts": "CI must upload Rust SDK package artifacts", + "oliphaunt-swift-sdk-package-artifacts": "CI must upload Swift SDK package artifacts", + "oliphaunt-kotlin-sdk-package-artifacts": "CI must upload Kotlin SDK package artifacts", + "oliphaunt-react-native-sdk-package-artifacts": "CI must upload React Native SDK package artifacts", + "oliphaunt-js-sdk-package-artifacts": "CI must upload TypeScript SDK package artifacts", + "oliphaunt-wasix-rust-package-artifacts": "CI must upload WASIX Rust binding package artifacts", + "oliphaunt-extension-package-artifacts": "CI must upload exact-extension package artifacts", + "oliphaunt-mobile-extension-package-artifacts": "CI must upload target-scoped mobile exact-extension package artifacts", + "target/sdk-artifacts/oliphaunt-rust": "CI must use the shared SDK artifact staging layout for Rust", + "target/sdk-artifacts/oliphaunt-swift": "CI must use the shared SDK artifact staging layout for Swift", + "target/sdk-artifacts/oliphaunt-kotlin": "CI must use the shared SDK artifact staging layout for Kotlin", + "target/sdk-artifacts/oliphaunt-react-native": "CI must use the shared SDK artifact staging layout for React Native", + "target/sdk-artifacts/oliphaunt-js": "CI must use the shared SDK artifact staging layout for TypeScript", + "target/sdk-artifacts/oliphaunt-wasix-rust": "CI must use the shared SDK artifact staging layout for the WASIX Rust binding", + "target/extension-artifacts": "CI must use the shared exact-extension package staging layout", + ".github/scripts/run-planned-moon-job.sh extension-packages": "CI must invoke the Moon-modeled exact-extension package builder", + ".github/scripts/run-planned-moon-job.sh mobile-extension-packages": "CI must invoke the Moon-modeled mobile exact-extension package builder", + "Download exact-extension package artifacts": "Mobile build jobs must consume package-shaped exact-extension artifacts", + "Download WASIX exact-extension artifacts": "CI exact-extension package assembly must consume WASIX extension artifact builder outputs", + "pattern: liboliphaunt-wasix-extension-artifacts-*": "CI exact-extension package assembly must download every WASIX extension artifact target output", + "target/extensions/wasix/release-assets": "CI must use the shared WASIX exact-extension release asset staging layout", + "extension-artifacts-native:\n name: build-extension-native (${{ matrix.target }})\n needs:\n - affected": ( + "Native exact-extension artifact builders must be grouped by target" + ), + "OLIPHAUNT_EXTENSION_PRODUCTS: ${{ matrix.extensions_csv }}": ( + "Exact-extension artifact builder jobs must pass the selected extension product set into the producer" + ), + "liboliphaunt-native-extension-artifacts-${{ matrix.target }}": ( + "Native exact-extension artifact uploads must be addressable by target" + ), + "liboliphaunt-native-extension-ccache-${{ matrix.target }}": ( + "Native exact-extension artifact builders must restore target-scoped compiler/build caches" + ), + "liboliphaunt-wasix-extension-artifacts-${{ matrix.target }}": ( + "WASIX exact-extension artifact uploads must be addressable by target" + ), + "OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-native": ( + "Native exact-extension artifact builders must not re-run upstream runtime producers inside the job" + ), + "OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh extension-artifacts-wasix": ( + "WASIX exact-extension artifact builders must consume downloaded runtime outputs, not re-run upstream producers" + ), + "OLIPHAUNT_EXPO_REQUIRE_PREBUILT_EXTENSIONS": "Mobile build jobs must require prebuilt exact-extension artifacts instead of source-built extension fallbacks", + "OLIPHAUNT_EXPO_REQUIRE_SDK_ARTIFACTS": "Mobile build jobs must require staged SDK package artifacts instead of silent source fallbacks", + "OLIPHAUNT_EXPO_SDK_ARTIFACT_ROOT": "Mobile build jobs must resolve SDK artifacts from the staged package artifact root", + "OLIPHAUNT_EXPO_EXTENSION_ARTIFACT_ROOT": "Mobile build jobs must resolve exact-extension artifacts from the staged package artifact root", + "Validate Android mobile app artifacts": "Android mobile build jobs must inspect the built app for exact selected-extension contents", + "Validate iOS mobile app artifacts": "iOS mobile build jobs must inspect the built app for exact selected-extension contents", + "check_staged_artifacts.py --require-mobile android --require-mobile-prebuilt-extensions": ( + "Android mobile artifact validation must require prebuilt exact-extension package inputs" + ), + "check_staged_artifacts.py --require-mobile ios --require-mobile-prebuilt-extensions": ( + "iOS mobile artifact validation must require prebuilt exact-extension package inputs" + ), + "OLIPHAUNT_EXPO_IOS_OLIPHAUNT_XCFRAMEWORK": "iOS mobile build jobs must consume the linked liboliphaunt XCFramework artifact", + "liboliphaunt-wasix-release-assets:": "CI must aggregate WASIX portable and AOT outputs into public release assets", + "liboliphaunt_wasix_aot_runtime_matrix: ${{ steps.plan.outputs.liboliphaunt_wasix_aot_runtime_matrix }}": ( + "CI affected planning must emit the WASIX AOT target matrix without a separate planning job" + ), + "matrix: ${{ fromJson(needs.affected.outputs.liboliphaunt_wasix_aot_runtime_matrix": ( + "WASIX AOT builders must consume the affected-plan target matrix directly" + ), + "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-aot')": ( + "CI must only build WASIX AOT artifacts when the affected planner selected AOT work" + ), + "contains(fromJson(needs.affected.outputs.jobs), 'liboliphaunt-wasix-release-assets')": ( + "CI must only aggregate WASIX release assets when the affected planner selected release aggregation" + ), + ".github/scripts/run-planned-moon-job.sh liboliphaunt-wasix-release-assets": ( + "CI must package WASIX public release assets through the planned Moon task" + ), + "target/oliphaunt-wasix/release-assets": "CI must upload WASIX public release assets", + "Stage target AOT artifact envelope": "WASIX AOT builders must upload a deterministic artifact envelope", + "target-triple.txt": "WASIX AOT artifact envelopes must identify their target triple explicitly", + "target/oliphaunt-wasix/aot-upload/**": "WASIX AOT upload must use the staged artifact envelope, not an implicit target path", + "Invalid WASIX AOT artifact envelope": "WASIX AOT consumers must validate the downloaded artifact envelope before restoring it", + } + for snippet, message in required_ci_snippets.items(): + if snippet not in ci: + fail(message) + require_text( + "src/runtimes/broker/moon.yml", + 'tags: ["release", "artifact", "ci-broker-runtime"]', + "Broker release-assets must be selected by the ci-broker-runtime Moon tag", + ) + require_text( + "src/runtimes/node-direct/moon.yml", + 'tags: ["release", "artifact", "ci-node-direct"]', + "Node direct release-assets must be selected by the ci-node-direct Moon tag", + ) + require_text( + "src/runtimes/node-direct/moon.yml", + "/target/oliphaunt-node-direct/npm-packages/**/*", + "Node direct Moon release-assets task must declare optional npm tarballs as outputs", + ) + require_text( + "src/runtimes/node-direct/tools/build-node-addon.sh", + "Node direct optional npm package staged", + "Node direct CI builder must stage optional npm tarballs for release publishing", + ) + require_text( + ".github/workflows/release.yml", + "Download Node direct optional npm packages", + "release workflow must download Node direct optional npm package artifacts from Builds", + ) + require_text( + "tools/release/release.py", + "node_direct_optional_npm_tarballs", + "Node direct release publish must validate staged optional npm tarballs", + ) + require_text( + "tools/release/release.py", + 'run(["npm", "publish", str(tarball), "--access", "public", "--provenance"])', + "Node direct optional npm publish must publish CI-built tarballs directly", + ) + for project_id in ( + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + ): + moon_file = ( + "src/bindings/wasix-rust/moon.yml" + if project_id == "oliphaunt-wasix-rust" + else f"src/sdks/{'js' if project_id == 'oliphaunt-js' else project_id.removeprefix('oliphaunt-')}/moon.yml" + ) + require_text( + moon_file, + f"tools/release/build-sdk-ci-artifacts.sh {project_id}", + f"{project_id} package task must stage publishable SDK artifacts", + ) + require_text( + moon_file, + f"/target/sdk-artifacts/{project_id}/**/*", + f"{project_id} package task must declare staged SDK package artifacts as Moon outputs", + ) + focused_wasix_jobs, *_ = ci_plan.plan_for_full_run(wasm_target="linux-x64-gnu") + if focused_wasix_jobs != {"affected", "liboliphaunt-wasix-runtime", "liboliphaunt-wasix-aot"}: + fail( + "focused WASIX target runs must build only the portable runtime and requested AOT producer, " + f"got {sorted(focused_wasix_jobs)}" + ) + require_text( + "tools/graph/ci_plan.py", + '"extension_artifacts_wasix_matrix": (', + "CI planner must model WASIX exact-extension artifact matrix output", + ) + require_text( + "tools/graph/ci_plan.py", + 'if "extension-artifacts-wasix" in jobs', + "CI planner must emit WASIX exact-extension rows only when the WASIX extension builder is selected", + ) + require_text( + "tools/graph/ci_plan.py", + 'extension_artifacts_wasix_matrix("all", selected_extension_products)', + "WASIX extension artifacts are portable and must use the portable selector, not the AOT target selector", + ) + wasix_release_needs = ( + "liboliphaunt-wasix-release-assets:\n" + " name: build-liboliphaunt-wasix-release-assets\n" + " needs:\n" + " - affected\n" + " - liboliphaunt-wasix-runtime\n" + " - liboliphaunt-wasix-aot" + ) + if wasix_release_needs not in ci: + fail("WASIX release asset builder must consume portable and AOT runtime builders") + if 'OLIPHAUNT_EXPO_MOBILE_EXTENSIONS: ""' in ci: + fail("mobile build jobs must not disable selected extensions with OLIPHAUNT_EXPO_MOBILE_EXTENSIONS=\"\"") + if "run: cargo run -p xtask -- release package-assets" in ci: + fail("CI must not bypass Moon for WASIX release asset packaging") + if "run: src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh" in ci: + fail("CI must not bypass Moon for portable WASIX runtime builds") + if "target/oliphaunt-wasix/aot/${{ matrix.target }}/**" in ci: + fail("WASIX AOT uploads must use the explicit target-triple artifact envelope") + if "run: src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh" in ci: + fail("CI must not bypass Moon for WASIX AOT builds") + if ci.index("mobile-build-android:") < ci.index("mobile-extension-packages:"): + fail("mobile exact-extension package producer must be declared before mobile Android build consumers") + if "mobile-build-android:\n name: mobile-build-android (${{ matrix.target }})\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-android" not in ci: + fail("Android mobile build must depend on mobile-extension-packages and the Android liboliphaunt target builder") + if "mobile-build-ios:\n name: mobile-build-ios\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-ios" not in ci: + fail("iOS mobile build must depend on mobile-extension-packages and the iOS liboliphaunt target builder") + if "mobile-build-android:\n name: mobile-build-android (${{ matrix.target }})\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-android\n - kotlin-sdk-package\n - react-native-sdk-package" not in ci: + fail("Android mobile build must depend on Android runtime, Kotlin, and React Native package artifacts") + require_text( + ".github/workflows/ci.yml", + "matrix: ${{ fromJson(needs.affected.outputs.react_native_android_mobile_app_matrix) }}", + "Android mobile build must use the React Native Android runtime target matrix", + ) + require_text( + ".github/workflows/ci.yml", + "react-native-mobile-android-app-${{ matrix.target }}", + "Android mobile build artifacts must be target-specific", + ) + if "mobile-build-ios:\n name: mobile-build-ios\n needs:\n - affected\n - mobile-extension-packages\n - liboliphaunt-native-ios\n - react-native-sdk-package\n - swift-sdk-package" not in ci: + fail("iOS mobile build must depend on iOS runtime, React Native, and Swift package artifacts") + if "swift-sdk-package:\n name: build-swift-sdk\n needs:\n - affected\n - liboliphaunt-native-ios" not in ci: + fail("Swift SDK package artifacts must depend only on the iOS native target builder that produces the Apple release asset") + require_text( + "tools/graph/ci_plan.py", + 'if "swift-sdk-package" in jobs:', + "CI affected planner must make Swift SDK package builds imply liboliphaunt target asset producers", + ) + require_text( + "tools/graph/ci_plan.py", + 'targets.add("ios-xcframework")', + "CI affected planner must narrow Swift SDK liboliphaunt target builds to the Apple SwiftPM target when possible", + ) + require_text( + "src/sdks/react-native/tools/expo-runner-common.sh", + "expo_single_sdk_artifact_file", + "React Native mobile runners must have a shared required-SDK-artifact resolver", + ) + require_text( + "src/sdks/react-native/tools/expo-android-runner.sh", + "install_kotlin_sdk_maven_artifacts_if_required", + "Android mobile runner must consume staged Kotlin Maven artifacts when CI requires SDK artifacts", + ) + require_text( + "src/sdks/react-native/tools/expo-ios-runner.sh", + "prepare_swift_sdk_artifact_git_repo_if_required", + "iOS mobile runner must consume the staged Swift source artifact when CI requires SDK artifacts", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + "publishAndroidReleasePublicationToMavenLocal", + "Kotlin SDK package builder must stage a Maven repository layout for Android consumers", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + 'mkdir -p "$artifact_root/maven"', + "Kotlin SDK package builder must stage Maven artifacts under target/sdk-artifacts/oliphaunt-kotlin/maven", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + 'check_staged_artifacts.py --require-sdk-product "$product"', + "SDK package builders must validate staged package artifacts for runtime/extension payload leaks", + ) + reject_text( + "tools/release/build-sdk-ci-artifacts.sh", + "outputs/aar/*-release.aar", + "Kotlin SDK package staging must not copy loose AARs; the staged Maven repository is the package boundary", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + "oliphaunt-android-gradle-plugin:publishToMavenLocal", + "Kotlin SDK package builder must stage the Android Gradle plugin Maven artifact", + ) + require_text( + "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", + "check_staged_artifacts.py \"${validation_args[@]}\"", + "mobile exact-extension package assembly must validate the staged package manifests and checksums it selected", + ) + require_text( + "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", + "OLIPHAUNT_EXTENSION_PACKAGE_PRODUCTS must list selected exact-extension products for mobile packaging", + "mobile exact-extension package assembly must fail closed without an explicit selected product list", + ) + reject_text( + "src/extensions/artifacts/packages/tools/package-mobile-release-assets.sh", + "args+=(--all)", + "mobile exact-extension package assembly must not fall back to all extension products", + ) + require_text( + "src/runtimes/liboliphaunt/native/moon.yml", + "tools/release/package-liboliphaunt-aggregate-assets.sh", + "liboliphaunt native aggregate assets must have one Moon-modeled packager/checker entrypoint", + ) + require_text( + "tools/release/check_staged_artifacts.py", + "validate_release_archive_payload(path)", + "staged exact-extension artifact checks must reject placeholder files that are not readable release archives", + ) + require_text( + "tools/graph/ci_plan.py", + 'jobs.add("mobile-extension-packages")', + "affected planner must select target-scoped exact-extension packages whenever mobile jobs are selected", + ) + reject_text( + "tools/graph/ci_plan.py", + 'if "extension-artifacts-native" in jobs:\n jobs.add("liboliphaunt-native")', + "affected planner must not create a coarse native-runtime waterfall for exact-extension artifact builds", + ) + reject_text( + ".github/workflows/release.yml", + "product_liboliphaunt_native == 'true' || steps.release_plan.outputs.product_oliphaunt_swift == 'true'", + "Swift SDK releases must consume staged Swift package artifacts, not force aggregate liboliphaunt asset downloads", + ) + require_text( + ".github/workflows/release.yml", + "steps.release_plan.outputs.product_liboliphaunt_native == 'true' }}", + "release workflow must still download aggregate liboliphaunt assets for liboliphaunt-native releases", + ) + require_text( + "tools/release/release.py", + "prepare_staged_swift_release_manifest", + "Swift SDK release must use the Package.swift.release produced by the SDK package builder", + ) + require_text( + "tools/release/release.py", + "def validate_staged_sdk_package", + "release dry-runs must validate staged SDK package artifacts before publish checks", + ) + for product_id in ( + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-react-native", + "oliphaunt-js", + "oliphaunt-wasix-rust", + ): + require_text( + "tools/release/release.py", + f'validate_staged_sdk_package("{product_id}")', + f"{product_id} release dry-run must validate the staged SDK package artifact", + ) + require_text( + ".github/scripts/run-planned-moon-job.sh", + "OLIPHAUNT_MOON_UPSTREAM", + "CI must be able to run downloaded-artifact consumer jobs without re-running Moon upstream producer tasks", + ) + for consumer_job in ( + "extension-packages", + "mobile-extension-packages", + "liboliphaunt-native-release-assets", + "liboliphaunt-wasix-aot", + "liboliphaunt-wasix-release-assets", + "mobile-build-android", + "mobile-build-ios", + ): + require_text( + ".github/workflows/ci.yml", + f"OLIPHAUNT_MOON_UPSTREAM=none MOON_CACHE=off .github/scripts/run-planned-moon-job.sh {consumer_job}", + f"{consumer_job} must consume downloaded builder artifacts without re-running upstream producer tasks", + ) + if "Stage mobile exact-extension packages" in ci: + fail("mobile build jobs must not locally stage extension packages; they must consume extension-package builder artifacts") + if "extension-packages-native" in ci: + fail("CI must not keep a native-only extension package shortcut; mobile must consume target-scoped exact-extension packages") + if "oliphaunt-extension-native-package-artifacts" in ci: + fail("CI must not publish native-only exact-extension package artifacts") + if "target/extension-artifacts-native" in ci: + fail("CI must not use a separate native-only extension package staging layout") + require_text( + "tools/release/release.py", + "requires staged exact-extension package artifacts", + "release CLI must fail closed when extension releases lack staged CI-built package artifacts", + ) + require_text( + "tools/release/release.py", + "validate_extension_release_package", + "release CLI must validate staged exact-extension package manifests before dry-run or publish", + ) + require_text( + "tools/release/release.py", + "staged_native_targets != declared_native_targets", + "release CLI must reject partial native exact-extension package artifacts", + ) + require_text( + "tools/release/release.py", + "staged_wasix_targets != declared_wasix_targets", + "release CLI must reject partial WASIX exact-extension package artifacts", + ) + require_text( + "tools/release/release.py", + "sha256_file(asset_path) != sha_value", + "release CLI must verify staged exact-extension artifact checksums", + ) + require_text( + "tools/release/release.py", + "validate_checksum_manifest(checksum_manifest, asset_dir)", + "release CLI must verify staged exact-extension checksum manifests exactly", + ) + require_text( + "tools/release/build-extension-ci-artifacts.py", + "native_asset_name(product, version", + "exact-extension package artifacts must be named by extension product version", + ) + require_text( + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "native-extension-assets.tsv", + "native exact-extension artifact producers must emit a target-addressed native asset index", + ) + require_text( + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "OLIPHAUNT_EXTENSION_PRODUCT", + "native exact-extension artifact producers must support product-scoped builds", + ) + require_text( + "src/extensions/artifacts/wasix/tools/package-release-assets.sh", + "OLIPHAUNT_EXTENSION_PRODUCT", + "WASIX exact-extension artifact producers must support product-scoped builds", + ) + require_text( + "tools/release/build-extension-ci-artifacts.py", + "native_assets_from_target_indexes", + "exact-extension package staging must consume target-addressed native asset indexes", + ) + require_text( + "tools/release/build-extension-ci-artifacts.py", + 'published_target_ids(family="native")', + "exact-extension package staging must only read declared published native target artifact indexes", + ) + require_text( + "tools/release/build-extension-ci-artifacts.py", + 'published_target_ids(family="wasix")', + "exact-extension package staging must only read declared published WASIX target artifact indexes", + ) + require_text( + "tools/release/build-extension-ci-artifacts.py", + "if require_native_targets and target not in require_native_targets:", + "mobile exact-extension package staging must filter out native targets that the mobile build did not request", + ) + require_text( + "tools/release/build-extension-ci-artifacts.py", + "index_contains_sql_name(product_index, sql_name)", + "exact-extension package staging must not let stale empty product-scoped native indexes shadow target-level indexes", + ) + require_text( + "tools/release/build-extension-ci-artifacts.py", + "-manifest.json", + "exact-extension package artifacts must publish a machine-readable release manifest", + ) + require_text( + "tools/release/check_github_release_assets.py", + "expected_extension_assets", + "GitHub release verification must derive exact-extension asset expectations from staged extension package manifests", + ) + require_text( + "tools/release/verify_github_release_attestations.py", + "exact-extension-artifact", + "Release attestation verification must include exact-extension artifact products", + ) + require_text( + "tools/release/release.py", + "liboliphaunt-native requires staged release assets", + "release CLI must fail closed when liboliphaunt releases lack staged CI-built runtime artifacts", + ) + require_text( + "tools/release/release.py", + "liboliphaunt-wasix requires staged release assets", + "release CLI must fail closed when WASIX releases lack staged CI-built runtime artifacts", + ) + require_text( + "tools/release/release.py", + "requires staged JSR source", + "release CLI must fail closed when TypeScript JSR release artifacts are not staged", + ) + require_text( + ".github/workflows/release.yml", + "Download SDK package artifacts", + "release workflow must download SDK package artifacts from the Builds workflow before publishing", + ) + require_text( + ".github/workflows/release.yml", + "Download liboliphaunt release assets", + "release workflow must download complete liboliphaunt assets from the Builds workflow before publishing", + ) + require_text( + ".github/workflows/release.yml", + "Download native helper release assets", + "release workflow must download broker and Node direct helper assets from the Builds workflow before publishing those helper products", + ) + require_text( + ".github/workflows/release.yml", + "Download WASIX release assets", + "release workflow must download complete WASIX runtime release assets from the Builds workflow before publishing", + ) + require_text( + ".github/workflows/release.yml", + "tools/release/release.py publish --product liboliphaunt-wasix --step github-release-assets", + "release workflow must publish WASIX GitHub assets through the liboliphaunt-wasix runtime product", + ) + require_text( + ".github/workflows/release.yml", + "oliphaunt-broker-release-assets", + "release workflow must name the broker Builds artifacts it consumes", + ) + require_text( + ".github/workflows/release.yml", + '[ "$PRODUCT_OLIPHAUNT_BROKER" = "true" ]', + "broker helper releases must download broker artifacts from Builds", + ) + require_text( + ".github/workflows/release.yml", + '[ "$PRODUCT_OLIPHAUNT_NODE_DIRECT" = "true" ]', + "Node direct helper releases must download Node direct artifacts from Builds", + ) + require_text( + ".github/workflows/release.yml", + "oliphaunt-node-direct-release-assets", + "release workflow must name the Node direct Builds artifacts it consumes", + ) + require_text( + ".github/workflows/release.yml", + "target/oliphaunt-broker/release-assets", + "release workflow must download broker artifacts into the canonical broker release asset root", + ) + require_text( + ".github/workflows/release.yml", + "target/oliphaunt-node-direct/release-assets", + "release workflow must download Node direct artifacts into the canonical Node direct release asset root", + ) + reject_text( + ".github/workflows/release.yml", + "target/release-assets/native", + "release workflow must not stage native helper artifacts in a generic release-assets/native bucket", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + 'stage_jsr_source_workspace "$work_root/check/package-shape/src/sdks/js" "$artifact_root/jsr-source"', + "TypeScript SDK builder must stage source for JSR publishing in addition to the npm tarball", + ) + require_text( + "tools/release/release.py", + 'staged_jsr_source_dir("oliphaunt-js")', + "TypeScript SDK release must publish JSR from staged CI-built source artifacts", + ) + require_text( + "tools/release/release.py", + "validate_staged_npm_package_tarball", + "npm SDK release steps must validate CI-built package tarballs before dry-run or publish", + ) + require_text( + "tools/release/release.py", + "must not contain workspace: dependency specifiers", + "staged npm SDK package validation must reject unpublished workspace protocol specs", + ) + require_text( + "tools/release/release.py", + "verify_staged_cargo_crate_identity", + "Cargo SDK release steps must verify staged CI-built .crate identity before dry-run or publish", + ) + for forbidden in ( + "tools/release/package-liboliphaunt-assets.sh", + "tools/release/package-broker-assets.sh", + "src/runtimes/node-direct/tools/build-node-addon.sh", + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "src/extensions/artifacts/wasix/tools/package-release-assets.sh", + "tools/release/build-extension-ci-artifacts.py", + "src/sdks/kotlin/tools/check-sdk.sh", + "src/sdks/react-native/tools/check-sdk.sh", + "src/sdks/js/tools/check-sdk.sh", + 'xtask(["release", "stage"])', + '"--staged-wasm"', + '"--staged-wasix-runtime"', + "OLIPHAUNT_RELEASE_REQUIRE_STAGED_", + "OLIPHAUNT_WASM_RELEASE_STAGED", + ): + reject_text( + "tools/release/release.py", + forbidden, + f"release CLI must consume staged Builds artifacts, not retain local fallback path {forbidden}", + ) + for forbidden in ( + "OLIPHAUNT_RELEASE_REQUIRE_STAGED_", + "OLIPHAUNT_WASM_RELEASE_STAGED", + ): + reject_text( + ".github/workflows/release.yml", + forbidden, + f"release workflow must not rely on staged-mode env flag {forbidden}; release CLI is staged-artifact-only", + ) + reject_text( + ".github/workflows/release.yml", + "Build liboliphaunt Linux asset", + "release workflow must not rebuild liboliphaunt Linux assets; it must consume Builds artifacts", + ) + reject_text( + ".github/workflows/release.yml", + "Build liboliphaunt Windows asset", + "release workflow must not rebuild liboliphaunt Windows assets; it must consume Builds artifacts", + ) + reject_text( + ".github/workflows/release.yml", + "Build broker Linux asset", + "release workflow must not rebuild broker Linux assets; it must consume Builds artifacts", + ) + reject_text( + ".github/workflows/release.yml", + "Build Node direct native asset", + "release workflow must not rebuild Node direct assets; it must consume Builds artifacts", + ) + require_text( + ".github/scripts/download-build-artifacts.sh", + "artifact_present", + "shared artifact downloader must select a successful Builds run containing every requested artifact", + ) + require_text( + ".github/scripts/download-build-artifacts.sh", + "required_job_success", + "shared artifact downloader must support the builder-gate handoff when non-builder checks fail", + ) + require_text( + ".github/workflows/release.yml", + "require-workflow-success.sh Builds \"$GITHUB_SHA\" 7200 --job artifact-builders", + "release workflow must require the same-SHA Builds artifact builder gate instead of the whole workflow conclusion", + ) + require_text( + ".github/workflows/release.yml", + "--job artifact-builders", + "release workflow artifact downloads must select artifacts from a run whose artifact-builders job succeeded", + ) + require_text( + ".github/scripts/download-wasix-runtime-build-artifacts.sh", + "--required-job artifact-builders", + "WASIX runtime artifact handoff must download from a Builds run whose artifact-builders job succeeded", + ) + require_text( + "tools/xtask/src/asset_io.rs", + "run_has_required_job_success", + "xtask WASIX artifact downloads must support filtering same-SHA runs by required builder job", + ) + if release.index("Download SDK package artifacts") > release.index("Validate selected release product dry-runs"): + fail("release workflow must stage SDK artifacts before selected release product dry-runs") + if release.index("Download liboliphaunt release assets") > release.index("Validate selected release product dry-runs"): + fail("release workflow must stage liboliphaunt runtime artifacts before selected release product dry-runs") + if release.index("Download native helper release assets") > release.index("Validate selected release product dry-runs"): + fail("release workflow must stage native helper artifacts before selected release product dry-runs") + if release.index("Download WASIX release assets") > release.index("Validate selected release product dry-runs"): + fail("release workflow must stage WASIX runtime release assets before selected release product dry-runs") + extension_packages_block = ci[ci.index("extension-packages:") : ci.index(" liboliphaunt-native-desktop:")] + if "Download portable WASIX runtime outputs" in extension_packages_block: + fail("extension-packages must consume WASIX extension artifact outputs, not raw portable runtime outputs") + + +def validate_target_matrices() -> None: + ci = read_text(".github/workflows/ci.yml") + release = read_text(".github/workflows/release.yml") + planner = read_text("tools/graph/ci_plan.py") + for output_name in ( + "liboliphaunt_native_desktop_runtime_matrix", + "liboliphaunt_native_android_runtime_matrix", + "liboliphaunt_native_ios_runtime_matrix", + ): + if output_name not in ci or f"fromJson(needs.affected.outputs.{output_name})" not in ci: + fail(f"CI {output_name} matrix must come from affected planner output") + for helper in ( + "liboliphaunt_native_desktop_runtime_matrix", + "liboliphaunt_native_android_runtime_matrix", + "liboliphaunt_native_ios_runtime_matrix", + ): + require_text( + "tools/graph/ci_plan.py", + f"artifact_target_matrix.{helper}", + f"CI affected planner must derive {helper} from release metadata artifact targets", + ) + if "broker_runtime_matrix" not in ci or "fromJson(needs.affected.outputs.broker_runtime_matrix)" not in ci: + fail("CI broker matrix must come from affected planner output") + if "node_direct_runtime_matrix" not in ci or "fromJson(needs.affected.outputs.node_direct_runtime_matrix)" not in ci: + fail("CI Node direct matrix must come from affected planner output") + if ( + "extension_artifacts_wasix_matrix" not in ci + or "fromJson(needs.affected.outputs.extension_artifacts_wasix_matrix)" not in ci + ): + fail("CI WASIX extension artifact matrix must come from affected planner output") + require_text( + ".github/workflows/ci.yml", + "Build native exact-extension artifacts", + "CI must build native exact-extension artifacts in their own producer job", + ) + if ( + "extension_artifacts_native_matrix" not in ci + or "fromJson(needs.affected.outputs.extension_artifacts_native_matrix)" not in ci + ): + fail("CI native extension artifact matrix must come from affected planner output") + require_text( + "src/extensions/artifacts/native/moon.yml", + "src/extensions/artifacts/native/tools/package-release-assets.sh", + "CI native exact-extension artifact producer must use the release-shaped native extension packager", + ) + require_text( + "src/extensions/artifacts/packages/moon.yml", + "tools/release/build-extension-ci-artifacts.py --all --require-native --require-wasix", + "CI exact-extension package producer must use the shared product artifact builder", + ) + require_text( + "src/runtimes/liboliphaunt/wasix/tools/build-runtime-portable.sh", + "cargo run -p xtask -- assets check --strict-generated", + "WASIX portable runtime build must validate generated extension/runtime assets", + ) + require_text( + "src/runtimes/liboliphaunt/wasix/tools/build-aot-target.sh", + "cargo run -p xtask -- assets check-aot --target-triple \"$target\"", + "WASIX AOT target build must validate target AOT artifacts", + ) + if "native-release-targets:" in release or "native-release-assets:" in release: + fail("release workflow must not define separate native asset builder jobs; Builds owns runtime/helper artifacts") + if "artifact_target_matrix.py native-release-hosts" in release: + fail("release workflow must not use the removed native-release-hosts matrix") + if "artifact_target_matrix" not in planner: + fail("shared affected planner must import the release artifact target matrix helper") + + liboliphaunt_matrix = artifact_target_matrix.liboliphaunt_native_runtime_matrix() + liboliphaunt_targets = {item["target"] for item in liboliphaunt_matrix["include"]} + expected_liboliphaunt_targets = { + target.target + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + published_only=True, + ) + } + if liboliphaunt_targets != expected_liboliphaunt_targets: + fail( + "liboliphaunt CI matrix does not match published native runtime targets: " + f"{sorted(liboliphaunt_targets)} vs {sorted(expected_liboliphaunt_targets)}" + ) + + extension_native_matrix = artifact_target_matrix.extension_artifacts_native_matrix() + extension_native_pairs = { + (product, item["target"]) + for item in extension_native_matrix["include"] + for product in item["extensions_csv"].split(",") + if product + } + expected_extension_native_pairs = { + (target.product, target.target) + for target in extension_artifact_targets.artifact_targets(family="native", published_only=True) + } + if extension_native_pairs != expected_extension_native_pairs: + fail( + "native extension artifact CI matrix does not match published exact-extension native product/target pairs: " + f"{sorted(extension_native_pairs)} vs {sorted(expected_extension_native_pairs)}" + ) + + broker_matrix = artifact_target_matrix.broker_runtime_matrix() + broker_targets = {item["target"] for item in broker_matrix["include"]} + expected_broker_targets = { + target.target + for target in artifact_targets.artifact_targets( + product="oliphaunt-broker", + kind="broker-helper", + published_only=True, + ) + } + if broker_targets != expected_broker_targets: + fail( + "broker CI matrix does not match published broker helper targets: " + f"{sorted(broker_targets)} vs {sorted(expected_broker_targets)}" + ) + + node_direct_matrix = artifact_target_matrix.node_direct_runtime_matrix() + node_direct_targets = {item["target"] for item in node_direct_matrix["include"]} + expected_node_direct_targets = { + target.target + for target in artifact_targets.artifact_targets( + product="oliphaunt-node-direct", + kind="node-direct-addon", + published_only=True, + ) + } + if node_direct_targets != expected_node_direct_targets: + fail( + "Node direct CI matrix does not match published Node direct targets: " + f"{sorted(node_direct_targets)} vs {sorted(expected_node_direct_targets)}" + ) + + extension_wasix_matrix = artifact_target_matrix.extension_artifacts_wasix_matrix() + extension_wasix_pairs = { + (product, item["target"]) + for item in extension_wasix_matrix["include"] + for product in item["extensions_csv"].split(",") + if product + } + expected_extension_wasix_pairs = { + (target.product, target.target) + for target in extension_artifact_targets.artifact_targets(family="wasix", published_only=True) + } + if extension_wasix_pairs != expected_extension_wasix_pairs: + fail( + "WASIX extension artifact CI matrix does not match published exact-extension WASIX product/target pairs: " + f"{sorted(extension_wasix_pairs)} vs {sorted(expected_extension_wasix_pairs)}" + ) + + +def validate_typescript_runtime_targets() -> None: + for target in artifact_targets.artifact_targets( + product="liboliphaunt-native", + kind="native-runtime", + surface="typescript-native-direct", + ): + path = "src/sdks/js/src/native/common.ts" + template = ts_template(target.asset) + if target.published: + require_text(path, template, f"TypeScript native resolver must advertise {target.id}") + require_text(path, target.target, f"TypeScript native resolver must expose target id {target.target}") + else: + reject_text(path, template, f"TypeScript native resolver must not advertise unpublished target {target.id}") + reject_text(path, target.target, f"TypeScript native resolver must not expose unpublished target id {target.target}") + + for target in artifact_targets.artifact_targets( + product="oliphaunt-broker", + kind="broker-helper", + surface="typescript-broker", + ): + path = "src/sdks/js/src/runtime/broker.ts" + template = ts_template(target.asset) + if target.published: + require_text(path, template, f"TypeScript broker resolver must advertise {target.id}") + require_text(path, target.target, f"TypeScript broker resolver must expose target id {target.target}") + else: + reject_text(path, template, f"TypeScript broker resolver must not advertise unpublished target {target.id}") + reject_text(path, target.target, f"TypeScript broker resolver must not expose unpublished target id {target.target}") + + for target in artifact_targets.artifact_targets( + product="oliphaunt-node-direct", + kind="node-direct-addon", + surface="npm-optional", + ): + path = "src/sdks/js/src/native/node-addon.ts" + template = ts_template(target.asset) + if target.published: + require_text(path, template, f"TypeScript Node direct resolver must advertise {target.id}") + require_text(path, target.target, f"TypeScript Node direct resolver must expose target id {target.target}") + else: + reject_text(path, template, f"TypeScript Node direct resolver must not advertise unpublished target {target.id}") + reject_text(path, target.target, f"TypeScript Node direct resolver must not expose unpublished target id {target.target}") + + +def validate_rust_broker_targets() -> None: + manifest = "src/sdks/rust/Cargo.toml" + path = "src/sdks/rust/src/broker.rs" + require_text( + manifest, + 'broker-helper = "oliphaunt-broker"', + "Rust SDK package metadata must identify the broker helper runtime it consumes", + ) + require_text( + manifest, + 'broker-version = "0.1.0"', + "Rust SDK package metadata must pin the compatible broker helper version", + ) + require_text( + path, + "OLIPHAUNT_BROKER_ASSET_DIR", + "Rust broker resolver must support package-shaped broker artifact fixtures", + ) + for target in artifact_targets.artifact_targets( + product="oliphaunt-broker", + kind="broker-helper", + surface="rust-broker", + ): + if target.published: + require_text(path, target.asset, f"Rust broker resolver must advertise {target.id}") + require_text(path, target.target, f"Rust broker resolver must expose target id {target.target}") + if target.executable_relative_path is not None: + require_text( + path, + target.executable_relative_path, + f"Rust broker resolver must expose helper path for {target.id}", + ) + else: + reject_text(path, target.asset, f"Rust broker resolver must not advertise unpublished target {target.id}") + reject_text(path, target.target, f"Rust broker resolver must not expose unpublished target id {target.target}") + + +def validate_expected_product_assets() -> None: + expected = { + "liboliphaunt-native": { + "liboliphaunt-{version}-macos-arm64.tar.gz", + "liboliphaunt-{version}-linux-x64-gnu.tar.gz", + "liboliphaunt-{version}-linux-arm64-gnu.tar.gz", + "liboliphaunt-{version}-windows-x64-msvc.zip", + "liboliphaunt-{version}-ios-xcframework.tar.gz", + "liboliphaunt-{version}-apple-spm-xcframework.zip", + "liboliphaunt-{version}-android-arm64-v8a.tar.gz", + "liboliphaunt-{version}-android-x86_64.tar.gz", + "liboliphaunt-{version}-runtime-resources.tar.gz", + "liboliphaunt-{version}-package-size.tsv", + "liboliphaunt-{version}-release-assets.sha256", + }, + "oliphaunt-broker": { + "oliphaunt-broker-{version}-macos-arm64.tar.gz", + "oliphaunt-broker-{version}-linux-x64-gnu.tar.gz", + "oliphaunt-broker-{version}-linux-arm64-gnu.tar.gz", + "oliphaunt-broker-{version}-windows-x64-msvc.zip", + "oliphaunt-broker-{version}-release-assets.sha256", + }, + "oliphaunt-node-direct": { + "oliphaunt-node-direct-{version}-macos-arm64.tar.gz", + "oliphaunt-node-direct-{version}-linux-x64-gnu.tar.gz", + "oliphaunt-node-direct-{version}-linux-arm64-gnu.tar.gz", + "oliphaunt-node-direct-{version}-windows-x64-msvc.zip", + "oliphaunt-node-direct-{version}-release-assets.sha256", + }, + "liboliphaunt-wasix": { + "liboliphaunt-wasix-{version}-runtime-portable.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-macos-arm64.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-linux-x64-gnu.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-linux-arm64-gnu.tar.zst", + "liboliphaunt-wasix-{version}-runtime-aot-windows-x64-msvc.tar.zst", + "liboliphaunt-wasix-{version}-release-assets.sha256", + }, + } + for product, assets in expected.items(): + actual = { + target.asset + for target in artifact_targets.artifact_targets( + product=product, + surface="github-release", + published_only=True, + ) + } + if actual != assets: + fail(f"{product} published artifact targets expected {sorted(assets)}, got {sorted(actual)}") + + +def main() -> int: + product_metadata.load_graph() + validate_target_shape() + validate_product_local_targets() + validate_extension_artifact_targets() + validate_github_asset_helpers() + validate_ci_release_artifacts() + validate_target_matrices() + validate_typescript_runtime_targets() + validate_rust_broker_targets() + validate_expected_product_assets() + print("artifact target checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/check_broker_release_assets.py b/tools/release/check_broker_release_assets.py new file mode 100755 index 00000000..a7e89389 --- /dev/null +++ b/tools/release/check_broker_release_assets.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Validate local oliphaunt-broker GitHub release assets.""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +import tarfile +import zipfile +from pathlib import Path +from typing import NoReturn + +import artifact_targets +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] + + +def fail(message: str) -> NoReturn: + print(f"check_broker_release_assets.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def expected_assets(version: str) -> list[str]: + return artifact_targets.expected_assets("oliphaunt-broker", version, surface="github-release") + + +def expected_broker_assets(version: str) -> list[str]: + return artifact_targets.expected_assets( + "oliphaunt-broker", + version, + surface="github-release", + kinds=["broker-helper"], + ) + + +def broker_targets_by_asset(version: str) -> dict[str, artifact_targets.ArtifactTarget]: + return { + target.asset_name(version): target + for target in artifact_targets.artifact_targets( + product="oliphaunt-broker", + surface="github-release", + published_only=True, + ) + if target.kind == "broker-helper" + } + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def checksum_manifest(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for index, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + parts = line.split(maxsplit=1) + if len(parts) != 2 or len(parts[0]) != 64: + fail(f"malformed checksum line {index}: {raw_line}") + values[parts[1].removeprefix("./")] = parts[0].lower() + return values + + +def validate_broker_tar_archive(path: Path, executable_path: str) -> None: + with tarfile.open(path, "r:gz") as archive: + names = set(archive.getnames()) + if executable_path not in names: + fail(f"{path.name} is missing {executable_path}") + if "manifest.properties" not in names: + fail(f"{path.name} is missing manifest.properties") + broker = archive.getmember(executable_path) + if not broker.isfile(): + fail(f"{path.name} {executable_path} is not a regular file") + if broker.mode & 0o111 == 0: + fail(f"{path.name} {executable_path} is not executable") + + +def validate_broker_zip_archive(path: Path, executable_path: str) -> None: + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + if executable_path not in names: + fail(f"{path.name} is missing {executable_path}") + if "manifest.properties" not in names: + fail(f"{path.name} is missing manifest.properties") + broker = archive.getinfo(executable_path) + if broker.is_dir(): + fail(f"{path.name} {executable_path} is not a regular file") + if broker.file_size == 0: + fail(f"{path.name} {executable_path} is empty") + + +def validate_broker_archive(path: Path, target: artifact_targets.ArtifactTarget) -> None: + executable_path = target.executable_relative_path + if executable_path is None: + fail(f"{target.id} is missing executable_relative_path") + if path.name.endswith(".tar.gz"): + validate_broker_tar_archive(path, executable_path) + elif path.suffix == ".zip": + validate_broker_zip_archive(path, executable_path) + else: + fail(f"{path.name} has unsupported broker archive extension") + + +def validate(asset_dir: Path, allow_partial: bool = False) -> None: + version = product_metadata.read_current_version("oliphaunt-broker") + required_assets = expected_assets(version) + broker_targets = broker_targets_by_asset(version) + missing = [asset for asset in required_assets if not (asset_dir / asset).is_file()] + if missing: + if not allow_partial: + fail("missing oliphaunt-broker release asset(s): " + ", ".join(missing)) + present_broker_assets = [ + asset for asset in expected_broker_assets(version) if (asset_dir / asset).is_file() + ] + if not present_broker_assets: + fail( + "partial oliphaunt-broker release asset validation requires at least one broker asset" + ) + + checksum_asset = asset_dir / f"oliphaunt-broker-{version}-release-assets.sha256" + if not checksum_asset.is_file(): + fail(f"missing checksum manifest: {checksum_asset.name}") + checksums = checksum_manifest(checksum_asset) + for asset in required_assets: + if allow_partial and not (asset_dir / asset).is_file(): + continue + if asset == checksum_asset.name: + continue + expected_digest = checksums.get(asset) + if expected_digest is None: + fail(f"{checksum_asset.name} does not cover {asset}") + actual = sha256(asset_dir / asset) + if actual != expected_digest: + fail(f"checksum mismatch for {asset}: expected {expected_digest}, got {actual}") + for asset in expected_broker_assets(version): + if allow_partial and not (asset_dir / asset).is_file(): + continue + target = broker_targets.get(asset) + if target is None: + fail(f"no artifact target metadata found for {asset}") + validate_broker_archive(asset_dir / asset, target) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--asset-dir", + default=str(ROOT / "target/oliphaunt-broker/release-assets"), + help="directory containing oliphaunt-broker release assets", + ) + parser.add_argument( + "--allow-partial", + action="store_true", + help="validate the broker assets present in asset-dir without requiring every published target", + ) + args = parser.parse_args(argv) + validate(Path(args.asset_dir).resolve(), allow_partial=args.allow_partial) + print(f"oliphaunt-broker release assets validated: {Path(args.asset_dir).resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_consumer_shape.py b/tools/release/check_consumer_shape.py new file mode 100755 index 00000000..1d928cbc --- /dev/null +++ b/tools/release/check_consumer_shape.py @@ -0,0 +1,1100 @@ +#!/usr/bin/env python3 +"""Validate tracked consumer-facing package shape for Oliphaunt products. + +This is deliberately not a public-registry install test. It proves that the +repository surfaces are directionally ready for consumers: package metadata, +install docs, generated app wiring, asset resolver hooks, and exact-extension +selection stay present and consistent with the release metadata. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn + +import product_metadata +import extension_artifact_targets + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_FIXTURE = ROOT / "src/shared/fixtures/consumer-shape/products.json" +SCHEMA = "oliphaunt-consumer-shape-v1" +SEVERITY_ORDER = {"P0": 0, "P1": 1, "P2": 2} +FORBIDDEN_INSTALL_SCRIPTS = {"preinstall", "install", "postinstall", "prepare"} + + +@dataclass(frozen=True) +class Finding: + severity: str + product: str + check: str + message: str + evidence: tuple[str, ...] + + @property + def id(self) -> str: + return f"{self.product}.{self.check}" + + +def fail(message: str) -> NoReturn: + print(f"check_consumer_shape.py: {message}", file=sys.stderr) + raise SystemExit(2) + + +def relative(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def read_text(path: str) -> str: + full = ROOT / path + if not full.is_file(): + fail(f"required checker input is missing: {path}") + return full.read_text(encoding="utf-8") + + +def read_optional_text(path: str) -> str | None: + full = ROOT / path + if not full.is_file(): + return None + return full.read_text(encoding="utf-8") + + +def read_json(path: str) -> dict: + try: + value = json.loads(read_text(path)) + except json.JSONDecodeError as error: + fail(f"{path} is not valid JSON: {error}") + if not isinstance(value, dict): + fail(f"{path} must contain a JSON object") + return value + + +def read_toml(path: str) -> dict: + try: + return tomllib.loads(read_text(path)) + except tomllib.TOMLDecodeError as error: + fail(f"{path} is not valid TOML: {error}") + + +def read_gradle_properties(path: str) -> dict[str, str]: + values: dict[str, str] = {} + for raw_line in read_text(path).splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def parse_products_json(raw: str | None) -> list[str]: + if raw is None: + return [] + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + fail(f"--products-json must be valid JSON: {error}") + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail("--products-json must be a JSON string list") + known = set(product_metadata.product_ids()) + unknown = sorted(set(value) - known) + if unknown: + fail(f"unknown release products: {', '.join(unknown)}") + return value + + +def load_fixture(path: Path) -> dict[str, dict]: + if not path.is_file(): + fail(f"consumer-shape fixture is missing: {relative(path)}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + fail(f"{relative(path)} is not valid JSON: {error}") + if payload.get("schema") != SCHEMA: + fail(f"{relative(path)} must declare schema {SCHEMA}") + products = payload.get("products") + if not isinstance(products, dict): + fail(f"{relative(path)} must contain a products object") + for product, config in products.items(): + if not isinstance(config, dict): + fail(f"{relative(path)} product {product} must be an object") + files = config.get("files") + if not isinstance(files, list) or not all(isinstance(item, str) for item in files): + fail(f"{relative(path)} product {product}.files must be a string list") + required_text = config.get("requiredText", {}) + if not isinstance(required_text, dict): + fail(f"{relative(path)} product {product}.requiredText must be an object") + for required_file, snippets in required_text.items(): + if required_file not in files: + fail(f"{relative(path)} product {product} requires text in an undeclared file: {required_file}") + if not isinstance(snippets, list) or not all(isinstance(item, str) for item in snippets): + fail(f"{relative(path)} product {product}.requiredText[{required_file}] must be a string list") + return products + + +def add( + findings: list[Finding], + product: str, + check: str, + message: str, + evidence: str | list[str] | tuple[str, ...], + *, + severity: str = "P1", +) -> None: + if isinstance(evidence, str): + evidence_tuple = (evidence,) + else: + evidence_tuple = tuple(evidence) + findings.append(Finding(severity, product, check, message, evidence_tuple)) + + +def require( + findings: list[Finding], + product: str, + check: str, + condition: bool, + message: str, + evidence: str | list[str] | tuple[str, ...], + *, + severity: str = "P1", +) -> None: + if not condition: + add(findings, product, check, message, evidence, severity=severity) + + +def validate_fixture_contract( + findings: list[Finding], + fixture: dict[str, dict], + selected_products: list[str], +) -> None: + for product in selected_products: + config = fixture.get(product) + if config is None: + add( + findings, + product, + "missing-fixture", + "Product has no consumer-shape fixture.", + "src/shared/fixtures/consumer-shape/products.json", + severity="P0", + ) + continue + for path in config["files"]: + if not (ROOT / path).is_file(): + add( + findings, + product, + "required-file", + "Consumer-shape fixture references a file that does not exist.", + path, + severity="P0", + ) + for path, snippets in config.get("requiredText", {}).items(): + text = read_optional_text(path) + if text is None: + continue + missing = [snippet for snippet in snippets if snippet not in text] + if missing: + add( + findings, + product, + "required-text", + "Consumer-facing fixture text is missing.", + [f"{path}: {snippet}" for snippet in missing], + severity="P1", + ) + + +def product_registry_packages(product: str) -> list[str]: + config = product_metadata.product_config(product) + packages = config.get("registry_packages", []) + if not isinstance(packages, list): + fail(f"{product}.registry_packages must be a list") + return [str(package) for package in packages] + + +def product_publish_targets(product: str) -> list[str]: + config = product_metadata.product_config(product) + targets = config.get("publish_targets", []) + if not isinstance(targets, list): + fail(f"{product}.publish_targets must be a list") + return [str(target) for target in targets] + + +def check_npm_package_common( + findings: list[Finding], + product: str, + path: str, + expected_name: str, + expected_directory: str, +) -> dict: + package = read_json(path) + require( + findings, + product, + "npm-name", + package.get("name") == expected_name, + "npm package name must match the public registry identity.", + f"{path}: name={package.get('name')!r}", + severity="P0", + ) + require( + findings, + product, + "npm-version", + package.get("version") == product_metadata.read_current_version(product), + "npm package version must match the release metadata product version.", + f"{path}: version={package.get('version')!r}", + severity="P0", + ) + repository = package.get("repository", {}) + require( + findings, + product, + "npm-repository", + isinstance(repository, dict) + and repository.get("url") == "git+https://github.com/f0rr0/oliphaunt.git" + and repository.get("directory") == expected_directory, + "npm package repository metadata must point at the public repo and product directory.", + f"{path}: repository={repository!r}", + ) + publish_config = package.get("publishConfig", {}) + require( + findings, + product, + "npm-provenance", + isinstance(publish_config, dict) + and publish_config.get("access") == "public" + and publish_config.get("provenance") is True, + "npm publishConfig must opt into public provenance publishing.", + f"{path}: publishConfig={publish_config!r}", + severity="P0", + ) + scripts = package.get("scripts", {}) + forbidden = sorted(set(scripts) & FORBIDDEN_INSTALL_SCRIPTS) if isinstance(scripts, dict) else [] + require( + findings, + product, + "npm-install-scripts", + not forbidden, + "Consumer installs must not run native build or repository-local setup scripts.", + f"{path}: forbidden scripts={', '.join(forbidden)}", + severity="P0", + ) + return package + + +def check_liboliphaunt(findings: list[Finding]) -> None: + product = "liboliphaunt-native" + version = read_text("src/runtimes/liboliphaunt/native/VERSION").strip() + require( + findings, + product, + "version-source", + version == product_metadata.read_current_version(product), + "liboliphaunt VERSION must be the release metadata version source.", + f"src/runtimes/liboliphaunt/native/VERSION={version!r}", + severity="P0", + ) + script = read_text("tools/release/package-liboliphaunt-assets.sh") + for required in [ + "assert_base_runtime_has_no_optional_extensions", + "liboliphaunt-${version}-release-assets.sha256", + "stage_runtime_resources=\"$stage_root/liboliphaunt-${version}-runtime-resources\"", + "archive_staged_dir \"$stage_runtime_resources\"", + "liboliphaunt-${version}-apple-spm-xcframework.zip", + ]: + require( + findings, + product, + "asset-packaging", + required in script, + "liboliphaunt release packaging must publish base runtime assets and checksums.", + f"tools/release/package-liboliphaunt-assets.sh missing {required}", + severity="P0", + ) + for forbidden in [ + "write_extension_asset_index_header", + "src/extensions/artifacts/native/tools/package-release-assets.sh", + ]: + require( + findings, + product, + "asset-packaging", + forbidden not in script, + "liboliphaunt release packaging must not publish exact-extension artifacts; extension products own those assets.", + f"tools/release/package-liboliphaunt-assets.sh still contains {forbidden}", + severity="P0", + ) + + +def check_rust(findings: list[Finding]) -> None: + product = "oliphaunt-rust" + manifest = read_toml("src/sdks/rust/Cargo.toml") + package = manifest.get("package", {}) + require( + findings, + product, + "cargo-name", + package.get("name") == "oliphaunt", + "Rust SDK crate name must match the public crates.io package.", + f"src/sdks/rust/Cargo.toml package.name={package.get('name')!r}", + severity="P0", + ) + require( + findings, + product, + "cargo-version", + package.get("version") == product_metadata.read_current_version(product), + "Rust SDK crate version must match the release metadata product version.", + f"src/sdks/rust/Cargo.toml package.version={package.get('version')!r}", + severity="P0", + ) + bins = {item.get("name") for item in manifest.get("bin", []) if isinstance(item, dict)} + require( + findings, + product, + "cargo-binaries", + "oliphaunt-resources" in bins, + "Rust SDK must ship the runtime resource helper binary it documents.", + "missing [[bin]] oliphaunt-resources", + severity="P0", + ) + require( + findings, + product, + "registry-package", + "crates:oliphaunt" in product_registry_packages(product), + "Rust SDK release metadata must publish the Rust SDK to crates.io.", + "src/sdks/rust/release.toml", + severity="P0", + ) + + +def check_broker(findings: list[Finding]) -> None: + product = "oliphaunt-broker" + manifest = read_toml("src/runtimes/broker/Cargo.toml") + package = manifest.get("package", {}) + require( + findings, + product, + "cargo-name", + package.get("name") == "oliphaunt-broker", + "Broker runtime package name must match the helper executable product.", + f"src/runtimes/broker/Cargo.toml package.name={package.get('name')!r}", + severity="P0", + ) + bins = {item.get("name") for item in manifest.get("bin", []) if isinstance(item, dict)} + require( + findings, + product, + "broker-binary", + "oliphaunt-broker" in bins, + "Broker runtime must ship the oliphaunt-broker executable.", + "missing [[bin]] oliphaunt-broker", + severity="P0", + ) + require( + findings, + product, + "release-assets", + "github-release-assets" in product_publish_targets(product), + "Broker runtime must publish platform helper binaries as release assets.", + "src/runtimes/broker/release.toml", + severity="P0", + ) + + +def check_node_direct(findings: list[Finding]) -> None: + product = "oliphaunt-node-direct" + package = read_json("src/runtimes/node-direct/package.json") + version = product_metadata.read_current_version(product) + require( + findings, + product, + "npm-name", + package.get("name") == "@oliphaunt/node-direct" and package.get("private") is True, + "Node direct root package must stay a private source/build package; only platform packages are published.", + f"src/runtimes/node-direct/package.json name={package.get('name')!r} private={package.get('private')!r}", + severity="P0", + ) + require( + findings, + product, + "npm-version", + package.get("version") == version, + "Node direct root package version must match the release metadata product version.", + f"src/runtimes/node-direct/package.json version={package.get('version')!r}", + severity="P0", + ) + metadata = package.get("oliphaunt", {}) + require( + findings, + product, + "node-direct-liboliphaunt-pin", + isinstance(metadata, dict) + and metadata.get("liboliphauntVersion") == product_metadata.read_current_version("liboliphaunt-native"), + "Node direct source package must pin the compatible native liboliphaunt runtime version.", + f"src/runtimes/node-direct/package.json oliphaunt={metadata!r}", + severity="P0", + ) + scripts = package.get("scripts", {}) + forbidden = sorted(set(scripts) & FORBIDDEN_INSTALL_SCRIPTS) if isinstance(scripts, dict) else [] + require( + findings, + product, + "npm-install-scripts", + not forbidden, + "Node direct source package must not run native build scripts during consumer install.", + f"src/runtimes/node-direct/package.json forbidden scripts={', '.join(forbidden)}", + severity="P0", + ) + require( + findings, + product, + "release-targets", + {"npm", "github-release-assets"}.issubset(product_publish_targets(product)) + and { + "node-api-prebuilds", + "npm-optional-platform-packages", + }.issubset(set(product_metadata.product_config(product).get("release_artifacts", []))), + "Node direct must publish both GitHub prebuild assets and optional npm platform packages.", + "src/runtimes/node-direct/release.toml", + severity="P0", + ) + + expected_packages = { + "darwin-arm64": ("@oliphaunt/node-direct-darwin-arm64", ("darwin",), ("arm64",), None), + "linux-x64-gnu": ("@oliphaunt/node-direct-linux-x64-gnu", ("linux",), ("x64",), ("glibc",)), + "linux-arm64-gnu": ("@oliphaunt/node-direct-linux-arm64-gnu", ("linux",), ("arm64",), ("glibc",)), + "win32-x64-msvc": ("@oliphaunt/node-direct-win32-x64-msvc", ("win32",), ("x64",), None), + } + require( + findings, + product, + "registry-packages", + set(product_registry_packages(product)) == {f"npm:{name}" for name, _os, _cpu, _libc in expected_packages.values()}, + "Node direct release metadata must publish exactly the optional platform npm packages.", + f"src/runtimes/node-direct/release.toml registry_packages={product_registry_packages(product)!r}", + severity="P0", + ) + for directory, (package_name, expected_os, expected_cpu, expected_libc) in expected_packages.items(): + package_path = f"src/runtimes/node-direct/packages/{directory}/package.json" + optional_package = check_npm_package_common( + findings, + product, + package_path, + package_name, + f"src/runtimes/node-direct/packages/{directory}", + ) + require( + findings, + product, + "node-direct-platform-package", + optional_package.get("optional") is True + and optional_package.get("os") == list(expected_os) + and optional_package.get("cpu") == list(expected_cpu) + and (expected_libc is None or optional_package.get("libc") == list(expected_libc)), + "Node direct platform packages must constrain npm installation to the matching OS, CPU, and libc.", + f"{package_path}: os={optional_package.get('os')!r} cpu={optional_package.get('cpu')!r} libc={optional_package.get('libc')!r}", + severity="P0", + ) + require( + findings, + product, + "node-direct-platform-package", + "prebuilds" in optional_package.get("files", []) + and optional_package.get("exports", {}).get("./oliphaunt_node.node") == "./prebuilds/oliphaunt_node.node", + "Node direct platform packages must expose the prebuilt addon by the stable export path.", + f"{package_path}: files={optional_package.get('files')!r} exports={optional_package.get('exports')!r}", + severity="P0", + ) + + +def check_swift(findings: list[Finding]) -> None: + product = "oliphaunt-swift" + version = read_text("src/sdks/swift/VERSION").strip() + lib_version = read_text("src/sdks/swift/LIBOLIPHAUNT_VERSION").strip() + require( + findings, + product, + "swift-version", + version == product_metadata.read_current_version(product), + "Swift SDK VERSION must be the release metadata product version.", + f"src/sdks/swift/VERSION={version!r}", + severity="P0", + ) + require( + findings, + product, + "swift-liboliphaunt-pin", + lib_version == product_metadata.read_current_version("liboliphaunt-native"), + "Swift SDK must pin the compatible liboliphaunt release.", + f"src/sdks/swift/LIBOLIPHAUNT_VERSION={lib_version!r}", + severity="P0", + ) + root_package = read_text("Package.swift") + for required in [ + 'name: "Oliphaunt"', + ".iOS(.v17)", + ".macOS(.v14)", + 'path: "src/sdks/swift/Sources/COliphaunt"', + 'path: "src/sdks/swift/Sources/Oliphaunt"', + ]: + require( + findings, + product, + "swiftpm-source-package", + required in root_package, + "Root SwiftPM source package must remain a normal Apple consumer entrypoint.", + f"Package.swift missing {required}", + severity="P0", + ) + renderer = read_text("tools/release/render_swiftpm_release_package.py") + for required in ["binaryTarget(", "checksum", "base Swift package must not require or publish extension files"]: + require( + findings, + product, + "swiftpm-release-manifest", + required in renderer, + "Swift release manifest renderer must checksum-pin the base binary target and keep extensions separate.", + f"tools/release/render_swiftpm_release_package.py missing {required}", + severity="P0", + ) + for forbidden in ["extension_rows", "OliphauntExtension"]: + require( + findings, + product, + "swiftpm-release-manifest", + forbidden not in renderer, + "Swift base release manifest renderer must not synthesize exact-extension products.", + f"tools/release/render_swiftpm_release_package.py still contains {forbidden}", + severity="P0", + ) + + +def check_kotlin(findings: list[Finding]) -> None: + product = "oliphaunt-kotlin" + props = read_gradle_properties("src/sdks/kotlin/gradle.properties") + require( + findings, + product, + "kotlin-coordinates", + props.get("GROUP") == "dev.oliphaunt", + "Kotlin SDK group must match the public Maven coordinates.", + f"src/sdks/kotlin/gradle.properties GROUP={props.get('GROUP')!r}", + severity="P0", + ) + require( + findings, + product, + "kotlin-version", + props.get("VERSION_NAME") == product_metadata.read_current_version(product), + "Kotlin SDK version must match the release metadata product version.", + f"src/sdks/kotlin/gradle.properties VERSION_NAME={props.get('VERSION_NAME')!r}", + severity="P0", + ) + pinned_lib = read_text( + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/liboliphaunt.version" + ).strip() + require( + findings, + product, + "android-liboliphaunt-pin", + pinned_lib == product_metadata.read_current_version("liboliphaunt-native"), + "Android Gradle plugin must pin the compatible liboliphaunt release.", + f"liboliphaunt.version={pinned_lib!r}", + severity="P0", + ) + plugin_source = read_text( + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntAndroidPlugin.java" + ) + resolver_source = read_text( + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java" + ) + android_plugin_surface = plugin_source + "\n" + resolver_source + for required in ["dev.oliphaunt.android", "oliphauntExtensions", "resolveOliphauntAndroidAssets"]: + require( + findings, + product, + "android-consumer-plugin", + required in plugin_source, + "Android SDK must expose an app-applied Gradle plugin for consumer asset wiring.", + f"OliphauntAndroidPlugin.java missing {required}", + severity="P0", + ) + for required in [ + "oliphaunt-extension-", + "oliphauntExtensionVersions", + "manifest.properties", + "release-assets.sha256", + "downloadAndVerifyExtension", + ]: + require( + findings, + product, + "android-exact-extension-resolver", + required in android_plugin_surface, + "Android asset resolver must consume exact-extension release products rather than liboliphaunt-bundled extension indexes.", + f"ResolveOliphauntAndroidAssetsTask.java missing {required}", + severity="P0", + ) + + +def check_react_native(findings: list[Finding]) -> None: + product = "oliphaunt-react-native" + package = check_npm_package_common( + findings, + product, + "src/sdks/react-native/package.json", + "@oliphaunt/react-native", + "src/sdks/react-native", + ) + require( + findings, + product, + "rn-peer-dependencies", + "react-native" in package.get("peerDependencies", {}) and "react" in package.get("peerDependencies", {}), + "React Native package must peer-depend on React and React Native instead of bundling app frameworks.", + "src/sdks/react-native/package.json peerDependencies", + severity="P0", + ) + require( + findings, + product, + "rn-codegen", + isinstance(package.get("codegenConfig"), dict) + and package["codegenConfig"].get("jsSrcsDir") == "src/specs" + and package.get("react-native") == "lib/module/index.js", + "React Native package must expose New Architecture Codegen metadata and compiled React Native entrypoint.", + "src/sdks/react-native/package.json", + severity="P0", + ) + metadata = package.get("oliphaunt", {}) + require( + findings, + product, + "rn-sdk-compatibility", + isinstance(metadata, dict) + and metadata.get("swiftSdkVersion") == product_metadata.read_current_version("oliphaunt-swift") + and metadata.get("kotlinSdkVersion") == product_metadata.read_current_version("oliphaunt-kotlin"), + "React Native package must pin compatible Swift and Kotlin SDK versions.", + f"src/sdks/react-native/package.json oliphaunt={metadata!r}", + severity="P0", + ) + plugin = read_text("src/sdks/react-native/app.plugin.js") + for required in [ + "extension '${extension}' must be an exact PostgreSQL extension name", + "oliphauntExtensions", + "OliphauntExtensions.json", + "pod 'Oliphaunt'", + "dev.oliphaunt.android", + ]: + require( + findings, + product, + "expo-config-plugin", + required in plugin, + "React Native config plugin must wire exact extension selection through iOS and Android app projects.", + f"src/sdks/react-native/app.plugin.js missing {required}", + severity="P0", + ) + podspec = read_text("src/sdks/react-native/OliphauntReactNative.podspec") + require( + findings, + product, + "rn-podspec", + 's.dependency "Oliphaunt", native_sdk_version' in podspec and "install_modules_dependencies(s)" in podspec, + "React Native podspec must delegate iOS runtime behavior to the Swift SDK and RN autolinking.", + "src/sdks/react-native/OliphauntReactNative.podspec", + severity="P0", + ) + + +def check_typescript(findings: list[Finding]) -> None: + product = "oliphaunt-js" + package = check_npm_package_common( + findings, + product, + "src/sdks/js/package.json", + "@oliphaunt/ts", + "src/sdks/js", + ) + require( + findings, + product, + "ts-no-runtime-deps", + not package.get("dependencies"), + "TypeScript SDK normal installs must not pull a surprise FFI or native-build dependency.", + "src/sdks/js/package.json dependencies", + severity="P0", + ) + expected_optional = { + "@oliphaunt/node-direct-darwin-arm64", + "@oliphaunt/node-direct-linux-x64-gnu", + "@oliphaunt/node-direct-linux-arm64-gnu", + "@oliphaunt/node-direct-win32-x64-msvc", + } + optional_dependencies = package.get("optionalDependencies", {}) + require( + findings, + product, + "ts-node-direct-optional-deps", + isinstance(optional_dependencies, dict) and set(optional_dependencies) == expected_optional, + "TypeScript SDK must select Node direct through exact optional platform packages.", + f"src/sdks/js/package.json optionalDependencies={optional_dependencies!r}", + severity="P0", + ) + metadata = package.get("oliphaunt", {}) + require( + findings, + product, + "ts-sdk-compatibility", + isinstance(metadata, dict) + and metadata.get("liboliphauntVersion") == product_metadata.read_current_version("liboliphaunt-native") + and metadata.get("brokerVersion") == product_metadata.read_current_version("oliphaunt-broker") + and metadata.get("nodeDirectAddonVersion") == product_metadata.read_current_version("oliphaunt-node-direct"), + "TypeScript SDK must pin compatible liboliphaunt, broker-helper, and Node direct versions.", + f"src/sdks/js/package.json oliphaunt={metadata!r}", + severity="P0", + ) + exports = package.get("exports", {}) + for export_name in [".", "./node", "./bun", "./deno", "./protocol", "./query"]: + require( + findings, + product, + "ts-exports", + export_name in exports, + "TypeScript SDK must publish runtime-specific and protocol exports.", + f"src/sdks/js/package.json missing exports[{export_name}]", + severity="P0", + ) + jsr = read_json("src/sdks/js/jsr.json") + require( + findings, + product, + "jsr-version", + jsr.get("version") == product_metadata.read_current_version(product), + "JSR version must match the TypeScript release metadata product version.", + f"src/sdks/js/jsr.json version={jsr.get('version')!r}", + severity="P0", + ) + jsr_exports = jsr.get("exports", {}) + require( + findings, + product, + "jsr-exports", + isinstance(jsr_exports, dict) and {"./deno", "./protocol", "./query"}.issubset(jsr_exports), + "JSR package must expose the Deno-native and shared protocol entrypoints.", + f"src/sdks/js/jsr.json exports={jsr_exports!r}", + severity="P0", + ) + + +def check_wasm(findings: list[Finding]) -> None: + product = "oliphaunt-wasix-rust" + manifest = read_toml("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + package = manifest.get("package", {}) + require( + findings, + product, + "wasm-crate-name", + package.get("name") == "oliphaunt-wasix", + "WASM crate name must match the public crate.", + f"oliphaunt-wasix Cargo.toml package.name={package.get('name')!r}", + severity="P0", + ) + require( + findings, + product, + "wasm-version", + package.get("version") == product_metadata.read_current_version(product), + "WASM crate version must match the release metadata product version.", + f"oliphaunt-wasix Cargo.toml package.version={package.get('version')!r}", + severity="P0", + ) + metadata = package.get("metadata", {}).get("oliphaunt-wasix", {}).get("assets", {}) + require( + findings, + product, + "wasm-pg18", + metadata.get("postgres-version") == "18.4", + "WASM consumer crate must advertise the active PostgreSQL 18.4 runtime.", + f"package.metadata.oliphaunt-wasix.assets.postgres-version={metadata.get('postgres-version')!r}", + severity="P0", + ) + features = manifest.get("features", {}) + require( + findings, + product, + "wasm-bundled-assets", + "bundled" in features and "extensions" in features, + "WASM crate must keep bundled runtime/assets as an explicit consumer feature.", + "oliphaunt-wasix Cargo.toml features", + severity="P0", + ) + + +def check_liboliphaunt_wasix(findings: list[Finding]) -> None: + product = "liboliphaunt-wasix" + version = read_text("src/runtimes/liboliphaunt/wasix/VERSION").strip() + require( + findings, + product, + "wasix-runtime-version", + version == product_metadata.read_current_version(product), + "WASIX runtime VERSION must be the release metadata product version.", + f"src/runtimes/liboliphaunt/wasix/VERSION={version!r}", + severity="P0", + ) + asset_manifest = read_toml("src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml") + asset_package = asset_manifest.get("package", {}) + require( + findings, + product, + "wasix-assets-crate", + asset_package.get("name") == "oliphaunt-wasix-assets" + and asset_package.get("version") == product_metadata.read_current_version(product), + "WASIX runtime asset crate must publish under the runtime product version.", + f"src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml package={asset_package!r}", + severity="P0", + ) + require( + findings, + product, + "wasix-publish-targets", + {"crates-io", "github-release-assets"}.issubset(product_publish_targets(product)), + "WASIX runtime must publish Cargo asset/AOT crates and GitHub runtime assets.", + "src/runtimes/liboliphaunt/wasix/release.toml", + severity="P0", + ) + registry_packages = set(product_registry_packages(product)) + require( + findings, + product, + "wasix-registry-packages", + { + "crates:oliphaunt-wasix-assets", + "crates:oliphaunt-wasix-aot-aarch64-apple-darwin", + "crates:oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + "crates:oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + "crates:oliphaunt-wasix-aot-x86_64-pc-windows-msvc", + }.issubset(registry_packages), + "WASIX runtime release metadata must expose the portable assets crate and every published AOT crate.", + f"src/runtimes/liboliphaunt/wasix/release.toml registry_packages={sorted(registry_packages)!r}", + severity="P0", + ) + + +def check_exact_extension(findings: list[Finding], product: str) -> None: + config = product_metadata.product_config(product) + package_path = product_metadata.package_path(product) + sql_name = config.get("extension_sql_name") + version_path = f"{package_path}/VERSION" + version = read_text(version_path).strip() + require( + findings, + product, + "extension-version", + version == product_metadata.read_current_version(product), + "Exact-extension VERSION must be the release metadata product version.", + f"{version_path}={version!r}", + severity="P0", + ) + require( + findings, + product, + "extension-release-metadata", + config.get("kind") == "exact-extension-artifact" + and product_publish_targets(product) == ["github-release-assets"] + and config.get("release_artifacts") == ["exact-extension-artifacts"] + and isinstance(sql_name, str) + and sql_name, + "Exact-extension release metadata must publish only exact GitHub artifact assets by SQL extension name.", + f"{package_path}/release.toml", + severity="P0", + ) + target_file = f"{package_path}/targets/artifacts.toml" + read_toml(target_file) + targets = extension_artifact_targets.artifact_targets(product=product, published_only=True) + native_targets = {target.target for target in targets if target.family == "native"} + wasix_targets = {target.target for target in targets if target.family == "wasix"} + require( + findings, + product, + "extension-targets", + { + "android-arm64-v8a", + "android-x86_64", + "ios-xcframework", + "linux-arm64-gnu", + "linux-x64-gnu", + "macos-arm64", + }.issubset(native_targets) + and wasix_targets == {"wasix-portable"}, + "Exact-extension artifact targets must cover mobile and non-Windows native artifact surfaces plus WASIX portable; optional platform opt-outs must be explicit in target metadata.", + f"{target_file}: native={sorted(native_targets)!r} wasix={sorted(wasix_targets)!r}", + severity="P0", + ) + require( + findings, + product, + "extension-consumer-assets", + all(target.kind == "native-static-registry" for target in targets if target.target.startswith("android-") or target.target == "ios-xcframework") + and all(target.kind == "native-dynamic" for target in targets if target.target.startswith(("linux-", "macos-", "windows-"))) + and all(target.kind == "wasix-runtime" for target in targets if target.family == "wasix"), + "Exact-extension target metadata must distinguish mobile static-registry artifacts, desktop dynamic artifacts, and WASIX runtime artifacts.", + target_file, + severity="P0", + ) + + +PRODUCT_CHECKS = { + "liboliphaunt-native": check_liboliphaunt, + "liboliphaunt-wasix": check_liboliphaunt_wasix, + "oliphaunt-rust": check_rust, + "oliphaunt-broker": check_broker, + "oliphaunt-node-direct": check_node_direct, + "oliphaunt-swift": check_swift, + "oliphaunt-kotlin": check_kotlin, + "oliphaunt-react-native": check_react_native, + "oliphaunt-js": check_typescript, + "oliphaunt-wasix-rust": check_wasm, +} + + +def exact_extension_products() -> set[str]: + return { + product + for product in product_metadata.product_ids() + if product_metadata.product_config(product).get("kind") == "exact-extension-artifact" + } + + +def known_consumer_products() -> set[str]: + return set(PRODUCT_CHECKS) | exact_extension_products() + + +def collect_findings(selected_products: list[str], fixture_path: Path) -> list[Finding]: + fixture = load_fixture(fixture_path) + findings: list[Finding] = [] + validate_fixture_contract(findings, fixture, selected_products) + for product in selected_products: + check = PRODUCT_CHECKS.get(product) + if check is not None: + check(findings) + continue + if product in exact_extension_products(): + check_exact_extension(findings, product) + continue + else: + add( + findings, + product, + "missing-product-check", + "Product has no consumer-shape metadata checker.", + "tools/release/check_consumer_shape.py", + severity="P0", + ) + return sorted(findings, key=lambda finding: (SEVERITY_ORDER[finding.severity], finding.product, finding.check)) + + +def apply_filters(findings: list[Finding], severities: list[str], ids: list[str]) -> list[Finding]: + severity_set = set(severities) + id_set = set(ids) + if severity_set: + findings = [finding for finding in findings if finding.severity in severity_set] + if id_set: + findings = [finding for finding in findings if finding.id in id_set] + return findings + + +def payload(selected_products: list[str], findings: list[Finding]) -> dict: + counts: dict[str, int] = {} + for finding in findings: + counts[finding.severity] = counts.get(finding.severity, 0) + 1 + return { + "schema": SCHEMA, + "products": selected_products, + "ready": len(findings) == 0, + "findingCount": len(findings), + "countsBySeverity": counts, + "findings": [ + { + "id": finding.id, + "severity": finding.severity, + "product": finding.product, + "check": finding.check, + "message": finding.message, + "evidence": list(finding.evidence), + } + for finding in findings + ], + } + + +def print_text(report: dict) -> None: + if report["ready"]: + print("consumer shape checks passed") + return + print(f"consumer shape gaps found: {report['findingCount']}") + for finding in report["findings"]: + print(f"- [{finding['severity']}] {finding['id']}: {finding['message']}") + for evidence in finding["evidence"]: + print(f" evidence: {evidence}") + + +def print_markdown(report: dict) -> None: + print("# Consumer Shape Readiness\n") + if report["ready"]: + print("No consumer-shape gaps were found.") + return + print("| Severity | Finding | Message | Evidence |") + print("| --- | --- | --- | --- |") + for finding in report["findings"]: + evidence = "
".join(str(item).replace("|", "\\|") for item in finding["evidence"]) + print( + f"| {finding['severity']} | `{finding['id']}` | " + f"{finding['message'].replace('|', '\\|')} | {evidence} |" + ) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--fixture", default=str(DEFAULT_FIXTURE)) + parser.add_argument("--products-json") + parser.add_argument("--product", action="append", default=[]) + parser.add_argument("--severity", action="append", choices=sorted(SEVERITY_ORDER)) + parser.add_argument("--id", action="append", default=[]) + parser.add_argument("--format", choices=["text", "json", "markdown"], default="text") + parser.add_argument("--require-ready", action="store_true") + args = parser.parse_args(argv) + + selected = args.product or parse_products_json(args.products_json) or sorted(known_consumer_products()) + unknown = sorted(set(selected) - known_consumer_products()) + if unknown: + fail(f"unknown consumer-shape products: {', '.join(unknown)}") + + findings = collect_findings(selected, Path(args.fixture)) + findings = apply_filters(findings, args.severity or [], args.id) + report = payload(selected, findings) + + if args.format == "json": + print(json.dumps(report, indent=2, sort_keys=True)) + elif args.format == "markdown": + print_markdown(report) + else: + print_text(report) + + if args.require_ready and findings: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_cratesio_publication.py b/tools/release/check_cratesio_publication.py new file mode 100755 index 00000000..db02c8e1 --- /dev/null +++ b/tools/release/check_cratesio_publication.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Check whether selected Cargo product crates are published on crates.io.""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +import tomllib +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import NoReturn + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +CRATES_IO_API = os.environ.get("CRATES_IO_API", "https://crates.io/api/v1") +REQUEST_ATTEMPTS = int(os.environ.get("OLIPHAUNT_REGISTRY_QUERY_ATTEMPTS", "3")) +REQUEST_RETRY_DELAY_SECONDS = float( + os.environ.get("OLIPHAUNT_REGISTRY_QUERY_RETRY_DELAY", "1.0") +) + + +def fail(message: str) -> NoReturn: + print(f"check_cratesio_publication.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def request_attempts() -> int: + return max(1, REQUEST_ATTEMPTS) + + +def sleep_before_retry(attempt: int) -> None: + if attempt + 1 < request_attempts() and REQUEST_RETRY_DELAY_SECONDS > 0: + time.sleep(REQUEST_RETRY_DELAY_SECONDS) + + +def retryable_http_error(error: urllib.error.HTTPError) -> bool: + return error.code == 429 or error.code >= 500 + + +def cargo_package_name(manifest_path: str) -> str: + path = ROOT / manifest_path + manifest = tomllib.loads(path.read_text(encoding="utf-8")) + package = manifest.get("package") + if not isinstance(package, dict): + fail(f"{manifest_path} does not define [package]") + name = package.get("name") + if not isinstance(name, str) or not name: + fail(f"{manifest_path} does not define package.name") + return name + + +def product_crates(product: str) -> list[str]: + config = product_metadata.product_config(product) + publish_targets = product_metadata.string_list(config, "publish_targets", product) + if "crates-io" not in publish_targets: + fail(f"{product} does not publish to crates.io") + crates: list[str] = [] + for version_file in product_metadata.version_files(product): + if Path(version_file).name == "Cargo.toml": + crates.append(cargo_package_name(version_file)) + if not crates: + fail(f"{product} does not declare Cargo.toml version files") + return crates + + +def query_crates(product: str) -> tuple[str, list[str], list[str], list[str]]: + version = product_metadata.read_current_version(product) + crates = product_crates(product) + missing: list[str] = [] + published: list[str] = [] + for crate in crates: + if crate_version_exists(crate, version): + published.append(crate) + else: + missing.append(crate) + return version, crates, missing, published + + +def assert_product_publication(product: str, *, require_published: bool) -> None: + version, crates, missing, published = query_crates(product) + if require_published and missing: + fail( + f"{product} tag exists but crates.io is missing version {version} for: " + + ", ".join(missing) + ) + if not require_published and published: + fail( + f"{product} version {version} is already published on crates.io for: " + + ", ".join(published) + ) + state = "published" if require_published else "unpublished" + print(f"{product} crates.io {state} check passed for {version}: {', '.join(crates)}") + + +def crate_version_exists(crate: str, version: str) -> bool: + crate_path = urllib.parse.quote(crate, safe="") + version_path = urllib.parse.quote(version, safe="") + url = f"{CRATES_IO_API.rstrip('/')}/crates/{crate_path}/{version_path}" + return cratesio_url_exists(url, f"{crate} {version}") + + +def crate_exists(crate: str) -> bool: + crate_path = urllib.parse.quote(crate, safe="") + url = f"{CRATES_IO_API.rstrip('/')}/crates/{crate_path}" + return cratesio_url_exists(url, crate) + + +def cratesio_url_exists(url: str, label: str) -> bool: + last_error: Exception | None = None + for attempt in range(request_attempts()): + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", + }, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return 200 <= response.status < 300 + except urllib.error.HTTPError as error: + if error.code == 404: + return False + if not retryable_http_error(error): + fail(f"crates.io returned HTTP {error.code} for {label}") + last_error = error + sleep_before_retry(attempt) + except urllib.error.URLError as error: + last_error = error + sleep_before_retry(attempt) + assert last_error is not None + if isinstance(last_error, urllib.error.HTTPError): + fail(f"crates.io returned HTTP {last_error.code} for {label}") + fail(f"failed to query crates.io for {label}: {last_error}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--product", required=True, help="release product id") + parser.add_argument( + "--require-published", + action="store_true", + help="fail if any Cargo crate for the product is missing from crates.io", + ) + parser.add_argument( + "--require-unpublished", + action="store_true", + help="fail if any Cargo crate for the product already exists on crates.io", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.require_published == args.require_unpublished: + fail("pass exactly one of --require-published or --require-unpublished") + + assert_product_publication( + args.product, + require_published=args.require_published, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_github_release_assets.py b/tools/release/check_github_release_assets.py new file mode 100755 index 00000000..b108c109 --- /dev/null +++ b/tools/release/check_github_release_assets.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Verify product-scoped GitHub release assets are present.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import NoReturn + +import artifact_targets +import product_metadata + + +GITHUB_API = os.environ.get("GITHUB_API", "https://api.github.com") + + +def fail(message: str) -> NoReturn: + print(f"check_github_release_assets.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def repository() -> str: + repo = os.environ.get("GITHUB_REPOSITORY") + if repo: + return repo + graph = product_metadata.load_graph() + policy = graph.get("policy") + if isinstance(policy, dict) and isinstance(policy.get("repository"), str): + return policy["repository"] + fail("GITHUB_REPOSITORY is not set and release metadata has no policy.repository") + + +def product_tag(product: str, version: str) -> str: + return f"{product_metadata.tag_prefix(product)}{version}" + + +def expected_assets(product: str, version: str) -> list[str]: + config = product_metadata.product_config(product) + if config.get("kind") == "exact-extension-artifact": + return expected_extension_assets(product) + return artifact_targets.expected_assets(product, version, surface="github-release") + + +def expected_extension_assets(product: str) -> list[str]: + manifest_path = Path("target") / "extension-artifacts" / product / "extension-artifacts.json" + if not manifest_path.is_file(): + fail( + f"{product} exact-extension release verification requires staged package manifest " + f"{manifest_path}; download the Builds workflow oliphaunt-extension-package-artifacts artifact first" + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assets = manifest.get("assets") + if not isinstance(assets, list): + fail(f"{manifest_path} must contain an assets array") + names: list[str] = [] + for index, asset in enumerate(assets): + if not isinstance(asset, dict): + fail(f"{manifest_path} assets[{index}] must be an object") + name = asset.get("name") + if not isinstance(name, str) or not name: + fail(f"{manifest_path} assets[{index}] must declare name") + names.append(name) + if not names: + fail(f"{manifest_path} does not declare any release assets") + version = product_metadata.read_current_version(product) + names.extend( + [ + f"{product}-{version}-manifest.json", + f"{product}-{version}-manifest.properties", + f"{product}-{version}-release-assets.sha256", + ] + ) + return sorted(set(names)) + + +def github_json(url: str) -> object: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "oliphaunt-release-check", + "X-GitHub-Api-Version": "2022-11-28", + } + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + fail(f"GitHub release not found for URL {url}") + fail(f"GitHub API returned HTTP {error.code} for {url}") + except urllib.error.URLError as error: + fail(f"failed to query GitHub release URL {url}: {error}") + + +def release_asset_names(repo: str, tag: str) -> list[str]: + repo_path = urllib.parse.quote(repo, safe="/") + tag_path = urllib.parse.quote(tag, safe="") + url = f"{GITHUB_API.rstrip('/')}/repos/{repo_path}/releases/tags/{tag_path}" + data = github_json(url) + if not isinstance(data, dict): + fail(f"GitHub release response for {tag} was not an object") + assets = data.get("assets") + if not isinstance(assets, list): + fail(f"GitHub release response for {tag} did not include assets") + names = [] + for asset in assets: + if isinstance(asset, dict) and isinstance(asset.get("name"), str): + names.append(asset["name"]) + return sorted(names) + + +def verify(product: str, version: str, assets: list[str]) -> None: + repo = repository() + tag = product_tag(product, version) + actual = release_asset_names(repo, tag) + missing = sorted(set(assets) - set(actual)) + if missing: + fail( + f"{product} GitHub release {tag} is missing required asset(s): " + + ", ".join(missing) + ) + print(f"{product} GitHub release assets verified for {tag}: {', '.join(assets)}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("product", help="release product id") + parser.add_argument( + "--version", + help="product version to check; defaults to the current product version", + ) + parser.add_argument( + "--asset", + action="append", + default=[], + help="required asset name; may be passed more than once", + ) + parser.add_argument( + "--default-assets", + action="store_true", + help="check the product's default release asset set", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + version = args.version or product_metadata.read_current_version(args.product) + assets = list(args.asset) + if args.default_assets: + assets.extend(expected_assets(args.product, version)) + if not assets: + fail("pass --default-assets or at least one --asset") + verify(args.product, version, sorted(set(assets))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_liboliphaunt_release_assets.py b/tools/release/check_liboliphaunt_release_assets.py new file mode 100755 index 00000000..da9a1f70 --- /dev/null +++ b/tools/release/check_liboliphaunt_release_assets.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +"""Validate liboliphaunt GitHub release assets before upload.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import sys +import tarfile +import zipfile +from pathlib import Path +from typing import NoReturn + +import artifact_targets +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] + + +def fail(message: str) -> NoReturn: + print(f"check_liboliphaunt_release_assets.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require_file(path: Path, description: str) -> None: + if not path.is_file(): + fail(f"missing {description}: {path}") + if path.stat().st_size <= 0: + fail(f"{description} is empty: {path}") + + +def parse_checksum_file(path: Path) -> dict[str, str]: + checksums: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + parts = line.split() + if len(parts) != 2: + fail(f"malformed checksum line in {path}: {line!r}") + digest, filename = parts + if not filename.startswith("./"): + fail(f"checksum path must be relative './name': {filename}") + checksums[filename[2:]] = digest + return checksums + + +def validate_checksums(asset_dir: Path, checksum_file: Path) -> None: + checksums = parse_checksum_file(checksum_file) + expected_assets = sorted( + path + for path in asset_dir.iterdir() + if path.is_file() and path.suffix != ".sha256" + ) + if not expected_assets: + fail(f"no release assets found in {asset_dir}") + for asset in expected_assets: + recorded = checksums.get(asset.name) + if recorded is None: + fail(f"checksum file does not cover release asset: {asset.name}") + actual = sha256(asset) + if recorded != actual: + fail(f"checksum mismatch for {asset.name}: expected {recorded}, got {actual}") + extra = sorted(set(checksums) - {asset.name for asset in expected_assets}) + if extra: + fail("checksum file contains entries for missing assets: " + ", ".join(extra)) + + +def generated_extension_metadata() -> dict[str, dict[str, object]]: + metadata_path = ROOT / "src/extensions/generated/sdk/rust.json" + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except OSError as error: + fail(f"read generated Rust SDK extension metadata {metadata_path}: {error}") + except json.JSONDecodeError as error: + fail(f"parse generated Rust SDK extension metadata {metadata_path}: {error}") + rows = metadata.get("extensions") + if not isinstance(rows, list): + fail(f"{metadata_path} must define an extensions array") + expected: dict[str, dict[str, object]] = {} + for index, row in enumerate(rows): + if not isinstance(row, dict): + fail(f"{metadata_path} extensions[{index}] must be an object") + sql_name = row.get("sql-name") + if not isinstance(sql_name, str) or not sql_name: + fail(f"{metadata_path} extensions[{index}] must define sql-name") + data_files = row.get("runtime-share-data-files") + if not isinstance(data_files, list) or not all(isinstance(value, str) for value in data_files): + fail(f"{metadata_path} extension {sql_name} must define runtime-share-data-files") + native_module_stem = row.get("native-module-stem") + if native_module_stem is not None and not isinstance(native_module_stem, str): + fail(f"{metadata_path} extension {sql_name} native-module-stem must be a string or null") + expected[sql_name] = { + "creates_extension": row.get("creates-extension") is True, + "data_files": data_files, + "data_files_tsv": ",".join(data_files) if data_files else "-", + "native_module_stem": native_module_stem, + } + return expected + + +def tar_member_names(path: Path) -> set[str]: + try: + with tarfile.open(path, "r:*") as archive: + names = set() + for member in archive.getmembers(): + name = member.name.removeprefix("./").rstrip("/") + if name: + names.add(name) + return names + except tarfile.TarError as error: + fail(f"{path} is not a readable tar archive: {error}") + + +def tar_text(path: Path, member_name: str) -> str: + try: + with tarfile.open(path, "r:*") as archive: + member = archive.getmember(member_name) + extracted = archive.extractfile(member) + if extracted is None: + fail(f"{path} member {member_name} is not a regular file") + return extracted.read().decode("utf-8") + except KeyError: + fail(f"{path} is missing {member_name}") + except UnicodeDecodeError as error: + fail(f"{path} member {member_name} is not UTF-8: {error}") + except tarfile.TarError as error: + fail(f"{path} is not a readable tar archive: {error}") + + +def validate_base_runtime_artifact_contents( + path: Path, + extension_metadata: dict[str, dict[str, object]], +) -> None: + names = tar_member_names(path) + runtime_prefix = "oliphaunt/runtime/files/" + for required_member in [ + "oliphaunt/package-size.tsv", + "oliphaunt/runtime/manifest.properties", + "oliphaunt/template-pgdata/manifest.properties", + ]: + if required_member not in names: + fail(f"{path} must contain {required_member}") + if f"{runtime_prefix}share/postgresql/README.release-fixture" not in names and not any( + name.startswith(runtime_prefix) for name in names + ): + fail(f"{path} must contain an oliphaunt/runtime/files tree") + for sql_name, metadata in extension_metadata.items(): + control = f"{runtime_prefix}share/postgresql/extension/{sql_name}.control" + if control in names: + fail(f"{path} base runtime must not contain optional extension control file {control}") + for data_file in metadata["data_files"]: + data_path = f"{runtime_prefix}share/postgresql/{data_file}" + if data_path in names: + fail(f"{path} base runtime must not contain optional extension data file {data_path}") + stem = metadata.get("native_module_stem") + if isinstance(stem, str) and stem: + for suffix in (".dylib", ".so", ".dll"): + module = f"{runtime_prefix}lib/postgresql/{stem}{suffix}" + if module in names: + fail(f"{path} base runtime must not contain optional extension module {module}") + + +def validate_extension_runtime_artifact_contents( + path: Path, + row: dict[str, str], + extension_metadata: dict[str, dict[str, object]], +) -> None: + sql_name = row["sql_name"] + metadata = extension_metadata[sql_name] + names = tar_member_names(path) + manifest = tar_text(path, "manifest.properties") + for expected in [ + "packageLayout=oliphaunt-extension-artifact-v1\n", + f"sqlName={sql_name}\n", + "files=files\n", + ]: + if expected not in manifest: + fail(f"{path} manifest must contain {expected.strip()!r}") + if not any(name.startswith("files/") for name in names): + fail(f"{path} must contain a files/ runtime tree") + if metadata["creates_extension"]: + control = f"files/share/postgresql/extension/{sql_name}.control" + if control not in names: + fail(f"{path} must contain selected extension control file {control}") + sql_prefix = f"files/share/postgresql/extension/{sql_name}--" + if not any(name.startswith(sql_prefix) and name.endswith(".sql") for name in names): + fail(f"{path} must contain at least one selected extension SQL file under {sql_prefix}*.sql") + stem = row["native_module_stem"] + if stem != "-": + module = f"files/lib/postgresql/{stem}.dylib" + if module not in names: + fail(f"{path} must contain selected extension native module {module}") + expected_data_files = set(metadata["data_files"]) + for data_file in sorted(expected_data_files): + data_path = f"files/share/postgresql/{data_file}" + if data_path not in names: + fail(f"{path} must contain selected extension data file {data_path}") + for other_sql_name, other_metadata in extension_metadata.items(): + if other_sql_name == sql_name: + continue + other_control = f"files/share/postgresql/extension/{other_sql_name}.control" + if other_control in names: + fail(f"{path} for {sql_name} must not contain unselected extension control file {other_control}") + other_stem = other_metadata.get("native_module_stem") + if isinstance(other_stem, str) and other_stem: + for suffix in (".dylib", ".so", ".dll"): + other_module = f"files/lib/postgresql/{other_stem}{suffix}" + if other_module in names: + fail(f"{path} for {sql_name} must not contain unselected extension module {other_module}") + for data_file in other_metadata["data_files"]: + if data_file in expected_data_files: + continue + other_data = f"files/share/postgresql/{data_file}" + if other_data in names: + fail(f"{path} for {sql_name} must not contain unselected extension data file {other_data}") + + +def validate_android_extension_artifact( + path: Path, + row: dict[str, str], + abi: str, +) -> None: + sql_name = row["sql_name"] + stem = row["native_module_stem"] + names = tar_member_names(path) + manifest = tar_text(path, "manifest.properties") + expected_archive = f"extensions/{stem}/liboliphaunt_extension_{stem}.a" + for expected in [ + "packageLayout=liboliphaunt-android-extension-artifact-v1\n", + f"abi={abi}\n", + f"sqlName={sql_name}\n", + f"nativeModuleStem={stem}\n", + f"archive={expected_archive}\n", + ]: + if expected not in manifest: + fail(f"{path} manifest must contain {expected.strip()!r}") + if expected_archive not in names: + fail(f"{path} must contain selected Android static archive {expected_archive}") + + +def validate_extension_index( + asset_dir: Path, + index_file: Path, + extension_metadata: dict[str, dict[str, object]], +) -> None: + required_columns = [ + "sql_name", + "creates_extension", + "native_module_stem", + "dependencies", + "shared_preload", + "mobile_prebuilt", + "mobile_static_archive_targets", + "runtime_artifact", + "ios_xcframework_artifact", + "android_arm64_artifact", + "android_x86_64_artifact", + "runtime_artifact_bytes", + "ios_xcframework_artifact_bytes", + "android_arm64_artifact_bytes", + "android_x86_64_artifact_bytes", + "data_files", + ] + with index_file.open("r", encoding="utf-8", newline="") as file: + reader = csv.DictReader(file, delimiter="\t") + if reader.fieldnames != required_columns: + fail(f"{index_file} has unexpected header: {reader.fieldnames}") + row_count = 0 + seen_sql_names: set[str] = set() + for row in reader: + row_count += 1 + sql_name = row["sql_name"] + if not sql_name: + fail(f"{index_file} row {row_count} has empty sql_name") + if sql_name in seen_sql_names: + fail(f"{index_file} contains duplicate sql_name {sql_name}") + seen_sql_names.add(sql_name) + runtime_artifact = row["runtime_artifact"] + if runtime_artifact == "-": + fail(f"{sql_name} must reference a runtime extension artifact") + require_file(asset_dir / runtime_artifact, f"{sql_name} runtime extension artifact") + metadata = extension_metadata.get(sql_name) + if metadata is None: + fail(f"{sql_name} is missing from generated Rust SDK extension metadata") + expected_creates_extension = "yes" if metadata["creates_extension"] else "no" + if row["creates_extension"] != expected_creates_extension: + fail( + f"{sql_name} creates_extension must match generated metadata: " + f"expected {expected_creates_extension!r}, got {row['creates_extension']!r}" + ) + expected_stem = metadata["native_module_stem"] or "-" + if row["native_module_stem"] != expected_stem: + fail( + f"{sql_name} native_module_stem must match generated metadata: " + f"expected {expected_stem!r}, got {row['native_module_stem']!r}" + ) + expected_data_files = metadata["data_files_tsv"] + if row["data_files"] != expected_data_files: + fail( + f"{sql_name} release artifact index data_files must match generated metadata: " + f"expected {expected_data_files!r}, got {row['data_files']!r}" + ) + validate_extension_runtime_artifact_contents( + asset_dir / runtime_artifact, + row, + extension_metadata, + ) + validate_recorded_bytes( + asset_dir, + runtime_artifact, + row["runtime_artifact_bytes"], + f"{sql_name} runtime extension artifact", + ) + if row["mobile_prebuilt"] == "yes" and row["native_module_stem"] != "-": + ios_artifact = row["ios_xcframework_artifact"] + android_arm64_artifact = row["android_arm64_artifact"] + android_x86_64_artifact = row["android_x86_64_artifact"] + if ios_artifact == "-" or android_arm64_artifact == "-" or android_x86_64_artifact == "-": + fail(f"{sql_name} is mobile-prebuilt but missing mobile artifact references") + require_file(asset_dir / ios_artifact, f"{sql_name} iOS extension artifact") + validate_swiftpm_xcframework_zip( + asset_dir / ios_artifact, + f"liboliphaunt_extension_{row['native_module_stem']}.xcframework", + f"{sql_name} iOS SwiftPM extension artifact", + ) + require_file(asset_dir / android_arm64_artifact, f"{sql_name} Android arm64 extension artifact") + require_file(asset_dir / android_x86_64_artifact, f"{sql_name} Android x86_64 extension artifact") + validate_android_extension_artifact( + asset_dir / android_arm64_artifact, + row, + "arm64-v8a", + ) + validate_android_extension_artifact( + asset_dir / android_x86_64_artifact, + row, + "x86_64", + ) + validate_recorded_bytes( + asset_dir, + ios_artifact, + row["ios_xcframework_artifact_bytes"], + f"{sql_name} iOS extension artifact", + ) + validate_recorded_bytes( + asset_dir, + android_arm64_artifact, + row["android_arm64_artifact_bytes"], + f"{sql_name} Android arm64 extension artifact", + ) + validate_recorded_bytes( + asset_dir, + android_x86_64_artifact, + row["android_x86_64_artifact_bytes"], + f"{sql_name} Android x86_64 extension artifact", + ) + else: + for column in [ + "ios_xcframework_artifact", + "android_arm64_artifact", + "android_x86_64_artifact", + "ios_xcframework_artifact_bytes", + "android_arm64_artifact_bytes", + "android_x86_64_artifact_bytes", + ]: + if row[column] != "-": + fail(f"{sql_name} {column} must be '-' when no mobile artifact is referenced") + if row_count == 0: + fail(f"{index_file} contains no extension rows") + + +def validate_recorded_bytes( + asset_dir: Path, + artifact: str, + recorded: str, + description: str, +) -> None: + if artifact == "-": + if recorded != "-": + fail(f"{description} byte count must be '-' when artifact is '-'") + return + try: + expected = int(recorded) + except ValueError: + fail(f"{description} byte count is not an integer: {recorded!r}") + actual = (asset_dir / artifact).stat().st_size + if expected != actual: + fail(f"{description} byte count mismatch for {artifact}: expected {expected}, got {actual}") + + +def parse_size_value(value: str, path: Path, line_number: int, field: str) -> int: + try: + parsed = int(value) + except ValueError: + fail(f"{path} line {line_number} has invalid {field}: {value!r}") + if parsed < 0: + fail(f"{path} line {line_number} has negative {field}: {value!r}") + return parsed + + +def validate_package_size_report(path: Path) -> None: + require_file(path, "liboliphaunt package-size release report") + with path.open("r", encoding="utf-8", newline="") as file: + reader = csv.DictReader(file, delimiter="\t") + expected_header = ["kind", "id", "extensions", "files", "bytes"] + if reader.fieldnames != expected_header: + fail(f"{path} has unexpected header: {reader.fieldnames}") + rows: dict[tuple[str, str], dict[str, str]] = {} + extension_rows: list[str] = [] + for line_number, row in enumerate(reader, start=2): + key = (row["kind"], row["id"]) + if key in rows: + fail(f"{path} repeats row {row['kind']}/{row['id']}") + rows[key] = row + parse_size_value(row["bytes"], path, line_number, "bytes") + if row["kind"] == "extension": + extension_rows.append(row["id"]) + parse_size_value(row["files"], path, line_number, "files") + elif row["files"] != "-": + fail(f"{path} line {line_number} package rows must use '-' for files") + + required_rows = [ + ("package", "total"), + ("package", "runtime"), + ("package", "template-pgdata"), + ("package", "static-registry"), + ("extensions", "selected"), + ] + missing = [f"{kind}/{identifier}" for kind, identifier in required_rows if (kind, identifier) not in rows] + if missing: + fail(f"{path} is missing required row(s): {', '.join(missing)}") + if rows[("extensions", "selected")]["bytes"] != "0": + fail(f"{path} base package-size report must have zero selected extension bytes") + if extension_rows: + fail( + f"{path} base package-size report must not include selected extension rows: " + + ", ".join(sorted(extension_rows)) + ) + total = parse_size_value(rows[("package", "total")]["bytes"], path, 0, "package total bytes") + parts = sum( + parse_size_value(rows[key]["bytes"], path, 0, f"{key[0]}/{key[1]} bytes") + for key in [ + ("package", "runtime"), + ("package", "template-pgdata"), + ("package", "static-registry"), + ] + ) + if total != parts: + fail(f"{path} package total bytes must equal runtime + template-pgdata + static-registry") + + +def validate_swiftpm_xcframework_zip(path: Path, expected_xcframework: str, description: str) -> None: + if path.suffix != ".zip": + fail(f"{description} must be a SwiftPM-compatible XCFramework .zip artifact: {path.name}") + try: + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + except zipfile.BadZipFile: + fail(f"{description} is not a valid zip archive: {path}") + info_plist = f"{expected_xcframework}/Info.plist" + if info_plist not in names: + fail(f"{description} must contain {info_plist}") + nested_manifests = [name for name in names if name.endswith("/manifest.properties")] + if nested_manifests: + fail( + f"{description} must contain exactly the XCFramework for SwiftPM, " + "not the generic staged extension tarball layout" + ) + + +def validate(asset_dir: Path) -> None: + version = product_metadata.read_current_version("liboliphaunt-native") + metadata = generated_extension_metadata() + required = artifact_targets.expected_assets("liboliphaunt-native", version, surface="github-release") + for filename in required: + require_file(asset_dir / filename, f"liboliphaunt release artifact {filename}") + leaked_extension_assets = sorted( + path.name + for path in asset_dir.iterdir() + if path.is_file() + and "extension" in path.name + and not path.name.endswith("-release-assets.sha256") + ) + if leaked_extension_assets: + fail( + "liboliphaunt-native release assets must not include exact-extension artifacts; " + "publish them through oliphaunt-extension-* products instead: " + + ", ".join(leaked_extension_assets) + ) + validate_base_runtime_artifact_contents( + asset_dir / f"liboliphaunt-{version}-runtime-resources.tar.gz", + metadata, + ) + validate_package_size_report(asset_dir / f"liboliphaunt-{version}-package-size.tsv") + validate_checksums(asset_dir, asset_dir / f"liboliphaunt-{version}-release-assets.sha256") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--asset-dir", + default="target/liboliphaunt/release-assets", + help="directory containing liboliphaunt release assets", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + asset_dir = (ROOT / args.asset_dir).resolve() + if not asset_dir.is_dir(): + fail(f"release asset directory does not exist: {asset_dir}") + validate(asset_dir) + print(f"liboliphaunt release assets validated: {asset_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_node_direct_release_assets.py b/tools/release/check_node_direct_release_assets.py new file mode 100644 index 00000000..53e1fab4 --- /dev/null +++ b/tools/release/check_node_direct_release_assets.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Validate local oliphaunt-node-direct GitHub release assets.""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +import tarfile +import zipfile +from pathlib import Path +from typing import NoReturn + +import artifact_targets +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] + + +def fail(message: str) -> NoReturn: + print(f"check_node_direct_release_assets.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def checksum_manifest(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for index, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + parts = line.split(maxsplit=1) + if len(parts) != 2 or len(parts[0]) != 64: + fail(f"malformed checksum line {index}: {raw_line}") + values[parts[1].removeprefix("./")] = parts[0].lower() + return values + + +def expected_assets(version: str) -> list[str]: + return artifact_targets.expected_assets("oliphaunt-node-direct", version, surface="github-release") + + +def expected_addon_assets(version: str) -> list[str]: + return artifact_targets.expected_assets( + "oliphaunt-node-direct", + version, + surface="github-release", + kinds=["node-direct-addon"], + ) + + +def addon_targets_by_asset(version: str) -> dict[str, artifact_targets.ArtifactTarget]: + return { + target.asset_name(version): target + for target in artifact_targets.artifact_targets( + product="oliphaunt-node-direct", + surface="github-release", + published_only=True, + ) + if target.kind == "node-direct-addon" + } + + +def validate_tar_archive(path: Path, member_name: str) -> None: + with tarfile.open(path, "r:gz") as archive: + names = set(archive.getnames()) + if member_name not in names: + fail(f"{path.name} is missing {member_name}") + member = archive.getmember(member_name) + if not member.isfile(): + fail(f"{path.name} {member_name} is not a regular file") + if member.size == 0: + fail(f"{path.name} {member_name} is empty") + + +def validate_zip_archive(path: Path, member_name: str) -> None: + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + if member_name not in names: + fail(f"{path.name} is missing {member_name}") + member = archive.getinfo(member_name) + if member.is_dir(): + fail(f"{path.name} {member_name} is not a regular file") + if member.file_size == 0: + fail(f"{path.name} {member_name} is empty") + + +def validate_addon_archive(path: Path, target: artifact_targets.ArtifactTarget) -> None: + member_name = target.library_relative_path + if member_name is None: + fail(f"{target.id} is missing library_relative_path") + if path.name.endswith(".tar.gz"): + validate_tar_archive(path, member_name) + elif path.suffix == ".zip": + validate_zip_archive(path, member_name) + else: + fail(f"{path.name} has unsupported Node direct archive extension") + + +def validate(asset_dir: Path, allow_partial: bool = False) -> None: + version = product_metadata.read_current_version("oliphaunt-node-direct") + required_assets = expected_assets(version) + addon_targets = addon_targets_by_asset(version) + missing = [asset for asset in required_assets if not (asset_dir / asset).is_file()] + if missing: + if not allow_partial: + fail("missing oliphaunt-node-direct release asset(s): " + ", ".join(missing)) + present_addons = [asset for asset in expected_addon_assets(version) if (asset_dir / asset).is_file()] + if not present_addons: + fail("partial oliphaunt-node-direct release asset validation requires at least one addon asset") + + checksum_asset = asset_dir / f"oliphaunt-node-direct-{version}-release-assets.sha256" + if not checksum_asset.is_file(): + fail(f"missing checksum manifest: {checksum_asset.name}") + checksums = checksum_manifest(checksum_asset) + for asset in required_assets: + if allow_partial and not (asset_dir / asset).is_file(): + continue + if asset == checksum_asset.name: + continue + expected_digest = checksums.get(asset) + if expected_digest is None: + fail(f"{checksum_asset.name} does not cover {asset}") + actual = sha256(asset_dir / asset) + if actual != expected_digest: + fail(f"checksum mismatch for {asset}: expected {expected_digest}, got {actual}") + for asset in expected_addon_assets(version): + if allow_partial and not (asset_dir / asset).is_file(): + continue + target = addon_targets.get(asset) + if target is None: + fail(f"no artifact target metadata found for {asset}") + validate_addon_archive(asset_dir / asset, target) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--asset-dir", + default=str(ROOT / "target/oliphaunt-node-direct/release-assets"), + help="directory containing oliphaunt-node-direct release assets", + ) + parser.add_argument( + "--allow-partial", + action="store_true", + help="validate the Node direct assets present in asset-dir without requiring every published target", + ) + args = parser.parse_args(argv) + validate(Path(args.asset_dir).resolve(), allow_partial=args.allow_partial) + print(f"oliphaunt-node-direct release assets validated: {Path(args.asset_dir).resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_publish_environment.py b/tools/release/check_publish_environment.py new file mode 100755 index 00000000..0607122c --- /dev/null +++ b/tools/release/check_publish_environment.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Fail fast when selected release products are missing publish credentials.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import NoReturn + +import product_metadata + +OIDC_TARGETS = {"crates-io", "npm", "jsr"} +MAVEN_TARGETS = {"maven-central"} +GITHUB_TARGETS = {"github-release", "github-release-assets", "swift-package-source-tag"} +FORBIDDEN_ENV_VARS = { + "CARGO_REGISTRY_TOKEN": ( + {"crates-io"}, + "Cargo publishing uses crates.io trusted publishing through GitHub Actions OIDC", + ), + "NPM_TOKEN": ( + {"npm"}, + "npm publishing uses trusted publishing with provenance through GitHub Actions OIDC", + ), + "NODE_AUTH_TOKEN": ( + {"npm"}, + "npm publishing uses trusted publishing with provenance through GitHub Actions OIDC", + ), + "JSR_TOKEN": ({"jsr"}, "JSR publishing uses GitHub Actions OIDC"), + "COCOAPODS_TRUNK_TOKEN": ( + set(), + "Apple SDK releases use SwiftPM plus GitHub assets, not CocoaPods trunk", + ), + "COCOAPODS_TRUNK_EMAIL": ( + set(), + "Apple SDK releases use SwiftPM plus GitHub assets, not CocoaPods trunk", + ), +} + + +def fail(message: str) -> NoReturn: + print(f"check_publish_environment.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def parse_products(raw: str) -> set[str]: + value = json.loads(raw) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail("--products-json must be a JSON string list") + products = set(value) + known = set(product_metadata.product_ids()) + unknown = sorted(products - known) + if unknown: + fail(f"unknown release products: {', '.join(unknown)}") + return products + + +def require_env(name: str, context: str, failures: list[str]) -> None: + if not os.environ.get(name): + failures.append(f"{context} requires {name}") + + +def require_any_env(names: list[str], context: str, failures: list[str]) -> None: + if not any(os.environ.get(name) for name in names): + failures.append(f"{context} requires one of {', '.join(names)}") + + +def selected_publish_targets(products: set[str]) -> set[str]: + targets: set[str] = set() + graph = product_metadata.load_graph() + for product in products: + config = product_metadata.product_config(product, graph) + targets.update(product_metadata.string_list(config, "publish_targets", product)) + return targets + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--products-json", required=True) + args = parser.parse_args(argv) + + products = parse_products(args.products_json) + publish_targets = selected_publish_targets(products) + failures: list[str] = [] + + for name, (blocked_targets, reason) in sorted(FORBIDDEN_ENV_VARS.items()): + applies_to_selection = bool(products) and ( + not blocked_targets or bool(publish_targets & blocked_targets) + ) + if applies_to_selection and os.environ.get(name): + failures.append(f"forbidden release credential {name} is set: {reason}") + + if publish_targets & OIDC_TARGETS: + require_env("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "trusted publishing", failures) + require_env("ACTIONS_ID_TOKEN_REQUEST_URL", "trusted publishing", failures) + + if publish_targets & GITHUB_TARGETS: + require_any_env(["GH_TOKEN", "GITHUB_TOKEN"], "GitHub release assets and tags", failures) + + if publish_targets & MAVEN_TARGETS: + for name in [ + "ORG_GRADLE_PROJECT_mavenCentralUsername", + "ORG_GRADLE_PROJECT_mavenCentralPassword", + "ORG_GRADLE_PROJECT_signingInMemoryKey", + "ORG_GRADLE_PROJECT_signingInMemoryKeyId", + "ORG_GRADLE_PROJECT_signingInMemoryKeyPassword", + ]: + require_env(name, "Maven Central publish", failures) + + if failures: + fail("missing publish environment:\n - " + "\n - ".join(failures)) + + print("publish environment checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_registry_publication.py b/tools/release/check_registry_publication.py new file mode 100755 index 00000000..77fe0d59 --- /dev/null +++ b/tools/release/check_registry_publication.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +"""Check selected product versions across public package registries.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import NoReturn + +import check_cratesio_publication +import product_metadata + + +NPM_REGISTRY = os.environ.get("NPM_REGISTRY", "https://registry.npmjs.org") +JSR_REGISTRY = os.environ.get("JSR_REGISTRY", "https://jsr.io") +MAVEN_CENTRAL_BASE = os.environ.get( + "MAVEN_CENTRAL_BASE", + "https://repo1.maven.org/maven2", +) +REQUEST_ATTEMPTS = int(os.environ.get("OLIPHAUNT_REGISTRY_QUERY_ATTEMPTS", "3")) +REQUEST_RETRY_DELAY_SECONDS = float( + os.environ.get("OLIPHAUNT_REGISTRY_QUERY_RETRY_DELAY", "1.0") +) +REGISTRY_TARGETS = { + "crates-io", + "npm", + "jsr", + "maven-central", +} + + +@dataclass(frozen=True) +class RegistryPackage: + kind: str + name: str + version: str + + @property + def label(self) -> str: + return f"{self.kind}:{self.name}@{self.version}" + + +def fail(message: str) -> NoReturn: + print(f"check_registry_publication.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def request_attempts() -> int: + return max(1, REQUEST_ATTEMPTS) + + +def sleep_before_retry(attempt: int) -> None: + if attempt + 1 < request_attempts() and REQUEST_RETRY_DELAY_SECONDS > 0: + time.sleep(REQUEST_RETRY_DELAY_SECONDS) + + +def retryable_http_error(error: urllib.error.HTTPError) -> bool: + return error.code == 429 or error.code >= 500 + + +def request_json(url: str) -> object: + last_error: Exception | None = None + for attempt in range(request_attempts()): + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", + }, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if not retryable_http_error(error): + raise + last_error = error + sleep_before_retry(attempt) + except urllib.error.URLError as error: + last_error = error + sleep_before_retry(attempt) + assert last_error is not None + raise last_error + + +def url_exists(url: str) -> bool: + last_error: Exception | None = None + for attempt in range(request_attempts()): + request = urllib.request.Request( + url, + method="HEAD", + headers={ + "Accept": "application/json", + "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", + }, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return 200 <= response.status < 300 + except urllib.error.HTTPError as error: + if error.code == 404: + return False + if error.code == 405: + return url_exists_via_get(url) + if not retryable_http_error(error): + fail(f"registry returned HTTP {error.code} for {url}") + last_error = error + sleep_before_retry(attempt) + except urllib.error.URLError as error: + last_error = error + sleep_before_retry(attempt) + assert last_error is not None + if isinstance(last_error, urllib.error.HTTPError): + fail(f"registry returned HTTP {last_error.code} for {url}") + fail(f"failed to query registry URL {url}: {last_error}") + + +def url_exists_via_get(url: str) -> bool: + last_error: Exception | None = None + for attempt in range(request_attempts()): + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "oliphaunt-release-check (https://github.com/f0rr0/oliphaunt)", + }, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return 200 <= response.status < 300 + except urllib.error.HTTPError as error: + if error.code == 404: + return False + if not retryable_http_error(error): + fail(f"registry returned HTTP {error.code} for {url}") + last_error = error + sleep_before_retry(attempt) + except urllib.error.URLError as error: + last_error = error + sleep_before_retry(attempt) + assert last_error is not None + if isinstance(last_error, urllib.error.HTTPError): + fail(f"registry returned HTTP {last_error.code} for {url}") + fail(f"failed to query registry URL {url}: {last_error}") + + +def npm_version_exists(package: str, version: str) -> bool: + package_path = urllib.parse.quote(package, safe="") + url = f"{NPM_REGISTRY.rstrip('/')}/{package_path}" + try: + data = request_json(url) + except urllib.error.HTTPError as error: + if error.code == 404: + return False + fail(f"npm registry returned HTTP {error.code} for {package}") + except urllib.error.URLError as error: + fail(f"failed to query npm registry for {package}: {error}") + if not isinstance(data, dict): + fail(f"npm registry returned malformed metadata for {package}") + versions = data.get("versions") + if not isinstance(versions, dict): + return False + return version in versions + + +def npm_package_exists(package: str) -> bool: + package_path = urllib.parse.quote(package, safe="") + url = f"{NPM_REGISTRY.rstrip('/')}/{package_path}" + try: + data = request_json(url) + except urllib.error.HTTPError as error: + if error.code == 404: + return False + fail(f"npm registry returned HTTP {error.code} for {package}") + except urllib.error.URLError as error: + fail(f"failed to query npm registry for {package}: {error}") + return isinstance(data, dict) + + +def maven_version_exists(coordinate: str, version: str) -> bool: + parts = coordinate.split(":") + if len(parts) != 2 or not all(parts): + fail(f"invalid Maven coordinate {coordinate!r}; expected group:artifact") + group, artifact = parts + group_path = "/".join(urllib.parse.quote(part, safe="") for part in group.split(".")) + artifact_path = urllib.parse.quote(artifact, safe="") + version_path = urllib.parse.quote(version, safe="") + url = ( + f"{MAVEN_CENTRAL_BASE.rstrip('/')}/{group_path}/{artifact_path}/" + f"{version_path}/{artifact_path}-{version_path}.pom" + ) + return url_exists(url) + + +def maven_coordinate_exists(coordinate: str) -> bool: + parts = coordinate.split(":") + if len(parts) != 2 or not all(parts): + fail(f"invalid Maven coordinate {coordinate!r}; expected group:artifact") + group, artifact = parts + group_path = "/".join(urllib.parse.quote(part, safe="") for part in group.split(".")) + artifact_path = urllib.parse.quote(artifact, safe="") + metadata_url = ( + f"{MAVEN_CENTRAL_BASE.rstrip('/')}/{group_path}/{artifact_path}/maven-metadata.xml" + ) + return url_exists(metadata_url) + + +def jsr_version_exists(package: str, version: str) -> bool: + if not package.startswith("@") or "/" not in package: + fail(f"invalid JSR package {package!r}; expected @scope/name") + scope, name = package[1:].split("/", 1) + scope_path = urllib.parse.quote(scope, safe="") + name_path = urllib.parse.quote(name, safe="") + url = f"{JSR_REGISTRY.rstrip('/')}/@{scope_path}/{name_path}/meta.json" + try: + data = request_json(url) + except urllib.error.HTTPError as error: + if error.code == 404: + return False + fail(f"JSR registry returned HTTP {error.code} for {package}") + except urllib.error.URLError as error: + fail(f"failed to query JSR registry for {package}: {error}") + if not isinstance(data, dict): + fail(f"JSR registry returned malformed metadata for {package}") + versions = data.get("versions") + if not isinstance(versions, dict): + return False + return version in versions + + +def jsr_package_exists(package: str) -> bool: + if not package.startswith("@") or "/" not in package: + fail(f"invalid JSR package {package!r}; expected @scope/name") + scope, name = package[1:].split("/", 1) + scope_path = urllib.parse.quote(scope, safe="") + name_path = urllib.parse.quote(name, safe="") + url = f"{JSR_REGISTRY.rstrip('/')}/@{scope_path}/{name_path}/meta.json" + try: + data = request_json(url) + except urllib.error.HTTPError as error: + if error.code == 404: + return False + fail(f"JSR registry returned HTTP {error.code} for {package}") + except urllib.error.URLError as error: + fail(f"failed to query JSR registry for {package}: {error}") + return isinstance(data, dict) + + +def package_exists(package: RegistryPackage) -> bool: + if package.kind == "crates": + return check_cratesio_publication.crate_version_exists(package.name, package.version) + if package.kind == "npm": + return npm_version_exists(package.name, package.version) + if package.kind == "jsr": + return jsr_version_exists(package.name, package.version) + if package.kind == "maven": + return maven_version_exists(package.name, package.version) + fail(f"unsupported registry package kind {package.kind!r}") + + +def package_identity_exists(package: RegistryPackage) -> bool: + if package.kind == "crates": + return check_cratesio_publication.crate_exists(package.name) + if package.kind == "npm": + return npm_package_exists(package.name) + if package.kind == "jsr": + return jsr_package_exists(package.name) + if package.kind == "maven": + return maven_coordinate_exists(package.name) + fail(f"unsupported registry package kind {package.kind!r}") + + +def parse_registry_package(raw: str, product: str, version: str) -> RegistryPackage: + kind, separator, name = raw.partition(":") + if separator != ":" or not kind or not name: + fail(f"{product}.registry_packages entry {raw!r} must use kind:name") + if kind not in {"crates", "npm", "jsr", "maven"}: + fail(f"{product}.registry_packages entry {raw!r} has unsupported kind {kind!r}") + return RegistryPackage(kind=kind, name=name, version=version) + + +def graph_registry_packages( + product: str, + graph: dict | None = None, + *, + version_override: str | None = None, +) -> list[RegistryPackage]: + data = graph if graph is not None else product_metadata.load_graph() + config = product_metadata.product_config(product, data) + version = version_override or product_metadata.read_current_version(product) + raw_packages = product_metadata.string_list(config, "registry_packages", product) + return [ + parse_registry_package(raw_package, product, version) + for raw_package in raw_packages + ] + + +def derived_crates_packages(product: str) -> list[RegistryPackage]: + version, crates, _, _ = check_cratesio_publication.query_crates(product) + return [ + RegistryPackage(kind="crates", name=crate, version=version) + for crate in crates + ] + + +def product_registry_packages( + product: str, + graph: dict | None = None, + *, + version_override: str | None = None, + registry_kind: str | None = None, +) -> list[RegistryPackage]: + data = graph if graph is not None else product_metadata.load_graph() + config = product_metadata.product_config(product, data) + publish_targets = set(product_metadata.string_list(config, "publish_targets", product)) + graph_packages = graph_registry_packages(product, data, version_override=version_override) + packages = list(graph_packages) + if "crates-io" in publish_targets: + derived_crates = derived_crates_packages(product) + if version_override is not None: + derived_crates = [ + RegistryPackage(kind=package.kind, name=package.name, version=version_override) + for package in derived_crates + ] + graph_crates = [package for package in packages if package.kind == "crates"] + if graph_crates: + derived_names = sorted(package.name for package in derived_crates) + graph_names = sorted(package.name for package in graph_crates) + if graph_names != derived_names: + fail( + f"{product}.registry_packages crates entries {graph_names} " + f"do not match Cargo manifests {derived_names}" + ) + else: + packages.extend(derived_crates) + missing_kinds = [] + expected_kinds = { + "npm": "npm", + "jsr": "jsr", + "maven-central": "maven", + } + for target, kind in expected_kinds.items(): + if target in publish_targets and not any(package.kind == kind for package in packages): + missing_kinds.append(kind) + if missing_kinds: + fail( + f"{product} publishes to {sorted(publish_targets & REGISTRY_TARGETS)} " + f"but is missing registry_packages entries for: {', '.join(missing_kinds)}" + ) + if registry_kind is not None: + packages = [package for package in packages if package.kind == registry_kind] + if not packages: + fail(f"{product} has no {registry_kind} registry packages to check") + return packages + + +def query_product_publication( + product: str, + *, + version_override: str | None = None, + registry_kind: str | None = None, + retries: int = 0, + retry_delay: float = 0.0, +) -> tuple[list[RegistryPackage], list[RegistryPackage], list[RegistryPackage]]: + packages = product_registry_packages( + product, + version_override=version_override, + registry_kind=registry_kind, + ) + if not packages: + return [], [], [] + + attempts = max(1, retries + 1) + last_missing: list[RegistryPackage] = [] + last_published: list[RegistryPackage] = [] + for attempt in range(attempts): + missing: list[RegistryPackage] = [] + published: list[RegistryPackage] = [] + for package in packages: + if package_exists(package): + published.append(package) + else: + missing.append(package) + last_missing = missing + last_published = published + if not missing or attempt == attempts - 1: + break + if retry_delay > 0: + time.sleep(retry_delay) + return packages, last_missing, last_published + + +def assert_product_publication( + product: str, + *, + require_published: bool, + version_override: str | None = None, + registry_kind: str | None = None, + retries: int = 0, + retry_delay: float = 0.0, +) -> None: + packages, missing, published = query_product_publication( + product, + version_override=version_override, + registry_kind=registry_kind, + retries=retries, + retry_delay=retry_delay, + ) + if not packages: + print(f"{product} has no external registry packages to check") + return + if require_published and missing: + fail( + f"{product} registry publication is missing: " + + ", ".join(package.label for package in missing) + ) + if not require_published and published: + fail( + f"{product} version is already published in public registries: " + + ", ".join(package.label for package in published) + ) + state = "published" if require_published else "unpublished" + print( + f"{product} registry {state} check passed: " + + ", ".join(package.label for package in packages) + ) + + +def report_product_publication( + product: str, + *, + version_override: str | None = None, + registry_kind: str | None = None, +) -> None: + packages, missing, published = query_product_publication( + product, + version_override=version_override, + registry_kind=registry_kind, + ) + if not packages: + print(f"{product} has no external registry packages to check") + return + if published: + print( + f"{product} registry versions already present: " + + ", ".join(package.label for package in published) + ) + if missing: + print( + f"{product} registry versions not yet present: " + + ", ".join(package.label for package in missing) + ) + + +def product_identity_status( + product: str, + *, + registry_kind: str | None = None, +) -> tuple[list[RegistryPackage], list[RegistryPackage], list[RegistryPackage]]: + packages = product_registry_packages(product, registry_kind=registry_kind) + present: list[RegistryPackage] = [] + missing: list[RegistryPackage] = [] + for package in packages: + if package_identity_exists(package): + present.append(package) + else: + missing.append(package) + return packages, present, missing + + +def assert_product_identities( + product: str, + *, + registry_kind: str | None = None, +) -> None: + packages, _, missing = product_identity_status(product, registry_kind=registry_kind) + if not packages: + print(f"{product} has no external registry package identities to check") + return + if missing: + fail( + f"{product} registry package identities are missing: " + + ", ".join(f"{package.kind}:{package.name}" for package in missing) + ) + print( + f"{product} registry identity check passed: " + + ", ".join(f"{package.kind}:{package.name}" for package in packages) + ) + + +def report_product_identities( + product: str, + *, + registry_kind: str | None = None, +) -> None: + packages, present, missing = product_identity_status(product, registry_kind=registry_kind) + if not packages: + print(f"{product} has no external registry package identities to check") + return + if present: + print( + f"{product} registry identities present: " + + ", ".join(f"{package.kind}:{package.name}" for package in present) + ) + if missing: + print( + f"{product} registry identities missing: " + + ", ".join(f"{package.kind}:{package.name}" for package in missing) + ) + + +def parse_products(raw: str | None, product: str | None) -> list[str]: + if bool(raw) == bool(product): + fail("pass exactly one of --product or --products-json") + if product: + return [product] + value = json.loads(raw or "") + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail("--products-json must be a JSON string list") + known = set(product_metadata.product_ids()) + unknown = sorted(set(value) - known) + if unknown: + fail(f"unknown release products: {', '.join(unknown)}") + return value + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--product", help="single release product id") + parser.add_argument("--products-json", help="JSON list of release product ids") + parser.add_argument( + "--version", + help="override the product version to check; valid only with --product", + ) + parser.add_argument( + "--registry-kind", + choices=["crates", "npm", "jsr", "maven"], + help="restrict checks to one registry package kind for the selected product", + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--require-published", action="store_true") + mode.add_argument("--require-unpublished", action="store_true") + mode.add_argument("--report", action="store_true") + mode.add_argument("--require-identities", action="store_true") + mode.add_argument("--report-identities", action="store_true") + parser.add_argument( + "--retries", + type=int, + default=0, + help="additional registry query attempts before failing", + ) + parser.add_argument( + "--retry-delay", + type=float, + default=0.0, + help="seconds to sleep between retry attempts", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.version and not args.product: + fail("--version can only be used with --product") + products = parse_products(args.products_json, args.product) + if args.retries < 0: + fail("--retries must be non-negative") + if args.retry_delay < 0: + fail("--retry-delay must be non-negative") + if args.require_identities: + missing_messages: list[str] = [] + for product in products: + packages, _, missing = product_identity_status(product, registry_kind=args.registry_kind) + if not packages: + print(f"{product} has no external registry package identities to check") + continue + if missing: + missing_messages.append( + f"{product}: " + + ", ".join(f"{package.kind}:{package.name}" for package in missing) + ) + else: + print( + f"{product} registry identity check passed: " + + ", ".join(f"{package.kind}:{package.name}" for package in packages) + ) + if missing_messages: + fail("registry package identities are missing:\n - " + "\n - ".join(missing_messages)) + return 0 + + for product in products: + if args.report_identities: + report_product_identities(product, registry_kind=args.registry_kind) + elif args.report: + report_product_publication( + product, + version_override=args.version, + registry_kind=args.registry_kind, + ) + else: + assert_product_publication( + product, + require_published=args.require_published, + version_override=args.version, + registry_kind=args.registry_kind, + retries=args.retries, + retry_delay=args.retry_delay, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_release_metadata.py b/tools/release/check_release_metadata.py new file mode 100755 index 00000000..e0a4eb8c --- /dev/null +++ b/tools/release/check_release_metadata.py @@ -0,0 +1,788 @@ +#!/usr/bin/env python3 +"""Validate release-owned version metadata and derived registry manifests.""" + +from __future__ import annotations + +import json +import re +import sys +import tomllib +from pathlib import Path +from typing import NoReturn + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] + + +def fail(message: str) -> NoReturn: + print(f"check_release_metadata.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def read_text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require_text(path: str, needle: str, message: str) -> None: + if needle not in read_text(path): + fail(message) + + +def reject_text(path: str, needle: str, message: str) -> None: + if needle in read_text(path): + fail(message) + + +def validate_no_consumer_install_scripts(package: dict, label: str) -> None: + scripts = package.get("scripts", {}) + if not isinstance(scripts, dict): + return + for script_name in ["preinstall", "install", "postinstall", "prepare"]: + if script_name in scripts: + fail(f"{label} package must not run {script_name} during consumer installs") + for script_name, command in scripts.items(): + if not isinstance(command, str): + continue + if re.search(r"\b(cargo|rustup)\b", command): + fail(f"{label} package script {script_name!r} must not require Rust tooling") + + +def load_graph() -> dict: + return product_metadata.load_graph() + + +def stable_version(version: str, product: str) -> None: + if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+", version): + fail(f"{product} must use a stable x.y.z release version, got {version!r}") + + +def cargo_manifest_version(path: str) -> str: + manifest = tomllib.loads(read_text(path)) + package = manifest.get("package") + if not isinstance(package, dict) or not isinstance(package.get("version"), str): + fail(f"{path} must declare [package].version") + return package["version"] + + +def cargo_manifest_name(path: str) -> str: + manifest = tomllib.loads(read_text(path)) + package = manifest.get("package") + if not isinstance(package, dict) or not isinstance(package.get("name"), str): + fail(f"{path} must declare [package].name") + return package["name"] + + +def gradle_property(path: str, name: str) -> str: + for raw_line in read_text(path).splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if key.strip() == name: + return value.strip() + fail(f"{path} must declare {name}") + + +def validate_graph_files(graph: dict) -> None: + products = product_metadata.graph_products(graph) + for product in products: + for path in [ + *product_metadata.version_files(product, graph), + *product_metadata.derived_version_files(product, graph), + ]: + if not (ROOT / path).is_file(): + fail(f"{product} release metadata path does not exist: {path}") + + +def validate_release_setup_docs() -> None: + setup = read_text("docs/maintainers/release-setup.md") + normalized_setup = re.sub(r"\s+", " ", setup) + required_fragments = [ + "Rust/Tauri: `cargo add oliphaunt`", + "iOS/macOS Swift: Xcode or SwiftPM", + 'Android/Kotlin: Maven Central plus `id("dev.oliphaunt.android")`', + "React Native/Expo: `pnpm add @oliphaunt/react-native` plus the Expo config", + "TypeScript/Node/Bun: `pnpm add @oliphaunt/ts`", + "TypeScript/Deno: `deno add jsr:@oliphaunt/ts`", + "Normal app consumers must not install Rust, run Cargo, build PostgreSQL", + "Do not set up CocoaPods trunk credentials", + "CocoaPods trunk is scheduled to become read-only on December 2, 2026", + "JSR's GitHub Actions OIDC publishing path", + "MAVEN_CENTRAL_USERNAME", + "SwiftPM plus GitHub release assets", + "oliphaunt-broker", + "consumer-shape --require-ready --products-json ''", + "check-registries --products-json '' --head-ref HEAD --require-identities", + "For the first public release, select every product", + "manually bootstrap any first Cargo crates", + "Manual registry bootstrap is a release-completion state", + "create and push the matching product tag at the same release commit", + "TypeScript broker mode needs the matching `oliphaunt-broker` runtime", + ] + for fragment in required_fragments: + normalized_fragment = re.sub(r"\s+", " ", fragment) + if normalized_fragment not in normalized_setup: + fail(f"release setup guide is missing {fragment!r}") + + before_publish_section = setup.split("Run these from GitHub Actions", 1)[0] + normalized_before_publish_section = re.sub(r"\s+", " ", before_publish_section) + if "Consumer shape is strict" not in normalized_before_publish_section: + fail("release setup guide must explain that strict consumer shape is a tracked package-shape gate") + if setup.count("Sonatype Central Portal token setup:") != 1: + fail("release setup guide must contain exactly one Sonatype token setup reference") + + +def validate_rust() -> None: + require_text( + "src/sdks/rust/tools/check-sdk.sh", + "--resolve-release-assets", + "Rust SDK package check must exercise release-shaped liboliphaunt asset resolution", + ) + require_text( + "src/sdks/rust/tools/check-sdk.sh", + "create-liboliphaunt-release-fixture.py", + "Rust SDK package check must use deterministic release-shaped liboliphaunt asset fixtures", + ) + require_text( + "src/sdks/rust/tools/check-sdk.sh", + "--resolve-broker-release-assets", + "Rust SDK package check must exercise release-shaped broker helper asset resolution", + ) + require_text( + "src/sdks/rust/tools/check-sdk.sh", + "create-broker-release-fixture.py", + "Rust SDK package check must use deterministic release-shaped broker asset fixtures", + ) + require_text( + "src/sdks/rust/src/bin/package_resources.rs", + "--resolve-broker-release-assets", + "Rust SDK resource resolver must expose broker helper release asset resolution", + ) + require_text( + "src/sdks/rust/README.md", + "OLIPHAUNT_BROKER_ASSET_DIR", + "Rust SDK README must document how packaged broker-mode apps find the broker helper", + ) + require_text( + "src/sdks/rust/src/bin/package_resources.rs", + '"linux-x64-gnu" => assets.push(format!("liboliphaunt-{version}-linux-x64-gnu.tar.gz"))', + "Rust SDK release asset resolver must support Linux x64 liboliphaunt assets", + ) + require_text( + "src/sdks/rust/src/bin/package_resources.rs", + '"linux-arm64-gnu" =>', + "Rust SDK release asset resolver must support Linux arm64 liboliphaunt assets", + ) + require_text( + "src/sdks/rust/src/bin/package_resources.rs", + '"windows-x64-msvc" =>', + "Rust SDK release asset resolver must support Windows x64 liboliphaunt assets", + ) + + +def validate_broker() -> None: + require_text( + "tools/release/package-broker-assets.sh", + "oliphaunt-broker-${version}-${target_id}.${asset_extension}", + "Broker runtime release must package platform-scoped oliphaunt-broker helper assets", + ) + require_text( + "tools/release/package-broker-assets.sh", + 'target_id="windows-x64-msvc"', + "Broker runtime release must package the Windows broker helper target", + ) + require_text( + "tools/release/package-broker-assets.sh", + "oliphaunt-broker-${version}-release-assets.sha256", + "Broker runtime release must publish a checksum manifest for broker helper assets", + ) + require_text( + "tools/release/check_broker_release_assets.py", + "executable_relative_path", + "Broker runtime release asset checker must verify the metadata-declared helper executable", + ) + + +def validate_swift(swift_version: str, liboliphaunt_version: str) -> None: + if read_text("src/sdks/swift/VERSION").strip() != swift_version: + fail("Swift VERSION must match oliphaunt-swift product version") + if read_text("src/sdks/swift/LIBOLIPHAUNT_VERSION").strip() != liboliphaunt_version: + fail("Swift LIBOLIPHAUNT_VERSION must match the current liboliphaunt product version") + require_text( + "Package.swift", + 'path: "src/sdks/swift/Sources/Oliphaunt"', + "root SwiftPM package must expose the Apple SDK from the monorepo root", + ) + require_text( + "Package.swift", + 'path: "src/sdks/swift/Sources/COliphaunt"', + "root SwiftPM package must expose the C bridge target from the monorepo root", + ) + require_text( + "tools/release/render_swiftpm_release_package.py", + "binaryTarget(", + "SwiftPM release manifest renderer must emit a binary liboliphaunt target", + ) + require_text( + "tools/release/render_swiftpm_release_package.py", + "liboliphaunt-native-v", + "SwiftPM release manifest renderer must use liboliphaunt GitHub release assets", + ) + require_text( + "src/sdks/swift/tools/check-sdk.sh", + "render_swiftpm_release_package.py", + "Swift SDK package check must render the public SwiftPM release manifest from release-shaped assets", + ) + require_text( + "src/sdks/swift/tools/check-sdk.sh", + "OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR", + "Swift SDK package check must consume real liboliphaunt release assets when CI provides them", + ) + require_text( + "src/sdks/swift/tools/check-sdk.sh", + "liboliphaunt-$liboliphaunt_version-apple-spm-xcframework.zip", + "Swift SDK package check must require the real Apple SwiftPM XCFramework release asset", + ) + require_text( + "src/sdks/swift/tools/check-sdk.sh", + "Swift package-shape requires OLIPHAUNT_SWIFT_RELEASE_ASSET_DIR", + "Swift SDK package check must fail closed instead of fabricating local release assets", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + "render_swiftpm_release_package.py", + "Swift SDK package artifact builder must render the staged public SwiftPM release manifest", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + '"$artifact_root/Package.swift.release"', + "Swift SDK package artifact builder must stage Package.swift.release as a release artifact", + ) + require_text( + "tools/release/build-sdk-ci-artifacts.sh", + "staged SwiftPM release manifest must not contain local file URLs", + "Swift SDK package artifact builder must reject local file URLs in release artifacts", + ) + reject_text( + "tools/release/build-sdk-ci-artifacts.sh", + 'cp "$work_root/check/package-shape/Package.swift.release"', + "Swift SDK package artifact builder must not stage the local validation manifest", + ) + require_text( + "tools/release/render_swiftpm_release_package.py", + "base Swift package must not require or publish extension files", + "SwiftPM release manifest renderer must keep exact extensions out of the base package", + ) + renderer = read_text("tools/release/render_swiftpm_release_package.py") + for forbidden in ("extension_rows", "dependency_closure", "OliphauntExtension"): + if forbidden in renderer: + fail(f"SwiftPM release manifest renderer must not synthesize base-package extension products: {forbidden}") + require_text( + "tools/release/publish_swiftpm_source_tag.py", + "commit-tree", + "SwiftPM source-tag publisher must create a release-only manifest commit", + ) + require_text( + "tools/release/publish_swiftpm_source_tag.py", + "--include-tree", + "SwiftPM source-tag publisher must be able to include generated release-tree files", + ) + require_text( + "tools/release/release.py", + "staged_swift_release_artifacts", + "release CLI must validate staged Swift source and SwiftPM manifest artifacts before dry-run or tagging", + ) + require_text( + "tools/release/release.py", + "Oliphaunt-source.zip", + "release CLI must require the staged Swift source archive", + ) + require_text( + "tools/release/release.py", + "Package.swift.release", + "release CLI must require the staged SwiftPM release manifest", + ) + require_text( + "tools/release/release.py", + "apple-spm-xcframework.zip", + "release CLI must validate that the staged SwiftPM manifest points at the Apple liboliphaunt binary artifact", + ) + require_text( + "tools/release/release.py", + "--manifest", + "release CLI must pass a SwiftPM manifest to the source-tag publisher", + ) + require_text( + "tools/release/release.py", + "--include-tree", + "release CLI must pass the SwiftPM release-tree root to the source-tag publisher", + ) + require_text( + "tools/release/release.py", + 'output_manifest = output_dir / "Package.swift.release"', + "release CLI must stage the SwiftPM binary manifest before tagging", + ) + require_text( + "src/sdks/swift/README.md", + "Normal iOS and macOS app consumers do not install Rust", + "Swift SDK README must make the no-Rust consumer install path explicit", + ) + require_text( + "src/sdks/swift/README.md", + "oliphaunt-extension-vector", + "Swift SDK README must describe exact-extension artifacts by release product, not hidden SwiftPM products", + ) + swift_readme = read_text("src/sdks/swift/README.md") + allowed_extension_api_symbols = { + "OliphauntExtensionArtifactResolution", + "OliphauntExtensionArtifactResolver", + "OliphauntExtensionReleaseAsset", + "OliphauntExtensionReleaseManifest", + "OliphauntExtensionSizeReport", + } + for symbol in re.findall(r"\bOliphauntExtension[A-Z][A-Za-z0-9]*\b", swift_readme): + if symbol not in allowed_extension_api_symbols: + fail( + "Swift SDK README must not advertise generated OliphauntExtension* " + f"products until they exist: {symbol}" + ) + for retired_podspec in [ + ROOT / "src/sdks/swift/COliphaunt.podspec", + ROOT / "src/sdks/swift/Oliphaunt.podspec", + ]: + if retired_podspec.exists(): + fail( + f"standalone Swift SDK must stay SwiftPM-only; remove {retired_podspec.relative_to(ROOT)}" + ) + + +def validate_kotlin(kotlin_version: str, liboliphaunt_version: str) -> None: + actual = gradle_property("src/sdks/kotlin/gradle.properties", "VERSION_NAME") + if actual != kotlin_version: + fail("Kotlin VERSION_NAME must match oliphaunt-kotlin product version") + plugin_liboliphaunt_version = read_text( + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/liboliphaunt.version" + ).strip() + if plugin_liboliphaunt_version != liboliphaunt_version: + fail("Kotlin Android Gradle plugin embedded liboliphaunt version must match liboliphaunt product version") + require_text( + "src/sdks/kotlin/oliphaunt/build.gradle.kts", + 'version = providers.gradleProperty("VERSION_NAME")', + "Kotlin publication must derive project.version from VERSION_NAME", + ) + require_text( + "src/sdks/kotlin/oliphaunt/build.gradle.kts", + 'group = providers.gradleProperty("GROUP")', + "Kotlin publication must derive group from gradle.properties", + ) + require_text( + "src/sdks/kotlin/oliphaunt-android-gradle-plugin/build.gradle.kts", + 'id = "dev.oliphaunt.android"', + "Kotlin release must publish the app-applied Android Gradle plugin marker", + ) + require_text( + "src/sdks/kotlin/README.md", + "Normal Android app consumers use Gradle, Maven Central", + "Kotlin README must make the no-Rust consumer install path explicit", + ) + require_text( + "src/sdks/kotlin/tools/check-sdk.sh", + "resolveOliphauntAndroidReleaseAssets", + "Kotlin SDK package check must exercise the Android release asset resolver", + ) + require_text( + "src/sdks/kotlin/tools/check-sdk.sh", + "create-liboliphaunt-release-fixture.py", + "Kotlin SDK package check must use deterministic release-shaped liboliphaunt asset fixtures", + ) + + +def validate_react_native(rn_version: str, swift_version: str, kotlin_version: str) -> None: + package = json.loads(read_text("src/sdks/react-native/package.json")) + validate_no_consumer_install_scripts(package, "React Native") + if package.get("version") != rn_version: + fail("React Native package.json version must match oliphaunt-react-native product version") + metadata = package.get("oliphaunt") + if not isinstance(metadata, dict): + fail("React Native package.json must include oliphaunt compatibility metadata") + if metadata.get("swiftSdkVersion") != swift_version: + fail("React Native package.json swiftSdkVersion must match current Swift SDK version") + if metadata.get("kotlinSdkVersion") != kotlin_version: + fail("React Native package.json kotlinSdkVersion must match current Kotlin SDK version") + require_text( + "src/sdks/react-native/OliphauntReactNative.podspec", + 'package["version"]', + "React Native podspec must derive its version from package.json", + ) + require_text( + "src/sdks/react-native/OliphauntReactNative.podspec", + 'package.fetch("oliphaunt", {}).fetch("swiftSdkVersion", package["version"])', + "React Native podspec must derive its Swift SDK dependency from package metadata", + ) + require_text( + "src/sdks/react-native/OliphauntReactNative.podspec", + 's.dependency "Oliphaunt", native_sdk_version', + "React Native podspec must depend on the compatible Swift SDK version", + ) + require_text( + "src/sdks/react-native/android/settings.gradle", + "if (configuredKotlinSdkDir != null && !configuredKotlinSdkDir.isBlank())", + "React Native Android local Kotlin SDK composite builds must be explicit development overrides", + ) + require_text( + "src/sdks/react-native/android/build.gradle", + '?: "dev.oliphaunt:oliphaunt:${kotlinSdkVersion}"', + "React Native Android package must default to the published Kotlin SDK Maven coordinate", + ) + require_text( + "src/sdks/react-native/tools/check-sdk.sh", + "local Kotlin SDK composite builds must be explicit development overrides", + "React Native package check must guard the release-shaped Kotlin dependency boundary", + ) + require_text( + "src/sdks/react-native/app.plugin.js", + "ios/podspecs", + "React Native Expo config plugin must resolve Swift SDK pods through npm-shipped podspec shims", + ) + require_text( + "src/sdks/react-native/app.plugin.js", + "pod 'COliphaunt', :podspec => File.join(oliphaunt_podspecs_path, 'COliphaunt.podspec')", + "React Native Expo config plugin must inject the C bridge podspec shim", + ) + require_text( + "src/sdks/react-native/ios/podspecs/COliphaunt.podspec", + "src/sdks/swift/Sources/COliphaunt", + "React Native C podspec shim must point CocoaPods at the released Swift SDK C bridge source", + ) + require_text( + "src/sdks/react-native/ios/podspecs/COliphaunt.podspec", + 's.module_map = "src/sdks/swift/Sources/COliphaunt/include/module.modulemap"', + "React Native C podspec shim must expose the COliphaunt module map", + ) + require_text( + "src/sdks/react-native/ios/podspecs/Oliphaunt.podspec", + "src/sdks/swift/Sources/Oliphaunt/**/*.swift", + "React Native Swift podspec shim must point CocoaPods at the released Swift SDK source", + ) + require_text( + "src/sdks/react-native/ios/podspecs/Oliphaunt.podspec", + 's.dependency "COliphaunt", swift_sdk_version', + "React Native Swift podspec shim must depend on the exact C bridge version", + ) + reject_text( + "src/sdks/react-native/package.json", + "prepare-apple-vendor", + "React Native package must not generate a vendored Swift SDK source slice before publishing", + ) + repository = package.get("repository") + if not isinstance(repository, dict): + fail("React Native package must declare repository metadata") + if repository.get("url") != "git+https://github.com/f0rr0/oliphaunt.git": + fail("React Native package repository URL must match canonical release repository") + if repository.get("directory") != "src/sdks/react-native": + fail("React Native package repository.directory must point at the package root") + publish_config = package.get("publishConfig") + if not isinstance(publish_config, dict) or publish_config.get("provenance") is not True: + fail("React Native package must request npm provenance") + require_text( + "src/sdks/react-native/README.md", + "Normal React Native and Expo app consumers do not install Rust", + "React Native README must make the no-Rust consumer install path explicit", + ) + + +def validate_typescript( + ts_version: str, + liboliphaunt_version: str, + broker_version: str, + node_direct_version: str, +) -> None: + package = json.loads(read_text("src/sdks/js/package.json")) + validate_no_consumer_install_scripts(package, "TypeScript") + if package.get("version") != ts_version: + fail("TypeScript package.json version must match oliphaunt-js product version") + metadata = package.get("oliphaunt") + if not isinstance(metadata, dict): + fail("TypeScript package.json must include oliphaunt compatibility metadata") + if metadata.get("liboliphauntVersion") != liboliphaunt_version: + fail("TypeScript package.json liboliphauntVersion must match current liboliphaunt product version") + if metadata.get("brokerVersion") != broker_version: + fail("TypeScript package.json brokerVersion must match current broker runtime version") + if metadata.get("nodeDirectAddonVersion") != node_direct_version: + fail("TypeScript package.json nodeDirectAddonVersion must match current Node direct runtime version") + if metadata.get("nodeDirectAddon") != "oliphaunt-node-direct": + fail("TypeScript package.json must identify the Node native-direct adapter it consumes") + if metadata.get("brokerHelper") != "oliphaunt-broker": + fail("TypeScript package.json must identify the Rust broker helper it consumes") + repository = package.get("repository") + if not isinstance(repository, dict): + fail("TypeScript package must declare repository metadata") + if repository.get("url") != "git+https://github.com/f0rr0/oliphaunt.git": + fail("TypeScript package repository URL must match canonical release repository") + if repository.get("directory") != "src/sdks/js": + fail("TypeScript package repository.directory must point at the package root") + publish_config = package.get("publishConfig") + if not isinstance(publish_config, dict) or publish_config.get("provenance") is not True: + fail("TypeScript npm registry artifact must request provenance") + dependencies = package.get("dependencies", {}) + if isinstance(dependencies, dict) and dependencies: + fail("TypeScript SDK normal installs must not declare hard runtime dependencies") + expected_optional = { + "@oliphaunt/node-direct-darwin-arm64", + "@oliphaunt/node-direct-linux-x64-gnu", + "@oliphaunt/node-direct-linux-arm64-gnu", + "@oliphaunt/node-direct-win32-x64-msvc", + } + optional_dependencies = package.get("optionalDependencies", {}) + if not isinstance(optional_dependencies, dict) or set(optional_dependencies) != expected_optional: + fail("TypeScript package.json must declare exactly the Node direct optional platform packages") + exports = package.get("exports") + if not isinstance(exports, dict): + fail("TypeScript package must declare explicit exports") + for export_name in [".", "./node", "./bun", "./deno", "./protocol", "./query"]: + if export_name not in exports: + fail(f"TypeScript package is missing export {export_name}") + + jsr_config = json.loads(read_text("src/sdks/js/jsr.json")) + if jsr_config.get("name") != "@oliphaunt/ts": + fail("TypeScript JSR package name must be @oliphaunt/ts") + if jsr_config.get("version") != ts_version: + fail("TypeScript jsr.json version must match oliphaunt-js product version") + jsr_exports = jsr_config.get("exports") + if not isinstance(jsr_exports, dict): + fail("TypeScript JSR config must declare explicit exports") + for export_name in [".", "./node", "./bun", "./deno", "./protocol", "./query"]: + if export_name not in jsr_exports: + fail(f"TypeScript JSR package is missing export {export_name}") + require_text( + "src/sdks/js/tools/check-sdk.sh", + "jsr publish --dry-run", + "TypeScript SDK checks must validate JSR package shape", + ) + require_text( + "src/sdks/js/tools/check-sdk.sh", + "packed TypeScript package must rewrite Node direct optional dependencies to exact published versions", + "TypeScript SDK checks must inspect the packed npm manifest for publish-safe Node direct optional dependencies", + ) + require_text( + "src/sdks/js/tools/check-sdk.sh", + 'tools/dev/bun.sh" "$package_dir/.oliphaunt-bun-smoke.ts"', + "TypeScript SDK checks must validate Bun through the pinned repo launcher", + ) + require_text( + "src/sdks/js/tools/check-sdk.sh", + ".oliphaunt-bun-smoke.ts", + "TypeScript SDK checks must run a Bun import smoke for the npm-registry package surface", + ) + require_text( + "src/sdks/js/README.md", + "Deno can consume packages from", + "TypeScript README must explain npm-vs-JSR install guidance for Deno consumers", + ) + require_text( + "src/sdks/js/README.md", + "There is no `postinstall` native compilation step", + "TypeScript README must make the no-build consumer install path explicit", + ) + require_text( + "src/sdks/js/README.md", + "Node.js, Bun, and Deno use `nativeDirect` by default", + "TypeScript README must document the consistent nativeDirect default", + ) + require_text( + "src/sdks/js/README.md", + "prebuilt Node direct adapter", + "TypeScript README must keep Node direct mode on a zero-build package-owned adapter path", + ) + require_text( + "src/sdks/js/ARCHITECTURE.md", + "Node.js, Bun, and Deno all default to\n`nativeDirect`", + "TypeScript architecture must keep default engine selection consistent across runtimes", + ) + require_text( + "src/sdks/js/ARCHITECTURE.md", + "oliphaunt-node-direct-*", + "TypeScript architecture must keep Node direct mode on prebuilt adapter release assets", + ) + require_text( + "src/sdks/js/tools/check-sdk.sh", + "cargo build -p oliphaunt-broker --locked", + "TypeScript broker smoke must build the Rust broker helper when no released helper is present", + ) + require_text( + "src/sdks/js/src/native/common.ts", + "liboliphaunt-native-v", + "TypeScript SDK must resolve product-scoped liboliphaunt release tags", + ) + require_text( + "src/sdks/js/src/native/assets-node.ts", + "liboliphauntReleaseAssetUrl", + "TypeScript Node/Bun native binding must download compatible liboliphaunt release assets", + ) + require_text( + "src/sdks/js/src/native/node-addon.ts", + "oliphaunt-node-direct", + "TypeScript Node native-direct binding must download compatible Node-API adapter release assets", + ) + require_text( + "src/runtimes/node-direct/native/node-addon/oliphaunt_node.cc", + "NAPI_MODULE", + "Node direct runtime must have a package-owned Node-API implementation", + ) + require_text( + "src/runtimes/node-direct/tools/build-node-addon.sh", + "oliphaunt-node-direct-$version-$target.tar.gz", + "Node direct release tooling must package the Node native-direct adapter release asset", + ) + require_text( + "src/runtimes/node-direct/tools/build-node-addon.sh", + "Node direct addon smoke passed", + "Node direct release tooling must load-smoke the compiled adapter before publishing it", + ) + require_text( + "src/runtimes/node-direct/tools/build-node-addon.sh", + "check_node_direct_release_assets.py", + "Node direct release tooling must validate addon archives and checksums after building", + ) + require_text( + "tools/release/release.py", + "check_node_direct_release_assets.py", + "Node direct release publishing must validate addon archives and checksums before upload/npm staging", + ) + require_text( + ".github/workflows/ci.yml", + ".github/scripts/run-planned-moon-job.sh node-direct", + "Node direct CI matrix must invoke the planned Moon job that includes release-shaped addon artifacts on each published target", + ) + require_text( + "src/runtimes/node-direct/moon.yml", + 'tags: ["release", "artifact", "ci-node-direct"]', + "Node direct release-assets must be selected by the ci-node-direct Moon tag", + ) + require_text( + ".github/workflows/ci.yml", + ".github/actions/setup-msvc", + "Node direct Windows CI must set up an MSVC developer environment for cl.exe", + ) + require_text( + ".github/actions/setup-msvc/action.yml", + "ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756", + "shared MSVC CI setup must use the pinned MSVC developer environment action", + ) + require_text( + ".github/actions/setup-msvc/action.yml", + "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER", + "shared MSVC CI setup must force Rust MSVC builds to use the MSVC linker under Git Bash", + ) + require_text( + "tools/release/release.py", + "node_direct_optional_npm_tarballs", + "Node direct release dry-run must validate staged optional npm tarballs from the builder job", + ) + require_text( + "src/sdks/js/src/native/assets-deno.ts", + "liboliphauntReleaseAssetUrl", + "TypeScript Deno native binding must download compatible liboliphaunt release assets", + ) + require_text( + "src/sdks/js/src/runtime/broker.ts", + "restorePhysicalArchiveWithBroker", + "TypeScript broker helper must restore physical archives without requiring third-party Node FFI", + ) + require_text( + "src/sdks/js/src/__tests__/asset-resolver.test.ts", + "nodeResolverInstallsVerifiedReleaseAsset", + "TypeScript release asset resolver must have regression coverage", + ) + require_text( + "src/sdks/js/src/runtime/broker.ts", + "resolveBrokerNativeInstall", + "TypeScript broker mode must resolve the liboliphaunt install before launching the Rust helper", + ) + require_text( + "src/sdks/js/src/runtime/broker.ts", + "OLIPHAUNT_INSTALL_DIR", + "TypeScript broker mode must pass the resolved PostgreSQL runtime tree to the Rust helper", + ) + require_text( + "src/sdks/js/src/runtime/broker.ts", + "LIBOLIPHAUNT_PATH", + "TypeScript broker mode must pass the resolved liboliphaunt library to the Rust helper", + ) + require_text( + "src/sdks/js/src/runtime/broker.ts", + "oliphauntBrokerReleaseAssetUrl", + "TypeScript broker mode must resolve the published Rust broker helper release asset", + ) + require_text( + "src/sdks/js/src/runtime/broker.ts", + "OLIPHAUNT_BROKER_ASSET_DIR", + "TypeScript broker helper resolver must support local release-asset fixtures for tests and release validation", + ) + + +def version_file_value(path: str) -> str: + if Path(path).name == "Cargo.toml": + return cargo_manifest_version(path) + return read_text(path).strip() + + +def validate_wasm(wasix_runtime_version: str, wasm_binding_version: str) -> None: + runtime_version_files = product_metadata.version_files("liboliphaunt-wasix") + for path in runtime_version_files: + if version_file_value(path) != wasix_runtime_version: + fail(f"{path} must use liboliphaunt-wasix runtime version {wasix_runtime_version}") + binding_version_files = product_metadata.version_files("oliphaunt-wasix-rust") + for path in binding_version_files: + if version_file_value(path) != wasm_binding_version: + fail(f"{path} must use oliphaunt-wasix binding version {wasm_binding_version}") + manifest = tomllib.loads(read_text("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml")) + dependencies = dict(manifest.get("dependencies", {})) + for target in manifest.get("target", {}).values(): + if isinstance(target, dict) and isinstance(target.get("dependencies"), dict): + dependencies.update(target["dependencies"]) + for name in [cargo_manifest_name(path) for path in runtime_version_files if Path(path).name == "Cargo.toml"]: + dependency = dependencies.get(name) + if not isinstance(dependency, dict): + fail(f"oliphaunt-wasix dependency {name} must be a table") + if dependency.get("version") != f"={wasix_runtime_version}": + fail(f"oliphaunt-wasix dependency {name} must pin version ={wasix_runtime_version}") + + +def main() -> int: + graph = load_graph() + validate_graph_files(graph) + validate_release_setup_docs() + + versions = { + product: product_metadata.read_current_version(product) + for product in product_metadata.product_ids(graph) + } + for product, version in versions.items(): + stable_version(version, product) + + validate_rust() + validate_broker() + validate_swift(versions["oliphaunt-swift"], versions["liboliphaunt-native"]) + validate_kotlin(versions["oliphaunt-kotlin"], versions["liboliphaunt-native"]) + validate_react_native( + versions["oliphaunt-react-native"], + versions["oliphaunt-swift"], + versions["oliphaunt-kotlin"], + ) + validate_typescript( + versions["oliphaunt-js"], + versions["liboliphaunt-native"], + versions["oliphaunt-broker"], + versions["oliphaunt-node-direct"], + ) + validate_wasm(versions["liboliphaunt-wasix"], versions["oliphaunt-wasix-rust"]) + + print("release metadata checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/check_release_please_config.py b/tools/release/check_release_please_config.py new file mode 100755 index 00000000..c8562a79 --- /dev/null +++ b/tools/release/check_release_please_config.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Validate release-please manifest-mode configuration. + +This is a transition guard while release-please becomes the version, changelog, +and tag owner. It checks the standard release-please files against current +product versions without re-implementing release planning. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, NoReturn + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +CONFIG_PATH = ROOT / "release-please-config.json" +MANIFEST_PATH = ROOT / ".release-please-manifest.json" + + +def fail(message: str) -> NoReturn: + print(f"check_release_please_config.py: {message}", file=sys.stderr) + raise SystemExit(2) + + +def rel(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def read_json(path: Path) -> dict[str, Any]: + if not path.is_file(): + fail(f"missing {rel(path)}") + with path.open(encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + fail(f"{rel(path)} must contain a JSON object") + return value + + +def require_file(path: Path, context: str) -> None: + if not path.is_file(): + fail(f"{context} references missing file {rel(path)}") + + +def reject_unsafe_relative_path(value: str, context: str) -> None: + parts = Path(value).parts + if Path(value).is_absolute() or ".." in parts: + fail(f"{context} must stay inside its release-please package path: {value!r}") + + +def package_version_file(package_path: str, package_config: dict[str, Any]) -> Path | None: + version_file = package_config.get("version-file") + if version_file is None: + return None + if not isinstance(version_file, str) or not version_file: + fail(f"{package_path}.version-file must be a non-empty string") + return ROOT / package_path / version_file + + +def read_raw_version(path: Path) -> str: + require_file(path, "release-please version-file") + return path.read_text(encoding="utf-8").strip() + + +def validate_extra_files(package_path: str, package_config: dict[str, Any]) -> None: + extra_files = package_config.get("extra-files", []) + if not isinstance(extra_files, list): + fail(f"{package_path}.extra-files must be a list") + for index, entry in enumerate(extra_files): + context = f"{package_path}.extra-files[{index}]" + if isinstance(entry, str): + reject_unsafe_relative_path(entry, context) + require_file(ROOT / package_path / entry, context) + continue + if not isinstance(entry, dict): + fail(f"{context} must be a path string or object") + path = entry.get("path") + if not isinstance(path, str) or not path: + fail(f"{context}.path must be a non-empty string") + reject_unsafe_relative_path(path, f"{context}.path") + require_file(ROOT / package_path / path, context) + entry_type = entry.get("type") + if entry_type in {"json", "toml", "yaml"} and not isinstance(entry.get("jsonpath"), str): + fail(f"{context} type {entry_type!r} requires jsonpath") + if entry_type == "xml" and not isinstance(entry.get("xpath"), str): + fail(f"{context} type 'xml' requires xpath") + + +def main() -> int: + config = read_json(CONFIG_PATH) + manifest = read_json(MANIFEST_PATH) + packages = config.get("packages") + if not isinstance(packages, dict) or not packages: + fail("release-please-config.json must define non-empty packages") + + products = product_metadata.graph_products() + paths_by_id = {product: product_metadata.package_path(product) for product in products} + expected_paths = {paths_by_id[product] for product in products} + actual_paths = set(packages) + if actual_paths != expected_paths: + fail( + "release-please packages must match release products:\n" + f"missing={sorted(expected_paths - actual_paths)}\n" + f"extra={sorted(actual_paths - expected_paths)}" + ) + if set(manifest) != expected_paths: + fail( + ".release-please-manifest.json paths must match release products:\n" + f"missing={sorted(expected_paths - set(manifest))}\n" + f"extra={sorted(set(manifest) - expected_paths)}" + ) + + if config.get("tag-separator") != "-": + fail("release-please tag-separator must be '-' for -v tags") + if config.get("include-v-in-tag") is not True: + fail("release-please must include v in tags") + if config.get("pull-request-title-pattern") != "chore${scope}: release${component} ${version}": + fail("release-please pull-request-title-pattern must keep release-please's parseable default shape") + plugins = config.get("plugins", []) + if plugins != ["node-workspace"]: + fail("release-please plugins must stay minimal: use node-workspace only") + + ids_by_path = {path: product for product, path in paths_by_id.items()} + for package_path, package_config in packages.items(): + if not isinstance(package_config, dict): + fail(f"{package_path} config must be an object") + product = ids_by_path[package_path] + component = package_config.get("component") + if component != product: + fail(f"{package_path}.component must be {product!r}, got {component!r}") + tag_prefix = product_metadata.tag_prefix(product) + if tag_prefix != f"{component}-v": + fail(f"{product} release-please component does not match tag prefix {tag_prefix!r}") + manifest_version = manifest.get(package_path) + current_version = product_metadata.read_current_version(product) + if manifest_version != current_version: + fail( + f"{package_path} manifest version {manifest_version!r} " + f"does not match current {product} version {current_version!r}" + ) + changelog_path = package_config.get("changelog-path", "CHANGELOG.md") + if not isinstance(changelog_path, str) or not changelog_path: + fail(f"{package_path}.changelog-path must be a non-empty string") + reject_unsafe_relative_path(changelog_path, f"{package_path}.changelog-path") + require_file(ROOT / package_path / changelog_path, f"{package_path}.changelog-path") + version_file = package_version_file(package_path, package_config) + if version_file is not None and read_raw_version(version_file) != current_version: + fail(f"{rel(version_file)} must match current {product} version {current_version}") + validate_extra_files(package_path, package_config) + + print("release-please config checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/check_release_versions.py b/tools/release/check_release_versions.py new file mode 100755 index 00000000..bf3ac47c --- /dev/null +++ b/tools/release/check_release_versions.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Validate selected product versions are publishable from current tags.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import NoReturn + +import check_github_release_assets +import check_registry_publication +import product_metadata +import release_plan + + +ROOT = Path(__file__).resolve().parents[2] + + +def fail(message: str) -> NoReturn: + print(f"check_release_versions.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_graph() -> dict: + return release_plan.load_graph() + + +def parse_products(raw: str | None, graph: dict) -> list[str]: + products = graph.get("products") + if not isinstance(products, dict): + fail("release metadata must define [products.] entries") + if raw is None: + return sorted(products) + value = json.loads(raw) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail("--products-json must be a JSON string list") + unknown = sorted(set(value) - set(products)) + if unknown: + fail(f"unknown release products: {', '.join(unknown)}") + return value + + +def parse_stable_version(version: str) -> tuple[int, int, int]: + match = re.fullmatch(r"([0-9]+)[.]([0-9]+)[.]([0-9]+)", version) + if not match: + fail(f"release version must be stable x.y.z for automated publish, got {version!r}") + return tuple(int(part) for part in match.groups()) + + +def git_output(args: list[str]) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def tag_match_pattern(prefix: str) -> str: + return f"{prefix}[0-9]*" if prefix else "[0-9]*" + + +def tag_prefixes(config: dict) -> list[str]: + prefix = config.get("tag_prefix") + if not isinstance(prefix, str) or not prefix: + fail("release products must declare tag_prefix") + legacy_prefixes = config.get("legacy_tag_prefixes", []) + if not isinstance(legacy_prefixes, list) or not all( + isinstance(item, str) for item in legacy_prefixes + ): + fail("legacy_tag_prefixes must be a string list when present") + return [prefix, *legacy_prefixes] + + +def product_tags(prefix: str) -> list[str]: + output = subprocess.check_output( + ["git", "tag", "--list", tag_match_pattern(prefix)], + cwd=ROOT, + text=True, + ) + return [line.strip() for line in output.splitlines() if line.strip()] + + +def tag_version(prefix: str, tag: str) -> tuple[int, int, int] | None: + if not tag.startswith(prefix): + return None + version = tag[len(prefix) :] + if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+", version): + return None + return parse_stable_version(version) + + +def tag_commit(tag: str) -> str: + return git_output(["rev-list", "-n", "1", tag]) + + +def tag_exists(tag: str) -> bool: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}^{{commit}}"], + cwd=ROOT, + check=False, + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def commit_for_ref(ref: str) -> str: + return git_output(["rev-parse", f"{ref}^{{commit}}"]) + + +def read_text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def react_native_compatibility_versions() -> tuple[str, str]: + package = json.loads(read_text("src/sdks/react-native/package.json")) + metadata = package.get("oliphaunt") + if not isinstance(metadata, dict): + fail("React Native package.json must declare oliphaunt compatibility metadata") + swift_version = metadata.get("swiftSdkVersion") + kotlin_version = metadata.get("kotlinSdkVersion") + if not isinstance(swift_version, str) or not isinstance(kotlin_version, str): + fail("React Native compatibility metadata must include Swift and Kotlin SDK versions") + return swift_version, kotlin_version + + +def typescript_compatibility_versions() -> tuple[str, str, str]: + package = json.loads(read_text("src/sdks/js/package.json")) + metadata = package.get("oliphaunt") + if not isinstance(metadata, dict): + fail("TypeScript package.json must declare oliphaunt compatibility metadata") + liboliphaunt_version = metadata.get("liboliphauntVersion") + broker_version = metadata.get("brokerVersion") + node_direct_version = metadata.get("nodeDirectAddonVersion") + if ( + not isinstance(liboliphaunt_version, str) + or not isinstance(broker_version, str) + or not isinstance(node_direct_version, str) + ): + fail("TypeScript compatibility metadata must include liboliphaunt, broker, and Node direct versions") + return liboliphaunt_version, broker_version, node_direct_version + + +def dependency_version_for(consumer: str, dependency: str) -> str: + if consumer == "oliphaunt-swift" and dependency == "liboliphaunt-native": + return read_text("src/sdks/swift/LIBOLIPHAUNT_VERSION").strip() + if consumer == "oliphaunt-react-native" and dependency == "oliphaunt-swift": + swift_version, _ = react_native_compatibility_versions() + return swift_version + if consumer == "oliphaunt-react-native" and dependency == "oliphaunt-kotlin": + _, kotlin_version = react_native_compatibility_versions() + return kotlin_version + if consumer == "oliphaunt-js" and dependency == "liboliphaunt-native": + liboliphaunt_version, _, _ = typescript_compatibility_versions() + return liboliphaunt_version + if consumer == "oliphaunt-js" and dependency == "oliphaunt-broker": + _, broker_version, _ = typescript_compatibility_versions() + return broker_version + if consumer == "oliphaunt-js" and dependency == "oliphaunt-node-direct": + _, _, node_direct_version = typescript_compatibility_versions() + return node_direct_version + return product_metadata.read_current_version(dependency) + + +def validate_product(product: str, config: dict, head_ref: str) -> bool: + prefix = config.get("tag_prefix") + if not isinstance(prefix, str) or not prefix: + fail(f"{product} must declare tag_prefix") + version = product_metadata.read_current_version(product) + current = parse_stable_version(version) + current_tag = f"{prefix}{version}" + head_commit = commit_for_ref(head_ref) + tags = product_tags(prefix) + if current_tag in tags: + current_tag_commit = tag_commit(current_tag) + if current_tag_commit != head_commit: + fail( + f"{product} version {version} is already tagged as {current_tag} " + f"at {current_tag_commit}, not release commit {head_commit}; " + "merge the release-please release PR before publishing" + ) + return True + previous_versions = [ + parsed + for candidate_prefix in tag_prefixes(config) + for tag in product_tags(candidate_prefix) + if (parsed := tag_version(candidate_prefix, tag)) is not None + ] + if previous_versions and current <= max(previous_versions): + latest = ".".join(str(part) for part in max(previous_versions)) + fail( + f"{product} version {version} is not newer than latest tagged version {latest}; " + "merge the release-please release PR before publishing" + ) + return False + + +def validate_registry_publication( + products: list[str], + graph: dict, + current_tag_at_head: dict[str, bool], + head_ref: str, +) -> None: + graph_products = graph.get("products") + if not isinstance(graph_products, dict): + fail("release metadata must define [products.] entries") + head_commit = commit_for_ref(head_ref) + for product in products: + config = graph_products[product] + targets = config.get("publish_targets", []) + if not isinstance(targets, list) or not all(isinstance(item, str) for item in targets): + fail(f"{product}.publish_targets must be a string list") + registry_targets = set(targets) & check_registry_publication.REGISTRY_TARGETS + if not registry_targets: + continue + if current_tag_at_head.get(product, False): + if "crates-io" in registry_targets: + check_registry_publication.assert_product_publication( + product, + require_published=True, + ) + else: + check_registry_publication.report_product_publication(product) + continue + packages, _, published = check_registry_publication.query_product_publication(product) + if not packages: + print(f"{product} has no external registry packages to check") + continue + if published: + prefix = config.get("tag_prefix") + if not isinstance(prefix, str) or not prefix: + fail(f"{product} must declare tag_prefix") + version = product_metadata.read_current_version(product) + current_tag = f"{prefix}{version}" + fail( + f"{product} version {version} is already published in public registries: " + + ", ".join(package.label for package in published) + + f"; the matching product tag {current_tag} is missing or does not " + f"point at release commit {head_commit}. If this was an intentional " + "first package identity bootstrap, create and push that product tag at " + "the same release commit, then rerun the release workflow as a completion " + "run. Otherwise merge the release-please release PR before publishing." + ) + print( + f"{product} registry unpublished check passed: " + + ", ".join(package.label for package in packages) + ) + + +def validate_dependency_tag( + consumer: str, + dependency: str, + dependency_version: str, + graph: dict, + selected: set[str], +) -> None: + parse_stable_version(dependency_version) + if dependency in selected: + return + dependency_config = graph["products"].get(dependency) + if not isinstance(dependency_config, dict): + fail(f"{consumer} declares unknown release dependency {dependency}") + prefix = dependency_config.get("tag_prefix") + if not isinstance(prefix, str) or not prefix: + fail(f"{dependency} must declare tag_prefix") + tag = f"{prefix}{dependency_version}" + if not tag_exists(tag): + fail( + f"{consumer} depends on {dependency} {dependency_version}, but release tag " + f"{tag} does not exist and {dependency} is not selected for this release" + ) + validate_released_dependency_artifacts(consumer, dependency, dependency_version, graph) + + +def validate_released_dependency_artifacts( + consumer: str, + dependency: str, + dependency_version: str, + graph: dict, +) -> None: + dependency_config = graph["products"].get(dependency) + if not isinstance(dependency_config, dict): + fail(f"{consumer} declares unknown release dependency {dependency}") + targets = dependency_config.get("publish_targets", []) + if not isinstance(targets, list) or not all(isinstance(item, str) for item in targets): + fail(f"{dependency}.publish_targets must be a string list") + registry_targets = set(targets) & check_registry_publication.REGISTRY_TARGETS + if registry_targets: + check_registry_publication.assert_product_publication( + dependency, + require_published=True, + version_override=dependency_version, + ) + if "github-release-assets" in targets: + check_github_release_assets.verify( + dependency, + dependency_version, + check_github_release_assets.expected_assets(dependency, dependency_version), + ) + + +def validate_release_dependencies(products: list[str], graph: dict) -> None: + selected = set(products) + graph_products = graph.get("products") + if not isinstance(graph_products, dict): + fail("release metadata must define [products.] entries") + moon_projects = graph.get("moon_projects") + if not isinstance(moon_projects, dict): + fail("Moon project graph is missing from release metadata") + product_project = { + product: release_plan.release_product_project_id(product, graph_products, moon_projects) + for product in graph_products + } + project_product = {project: product for product, project in product_project.items()} + for product in products: + config = graph_products.get(product) + if not isinstance(config, dict): + fail(f"selected product {product} is missing from release metadata") + project = moon_projects.get(product_project[product], {}) + dependencies = [ + project_product[dependency] + for dependency in project.get("dependsOn", []) + if dependency in project_product + ] + for dependency in dependencies: + validate_dependency_tag( + product, + dependency, + dependency_version_for(product, dependency), + graph, + selected, + ) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--products-json", help="JSON list of selected product ids") + parser.add_argument( + "--head-ref", + default="HEAD", + help="release commit ref; an existing current-version tag is allowed only if it points here", + ) + parser.add_argument( + "--check-registries", + action="store_true", + help="also validate selected product versions against external package registries", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + graph = load_graph() + selected = parse_products(args.products_json, graph) + current_tag_at_head: dict[str, bool] = {} + for product in selected: + current_tag_at_head[product] = validate_product( + product, + graph["products"][product], + args.head_ref, + ) + validate_release_dependencies(selected, graph) + if args.check_registries: + validate_registry_publication(selected, graph, current_tag_at_head, args.head_ref) + print("release version checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_staged_artifacts.py b/tools/release/check_staged_artifacts.py new file mode 100755 index 00000000..62b063a1 --- /dev/null +++ b/tools/release/check_staged_artifacts.py @@ -0,0 +1,1012 @@ +#!/usr/bin/env python3 +"""Validate staged release/build artifacts without rebuilding them. + +This checker enforces the packaging boundary: + +* SDK packages are wrappers and must not accidentally embed runtime or extension + payloads. +* Exact-extension packages must contain only declared artifact targets, with + checksums matching their manifests. +* Mobile app artifacts must contain only the extensions selected for that app. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import tarfile +import zipfile +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn + +import extension_artifact_targets +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +SDK_ROOT = ROOT / "target" / "sdk-artifacts" +EXTENSION_ROOT = ROOT / "target" / "extension-artifacts" +MOBILE_ROOT = ROOT / "target" / "mobile-build" / "react-native" + +SDK_PRODUCTS = { + "oliphaunt-rust", + "oliphaunt-swift", + "oliphaunt-kotlin", + "oliphaunt-js", + "oliphaunt-react-native", + "oliphaunt-wasix-rust", +} + +SDK_RUNTIME_PAYLOAD_PATTERNS = [ + re.compile(pattern) + for pattern in ( + r"(^|/)assets/oliphaunt/runtime/", + r"(^|/)assets/oliphaunt/template-pgdata/", + r"(^|/)assets/oliphaunt/static-registry/archives/", + r"(^|/)oliphaunt/runtime/files/", + r"(^|/)runtime/files/share/postgresql/", + r"(^|/)share/postgresql/extension/[^/]+\.(control|sql)$", + r"(^|/)release-assets/", + r"(^|/)extension-artifacts\.json$", + r"(^|/)liboliphaunt\.(so|dylib|dll|a|lib)$", + r"(^|/)liboliphaunt_extensions\.(so|dylib|dll|a|lib)$", + r"(^|/)liboliphaunt_extension_[^/]+\.(so|dylib|dll|a|lib)$", + r"\.xcframework(/|$)", + ) +] + +KOTLIN_ALLOWED_NATIVE_PAYLOADS = { + "liboliphaunt_kotlin_android.so", +} +KOTLIN_RELEASE_ABIS = {"arm64-v8a", "x86_64"} + + +def fail(message: str) -> NoReturn: + print(f"check_staged_artifacts.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def rel(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return str(path) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_json(path: Path) -> dict[str, object]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + fail(f"{rel(path)} is not valid JSON: {error}") + if not isinstance(data, dict): + fail(f"{rel(path)} must contain a JSON object") + return data + + +def read_properties_text(text: str) -> dict[str, str]: + parsed: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + fail(f"invalid properties line: {raw!r}") + key, value = line.split("=", 1) + parsed[key] = value + return parsed + + +def csv_values(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def archive_tar_names(path: Path) -> list[str]: + try: + with tarfile.open(path, "r:*") as archive: + return sorted(member.name for member in archive.getmembers() if member.isfile()) + except tarfile.TarError as error: + fail(f"{rel(path)} is not a readable tar archive: {error}") + + +def archive_zip_names(path: Path) -> list[str]: + try: + with zipfile.ZipFile(path) as archive: + return sorted(name for name in archive.namelist() if not name.endswith("/")) + except zipfile.BadZipFile as error: + fail(f"{rel(path)} is not a readable zip archive: {error}") + + +def validate_zstd_archive_magic(path: Path) -> None: + with path.open("rb") as handle: + magic = handle.read(4) + if magic != b"\x28\xb5\x2f\xfd": + fail(f"{rel(path)} is not a zstd archive") + + +def validate_release_archive_payload(path: Path) -> None: + if path.name.endswith(".tar.gz") or path.name.endswith(".tgz") or path.name.endswith(".crate"): + names = archive_tar_names(path) + if not names: + fail(f"{rel(path)} must contain at least one file") + return + if path.name.endswith(".zip") or path.name.endswith(".aar") or path.name.endswith(".jar"): + names = archive_zip_names(path) + if not names: + fail(f"{rel(path)} must contain at least one file") + return + if path.name.endswith(".tar.zst"): + validate_zstd_archive_magic(path) + + +def directory_names(root: Path) -> list[str]: + return sorted(str(path.relative_to(root)) for path in root.rglob("*") if path.is_file()) + + +def path_bytes(path: Path) -> int: + if path.is_file(): + return path.stat().st_size + if path.is_dir(): + return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) + fail(f"missing path while measuring bytes: {rel(path)}") + + +def zip_read_text(path: Path, name: str) -> str: + try: + with zipfile.ZipFile(path) as archive: + with archive.open(name) as handle: + return handle.read().decode("utf-8") + except KeyError: + fail(f"{rel(path)} is missing {name}") + except zipfile.BadZipFile as error: + fail(f"{rel(path)} is not a readable zip archive: {error}") + + +def dir_read_text(root: Path, name: str) -> str: + path = root / name + if not path.is_file(): + fail(f"{rel(root)} is missing {name}") + return path.read_text(encoding="utf-8") + + +def generated_extension_rows() -> dict[str, dict[str, object]]: + metadata = ROOT / "src" / "extensions" / "generated" / "sdk" / "react-native.json" + data = read_json(metadata) + rows = data.get("extensions") + if not isinstance(rows, list): + fail(f"{rel(metadata)} must contain an extensions array") + result: dict[str, dict[str, object]] = {} + for row in rows: + if not isinstance(row, dict): + continue + sql_name = row.get("sql-name") + if isinstance(sql_name, str) and sql_name: + result[sql_name] = row + return result + + +def creates_extension(sql_name: str, rows: dict[str, dict[str, object]]) -> bool: + row = rows.get(sql_name) + if row is None: + fail(f"selected extension {sql_name!r} is missing from generated extension metadata") + return row.get("creates-extension") is not False + + +def native_module_stem(sql_name: str, rows: dict[str, dict[str, object]]) -> str: + row = rows.get(sql_name) + if row is None: + fail(f"selected extension {sql_name!r} is missing from generated extension metadata") + stem = row.get("native-module-stem") + return stem if isinstance(stem, str) else "" + + +def native_module_extensions(selected: list[str], rows: dict[str, dict[str, object]]) -> list[str]: + return sorted( + extension + for extension in selected + if (stem := native_module_stem(extension, rows)) and stem != "-" + ) + + +def extension_name_for_asset(path_name: str) -> str | None: + name = Path(path_name).name + if name.endswith(".control"): + return name.removesuffix(".control") + if "--" in name and name.endswith(".sql"): + return name.split("--", 1)[0] + return None + + +def reject_sdk_runtime_payload(product: str, artifact: Path, names: Iterable[str]) -> None: + for name in names: + basename = Path(name).name + if product == "oliphaunt-kotlin" and basename in KOTLIN_ALLOWED_NATIVE_PAYLOADS: + continue + for pattern in SDK_RUNTIME_PAYLOAD_PATTERNS: + if pattern.search(name): + fail(f"{product} SDK artifact {rel(artifact)} must not include runtime/extension payload {name}") + + +def validate_kotlin_android_aar(artifact: Path, names: Iterable[str]) -> None: + name_set = set(names) + present_abis = { + parts[1] + for name in name_set + if (parts := name.split("/")) and len(parts) == 3 and parts[0] == "jni" and parts[2] == "liboliphaunt_kotlin_android.so" + } + if present_abis != KOTLIN_RELEASE_ABIS: + fail( + f"Kotlin Android release AAR {rel(artifact)} must contain JNI adapters for " + f"{', '.join(sorted(KOTLIN_RELEASE_ABIS))}; got {', '.join(sorted(present_abis)) or '(none)'}" + ) + + +def check_sdk_product(product: str, *, require: bool) -> bool: + root = SDK_ROOT / product + if not root.exists(): + if require: + fail(f"missing staged SDK artifacts for {product} under {rel(root)}") + return False + + checked = False + if product in {"oliphaunt-js", "oliphaunt-react-native"}: + tarballs = sorted(root.glob("*.tgz")) + if not tarballs and require: + fail(f"{product} must stage an npm tarball under {rel(root)}") + for tarball in tarballs: + reject_sdk_runtime_payload(product, tarball, archive_tar_names(tarball)) + checked = True + elif product == "oliphaunt-swift": + archives = sorted(root.glob("*.zip")) + if not archives and require: + fail(f"{product} must stage a source zip under {rel(root)}") + for archive in archives: + reject_sdk_runtime_payload(product, archive, archive_zip_names(archive)) + checked = True + release_manifest = root / "Package.swift.release" + if not release_manifest.exists() and require: + fail(f"{product} must stage {rel(release_manifest)} for release installation") + if release_manifest.exists(): + text = release_manifest.read_text(encoding="utf-8") + if "file://" in text: + fail(f"{rel(release_manifest)} must not contain local file URLs") + if "liboliphaunt-native-v" not in text or "checksum:" not in text: + fail(f"{rel(release_manifest)} must reference checksummed public liboliphaunt assets") + elif product == "oliphaunt-kotlin": + maven_root = root / "maven" + if not maven_root.is_dir(): + if require: + fail(f"{product} must stage a Maven repository under {rel(maven_root)}") + return False + archives = sorted([*root.glob("*.aar"), *root.glob("*.jar")]) + for archive in archives: + names = archive_zip_names(archive) + reject_sdk_runtime_payload(product, archive, names) + if archive.suffix == ".aar": + validate_kotlin_android_aar(archive, names) + checked = True + maven_artifacts = sorted(maven_root.rglob("*")) + for artifact in (path for path in maven_artifacts if path.suffix in {".aar", ".jar"}): + names = archive_zip_names(artifact) + reject_sdk_runtime_payload(product, artifact, names) + if artifact.suffix == ".aar": + validate_kotlin_android_aar(artifact, names) + checked = True + elif product in {"oliphaunt-rust", "oliphaunt-wasix-rust"}: + crates = sorted(root.glob("*.crate")) + if not crates and require: + fail(f"{product} must stage a Cargo crate under {rel(root)}") + for crate in crates: + reject_sdk_runtime_payload(product, crate, archive_tar_names(crate)) + checked = True + else: + fail(f"unsupported SDK product {product}") + + if require and not checked: + fail(f"{product} did not contain any inspectable staged package artifacts under {rel(root)}") + if checked: + print(f"validated SDK artifact cleanliness: {product}") + return checked + + +def exact_extension_products() -> list[str]: + products: list[str] = [] + for product in product_metadata.product_ids(): + if product_metadata.product_config(product).get("kind") == "exact-extension-artifact": + products.append(product) + return sorted(products) + + +def extension_artifact_kind_allowed(family: str, target: str, kind: str) -> bool: + if family == "wasix": + return target == "wasix-portable" and kind == "wasix-runtime" + if family != "native": + return False + if target == "ios-xcframework": + return kind in {"runtime", "ios-xcframework"} + if target.startswith("android-"): + return kind in {"runtime", "android-static-archive"} + return kind == "runtime" + + +def check_extension_product(product: str, *, require: bool, require_full_targets: bool) -> bool: + root = EXTENSION_ROOT / product + manifest = root / "extension-artifacts.json" + if not manifest.exists(): + if require: + fail(f"missing staged exact-extension package manifest for {product} under {rel(root)}") + return False + data = read_json(manifest) + expected = { + "schema": "oliphaunt-extension-ci-artifacts-v1", + "product": product, + "version": product_metadata.read_current_version(product), + } + for key, value in expected.items(): + if data.get(key) != value: + fail(f"{rel(manifest)} has {key}={data.get(key)!r}, expected {value!r}") + sql_name = data.get("sqlName") + expected_sql_name = product_metadata.product_config(product).get("extension_sql_name") + if sql_name != expected_sql_name: + fail(f"{rel(manifest)} has sqlName={sql_name!r}, expected {expected_sql_name!r}") + + assets = data.get("assets") + if not isinstance(assets, list) or not assets: + fail(f"{rel(manifest)} must declare at least one asset") + + seen_names: set[str] = set() + staged_targets: set[str] = set() + allowed_targets = { + target.target for target in extension_artifact_targets.artifact_targets(product=product, published_only=True) + } + for asset in assets: + if not isinstance(asset, dict): + fail(f"{rel(manifest)} contains a non-object asset entry") + family = asset.get("family") + target = asset.get("target") + kind = asset.get("kind") + name = asset.get("name") + path_value = asset.get("path") + sha = asset.get("sha256") + bytes_value = asset.get("bytes") + if not all(isinstance(value, str) and value for value in (family, target, kind, name, path_value, sha)): + fail(f"{rel(manifest)} contains an incomplete asset entry: {asset!r}") + if not isinstance(bytes_value, int) or bytes_value <= 0: + fail(f"{rel(manifest)} asset {name} must declare positive bytes") + if name in seen_names: + fail(f"{rel(manifest)} declares duplicate asset name {name}") + seen_names.add(name) + staged_targets.add(target) + if target not in allowed_targets: + fail(f"{rel(manifest)} stages undeclared target={target!r}") + if not extension_artifact_kind_allowed(family, target, kind): + fail(f"{rel(manifest)} stages invalid artifact kind={kind!r} for family={family!r} target={target!r}") + path = ROOT / path_value + if path.parent != root / "release-assets" or path.name != name: + fail(f"{rel(manifest)} asset {name} must live directly under {rel(root / 'release-assets')}") + if not path.is_file(): + fail(f"{rel(manifest)} references missing asset {rel(path)}") + if path.stat().st_size != bytes_value: + fail(f"{rel(path)} size does not match {rel(manifest)}") + if sha256_file(path) != sha: + fail(f"{rel(path)} checksum does not match {rel(manifest)}") + validate_release_archive_payload(path) + + release_manifest = root / "release-assets" / f"{product}-{expected['version']}-manifest.json" + if not release_manifest.exists(): + fail(f"{product} must stage release manifest {rel(release_manifest)}") + if read_json(release_manifest) != data: + fail(f"{rel(release_manifest)} must match {rel(manifest)} exactly") + properties_manifest = root / "release-assets" / f"{product}-{expected['version']}-manifest.properties" + if not properties_manifest.exists(): + fail(f"{product} must stage properties manifest {rel(properties_manifest)}") + properties = read_properties_text(properties_manifest.read_text(encoding="utf-8")) + expected_properties = { + "schema": "oliphaunt-extension-release-manifest-v1", + "product": product, + "version": str(expected["version"]), + "sqlName": str(expected_sql_name), + } + for key, value in expected_properties.items(): + if properties.get(key) != value: + fail(f"{rel(properties_manifest)} has {key}={properties.get(key)!r}, expected {value!r}") + expected_property_assets = { + f"{asset['family']}.{asset['target']}.{asset['kind']}": asset["name"] + for asset in assets + if isinstance(asset, dict) + } + actual_property_assets = { + key.removeprefix("asset."): value + for key, value in properties.items() + if key.startswith("asset.") + } + if actual_property_assets != expected_property_assets: + fail( + f"{rel(properties_manifest)} asset rows must match {rel(manifest)} exactly: " + f"{actual_property_assets!r} vs {expected_property_assets!r}" + ) + checksum_manifest = root / "release-assets" / f"{product}-{expected['version']}-release-assets.sha256" + if not checksum_manifest.exists(): + fail(f"{product} must stage checksum manifest {rel(checksum_manifest)}") + validate_checksum_manifest(checksum_manifest, root / "release-assets") + + if require_full_targets: + missing = allowed_targets - staged_targets + if missing: + rendered = ", ".join(sorted(missing)) + fail(f"{product} is missing published exact-extension targets: {rendered}") + print(f"validated exact-extension package artifacts: {product}") + return True + + +def validate_checksum_manifest(path: Path, asset_dir: Path) -> None: + declared: dict[str, str] = {} + for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + fail(f"{rel(path)}:{line_number} must contain ' ./'") + sha, name = parts + if not re.fullmatch(r"[0-9a-f]{64}", sha) or not name.startswith("./") or "/" in name[2:]: + fail(f"{rel(path)}:{line_number} contains an invalid checksum entry") + declared[name[2:]] = sha + expected_names = sorted(item.name for item in asset_dir.iterdir() if item.is_file() and item != path) + if sorted(declared) != expected_names: + fail(f"{rel(path)} must cover release assets exactly") + for name, expected_sha in declared.items(): + actual = sha256_file(asset_dir / name) + if actual != expected_sha: + fail(f"{rel(path)} checksum mismatch for {name}") + + +@dataclass(frozen=True) +class MobileArtifact: + platform: str + path: Path + names: list[str] + + def read_text(self, name: str) -> str: + if self.path.is_dir(): + return dir_read_text(self.path, name) + return zip_read_text(self.path, name) + + +def discover_mobile_artifacts(platform: str) -> list[MobileArtifact]: + if platform == "android": + return [ + MobileArtifact("android", apk, archive_zip_names(apk)) + for apk in sorted((MOBILE_ROOT / "android").glob("*.apk")) + ] + if platform == "ios": + ios_root = MOBILE_ROOT / "ios" + apps = sorted(ios_root.glob("*.app")) + return [MobileArtifact("ios", app, directory_names(app)) for app in apps] + fail(f"unsupported mobile platform {platform}") + + +def mobile_prefix(platform: str) -> str: + if platform == "android": + return "assets/oliphaunt/" + if platform == "ios": + return "OliphauntReactNativeResources.bundle/oliphaunt/" + fail(f"unsupported mobile platform {platform}") + + +def mobile_target_for_artifact(artifact: MobileArtifact) -> str: + if artifact.platform == "ios": + return "ios-xcframework" + abis = sorted( + name.split("/", 2)[1] + for name in artifact.names + if name.startswith("lib/") and name.endswith("/liboliphaunt.so") + ) + if len(abis) != 1: + fail(f"{rel(artifact.path)} must contain exactly one Android liboliphaunt ABI, got {abis}") + abi = abis[0] + if abi == "arm64-v8a": + return "android-arm64-v8a" + if abi == "x86_64": + return "android-x86_64" + fail(f"{rel(artifact.path)} contains unsupported Android ABI {abi}") + + +def mobile_build_report(platform: str) -> dict[str, object] | None: + report = MOBILE_ROOT / platform / "build-report.json" + if not report.is_file(): + return None + data = read_json(report) + if data.get("schema") != "oliphaunt-react-native-mobile-build-v1": + fail(f"{rel(report)} has invalid mobile build report schema") + if data.get("platform") != platform: + fail(f"{rel(report)} has platform={data.get('platform')!r}, expected {platform!r}") + return data + + +def resolve_report_path(value: object, report_path: Path, field: str) -> Path: + if not isinstance(value, str) or not value: + fail(f"{rel(report_path)} must declare {field}") + path = Path(value) + if not path.is_absolute(): + path = ROOT / path + return path + + +def check_extension_package_has_mobile_target(sql_name: str, target: str) -> None: + for product in exact_extension_products(): + manifest = EXTENSION_ROOT / product / "extension-artifacts.json" + if not manifest.is_file(): + continue + data = read_json(manifest) + if data.get("sqlName") != sql_name: + continue + assets = data.get("assets") + if not isinstance(assets, list): + fail(f"{rel(manifest)} must declare assets") + runtime_matches = [ + asset + for asset in assets + if isinstance(asset, dict) + and asset.get("family") == "native" + and asset.get("target") == target + and asset.get("kind") == "runtime" + ] + if len(runtime_matches) != 1: + fail(f"{sql_name} exact-extension package must contain one native runtime asset for {target}") + if target == "ios-xcframework": + framework_matches = [ + asset + for asset in assets + if isinstance(asset, dict) + and asset.get("family") == "native" + and asset.get("target") == target + and asset.get("kind") == "ios-xcframework" + ] + if len(framework_matches) != 1: + fail(f"{sql_name} exact-extension package must contain one iOS XCFramework asset") + return + fail(f"no exact-extension package found for selected mobile extension {sql_name}") + + +def check_ios_prebuilt_extension_linkage(artifact: MobileArtifact, stems: list[str]) -> None: + if not stems: + return + + source_leaks = sorted( + name + for name in artifact.names + if "/static-registry/oliphaunt_static_registry.c" in name + or "/extension-frameworks/" in name + or name.endswith(".xcframework") + ) + if source_leaks: + fail( + f"{rel(artifact.path)} includes build-only iOS static-extension inputs as app resources: " + f"{', '.join(source_leaks[:10])}" + ) + + report = mobile_build_report("ios") + if report is None: + fail(f"{rel(artifact.path)} requires {rel(MOBILE_ROOT / 'ios' / 'build-report.json')} for iOS extension link evidence") + scratch_root = report.get("scratchRoot") + if not isinstance(scratch_root, str) or not scratch_root: + fail(f"{rel(MOBILE_ROOT / 'ios' / 'build-report.json')} must declare scratchRoot for iOS extension link evidence") + scratch_path = Path(scratch_root) + xcode_log = scratch_path / "xcodebuild.log" + if not xcode_log.is_file(): + fail(f"iOS extension link evidence is missing xcodebuild log: {rel(xcode_log)}") + log_text = xcode_log.read_text(encoding="utf-8", errors="replace") + if "** BUILD SUCCEEDED **" not in log_text: + fail(f"iOS extension link evidence requires a successful xcodebuild log: {rel(xcode_log)}") + + pods_support = ( + scratch_path + / "src" + / "sdks" + / "react-native" + / "examples" + / "expo" + / "ios" + / "Pods" + / "Target Support Files" + / "OliphauntReactNative" + ) + input_file = pods_support / "OliphauntReactNative-xcframeworks-input-files.xcfilelist" + output_file = pods_support / "OliphauntReactNative-xcframeworks-output-files.xcfilelist" + if not input_file.is_file(): + fail(f"iOS extension link evidence is missing CocoaPods XCFramework input file list: {rel(input_file)}") + if not output_file.is_file(): + fail(f"iOS extension link evidence is missing CocoaPods XCFramework output file list: {rel(output_file)}") + + expected_frameworks = {f"liboliphaunt_extension_{stem}" for stem in stems} + pod_text = input_file.read_text(encoding="utf-8", errors="replace") + "\n" + output_file.read_text( + encoding="utf-8", errors="replace" + ) + pod_frameworks = set(re.findall(r"liboliphaunt_extension_[A-Za-z0-9_]+", pod_text)) + products_root = scratch_path / "DerivedData" / "Build" / "Products" + if not products_root.is_dir(): + fail(f"iOS extension link evidence is missing Xcode build products: {rel(products_root)}") + built_frameworks = { + path.name.removesuffix(".a").removesuffix(".framework") + for path in products_root.rglob("liboliphaunt_extension_*") + if path.name.endswith((".a", ".framework")) + } + + missing_pods = sorted(expected_frameworks - pod_frameworks) + if missing_pods: + fail( + f"CocoaPods file lists do not include selected iOS extension link input(s): " + f"{', '.join(missing_pods)}" + ) + missing_built = sorted(expected_frameworks - built_frameworks) + if missing_built: + fail( + f"Xcode build products do not include selected iOS extension linked artifact(s): " + f"{', '.join(missing_built)}" + ) + unexpected_pods = sorted(pod_frameworks - expected_frameworks) + if unexpected_pods: + fail( + f"CocoaPods file lists include unselected iOS extension link input(s): " + f"{', '.join(unexpected_pods)}" + ) + unexpected_built = sorted(built_frameworks - expected_frameworks) + if unexpected_built: + fail( + f"Xcode build products include unselected iOS extension linked artifact(s): " + f"{', '.join(unexpected_built)}" + ) + + +def check_android_prebuilt_extension_linkage( + artifact: MobileArtifact, + stems: list[str], + report: dict[str, object], + report_path: Path, + expected_abi: str, + static_registry: dict[str, str], + target: str, +) -> None: + if not stems: + return + + evidence_path = resolve_report_path(report.get("androidLinkEvidence"), report_path, "androidLinkEvidence") + if not evidence_path.is_file(): + fail(f"Android extension link evidence is missing: {rel(evidence_path)}") + linked_stems: set[str] = set() + linked_dependencies: set[str] = set() + evidence_abi = "" + runtime_path = "" + schema_rows = 0 + abi_rows = 0 + + def require_existing_path(raw_path: str, line_number: int, row_kind: str) -> Path: + path = Path(raw_path) + if not path.is_absolute(): + path = evidence_path.parent / path + if not path.is_file(): + fail(f"{rel(evidence_path)}:{line_number} {row_kind} path does not exist: {path}") + return path + + for line_number, raw in enumerate(evidence_path.read_text(encoding="utf-8").splitlines(), start=1): + parts = raw.split("\t") + if not parts or not parts[0]: + continue + kind = parts[0] + if kind == "schema": + if parts != ["schema", "oliphaunt-android-static-extension-link-v1"]: + fail(f"{rel(evidence_path)}:{line_number} has invalid schema row") + schema_rows += 1 + elif kind == "abi": + if len(parts) != 2: + fail(f"{rel(evidence_path)}:{line_number} has invalid abi row") + evidence_abi = parts[1] + abi_rows += 1 + elif kind == "runtime": + if len(parts) != 3 or parts[1] != "liboliphaunt": + fail(f"{rel(evidence_path)}:{line_number} has invalid runtime row") + path = require_existing_path(parts[2], line_number, "runtime") + if path.name != "liboliphaunt.so": + fail(f"{rel(evidence_path)}:{line_number} runtime path must end in liboliphaunt.so") + if runtime_path: + fail(f"{rel(evidence_path)} contains duplicate runtime rows") + runtime_path = str(path) + elif kind == "extension": + if len(parts) != 3: + fail(f"{rel(evidence_path)}:{line_number} has invalid extension row") + stem, archive = parts[1], parts[2] + expected_name = f"liboliphaunt_extension_{stem}.a" + path = require_existing_path(archive, line_number, "extension") + expected_relative = static_registry.get(f"module.{stem}.archive.{target}") + if not expected_relative: + fail(f"{rel(artifact.path)} static registry manifest has no module.{stem}.archive.{target} entry") + if path.name != expected_name: + fail(f"{rel(evidence_path)}:{line_number} archive {archive!r} does not match stem {stem!r}") + if not path.as_posix().endswith(expected_relative): + fail( + f"{rel(evidence_path)}:{line_number} archive {archive!r} does not match " + f"static-registry path {expected_relative!r}" + ) + linked_stems.add(stem) + elif kind == "dependency": + if len(parts) != 3 or not parts[1]: + fail(f"{rel(evidence_path)}:{line_number} has invalid dependency row") + dependency_name = parts[1] + path = require_existing_path(parts[2], line_number, "dependency") + expected_relative = static_registry.get(f"dependency.{dependency_name}.archive.{target}") + if not expected_relative: + fail( + f"{rel(evidence_path)}:{line_number} dependency {dependency_name!r} is not declared " + f"by the static-registry manifest for {target}" + ) + if not path.as_posix().endswith(expected_relative): + fail( + f"{rel(evidence_path)}:{line_number} dependency path {parts[2]!r} does not match " + f"static-registry path {expected_relative!r}" + ) + linked_dependencies.add(dependency_name) + else: + fail(f"{rel(evidence_path)}:{line_number} has unknown row kind {kind!r}") + if schema_rows != 1: + fail(f"{rel(evidence_path)} must contain exactly one schema row") + if abi_rows != 1: + fail(f"{rel(evidence_path)} must contain exactly one abi row") + if evidence_abi != expected_abi: + fail(f"{rel(evidence_path)} declares abi={evidence_abi!r}, expected {expected_abi!r}") + if not runtime_path: + fail(f"{rel(evidence_path)} does not show liboliphaunt runtime link input") + expected_stems = set(stems) + missing = sorted(expected_stems - linked_stems) + if missing: + fail( + f"{rel(evidence_path)} does not show selected Android extension archive link input(s): " + f"{', '.join(missing)}" + ) + unexpected = sorted(linked_stems - expected_stems) + if unexpected: + fail( + f"{rel(evidence_path)} shows unselected Android extension archive link input(s): " + f"{', '.join(unexpected)}" + ) + expected_dependencies = set(csv_values(static_registry.get("dependencyArchives"))) + missing_dependencies = sorted(expected_dependencies - linked_dependencies) + if missing_dependencies: + fail( + f"{rel(evidence_path)} does not show required Android extension dependency archive link input(s): " + f"{', '.join(missing_dependencies)}" + ) + unexpected_dependencies = sorted(linked_dependencies - expected_dependencies) + if unexpected_dependencies: + fail( + f"{rel(evidence_path)} shows unselected Android extension dependency archive link input(s): " + f"{', '.join(unexpected_dependencies)}" + ) + + +def check_mobile_artifact(artifact: MobileArtifact, *, require_prebuilt_extensions: bool) -> None: + prefix = mobile_prefix(artifact.platform) + runtime_manifest_name = f"{prefix}runtime/manifest.properties" + static_registry_manifest_name = f"{prefix}static-registry/manifest.properties" + package_size_name = f"{prefix}package-size.tsv" + + runtime = read_properties_text(artifact.read_text(runtime_manifest_name)) + if runtime.get("schema") != "oliphaunt-runtime-resources-v1": + fail(f"{rel(artifact.path)} has invalid runtime resource manifest schema") + selected = csv_values(runtime.get("extensions")) + selected_set = set(selected) + rows = generated_extension_rows() + target = mobile_target_for_artifact(artifact) + + report_path = MOBILE_ROOT / artifact.platform / "build-report.json" + report = mobile_build_report(artifact.platform) + if report is None: + fail(f"{rel(artifact.path)} requires mobile build report {rel(report_path)}") + report_artifact = resolve_report_path(report.get("appArtifact"), report_path, "appArtifact") + if report_artifact.resolve() != artifact.path.resolve(): + fail(f"{rel(report_path)} appArtifact={report_artifact} does not match inspected artifact {artifact.path}") + if report.get("appArtifactBytes") != path_bytes(artifact.path): + fail(f"{rel(report_path)} appArtifactBytes does not match inspected artifact size") + selected_from_report = report.get("selectedExtensions") + if not isinstance(selected_from_report, list): + fail(f"{rel(report_path)} selectedExtensions must be an array") + report_selected = sorted(str(value) for value in selected_from_report if str(value)) + if report_selected != sorted(selected): + fail(f"{rel(report_path)} selectedExtensions={report_selected} must match runtime manifest {sorted(selected)}") + if artifact.platform == "android": + expected_abi = "arm64-v8a" if target == "android-arm64-v8a" else "x86_64" + if report.get("abi") != expected_abi: + fail(f"{rel(report_path)} abi={report.get('abi')!r}, expected {expected_abi!r}") + else: + expected_abi = "" + + extension_asset_names = [ + name + for name in artifact.names + if f"{prefix}runtime/files/share/postgresql/extension/" in name + and (name.endswith(".control") or name.endswith(".sql")) + ] + present_extensions = {extension for name in extension_asset_names if (extension := extension_name_for_asset(name))} + unexpected = sorted(present_extensions - selected_set) + if unexpected: + fail(f"{rel(artifact.path)} includes unselected extension assets: {', '.join(unexpected)}") + for extension in selected: + if creates_extension(extension, rows): + has_control = any(name.endswith(f"/{extension}.control") for name in extension_asset_names) + has_sql = any(f"/{extension}--" in name and name.endswith(".sql") for name in extension_asset_names) + if not has_control or not has_sql: + fail(f"{rel(artifact.path)} is missing selected {extension} control/SQL assets") + if require_prebuilt_extensions: + check_extension_package_has_mobile_target(extension, target) + + stems = sorted(stem for extension in selected if (stem := native_module_stem(extension, rows)) and stem != "-") + static_registry = read_properties_text(artifact.read_text(static_registry_manifest_name)) + registered = sorted(csv_values(static_registry.get("registeredExtensions"))) + native_selected = native_module_extensions(selected, rows) + if stems: + if runtime.get("mobileStaticRegistryState") != "complete": + fail(f"{rel(artifact.path)} must mark mobile static registry complete for native-module extensions") + if registered != native_selected: + fail(f"{rel(artifact.path)} static registry registeredExtensions={registered}, expected {native_selected}") + if artifact.platform == "android" and not any(name.endswith("/liboliphaunt_extensions.so") for name in artifact.names): + fail(f"{rel(artifact.path)} Android app is missing liboliphaunt_extensions.so") + if artifact.platform == "android" and require_prebuilt_extensions: + check_android_prebuilt_extension_linkage(artifact, stems, report, report_path, expected_abi, static_registry, target) + if artifact.platform == "ios" and require_prebuilt_extensions: + check_ios_prebuilt_extension_linkage(artifact, stems) + if any("static-registry/archives/" in name for name in artifact.names): + fail(f"{rel(artifact.path)} must not ship build-only static-registry archives") + else: + if runtime.get("mobileStaticRegistryState") not in {"", "not-required"}: + fail(f"{rel(artifact.path)} must not claim a static registry for SQL-only extensions") + + package_size = artifact.read_text(package_size_name) + extension_rows = [ + line.split("\t") + for line in package_size.splitlines() + if line.startswith("extension\t") + ] + package_size_extensions = sorted(parts[1] for parts in extension_rows if len(parts) >= 2) + if package_size_extensions != sorted(selected): + fail( + f"{rel(artifact.path)} package-size extension rows {package_size_extensions} " + f"must exactly match selected extensions {sorted(selected)}" + ) + print(f"validated mobile app extension contents: {artifact.platform} {rel(artifact.path)}") + + +def check_mobile_platform(platform: str, *, require: bool, require_prebuilt_extensions: bool) -> bool: + artifacts = discover_mobile_artifacts(platform) + if not artifacts: + if require: + fail(f"missing staged React Native {platform} mobile app artifacts under {rel(MOBILE_ROOT / platform)}") + return False + for artifact in artifacts: + check_mobile_artifact(artifact, require_prebuilt_extensions=require_prebuilt_extensions) + return True + + +def expand_products(values: list[str], *, all_products: set[str], label: str) -> list[str]: + expanded: list[str] = [] + for value in values: + if value == "all": + expanded.extend(sorted(all_products)) + else: + if value not in all_products: + fail(f"unknown {label} {value}; expected one of: all, {', '.join(sorted(all_products))}") + expanded.append(value) + return sorted(set(expanded)) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--require-sdk-product", action="append", default=[], help="SDK product to require, or all") + parser.add_argument( + "--require-extension-product", + action="append", + default=[], + help="exact-extension product to require, or all", + ) + parser.add_argument( + "--require-full-extension-targets", + action="store_true", + help="require exact-extension packages to contain every published target", + ) + parser.add_argument( + "--require-mobile", + action="append", + default=[], + choices=["android", "ios", "all"], + help="mobile app artifact platform to require", + ) + parser.add_argument( + "--require-mobile-prebuilt-extensions", + action="store_true", + help="mobile artifacts must have matching staged exact-extension packages for their selected extensions", + ) + parser.add_argument( + "--inspect-present", + action="store_true", + help="also inspect any present staged SDK, extension, and mobile artifacts", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + checked = 0 + + required_sdk_products = expand_products( + args.require_sdk_product, + all_products=SDK_PRODUCTS, + label="SDK product", + ) + for product in required_sdk_products: + checked += int(check_sdk_product(product, require=True)) + if args.inspect_present: + for product in sorted(SDK_PRODUCTS - set(required_sdk_products)): + checked += int(check_sdk_product(product, require=False)) + + extension_products = set(exact_extension_products()) + required_extension_products = expand_products( + args.require_extension_product, + all_products=extension_products, + label="exact-extension product", + ) + for product in required_extension_products: + checked += int( + check_extension_product( + product, + require=True, + require_full_targets=args.require_full_extension_targets, + ) + ) + if args.inspect_present: + for product in sorted(extension_products - set(required_extension_products)): + checked += int(check_extension_product(product, require=False, require_full_targets=False)) + + required_mobile = set() + for value in args.require_mobile: + if value == "all": + required_mobile.update({"android", "ios"}) + else: + required_mobile.add(value) + for platform in sorted(required_mobile): + checked += int( + check_mobile_platform( + platform, + require=True, + require_prebuilt_extensions=args.require_mobile_prebuilt_extensions, + ) + ) + if args.inspect_present: + for platform in sorted({"android", "ios"} - required_mobile): + checked += int( + check_mobile_platform( + platform, + require=False, + require_prebuilt_extensions=args.require_mobile_prebuilt_extensions, + ) + ) + + if checked == 0: + fail("no staged artifacts were checked; pass --require-* or --inspect-present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/check_wasm_crate_payloads.py b/tools/release/check_wasm_crate_payloads.py new file mode 100755 index 00000000..4cc789b6 --- /dev/null +++ b/tools/release/check_wasm_crate_payloads.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Validate that staged oliphaunt-wasix crates include generated payloads.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from typing import NoReturn + + +AOT_TARGETS = [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] + + +def fail(message: str) -> NoReturn: + print(f"check_wasm_crate_payloads.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def package_files(package: str, allow_dirty: bool) -> set[str]: + command = ["cargo", "package", "--list", "-p", package, "--locked"] + if allow_dirty: + command.append("--allow-dirty") + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + raise SystemExit(result.returncode) + return {line.strip() for line in result.stdout.splitlines() if line.strip()} + + +def require_package_entries(package: str, files: set[str], required: list[str]) -> None: + missing = sorted(entry for entry in required if entry not in files) + if missing: + fail(f"{package} package is missing generated release payload entries: {', '.join(missing)}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--allow-dirty", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + require_package_entries( + "oliphaunt-wasix-assets", + package_files("oliphaunt-wasix-assets", args.allow_dirty), + [ + "payload/manifest.json", + ], + ) + for target in AOT_TARGETS: + package = f"oliphaunt-wasix-aot-{target}" + require_package_entries( + package, + package_files(package, args.allow_dirty), + [ + "artifacts/manifest.json", + ], + ) + print("WASM crate generated payload package contents verified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/extension_artifact_targets.py b/tools/release/extension_artifact_targets.py new file mode 100644 index 00000000..1c6df613 --- /dev/null +++ b/tools/release/extension_artifact_targets.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Exact-extension release artifact target metadata.""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA = "oliphaunt-extension-artifact-targets-v1" +FAMILIES = {"native", "wasix"} +KINDS = { + "native-dynamic", + "native-static-registry", + "wasix-runtime", +} +STATUSES = {"supported", "planned", "unsupported"} + + +@dataclass(frozen=True) +class ExtensionArtifactTarget: + product: str + sql_name: str + target: str + family: str + kind: str + published: bool + status: str + source_file: Path + unsupported_reason: str | None = None + + +def _read_toml(path: Path) -> dict: + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as error: + product_metadata.fail(f"{path.relative_to(ROOT)} is invalid TOML: {error}") + if not isinstance(data, dict): + product_metadata.fail(f"{path.relative_to(ROOT)} must contain a TOML table") + return data + + +def _exact_extension_products() -> list[str]: + products: list[str] = [] + for product in product_metadata.product_ids(): + if product_metadata.product_config(product).get("kind") == "exact-extension-artifact": + products.append(product) + return sorted(products) + + +def _extension_sql_name(product: str) -> str: + value = product_metadata.product_config(product).get("extension_sql_name") + if not isinstance(value, str) or not value: + product_metadata.fail(f"{product} release.toml must declare extension_sql_name") + return value + + +def _bool(value: object, label: str) -> bool: + if isinstance(value, bool): + return value + product_metadata.fail(f"{label} must be true or false") + + +def _string(value: object, label: str) -> str: + if isinstance(value, str) and value: + return value + product_metadata.fail(f"{label} must be a non-empty string") + + +def artifact_target_file(product: str) -> Path: + return ROOT / product_metadata.package_path(product) / "targets" / "artifacts.toml" + + +def artifact_targets( + *, + product: str | None = None, + family: str | None = None, + published_only: bool = False, +) -> list[ExtensionArtifactTarget]: + products = [product] if product is not None else _exact_extension_products() + parsed: list[ExtensionArtifactTarget] = [] + for product_id in products: + if product_id not in product_metadata.product_ids(): + product_metadata.fail(f"unknown exact-extension product {product_id}") + if product_metadata.product_config(product_id).get("kind") != "exact-extension-artifact": + product_metadata.fail(f"{product_id} is not an exact-extension artifact product") + path = artifact_target_file(product_id) + if not path.is_file(): + product_metadata.fail(f"{product_id} must declare exact-extension artifact targets at {path.relative_to(ROOT)}") + data = _read_toml(path) + if data.get("schema") != SCHEMA: + product_metadata.fail(f"{path.relative_to(ROOT)} must use schema = {SCHEMA!r}") + rows = data.get("targets") + if not isinstance(rows, list) or not rows: + product_metadata.fail(f"{path.relative_to(ROOT)} must define [[targets]] rows") + sql_name = _extension_sql_name(product_id) + seen: set[tuple[str, str, str]] = set() + for index, row in enumerate(rows): + if not isinstance(row, dict): + product_metadata.fail(f"{path.relative_to(ROOT)} targets[{index}] must be a table") + target = _string(row.get("target"), f"{path.relative_to(ROOT)} targets[{index}].target") + target_family = _string(row.get("family"), f"{path.relative_to(ROOT)} targets[{index}].family") + kind = _string(row.get("kind"), f"{path.relative_to(ROOT)} targets[{index}].kind") + status = _string(row.get("status"), f"{path.relative_to(ROOT)} targets[{index}].status") + published = _bool(row.get("published"), f"{path.relative_to(ROOT)} targets[{index}].published") + if target_family not in FAMILIES: + product_metadata.fail(f"{path.relative_to(ROOT)} target {target} has invalid family {target_family!r}") + if kind not in KINDS: + product_metadata.fail(f"{path.relative_to(ROOT)} target {target} has invalid kind {kind!r}") + if status not in STATUSES: + product_metadata.fail(f"{path.relative_to(ROOT)} target {target} has invalid status {status!r}") + if target_family == "wasix" and kind != "wasix-runtime": + product_metadata.fail(f"{path.relative_to(ROOT)} target {target} must use kind wasix-runtime for wasix family") + if target_family == "native" and kind == "wasix-runtime": + product_metadata.fail(f"{path.relative_to(ROOT)} target {target} cannot use wasix-runtime for native family") + reason = row.get("unsupported_reason") + if published and status != "supported": + product_metadata.fail(f"{path.relative_to(ROOT)} target {target} cannot be published with status {status}") + if not published and (not isinstance(reason, str) or not reason): + product_metadata.fail(f"{path.relative_to(ROOT)} unpublished target {target} must explain unsupported_reason") + key = (target, target_family, kind) + if key in seen: + product_metadata.fail(f"{path.relative_to(ROOT)} has duplicate target row {key}") + seen.add(key) + if family is not None and target_family != family: + continue + if published_only and not published: + continue + parsed.append( + ExtensionArtifactTarget( + product=product_id, + sql_name=sql_name, + target=target, + family=target_family, + kind=kind, + published=published, + status=status, + source_file=path, + unsupported_reason=reason if isinstance(reason, str) else None, + ) + ) + return parsed + + +def published_target_ids(*, family: str) -> list[str]: + return sorted({target.target for target in artifact_targets(family=family, published_only=True)}) diff --git a/tools/release/liboliphaunt-extension-guard.sh b/tools/release/liboliphaunt-extension-guard.sh new file mode 100644 index 00000000..f78ed37c --- /dev/null +++ b/tools/release/liboliphaunt-extension-guard.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +oliphaunt_assert_base_runtime_has_no_optional_extensions() { + local catalog_file="${1:?missing extension catalog TSV}" + local runtime="${2:?missing runtime root}" + local extension_dir="$runtime/share/postgresql/extension" + local module_dir="$runtime/lib/postgresql" + local failures=() + local sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy + + while IFS=$'\t' read -r sql_name pg_major creates_extension stem dependencies shared_preload desktop_prebuilt mobile_prebuilt mobile_static_required mobile_static_targets data_files artifact_policy; do + [ -n "$sql_name" ] || continue + if [ -f "$extension_dir/$sql_name.control" ]; then + failures+=("control:$sql_name") + fi + if [ "$stem" != "-" ]; then + local suffix + for suffix in dylib so dll; do + if [ -f "$module_dir/$stem.$suffix" ]; then + failures+=("module:$stem.$suffix") + fi + done + fi + if [ "$data_files" != "-" ]; then + local data_file + IFS=',' read -r -a data_file_array <<<"$data_files" + for data_file in "${data_file_array[@]}"; do + [ -n "$data_file" ] || continue + if [ -e "$runtime/share/postgresql/$data_file" ]; then + failures+=("data:$data_file") + fi + done + fi + done < <(awk -F '\t' 'NR > 1 { print }' "$catalog_file") + + if [ "${#failures[@]}" -gt 0 ]; then + printf 'base liboliphaunt runtime contains optional extension artifact(s):\n' >&2 + printf ' %s\n' "${failures[@]}" >&2 + return 1 + fi +} diff --git a/tools/release/moon.yml b/tools/release/moon.yml new file mode 100644 index 00000000..f0062a48 --- /dev/null +++ b/tools/release/moon.yml @@ -0,0 +1,150 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "release-tools" +language: "python" +layer: "tool" +stack: "infrastructure" +tags: ["tools", "release"] +dependsOn: + - id: "postgres18" + scope: "build" + - id: "source-toolchains" + scope: "build" + - id: "third-party-shared" + scope: "build" + - id: "third-party-native" + scope: "build" + - id: "third-party-wasix" + scope: "build" + - id: "extensions" + scope: "build" + - id: "liboliphaunt-native" + scope: "build" + - id: "liboliphaunt-wasix" + scope: "build" + - id: "oliphaunt-rust" + scope: "build" + - id: "oliphaunt-broker" + scope: "build" + - id: "oliphaunt-node-direct" + scope: "build" + - id: "oliphaunt-swift" + scope: "build" + - id: "oliphaunt-kotlin" + scope: "build" + - id: "oliphaunt-react-native" + scope: "build" + - id: "oliphaunt-js" + scope: "build" + - id: "oliphaunt-wasix-rust" + scope: "build" + +project: + title: "Release Tools" + description: "Release-please identity, product-local metadata, packaging gates, and release workflow helpers." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "tools/release/release.py check" + inputs: + - "/.github/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/README.md" + - "/docs/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/policy/**/*" + - "/tools/release/**/*" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "tools/release/release.py check" + inputs: + - "/.github/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/README.md" + - "/docs/**/*" + - "/package.json" + - "/pnpm-lock.yaml" + - "/release-please-config.json" + - "/.release-please-manifest.json" + - "/src/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/policy/**/*" + - "/tools/release/**/*" + options: + cache: local + runFromWorkspaceRoot: true + consumer-shape: + tags: ["release", "package"] + command: "tools/release/release.py consumer-shape --format markdown" + inputs: + - "/src/shared/fixtures/consumer-shape/**/*" + - "/Cargo.lock" + - "/Cargo.toml" + - "/Package.swift" + - "/package.json" + - "/pnpm-lock.yaml" + - "/src/runtimes/liboliphaunt/native/**/*" + - "/src/runtimes/node-direct/**/*" + - "/src/sdks/rust/**/*" + - "/src/sdks/swift/**/*" + - "/src/sdks/kotlin/**/*" + - "/src/sdks/react-native/**/*" + - "!/src/sdks/react-native/**/node_modules" + - "!/src/sdks/react-native/**/node_modules/**" + - "/src/sdks/js/**/*" + - "/src/bindings/wasix-rust/**/*" + - "!/src/**/node_modules" + - "!/src/**/node_modules/**" + - "!/src/**/.build" + - "!/src/**/.build/**" + - "!/src/**/.gradle/**" + - "!/src/**/.cxx/**" + - "!/src/**/.next/**" + - "!/src/**/.source/**" + - "!/src/**/build/**" + - "!/src/**/out/**" + - "!/src/**/Pods/**" + - "!/src/**/DerivedData/**" + - "/tools/release/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/tools/release/package-broker-assets.sh b/tools/release/package-broker-assets.sh new file mode 100755 index 00000000..4a3d5b55 --- /dev/null +++ b/tools/release/package-broker-assets.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +version="$(python3 tools/release/product_metadata.py version oliphaunt-broker)" +out_dir="${OLIPHAUNT_BROKER_RELEASE_ASSETS:-$root/target/oliphaunt-broker/release-assets}" +stage_root="$root/target/oliphaunt-broker/release-stage" +host_os="$(uname -s)" +host_arch="$(uname -m)" + +fail() { + echo "package-broker-assets.sh: $*" >&2 + exit 1 +} + +case "$host_os:$host_arch" in + Darwin:arm64) target_id="macos-arm64" ;; + Linux:x86_64|Linux:amd64) target_id="linux-x64-gnu" ;; + Linux:aarch64|Linux:arm64) target_id="linux-arm64-gnu" ;; + MINGW*:x86_64|MSYS*:x86_64|CYGWIN*:x86_64) target_id="windows-x64-msvc" ;; + *) fail "unsupported oliphaunt-broker release asset host $host_os/$host_arch" ;; +esac + +asset_extension="tar.gz" +broker_stage_name="oliphaunt-broker" +if [ "$target_id" = "windows-x64-msvc" ]; then + asset_extension="zip" + broker_stage_name="oliphaunt-broker.exe" +fi + +cargo_target_dir="${CARGO_TARGET_DIR:-$root/target}" +case "$cargo_target_dir" in + /*) ;; + *) cargo_target_dir="$root/$cargo_target_dir" ;; +esac +broker_bin="$cargo_target_dir/release/$broker_stage_name" + +asset="oliphaunt-broker-${version}-${target_id}.${asset_extension}" +checksum_asset="oliphaunt-broker-${version}-release-assets.sha256" +stage="$stage_root/oliphaunt-broker-${version}-${target_id}" + +rm -rf "$stage_root" "$out_dir" +mkdir -p "$stage/bin" "$out_dir" + +cargo build -p oliphaunt-broker --release --locked +[ -x "$broker_bin" ] || fail "missing broker helper at $broker_bin" + +cp "$broker_bin" "$stage/bin/$broker_stage_name" +chmod 0755 "$stage/bin/$broker_stage_name" +cat >"$stage/manifest.properties" <&2 + exit 1 +} +cd "$root" + +fail() { + echo "package-liboliphaunt-aggregate-assets.sh: $*" >&2 + exit 1 +} + +asset_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-target/liboliphaunt/release-assets}" +[ -d "$asset_dir" ] || fail "missing liboliphaunt release asset directory: $asset_dir" + +version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +checksum_file="$asset_dir/liboliphaunt-${version}-release-assets.sha256" + +python3 - "$asset_dir" "$checksum_file" <<'PY' +from __future__ import annotations + +import hashlib +import sys +from pathlib import Path + +asset_dir = Path(sys.argv[1]) +checksum_file = Path(sys.argv[2]) +payloads = sorted( + path + for path in asset_dir.iterdir() + if path.is_file() + and path != checksum_file + and ( + path.name.endswith(".tar.gz") + or path.name.endswith(".tar.zst") + or path.name.endswith(".zip") + or path.name.endswith(".tsv") + ) +) +if not payloads: + raise SystemExit(f"no liboliphaunt release payload assets found in {asset_dir}") + +lines = [] +for path in payloads: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + lines.append(f"{digest} ./{path.name}\n") +checksum_file.write_text("".join(lines), encoding="utf-8") +PY + +tools/release/check_liboliphaunt_release_assets.py --asset-dir "$asset_dir" diff --git a/tools/release/package-liboliphaunt-assets.sh b/tools/release/package-liboliphaunt-assets.sh new file mode 100755 index 00000000..129cac39 --- /dev/null +++ b/tools/release/package-liboliphaunt-assets.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "package-liboliphaunt-assets.sh: $*" >&2 + exit 1 +} + +require() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +source "$root/tools/release/liboliphaunt-extension-guard.sh" + +require awk +require cargo +require ditto +require python3 +require rsync +require shasum + +version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +out_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-$root/target/liboliphaunt/release-assets}" +stage_root="$root/target/liboliphaunt/release-stage" +headers_dir="$root/src/runtimes/liboliphaunt/native/include" +macos_work_root="${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18}" +ios_work_root="${OLIPHAUNT_IOS_XCFRAMEWORK_ROOT:-$root/target/liboliphaunt-ios-xcframework}" +android_arm64_work_root="${OLIPHAUNT_ANDROID_ARM64_ROOT:-$root/target/liboliphaunt-pg18-android-arm64}" +android_x86_64_work_root="${OLIPHAUNT_ANDROID_X86_64_ROOT:-$root/target/liboliphaunt-pg18-android-x86_64}" + +if [ "$(uname -s)" != "Darwin" ]; then + fail "liboliphaunt release assets require macOS so iOS XCFrameworks and Android NDK Darwin toolchains are validated together" +fi + +rm -rf "$out_dir" "$stage_root" +mkdir -p "$out_dir" "$stage_root" + +catalog_file="$stage_root/extension-catalog.tsv" + +require_file() { + local path="$1" + local description="$2" + [ -f "$path" ] || fail "missing $description at $path" +} + +require_dir() { + local path="$1" + local description="$2" + [ -d "$path" ] || fail "missing $description at $path" +} + +merge_release_asset_input_dirs() { + local input_dirs="${OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS:-}" + [ -n "$input_dirs" ] || return 0 + local input_dir asset + IFS=':' read -r -a input_dir_array <<<"$input_dirs" + for input_dir in "${input_dir_array[@]}"; do + [ -n "$input_dir" ] || continue + [ -d "$input_dir" ] || fail "release asset input directory does not exist: $input_dir" + while IFS= read -r asset; do + [ -n "$asset" ] || continue + cp -p "$asset" "$out_dir/" + done < <(find "$input_dir" -maxdepth 1 -type f \( -name 'liboliphaunt-*.tar.gz' -o -name 'liboliphaunt-*.zip' -o -name 'liboliphaunt-*.tsv' -o -name 'liboliphaunt-*.tar.zst' \) -print | sort) + done +} + +archive_staged_dir() { + local staged="$1" + local name + name="$(basename "$staged")" + tools/release/archive_dir.py "$staged" "$out_dir/${name}.tar.gz" +} + +archive_swiftpm_xcframework() { + local xcframework="$1" + local output="$2" + [ -d "$xcframework" ] || fail "missing SwiftPM XCFramework input at $xcframework" + rm -f "$output" + ( + cd "$(dirname "$xcframework")" + ditto -c -k --keepParent "$(basename "$xcframework")" "$output" + ) +} + +fetch_release_source_assets() { + if [ "${OLIPHAUNT_RELEASE_FETCH_ASSETS:-1}" = "0" ]; then + return 0 + fi + echo "==> Fetching pinned native runtime source assets" + bun tools/policy/fetch-sources.mjs native-runtime >/tmp/liboliphaunt-release-assets-fetch.log +} + +fetch_release_source_assets + +echo "==> Building liboliphaunt macOS" +src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh >/tmp/liboliphaunt-release-macos.log +macos_lib="$macos_work_root/out/liboliphaunt.dylib" +macos_runtime="$macos_work_root/install" +[ -f "$macos_lib" ] || fail "missing macOS liboliphaunt dylib at $macos_lib" +[ -d "$macos_runtime" ] || fail "missing macOS PostgreSQL runtime at $macos_runtime" + +echo "==> Reading exact extension catalog" +cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions >"$catalog_file" +oliphaunt_assert_base_runtime_has_no_optional_extensions "$catalog_file" "$macos_runtime" || + fail "base release runtime must not ship optional extension assets; selected extensions belong in exact extension artifacts" + +stage_runtime_resources="$stage_root/liboliphaunt-${version}-runtime-resources" +env OLIPHAUNT_INSTALL_DIR="$macos_runtime" \ + cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ + --output "$stage_runtime_resources" \ + --force >/tmp/liboliphaunt-release-runtime-resources.log +base_ios_runtime_resources="$stage_runtime_resources/oliphaunt" +[ -d "$base_ios_runtime_resources" ] || fail "runtime-resource package did not create $base_ios_runtime_resources" +require_file "$base_ios_runtime_resources/package-size.tsv" "base runtime package-size report" +cp "$base_ios_runtime_resources/package-size.tsv" "$out_dir/liboliphaunt-${version}-package-size.tsv" + +echo "==> Building liboliphaunt iOS XCFramework" +env OLIPHAUNT_IOS_RUNTIME_RESOURCES_ROOT="$base_ios_runtime_resources" \ + src/runtimes/liboliphaunt/native/bin/build-ios-xcframework.sh >/tmp/liboliphaunt-release-ios.log +ios_xcframework="$ios_work_root/out/liboliphaunt.xcframework" +[ -d "$ios_xcframework" ] || fail "missing iOS XCFramework at $ios_xcframework" + +echo "==> Building liboliphaunt Android arm64-v8a" +env OLIPHAUNT_ANDROID_ABI=arm64-v8a \ + OLIPHAUNT_ANDROID_ARM64_ROOT="$android_arm64_work_root" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-android-arm64.sh >/tmp/liboliphaunt-release-android-arm64.log +android_arm64_lib="$android_arm64_work_root/out/liboliphaunt.so" +[ -f "$android_arm64_lib" ] || fail "missing Android arm64-v8a liboliphaunt shared library at $android_arm64_lib" + +echo "==> Building liboliphaunt Android x86_64" +env OLIPHAUNT_ANDROID_ABI=x86_64 \ + OLIPHAUNT_ANDROID_X86_64_ROOT="$android_x86_64_work_root" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-android-x86_64.sh >/tmp/liboliphaunt-release-android-x86_64.log +android_x86_64_lib="$android_x86_64_work_root/out/liboliphaunt.so" +[ -f "$android_x86_64_lib" ] || fail "missing Android x86_64 liboliphaunt shared library at $android_x86_64_lib" + +stage_macos="$stage_root/liboliphaunt-${version}-macos-arm64" +mkdir -p "$stage_macos/include" "$stage_macos/lib" "$stage_macos/runtime" +rsync -a --delete "$headers_dir/" "$stage_macos/include/" +cp "$macos_lib" "$stage_macos/lib/" +rsync -a --delete "$macos_runtime/" "$stage_macos/runtime/" + +stage_ios="$stage_root/liboliphaunt-${version}-ios-xcframework" +mkdir -p "$stage_ios" +rsync -a --delete "$ios_xcframework" "$stage_ios/" +if [ -f "$ios_work_root/out/liboliphaunt_mobile_static_registry.c" ]; then + cp "$ios_work_root/out/liboliphaunt_mobile_static_registry.c" "$stage_ios/" +fi + +stage_android_arm64="$stage_root/liboliphaunt-${version}-android-arm64-v8a" +mkdir -p "$stage_android_arm64/include" "$stage_android_arm64/jni/arm64-v8a" +rsync -a --delete "$headers_dir/" "$stage_android_arm64/include/" +cp "$android_arm64_lib" "$stage_android_arm64/jni/arm64-v8a/" +if [ -f "$android_arm64_work_root/out/liboliphaunt_mobile_static_registry.c" ]; then + cp "$android_arm64_work_root/out/liboliphaunt_mobile_static_registry.c" "$stage_android_arm64/" +fi + +stage_android_x86_64="$stage_root/liboliphaunt-${version}-android-x86_64" +mkdir -p "$stage_android_x86_64/include" "$stage_android_x86_64/jni/x86_64" +rsync -a --delete "$headers_dir/" "$stage_android_x86_64/include/" +cp "$android_x86_64_lib" "$stage_android_x86_64/jni/x86_64/" +if [ -f "$android_x86_64_work_root/out/liboliphaunt_mobile_static_registry.c" ]; then + cp "$android_x86_64_work_root/out/liboliphaunt_mobile_static_registry.c" "$stage_android_x86_64/" +fi + +archive_staged_dir "$stage_macos" +archive_staged_dir "$stage_ios" +archive_swiftpm_xcframework \ + "$stage_ios/liboliphaunt.xcframework" \ + "$out_dir/liboliphaunt-${version}-apple-spm-xcframework.zip" +archive_staged_dir "$stage_android_arm64" +archive_staged_dir "$stage_android_x86_64" +archive_staged_dir "$stage_runtime_resources" + +merge_release_asset_input_dirs + +( + cd "$out_dir" + find . -maxdepth 1 -type f \ + \( -name '*.tar.gz' -o -name '*.tar.zst' -o -name '*.tsv' -o -name '*.zip' \) \ + -print0 | + sort -z | + xargs -0 shasum -a 256 > "liboliphaunt-${version}-release-assets.sha256" +) + +tools/release/check_liboliphaunt_release_assets.py --asset-dir "$out_dir" + +echo "liboliphauntReleaseAssetDir=$out_dir" diff --git a/tools/release/package-liboliphaunt-linux-assets.sh b/tools/release/package-liboliphaunt-linux-assets.sh new file mode 100755 index 00000000..5073a25b --- /dev/null +++ b/tools/release/package-liboliphaunt-linux-assets.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "package-liboliphaunt-linux-assets.sh: $*" >&2 + exit 1 +} + +fetch_release_source_assets() { + if [ "${OLIPHAUNT_RELEASE_FETCH_ASSETS:-1}" = "0" ]; then + return 0 + fi + echo "==> Fetching pinned source assets" + bun tools/policy/fetch-sources.mjs native-runtime >/tmp/liboliphaunt-release-linux-assets-fetch.log +} + +if [ "$(uname -s)" != "Linux" ]; then + fail "Linux liboliphaunt release assets must be built on Linux" +fi + +case "$(uname -m)" in + x86_64|amd64) target_id="linux-x64-gnu" ;; + aarch64|arm64) target_id="linux-arm64-gnu" ;; + *) fail "unsupported Linux architecture $(uname -m)" ;; +esac + +version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +out_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-$root/target/liboliphaunt/release-assets}" +stage_root="$root/target/liboliphaunt/release-stage-$target_id" +work_root="${OLIPHAUNT_LINUX_WORK_ROOT:-$root/target/liboliphaunt-pg18-$target_id}" +headers_dir="$root/src/runtimes/liboliphaunt/native/include" +lib="$work_root/out/liboliphaunt.so" +runtime="$work_root/install" +stage="$stage_root/liboliphaunt-${version}-${target_id}" +asset="liboliphaunt-${version}-${target_id}.tar.gz" + +rm -rf "$stage_root" +mkdir -p "$out_dir" "$stage/include" "$stage/lib" "$stage/runtime" + +fetch_release_source_assets + +echo "==> Building liboliphaunt $target_id" +src/runtimes/liboliphaunt/native/bin/build-postgres18-linux.sh >/tmp/liboliphaunt-release-"$target_id".log + +[ -f "$lib" ] || fail "missing Linux liboliphaunt shared library at $lib" +[ -x "$runtime/bin/initdb" ] || fail "missing Linux initdb at $runtime/bin/initdb" +[ -x "$runtime/bin/postgres" ] || fail "missing Linux postgres at $runtime/bin/postgres" + +rsync -a --delete "$headers_dir/" "$stage/include/" +cp "$lib" "$stage/lib/" +rsync -a --delete "$runtime/" "$stage/runtime/" + +echo "==> Smoke testing staged liboliphaunt $target_id release layout" +env \ + OLIPHAUNT_WORK_ROOT="$work_root" \ + LIBOLIPHAUNT_PATH="$stage/lib/liboliphaunt.so" \ + OLIPHAUNT_INSTALL_DIR="$stage/runtime" \ + OLIPHAUNT_SMOKE_BIN_DIR="$stage_root/smoke-bin-$target_id" \ + OLIPHAUNT_SMOKE_ROOT="$stage_root/smoke-root-$target_id" \ + node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs + +tools/release/archive_dir.py "$stage" "$out_dir/$asset" +echo "liboliphauntLinuxReleaseAsset=$out_dir/$asset" diff --git a/tools/release/package-liboliphaunt-macos-assets.sh b/tools/release/package-liboliphaunt-macos-assets.sh new file mode 100755 index 00000000..3a6cd815 --- /dev/null +++ b/tools/release/package-liboliphaunt-macos-assets.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" +source "$root/tools/release/liboliphaunt-extension-guard.sh" + +fail() { + echo "package-liboliphaunt-macos-assets.sh: $*" >&2 + exit 1 +} + +fetch_release_source_assets() { + if [ "${OLIPHAUNT_RELEASE_FETCH_ASSETS:-1}" = "0" ]; then + return 0 + fi + echo "==> Fetching pinned source assets" + bun tools/policy/fetch-sources.mjs native-runtime >/tmp/liboliphaunt-release-macos-assets-fetch.log +} + +if [ "$(uname -s)" != "Darwin" ]; then + fail "macOS liboliphaunt release assets must be built on macOS" +fi + +case "$(uname -m)" in + arm64|aarch64) target_id="macos-arm64" ;; + *) fail "unsupported macOS architecture $(uname -m)" ;; +esac + +version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +out_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-$root/target/liboliphaunt/release-assets}" +stage_root="$root/target/liboliphaunt/release-stage-$target_id" +work_root="${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18}" +headers_dir="$root/src/runtimes/liboliphaunt/native/include" +lib="$work_root/out/liboliphaunt.dylib" +runtime="$work_root/install" +stage="$stage_root/liboliphaunt-${version}-${target_id}" +asset="liboliphaunt-${version}-${target_id}.tar.gz" +catalog_file="$stage_root/extension-catalog.tsv" + +rm -rf "$stage_root" +mkdir -p "$out_dir" "$stage/include" "$stage/lib" "$stage/runtime" + +fetch_release_source_assets + +echo "==> Building liboliphaunt $target_id" +OLIPHAUNT_BUILD_EXTENSIONS="${OLIPHAUNT_BUILD_EXTENSIONS:-0}" \ + src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh >/tmp/liboliphaunt-release-"$target_id".log + +[ -f "$lib" ] || fail "missing macOS liboliphaunt dylib at $lib" +[ -x "$runtime/bin/initdb" ] || fail "missing macOS initdb at $runtime/bin/initdb" +[ -x "$runtime/bin/postgres" ] || fail "missing macOS postgres at $runtime/bin/postgres" + +echo "==> Verifying base liboliphaunt $target_id runtime is extension-clean" +cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions >"$catalog_file" +oliphaunt_assert_base_runtime_has_no_optional_extensions "$catalog_file" "$runtime" || + fail "base $target_id runtime must not ship optional extension assets" + +rsync -a --delete "$headers_dir/" "$stage/include/" +cp "$lib" "$stage/lib/" +rsync -a --delete "$runtime/" "$stage/runtime/" + +echo "==> Smoke testing staged liboliphaunt $target_id release layout" +env \ + OLIPHAUNT_WORK_ROOT="$work_root" \ + LIBOLIPHAUNT_PATH="$stage/lib/liboliphaunt.dylib" \ + OLIPHAUNT_INSTALL_DIR="$stage/runtime" \ + OLIPHAUNT_SMOKE_BIN_DIR="$stage_root/smoke-bin-$target_id" \ + OLIPHAUNT_SMOKE_ROOT="$stage_root/smoke-root-$target_id" \ + node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs + +tools/release/archive_dir.py "$stage" "$out_dir/$asset" +echo "liboliphauntMacosReleaseAsset=$out_dir/$asset" diff --git a/tools/release/package-liboliphaunt-mobile-assets.sh b/tools/release/package-liboliphaunt-mobile-assets.sh new file mode 100755 index 00000000..fcdcb5a8 --- /dev/null +++ b/tools/release/package-liboliphaunt-mobile-assets.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "must run inside the Oliphaunt git checkout" >&2 + exit 1 +} +cd "$root" + +fail() { + echo "package-liboliphaunt-mobile-assets.sh: $*" >&2 + exit 1 +} + +require() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +source "$root/tools/release/liboliphaunt-extension-guard.sh" + +require cargo +require python3 +require rsync + +target_id="${1:-}" +case "$target_id" in + android-arm64-v8a|android-x86_64|ios-xcframework) + ;; + *) + fail "usage: tools/release/package-liboliphaunt-mobile-assets.sh [android-arm64-v8a|android-x86_64|ios-xcframework]" + ;; +esac + +if [ "$target_id" = "ios-xcframework" ]; then + require ditto +fi + +version="$(python3 tools/release/product_metadata.py version liboliphaunt-native)" +out_dir="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS:-$root/target/liboliphaunt/release-assets}" +stage_root="${OLIPHAUNT_LIBOLIPHAUNT_RELEASE_STAGE_ROOT:-$root/target/liboliphaunt/release-stage-$target_id}" +headers_dir="$root/src/runtimes/liboliphaunt/native/include" + +rm -rf "$stage_root" +mkdir -p "$out_dir" "$stage_root" + +archive_staged_dir() { + local staged="$1" + local name + name="$(basename "$staged")" + tools/release/archive_dir.py "$staged" "$out_dir/${name}.tar.gz" +} + +archive_swiftpm_xcframework() { + local xcframework="$1" + local output="$2" + [ -d "$xcframework" ] || fail "missing SwiftPM XCFramework input at $xcframework" + rm -f "$output" + ( + cd "$(dirname "$xcframework")" + ditto -c -k --keepParent "$(basename "$xcframework")" "$output" + ) +} + +package_android() { + local abi="$1" + local work_root="$2" + local lib="$work_root/out/liboliphaunt.so" + local stage="$stage_root/liboliphaunt-${version}-android-${abi}" + + [ -f "$lib" ] || fail "missing Android $abi liboliphaunt shared library at $lib" + + mkdir -p "$stage/include" "$stage/jni/$abi" + rsync -a --delete "$headers_dir/" "$stage/include/" + cp "$lib" "$stage/jni/$abi/" + if [ -f "$work_root/out/liboliphaunt_mobile_static_registry.c" ]; then + cp "$work_root/out/liboliphaunt_mobile_static_registry.c" "$stage/" + fi + archive_staged_dir "$stage" +} + +package_ios() { + local ios_work_root="${OLIPHAUNT_IOS_XCFRAMEWORK_ROOT:-$root/target/liboliphaunt-ios-xcframework}" + local macos_work_root="${OLIPHAUNT_WORK_ROOT:-$root/target/liboliphaunt-pg18}" + local ios_xcframework="$ios_work_root/out/liboliphaunt.xcframework" + local macos_runtime="$macos_work_root/install" + local catalog_file="$stage_root/extension-catalog.tsv" + local runtime_stage="$stage_root/liboliphaunt-${version}-runtime-resources" + local stage_ios="$stage_root/liboliphaunt-${version}-ios-xcframework" + + [ -d "$ios_xcframework" ] || fail "missing iOS XCFramework at $ios_xcframework" + [ -d "$macos_runtime" ] || fail "missing macOS PostgreSQL runtime at $macos_runtime" + + cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions >"$catalog_file" + oliphaunt_assert_base_runtime_has_no_optional_extensions "$catalog_file" "$macos_runtime" || + fail "base iOS release runtime must not ship optional extension assets; selected extensions belong in exact extension artifacts" + + env OLIPHAUNT_INSTALL_DIR="$macos_runtime" \ + cargo run -p oliphaunt --bin oliphaunt-resources --locked -- \ + --output "$runtime_stage" \ + --force >/tmp/liboliphaunt-release-mobile-runtime-resources.log + local base_runtime_resources="$runtime_stage/oliphaunt" + [ -d "$base_runtime_resources" ] || fail "runtime-resource package did not create $base_runtime_resources" + [ -f "$base_runtime_resources/package-size.tsv" ] || fail "missing base runtime package-size report" + cp "$base_runtime_resources/package-size.tsv" "$out_dir/liboliphaunt-${version}-package-size.tsv" + + mkdir -p "$stage_ios" + rsync -a --delete "$ios_xcframework" "$stage_ios/" + if [ -f "$ios_work_root/out/liboliphaunt_mobile_static_registry.c" ]; then + cp "$ios_work_root/out/liboliphaunt_mobile_static_registry.c" "$stage_ios/" + fi + + archive_staged_dir "$stage_ios" + archive_swiftpm_xcframework \ + "$stage_ios/liboliphaunt.xcframework" \ + "$out_dir/liboliphaunt-${version}-apple-spm-xcframework.zip" + archive_staged_dir "$runtime_stage" +} + +case "$target_id" in + android-arm64-v8a) + package_android arm64-v8a "${OLIPHAUNT_ANDROID_ARM64_ROOT:-$root/target/liboliphaunt-pg18-android-arm64}" + ;; + android-x86_64) + package_android x86_64 "${OLIPHAUNT_ANDROID_X86_64_ROOT:-$root/target/liboliphaunt-pg18-android-x86_64}" + ;; + ios-xcframework) + package_ios + ;; +esac + +echo "liboliphauntMobileReleaseAssetDir=$out_dir" diff --git a/tools/release/package-liboliphaunt-windows-assets.ps1 b/tools/release/package-liboliphaunt-windows-assets.ps1 new file mode 100644 index 00000000..83742acc --- /dev/null +++ b/tools/release/package-liboliphaunt-windows-assets.ps1 @@ -0,0 +1,149 @@ +param() + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$Root = git rev-parse --show-toplevel +if ($LASTEXITCODE -ne 0 -or -not $Root) { + $Root = (Get-Location).Path +} +Set-Location $Root + +function Fail($Message) { + Write-Error "package-liboliphaunt-windows-assets.ps1: $Message" + exit 1 +} + +function Assert-BaseRuntimeHasNoOptionalExtensions($CatalogFile, $RuntimeRoot) { + $extensionDir = Join-Path $RuntimeRoot "share/postgresql/extension" + $moduleDir = Join-Path $RuntimeRoot "lib/postgresql" + $failures = New-Object System.Collections.Generic.List[string] + $rows = Get-Content $CatalogFile | Select-Object -Skip 1 + foreach ($row in $rows) { + if (-not $row) { + continue + } + $columns = $row -split "`t", 12 + if ($columns.Count -lt 12) { + Fail "malformed extension catalog row in $CatalogFile`: $row" + } + $sqlName = $columns[0] + $stem = $columns[3] + $dataFiles = $columns[10] + if (Test-Path (Join-Path $extensionDir "$sqlName.control")) { + $failures.Add("control:$sqlName") | Out-Null + } + if ($stem -and $stem -ne "-") { + foreach ($suffix in @("dll", "so", "dylib")) { + if (Test-Path (Join-Path $moduleDir "$stem.$suffix")) { + $failures.Add("module:$stem.$suffix") | Out-Null + } + } + } + if ($dataFiles -and $dataFiles -ne "-") { + foreach ($dataFile in $dataFiles.Split(",")) { + if ($dataFile -and (Test-Path (Join-Path (Join-Path $RuntimeRoot "share/postgresql") $dataFile))) { + $failures.Add("data:$dataFile") | Out-Null + } + } + } + } + if ($failures.Count -gt 0) { + $joined = [string]::Join(", ", $failures) + Fail "base Windows liboliphaunt runtime contains optional extension artifact(s): $joined" + } +} + +if (-not $IsWindows) { + Fail "Windows liboliphaunt release assets must be built on Windows" +} + +if ($env:OLIPHAUNT_RELEASE_FETCH_ASSETS -ne "0") { + Write-Output "==> Fetching pinned source assets" + bun tools/policy/fetch-sources.mjs native-runtime *> "$env:TEMP\liboliphaunt-release-windows-assets-fetch.log" + if ($LASTEXITCODE -ne 0) { + Fail "failed to fetch pinned source assets" + } +} + +$Version = python tools/release/product_metadata.py version liboliphaunt-native +if ($LASTEXITCODE -ne 0 -or -not $Version) { + Fail "failed to read liboliphaunt version" +} +$Version = $Version.Trim() +$TargetId = "windows-x64-msvc" +$OutDir = if ($env:OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS) { + $env:OLIPHAUNT_LIBOLIPHAUNT_RELEASE_ASSETS +} else { + Join-Path $Root "target/liboliphaunt/release-assets" +} +$StageRoot = Join-Path $Root "target/liboliphaunt/release-stage-$TargetId" +$CatalogFile = Join-Path $StageRoot "extension-catalog.tsv" +$WorkRoot = if ($env:OLIPHAUNT_WINDOWS_WORK_ROOT) { + $env:OLIPHAUNT_WINDOWS_WORK_ROOT +} elseif ($env:OLIPHAUNT_WORK_ROOT) { + $env:OLIPHAUNT_WORK_ROOT +} else { + Join-Path $Root "target/liboliphaunt-pg18-$TargetId" +} +$HeadersDir = Join-Path $Root "src/runtimes/liboliphaunt/native/include" +$Dll = Join-Path $WorkRoot "out/bin/oliphaunt.dll" +$ImportLib = Join-Path $WorkRoot "out/lib/oliphaunt.lib" +$Runtime = Join-Path $WorkRoot "install" +$Stage = Join-Path $StageRoot "liboliphaunt-$Version-$TargetId" +$Asset = "liboliphaunt-$Version-$TargetId.zip" + +Remove-Item -Recurse -Force $StageRoot -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $OutDir, (Join-Path $Stage "include"), (Join-Path $Stage "bin"), (Join-Path $Stage "lib"), (Join-Path $Stage "runtime") | Out-Null + +Write-Output "==> Building liboliphaunt $TargetId" +pwsh -NoProfile -ExecutionPolicy Bypass -File src/runtimes/liboliphaunt/native/bin/build-postgres18-windows.ps1 *> "$env:TEMP\liboliphaunt-release-$TargetId.log" +if ($LASTEXITCODE -ne 0) { + Get-Content "$env:TEMP\liboliphaunt-release-$TargetId.log" -Tail 160 | Write-Error + Fail "failed to build liboliphaunt $TargetId" +} + +if (-not (Test-Path $Dll)) { + Fail "missing Windows liboliphaunt DLL at $Dll" +} +if (-not (Test-Path $ImportLib)) { + Fail "missing Windows liboliphaunt import library at $ImportLib" +} +if (-not (Test-Path (Join-Path $Runtime "bin/initdb.exe"))) { + Fail "missing Windows initdb at $(Join-Path $Runtime "bin/initdb.exe")" +} +if (-not (Test-Path (Join-Path $Runtime "bin/postgres.exe"))) { + Fail "missing Windows postgres at $(Join-Path $Runtime "bin/postgres.exe")" +} + +Write-Output "==> Verifying base liboliphaunt $TargetId runtime is extension-clean" +cargo run -p oliphaunt --bin oliphaunt-resources --locked -- --list-extensions > $CatalogFile +if ($LASTEXITCODE -ne 0) { + Fail "failed to read exact extension catalog" +} +Assert-BaseRuntimeHasNoOptionalExtensions $CatalogFile $Runtime + +Copy-Item -Recurse -Force (Join-Path $HeadersDir "*") (Join-Path $Stage "include") +Copy-Item -Force $Dll (Join-Path $Stage "bin") +Copy-Item -Force $ImportLib (Join-Path $Stage "lib") +Copy-Item -Recurse -Force (Join-Path $Runtime "*") (Join-Path $Stage "runtime") + +Write-Output "==> Smoke testing staged liboliphaunt $TargetId release layout" +$SmokeRoot = Join-Path $env:TEMP "liboliphaunt-release-smoke-$TargetId" +Remove-Item -Recurse -Force $SmokeRoot -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $SmokeRoot | Out-Null +$env:OLIPHAUNT_WORK_ROOT = $WorkRoot +$env:LIBOLIPHAUNT_PATH = Join-Path $Stage "bin/oliphaunt.dll" +$env:OLIPHAUNT_INSTALL_DIR = Join-Path $Stage "runtime" +$env:OLIPHAUNT_SMOKE_BIN_DIR = Join-Path $StageRoot "smoke-bin-$TargetId" +$env:OLIPHAUNT_SMOKE_ROOT = $SmokeRoot +node src/runtimes/liboliphaunt/native/tools/run-host-c-smoke.mjs +if ($LASTEXITCODE -ne 0) { + Fail "staged Windows liboliphaunt release smoke failed" +} + +python tools/release/archive_dir.py $Stage (Join-Path $OutDir $Asset) +if ($LASTEXITCODE -ne 0) { + Fail "failed to archive Windows liboliphaunt asset" +} +Write-Output "liboliphauntWindowsReleaseAsset=$(Join-Path $OutDir $Asset)" diff --git a/tools/release/product_metadata.py b/tools/release/product_metadata.py new file mode 100644 index 00000000..ee015769 --- /dev/null +++ b/tools/release/product_metadata.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""Shared release product metadata. + +Release identity comes from release-please manifest-mode config. Product-local +``release.toml`` files hold package and artifact metadata that release-please +does not own. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tomllib +from functools import lru_cache +from pathlib import Path +from typing import Any, NoReturn + + +ROOT = Path(__file__).resolve().parents[2] +RELEASE_PLEASE_CONFIG_PATH = ROOT / "release-please-config.json" +RELEASE_PLEASE_MANIFEST_PATH = ROOT / ".release-please-manifest.json" + + +def fail(message: str) -> NoReturn: + print(f"product_metadata.py: {message}", file=sys.stderr) + raise SystemExit(2) + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.is_file(): + fail(f"missing {path.relative_to(ROOT)}") + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + fail(f"{path.relative_to(ROOT)} must contain a JSON object") + return value + + +def _read_toml(path: Path) -> dict[str, Any]: + if not path.is_file(): + fail(f"missing {path.relative_to(ROOT)}") + value = tomllib.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + fail(f"{path.relative_to(ROOT)} must contain a TOML table") + return value + + +@lru_cache(maxsize=1) +def _release_please_config() -> dict[str, Any]: + return _read_json(RELEASE_PLEASE_CONFIG_PATH) + + +@lru_cache(maxsize=1) +def _release_please_manifest() -> dict[str, Any]: + return _read_json(RELEASE_PLEASE_MANIFEST_PATH) + + +def _moon_bin() -> str: + if moon_bin := os.environ.get("MOON_BIN"): + return moon_bin + proto_moon = Path.home() / ".proto" / "bin" / "moon" + return str(proto_moon) if proto_moon.exists() else "moon" + + +@lru_cache(maxsize=1) +def _packages() -> dict[str, dict[str, Any]]: + packages = _release_please_config().get("packages") + if not isinstance(packages, dict) or not packages: + fail("release-please-config.json must define packages") + parsed: dict[str, dict[str, Any]] = {} + for package_path, package_config in packages.items(): + if not isinstance(package_path, str) or not package_path: + fail("release-please package paths must be non-empty strings") + if not isinstance(package_config, dict): + fail(f"{package_path} release-please config must be an object") + parsed[package_path] = package_config + return parsed + + +@lru_cache(maxsize=1) +def _release_please_packages_by_component() -> dict[str, tuple[str, dict[str, Any]]]: + packages: dict[str, tuple[str, dict[str, Any]]] = {} + for package_path, package_config in _packages().items(): + component = package_config.get("component") + if not isinstance(component, str) or not component: + fail(f"{package_path}.component must be a non-empty string") + if component in packages: + fail(f"duplicate release-please component {component}") + packages[component] = (package_path, package_config) + return packages + + +@lru_cache(maxsize=1) +def _moon_query_projects() -> list[dict[str, Any]]: + output = subprocess.check_output([_moon_bin(), "query", "projects"], cwd=ROOT, text=True) + value = json.loads(output) + projects = value.get("projects") + if not isinstance(projects, list): + fail("moon query projects did not return a projects array") + return projects + + +def _moon_project_release_metadata(project: dict[str, Any]) -> dict[str, Any] | None: + config = project.get("config") if isinstance(project.get("config"), dict) else {} + project_config = config.get("project") if isinstance(config.get("project"), dict) else {} + metadata = project_config.get("metadata") if isinstance(project_config.get("metadata"), dict) else {} + release = metadata.get("release") + return release if isinstance(release, dict) else None + + +@lru_cache(maxsize=1) +def _moon_release_projects_by_component() -> dict[str, dict[str, Any]]: + projects: dict[str, dict[str, Any]] = {} + for project in _moon_query_projects(): + if not isinstance(project, dict) or not isinstance(project.get("id"), str): + continue + config = project.get("config") if isinstance(project.get("config"), dict) else {} + tags = config.get("tags") if isinstance(config.get("tags"), list) else [] + release = _moon_project_release_metadata(project) + if "release-product" not in tags: + if release is not None: + fail(f"Moon project {project['id']} declares release metadata but is not tagged release-product") + continue + if release is None: + fail(f"Moon release product {project['id']} must declare project.metadata.release") + component = release.get("component") + package_path = release.get("packagePath") + if not isinstance(component, str) or not component: + fail(f"Moon release product {project['id']} must declare release.component") + if component != project["id"]: + fail(f"Moon release product {project['id']} release.component must match the project id") + if not isinstance(package_path, str) or not package_path: + fail(f"Moon release product {project['id']} must declare release.packagePath") + if component in projects: + fail(f"duplicate Moon release component {component}") + projects[component] = { + "project_id": project["id"], + "project_source": project.get("source") or "", + "path": package_path, + "release": release, + } + if not projects: + fail("Moon project graph does not contain any release-product projects") + return dict(sorted(projects.items())) + + +@lru_cache(maxsize=1) +def _product_paths_by_id() -> dict[str, str]: + moon_products = _moon_release_projects_by_component() + release_please_products = _release_please_packages_by_component() + moon_components = set(moon_products) + release_please_components = set(release_please_products) + if moon_components != release_please_components: + fail( + "Moon release-product components must match release-please components: " + f"moon={sorted(moon_components)}, release-please={sorted(release_please_components)}" + ) + paths: dict[str, str] = {} + for component, metadata in moon_products.items(): + package_path = metadata["path"] + release_please_path, package_config = release_please_products[component] + if release_please_path != package_path: + fail( + f"{component} Moon release.packagePath {package_path!r} must match " + f"release-please package path {release_please_path!r}" + ) + if package_config.get("component") != component: + fail(f"{package_path}.component must be {component!r}") + paths[component] = package_path + return paths + + +def package_path(product: str) -> str: + paths = _product_paths_by_id() + value = paths.get(product) + if value is None: + fail(f"unknown release product {product!r}") + return value + + +def _package_config(product: str) -> dict[str, Any]: + package = _release_please_packages_by_component().get(product) + if package is None: + fail(f"unknown release-please component {product!r}") + package_path_from_release_please, config = package + moon_package_path = package_path(product) + if package_path_from_release_please != moon_package_path: + fail( + f"{product} release-please path {package_path_from_release_please!r} must match " + f"Moon package path {moon_package_path!r}" + ) + return config + + +def _release_metadata_path(product: str) -> Path: + return ROOT / package_path(product) / "release.toml" + + +def _release_metadata(product: str) -> dict[str, Any]: + metadata = _read_toml(_release_metadata_path(product)) + metadata_id = metadata.get("id") + if metadata_id != product: + fail(f"{_release_metadata_path(product).relative_to(ROOT)} must declare id = {product!r}") + return metadata + + +def load_graph() -> dict[str, Any]: + """Compatibility return value for callers that still accept a graph arg.""" + + return { + "policy": { + "repository": "f0rr0/oliphaunt", + "default_branch": "main", + "versioning": "independent", + }, + "products": graph_products(), + "artifact_targets": [], + } + + +def graph_products(graph: dict | None = None) -> dict[str, dict[str, Any]]: + products: dict[str, dict[str, Any]] = {} + manifest = _release_please_manifest() + for product, path in _product_paths_by_id().items(): + config = dict(_release_metadata(product)) + package_config = _package_config(product) + config["path"] = path + config["tag_prefix"] = tag_prefix(product) + config["changelog_path"] = changelog_path(product) + config["version_files"] = version_files(product) + config.setdefault("derived_version_files", []) + if path not in manifest: + fail(f".release-please-manifest.json is missing {path}") + products[product] = config + return products + + +def product_config(product: str, graph: dict | None = None) -> dict[str, Any]: + config = graph_products().get(product) + if config is None: + fail(f"unknown release product {product!r}") + return config + + +def product_ids(graph: dict | None = None) -> list[str]: + return list(graph_products()) + + +def string_list(config: dict, key: str, product: str) -> list[str]: + value = config.get(key, []) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail(f"{product}.{key} must be a string list") + return value + + +def _package_relative_path(product: str, relative: str, context: str) -> str: + path = Path(relative) + if path.is_absolute() or ".." in path.parts: + fail(f"{context} must stay inside release package path: {relative!r}") + return (Path(package_path(product)) / path).as_posix() + + +def _canonical_version_file(product: str) -> str: + package_config = _package_config(product) + release_type = package_config.get("release-type") + version_file = package_config.get("version-file") + if isinstance(version_file, str) and version_file: + return _package_relative_path(product, version_file, f"{product}.version-file") + if release_type == "rust": + return _package_relative_path(product, "Cargo.toml", f"{product}.rust") + if release_type in {"node", "expo"}: + return _package_relative_path(product, "package.json", f"{product}.node") + fail(f"{product} release-please config must declare version-file for release type {release_type!r}") + + +def _extra_version_files(product: str) -> list[str]: + files: list[str] = [] + package_config = _package_config(product) + extra_files = package_config.get("extra-files", []) + if not isinstance(extra_files, list): + fail(f"{product}.extra-files must be a list") + for index, entry in enumerate(extra_files): + context = f"{product}.extra-files[{index}]" + if isinstance(entry, str): + files.append(_package_relative_path(product, entry, context)) + continue + if not isinstance(entry, dict): + fail(f"{context} must be a path string or object") + path = entry.get("path") + if not isinstance(path, str) or not path: + fail(f"{context}.path must be a non-empty string") + files.append(_package_relative_path(product, path, f"{context}.path")) + return files + + +def version_files(product: str, graph: dict | None = None) -> list[str]: + files = [_canonical_version_file(product), *_extra_version_files(product)] + for path in files: + if not (ROOT / path).is_file(): + fail(f"{product} version file does not exist: {path}") + return files + + +def derived_version_files(product: str, graph: dict | None = None) -> list[str]: + return string_list(_release_metadata(product), "derived_version_files", product) + + +def changelog_path(product: str, graph: dict | None = None) -> str: + package_config = _package_config(product) + relative = package_config.get("changelog-path", "CHANGELOG.md") + if not isinstance(relative, str) or not relative: + fail(f"{product}.changelog-path must be a non-empty string") + path = _package_relative_path(product, relative, f"{product}.changelog-path") + if not (ROOT / path).is_file(): + fail(f"{product} changelog does not exist: {path}") + return path + + +def tag_prefix(product: str, graph: dict | None = None) -> str: + config = _release_please_config() + package_config = _package_config(product) + component = package_config.get("component") + if component != product: + fail(f"{product} release-please component must match product id") + if config.get("include-v-in-tag") is not True: + fail("release-please must include v in product tags") + separator = config.get("tag-separator") + if separator != "-": + fail("release-please tag-separator must be '-'") + return f"{product}{separator}v" + + +def parser_for_version_file(product: str, path: str) -> str: + name = Path(path).name + if name == "Cargo.toml": + return "cargo" + if name == "package.json": + return "json:version" + if name == "gradle.properties": + return "gradle:VERSION_NAME" + if name in {"VERSION", "LIBOLIPHAUNT_VERSION"}: + return "raw" + if name == "jsr.json": + return "json:version" + fail(f"{product}.version_files has unsupported version file type: {path}") + + +def canonical_version_spec(product: str, graph: dict | None = None) -> tuple[str, str]: + path = version_files(product)[0] + return path, parser_for_version_file(product, path) + + +def product_version_specs(graph: dict | None = None) -> dict[str, tuple[str, str]]: + return { + product: canonical_version_spec(product) + for product in graph_products() + } + + +def compatibility_version_specs(graph: dict | None = None) -> dict[str, tuple[str, str]]: + specs: dict[str, tuple[str, str]] = {} + for product in product_ids(): + raw_specs = _release_metadata(product).get("compatibility_versions", {}) + if not isinstance(raw_specs, dict): + fail(f"{product}.compatibility_versions must be a table when present") + for spec_id, spec in raw_specs.items(): + if not isinstance(spec_id, str) or not spec_id: + fail(f"{product}.compatibility_versions keys must be non-empty strings") + if not isinstance(spec, dict): + fail(f"{product}.compatibility_versions.{spec_id} must be a table") + path = spec.get("path") + parser = spec.get("parser") + if not isinstance(path, str) or not path: + fail(f"{product}.compatibility_versions.{spec_id}.path must be a non-empty string") + if not isinstance(parser, str) or not parser: + fail(f"{product}.compatibility_versions.{spec_id}.parser must be a non-empty string") + if not (ROOT / path).is_file(): + fail(f"{product}.compatibility_versions.{spec_id} path does not exist: {path}") + specs[spec_id] = (path, parser) + return specs + + +def release_owned_version_specs(graph: dict | None = None) -> dict[str, tuple[str, str]]: + return { + **product_version_specs(), + **compatibility_version_specs(), + } + + +def parse_cargo_version(text: str, path: str) -> str: + in_package = False + for line in text.splitlines(): + stripped = line.strip() + if stripped == "[package]": + in_package = True + continue + if in_package and stripped.startswith("["): + break + if in_package: + match = re.match(r'version\s*=\s*"([^"]+)"', stripped) + if match: + return match.group(1) + return "" + + +def parse_gradle_property(text: str, name: str) -> str: + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if key.strip() == name: + return value.strip() + return "" + + +def parse_json_path(text: str, dotted: str) -> str: + value: object = json.loads(text) + for key in dotted.split("."): + if not isinstance(value, dict) or key not in value: + return "" + value = value[key] + return str(value) + + +def parse_version_text(text: str, path: str, parser: str) -> str: + if parser == "raw": + return text.strip() + if parser == "cargo": + return parse_cargo_version(text, path) + if parser.startswith("gradle:"): + return parse_gradle_property(text, parser.split(":", 1)[1]) + if parser.startswith("json:"): + return parse_json_path(text, parser.split(":", 1)[1]) + fail(f"unknown version parser {parser!r}") + + +def read_current_version(product: str, graph: dict | None = None) -> str: + path, parser = canonical_version_spec(product) + version = parse_version_text((ROOT / path).read_text(encoding="utf-8"), path, parser) + if not version: + fail(f"{path} does not define a release version for {product}") + return version + + +def ensure_semver(product: str, version: str) -> str: + if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?", version): + fail(f"{product} version is not semver-like: {version!r}") + return version + + +def main(argv: list[str]) -> int: + if len(argv) == 2 and argv[0] == "version": + print(ensure_semver(argv[1], read_current_version(argv[1]))) + return 0 + fail("usage: tools/release/product_metadata.py version ") + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/publish_swiftpm_source_tag.py b/tools/release/publish_swiftpm_source_tag.py new file mode 100755 index 00000000..8462439e --- /dev/null +++ b/tools/release/publish_swiftpm_source_tag.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Publish or verify the semver source tag SwiftPM needs for the Apple SDK.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import NoReturn + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +SEMVER_RE = re.compile( + r"^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$" +) + + +def fail(message: str) -> NoReturn: + print(f"publish_swiftpm_source_tag.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def git_output(args: list[str]) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def git_run(args: list[str], *, env: dict[str, str] | None = None) -> None: + subprocess.run(["git", *args], cwd=ROOT, env=env, check=True) + + +def commit_for_ref(ref: str) -> str: + return git_output(["rev-parse", f"{ref}^{{commit}}"]) + + +def tag_ref(tag: str) -> str: + return f"refs/tags/{tag}" + + +def tag_commit(tag: str) -> str | None: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{tag_ref(tag)}^{{commit}}"], + cwd=ROOT, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + return result.stdout.strip() + return None + + +def swiftpm_tag() -> str: + version = product_metadata.read_current_version("oliphaunt-swift") + if SEMVER_RE.fullmatch(version) is None: + fail(f"SwiftPM requires a semantic version tag; oliphaunt-swift version is {version!r}") + return version + + +def commit_parents(commit: str) -> list[str]: + parts = git_output(["rev-list", "--parents", "-n", "1", commit]).split() + return parts[1:] + + +def file_at_ref(ref: str, path: str) -> str | None: + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], + cwd=ROOT, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + return result.stdout if result.returncode == 0 else None + + +def tree_for_commit(commit: str) -> str: + return git_output(["rev-parse", f"{commit}^{{tree}}"]) + + +def synthetic_commit_matches(commit: str, parent: str, expected_tree: str) -> bool: + return commit_parents(commit) == [parent] and tree_for_commit(commit) == expected_tree + + +def iter_tree_files(root: Path) -> list[Path]: + files: list[Path] = [] + for path in sorted(root.rglob("*")): + if path.is_file(): + files.append(path) + elif not path.is_dir(): + fail(f"SwiftPM generated release tree contains unsupported file type: {path}") + return files + + +def add_blob_to_index(env: dict[str, str], path: str, data: str | bytes) -> None: + binary = isinstance(data, bytes) + blob_output = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], + cwd=ROOT, + env=env, + check=True, + text=not binary, + input=data, + stdout=subprocess.PIPE, + ).stdout + blob = blob_output.decode("utf-8").strip() if binary else blob_output.strip() + git_run(["update-index", "--add", "--cacheinfo", f"100644,{blob},{path}"], env=env) + + +def create_swiftpm_release_tree( + target_commit: str, + manifest: str, + include_trees: list[Path], +) -> str: + base_tree = git_output(["rev-parse", f"{target_commit}^{{tree}}"]) + with tempfile.TemporaryDirectory(prefix="oliphaunt-swiftpm-index.") as tmp: + env = {**os.environ, "GIT_INDEX_FILE": str(Path(tmp) / "index")} + git_run(["read-tree", base_tree], env=env) + add_blob_to_index(env, "Package.swift", manifest) + for include_tree in include_trees: + root = include_tree.resolve() + if not root.is_dir(): + fail(f"SwiftPM generated release tree does not exist: {include_tree}") + for file in iter_tree_files(root): + relative = file.relative_to(root).as_posix() + if relative == "Package.swift" or relative.startswith(".git/") or "/.git/" in relative: + fail(f"SwiftPM generated release tree contains forbidden path: {relative}") + add_blob_to_index(env, relative, file.read_bytes()) + return subprocess.run( + ["git", "write-tree"], + cwd=ROOT, + env=env, + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout.strip() + + +def create_swiftpm_manifest_commit(target_commit: str, tree: str, version: str) -> str: + return subprocess.run( + [ + "git", + "commit-tree", + tree, + "-p", + target_commit, + "-m", + f"Release Oliphaunt Swift {version} SwiftPM manifest", + ], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + ).stdout.strip() + + +def ensure_tag(target: str, *, manifest_path: str | None, include_trees: list[str], push: bool) -> str: + tag = swiftpm_tag() + version = product_metadata.read_current_version("oliphaunt-swift") + target_commit = commit_for_ref(target) + manifest = None + tag_target = target_commit + expected_tree = tree_for_commit(target_commit) + + if manifest_path is not None: + manifest = (ROOT / manifest_path).read_text(encoding="utf-8") + if "binaryTarget(" not in manifest or "liboliphaunt-native-v" not in manifest: + fail("SwiftPM release manifest must contain a checksum-pinned liboliphaunt binaryTarget") + expected_tree = create_swiftpm_release_tree( + target_commit, + manifest, + [(ROOT / include_tree) for include_tree in include_trees], + ) + tag_target = create_swiftpm_manifest_commit(target_commit, expected_tree, version) + + existing = tag_commit(tag) + if existing is not None: + if manifest is not None and synthetic_commit_matches(existing, target_commit, expected_tree): + print(f"SwiftPM version tag {tag} already points at a release manifest commit for {target_commit}") + tag_target = existing + elif existing != tag_target: + fail( + f"SwiftPM version tag {tag} already points at {existing}, " + f"not expected SwiftPM release commit {tag_target}" + ) + else: + print(f"SwiftPM version tag {tag} already points at {tag_target}") + else: + git_run(["tag", tag, tag_target]) + print(f"created SwiftPM version tag {tag} at {tag_target}") + + if push: + git_run(["push", "origin", tag_ref(tag)]) + print(f"pushed SwiftPM version tag {tag} to origin") + return tag + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--target", + default=os.environ.get("GITHUB_SHA", "HEAD"), + help="commitish that the SwiftPM version tag must derive from", + ) + parser.add_argument( + "--manifest", + help=( + "generated public SwiftPM Package.swift to place in a release-only " + "tag commit; when omitted, the semver tag points directly at --target" + ), + ) + parser.add_argument( + "--include-tree", + action="append", + default=[], + help=( + "generated repository-relative file tree to include in the release-only " + "SwiftPM tag commit; may be passed multiple times" + ), + ) + parser.add_argument( + "--push", + action="store_true", + help="push the tag to origin after creating or verifying it locally", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + ensure_tag(args.target, manifest_path=args.manifest, include_trees=args.include_tree, push=args.push) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/release.py b/tools/release/release.py new file mode 100755 index 00000000..4346a2ef --- /dev/null +++ b/tools/release/release.py @@ -0,0 +1,1477 @@ +#!/usr/bin/env python3 +"""Single public release CLI for Oliphaunt product releases.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tarfile +import time +from pathlib import Path +from typing import NoReturn + +import artifact_targets +import check_cratesio_publication +import extension_artifact_targets +import product_metadata +import release_plan + + +ROOT = Path(__file__).resolve().parents[2] +EXTENSION_PRODUCT_PREFIX = "oliphaunt-extension-" +NODE_DIRECT_PACKAGE_DIRS = { + "@oliphaunt/node-direct-darwin-arm64": ROOT / "src/runtimes/node-direct/packages/darwin-arm64", + "@oliphaunt/node-direct-linux-x64-gnu": ROOT / "src/runtimes/node-direct/packages/linux-x64-gnu", + "@oliphaunt/node-direct-linux-arm64-gnu": ROOT / "src/runtimes/node-direct/packages/linux-arm64-gnu", + "@oliphaunt/node-direct-win32-x64-msvc": ROOT / "src/runtimes/node-direct/packages/win32-x64-msvc", +} + + +def fail(message: str) -> NoReturn: + print(f"release.py: {message}", file=sys.stderr) + raise SystemExit(2) + + +def run(args: list[str], *, cwd: Path = ROOT, env: dict[str, str] | None = None) -> None: + print("\n==> " + " ".join(args), flush=True) + result = subprocess.run(args, cwd=cwd, env=env, check=False) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def output(args: list[str], *, cwd: Path = ROOT) -> str: + return subprocess.check_output(args, cwd=cwd, text=True).strip() + + +def succeeds(args: list[str], *, cwd: Path = ROOT) -> bool: + result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=False) + return result.returncode == 0 + + +def pnpm_pack_for_npm_publish(package_dir: Path) -> Path: + """Pack with pnpm so workspace: dependency specs become publishable versions.""" + + package = json.loads((package_dir / "package.json").read_text(encoding="utf-8")) + package_name = package.get("name") + package_version = package.get("version") + if not isinstance(package_name, str) or not package_name: + fail(f"{package_dir.relative_to(ROOT)}/package.json must declare a package name") + if not isinstance(package_version, str) or not package_version: + fail(f"{package_dir.relative_to(ROOT)}/package.json must declare a package version") + safe_name = package_name.replace("@", "").replace("/", "-") + pack_dir = ROOT / "target" / "release" / "npm-packages" / safe_name + shutil.rmtree(pack_dir, ignore_errors=True) + pack_dir.mkdir(parents=True, exist_ok=True) + rendered = output( + ["pnpm", "pack", "--pack-destination", str(pack_dir), "--json"], + cwd=package_dir, + ) + try: + manifest = json.loads(rendered) + except json.JSONDecodeError as error: + fail(f"pnpm pack for {package_name} did not emit JSON: {error}") + filename = manifest.get("filename") if isinstance(manifest, dict) else None + if not isinstance(filename, str) or not filename.endswith(".tgz"): + fail(f"pnpm pack for {package_name} did not report a .tgz filename") + tarball = pack_dir / filename + if not tarball.is_file(): + fail(f"pnpm pack for {package_name} did not create {tarball.relative_to(ROOT)}") + return tarball + + +def sdk_artifact_dir(product: str) -> Path: + return ROOT / "target" / "sdk-artifacts" / product + + +def require_staged_sdk_artifact(product: str, description: str, suffixes: tuple[str, ...]) -> list[Path]: + directory = sdk_artifact_dir(product) + matches = sorted( + path + for path in directory.glob("*") + if path.is_file() and path.name != "artifacts.txt" and path.suffix in suffixes + ) + if not matches: + fail( + f"{product} requires staged {description} artifact(s) under " + f"{directory.relative_to(ROOT)}; download the Builds workflow SDK package artifacts " + "before release validation or publishing" + ) + return matches + + +def staged_swift_release_artifacts() -> tuple[Path, Path]: + matches = require_staged_sdk_artifact("oliphaunt-swift", "Swift package", (".zip", ".release")) + source_archives = [path for path in matches if path.name == "Oliphaunt-source.zip"] + manifests = [path for path in matches if path.name == "Package.swift.release"] + if len(source_archives) != 1 or len(manifests) != 1: + fail( + "oliphaunt-swift release requires exactly one staged Oliphaunt-source.zip " + "and one staged Package.swift.release under target/sdk-artifacts/oliphaunt-swift" + ) + manifest_text = manifests[0].read_text(encoding="utf-8") + required_fragments = [ + "binaryTarget(", + "liboliphaunt-native-v", + "liboliphaunt-", + "apple-spm-xcframework.zip", + "checksum:", + ] + for fragment in required_fragments: + if fragment not in manifest_text: + fail(f"oliphaunt-swift staged Package.swift.release is missing {fragment!r}") + return source_archives[0], manifests[0] + + +def prepare_staged_swift_release_manifest() -> Path: + _source_archive, staged_manifest = staged_swift_release_artifacts() + output_dir = ROOT / "target" / "oliphaunt-swift" + release_tree = output_dir / "release-tree" + shutil.rmtree(release_tree, ignore_errors=True) + release_tree.mkdir(parents=True, exist_ok=True) + output_manifest = output_dir / "Package.swift.release" + shutil.copy2(staged_manifest, output_manifest) + return output_manifest + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def staged_cargo_crate(product: str) -> Path: + matches = require_staged_sdk_artifact(product, "Cargo package", (".crate",)) + if len(matches) != 1: + fail(f"{product} staged Cargo artifacts must contain exactly one .crate, got {len(matches)}") + return matches[0] + + +def verify_staged_cargo_crate_identity( + product: str, + package: str, + version: str, + *, + allow_dirty: bool, +) -> None: + staged = staged_cargo_crate(product) + expected_name = f"{package}-{version}.crate" + if staged.name != expected_name: + fail(f"{product} staged Cargo crate must be named {expected_name}, got {staged.name}") + print(f"validated staged Cargo crate identity: {product} -> {staged.relative_to(ROOT)}") + + +def staged_npm_package_tarball(product: str) -> Path | None: + matches = require_staged_sdk_artifact(product, "npm package", (".tgz",)) + if not matches: + return None + if len(matches) != 1: + fail(f"{product} staged npm package artifacts must contain exactly one .tgz, got {len(matches)}") + validate_staged_npm_package_tarball(product, matches[0]) + return matches[0] + + +def staged_kotlin_maven_repo() -> Path: + root = sdk_artifact_dir("oliphaunt-kotlin") / "maven" + if not root.is_dir(): + fail( + "oliphaunt-kotlin requires staged Maven repository artifacts under " + f"{root.relative_to(ROOT)}; download the Builds workflow Kotlin SDK package artifacts " + "before release validation or publishing" + ) + version = current_product_version("oliphaunt-kotlin") + required = [ + root / f"dev/oliphaunt/oliphaunt-android/{version}/oliphaunt-android-{version}.aar", + root / f"dev/oliphaunt/oliphaunt-android/{version}/oliphaunt-android-{version}.pom", + root / f"dev/oliphaunt/oliphaunt-android/{version}/oliphaunt-android-{version}.module", + root / ( + f"dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/" + f"oliphaunt-android-gradle-plugin-{version}.jar" + ), + root / ( + f"dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/" + f"oliphaunt-android-gradle-plugin-{version}.pom" + ), + root / ( + f"dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/" + f"oliphaunt-android-gradle-plugin-{version}.module" + ), + root / ( + f"dev/oliphaunt/android/dev.oliphaunt.android.gradle.plugin/{version}/" + f"dev.oliphaunt.android.gradle.plugin-{version}.pom" + ), + ] + missing = [path.relative_to(ROOT) for path in required if not path.is_file()] + if missing: + fail("oliphaunt-kotlin staged Maven repository is missing: " + ", ".join(str(path) for path in missing)) + for path in root.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(root) + if relative.parts[:2] != ("dev", "oliphaunt"): + fail(f"oliphaunt-kotlin staged Maven repository contains unexpected path {path.relative_to(ROOT)}") + if path.suffix in {".lastUpdated", ".lock"}: + fail(f"oliphaunt-kotlin staged Maven repository contains local resolver state {path.relative_to(ROOT)}") + print(f"validated staged Kotlin Maven repository: {root.relative_to(ROOT)}") + return root + + +def json_contains_workspace_protocol(value: object) -> bool: + if isinstance(value, str): + return value.startswith("workspace:") + if isinstance(value, list): + return any(json_contains_workspace_protocol(item) for item in value) + if isinstance(value, dict): + return any(json_contains_workspace_protocol(item) for item in value.values()) + return False + + +def validate_staged_npm_package_tarball(product: str, tarball: Path) -> None: + package_dir = ROOT / product_metadata.package_path(product) + package_json = package_dir / "package.json" + if not package_json.is_file(): + fail(f"{product} has no package.json at {package_json.relative_to(ROOT)}") + source_package = json.loads(package_json.read_text(encoding="utf-8")) + expected_name = source_package.get("name") + expected_version = current_product_version(product) + if not isinstance(expected_name, str) or not expected_name: + fail(f"{package_json.relative_to(ROOT)} must declare a package name") + expected_filename = f"{safe_npm_package_filename_prefix(expected_name)}-{expected_version}.tgz" + if tarball.name != expected_filename: + fail(f"{product} staged npm tarball must be named {expected_filename}, got {tarball.name}") + + try: + with tarfile.open(tarball, "r:gz") as archive: + names = set(archive.getnames()) + if "package/package.json" not in names: + fail(f"{tarball.relative_to(ROOT)} is missing package/package.json") + package_member = archive.extractfile("package/package.json") + if package_member is None: + fail(f"{tarball.relative_to(ROOT)} package/package.json could not be read") + with package_member: + packed_package = json.loads(package_member.read().decode("utf-8")) + if packed_package.get("name") != expected_name: + fail( + f"{tarball.relative_to(ROOT)} package name must be {expected_name}, " + f"got {packed_package.get('name')!r}" + ) + if packed_package.get("version") != expected_version: + fail( + f"{tarball.relative_to(ROOT)} package version must be {expected_version}, " + f"got {packed_package.get('version')!r}" + ) + if json_contains_workspace_protocol(packed_package): + fail(f"{tarball.relative_to(ROOT)} must not contain workspace: dependency specifiers") + if not any(name.startswith("package/lib/") for name in names): + fail(f"{tarball.relative_to(ROOT)} must contain built package/lib output") + except (tarfile.TarError, json.JSONDecodeError, UnicodeDecodeError) as error: + fail(f"{tarball.relative_to(ROOT)} is not a valid staged npm package tarball: {error}") + + +def staged_jsr_source_dir(product: str) -> Path | None: + directory = sdk_artifact_dir(product) / "jsr-source" + if not directory.is_dir(): + fail( + f"{product} requires staged JSR source under {directory.relative_to(ROOT)}; " + "download the Builds workflow SDK package artifacts before release validation or publishing" + ) + required = ["jsr.json", "package.json", "src"] + missing = [name for name in required if not (directory / name).exists()] + if missing: + fail(f"{product} staged JSR source is missing: {', '.join(missing)}") + return directory + + +def npm_publish_pnpm_packed_package(package_dir: Path, *, product: str | None = None) -> None: + tarball = staged_npm_package_tarball(product) if product is not None else None + if tarball is None: + tarball = pnpm_pack_for_npm_publish(package_dir) + run(["npm", "publish", str(tarball), "--access", "public", "--provenance"]) + + +def xtask(args: list[str], *, quiet: bool = False) -> str: + command = ["cargo", "run"] + if quiet: + command.append("--quiet") + command.extend(["-p", "xtask", "--", *args]) + if quiet: + return output(command) + run(command) + return "" + + +def cargo_publish_args(allow_dirty: bool) -> list[str]: + return ["--allow-dirty"] if allow_dirty else [] + + +def cargo_package_args(allow_dirty: bool) -> list[str]: + return ["--allow-dirty"] if allow_dirty else [] + + +def host_aot_manifest(target: str) -> Path | None: + candidates = [ + ROOT / "target" / "oliphaunt-wasix" / "aot" / target / "manifest.json", + ROOT / "src" / "runtimes" / "liboliphaunt" / "wasix" / "crates" / "aot" / target / "artifacts" / "manifest.json", + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + +def wasm_aot_target_triples() -> list[str]: + matrix = json.loads(xtask(["assets", "ci-matrix", "--target", "all"], quiet=True)) + include = matrix.get("include") + if not isinstance(include, list): + fail("WASIX AOT CI matrix did not contain an include list") + targets: list[str] = [] + for item in include: + if not isinstance(item, dict) or not isinstance(item.get("target"), str): + fail("WASIX AOT CI matrix target entries must contain raw target triples") + targets.append(item["target"]) + return targets + + +def require_release_portable_assets() -> None: + manifest = ROOT / "target" / "oliphaunt-wasix" / "assets" / "manifest.json" + if not manifest.is_file(): + fail("missing release portable assets; download or build Builds workflow WASM runtime outputs first") + + +def require_release_aot_artifacts() -> None: + for target in wasm_aot_target_triples(): + manifest = host_aot_manifest(target) + if manifest is None: + fail(f"missing release AOT artifacts for {target}") + + +def passthrough_value(args: list[str], name: str) -> str | None: + index = 0 + while index < len(args): + value = args[index] + if value == name: + if index + 1 >= len(args): + fail(f"{name} requires a value") + return args[index + 1] + if value.startswith(f"{name}="): + return value.split("=", 1)[1] + index += 1 + return None + + +def selected_products_from_passthrough(args: list[str]) -> list[str]: + raw = passthrough_value(args, "--products-json") + if raw is None: + return [] + value = json.loads(raw) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + fail("--products-json must be a JSON string list") + known = set(product_metadata.product_ids()) + unknown = sorted(set(value) - known) + if unknown: + fail(f"unknown release products: {', '.join(unknown)}") + selected = set(value) + graph = release_plan.load_graph() + return release_plan.release_order(graph["products"], graph["moon_projects"], selected) + + +def product_tag(product: str) -> str: + return f"{product_metadata.tag_prefix(product)}{product_metadata.read_current_version(product)}" + + +def is_extension_product(product: str) -> bool: + return product.startswith(EXTENSION_PRODUCT_PREFIX) + + +def selected_extension_products(products: list[str]) -> list[str]: + return sorted(product for product in products if is_extension_product(product)) + + +def extension_sql_name(product: str) -> str: + config = product_metadata.product_config(product) + value = config.get("extension_sql_name") + if not isinstance(value, str) or not value: + fail(f"{product} release metadata must declare extension_sql_name") + return value + + +def github_output(values: dict[str, str]) -> None: + for key, value in values.items(): + print(f"{key}={value}") + + +def current_product_version(product: str) -> str: + return product_metadata.read_current_version(product) + + +def verify_release_tag(product: str, head_ref: str) -> None: + run(["tools/release/verify_product_tag.py", product, "--target", head_ref]) + + +def glob_release_assets(asset_dir: Path, suffixes: tuple[str, ...]) -> list[str]: + if not asset_dir.is_dir(): + fail(f"release asset directory does not exist: {asset_dir.relative_to(ROOT)}") + assets = sorted( + path + for path in asset_dir.iterdir() + if path.is_file() and any(path.name.endswith(suffix) for suffix in suffixes) + ) + if not assets: + fail(f"no release assets found in {asset_dir.relative_to(ROOT)}") + return [str(path.relative_to(ROOT)) for path in assets] + + +def upload_github_release_assets(product: str, *, tag: str | None = None, assets: list[str] | None = None) -> None: + command = [ + "tools/release/upload_github_release_assets.py", + product, + "--tag", + tag or product_tag(product), + ] + for asset in assets or []: + command.extend(["--asset", asset]) + run(command) + + +def npm_package_is_published(package_name: str, version: str) -> bool: + result = subprocess.run( + ["npm", "view", f"{package_name}@{version}", "version"], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + return result.returncode == 0 and result.stdout.strip() == version + + +def url_exists(url: str) -> bool: + return succeeds(["curl", "-fsIL", "--retry", "3", "--connect-timeout", "10", url]) + + +def git_commit(ref: str) -> str | None: + result = subprocess.run( + ["git", "rev-list", "-n", "1", ref], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return None + value = result.stdout.strip() + return value or None + + +def product_tag_points_at(product: str, head_ref: str) -> bool: + tag_commit = git_commit(product_tag(product)) + head_commit = git_commit(head_ref) + return tag_commit is not None and head_commit is not None and tag_commit == head_commit + + +def product_registry_is_published(product: str) -> bool: + return succeeds( + [ + "tools/release/check_registry_publication.py", + "--product", + product, + "--require-published", + ] + ) + + +def published_rerun(product: str, head_ref: str) -> bool: + return product_tag_points_at(product, head_ref) and product_registry_is_published(product) + + +def wait_for_cratesio_package(crate: str, version: str, *, retries: int = 12, retry_delay: float = 10.0) -> None: + for attempt in range(retries + 1): + if check_cratesio_publication.crate_version_exists(crate, version): + return + if attempt < retries: + print(f"waiting for crates.io to index {crate} {version}...") + time.sleep(retry_delay) + fail(f"crates.io did not report {crate} {version} after publish") + + +def cargo_publish_package(package: str, version: str, *, allow_dirty: bool = False) -> None: + if check_cratesio_publication.crate_version_exists(package, version): + print(f"{package} {version} is already published on crates.io; skipping cargo publish.") + return + run( + [ + "cargo", + "publish", + "-p", + package, + "--locked", + *cargo_publish_args(allow_dirty), + ] + ) + wait_for_cratesio_package(package, version) + + +def validate_wasix_runtime_inputs(allow_dirty: bool) -> None: + require_release_portable_assets() + require_release_aot_artifacts() + xtask(["assets", "check", "--strict-generated"]) + targets = output(["cargo", "run", "--quiet", "-p", "xtask", "--", "assets", "aot-targets"]) + for target in [line.strip() for line in targets.splitlines() if line.strip()]: + xtask(["assets", "check-aot", "--target-triple", target]) + run(["tools/policy/check-crate-package.sh", *cargo_package_args(allow_dirty)]) + run(["tools/release/check_wasm_crate_payloads.py", *cargo_package_args(allow_dirty)]) + + +def run_wasix_runtime_staged_dry_run(allow_dirty: bool) -> None: + validate_wasix_runtime_inputs(allow_dirty) + packages = output(["cargo", "run", "--quiet", "-p", "xtask", "--", "assets", "internal-packages"]) + for package in [line.strip() for line in packages.splitlines() if line.strip()]: + run(["cargo", "publish", "-p", package, "--dry-run", "--locked", *cargo_publish_args(allow_dirty)]) + + +def run_wasix_runtime_release_dry_run(allow_dirty: bool) -> None: + run_wasix_runtime_staged_dry_run(allow_dirty) + + +def run_wasm_release_dry_run(allow_dirty: bool) -> None: + validate_staged_sdk_package("oliphaunt-wasix-rust") + verify_staged_cargo_crate_identity( + "oliphaunt-wasix-rust", + "oliphaunt-wasix", + current_product_version("oliphaunt-wasix-rust"), + allow_dirty=allow_dirty, + ) + print("validated staged WASIX Rust binding crate; skipping source cargo publish dry-run.") + + +def publish_wasix_runtime_staged_crates() -> None: + validate_wasix_runtime_inputs(allow_dirty=True) + version = current_product_version("liboliphaunt-wasix") + packages = output(["cargo", "run", "--quiet", "-p", "xtask", "--", "assets", "internal-packages"]) + for package in [line.strip() for line in packages.splitlines() if line.strip()]: + cargo_publish_package(package, version, allow_dirty=True) + + +def publish_wasm_staged_crates() -> None: + publish_wasix_runtime_staged_crates() + version = current_product_version("oliphaunt-wasix-rust") + cargo_publish_package("oliphaunt-wasix", version, allow_dirty=True) + + +def publish_wasix_runtime_crates_io(head_ref: str) -> None: + if published_rerun("liboliphaunt-wasix", head_ref): + print("liboliphaunt-wasix internal crates are already published at this commit; skipping crates.io publish.") + return + + verify_release_tag("liboliphaunt-wasix", head_ref) + publish_wasix_runtime_staged_crates() + run( + [ + "tools/release/check_registry_publication.py", + "--product", + "liboliphaunt-wasix", + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ] + ) + + +def publish_wasm_crates_io(head_ref: str) -> None: + if published_rerun("oliphaunt-wasix-rust", head_ref): + print("oliphaunt-wasix is already published at this commit; skipping crates.io publish.") + return + + verify_release_tag("oliphaunt-wasix-rust", head_ref) + version = current_product_version("oliphaunt-wasix-rust") + verify_staged_cargo_crate_identity( + "oliphaunt-wasix-rust", + "oliphaunt-wasix", + version, + allow_dirty=False, + ) + cargo_publish_package("oliphaunt-wasix", version) + run( + [ + "tools/release/check_registry_publication.py", + "--product", + "oliphaunt-wasix-rust", + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ] + ) + + +def liboliphaunt_release_asset_dir() -> Path: + return ROOT / "target" / "liboliphaunt" / "release-assets" + + +def liboliphaunt_release_assets_ready() -> bool: + asset_dir = liboliphaunt_release_asset_dir() + if not asset_dir.is_dir(): + return False + return any(path.is_file() for path in asset_dir.iterdir()) + + +def ensure_liboliphaunt_release_assets() -> None: + if liboliphaunt_release_assets_ready(): + run(["tools/release/check_liboliphaunt_release_assets.py", "--asset-dir", "target/liboliphaunt/release-assets"]) + return + fail( + "liboliphaunt-native requires staged release assets under " + "target/liboliphaunt/release-assets; download the Builds workflow " + "liboliphaunt-native-release-assets artifact before release validation or publishing" + ) + + +def run_liboliphaunt_dry_run() -> None: + ensure_liboliphaunt_release_assets() + + +def staged_runtime_input_dirs(env_name: str) -> list[Path]: + raw = os.environ.get(env_name) or os.environ.get("OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS") or "" + dirs = [Path(item).expanduser() for item in raw.split(":") if item] + return [path if path.is_absolute() else ROOT / path for path in dirs] + + +def copy_staged_runtime_assets( + *, + product: str, + destination: Path, + env_name: str, + patterns: tuple[str, ...], +) -> None: + source_dirs = staged_runtime_input_dirs(env_name) + if not source_dirs: + fail( + f"{product} requires staged runtime artifacts; set {env_name} or " + "OLIPHAUNT_RELEASE_ASSET_INPUT_DIRS to the downloaded Builds artifact directory" + ) + destination.mkdir(parents=True, exist_ok=True) + copied = 0 + for source_dir in source_dirs: + if not source_dir.is_dir(): + fail(f"{product} release asset input directory does not exist: {source_dir}") + for pattern in patterns: + for asset in sorted(source_dir.glob(pattern)): + if asset.is_file(): + shutil.copy2(asset, destination / asset.name) + copied += 1 + if copied == 0: + fail(f"{product} found no staged runtime artifacts matching {patterns} under {source_dirs}") + + +def ensure_broker_release_assets() -> None: + asset_dir = ROOT / "target" / "oliphaunt-broker" / "release-assets" + if not any(asset_dir.glob("oliphaunt-broker-*.tar.gz")) and not any(asset_dir.glob("oliphaunt-broker-*.zip")): + copy_staged_runtime_assets( + product="oliphaunt-broker", + destination=asset_dir, + env_name="OLIPHAUNT_BROKER_RELEASE_ASSET_INPUT_DIRS", + patterns=("oliphaunt-broker-*.tar.gz", "oliphaunt-broker-*.zip"), + ) + version = current_product_version("oliphaunt-broker") + run( + [ + "tools/release/write_checksum_manifest.py", + "--asset-dir", + str(asset_dir.relative_to(ROOT)), + "--output", + f"oliphaunt-broker-{version}-release-assets.sha256", + "--pattern", + "oliphaunt-broker-*.tar.gz", + "--pattern", + "oliphaunt-broker-*.zip", + ] + ) + run(["tools/release/check_broker_release_assets.py", "--asset-dir", str(asset_dir.relative_to(ROOT))]) + + +def ensure_node_direct_release_assets() -> None: + asset_dir = ROOT / "target" / "oliphaunt-node-direct" / "release-assets" + if not any(asset_dir.glob("oliphaunt-node-direct-*.tar.gz")) and not any(asset_dir.glob("oliphaunt-node-direct-*.zip")): + copy_staged_runtime_assets( + product="oliphaunt-node-direct", + destination=asset_dir, + env_name="OLIPHAUNT_NODE_ADDON_ASSET_INPUT_DIRS", + patterns=("oliphaunt-node-direct-*.tar.gz", "oliphaunt-node-direct-*.zip"), + ) + version = current_product_version("oliphaunt-node-direct") + run( + [ + "tools/release/write_checksum_manifest.py", + "--asset-dir", + str(asset_dir.relative_to(ROOT)), + "--output", + f"oliphaunt-node-direct-{version}-release-assets.sha256", + "--pattern", + "oliphaunt-node-direct-*.tar.gz", + "--pattern", + "oliphaunt-node-direct-*.zip", + ] + ) + run(["tools/release/check_node_direct_release_assets.py", "--asset-dir", str(asset_dir.relative_to(ROOT))]) + + +def extension_package_dir(product: str) -> Path: + return ROOT / "target" / "extension-artifacts" / product + + +def read_json_file(path: Path) -> object: + try: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + except json.JSONDecodeError as error: + fail(f"{path.relative_to(ROOT)} is not valid JSON: {error}") + + +def validate_checksum_manifest(checksum_manifest: Path, asset_dir: Path) -> None: + declared: dict[str, str] = {} + for line_number, raw_line in enumerate(checksum_manifest.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + fail(f"{checksum_manifest.relative_to(ROOT)}:{line_number} must contain ' ./'") + sha, name = parts + if len(sha) != 64 or any(char not in "0123456789abcdef" for char in sha): + fail(f"{checksum_manifest.relative_to(ROOT)}:{line_number} has invalid sha256 {sha!r}") + if not name.startswith("./") or "/" in name[2:]: + fail(f"{checksum_manifest.relative_to(ROOT)}:{line_number} must reference a direct asset path like ./name") + asset_name = name[2:] + if asset_name in declared: + fail(f"{checksum_manifest.relative_to(ROOT)} declares duplicate checksum entry for {asset_name}") + declared[asset_name] = sha + + expected = sorted(path.name for path in asset_dir.iterdir() if path.is_file() and path != checksum_manifest) + if sorted(declared) != expected: + fail( + f"{checksum_manifest.relative_to(ROOT)} entries must exactly match release asset files: " + f"{sorted(declared)} vs {expected}" + ) + for asset_name, expected_sha in declared.items(): + asset_path = asset_dir / asset_name + actual_sha = sha256_file(asset_path) + if actual_sha != expected_sha: + fail(f"{checksum_manifest.relative_to(ROOT)} checksum for {asset_name} is {expected_sha}, got {actual_sha}") + + +def validate_extension_release_package(product: str) -> None: + package_dir = extension_package_dir(product) + asset_dir = package_dir / "release-assets" + manifest = package_dir / "extension-artifacts.json" + if not manifest.is_file() or not asset_dir.is_dir(): + fail(f"{product} extension package is missing {manifest.relative_to(ROOT)} or {asset_dir.relative_to(ROOT)}") + + data = read_json_file(manifest) + if not isinstance(data, dict): + fail(f"{manifest.relative_to(ROOT)} must contain a JSON object") + version = current_product_version(product) + sql_name = extension_sql_name(product) + expected = { + "schema": "oliphaunt-extension-ci-artifacts-v1", + "product": product, + "version": version, + "sqlName": sql_name, + } + for key, value in expected.items(): + if data.get(key) != value: + fail(f"{manifest.relative_to(ROOT)} has {key}={data.get(key)!r}, expected {value!r}") + + release_manifest = asset_dir / f"{product}-{version}-manifest.json" + properties_manifest = asset_dir / f"{product}-{version}-manifest.properties" + checksum_manifest = asset_dir / f"{product}-{version}-release-assets.sha256" + for required in (release_manifest, properties_manifest, checksum_manifest): + if not required.is_file(): + fail(f"{product} extension package is missing {required.relative_to(ROOT)}") + validate_checksum_manifest(checksum_manifest, asset_dir) + + release_data = read_json_file(release_manifest) + if release_data != data: + fail(f"{release_manifest.relative_to(ROOT)} must match {manifest.relative_to(ROOT)} exactly") + + assets = data.get("assets") + if not isinstance(assets, list) or not assets: + fail(f"{manifest.relative_to(ROOT)} must declare at least one extension asset") + + declared_native_targets = { + target.target + for target in extension_artifact_targets.artifact_targets( + product=product, + family="native", + published_only=True, + ) + } + declared_wasix_targets = { + target.target + for target in extension_artifact_targets.artifact_targets( + product=product, + family="wasix", + published_only=True, + ) + } + staged_native_targets: set[str] = set() + staged_wasix_targets: set[str] = set() + seen_assets: set[str] = set() + for asset in assets: + if not isinstance(asset, dict): + fail(f"{manifest.relative_to(ROOT)} contains a non-object asset entry") + family = asset.get("family") + target = asset.get("target") + kind = asset.get("kind") + name = asset.get("name") + path_value = asset.get("path") + sha_value = asset.get("sha256") + bytes_value = asset.get("bytes") + if not all(isinstance(value, str) and value for value in (family, target, kind, name, path_value, sha_value)): + fail(f"{manifest.relative_to(ROOT)} contains an incomplete asset entry: {asset!r}") + if not isinstance(bytes_value, int) or bytes_value <= 0: + fail(f"{manifest.relative_to(ROOT)} asset {name} must declare positive bytes") + if family == "native": + staged_native_targets.add(target) + elif family == "wasix": + staged_wasix_targets.add(target) + else: + fail(f"{manifest.relative_to(ROOT)} asset {name} has unsupported family {family!r}") + if name in seen_assets: + fail(f"{manifest.relative_to(ROOT)} declares duplicate asset name {name}") + seen_assets.add(name) + + asset_path = ROOT / path_value + if asset_path.parent != asset_dir or asset_path.name != name: + fail(f"{manifest.relative_to(ROOT)} asset {name} must live directly under {asset_dir.relative_to(ROOT)}") + if not asset_path.is_file(): + fail(f"{manifest.relative_to(ROOT)} references missing asset {asset_path.relative_to(ROOT)}") + if asset_path.stat().st_size != bytes_value: + fail(f"{asset_path.relative_to(ROOT)} size does not match staged manifest") + if sha256_file(asset_path) != sha_value: + fail(f"{asset_path.relative_to(ROOT)} checksum does not match staged manifest") + + if staged_native_targets != declared_native_targets: + fail( + f"{product} staged native extension targets must match declared published targets: " + f"{sorted(staged_native_targets)} vs {sorted(declared_native_targets)}" + ) + if staged_wasix_targets != declared_wasix_targets: + fail( + f"{product} staged WASIX extension targets must match declared published targets: " + f"{sorted(staged_wasix_targets)} vs {sorted(declared_wasix_targets)}" + ) + + +def extension_release_package_ready(product: str) -> bool: + package_dir = extension_package_dir(product) + asset_dir = package_dir / "release-assets" + manifest = package_dir / "extension-artifacts.json" + if not manifest.is_file() or not asset_dir.is_dir(): + return False + validate_extension_release_package(product) + return True + + +def ensure_extension_release_package(product: str) -> None: + if extension_release_package_ready(product): + return + fail( + f"{product} requires staged exact-extension package artifacts under " + f"{extension_package_dir(product).relative_to(ROOT)}; download the Builds workflow " + "oliphaunt-extension-package-artifacts artifact before release validation or publishing" + ) + + +def extension_asset_paths(product: str) -> list[str]: + ensure_extension_release_package(product) + asset_dir = extension_package_dir(product) / "release-assets" + if not asset_dir.is_dir(): + fail(f"{product} extension package did not create {asset_dir.relative_to(ROOT)}") + assets = sorted(path for path in asset_dir.iterdir() if path.is_file()) + if not assets: + fail(f"{product} extension package produced no release assets") + return [str(path.relative_to(ROOT)) for path in assets] + + +def run_extension_artifact_dry_run(product: str) -> None: + for asset in extension_asset_paths(product): + print(f"{product} release asset: {asset}") + + +def validate_staged_sdk_package(product: str) -> None: + run(["python3", "tools/release/check_staged_artifacts.py", "--require-sdk-product", product]) + + +def run_rust_sdk_dry_run(allow_dirty: bool, head_ref: str) -> None: + version = current_product_version("oliphaunt-rust") + validate_staged_sdk_package("oliphaunt-rust") + verify_staged_cargo_crate_identity( + "oliphaunt-rust", + "oliphaunt", + version, + allow_dirty=allow_dirty, + ) + print("validated staged Rust SDK crate; skipping source cargo publish dry-run.") + + +def run_broker_dry_run() -> None: + ensure_broker_release_assets() + + +def run_swift_sdk_dry_run() -> None: + validate_staged_sdk_package("oliphaunt-swift") + prepare_staged_swift_release_manifest() + + +def run_kotlin_sdk_dry_run() -> None: + validate_staged_sdk_package("oliphaunt-kotlin") + staged_kotlin_maven_repo() + + +def run_react_native_sdk_dry_run() -> None: + validate_staged_sdk_package("oliphaunt-react-native") + require_staged_sdk_artifact("oliphaunt-react-native", "npm package", (".tgz",)) + + +def run_typescript_sdk_dry_run() -> None: + validate_staged_sdk_package("oliphaunt-js") + require_staged_sdk_artifact("oliphaunt-js", "npm package", (".tgz",)) + staged_jsr_source_dir("oliphaunt-js") + + +def run_node_direct_dry_run() -> None: + run(["src/runtimes/node-direct/tools/check-package.sh", "package-shape"]) + ensure_node_direct_release_assets() + node_direct_optional_npm_tarballs(current_product_version("oliphaunt-node-direct")) + + +def run_product_publish_dry_runs(products: list[str], *, allow_dirty: bool, head_ref: str) -> None: + for product in products: + if product == "liboliphaunt-native": + run_liboliphaunt_dry_run() + elif product == "liboliphaunt-wasix": + run_wasix_runtime_release_dry_run(allow_dirty) + elif product == "oliphaunt-rust": + run_rust_sdk_dry_run(allow_dirty, head_ref) + elif product == "oliphaunt-broker": + run_broker_dry_run() + elif product == "oliphaunt-node-direct": + run_node_direct_dry_run() + elif product == "oliphaunt-swift": + run_swift_sdk_dry_run() + elif product == "oliphaunt-kotlin": + run_kotlin_sdk_dry_run() + elif product == "oliphaunt-react-native": + run_react_native_sdk_dry_run() + elif product == "oliphaunt-js": + run_typescript_sdk_dry_run() + elif product == "oliphaunt-wasix-rust": + if published_rerun("oliphaunt-wasix-rust", head_ref): + print("oliphaunt-wasix is already published at this commit; skipping WASM publish dry-run.") + else: + run_wasm_release_dry_run(allow_dirty) + elif is_extension_product(product): + run_extension_artifact_dry_run(product) + else: + fail(f"no publish dry-run handler for {product}") + + +def command_plan(args: list[str]) -> None: + raise SystemExit(release_plan.main(args)) + + +def command_check(args: list[str]) -> None: + run(["python3", "tools/policy/check-release-policy.py"]) + run(["python3", "tools/release/check_release_please_config.py"]) + run(["python3", "tools/release/check_artifact_targets.py"]) + run(["python3", "tools/release/check_release_metadata.py"]) + + +def command_check_registries(args: list[str]) -> None: + require_identities = "--require-identities" in args + args = [value for value in args if value != "--require-identities"] + if not args: + print("No release products selected; registry publication checks skipped.") + return + run(["tools/release/check_release_versions.py", *args, "--check-registries"]) + if require_identities: + products_json = passthrough_value(args, "--products-json") + if products_json is None: + fail("check-registries --require-identities requires --products-json") + run( + [ + "tools/release/check_registry_publication.py", + "--products-json", + products_json, + "--require-identities", + ] + ) + + +def command_consumer_shape(args: list[str]) -> None: + result = subprocess.run(["tools/release/check_consumer_shape.py", *args], cwd=ROOT, check=False) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def consumer_shape_scope_args(args: list[str]) -> list[str]: + scoped: list[str] = [] + index = 0 + while index < len(args): + value = args[index] + if value == "--products-json": + if index + 1 >= len(args): + fail("--products-json requires a value") + scoped.extend([value, args[index + 1]]) + index += 2 + continue + if value.startswith("--products-json="): + scoped.append(value) + index += 1 + return scoped + + +def command_verify_release(args: list[str]) -> None: + run(["tools/release/check_release_versions.py", *args, "--check-registries"]) + command_consumer_shape(["--require-ready", *consumer_shape_scope_args(args)]) + run(["tools/release/verify_github_release_attestations.py", *args]) + + +def publish_existing_tag_outputs(product: str, head_ref: str, fmt: str) -> None: + values = { + "tag": product_tag(product), + "exists_at_head": "true" if published_rerun(product, head_ref) else "false", + } + if fmt == "github-output": + github_output(values) + return + for key, value in values.items(): + print(f"{key}: {value}") + + +def publish_liboliphaunt_github_assets(head_ref: str) -> None: + verify_release_tag("liboliphaunt-native", head_ref) + ensure_liboliphaunt_release_assets() + assets = glob_release_assets( + ROOT / "target/liboliphaunt/release-assets", + (".tar.gz", ".tar.zst", ".tsv", ".zip", ".sha256"), + ) + upload_github_release_assets("liboliphaunt-native", assets=assets) + + +def publish_swift_release(head_ref: str) -> None: + verify_release_tag("oliphaunt-swift", head_ref) + manifest = prepare_staged_swift_release_manifest() + run( + [ + "tools/release/publish_swiftpm_source_tag.py", + "--target", + head_ref, + "--manifest", + str(manifest.relative_to(ROOT)), + "--include-tree", + "target/oliphaunt-swift/release-tree", + "--push", + ] + ) + upload_github_release_assets("oliphaunt-swift") + + +def kotlin_artifacts_published(version: str) -> bool: + urls = [ + f"https://repo1.maven.org/maven2/dev/oliphaunt/oliphaunt/{version}/oliphaunt-{version}.pom", + f"https://repo1.maven.org/maven2/dev/oliphaunt/oliphaunt-android-gradle-plugin/{version}/oliphaunt-android-gradle-plugin-{version}.pom", + f"https://repo1.maven.org/maven2/dev/oliphaunt/android/dev.oliphaunt.android.gradle.plugin/{version}/dev.oliphaunt.android.gradle.plugin-{version}.pom", + ] + return all(url_exists(url) for url in urls) + + +def publish_kotlin_maven(head_ref: str) -> None: + verify_release_tag("oliphaunt-kotlin", head_ref) + staged_kotlin_maven_repo() + version = current_product_version("oliphaunt-kotlin") + if kotlin_artifacts_published(version): + print(f"dev.oliphaunt Android artifacts {version} are already published on Maven Central; skipping publishAndReleaseToMavenCentral.") + else: + run( + [ + "src/sdks/kotlin/gradlew", + "-p", + "src/sdks/kotlin", + ":oliphaunt:publishAndReleaseToMavenCentral", + ":oliphaunt-android-gradle-plugin:publishAndReleaseToMavenCentral", + f"-PoliphauntBuildRoot={ROOT / 'target/liboliphaunt-sdk-check/gradle/oliphaunt-kotlin-release'}", + f"-PoliphauntCxxBuildRoot={ROOT / 'target/liboliphaunt-sdk-check/cxx/oliphaunt-kotlin-release'}", + "--project-cache-dir", + str(ROOT / "target/liboliphaunt-sdk-check/gradle-cache/oliphaunt-kotlin-release"), + "--configuration-cache", + ] + ) + run( + [ + "tools/release/check_registry_publication.py", + "--product", + "oliphaunt-kotlin", + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ] + ) + upload_github_release_assets("oliphaunt-kotlin") + + +def publish_react_native_npm(head_ref: str) -> None: + verify_release_tag("oliphaunt-react-native", head_ref) + version = current_product_version("oliphaunt-react-native") + if npm_package_is_published("@oliphaunt/react-native", version): + print(f"@oliphaunt/react-native {version} is already published on npm; skipping npm publish.") + else: + npm_publish_pnpm_packed_package( + ROOT / "src/sdks/react-native", + product="oliphaunt-react-native", + ) + run( + [ + "tools/release/check_registry_publication.py", + "--product", + "oliphaunt-react-native", + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ] + ) + upload_github_release_assets("oliphaunt-react-native") + + +def publish_rust_crates_io(head_ref: str) -> None: + if published_rerun("oliphaunt-rust", head_ref): + print("oliphaunt-rust is already published at this commit; skipping crates.io publish.") + return + verify_release_tag("oliphaunt-rust", head_ref) + version = current_product_version("oliphaunt-rust") + verify_staged_cargo_crate_identity( + "oliphaunt-rust", + "oliphaunt", + version, + allow_dirty=False, + ) + cargo_publish_package("oliphaunt", version) + run( + [ + "tools/release/check_registry_publication.py", + "--product", + "oliphaunt-rust", + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ] + ) + + +def publish_broker_release_assets(head_ref: str) -> None: + verify_release_tag("oliphaunt-broker", head_ref) + ensure_broker_release_assets() + assets = glob_release_assets( + ROOT / "target/oliphaunt-broker/release-assets", + (".tar.gz", ".zip", ".sha256"), + ) + upload_github_release_assets("oliphaunt-broker", assets=assets) + + +def publish_node_direct_release_assets(head_ref: str) -> None: + verify_release_tag("oliphaunt-node-direct", head_ref) + ensure_node_direct_release_assets() + asset_dir = ROOT / "target/oliphaunt-node-direct/release-assets" + assets = glob_release_assets(asset_dir, (".tar.gz", ".zip", ".sha256")) + upload_github_release_assets("oliphaunt-node-direct", assets=assets) + + +def node_direct_optional_package_targets(version: str) -> list[tuple[str, Path, artifact_targets.ArtifactTarget]]: + packages: list[tuple[str, Path, artifact_targets.ArtifactTarget]] = [] + for target in artifact_targets.artifact_targets( + product="oliphaunt-node-direct", + kind="node-direct-addon", + surface="npm-optional", + published_only=True, + ): + package_name = target.npm_package + if package_name is None: + fail(f"{target.id} must declare npm_package for npm optional package publication") + package_dir = NODE_DIRECT_PACKAGE_DIRS.get(package_name) + if package_dir is None: + fail(f"{target.id} declares unknown Node direct npm package {package_name}") + package_json = json.loads((package_dir / "package.json").read_text(encoding="utf-8")) + if package_json.get("name") != package_name: + fail(f"{package_dir.relative_to(ROOT)}/package.json name must be {package_name}") + if package_json.get("version") != version: + fail(f"{package_name} package version must match oliphaunt-node-direct {version}") + packages.append((package_name, package_dir, target)) + if sorted(package for package, _, _ in packages) != sorted(NODE_DIRECT_PACKAGE_DIRS): + fail("Node direct npm optional package metadata must match published artifact targets exactly") + return packages + + +def safe_npm_package_filename_prefix(package_name: str) -> str: + return package_name.removeprefix("@").replace("/", "-") + + +def node_direct_npm_package_dir() -> Path: + return ROOT / "target" / "oliphaunt-node-direct" / "npm-packages" + + +def expected_node_direct_npm_tarball(package_name: str, version: str) -> Path: + return node_direct_npm_package_dir() / f"{safe_npm_package_filename_prefix(package_name)}-{version}.tgz" + + +def validate_node_direct_optional_tarball(package_name: str, version: str, tarball: Path) -> None: + if not tarball.is_file(): + fail(f"missing Node direct optional npm package artifact: {tarball.relative_to(ROOT)}") + try: + with tarfile.open(tarball, "r:gz") as archive: + names = set(archive.getnames()) + if "package/package.json" not in names: + fail(f"{tarball.relative_to(ROOT)} is missing package/package.json") + if "package/prebuilds/oliphaunt_node.node" not in names: + fail(f"{tarball.relative_to(ROOT)} is missing package/prebuilds/oliphaunt_node.node") + package_member = archive.extractfile("package/package.json") + if package_member is None: + fail(f"{tarball.relative_to(ROOT)} package/package.json could not be read") + with package_member: + package = json.loads(package_member.read().decode("utf-8")) + if package.get("name") != package_name: + fail(f"{tarball.relative_to(ROOT)} package name must be {package_name}, got {package.get('name')!r}") + if package.get("version") != version: + fail(f"{tarball.relative_to(ROOT)} package version must be {version}, got {package.get('version')!r}") + prebuild = archive.getmember("package/prebuilds/oliphaunt_node.node") + if not prebuild.isfile() or prebuild.size <= 0: + fail(f"{tarball.relative_to(ROOT)} prebuilt addon must be a non-empty regular file") + except (tarfile.TarError, json.JSONDecodeError, UnicodeDecodeError) as error: + fail(f"{tarball.relative_to(ROOT)} is not a valid Node direct optional npm tarball: {error}") + + +def node_direct_optional_npm_tarballs(version: str) -> list[tuple[str, Path]]: + tarballs: list[tuple[str, Path]] = [] + for package_name, _package_dir, _target in node_direct_optional_package_targets(version): + tarball = expected_node_direct_npm_tarball(package_name, version) + validate_node_direct_optional_tarball(package_name, version, tarball) + tarballs.append((package_name, tarball)) + unexpected = sorted( + path.name + for path in node_direct_npm_package_dir().glob("*.tgz") + if path not in {tarball for _, tarball in tarballs} + ) + if unexpected: + fail("unexpected Node direct optional npm package artifact(s): " + ", ".join(unexpected)) + return tarballs + + +def publish_node_direct_npm_optional_packages(head_ref: str) -> None: + verify_release_tag("oliphaunt-node-direct", head_ref) + version = current_product_version("oliphaunt-node-direct") + ensure_node_direct_release_assets() + tarballs = node_direct_optional_npm_tarballs(version) + for package_name, tarball in tarballs: + if npm_package_is_published(package_name, version): + print(f"{package_name} {version} is already published on npm; skipping npm publish.") + continue + run(["npm", "publish", str(tarball), "--access", "public", "--provenance"]) + + +def publish_typescript_npm_jsr(head_ref: str) -> None: + verify_release_tag("oliphaunt-js", head_ref) + version = current_product_version("oliphaunt-js") + if npm_package_is_published("@oliphaunt/ts", version): + print(f"@oliphaunt/ts {version} is already published on npm; skipping npm publish.") + else: + npm_publish_pnpm_packed_package(ROOT / "src/sdks/js", product="oliphaunt-js") + if succeeds( + [ + "tools/release/check_registry_publication.py", + "--product", + "oliphaunt-js", + "--registry-kind", + "jsr", + "--require-published", + ] + ): + print(f"jsr:@oliphaunt/ts {version} is already published; skipping jsr publish.") + else: + jsr_source = staged_jsr_source_dir("oliphaunt-js") or (ROOT / "src/sdks/js") + run(["pnpm", "exec", "jsr", "publish"], cwd=jsr_source) + run( + [ + "tools/release/check_registry_publication.py", + "--product", + "oliphaunt-js", + "--require-published", + "--retries", + "12", + "--retry-delay", + "10", + ] + ) + upload_github_release_assets("oliphaunt-js", assets=[]) + + +def publish_wasm_release_assets() -> None: + asset_dir = ROOT / "target/oliphaunt-wasix/release-assets" + if not asset_dir.is_dir() or not any(asset_dir.iterdir()): + fail( + "liboliphaunt-wasix requires staged release assets under " + "target/oliphaunt-wasix/release-assets; download the Builds workflow " + "liboliphaunt-wasix-release-assets artifact before release validation or publishing" + ) + assets = glob_release_assets(asset_dir, (".tar.zst", ".sha256")) + upload_github_release_assets("liboliphaunt-wasix", assets=assets) + + +def publish_extension_release_assets(product: str, head_ref: str) -> None: + verify_release_tag(product, head_ref) + upload_github_release_assets(product, assets=extension_asset_paths(product)) + + +def publish_selected_extension_release_assets(products: list[str], head_ref: str) -> None: + extensions = selected_extension_products(products) + if not extensions: + fail("no extension products selected") + for product in extensions: + verify_release_tag(product, head_ref) + upload_github_release_assets(product, assets=extension_asset_paths(product)) + + +def command_publish_product_step(args: argparse.Namespace) -> None: + product = args.product + step = args.step + head_ref = args.head_ref + if product is None or step is None: + fail("publish product step requires --product and --step") + known = set(product_metadata.product_ids()) + if product not in known: + fail(f"unknown release product: {product}") + + if step == "existing-tag": + publish_existing_tag_outputs(product, head_ref, args.format) + elif product == "liboliphaunt-native" and step == "github-release-assets": + publish_liboliphaunt_github_assets(head_ref) + elif product == "liboliphaunt-wasix" and step == "crates-io": + publish_wasix_runtime_crates_io(head_ref) + elif product == "liboliphaunt-wasix" and step == "github-release-assets": + verify_release_tag("liboliphaunt-wasix", head_ref) + publish_wasm_release_assets() + elif product == "oliphaunt-swift" and step == "github-release": + publish_swift_release(head_ref) + elif product == "oliphaunt-kotlin" and step == "maven-central": + publish_kotlin_maven(head_ref) + elif product == "oliphaunt-react-native" and step == "npm": + publish_react_native_npm(head_ref) + elif product == "oliphaunt-rust" and step == "crates-io": + publish_rust_crates_io(head_ref) + elif product == "oliphaunt-broker" and step == "github-release-assets": + publish_broker_release_assets(head_ref) + elif product == "oliphaunt-node-direct" and step == "github-release-assets": + publish_node_direct_release_assets(head_ref) + elif product == "oliphaunt-node-direct" and step == "npm": + publish_node_direct_npm_optional_packages(head_ref) + elif product == "oliphaunt-js" and step == "npm-jsr": + publish_typescript_npm_jsr(head_ref) + elif product == "oliphaunt-wasix-rust" and step == "crates-io": + publish_wasm_crates_io(head_ref) + elif is_extension_product(product) and step == "github-release-assets": + publish_extension_release_assets(product, head_ref) + else: + fail(f"unsupported publish step {product}:{step}") + + +def command_publish_dry_run(args: argparse.Namespace, passthrough: list[str]) -> None: + command_check([]) + products = selected_products_from_passthrough(passthrough) + if products: + registry_args = ( + passthrough + if "--require-identities" in passthrough + else [*passthrough, "--require-identities"] + ) + command_check_registries(registry_args) + run_product_publish_dry_runs( + products, + allow_dirty=args.allow_dirty, + head_ref=passthrough_value(passthrough, "--head-ref") or "HEAD", + ) + return + if args.wasm: + run_wasm_release_dry_run(args.allow_dirty) + if passthrough: + command_check_registries(passthrough) + + +def command_publish(args: argparse.Namespace, passthrough: list[str]) -> None: + products = selected_products_from_passthrough(passthrough) + if args.step == "github-release-assets" and not args.product and selected_extension_products(products): + publish_selected_extension_release_assets(products, args.head_ref) + return + if args.product or args.step: + command_publish_product_step(args) + return + products_args = passthrough + run(["tools/release/check_publish_environment.py", *products_args]) + command_publish_dry_run(args, passthrough) + print("publish environment and dry-run checks passed; package-native publish steps run in the Release workflow") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + for name in ["plan", "check", "check-registries", "consumer-shape", "verify-release"]: + subparsers.add_parser(name, add_help=False) + + dry_run = subparsers.add_parser("publish-dry-run") + dry_run.add_argument("--wasm", action="store_true") + dry_run.add_argument("--allow-dirty", action="store_true") + + publish = subparsers.add_parser("publish") + publish.add_argument("--wasm", action="store_true") + publish.add_argument("--allow-dirty", action="store_true") + publish.add_argument("--product") + publish.add_argument("--step") + publish.add_argument("--head-ref", default="HEAD") + publish.add_argument("--format", choices=["text", "github-output"], default="text") + + args, passthrough = parser.parse_known_args(argv) + command = args.command + + if command == "plan": + command_plan(passthrough) + elif command == "check": + command_check(passthrough) + elif command == "check-registries": + command_check_registries(passthrough) + elif command == "consumer-shape": + command_consumer_shape(passthrough) + elif command == "verify-release": + command_verify_release(passthrough) + elif command == "publish-dry-run": + command_publish_dry_run(args, passthrough) + elif command == "publish": + command_publish(args, passthrough) + else: + fail(f"unknown command {command}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/release_plan.py b/tools/release/release_plan.py new file mode 100644 index 00000000..f50657e8 --- /dev/null +++ b/tools/release/release_plan.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import pathlib +import subprocess +import sys +from collections import deque +from typing import Iterable + +import product_metadata + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +GENERATED_PATH_PARTS = { + ".build", + ".cxx", + ".expo", + ".gradle", + ".kotlin", + ".moon", + ".next", + ".source", + "DerivedData", + "Pods", + "__pycache__", + "dist", + "lib", + "node_modules", + "out", + "target", +} +RELEASE_DEPENDENCY_SCOPES = {"production", "peer"} + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def load_graph() -> dict: + graph = product_metadata.load_graph() + graph["moon_projects"] = moon_projects_by_id() + return graph + + +def moon_bin() -> str: + if configured := os.environ.get("MOON_BIN"): + return configured + proto_moon = pathlib.Path.home() / ".proto" / "bin" / "moon" + return str(proto_moon) if proto_moon.exists() else "moon" + + +def run_git(args: list[str]) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True) + + +def run_moon(args: list[str]) -> dict: + output = subprocess.check_output([moon_bin(), *args], cwd=ROOT, text=True) + return json.loads(output) + + +def moon_projects_by_id() -> dict[str, dict]: + data = run_moon(["query", "projects"]) + projects = data.get("projects") + if not isinstance(projects, list): + fail("moon query projects did not return a projects array") + + parsed: dict[str, dict] = {} + for project in projects: + if not isinstance(project, dict) or not isinstance(project.get("id"), str): + continue + config = project.get("config") if isinstance(project.get("config"), dict) else {} + raw_deps = project.get("dependencies") or config.get("dependsOn") or [] + dependencies: dict[str, str] = {} + if isinstance(raw_deps, list): + for dependency in raw_deps: + if isinstance(dependency, str): + dependencies[dependency] = "production" + elif isinstance(dependency, dict) and isinstance(dependency.get("id"), str): + dependencies[dependency["id"]] = str(dependency.get("scope") or "production") + parsed[project["id"]] = { + "id": project["id"], + "source": project.get("source") or config.get("source") or "", + "dependsOn": sorted(dependencies), + "dependencyScopes": dict(sorted(dependencies.items())), + "tags": sorted(config.get("tags") or []), + "project": config.get("project") if isinstance(config.get("project"), dict) else {}, + } + return parsed + + +def tag_match_pattern(prefix: str) -> str: + return f"{prefix}[0-9]*" if prefix else "[0-9]*" + + +def tag_prefixes(product_config: dict) -> list[str]: + prefix = product_config.get("tag_prefix") + if not isinstance(prefix, str) or not prefix: + fail("release metadata product entries must declare tag_prefix") + legacy_prefixes = product_config.get("legacy_tag_prefixes", []) + if not isinstance(legacy_prefixes, list) or not all( + isinstance(item, str) for item in legacy_prefixes + ): + fail("release metadata legacy_tag_prefixes must be a string list when present") + return [prefix, *legacy_prefixes] + + +def latest_tag_for_prefix(prefix: str, head_ref: str) -> str: + result = subprocess.run( + [ + "git", + "describe", + "--tags", + "--abbrev=0", + "--match", + tag_match_pattern(prefix), + head_ref, + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + if result.returncode == 0: + return result.stdout.strip() + return "" + + +def latest_product_tag(product_config: dict, head_ref: str) -> str: + for prefix in tag_prefixes(product_config): + if tag := latest_tag_for_prefix(prefix, head_ref): + return tag + return EMPTY_TREE + + +def commit_for_ref(ref: str) -> str: + return run_git(["rev-parse", f"{ref}^{{commit}}"]).strip() + + +def changed_files_from_refs(base_ref: str, head_ref: str) -> list[str]: + try: + if base_ref == EMPTY_TREE: + output = run_git(["diff", "--name-only", base_ref, head_ref, "--"]) + else: + output = run_git(["diff", "--name-only", f"{base_ref}...{head_ref}", "--"]) + except subprocess.CalledProcessError as error: + fail(f"failed to read changed files between {base_ref} and {head_ref}: {error}") + return sorted(line for line in output.splitlines() if line) + + +def normalize_files(files: Iterable[str]) -> list[str]: + normalized: set[str] = set() + for file in files: + path = file.strip().replace("\\", "/") + if path.startswith("./"): + path = path[2:] + if path and not is_generated_local_state(path): + normalized.add(path) + return sorted(normalized) + + +def is_generated_local_state(path: str) -> bool: + if path.startswith("target/"): + return True + return any(part in GENERATED_PATH_PARTS for part in pathlib.Path(path).parts) + + +def split_patterns(patterns: Iterable[str]) -> tuple[list[str], list[str]]: + includes: list[str] = [] + excludes: list[str] = [] + for pattern in patterns: + if pattern.startswith("!"): + excludes.append(pattern[1:]) + else: + includes.append(pattern) + return includes, excludes + + +def matches_pattern(path: str, pattern: str) -> bool: + return fnmatch.fnmatchcase(path, pattern) + + +def matches_any(path: str, patterns: Iterable[str]) -> bool: + return any(matches_pattern(path, pattern) for pattern in patterns) + + +def product_matches(path: str, patterns: Iterable[str]) -> bool: + includes, excludes = split_patterns(patterns) + return matches_any(path, includes) and not matches_any(path, excludes) + + +def owner_project_for_path(projects: dict[str, dict], path: str) -> str | None: + # Moon 2.3 exposes project sources/dependencies as JSON, but does not expose + # a non-executing stdin changed-file affectedness query. Release planning + # keeps this as a pure adapter over `moon query projects`; no hand-authored + # source globs or dependency graph are allowed here. + if is_generated_local_state(path): + return None + matches = [ + project + for project in projects.values() + if project["source"] == "." + or path == project["source"] + or path.startswith(f"{project['source']}/") + ] + matches.sort(key=lambda project: len(project["source"]), reverse=True) + return matches[0]["id"] if matches else None + + +def dependents_by_project(projects: dict[str, dict], *, release_only: bool = False) -> dict[str, set[str]]: + dependents: dict[str, set[str]] = {project: set() for project in projects} + for project, config in projects.items(): + scopes = config.get("dependencyScopes", {}) + for dependency in config.get("dependsOn", []): + if release_only and scopes.get(dependency, "production") not in RELEASE_DEPENDENCY_SCOPES: + continue + dependents.setdefault(dependency, set()).add(project) + return dependents + + +def downstream_projects( + projects: dict[str, dict], + direct: Iterable[str], + *, + release_only: bool = False, +) -> set[str]: + dependents = dependents_by_project(projects, release_only=release_only) + selected: set[str] = set(direct) + queue: deque[str] = deque(sorted(selected)) + while queue: + current = queue.popleft() + for downstream in sorted(dependents.get(current, set())): + if downstream not in selected: + selected.add(downstream) + queue.append(downstream) + return selected + + +def release_product_project_id(product: str, products: dict[str, dict], projects: dict[str, dict]) -> str: + if product in projects: + return product + package_path = products[product].get("path") + if not isinstance(package_path, str) or not package_path: + fail(f"release product {product} is missing package path metadata") + matches = [ + project + for project in projects.values() + if package_path == project["source"] or package_path.startswith(f"{project['source']}/") + ] + matches.sort(key=lambda project: len(project["source"]), reverse=True) + if not matches: + fail(f"release product {product} has no owning Moon project for {package_path}") + return matches[0]["id"] + + +def release_products_for_projects( + products: dict[str, dict], + projects: dict[str, dict], + project_ids: Iterable[str], +) -> set[str]: + selected_projects = set(project_ids) + selected: set[str] = set() + for product in products: + project_id = release_product_project_id(product, products, projects) + if project_id in selected_projects: + selected.add(product) + return selected + + +def release_order(products: dict[str, dict], projects: dict[str, dict], selected: Iterable[str]) -> list[str]: + selected_set = set(selected) + product_project = { + product: release_product_project_id(product, products, projects) + for product in products + } + ordered: list[str] = [] + remaining = set(selected_set) + while remaining: + ready: list[str] = [] + for product in sorted(remaining): + project_id = product_project[product] + project_config = projects.get(project_id, {}) + scopes = project_config.get("dependencyScopes", {}) + deps = { + dependency + for dependency in project_config.get("dependsOn", []) + if scopes.get(dependency, "production") in RELEASE_DEPENDENCY_SCOPES + } + selected_deps = { + candidate + for candidate, candidate_project in product_project.items() + if candidate in selected_set and candidate_project in deps + } + if selected_deps <= set(ordered): + ready.append(product) + if not ready: + fail(f"Moon release product graph has a dependency cycle: {sorted(remaining)}") + ordered.extend(ready) + remaining.difference_update(ready) + return ordered + + +def docs_only_change(files: Iterable[str]) -> bool: + normalized = list(files) + return bool(normalized) and all( + file.startswith("docs/") + or file.startswith("src/docs/") + or file in {"README.md"} + for file in normalized + ) + + +def build_plan(graph: dict, files: list[str]) -> dict: + products = graph.get("products") + if not isinstance(products, dict): + fail("release metadata must define [products.] entries") + projects = graph.get("moon_projects") + if not isinstance(projects, dict): + fail("Moon project graph is missing from release plan metadata") + + direct_projects = { + project + for file in files + if (project := owner_project_for_path(projects, file)) is not None + } + affected_projects = downstream_projects(projects, direct_projects) + release_projects = downstream_projects(projects, direct_projects, release_only=True) + release_product_set = release_products_for_projects(products, projects, release_projects) + release_products = release_order(products, projects, release_product_set) + release_product_projects = { + release_product_project_id(product, products, projects) + for product in release_products + } + direct = release_order( + products, + projects, + release_products_for_projects(products, projects, direct_projects), + ) + return finalize_plan({ + "changedFiles": files, + "directProducts": direct, + "releaseProducts": release_products, + "directMoonProjects": sorted(direct_projects), + "affectedMoonProjects": sorted(affected_projects), + "releaseMoonProjects": sorted(release_product_projects), + "productIds": list(products), + "hasReleaseChanges": bool(release_products), + "docsOnly": not release_products and docs_only_change(files), + "versioning": graph.get("policy", {}).get("versioning", "independent"), + "extensionSelection": "exact-sql-extension", + }) + + +def build_plan_from_product_tags( + graph: dict, + head_ref: str, + include_current_tags: bool = False, +) -> dict: + products = graph.get("products") + if not isinstance(products, dict): + fail("release metadata must define [products.] entries") + + direct: set[str] = set() + changed: set[str] = set() + product_base_refs: dict[str, str] = {} + current_tagged_products: set[str] = set() + head_commit = commit_for_ref(head_ref) if include_current_tags else "" + + for product, config in products.items(): + base_ref = latest_product_tag(config, head_ref) + product_base_refs[product] = base_ref + if include_current_tags and base_ref != EMPTY_TREE: + tag_commit = commit_for_ref(base_ref) + if tag_commit == head_commit: + direct.add(product) + current_tagged_products.add(product) + continue + product_files = changed_files_from_refs(base_ref, head_ref) + changed.update(product_files) + product_plan = build_plan(graph, normalize_files(product_files)) + if product in product_plan.get("releaseProducts", []): + direct.add(product) + + projects = graph.get("moon_projects") + if not isinstance(projects, dict): + fail("Moon project graph is missing from release plan metadata") + direct_projects = { + release_product_project_id(product, products, projects) + for product in direct + } + affected_projects = downstream_projects(projects, direct_projects) + release_projects = downstream_projects(projects, direct_projects, release_only=True) + release_products = release_order( + products, + projects, + release_products_for_projects(products, projects, release_projects), + ) + return finalize_plan({ + "changedFiles": sorted(changed), + "directProducts": release_order(products, projects, direct), + "releaseProducts": release_products, + "directMoonProjects": sorted(direct_projects), + "affectedMoonProjects": sorted(affected_projects), + "releaseMoonProjects": sorted(release_projects), + "productIds": list(products), + "hasReleaseChanges": bool(release_products), + "docsOnly": not release_products and docs_only_change(changed), + "versioning": graph.get("policy", {}).get("versioning", "independent"), + "extensionSelection": "exact-sql-extension", + "productBaseRefs": product_base_refs, + "currentTaggedProducts": sorted(current_tagged_products), + }) + + +def release_products_slug(products: list[str]) -> str: + if not products: + return "none" + short_names = { + "liboliphaunt-native": "native", + } + return "-".join(short_names.get(product, product.replace("oliphaunt-", "")) for product in products) + + +def finalize_plan(plan: dict) -> dict: + hash_input = { + "changedFiles": plan.get("changedFiles", []), + "directProducts": plan.get("directProducts", []), + "releaseProducts": plan.get("releaseProducts", []), + "productBaseRefs": plan.get("productBaseRefs", {}), + "currentTaggedProducts": plan.get("currentTaggedProducts", []), + } + digest = hashlib.sha256( + json.dumps(hash_input, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest()[:12] + plan["planHash"] = digest + plan["releaseBranch"] = f"release/{release_products_slug(plan.get('releaseProducts', []))}-{digest}" + return plan + + +def print_github_output(plan: dict) -> None: + products = plan["releaseProducts"] + extension_products = sorted(product for product in products if product.startswith("oliphaunt-extension-")) + print(f"has_release_changes={str(plan['hasReleaseChanges']).lower()}") + print(f"has_extension_products={str(bool(extension_products)).lower()}") + print(f"docs_only={str(plan['docsOnly']).lower()}") + print(f"products_csv={','.join(products)}") + print(f"products_json={json.dumps(products, separators=(',', ':'))}") + print(f"extension_products_json={json.dumps(extension_products, separators=(',', ':'))}") + print(f"plan_hash={plan['planHash']}") + print(f"release_branch={plan['releaseBranch']}") + for product in plan.get("productIds", []): + key = "product_" + product.replace("-", "_") + print(f"{key}={str(product in products).lower()}") + print( + "direct_products_json=" + f"{json.dumps(plan['directProducts'], separators=(',', ':'))}" + ) + print( + "product_base_refs_json=" + f"{json.dumps(plan.get('productBaseRefs', {}), separators=(',', ':'))}" + ) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Plan independent Oliphaunt product releases from changed files." + ) + parser.add_argument("--base-ref", help="base git ref for diff planning") + parser.add_argument("--head-ref", default="HEAD", help="head git ref for diff planning") + parser.add_argument( + "--from-product-tags", + action="store_true", + help="plan from each product's latest tag instead of one shared base ref", + ) + parser.add_argument( + "--include-current-tags", + action="store_true", + help="with --from-product-tags, keep products selected when their latest tag already points at HEAD", + ) + parser.add_argument( + "--changed-file", + action="append", + default=[], + help="explicit changed file; may be passed more than once", + ) + parser.add_argument( + "--format", + choices=["text", "json", "github-output"], + default="text", + help="output format", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.changed_file: + files = normalize_files(args.changed_file) + graph = load_graph() + plan = build_plan(graph, files) + elif args.from_product_tags: + graph = load_graph() + plan = build_plan_from_product_tags( + graph, + args.head_ref, + include_current_tags=args.include_current_tags, + ) + elif args.base_ref: + files = changed_files_from_refs(args.base_ref, args.head_ref) + graph = load_graph() + plan = build_plan(graph, files) + else: + files = [] + graph = load_graph() + plan = build_plan(graph, files) + + if args.format == "json": + print(json.dumps(plan, indent=2, sort_keys=True)) + elif args.format == "github-output": + print_github_output(plan) + else: + changed_files = plan.get("changedFiles", []) + if not changed_files: + print("No changed files were provided; no product release is planned.") + elif plan["hasReleaseChanges"]: + print("Release products: " + ", ".join(plan["releaseProducts"])) + print("Direct products: " + ", ".join(plan["directProducts"])) + else: + print("No product release is planned for these changes.") + return 0 diff --git a/tools/release/render_swiftpm_release_package.py b/tools/release/render_swiftpm_release_package.py new file mode 100755 index 00000000..22d57166 --- /dev/null +++ b/tools/release/render_swiftpm_release_package.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Render the public SwiftPM manifest for an Oliphaunt Apple SDK release.""" + +from __future__ import annotations + +import argparse +import hashlib +import plistlib +import sys +import urllib.error +import urllib.request +import zipfile +from pathlib import Path +from typing import NoReturn + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] +REPOSITORY = "f0rr0/oliphaunt" + + +def fail(message: str) -> NoReturn: + print(f"render_swiftpm_release_package.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def checksum_from_manifest(text: str, asset: str) -> str | None: + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + parts = line.split() + if len(parts) != 2: + continue + digest, filename = parts + if filename == f"./{asset}" or filename == asset: + return digest + return None + + +def validate_apple_xcframework_asset(path: Path) -> None: + try: + with zipfile.ZipFile(path) as archive: + try: + info_data = archive.read("liboliphaunt.xcframework/Info.plist") + except KeyError: + fail(f"SwiftPM Apple XCFramework asset is missing liboliphaunt.xcframework/Info.plist: {path}") + try: + info = plistlib.loads(info_data) + except Exception as error: + fail(f"SwiftPM Apple XCFramework Info.plist is invalid in {path}: {error}") + if not isinstance(info, dict): + fail(f"SwiftPM Apple XCFramework Info.plist must be a plist dictionary in {path}") + libraries = info.get("AvailableLibraries") + if not isinstance(libraries, list) or not libraries: + fail(f"SwiftPM Apple XCFramework Info.plist has no AvailableLibraries in {path}") + archive_names = set(archive.namelist()) + platforms: set[tuple[str, str]] = set() + for library in libraries: + if not isinstance(library, dict): + continue + platform = library.get("SupportedPlatform") + variant = library.get("SupportedPlatformVariant", "") + library_path = library.get("LibraryPath") + identifier = library.get("LibraryIdentifier") + if not isinstance(platform, str) or not isinstance(library_path, str) or not isinstance(identifier, str): + continue + platforms.add((platform, variant if isinstance(variant, str) else "")) + candidate = f"liboliphaunt.xcframework/{identifier}/{library_path}" + if candidate not in archive_names and not any(name.startswith(f"{candidate}/") for name in archive_names): + fail(f"SwiftPM Apple XCFramework is missing declared library {candidate}") + except zipfile.BadZipFile as error: + fail(f"SwiftPM Apple XCFramework asset is not a readable zip file: {path}: {error}") + + required = {("macos", ""), ("ios", ""), ("ios", "simulator")} + missing = required - platforms + if missing: + rendered = ", ".join(f"{platform}{('-' + variant) if variant else ''}" for platform, variant in sorted(missing)) + fail(f"SwiftPM Apple XCFramework asset {path} is missing required slice(s): {rendered}") + + +def resolve_checksum(asset_dir: Path, asset_base_url: str, asset: str, version: str) -> str: + local_asset = asset_dir / asset + if local_asset.is_file(): + if local_asset.stat().st_size <= 0: + fail(f"SwiftPM Apple XCFramework asset is empty: {local_asset}") + validate_apple_xcframework_asset(local_asset) + return sha256(local_asset) + + local_manifest = asset_dir / f"liboliphaunt-{version}-release-assets.sha256" + if local_manifest.is_file(): + checksum = checksum_from_manifest(local_manifest.read_text(encoding="utf-8"), asset) + if checksum: + return checksum + + manifest_url = f"{asset_base_url.rstrip('/')}/liboliphaunt-{version}-release-assets.sha256" + try: + with urllib.request.urlopen(manifest_url, timeout=20) as response: + text = response.read().decode("utf-8") + except (OSError, UnicodeDecodeError, urllib.error.URLError) as error: + fail( + f"SwiftPM asset {asset} is not present in {asset_dir}, and checksum " + f"manifest could not be read from {manifest_url}: {error}" + ) + checksum = checksum_from_manifest(text, asset) + if not checksum: + fail(f"checksum manifest {manifest_url} does not contain {asset}") + return checksum + + +def render_manifest( + asset_dir: Path, + asset_base_url: str, + liboliphaunt_version: str, + checksum: str, + generated_tree: Path | None, +) -> str: + asset = f"liboliphaunt-{liboliphaunt_version}-apple-spm-xcframework.zip" + url = f"{asset_base_url.rstrip('/')}/{asset}" + if generated_tree is not None: + generated_tree.mkdir(parents=True, exist_ok=True) + return f"""// swift-tools-version: 6.0 + +import PackageDescription + +// Generated by tools/release/render_swiftpm_release_package.py. +// This is the public SwiftPM release manifest. The source package under +// src/sdks/swift remains the local development package. +// Exact PostgreSQL extensions are released as separate opt-in extension +// artifacts. The base Swift package must not require or publish extension files. +let package = Package( + name: "Oliphaunt", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library(name: "Oliphaunt", targets: ["Oliphaunt"]) + ], + targets: [ + .binaryTarget( + name: "liboliphaunt", + url: "{url}", + checksum: "{checksum}" + ), + .target( + name: "COliphaunt", + dependencies: ["liboliphaunt"], + path: "src/sdks/swift/Sources/COliphaunt", + publicHeadersPath: "include" + ), + .target( + name: "Oliphaunt", + dependencies: ["COliphaunt"], + path: "src/sdks/swift/Sources/Oliphaunt" + ) + ] +) +""" + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--asset-dir", + default="target/liboliphaunt/release-assets", + help="directory containing liboliphaunt release assets", + ) + parser.add_argument( + "--asset-base-url", + help="base URL for liboliphaunt release assets; defaults to the GitHub release URL", + ) + parser.add_argument( + "--output", + help="write the rendered manifest here; stdout is used when omitted", + ) + parser.add_argument( + "--generated-tree", + help=( + "create the generated SwiftPM release tree root; exact extension " + "artifacts are released as separate opt-in products" + ), + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + liboliphaunt_version = product_metadata.read_current_version("liboliphaunt-native") + asset_dir = (ROOT / args.asset_dir).resolve() + asset = f"liboliphaunt-{liboliphaunt_version}-apple-spm-xcframework.zip" + base_url = args.asset_base_url or ( + f"https://github.com/{REPOSITORY}/releases/download/liboliphaunt-native-v{liboliphaunt_version}" + ) + checksum = resolve_checksum(asset_dir, base_url, asset, liboliphaunt_version) + generated_tree = (ROOT / args.generated_tree).resolve() if args.generated_tree else None + manifest = render_manifest(asset_dir, base_url, liboliphaunt_version, checksum, generated_tree) + if args.output: + output = ROOT / args.output + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(manifest, encoding="utf-8") + else: + print(manifest, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/sync-example-lockfiles.py b/tools/release/sync-example-lockfiles.py similarity index 86% rename from scripts/sync-example-lockfiles.py rename to tools/release/sync-example-lockfiles.py index ff269725..3e49444d 100755 --- a/scripts/sync-example-lockfiles.py +++ b/tools/release/sync-example-lockfiles.py @@ -6,17 +6,17 @@ import tomllib -ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT = pathlib.Path(__file__).resolve().parents[2] LOCKFILES = [ - ROOT / "examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock", + ROOT / "src/bindings/wasix-rust/examples/tauri-sqlx-vanilla/src-tauri/Cargo.lock", ] INTERNAL_PACKAGE_MANIFESTS = [ - ROOT / "Cargo.toml", - ROOT / "crates/assets/Cargo.toml", - ROOT / "crates/aot/aarch64-apple-darwin/Cargo.toml", - ROOT / "crates/aot/aarch64-unknown-linux-gnu/Cargo.toml", - ROOT / "crates/aot/x86_64-pc-windows-msvc/Cargo.toml", - ROOT / "crates/aot/x86_64-unknown-linux-gnu/Cargo.toml", + ROOT / "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml", + ROOT / "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", + ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-apple-darwin/Cargo.toml", + ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/aarch64-unknown-linux-gnu/Cargo.toml", + ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-pc-windows-msvc/Cargo.toml", + ROOT / "src/runtimes/liboliphaunt/wasix/crates/aot/x86_64-unknown-linux-gnu/Cargo.toml", ] PACKAGE_START_RE = re.compile(r"^\s*\[\[package\]\]\s*$") STRING_KEY_RE = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*(?:#.*)?$') @@ -149,7 +149,7 @@ def main() -> int: for change in all_changes: print(change, file=sys.stderr) if args.check: - print("example lockfiles are stale; run `scripts/sync-example-lockfiles.py`", file=sys.stderr) + print("example lockfiles are stale; run `tools/release/sync-example-lockfiles.py`", file=sys.stderr) return 1 print("updated example lockfiles") diff --git a/tools/release/upload_github_release_assets.py b/tools/release/upload_github_release_assets.py new file mode 100755 index 00000000..116d4a66 --- /dev/null +++ b/tools/release/upload_github_release_assets.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Upload assets to a product-scoped GitHub release created by release-please.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path +from typing import NoReturn + +import product_metadata + + +ROOT = Path(__file__).resolve().parents[2] + + +def fail(message: str) -> NoReturn: + print(f"upload_github_release_assets.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def default_tag(product: str) -> str: + prefix = product_metadata.tag_prefix(product) + return f"{prefix}{product_metadata.read_current_version(product)}" + + +def release_exists(tag: str, repo: str) -> bool: + result = subprocess.run( + ["gh", "release", "view", tag, "--repo", repo], + cwd=ROOT, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def run_gh(args: list[str]) -> None: + subprocess.run(["gh", *args], cwd=ROOT, check=True) + + +def upload_release_assets(product: str, tag: str, repo: str, assets: list[str]) -> None: + if not release_exists(tag, repo): + fail( + f"{product} GitHub release {tag} does not exist. " + "Run release-please before package-native publish steps." + ) + if assets: + run_gh(["release", "upload", tag, *assets, "--clobber", "--repo", repo]) + else: + print(f"{product} GitHub release {tag} exists; no assets to upload.") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("product", help="release product id") + parser.add_argument("--tag", help="release tag; defaults to the product tag prefix plus current version") + parser.add_argument( + "--repo", + default=os.environ.get("GITHUB_REPOSITORY", ""), + help="GitHub repository in owner/name form", + ) + parser.add_argument( + "--asset", + action="append", + default=[], + help="asset file to upload; may be passed more than once", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if not args.repo: + fail("--repo or GITHUB_REPOSITORY is required") + assets = [str(Path(asset)) for asset in args.asset] + for asset in assets: + if not (ROOT / asset).is_file() and not Path(asset).is_file(): + fail(f"release asset does not exist: {asset}") + upload_release_assets( + product=args.product, + tag=args.tag or default_tag(args.product), + repo=args.repo, + assets=assets, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/verify_github_release_attestations.py b/tools/release/verify_github_release_attestations.py new file mode 100755 index 00000000..ae9a3582 --- /dev/null +++ b/tools/release/verify_github_release_attestations.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Verify GitHub artifact attestations for asset-backed product releases.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import NoReturn + +import check_github_release_assets +import product_metadata + + +BASE_ASSET_BACKED_PRODUCTS = { + "liboliphaunt-native", + "liboliphaunt-wasix", + "oliphaunt-broker", + "oliphaunt-node-direct", +} + + +def asset_backed_products() -> set[str]: + products = set(BASE_ASSET_BACKED_PRODUCTS) + for product in product_metadata.product_ids(): + if product_metadata.product_config(product).get("kind") == "exact-extension-artifact": + products.add(product) + return products + + +def fail(message: str) -> NoReturn: + print(f"verify_github_release_attestations.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def parse_products(value: str | None) -> list[str]: + if not value: + return sorted(asset_backed_products()) + parsed = json.loads(value) + if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): + fail("--products-json must be a JSON string array") + return [product for product in parsed if product in asset_backed_products()] + + +def run(args: list[str], *, cwd: Path | None = None) -> None: + print("\n==> " + " ".join(args), flush=True) + subprocess.run(args, cwd=cwd, check=True) + + +def verify_product(product: str, destination: Path) -> None: + version = product_metadata.read_current_version(product) + tag = check_github_release_assets.product_tag(product, version) + repo = check_github_release_assets.repository() + signer_workflow = f"{repo}/.github/workflows/release.yml" + assets = check_github_release_assets.expected_assets(product, version) + check_github_release_assets.verify(product, version, assets) + product_dir = destination / product + product_dir.mkdir(parents=True, exist_ok=True) + for asset in assets: + run(["gh", "release", "download", tag, "--repo", repo, "--pattern", asset, "--dir", str(product_dir)]) + run( + [ + "gh", + "attestation", + "verify", + str(product_dir / asset), + "--repo", + repo, + "--signer-workflow", + signer_workflow, + "--source-ref", + "refs/heads/main", + "--deny-self-hosted-runners", + ] + ) + print(f"{product} GitHub release attestations verified for {tag}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--product", action="append", default=[], help="product id to verify") + parser.add_argument("--products-json", help="JSON product id array from the release plan") + parser.add_argument("--head-ref", help="accepted for release.py passthrough; not used") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if shutil.which("gh") is None: + fail("gh CLI is required to verify GitHub release attestations") + products = args.product or parse_products(args.products_json) + unknown = sorted(set(products) - asset_backed_products()) + if unknown: + fail("attestation verification is only defined for asset-backed products: " + ", ".join(unknown)) + if not products: + print("no asset-backed products selected; GitHub attestation verification skipped") + return 0 + with tempfile.TemporaryDirectory(prefix="oliphaunt-release-attestations.") as tmp: + destination = Path(tmp) + for product in products: + verify_product(product, destination) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/verify_product_tag.py b/tools/release/verify_product_tag.py new file mode 100755 index 00000000..4309aa17 --- /dev/null +++ b/tools/release/verify_product_tag.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Verify a product-scoped release-please tag points at the release commit.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from typing import NoReturn + +import product_metadata + + +def fail(message: str) -> NoReturn: + print(f"verify_product_tag.py: {message}", file=sys.stderr) + raise SystemExit(1) + + +def git_output(args: list[str]) -> str: + return subprocess.check_output(["git", *args], text=True).strip() + + +def commit_for_ref(ref: str) -> str: + return git_output(["rev-parse", f"{ref}^{{commit}}"]) + + +def tag_ref(tag: str) -> str: + return f"refs/tags/{tag}" + + +def tag_commit(tag: str) -> str | None: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{tag_ref(tag)}^{{commit}}"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + return result.stdout.strip() + return None + + +def product_tag(product: str) -> str: + prefix = product_metadata.tag_prefix(product) + version = product_metadata.read_current_version(product) + return f"{prefix}{version}" + + +def verify_tag(product: str, target: str) -> str: + tag = product_tag(product) + target_commit = commit_for_ref(target) + existing = tag_commit(tag) + if existing is None: + fail(f"{tag} does not exist. Run release-please before package-native publish steps.") + if existing != target_commit: + fail(f"{tag} points at {existing}, not release commit {target_commit}") + print(f"{tag} points at {target_commit}") + return tag + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("product", help="release product id") + parser.add_argument( + "--target", + default=os.environ.get("GITHUB_SHA", "HEAD"), + help="commitish that the tag must point at", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + verify_tag(args.product, args.target) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release/write_checksum_manifest.py b/tools/release/write_checksum_manifest.py new file mode 100755 index 00000000..0199ff4a --- /dev/null +++ b/tools/release/write_checksum_manifest.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Write a deterministic sha256 manifest for release assets.""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def matching_assets(asset_dir: Path, patterns: list[str]) -> list[Path]: + assets: dict[str, Path] = {} + for pattern in patterns: + for path in asset_dir.glob(pattern): + if path.is_file(): + assets[path.name] = path + return [assets[name] for name in sorted(assets)] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--asset-dir", required=True, help="directory containing assets") + parser.add_argument("--output", required=True, help="checksum manifest file name") + parser.add_argument( + "--pattern", + action="append", + required=True, + help="glob pattern, relative to asset-dir; may be passed more than once", + ) + args = parser.parse_args() + + asset_dir = Path(args.asset_dir).resolve() + output = asset_dir / args.output + assets = matching_assets(asset_dir, args.pattern) + with output.open("w", encoding="utf-8", newline="\n") as handle: + for asset in assets: + if asset == output: + continue + handle.write(f"{sha256(asset)} {asset.name}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/runtime/preflight.sh b/tools/runtime/preflight.sh new file mode 100755 index 00000000..9682c8a6 --- /dev/null +++ b/tools/runtime/preflight.sh @@ -0,0 +1,510 @@ +#!/usr/bin/env sh + +# Shared runtime prerequisite checks for runtime/smoke lanes. This file is +# intentionally POSIX-sh compatible because product SDK checks source it from +# both bash and sh scripts. + +oliphaunt_runtime_repo_root() { + oliphaunt_runtime_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$oliphaunt_runtime_root" ]; then + printf '%s\n' "$oliphaunt_runtime_root" + return 0 + fi + if [ -n "${OLIPHAUNT_WORKSPACE_ROOT:-}" ] && + [ -f "$OLIPHAUNT_WORKSPACE_ROOT/package.json" ] && + [ -d "$OLIPHAUNT_WORKSPACE_ROOT/src" ]; then + (cd "$OLIPHAUNT_WORKSPACE_ROOT" && pwd -P) + return 0 + fi + if [ -f package.json ] && [ -d src ]; then + pwd -P + return 0 + fi + echo "must run inside the Oliphaunt workspace" >&2 + return 1 +} + +oliphaunt_runtime_host_library_name() { + case "$(uname -s)" in + Darwin) printf '%s\n' liboliphaunt.dylib ;; + MINGW* | MSYS* | CYGWIN*) printf '%s\n' oliphaunt.dll ;; + *) printf '%s\n' liboliphaunt.so ;; + esac +} + +oliphaunt_runtime_host_library_suffix() { + case "$(uname -s)" in + Darwin) printf '%s\n' dylib ;; + MINGW* | MSYS* | CYGWIN*) printf '%s\n' dll ;; + *) printf '%s\n' so ;; + esac +} + +oliphaunt_runtime_native_host_target_id() { + case "$(uname -s):$(uname -m)" in + Darwin:arm64) printf '%s\n' macos-arm64 ;; + Darwin:x86_64) printf '%s\n' macos-x64 ;; + Linux:x86_64 | Linux:amd64) printf '%s\n' linux-x64-gnu ;; + Linux:aarch64 | Linux:arm64) printf '%s\n' linux-arm64-gnu ;; + MINGW*:x86_64 | MSYS*:x86_64 | CYGWIN*:x86_64) printf '%s\n' windows-x64-msvc ;; + *) + echo "unsupported native host target: $(uname -s)/$(uname -m)" >&2 + return 2 + ;; + esac +} + +oliphaunt_runtime_native_host_work_root() { + if [ -n "${OLIPHAUNT_WORK_ROOT:-}" ]; then + printf '%s\n' "$OLIPHAUNT_WORK_ROOT" + return + fi + + case "$(uname -s)" in + Darwin) + printf '%s\n' "$(oliphaunt_runtime_repo_root)/target/liboliphaunt-pg18" + ;; + Linux) + printf '%s\n' "$(oliphaunt_runtime_repo_root)/target/liboliphaunt-pg18-$(oliphaunt_runtime_native_host_target_id)" + ;; + MINGW* | MSYS* | CYGWIN*) + printf '%s\n' "$(oliphaunt_runtime_repo_root)/target/liboliphaunt-pg18-windows-x64-msvc" + ;; + *) + printf '%s\n' "$(oliphaunt_runtime_repo_root)/target/liboliphaunt-pg18" + ;; + esac +} + +oliphaunt_runtime_native_host_default_lib() { + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + printf '%s/out/bin/%s\n' "$(oliphaunt_runtime_native_host_work_root)" "$(oliphaunt_runtime_host_library_name)" + ;; + *) + printf '%s/out/%s\n' "$(oliphaunt_runtime_native_host_work_root)" "$(oliphaunt_runtime_host_library_name)" + ;; + esac +} + +oliphaunt_runtime_native_host_default_install_dir() { + printf '%s/install\n' "$(oliphaunt_runtime_native_host_work_root)" +} + +oliphaunt_runtime_native_host_default_initdb() { + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) printf '%s/bin/initdb.exe\n' "$(oliphaunt_runtime_native_host_default_install_dir)" ;; + *) printf '%s/bin/initdb\n' "$(oliphaunt_runtime_native_host_default_install_dir)" ;; + esac +} + +oliphaunt_runtime_native_host_default_postgres() { + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) printf '%s/bin/postgres.exe\n' "$(oliphaunt_runtime_native_host_default_install_dir)" ;; + *) printf '%s/bin/postgres\n' "$(oliphaunt_runtime_native_host_default_install_dir)" ;; + esac +} + +oliphaunt_runtime_native_host_default_pg_config() { + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) printf '%s/bin/pg_config.exe\n' "$(oliphaunt_runtime_native_host_default_install_dir)" ;; + *) printf '%s/bin/pg_config\n' "$(oliphaunt_runtime_native_host_default_install_dir)" ;; + esac +} + +oliphaunt_runtime_native_host_lib() { + printf '%s\n' "${LIBOLIPHAUNT_PATH:-$(oliphaunt_runtime_native_host_default_lib)}" +} + +oliphaunt_runtime_native_host_install_dir() { + printf '%s\n' "${OLIPHAUNT_INSTALL_DIR:-$(oliphaunt_runtime_native_host_default_install_dir)}" +} + +oliphaunt_runtime_native_host_initdb() { + printf '%s\n' "${OLIPHAUNT_INITDB:-$(oliphaunt_runtime_native_host_default_initdb)}" +} + +oliphaunt_runtime_native_host_postgres() { + printf '%s\n' "${OLIPHAUNT_POSTGRES:-$(oliphaunt_runtime_native_host_default_postgres)}" +} + +oliphaunt_runtime_native_host_pg_config() { + printf '%s\n' "${OLIPHAUNT_PG_CONFIG:-$(oliphaunt_runtime_native_host_default_pg_config)}" +} + +oliphaunt_runtime_native_host_export_defaults() { + oliphaunt_runtime_default_lib="$(oliphaunt_runtime_native_host_default_lib)" + oliphaunt_runtime_default_install="$(oliphaunt_runtime_native_host_default_install_dir)" + oliphaunt_runtime_default_initdb="$(oliphaunt_runtime_native_host_default_initdb)" + oliphaunt_runtime_default_postgres="$(oliphaunt_runtime_native_host_default_postgres)" + oliphaunt_runtime_default_pg_config="$(oliphaunt_runtime_native_host_default_pg_config)" + oliphaunt_runtime_default_broker="$(oliphaunt_runtime_repo_root)/target/debug/oliphaunt-broker" + + if [ -z "${LIBOLIPHAUNT_PATH:-}" ] && [ -f "$oliphaunt_runtime_default_lib" ]; then + export LIBOLIPHAUNT_PATH="$oliphaunt_runtime_default_lib" + fi + if [ -z "${OLIPHAUNT_INSTALL_DIR:-}" ] && [ -d "$oliphaunt_runtime_default_install" ]; then + export OLIPHAUNT_INSTALL_DIR="$oliphaunt_runtime_default_install" + fi + if [ -z "${OLIPHAUNT_INITDB:-}" ] && [ -x "$oliphaunt_runtime_default_initdb" ]; then + export OLIPHAUNT_INITDB="$oliphaunt_runtime_default_initdb" + fi + if [ -z "${OLIPHAUNT_POSTGRES:-}" ] && [ -x "$oliphaunt_runtime_default_postgres" ]; then + export OLIPHAUNT_POSTGRES="$oliphaunt_runtime_default_postgres" + fi + if [ -z "${OLIPHAUNT_PG_CONFIG:-}" ] && [ -x "$oliphaunt_runtime_default_pg_config" ]; then + export OLIPHAUNT_PG_CONFIG="$oliphaunt_runtime_default_pg_config" + fi + if [ -z "${OLIPHAUNT_POSTGRES_TOOL_DIR:-}" ] && [ -x "$oliphaunt_runtime_default_postgres" ]; then + export OLIPHAUNT_POSTGRES_TOOL_DIR="$oliphaunt_runtime_default_install/bin" + fi + if [ -z "${OLIPHAUNT_BROKER:-}" ] && [ -x "$oliphaunt_runtime_default_broker" ]; then + export OLIPHAUNT_BROKER="$oliphaunt_runtime_default_broker" + fi +} + +oliphaunt_runtime_native_host_base_ready() { + [ -f "$(oliphaunt_runtime_native_host_lib)" ] && + [ -d "$(oliphaunt_runtime_native_host_install_dir)" ] && + [ -x "$(oliphaunt_runtime_native_host_initdb)" ] && + [ -x "$(oliphaunt_runtime_native_host_postgres)" ] && + [ -x "$(oliphaunt_runtime_native_host_pg_config)" ] +} + +oliphaunt_runtime_native_host_has_icu() { + oliphaunt_runtime_pg_config="$(oliphaunt_runtime_native_host_pg_config)" + oliphaunt_runtime_install_dir="$(oliphaunt_runtime_native_host_install_dir)" + [ -x "$oliphaunt_runtime_pg_config" ] || return 1 + case "$("$oliphaunt_runtime_pg_config" --configure 2>/dev/null || true)" in + *"--with-icu"*) ;; + *) return 1 ;; + esac + [ -f "$oliphaunt_runtime_install_dir/include/pg_config.h" ] || return 1 + grep -Eq '^#define USE_ICU 1\b' "$oliphaunt_runtime_install_dir/include/pg_config.h" +} + +oliphaunt_runtime_native_host_can_initdb() { + oliphaunt_runtime_initdb="$(oliphaunt_runtime_native_host_initdb)" + [ -x "$oliphaunt_runtime_initdb" ] || return 1 + + oliphaunt_runtime_probe_dir="$(mktemp -d "${TMPDIR:-/tmp}/oliphaunt-runtime-initdb.XXXXXX")" + if "$oliphaunt_runtime_initdb" -D "$oliphaunt_runtime_probe_dir/pgdata" --no-sync >/dev/null 2>&1; then + oliphaunt_runtime_initdb_status=0 + else + oliphaunt_runtime_initdb_status="$?" + fi + rm -rf "$oliphaunt_runtime_probe_dir" + return "$oliphaunt_runtime_initdb_status" +} + +oliphaunt_runtime_native_extension_inventory() { + oliphaunt_runtime_inventory_script="$(oliphaunt_runtime_repo_root)/src/runtimes/liboliphaunt/native/bin/build-postgres18-macos.sh" + [ -x "$oliphaunt_runtime_inventory_script" ] || return 1 + "$oliphaunt_runtime_inventory_script" --print-required-extension-artifacts +} + +oliphaunt_runtime_extension_sql_file_exists() { + for oliphaunt_runtime_sql_path in "$1/$2"--*.sql; do + [ -f "$oliphaunt_runtime_sql_path" ] && return 0 + done + return 1 +} + +oliphaunt_runtime_native_host_extensions_ready() { + oliphaunt_runtime_verbose="${1:-0}" + oliphaunt_runtime_install_dir="$(oliphaunt_runtime_native_host_install_dir)" + oliphaunt_runtime_lib="$(oliphaunt_runtime_native_host_lib)" + oliphaunt_runtime_out_dir="$(dirname "$oliphaunt_runtime_lib")" + oliphaunt_runtime_extension_dir="$oliphaunt_runtime_install_dir/share/postgresql/extension" + oliphaunt_runtime_normal_module_dir="$oliphaunt_runtime_install_dir/lib/postgresql" + oliphaunt_runtime_embedded_module_dir="$oliphaunt_runtime_out_dir/modules" + oliphaunt_runtime_module_suffix="$(oliphaunt_runtime_host_library_suffix)" + oliphaunt_runtime_ok=1 + + if ! oliphaunt_runtime_inventory="$(oliphaunt_runtime_native_extension_inventory 2>/dev/null)"; then + [ "$oliphaunt_runtime_verbose" -eq 0 ] || + echo "could not read native extension artifact inventory from liboliphaunt build script" >&2 + return 1 + fi + + while IFS=: read -r oliphaunt_runtime_kind oliphaunt_runtime_name; do + [ -n "$oliphaunt_runtime_kind" ] || continue + case "$oliphaunt_runtime_kind" in + control) + if [ ! -f "$oliphaunt_runtime_extension_dir/$oliphaunt_runtime_name.control" ]; then + [ "$oliphaunt_runtime_verbose" -eq 0 ] || + echo "missing native extension control: $oliphaunt_runtime_extension_dir/$oliphaunt_runtime_name.control" >&2 + oliphaunt_runtime_ok=0 + fi + if ! oliphaunt_runtime_extension_sql_file_exists "$oliphaunt_runtime_extension_dir" "$oliphaunt_runtime_name"; then + [ "$oliphaunt_runtime_verbose" -eq 0 ] || + echo "missing native extension SQL: $oliphaunt_runtime_extension_dir/$oliphaunt_runtime_name--*.sql" >&2 + oliphaunt_runtime_ok=0 + fi + ;; + module) + if [ ! -f "$oliphaunt_runtime_normal_module_dir/$oliphaunt_runtime_name.$oliphaunt_runtime_module_suffix" ]; then + [ "$oliphaunt_runtime_verbose" -eq 0 ] || + echo "missing native extension PostgreSQL module: $oliphaunt_runtime_normal_module_dir/$oliphaunt_runtime_name.$oliphaunt_runtime_module_suffix" >&2 + oliphaunt_runtime_ok=0 + fi + if [ ! -f "$oliphaunt_runtime_embedded_module_dir/$oliphaunt_runtime_name.$oliphaunt_runtime_module_suffix" ]; then + [ "$oliphaunt_runtime_verbose" -eq 0 ] || + echo "missing native extension embedded module: $oliphaunt_runtime_embedded_module_dir/$oliphaunt_runtime_name.$oliphaunt_runtime_module_suffix" >&2 + oliphaunt_runtime_ok=0 + fi + ;; + *) + [ "$oliphaunt_runtime_verbose" -eq 0 ] || + echo "unknown native extension artifact inventory row: $oliphaunt_runtime_kind:$oliphaunt_runtime_name" >&2 + oliphaunt_runtime_ok=0 + ;; + esac + done <&2 + return 2 + ;; + esac +} + +oliphaunt_runtime_native_host_diagnostics() { + oliphaunt_runtime_profile="${1:-basic}" + oliphaunt_runtime_native_host_export_defaults + + if ! oliphaunt_runtime_native_host_base_ready; then + cat >&2 <&2 <&2 <&2 <&2 + return 1 + } + "$oliphaunt_runtime_android_script" --check-current +} + +oliphaunt_runtime_ios_simulator_require() { + if [ "$(uname -s)" != "Darwin" ]; then + echo "iOS simulator runtime preflight requires Darwin" >&2 + return 2 + fi + oliphaunt_runtime_ios_script="$(oliphaunt_runtime_repo_root)/src/runtimes/liboliphaunt/native/bin/check-postgres18-ios-simulator.sh" + [ -x "$oliphaunt_runtime_ios_script" ] || { + echo "missing iOS simulator liboliphaunt check script: $oliphaunt_runtime_ios_script" >&2 + return 1 + } + "$oliphaunt_runtime_ios_script" +} + +oliphaunt_runtime_wasm_host_triple() { + rustc -vV | awk '/^host:/{print $2}' +} + +oliphaunt_runtime_wasm_asset_mode() { + python3 - <<'PY' +import json +from pathlib import Path + +manifest = json.loads(Path("target/oliphaunt-wasix/assets/manifest.json").read_text()) +has_extensions = bool(manifest.get("extensions")) +has_pg_dump = bool(manifest.get("pg-dump")) +print("full" if has_extensions and has_pg_dump else "core") +PY +} + +oliphaunt_runtime_wasm_require() { + oliphaunt_runtime_mode="${1:-smoke}" + oliphaunt_runtime_host="$(oliphaunt_runtime_wasm_host_triple)" + if [ ! -f "target/oliphaunt-wasix/assets/manifest.json" ]; then + echo "missing generated portable WASIX assets at target/oliphaunt-wasix/assets" >&2 + return 1 + fi + if [ ! -f "target/oliphaunt-wasix/aot/$oliphaunt_runtime_host/manifest.json" ] && + [ ! -f "src/runtimes/liboliphaunt/wasix/crates/aot/$oliphaunt_runtime_host/artifacts/manifest.json" ]; then + echo "missing host WASIX AOT artifacts for $oliphaunt_runtime_host" >&2 + return 1 + fi + + oliphaunt_runtime_asset_mode="$(oliphaunt_runtime_wasm_asset_mode)" + if [ "$oliphaunt_runtime_asset_mode" = "core" ]; then + if [ "$oliphaunt_runtime_mode" = "regression" ]; then + echo "full WASIX assets are required for liboliphaunt-wasix:regression; core-only assets would skip extension and pg_dump evidence" >&2 + return 1 + fi + export OLIPHAUNT_WASM_SKIP_EXTENSIONS_FOR_PERF=1 + fi + export OLIPHAUNT_RUNTIME_WASM_ASSET_MODE="$oliphaunt_runtime_asset_mode" +} + +oliphaunt_runtime_preflight_main() { + oliphaunt_runtime_target="${1:-}" + shift || true + case "$oliphaunt_runtime_target" in + native-host) + oliphaunt_runtime_action="check" + oliphaunt_runtime_profile="basic" + while [ "$#" -gt 0 ]; do + case "$1" in + --check) oliphaunt_runtime_action="check" ;; + --require) oliphaunt_runtime_action="require" ;; + --diagnose) oliphaunt_runtime_action="diagnose" ;; + --print-env) oliphaunt_runtime_action="print-env" ;; + --extensions|--full) oliphaunt_runtime_profile="extensions" ;; + *) + echo "usage: tools/runtime/preflight.sh native-host [--check|--require|--diagnose|--print-env] [--extensions]" >&2 + return 2 + ;; + esac + shift + done + case "$oliphaunt_runtime_action" in + check) + oliphaunt_runtime_native_host_ready "$oliphaunt_runtime_profile" + ;; + require) + oliphaunt_runtime_native_host_require "$oliphaunt_runtime_profile" + ;; + diagnose) + oliphaunt_runtime_native_host_diagnostics "$oliphaunt_runtime_profile" + ;; + print-env) + oliphaunt_runtime_native_host_export_defaults + printf 'LIBOLIPHAUNT_PATH=%s\n' "$(oliphaunt_runtime_native_host_lib)" + printf 'OLIPHAUNT_INSTALL_DIR=%s\n' "$(oliphaunt_runtime_native_host_install_dir)" + printf 'OLIPHAUNT_INITDB=%s\n' "$(oliphaunt_runtime_native_host_initdb)" + printf 'OLIPHAUNT_POSTGRES=%s\n' "$(oliphaunt_runtime_native_host_postgres)" + printf 'OLIPHAUNT_PG_CONFIG=%s\n' "$(oliphaunt_runtime_native_host_pg_config)" + ;; + esac + ;; + android-arm64) + oliphaunt_runtime_android_arm64_require + ;; + ios-simulator) + oliphaunt_runtime_ios_simulator_require + ;; + wasm) + oliphaunt_runtime_mode="smoke" + while [ "$#" -gt 0 ]; do + case "$1" in + --mode) + shift + oliphaunt_runtime_mode="${1:-}" + ;; + smoke|regression) + oliphaunt_runtime_mode="$1" + ;; + *) + echo "usage: tools/runtime/preflight.sh wasm [--mode smoke|regression]" >&2 + return 2 + ;; + esac + shift + done + case "$oliphaunt_runtime_mode" in + smoke|regression) ;; + *) + echo "usage: tools/runtime/preflight.sh wasm [--mode smoke|regression]" >&2 + return 2 + ;; + esac + oliphaunt_runtime_wasm_require "$oliphaunt_runtime_mode" + ;; + *) + cat >&2 <<'MSG' +usage: + tools/runtime/preflight.sh native-host [--check|--require|--diagnose|--print-env] [--extensions] + tools/runtime/preflight.sh android-arm64 + tools/runtime/preflight.sh ios-simulator + tools/runtime/preflight.sh wasm [--mode smoke|regression] +MSG + return 2 + ;; + esac +} + +if [ "$(basename "$0")" = "preflight.sh" ]; then + oliphaunt_runtime_preflight_main "$@" +fi diff --git a/tools/runtime/with-native-runtime-lock.py b/tools/runtime/with-native-runtime-lock.py new file mode 100755 index 00000000..a561b79a --- /dev/null +++ b/tools/runtime/with-native-runtime-lock.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Run a command while holding the shared native runtime test lock.""" + +from __future__ import annotations + +import errno +import os +from pathlib import Path +import subprocess +import sys +import time + +if os.name == "nt": + import msvcrt +else: + import fcntl + + +DEFAULT_TIMEOUT_SECONDS = 30 * 60 + + +def repo_root() -> Path: + try: + output = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + stderr=subprocess.DEVNULL, + text=True, + ) + except (OSError, subprocess.CalledProcessError): + return Path.cwd() + return Path(output.strip()) + + +def lock_path() -> Path: + configured = os.environ.get("OLIPHAUNT_NATIVE_RUNTIME_LOCK_FILE") + if configured: + return Path(configured) + return repo_root() / "target" / "oliphaunt-runtime-locks" / "native-runtime-tests.lock" + + +def timeout_seconds() -> float: + configured = os.environ.get("OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS") + if not configured: + return float(DEFAULT_TIMEOUT_SECONDS) + try: + timeout = float(configured) + except ValueError: + raise SystemExit( + "OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS must be a number" + ) from None + if timeout <= 0: + raise SystemExit( + "OLIPHAUNT_NATIVE_RUNTIME_LOCK_TIMEOUT_SECONDS must be greater than zero" + ) + return timeout + + +def open_lock_file(lock_file: Path): + lock_file.parent.mkdir(parents=True, exist_ok=True) + handle = lock_file.open("a+b") + if os.name == "nt": + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + return handle + + +def try_lock(handle) -> None: + if os.name == "nt": + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def unlock(handle) -> None: + if os.name == "nt": + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def is_lock_contention(error: OSError) -> bool: + if os.name == "nt": + return error.errno in { + errno.EACCES, + getattr(errno, "EDEADLK", errno.EACCES), + errno.EAGAIN, + } + return error.errno in {errno.EACCES, errno.EAGAIN} + + +def acquire_lock(lock_file: Path, timeout: float): + handle = open_lock_file(lock_file) + deadline = time.monotonic() + timeout + last_notice = 0.0 + + while True: + try: + try_lock(handle) + break + except OSError as error: + if not is_lock_contention(error): + handle.close() + raise + now = time.monotonic() + if now >= deadline: + handle.close() + raise TimeoutError( + f"timed out waiting for native runtime test lock after {timeout:.0f}s: {lock_file}" + ) from error + if now - last_notice >= 30: + print( + f"waiting for native runtime test lock: {lock_file}", + file=sys.stderr, + flush=True, + ) + last_notice = now + time.sleep(0.25) + + handle.seek(0) + handle.truncate() + metadata = ( + f"pid={os.getpid()}\n" + f"cwd={Path.cwd()}\n" + f"started_at_unix={int(time.time())}\n" + f"command={' '.join(sys.argv[1:])}\n" + ) + handle.write(metadata.encode("utf-8")) + handle.flush() + return handle + + +def main() -> int: + if len(sys.argv) < 2: + print( + "usage: tools/runtime/with-native-runtime-lock.py [args...]", + file=sys.stderr, + ) + return 2 + + path = lock_path() + try: + handle = acquire_lock(path, timeout_seconds()) + except TimeoutError as error: + print(error, file=sys.stderr) + return 124 + + try: + completed = subprocess.run(sys.argv[1:], check=False) + finally: + unlock(handle) + handle.close() + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test/create-broker-release-fixture.py b/tools/test/create-broker-release-fixture.py new file mode 100644 index 00000000..d82bcedd --- /dev/null +++ b/tools/test/create-broker-release-fixture.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Create small oliphaunt-broker release-shaped assets for SDK checks.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from release_fixture_utils import write_checksum_manifest, write_tar_gz, write_zip + + +def broker_entries(target: str, executable: str) -> dict[str, bytes]: + return { + executable: b"#!/bin/sh\necho oliphaunt-broker release fixture\n", + "manifest.properties": ( + b"schema=oliphaunt-broker-release-assets-v1\n" + b"product=oliphaunt-broker\n" + + f"target={target}\n".encode() + + f"binary={executable}\n".encode() + ), + } + + +def write_fixture_assets(asset_dir: Path, version: str) -> None: + asset_dir.mkdir(parents=True, exist_ok=True) + executable_modes = {"bin/oliphaunt-broker": 0o755, "bin/oliphaunt-broker.exe": 0o755} + + for target in ["macos-arm64", "linux-x64-gnu", "linux-arm64-gnu"]: + write_tar_gz( + asset_dir / f"oliphaunt-broker-{version}-{target}.tar.gz", + broker_entries(target, "bin/oliphaunt-broker"), + executable_modes, + ) + + write_zip( + asset_dir / f"oliphaunt-broker-{version}-windows-x64-msvc.zip", + broker_entries("windows-x64-msvc", "bin/oliphaunt-broker.exe"), + executable_modes, + ) + write_checksum_manifest(asset_dir, f"oliphaunt-broker-{version}-release-assets.sha256") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--asset-dir", required=True, help="directory to write release-shaped assets into") + parser.add_argument("--version", required=True, help="oliphaunt-broker version to encode in asset names") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + write_fixture_assets(Path(args.asset_dir).resolve(), args.version) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test/create-liboliphaunt-release-fixture.py b/tools/test/create-liboliphaunt-release-fixture.py new file mode 100644 index 00000000..b2234b7a --- /dev/null +++ b/tools/test/create-liboliphaunt-release-fixture.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Create small liboliphaunt release-shaped assets for SDK package checks. + +The generated assets are not runnable PostgreSQL builds. They intentionally +exercise the consumer-facing release contract: product-scoped asset names, +checksums, archive layouts, and runtime-resource extraction. +""" + +from __future__ import annotations + +import argparse +import plistlib +from pathlib import Path + +from release_fixture_utils import write_checksum_manifest, write_tar_gz, write_zip + + +def runtime_resource_entries() -> dict[str, bytes]: + return { + "oliphaunt/package-size.tsv": ( + b"kind\tid\textensions\tfiles\tbytes\n" + b"package\ttotal\t-\t-\t96\n" + b"package\truntime\t-\t-\t31\n" + b"package\ttemplate-pgdata\t-\t-\t20\n" + b"package\tstatic-registry\t-\t-\t45\n" + b"extensions\tselected\t-\t-\t0\n" + ), + "oliphaunt/runtime/files/share/postgresql/README.release-fixture": ( + b"release-shaped runtime fixture\n" + ), + "oliphaunt/static-registry/manifest.properties": ( + b"schema=oliphaunt-static-registry-v1\n" + b"registered=\n" + b"pending=\n" + ), + "oliphaunt/runtime/manifest.properties": ( + b"schema=oliphaunt-runtime-resources-v1\n" + b"cacheKey=release-fixture-runtime\n" + b"layout=postgres-runtime-files-v1\n" + b"extensions=\n" + b"sharedPreloadLibraries=\n" + b"mobileStaticRegistryState=not-required\n" + b"mobileStaticRegistryRegistered=\n" + b"mobileStaticRegistryPending=\n" + b"nativeModuleStems=\n" + b"mobileStaticRegistrySource=\n" + ), + "oliphaunt/template-pgdata/files/PG_VERSION": b"18\n", + "oliphaunt/template-pgdata/manifest.properties": ( + b"schema=oliphaunt-runtime-resources-v1\n" + b"cacheKey=release-fixture-template\n" + b"layout=postgres-template-pgdata-v1\n" + b"extensions=\n" + b"sharedPreloadLibraries=\n" + b"mobileStaticRegistryState=not-required\n" + b"mobileStaticRegistryRegistered=\n" + b"mobileStaticRegistryPending=\n" + b"nativeModuleStems=\n" + b"mobileStaticRegistrySource=\n" + ), + } + + +def xcframework_entries() -> dict[str, bytes]: + libraries = [ + { + "LibraryIdentifier": "macos-arm64", + "LibraryPath": "liboliphaunt.framework", + "SupportedArchitectures": ["arm64"], + "SupportedPlatform": "macos", + }, + { + "LibraryIdentifier": "ios-arm64", + "LibraryPath": "liboliphaunt.framework", + "SupportedArchitectures": ["arm64"], + "SupportedPlatform": "ios", + }, + { + "LibraryIdentifier": "ios-arm64_x86_64-simulator", + "LibraryPath": "liboliphaunt.framework", + "SupportedArchitectures": ["arm64", "x86_64"], + "SupportedPlatform": "ios", + "SupportedPlatformVariant": "simulator", + }, + ] + info = plistlib.dumps( + { + "AvailableLibraries": libraries, + "CFBundlePackageType": "XFWK", + "XCFrameworkFormatVersion": "1.0", + }, + sort_keys=True, + ) + entries = {"liboliphaunt.xcframework/Info.plist": info} + for library in libraries: + identifier = library["LibraryIdentifier"] + framework_root = f"liboliphaunt.xcframework/{identifier}/liboliphaunt.framework" + entries[f"{framework_root}/liboliphaunt"] = b"not-a-real-framework-binary\n" + entries[f"{framework_root}/Info.plist"] = plistlib.dumps( + { + "CFBundleExecutable": "liboliphaunt", + "CFBundleIdentifier": "dev.oliphaunt.liboliphaunt.fixture", + "CFBundleName": "liboliphaunt", + "CFBundlePackageType": "FMWK", + }, + sort_keys=True, + ) + return entries + + +def write_fixture_assets(asset_dir: Path, version: str) -> None: + asset_dir.mkdir(parents=True, exist_ok=True) + + (asset_dir / f"liboliphaunt-{version}-package-size.tsv").write_text( + "\n".join( + [ + "kind\tid\textensions\tfiles\tbytes", + "package\ttotal\t-\t-\t96", + "package\truntime\t-\t-\t31", + "package\ttemplate-pgdata\t-\t-\t20", + "package\tstatic-registry\t-\t-\t45", + "extensions\tselected\t-\t-\t0", + ] + ) + + "\n", + encoding="utf-8", + ) + + write_tar_gz( + asset_dir / f"liboliphaunt-{version}-runtime-resources.tar.gz", + runtime_resource_entries(), + ) + write_tar_gz( + asset_dir / f"liboliphaunt-{version}-macos-arm64.tar.gz", + {"lib/liboliphaunt.dylib": b"not-a-real-dylib\n"}, + ) + write_tar_gz( + asset_dir / f"liboliphaunt-{version}-linux-x64-gnu.tar.gz", + {"lib/liboliphaunt.so": b"not-a-real-elf\n"}, + ) + write_tar_gz( + asset_dir / f"liboliphaunt-{version}-linux-arm64-gnu.tar.gz", + {"lib/liboliphaunt.so": b"not-a-real-elf\n"}, + ) + write_tar_gz( + asset_dir / f"liboliphaunt-{version}-ios-xcframework.tar.gz", + xcframework_entries(), + ) + write_tar_gz( + asset_dir / f"liboliphaunt-{version}-android-arm64-v8a.tar.gz", + {"jni/arm64-v8a/liboliphaunt.so": b"not-a-real-android-elf\n"}, + ) + write_tar_gz( + asset_dir / f"liboliphaunt-{version}-android-x86_64.tar.gz", + {"jni/x86_64/liboliphaunt.so": b"not-a-real-android-elf\n"}, + ) + write_zip( + asset_dir / f"liboliphaunt-{version}-windows-x64-msvc.zip", + {"bin/oliphaunt.dll": b"not-a-real-dll\n"}, + ) + write_zip( + asset_dir / f"liboliphaunt-{version}-apple-spm-xcframework.zip", + xcframework_entries(), + ) + + write_checksum_manifest(asset_dir, f"liboliphaunt-{version}-release-assets.sha256") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--asset-dir", required=True, help="directory to write release-shaped assets into") + parser.add_argument("--version", required=True, help="liboliphaunt version to encode in asset names") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + write_fixture_assets(Path(args.asset_dir).resolve(), args.version) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test/moon.yml b/tools/test/moon.yml new file mode 100644 index 00000000..c3bca18e --- /dev/null +++ b/tools/test/moon.yml @@ -0,0 +1,27 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "test-tools" +language: "javascript" +layer: "tool" +stack: "infrastructure" +tags: ["tools", "testing", "repo-hygiene"] + +project: + title: "Test Tools" + description: "Shared deterministic test runners used by product-native package scripts." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/core" + paths: + "**/*": ["@oliphaunt/core"] + +tasks: + check: + tags: ["quality", "static"] + command: "sh -c 'node --check tools/test/run-js-tests.mjs && python3 -m py_compile tools/test/create-liboliphaunt-release-fixture.py tools/test/create-broker-release-fixture.py tools/test/release_fixture_utils.py'" + inputs: + - "/tools/test/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/tools/test/release_fixture_utils.py b/tools/test/release_fixture_utils.py new file mode 100644 index 00000000..4b81f42d --- /dev/null +++ b/tools/test/release_fixture_utils.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Shared helpers for small release-shaped fixture assets.""" + +from __future__ import annotations + +import hashlib +import io +import tarfile +import zipfile +from pathlib import Path +from tarfile import TarInfo + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def add_tar_file(archive: tarfile.TarFile, name: str, data: bytes, mode: int = 0o644) -> None: + info = TarInfo(name) + info.size = len(data) + info.mode = mode + info.mtime = 0 + archive.addfile(info, io.BytesIO(data)) + + +def write_tar_gz(path: Path, entries: dict[str, bytes], modes: dict[str, int] | None = None) -> None: + with tarfile.open(path, "w:gz", format=tarfile.PAX_FORMAT) as archive: + for name, data in sorted(entries.items()): + add_tar_file(archive, name, data, mode=(modes or {}).get(name, 0o644)) + + +def write_zip(path: Path, entries: dict[str, bytes], modes: dict[str, int] | None = None) -> None: + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in sorted(entries.items()): + info = zipfile.ZipInfo(name) + info.date_time = (1980, 1, 1, 0, 0, 0) + info.external_attr = (modes or {}).get(name, 0o644) << 16 + archive.writestr(info, data) + + +def write_checksum_manifest(asset_dir: Path, name: str) -> None: + checksum_asset = asset_dir / name + lines = [] + for asset in sorted(path for path in asset_dir.iterdir() if path.is_file() and path != checksum_asset): + lines.append(f"{sha256(asset)} ./{asset.name}") + checksum_asset.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/tools/test/run-js-tests.mjs b/tools/test/run-js-tests.mjs new file mode 100755 index 00000000..b0fd2076 --- /dev/null +++ b/tools/test/run-js-tests.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +function fail(message) { + console.error(`run-js-tests: ${message}`); + process.exit(1); +} + +function walk(dir, files = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, files); + } else if (entry.isFile() && entry.name.endsWith('.test.ts')) { + files.push(fullPath); + } + } + return files; +} + +function jsonArrayEnv(name) { + const raw = process.env[name]; + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((value) => typeof value === 'string')) { + return parsed; + } + } catch { + // Fall through to the consistent error below. + } + fail(`${name} must be a JSON array of strings`); +} + +function coverageArgs() { + if (process.env.OLIPHAUNT_VITEST_COVERAGE !== '1') { + return []; + } + const reportsDir = process.env.OLIPHAUNT_VITEST_COVERAGE_DIR; + if (!reportsDir) { + fail('OLIPHAUNT_VITEST_COVERAGE_DIR is required when coverage is enabled'); + } + + const args = [ + '--coverage.enabled=true', + '--coverage.provider=v8', + `--coverage.reportsDirectory=${reportsDir}`, + '--coverage.reporter=text', + '--coverage.reporter=lcov', + '--coverage.reporter=json-summary', + '--coverage.thresholds.branches=0', + '--coverage.thresholds.functions=0', + '--coverage.thresholds.statements=0', + ]; + + const lineThreshold = process.env.OLIPHAUNT_VITEST_COVERAGE_LINES; + if (lineThreshold) { + args.push(`--coverage.thresholds.lines=${lineThreshold}`); + } + for (const pattern of jsonArrayEnv('OLIPHAUNT_VITEST_COVERAGE_INCLUDE')) { + args.push(`--coverage.include=${pattern}`); + } + for (const pattern of jsonArrayEnv('OLIPHAUNT_VITEST_COVERAGE_EXCLUDE')) { + args.push(`--coverage.exclude=${pattern}`); + } + return args; +} + +const testRoot = process.argv[2] ?? 'src/__tests__'; +const absoluteRoot = path.resolve(process.cwd(), testRoot); +if (!existsSync(absoluteRoot) || !statSync(absoluteRoot).isDirectory()) { + fail(`test directory does not exist: ${testRoot}`); +} + +const tests = walk(absoluteRoot).sort((left, right) => left.localeCompare(right)); +if (tests.length === 0) { + fail(`no *.test.ts files discovered under ${testRoot}`); +} + +const relativeTests = tests.map((test) => path.relative(process.cwd(), test)); +const vitestArgs = [ + 'run', + '--pool=forks', + '--fileParallelism=false', + ...coverageArgs(), + ...relativeTests, +]; +console.log(`\n==> vitest ${vitestArgs.join(' ')}`); +const result = spawnSync('pnpm', ['exec', 'vitest', ...vitestArgs], { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit', +}); +if (result.error) { + fail(`could not run vitest: ${result.error.message}`); +} +if (result.status !== 0) { + process.exit(result.status ?? 1); +} diff --git a/xtask/Cargo.toml b/tools/xtask/Cargo.toml similarity index 52% rename from xtask/Cargo.toml rename to tools/xtask/Cargo.toml index e6afb8de..974a4e8b 100644 --- a/xtask/Cargo.toml +++ b/tools/xtask/Cargo.toml @@ -3,39 +3,39 @@ name = "xtask" version = "0.0.0" edition = "2024" rust-version = "1.93" +license.workspace = true publish = false [features] +default = [] wasix-runner = ["dep:wasmer", "dep:wasmer-wasix", "dep:webc"] -template-runner = ["wasix-runner", "wasmer/llvm", "wasmer-wasix/host-fs"] +template-runner = [ + "wasix-runner", + "wasmer/llvm", + "wasmer-wasix/host-fs", + "dep:tokio", +] aot-serializer = [ "template-runner", "wasmer/wasmer-artifact-create", + "dep:wasmer-types", ] [dependencies] anyhow = "1" async-trait = "0.1" -directories = "6" -futures-util = "0.3" -pglite-oxide = { path = "..", features = ["extensions"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -sqlx = { version = "0.8", default-features = false, features = [ - "postgres", - "runtime-tokio", -] } tar = "0.4" -tokio = { version = "1", features = ["rt-multi-thread"] } -tokio-postgres = "0.7" +tokio = { version = "1", features = ["rt-multi-thread"], optional = true } toml = "0.9" walkdir = "2" -wasmer = { version = "=7.2.0-alpha.3", default-features = false, features = [ +wasmer = { version = "7.2.0-alpha.3", default-features = false, features = [ "sys", ], optional = true } -wasmer-types = "=7.2.0-alpha.3" -wasmer-wasix = { version = "=0.702.0-alpha.3", default-features = false, features = [ +wasmer-types = { version = "7.2.0-alpha.3", optional = true } +wasmer-wasix = { version = "0.702.0-alpha.3", default-features = false, features = [ "sys-minimal", "sys-poll", "sys-thread", diff --git a/tools/xtask/moon.yml b/tools/xtask/moon.yml new file mode 100644 index 00000000..c1ede8c4 --- /dev/null +++ b/tools/xtask/moon.yml @@ -0,0 +1,59 @@ +$schema: "https://moonrepo.dev/schemas/project.json" + +id: "xtask" +language: "rust" +layer: "tool" +stack: "systems" +tags: ["tools", "rust", "xtask", "wasm"] + +project: + title: "xtask" + description: "Rust helper binary for WASM assets, release staging, and source-policy checks." + owner: "oliphaunt" + +owners: + defaultOwner: "@oliphaunt/wasm" + paths: + "**/*.rs": ["@oliphaunt/wasm"] + "Cargo.toml": ["@oliphaunt/wasm"] + +tasks: + check: + tags: ["quality", "static"] + command: "cargo check -p xtask --locked" + env: + CARGO_TARGET_DIR: "target/moon/xtask/check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/tools/xtask/**/*" + options: + cache: true + runFromWorkspaceRoot: true + test: + tags: ["quality", "unit"] + command: "cargo check -p xtask --features template-runner --locked" + env: + CARGO_TARGET_DIR: "target/moon/xtask/test" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/tools/xtask/**/*" + options: + cache: true + runFromWorkspaceRoot: true + release-check: + tags: ["release", "package"] + command: "cargo check -p xtask --features aot-serializer --locked" + env: + CARGO_TARGET_DIR: "target/moon/xtask/release-check" + inputs: + - "/Cargo.lock" + - "/Cargo.toml" + - "/rust-toolchain.toml" + - "/tools/xtask/**/*" + options: + cache: true + runFromWorkspaceRoot: true diff --git a/tools/xtask/src/aot_serializer.rs b/tools/xtask/src/aot_serializer.rs new file mode 100644 index 00000000..a7eff7c4 --- /dev/null +++ b/tools/xtask/src/aot_serializer.rs @@ -0,0 +1,213 @@ +#[cfg(feature = "aot-serializer")] +use std::env; +#[cfg(feature = "aot-serializer")] +use std::fs; +#[cfg(feature = "aot-serializer")] +use std::io; +#[cfg(feature = "aot-serializer")] +use std::path::{Path, PathBuf}; + +#[cfg(feature = "aot-serializer")] +use anyhow::{Context, anyhow}; +use anyhow::{Result, bail}; +#[cfg(feature = "aot-serializer")] +use zstd::stream::write::Encoder as ZstdEncoder; + +#[cfg(feature = "aot-serializer")] +use crate::value_after; + +pub(crate) fn aot_serializer(args: Vec) -> Result<()> { + match args.first().map(String::as_str) { + Some("serialize") => serialize_aot_cli(&args[1..]), + Some("probe") => probe_aot_serializer_in_process(), + Some(other) => bail!("unknown aot-serializer subcommand: {other}"), + None => bail!( + "usage: cargo run -p xtask --features aot-serializer -- aot-serializer " + ), + } +} + +#[cfg(not(feature = "aot-serializer"))] +fn serialize_aot_cli(_args: &[String]) -> Result<()> { + bail!("xtask aot-serializer requires `cargo run -p xtask --features aot-serializer -- ...`") +} + +#[cfg(feature = "aot-serializer")] +fn serialize_aot_cli(args: &[String]) -> Result<()> { + let input = value_after(args, "--input") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("--input is required"))?; + let output = value_after(args, "--output") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("--output is required"))?; + serialize_aot_module(&input, &output) +} + +#[cfg(not(feature = "aot-serializer"))] +fn probe_aot_serializer_in_process() -> Result<()> { + bail!( + "xtask aot-serializer probe requires `cargo run -p xtask --features aot-serializer -- ...`" + ) +} + +#[cfg(feature = "aot-serializer")] +fn probe_aot_serializer_in_process() -> Result<()> { + let engine = llvm_aot_engine(); + let store = wasmer::Store::new(engine.clone()); + const EMPTY_WASM: &[u8] = b"\0asm\x01\0\0\0"; + let module = + wasmer::Module::new(&store, EMPTY_WASM).context("compile LLVM AOT probe module")?; + let serialized = module + .serialize() + .context("serialize LLVM AOT probe module")?; + print_aot_engine_config(&engine); + println!("serialized-probe-bytes: {}", serialized.len()); + Ok(()) +} + +#[cfg(feature = "aot-serializer")] +fn serialize_aot_module(input: &Path, output: &Path) -> Result<()> { + let engine = llvm_aot_engine(); + print_aot_engine_config(&engine); + println!("host-target: {}-{}", env::consts::OS, env::consts::ARCH); + + let store = wasmer::Store::new(engine); + let bytes = fs::read(input).with_context(|| format!("read {}", input.display()))?; + let module = wasmer::Module::new(&store, &bytes) + .with_context(|| format!("compile {}", input.display()))?; + let serialized = module.serialize().context("serialize module")?; + + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let file = fs::File::create(output).with_context(|| format!("create {}", output.display()))?; + let mut encoder = ZstdEncoder::new(file, 19) + .with_context(|| format!("create zstd encoder for {}", output.display()))?; + let mut serialized_slice = serialized.as_ref(); + io::copy(&mut serialized_slice, &mut encoder) + .with_context(|| format!("write {}", output.display()))?; + encoder + .finish() + .with_context(|| format!("finish {}", output.display()))?; + println!( + "serialized {} bytes to {}", + serialized.len(), + output.display() + ); + Ok(()) +} + +#[cfg(feature = "aot-serializer")] +fn llvm_aot_engine() -> wasmer::Engine { + use wasmer::sys::{CompilerConfig, EngineBuilder, Features, LLVM}; + + let mut features = Features::new(); + features.exceptions(true); + let mut llvm = LLVM::default(); + if env_flag("OLIPHAUNT_WASM_WASMER_PERFMAP") { + llvm.enable_perfmap(); + } + if env_flag_default_true("OLIPHAUNT_WASM_AOT_NON_VOLATILE_MEMOPS") { + llvm.enable_non_volatile_memops(); + } + if env_flag_default_true("OLIPHAUNT_WASM_AOT_READONLY_FUNCREF_TABLE") { + llvm.enable_readonly_funcref_table(); + } + EngineBuilder::new(llvm) + .set_target(Some(portable_aot_target())) + .set_features(Some(features)) + .engine() + .into() +} + +#[cfg(feature = "aot-serializer")] +fn portable_aot_target() -> wasmer_types::target::Target { + use wasmer_types::target::{Architecture, CpuFeature, Target, Triple}; + + let triple = Triple::host(); + let mut cpu_features = CpuFeature::set(); + match triple.architecture { + Architecture::X86_64 => { + cpu_features.insert(CpuFeature::SSE2); + } + Architecture::Aarch64(_) => { + cpu_features.insert(CpuFeature::NEON); + } + _ => {} + } + + Target::new(triple, cpu_features) +} + +#[cfg(feature = "aot-serializer")] +fn print_aot_engine_config(engine: &wasmer::Engine) { + let target = portable_aot_target(); + println!("wasmer-engine: llvm"); + println!("wasmer-engine-id: {}", engine.deterministic_id()); + println!("wasmer-target-triple: {}", target.triple()); + println!( + "wasmer-target-cpu-features: {}", + format_aot_cpu_features(&target) + ); + println!("wasmer-feature-exceptions: enabled"); + println!("wasmer-llvm-target-cpu: generic"); + println!( + "wasmer-llvm-non-volatile-memops: {}", + enabled_label(env_flag_default_true( + "OLIPHAUNT_WASM_AOT_NON_VOLATILE_MEMOPS" + )) + ); + println!( + "wasmer-llvm-readonly-funcref-table: {}", + enabled_label(env_flag_default_true( + "OLIPHAUNT_WASM_AOT_READONLY_FUNCREF_TABLE" + )) + ); +} + +#[cfg(feature = "aot-serializer")] +fn format_aot_cpu_features(target: &wasmer_types::target::Target) -> String { + let mut features = target + .cpu_features() + .iter() + .map(|feature| feature.to_string()) + .collect::>(); + features.sort(); + if features.is_empty() { + "none".to_owned() + } else { + features.join(",") + } +} + +#[cfg(feature = "aot-serializer")] +fn env_flag(name: &str) -> bool { + env::var(name) + .map(|value| { + let value = value.trim(); + !value.is_empty() + && !matches!( + value.to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" + ) + }) + .unwrap_or(false) +} + +#[cfg(feature = "aot-serializer")] +fn env_flag_default_true(name: &str) -> bool { + env::var(name) + .map(|value| { + let value = value.trim(); + !matches!( + value.to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" | "off" + ) + }) + .unwrap_or(true) +} + +#[cfg(feature = "aot-serializer")] +fn enabled_label(enabled: bool) -> &'static str { + if enabled { "enabled" } else { "disabled" } +} diff --git a/tools/xtask/src/asset_checks.rs b/tools/xtask/src/asset_checks.rs new file mode 100644 index 00000000..fae84497 --- /dev/null +++ b/tools/xtask/src/asset_checks.rs @@ -0,0 +1,1711 @@ +use super::*; +use crate::source_spine::source_checkout_path; + +pub(crate) fn check_generated_manifest(manifest: &SourcesManifest, strict: bool) -> Result<()> { + check_generated_manifest_with_outputs( + manifest, + strict, + BuildOutputs::discover_for_source_lane(DEFAULT_SOURCE_LANE), + ) +} + +pub(crate) fn check_generated_manifest_for_aot( + manifest: &SourcesManifest, + strict: bool, +) -> Result<()> { + check_generated_manifest_with_outputs( + manifest, + strict, + BuildOutputs::discover_for_aot(DEFAULT_SOURCE_LANE), + ) +} + +fn check_generated_manifest_with_outputs( + manifest: &SourcesManifest, + strict: bool, + outputs: Result, +) -> Result<()> { + let source_lane = DEFAULT_SOURCE_LANE; + match outputs.and_then(|outputs| effective_source_pins(manifest, &outputs)) { + Ok(expected_sources) => check_generated_manifest_sources_in( + generated_assets_dir_for_source_lane(source_lane)?, + &expected_sources, + source_lane, + strict, + ), + Err(err) if !strict => { + eprintln!( + "warning: skipping generated asset manifest source-pin check for {source_lane}: {err:#}" + ); + Ok(()) + } + Err(err) => Err(err).context("derive expected generated asset manifest source pins"), + } +} + +pub(crate) fn check_generated_manifest_sources_in( + asset_dir: &Path, + expected_sources: &[SourcePin], + expected_label: &str, + strict: bool, +) -> Result<()> { + let path = asset_dir.join("manifest.json"); + if !path.exists() { + if strict { + bail!("generated asset manifest is missing at {}", path.display()); + } + eprintln!( + "warning: generated asset manifest is missing at {}", + path.display() + ); + return Ok(()); + } + + let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let generated: GeneratedAssetManifest = + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?; + if expected_label == DEFAULT_SOURCE_LANE { + let actual = generated.source_lane.as_deref().unwrap_or(""); + ensure_eq( + actual, + expected_label, + "generated asset manifest source-lane", + )?; + } + + let mut drift = Vec::new(); + for source in expected_sources { + match generated + .sources + .iter() + .find(|generated| generated.name == source.name) + { + Some(generated) + if generated.url == source.url + && generated.branch == source.branch + && generated.commit == source.commit => {} + Some(generated) => drift.push(format!( + "{} generated={}/{}@{} expected={}/{}@{}", + source.name, + generated.url, + generated.branch, + generated.commit, + source.url, + source.branch, + source.commit + )), + None => drift.push(format!("{} missing from generated manifest", source.name)), + } + } + let expected_source_names = expected_sources + .iter() + .map(|source| source.name.as_str()) + .collect::>(); + for source in &generated.sources { + if !expected_source_names.contains(source.name.as_str()) { + drift.push(format!( + "{} is unexpected in generated manifest", + source.name + )); + } + } + + if drift.is_empty() { + println!("generated asset manifest source pins match {expected_label}"); + return Ok(()); + } + + let details = drift.join("; "); + if strict { + bail!("generated asset manifest has stale source pins: {details}"); + } + eprintln!("warning: generated asset manifest has stale source pins: {details}"); + Ok(()) +} + +pub(crate) fn verify_committed_assets() -> Result<()> { + check_source_free_repo()?; + let manifest = load_sources_manifest()?; + validate_sources_manifest(&manifest)?; + check_no_legacy_runtime_shims()?; + check_production_wasix_build_inputs()?; + check_postgres_source_spine()?; + check_source_lane_isolation()?; + check_rust_startup_abi_boundary()?; + check_or_write_asset_input_fingerprint(false)?; + check_no_committed_portable_asset_blobs()?; + check_no_committed_aot_artifacts()?; + check_aot_crate_templates(&manifest)?; + verify_generated_extension_surface_if_available()?; + check_source_controlled_wasix_export_list()?; + println!("source-controlled asset inputs and crate templates passed"); + Ok(()) +} + +pub(crate) fn check_source_free_repo() -> Result<()> { + if Path::new(".gitmodules").exists() { + bail!("tracked upstream source checkouts are not allowed: remove .gitmodules"); + } + if is_release_staged_workspace() && !Path::new(".git").exists() { + return Ok(()); + } + for path in [ + "src/runtimes/liboliphaunt/wasix/assets/build/build", + "src/runtimes/liboliphaunt/wasix/assets/build/work", + ] { + if Path::new(path).exists() { + bail!( + "{path} must not exist under source control roots; generated WASIX build/work data lives under target/oliphaunt-wasix/wasix-build" + ); + } + } + for path in [ + "assets", + SOURCE_CHECKOUT_ROOT, + WASIX_GENERATED_BUILD_DIR, + WASIX_GENERATED_WORK_DIR, + GENERATED_ASSETS_DIR, + RELEASE_STAGE_DIR, + ] { + let tracked = command_output("git", &["ls-files", path], Path::new("."))?; + if !tracked.trim().is_empty() { + bail!( + "{path} contains tracked generated/source checkout files:\n{}", + tracked.trim() + ); + } + } + Ok(()) +} + +pub(crate) fn is_release_staged_workspace() -> bool { + env::var_os("OLIPHAUNT_WASM_RELEASE_STAGED").as_deref() == Some(std::ffi::OsStr::new("1")) +} + +fn check_no_committed_portable_asset_blobs() -> Result<()> { + let tracked = command_output( + "git", + &[ + "ls-files", + ASSET_CRATE_PAYLOAD_DIR, + LEGACY_STATIC_WASI_ARCHIVE, + "assets/bin", + "assets/prepopulated", + "src/extensions/artifacts/*.tar.gz", + ], + Path::new("."), + )?; + if !tracked.trim().is_empty() { + bail!( + "portable WASIX asset payloads must be generated by CI/release and must not be committed:\n{}", + tracked.trim() + ); + } + println!("committed repo contains no portable WASIX asset blobs"); + Ok(()) +} + +pub(crate) fn check_or_write_asset_input_fingerprint(write: bool) -> Result<()> { + let fingerprint = asset_input_fingerprint()?; + let path = Path::new(ASSET_INPUT_FINGERPRINT_PATH); + if write { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, format!("{fingerprint}\n")) + .with_context(|| format!("write {}", path.display()))?; + println!("wrote {}", path.display()); + return Ok(()); + } + + let expected = fs::read_to_string(path).with_context(|| { + format!( + "read {}; run `cargo run -p xtask -- assets input-fingerprint --write` after refreshing assets", + path.display() + ) + })?; + ensure_eq( + fingerprint.as_str(), + expected.trim(), + "committed asset input fingerprint", + ) +} + +fn asset_input_fingerprint() -> Result { + let tracked = command_output( + "git", + &[ + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "src/sources/third-party", + "src/sources/toolchains", + "src/extensions/catalog/extensions.promoted.toml", + "src/extensions/catalog/extensions.smoke.toml", + "src/extensions/contrib", + "src/extensions/external", + "src/extensions/schemas", + WASIX_BUILD_SOURCE_ROOT, + "src/runtimes/liboliphaunt/native/portable-uuid", + "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", + "src/runtimes/liboliphaunt/wasix/crates/assets/build.rs", + "src/runtimes/liboliphaunt/wasix/crates/assets/src", + "src/runtimes/liboliphaunt/wasix/crates/aot", + "tools/xtask/src", + ], + Path::new("."), + )?; + let mut files = tracked + .lines() + .filter(|line| { + Path::new(line).exists() + && !line.starts_with("src/runtimes/liboliphaunt/wasix/assets/build/build/") + && !line.starts_with("src/runtimes/liboliphaunt/wasix/assets/build/work/") + }) + .map(str::to_owned) + .collect::>(); + files.sort(); + files.dedup(); + if files.is_empty() { + bail!("no tracked asset input files found"); + } + + let mut hasher = Sha256::new(); + for file in files { + let bytes = asset_input_fingerprint_bytes(&file)?; + hasher.update(file.as_bytes()); + hasher.update([0]); + hasher.update(sha256_bytes(&bytes).as_bytes()); + hasher.update([0]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn asset_input_fingerprint_bytes(file: &str) -> Result> { + let bytes = fs::read(file).with_context(|| format!("read {file}"))?; + if !is_internal_asset_package_manifest(file) { + return Ok(bytes); + } + + let text = String::from_utf8(bytes).with_context(|| format!("read {file} as UTF-8"))?; + Ok(normalize_internal_asset_package_manifest(&text).into_bytes()) +} + +fn is_internal_asset_package_manifest(file: &str) -> bool { + file == "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml" + || (file.starts_with("src/runtimes/liboliphaunt/wasix/crates/aot/") + && file.ends_with("/Cargo.toml")) +} + +fn normalize_internal_asset_package_manifest(text: &str) -> String { + let mut normalized = String::with_capacity(text.len()); + let mut in_package = false; + + for chunk in text.split_inclusive('\n') { + let line = chunk.strip_suffix('\n').unwrap_or(chunk); + let logical = line.strip_suffix('\r').unwrap_or(line); + let trimmed = logical.trim(); + if trimmed.starts_with('[') { + in_package = trimmed == "[package]"; + } + + if in_package && is_toml_key(logical, "version") { + let indent_len = logical.len() - logical.trim_start().len(); + normalized.push_str(&logical[..indent_len]); + normalized.push_str("version = \"\""); + if line.ends_with('\r') { + normalized.push('\r'); + } + if chunk.ends_with('\n') { + normalized.push('\n'); + } + } else { + normalized.push_str(chunk); + } + } + + normalized +} + +fn is_toml_key(line: &str, key: &str) -> bool { + line.trim_start() + .strip_prefix(key) + .is_some_and(|rest| rest.trim_start().starts_with('=')) +} + +pub(crate) fn verify_asset_manifest_hashes() -> Result<()> { + let manifest_path = Path::new(GENERATED_ASSETS_DIR).join("manifest.json"); + let text = fs::read_to_string(&manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest: AssetManifestOut = + serde_json::from_str(&text).context("parse generated asset manifest")?; + let base = Path::new(GENERATED_ASSETS_DIR); + + let runtime_archive = base.join(&manifest.runtime.archive); + verify_file_sha256( + &runtime_archive, + &manifest.runtime.sha256, + "runtime archive", + )?; + let runtime_module = archive_entry_bytes(&runtime_archive, "oliphaunt/bin/oliphaunt")?; + ensure_eq( + &sha256_bytes(&runtime_module), + &manifest.runtime.module_sha256, + "runtime module sha256", + )?; + for module in &manifest.runtime_support { + let bytes = archive_entry_bytes(&runtime_archive, &format!("oliphaunt/{}", module.path))?; + ensure_eq( + &sha256_bytes(&bytes), + &module.sha256, + &format!("runtime support {} sha256", module.name), + )?; + ensure_eq( + &sha256_bytes(&bytes), + &module.module_sha256, + &format!("runtime support {} module sha256", module.name), + )?; + } + + if let Some(pg_dump) = &manifest.pg_dump { + verify_file_sha256(&base.join(&pg_dump.path), &pg_dump.sha256, "pg_dump wasm")?; + ensure_eq( + &pg_dump.sha256, + &pg_dump.module_sha256, + "pg_dump module sha256", + )?; + } + if let Some(initdb) = &manifest.initdb { + verify_file_sha256(&base.join(&initdb.path), &initdb.sha256, "initdb wasm")?; + ensure_eq( + &initdb.sha256, + &initdb.module_sha256, + "initdb module sha256", + )?; + } + + for extension in &manifest.extensions { + let archive = base.join(&extension.archive); + verify_file_sha256( + &archive, + &extension.sha256, + &format!("extension {} archive", extension.sql_name), + )?; + if let Some(native_module) = &extension.native_module { + let entry = format!("lib/postgresql/{native_module}"); + let bytes = archive_entry_bytes(&archive, &entry)?; + ensure_eq( + &sha256_bytes(&bytes), + &extension.module_sha256, + &format!("extension {} module sha256", extension.sql_name), + )?; + } + for module in &extension.native_modules { + let bytes = archive_entry_bytes(&archive, &module.path)?; + ensure_eq( + &sha256_bytes(&bytes), + &module.module_sha256, + &format!( + "extension {} native module {} sha256", + extension.sql_name, module.name + ), + )?; + } + } + + let pgdata_archive = base.join("prepopulated/pgdata-template.tar.zst"); + verify_pgdata_template_hash(&pgdata_archive)?; + if let Some(template) = &manifest.pgdata_template { + verify_file_sha256( + &base.join(&template.archive), + &template.sha256, + "PGDATA template", + )?; + ensure_file(&base.join(&template.manifest))?; + ensure_eq( + &template.runtime_module_sha256, + &manifest.runtime.module_sha256, + "PGDATA template runtime module sha256", + )?; + if let Some(initdb) = &manifest.initdb { + ensure_eq( + &template.initdb_module_sha256, + &initdb.module_sha256, + "PGDATA template initdb module sha256", + )?; + } + } + + if is_release_staged_workspace() { + verify_root_asset_metadata(&manifest, &manifest.runtime.module_sha256)?; + verify_file_sha256( + &pgdata_archive, + &cargo_metadata_value("pgdata-template-archive-sha256")?, + "PGDATA template archive metadata", + )?; + } + + println!("generated asset hashes match manifests"); + Ok(()) +} + +fn verify_pgdata_template_hash(pgdata_archive: &Path) -> Result<()> { + let manifest_path = Path::new(GENERATED_ASSETS_DIR).join("prepopulated/pgdata-template.json"); + ensure!( + manifest_path.exists() && pgdata_archive.exists(), + "generated assets must include the bundled PGDATA template required by the default runtime; expected both {} and {}", + manifest_path.display(), + pgdata_archive.display() + ); + let text = fs::read_to_string(&manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("parse {}", manifest_path.display()))?; + let expected = manifest + .get("archiveSha256") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow!("{} is missing archiveSha256", manifest_path.display()))?; + verify_file_sha256(pgdata_archive, expected, "PGDATA template archive")?; + Ok(()) +} + +fn verify_root_asset_metadata( + manifest: &AssetManifestOut, + runtime_module_sha256: &str, +) -> Result<()> { + verify_metadata_value( + "runtime-archive-sha256", + &manifest.runtime.sha256, + "runtime archive metadata", + )?; + verify_metadata_value( + "oliphaunt-wasix-sha256", + runtime_module_sha256, + "runtime module metadata", + )?; + verify_metadata_value( + "postgres-version", + &manifest.runtime.postgres_version, + "PostgreSQL version metadata", + )?; + let pg18 = load_postgres_source_manifest()?; + verify_metadata_value( + "postgres-source-url", + &pg18.postgresql.url, + "PostgreSQL source URL metadata", + )?; + verify_metadata_value( + "postgres-source-sha256", + &pg18.postgresql.sha256, + "PostgreSQL source sha256 metadata", + )?; + verify_metadata_value( + "postgres-patch-count", + &pg18.patches.series.len().to_string(), + "PostgreSQL patch count metadata", + )?; + if let Some(pg_dump) = &manifest.pg_dump { + verify_metadata_value("pg-dump-wasix-sha256", &pg_dump.sha256, "pg_dump metadata")?; + } + if let Some(initdb) = &manifest.initdb { + verify_metadata_value("initdb-wasix-sha256", &initdb.sha256, "initdb metadata")?; + } + Ok(()) +} + +fn verify_metadata_value(key: &str, expected: &str, field: &str) -> Result<()> { + let actual = cargo_metadata_value(key)?; + ensure_eq(&actual, expected, field) +} + +fn cargo_metadata_value(key: &str) -> Result { + let text = fs::read_to_string("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml") + .context("read src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml")?; + let needle = format!("{key} = \""); + let start = text.find(&needle).ok_or_else(|| { + anyhow!( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml metadata key '{key}' is missing" + ) + })? + needle.len(); + let end = text[start..].find('"').ok_or_else(|| { + anyhow!("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml metadata key '{key}' is unterminated") + })?; + Ok(text[start..start + end].to_owned()) +} + +fn verify_file_sha256(path: &Path, expected: &str, field: &str) -> Result<()> { + ensure_file(path)?; + let actual = sha256_file(path)?; + ensure_eq(&actual, expected, field) +} + +fn check_no_committed_aot_artifacts() -> Result<()> { + let tracked = command_output( + "git", + &["ls-files", "src/runtimes/liboliphaunt/wasix/crates/aot"], + Path::new("."), + )?; + let committed_artifacts = tracked + .lines() + .filter(|path| path.contains("/artifacts/")) + .collect::>(); + if !committed_artifacts.is_empty() { + bail!( + "native AOT artifacts must be generated by CI and must not be committed:\n{}", + committed_artifacts.join("\n") + ); + } + println!("committed repo contains no native AOT artifact blobs"); + Ok(()) +} + +fn check_aot_crate_templates(sources: &SourcesManifest) -> Result<()> { + let expected = supported_aot_targets(); + for target in expected { + let crate_dir = Path::new("src/runtimes/liboliphaunt/wasix/crates/aot").join(target); + ensure_file(&crate_dir.join("Cargo.toml"))?; + ensure_file(&crate_dir.join("README.md"))?; + ensure_file(&crate_dir.join("build.rs"))?; + let lib = crate_dir.join("src/lib.rs"); + ensure_file(&lib)?; + + let cargo_toml = fs::read_to_string(crate_dir.join("Cargo.toml")) + .with_context(|| format!("read {}/Cargo.toml", crate_dir.display()))?; + if !cargo_toml.contains("\"build.rs\"") || !cargo_toml.contains("\"artifacts/**\"") { + bail!( + "{} must include build.rs and generated artifacts/** when CI materializes the AOT crate", + crate_dir.join("Cargo.toml").display() + ); + } + + let lib_text = + fs::read_to_string(&lib).with_context(|| format!("read {}", lib.display()))?; + for required in [ + "#![deny(unsafe_code)]", + "include!(concat!(env!(\"OUT_DIR\")", + ] { + if !lib_text.contains(required) { + bail!("{} is not a source-only AOT crate template", lib.display()); + } + } + if lib_text.contains("include_bytes!") || lib_text.contains("include_str!(\"../artifacts/") + { + bail!( + "{} embeds generated AOT artifacts; generated artifacts belong only in CI/release workspaces", + lib.display() + ); + } + let build_rs = fs::read_to_string(crate_dir.join("build.rs")) + .with_context(|| format!("read {}/build.rs", crate_dir.display()))?; + for required in [ + "OLIPHAUNT_WASM_GENERATED_AOT_DIR", + "target/oliphaunt-wasix/aot", + "wasmer-version", + sources.toolchain.wasmer.as_str(), + "wasmer-wasix-version", + sources.toolchain.wasmer_wasix.as_str(), + ] { + if !build_rs.contains(required) { + bail!( + "{} build.rs is missing source-only AOT marker {required}", + crate_dir.display() + ); + } + } + } + println!("AOT crates are source-only templates for CI-generated release artifacts"); + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +struct AotTargetSpec { + triple: &'static str, + target_id: &'static str, + runner_os: &'static str, + package: &'static str, + llvm_url: &'static str, +} + +#[derive(Debug, Serialize)] +struct AotCiMatrix { + include: Vec, +} + +#[derive(Debug, Serialize)] +struct AotCiTarget { + os: &'static str, + target: &'static str, + target_id: &'static str, + package: &'static str, + artifact: String, + llvm_url: &'static str, +} + +fn aot_target_specs() -> &'static [AotTargetSpec] { + &[ + AotTargetSpec { + triple: "aarch64-apple-darwin", + target_id: "macos-arm64", + runner_os: "macos-15", + package: "oliphaunt-wasix-aot-aarch64-apple-darwin", + llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-darwin-aarch64.tar.xz", + }, + AotTargetSpec { + triple: "x86_64-unknown-linux-gnu", + target_id: "linux-x64-gnu", + runner_os: "ubuntu-latest", + package: "oliphaunt-wasix-aot-x86_64-unknown-linux-gnu", + llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-amd64.tar.xz", + }, + AotTargetSpec { + triple: "aarch64-unknown-linux-gnu", + target_id: "linux-arm64-gnu", + runner_os: "ubuntu-24.04-arm", + package: "oliphaunt-wasix-aot-aarch64-unknown-linux-gnu", + llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-linux-aarch64.tar.xz", + }, + AotTargetSpec { + triple: "x86_64-pc-windows-msvc", + target_id: "windows-x64-msvc", + runner_os: "windows-latest", + package: "oliphaunt-wasix-aot-x86_64-pc-windows-msvc", + llvm_url: "https://github.com/wasmerio/llvm-custom-builds/releases/download/22.x/llvm-windows-amd64.tar.xz", + }, + ] +} + +pub(crate) fn supported_aot_targets() -> Vec<&'static str> { + aot_target_specs().iter().map(|spec| spec.triple).collect() +} + +pub(crate) fn supported_aot_target_ids() -> Vec<&'static str> { + aot_target_specs() + .iter() + .map(|spec| spec.target_id) + .collect() +} + +pub(crate) fn aot_target_id_for_triple(target_triple: &str) -> Result<&'static str> { + aot_target_specs() + .iter() + .find(|spec| spec.triple == target_triple) + .map(|spec| spec.target_id) + .with_context(|| { + format!( + "unsupported AOT target triple {target_triple}; supported triples are {}", + supported_aot_targets().join(", ") + ) + }) +} + +pub(crate) fn aot_triple_for_target_selector(selector: &str) -> Result<&'static str> { + aot_target_specs() + .iter() + .find(|spec| selector == spec.triple || selector == spec.target_id) + .map(|spec| spec.triple) + .with_context(|| { + format!( + "unsupported AOT target {selector}; supported target ids are {}", + supported_aot_target_ids().join(", ") + ) + }) +} + +pub(crate) fn aot_artifact_name(target_triple: &str) -> String { + let target_id = aot_target_id_for_triple(target_triple) + .expect("AOT artifact names are only generated for supported target triples"); + format!("liboliphaunt-wasix-runtime-aot-{target_id}") +} + +fn portable_wasix_artifact_name() -> &'static str { + "liboliphaunt-wasix-runtime-portable" +} + +pub(crate) fn print_supported_aot_targets() -> Result<()> { + for spec in aot_target_specs() { + println!("{}", spec.target_id); + } + Ok(()) +} + +pub(crate) fn print_internal_asset_packages() -> Result<()> { + println!("oliphaunt-wasix-assets"); + for spec in aot_target_specs() { + println!("{}", spec.package); + } + Ok(()) +} + +pub(crate) fn print_ci_artifact_names() -> Result<()> { + println!("{}", portable_wasix_artifact_name()); + for spec in aot_target_specs() { + println!("{}", aot_artifact_name(spec.triple)); + } + Ok(()) +} + +pub(crate) fn print_aot_ci_matrix(args: &[String]) -> Result<()> { + let requested = value_after(args, "--target") + .or_else(|| value_after(args, "--target-triple")) + .unwrap_or("all"); + let github_output = args.iter().any(|arg| arg == "--github-output"); + let targets = aot_target_specs() + .iter() + .filter(|spec| { + requested == "all" || requested == spec.triple || requested == spec.target_id + }) + .map(|spec| AotCiTarget { + os: spec.runner_os, + target: spec.triple, + target_id: spec.target_id, + package: spec.package, + artifact: aot_artifact_name(spec.triple), + llvm_url: spec.llvm_url, + }) + .collect::>(); + ensure!( + !targets.is_empty(), + "unsupported native AOT target: {requested}" + ); + let matrix = AotCiMatrix { include: targets }; + let json = serde_json::to_string(&matrix).context("serialize AOT CI matrix")?; + if github_output { + println!("matrix={json}"); + } else { + println!("{}", serde_json::to_string_pretty(&matrix)?); + } + Ok(()) +} + +pub(crate) fn ensure_supported_aot_target(target: &str) -> Result<()> { + if aot_target_specs().iter().any(|spec| spec.triple == target) { + return Ok(()); + } + bail!( + "unsupported AOT target {target}; supported targets are {}", + supported_aot_targets().join(", ") + ) +} + +pub(crate) fn verify_generated_extension_surface() -> Result<()> { + let manifest_path = Path::new(GENERATED_ASSETS_DIR).join("manifest.json"); + let manifest_text = fs::read_to_string(&manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest: AssetManifestOut = + serde_json::from_str(&manifest_text).context("parse committed asset manifest")?; + if skip_extensions_for_perf_probe() && manifest.extensions.is_empty() { + println!("core-only asset manifest detected; skipping generated extension surface guard"); + return Ok(()); + } + let catalog_text = fs::read_to_string("src/extensions/generated/extensions.catalog.json") + .context("read src/extensions/generated/extensions.catalog.json")?; + let catalog: serde_json::Value = + serde_json::from_str(&catalog_text).context("parse generated extension catalog")?; + let generated = fs::read_to_string( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/generated_extensions.rs", + ) + .context( + "read src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/generated_extensions.rs", + )?; + + let mut packaged_constants = BTreeMap::new(); + let mut promoted_constants = BTreeMap::new(); + for entry in catalog + .get("extensions") + .and_then(|value| value.as_array()) + .ok_or_else(|| anyhow!("extension catalog is missing extensions array"))? + { + let sql_name = entry + .get("sql-name") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow!("extension is missing sql-name"))?; + let rust_constant = entry + .get("rust-constant") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow!("extension {sql_name} is missing rust-constant"))?; + let requested = entry + .pointer("/promotion/requested") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let packaged = entry + .pointer("/promotion/packaged") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let has_archive = entry + .pointer("/promotion/archive") + .and_then(|value| value.as_str()) + .is_some(); + if requested && packaged && has_archive { + packaged_constants.insert(sql_name.to_owned(), rust_constant.to_owned()); + } + let promoted = entry + .pointer("/promotion/promoted") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + if promoted { + promoted_constants.insert(sql_name.to_owned(), rust_constant.to_owned()); + } + } + + let manifest_packaged_sql_names = manifest + .extensions + .iter() + .map(|extension| extension.sql_name.clone()) + .collect::>(); + let catalog_packaged_sql_names = packaged_constants.keys().cloned().collect::>(); + if manifest_packaged_sql_names != catalog_packaged_sql_names { + bail!( + "packaged extension catalog and asset manifest disagree: manifest-only={:?} catalog-only={:?}", + manifest_packaged_sql_names + .difference(&catalog_packaged_sql_names) + .collect::>(), + catalog_packaged_sql_names + .difference(&manifest_packaged_sql_names) + .collect::>() + ); + } + + let manifest_promoted_sql_names = manifest + .extensions + .iter() + .filter(|extension| extension.smoke_status.promoted) + .map(|extension| extension.sql_name.clone()) + .collect::>(); + let catalog_sql_names = promoted_constants.keys().cloned().collect::>(); + if manifest_promoted_sql_names != catalog_sql_names { + bail!( + "promoted extension catalog and asset manifest disagree: manifest-only={:?} catalog-only={:?}", + manifest_promoted_sql_names + .difference(&catalog_sql_names) + .collect::>(), + catalog_sql_names + .difference(&manifest_promoted_sql_names) + .collect::>() + ); + } + + for extension in &manifest.extensions { + let rust_constant = packaged_constants.get(&extension.sql_name).ok_or_else(|| { + anyhow!( + "extension {} missing from packaged catalog", + extension.sql_name + ) + })?; + let candidate_const = format!("CANDIDATE_{rust_constant}"); + for (needle, description) in [ + ( + format!("pub(crate) const {candidate_const}: Extension ="), + "packaged candidate extension constant", + ), + ( + format!(" {candidate_const},"), + "extensions::CANDIDATES entry", + ), + (format!("{:?}", extension.sql_name), "extension SQL name"), + (format!("{:?}", extension.archive), "extension archive path"), + ] { + if !generated.contains(&needle) { + bail!("generated extension API is stale: missing {description} {needle}"); + } + } + if extension.smoke_status.promoted { + for (needle, description) in [ + ( + format!("pub const {rust_constant}: Extension = {candidate_const};"), + "public extension constant", + ), + (format!(" {rust_constant},"), "extensions::ALL entry"), + ] { + if !generated.contains(&needle) { + bail!("generated extension API is stale: missing {description} {needle}"); + } + } + } + if extension.smoke_status.promoted { + for status in [ + &extension.smoke_status.direct, + &extension.smoke_status.server, + &extension.smoke_status.restart, + &extension.smoke_status.dump_restore, + ] { + ensure_eq( + status, + "passed", + &format!("extension {} smoke status", extension.sql_name), + )?; + } + } + } + println!("generated extension API matches asset manifest and catalog"); + Ok(()) +} + +fn verify_generated_extension_surface_if_available() -> Result<()> { + let manifest_path = Path::new(GENERATED_ASSETS_DIR).join("manifest.json"); + if !manifest_path.exists() { + eprintln!( + "warning: generated asset manifest is unavailable at {}; skipping generated extension manifest parity in source-only verification", + manifest_path.display() + ); + return Ok(()); + } + let manifest_text = fs::read_to_string(&manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest: AssetManifestOut = + serde_json::from_str(&manifest_text).context("parse generated asset manifest")?; + if manifest.extensions.is_empty() { + eprintln!( + "warning: generated asset manifest is core-only; skipping generated extension manifest parity in source-only verification" + ); + return Ok(()); + } + verify_generated_extension_surface() +} + +pub(crate) fn check_no_legacy_runtime_shims() -> Result<()> { + let banned = [ + ( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs", + &[ + "normalize_runtime_tree", + "mirror_configured_share_layout", + "mirror_configured_lib_layout", + "normalize_pgdata_config", + "share/timezonesets/Default", + "write minimal timezoneset", + "log_timezone = UTC", + "timezone = UTC", + ][..], + ), + ( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs", + &[ + "\"oliphaunt_wasix_initdb\"", + "\"oliphaunt_wasix_backend\"", + "PostgresRecoverProtocolError", + ][..], + ), + ]; + + let mut failures = Vec::new(); + for (path, patterns) in banned { + let text = fs::read_to_string(path).with_context(|| format!("read {path}"))?; + for pattern in patterns { + if text.contains(pattern) { + failures.push(format!( + "{path} contains legacy runtime shim marker {pattern:?}" + )); + } + } + } + + if !failures.is_empty() { + bail!("{}", failures.join("; ")); + } + println!("legacy runtime shim source guard passed"); + Ok(()) +} + +pub(crate) fn check_production_wasix_build_inputs() -> Result<()> { + for required in [ + WASIX_BRIDGE_PATH, + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim_abi_test.c", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_shim.c", + "src/runtimes/liboliphaunt/wasix/assets/build/analyze_pgl_stubs.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_openssl.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_sqlite.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_geos.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libxml2.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_jsonc.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_proj.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh", + "src/extensions/external/postgis/tools/build_wasix.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c", + "src/runtimes/liboliphaunt/native/portable-uuid/include/uuid/uuid.h", + "src/runtimes/liboliphaunt/native/portable-uuid/portable_uuid.c", + POSTGRES_SOURCE_MANIFEST_PATH, + POSTGRES_PATCH_SERIES_PATH, + POSTGRES_EXPERIMENT_DISPOSITION_PATH, + ] { + if !Path::new(required).exists() { + bail!("production WASIX build input is missing: {required}"); + } + } + check_wasix_shell_script_syntax()?; + check_root_asset_metadata_keys()?; + + let production_files = [ + "tools/xtask/src/asset_pipeline.rs", + "src/runtimes/liboliphaunt/wasix/assets/build/analyze_pgl_stubs.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_openssl.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_sqlite.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_geos.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libxml2.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_jsonc.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_proj.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh", + "src/extensions/external/postgis/tools/build_wasix.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c", + ]; + for path in production_files { + let text = fs::read_to_string(path).with_context(|| format!("read {path}"))?; + if path == "src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh" + && text.contains("--disable-spinlocks") + { + bail!( + "{path} disables PostgreSQL spinlocks; WASIX builds must use the toolchain atomics path" + ); + } + } + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_wasix_env.sh", + &[ + "WASIX_HOME:=/opt/wasixcc-home/.wasixcc", + "ln -s \"$WASIX_HOME\" \"$HOME/.wasixcc\"", + "export PATH=\"$WASIX_HOME/bin:$PATH\"", + ], + )?; + for path in wasix_build_scripts_requiring_docker_env()? { + ensure_file_contains_all(&path, &["docker_wasix_env.sh"])?; + } + + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/profile_flags.sh", + &[ + "release)", + "-O2 -g0", + "release-o3)", + "-O3 -g0 -flto=thin", + "-flto=thin", + "release-os)", + "-Os -g0", + "release-oz)", + "-Oz -g0", + "--converge:--strip-debug:--strip-producers", + "WASIXCC_RUN_WASM_OPT", + "WASIXCC_WASM_OPT_FLAGS", + "OLIPHAUNT_WASM_ALLOW_ASYNCIFY_EXPERIMENT", + "OLIPHAUNT_WASM_WASIX_BACKEND_TIMING", + "production WASIX artifacts require WebAssembly exceptions", + "build_wasix_icu_sha256", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh", + &[ + "build_wasix_icu.sh", + "--with-icu", + "ICU_CFLAGS", + "ICU_LIBS", + "oliphaunt_wasix_icu_cflags", + "oliphaunt_wasix_icu_libs", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_icu_link.sh", + &[ + "oliphaunt_wasix_cxx_runtime_libs", + "oliphaunt_wasix_icu_cflags", + "oliphaunt_wasix_icu_libs", + "U_STATIC_IMPLEMENTATION", + "WASIX_CXX_RUNTIME_LIB_DIR", + "sysroot-exnref-ehpic", + "libicui18n.a", + "libicuuc.a", + "libicudata.a", + "libc++.a", + "libc++abi.a", + "libunwind.a", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh", + &[ + "unicode/ucol.h", + "--with-data-packaging=static", + "static-consumer", + "real-data-archive", + "icu_static_data_ready", + "icu_install_static_data_archive", + "members=\"$(ar -t \"$data_archive\")\"", + "icu_wasix_config_ready", + "icu_cv_host_frag=mh-linux", + "wasix-platform-fragment=mh-linux", + "ac_cv_var_tzname=no", + "ac_cv_var__tzname=no", + "wasix-timezone-cache=no-tzname", + "icu_pkgdata_opts=\"-O $ICU_BUILD_DIR/data/icupkg.inc -w\"", + "wasix-data-packaging=without-assembly", + "stubdata\\.ao", + "packagedata", + "--disable-tools", + "--disable-icuio", + "--disable-layoutex", + "makeconv", + "genrb", + "pkgdata", + "libicui18n.a", + "libicuuc.a", + "libicudata.a", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/pg_config_wasix.sh", + &[ + ". \"$ROOT/source_lane.sh\"", + "oliphaunt_wasix_default_build_dir", + "PGSRC must be set when pg_config_wasix.sh runs", + "PG18 PGSRC is missing .oliphaunt-wasix-postgres-version", + "PG18 PGSRC is missing .oliphaunt-wasix-source-fingerprint", + ".oliphaunt-wasix-postgres-version", + "source_toml=\"$ROOT/postgres/source.toml\"", + "PostgreSQL $(postgres_version)", + "--includedir-server", + "$BUILD_DIR/src/include", + ], + )?; + ensure_file_contains_all( + WASIX_BRIDGE_PATH, + &[ + "oliphaunt_wasix_backend_timing_reset", + "oliphaunt_wasix_backend_timing_start", + "oliphaunt_wasix_backend_timing_end", + "oliphaunt_wasix_backend_timing_elapsed_us", + "CLOCK_MONOTONIC", + "#ifdef OLIPHAUNT_WASIX_BACKEND_TIMING", + "oliphaunt_wasix_set_force_host_error_recovery", + "force_host_error_recovery", + "Hosts without that support", + "oliphaunt_wasix_set_active", + "oliphaunt_wasix_longjmp", + "oliphaunt_wasix_siglongjmp", + "memcmp(env, (void *) postgresmain_sigjmp_buf, sizeof(jmp_buf)) == 0", + "oliphaunt_wasix_getegid", + "oliphaunt_wasix_getpwuid_r", + "oliphaunt_wasix_run_atexit_funcs", + ], + )?; + check_wasix_bridge_abi_harness()?; + check_wasix_initdb_shim_abi_harness()?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", + &[ + "OLIPHAUNT_WASM_BUILD_PROFILE", + "OLIPHAUNT_WASM_WASIX_BACKEND_TIMING", + ".oliphaunt-wasix-build-profile", + ".oliphaunt-wasix-icu-build", + "oliphaunt_wasix_wasix_profile_signature", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", + &[ + "build_wasix_icu.sh", + "oliphaunt_wasix_icu_cflags", + "oliphaunt_wasix_icu_libs", + "ICU_CFLAGS", + "ICU_LIBS", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh", + &[ + "build_wasix_icu.sh", + "oliphaunt_wasix_icu_cflags", + "oliphaunt_wasix_icu_libs", + "ICU_CFLAGS", + "ICU_LIBS", + ], + )?; + for path in [ + "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", + ] { + ensure_file_contains_all(path, &["OLIPHAUNT_WASM_SKIP_IMAGE_BUILD"])?; + } + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh", + &[ + "oliphaunt_wasix_run_extension_build_in_docker_if_needed", + "OLIPHAUNT_WASM_EXTENSION_BUILD_IN_DOCKER", + "command -v wasixcc", + "oliphaunt_wasix_extension_build_outputs_exist", + "required_build_files", + "required_build_globs", + ], + )?; + ensure_file_contains_all( + "src/extensions/external/postgis/tools/build_wasix.sh", + &[ + "oliphaunt_wasix_run_extension_build_in_docker_if_needed", + "oliphaunt_wasix_extension_build_outputs_exist", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh", + &[ + "oliphaunt_wasix_apply_wasix_profile configure", + "oliphaunt_wasix_apply_wasix_profile build", + ], + )?; + + println!("production WASIX build input guard passed"); + Ok(()) +} + +fn wasix_build_scripts_requiring_docker_env() -> Result> { + let scripts = crate::postgres_guard::wasix_build_shell_scripts()? + .into_iter() + .filter(|path| { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + file_name.starts_with("build_wasix_") + || matches!( + file_name, + "analyze_pgl_stubs.sh" + | "docker_contrib_extensions.sh" + | "docker_oliphaunt.sh" + | "docker_pgdump.sh" + | "docker_pgxs_extensions.sh" + | "docker_runtime_support.sh" + ) + }) + .collect::>(); + ensure!( + !scripts.is_empty(), + "WASIX build guard found no scripts requiring docker_wasix_env.sh" + ); + Ok(scripts) +} + +fn check_root_asset_metadata_keys() -> Result<()> { + let path = "src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml"; + let text = fs::read_to_string(path).with_context(|| format!("read {path}"))?; + for required in [ + "postgres-version", + "postgres-source-url", + "postgres-source-sha256", + "postgres-patch-count", + "runtime-archive-sha256", + "oliphaunt-wasix-sha256", + "pgdata-template-archive-sha256", + "pg-dump-wasix-sha256", + "initdb-wasix-sha256", + ] { + let needle = format!("{required} = \""); + ensure!( + text.contains(&needle), + "{path} is missing WASIX asset metadata key {required}" + ); + } + Ok(()) +} + +pub(crate) fn check_canonical_asset_layout(strict: bool) -> Result<()> { + check_canonical_asset_layout_in(Path::new(GENERATED_ASSETS_DIR), strict) +} + +pub(crate) fn check_canonical_asset_layout_in(asset_dir: &Path, strict: bool) -> Result<()> { + let runtime_archive = asset_dir.join("oliphaunt.wasix.tar.zst"); + if !runtime_archive.exists() { + if strict { + bail!( + "runtime asset archive is missing at {}", + runtime_archive.display() + ); + } + eprintln!( + "warning: runtime asset archive is missing at {}", + runtime_archive.display() + ); + return Ok(()); + } + + let runtime_entries = archive_entries(&runtime_archive)?; + let mut required_paths = vec![ + "oliphaunt/bin/oliphaunt", + "oliphaunt/bin/postgres", + "oliphaunt/bin/initdb", + "oliphaunt/lib/postgresql/plpgsql.so", + "oliphaunt/share/postgresql/extension/plpgsql.control", + "oliphaunt/share/postgresql/timezone/UTC", + "oliphaunt/share/postgresql/timezone/America/New_York", + "oliphaunt/share/postgresql/timezonesets/Default", + ]; + if !skip_extensions_for_perf_probe() { + required_paths.push("oliphaunt/bin/pg_dump"); + } + for required in required_paths { + if !runtime_entries.contains(required) { + bail!( + "runtime archive {} is missing canonical path {required}", + runtime_archive.display() + ); + } + } + for forbidden in [ + "oliphaunt/share/extension", + "oliphaunt/share/timezonesets", + "oliphaunt/lib/plpgsql.so", + "oliphaunt/lib/dict_snowball.so", + ] { + if runtime_entries.contains(forbidden) + || runtime_entries + .iter() + .any(|entry| entry.starts_with(&format!("{forbidden}/"))) + { + bail!( + "runtime archive {} contains non-canonical duplicate path {forbidden}", + runtime_archive.display() + ); + } + } + + let extensions_dir = asset_dir.join("extensions"); + if extensions_dir.exists() { + for entry in fs::read_dir(&extensions_dir) + .with_context(|| format!("read {}", extensions_dir.display()))? + { + let path = entry?.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("zst") { + continue; + } + check_extension_archive_layout(&path)?; + } + } else if strict && !skip_extensions_for_perf_probe() { + bail!( + "extension asset directory is missing at {}", + extensions_dir.display() + ); + } + + println!("canonical asset layout guard passed"); + Ok(()) +} + +fn check_extension_archive_layout(path: &Path) -> Result<()> { + let entries = archive_entries(path)?; + for entry in entries { + if matches!( + entry.as_str(), + "lib" + | "lib/postgresql" + | "share" + | "share/proj" + | "share/postgresql" + | "share/postgresql/extension" + | "share/postgresql/tsearch_data" + ) { + continue; + } + if entry.starts_with("lib/postgresql/") + || entry.starts_with("share/proj/") + || entry.starts_with("share/postgresql/extension/") + || entry.starts_with("share/postgresql/tsearch_data/") + { + continue; + } + bail!( + "extension archive {} contains non-canonical path {entry}", + path.display() + ); + } + Ok(()) +} + +pub(crate) fn audit_upstream_fixes(_manifest: &SourcesManifest, _strict: bool) -> Result<()> { + check_postgres_source_spine()?; + check_production_wasix_build_inputs()?; + check_no_legacy_runtime_shims()?; + check_source_lane_isolation()?; + println!("audited PG18 WASIX runtime guards"); + Ok(()) +} + +pub(crate) fn ensure_file_contains_all(path: impl AsRef, markers: &[&str]) -> Result<()> { + let path = path.as_ref(); + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let missing = markers + .iter() + .copied() + .filter(|marker| !text.contains(marker)) + .collect::>(); + if !missing.is_empty() { + bail!( + "{} is missing required upstream replacement markers: {}", + path.display(), + missing.join(", ") + ); + } + Ok(()) +} + +pub(crate) fn ensure_file_not_contains_any(path: &str, markers: &[&str]) -> Result<()> { + let text = fs::read_to_string(path).with_context(|| format!("read {path}"))?; + let present = markers + .iter() + .copied() + .filter(|marker| text.contains(marker)) + .collect::>(); + if !present.is_empty() { + bail!( + "{path} contains production-excluded markers: {}", + present.join(", ") + ); + } + Ok(()) +} + +pub(crate) fn check_manifest_source_checkouts_filtered( + manifest: &SourcesManifest, + strict_local: bool, + include: F, +) -> Result<()> +where + F: Fn(&SourcePin) -> bool, +{ + for source in &manifest.sources { + if !include(source) { + continue; + } + let Some(path) = source_checkout_path(source.name.as_str()) else { + if strict_local { + bail!("source '{}' has no configured checkout path", source.name); + } + eprintln!( + "warning: source '{}' has no configured checkout path", + source.name + ); + continue; + }; + if source.kind == SourceKind::Archive { + check_archive_source_path(source, &path, strict_local)?; + continue; + } + if !path.join(".git").exists() { + if strict_local { + bail!("missing local checkout {}", path.display()); + } + eprintln!("warning: local checkout {} is missing", path.display()); + continue; + } + let head = command_output("git", &["rev-parse", "HEAD"], &path) + .with_context(|| format!("read HEAD for {}", path.display()))?; + if head.trim() != source.commit { + if strict_local { + bail!( + "local {} checkout is at {}, expected {} from source metadata", + path.display(), + head.trim(), + source.commit + ); + } + eprintln!( + "warning: local {} checkout is at {}, expected {}", + path.display(), + head.trim(), + source.commit + ); + } + let branch = command_output("git", &["branch", "--show-current"], &path) + .unwrap_or_else(|_| String::from("")); + if strict_local && branch.trim() != source.branch { + bail!( + "local {} checkout is on branch '{}', expected '{}'", + path.display(), + branch.trim(), + source.branch + ); + } + let status = source_checkout_status_for_source(source.name.as_str(), &path) + .with_context(|| format!("read status for {}", path.display()))?; + if !status.trim().is_empty() { + if strict_local { + bail!( + "local {} checkout ({}) has uncommitted changes; preserve them before strict asset builds", + path.display(), + source.name + ); + } + eprintln!( + "warning: local {} checkout ({}) has uncommitted changes", + path.display(), + source.name + ); + } + } + Ok(()) +} + +fn check_archive_source_path(source: &SourcePin, path: &Path, strict_local: bool) -> Result<()> { + let stamp_path = path.join(".oliphaunt-source-pin"); + let expected = source.archive_stamp(); + if !path.is_dir() || !stamp_path.is_file() { + if strict_local { + bail!("missing local archive source {}", path.display()); + } + eprintln!( + "warning: local archive source {} is missing", + path.display() + ); + return Ok(()); + } + let actual = fs::read_to_string(&stamp_path) + .with_context(|| format!("read {}", stamp_path.display()))?; + if actual != expected { + if strict_local { + bail!( + "local archive source {} ({}) does not match source metadata", + path.display(), + source.name + ); + } + eprintln!( + "warning: local archive source {} ({}) does not match source metadata", + path.display(), + source.name + ); + } + Ok(()) +} + +fn source_checkout_status(path: &Path) -> Result { + command_output("git", &["status", "--porcelain"], path) +} + +pub(crate) fn source_checkout_status_for_source(name: &str, path: &Path) -> Result { + if name == "postgres18-extension-sources" { + return command_output( + "git", + &["status", "--porcelain", "--ignore-submodules=all"], + path, + ); + } + source_checkout_status(path) +} + +#[cfg(unix)] +fn check_wasix_bridge_abi_harness() -> Result<()> { + let bridge = Path::new(WASIX_BRIDGE_PATH); + let harness = Path::new( + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge_abi_test.c", + ); + if !harness.exists() { + bail!("missing WASIX bridge ABI harness at {}", harness.display()); + } + + let out_dir = Path::new("target/xtask"); + fs::create_dir_all(out_dir).with_context(|| format!("create {}", out_dir.display()))?; + let binary = out_dir.join("oliphaunt_wasix_bridge_abi_test"); + let cc = env::var("CC").unwrap_or_else(|_| "cc".to_owned()); + let status = Command::new(&cc) + .args(["-std=c11", "-Wall", "-Wextra"]) + .arg(bridge) + .arg(harness) + .arg("-o") + .arg(&binary) + .status() + .with_context(|| format!("compile WASIX bridge ABI harness with {cc}"))?; + if !status.success() { + bail!("WASIX bridge ABI harness compilation failed with {status}"); + } + let status = Command::new(&binary) + .stdout(Stdio::null()) + .status() + .with_context(|| format!("run {}", binary.display()))?; + if !status.success() { + bail!("WASIX bridge ABI harness failed with {status}"); + } + println!("WASIX bridge ABI harness passed"); + Ok(()) +} + +#[cfg(unix)] +fn check_wasix_initdb_shim_abi_harness() -> Result<()> { + let shim = Path::new( + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c", + ); + let harness = Path::new( + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim_abi_test.c", + ); + if !harness.exists() { + bail!( + "missing WASIX initdb shim ABI harness at {}", + harness.display() + ); + } + + let out_dir = Path::new("target/xtask"); + fs::create_dir_all(out_dir).with_context(|| format!("create {}", out_dir.display()))?; + let binary = out_dir.join("oliphaunt_wasix_initdb_shim_abi_test"); + let cc = env::var("CC").unwrap_or_else(|_| "cc".to_owned()); + let status = Command::new(&cc) + .args(["-std=c11", "-Wall", "-Wextra"]) + .arg(shim) + .arg(harness) + .arg("-o") + .arg(&binary) + .status() + .with_context(|| format!("compile {}", harness.display()))?; + if !status.success() { + bail!("failed to compile {}", harness.display()); + } + + let status = Command::new(&binary) + .status() + .with_context(|| format!("run {}", binary.display()))?; + if !status.success() { + bail!("WASIX initdb shim ABI harness failed"); + } + Ok(()) +} + +#[cfg(not(unix))] +fn check_wasix_initdb_shim_abi_harness() -> Result<()> { + println!("skipping WASIX initdb shim ABI harness on non-Unix host"); + Ok(()) +} + +#[cfg(not(unix))] +fn check_wasix_bridge_abi_harness() -> Result<()> { + eprintln!("warning: skipping POSIX WASIX bridge ABI harness on non-Unix host"); + Ok(()) +} diff --git a/tools/xtask/src/asset_io.rs b/tools/xtask/src/asset_io.rs new file mode 100644 index 00000000..eeb4da53 --- /dev/null +++ b/tools/xtask/src/asset_io.rs @@ -0,0 +1,435 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; + +use super::*; + +pub(super) fn download_assets(args: &[String]) -> Result<()> { + let targets = asset_download_targets(args)?; + let required_job = value_after(args, "--required-job"); + if args.iter().any(|arg| arg == "--release") { + let tag = value_after(args, "--release").context("--release requires a tag")?; + ensure!( + value_after(args, "--run-id").is_none() + && value_after(args, "--sha").is_none() + && !args.iter().any(|arg| arg == "--latest-compatible") + && required_job.is_none(), + "assets download accepts only one of --run-id, --sha, --latest-compatible, or --release; --required-job applies only to workflow-run downloads" + ); + download_assets_from_release(tag, &targets)?; + let target_list = targets.join(", "); + println!("downloaded and installed release assets from {tag} / {target_list}"); + return Ok(()); + } + + let candidates = asset_download_run_candidates(args, required_job)?; + let mut last_error = None; + + let candidate_count = candidates.len(); + for (index, run_id) in candidates.into_iter().enumerate() { + match download_assets_from_run(&run_id, &targets) { + Ok(()) => { + let target_list = targets.join(", "); + println!( + "downloaded and installed Builds workflow runtime artifacts from run {run_id} / {target_list}" + ); + return Ok(()); + } + Err(error) => { + if index + 1 < candidate_count { + eprintln!( + "Builds workflow run {run_id} does not contain compatible runtime artifacts: {error:#}" + ); + last_error = Some(error); + continue; + } + return Err(error); + } + } + } + + if let Some(error) = last_error { + Err(error).context("no compatible Builds workflow runtime artifact found") + } else { + bail!("no Builds workflow runtime artifact found") + } +} + +fn asset_download_targets(args: &[String]) -> Result> { + let all_targets = args.iter().any(|arg| arg == "--all-targets"); + let explicit_target = + value_after(args, "--target").or_else(|| value_after(args, "--target-triple")); + if all_targets && explicit_target.is_some() { + bail!("assets download accepts either --all-targets or --target/--target-triple, not both"); + } + if all_targets { + Ok(supported_aot_targets() + .iter() + .map(|target| (*target).to_owned()) + .collect()) + } else { + let target = explicit_target + .map(aot_triple_for_target_selector) + .transpose()? + .unwrap_or(host_target_triple()); + ensure_supported_aot_target(target)?; + Ok(vec![target.to_owned()]) + } +} + +fn asset_download_run_candidates( + args: &[String], + required_job: Option<&str>, +) -> Result> { + let run_id = value_after(args, "--run-id"); + let sha = value_after(args, "--sha"); + let latest_compatible = args.iter().any(|arg| arg == "--latest-compatible"); + let selected_modes = + usize::from(run_id.is_some()) + usize::from(sha.is_some()) + usize::from(latest_compatible); + if selected_modes != 1 { + bail!( + "assets download requires exactly one of --run-id , --sha , or --latest-compatible" + ); + } + + if let Some(run_id) = run_id { + return filter_runs_by_required_job(vec![run_id.to_owned()], required_job); + } + + if let Some(sha) = sha { + let output = command_output( + "gh", + &[ + "run", + "list", + "--workflow", + "Builds", + "--commit", + sha, + "--limit", + "20", + "--json", + "databaseId,status", + "--jq", + ".[].databaseId", + ], + Path::new("."), + ) + .with_context(|| format!("find Builds workflow run for SHA {sha}"))?; + return filter_runs_by_required_job(parse_gh_run_ids(&output)?, required_job); + } + + let branch = value_after(args, "--branch").unwrap_or("main"); + let output = command_output( + "gh", + &[ + "run", + "list", + "--workflow", + "Builds", + "--branch", + branch, + "--status", + "success", + "--limit", + "20", + "--json", + "databaseId", + "--jq", + ".[].databaseId", + ], + Path::new("."), + ) + .with_context(|| format!("find latest successful Builds workflow runs on {branch}"))?; + filter_runs_by_required_job(parse_gh_run_ids(&output)?, required_job) +} + +fn parse_gh_run_ids(output: &str) -> Result> { + let runs = output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && *line != "null") + .map(str::to_owned) + .collect::>(); + ensure!(!runs.is_empty(), "no Builds workflow artifact found"); + Ok(runs) +} + +fn filter_runs_by_required_job( + run_ids: Vec, + required_job: Option<&str>, +) -> Result> { + let Some(required_job) = required_job else { + return Ok(run_ids); + }; + + let mut matched = Vec::new(); + for run_id in run_ids { + if run_has_required_job_success(&run_id, required_job)? { + matched.push(run_id); + } + } + ensure!( + !matched.is_empty(), + "no Builds workflow artifact run has required job '{required_job}' with conclusion 'success'" + ); + Ok(matched) +} + +fn run_has_required_job_success(run_id: &str, required_job: &str) -> Result { + let output = command_output( + "gh", + &["run", "view", run_id, "--json", "jobs"], + Path::new("."), + ) + .with_context(|| format!("inspect Builds workflow run {run_id}"))?; + let value: serde_json::Value = serde_json::from_str(&output) + .with_context(|| format!("parse Builds workflow run {run_id} job JSON"))?; + let conclusion = value + .get("jobs") + .and_then(serde_json::Value::as_array) + .and_then(|jobs| { + jobs.iter().find(|job| { + job.get("name").and_then(serde_json::Value::as_str) == Some(required_job) + }) + }) + .and_then(|job| job.get("conclusion")) + .and_then(serde_json::Value::as_str); + Ok(conclusion == Some("success")) +} + +fn download_assets_from_run(run_id: &str, targets: &[String]) -> Result<()> { + let download_dir = Path::new("target/oliphaunt-wasix/downloads").join(run_id); + if download_dir.exists() { + fs::remove_dir_all(&download_dir) + .with_context(|| format!("remove {}", download_dir.display()))?; + } + fs::create_dir_all(&download_dir) + .with_context(|| format!("create {}", download_dir.display()))?; + run( + "gh", + &[ + "run", + "download", + run_id, + "--name", + "liboliphaunt-wasix-runtime-portable", + "--dir", + download_dir.to_str().expect("download dir is utf-8"), + ], + )?; + for target in targets { + let target_download_dir = download_dir.join(generated_aot_dir(target)); + fs::create_dir_all(&target_download_dir) + .with_context(|| format!("create {}", target_download_dir.display()))?; + run( + "gh", + &[ + "run", + "download", + run_id, + "--name", + &aot_artifact_name(target), + "--dir", + target_download_dir.to_str().expect("download dir is utf-8"), + ], + )?; + } + verify_downloaded_asset_fingerprint(&download_dir)?; + install_downloaded_artifacts(&download_dir, targets)?; + for target in targets { + install_local_assets_for_target(target)?; + } + Ok(()) +} + +fn download_assets_from_release(tag: &str, targets: &[String]) -> Result<()> { + let download_dir = Path::new("target/oliphaunt-wasix/downloads").join(format!("release-{tag}")); + if download_dir.exists() { + fs::remove_dir_all(&download_dir) + .with_context(|| format!("remove {}", download_dir.display()))?; + } + fs::create_dir_all(&download_dir) + .with_context(|| format!("create {}", download_dir.display()))?; + + download_and_extract_release_asset( + tag, + &format!( + "liboliphaunt-wasix-{}-runtime-portable.tar.zst", + wasm_release_version_from_tag(tag) + ), + &download_dir, + )?; + for target in targets { + download_and_extract_release_asset( + tag, + &format!( + "liboliphaunt-wasix-{}-runtime-aot-{}.tar.zst", + wasm_release_version_from_tag(tag), + aot_target_id_for_triple(target)? + ), + &download_dir, + )?; + } + + verify_downloaded_asset_fingerprint(&download_dir)?; + install_downloaded_artifacts(&download_dir, targets)?; + for target in targets { + install_local_assets_for_target(target)?; + } + Ok(()) +} + +fn download_and_extract_release_asset(tag: &str, asset: &str, download_dir: &Path) -> Result<()> { + let archive = download_dir.join(asset); + let url = format!("https://github.com/f0rr0/oliphaunt/releases/download/{tag}/{asset}"); + run( + "curl", + &[ + "-fsSL", + "--retry", + "3", + "--output", + archive + .to_str() + .expect("release asset archive path is utf-8"), + &url, + ], + ) + .with_context(|| format!("download release asset {asset} from {url}"))?; + extract_tar_zst(&archive, download_dir) + .with_context(|| format!("extract release asset {}", archive.display()))?; + Ok(()) +} + +fn wasm_release_version_from_tag(tag: &str) -> String { + tag.rsplit_once("-v") + .map(|(_, version)| version) + .filter(|version| !version.is_empty()) + .unwrap_or(tag) + .to_owned() +} + +fn extract_tar_zst(archive: &Path, destination: &Path) -> Result<()> { + let file = fs::File::open(archive).with_context(|| format!("open {}", archive.display()))?; + let decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("create zstd decoder for {}", archive.display()))?; + let mut tar = tar::Archive::new(decoder); + tar.unpack(destination).with_context(|| { + format!( + "unpack {} into {}", + archive.display(), + destination.display() + ) + }) +} + +fn verify_downloaded_asset_fingerprint(download_dir: &Path) -> Result<()> { + let expected = fs::read_to_string(ASSET_INPUT_FINGERPRINT_PATH) + .with_context(|| format!("read {}", ASSET_INPUT_FINGERPRINT_PATH))?; + let downloaded_path = download_dir.join(ASSET_INPUT_FINGERPRINT_PATH); + let downloaded = fs::read_to_string(&downloaded_path) + .with_context(|| format!("read {}", downloaded_path.display()))?; + ensure_eq( + downloaded.trim(), + expected.trim(), + "downloaded asset-input fingerprint", + ) +} + +fn install_downloaded_artifacts(download_dir: &Path, targets: &[String]) -> Result<()> { + let downloaded_assets = download_dir.join(GENERATED_ASSETS_DIR); + ensure_file(&downloaded_assets.join("manifest.json"))?; + let downloaded_manifest = read_asset_manifest_from(&downloaded_assets)?; + ensure_packaged_asset_matches_source_lane(&downloaded_manifest, DEFAULT_SOURCE_LANE)?; + + for target in targets { + let downloaded_aot = download_dir.join("target/oliphaunt-wasix/aot").join(target); + ensure_file(&downloaded_aot.join("manifest.json"))?; + ensure_aot_manifest_matches_source_lane( + &downloaded_aot.join("manifest.json"), + target, + DEFAULT_SOURCE_LANE, + )?; + } + + copy_dir_all(&downloaded_assets, Path::new(GENERATED_ASSETS_DIR))?; + for target in targets { + let downloaded_aot = download_dir.join("target/oliphaunt-wasix/aot").join(target); + copy_dir_all(&downloaded_aot, &generated_aot_dir(target))?; + } + Ok(()) +} + +pub(super) fn ensure_aot_manifest_matches_source_lane( + manifest_path: &Path, + target: &str, + source_lane: &str, +) -> Result<()> { + let expected = canonical_source_lane(source_lane)?; + let text = fs::read_to_string(manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest: AotManifest = serde_json::from_str(&text) + .with_context(|| format!("parse {}", manifest_path.display()))?; + let actual = manifest.source_lane.as_deref().unwrap_or(""); + ensure_eq(actual, expected, "AOT manifest source-lane")?; + ensure_eq( + &manifest.target_triple, + target, + "AOT manifest target-triple", + )?; + ensure!( + !manifest.artifacts.is_empty(), + "AOT manifest {} contains no artifacts", + manifest_path.display() + ); + match expected { + "stable" => { + ensure_postgres_source_fingerprint_matches_current( + manifest.source_fingerprint.as_deref(), + "PG18 AOT manifest source-fingerprint", + )?; + if let Some(postgres_version) = manifest.postgres_version.as_deref() { + ensure!( + postgres_version.starts_with("18."), + "AOT manifest is PostgreSQL {postgres_version}, not the PG18 WASIX runtime" + ); + } + } + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } + Ok(()) +} + +pub(super) fn install_local_assets(args: &[String]) -> Result<()> { + let target = value_after(args, "--target-triple").unwrap_or(host_target_triple()); + install_local_assets_for_target(target) +} + +fn install_local_assets_for_target(target: &str) -> Result<()> { + ensure_supported_aot_target(target)?; + let generated_assets = Path::new(GENERATED_ASSETS_DIR); + ensure_file(&generated_assets.join("manifest.json"))?; + let generated_manifest = read_asset_manifest_from(generated_assets)?; + ensure_packaged_asset_matches_source_lane(&generated_manifest, DEFAULT_SOURCE_LANE)?; + check_canonical_asset_layout(true)?; + check_generated_manifest_for_aot(&load_sources_manifest()?, true)?; + verify_asset_manifest_hashes()?; + verify_generated_extension_surface()?; + + find_aot_artifact_dir(target)?; + check_aot_package_manifest(target, DEFAULT_SOURCE_LANE)?; + println!("local generated assets are installed for {target}"); + Ok(()) +} + +pub(super) fn run_asset_smoke_tests(args: &[String]) -> Result<()> { + if let Some(arg) = args.first() { + bail!("unknown assets smoke flag: {arg}"); + } + run( + "src/runtimes/liboliphaunt/wasix/tools/runtime-smoke.sh", + &[], + ) +} diff --git a/tools/xtask/src/asset_manifest.rs b/tools/xtask/src/asset_manifest.rs new file mode 100644 index 00000000..842db634 --- /dev/null +++ b/tools/xtask/src/asset_manifest.rs @@ -0,0 +1,580 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use wasmparser::{Dylink0Subsection, ExternalKind, KnownCustom, Parser, Payload, TypeRef}; + +#[derive(Debug, Deserialize)] +pub(super) struct SourcesManifest { + pub(super) toolchain: Toolchain, + pub(super) build: BuildConfig, + pub(super) sources: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct WasixToolchainManifest { + pub(super) toolchain: Toolchain, + pub(super) build: BuildConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct GeneratedAssetManifest { + #[serde(default)] + pub(super) source_lane: Option, + #[serde(default)] + pub(super) sources: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PostgresSourceManifest { + pub(super) postgresql: PostgresPostgresqlSource, + pub(super) patches: PostgresPatchManifest, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PostgresSharedSourceManifest { + pub(super) postgresql: PostgresPostgresqlSource, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PostgresProductPatchManifest { + pub(super) patches: PostgresPatchManifest, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PostgresPostgresqlSource { + pub(super) version: String, + pub(super) url: String, + pub(super) sha256: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PostgresPatchManifest { + pub(super) series: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Toolchain { + pub(super) wasmer: String, + #[serde(rename = "wasmer-wasix")] + pub(super) wasmer_wasix: String, + #[allow(dead_code)] + pub(super) wasixcc: String, + #[allow(dead_code)] + pub(super) llvm: String, + #[allow(dead_code)] + pub(super) docker_image: String, + #[allow(dead_code)] + pub(super) docker_image_digest: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct BuildConfig { + pub(super) postgres_prefix: String, + pub(super) postgres_pkglibdir: String, + pub(super) postgres_sharedir: String, + pub(super) main_flags: Vec, + pub(super) extension_flags: Vec, + pub(super) archive_format: String, + pub(super) deterministic_archives: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(super) struct SourcePin { + pub(super) name: String, + #[serde(default, skip_serializing_if = "SourceKind::is_git")] + pub(super) kind: SourceKind, + pub(super) url: String, + pub(super) branch: String, + pub(super) commit: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) strip_prefix: Option, + #[serde(skip)] + pub(super) origin: SourceOrigin, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) enum SourceKind { + Git, + Archive, +} + +impl Default for SourceKind { + fn default() -> Self { + Self::Git + } +} + +impl SourceKind { + pub(super) fn is_git(&self) -> bool { + matches!(self, Self::Git) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SourceOrigin { + SharedThirdParty, + NativeThirdParty, + WasixThirdParty, + Extension, + Generated, +} + +impl Default for SourceOrigin { + fn default() -> Self { + Self::Generated + } +} + +impl SourcePin { + pub(super) fn archive_stamp(&self) -> String { + format!( + "name={}\nkind=archive\nurl={}\nbranch={}\ncommit={}\nsha256={}\nstrip-prefix={}\n", + self.name, + self.url, + self.branch, + self.commit, + self.sha256.as_deref().unwrap_or(""), + self.strip_prefix.as_deref().unwrap_or("") + ) + } +} + +pub(super) struct ExtensionArtifact<'a> { + pub(super) name: &'a str, + pub(super) sql_name: &'a str, + pub(super) archive: &'a str, + pub(super) path: &'a Path, + pub(super) module_path: Option<&'a Path>, + pub(super) native_module: Option<&'a str>, + pub(super) native_modules: &'a [OwnedExtensionNativeModule], + pub(super) stable: bool, +} + +pub(super) struct OwnedExtensionArtifact { + pub(super) name: String, + pub(super) sql_name: String, + pub(super) archive: String, + pub(super) path: PathBuf, + pub(super) module_path: Option, + pub(super) native_module: Option, + pub(super) native_modules: Vec, + pub(super) stable: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct OwnedExtensionNativeModule { + pub(super) name: String, + pub(super) runtime_path: String, + pub(super) path: PathBuf, +} + +pub(super) struct BinaryPackage<'a> { + pub(super) name: &'a str, + pub(super) path: &'a Path, + pub(super) runtime_path: &'a str, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct BuildOutputManifestOut { + pub(super) format_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_lane: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) postgres_version: Option, + pub(super) build_profile: String, + pub(super) modules: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct BuildModuleManifestOut { + pub(super) name: String, + pub(super) kind: String, + pub(super) path: String, + pub(super) sha256: String, + pub(super) link: WasmLinkMetadataOut, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct AssetManifestOut { + pub(super) format_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_lane: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_fingerprint: Option, + pub(super) runtime: RuntimeAssetOut, + pub(super) runtime_support: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pg_dump: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) initdb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pgdata_template: Option, + pub(super) extensions: Vec, + pub(super) sources: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct RuntimeAssetOut { + pub(super) archive: String, + pub(super) sha256: String, + pub(super) module_sha256: String, + pub(super) postgres_version: String, + pub(super) runtime_kind: String, + pub(super) link: WasmLinkMetadataOut, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct BinaryAssetOut { + pub(super) name: String, + pub(super) path: String, + pub(super) sha256: String, + pub(super) module_sha256: String, + pub(super) size: u64, + pub(super) link: WasmLinkMetadataOut, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct PgDataTemplateAssetOut { + pub(super) archive: String, + pub(super) manifest: String, + pub(super) sha256: String, + pub(super) size: u64, + pub(super) runtime_module_sha256: String, + pub(super) initdb_module_sha256: String, + pub(super) source_pins_sha256: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_lane: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_fingerprint: Option, + pub(super) postgres_version: String, + pub(super) catalog_version: String, + pub(super) init_profile: String, + pub(super) wasmer_version: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct ExtensionAssetOut { + pub(super) name: String, + pub(super) sql_name: String, + pub(super) source_kind: String, + pub(super) archive: String, + pub(super) sha256: String, + pub(super) module_sha256: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) native_module: Option, + #[serde(default)] + pub(super) native_modules: Vec, + pub(super) size: u64, + pub(super) stable: bool, + pub(super) control_files: Vec, + pub(super) dependencies: Vec, + pub(super) native_dependencies: Vec, + pub(super) load_order: Vec, + pub(super) lifecycle: ExtensionLifecycleOut, + pub(super) extension_imports: Vec, + pub(super) core_exports_required: Vec, + pub(super) unresolved_imports: Vec, + pub(super) installed_files: Vec, + pub(super) smoke_status: ExtensionSmokeStatusOut, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) link: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct ExtensionLifecycleOut { + pub(super) create_extension: bool, + pub(super) create_schema: Option, + pub(super) load_sql: Vec, + pub(super) post_create_sql: Vec, + pub(super) startup_config: Vec, + pub(super) preload_required: bool, + pub(super) restart_required: bool, + pub(super) shared_memory_required: bool, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) struct ExtensionSmokeStatusOut { + pub(super) promoted: bool, + pub(super) direct: String, + pub(super) server: String, + pub(super) restart: String, + pub(super) dump_restore: String, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) struct WasmLinkMetadataOut { + pub(super) has_dylink0: bool, + pub(super) dylink_needed: Vec, + pub(super) dylink_runtime_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) dylink_memory: Option, + pub(super) dylink_imports: Vec, + pub(super) dylink_exports: Vec, + pub(super) imports: Vec, + pub(super) exports: Vec, + pub(super) memories: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) struct WasmDylinkMemoryOut { + pub(super) memory_size: u32, + pub(super) memory_alignment: u32, + pub(super) table_size: u32, + pub(super) table_alignment: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) struct WasmDylinkSymbolOut { + pub(super) module: Option, + pub(super) name: String, + pub(super) flags: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) struct WasmImportOut { + pub(super) module: String, + pub(super) name: String, + pub(super) kind: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) struct WasmExportOut { + pub(super) name: String, + pub(super) kind: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) struct WasmMemoryOut { + pub(super) initial_pages: u64, + pub(super) maximum_pages: Option, + pub(super) memory64: bool, + pub(super) shared: bool, + pub(super) page_size_log2: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct AotManifest { + pub(super) format_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_lane: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) postgres_version: Option, + pub(super) target_triple: String, + pub(super) engine: String, + pub(super) wasmer_version: String, + pub(super) wasmer_wasix_version: String, + pub(super) artifacts: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) struct AotManifestArtifact { + pub(super) name: String, + pub(super) path: String, + pub(super) sha256: String, + pub(super) raw_sha256: String, + pub(super) raw_size: u64, + pub(super) module_sha256: String, + pub(super) compressed: bool, +} + +pub(super) fn read_wasm_link_metadata(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + let mut metadata = WasmLinkMetadataOut { + has_dylink0: false, + dylink_needed: Vec::new(), + dylink_runtime_paths: Vec::new(), + dylink_memory: None, + dylink_imports: Vec::new(), + dylink_exports: Vec::new(), + imports: Vec::new(), + exports: Vec::new(), + memories: Vec::new(), + }; + + for payload in Parser::new(0).parse_all(&bytes) { + match payload.with_context(|| format!("parse {}", path.display()))? { + Payload::ImportSection(reader) => { + for import in reader.into_imports() { + let import = + import.with_context(|| format!("read import from {}", path.display()))?; + metadata.imports.push(WasmImportOut { + module: import.module.to_owned(), + name: import.name.to_owned(), + kind: type_ref_kind(import.ty).to_owned(), + }); + } + } + Payload::ExportSection(reader) => { + for export in reader { + let export = + export.with_context(|| format!("read export from {}", path.display()))?; + metadata.exports.push(WasmExportOut { + name: export.name.to_owned(), + kind: external_kind_name(export.kind).to_owned(), + }); + } + } + Payload::MemorySection(reader) => { + for memory in reader { + let memory = + memory.with_context(|| format!("read memory from {}", path.display()))?; + metadata.memories.push(wasm_memory_out(memory)); + } + } + Payload::CustomSection(section) if section.name() == "dylink.0" => { + metadata.has_dylink0 = true; + let KnownCustom::Dylink0(reader) = section.as_known() else { + bail!("{} contains an unreadable dylink.0 section", path.display()); + }; + for subsection in reader { + match subsection + .with_context(|| format!("read dylink.0 from {}", path.display()))? + { + Dylink0Subsection::MemInfo(info) => { + metadata.dylink_memory = Some(WasmDylinkMemoryOut { + memory_size: info.memory_size, + memory_alignment: info.memory_alignment, + table_size: info.table_size, + table_alignment: info.table_alignment, + }); + } + Dylink0Subsection::Needed(needed) => { + metadata + .dylink_needed + .extend(needed.into_iter().map(str::to_owned)); + } + Dylink0Subsection::RuntimePath(paths) => { + metadata + .dylink_runtime_paths + .extend(paths.into_iter().map(str::to_owned)); + } + Dylink0Subsection::ImportInfo(imports) => { + metadata + .dylink_imports + .extend(imports.into_iter().map(|import| WasmDylinkSymbolOut { + module: Some(import.module.to_owned()), + name: import.field.to_owned(), + flags: import.flags.bits(), + })); + } + Dylink0Subsection::ExportInfo(exports) => { + metadata + .dylink_exports + .extend(exports.into_iter().map(|export| WasmDylinkSymbolOut { + module: None, + name: export.name.to_owned(), + flags: export.flags.bits(), + })); + } + Dylink0Subsection::Unknown { .. } => {} + } + } + } + _ => {} + } + } + + metadata.dylink_needed.sort(); + metadata.dylink_needed.dedup(); + metadata.dylink_runtime_paths.sort(); + metadata.dylink_runtime_paths.dedup(); + metadata.dylink_imports.sort_by(|left, right| { + (left.module.as_deref(), left.name.as_str(), left.flags).cmp(&( + right.module.as_deref(), + right.name.as_str(), + right.flags, + )) + }); + metadata.dylink_exports.sort_by(|left, right| { + (left.module.as_deref(), left.name.as_str(), left.flags).cmp(&( + right.module.as_deref(), + right.name.as_str(), + right.flags, + )) + }); + metadata.imports.sort_by(|left, right| { + (left.module.as_str(), left.name.as_str(), left.kind.as_str()).cmp(&( + right.module.as_str(), + right.name.as_str(), + right.kind.as_str(), + )) + }); + metadata.exports.sort_by(|left, right| { + (left.name.as_str(), left.kind.as_str()).cmp(&(right.name.as_str(), right.kind.as_str())) + }); + metadata.memories.sort_by(|left, right| { + ( + left.initial_pages, + left.maximum_pages, + left.memory64, + left.shared, + left.page_size_log2, + ) + .cmp(&( + right.initial_pages, + right.maximum_pages, + right.memory64, + right.shared, + right.page_size_log2, + )) + }); + + Ok(metadata) +} + +fn type_ref_kind(ty: TypeRef) -> &'static str { + match ty { + TypeRef::Func(_) | TypeRef::FuncExact(_) => "func", + TypeRef::Table(_) => "table", + TypeRef::Memory(_) => "memory", + TypeRef::Global(_) => "global", + TypeRef::Tag(_) => "tag", + } +} + +fn external_kind_name(kind: ExternalKind) -> &'static str { + match kind { + ExternalKind::Func | ExternalKind::FuncExact => "func", + ExternalKind::Table => "table", + ExternalKind::Memory => "memory", + ExternalKind::Global => "global", + ExternalKind::Tag => "tag", + } +} + +fn wasm_memory_out(memory: wasmparser::MemoryType) -> WasmMemoryOut { + WasmMemoryOut { + initial_pages: memory.initial, + maximum_pages: memory.maximum, + memory64: memory.memory64, + shared: memory.shared, + page_size_log2: memory.page_size_log2, + } +} diff --git a/tools/xtask/src/asset_pipeline.rs b/tools/xtask/src/asset_pipeline.rs new file mode 100644 index 00000000..1f9d3844 --- /dev/null +++ b/tools/xtask/src/asset_pipeline.rs @@ -0,0 +1,3081 @@ +use super::*; + +pub(crate) struct BuildOutputs { + source_lane: String, + source_fingerprint: Option, + postgres_version: String, + build_dir: PathBuf, + source_dir: PathBuf, + package_stage: PathBuf, + modules: Vec, +} + +struct BuildModuleOutput { + name: String, + kind: String, + path: PathBuf, + aot_file: String, + requires_aot: bool, +} + +fn postgres_source_dir() -> Result { + let manifest = load_postgres_source_manifest()?; + let source = postgres_default_source_dir(&manifest); + ensure!( + source.join(".oliphaunt-wasix-source-fingerprint").is_file(), + "missing prepared PG18 WASIX source at {}; run {POSTGRES_PREPARE_SCRIPT}", + source.display() + ); + check_prepared_postgres_source(&manifest, &source, Path::new(WASIX_POSTGRES_WORK_DIR))?; + Ok(source) +} + +fn postgres_version_for_source_lane(source_lane: &str, source_dir: &Path) -> Result { + match source_lane { + "stable" => { + let version_path = source_dir.join(".oliphaunt-wasix-postgres-version"); + let version = fs::read_to_string(&version_path) + .with_context(|| format!("read {}", version_path.display()))?; + let version = version.trim(); + ensure!( + !version.is_empty(), + "{} must contain a PostgreSQL version", + version_path.display() + ); + Ok(version.to_owned()) + } + other => bail!("unsupported WASIX asset source lane {other:?}"), + } +} + +fn source_fingerprint_for_source_lane( + source_lane: &str, + source_dir: &Path, +) -> Result> { + match source_lane { + "stable" => { + let path = source_dir.join(".oliphaunt-wasix-source-fingerprint"); + let fingerprint = + fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let fingerprint = fingerprint.trim(); + ensure!( + !fingerprint.is_empty(), + "{} must contain a PG18 source fingerprint", + path.display() + ); + Ok(Some(fingerprint.to_owned())) + } + other => bail!("unsupported WASIX asset source lane {other:?}"), + } +} + +fn expected_postgres_source_fingerprint() -> Result { + let manifest = load_postgres_source_manifest()?; + postgres_expected_source_fingerprint(&manifest) +} + +pub(crate) fn ensure_postgres_source_fingerprint_matches_current( + actual: Option<&str>, + field: &str, +) -> Result<()> { + let expected = expected_postgres_source_fingerprint()?; + ensure_eq(actual.unwrap_or(""), &expected, field) +} + +fn postgres_major_version(postgres_version: &str) -> String { + postgres_version + .split('.') + .next() + .filter(|major| !major.is_empty()) + .unwrap_or(postgres_version) + .to_owned() +} + +pub(crate) fn ensure_packaged_asset_matches_source_lane( + manifest: &AssetManifestOut, + source_lane: &str, +) -> Result<()> { + let expected = canonical_source_lane(source_lane)?; + if let Some(actual) = manifest.source_lane.as_deref() { + ensure_eq(actual, expected, "packaged asset manifest source-lane")?; + } + match expected { + "stable" => ensure!( + manifest.runtime.postgres_version.starts_with("18."), + "packaged assets are PostgreSQL {}, not the PG18 WASIX runtime", + manifest.runtime.postgres_version + ), + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } + if expected == "stable" { + ensure_postgres_source_fingerprint_matches_current( + manifest.source_fingerprint.as_deref(), + "packaged asset manifest source-fingerprint", + )?; + } + Ok(()) +} + +fn ensure_build_output_manifest_matches_source_lane( + manifest: &BuildOutputManifestOut, + source_lane: &str, +) -> Result<()> { + let expected = canonical_source_lane(source_lane)?; + let actual = manifest.source_lane.as_deref().unwrap_or(""); + match expected { + "stable" => { + ensure_eq(actual, "stable", "WASIX build output manifest source-lane")?; + let pg18 = load_postgres_source_manifest()?; + ensure_eq( + manifest.postgres_version.as_deref().unwrap_or(""), + pg18.postgresql.version.as_str(), + "WASIX build output manifest postgres-version", + )?; + ensure_postgres_source_fingerprint_matches_current( + manifest.source_fingerprint.as_deref(), + "WASIX build output manifest source-fingerprint", + )?; + ensure_postgres_build_output_manifest_paths_are_stable(manifest)?; + } + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } + if let Some(postgres_version) = manifest.postgres_version.as_deref() { + match expected { + "stable" => ensure!( + postgres_version.starts_with("18."), + "WASIX build output manifest is PostgreSQL {postgres_version}, not the PG18 WASIX runtime" + ), + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } + } + Ok(()) +} + +fn ensure_postgres_build_output_manifest_paths_are_stable( + manifest: &BuildOutputManifestOut, +) -> Result<()> { + let postgres_root = Path::new(WASIX_POSTGRES_DOCKER_BUILD_DIR); + for module in &manifest.modules { + let path = Path::new(&module.path); + ensure!( + path.starts_with(postgres_root), + "PostgreSQL build output manifest module {} points outside the stable build root: {}", + module.name, + module.path + ); + } + Ok(()) +} + +pub(crate) fn canonical_source_lane(source_lane: &str) -> Result<&'static str> { + match source_lane { + "stable" | "released" | "packaged" | "default" => Ok(DEFAULT_SOURCE_LANE), + other => bail!("unsupported WASIX asset source lane {other:?}"), + } +} + +pub(crate) fn build_output_manifest_path_for_source_lane( + source_lane: &str, +) -> Result<&'static Path> { + match canonical_source_lane(source_lane)? { + "stable" => Ok(Path::new(WASIX_POSTGRES_BUILD_MANIFEST_PATH)), + other => bail!("unsupported WASIX asset source lane {other:?}"), + } +} + +pub(crate) fn build_output_manifest_paths_for_source_lane( + source_lane: &str, +) -> Result> { + let primary = build_output_manifest_path_for_source_lane(source_lane)?; + let _ = canonical_source_lane(source_lane)?; + Ok(vec![primary]) +} + +pub(crate) fn generated_assets_dir_for_source_lane(source_lane: &str) -> Result<&'static Path> { + match canonical_source_lane(source_lane)? { + "stable" => Ok(Path::new(GENERATED_ASSETS_DIR)), + other => bail!("unsupported WASIX asset source lane {other:?}"), + } +} + +pub(crate) fn generated_aot_source_dir_for_source_lane( + target: &str, + source_lane: &str, +) -> Result { + match canonical_source_lane(source_lane)? { + "stable" => Ok(Path::new(WASIX_POSTGRES_GENERATED_BUILD_DIR) + .join("aot") + .join(target)), + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } +} + +fn generated_aot_inputs_dir_for_source_lane(source_lane: &str) -> Result { + match canonical_source_lane(source_lane)? { + "stable" => Ok(Path::new(WASIX_POSTGRES_GENERATED_BUILD_DIR).join("aot-inputs")), + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } +} + +pub(crate) fn generated_aot_dir_for_source_lane( + target: &str, + source_lane: &str, +) -> Result { + match canonical_source_lane(source_lane)? { + "stable" => Ok(Path::new(GENERATED_AOT_DIR).join(target)), + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } +} + +pub(crate) fn skip_extensions_for_perf_probe() -> bool { + env::var("OLIPHAUNT_WASM_SKIP_EXTENSIONS_FOR_PERF").as_deref() == Ok("1") +} + +impl BuildOutputs { + pub(crate) fn discover_for_source_lane(source_lane: &str) -> Result { + let source_lane = canonical_source_lane(source_lane)?; + let (canonical_source_lane, build_dir, source_dir, package_stage) = match source_lane { + "stable" => ( + "stable".to_owned(), + PathBuf::from(WASIX_POSTGRES_DOCKER_BUILD_DIR), + postgres_source_dir()?, + PathBuf::from(WASIX_POSTGRES_GENERATED_BUILD_DIR).join("package-stage"), + ), + other => unreachable!("canonical_source_lane returned an unsupported lane: {other}"), + }; + let mut modules = vec![ + BuildModuleOutput { + name: "runtime:oliphaunt".to_owned(), + kind: "runtime".to_owned(), + path: build_dir.join("src/backend/oliphaunt"), + aot_file: "oliphaunt-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }, + BuildModuleOutput { + name: "runtime-support:plpgsql".to_owned(), + kind: "runtime-support".to_owned(), + path: build_dir.join("src/pl/plpgsql/src/plpgsql.so"), + aot_file: "plpgsql-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }, + BuildModuleOutput { + name: "runtime-support:dict_snowball".to_owned(), + kind: "runtime-support".to_owned(), + path: build_dir.join("src/backend/snowball/dict_snowball.so"), + aot_file: "dict_snowball-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }, + BuildModuleOutput { + name: "tool:initdb".to_owned(), + kind: "tool".to_owned(), + path: build_dir.join("src/bin/initdb/initdb"), + aot_file: "initdb-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }, + ]; + if !skip_extensions_for_perf_probe() { + modules.push(BuildModuleOutput { + name: "tool:pg_dump".to_owned(), + kind: "tool".to_owned(), + path: build_dir.join("src/bin/pg_dump/pg_dump"), + aot_file: "pg_dump-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }); + } + if !skip_extensions_for_perf_probe() { + for extension in extension_catalog::promoted_build_specs()? { + for support_module in &extension.native_support_modules { + modules.push(BuildModuleOutput { + name: format!("extension:{}:{}", extension.sql_name, support_module.name), + kind: "extension".to_owned(), + path: build_dir.join(&support_module.build_path), + aot_file: support_module.aot_file.clone(), + requires_aot: true, + }); + } + if extension.module_file.is_some() { + modules.push(BuildModuleOutput { + name: format!("extension:{}", extension.sql_name), + kind: "extension".to_owned(), + path: extension_build_module_path(&build_dir, &extension)?, + aot_file: format!( + "{}-llvm-opta.bin.zst", + extension_aot_file_stem(&extension) + ), + requires_aot: true, + }); + } + } + } + + let outputs = Self { + postgres_version: postgres_version_for_source_lane( + &canonical_source_lane, + &source_dir, + )?, + source_fingerprint: source_fingerprint_for_source_lane( + &canonical_source_lane, + &source_dir, + )?, + source_lane: canonical_source_lane, + build_dir, + source_dir, + package_stage, + modules, + }; + outputs.ensure_required_files()?; + Ok(outputs) + } + + pub(crate) fn discover_for_aot(source_lane: &str) -> Result { + let canonical = canonical_source_lane(source_lane)?; + if canonical == DEFAULT_SOURCE_LANE { + return Self::discover_for_source_lane(source_lane).or_else(|build_err| { + eprintln!( + "warning: transient WASIX build tree unavailable for {source_lane} AOT packaging: {build_err:#}" + ); + Self::from_packaged_assets_for_source_lane(source_lane) + }); + } + unreachable!("canonical_source_lane returned an unsupported lane: {canonical}") + } + + fn from_packaged_assets_for_source_lane(source_lane: &str) -> Result { + let manifest = read_asset_manifest_for_source_lane(source_lane)?; + ensure_packaged_asset_matches_source_lane(&manifest, source_lane)?; + let canonical_source_lane = canonical_source_lane(source_lane)?; + let base = generated_aot_inputs_dir_for_source_lane(source_lane)?; + if base.exists() { + fs::remove_dir_all(&base).with_context(|| format!("remove {}", base.display()))?; + } + fs::create_dir_all(&base).with_context(|| format!("create {}", base.display()))?; + + let assets_base = generated_assets_dir_for_source_lane(source_lane)?; + let runtime_archive = assets_base.join(&manifest.runtime.archive); + let runtime_path = base.join("runtime/oliphaunt"); + write_bytes_file( + &runtime_path, + &archive_entry_bytes(&runtime_archive, "oliphaunt/bin/oliphaunt")?, + )?; + + let mut modules = vec![BuildModuleOutput { + name: "runtime:oliphaunt".to_owned(), + kind: "runtime".to_owned(), + path: runtime_path, + aot_file: "oliphaunt-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }]; + + for support in &manifest.runtime_support { + let path = base.join("runtime-support").join(&support.name); + write_bytes_file( + &path, + &archive_entry_bytes(&runtime_archive, &format!("oliphaunt/{}", support.path))?, + )?; + modules.push(BuildModuleOutput { + name: format!("runtime-support:{}", support.name), + kind: "runtime-support".to_owned(), + path, + aot_file: format!("{}-llvm-opta.bin.zst", support.name), + requires_aot: true, + }); + } + + if let Some(pg_dump) = &manifest.pg_dump { + let path = base.join("tools/pg_dump"); + copy_file(&assets_base.join(&pg_dump.path), &path)?; + modules.push(BuildModuleOutput { + name: "tool:pg_dump".to_owned(), + kind: "tool".to_owned(), + path, + aot_file: "pg_dump-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }); + } + if let Some(initdb) = &manifest.initdb { + let path = base.join("tools/initdb"); + copy_file(&assets_base.join(&initdb.path), &path)?; + modules.push(BuildModuleOutput { + name: "tool:initdb".to_owned(), + kind: "tool".to_owned(), + path, + aot_file: "initdb-llvm-opta.bin.zst".to_owned(), + requires_aot: true, + }); + } + + for extension in &manifest.extensions { + let mut native_modules = extension.native_modules.clone(); + if native_modules.is_empty() { + if let Some(native_module) = extension.native_module.as_deref() + && !extension.module_sha256.is_empty() + { + native_modules.push(BinaryAssetOut { + name: extension.sql_name.clone(), + path: format!("lib/postgresql/{native_module}"), + sha256: extension.module_sha256.clone(), + module_sha256: extension.module_sha256.clone(), + size: 0, + link: extension.link.clone().unwrap_or_default(), + }); + } + } + for native_module in native_modules { + if native_module.module_sha256.is_empty() { + continue; + } + let path = base.join("extensions").join(&extension.sql_name).join( + Path::new(&native_module.path) + .file_name() + .unwrap_or_default(), + ); + write_bytes_file( + &path, + &archive_entry_bytes( + &assets_base.join(&extension.archive), + &native_module.path, + )?, + )?; + modules.push(BuildModuleOutput { + name: if native_module.name == extension.sql_name { + format!("extension:{}", extension.sql_name) + } else { + format!("extension:{}:{}", extension.sql_name, native_module.name) + }, + kind: "extension".to_owned(), + path, + aot_file: format!("{}-llvm-opta.bin.zst", native_module.name.replace('/', "_")), + requires_aot: true, + }); + } + } + + Ok(Self { + source_lane: canonical_source_lane.to_owned(), + source_fingerprint: manifest.source_fingerprint.clone(), + postgres_version: manifest.runtime.postgres_version.clone(), + build_dir: base.clone(), + source_dir: base.clone(), + package_stage: base, + modules, + }) + } + + fn ensure_required_files(&self) -> Result<()> { + for module in &self.modules { + ensure_file(&module.path)?; + } + self.ensure_build_source_markers()?; + ensure_file(&self.build_dir.join("src/timezone/compiled/UTC"))?; + ensure_file( + &self + .build_dir + .join("src/backend/snowball/snowball_create.sql"), + )?; + Ok(()) + } + + fn ensure_build_source_markers(&self) -> Result<()> { + match self.source_lane.as_str() { + "stable" => { + let source_fingerprint = self + .source_fingerprint + .as_deref() + .ok_or_else(|| anyhow!("PG18 build outputs are missing source fingerprint"))?; + ensure_matching_marker( + source_fingerprint, + &self.build_dir.join(".oliphaunt-wasix-source-fingerprint"), + "PG18 build source fingerprint", + )?; + ensure_matching_marker( + &self.postgres_version, + &self.build_dir.join(".oliphaunt-wasix-postgres-version"), + "PG18 build PostgreSQL version marker", + )?; + } + other => bail!("unsupported WASIX asset source lane {other:?}"), + } + Ok(()) + } + + fn module_path(&self, name: &str) -> Result<&Path> { + self.modules + .iter() + .find(|module| module.name == name) + .map(|module| module.path.as_path()) + .ok_or_else(|| anyhow!("missing build output module {name}")) + } + + fn manifest_path(&self) -> Result<&'static Path> { + build_output_manifest_path_for_source_lane(&self.source_lane) + } + + fn write_manifest(&self) -> Result<()> { + let manifest = BuildOutputManifestOut { + format_version: 1, + source_lane: Some(self.source_lane.clone()), + source_fingerprint: self.source_fingerprint.clone(), + postgres_version: Some(self.postgres_version.clone()), + build_profile: fs::read_to_string( + self.build_dir.join(".oliphaunt-wasix-build-profile"), + ) + .context("read WASIX build profile signature")?, + modules: self + .modules + .iter() + .map(|module| { + Ok(BuildModuleManifestOut { + name: module.name.clone(), + kind: module.kind.clone(), + path: module.path.to_string_lossy().into_owned(), + sha256: sha256_file(&module.path)?, + link: read_wasm_link_metadata(&module.path)?, + }) + }) + .collect::>>()?, + }; + for module in &manifest.modules { + validate_module_link_metadata(module)?; + } + ensure_build_output_manifest_matches_source_lane(&manifest, &self.source_lane)?; + let text = serde_json::to_string_pretty(&manifest) + .context("serialize WASIX build output manifest")?; + let path = self.manifest_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, format!("{text}\n")).with_context(|| format!("write {}", path.display())) + } +} + +fn extension_build_module_path( + build_dir: &Path, + extension: &extension_catalog::PromotedExtensionBuildSpec, +) -> Result { + let module_file = extension + .module_file + .as_deref() + .ok_or_else(|| anyhow!("extension {} has no native module", extension.sql_name))?; + match extension.build_kind.as_str() { + "postgres-contrib" => { + let contrib_dir = extension + .contrib_dir + .as_deref() + .ok_or_else(|| anyhow!("contrib extension {} has no contrib_dir", extension.id))?; + Ok(build_dir + .join("contrib") + .join(contrib_dir) + .join(module_file)) + } + kind if extension_catalog::is_pgxs_style_build_kind(kind) => { + Ok(pgxs_extension_build_dir(build_dir, extension).join(module_file)) + } + kind if extension_catalog::is_recipe_staged_build_kind(kind) => { + let staging = extension + .staging + .as_ref() + .ok_or_else(|| anyhow!("extension {} has no staging metadata", extension.id))?; + let module_source_dir = staging.module_source_dir.as_deref().ok_or_else(|| { + anyhow!( + "extension {} staging metadata has no module_source_dir", + extension.id + ) + })?; + Ok(build_dir.join(module_source_dir).join(module_file)) + } + other => bail!( + "promoted extension {} has unsupported build kind {other}", + extension.sql_name + ), + } +} + +fn pgxs_extension_build_dir( + build_dir: &Path, + extension: &extension_catalog::PromotedExtensionBuildSpec, +) -> PathBuf { + build_dir.join("pgxs").join(&extension.id) +} + +fn extension_aot_file_stem(extension: &extension_catalog::PromotedExtensionBuildSpec) -> String { + extension.sql_name.replace('/', "_") +} + +fn validate_build_profile_outputs(outputs: &BuildOutputs, profile: &str) -> Result<()> { + let signature_path = outputs.build_dir.join(".oliphaunt-wasix-build-profile"); + let signature = fs::read_to_string(&signature_path) + .with_context(|| format!("read {}", signature_path.display()))?; + let profile_line = format!("profile={profile}"); + if !signature.lines().any(|line| line == profile_line) { + bail!( + "WASIX build profile signature does not match requested profile {profile}: {}", + signature_path.display() + ); + } + + if profile.starts_with("release") { + let cflags = signature + .lines() + .find_map(|line| line.strip_prefix("cflags=")) + .unwrap_or_default(); + let has_release_opt = ["-O2", "-O3", "-Os", "-Oz"] + .iter() + .any(|flag| cflags.split_whitespace().any(|part| part == *flag)); + if !has_release_opt || !cflags.split_whitespace().any(|part| part == "-g0") { + bail!( + "release WASIX profile must include an optimizing -O flag and -g0; got cflags={cflags:?}" + ); + } + + let makefile = outputs.build_dir.join("src/Makefile.global"); + let makefile_text = fs::read_to_string(&makefile) + .with_context(|| format!("read {}", makefile.display()))?; + if !["-O2", "-O3", "-Os", "-Oz"] + .iter() + .any(|flag| makefile_text.contains(flag)) + { + bail!( + "release WASIX build did not propagate optimization flags into {}", + makefile.display() + ); + } + } + + Ok(()) +} + +fn validate_module_link_metadata(module: &BuildModuleManifestOut) -> Result<()> { + if module.link.exports.is_empty() { + bail!("{} has no WASM exports", module.name); + } + + match module.kind.as_str() { + "runtime" => { + let missing = required_runtime_abi_exports() + .iter() + .copied() + .filter(|export| !has_wasm_export(&module.link, export)) + .collect::>(); + if !missing.is_empty() { + bail!( + "{} is missing required Rust/WASIX ABI exports: {}", + module.name, + missing.join(", ") + ); + } + for banned in [ + "oliphaunt_wasix_initdb", + "oliphaunt_wasix_backend", + "PostgresRecoverProtocolError", + ] { + if has_wasm_export(&module.link, banned) { + bail!( + "{} exports legacy builder-branch lifecycle entrypoint {banned}", + module.name + ); + } + } + } + "runtime-support" | "extension" => { + if !module.link.has_dylink0 { + bail!("{} is not a WASM dynamic-linking side module", module.name); + } + if module.link.imports.is_empty() && module.link.dylink_imports.is_empty() { + bail!( + "{} has no imports; side-module linkage is suspicious", + module.name + ); + } + } + "tool" => {} + other => bail!("{} has unknown build output kind {other}", module.name), + } + + Ok(()) +} + +fn validate_build_output_link_closure(outputs: &BuildOutputs) -> Result<()> { + let runtime = outputs + .modules + .iter() + .find(|module| module.kind == "runtime") + .ok_or_else(|| anyhow!("build outputs are missing runtime module"))?; + let runtime_link = read_wasm_link_metadata(&runtime.path)?; + let runtime_exports = runtime_link + .exports + .iter() + .flat_map(|export| { + let name = export.name.trim_start_matches('_').to_owned(); + [export.name.clone(), name] + }) + .collect::>(); + + let side_modules = outputs + .modules + .iter() + .filter(|module| matches!(module.kind.as_str(), "runtime-support" | "extension")) + .collect::>(); + let side_module_links = side_modules + .iter() + .map(|module| { + Ok::<_, anyhow::Error>((module.name.clone(), read_wasm_link_metadata(&module.path)?)) + }) + .collect::>>()?; + let side_module_exports = side_module_links + .iter() + .map(|(name, link)| (name.clone(), wasm_export_name_set(link))) + .collect::>(); + + let mut failures = Vec::new(); + for module in side_modules { + let link = side_module_links + .get(&module.name) + .ok_or_else(|| anyhow!("missing link metadata for {}", module.name))?; + let provider_exports = side_module_provider_exports(&module.name, &side_module_exports); + for import in &link.imports { + if !import_should_resolve_from_runtime(import) { + continue; + } + if import_resolves_from_linked_module_exports(import, &provider_exports) { + continue; + } + let normalized = import.name.trim_start_matches('_'); + if !runtime_exports.contains(import.name.as_str()) + && !runtime_exports.contains(normalized) + { + failures.push(format!( + "{} imports {}.{}", + module.name, import.module, import.name + )); + } + } + } + + if !failures.is_empty() { + bail!( + "WASIX dynamic-link closure has unresolved side-module imports: {}", + failures.join(", ") + ); + } + Ok(()) +} + +fn side_module_provider_exports( + module_name: &str, + exports_by_name: &BTreeMap>, +) -> HashSet { + let mut exports = exports_by_name + .get(module_name) + .cloned() + .unwrap_or_default(); + if let Some(sql_name) = extension_module_sql_name(module_name) { + let support_prefix = format!("extension:{sql_name}:"); + for (name, module_exports) in exports_by_name { + if name.starts_with(&support_prefix) { + exports.extend(module_exports.iter().cloned()); + } + } + } + exports +} + +fn extension_module_sql_name(module_name: &str) -> Option<&str> { + module_name + .strip_prefix("extension:") + .and_then(|rest| rest.split(':').next()) + .filter(|sql_name| !sql_name.is_empty()) +} + +pub(crate) fn generate_wasix_export_list(write: bool, source_lane: &str) -> Result<()> { + let output = wasix_export_list_text(source_lane)?; + if write { + let path = Path::new("src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, output).with_context(|| format!("write {}", path.display()))?; + } else { + print!("{output}"); + } + Ok(()) +} + +pub(crate) fn check_generated_wasix_export_list(strict: bool) -> Result<()> { + let expected = match wasix_export_list_text(DEFAULT_SOURCE_LANE) { + Ok(expected) => expected, + Err(err) if !strict => { + eprintln!("warning: skipping generated WASIX export-list check: {err:#}"); + return Ok(()); + } + Err(err) => return Err(err).context("generate expected WASIX export list"), + }; + let path = Path::new("src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports"); + if !path.exists() { + if strict { + bail!( + "generated WASIX export list is missing at {}; run `cargo run -p xtask -- assets export-list --write`", + path.display() + ); + } + eprintln!( + "warning: generated WASIX export list is missing at {}", + path.display() + ); + return Ok(()); + } + let actual = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + if actual != expected { + if strict { + bail!( + "generated WASIX export list is stale at {}; run `cargo run -p xtask -- assets export-list --write`", + path.display() + ); + } + eprintln!( + "warning: generated WASIX export list is stale at {}", + path.display() + ); + } + Ok(()) +} + +pub(crate) fn check_source_controlled_wasix_export_list() -> Result<()> { + let path = Path::new("src/runtimes/liboliphaunt/wasix/assets/generated/wasix-dl.exports"); + ensure_file(path)?; + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + ensure!( + !text.trim().is_empty(), + "{} must not be empty", + path.display() + ); + for symbol in [ + "ProcessStartupPacket", + "PostgresMainLoopOnce", + "PostgresMainLongJmp", + "PostgresSendReadyForQueryIfNecessary", + "oliphaunt_wasix_get_proc_port", + "oliphaunt_wasix_pq_flush", + "oliphaunt_wasix_send_conn_data", + "oliphaunt_wasix_set_active", + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_protocol_stream_active", + "oliphaunt_wasix_start", + "oliphaunt_wasix_set_protocol_transport", + "oliphaunt_wasix_input_write", + "oliphaunt_wasix_output_read", + "malloc", + "free", + ] { + ensure!( + text.lines().any(|line| line == symbol), + "{} is missing required runtime/protocol export symbol {symbol}", + path.display() + ); + } + let mut previous: Option<&str> = None; + for line in text.lines().filter(|line| !line.trim().is_empty()) { + if let Some(previous) = previous { + ensure!( + previous <= line, + "{} must stay sorted for deterministic reviews; {previous} appears before {line}", + path.display() + ); + } + previous = Some(line); + } + println!("source-controlled WASIX export-list guard passed"); + Ok(()) +} + +fn wasix_export_list_text(source_lane: &str) -> Result { + for manifest_path in build_output_manifest_paths_for_source_lane(source_lane)? { + if !manifest_path.exists() { + continue; + } + let manifest = read_build_output_manifest(manifest_path)?; + match ensure_build_output_manifest_matches_source_lane(&manifest, source_lane) { + Ok(()) => return wasix_export_list_from_modules(&manifest.modules), + Err(err) => { + eprintln!( + "warning: ignoring WASIX build output manifest {} while generating export list for {source_lane}: {err:#}", + manifest_path.display() + ); + } + } + } + let asset_dir = generated_assets_dir_for_source_lane(source_lane)?; + if asset_dir.join("manifest.json").exists() { + let manifest = read_asset_manifest_for_source_lane(source_lane)?; + if ensure_packaged_asset_matches_source_lane(&manifest, source_lane).is_ok() { + let modules = build_output_modules_from_asset_manifest(&manifest); + return wasix_export_list_from_modules(&modules); + } + eprintln!( + "warning: ignoring generated asset manifest for PostgreSQL {} while generating export list for {source_lane}", + manifest.runtime.postgres_version + ); + } + + let outputs = BuildOutputs::discover_for_source_lane(source_lane)?; + let modules = outputs + .modules + .iter() + .map(|module| { + Ok(BuildModuleManifestOut { + name: module.name.clone(), + kind: module.kind.clone(), + path: module.path.to_string_lossy().into_owned(), + sha256: String::new(), + link: read_wasm_link_metadata(&module.path)?, + }) + }) + .collect::>>()?; + wasix_export_list_from_modules(&modules) +} + +fn read_build_output_manifest(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +pub(crate) fn read_asset_manifest_for_source_lane(source_lane: &str) -> Result { + read_asset_manifest_from(generated_assets_dir_for_source_lane(source_lane)?) +} + +pub(crate) fn read_asset_manifest_from(asset_dir: &Path) -> Result { + let path = asset_dir.join("manifest.json"); + let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +fn build_output_modules_from_asset_manifest( + manifest: &AssetManifestOut, +) -> Vec { + let mut modules = vec![BuildModuleManifestOut { + name: "runtime:oliphaunt".to_owned(), + kind: "runtime".to_owned(), + path: manifest.runtime.archive.clone(), + sha256: manifest.runtime.module_sha256.clone(), + link: manifest.runtime.link.clone(), + }]; + + modules.extend( + manifest + .runtime_support + .iter() + .map(|module| BuildModuleManifestOut { + name: format!("runtime-support:{}", module.name), + kind: "runtime-support".to_owned(), + path: module.path.clone(), + sha256: module.module_sha256.clone(), + link: module.link.clone(), + }), + ); + + if let Some(pg_dump) = &manifest.pg_dump { + modules.push(BuildModuleManifestOut { + name: "tool:pg_dump".to_owned(), + kind: "tool".to_owned(), + path: pg_dump.path.clone(), + sha256: pg_dump.module_sha256.clone(), + link: pg_dump.link.clone(), + }); + } + if let Some(initdb) = &manifest.initdb { + modules.push(BuildModuleManifestOut { + name: "tool:initdb".to_owned(), + kind: "tool".to_owned(), + path: initdb.path.clone(), + sha256: initdb.module_sha256.clone(), + link: initdb.link.clone(), + }); + } + + for extension in &manifest.extensions { + for native_module in &extension.native_modules { + modules.push(BuildModuleManifestOut { + name: if native_module.name == extension.sql_name { + format!("extension:{}", extension.sql_name) + } else { + format!("extension:{}:{}", extension.sql_name, native_module.name) + }, + kind: "extension".to_owned(), + path: native_module.path.clone(), + sha256: native_module.module_sha256.clone(), + link: native_module.link.clone(), + }); + } + let has_primary_native_module = extension + .native_modules + .iter() + .any(|module| module.name == extension.sql_name); + if !has_primary_native_module && let Some(link) = extension.link.clone() { + modules.push(BuildModuleManifestOut { + name: format!("extension:{}", extension.sql_name), + kind: "extension".to_owned(), + path: extension.archive.clone(), + sha256: extension.module_sha256.clone(), + link, + }); + } + } + + modules +} + +fn wasix_export_list_from_modules(modules: &[BuildModuleManifestOut]) -> Result { + for module in modules { + validate_module_link_metadata(module)?; + } + + let runtime = modules + .iter() + .find(|module| module.kind == "runtime") + .ok_or_else(|| anyhow!("build outputs are missing runtime module"))?; + let runtime_exports = wasm_export_name_set(&runtime.link); + let side_module_exports = modules + .iter() + .filter(|module| matches!(module.kind.as_str(), "runtime-support" | "extension")) + .map(|module| (module.name.clone(), wasm_export_name_set(&module.link))) + .collect::>(); + let mut required_exports = BTreeSet::::new(); + let mut unresolved = Vec::new(); + + for abi_export in required_runtime_abi_exports().iter().copied() { + let normalized = abi_export.trim_start_matches('_'); + if runtime_exports.contains(abi_export) { + required_exports.insert(abi_export.to_owned()); + } else if runtime_exports.contains(normalized) { + required_exports.insert(normalized.to_owned()); + } else { + unresolved.push(format!("runtime ABI export {abi_export}")); + } + } + + for module in modules + .iter() + .filter(|module| matches!(module.kind.as_str(), "runtime-support" | "extension")) + { + let module_exports = side_module_provider_exports(&module.name, &side_module_exports); + for import in &module.link.imports { + if !import_should_resolve_from_runtime(import) { + continue; + } + if import_resolves_from_linked_module_exports(import, &module_exports) { + continue; + } + let normalized = import.name.trim_start_matches('_'); + if runtime_exports.contains(import.name.as_str()) { + required_exports.insert(import.name.clone()); + } else if runtime_exports.contains(normalized) { + required_exports.insert(normalized.to_owned()); + } else { + unresolved.push(format!( + "{} imports {}.{}", + module.name, import.module, import.name + )); + } + } + } + + if !unresolved.is_empty() { + bail!( + "cannot generate WASIX dynamic-link export list with unresolved imports: {}", + unresolved.join(", ") + ); + } + + Ok(required_exports.into_iter().collect::>().join("\n") + "\n") +} + +pub(crate) fn required_runtime_abi_exports() -> &'static [&'static str] { + &[ + "_start", + "oliphaunt_wasix_set_active", + "oliphaunt_wasix_start", + "oliphaunt_wasix_get_proc_port", + "ProcessStartupPacket", + "oliphaunt_wasix_send_conn_data", + "oliphaunt_wasix_pq_flush", + "pq_buffer_remaining_data", + "PostgresMainLoopOnce", + "PostgresSendReadyForQueryIfNecessary", + "PostgresMainLongJmp", + "oliphaunt_wasix_set_protocol_stdio", + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_protocol_stream_active", + "oliphaunt_wasix_input_reset", + "oliphaunt_wasix_input_write", + "oliphaunt_wasix_input_available", + "oliphaunt_wasix_output_reset", + "oliphaunt_wasix_output_len", + "oliphaunt_wasix_output_read", + "oliphaunt_wasix_set_protocol_transport", + ] +} + +fn import_should_resolve_from_runtime(import: &WasmImportOut) -> bool { + if import_is_wasix_linker_provided(import) { + return false; + } + matches!(import.module.as_str(), "env" | "GOT.func" | "GOT.mem") +} + +fn import_is_wasix_linker_provided(import: &WasmImportOut) -> bool { + match (import.module.as_str(), import.name.as_str()) { + ( + "env", + "__c_longjmp" + | "__cpp_exception" + | "__indirect_function_table" + | "__memory_base" + | "__stack_pointer" + | "__table_base" + | "memory", + ) => true, + ("GOT.mem", "__heap_base" | "__stack_high" | "__stack_low") => true, + _ => false, + } +} + +fn import_resolves_from_linked_module_exports( + import: &WasmImportOut, + module_exports: &HashSet, +) -> bool { + module_exports.contains(import.name.as_str()) + || module_exports.contains(import.name.trim_start_matches('_')) +} + +fn extension_asset_provider_exports( + primary_link: &WasmLinkMetadataOut, + sql_name: &str, + native_module_links: &BTreeMap, +) -> HashSet { + let mut exports = wasm_export_name_set(primary_link); + for (name, link) in native_module_links { + if name == sql_name { + continue; + } + exports.extend(wasm_export_name_set(link)); + } + exports +} + +fn wasm_export_name_set(link: &WasmLinkMetadataOut) -> HashSet { + link.exports + .iter() + .flat_map(|export| { + let normalized = export.name.trim_start_matches('_').to_owned(); + [export.name.clone(), normalized] + }) + .collect() +} + +fn has_wasm_export(link: &WasmLinkMetadataOut, name: &str) -> bool { + link.exports + .iter() + .any(|export| export.name == name || export.name == format!("_{name}")) +} + +pub(crate) fn build_asset_spine( + _manifest: &SourcesManifest, + profile: &str, + target: &str, + args: &[String], +) -> Result<()> { + let execute = args.iter().any(|arg| arg == "--execute") + || env::var("OLIPHAUNT_WASM_EXECUTE_ASSET_BUILD").as_deref() == Ok("1"); + let source_lane = + canonical_source_lane(value_after(args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE))?; + let backend_script = match source_lane { + "stable" => "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", + other => bail!("unsupported WASIX asset source lane {other:?}"), + }; + + println!("asset build inputs validated"); + println!("profile={profile}"); + println!("target-triple={target}"); + + let commands = asset_build_commands(backend_script)?; + + if !execute { + println!("source-spine build is ready but not executed by default"); + println!("run with --execute or OLIPHAUNT_WASM_EXECUTE_ASSET_BUILD=1 to invoke:"); + for command in &commands { + println!(" {}", command.script); + } + println!("follow with `assets package` and `assets aot` to refresh publishable artifacts"); + return Ok(()); + } + + for command_spec in commands { + if skip_extensions_for_perf_probe() && command_spec.skip_for_core_probe { + println!("skipping {} for core-only perf probe", command_spec.script); + continue; + } + let mut command = Command::new("bash"); + command + .arg(&command_spec.script) + .env("OLIPHAUNT_WASM_BUILD_PROFILE", profile); + run_command(&mut command)?; + } + + let outputs = BuildOutputs::discover_for_source_lane(source_lane)?; + validate_build_profile_outputs(&outputs, profile)?; + outputs.write_manifest()?; + validate_build_output_link_closure(&outputs)?; + println!( + "wrote WASIX build output manifest to {}", + outputs.manifest_path()?.display() + ); + Ok(()) +} + +struct AssetBuildCommand { + script: String, + skip_for_core_probe: bool, +} + +fn asset_build_commands(backend_script: &str) -> Result> { + let mut commands = vec![ + AssetBuildCommand { + script: backend_script.to_owned(), + skip_for_core_probe: false, + }, + AssetBuildCommand { + script: "src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh" + .to_owned(), + skip_for_core_probe: false, + }, + AssetBuildCommand { + script: "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh".to_owned(), + skip_for_core_probe: false, + }, + AssetBuildCommand { + script: "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgxs_extensions.sh" + .to_owned(), + skip_for_core_probe: true, + }, + AssetBuildCommand { + script: "src/runtimes/liboliphaunt/wasix/assets/build/docker_contrib_extensions.sh" + .to_owned(), + skip_for_core_probe: true, + }, + ]; + for extension in extension_catalog::promoted_build_specs()? { + if !extension_catalog::is_recipe_staged_build_kind(&extension.build_kind) { + continue; + } + let script = extension.build_script.clone().ok_or_else(|| { + anyhow!( + "recipe-staged extension {} has no WASIX build script", + extension.sql_name + ) + })?; + commands.push(AssetBuildCommand { + script, + skip_for_core_probe: true, + }); + } + commands.push(AssetBuildCommand { + script: "src/runtimes/liboliphaunt/wasix/assets/build/docker_pgdump.sh".to_owned(), + skip_for_core_probe: true, + }); + Ok(commands) +} + +pub(crate) fn release_build_assets( + manifest: &SourcesManifest, + profile: &str, + target: &str, + args: &[String], +) -> Result<()> { + let source_lane = value_after(args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + let mut build_args = vec![ + "build".to_owned(), + "--profile".to_owned(), + profile.to_owned(), + "--target-triple".to_owned(), + target.to_owned(), + "--execute".to_owned(), + ]; + build_args.extend( + args.iter() + .filter(|arg| { + matches!( + arg.as_str(), + "--skip-build" | "--skip-aot" | "--skip-package-size" + ) + }) + .cloned(), + ); + + if !args.iter().any(|arg| arg == "--skip-build") { + build_asset_spine(manifest, profile, target, &build_args)?; + } else { + eprintln!("warning: skipping WASIX rebuild by request"); + } + + let outputs = BuildOutputs::discover_for_source_lane(source_lane)?; + validate_build_profile_outputs(&outputs, profile)?; + outputs.write_manifest()?; + validate_build_output_link_closure(&outputs)?; + + let skip_aot = args.iter().any(|arg| arg == "--skip-aot"); + package_assets_with_options(manifest, target, false, source_lane)?; + let asset_dir = generated_assets_dir_for_source_lane(source_lane)?; + check_canonical_asset_layout_in(asset_dir, true)?; + let expected_sources = effective_source_pins(manifest, &outputs)?; + check_generated_manifest_sources_in(asset_dir, &expected_sources, source_lane, true)?; + + if !skip_aot { + generate_aot_artifacts(target, source_lane)?; + package_aot_artifacts(target, &outputs, manifest)?; + check_aot_package_manifest(target, source_lane)?; + } else { + eprintln!("warning: skipping AOT generation by request"); + } + + if !args.iter().any(|arg| arg == "--skip-package-size") { + enforce_package_size_for_source_lane(source_lane)?; + } + + Ok(()) +} + +pub(crate) fn generate_aot_artifacts(target: &str, source_lane: &str) -> Result<()> { + let outputs = BuildOutputs::discover_for_aot(source_lane)?; + let source_dir = generated_aot_source_dir_for_source_lane(target, &outputs.source_lane)?; + fs::create_dir_all(&source_dir).with_context(|| format!("create {}", source_dir.display()))?; + let serializer = ensure_aot_serializer_binary()?; + + for module in outputs.modules.iter().filter(|module| module.requires_aot) { + let output = source_dir.join(&module.aot_file); + generate_one_aot_artifact(&serializer, &module.path, &output)?; + } + Ok(()) +} + +pub(crate) fn package_aot_only( + manifest: &SourcesManifest, + target: &str, + source_lane: &str, +) -> Result<()> { + let outputs = BuildOutputs::discover_for_aot(source_lane)?; + package_aot_artifacts(target, &outputs, manifest)?; + check_aot_package_manifest(target, source_lane) +} + +fn ensure_aot_serializer_binary() -> Result { + let mut command = Command::new("cargo"); + command + .args([ + "build", + "-p", + "xtask", + "--release", + "--locked", + "--features", + "aot-serializer", + ]) + .env("CARGO_INCREMENTAL", "0"); + if env::var_os("LLVM_SYS_221_PREFIX").is_none() && Path::new("/opt/homebrew/opt/llvm").exists() + { + command.env("LLVM_SYS_221_PREFIX", "/opt/homebrew/opt/llvm"); + } + configure_windows_llvm_aot_link(&mut command); + run_command(&mut command).context("build maintainer AOT serializer")?; + + let target_dir = env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target")); + let target_dir = if target_dir.is_absolute() { + target_dir + } else { + env::current_dir() + .context("read current directory")? + .join(target_dir) + }; + let serializer = target_dir + .join("release") + .join(format!("xtask{}", env::consts::EXE_SUFFIX)); + ensure_file(&serializer)?; + Ok(serializer) +} + +fn generate_one_aot_artifact(serializer: &Path, input: &Path, output: &Path) -> Result<()> { + ensure_file(input)?; + let input = + fs::canonicalize(input).with_context(|| format!("canonicalize {}", input.display()))?; + let output = if output.is_absolute() { + output.to_path_buf() + } else { + env::current_dir() + .context("read current directory")? + .join(output) + }; + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + + let mut command = Command::new(serializer); + command + .args(["aot-serializer", "serialize", "--input"]) + .arg(&input) + .arg("--output") + .arg(output) + .env("CARGO_INCREMENTAL", "0"); + if env::var_os("LLVM_SYS_221_PREFIX").is_none() && Path::new("/opt/homebrew/opt/llvm").exists() + { + command.env("LLVM_SYS_221_PREFIX", "/opt/homebrew/opt/llvm"); + } + configure_windows_llvm_aot_link(&mut command); + run_command(&mut command) + .with_context(|| format!("generate AOT artifact for {}", input.display())) +} + +fn configure_windows_llvm_aot_link(command: &mut Command) { + if !cfg!(windows) { + return; + } + + let Some(prefix) = env::var_os("LLVM_SYS_221_PREFIX").or_else(|| env::var_os("LLVM_PATH")) + else { + return; + }; + let llvm_lib = PathBuf::from(prefix).join("lib"); + if llvm_lib.is_dir() { + let mut lib = llvm_lib.display().to_string(); + if let Some(existing) = env::var_os("LIB").and_then(|value| value.into_string().ok()) + && !existing.is_empty() + { + lib.push(';'); + lib.push_str(&existing); + } + command.env("LIB", lib); + } +} + +pub(crate) fn package_assets( + manifest: &SourcesManifest, + target: &str, + source_lane: &str, +) -> Result<()> { + package_assets_with_options(manifest, target, true, source_lane) +} + +pub(crate) fn package_assets_without_aot( + manifest: &SourcesManifest, + source_lane: &str, +) -> Result<()> { + package_assets_with_options(manifest, host_target_triple(), false, source_lane) +} + +fn package_assets_with_options( + manifest: &SourcesManifest, + target: &str, + include_aot: bool, + source_lane: &str, +) -> Result<()> { + let outputs = BuildOutputs::discover_for_source_lane(source_lane)?; + outputs.write_manifest()?; + validate_build_output_link_closure(&outputs)?; + let build = &outputs.build_dir; + let source = &outputs.source_dir; + let stage = &outputs.package_stage; + + if stage.exists() { + fs::remove_dir_all(stage).with_context(|| format!("remove {}", stage.display()))?; + } + fs::create_dir_all(stage).with_context(|| format!("create {}", stage.display()))?; + + let runtime_stage = stage.join("runtime/oliphaunt"); + stage_runtime_tree(build, source, &runtime_stage)?; + let assets_dir = generated_assets_dir_for_source_lane(source_lane)?; + if assets_dir.exists() { + fs::remove_dir_all(assets_dir) + .with_context(|| format!("remove {}", assets_dir.display()))?; + } + fs::create_dir_all(assets_dir).with_context(|| format!("create {}", assets_dir.display()))?; + if skip_extensions_for_perf_probe() { + fs::create_dir_all(assets_dir.join("extensions")) + .with_context(|| format!("create {}", assets_dir.join("extensions").display()))?; + } + + let runtime_archive = assets_dir.join("oliphaunt.wasix.tar.zst"); + deterministic_tar_zst(&runtime_stage, Path::new("oliphaunt"), &runtime_archive)?; + + let pg_dump = if skip_extensions_for_perf_probe() { + None + } else { + let pg_dump = assets_dir.join("bin/pg_dump.wasix.wasm"); + copy_file(outputs.module_path("tool:pg_dump")?, &pg_dump)?; + Some(pg_dump) + }; + let initdb = assets_dir.join("bin/initdb.wasix.wasm"); + copy_file(outputs.module_path("tool:initdb")?, &initdb)?; + + let extension_artifacts = + build_promoted_extension_artifacts(source, build, stage, assets_dir, &outputs)?; + let extension_artifact_refs = extension_artifacts + .iter() + .map(|extension| ExtensionArtifact { + name: extension.name.as_str(), + sql_name: extension.sql_name.as_str(), + archive: extension.archive.as_str(), + path: extension.path.as_path(), + module_path: extension.module_path.as_deref(), + native_module: extension.native_module.as_deref(), + native_modules: &extension.native_modules, + stable: extension.stable, + }) + .collect::>(); + + if include_aot { + package_aot_artifacts(target, &outputs, manifest)?; + } + generate_pgdata_template_from_runtime_stage(manifest, &outputs, &runtime_stage, assets_dir)?; + write_asset_manifest( + manifest, + &outputs, + assets_dir, + outputs.module_path("runtime:oliphaunt")?, + &runtime_archive, + pg_dump.as_deref(), + &initdb, + &[ + BinaryPackage { + name: "plpgsql", + path: outputs.module_path("runtime-support:plpgsql")?, + runtime_path: "lib/postgresql/plpgsql.so", + }, + BinaryPackage { + name: "dict_snowball", + path: outputs.module_path("runtime-support:dict_snowball")?, + runtime_path: "lib/postgresql/dict_snowball.so", + }, + ], + &extension_artifact_refs, + )?; + + println!("packaged runtime assets into {}", assets_dir.display()); + if include_aot { + println!("packaged {target} AOT artifacts"); + } else { + println!("skipped {target} AOT artifact packaging by request"); + } + Ok(()) +} + +pub(crate) fn generate_pgdata_template_asset( + manifest: &SourcesManifest, + source_lane: &str, +) -> Result<()> { + let outputs = BuildOutputs::discover_for_source_lane(source_lane)?; + let stage_root = outputs.package_stage.join("template-runtime"); + if stage_root.exists() { + fs::remove_dir_all(&stage_root) + .with_context(|| format!("remove {}", stage_root.display()))?; + } + stage_runtime_tree(&outputs.build_dir, &outputs.source_dir, &stage_root)?; + generate_pgdata_template_from_runtime_stage( + manifest, + &outputs, + &stage_root, + generated_assets_dir_for_source_lane(source_lane)?, + ) +} + +fn generate_pgdata_template_from_runtime_stage( + manifest: &SourcesManifest, + outputs: &BuildOutputs, + runtime_stage: &Path, + assets_dir: &Path, +) -> Result<()> { + let output_dir = assets_dir.join("prepopulated"); + if output_dir.exists() { + fs::remove_dir_all(&output_dir) + .with_context(|| format!("remove {}", output_dir.display()))?; + } + fs::create_dir_all(&output_dir).with_context(|| format!("create {}", output_dir.display()))?; + + let work_root = assets_dir.join("template-work"); + if work_root.exists() { + fs::remove_dir_all(&work_root) + .with_context(|| format!("remove {}", work_root.display()))?; + } + fs::create_dir_all(&work_root).with_context(|| format!("create {}", work_root.display()))?; + + template_runner::run_wasix_initdb_template(runtime_stage, &work_root)?; + + let pgdata = work_root.join("pgdata"); + ensure!( + pgdata.join("PG_VERSION").is_file() && pgdata.join("global/pg_control").is_file(), + "WASIX initdb did not create a complete PGDATA template at {}", + pgdata.display() + ); + template_runner::clean_generated_pgdata_template(&pgdata)?; + + let archive = output_dir.join("pgdata-template.tar.zst"); + deterministic_tar_zst(&pgdata, Path::new(""), &archive)?; + let manifest_path = output_dir.join("pgdata-template.json"); + let source_pins = effective_source_pins(manifest, outputs)?; + let mut manifest_json = serde_json::json!({ + "architectureIndependent": true, + "archiveSha256": sha256_file(&archive)?, + "catalogVersion": postgres_catalog_version(&outputs.source_dir)?, + "generatedBy": "wasix-initdb", + "initProfile": template_runner::default_initdb_profile(), + "initdbSha256": sha256_file(outputs.module_path("tool:initdb")?)?, + "postgresVersion": postgres_major_version(&outputs.postgres_version), + "sourceLane": outputs.source_lane.as_str(), + "sourcePinsSha256": source_pins_sha256(&source_pins)?, + "wasmerVersion": manifest.toolchain.wasmer, + "wasmSha256": sha256_file(outputs.module_path("runtime:oliphaunt")?)?, + }); + if let Some(source_fingerprint) = &outputs.source_fingerprint { + manifest_json["sourceFingerprint"] = serde_json::json!(source_fingerprint); + } + fs::write( + &manifest_path, + format!("{}\n", serde_json::to_string_pretty(&manifest_json)?), + ) + .with_context(|| format!("write {}", manifest_path.display()))?; + fs::remove_dir_all(&work_root).with_context(|| format!("remove {}", work_root.display()))?; + Ok(()) +} + +fn build_promoted_extension_artifacts( + source: &Path, + build: &Path, + stage: &Path, + assets_dir: &Path, + outputs: &BuildOutputs, +) -> Result> { + if skip_extensions_for_perf_probe() { + return Ok(Vec::new()); + } + + let mut packages = Vec::new(); + for extension in extension_catalog::promoted_build_specs()? { + let extension_stage = stage.join("extensions").join(&extension.sql_name); + stage_promoted_extension(source, build, &extension, &extension_stage)?; + let archive_path = assets_dir.join(&extension.archive); + deterministic_tar_zst(&extension_stage, Path::new(""), &archive_path)?; + let native_modules = extension_native_module_artifacts(&extension, outputs)?; + packages.push(OwnedExtensionArtifact { + name: extension.display_name, + sql_name: extension.sql_name.clone(), + archive: extension.archive.clone(), + path: archive_path, + module_path: if extension.module_file.is_some() { + Some( + outputs + .module_path(&format!("extension:{}", extension.sql_name))? + .to_path_buf(), + ) + } else { + None + }, + native_module: extension.module_file.clone(), + native_modules, + stable: extension.stable, + }); + } + Ok(packages) +} + +fn extension_native_module_artifacts( + extension: &extension_catalog::PromotedExtensionBuildSpec, + outputs: &BuildOutputs, +) -> Result> { + let mut modules = Vec::new(); + for support_module in &extension.native_support_modules { + modules.push(OwnedExtensionNativeModule { + name: support_module.name.clone(), + runtime_path: support_module.runtime_path.clone(), + path: outputs + .module_path(&format!( + "extension:{}:{}", + extension.sql_name, support_module.name + ))? + .to_path_buf(), + }); + } + if let Some(module_file) = &extension.module_file { + modules.push(OwnedExtensionNativeModule { + name: extension.sql_name.clone(), + runtime_path: format!("lib/postgresql/{module_file}"), + path: outputs + .module_path(&format!("extension:{}", extension.sql_name))? + .to_path_buf(), + }); + } + Ok(modules) +} + +fn stage_promoted_extension( + source: &Path, + build: &Path, + extension: &extension_catalog::PromotedExtensionBuildSpec, + stage: &Path, +) -> Result<()> { + match extension.build_kind.as_str() { + "postgres-contrib" => stage_contrib_extension(source, build, extension, stage), + kind if extension_catalog::is_pgxs_style_build_kind(kind) => { + stage_pgxs_style_extension(build, extension, stage) + } + kind if extension_catalog::is_recipe_staged_build_kind(kind) => { + stage_recipe_staged_extension(build, extension, stage) + } + other => bail!( + "promoted extension {} has unsupported packaging build kind {other}", + extension.sql_name + ), + } +} + +fn stage_recipe_staged_extension( + build: &Path, + extension: &extension_catalog::PromotedExtensionBuildSpec, + stage: &Path, +) -> Result<()> { + let staging = extension + .staging + .as_ref() + .ok_or_else(|| anyhow!("extension {} has no staging metadata", extension.id))?; + let extension_sql_dir = stage.join("share/postgresql/extension"); + let module_dir = stage.join("lib/postgresql"); + fs::create_dir_all(&extension_sql_dir) + .with_context(|| format!("create {}", extension_sql_dir.display()))?; + fs::create_dir_all(&module_dir).with_context(|| format!("create {}", module_dir.display()))?; + + let module_file = extension + .module_file + .as_deref() + .ok_or_else(|| anyhow!("extension {} has no native module file", extension.id))?; + let module_source_dir = staging.module_source_dir.as_deref().ok_or_else(|| { + anyhow!( + "extension {} staging metadata has no module_source_dir", + extension.id + ) + })?; + copy_file( + &build.join(module_source_dir).join(module_file), + &module_dir.join(module_file), + )?; + for support_module in &extension.native_support_modules { + let source = build.join(&support_module.build_path); + ensure!( + source.is_file(), + "extension {} build did not produce support module {}", + extension.id, + source.display() + ); + copy_file(&source, &stage.join(&support_module.runtime_path))?; + } + let control_source = staging.control_source.as_deref().ok_or_else(|| { + anyhow!( + "extension {} staging metadata has no control_source", + extension.id + ) + })?; + let control_source = build.join(control_source); + let control_file_name = control_source.file_name().ok_or_else(|| { + anyhow!( + "control source has no file name: {}", + control_source.display() + ) + })?; + copy_file(&control_source, &extension_sql_dir.join(control_file_name))?; + + let sql_source_dir = staging.sql_source_dir.as_deref().ok_or_else(|| { + anyhow!( + "extension {} staging metadata has no sql_source_dir", + extension.id + ) + })?; + let sql_source_dir = build.join(sql_source_dir); + let copied_sql = copy_extension_sql_dir(&sql_source_dir, &extension_sql_dir)?; + ensure!( + copied_sql, + "extension {} build did not produce extension SQL files under {}", + extension.id, + sql_source_dir.display() + ); + for excluded in &extension.excluded_sql_extensions { + let excluded_control = format!("{excluded}.control"); + ensure!( + !extension_sql_dir.join(&excluded_control).exists(), + "extension {} archive must not include excluded extension control file {excluded_control}", + extension.id + ); + } + for data_dir in &staging.data_dirs { + let source = build.join(&data_dir.source); + ensure!( + source.is_dir(), + "extension {} staging data directory is missing: {}", + extension.id, + source.display() + ); + copy_dir_all(&source, &stage.join(&data_dir.destination))?; + } + Ok(()) +} + +fn stage_pgxs_style_extension( + build: &Path, + extension: &extension_catalog::PromotedExtensionBuildSpec, + stage: &Path, +) -> Result<()> { + let source = Path::new(&extension.source_dir); + let build_dir = pgxs_extension_build_dir(build, extension); + let sql_name = extension.sql_name.as_str(); + let extension_sql_dir = stage.join("share/postgresql/extension"); + fs::create_dir_all(stage.join("share/postgresql/extension")) + .with_context(|| format!("create {}", extension_sql_dir.display()))?; + if let Some(module_file) = &extension.module_file { + fs::create_dir_all(stage.join("lib/postgresql")) + .with_context(|| format!("create {}", stage.join("lib/postgresql").display()))?; + copy_file( + &build_dir.join(module_file), + &stage.join("lib/postgresql").join(module_file), + )?; + } + if extension.lifecycle.create_extension || extension.control_file.is_some() { + let control_file = extension + .control_file + .as_deref() + .map(Path::new) + .filter(|path| path.is_file()) + .map(Path::to_path_buf) + .unwrap_or_else(|| source.join(format!("{sql_name}.control"))); + copy_file( + &control_file, + &stage + .join("share/postgresql/extension") + .join(control_file.file_name().unwrap_or_default()), + )?; + } + let mut copied_root_sql = copy_extension_sql_files(&build_dir, sql_name, &extension_sql_dir)?; + if !copied_root_sql { + copied_root_sql = copy_extension_sql_files(source, sql_name, &extension_sql_dir)?; + } + if !copied_root_sql { + let copied_build_sql_dir = + copy_extension_sql_dir(&build_dir.join("sql"), &extension_sql_dir)?; + if !copied_build_sql_dir { + copy_extension_sql_dir(&source.join("sql"), &extension_sql_dir)?; + } + } + if extension.id == "age" { + let age_sql = extension_sql_dir.join("age--1.7.0.sql"); + let age_sql_text = + fs::read_to_string(&age_sql).with_context(|| format!("read {}", age_sql.display()))?; + ensure!( + age_sql_text.contains("CREATE TYPE graphid"), + "{} must contain AGE graphid type definition", + age_sql.display() + ); + ensure!( + !age_sql_text + .lines() + .any(|line| line.trim() == "PASSEDBYVALUE,"), + "{} still declares graphid PASSEDBYVALUE for wasm32/WASIX; rebuild AGE with SIZEOF_DATUM=4", + age_sql.display() + ); + } + Ok(()) +} + +fn copy_extension_sql_files(source: &Path, sql_name: &str, destination: &Path) -> Result { + if !source.is_dir() { + return Ok(false); + } + let mut copied = false; + for entry in sorted_children(source)? { + if !entry.is_file() { + continue; + } + let Some(name) = entry.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if (name.starts_with(&format!("{sql_name}--")) || name == format!("{sql_name}.sql")) + && name.ends_with(".sql") + { + copy_file(&entry, &destination.join(name))?; + copied = true; + } + } + Ok(copied) +} + +fn copy_extension_sql_dir(source: &Path, destination: &Path) -> Result { + if !source.is_dir() { + return Ok(false); + } + let mut copied = false; + for entry in sorted_files(source)? { + if entry.extension().and_then(|ext| ext.to_str()) != Some("sql") { + continue; + } + let file_name = entry + .file_name() + .ok_or_else(|| anyhow!("SQL file has no name: {}", entry.display()))?; + copy_file(&entry, &destination.join(file_name))?; + copied = true; + } + Ok(copied) +} + +fn stage_contrib_extension( + source: &Path, + build: &Path, + extension: &extension_catalog::PromotedExtensionBuildSpec, + stage: &Path, +) -> Result<()> { + let contrib_dir = extension + .contrib_dir + .as_deref() + .ok_or_else(|| anyhow!("contrib extension {} has no contrib_dir", extension.id))?; + let extension_source = source.join("contrib").join(contrib_dir); + fs::create_dir_all(stage.join("share/postgresql/extension")).with_context(|| { + format!( + "create {}", + stage.join("share/postgresql/extension").display() + ) + })?; + if let Some(module_file) = &extension.module_file { + fs::create_dir_all(stage.join("lib/postgresql")) + .with_context(|| format!("create {}", stage.join("lib/postgresql").display()))?; + copy_file( + &build.join("contrib").join(contrib_dir).join(module_file), + &stage.join("lib/postgresql").join(module_file), + )?; + } + if extension.lifecycle.create_extension || extension.control_file.is_some() { + let control_file = extension_source.join(format!("{}.control", extension.sql_name)); + copy_file( + &control_file, + &stage + .join("share/postgresql/extension") + .join(control_file.file_name().unwrap_or_default()), + )?; + } + for entry in sorted_children(&extension_source)? { + if !entry.is_file() { + continue; + } + let Some(name) = entry.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if (name.starts_with(&format!("{}--", extension.sql_name)) + || name == format!("{}.sql", extension.sql_name)) + && name.ends_with(".sql") + { + copy_file(&entry, &stage.join("share/postgresql/extension").join(name))?; + } else if name.ends_with(".rules") { + let tsearch_data = stage.join("share/postgresql/tsearch_data"); + fs::create_dir_all(&tsearch_data) + .with_context(|| format!("create {}", tsearch_data.display()))?; + copy_file(&entry, &tsearch_data.join(name))?; + } + } + Ok(()) +} + +fn stage_runtime_tree(build: &Path, source: &Path, runtime: &Path) -> Result<()> { + let bin = runtime.join("bin"); + let lib = runtime.join("lib/postgresql"); + let share = runtime.join("share/postgresql"); + fs::create_dir_all(&bin).with_context(|| format!("create {}", bin.display()))?; + fs::create_dir_all(&lib).with_context(|| format!("create {}", lib.display()))?; + fs::create_dir_all(&share).with_context(|| format!("create {}", share.display()))?; + + copy_file(&build.join("src/backend/oliphaunt"), &bin.join("oliphaunt"))?; + copy_file(&build.join("src/backend/oliphaunt"), &bin.join("postgres"))?; + if !skip_extensions_for_perf_probe() { + copy_file(&build.join("src/bin/pg_dump/pg_dump"), &bin.join("pg_dump"))?; + } + copy_file(&build.join("src/bin/initdb/initdb"), &bin.join("initdb"))?; + fs::write(runtime.join("password"), b"password\n") + .with_context(|| format!("write {}", runtime.join("password").display()))?; + + copy_file( + &build.join("src/include/catalog/postgres.bki"), + &share.join("postgres.bki"), + )?; + copy_file( + &build.join("src/include/catalog/system_constraints.sql"), + &share.join("system_constraints.sql"), + )?; + for relative in [ + "src/backend/catalog/system_functions.sql", + "src/backend/catalog/system_views.sql", + "src/backend/catalog/information_schema.sql", + "src/backend/catalog/sql_features.txt", + "src/backend/libpq/pg_hba.conf.sample", + "src/backend/libpq/pg_ident.conf.sample", + "src/backend/utils/misc/postgresql.conf.sample", + ] { + let source_path = source.join(relative); + let file_name = source_path + .file_name() + .ok_or_else(|| anyhow!("source file has no name: {}", source_path.display()))?; + copy_file(&source_path, &share.join(file_name))?; + } + + copy_file( + &build.join("src/backend/snowball/snowball_create.sql"), + &share.join("snowball_create.sql"), + )?; + copy_file( + &build.join("src/backend/snowball/dict_snowball.so"), + &lib.join("dict_snowball.so"), + )?; + copy_file( + &build.join("src/pl/plpgsql/src/plpgsql.so"), + &lib.join("plpgsql.so"), + )?; + + let extension_dir = share.join("extension"); + fs::create_dir_all(&extension_dir) + .with_context(|| format!("create {}", extension_dir.display()))?; + for relative in [ + "src/pl/plpgsql/src/plpgsql.control", + "src/pl/plpgsql/src/plpgsql--1.0.sql", + ] { + let source_path = source.join(relative); + let file_name = source_path + .file_name() + .ok_or_else(|| anyhow!("source file has no name: {}", source_path.display()))?; + copy_file(&source_path, &extension_dir.join(file_name))?; + } + + copy_tree_filtered( + &source.join("src/backend/tsearch/dicts"), + &share.join("tsearch_data"), + None, + )?; + copy_tree_filtered( + &source.join("src/timezone/tznames"), + &share.join("timezonesets"), + Some(&["Makefile", "meson.build", "README"]), + )?; + stage_timezone_database(source, build, &share)?; + Ok(()) +} + +fn stage_timezone_database(source: &Path, build: &Path, share: &Path) -> Result<()> { + let tzdata = source.join("src/timezone/data/tzdata.zi"); + ensure_file(&tzdata)?; + let compiled_timezone_dir = build.join("src/timezone/compiled"); + + let timezone_dir = share.join("timezone"); + if timezone_dir.exists() { + fs::remove_dir_all(&timezone_dir) + .with_context(|| format!("remove {}", timezone_dir.display()))?; + } + fs::create_dir_all(&timezone_dir) + .with_context(|| format!("create {}", timezone_dir.display()))?; + copy_tree_filtered(&compiled_timezone_dir, &timezone_dir, None).with_context(|| { + format!( + "copy compiled PostgreSQL timezone database from {}", + compiled_timezone_dir.display() + ) + })?; + + for required in ["UTC", "GMT", "Etc/UTC", "America/New_York"] { + let path = timezone_dir.join(required); + if !path.is_file() { + bail!( + "compiled PostgreSQL timezone database is missing required zone {}", + path.display() + ); + } + } + Ok(()) +} + +fn package_aot_artifacts( + target: &str, + outputs: &BuildOutputs, + sources: &SourcesManifest, +) -> Result<()> { + let source_dir = generated_aot_source_dir_for_source_lane(target, &outputs.source_lane)?; + if !source_dir.exists() { + let source_lane_arg = if outputs.source_lane == DEFAULT_SOURCE_LANE { + String::new() + } else { + format!(" --source-lane {}", outputs.source_lane) + }; + bail!( + "AOT source directory {} is missing; run `cargo run -p xtask -- assets aot --target-triple {target}{source_lane_arg}` before packaging", + source_dir.display() + ); + } + + let artifacts_dir = generated_aot_dir_for_source_lane(target, &outputs.source_lane)?; + if artifacts_dir.exists() { + fs::remove_dir_all(&artifacts_dir) + .with_context(|| format!("remove {}", artifacts_dir.display()))?; + } + fs::create_dir_all(&artifacts_dir) + .with_context(|| format!("create {}", artifacts_dir.display()))?; + + let mut manifest_artifacts = Vec::new(); + for module in outputs.modules.iter().filter(|module| module.requires_aot) { + let name = module.name.as_str(); + let file = module.aot_file.as_str(); + let source = source_dir.join(file); + if !source.exists() { + bail!( + "missing AOT artifact {}; run AOT generation for target {target} before packaging", + source.display() + ); + } + let destination = artifacts_dir.join(file); + copy_file(&source, &destination)?; + let raw_artifact = decode_zstd_file(&destination) + .with_context(|| format!("decode AOT artifact {}", destination.display()))?; + let module_sha256 = outputs + .modules + .iter() + .find(|module| module.name == name) + .map(|module| sha256_file(&module.path)) + .transpose()? + .ok_or_else(|| anyhow!("missing build output module {name} for AOT manifest"))?; + manifest_artifacts.push(AotManifestArtifact { + name: name.to_owned(), + path: file.to_owned(), + sha256: sha256_file(&destination)?, + raw_sha256: sha256_bytes(&raw_artifact), + raw_size: raw_artifact.len() as u64, + module_sha256, + compressed: true, + }); + } + ensure!( + !manifest_artifacts.is_empty(), + "AOT packaging produced an empty manifest for {target}" + ); + + let manifest = AotManifest { + format_version: 1, + source_lane: Some(outputs.source_lane.clone()), + source_fingerprint: outputs.source_fingerprint.clone(), + postgres_version: Some(outputs.postgres_version.clone()), + target_triple: target.to_owned(), + engine: "llvm-opta".to_owned(), + wasmer_version: sources.toolchain.wasmer.clone(), + wasmer_wasix_version: sources.toolchain.wasmer_wasix.clone(), + artifacts: manifest_artifacts, + }; + let manifest_json = + serde_json::to_string_pretty(&manifest).context("serialize AOT manifest")?; + fs::write( + artifacts_dir.join("manifest.json"), + format!("{manifest_json}\n"), + ) + .with_context(|| format!("write {}", artifacts_dir.join("manifest.json").display()))?; + Ok(()) +} + +pub(crate) fn check_aot_package_manifest(target: &str, source_lane: &str) -> Result<()> { + let outputs = BuildOutputs::discover_for_aot(source_lane)?; + let artifacts_dir = find_aot_artifact_dir_for_source_lane(target, &outputs.source_lane)?; + let manifest_path = artifacts_dir.join("manifest.json"); + ensure_file(&manifest_path)?; + let text = fs::read_to_string(&manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest: AotManifest = serde_json::from_str(&text) + .with_context(|| format!("parse {}", manifest_path.display()))?; + let actual_lane = manifest.source_lane.as_deref().unwrap_or(""); + ensure_eq( + actual_lane, + outputs.source_lane.as_str(), + "AOT manifest source-lane", + )?; + if let Some(source_fingerprint) = outputs.source_fingerprint.as_deref() { + ensure_eq( + manifest + .source_fingerprint + .as_deref() + .unwrap_or(""), + source_fingerprint, + "AOT manifest source-fingerprint", + )?; + } + if let Some(postgres_version) = manifest.postgres_version.as_deref() { + ensure_eq( + postgres_version, + outputs.postgres_version.as_str(), + "AOT manifest postgres-version", + )?; + } + ensure_eq( + &manifest.target_triple, + target, + "AOT manifest target-triple", + )?; + ensure_eq(&manifest.engine, "llvm-opta", "AOT manifest engine")?; + ensure_eq( + &manifest.wasmer_version, + "7.2.0-alpha.3", + "AOT manifest wasmer-version", + )?; + ensure_eq( + &manifest.wasmer_wasix_version, + "0.702.0-alpha.3", + "AOT manifest wasmer-wasix-version", + )?; + ensure!( + !manifest.artifacts.is_empty(), + "AOT manifest {} contains no artifacts", + manifest_path.display() + ); + + for artifact in &manifest.artifacts { + let path = artifacts_dir.join(&artifact.path); + ensure_file(&path)?; + let actual_hash = sha256_file(&path)?; + ensure_eq( + &actual_hash, + &artifact.sha256, + &format!("AOT artifact {} sha256", artifact.name), + )?; + if artifact.compressed { + let raw = decode_zstd_file(&path) + .with_context(|| format!("decode AOT artifact {}", path.display()))?; + ensure_eq( + &sha256_bytes(&raw), + &artifact.raw_sha256, + &format!("AOT artifact {} raw sha256", artifact.name), + )?; + let actual_raw_size = raw.len() as u64; + if actual_raw_size != artifact.raw_size { + bail!( + "AOT artifact {} raw size mismatch: expected {} got {}", + artifact.name, + artifact.raw_size, + actual_raw_size + ); + } + } + let module = outputs + .modules + .iter() + .find(|module| module.name == artifact.name) + .ok_or_else(|| anyhow!("AOT manifest references unknown module {}", artifact.name))?; + ensure!( + module.requires_aot, + "AOT manifest references non-release-AOT module {}", + artifact.name + ); + let module_hash = sha256_file(&module.path)?; + ensure_eq( + &module_hash, + &artifact.module_sha256, + &format!("AOT artifact {} source module sha256", artifact.name), + )?; + } + let expected = outputs + .modules + .iter() + .filter(|module| module.requires_aot) + .map(|module| module.name.as_str()) + .collect::>(); + let actual = manifest + .artifacts + .iter() + .map(|artifact| artifact.name.as_str()) + .collect::>(); + ensure!( + actual == expected, + "AOT manifest module set mismatch: expected {expected:?} got {actual:?}" + ); + Ok(()) +} + +pub(crate) fn generated_aot_dir(target: &str) -> PathBuf { + Path::new(GENERATED_AOT_DIR).join(target) +} + +fn crate_aot_artifact_dir(target: &str) -> PathBuf { + Path::new("src/runtimes/liboliphaunt/wasix/crates/aot") + .join(target) + .join("artifacts") +} + +pub(crate) fn find_aot_artifact_dir(target: &str) -> Result { + find_aot_artifact_dir_for_source_lane(target, DEFAULT_SOURCE_LANE) +} + +fn find_aot_artifact_dir_for_source_lane(target: &str, source_lane: &str) -> Result { + let generated = generated_aot_dir_for_source_lane(target, source_lane)?; + if generated.join("manifest.json").is_file() { + return Ok(generated); + } + let crate_dir = crate_aot_artifact_dir(target); + if crate_dir.join("manifest.json").is_file() { + return Ok(crate_dir); + } + bail!( + "missing AOT artifacts for {target}; expected {} or {}", + generated.display(), + crate_dir.display() + ) +} + +fn write_asset_manifest( + sources: &SourcesManifest, + outputs: &BuildOutputs, + assets_dir: &Path, + runtime_module: &Path, + runtime_archive: &Path, + pg_dump: Option<&Path>, + initdb: &Path, + runtime_support: &[BinaryPackage<'_>], + extensions: &[ExtensionArtifact<'_>], +) -> Result<()> { + let runtime_link = read_wasm_link_metadata(runtime_module)?; + let runtime_exports = wasm_export_name_set(&runtime_link); + let extension_metadata = extension_catalog::manifest_metadata_by_sql_name()?; + let effective_sources = effective_source_pins(sources, outputs)?; + let manifest = AssetManifestOut { + format_version: 1, + source_lane: Some(outputs.source_lane.clone()), + source_fingerprint: outputs.source_fingerprint.clone(), + runtime: RuntimeAssetOut { + archive: "oliphaunt.wasix.tar.zst".to_owned(), + sha256: sha256_file(runtime_archive)?, + module_sha256: sha256_file(runtime_module)?, + postgres_version: outputs.postgres_version.clone(), + runtime_kind: "wasix-dynamic-main".to_owned(), + link: runtime_link.clone(), + }, + runtime_support: runtime_support + .iter() + .map(|module| { + Ok::<_, anyhow::Error>(BinaryAssetOut { + name: module.name.to_owned(), + path: module.runtime_path.to_owned(), + sha256: sha256_file(module.path)?, + module_sha256: sha256_file(module.path)?, + size: fs::metadata(module.path) + .with_context(|| format!("metadata {}", module.path.display()))? + .len(), + link: read_wasm_link_metadata(module.path)?, + }) + }) + .collect::>>()?, + pg_dump: pg_dump + .map(|pg_dump| { + Ok::<_, anyhow::Error>(BinaryAssetOut { + name: "pg_dump".to_owned(), + path: "bin/pg_dump.wasix.wasm".to_owned(), + sha256: sha256_file(pg_dump)?, + module_sha256: sha256_file(pg_dump)?, + size: fs::metadata(pg_dump) + .with_context(|| format!("metadata {}", pg_dump.display()))? + .len(), + link: read_wasm_link_metadata(pg_dump)?, + }) + }) + .transpose()?, + initdb: Some(BinaryAssetOut { + name: "initdb".to_owned(), + path: "bin/initdb.wasix.wasm".to_owned(), + sha256: sha256_file(initdb)?, + module_sha256: sha256_file(initdb)?, + size: fs::metadata(initdb) + .with_context(|| format!("metadata {}", initdb.display()))? + .len(), + link: read_wasm_link_metadata(initdb)?, + }), + pgdata_template: Some(pgdata_template_asset_out( + sources, + outputs, + runtime_module, + initdb, + &assets_dir.join("prepopulated/pgdata-template.tar.zst"), + &assets_dir.join("prepopulated/pgdata-template.json"), + )?), + extensions: extensions + .iter() + .map(|extension| { + let link = extension + .module_path + .map(read_wasm_link_metadata) + .transpose()?; + let native_module_links = extension + .native_modules + .iter() + .map(|module| { + Ok::<_, anyhow::Error>(( + module.name.clone(), + read_wasm_link_metadata(&module.path)?, + )) + }) + .collect::>>()?; + let metadata = extension_metadata.get(extension.sql_name).ok_or_else(|| { + anyhow!( + "extension {} is missing from generated extension catalog", + extension.sql_name + ) + })?; + let mut core_exports_required = Vec::new(); + let mut unresolved_imports = Vec::new(); + if let Some(link) = &link { + let module_exports = extension_asset_provider_exports( + link, + extension.sql_name, + &native_module_links, + ); + for import in &link.imports { + if !import_should_resolve_from_runtime(import) { + continue; + } + if import_resolves_from_linked_module_exports(import, &module_exports) { + continue; + } + let normalized = import.name.trim_start_matches('_'); + if runtime_exports.contains(import.name.as_str()) { + core_exports_required.push(import.name.clone()); + } else if runtime_exports.contains(normalized) { + core_exports_required.push(normalized.to_owned()); + } else { + unresolved_imports.push(import.clone()); + } + } + } + core_exports_required.sort(); + core_exports_required.dedup(); + let installed_files = archive_file_list(extension.path)?; + let control_files = extension_control_files_for_asset_manifest( + outputs.source_lane.as_str(), + &metadata, + &installed_files, + extension.sql_name, + )?; + let promoted = extension.stable + && metadata.smoke_status.direct == "passed" + && metadata.smoke_status.server == "passed" + && metadata.smoke_status.restart == "passed" + && metadata.smoke_status.dump_restore == "passed"; + ensure!( + !metadata.smoke_status.promoted || promoted, + "extension {} catalog metadata marks the asset promoted but current package evidence is insufficient", + extension.sql_name + ); + let native_modules = extension + .native_modules + .iter() + .map(|module| { + let link = native_module_links.get(&module.name).cloned().ok_or_else( + || anyhow!("missing link metadata for {}", module.name), + )?; + Ok::<_, anyhow::Error>(BinaryAssetOut { + name: module.name.clone(), + path: module.runtime_path.clone(), + sha256: sha256_file(&module.path)?, + module_sha256: sha256_file(&module.path)?, + size: fs::metadata(&module.path) + .with_context(|| { + format!("metadata {}", module.path.display()) + })? + .len(), + link, + }) + }) + .collect::>>()?; + Ok(ExtensionAssetOut { + name: extension.name.to_owned(), + sql_name: extension.sql_name.to_owned(), + source_kind: metadata.source_kind.clone(), + archive: extension.archive.to_owned(), + sha256: sha256_file(extension.path)?, + module_sha256: extension + .module_path + .map(sha256_file) + .transpose()? + .unwrap_or_default(), + native_module: extension.native_module.map(str::to_owned), + native_modules, + size: fs::metadata(extension.path) + .with_context(|| format!("metadata {}", extension.path.display()))? + .len(), + stable: extension.stable, + control_files, + dependencies: metadata.dependencies.clone(), + native_dependencies: metadata.native_dependencies.clone(), + load_order: metadata.load_order.clone(), + lifecycle: ExtensionLifecycleOut { + create_extension: metadata.lifecycle.create_extension, + create_schema: metadata.lifecycle.create_schema.clone(), + load_sql: metadata.lifecycle.load_sql.clone(), + post_create_sql: metadata.lifecycle.post_create_sql.clone(), + startup_config: metadata.lifecycle.startup_config.clone(), + preload_required: metadata.lifecycle.preload_required, + restart_required: metadata.lifecycle.restart_required, + shared_memory_required: metadata.lifecycle.shared_memory_required, + }, + extension_imports: link + .as_ref() + .map(|link| link.imports.clone()) + .unwrap_or_default(), + core_exports_required, + unresolved_imports, + installed_files, + smoke_status: ExtensionSmokeStatusOut { + promoted, + direct: metadata.smoke_status.direct.clone(), + server: metadata.smoke_status.server.clone(), + restart: metadata.smoke_status.restart.clone(), + dump_restore: metadata.smoke_status.dump_restore.clone(), + }, + link, + }) + }) + .collect::>>()?, + sources: effective_sources, + }; + + let text = serde_json::to_string_pretty(&manifest).context("serialize asset manifest")?; + let manifest_path = assets_dir.join("manifest.json"); + fs::write(&manifest_path, format!("{text}\n")) + .with_context(|| format!("write {}", manifest_path.display()))?; + Ok(()) +} + +fn extension_control_files_for_asset_manifest( + source_lane: &str, + metadata: &extension_catalog::ManifestExtensionMetadata, + installed_files: &[String], + sql_name: &str, +) -> Result> { + ensure_eq( + canonical_source_lane(source_lane)?, + DEFAULT_SOURCE_LANE, + "extension manifest source lane", + )?; + + let mut control_files = installed_files + .iter() + .filter(|path| { + path.starts_with("share/postgresql/extension/") && path.ends_with(".control") + }) + .cloned() + .collect::>(); + control_files.sort(); + control_files.dedup(); + if metadata.lifecycle.create_extension || !metadata.control_files.is_empty() { + ensure!( + !control_files.is_empty(), + "PG18 extension {sql_name} manifest control-files must come from packaged extension archive contents" + ); + } + ensure!( + control_files + .iter() + .all(|path| !path.contains("removed-fork")), + "PG18 extension {sql_name} manifest control-files must not reference removed source paths" + ); + Ok(control_files) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest_extension_metadata( + create_extension: bool, + control_files: Vec<&str>, + ) -> extension_catalog::ManifestExtensionMetadata { + extension_catalog::ManifestExtensionMetadata { + source_kind: "postgres-contrib".to_owned(), + control_files: control_files.into_iter().map(str::to_owned).collect(), + dependencies: Vec::new(), + native_dependencies: Vec::new(), + load_order: Vec::new(), + lifecycle: extension_catalog::ManifestExtensionLifecycle { + create_extension, + create_schema: None, + load_sql: Vec::new(), + post_create_sql: Vec::new(), + startup_config: Vec::new(), + preload_required: false, + restart_required: false, + shared_memory_required: false, + }, + smoke_status: extension_catalog::ManifestExtensionSmokeStatus { + promoted: true, + direct: "passed".to_owned(), + server: "passed".to_owned(), + restart: "passed".to_owned(), + dump_restore: "not-run".to_owned(), + }, + } + } + + #[test] + fn pg18_lane_uses_packaged_control_files() { + let metadata = manifest_extension_metadata( + true, + vec!["target/oliphaunt-sources/checkouts/removed-fork/contrib/pg_trgm/pg_trgm.control"], + ); + let installed_files = vec![ + "share/postgresql/extension/pg_trgm.control".to_owned(), + "share/postgresql/extension/pg_trgm--1.6.sql".to_owned(), + "share/postgresql/extension/pg_trgm.control".to_owned(), + ]; + + let control_files = extension_control_files_for_asset_manifest( + "stable", + &metadata, + &installed_files, + "pg_trgm", + ) + .expect("PG18 packaged control files"); + + assert_eq!( + control_files, + vec!["share/postgresql/extension/pg_trgm.control"] + ); + } + + #[test] + fn legacy_pg17_lane_is_not_selectable_for_control_files() { + let metadata = manifest_extension_metadata( + true, + vec!["target/oliphaunt-sources/checkouts/removed-fork/contrib/pg_trgm/pg_trgm.control"], + ); + let installed_files = vec!["share/postgresql/extension/pg_trgm.control".to_owned()]; + + let error = extension_control_files_for_asset_manifest( + "pg17", + &metadata, + &installed_files, + "pg_trgm", + ) + .expect_err("PG17 lane must no longer be selectable"); + + assert!( + error + .to_string() + .contains("unsupported WASIX asset source lane") + ); + } + + #[test] + fn pg18_lane_requires_packaged_control_files_for_create_extension() { + let metadata = manifest_extension_metadata( + true, + vec!["target/oliphaunt-sources/checkouts/removed-fork/contrib/pg_trgm/pg_trgm.control"], + ); + let error = extension_control_files_for_asset_manifest("stable", &metadata, &[], "pg_trgm") + .expect_err("PG18 missing packaged control file should fail"); + + assert!( + error + .to_string() + .contains("must come from packaged extension archive contents") + ); + } + + #[test] + fn pg18_lane_rejects_released_control_paths() { + let metadata = manifest_extension_metadata(true, Vec::new()); + let installed_files = + vec!["share/postgresql/extension/removed-fork-leak.control".to_owned()]; + let error = extension_control_files_for_asset_manifest( + "stable", + &metadata, + &installed_files, + "pg_trgm", + ) + .expect_err("PG18 released path leak should fail"); + + assert!( + error + .to_string() + .contains("must not reference removed source paths") + ); + } + + fn temp_aot_manifest_path(label: &str) -> PathBuf { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_nanos(); + std::env::temp_dir().join(format!( + "oliphaunt-xtask-aot-manifest-{}-{now}-{label}.json", + std::process::id() + )) + } + + fn write_downloaded_aot_manifest( + path: &Path, + source_lane: Option<&str>, + source_fingerprint: Option<&str>, + postgres_version: Option<&str>, + ) { + let manifest = AotManifest { + format_version: 1, + source_lane: source_lane.map(str::to_owned), + source_fingerprint: source_fingerprint.map(str::to_owned), + postgres_version: postgres_version.map(str::to_owned), + target_triple: "aarch64-apple-darwin".to_owned(), + engine: "llvm-opta".to_owned(), + wasmer_version: "7.2.0-alpha.3".to_owned(), + wasmer_wasix_version: "0.702.0-alpha.3".to_owned(), + artifacts: vec![AotManifestArtifact { + name: "runtime:oliphaunt".to_owned(), + path: "oliphaunt.aot.zst".to_owned(), + sha256: "archive".to_owned(), + raw_sha256: "raw".to_owned(), + raw_size: 1, + module_sha256: "module".to_owned(), + compressed: true, + }], + }; + fs::write( + path, + serde_json::to_string(&manifest).expect("serialize AOT manifest"), + ) + .expect("write AOT manifest"); + } + + #[test] + fn downloaded_stable_pg18_aot_manifest_is_validated_before_install() { + let path = temp_aot_manifest_path("pg18-ok"); + let fingerprint = expected_postgres_source_fingerprint().expect("PG18 fingerprint"); + write_downloaded_aot_manifest( + &path, + Some("stable"), + Some(&fingerprint), + Some("18.4-wasix-oliphaunt"), + ); + + ensure_aot_manifest_matches_source_lane(&path, "aarch64-apple-darwin", DEFAULT_SOURCE_LANE) + .expect("stable downloaded AOT manifest"); + + let _ = fs::remove_file(path); + } + + #[test] + fn downloaded_stable_pg18_aot_manifest_requires_source_fingerprint() { + let path = temp_aot_manifest_path("pg18-missing-fingerprint"); + write_downloaded_aot_manifest(&path, Some("stable"), None, Some("18.4-wasix-oliphaunt")); + + let error = ensure_aot_manifest_matches_source_lane( + &path, + "aarch64-apple-darwin", + DEFAULT_SOURCE_LANE, + ) + .expect_err("PG18 downloaded AOT manifest should require source fingerprint"); + + assert!( + error + .to_string() + .contains("PG18 AOT manifest source-fingerprint") + ); + let _ = fs::remove_file(path); + } + + fn wasm_import(module: &str, name: &str, kind: &str) -> WasmImportOut { + WasmImportOut { + module: module.to_owned(), + name: name.to_owned(), + kind: kind.to_owned(), + } + } + + #[test] + fn wasix_linker_provided_imports_do_not_require_runtime_exports() { + for import in [ + wasm_import("env", "memory", "memory"), + wasm_import("env", "__indirect_function_table", "table"), + wasm_import("env", "__stack_pointer", "global"), + wasm_import("env", "__c_longjmp", "tag"), + wasm_import("env", "__cpp_exception", "tag"), + wasm_import("env", "__memory_base", "global"), + wasm_import("env", "__table_base", "global"), + wasm_import("GOT.mem", "__heap_base", "global"), + wasm_import("GOT.mem", "__stack_high", "global"), + wasm_import("GOT.mem", "__stack_low", "global"), + ] { + assert!( + !import_should_resolve_from_runtime(&import), + "{import:?} should be provided by the WASIX dynamic linker" + ); + } + } + + #[test] + fn side_module_exports_satisfy_their_own_dynamic_symbol_imports() { + let module_exports = HashSet::from([ + "GEOSArea_r".to_owned(), + "_ZN10FlatGeobuf11PackedRTree4initEt".to_owned(), + "ZN10FlatGeobuf11PackedRTree4initEt".to_owned(), + ]); + + for import in [ + wasm_import("env", "GEOSArea_r", "func"), + wasm_import("GOT.func", "_ZN10FlatGeobuf11PackedRTree4initEt", "global"), + wasm_import("GOT.func", "ZN10FlatGeobuf11PackedRTree4initEt", "global"), + ] { + assert!(import_should_resolve_from_runtime(&import)); + assert!( + import_resolves_from_linked_module_exports(&import, &module_exports), + "{import:?} should be self-resolved by the linked side module" + ); + } + } + + #[test] + fn unresolved_extension_imports_still_require_runtime_exports() { + let runtime_import = wasm_import("env", "SearchSysCache1", "func"); + let module_exports = HashSet::from(["GEOSArea_r".to_owned()]); + + assert!(import_should_resolve_from_runtime(&runtime_import)); + assert!(!import_resolves_from_linked_module_exports( + &runtime_import, + &module_exports + )); + } +} + +fn pgdata_template_asset_out( + sources: &SourcesManifest, + outputs: &BuildOutputs, + runtime_module: &Path, + initdb_module: &Path, + archive: &Path, + manifest: &Path, +) -> Result { + ensure_file(archive)?; + ensure_file(manifest)?; + let manifest_text = + fs::read_to_string(manifest).with_context(|| format!("read {}", manifest.display()))?; + let manifest_json: serde_json::Value = serde_json::from_str(&manifest_text) + .with_context(|| format!("parse {}", manifest.display()))?; + let template_source_lane = manifest_json + .get("sourceLane") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + ensure_eq( + template_source_lane, + outputs.source_lane.as_str(), + "PGDATA template manifest sourceLane", + )?; + if let Some(source_fingerprint) = outputs.source_fingerprint.as_deref() { + let template_source_fingerprint = manifest_json + .get("sourceFingerprint") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + ensure_eq( + template_source_fingerprint, + source_fingerprint, + "PGDATA template manifest sourceFingerprint", + )?; + } + let source_pins = effective_source_pins(sources, outputs)?; + Ok(PgDataTemplateAssetOut { + archive: "prepopulated/pgdata-template.tar.zst".to_owned(), + manifest: "prepopulated/pgdata-template.json".to_owned(), + sha256: sha256_file(archive)?, + size: fs::metadata(archive) + .with_context(|| format!("metadata {}", archive.display()))? + .len(), + runtime_module_sha256: sha256_file(runtime_module)?, + initdb_module_sha256: sha256_file(initdb_module)?, + source_pins_sha256: source_pins_sha256(&source_pins)?, + source_lane: Some(outputs.source_lane.clone()), + source_fingerprint: outputs.source_fingerprint.clone(), + postgres_version: postgres_major_version(&outputs.postgres_version), + catalog_version: manifest_json + .get("catalogVersion") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_owned(), + init_profile: template_runner::default_initdb_profile().to_owned(), + wasmer_version: sources.toolchain.wasmer.clone(), + }) +} + +pub(crate) fn effective_source_pins( + sources: &SourcesManifest, + outputs: &BuildOutputs, +) -> Result> { + let mut pins = sources + .sources + .iter() + .filter(|source| !is_released_source_pin_for_pg18_manifest(source)) + .cloned() + .collect::>(); + let pg18 = load_postgres_source_manifest()?; + pins.push(SourcePin { + name: "postgresql".to_owned(), + kind: SourceKind::Git, + url: pg18.postgresql.url, + branch: format!("v{}", pg18.postgresql.version), + commit: pg18.postgresql.sha256, + sha256: None, + strip_prefix: None, + origin: SourceOrigin::Generated, + }); + + let fingerprint = if let Some(fingerprint) = outputs.source_fingerprint.as_deref() { + fingerprint.to_owned() + } else { + let fingerprint_path = outputs + .source_dir + .join(".oliphaunt-wasix-source-fingerprint"); + fs::read_to_string(&fingerprint_path) + .with_context(|| format!("read {}", fingerprint_path.display()))? + .trim() + .to_owned() + }; + let patch_fingerprint = fingerprint + .trim() + .rsplit(':') + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("PG18 source fingerprint is invalid: {fingerprint:?}"))?; + pins.push(SourcePin { + name: "oliphaunt-wasix-stable-patches".to_owned(), + kind: SourceKind::Git, + url: "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches".to_owned(), + branch: "series".to_owned(), + commit: patch_fingerprint.to_owned(), + sha256: None, + strip_prefix: None, + origin: SourceOrigin::Generated, + }); + + Ok(pins) +} + +fn is_released_source_pin_for_pg18_manifest(source: &SourcePin) -> bool { + source.name.contains("removed-fork") + || source.branch.contains("removed-fork") + || source.url.contains("removed-fork") +} + +pub(crate) fn load_postgres_source_manifest() -> Result { + let shared_path = repo_relative_path(POSTGRES_SHARED_SOURCE_MANIFEST_PATH); + let product_path = repo_relative_path(POSTGRES_SOURCE_MANIFEST_PATH); + let shared_text = fs::read_to_string(&shared_path) + .with_context(|| format!("read {}", shared_path.display()))?; + let product_text = fs::read_to_string(&product_path) + .with_context(|| format!("read {}", product_path.display()))?; + let shared: PostgresSharedSourceManifest = + toml::from_str(&shared_text).with_context(|| format!("parse {}", shared_path.display()))?; + let product: PostgresProductPatchManifest = toml::from_str(&product_text) + .with_context(|| format!("parse {}", product_path.display()))?; + Ok(PostgresSourceManifest { + postgresql: shared.postgresql, + patches: product.patches, + }) +} + +pub(crate) fn repo_relative_path(path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + if path.is_absolute() || path.exists() { + return path.to_path_buf(); + } + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +} + +fn source_pins_sha256(sources: &[SourcePin]) -> Result { + let pins = serde_json::to_vec(sources).context("serialize source pins")?; + Ok(sha256_bytes(&pins)) +} + +fn postgres_catalog_version(source_dir: &Path) -> Result { + let path = source_dir.join("src/include/catalog/catversion.h"); + let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("#define CATALOG_VERSION_NO") { + let value = rest.trim(); + if !value.is_empty() { + return Ok(value.to_owned()); + } + } + } + bail!("{} does not define CATALOG_VERSION_NO", path.display()) +} + +pub(crate) fn update_staged_root_asset_metadata(workspace: &Path) -> Result<()> { + let asset_dir = workspace.join(GENERATED_ASSETS_DIR); + let manifest = read_asset_manifest_from(&asset_dir)?; + let runtime_archive = asset_dir.join(&manifest.runtime.archive); + let runtime_module = archive_entry_bytes(&runtime_archive, "oliphaunt/bin/oliphaunt")?; + update_root_asset_metadata_in( + workspace, + &asset_dir, + &manifest, + &sha256_bytes(&runtime_module), + ) +} + +fn update_root_asset_metadata_in( + workspace: &Path, + asset_dir: &Path, + manifest: &AssetManifestOut, + runtime_module_sha256: &str, +) -> Result<()> { + let path = workspace.join("src/bindings/wasix-rust/crates/oliphaunt-wasix/Cargo.toml"); + let mut text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let pg18 = load_postgres_source_manifest()?; + text = replace_metadata_value(text, "postgres-version", &manifest.runtime.postgres_version); + text = replace_metadata_value(text, "postgres-source-url", &pg18.postgresql.url); + text = replace_metadata_value(text, "postgres-source-sha256", &pg18.postgresql.sha256); + text = replace_metadata_value( + text, + "postgres-patch-count", + &pg18.patches.series.len().to_string(), + ); + text = replace_metadata_value(text, "runtime-archive-sha256", &manifest.runtime.sha256); + text = replace_metadata_value(text, "oliphaunt-wasix-sha256", runtime_module_sha256); + let pgdata_template = asset_dir.join("prepopulated/pgdata-template.tar.zst"); + if pgdata_template.exists() { + text = replace_metadata_value( + text, + "pgdata-template-archive-sha256", + &sha256_file(&pgdata_template)?, + ); + } + if let Some(pg_dump) = &manifest.pg_dump { + text = replace_metadata_value(text, "pg-dump-wasix-sha256", &pg_dump.sha256); + } + if let Some(initdb) = &manifest.initdb { + text = replace_metadata_value(text, "initdb-wasix-sha256", &initdb.sha256); + } + fs::write(&path, text).with_context(|| format!("write {}", path.display())) +} + +fn replace_metadata_value(mut text: String, key: &str, value: &str) -> String { + let needle = format!("{key} = \""); + let Some(start) = text.find(&needle) else { + eprintln!("warning: Cargo.toml metadata key '{key}' is missing; not updating it"); + return text; + }; + let value_start = start + needle.len(); + let Some(relative_end) = text[value_start..].find('"') else { + return text; + }; + text.replace_range(value_start..value_start + relative_end, value); + text +} + +fn ensure_matching_marker(expected: &str, actual_path: &Path, field: &str) -> Result<()> { + let actual = fs::read_to_string(actual_path) + .with_context(|| format!("read {}", actual_path.display()))?; + ensure_eq(actual.trim(), expected, field) +} diff --git a/xtask/src/extension_catalog.rs b/tools/xtask/src/extension_catalog.rs similarity index 56% rename from xtask/src/extension_catalog.rs rename to tools/xtask/src/extension_catalog.rs index 287d560a..82bdd8de 100644 --- a/xtask/src/extension_catalog.rs +++ b/tools/xtask/src/extension_catalog.rs @@ -1,27 +1,23 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; +use std::process::Command; use anyhow::{Context, Result, anyhow, bail, ensure}; use serde::{Deserialize, Serialize}; -const CATALOG_PATH: &str = "assets/generated/extensions.catalog.json"; -const BUILD_PLAN_PATH: &str = "assets/generated/extensions.build-plan.json"; -const CONTRIB_BUILD_PLAN_PATH: &str = "assets/generated/contrib-build.tsv"; -const PGXS_BUILD_PLAN_PATH: &str = "assets/generated/pgxs-build.tsv"; -const PROMOTION_CONFIG_PATH: &str = "assets/extensions.promoted.toml"; -const SMOKE_CONFIG_PATH: &str = "assets/extensions.smoke.toml"; -const PGLITE_REPL_EXTENSIONS: &str = "assets/checkouts/pglite/docs/repl/allExtensions.ts"; -const PGLITE_DOCS_EXTENSIONS: &str = "assets/checkouts/pglite/docs/extensions/extensions.data.ts"; -const PGLITE_PACKAGE_JSON: &str = "assets/checkouts/pglite/packages/pglite/package.json"; -const PGLITE_CONTRIB_SRC: &str = "assets/checkouts/pglite/packages/pglite/src/contrib"; -const PGLITE_TESTS: &str = "assets/checkouts/pglite/packages/pglite/tests"; -const PGLITE_POSTGIS_TESTS: &str = "assets/checkouts/pglite/packages/pglite-postgis/tests"; -const POSTGRES_CONTRIB: &str = "assets/checkouts/postgres-pglite/contrib"; -const POSTGRES_OTHER_EXTENSIONS: &str = "assets/checkouts/postgres-pglite/pglite/other_extensions"; -const PGVECTOR_CHECKOUT: &str = "assets/checkouts/pgvector"; -const EXTERNAL_EXTENSION_CHECKOUT_ROOT: &str = "assets/checkouts"; -const ASSET_MANIFEST: &str = "target/pglite-oxide/assets/manifest.json"; +const CATALOG_PATH: &str = "src/extensions/generated/extensions.catalog.json"; +const BUILD_PLAN_PATH: &str = "src/extensions/generated/extensions.build-plan.json"; +const CONTRIB_BUILD_PLAN_PATH: &str = "src/extensions/generated/contrib-build.tsv"; +const PGXS_BUILD_PLAN_PATH: &str = "src/extensions/generated/pgxs-build.tsv"; +const PROMOTION_CONFIG_PATH: &str = "src/extensions/catalog/extensions.promoted.toml"; +const SMOKE_CONFIG_PATH: &str = "src/extensions/catalog/extensions.smoke.toml"; +const POSTGRES_CONTRIB: &str = "src/postgres/versions/18/contrib"; +const POSTGRES_OTHER_EXTENSIONS: &str = "src/extensions/external"; +const EXTERNAL_EXTENSION_RECIPE_ROOT: &str = "src/extensions/external"; +const PGVECTOR_CHECKOUT: &str = "target/oliphaunt-sources/checkouts/pgvector"; +const EXTERNAL_EXTENSION_CHECKOUT_ROOT: &str = "target/oliphaunt-sources/checkouts"; +const ASSET_MANIFEST: &str = "target/oliphaunt-wasix/assets/manifest.json"; pub(crate) fn extensions(args: Vec) -> Result<()> { match args.first().map(String::as_str) { @@ -76,6 +72,9 @@ pub(crate) fn extensions(args: Vec) -> Result<()> { } pub(crate) fn check_catalog_file(strict: bool) -> Result<()> { + if !extension_discovery_inputs_available(strict)? { + return Ok(()); + } let catalog = discover_catalog()?; validate_catalog(&catalog)?; let expected = serde_json::to_string_pretty(&catalog).context("serialize extension catalog")?; @@ -110,6 +109,9 @@ pub(crate) fn check_catalog_file(strict: bool) -> Result<()> { } pub(crate) fn check_build_plan_file(strict: bool) -> Result<()> { + if !extension_discovery_inputs_available(strict)? { + return Ok(()); + } let catalog = discover_catalog()?; validate_catalog(&catalog)?; let expected = build_plan_texts(&catalog)?; @@ -149,7 +151,7 @@ pub(crate) fn check_build_plan_file(strict: bool) -> Result<()> { let matches = if path == Path::new(BUILD_PLAN_PATH) { extension_build_plan_text_matches_source_control(&actual, text)? } else { - actual.trim_end() == text.trim_end() + extension_build_plan_tsv_matches_source_control(&actual, text) }; if !matches { if strict { @@ -167,6 +169,38 @@ pub(crate) fn check_build_plan_file(strict: bool) -> Result<()> { Ok(()) } +fn extension_build_plan_tsv_matches_source_control(actual: &str, expected: &str) -> bool { + normalize_extension_build_plan_tsv(actual) == normalize_extension_build_plan_tsv(expected) +} + +fn normalize_extension_build_plan_tsv(text: &str) -> String { + text.replace("\r\n", "\n") + .replace('\r', "\n") + .trim_end() + .to_owned() +} + +fn extension_discovery_inputs_available(strict: bool) -> Result { + for required in [CATALOG_PATH, PROMOTION_CONFIG_PATH, SMOKE_CONFIG_PATH] { + let path = Path::new(required); + if path.exists() { + continue; + } + if strict { + bail!( + "extension graph input is missing at {}; restore the committed extension catalog/config", + path.display() + ); + } + eprintln!( + "warning: extension graph input is missing at {}; skipping generated extension catalog checks in source-only verification", + path.display() + ); + return Ok(false); + } + Ok(true) +} + fn extension_catalog_text_matches_source_control(actual: &str, expected: &str) -> Result { let actual: serde_json::Value = serde_json::from_str(actual).context("parse generated extension catalog")?; @@ -221,55 +255,32 @@ fn normalize_generated_inputs_for_source_control( pub(crate) fn manifest_metadata_by_sql_name() -> Result> { - let catalog = discover_catalog()?; - validate_catalog(&catalog)?; - Ok(catalog - .extensions - .into_iter() - .map(|extension| { - ( - extension.sql_name.clone(), - ManifestExtensionMetadata { - source_kind: extension.source_kind, - control_files: extension.control_file.into_iter().collect(), - dependencies: extension.dependencies, - native_dependencies: extension.native_dependencies, - load_order: extension.load_order, - lifecycle: ManifestExtensionLifecycle { - create_extension: extension.lifecycle.create_extension, - create_schema: extension.lifecycle.create_schema, - load_sql: extension.lifecycle.load_sql, - post_create_sql: extension.lifecycle.post_create_sql, - startup_config: extension.lifecycle.startup_config, - preload_required: extension.lifecycle.preload_required, - restart_required: extension.lifecycle.restart_required, - shared_memory_required: extension.lifecycle.shared_memory_required, - }, - smoke_status: ManifestExtensionSmokeStatus { - promoted: extension.promotion.promoted, - direct: extension.smoke.direct, - server: extension.smoke.server, - restart: extension.smoke.restart, - dump_restore: extension.smoke.dump_restore, - }, - }, - ) - }) - .collect()) + if extension_discovery_inputs_available(false)? { + let catalog = discover_catalog()?; + validate_catalog(&catalog)?; + Ok(catalog + .extensions + .into_iter() + .map(|extension| { + ( + extension.sql_name.clone(), + manifest_metadata_from_catalog_entry(extension), + ) + }) + .collect()) + } else { + manifest_metadata_by_sql_name_from_generated_plan() + } } pub(crate) fn promoted_build_specs() -> Result> { - let catalog = discover_catalog()?; - validate_catalog(&catalog)?; - build_specs(&catalog) -} - -pub(crate) fn build_plan_contrib_path() -> &'static str { - CONTRIB_BUILD_PLAN_PATH -} - -pub(crate) fn build_plan_pgxs_path() -> &'static str { - PGXS_BUILD_PLAN_PATH + if extension_discovery_inputs_available(false)? { + let catalog = discover_catalog()?; + validate_catalog(&catalog)?; + build_specs(&catalog) + } else { + promoted_build_specs_from_generated_plan() + } } fn build_specs(catalog: &ExtensionCatalog) -> Result> { @@ -284,12 +295,30 @@ fn build_specs(catalog: &ExtensionCatalog) -> Result Result, + pub(crate) required_build_files: Vec, + pub(crate) required_build_globs: Vec, pub(crate) source_dir: String, pub(crate) make_args: Vec, pub(crate) contrib_dir: Option, @@ -326,6 +364,9 @@ pub(crate) struct PromotedExtensionBuildSpec { pub(crate) stable: bool, pub(crate) dependencies: Vec, pub(crate) native_dependencies: Vec, + pub(crate) native_support_modules: Vec, + pub(crate) excluded_sql_extensions: Vec, + pub(crate) staging: Option, pub(crate) load_order: Vec, pub(crate) lifecycle: ExtensionLifecycle, pub(crate) smoke: ExtensionSmokeEvidence, @@ -411,7 +452,7 @@ fn build_plan_texts(catalog: &ExtensionCatalog) -> Result { extension.stable )); } - "pgxs-external" => { + kind if is_pgxs_style_build_kind(kind) => { pgxs_tsv.push_str(&format!( "{}\t{}\t{}\t{}\t{}\t{}\t{}\n", extension.id, @@ -423,7 +464,7 @@ fn build_plan_texts(catalog: &ExtensionCatalog) -> Result { shell_words(&extension.make_args) )); } - "postgis" => {} + kind if is_recipe_staged_build_kind(kind) => {} other => bail!( "extension {} has unsupported build kind {other}", extension.id @@ -463,6 +504,9 @@ fn build_plan(catalog: &ExtensionCatalog) -> Result { display_name: spec.display_name, source_kind: spec.source_kind, build_kind: spec.build_kind, + build_script: spec.build_script, + required_build_files: spec.required_build_files, + required_build_globs: spec.required_build_globs, source_dir: spec.source_dir, make_args: spec.make_args, contrib_dir: spec.contrib_dir, @@ -472,6 +516,9 @@ fn build_plan(catalog: &ExtensionCatalog) -> Result { stable: spec.stable, dependencies: spec.dependencies, native_dependencies: spec.native_dependencies, + native_support_modules: spec.native_support_modules, + excluded_sql_extensions: spec.excluded_sql_extensions, + staging: spec.staging, load_order: spec.load_order, lifecycle: spec.lifecycle, smoke: spec.smoke, @@ -481,14 +528,152 @@ fn build_plan(catalog: &ExtensionCatalog) -> Result { }) } +fn promoted_build_specs_from_generated_plan() -> Result> { + let path = Path::new(BUILD_PLAN_PATH); + let text = fs::read_to_string(path).with_context(|| { + format!( + "read {}; extension discovery inputs are unavailable, so generated build plan fallback is required", + path.display() + ) + })?; + let plan: ExtensionBuildPlan = + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?; + ensure!( + plan.format_version == 1, + "extension build plan format must be 1" + ); + Ok(plan + .extensions + .into_iter() + .map(|extension| PromotedExtensionBuildSpec { + id: extension.id, + display_name: extension.display_name, + sql_name: extension.sql_name, + source_kind: extension.source_kind, + build_kind: extension.build_kind, + build_script: extension.build_script, + required_build_files: extension.required_build_files, + required_build_globs: extension.required_build_globs, + source_dir: extension.source_dir, + make_args: extension.make_args, + contrib_dir: extension.contrib_dir, + module_file: extension.module_file, + archive: extension.archive, + control_file: extension.control_file, + stable: extension.stable, + dependencies: extension.dependencies, + native_dependencies: extension.native_dependencies, + native_support_modules: extension.native_support_modules, + excluded_sql_extensions: extension.excluded_sql_extensions, + staging: extension.staging, + load_order: extension.load_order, + lifecycle: extension.lifecycle, + smoke: extension.smoke, + tests: extension.tests, + }) + .collect()) +} + +fn manifest_metadata_by_sql_name_from_generated_plan() +-> Result> { + let path = Path::new(BUILD_PLAN_PATH); + let text = fs::read_to_string(path).with_context(|| { + format!( + "read {}; extension discovery inputs are unavailable, so generated build plan fallback is required", + path.display() + ) + })?; + let plan: ExtensionBuildPlan = + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?; + ensure!( + plan.format_version == 1, + "extension build plan format must be 1" + ); + Ok(plan + .extensions + .into_iter() + .map(|extension| { + ( + extension.sql_name.clone(), + manifest_metadata_from_build_plan_entry(extension), + ) + }) + .collect()) +} + +fn manifest_metadata_from_catalog_entry( + extension: ExtensionCatalogEntry, +) -> ManifestExtensionMetadata { + ManifestExtensionMetadata { + source_kind: extension.source_kind, + control_files: extension.control_file.into_iter().collect(), + dependencies: extension.dependencies, + native_dependencies: extension.native_dependencies, + load_order: extension.load_order, + lifecycle: manifest_lifecycle_from_extension(extension.lifecycle), + smoke_status: ManifestExtensionSmokeStatus { + promoted: extension.promotion.promoted, + ..manifest_smoke_status_from_evidence(extension.smoke) + }, + } +} + +fn manifest_metadata_from_build_plan_entry( + extension: ExtensionBuildPlanEntry, +) -> ManifestExtensionMetadata { + let promoted = extension.stable + && extension.smoke.direct == "passed" + && extension.smoke.server == "passed" + && extension.smoke.restart == "passed" + && extension.smoke.dump_restore == "passed"; + ManifestExtensionMetadata { + source_kind: extension.source_kind, + control_files: extension.control_file.into_iter().collect(), + dependencies: extension.dependencies, + native_dependencies: extension.native_dependencies, + load_order: extension.load_order, + lifecycle: manifest_lifecycle_from_extension(extension.lifecycle), + smoke_status: ManifestExtensionSmokeStatus { + promoted, + ..manifest_smoke_status_from_evidence(extension.smoke) + }, + } +} + +fn manifest_lifecycle_from_extension(lifecycle: ExtensionLifecycle) -> ManifestExtensionLifecycle { + ManifestExtensionLifecycle { + create_extension: lifecycle.create_extension, + create_schema: lifecycle.create_schema, + load_sql: lifecycle.load_sql, + post_create_sql: lifecycle.post_create_sql, + startup_config: lifecycle.startup_config, + preload_required: lifecycle.preload_required, + restart_required: lifecycle.restart_required, + shared_memory_required: lifecycle.shared_memory_required, + } +} + +fn manifest_smoke_status_from_evidence( + smoke: ExtensionSmokeEvidence, +) -> ManifestExtensionSmokeStatus { + ManifestExtensionSmokeStatus { + promoted: false, + direct: smoke.direct, + server: smoke.server, + restart: smoke.restart, + dump_restore: smoke.dump_restore, + } +} + fn write_generated_extension_api(catalog: &ExtensionCatalog) -> Result<()> { let promoted = promoted_extensions(catalog); let candidates = packaged_extensions(catalog); let mut text = String::new(); text.push_str("// @generated by `cargo run -p xtask -- extensions generate`\n\n"); - text.push_str("use super::{Extension, ExtensionSetup};\n\n"); + text.push_str("use super::{Extension, ExtensionNativeModule, ExtensionSetup};\n\n"); text.push_str("const EMPTY_SQL_NAMES: &[&str] = &[];\n"); - text.push_str("const EMPTY_SQL: &[&str] = &[];\n\n"); + text.push_str("const EMPTY_SQL: &[&str] = &[];\n"); + text.push_str("const EMPTY_NATIVE_MODULES: &[ExtensionNativeModule] = &[];\n\n"); for extension in &candidates { let prefix = extension.rust_constant.as_str(); @@ -524,25 +709,46 @@ fn write_generated_extension_api(catalog: &ExtensionCatalog) -> Result<()> { rust_string_array(&extension.lifecycle.post_create_sql) )); } + let native_support_modules = api_native_support_modules(extension)?; + if native_support_modules.is_empty() { + text.push_str(&format!( + "const {candidate_const}_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = EMPTY_NATIVE_MODULES;\n" + )); + } else { + let native_modules = native_support_modules + .iter() + .map(|(runtime_path, aot_name)| { + format!( + "ExtensionNativeModule::new({runtime_path:?}, {})", + option_string_literal(aot_name.as_deref()) + ) + }) + .collect::>() + .join(", "); + text.push_str(&format!( + "const {candidate_const}_NATIVE_SUPPORT_MODULES: &[ExtensionNativeModule] = &[{native_modules}];\n" + )); + } text.push('\n'); - let archive = extension - .promotion - .archive - .as_deref() - .ok_or_else(|| anyhow!("packaged extension {} is missing archive", extension.id))?; + let archive = extension.promotion.archive.as_deref().ok_or_else(|| { + anyhow!( + "release-ready extension {} is missing archive", + extension.id + ) + })?; + let aot_name = extension.native_module_file.as_ref().and( + extension + .promotion + .packaged + .then(|| format!("extension:{}", extension.sql_name)), + ); text.push_str(&format!( - "pub(crate) const {candidate_const}: Extension = Extension::new(\n {:?},\n {:?},\n {:?},\n {},\n {},\n {candidate_const}_DEPENDENCIES,\n ExtensionSetup::new(\n {},\n {},\n {candidate_const}_LOAD_SQL,\n {candidate_const}_POST_CREATE_SQL,\n ),\n);\n\n", + "pub(crate) const {candidate_const}: Extension = Extension::new(\n {:?},\n {:?},\n {:?},\n {candidate_const}_NATIVE_SUPPORT_MODULES,\n {},\n {},\n {candidate_const}_DEPENDENCIES,\n ExtensionSetup::new(\n {},\n {},\n {candidate_const}_LOAD_SQL,\n {candidate_const}_POST_CREATE_SQL,\n ),\n);\n\n", extension.display_name, extension.sql_name, archive, option_string_literal(extension.native_module_file.as_deref()), - option_string_literal( - extension - .native_module_file - .as_ref() - .map(|_| format!("extension:{}", extension.sql_name)) - .as_deref() - ), + option_string_literal(aot_name.as_deref()), extension.lifecycle.create_extension, option_string_literal(extension.lifecycle.create_schema.as_deref()), )); @@ -573,8 +779,11 @@ fn write_generated_extension_api(catalog: &ExtensionCatalog) -> Result<()> { "pub(crate) const CANDIDATES: &[Extension] = &[{candidates_all}];\n" )); - fs::write("src/pglite/generated_extensions.rs", text) - .context("write src/pglite/generated_extensions.rs") + let path = Path::new( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/generated_extensions.rs", + ); + fs::write(path, text).with_context(|| format!("write {}", path.display()))?; + format_rust_source(path) } fn promoted_extensions(catalog: &ExtensionCatalog) -> Vec<&ExtensionCatalogEntry> { @@ -585,6 +794,19 @@ fn promoted_extensions(catalog: &ExtensionCatalog) -> Vec<&ExtensionCatalogEntry .collect() } +fn format_rust_source(path: &Path) -> Result<()> { + let status = Command::new("rustfmt") + .arg(path) + .status() + .with_context(|| format!("run rustfmt on {}", path.display()))?; + ensure!( + status.success(), + "rustfmt failed for {} with status {status}", + path.display() + ); + Ok(()) +} + fn packaged_extensions(catalog: &ExtensionCatalog) -> Vec<&ExtensionCatalogEntry> { catalog .extensions @@ -613,54 +835,31 @@ fn option_string_literal(value: Option<&str>) -> String { } fn discover_catalog() -> Result { - let repl = parse_repl_exports(Path::new(PGLITE_REPL_EXTENSIONS))?; - let docs = parse_docs_catalog(Path::new(PGLITE_DOCS_EXTENSIONS))?; - let package_exports = parse_package_exports(Path::new(PGLITE_PACKAGE_JSON))?; + let mut catalog = read_committed_catalog()?; let promotion_requests = parse_promotion_config(Path::new(PROMOTION_CONFIG_PATH))?; let smoke_evidence = parse_smoke_config(Path::new(SMOKE_CONFIG_PATH))?; let packaged = parse_packaged_manifest(Path::new(ASSET_MANIFEST))?; - let submodules = parse_other_extension_submodules(Path::new(POSTGRES_OTHER_EXTENSIONS))?; - let mut ids = BTreeSet::new(); - ids.extend(repl.keys().cloned()); - ids.extend(docs.keys().filter(|id| id.as_str() != "live").cloned()); - - let mut entries = Vec::new(); - for id in ids { - let repl_export = repl.get(&id); - let docs_entry = docs.get(&id); - let sql_name = discover_sql_name(&id, repl_export, docs_entry)?; - let control_file = discover_control_file(&id, &sql_name, repl_export); - let control = control_file - .as_ref() - .filter(|path| path.is_file()) - .map(|path| parse_control_file(path)) - .transpose()?; - let tests = discover_test_paths(&id); - let lifecycle = classify_lifecycle(&id, control.as_ref()); - let dependencies = discover_dependencies(&id, control.as_ref()); - let source_kind = classify_source_kind(&id, repl_export, docs_entry); - let native_module_file = - discover_native_module_file(&id, &sql_name, source_kind, control.as_ref())?; + for extension in &mut catalog.extensions { let request = promotion_requests - .get(id.as_str()) - .or_else(|| promotion_requests.get(sql_name.as_str())); - let asset = packaged.get(sql_name.as_str()); + .get(extension.id.as_str()) + .or_else(|| promotion_requests.get(extension.sql_name.as_str())); + let asset = packaged.get(extension.sql_name.as_str()); let archive = asset .and_then(|asset| asset.archive.clone()) .or_else(|| request.and_then(|request| request.archive.clone())) - .or_else(|| request.map(|_| format!("extensions/{sql_name}.tar.zst"))); + .or_else(|| request.map(|_| format!("extensions/{}.tar.zst", extension.sql_name))); let requested = request.map(|request| request.build).unwrap_or(false); let stable = request.map(|request| request.stable).unwrap_or(false); let blocker = request.and_then(|request| request.blocker.clone()); let packaged = asset.is_some(); let asset_stable = asset.map(|asset| asset.stable).unwrap_or(false); - let smoke = smoke_evidence - .get(id.as_str()) - .or_else(|| smoke_evidence.get(sql_name.as_str())) + extension.smoke = smoke_evidence + .get(extension.id.as_str()) + .or_else(|| smoke_evidence.get(extension.sql_name.as_str())) .cloned() .unwrap_or_default(); - let promotion = PromotionStatus { + extension.promotion = PromotionStatus { configured: request.is_some(), requested, packaged, @@ -668,118 +867,69 @@ fn discover_catalog() -> Result { && stable && packaged && asset_stable - && smoke.direct == "passed" - && smoke.server == "passed" - && smoke.restart == "passed", + && extension.smoke.direct == "passed" + && extension.smoke.server == "passed" + && extension.smoke.restart == "passed" + && extension.smoke.dump_restore == "passed", stable, archive, module_sha256: asset.and_then(|asset| asset.module_sha256.clone()), blocker, }; - let mut notes = Vec::new(); - if id == "live" { - notes.push("PGlite plugin, not a SQL extension".to_owned()); - } - if control_file.as_ref().is_none_or(|path| !path.is_file()) - && lifecycle.create_extension - && source_kind != "pglite-plugin" - { - notes.push("control file unavailable in current checkout; source submodule may not be initialized".to_owned()); - } - if let Some(submodule) = submodules.get(&id) { - notes.push(format!( - "postgres-pglite submodule {} pinned at {}", - submodule.url, submodule.commit - )); - } - if let Some(blocker) = &promotion.blocker { - notes.push(format!("promotion blocker: {blocker}")); + extension + .notes + .retain(|note| !note.starts_with("promotion blocker: ")); + if let Some(blocker) = &extension.promotion.blocker { + extension + .notes + .push(format!("promotion blocker: {blocker}")); } - - entries.push(ExtensionCatalogEntry { - id: id.clone(), - sql_name, - rust_constant: rust_constant_name(&id), - display_name: docs_entry - .map(|entry| entry.name.clone()) - .unwrap_or_else(|| id.clone()), - source_kind: source_kind.to_owned(), - pglite_import_name: repl_export - .map(|entry| entry.import_name.clone()) - .or_else(|| docs_entry.map(|entry| entry.import_name.clone())) - .unwrap_or_else(|| id.clone()), - pglite_import_path: repl_export - .map(|entry| entry.import_path.clone()) - .or_else(|| docs_entry.map(|entry| entry.import_path.clone())), - package_export: package_exports.get(&id).cloned().or_else(|| { - (source_kind == "postgres-contrib") - .then(|| package_exports.get("*").map(|_| format!("./contrib/{id}"))) - .flatten() - }), - tags: docs_entry - .map(|entry| entry.tags.clone()) - .unwrap_or_default(), - bundle_size: docs_entry.and_then(|entry| entry.size), - control_file: control_file - .filter(|path| path.is_file()) - .map(|path| normalize_path(&path)), - control, - dependencies, - native_dependencies: Vec::new(), - load_order: known_load_order(&id), - lifecycle, - smoke, - tests, - native_module_file, - promotion, - notes, - }); } - entries.sort_by(|left, right| left.id.cmp(&right.id)); + catalog + .extensions + .sort_by(|left, right| left.id.cmp(&right.id)); + catalog.generated_from = catalog_inputs(); + Ok(catalog) +} - Ok(ExtensionCatalog { - format_version: 1, - generated_from: vec![ - CatalogInput { - name: "pglite-repl-exports".to_owned(), - path: PGLITE_REPL_EXTENSIONS.to_owned(), - }, - CatalogInput { - name: "pglite-docs-catalog".to_owned(), - path: PGLITE_DOCS_EXTENSIONS.to_owned(), - }, - CatalogInput { - name: "pglite-package-exports".to_owned(), - path: PGLITE_PACKAGE_JSON.to_owned(), - }, - CatalogInput { - name: "pglite-contrib-modules".to_owned(), - path: PGLITE_CONTRIB_SRC.to_owned(), - }, - CatalogInput { - name: "postgres-contrib".to_owned(), - path: POSTGRES_CONTRIB.to_owned(), - }, - CatalogInput { - name: "postgres-pglite-other-extensions".to_owned(), - path: POSTGRES_OTHER_EXTENSIONS.to_owned(), - }, - CatalogInput { - name: "extension-promotion-config".to_owned(), - path: PROMOTION_CONFIG_PATH.to_owned(), - }, - CatalogInput { - name: "extension-smoke-evidence".to_owned(), - path: SMOKE_CONFIG_PATH.to_owned(), - }, - CatalogInput { - name: "asset-manifest-evidence".to_owned(), - path: ASSET_MANIFEST.to_owned(), - }, - ], - extensions: entries, - }) +fn read_committed_catalog() -> Result { + let path = Path::new(CATALOG_PATH); + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +fn catalog_inputs() -> Vec { + vec![ + CatalogInput { + name: "postgres18-source".to_owned(), + path: "src/postgres/versions/18/source.toml".to_owned(), + }, + CatalogInput { + name: "extension-catalog".to_owned(), + path: "src/extensions/catalog".to_owned(), + }, + CatalogInput { + name: "postgres-contrib".to_owned(), + path: POSTGRES_CONTRIB.to_owned(), + }, + CatalogInput { + name: "external-extension-recipes".to_owned(), + path: POSTGRES_OTHER_EXTENSIONS.to_owned(), + }, + CatalogInput { + name: "extension-promotion-config".to_owned(), + path: PROMOTION_CONFIG_PATH.to_owned(), + }, + CatalogInput { + name: "extension-smoke-evidence".to_owned(), + path: SMOKE_CONFIG_PATH.to_owned(), + }, + CatalogInput { + name: "asset-manifest-evidence".to_owned(), + path: ASSET_MANIFEST.to_owned(), + }, + ] } fn validate_catalog(catalog: &ExtensionCatalog) -> Result<()> { @@ -828,7 +978,7 @@ fn validate_catalog(catalog: &ExtensionCatalog) -> Result<()> { extension.id ); ensure!( - extension.source_kind != "pglite-plugin", + extension.source_kind != "oliphaunt-plugin", "requested extension {} is not a SQL extension", extension.id ); @@ -897,7 +1047,7 @@ fn validate_catalog(catalog: &ExtensionCatalog) -> Result<()> { .extensions .iter() .any(|extension| extension.id == required || extension.sql_name == required), - "extension catalog is missing required PGlite extension {required}" + "extension catalog is missing required Oliphaunt extension {required}" ); } Ok(()) @@ -912,93 +1062,50 @@ fn api_dependencies(extension: &ExtensionCatalogEntry) -> Vec { .collect() } -fn runtime_provided_sql_extensions() -> &'static [&'static str] { - &["plpgsql"] +fn api_native_support_modules( + extension: &ExtensionCatalogEntry, +) -> Result)>> { + Ok(wasix_native_support_modules(&extension.sql_name)? + .into_iter() + .map(|module| { + ( + module.runtime_path, + extension + .promotion + .stable + .then(|| format!("extension:{}:{}", extension.sql_name, module.name)), + ) + }) + .collect()) } -fn parse_repl_exports(path: &Path) -> Result> { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let mut exports = BTreeMap::new(); - for line in text.lines() { - let line = line.trim(); - let Some(rest) = line.strip_prefix("export { ") else { - continue; - }; - let Some((name, module_part)) = rest.split_once(" } from ") else { - continue; - }; - let import_path = strip_quoted(module_part.trim()) - .ok_or_else(|| anyhow!("could not parse export module from {line:?}"))?; - exports.insert( - name.to_owned(), - ReplExport { - import_name: name.to_owned(), - import_path: import_path.to_owned(), - }, - ); - } - Ok(exports) +fn wasix_native_support_modules(sql_name: &str) -> Result> { + let mut modules = wasix_target_recipe(sql_name)? + .map(|recipe| recipe.native_support_modules) + .unwrap_or_default(); + modules.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(modules) } -fn parse_docs_catalog(path: &Path) -> Result> { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let mut entries = BTreeMap::new(); - let mut current = DocsCatalogEntryBuilder::default(); - let mut in_entry = false; - for line in text.lines() { - let trimmed = line.trim(); - if trimmed == "{" { - in_entry = true; - current = DocsCatalogEntryBuilder::default(); - continue; - } - if !in_entry { - continue; - } - if trimmed == "}," || trimmed == "}" { - if let Some(entry) = std::mem::take(&mut current).finish() { - entries.insert(entry.import_name.clone(), entry); - } - in_entry = false; - continue; - } - if let Some(value) = parse_string_field(trimmed, "name") { - current.name = Some(value.to_owned()); - } else if let Some(value) = parse_string_field(trimmed, "importPath") { - current.import_path = Some(value.to_owned()); - } else if let Some(value) = parse_string_field(trimmed, "importName") { - current.import_name = Some(value.to_owned()); - } else if let Some(value) = parse_u64_field(trimmed, "size") { - current.size = Some(value); - } else if let Some(tags) = parse_tags_field(trimmed) { - current.tags = tags; - } +fn wasix_target_recipe(sql_name: &str) -> Result> { + let path = Path::new(EXTERNAL_EXTENSION_RECIPE_ROOT) + .join(sql_name) + .join("targets/wasix.toml"); + if !path.exists() { + return Ok(None); } - Ok(entries) + let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let mut recipe: ExtensionTargetRecipe = + toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?; + recipe + .native_support_modules + .sort_by(|left, right| left.name.cmp(&right.name)); + recipe.excluded_sql_extensions.sort(); + Ok(Some(recipe)) } -fn parse_package_exports(path: &Path) -> Result> { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let json: serde_json::Value = - serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?; - let mut exports = BTreeMap::new(); - let Some(map) = json.get("exports").and_then(|value| value.as_object()) else { - return Ok(exports); - }; - for key in map.keys() { - let Some(name) = key.strip_prefix("./") else { - continue; - }; - if name == "contrib" { - continue; - } - if let Some(name) = name.strip_prefix("contrib/") { - exports.insert(name.to_owned(), key.clone()); - } else { - exports.insert(name.to_owned(), key.clone()); - } - } - Ok(exports) +fn runtime_provided_sql_extensions() -> &'static [&'static str] { + &["plpgsql"] } fn parse_promotion_config(path: &Path) -> Result> { @@ -1103,417 +1210,51 @@ fn parse_packaged_manifest(path: &Path) -> Result Result> { - let gitmodules = path - .parent() - .and_then(Path::parent) - .map(|root| root.join(".gitmodules")) - .ok_or_else(|| anyhow!("could not resolve postgres-pglite .gitmodules"))?; - let gitmodules_text = fs::read_to_string(&gitmodules) - .with_context(|| format!("read {}", gitmodules.display()))?; - let mut urls = BTreeMap::new(); - let mut current: Option = None; - for line in gitmodules_text.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("[submodule ") { - current = trimmed - .split('"') - .nth(1) - .and_then(|value| value.strip_prefix("pglite/other_extensions/")) - .map(str::to_owned); - } else if let Some(url) = trimmed.strip_prefix("url = ") - && let Some(name) = ¤t - { - urls.insert(name.clone(), url.to_owned()); - } - } - - let status = std::process::Command::new("git") - .args([ - "-C", - "assets/checkouts/postgres-pglite", - "submodule", - "status", - ]) - .output() - .context("read postgres-pglite submodule status")?; - let status_text = String::from_utf8(status.stdout).context("submodule status utf8")?; - let mut pins = BTreeMap::new(); - for line in status_text.lines() { - let trimmed = line.trim_start_matches(['-', '+', ' ']); - let mut parts = trimmed.split_whitespace(); - let Some(commit) = parts.next() else { - continue; - }; - let Some(path) = parts.next() else { - continue; - }; - let Some(name) = path.strip_prefix("pglite/other_extensions/") else { - continue; - }; - if let Some(url) = urls.get(name) { - pins.insert( - name.to_owned(), - SubmodulePin { - url: url.clone(), - commit: commit.to_owned(), - }, - ); - } - } - Ok(pins) -} - -fn discover_sql_name( - id: &str, - repl_export: Option<&ReplExport>, - _docs_entry: Option<&DocsCatalogEntry>, -) -> Result { - if id == "uuid_ossp" { - return Ok("uuid-ossp".to_owned()); - } - if id == "vector" { - return Ok("vector".to_owned()); - } - let control_file = discover_control_file(id, id, repl_export); - if let Some(path) = control_file - && path.is_file() - && let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) - { - return Ok(stem.to_owned()); - } - Ok(id.to_owned()) -} - -fn discover_control_file( - id: &str, - sql_name: &str, - repl_export: Option<&ReplExport>, -) -> Option { - let candidates = if repl_export - .map(|entry| entry.import_path.contains("/contrib/")) - .unwrap_or(false) - { - let dashed_id = id.replace('_', "-"); - vec![ - Path::new(POSTGRES_CONTRIB) - .join(id) - .join(format!("{sql_name}.control")), - Path::new(POSTGRES_CONTRIB) - .join(id) - .join(format!("{id}.control")), - Path::new(POSTGRES_CONTRIB) - .join(id) - .join(format!("{dashed_id}.control")), - Path::new(POSTGRES_CONTRIB) - .join(&dashed_id) - .join(format!("{sql_name}.control")), - Path::new(POSTGRES_CONTRIB) - .join(&dashed_id) - .join(format!("{dashed_id}.control")), - ] - } else { - vec![ - Path::new(&extension_source_dir_for( - id, - classify_source_kind(id, repl_export, None), - )) - .join(format!("{sql_name}.control")), - ] - }; - candidates.into_iter().find(|path| path.is_file()) -} - -fn parse_control_file(path: &Path) -> Result { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let mut control = ControlMetadata::default(); - for line in text.lines() { - let line = line.split('#').next().unwrap_or_default().trim(); - if line.is_empty() { - continue; - } - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let key = key.trim(); - let value = strip_quoted(value.trim()) - .unwrap_or_else(|| value.trim().trim_matches('"')) - .to_owned(); - match key { - "default_version" => control.default_version = Some(value), - "module_pathname" => control.module_pathname = Some(value), - "requires" => { - control.requires = value - .split(',') - .map(|item| item.trim().trim_matches('"').to_owned()) - .filter(|item| !item.is_empty()) - .collect(); - } - "relocatable" => control.relocatable = Some(value), - "schema" => control.schema = Some(value), - _ => {} - } - } - Ok(control) -} - -fn discover_dependencies(id: &str, control: Option<&ControlMetadata>) -> Vec { - let mut dependencies = BTreeSet::new(); - if let Some(control) = control { - dependencies.extend(control.requires.iter().cloned()); - } - if id == "earthdistance" { - dependencies.insert("cube".to_owned()); - } - dependencies.into_iter().collect() +pub(crate) fn is_pgxs_style_build_kind(kind: &str) -> bool { + matches!(kind, "pgxs-external" | "pgxs-sql-only") } -fn discover_native_module_file( - id: &str, - sql_name: &str, - source_kind: &str, - control: Option<&ControlMetadata>, -) -> Result> { - if let Some(module_pathname) = control.and_then(|control| control.module_pathname.as_deref()) { - return Ok(module_pathname_to_file(module_pathname)); - } - - let source_dir = extension_source_dir_for(id, source_kind); - if source_dir.is_empty() { - return Ok(None); - } - let makefile = Path::new(&source_dir).join("Makefile"); - if !makefile.is_file() { - return Ok(None); - } - discover_native_module_file_from_makefile(&makefile, sql_name) +pub(crate) fn is_recipe_staged_build_kind(kind: &str) -> bool { + matches!(kind, "autotools") } -fn module_pathname_to_file(module_pathname: &str) -> Option { - let value = module_pathname - .strip_prefix("$libdir/") - .or_else(|| module_pathname.strip_prefix("${libdir}/")) - .unwrap_or(module_pathname) - .trim(); - if value.is_empty() { - return None; - } - let file = Path::new(value) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(value); - if file.ends_with(".so") { - Some(file.to_owned()) - } else { - Some(format!("{file}.so")) - } -} - -fn discover_native_module_file_from_makefile( - makefile: &Path, - sql_name: &str, -) -> Result> { - let text = - fs::read_to_string(makefile).with_context(|| format!("read {}", makefile.display()))?; - let mut variables = BTreeMap::new(); - for line in text.lines() { - let line = line.split('#').next().unwrap_or_default().trim(); - if line.is_empty() || line.starts_with("ifeq") || line.starts_with("ifneq") { - continue; - } - let Some((key, value)) = parse_make_assignment(line) else { - continue; - }; - variables.insert(key.to_owned(), value.to_owned()); - } - - for key in ["MODULE_big", "MODULES"] { - let Some(value) = variables.get(key) else { - continue; - }; - let expanded = expand_make_value(value, &variables, 0); - let modules = expanded - .split_whitespace() - .filter(|item| !item.is_empty()) - .collect::>(); - if modules.is_empty() { - continue; - } - let selected = modules - .iter() - .copied() - .find(|module| *module == sql_name) - .unwrap_or(modules[0]); - return Ok(Some(if selected.ends_with(".so") { - selected.to_owned() - } else { - format!("{selected}.so") - })); - } - Ok(None) -} - -fn parse_make_assignment(line: &str) -> Option<(&str, &str)> { - for operator in [":=", "?=", "="] { - if let Some((key, value)) = line.split_once(operator) { - let key = key.trim(); - if key - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') - { - return Some((key, value.trim())); - } +fn build_kind( + extension: &ExtensionCatalogEntry, + wasix_target: Option<&ExtensionTargetRecipe>, +) -> Result { + match extension.source_kind.as_str() { + "postgres-contrib" => Ok("postgres-contrib".to_owned()), + "oliphaunt-other-extension" => { + let Some(kind) = wasix_target + .and_then(|target| target.build_kind.as_deref()) + .filter(|kind| !kind.is_empty()) + else { + return Ok("pgxs-external".to_owned()); + }; + ensure!( + is_pgxs_style_build_kind(kind), + "extension {} has unsupported oliphaunt-other-extension WASIX build kind {kind}", + extension.id + ); + Ok(kind.to_owned()) } - } - None -} - -fn expand_make_value(value: &str, variables: &BTreeMap, depth: usize) -> String { - if depth > 8 { - return value.to_owned(); - } - let mut out = String::new(); - let mut rest = value; - while let Some(index) = rest.find('$') { - out.push_str(&rest[..index]); - rest = &rest[index..]; - let Some(open) = rest.chars().nth(1) else { - out.push('$'); - rest = &rest[1..]; - continue; - }; - let close = match open { - '(' => ')', - '{' => '}', - _ => { - out.push('$'); - rest = &rest[1..]; - continue; - } - }; - let Some(close_index) = rest.find(close) else { - out.push('$'); - rest = &rest[1..]; - continue; - }; - let key = &rest[2..close_index]; - if let Some(replacement) = variables.get(key) { - out.push_str(&expand_make_value(replacement, variables, depth + 1)); - } else { - out.push_str(&rest[..=close_index]); + "postgis" => { + let kind = wasix_target + .and_then(|target| target.build_kind.as_deref()) + .ok_or_else(|| { + anyhow!("extension {} has no WASIX target build_kind", extension.id) + })?; + ensure!( + is_recipe_staged_build_kind(kind), + "extension {} has unsupported recipe-staged WASIX build kind {kind}", + extension.id + ); + Ok(kind.to_owned()) } - rest = &rest[close_index + 1..]; - } - out.push_str(rest); - out -} - -fn classify_lifecycle(id: &str, control: Option<&ControlMetadata>) -> ExtensionLifecycle { - let mut lifecycle = ExtensionLifecycle { - create_extension: id != "auto_explain", - create_schema: Some( - control - .and_then(|control| control.schema.clone()) - .unwrap_or_else(|| "pg_catalog".to_owned()), + other => bail!( + "extension {} has unsupported source kind {other}", + extension.id ), - load_sql: Vec::new(), - post_create_sql: Vec::new(), - startup_config: Vec::new(), - preload_required: false, - restart_required: false, - shared_memory_required: false, - }; - match id { - "auto_explain" => { - lifecycle.create_extension = false; - lifecycle.create_schema = None; - lifecycle.load_sql = vec![ - "LOAD 'auto_explain';".to_owned(), - "SET auto_explain.log_min_duration = '0';".to_owned(), - "SET auto_explain.log_analyze = 'true';".to_owned(), - "SET auto_explain.log_level = 'NOTICE';".to_owned(), - ]; - } - "age" => { - lifecycle.load_sql.push("LOAD 'age';".to_owned()); - lifecycle - .post_create_sql - .push("SET search_path = ag_catalog, \"$user\", public;".to_owned()); - } - _ => {} - } - lifecycle -} - -fn classify_source_kind( - id: &str, - repl_export: Option<&ReplExport>, - docs_entry: Option<&DocsCatalogEntry>, -) -> &'static str { - if id == "live" - || docs_entry - .map(|entry| entry.tags.iter().any(|tag| tag == "pglite plugin")) - .unwrap_or(false) - { - "pglite-plugin" - } else if repl_export - .map(|entry| entry.import_path.contains("/contrib/")) - .unwrap_or(false) - { - "postgres-contrib" - } else if id == "postgis" { - "postgis" - } else { - "pglite-other-extension" - } -} - -fn discover_test_paths(id: &str) -> Vec { - let mut paths = Vec::new(); - let mut test_names = vec![id.to_owned()]; - if id == "vector" { - test_names.push("pgvector".to_owned()); - } - for test_name in test_names { - for candidate in [ - Path::new(PGLITE_TESTS) - .join("contrib") - .join(format!("{test_name}.test.js")), - Path::new(PGLITE_TESTS) - .join("contrib") - .join(format!("{test_name}.test.ts")), - Path::new(PGLITE_TESTS).join(format!("{test_name}.test.js")), - Path::new(PGLITE_TESTS).join(format!("{test_name}.test.ts")), - Path::new(PGLITE_POSTGIS_TESTS).join(format!("{test_name}.test.ts")), - Path::new(PGLITE_POSTGIS_TESTS).join(format!("{test_name}.test.js")), - ] { - if candidate.is_file() { - paths.push(normalize_path(&candidate)); - } - } - } - paths.sort(); - paths.dedup(); - paths -} - -fn known_load_order(id: &str) -> Vec { - match id { - "postgis" => vec![ - "lib/postgresql/postgis-3.so".to_owned(), - "lib/postgresql/postgis_topology-3.so".to_owned(), - "lib/postgresql/postgis_raster-3.so".to_owned(), - ], - _ => Vec::new(), - } -} - -fn build_kind(extension: &ExtensionCatalogEntry) -> &'static str { - match extension.source_kind.as_str() { - "postgres-contrib" => "postgres-contrib", - "pglite-other-extension" => "pgxs-external", - "postgis" => "postgis", - _ => "unsupported", } } @@ -1536,8 +1277,8 @@ fn extension_source_dir_for(id: &str, source_kind: &str) -> String { .join(extension_contrib_dir_name(id)) .to_string_lossy() .replace('\\', "/"), - "pglite-other-extension" if id == "vector" => PGVECTOR_CHECKOUT.to_owned(), - "pglite-other-extension" | "postgis" => Path::new(EXTERNAL_EXTENSION_CHECKOUT_ROOT) + "oliphaunt-other-extension" if id == "vector" => PGVECTOR_CHECKOUT.to_owned(), + "oliphaunt-other-extension" | "postgis" => Path::new(EXTERNAL_EXTENSION_CHECKOUT_ROOT) .join(id) .to_string_lossy() .replace('\\', "/"), @@ -1552,57 +1293,6 @@ fn extension_contrib_dir_name(id: &str) -> String { } } -fn parse_string_field<'a>(line: &'a str, field: &str) -> Option<&'a str> { - let rest = line.strip_prefix(&format!("{field}: "))?; - strip_quoted(rest.trim_end_matches(',').trim()) -} - -fn parse_u64_field(line: &str, field: &str) -> Option { - let rest = line.strip_prefix(&format!("{field}: "))?; - rest.trim_end_matches(',').trim().parse().ok() -} - -fn parse_tags_field(line: &str) -> Option> { - let rest = line.strip_prefix("tags: ")?; - let rest = rest.trim().trim_end_matches(',').trim(); - let rest = rest.strip_prefix('[')?.strip_suffix(']')?; - Some( - rest.split(',') - .filter_map(|item| strip_quoted(item.trim()).map(str::to_owned)) - .collect(), - ) -} - -fn strip_quoted(value: &str) -> Option<&str> { - let value = value.trim().trim_end_matches(','); - if value.len() < 2 { - return None; - } - let quote = value.as_bytes()[0] as char; - if quote != '\'' && quote != '"' { - return None; - } - value - .strip_prefix(quote) - .and_then(|value| value.strip_suffix(quote)) -} - -fn rust_constant_name(id: &str) -> String { - id.chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_uppercase() - } else { - '_' - } - }) - .collect() -} - -fn normalize_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} - fn shell_words(words: &[String]) -> String { if words.is_empty() { "-".to_owned() @@ -1648,6 +1338,12 @@ struct ExtensionBuildPlanEntry { display_name: String, source_kind: String, build_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + build_script: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + required_build_files: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + required_build_globs: Vec, source_dir: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] make_args: Vec, @@ -1661,12 +1357,68 @@ struct ExtensionBuildPlanEntry { stable: bool, dependencies: Vec, native_dependencies: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + native_support_modules: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + excluded_sql_extensions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + staging: Option, load_order: Vec, lifecycle: ExtensionLifecycle, smoke: ExtensionSmokeEvidence, tests: Vec, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct ExtensionTargetRecipe { + #[serde(default)] + build_kind: Option, + #[serde(default)] + build_script: Option, + #[serde(default)] + required_build_files: Vec, + #[serde(default)] + required_build_globs: Vec, + #[serde(default)] + native_support_modules: Vec, + #[serde(default)] + excluded_sql_extensions: Vec, + #[serde(default)] + staging: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct NativeSupportModuleSpec { + pub(crate) name: String, + #[serde(rename = "runtime-path", alias = "runtime_path")] + pub(crate) runtime_path: String, + #[serde(rename = "build-path", alias = "build_path")] + pub(crate) build_path: String, + #[serde(rename = "aot-file", alias = "aot_file")] + pub(crate) aot_file: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ExtensionStagingSpec { + #[serde(rename = "module-source-dir", alias = "module_source_dir")] + pub(crate) module_source_dir: Option, + #[serde(rename = "control-source", alias = "control_source")] + pub(crate) control_source: Option, + #[serde(rename = "sql-source-dir", alias = "sql_source_dir")] + pub(crate) sql_source_dir: Option, + #[serde(default, rename = "data-dirs", alias = "data_dirs")] + pub(crate) data_dirs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ExtensionStagingDataDirSpec { + pub(crate) source: String, + pub(crate) destination: String, +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] struct ExtensionCatalogEntry { @@ -1675,9 +1427,9 @@ struct ExtensionCatalogEntry { rust_constant: String, display_name: String, source_kind: String, - pglite_import_name: String, + upstream_import_name: String, #[serde(skip_serializing_if = "Option::is_none")] - pglite_import_path: Option, + upstream_import_path: Option, #[serde(skip_serializing_if = "Option::is_none")] package_export: Option, tags: Vec, @@ -1699,42 +1451,6 @@ struct ExtensionCatalogEntry { notes: Vec, } -#[derive(Debug, Clone)] -struct ReplExport { - import_name: String, - import_path: String, -} - -#[derive(Debug, Clone)] -struct DocsCatalogEntry { - name: String, - import_path: String, - import_name: String, - tags: Vec, - size: Option, -} - -#[derive(Debug, Default)] -struct DocsCatalogEntryBuilder { - name: Option, - import_path: Option, - import_name: Option, - tags: Vec, - size: Option, -} - -impl DocsCatalogEntryBuilder { - fn finish(self) -> Option { - Some(DocsCatalogEntry { - name: self.name?, - import_path: self.import_path?, - import_name: self.import_name?, - tags: self.tags, - size: self.size, - }) - } -} - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] struct ControlMetadata { @@ -1865,12 +1581,6 @@ struct PackagedExtension { stable: bool, } -#[derive(Debug)] -struct SubmodulePin { - url: String, - commit: String, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] struct AssetManifest { @@ -1888,3 +1598,196 @@ struct AssetManifestExtension { #[serde(default)] stable: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + struct CurrentDirGuard { + previous: std::path::PathBuf, + } + + impl CurrentDirGuard { + fn repo_root() -> Result { + let previous = std::env::current_dir().context("read current test directory")?; + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + std::env::set_current_dir(&repo_root) + .with_context(|| format!("set current directory to {}", repo_root.display()))?; + Ok(Self { previous }) + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.previous); + } + } + + fn build_plan_entry_for_manifest_metadata_test() -> ExtensionBuildPlanEntry { + ExtensionBuildPlanEntry { + id: "vector".to_owned(), + sql_name: "vector".to_owned(), + display_name: "pgvector".to_owned(), + source_kind: "oliphaunt-other-extension".to_owned(), + build_kind: "pgxs-external".to_owned(), + build_script: None, + required_build_files: Vec::new(), + required_build_globs: Vec::new(), + source_dir: "target/oliphaunt-sources/checkouts/pgvector".to_owned(), + make_args: Vec::new(), + contrib_dir: None, + module_file: Some("vector.so".to_owned()), + archive: "extensions/vector.tar.zst".to_owned(), + control_file: Some( + "target/oliphaunt-sources/checkouts/pgvector/vector.control".to_owned(), + ), + stable: true, + dependencies: vec!["plpgsql".to_owned()], + native_dependencies: vec!["runtime:oliphaunt".to_owned()], + native_support_modules: Vec::new(), + excluded_sql_extensions: Vec::new(), + staging: None, + load_order: vec!["vector".to_owned()], + lifecycle: ExtensionLifecycle { + create_extension: true, + create_schema: Some("extensions".to_owned()), + load_sql: vec!["select 1".to_owned()], + post_create_sql: vec!["select 2".to_owned()], + startup_config: vec!["shared_preload_libraries=vector".to_owned()], + preload_required: true, + restart_required: true, + shared_memory_required: false, + }, + smoke: ExtensionSmokeEvidence { + direct: "passed".to_owned(), + server: "passed".to_owned(), + restart: "passed".to_owned(), + dump_restore: "passed".to_owned(), + }, + tests: vec!["src/extensions/tests/vector.test.ts".to_owned()], + } + } + + #[test] + fn extension_build_plan_tsv_freshness_is_checkout_line_ending_stable() { + let expected = "# id\tsql_name\tcontrib_dir\tmodule_file\tarchive\tstable\namcheck\tamcheck\tamcheck\tamcheck.so\textensions/amcheck.tar.zst\ttrue\n"; + let windows_checkout = "# id\tsql_name\tcontrib_dir\tmodule_file\tarchive\tstable\r\namcheck\tamcheck\tamcheck\tamcheck.so\textensions/amcheck.tar.zst\ttrue\r\n"; + + assert!(extension_build_plan_tsv_matches_source_control( + windows_checkout, + expected + )); + } + + #[test] + fn build_plan_manifest_metadata_preserves_runtime_contract() { + let metadata = + manifest_metadata_from_build_plan_entry(build_plan_entry_for_manifest_metadata_test()); + + assert_eq!(metadata.source_kind, "oliphaunt-other-extension"); + assert_eq!( + metadata.control_files, + vec!["target/oliphaunt-sources/checkouts/pgvector/vector.control"] + ); + assert_eq!(metadata.dependencies, vec!["plpgsql"]); + assert_eq!(metadata.native_dependencies, vec!["runtime:oliphaunt"]); + assert_eq!(metadata.load_order, vec!["vector"]); + assert!(metadata.lifecycle.create_extension); + assert_eq!( + metadata.lifecycle.create_schema.as_deref(), + Some("extensions") + ); + assert_eq!(metadata.lifecycle.load_sql, vec!["select 1"]); + assert_eq!(metadata.lifecycle.post_create_sql, vec!["select 2"]); + assert!(metadata.lifecycle.preload_required); + assert!(metadata.lifecycle.restart_required); + assert!(!metadata.lifecycle.shared_memory_required); + assert!(metadata.smoke_status.promoted); + assert_eq!(metadata.smoke_status.direct, "passed"); + assert_eq!(metadata.smoke_status.server, "passed"); + assert_eq!(metadata.smoke_status.restart, "passed"); + assert_eq!(metadata.smoke_status.dump_restore, "passed"); + } + + #[test] + fn build_plan_manifest_metadata_requires_all_smoke_evidence_for_promoted() { + let mut entry = build_plan_entry_for_manifest_metadata_test(); + entry.smoke.restart = "not-run".to_owned(); + + let metadata = manifest_metadata_from_build_plan_entry(entry); + + assert!(!metadata.smoke_status.promoted); + assert_eq!(metadata.smoke_status.restart, "not-run"); + + let mut entry = build_plan_entry_for_manifest_metadata_test(); + entry.smoke.dump_restore = "not-run".to_owned(); + + let metadata = manifest_metadata_from_build_plan_entry(entry); + + assert!(!metadata.smoke_status.promoted); + assert_eq!(metadata.smoke_status.dump_restore, "not-run"); + } + + #[test] + fn postgis_build_spec_uses_wasix_target_recipe_metadata() -> Result<()> { + let _cwd = CurrentDirGuard::repo_root()?; + let specs = promoted_build_specs()?; + let postgis = specs + .iter() + .find(|extension| extension.sql_name == "postgis") + .expect("postgis must be a promoted build spec"); + + assert_eq!(postgis.build_kind, "autotools"); + assert_eq!( + postgis.build_script.as_deref(), + Some("src/extensions/external/postgis/tools/build_wasix.sh") + ); + assert_eq!( + postgis.required_build_files, + vec![ + "postgis/postgis-3.so", + "postgis/liboliphaunt_postgis_deps.so", + "extensions/postgis/postgis.control", + "share/proj/proj.db", + ] + ); + assert_eq!( + postgis.required_build_globs, + vec!["extensions/postgis/sql/postgis--*.sql"] + ); + assert_eq!( + postgis + .native_support_modules + .iter() + .map(|module| module.name.as_str()) + .collect::>(), + vec!["postgis_deps"] + ); + assert!( + postgis + .excluded_sql_extensions + .contains(&"postgis_raster".to_owned()) + ); + + let staging = postgis + .staging + .as_ref() + .expect("postgis must declare WASIX staging metadata"); + assert_eq!( + staging.module_source_dir.as_deref(), + Some("postgis/postgis") + ); + assert_eq!( + staging.control_source.as_deref(), + Some("postgis/extensions/postgis/postgis.control") + ); + assert_eq!( + staging.sql_source_dir.as_deref(), + Some("postgis/extensions/postgis/sql") + ); + assert_eq!(staging.data_dirs.len(), 1); + assert_eq!(staging.data_dirs[0].source, "postgis/share/proj"); + assert_eq!(staging.data_dirs[0].destination, "share/proj"); + Ok(()) + } +} diff --git a/tools/xtask/src/fs_utils.rs b/tools/xtask/src/fs_utils.rs new file mode 100644 index 00000000..c808e327 --- /dev/null +++ b/tools/xtask/src/fs_utils.rs @@ -0,0 +1,322 @@ +use std::collections::HashSet; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; +use zstd::stream::write::Encoder as ZstdEncoder; + +pub(crate) fn archive_entry_bytes(archive_path: &Path, entry_name: &str) -> Result> { + let file = + fs::File::open(archive_path).with_context(|| format!("open {}", archive_path.display()))?; + let decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("create zstd decoder for {}", archive_path.display()))?; + let mut archive = tar::Archive::new(decoder); + for entry in archive + .entries() + .with_context(|| format!("read {}", archive_path.display()))? + { + let mut entry = + entry.with_context(|| format!("read entry from {}", archive_path.display()))?; + let path = entry + .path() + .with_context(|| format!("read path from {}", archive_path.display()))? + .to_string_lossy() + .trim_start_matches("./") + .to_owned(); + if path == entry_name { + let mut bytes = Vec::new(); + io::copy(&mut entry, &mut bytes) + .with_context(|| format!("read {entry_name} from {}", archive_path.display()))?; + return Ok(bytes); + } + } + bail!( + "{} is missing archive entry {entry_name}", + archive_path.display() + ) +} + +pub(crate) fn archive_entries(path: &Path) -> Result> { + let file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?; + let decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("decode {}", path.display()))?; + let mut archive = tar::Archive::new(decoder); + let mut entries = HashSet::new(); + for entry in archive + .entries() + .with_context(|| format!("read entries from {}", path.display()))? + { + let entry = entry.with_context(|| format!("read entry from {}", path.display()))?; + let entry_path = entry + .path() + .with_context(|| format!("read entry path from {}", path.display()))?; + let entry = entry_path + .to_str() + .ok_or_else(|| anyhow!("archive {} has non-UTF-8 path", path.display()))? + .trim_start_matches("./") + .trim_end_matches('/') + .to_string(); + if !entry.is_empty() { + entries.insert(entry); + } + } + Ok(entries) +} + +pub(crate) fn write_bytes_file(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, bytes).with_context(|| format!("write {}", path.display())) +} + +pub(crate) fn deterministic_tar_zst( + source_root: &Path, + archive_root: &Path, + output: &Path, +) -> Result<()> { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let file = fs::File::create(output).with_context(|| format!("create {}", output.display()))?; + let encoder = + ZstdEncoder::new(file, 19).with_context(|| format!("create zstd {}", output.display()))?; + let mut builder = tar::Builder::new(encoder); + append_tree(&mut builder, source_root, source_root, archive_root)?; + let encoder = builder.into_inner().context("finish tar stream")?; + encoder + .finish() + .with_context(|| format!("finish {}", output.display()))?; + Ok(()) +} + +pub(crate) fn archive_file_list(path: &Path) -> Result> { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + let raw = if bytes.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]) { + let mut decoder = zstd::stream::read::Decoder::new(std::io::Cursor::new(bytes)) + .with_context(|| format!("create zstd decoder for {}", path.display()))?; + let mut raw = Vec::new(); + io::copy(&mut decoder, &mut raw) + .with_context(|| format!("decompress {}", path.display()))?; + raw + } else { + bytes + }; + let mut archive = tar::Archive::new(std::io::Cursor::new(raw)); + let mut files = Vec::new(); + for entry in archive + .entries() + .with_context(|| format!("read tar entries from {}", path.display()))? + { + let entry = entry.with_context(|| format!("read tar entry from {}", path.display()))?; + if entry.header().entry_type().is_file() { + files.push( + entry + .path() + .with_context(|| format!("read tar path from {}", path.display()))? + .to_string_lossy() + .replace('\\', "/"), + ); + } + } + files.sort(); + Ok(files) +} + +fn append_tree( + builder: &mut tar::Builder, + root: &Path, + current: &Path, + archive_root: &Path, +) -> Result<()> { + let relative = current + .strip_prefix(root) + .with_context(|| format!("strip {} from {}", root.display(), current.display()))?; + let archive_path = if relative.as_os_str().is_empty() { + archive_root.to_path_buf() + } else { + archive_root.join(relative) + }; + + if !archive_path.as_os_str().is_empty() { + let mut header = tar::Header::new_gnu(); + header.set_mtime(0); + header.set_uid(0); + header.set_gid(0); + header.set_username("root").ok(); + header.set_groupname("root").ok(); + if current.is_dir() { + header.set_entry_type(tar::EntryType::Directory); + header.set_mode(0o755); + header.set_size(0); + header.set_cksum(); + builder + .append_data(&mut header, &archive_path, io::empty()) + .with_context(|| format!("append directory {}", archive_path.display()))?; + } else if current.is_file() { + let bytes = fs::read(current).with_context(|| format!("read {}", current.display()))?; + header.set_entry_type(tar::EntryType::Regular); + header.set_mode(if is_executable(current) { 0o755 } else { 0o644 }); + header.set_size(bytes.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, &archive_path, bytes.as_slice()) + .with_context(|| format!("append file {}", archive_path.display()))?; + } + } + + if current.is_dir() { + for child in sorted_children(current)? { + append_tree(builder, root, &child, archive_root)?; + } + } + Ok(()) +} + +pub(crate) fn copy_tree_filtered( + source: &Path, + destination: &Path, + skip_names: Option<&[&str]>, +) -> Result<()> { + fs::create_dir_all(destination).with_context(|| format!("create {}", destination.display()))?; + for entry in sorted_files(source)? { + let relative = entry + .strip_prefix(source) + .with_context(|| format!("strip {} from {}", source.display(), entry.display()))?; + if let Some(file_name) = relative.file_name().and_then(|name| name.to_str()) + && skip_names + .map(|names| names.contains(&file_name)) + .unwrap_or(false) + { + continue; + } + copy_file(&entry, &destination.join(relative))?; + } + Ok(()) +} + +pub(crate) fn sorted_children(path: &Path) -> Result> { + let mut children = fs::read_dir(path) + .with_context(|| format!("read directory {}", path.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>() + .with_context(|| format!("read child in {}", path.display()))?; + children.sort(); + Ok(children) +} + +pub(crate) fn sorted_files(path: &Path) -> Result> { + let mut files = Vec::new(); + for entry in WalkDir::new(path) { + let entry = entry.with_context(|| format!("walk {}", path.display()))?; + if entry.file_type().is_file() { + files.push(entry.path().to_path_buf()); + } + } + files.sort(); + Ok(files) +} + +pub(crate) fn copy_file(source: &Path, destination: &Path) -> Result<()> { + ensure_file(source)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::copy(source, destination) + .with_context(|| format!("copy {} -> {}", source.display(), destination.display()))?; + Ok(()) +} + +pub(crate) fn copy_dir_all(source: &Path, destination: &Path) -> Result<()> { + if destination.exists() { + fs::remove_dir_all(destination) + .with_context(|| format!("remove {}", destination.display()))?; + } + fs::create_dir_all(destination).with_context(|| format!("create {}", destination.display()))?; + for entry in WalkDir::new(source) { + let entry = entry.with_context(|| format!("walk {}", source.display()))?; + let path = entry.path(); + let relative = path + .strip_prefix(source) + .with_context(|| format!("strip {} from {}", source.display(), path.display()))?; + if relative.as_os_str().is_empty() { + continue; + } + let output = destination.join(relative); + if entry.file_type().is_dir() { + fs::create_dir_all(&output).with_context(|| format!("create {}", output.display()))?; + } else if entry.file_type().is_file() { + copy_file(path, &output)?; + } + } + Ok(()) +} + +pub(crate) fn ensure_file(path: &Path) -> Result<()> { + if !path.is_file() { + bail!("expected file missing: {}", path.display()); + } + Ok(()) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.eq_ignore_ascii_case("exe")) + .unwrap_or(false) +} + +pub(crate) fn sha256_file(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + Ok(sha256_bytes(&bytes)) +} + +pub(crate) fn sha256_text_file_lf(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + Ok(sha256_bytes(text.replace("\r\n", "\n").as_bytes())) +} + +pub(crate) fn decode_zstd_file(path: &Path) -> Result> { + let file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut decoder = zstd::stream::read::Decoder::new(file) + .with_context(|| format!("create zstd decoder for {}", path.display()))?; + let mut raw = Vec::new(); + io::copy(&mut decoder, &mut raw).with_context(|| format!("decompress {}", path.display()))?; + Ok(raw) +} + +pub(crate) fn sha256_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_text_file_lf_is_independent_of_crlf_checkout() { + let path = std::env::temp_dir().join(format!( + "oliphaunt-xtask-crlf-hash-{}.txt", + std::process::id() + )); + fs::write(&path, "alpha\r\nbeta\r\n").expect("write CRLF fixture"); + let actual = sha256_text_file_lf(&path).expect("hash normalized text"); + fs::remove_file(&path).ok(); + + assert_eq!(actual, sha256_bytes(b"alpha\nbeta\n")); + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs new file mode 100644 index 00000000..06eb640a --- /dev/null +++ b/tools/xtask/src/main.rs @@ -0,0 +1,536 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +mod aot_serializer; +mod asset_checks; +mod asset_io; +mod asset_manifest; +mod asset_pipeline; +mod extension_catalog; +mod fs_utils; +mod postgres_guard; +mod release_workspace; +mod source_spine; +mod template_runner; + +use crate::aot_serializer::aot_serializer; +use crate::asset_checks::*; +#[cfg(test)] +use crate::asset_io::ensure_aot_manifest_matches_source_lane; +use crate::asset_io::{download_assets, install_local_assets, run_asset_smoke_tests}; +use crate::asset_manifest::*; +use crate::asset_pipeline::*; +use crate::fs_utils::*; +use crate::postgres_guard::{ + check_postgres_source_spine, check_prepared_postgres_source, check_rust_startup_abi_boundary, + check_source_lane_isolation, check_wasix_shell_script_syntax, postgres_default_source_dir, + postgres_expected_source_fingerprint, +}; +use crate::release_workspace::{ + package_release_assets, run_in_release_workspace, stage_release_workspace, +}; +use crate::source_spine::{ + SourceFetchScope, check_source_spine_for_source_lane, check_sources_manifest, + check_sources_manifest_for_asset_build, fetch_pinned_sources_for_source_lane, + load_sources_manifest, validate_sources_manifest, +}; + +const WASIX_BUILD_SOURCE_ROOT: &str = "src/runtimes/liboliphaunt/wasix/assets/build"; +const WASIX_GENERATED_BUILD_DIR: &str = "target/oliphaunt-wasix/wasix-build/build"; +const WASIX_GENERATED_WORK_DIR: &str = "target/oliphaunt-wasix/wasix-build/work"; +const WASIX_DOCKER_BUILD_DIR: &str = "target/oliphaunt-wasix/wasix-build/work/docker-oliphaunt"; +const WASIX_POSTGRES_WORK_DIR: &str = "target/oliphaunt-wasix/wasix-build"; +const WASIX_POSTGRES_GENERATED_BUILD_DIR: &str = WASIX_GENERATED_BUILD_DIR; +const WASIX_POSTGRES_DOCKER_BUILD_DIR: &str = WASIX_DOCKER_BUILD_DIR; +const WASIX_PATCHED_SOURCE_DIR: &str = + "target/oliphaunt-wasix/wasix-build/work/postgres18-wasix-src"; +const WASIX_BUILD_MANIFEST_PATH: &str = "target/oliphaunt-wasix/wasix-build/build/outputs.json"; +const WASIX_POSTGRES_BUILD_MANIFEST_PATH: &str = WASIX_BUILD_MANIFEST_PATH; +const WASIX_BRIDGE_PATH: &str = + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_bridge.c"; +const POSTGRES_SOURCE_MANIFEST_PATH: &str = + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/source.toml"; +const POSTGRES_SHARED_SOURCE_MANIFEST_PATH: &str = "src/postgres/versions/18/source.toml"; +const POSTGRES_PATCH_DIR: &str = "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches"; +const POSTGRES_PATCH_SERIES_PATH: &str = + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/series"; +const POSTGRES_EXPERIMENT_DISPOSITION_PATH: &str = + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/experiment-patch-disposition.toml"; +const POSTGRES_PREPARE_SCRIPT: &str = + "src/runtimes/liboliphaunt/wasix/assets/build/prepare_postgres_source.sh"; +const DEFAULT_SOURCE_LANE: &str = "stable"; +const DEFAULT_ASSET_BUILD_PROFILE: &str = "release"; +const SOURCE_CHECKOUT_ROOT: &str = "target/oliphaunt-sources/checkouts"; +const ASSET_INPUT_FINGERPRINT_PATH: &str = + "src/runtimes/liboliphaunt/wasix/assets/generated/asset-inputs.sha256"; +const GENERATED_ASSETS_DIR: &str = "target/oliphaunt-wasix/assets"; +const GENERATED_AOT_DIR: &str = "target/oliphaunt-wasix/aot"; +const ASSET_CRATE_PAYLOAD_DIR: &str = "src/runtimes/liboliphaunt/wasix/crates/assets/payload"; +const RELEASE_STAGE_DIR: &str = "target/oliphaunt-wasix/release"; +const RELEASE_ASSET_BUNDLE_DIR: &str = "target/oliphaunt-wasix/release-assets"; +const LEGACY_STATIC_WASI_ARCHIVE: &str = concat!("assets/", "oliphaunt-", "wasi.tar.zst"); +const RUST_HOST_REQUIRED_RUNTIME_EXPORTS: &[&str] = &[ + "_start", + "oliphaunt_wasix_set_active", + "oliphaunt_wasix_start", + "oliphaunt_wasix_get_proc_port", + "ProcessStartupPacket", + "oliphaunt_wasix_send_conn_data", + "oliphaunt_wasix_pq_flush", + "pq_buffer_remaining_data", + "PostgresMainLoopOnce", + "PostgresSendReadyForQueryIfNecessary", + "PostgresMainLongJmp", + "oliphaunt_wasix_protocol_stream_active", + "oliphaunt_wasix_input_reset", + "oliphaunt_wasix_input_write", + "oliphaunt_wasix_input_available", + "oliphaunt_wasix_output_reset", + "oliphaunt_wasix_output_len", + "oliphaunt_wasix_output_read", +]; +const RUST_HOST_OPTIONAL_RUNTIME_EXPORTS: &[&str] = &[ + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_run_atexit_funcs", + "oliphaunt_wasix_backend_timing_reset", + "oliphaunt_wasix_backend_timing_elapsed_us", + "oliphaunt_wasix_set_protocol_transport", +]; +const RUNTIME_EXPORT_LIST_COMPAT_EXPORTS: &[&str] = &[ + "oliphaunt_wasix_set_protocol_stdio", + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_set_protocol_transport", +]; +const PG18_POSTGRES_HOST_EXPORTS: &[&str] = &[ + "ProcessStartupPacket", + "oliphaunt_wasix_start", + "oliphaunt_wasix_pq_flush", + "oliphaunt_wasix_get_proc_port", + "oliphaunt_wasix_send_conn_data", + "PostgresSendReadyForQueryIfNecessary", + "PostgresMainLongJmp", + "PostgresMainLoopOnce", +]; + +fn main() -> Result<()> { + let mut args = env::args().skip(1); + match args.next().as_deref() { + Some("assets") => assets(args.collect()), + Some("extensions") => extension_catalog::extensions(args.collect()), + Some("release") => release(args.collect()), + Some("package-size") => package_size(args.collect()), + Some("aot-serializer") => aot_serializer(args.collect()), + Some("help") | None => { + print_usage(); + Ok(()) + } + Some(other) => bail!("unknown xtask command: {other}"), + } +} + +fn assets(args: Vec) -> Result<()> { + match args.first().map(String::as_str) { + Some("check") => { + let strict_local = args.iter().any(|arg| arg == "--strict-local"); + let strict_generated = args.iter().any(|arg| arg == "--strict-generated"); + let release_staged = is_release_staged_workspace(); + let manifest = check_sources_manifest(strict_local)?; + check_source_free_repo()?; + check_no_legacy_runtime_shims()?; + check_production_wasix_build_inputs()?; + check_postgres_source_spine()?; + check_source_lane_isolation()?; + check_rust_startup_abi_boundary()?; + check_canonical_asset_layout(strict_generated)?; + check_generated_manifest(&manifest, strict_generated)?; + if strict_generated { + verify_asset_manifest_hashes()?; + verify_generated_extension_surface()?; + } + if !release_staged { + extension_catalog::check_catalog_file(strict_generated)?; + extension_catalog::check_build_plan_file(strict_generated)?; + } + check_generated_wasix_export_list(strict_generated) + } + Some("verify-committed") => verify_committed_assets(), + Some("audit-upstream") => { + let strict = args.iter().any(|arg| arg == "--strict"); + let manifest = check_sources_manifest(false)?; + audit_upstream_fixes(&manifest, strict) + } + Some("build") => { + let manifest = check_sources_manifest(false)?; + let profile = value_after(&args, "--profile").unwrap_or(DEFAULT_ASSET_BUILD_PROFILE); + let target = value_after(&args, "--target-triple").unwrap_or(env::consts::ARCH); + build_asset_spine(&manifest, profile, target, &args) + } + Some("template") => { + let manifest = check_sources_manifest(false)?; + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + generate_pgdata_template_asset(&manifest, source_lane) + } + Some("fetch") => { + let manifest = load_sources_manifest()?; + validate_sources_manifest(&manifest)?; + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + let prepare_postgres_source = !args.iter().any(|arg| arg == "--skip-postgres-prepare"); + let source_scope = + SourceFetchScope::parse(value_after(&args, "--scope").unwrap_or("all"))?; + fetch_pinned_sources_for_source_lane( + &manifest, + source_lane, + prepare_postgres_source, + source_scope, + ) + } + Some("release-build") => { + let manifest = check_sources_manifest_for_asset_build(&args)?; + let profile = value_after(&args, "--profile").unwrap_or(DEFAULT_ASSET_BUILD_PROFILE); + let target = value_after(&args, "--target-triple").unwrap_or(host_target_triple()); + release_build_assets(&manifest, profile, target, &args) + } + Some("build-host") => { + let manifest = check_sources_manifest_for_asset_build(&args)?; + release_build_assets( + &manifest, + DEFAULT_ASSET_BUILD_PROFILE, + host_target_triple(), + &args, + ) + } + Some("download") => download_assets(&args), + Some("install-local") => install_local_assets(&args), + Some("update-root-metadata") => update_staged_root_asset_metadata(Path::new(".")), + Some("ci-matrix") => print_aot_ci_matrix(&args), + Some("ci-artifacts") => print_ci_artifact_names(), + Some("aot-targets") => print_supported_aot_targets(), + Some("internal-packages") => print_internal_asset_packages(), + Some("package") => { + let manifest = check_sources_manifest(false)?; + let target = value_after(&args, "--target-triple").unwrap_or(host_target_triple()); + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + if args.iter().any(|arg| arg == "--skip-aot") { + package_assets_without_aot(&manifest, source_lane) + } else { + package_assets(&manifest, target, source_lane) + } + } + Some("package-aot") => { + let manifest = check_sources_manifest(false)?; + let target = value_after(&args, "--target-triple").unwrap_or(host_target_triple()); + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + package_aot_only(&manifest, target, source_lane) + } + Some("check-aot") => { + let target = value_after(&args, "--target-triple").unwrap_or(host_target_triple()); + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + check_aot_package_manifest(target, source_lane) + } + Some("export-list") => { + let write = args.iter().any(|arg| arg == "--write"); + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + generate_wasix_export_list(write, source_lane) + } + Some("input-fingerprint") => { + let write = args.iter().any(|arg| arg == "--write"); + check_or_write_asset_input_fingerprint(write) + } + Some("aot") => { + let target = value_after(&args, "--target-triple").unwrap_or(host_target_triple()); + let source_lane = value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE); + generate_aot_artifacts(target, source_lane) + } + Some("source-spine") => { + let check_patch = args.iter().any(|arg| arg == "--check-patch-applies"); + let source_lane = canonical_source_lane( + value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE), + )?; + let manifest = load_sources_manifest()?; + validate_sources_manifest(&manifest)?; + println!( + "validated {} pinned asset sources for {source_lane}", + manifest.sources.len() + ); + let strict_local = source_lane == DEFAULT_SOURCE_LANE + || args.iter().any(|arg| arg == "--strict-local"); + check_source_spine_for_source_lane(&manifest, source_lane, strict_local, check_patch) + } + Some("smoke") => run_asset_smoke_tests(&args[1..]), + Some(other) => bail!("unknown assets subcommand: {other}"), + None => { + bail!( + "usage: cargo run -p xtask -- assets " + ) + } + } +} + +fn release(args: Vec) -> Result<()> { + match args.first().map(String::as_str) { + Some("stage") => stage_release_workspace(), + Some("package-assets") => package_release_assets(), + Some("dry-run") => { + stage_release_workspace()?; + run_in_release_workspace( + "tools/release/release.py", + &["publish-dry-run", "--staged-wasm", "--allow-dirty"], + ) + } + Some("publish") => { + stage_release_workspace()?; + run_in_release_workspace( + "tools/release/release.py", + &["publish-dry-run", "--staged-wasm", "--allow-dirty"], + )?; + bail!( + "xtask release publish staged and validated the release workspace; publishing belongs to the protected Release workflow" + ) + } + Some(other) => bail!("unknown release subcommand: {other}"), + None => { + bail!("usage: cargo run -p xtask -- release ") + } + } +} + +fn package_size(args: Vec) -> Result<()> { + let enforce = args.iter().any(|arg| arg == "--enforce"); + let source_lane = + canonical_source_lane(value_after(&args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE))?; + ensure!( + source_lane == DEFAULT_SOURCE_LANE, + "package-size checks publishable crate tarballs for the stable source lane only; source lane {source_lane:?} is legacy-only" + ); + let package_dir = Path::new("target/package"); + if !package_dir.exists() { + fs::create_dir_all(package_dir) + .with_context(|| format!("create {}", package_dir.display()))?; + } else { + fs::remove_dir_all(package_dir) + .with_context(|| format!("remove {}", package_dir.display()))?; + } + run( + "cargo", + &[ + "package", + "--workspace", + "--exclude", + "xtask", + "--locked", + "--no-verify", + "--allow-dirty", + ], + )?; + + let limit = 10 * 1024 * 1024; + let mut failures = Vec::new(); + for entry in WalkDir::new(package_dir).max_depth(1) { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("crate") { + continue; + } + let size = entry.metadata()?.len(); + println!("{} {} bytes", path.display(), size); + if size > limit { + failures.push((path.to_path_buf(), size)); + } + } + + if enforce && !failures.is_empty() { + let details = failures + .iter() + .map(|(path, size)| format!("{} ({size} bytes)", path.display())) + .collect::>() + .join(", "); + bail!("crate package size limit exceeded: {details}"); + } + Ok(()) +} + +fn enforce_package_size_for_source_lane(source_lane: &str) -> Result<()> { + package_size(vec![ + "--enforce".to_owned(), + "--source-lane".to_owned(), + source_lane.to_owned(), + ]) +} + +fn host_target_triple() -> &'static str { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + return "aarch64-apple-darwin"; + } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { + return "x86_64-unknown-linux-gnu"; + } + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + { + return "aarch64-unknown-linux-gnu"; + } + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + { + return "x86_64-pc-windows-msvc"; + } + #[allow(unreachable_code)] + "unsupported" +} + +fn ensure_eq(actual: &str, expected: &str, field: &str) -> Result<()> { + if actual != expected { + bail!("{field} must be '{expected}', got '{actual}'"); + } + Ok(()) +} + +fn ensure_contains(values: &[String], expected: &str, field: &str) -> Result<()> { + if !values.iter().any(|value| value == expected) { + bail!("{field} must contain '{expected}'"); + } + Ok(()) +} + +fn ensure_no_flag_contains(values: &[String], forbidden: &str, field: &str) -> Result<()> { + let forbidden_lower = forbidden.to_ascii_lowercase(); + if let Some(value) = values + .iter() + .find(|value| value.to_ascii_lowercase().contains(&forbidden_lower)) + { + bail!("{field} must not contain '{forbidden}', got '{value}'"); + } + Ok(()) +} + +fn command_output(command: &str, args: &[&str], cwd: &Path) -> Result { + let output = Command::new(command) + .args(args) + .current_dir(cwd) + .stderr(Stdio::inherit()) + .output() + .map_err(|err| anyhow!("failed to spawn {command}: {err}"))?; + if !output.status.success() { + bail!("{command} {} failed with {}", args.join(" "), output.status); + } + String::from_utf8(output.stdout).context("command output was not valid UTF-8") +} + +pub(crate) fn value_after<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.windows(2) + .find(|window| window[0] == name) + .map(|window| window[1].as_str()) +} + +fn run(command: &str, args: &[&str]) -> Result<()> { + let mut command = command_for_host(command); + command.args(args); + run_command(&mut command) +} + +fn command_for_host(command: &str) -> Command { + if cfg!(windows) + && Path::new(command) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("sh")) + { + let mut shell = Command::new(windows_bash_path()); + shell.arg("--noprofile").arg("--norc"); + shell.arg(command); + return shell; + } + Command::new(command) +} + +#[cfg(windows)] +fn windows_bash_path() -> PathBuf { + for path in [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + ] { + let path = PathBuf::from(path); + if path.is_file() { + return path; + } + } + PathBuf::from("bash") +} + +#[cfg(not(windows))] +fn windows_bash_path() -> &'static str { + "bash" +} + +fn run_command(command: &mut Command) -> Result<()> { + let status = command + .status() + .map_err(|err| anyhow!("failed to spawn command: {err}"))?; + if !status.success() { + bail!("command failed with {status}"); + } + Ok(()) +} + +fn print_usage() { + eprintln!("usage:"); + eprintln!(" cargo run -p xtask -- assets check [--strict-local] [--strict-generated]"); + eprintln!(" cargo run -p xtask -- assets verify-committed"); + eprintln!(" cargo run -p xtask -- assets audit-upstream [--strict]"); + eprintln!( + " cargo run -p xtask -- assets source-spine [--strict-local] [--check-patch-applies]" + ); + eprintln!( + " cargo run -p xtask -- assets fetch [--skip-postgres-prepare] [--scope all|native-runtime|wasix-runtime|extensions]" + ); + eprintln!(" cargo run -p xtask --features aot-serializer -- assets build-host"); + eprintln!( + " cargo run -p xtask -- assets download --sha [--required-job ] --target " + ); + eprintln!(" cargo run -p xtask -- assets download --run-id --all-targets"); + eprintln!(" cargo run -p xtask -- assets download --latest-compatible --target "); + eprintln!(" cargo run -p xtask -- assets download --release --target "); + eprintln!(" cargo run -p xtask -- assets install-local --target-triple "); + eprintln!( + " cargo run -p xtask -- assets ci-matrix [--target ] [--github-output]" + ); + eprintln!(" cargo run -p xtask -- assets ci-artifacts"); + eprintln!(" cargo run -p xtask -- assets aot-targets"); + eprintln!(" cargo run -p xtask -- assets internal-packages"); + eprintln!(" cargo run -p xtask -- assets input-fingerprint --write"); + eprintln!( + " cargo run -p xtask -- assets build --profile release --target-triple [--execute]" + ); + eprintln!(" cargo run -p xtask --features template-runner -- assets template"); + eprintln!( + " cargo run -p xtask --features template-runner -- assets release-build --profile release --target-triple [--fetch]" + ); + eprintln!(" cargo run -p xtask -- assets aot --target-triple "); + eprintln!( + " cargo run -p xtask --features aot-serializer -- assets package [--target-triple ] [--skip-aot]" + ); + eprintln!(" cargo run -p xtask -- assets package-aot [--target-triple ]"); + eprintln!(" cargo run -p xtask -- assets check-aot [--target-triple ]"); + eprintln!(" cargo run -p xtask -- assets export-list [--write]"); + eprintln!(" cargo run -p xtask -- assets smoke"); + eprintln!(" cargo run -p xtask -- release stage"); + eprintln!(" cargo run -p xtask -- release package-assets"); + eprintln!(" cargo run -p xtask -- release dry-run"); + eprintln!(" cargo run -p xtask -- release publish"); + eprintln!(" cargo run -p xtask -- extensions discover [--write]"); + eprintln!(" cargo run -p xtask -- extensions build-plan [--write|--check]"); + eprintln!(" cargo run -p xtask -- extensions generate"); + eprintln!(" cargo run -p xtask -- extensions check"); + eprintln!(" cargo run -p xtask -- package-size --enforce"); + eprintln!(" cargo run -p oliphaunt-perf -- bench"); + eprintln!(" cargo run -p oliphaunt-perf -- native-liboliphaunt --engine direct --suite rtt"); + eprintln!(" cargo run -p oliphaunt-perf -- native-postgres --suite rtt"); +} diff --git a/tools/xtask/src/postgres_guard.rs b/tools/xtask/src/postgres_guard.rs new file mode 100644 index 00000000..f00de86a --- /dev/null +++ b/tools/xtask/src/postgres_guard.rs @@ -0,0 +1,1713 @@ +use super::*; +use crate::source_spine::source_checkout_path; + +pub(crate) fn check_wasix_shell_script_syntax() -> Result<()> { + for script in wasix_build_shell_scripts()? { + let mut command = Command::new("bash"); + command.arg("-n").arg(&script); + run_command(&mut command).with_context(|| format!("syntax check {}", script.display()))?; + } + Ok(()) +} + +pub(crate) fn wasix_build_shell_scripts() -> Result> { + let mut scripts = sorted_children(Path::new(WASIX_BUILD_SOURCE_ROOT))? + .into_iter() + .filter(|path| path.is_file()) + .filter(|path| path.extension().and_then(|extension| extension.to_str()) == Some("sh")) + .collect::>(); + let external_root = Path::new("src/extensions/external"); + if external_root.exists() { + for entry in WalkDir::new(external_root) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) + .map(|entry| entry.into_path()) + .filter(|path| path.extension().and_then(|extension| extension.to_str()) == Some("sh")) + { + scripts.push(entry); + } + } + scripts.sort(); + scripts.dedup(); + ensure!( + !scripts.is_empty(), + "WASIX build source root has no shell scripts: {WASIX_BUILD_SOURCE_ROOT}" + ); + Ok(scripts) +} + +pub(crate) fn check_postgres_source_spine() -> Result<()> { + let manifest = load_postgres_source_manifest()?; + ensure_eq( + &manifest.postgresql.version, + "18.4", + "pinned PostgreSQL version", + )?; + ensure_eq( + &manifest.postgresql.sha256, + "81a81ec695fb0c7901407defaa1d2f7973617154cf27ba74e3a7ab8e64436094", + "pinned PostgreSQL source sha256", + )?; + + let series_text = fs::read_to_string(POSTGRES_PATCH_SERIES_PATH) + .with_context(|| format!("read {POSTGRES_PATCH_SERIES_PATH}"))?; + let series = series_text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect::>(); + let file_series = series + .iter() + .map(|entry| (*entry).to_owned()) + .collect::>(); + ensure!( + manifest.patches.series == file_series, + "{} [patches].series must exactly match {}", + POSTGRES_SOURCE_MANIFEST_PATH, + POSTGRES_PATCH_SERIES_PATH + ); + check_prepared_postgres_source_if_present(&manifest)?; + for required in [ + "0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch", + "0002-oliphaunt-wasix-add-backend-host-io-hooks.patch", + "0003-oliphaunt-wasix-export-startup-packet-parser.patch", + "0004-oliphaunt-wasix-add-host-lifecycle-exports.patch", + "0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch", + "0006-oliphaunt-wasix-report-copy-protocol-state.patch", + "0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch", + "0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch", + "0009-oliphaunt-wasix-route-process-identity-through-port.patch", + "0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch", + "0011-oliphaunt-wasix-prefer-posix-semaphores.patch", + "0012-oliphaunt-wasix-capture-startup-errors.patch", + "0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch", + "0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch", + "0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch", + "0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch", + "0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch", + "0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch", + "0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch", + "0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch", + "0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch", + "0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch", + "0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch", + ] { + ensure!( + series.contains(&required), + "{} must list required PG18 WASIX patch {required}", + POSTGRES_PATCH_SERIES_PATH + ); + } + + let mut seen = BTreeSet::new(); + let mut combined_patch_text = String::new(); + let mut patch_texts = Vec::new(); + for patch_name in &series { + ensure!( + !patch_name.contains('/') && patch_name.ends_with(".patch"), + "{} contains invalid PG18 patch entry {patch_name:?}", + POSTGRES_PATCH_SERIES_PATH + ); + ensure!( + seen.insert(*patch_name), + "{} contains duplicate PG18 patch entry {patch_name}", + POSTGRES_PATCH_SERIES_PATH + ); + let patch_path = Path::new(POSTGRES_PATCH_DIR).join(patch_name); + ensure_file(&patch_path)?; + let text = fs::read_to_string(&patch_path) + .with_context(|| format!("read {}", patch_path.display()))?; + combined_patch_text.push_str(&text); + combined_patch_text.push('\n'); + patch_texts.push(((*patch_name).to_owned(), text)); + } + check_postgres_patch_series_hygiene(&patch_texts)?; + + for entry in + fs::read_dir(POSTGRES_PATCH_DIR).with_context(|| format!("read {POSTGRES_PATCH_DIR}"))? + { + let entry = entry.with_context(|| format!("read entry in {POSTGRES_PATCH_DIR}"))?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("patch") { + continue; + } + let patch_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("invalid PG18 patch filename {}", path.display()))?; + ensure!( + seen.contains(patch_name), + "{} contains orphan patch file {} not listed in {}", + POSTGRES_PATCH_DIR, + patch_name, + POSTGRES_PATCH_SERIES_PATH + ); + } + + for banned in [ + concat!("__PG", "LITE__"), + concat!("PG", "LITE_WASIX_DL"), + concat!("PG", "LITE_HOST_EXPORT"), + concat!("postgres-", "pg", "lite"), + concat!("postgres-", "pg", "lite-wasix-src"), + concat!("REL_17_5-", "pg", "lite"), + "prepare_patched_source.sh", + "ProcessStartupPacket: STUB", + "need_tas=no", + "--disable-spinlocks", + ] { + ensure!( + !combined_patch_text.contains(banned), + "PG18 WASIX patch stack must not inherit released Oliphaunt marker {banned:?}" + ); + } + + check_postgres_host_export_surface(&combined_patch_text)?; + check_postgres_legacy_symbol_leaks(&manifest)?; + check_postgres_released_lane_boundary()?; + ensure_pg18_experiment_patch_disposition()?; + + ensure_file_contains_all( + POSTGRES_PREPARE_SCRIPT, + &[ + "source_has_patch_artifacts", + "patch --no-backup-if-mismatch -p1", + "*.orig", + "*.rej", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh", + &[ + "prepare_postgres_source.sh", + "--with-template=wasix-dl", + "OLIPHAUNT_SHIM", + "-sMODULE_KIND=dynamic-main", + "-Dlongjmp=oliphaunt_wasix_longjmp", + "-Dsiglongjmp=oliphaunt_wasix_siglongjmp", + ], + )?; + ensure_file_not_contains_any( + "src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh", + &[ + concat!("__PG", "LITE__"), + concat!("PG", "LITE_WASIX_DL"), + concat!("PG", "LITE_HOST_EXPORT"), + "--disable-spinlocks", + "-Dgeteuid=oliphaunt_wasix_geteuid", + "-Dgetuid=oliphaunt_wasix_getuid", + "-Dgetegid=oliphaunt_wasix_getegid", + "-Dgetgid=oliphaunt_wasix_getgid", + "-Dgetpwuid=oliphaunt_wasix_getpwuid", + "-Dshmget=oliphaunt_wasix_shmget", + "-Dshmat=oliphaunt_wasix_shmat", + "-Dshmdt=oliphaunt_wasix_shmdt", + "-Dshmctl=oliphaunt_wasix_shmctl", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", + &[ + ". \"$ROOT/source_lane.sh\"", + "SOURCE_LANE=\"$(oliphaunt_wasix_source_lane)\"", + "oliphaunt_wasix_default_build_dir \"$SOURCE_LANE\"", + "oliphaunt_wasix_prepare_source_for_docker \"$SOURCE_LANE\"", + "configure_wasix_dl.sh", + "OLIPHAUNT_WASM_SOURCE_LANE=\"$SOURCE_LANE\"", + ".oliphaunt-wasix-source-fingerprint", + ".oliphaunt-wasix-postgres-version", + ".oliphaunt-wasix-bridge-sha256", + ".oliphaunt-wasix-build-profile", + "make -s -j\"$JOBS\" -C \"$BUILD_DIR/src/backend\" oliphaunt", + "/usr/sbin/zic", + "src/timezone/compiled/UTC", + "src/timezone/compiled/America/New_York", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_runtime_support.sh", + &[ + "make -s -j\"$JOBS\" -C \"$BUILD_DIR/src/pl/plpgsql/src\" all", + "make -s -j\"$JOBS\" -C \"$BUILD_DIR/src/backend/snowball\" all", + "src/pl/plpgsql/src/plpgsql.so", + "src/backend/snowball/dict_snowball.so", + "src/backend/snowball/snowball_create.sql", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh", + &[ + "oliphaunt_wasix_generated_build_dir", + "oliphaunt_wasix_scratch_build_dir", + "$CONTAINER_GENERATED_ROOT/build", + ".oliphaunt-wasix-source-fingerprint", + ".oliphaunt-wasix-postgres-version", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", + &[ + "oliphaunt_wasix_scratch_build_dir", + "wasix-initdb", + "-Dgetegid=oliphaunt_wasix_getegid", + "-Dgetgid=oliphaunt_wasix_getgid", + "-Dgetpwuid_r=oliphaunt_wasix_getpwuid_r", + ], + )?; + ensure_file_not_contains_any( + "src/runtimes/liboliphaunt/wasix/assets/build/docker_initdb.sh", + &["$CONTAINER_GENERATED_ROOT/build/wasix-initdb"], + )?; + ensure_file_contains_all( + "tools/xtask/src/asset_pipeline.rs", + &["let control_file = extension_source.join(format!(\"{}.control\", extension.sql_name));"], + )?; + + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0002-oliphaunt-wasix-add-backend-host-io-hooks.patch", + &[ + "OliphauntWasmHostIO", + "oliphaunt_wasix_io", + "secure_raw_read", + "secure_raw_write", + "WL_POSTMASTER_DEATH", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0001-oliphaunt-wasix-add-wasix-dl-build-spine.patch", + &[ + "src/template/wasix-dl", + "DOLIPHAUNT_WASM_SINGLE_USER", + "does not disable spinlocks", + "oliphaunt: $(OBJS)", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0004-oliphaunt-wasix-add-host-lifecycle-exports.patch", + &[ + "OLIPHAUNT_WASM_HOST_EXPORT(\"oliphaunt_wasix_start\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"oliphaunt_wasix_send_conn_data\")", + "oliphaunt_wasix_process_startup_options", + "ReadyForQuery(DestRemote)", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0005-oliphaunt-wasix-add-loop-pumped-protocol-exports.patch", + &[ + "OLIPHAUNT_WASM_HOST_EXPORT(\"PostgresMainLoopOnce\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"PostgresMainLongJmp\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"PostgresSendReadyForQueryIfNecessary\")", + "PostgresSendReadyForQueryIfNecessary();", + "PG_exception_stack = &postgresmain_sigjmp_buf", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0006-oliphaunt-wasix-report-copy-protocol-state.patch", + &[ + "src/backend/commands/copyfromparse.c", + "src/backend/commands/copyto.c", + "src/backend/replication/walsender.c", + "oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_IN)", + "oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT)", + "oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH)", + "extern void oliphaunt_wasix_protocol_report_copy_response(int state)", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0007-oliphaunt-wasix-add-wasix-pgxs-side-module-support.patch", + &[ + "src/makefiles/Makefile.wasix-dl", + "WASM_DL_NM ?= wasixnm", + "wasm_dl_extension_imports_dir := $(wasm_dl_extension_dir)/imports", + "$(DESTDIR)$(wasm_dl_extension_imports_dir)", + "for mod in $(MODULES); do", + "PORTNAME=wasix-dl", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0008-oliphaunt-wasix-reset-copy-state-on-error-recovery.patch", + &[ + "PostgresMainLongJmp", + "pq_comm_reset();", + "oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE)", + "before the ErrorResponse is written", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0009-oliphaunt-wasix-route-process-identity-through-port.patch", + &[ + "src/include/port/wasix-dl.h", + "extern uid_t oliphaunt_wasix_geteuid(void)", + "extern gid_t oliphaunt_wasix_getegid(void)", + "extern int oliphaunt_wasix_getpwuid_r", + "#define geteuid oliphaunt_wasix_geteuid", + "#define getegid oliphaunt_wasix_getegid", + "#define getpwuid_r oliphaunt_wasix_getpwuid_r", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0010-oliphaunt-wasix-route-sysv-shmem-through-port.patch", + &[ + "src/include/port/wasix-dl/sys/ipc.h", + "src/include/port/wasix-dl/sys/shm.h", + "extern int oliphaunt_wasix_shmget(key_t key, size_t size, int shmflg)", + "#define shmget oliphaunt_wasix_shmget", + "#define shmctl oliphaunt_wasix_shmctl", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0011-oliphaunt-wasix-prefer-posix-semaphores.patch", + &[ + "src/template/wasix-dl", + "PREFERRED_SEMAPHORES=UNNAMED_POSIX", + "PostgreSQL's POSIX templates", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0012-oliphaunt-wasix-capture-startup-errors.patch", + &[ + "InitPostgres()", + "oliphaunt_wasix_begin_startup_error_capture", + "oliphaunt_wasix_init_protocol_port()", + "whereToSendOutput = DestRemote", + "oliphaunt_wasix_startup_error_capture_active = 1", + "extern volatile int oliphaunt_wasix_startup_error_capture_active", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0013-oliphaunt-wasix-fail-active-portals-on-host-recovery.patch", + &[ + "src/backend/utils/mmgr/portalmem.c", + "AtAbort_Portals", + "portal->status == PORTAL_ACTIVE", + "is_oliphaunt_active != 0", + "MarkPortalFailed(portal)", + "extern volatile int is_oliphaunt_active", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch", + &[ + "src/common/hashfn.c", + "#if defined(__wasi__) && !defined(WORDS_BIGENDIAN)", + "oliphaunt_wasix_hash_load32", + "memcpy(&value, ptr, sizeof(value))", + "hash_bytes_extended", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch", + &[ + "src/backend/access/transam/xact.c", + "TransactionIdIsCurrentTransactionId", + "nParallelCurrentXids == 0", + "s->parent == NULL", + "s->state != TRANS_ABORT", + "s->nChildXids == 0", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch", + &[ + "src/backend/access/nbtree/nbtsearch.c", + "#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER)", + "INTEGER_BTREE_FAM_OID", + "rel->rd_opcintype[i - 1] == INT4OID", + "scankey->sk_collation == InvalidOid", + "FunctionCall2Coll", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch", + &[ + "src/backend/access/nbtree/nbtdedup.c", + "src/backend/access/nbtree/nbtinsert.c", + "TM_IndexDelete deltids[MaxTIDsPerBTreePage]", + "TM_IndexStatus status[MaxTIDsPerBTreePage]", + "#if !defined(__wasi__) || !defined(OLIPHAUNT_WASM_SINGLE_USER)", + "palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexDelete))", + "palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexStatus))", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch", + &[ + "src/bin/pg_dump/connectdb.c", + "src/bin/pg_dump/connectdb.h", + "src/bin/pg_dump/pg_dumpall.c", + "executeDumpQuery", + "executeQuery LTO collision", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0019-oliphaunt-wasix-schedule-ready-after-host-recovery.patch", + &[ + "src/backend/tcop/postgres.c", + "PostgresMainLongJmp", + "Host-forced ERROR recovery", + "send_ready_for_query = true", + "if (!ignore_till_sync)", + "skip-till-Sync", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0020-oliphaunt-wasix-rearm-exception-stack-after-host-recovery.patch", + &[ + "src/backend/tcop/postgres.c", + "PostgresMainLongJmp", + "PG_exception_stack = &postgresmain_sigjmp_buf", + "top-level exception stack", + "Host-forced recovery", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0032-oliphaunt-wasix-avoid-xlog-size-checkpoint-requests.patch", + &[ + "src/backend/access/transam/xlog.c", + "src/backend/postmaster/checkpointer.c", + "RequestCheckpoint(CHECKPOINT_CAUSE_XLOG)", + "#ifndef OLIPHAUNT_WASM_SINGLE_USER", + "if (!IsPostmasterEnvironment)", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0033-oliphaunt-wasix-use-lightweight-embedded-runtime-paths.patch", + &[ + "src/backend/port/posix_sema.c", + "src/backend/utils/misc/guc.c", + "PGSemaphoreReset", + "sem_trywait(PG_SEM_REF(sema))", + "ReportGUCOption", + "guc_strdup(LOG, val)", + "#ifndef OLIPHAUNT_WASM_SINGLE_USER", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/postgres/patches/0034-oliphaunt-wasix-set-embedded-postmaster-environment.patch", + &[ + "src/backend/tcop/postgres.c", + "oliphaunt_wasix_start", + "IsPostmasterEnvironment = true", + "IsUnderPostmaster = true", + "paired with the prior checkpoint patch", + ], + )?; + ensure_file_contains_all( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs", + &[ + "const DEFAULT_STARTUP_GUCS", + "(\"log_checkpoints\", \"false\")", + "(\"max_wal_senders\", \"0\")", + "(\"wal_buffers\", \"4MB\")", + "(\"min_wal_size\", \"80MB\")", + "(\"shared_buffers\", \"128MB\")", + ], + )?; + ensure_file_contains_all( + WASIX_BRIDGE_PATH, + &[ + "oliphaunt_wasix_startup_error_capture_active", + "oliphaunt_wasix_protocol_report_copy_response", + "oliphaunt_wasix_getegid", + "oliphaunt_wasix_getpwuid_r", + "oliphaunt_wasix_shmget", + "oliphaunt_wasix_shmctl", + "OLIPHAUNT_WASIX_PROTOCOL_COPY_IN", + "OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT", + "OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH", + "oliphaunt_wasix_protocol_stream_requested", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_shim/oliphaunt_wasix_initdb_shim.c", + &[ + "oliphaunt_wasix_getegid", + "oliphaunt_wasix_getgid", + "oliphaunt_wasix_getpwuid_r", + "errno = ERANGE", + ], + )?; + + println!("PostgreSQL source-spine guard passed"); + Ok(()) +} + +fn check_postgres_host_export_surface(combined_patch_text: &str) -> Result<()> { + for &export in PG18_POSTGRES_HOST_EXPORTS { + let marker = format!("OLIPHAUNT_WASM_HOST_EXPORT(\"{export}\")"); + ensure!( + combined_patch_text.contains(&marker), + "PG18 patch stack must explicitly export PostgreSQL host ABI symbol {export}" + ); + } + Ok(()) +} + +fn check_postgres_legacy_symbol_leaks(manifest: &PostgresSourceManifest) -> Result<()> { + let mut roots = vec![ + PathBuf::from(POSTGRES_SOURCE_MANIFEST_PATH), + PathBuf::from(POSTGRES_PATCH_DIR), + PathBuf::from(POSTGRES_PREPARE_SCRIPT), + PathBuf::from("src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh"), + PathBuf::from("src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh"), + PathBuf::from( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs", + ), + ]; + + let prepared_source = postgres_default_source_dir(manifest); + if prepared_source.exists() { + roots.push(prepared_source); + } + for generated_root in [Path::new(WASIX_POSTGRES_DOCKER_BUILD_DIR)] { + if generated_root.exists() { + roots.push(generated_root.to_path_buf()); + } + } + + let banned = [ + concat!("__PG", "LITE__"), + concat!("PG", "LITE_"), + concat!("PG", "L_"), + "pgl_", + concat!("pgl_startPG", "lite"), + concat!("pgl_setPG", "liteActive"), + concat!("pg", "lite_"), + ]; + let mut leaks = Vec::new(); + for root in roots { + if !root.exists() { + continue; + } + if root.is_file() { + collect_pg18_legacy_symbol_leaks(&root, &banned, &mut leaks)?; + continue; + } + for entry in WalkDir::new(&root) { + let entry = entry.with_context(|| format!("walk {}", root.display()))?; + if !entry.file_type().is_file() { + continue; + } + collect_pg18_legacy_symbol_leaks(entry.path(), &banned, &mut leaks)?; + } + } + + ensure!( + leaks.is_empty(), + "PG18 WASIX runtime must not leak legacy fork ABI markers:\n{}", + leaks.join("\n") + ); + Ok(()) +} + +fn collect_pg18_legacy_symbol_leaks( + path: &Path, + banned: &[&str], + leaks: &mut Vec, +) -> Result<()> { + if matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("wasm") + | Some("o") + | Some("a") + | Some("so") + | Some("dylib") + | Some("dll") + | Some("zst") + ) { + return Ok(()); + } + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + if bytes.contains(&0) { + return Ok(()); + } + let Ok(text) = String::from_utf8(bytes) else { + return Ok(()); + }; + for (line_no, line) in text.lines().enumerate() { + for marker in banned { + if line.contains(marker) { + leaks.push(format!( + "{}:{} contains {marker:?}", + path.display(), + line_no + 1 + )); + } + } + } + Ok(()) +} + +fn check_postgres_patch_series_hygiene(patches: &[(String, String)]) -> Result<()> { + ensure!( + patches.len() == 38, + "PG18 WASIX patch series should stay reviewable at exactly 38 patches before adding a new audited step; got {}", + patches.len() + ); + for (index, (patch_name, patch_text)) in patches.iter().enumerate() { + let expected_prefix = format!("{:04}-oliphaunt-wasix-", index + 1); + ensure!( + patch_name.starts_with(&expected_prefix) && patch_name.ends_with(".patch"), + "PG18 patch {patch_name} must use sequential prefix {expected_prefix}*.patch" + ); + ensure!( + !patch_text.contains("TODO") && !patch_text.contains("FIXME"), + "PG18 patch {patch_name} must not carry TODO/FIXME placeholders" + ); + ensure!( + patch_text.contains("From: Oliphaunt Maintainers "), + "PG18 patch {patch_name} must keep the Oliphaunt maintainer header" + ); + let subject = patch_text + .lines() + .find_map(|line| line.strip_prefix("Subject: [PATCH] oliphaunt-wasix: ")) + .ok_or_else(|| anyhow!("PG18 patch {patch_name} is missing an Oliphaunt subject"))?; + let expected_slug = patch_name + .trim_end_matches(".patch") + .trim_start_matches(&expected_prefix); + let actual_slug = pg18_patch_subject_slug(subject); + if patch_name != "0021-oliphaunt-wasix-declare-wasix-fork.patch" { + ensure_eq( + actual_slug.as_str(), + expected_slug, + &format!("PG18 patch {patch_name} subject slug"), + )?; + } + let diff_start = patch_text + .find("\ndiff --git ") + .or_else(|| patch_text.find("\n---\n")) + .ok_or_else(|| anyhow!("PG18 patch {patch_name} is missing a diff body"))?; + let header_text = &patch_text[..diff_start]; + let rationale_lines = header_text + .lines() + .skip_while(|line| !line.starts_with("Subject: ")) + .skip(1) + .filter(|line| { + let line = line.trim(); + !line.is_empty() + && !line.starts_with("---") + && !line.starts_with("From:") + && !line.starts_with("Date:") + }) + .count(); + ensure!( + rationale_lines >= 2, + "PG18 patch {patch_name} must include a short rationale before the diff" + ); + } + Ok(()) +} + +fn pg18_patch_subject_slug(subject: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in subject.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_was_dash = false; + } else if ch == '/' { + continue; + } else if !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + slug.trim_matches('-').to_owned() +} + +fn check_prepared_postgres_source_if_present(manifest: &PostgresSourceManifest) -> Result<()> { + let source = postgres_default_source_dir(manifest); + if !source.exists() { + return Ok(()); + } + check_prepared_postgres_source(manifest, &source, Path::new(WASIX_POSTGRES_WORK_DIR)) +} + +pub(crate) fn check_prepared_postgres_source( + manifest: &PostgresSourceManifest, + source: &Path, + work_root: &Path, +) -> Result<()> { + ensure!( + source.is_dir(), + "prepared PG18 source path is not a directory: {}", + source.display() + ); + + let version_path = source.join(".oliphaunt-wasix-postgres-version"); + let version = fs::read_to_string(&version_path) + .with_context(|| format!("read {}", version_path.display()))?; + ensure_eq( + version.trim(), + manifest.postgresql.version.as_str(), + "prepared PG18 source version marker", + )?; + let expected_fingerprint = postgres_expected_source_fingerprint(manifest)?; + let source_fingerprint_path = source.join(".oliphaunt-wasix-source-fingerprint"); + let source_fingerprint = fs::read_to_string(&source_fingerprint_path) + .with_context(|| format!("read {}", source_fingerprint_path.display()))?; + ensure_eq( + source_fingerprint.trim(), + &expected_fingerprint, + "prepared PG18 source fingerprint marker", + )?; + let work_fingerprint_path = work_root.join(".source-fingerprint"); + let work_fingerprint = fs::read_to_string(&work_fingerprint_path) + .with_context(|| format!("read {}", work_fingerprint_path.display()))?; + ensure_eq( + work_fingerprint.trim(), + &expected_fingerprint, + "prepared PG18 work fingerprint marker", + )?; + + let mut artifacts = Vec::new(); + for entry in WalkDir::new(&source) { + let entry = entry.with_context(|| format!("walk {}", source.display()))?; + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + if matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("orig" | "rej") + ) { + artifacts.push(path.display().to_string()); + } + } + ensure!( + artifacts.is_empty(), + "prepared PG18 source contains patch backup/reject artifacts: {}", + artifacts.join(", ") + ); + + check_postgres_packaging_inputs(&source)?; + check_postgres_applied_runtime_abi(&source)?; + check_postgres_applied_perf_patches(&source)?; + + Ok(()) +} + +pub(crate) fn postgres_default_source_dir(manifest: &PostgresSourceManifest) -> PathBuf { + Path::new(WASIX_POSTGRES_WORK_DIR) + .join("work") + .join(format!( + "postgresql-{}-oliphaunt-wasix-src", + manifest.postgresql.version + )) +} + +pub(crate) fn postgres_work_root_for_source(source: &Path) -> Result { + let work_dir = source.parent().ok_or_else(|| { + anyhow!( + "prepared PG18 source path has no parent work directory: {}", + source.display() + ) + })?; + let work_root = work_dir.parent().ok_or_else(|| { + anyhow!( + "prepared PG18 source path has no parent work root: {}", + source.display() + ) + })?; + Ok(work_root.to_path_buf()) +} + +pub(crate) fn postgres_expected_source_fingerprint( + manifest: &PostgresSourceManifest, +) -> Result { + Ok(format!( + "{}:{}:{}", + manifest.postgresql.version, + manifest.postgresql.sha256, + postgres_patch_series_hash()? + )) +} + +fn postgres_patch_series_hash() -> Result { + let mut hasher = Sha256::new(); + for path in postgres_fingerprint_inputs()? { + let hash = sha256_text_file_lf(&path)?; + hasher.update(hash.as_bytes()); + hasher.update(b"\n"); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn postgres_fingerprint_inputs() -> Result> { + let mut paths = vec![repo_relative_path(POSTGRES_PATCH_SERIES_PATH)]; + for entry in sorted_children(&repo_relative_path(POSTGRES_PATCH_DIR))? { + if entry.extension().and_then(|extension| extension.to_str()) == Some("patch") { + paths.push(entry); + } + } + Ok(paths) +} + +fn check_postgres_applied_runtime_abi(source: &Path) -> Result<()> { + ensure_file_contains_all( + source.join("src/include/port/wasix-dl.h"), + &[ + "extern sigjmp_buf postgresmain_sigjmp_buf", + "extern ssize_t oliphaunt_wasix_host_read", + "extern ssize_t oliphaunt_wasix_host_write", + "extern void oliphaunt_wasix_process_startup_options", + "extern volatile int oliphaunt_wasix_startup_error_capture_active", + "OLIPHAUNT_WASIX_PROTOCOL_COPY_IN", + "OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT", + "OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH", + "extern void oliphaunt_wasix_protocol_report_copy_response(int state)", + ], + )?; + ensure_file_contains_all( + source.join("src/include/libpq/libpq-be.h"), + &[ + "typedef struct OliphauntWasmHostIO", + "(*read) (void *context, void *ptr, size_t len)", + "(*write) (void *context, const void *ptr, size_t len)", + "OliphauntWasmHostIO *oliphaunt_wasix_io", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/libpq/be-secure.c"), + &[ + "port->oliphaunt_wasix_io != NULL", + "port->oliphaunt_wasix_io->read", + "port->oliphaunt_wasix_io->write", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/tcop/backend_startup.c"), + &[ + "OLIPHAUNT_WASM_HOST_EXPORT(\"ProcessStartupPacket\") int", + "ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done)", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/tcop/postgres.c"), + &[ + "OLIPHAUNT_WASM_HOST_EXPORT(\"oliphaunt_wasix_start\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"oliphaunt_wasix_pq_flush\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"oliphaunt_wasix_get_proc_port\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"oliphaunt_wasix_send_conn_data\")", + "oliphaunt_wasix_init_protocol_port", + "oliphaunt_wasix_process_startup_options(MyProcPort)", + "ReadyForQuery(DestRemote)", + "oliphaunt_wasix_startup_error_capture_active = 1", + "OLIPHAUNT_WASM_HOST_EXPORT(\"PostgresSendReadyForQueryIfNecessary\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"PostgresMainLongJmp\")", + "OLIPHAUNT_WASM_HOST_EXPORT(\"PostgresMainLoopOnce\")", + "oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_NONE)", + "PG_exception_stack = &postgresmain_sigjmp_buf", + "if (!ignore_till_sync)", + "send_ready_for_query = true", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/commands/copyfromparse.c"), + &["oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_IN)"], + )?; + ensure_file_contains_all( + source.join("src/backend/commands/copyto.c"), + &["oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_OUT)"], + )?; + ensure_file_contains_all( + source.join("src/backend/replication/walsender.c"), + &[ + "oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_IN)", + "oliphaunt_wasix_protocol_report_copy_response(OLIPHAUNT_WASIX_PROTOCOL_COPY_BOTH)", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/utils/mmgr/portalmem.c"), + &[ + "portal->status == PORTAL_ACTIVE", + "is_oliphaunt_active != 0", + "MarkPortalFailed(portal)", + ], + )?; + ensure_file_contains_all( + WASIX_BRIDGE_PATH, + &[ + "oliphaunt_wasix_set_active", + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_set_protocol_stdio", + "oliphaunt_wasix_set_protocol_transport", + "oliphaunt_wasix_protocol_stream_active", + "oliphaunt_wasix_input_reset", + "oliphaunt_wasix_input_write", + "oliphaunt_wasix_input_available", + "oliphaunt_wasix_output_reset", + "oliphaunt_wasix_output_len", + "oliphaunt_wasix_output_read", + ], + )?; + Ok(()) +} + +fn check_postgres_applied_perf_patches(source: &Path) -> Result<()> { + ensure_file_contains_all( + source.join("src/common/hashfn.c"), + &[ + "oliphaunt_wasix_hash_load32", + "memcpy(&value, ptr, sizeof(value))", + "hash_bytes_extended", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/access/transam/xact.c"), + &[ + "nParallelCurrentXids == 0", + "s->parent == NULL", + "s->nChildXids == 0", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/access/nbtree/nbtsearch.c"), + &[ + "#if defined(__wasi__) && defined(OLIPHAUNT_WASM_SINGLE_USER)", + "INTEGER_BTREE_FAM_OID", + "rel->rd_opcintype[i - 1] == INT4OID", + "FunctionCall2Coll", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/access/nbtree/nbtdedup.c"), + &[ + "TM_IndexDelete deltids[MaxTIDsPerBTreePage]", + "TM_IndexStatus status[MaxTIDsPerBTreePage]", + "#if !defined(__wasi__) || !defined(OLIPHAUNT_WASM_SINGLE_USER)", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/access/nbtree/nbtinsert.c"), + &[ + "TM_IndexDelete deltids[MaxTIDsPerBTreePage]", + "TM_IndexStatus status[MaxTIDsPerBTreePage]", + "#if !defined(__wasi__) || !defined(OLIPHAUNT_WASM_SINGLE_USER)", + ], + )?; + ensure_file_contains_all( + source.join("src/bin/pg_dump/connectdb.c"), + &["executeDumpQuery(PGconn *conn, const char *query)"], + )?; + ensure_file_contains_all( + source.join("src/bin/pg_dump/connectdb.h"), + &["extern PGresult *executeDumpQuery(PGconn *conn, const char *query)"], + )?; + ensure_file_contains_all( + source.join("src/bin/pg_dump/pg_dumpall.c"), + &["executeDumpQuery(conn"], + )?; + ensure_file_contains_all( + source.join("src/backend/tcop/postgres.c"), + &[ + "#ifndef OLIPHAUNT_WASM_SINGLE_USER", + "pgstat_report_query_id(0, true)", + "pgstat_report_query_id(query->queryId, false)", + "pgstat_report_query_id(stmt->queryId, false)", + "pgstat_report_plan_id(0, true)", + "pgstat_report_plan_id(plan->planId, false)", + "pgstat_report_plan_id(stmt->planId, false)", + ], + )?; + ensure_file_contains_all( + source.join("src/backend/optimizer/plan/planner.c"), + &[ + "#ifndef OLIPHAUNT_WASM_SINGLE_USER", + "pgstat_report_plan_id(result->planId, false)", + ], + )?; + Ok(()) +} + +pub(crate) fn check_source_lane_isolation() -> Result<()> { + ensure!( + canonical_source_lane("pg17").is_err(), + "legacy PG17 source lane must not remain selectable after PG18 promotion" + ); + ensure_eq( + canonical_source_lane("released")?, + DEFAULT_SOURCE_LANE, + "canonical stable released alias", + )?; + ensure_eq( + canonical_source_lane("stable")?, + "stable", + "canonical PG18 source lane", + )?; + ensure!( + build_output_manifest_paths_for_source_lane(DEFAULT_SOURCE_LANE)? + .contains(&Path::new(WASIX_BUILD_MANIFEST_PATH)), + "stable PG18 build output manifest fallback path drifted" + ); + ensure!( + build_output_manifest_path_for_source_lane(DEFAULT_SOURCE_LANE)? + == Path::new(WASIX_POSTGRES_BUILD_MANIFEST_PATH), + "stable PG18 build output manifest path drifted" + ); + ensure!( + generated_assets_dir_for_source_lane(DEFAULT_SOURCE_LANE)? + == Path::new(GENERATED_ASSETS_DIR), + "stable portable asset path drifted" + ); + ensure!( + generated_aot_source_dir_for_source_lane("aarch64", "stable")? + == Path::new(WASIX_POSTGRES_GENERATED_BUILD_DIR) + .join("aot") + .join("aarch64"), + "PG18 AOT source path drifted" + ); + ensure!( + generated_aot_dir_for_source_lane("aarch64", DEFAULT_SOURCE_LANE)? + == Path::new(GENERATED_AOT_DIR).join("aarch64"), + "stable AOT artifact path drifted" + ); + + let extension_catalog = fs::read_to_string("tools/xtask/src/extension_catalog.rs") + .context("read tools/xtask/src/extension_catalog.rs for source-lane isolation guard")?; + for marker in [ + "manifest_metadata_by_sql_name_from_generated_plan", + "extension_discovery_inputs_available(false)?", + "extension discovery inputs are unavailable, so generated build plan fallback is required", + ] { + ensure!( + extension_catalog.contains(marker), + "extension catalog source-lane isolation guard is missing marker {marker:?}" + ); + } + let source_lane_sh = + fs::read_to_string("src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh") + .context("read src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh")?; + for marker in [ + "PG18 build source fingerprint mismatch", + "PG18 build PostgreSQL version marker mismatch", + ] { + ensure!( + source_lane_sh.contains(marker), + "source_lane.sh must fail closed with marker {marker:?}" + ); + } + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", + &[ + "include = [", + "\"payload/**\"", + "[dependencies]", + "serde_json = \"1\"", + ], + )?; + let asset_build_rs = + fs::read_to_string("src/runtimes/liboliphaunt/wasix/crates/assets/build.rs") + .context("read src/runtimes/liboliphaunt/wasix/crates/assets/build.rs")?; + for marker in [ + "OLIPHAUNT_WASM_GENERATED_ASSETS_DIR", + "repo_root_from_manifest_dir", + "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml", + "target/oliphaunt-wasix/assets", + "manifest_dir.join(\"payload\")", + "write_source_only_assets", + "source-only-template", + "optional_include_bytes_body(&pg_dump)", + ] { + ensure!( + asset_build_rs.contains(marker), + "asset crate source-only build script guard is missing marker {marker:?}" + ); + } + for marker in [ + "OLIPHAUNT_WASM_SOURCE_LANE", + "validate_asset_manifest_source_lane", + "is_released_source_lane", + "unsupported OLIPHAUNT_WASM_SOURCE_LANE", + ] { + ensure!( + !asset_build_rs.contains(marker), + "asset crate build script must not keep source-lane asset selection marker {marker:?}" + ); + } + ensure_file_contains_all( + "tools/xtask/src/asset_pipeline.rs", + &[ + "stage_recipe_staged_extension(build, extension, stage)", + "archive must not include excluded extension control file", + ], + )?; + ensure_file_contains_all( + "src/runtimes/liboliphaunt/wasix/crates/assets/src/lib.rs", + &[ + "pub source_fingerprint: Option", + "pg18_manifest_metadata_round_trips", + ], + )?; + ensure_file_contains_all( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/assets.rs", + &[ + "asset_manifest_metadata", + "pgdata_template_source_fingerprint", + ], + )?; + ensure_file_contains_all( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/lib.rs", + &["AssetManifestMetadata", "asset_manifest_metadata"], + )?; + ensure_file_contains_all( + "tools/perf/runner/src/report.rs", + &[ + "WasixRuntimeAssetReport", + "wasix_runtime_assets", + "asset_manifest_metadata", + "source_lane: metadata", + "pgdata_template_source_fingerprint", + ], + )?; + ensure_file_contains_all( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/base.rs", + &[ + "pub source_fingerprint: Option", + "embedded PGDATA template source fingerprint mismatch", + "full_runtime_layout_matches_current", + "ensure_existing_pgdata_matches_runtime", + "existing PGDATA at {} is PostgreSQL {}", + "source-fingerprint=", + ], + )?; + ensure_file_contains_all( + "src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/aot.rs", + &[ + "source_fingerprint: Option", + "AOT manifest source fingerprint mismatch", + "AOT manifest is missing postgres-version metadata", + ], + )?; + check_postgres_released_lane_boundary()?; + println!("stable source isolation guard passed"); + Ok(()) +} + +fn check_postgres_released_lane_boundary() -> Result<()> { + let pg18_owned_files = [ + POSTGRES_SOURCE_MANIFEST_PATH, + POSTGRES_PREPARE_SCRIPT, + "src/runtimes/liboliphaunt/wasix/assets/build/configure_wasix_dl.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/docker_oliphaunt.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/wasix_third_party.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_openssl.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_sqlite.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_geos.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libxml2.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_jsonc.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_proj.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_libiconv.sh", + "src/runtimes/liboliphaunt/wasix/assets/build/build_wasix_icu.sh", + "src/extensions/external/postgis/tools/build_wasix.sh", + ]; + let released_lane_markers = [ + WASIX_PATCHED_SOURCE_DIR, + "prepare_patched_source.sh", + concat!("__PG", "LITE__"), + concat!("PG", "LITE_WASIX_DL"), + concat!("PG", "LITE_HOST_EXPORT"), + ]; + + let mut failures = Vec::new(); + for path in pg18_owned_files { + let text = fs::read_to_string(path).with_context(|| format!("read {path}"))?; + for marker in released_lane_markers { + if text.contains(marker) { + failures.push(format!( + "{path} must not depend on released PG17/Oliphaunt marker {marker:?}" + )); + } + } + } + + let source_lane_sh = + fs::read_to_string("src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh") + .context("read src/runtimes/liboliphaunt/wasix/assets/build/source_lane.sh")?; + for marker in [ + "$CONTAINER_GENERATED_ROOT/work/docker-oliphaunt", + "$CONTAINER_GENERATED_ROOT/build", + "local host_pgsrc", + "prepare_postgres_source.sh", + "prepared PG18 source is outside repo mount", + ] { + if !source_lane_sh.contains(marker) { + failures.push(format!( + "source_lane.sh PG18 arm is missing lane-owned marker {marker:?}" + )); + } + } + + if !failures.is_empty() { + bail!("{}", failures.join("; ")); + } + Ok(()) +} + +fn check_postgres_packaging_inputs(source: &Path) -> Result<()> { + for required in [ + "src/bin/initdb/initdb.c", + "src/bin/pg_dump/pg_dump.c", + "src/bin/pg_dump/connectdb.c", + "src/bin/pg_dump/connectdb.h", + "src/pl/plpgsql/src/Makefile", + "src/pl/plpgsql/src/plpgsql.control", + "src/pl/plpgsql/src/plpgsql--1.0.sql", + "src/pl/plpgsql/src/pl_handler.c", + "src/backend/snowball/Makefile", + "src/backend/snowball/dict_snowball.c", + "src/backend/snowball/snowball_create.pl", + "src/backend/snowball/snowball.sql.in", + "src/backend/snowball/snowball_func.sql.in", + "src/timezone/data/tzdata.zi", + "src/timezone/tznames/Default", + ] { + ensure_file(&source.join(required))?; + } + + let promoted_specs = extension_catalog::promoted_build_specs()?; + for extension in promoted_specs + .iter() + .filter(|extension| extension.source_kind == "postgis") + { + let source_dir = Path::new(&extension.source_dir); + ensure_file(&source_dir.join("configure.ac"))?; + ensure_file(&source_dir.join("extensions/postgis/Makefile.in"))?; + ensure_file(&source_dir.join("postgis/postgis.sql.in"))?; + ensure_file(&source_dir.join("libpgcommon/sql/AddToSearchPath.sql.inc"))?; + } + + let mut checked_contrib = 0usize; + let mut missing = Vec::new(); + for extension in promoted_specs + .iter() + .filter(|extension| extension.build_kind == "postgres-contrib") + { + checked_contrib += 1; + let Some(contrib_dir) = extension.contrib_dir.as_deref() else { + missing.push(format!("{}: missing generated contrib_dir", extension.id)); + continue; + }; + let extension_source = source.join("contrib").join(contrib_dir); + if !extension_source.is_dir() { + missing.push(format!( + "{}: missing PG18 contrib source directory {}", + extension.id, + extension_source.display() + )); + continue; + } + let makefile = extension_source.join("Makefile"); + if !makefile.is_file() { + missing.push(format!( + "{}: missing PG18 contrib Makefile {}", + extension.id, + makefile.display() + )); + } + if extension.lifecycle.create_extension || extension.control_file.is_some() { + let control = extension_source.join(format!("{}.control", extension.sql_name)); + if !control.is_file() { + missing.push(format!( + "{}: missing PG18 contrib control file {}", + extension.id, + control.display() + )); + } + } + if extension.lifecycle.create_extension + && !extension_source_contains_packaged_sql(&extension_source, &extension.sql_name)? + { + missing.push(format!( + "{}: missing PG18 contrib SQL file for CREATE EXTENSION {} in {}", + extension.id, + extension.sql_name, + extension_source.display() + )); + } + } + + ensure!( + checked_contrib > 0, + "PG18 packaging input guard did not find any promoted postgres-contrib extensions" + ); + ensure!( + missing.is_empty(), + "PG18 prepared source is missing promoted contrib packaging inputs: {}", + missing.join("; ") + ); + check_postgres_pgxs_packaging_inputs()?; + Ok(()) +} + +fn check_postgres_pgxs_packaging_inputs() -> Result<()> { + let manifest = load_sources_manifest()?; + let mut checked_pgxs = 0usize; + let mut missing = Vec::new(); + for extension in extension_catalog::promoted_build_specs()? + .iter() + .filter(|extension| extension_catalog::is_pgxs_style_build_kind(&extension.build_kind)) + { + checked_pgxs += 1; + ensure!( + extension + .source_dir + .starts_with("target/oliphaunt-sources/checkouts/"), + "PG18 PGXS extension {} source dir must be lane-neutral under target/oliphaunt-sources/checkouts, got {}", + extension.id, + extension.source_dir + ); + ensure!( + !extension + .source_dir + .contains(concat!("postgres-", "pg", "lite")), + "PG18 PGXS extension {} must not use removed fork source dir {}", + extension.id, + extension.source_dir + ); + ensure!( + source_pin_for_checkout_dir(&manifest, &extension.source_dir).is_some(), + "PG18 PGXS extension {} source dir {} is not pinned in source metadata", + extension.id, + extension.source_dir + ); + if let Some(module_file) = extension.module_file.as_deref() { + ensure!( + module_file.ends_with(".so"), + "PG18 PGXS extension {} native module must be a WASIX side module name, got {}", + extension.id, + module_file + ); + } + + let source = Path::new(&extension.source_dir); + if !source.is_dir() { + eprintln!( + "warning: PG18 PGXS extension {} source checkout is missing at {}; run source-spine --strict-local after fetching shared extension checkouts", + extension.id, + source.display() + ); + continue; + } + if !source.join("Makefile").is_file() { + missing.push(format!( + "{}: missing PGXS Makefile {}", + extension.id, + source.join("Makefile").display() + )); + } + if extension.lifecycle.create_extension || extension.control_file.is_some() { + let control_file = extension + .control_file + .as_deref() + .map(Path::new) + .filter(|path| path.is_file()) + .map(Path::to_path_buf) + .unwrap_or_else(|| source.join(format!("{}.control", extension.sql_name))); + if !control_file.is_file() { + missing.push(format!( + "{}: missing PGXS control file {}", + extension.id, + control_file.display() + )); + } + } + if extension.lifecycle.create_extension + && !pgxs_extension_source_contains_packaged_sql(source, &extension.sql_name)? + { + missing.push(format!( + "{}: missing PGXS SQL file for CREATE EXTENSION {} in {} or {}/sql", + extension.id, + extension.sql_name, + source.display(), + source.display() + )); + } + } + + ensure!( + checked_pgxs > 0, + "PG18 packaging input guard did not find any promoted PGXS external extensions" + ); + ensure!( + missing.is_empty(), + "PG18 promoted PGXS packaging inputs are incomplete: {}", + missing.join("; ") + ); + Ok(()) +} + +fn extension_source_contains_packaged_sql(source: &Path, sql_name: &str) -> Result { + if !source.is_dir() { + return Ok(false); + } + for entry in sorted_children(source)? { + if !entry.is_file() { + continue; + } + let Some(name) = entry.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if (name.starts_with(&format!("{sql_name}--")) || name == format!("{sql_name}.sql")) + && name.ends_with(".sql") + { + return Ok(true); + } + } + Ok(false) +} + +fn pgxs_extension_source_contains_packaged_sql(source: &Path, sql_name: &str) -> Result { + if extension_source_contains_packaged_sql(source, sql_name)? { + return Ok(true); + } + extension_source_contains_any_sql(&source.join("sql")) +} + +fn extension_source_contains_any_sql(source: &Path) -> Result { + if !source.is_dir() { + return Ok(false); + } + for entry in sorted_children(source)? { + if entry.extension().and_then(|extension| extension.to_str()) == Some("sql") { + return Ok(true); + } + } + Ok(false) +} + +fn source_pin_for_checkout_dir<'a>( + manifest: &'a SourcesManifest, + source_dir: &str, +) -> Option<&'a SourcePin> { + let expected = normalize_manifest_path(Path::new(source_dir)); + manifest.sources.iter().find(|source| { + source_checkout_path(source.name.as_str()) + .map(|path| normalize_manifest_path(&path)) + .as_deref() + == Some(expected.as_str()) + }) +} + +fn normalize_manifest_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn ensure_pg18_experiment_patch_disposition() -> Result<()> { + let text = fs::read_to_string(POSTGRES_EXPERIMENT_DISPOSITION_PATH) + .with_context(|| format!("read {POSTGRES_EXPERIMENT_DISPOSITION_PATH}"))?; + for required in [ + "0001-wasix-use-posix-dsm-not-sysv.patch", + "0003-wasix-libpq-static-encoding-shim.patch", + "0004-wasix-core-execbackend-initdb-runtime.patch", + "0005-pg-dump-avoid-lto-executequery-collision.patch", + "0006-like-literal-substring-fast-path.patch", + "0007-top-xid-current-transaction-fast-path.patch", + "0008-btree-int4-compare-fast-path.patch", + "0009-btree-delete-stack-state.patch", + "0010-btree-bottomup-delete-runtime-toggle.patch", + "0011-btree-first-int4-compare-fast-path.patch", + "0012-hash-bytes-unaligned-load-fast-path.patch", + "do-not-port-experiment-patches-without-a-recorded-wasix-runtime-rationale", + "ported as 0014-oliphaunt-wasix-speed-up-hash-bytes-unaligned-loads.patch", + "ported as 0015-oliphaunt-wasix-add-top-xid-current-transaction-fast-path.patch", + "ported as 0016-oliphaunt-wasix-add-btree-int4-compare-fast-path.patch", + "ported as 0017-oliphaunt-wasix-keep-btree-delete-scratch-on-stack.patch", + "ported as 0018-oliphaunt-wasix-avoid-pg-dump-executequery-lto-collision.patch", + "rejected-for-default-lane", + "deferred", + ] { + ensure!( + text.contains(required), + "{} must record experiment patch disposition marker {required:?}", + POSTGRES_EXPERIMENT_DISPOSITION_PATH + ); + } + for banned in ["adopt-without-review", "blind-port", "TODO decide"] { + ensure!( + !text.contains(banned), + "{} contains unresolved experiment disposition marker {banned:?}", + POSTGRES_EXPERIMENT_DISPOSITION_PATH + ); + } + + let disposition_experiments = text + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("experiment = ") + .and_then(parse_toml_string_literal) + }) + .collect::>(); + let source_path = text + .lines() + .find_map(|line| { + line.trim() + .strip_prefix("source_path = ") + .and_then(parse_toml_string_literal) + }) + .ok_or_else(|| { + anyhow!( + "{} must record the source_path for the full-PG experiment patches", + POSTGRES_EXPERIMENT_DISPOSITION_PATH + ) + })?; + let experiment_patch_dir = Path::new(&source_path); + if experiment_patch_dir.is_dir() { + let mut experiment_patches = BTreeSet::new(); + for entry in fs::read_dir(experiment_patch_dir) + .with_context(|| format!("read {}", experiment_patch_dir.display()))? + { + let entry = entry + .with_context(|| format!("read entry in {}", experiment_patch_dir.display()))?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("patch") { + continue; + } + let patch_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("invalid experiment patch filename {}", path.display()))?; + experiment_patches.insert(patch_name.to_owned()); + } + ensure!( + disposition_experiments == experiment_patches, + "{} must exactly cover experiment patches under {}; missing={:?} stale={:?}", + POSTGRES_EXPERIMENT_DISPOSITION_PATH, + experiment_patch_dir.display(), + experiment_patches + .difference(&disposition_experiments) + .collect::>(), + disposition_experiments + .difference(&experiment_patches) + .collect::>() + ); + } + Ok(()) +} + +fn parse_toml_string_literal(value: &str) -> Option { + let value = value.trim().trim_end_matches(','); + let value = value.strip_prefix('"')?.strip_suffix('"')?; + Some(value.to_owned()) +} + +pub(crate) fn check_rust_startup_abi_boundary() -> Result<()> { + let path = + Path::new("src/bindings/wasix-rust/crates/oliphaunt-wasix/src/oliphaunt/postgres_mod.rs"); + let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + + for marker in [ + "struct OliphauntLifecycleExports", + "struct WasixProtocolExports", + "fn ensure_integrated_oliphaunt_contract", + "fn record_backend_c_timings", + "oliphaunt_wasix_backend_timing_reset", + "oliphaunt_wasix_backend_timing_elapsed_us", + "host_requires_process_exit_error_recovery", + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_set_protocol_transport", + "oliphaunt_wasix_protocol_stream_active", + "The upstream lifecycle is already running by this point", + ] { + if !text.contains(marker) { + bail!( + "{} must keep upstream lifecycle exports separate from WASIX protocol ABI; missing {marker:?}", + path.display() + ); + } + } + if text.contains("struct Exports") { + bail!( + "{} must not collapse Oliphaunt lifecycle and WASIX protocol exports into a generic Exports struct", + path.display() + ); + } + check_rust_host_runtime_abi_surface(&text)?; + + let lifecycle_start = text + .find("struct OliphauntLifecycleExports") + .ok_or_else(|| anyhow!("missing OliphauntLifecycleExports"))?; + let protocol_start = text + .find("struct WasixProtocolExports") + .ok_or_else(|| anyhow!("missing WasixProtocolExports"))?; + let lifecycle_block = &text[lifecycle_start..protocol_start]; + for protocol_marker in [ + "ProcessStartupPacket", + "PostgresMainLoopOnce", + "oliphaunt_wasix_input", + ] { + if lifecycle_block.contains(protocol_marker) { + bail!( + "{} lifecycle export block leaked WASIX protocol marker {protocol_marker:?}", + path.display() + ); + } + } + for lifecycle_marker in [ + "wasi_start", + "set_force_host_error_recovery", + "set_active", + "start_oliphaunt", + ] { + if !lifecycle_block.contains(lifecycle_marker) { + bail!( + "{} must drive the integrated Oliphaunt lifecycle; missing {lifecycle_marker:?}", + path.display() + ); + } + } + + println!("Rust startup ABI boundary guard passed"); + Ok(()) +} + +fn check_rust_host_runtime_abi_surface(postgres_mod: &str) -> Result<()> { + let runtime_exports = required_runtime_abi_exports() + .iter() + .copied() + .collect::>(); + for &export in RUST_HOST_REQUIRED_RUNTIME_EXPORTS { + ensure!( + postgres_mod.contains(&format!("\"{export}\"")), + "Rust WASIX host must load required runtime export {export}" + ); + ensure!( + runtime_exports.contains(export), + "WASIX runtime export validator must require Rust host export {export}" + ); + } + for &export in RUST_HOST_OPTIONAL_RUNTIME_EXPORTS { + ensure!( + postgres_mod.contains(&format!("\"{export}\"")), + "Rust WASIX host must consciously load optional runtime export {export}" + ); + } + for &export in RUNTIME_EXPORT_LIST_COMPAT_EXPORTS { + ensure!( + runtime_exports.contains(export), + "WASIX runtime export validator must keep compatibility export {export}" + ); + } + for export in [ + "oliphaunt_wasix_set_force_host_error_recovery", + "oliphaunt_wasix_set_protocol_transport", + ] { + ensure!( + runtime_exports.contains(export), + "WASIX runtime export validator must require optional Rust host export {export} for current generated assets" + ); + } + for legacy in [ + "oliphaunt_wasix_initdb", + "oliphaunt_wasix_backend", + "PostgresRecoverProtocolError", + ] { + ensure!( + !postgres_mod.contains(&format!("\"{legacy}\"")), + "Rust WASIX host must not load legacy builder-branch export {legacy}" + ); + } + Ok(()) +} diff --git a/tools/xtask/src/release_workspace.rs b/tools/xtask/src/release_workspace.rs new file mode 100644 index 00000000..52c5d82b --- /dev/null +++ b/tools/xtask/src/release_workspace.rs @@ -0,0 +1,214 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; + +use super::*; +use crate::asset_io::ensure_aot_manifest_matches_source_lane; + +const RELEASE_RELEVANT_UNTRACKED_PATHS: &[&str] = &[ + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", + "src/extensions", + "src/bindings/wasix-rust", + "src/runtimes/liboliphaunt/wasix", + "tools/xtask", +]; + +pub(super) fn stage_release_workspace() -> Result<()> { + let stage_root = Path::new(RELEASE_STAGE_DIR); + let workspace = stage_root.join("workspace"); + if stage_root.exists() { + fs::remove_dir_all(stage_root) + .with_context(|| format!("remove {}", stage_root.display()))?; + } + fs::create_dir_all(&workspace).with_context(|| format!("create {}", workspace.display()))?; + + ensure_no_unexpected_untracked_release_files()?; + let tracked = command_output("git", &["ls-files", "-z", "--cached"], Path::new("."))?; + for path in tracked.split('\0').filter(|path| !path.is_empty()) { + let source = Path::new(path); + let destination = workspace.join(path); + copy_file(source, &destination)?; + } + + let generated_assets = Path::new(GENERATED_ASSETS_DIR); + ensure_file(&generated_assets.join("manifest.json"))?; + let generated_manifest = read_asset_manifest_from(generated_assets)?; + ensure_packaged_asset_matches_source_lane(&generated_manifest, DEFAULT_SOURCE_LANE)?; + copy_dir_all(generated_assets, &workspace.join(ASSET_CRATE_PAYLOAD_DIR))?; + copy_dir_all(generated_assets, &workspace.join(GENERATED_ASSETS_DIR))?; + update_staged_root_asset_metadata(&workspace)?; + + for target in supported_aot_targets() { + let generated_aot = generated_aot_dir(target); + if generated_aot.join("manifest.json").is_file() { + ensure_aot_manifest_matches_source_lane( + &generated_aot.join("manifest.json"), + target, + DEFAULT_SOURCE_LANE, + )?; + copy_dir_all( + &generated_aot, + &workspace + .join("src/runtimes/liboliphaunt/wasix/crates/aot") + .join(target) + .join("artifacts"), + )?; + copy_dir_all( + &generated_aot, + &workspace.join("target/oliphaunt-wasix/aot").join(target), + )?; + } + } + + fs::write( + stage_root.join("README.txt"), + "Generated liboliphaunt-wasix release workspace.\n", + ) + .with_context(|| format!("write {}", stage_root.join("README.txt").display()))?; + println!("staged release workspace at {}", workspace.display()); + Ok(()) +} + +fn ensure_no_unexpected_untracked_release_files() -> Result<()> { + let mut args = vec!["ls-files", "-z", "--others", "--exclude-standard", "--"]; + args.extend(RELEASE_RELEVANT_UNTRACKED_PATHS); + let untracked = command_output("git", &args, Path::new("."))? + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_owned) + .collect::>(); + if !untracked.is_empty() { + return Err(anyhow!( + "WASM release staging refuses untracked release-relevant files; add them to git or move them out of release roots: {}", + untracked.join(", ") + )); + } + Ok(()) +} + +pub(super) fn package_release_assets() -> Result<()> { + let output_dir = Path::new(RELEASE_ASSET_BUNDLE_DIR); + if output_dir.exists() { + fs::remove_dir_all(output_dir) + .with_context(|| format!("remove {}", output_dir.display()))?; + } + fs::create_dir_all(output_dir).with_context(|| format!("create {}", output_dir.display()))?; + + let version = wasix_runtime_release_version()?; + let mut bundles = Vec::new(); + bundles.push(package_release_portable_assets(output_dir, &version)?); + for target in supported_aot_targets() { + bundles.push(package_release_aot_assets(output_dir, target, &version)?); + } + + let mut checksum_lines = Vec::new(); + for bundle in &bundles { + let name = bundle + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + anyhow!( + "release asset path is not valid UTF-8: {}", + bundle.display() + ) + })?; + checksum_lines.push(format!("{} {name}", sha256_file(bundle)?)); + } + checksum_lines.sort(); + let checksum_path = output_dir.join(format!( + "liboliphaunt-wasix-{version}-release-assets.sha256" + )); + fs::write(&checksum_path, format!("{}\n", checksum_lines.join("\n"))) + .with_context(|| format!("write {}", checksum_path.display()))?; + + println!("packaged public release assets in {}", output_dir.display()); + Ok(()) +} + +fn package_release_portable_assets(output_dir: &Path, version: &str) -> Result { + let generated_assets = Path::new(GENERATED_ASSETS_DIR); + ensure_file(&generated_assets.join("manifest.json"))?; + let manifest = read_asset_manifest_from(generated_assets)?; + ensure_packaged_asset_matches_source_lane(&manifest, DEFAULT_SOURCE_LANE)?; + ensure_file(Path::new(ASSET_INPUT_FINGERPRINT_PATH))?; + + let staging = output_dir.join("staging/portable-wasix"); + if staging.exists() { + fs::remove_dir_all(&staging).with_context(|| format!("remove {}", staging.display()))?; + } + copy_dir_all(generated_assets, &staging.join(GENERATED_ASSETS_DIR))?; + copy_dir_all( + Path::new("src/extensions/generated"), + &staging.join("src/extensions/generated"), + )?; + copy_dir_all( + Path::new("src/runtimes/liboliphaunt/wasix/assets/generated"), + &staging.join("src/runtimes/liboliphaunt/wasix/assets/generated"), + )?; + + let output = output_dir.join(format!( + "liboliphaunt-wasix-{version}-runtime-portable.tar.zst" + )); + deterministic_tar_zst(&staging, Path::new(""), &output)?; + fs::remove_dir_all(&staging).with_context(|| format!("remove {}", staging.display()))?; + Ok(output) +} + +fn package_release_aot_assets(output_dir: &Path, target: &str, version: &str) -> Result { + ensure_supported_aot_target(target)?; + let generated_aot = generated_aot_dir(target); + ensure_file(&generated_aot.join("manifest.json"))?; + check_aot_package_manifest(target, DEFAULT_SOURCE_LANE)?; + + let target_id = aot_target_id_for_triple(target)?; + let output = output_dir.join(format!( + "liboliphaunt-wasix-{version}-runtime-aot-{target_id}.tar.zst" + )); + deterministic_tar_zst( + &generated_aot, + &Path::new("target/oliphaunt-wasix/aot").join(target), + &output, + )?; + Ok(output) +} + +fn wasix_runtime_release_version() -> Result { + let manifest = fs::read_to_string("src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml") + .context("read src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml")?; + let mut in_package = false; + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed == "[package]" { + in_package = true; + continue; + } + if in_package && trimmed.starts_with('[') { + break; + } + if in_package && trimmed.starts_with("version") { + let Some((_, raw_value)) = trimmed.split_once('=') else { + continue; + }; + let version = raw_value.trim().trim_matches('"'); + if !version.is_empty() { + return Ok(version.to_owned()); + } + } + } + Err(anyhow!( + "src/runtimes/liboliphaunt/wasix/crates/assets/Cargo.toml [package].version is missing" + )) +} + +pub(super) fn run_in_release_workspace(command: &str, args: &[&str]) -> Result<()> { + let workspace = Path::new(RELEASE_STAGE_DIR).join("workspace"); + let mut command = command_for_host(command); + command + .args(args) + .current_dir(&workspace) + .env("OLIPHAUNT_WASM_RELEASE_STAGED", "1"); + run_command(&mut command) +} diff --git a/tools/xtask/src/source_spine.rs b/tools/xtask/src/source_spine.rs new file mode 100644 index 00000000..63e47516 --- /dev/null +++ b/tools/xtask/src/source_spine.rs @@ -0,0 +1,720 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; + +use crate::postgres_guard::{ + check_postgres_source_spine, check_prepared_postgres_source, check_source_lane_isolation, + postgres_work_root_for_source, +}; + +use super::*; + +pub(super) fn check_sources_manifest(strict_local: bool) -> Result { + let manifest = load_sources_manifest()?; + validate_sources_manifest(&manifest)?; + if strict_local { + check_source_spine_for_source_lane(&manifest, DEFAULT_SOURCE_LANE, true, false)?; + } + println!("validated {} pinned asset sources", manifest.sources.len()); + Ok(manifest) +} + +pub(super) fn check_sources_manifest_for_asset_build(args: &[String]) -> Result { + let manifest = load_sources_manifest()?; + validate_sources_manifest(&manifest)?; + let source_lane = + canonical_source_lane(value_after(args, "--source-lane").unwrap_or(DEFAULT_SOURCE_LANE))?; + if args.iter().any(|arg| arg == "--fetch") { + fetch_pinned_sources_for_source_lane(&manifest, source_lane, true, SourceFetchScope::All)?; + } else { + check_source_spine_for_source_lane(&manifest, source_lane, true, false)?; + } + println!( + "validated {} pinned asset sources for {source_lane}", + manifest.sources.len() + ); + Ok(manifest) +} + +pub(super) fn fetch_pinned_sources_for_source_lane( + manifest: &SourcesManifest, + source_lane: &str, + prepare_postgres_source: bool, + source_scope: SourceFetchScope, +) -> Result<()> { + match canonical_source_lane(source_lane)? { + "stable" => { + fetch_manifest_sources_filtered(manifest, |source| { + source_scope.includes(source.origin) + })?; + if prepare_postgres_source { + prepare_postgres_source_tree()?; + } + check_source_spine_for_source_lane_filtered(manifest, "stable", true, false, |source| { + source_scope.includes(source.origin) + }) + } + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SourceFetchScope { + All, + NativeRuntime, + WasixRuntime, + Extensions, +} + +impl SourceFetchScope { + pub(super) fn parse(value: &str) -> Result { + match value { + "all" => Ok(Self::All), + "native-runtime" => Ok(Self::NativeRuntime), + "wasix-runtime" => Ok(Self::WasixRuntime), + "extensions" => Ok(Self::Extensions), + other => bail!( + "unsupported source fetch scope {other:?}; expected one of: all, native-runtime, wasix-runtime, extensions" + ), + } + } + + fn includes(self, origin: SourceOrigin) -> bool { + match self { + Self::All => true, + Self::NativeRuntime => matches!( + origin, + SourceOrigin::SharedThirdParty + | SourceOrigin::NativeThirdParty + | SourceOrigin::Extension + ), + Self::WasixRuntime => matches!( + origin, + SourceOrigin::SharedThirdParty + | SourceOrigin::WasixThirdParty + | SourceOrigin::Extension + ), + Self::Extensions => matches!(origin, SourceOrigin::Extension), + } + } +} + +fn fetch_manifest_sources_filtered(manifest: &SourcesManifest, include: F) -> Result<()> +where + F: Fn(&SourcePin) -> bool, +{ + for source in &manifest.sources { + if !include(source) { + eprintln!("skipping source '{}' for selected source lane", source.name); + continue; + } + let Some(path) = source_checkout_path(source.name.as_str()) else { + eprintln!( + "warning: source '{}' has no configured checkout path; skipping fetch", + source.name + ); + continue; + }; + if source.kind == SourceKind::Archive { + fetch_archive_source(source, &path)?; + continue; + } + if !path.exists() || !path.join(".git").exists() { + init_source_checkout(source, &path)?; + } + ensure_clean_checkout(source, &path)?; + ensure_source_remote(&path, source)?; + fetch_git_source_with_retries(source, &path)?; + let mut checkout = Command::new("git"); + checkout + .args(["checkout", "-B", &source.branch, &source.commit]) + .current_dir(&path); + run_command(&mut checkout).with_context(|| { + format!( + "checkout {} at {} in {}", + source.name, + source.commit, + path.display() + ) + })?; + } + Ok(()) +} + +fn fetch_git_source_with_retries(source: &SourcePin, path: &Path) -> Result<()> { + const ATTEMPTS: u32 = 5; + for attempt in 1..=ATTEMPTS { + let mut fetch = Command::new("git"); + fetch + .args([ + "fetch", + "--no-tags", + "--depth", + "1", + "origin", + &source.commit, + ]) + .current_dir(path); + match run_command(&mut fetch) { + Ok(()) => return Ok(()), + Err(error) if attempt < ATTEMPTS => { + let delay = Duration::from_secs(u64::from(attempt) * 5); + eprintln!( + "fetch {} failed on attempt {attempt}/{ATTEMPTS}: {error}; retrying in {}s", + source.name, + delay.as_secs() + ); + thread::sleep(delay); + } + Err(error) => { + return Err(error).with_context(|| format!("fetch {}", source.name)); + } + } + } + unreachable!("fetch retry loop should return on success or final failure") +} + +fn fetch_archive_source(source: &SourcePin, path: &Path) -> Result<()> { + if archive_source_ready(source, path)? { + return Ok(()); + } + + if path.exists() { + if path.join(".git").exists() { + let status = source_checkout_status_for_source(source.name.as_str(), path) + .with_context(|| format!("read status for {}", path.display()))?; + if !status.trim().is_empty() { + bail!( + "archive source path {} ({}) is a dirty git checkout; preserve it before replacing it with an archive source", + path.display(), + source.name + ); + } + } + fs::remove_dir_all(path) + .with_context(|| format!("replace stale archive source {}", path.display()))?; + } + + let archive = fetch_source_archive(source)?; + let extract_root = path.with_file_name(format!(".{}-extracting", source.name)); + if extract_root.exists() { + fs::remove_dir_all(&extract_root) + .with_context(|| format!("remove stale {}", extract_root.display()))?; + } + fs::create_dir_all(&extract_root) + .with_context(|| format!("create {}", extract_root.display()))?; + + let mut extract = Command::new("tar"); + extract + .args([ + "-xzf", + archive.to_str().expect("archive path is utf-8"), + "-C", + ]) + .arg(&extract_root); + run_command(&mut extract).with_context(|| format!("extract {}", archive.display()))?; + + let strip_prefix = archive_strip_prefix(source)?; + let extracted = extract_root.join(strip_prefix); + ensure!( + extracted.is_dir(), + "archive source '{}' did not contain expected root {}", + source.name, + extracted.display() + ); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::rename(&extracted, path).with_context(|| { + format!( + "move extracted archive source {} to {}", + extracted.display(), + path.display() + ) + })?; + fs::remove_dir_all(&extract_root) + .with_context(|| format!("remove {}", extract_root.display()))?; + fs::write(archive_source_stamp_path(path), source.archive_stamp()) + .with_context(|| format!("write archive source stamp for {}", path.display()))?; + Ok(()) +} + +fn fetch_source_archive(source: &SourcePin) -> Result { + let sha256 = archive_sha256(source)?; + let archive_dir = Path::new("target/oliphaunt-sources/archives"); + fs::create_dir_all(archive_dir).with_context(|| format!("create {}", archive_dir.display()))?; + let archive = archive_dir.join(format!("{}-{sha256}.tar.gz", source.name)); + if archive.exists() { + let actual = sha256_file(&archive)?; + if actual == sha256 { + return Ok(archive); + } + fs::remove_file(&archive) + .with_context(|| format!("remove sha-mismatched {}", archive.display()))?; + } + + let tmp_archive = archive.with_extension("tar.gz.tmp"); + if tmp_archive.exists() { + fs::remove_file(&tmp_archive) + .with_context(|| format!("remove stale {}", tmp_archive.display()))?; + } + let mut download = Command::new("curl"); + download.args([ + "--fail", + "--location", + "--silent", + "--show-error", + "--retry", + "8", + "--retry-all-errors", + "--retry-delay", + "5", + "--connect-timeout", + "20", + &source.url, + "-o", + tmp_archive.to_str().expect("archive path is utf-8"), + ]); + run_command(&mut download).with_context(|| format!("download {}", source.name))?; + let actual = sha256_file(&tmp_archive)?; + ensure_eq(&actual, &sha256, &format!("{} archive sha256", source.name))?; + fs::rename(&tmp_archive, &archive).with_context(|| { + format!( + "promote downloaded archive {} to {}", + tmp_archive.display(), + archive.display() + ) + })?; + Ok(archive) +} + +fn archive_source_ready(source: &SourcePin, path: &Path) -> Result { + if !path.is_dir() { + return Ok(false); + } + let stamp = archive_source_stamp_path(path); + if !stamp.is_file() { + return Ok(false); + } + let actual = fs::read_to_string(&stamp).with_context(|| format!("read {}", stamp.display()))?; + Ok(actual == source.archive_stamp()) +} + +fn archive_source_stamp_path(path: &Path) -> PathBuf { + path.join(".oliphaunt-source-pin") +} + +fn archive_sha256(source: &SourcePin) -> Result { + let sha256 = source + .sha256 + .as_deref() + .ok_or_else(|| anyhow!("archive source '{}' is missing sha256", source.name))?; + ensure!( + sha256.len() == 64 && sha256.chars().all(|ch| ch.is_ascii_hexdigit()), + "archive source '{}' has invalid sha256 {}", + source.name, + sha256 + ); + Ok(sha256.to_owned()) +} + +fn archive_strip_prefix(source: &SourcePin) -> Result<&str> { + source + .strip_prefix + .as_deref() + .filter(|prefix| !prefix.is_empty() && !prefix.contains("..") && !prefix.starts_with('/')) + .ok_or_else(|| anyhow!("archive source '{}' has invalid strip-prefix", source.name)) +} + +pub(super) fn check_source_spine_for_source_lane( + manifest: &SourcesManifest, + source_lane: &str, + strict_local: bool, + check_patch_applies: bool, +) -> Result<()> { + match canonical_source_lane(source_lane)? { + "stable" => { + check_source_free_repo()?; + check_manifest_source_checkouts_filtered(manifest, strict_local, |_| true)?; + check_postgres_source_spine()?; + if check_patch_applies { + prepare_postgres_source_tree()?; + } + check_source_lane_isolation()?; + Ok(()) + } + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } +} + +fn check_source_spine_for_source_lane_filtered( + manifest: &SourcesManifest, + source_lane: &str, + strict_local: bool, + check_patch_applies: bool, + include: F, +) -> Result<()> +where + F: Fn(&SourcePin) -> bool, +{ + match canonical_source_lane(source_lane)? { + "stable" => { + check_source_free_repo()?; + check_manifest_source_checkouts_filtered(manifest, strict_local, include)?; + check_postgres_source_spine()?; + if check_patch_applies { + prepare_postgres_source_tree()?; + } + check_source_lane_isolation()?; + Ok(()) + } + _ => unreachable!("canonical_source_lane returned an unsupported lane"), + } +} + +fn prepare_postgres_source_tree() -> Result { + let output = command_output("bash", &[POSTGRES_PREPARE_SCRIPT], Path::new("."))?; + let source = output + .lines() + .rev() + .map(str::trim) + .find(|line| !line.is_empty()) + .ok_or_else(|| anyhow!("{POSTGRES_PREPARE_SCRIPT} did not print a source path"))?; + let source = PathBuf::from(source); + ensure!( + source.join(".oliphaunt-wasix-source-fingerprint").is_file(), + "PG18 source-prep script did not produce a fingerprinted source tree at {}", + source.display() + ); + ensure_file(&source.join(".oliphaunt-wasix-postgres-version"))?; + let manifest = load_postgres_source_manifest()?; + let work_root = postgres_work_root_for_source(&source)?; + check_prepared_postgres_source(&manifest, &source, &work_root)?; + Ok(source) +} + +fn init_source_checkout(source: &SourcePin, path: &Path) -> Result<()> { + if path.exists() && !path.join(".git").exists() { + if path.read_dir()?.next().is_none() { + fs::remove_dir_all(path) + .with_context(|| format!("remove empty source placeholder {}", path.display()))?; + } else { + bail!( + "source checkout path {} exists but is not a git checkout; remove it or move it aside", + path.display() + ); + } + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + + let mut command = Command::new("git"); + command.arg("init").arg(path); + run_command(&mut command) + .with_context(|| format!("initialize source checkout {}", path.display()))?; + ensure_source_remote(path, source) +} + +fn ensure_source_remote(path: &Path, source: &SourcePin) -> Result<()> { + let remotes = command_output("git", &["remote"], path) + .with_context(|| format!("read git remotes for {}", path.display()))?; + let mut command = Command::new("git"); + if remotes.lines().any(|remote| remote == "origin") { + command.args(["remote", "set-url", "origin", &source.url]); + } else { + command.args(["remote", "add", "origin", &source.url]); + } + command.current_dir(path); + run_command(&mut command).with_context(|| { + format!( + "configure origin remote for {} at {}", + source.name, + path.display() + ) + }) +} + +pub(super) fn source_checkout_path(name: &str) -> Option { + if !valid_source_name_component(name) { + return None; + } + Some(Path::new(SOURCE_CHECKOUT_ROOT).join(name)) +} + +fn valid_source_name_component(name: &str) -> bool { + !name.is_empty() + && !name.contains("..") + && !name.contains('/') + && !name.contains('\\') + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) +} + +fn ensure_clean_checkout(source: &SourcePin, path: &Path) -> Result<()> { + if !path.exists() { + bail!("source checkout is missing: {}", path.display()); + } + let status = source_checkout_status_for_source(source.name.as_str(), path) + .with_context(|| format!("read status for {}", path.display()))?; + if !status.trim().is_empty() { + bail!( + "source checkout {} ({}) has uncommitted changes; preserve them before fetching pins", + path.display(), + source.name + ); + } + Ok(()) +} + +pub(super) fn load_sources_manifest() -> Result { + let toolchain_path = Path::new("src/sources/toolchains/wasix.toml"); + let toolchain_text = fs::read_to_string(toolchain_path) + .with_context(|| format!("read {}", toolchain_path.display()))?; + let wasix: WasixToolchainManifest = toml::from_str(&toolchain_text) + .with_context(|| format!("parse {}", toolchain_path.display()))?; + + let mut sources = Vec::new(); + let mut names = BTreeSet::new(); + let sources_root = Path::new("src/sources/third-party"); + for domain in ["shared", "native", "wasix"] { + let domain_dir = sources_root.join(domain); + if !domain_dir.exists() { + continue; + } + let mut entries = fs::read_dir(&domain_dir) + .with_context(|| format!("read {}", domain_dir.display()))? + .collect::>>() + .with_context(|| format!("list {}", domain_dir.display()))?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("toml") { + continue; + } + let origin = match domain { + "shared" => SourceOrigin::SharedThirdParty, + "native" => SourceOrigin::NativeThirdParty, + "wasix" => SourceOrigin::WasixThirdParty, + _ => unreachable!("source domain list is closed"), + }; + push_source_pin(&mut sources, &mut names, &path, origin)?; + } + } + for path in extension_source_pin_paths()? { + push_source_pin(&mut sources, &mut names, &path, SourceOrigin::Extension)?; + } + + Ok(SourcesManifest { + toolchain: wasix.toolchain, + build: wasix.build, + sources, + }) +} + +pub(super) fn validate_sources_manifest(manifest: &SourcesManifest) -> Result<()> { + if manifest.sources.is_empty() { + bail!("source metadata must contain at least one source pin"); + } + ensure_eq( + &manifest.toolchain.wasmer, + "7.2.0-alpha.3", + "toolchain.wasmer", + )?; + ensure_eq( + &manifest.toolchain.wasmer_wasix, + "0.702.0-alpha.3", + "toolchain.wasmer-wasix", + )?; + if !manifest + .toolchain + .docker_image_digest + .strip_prefix("sha256:") + .is_some_and(|digest| digest.len() == 64 && digest.chars().all(|ch| ch.is_ascii_hexdigit())) + { + bail!( + "toolchain.docker_image_digest must pin a concrete sha256 digest, got {}", + manifest.toolchain.docker_image_digest + ); + } + let dockerfile = + fs::read_to_string("src/runtimes/liboliphaunt/wasix/assets/build/docker/Dockerfile") + .context("read WASIX build Dockerfile")?; + if !dockerfile.contains(&format!( + "FROM ubuntu:24.04@{}", + manifest.toolchain.docker_image_digest + )) { + bail!( + "WASIX build Dockerfile must pin the same base image digest as src/sources/toolchains/wasix.toml" + ); + } + ensure_eq( + &manifest.build.postgres_prefix, + "/", + "build.postgres_prefix", + )?; + ensure_eq( + &manifest.build.postgres_pkglibdir, + "/lib/postgresql", + "build.postgres_pkglibdir", + )?; + ensure_eq( + &manifest.build.postgres_sharedir, + "/share/postgresql", + "build.postgres_sharedir", + )?; + ensure_contains( + &manifest.build.main_flags, + "-fwasm-exceptions", + "build.main_flags", + )?; + ensure_no_flag_contains(&manifest.build.main_flags, "asyncify", "build.main_flags")?; + ensure_contains( + &manifest.build.extension_flags, + "-fwasm-exceptions", + "build.extension_flags", + )?; + ensure_no_flag_contains( + &manifest.build.extension_flags, + "asyncify", + "build.extension_flags", + )?; + ensure_contains( + &manifest.build.extension_flags, + "-fPIC", + "build.extension_flags", + )?; + ensure_contains( + &manifest.build.extension_flags, + "-Wl,-shared", + "build.extension_flags", + )?; + ensure_eq( + &manifest.build.archive_format, + "tar.zst", + "build.archive_format", + )?; + if !manifest.build.deterministic_archives { + bail!("build.deterministic_archives must be true"); + } + for source in &manifest.sources { + if !valid_source_name_component(&source.name) + || source.url.trim().is_empty() + || source.branch.trim().is_empty() + || source.commit.len() < 40 + { + bail!("invalid source pin in source metadata: {source:?}"); + } + match source.kind { + SourceKind::Git => { + if source.sha256.is_some() || source.strip_prefix.is_some() { + bail!( + "git source '{}' must not set sha256 or strip-prefix", + source.name + ); + } + } + SourceKind::Archive => { + let sha256 = archive_sha256(source)?; + archive_strip_prefix(source)?; + ensure_eq( + &source.commit, + &sha256, + &format!("{} archive commit must equal archive sha256", source.name), + )?; + if !source.url.ends_with(".tar.gz") && !source.url.ends_with(".tgz") { + bail!( + "archive source '{}' must point at a .tar.gz or .tgz URL", + source.name + ); + } + } + } + } + Ok(()) +} + +fn extension_source_pin_paths() -> Result> { + let root = Path::new("src/extensions/external"); + if !root.exists() { + return Ok(Vec::new()); + } + let mut paths = Vec::new(); + collect_extension_source_pin_paths(root, &mut paths)?; + paths.sort(); + Ok(paths) +} + +fn collect_extension_source_pin_paths(dir: &Path, paths: &mut Vec) -> Result<()> { + let mut entries = fs::read_dir(dir) + .with_context(|| format!("read {}", dir.display()))? + .collect::>>() + .with_context(|| format!("list {}", dir.display()))?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_extension_source_pin_paths(&path, paths)?; + } else if path.file_name().and_then(|name| name.to_str()) == Some("source.toml") { + paths.push(path); + } + } + Ok(()) +} + +fn push_source_pin( + sources: &mut Vec, + names: &mut BTreeSet, + path: &Path, + origin: SourceOrigin, +) -> Result<()> { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let mut source: SourcePin = + toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?; + source.origin = origin; + if !names.insert(source.name.clone()) { + bail!("duplicate source pin '{}' in source metadata", source.name); + } + sources.push(source); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{SourceFetchScope, source_checkout_path}; + use crate::SOURCE_CHECKOUT_ROOT; + use crate::asset_manifest::SourceOrigin; + + #[test] + fn source_checkout_path_is_derived_from_portable_source_name() { + assert_eq!( + source_checkout_path("postgis").expect("valid source"), + Path::new(SOURCE_CHECKOUT_ROOT).join("postgis") + ); + assert_eq!( + source_checkout_path("json-c").expect("valid source"), + Path::new(SOURCE_CHECKOUT_ROOT).join("json-c") + ); + + assert!(source_checkout_path("").is_none()); + assert!(source_checkout_path("../postgis").is_none()); + assert!(source_checkout_path("nested/postgis").is_none()); + assert!(source_checkout_path("nested\\postgis").is_none()); + } + + #[test] + fn runtime_fetch_scopes_include_extension_source_pins() { + assert!(SourceFetchScope::NativeRuntime.includes(SourceOrigin::Extension)); + assert!(SourceFetchScope::WasixRuntime.includes(SourceOrigin::Extension)); + assert!(SourceFetchScope::Extensions.includes(SourceOrigin::Extension)); + } +} diff --git a/tools/xtask/src/template_runner.rs b/tools/xtask/src/template_runner.rs new file mode 100644 index 00000000..d8844ec3 --- /dev/null +++ b/tools/xtask/src/template_runner.rs @@ -0,0 +1,384 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +pub(crate) fn default_initdb_profile() -> &'static str { + "allow-group-access,encoding=UTF8,locale=C.UTF-8,locale-provider=libc,auth=trust,no-sync" +} + +pub(crate) fn clean_generated_pgdata_template(pgdata: &Path) -> Result<()> { + for name in ["postmaster.pid", "postmaster.opts"] { + let path = pgdata.join(name); + if path.exists() { + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + } + } + Ok(()) +} + +#[cfg(feature = "template-runner")] +pub(crate) fn run_wasix_initdb_template(runtime_stage: &Path, work_root: &Path) -> Result<()> { + use std::env; + use std::sync::Arc; + + use wasmer::Engine; + use wasmer_wasix::bin_factory::BinaryPackage; + use wasmer_wasix::runners::wasi::{RuntimeOrEngine, WasiRunner}; + use wasmer_wasix::runtime::task_manager::tokio::TokioTaskManager; + use wasmer_wasix::runtime::{PluggableRuntime, Runtime}; + use wasmer_wasix::virtual_fs; + use wasmer_wasix::virtual_fs::null_file::NullFile; + + use crate::fs_utils::{copy_file, copy_tree_filtered}; + + let package_dir = work_root.join("package"); + let package_root = work_root.join("root"); + let pgdata_root = work_root.join("pgdata"); + fs::create_dir_all(package_dir.join("modules")) + .with_context(|| format!("create {}", package_dir.join("modules").display()))?; + fs::create_dir_all(&pgdata_root) + .with_context(|| format!("create {}", pgdata_root.display()))?; + copy_tree_filtered(runtime_stage, &package_root, None)?; + copy_file( + &runtime_stage.join("bin/initdb"), + &package_dir.join("modules/initdb.wasm"), + )?; + copy_file( + &runtime_stage.join("bin/oliphaunt"), + &package_dir.join("modules/postgres.wasm"), + )?; + let wasmer_toml = r#" +[package] +name = "oliphaunt-wasix/initdb-template" +version = "0.0.0" +description = "oliphaunt-wasix generated PGDATA template builder" + +[[module]] +name = "initdb" +source = "modules/initdb.wasm" +abi = "wasi" + +[[module]] +name = "postgres" +source = "modules/postgres.wasm" +abi = "wasi" + +[[command]] +name = "initdb" +module = "initdb" + +[[command]] +name = "postgres" +module = "postgres" +"#; + fs::write(package_dir.join("wasmer.toml"), wasmer_toml) + .with_context(|| format!("write {}", package_dir.join("wasmer.toml").display()))?; + + let tokio_runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("create Tokio runtime for WASIX initdb template generation")?; + let _guard = tokio_runtime.enter(); + let engine = Engine::default(); + let task_manager = Arc::new(TokioTaskManager::new(tokio_runtime.handle().clone())); + let mut runtime = PluggableRuntime::new(task_manager); + runtime.set_engine(engine.clone()); + runtime.set_package_loader(LocalOnlyPackageLoader); + let runtime: Arc = Arc::new(runtime); + let package = tokio_runtime + .block_on(BinaryPackage::from_dir(&package_dir, runtime.as_ref())) + .context("load WASIX initdb package")?; + let root_fs = Arc::new( + virtual_fs::host_fs::FileSystem::new(tokio_runtime.handle().clone(), &package_root) + .with_context(|| { + format!( + "create WASIX template root filesystem at {}", + package_root.display() + ) + })?, + ) as Arc; + let pgdata_fs = Arc::new( + virtual_fs::host_fs::FileSystem::new(tokio_runtime.handle().clone(), &pgdata_root) + .with_context(|| { + format!( + "create WASIX template PGDATA filesystem at {}", + pgdata_root.display() + ) + })?, + ) as Arc; + + let (stdout_file, stdout_capture) = TailCaptureFile::new(64 * 1024); + let (stderr_file, stderr_capture) = TailCaptureFile::new(64 * 1024); + let run_result = { + let mut runner = WasiRunner::new(); + runner.with_current_dir("/"); + runner.with_mount("/".to_owned(), root_fs); + runner.with_mount("/base".to_owned(), pgdata_fs); + runner.with_args(default_initdb_args()); + runner.with_envs([ + ("PGDATA", "/base"), + ("PGSYSCONFDIR", "/base"), + ("HOME", "/home/postgres"), + ("USER", "postgres"), + ("LOGNAME", "postgres"), + ("PGCLIENTENCODING", "UTF8"), + ("PATH", "/bin"), + ("LC_CTYPE", "C.UTF-8"), + ("TZ", "UTC"), + ("PGTZ", "UTC"), + ("PG_COLOR", "never"), + ]); + runner.with_stdin(Box::::default()); + runner.with_stdout(Box::new(stdout_file)); + runner.with_stderr(Box::new(stderr_file)); + runner.run_command("initdb", &package, RuntimeOrEngine::Runtime(runtime)) + }; + let stdout = stdout_capture.text(); + let stderr = stderr_capture.text(); + if env::var_os("OLIPHAUNT_WASM_TEMPLATE_LOG").is_some() || run_result.is_err() { + print_captured_wasix_output("initdb stdout", &stdout); + print_captured_wasix_output("initdb stderr", &stderr); + } + run_result.context("run WASIX initdb to generate PGDATA template") +} + +#[cfg(not(feature = "template-runner"))] +pub(crate) fn run_wasix_initdb_template(_runtime_stage: &Path, _work_root: &Path) -> Result<()> { + bail!( + "`assets template` and template generation during release-build require `cargo run -p xtask --features template-runner -- ...` so xtask has a maintainer-only Wasmer compiler backend" + ) +} + +#[cfg_attr(not(feature = "template-runner"), allow(dead_code))] +fn default_initdb_args() -> Vec<&'static str> { + vec![ + "--allow-group-access", + "--encoding", + "UTF8", + "--locale=C.UTF-8", + "--locale-provider=libc", + "--auth=trust", + "--no-sync", + "-D", + "/base", + ] +} + +#[cfg(feature = "template-runner")] +fn print_captured_wasix_output(label: &str, output: &str) { + if output.trim().is_empty() { + eprintln!("{label}: "); + } else { + eprintln!("--- {label} ---"); + eprint!("{output}"); + if !output.ends_with('\n') { + eprintln!(); + } + eprintln!("--- end {label} ---"); + } +} + +#[cfg(feature = "template-runner")] +#[derive(Debug, Default)] +struct LocalOnlyPackageLoader; + +#[cfg(feature = "template-runner")] +#[derive(Debug, Clone)] +struct TailCaptureFile { + inner: std::sync::Arc>, + limit: usize, +} + +#[cfg(feature = "template-runner")] +#[derive(Debug, Default)] +struct TailCaptureState { + bytes: std::collections::VecDeque, +} + +#[cfg(feature = "template-runner")] +#[derive(Debug, Clone)] +struct TailCaptureHandle { + inner: std::sync::Arc>, +} + +#[cfg(feature = "template-runner")] +impl TailCaptureFile { + fn new(limit: usize) -> (Self, TailCaptureHandle) { + let inner = std::sync::Arc::new(std::sync::Mutex::new(TailCaptureState::default())); + ( + Self { + inner: inner.clone(), + limit, + }, + TailCaptureHandle { inner }, + ) + } + + fn push_tail(&self, bytes: &[u8]) { + let Ok(mut state) = self.inner.lock() else { + return; + }; + for byte in bytes { + state.bytes.push_back(*byte); + while state.bytes.len() > self.limit { + state.bytes.pop_front(); + } + } + } +} + +#[cfg(feature = "template-runner")] +impl TailCaptureHandle { + fn text(&self) -> String { + let Ok(state) = self.inner.lock() else { + return "

OG^bt6yG2??7FhdGVc8feK}syBta)V!iG4 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 UD9cJ(z+Vc8``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBevYS$V@L(#+o>0M8x(k4{Kcp9UJ#tKM)?F= z$9w_a3sO!C^mpbP0 Hl+XkKM72er literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/tabIcons/explore@2x.png b/src/sdks/react-native/examples/expo/assets/images/tabIcons/explore@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..21b9bd26634ab84d284f737916b14294d4f267b9 GIT binary patch literal 347 zcmeAS@N?(olHy`uVBq!ia0vp^h9Jzr1|*B;ILZJi&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=5- zQFX(-2W+y8<_;bikNB$vUtRS6z3)j~B9nfv?E(hDtFMxOMmX*~W;4A@Fo54+s(WMd z!F>OW=d7xHx%1l27Yj~Y^d)T9v0aOO=YOcwYqx)SVViwTc8#o9l<|S|39sayGO#F{ zdT>v*D&x1-yY#?O!n=OMv}j(>TWxcmvn>^1+;(Q&))eLpG3SVb0$~O%qCAPL&Jl{O z0lR;%R9NTA^|3|vQPdIv{u_nPOG~DCxF6o?U#cwjYC~gpQ>XK9?b(cFe$!lLUv0ks ort5xy{bO_M7q+bIO1HL~uZ{0AeB!2k8R&NgPgg&ebxsLQ02VNSvj6}9 literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/tabIcons/explore@3x.png b/src/sdks/react-native/examples/expo/assets/images/tabIcons/explore@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..422202d5e195f089679236b712492f760dd7b2a0 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^o*>M@1|$oMx$gie&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=r zn=rYIIf2D2p?M1LF2_xbcNK1M@pA-JuP7{7^MCTXUys^lW?=ZxJf&|p zZ|Ucc(!1ZerAnHV-nRDLdnwE~%c)dry}ni0-n3m4wWr>+__yp}jOJpu(|j+A1A2{K zs`gF|S8VYm|AUjgvb%-=}xl&cjPCcm7{+=`Ka#8 uci0PO-9C_+=VNi!R@kqYnV~^yFLV2`1K(!Oea;Px1O`u6KbLh*2~7Y5`MPib literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/tabIcons/home.png b/src/sdks/react-native/examples/expo/assets/images/tabIcons/home.png new file mode 100644 index 0000000000000000000000000000000000000000..ad5699c4295db84c342833358bbc47c159882f45 GIT binary patch literal 253 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O)#NuDl_Ar*{or)=bHP!Mq4zpFvnqD}ecOOCvT zz6UAW%62u%aSA!}Jo%vEQ*qK;qwn_?hE544$rjF6*2UngGy!S!DnK literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/tabIcons/home@2x.png b/src/sdks/react-native/examples/expo/assets/images/tabIcons/home@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..22a1f2c7442f3ee07b8af0bb511652c7931e4888 GIT binary patch literal 343 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=Fu!ex z5R76h+n^f3q$jvY@m@o*1%{KjjaD#jV1FNcwC#?fA9M7dIi>x*i6% literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/tabIcons/home@3x.png b/src/sdks/react-native/examples/expo/assets/images/tabIcons/home@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f5d1f9a41eef3a1a3581d3c8a9d4b2a2116e6c00 GIT binary patch literal 479 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ79h;%I?XTvD9BhG z1|_0K&Q$GmBFpBb}>&BetT1TD~`LDWlGPWux)c4$mb4ymuxN~c= zR$0+f-&H^br{;tRPTuW$b8?d9ne!7`7rranH$CM<0)qjYXMgKmW{Kvd&4*2E-U*y< zey)`7c(d+C*p*7XPybdMDyjYYb1y$&bBMsgCJs@T6$(zQf?6E`jxAhD#0l+TeJgnW zT=B&13(Svf&Tx!A^0}+^>x$QZ&gV-`XmoV{pdtIH#NbM0+{WXr{`dT!i*5f=W4!Z9 mP}>x73;)TT9YC<7mf>_~gnIu4&ojXIVDNPHb6Mw<&;$TZ55|K4 literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/tutorial-web.png b/src/sdks/react-native/examples/expo/assets/images/tutorial-web.png new file mode 100644 index 0000000000000000000000000000000000000000..e4a8c58f7c433af31a0549fbb3f53eb0d528e682 GIT binary patch literal 58959 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 z zLh{E1&s#QLD8)OU z+zjq!xGh^{?9odZgGv}?_Ff>wn~7+cWl|NW)n3gvj&!&$Jtf2yv)5Kh2u4U%+!Q9G z(yQs{w$HGFF~Mpfy&whe@9lN(J`VP**cwn3&f*;Mm(bQI9oPFf5V)Mz6*r-~6Koim z4Ma)CB9_99{uWE&7R*2{RM~8dLwM&z!8LkuegHk?DzHU;%P7e6S-hp^gwBdqsVs^N@gwWcDf2-pD+Ku_Is&E&26QpD=4l z8)G)7)GfDDmJVZH#vb;?U}mxfmh1Pc*6hDk31)HUX_j82B(g%fm}-`=!}*zyCjC2@ zh<(6ij8@o9E)}xj26~2a*H^Bs6F3`03{|$mWCP58>KlZsUPLkU3JWzBH0)ZgYjhGK z$9U7Y?9{=ujWy5abT*F55O6=DKsN(ZYYmlCI`rZy_juwGcOf>~^`H;#8b{L#+(@aT zm?`k^1<|E79};<}UuygvPka;f8f}XDW8maudE2bsD?QF*z1g4d{~c)FYmcEBQwD5P zXjEo2npQq2+;1;%z!aUplk-~#*=Pnu-3#Nhe>%nU&<c>Yd75x$9?Jqd|Y)wYyb6X`KzQAWLbk8ob}#eq#Y()P+dJs&cnaMmWYk3N7LB;Oa|XxD-ssVMP+8t&f6?hZrbB6T z5Np-rd3)Lqs?Jp!?9ZsV_*^X1=MA`SK*5y0|1MXnu+SV&b=emNE4GwU?Fg;)=RB)I z1{fl4cMLGNSca{44(|uS%p{+^pevoqq0&7m>uHMgQ~=60Ph0u8RNXI?P8CGQ;iD_b zRU?H`-+cMZN5qkmk?C@u72+TWMyFWfm;(GcU+fRp_@P3^>cL?-7M|~BCB6>QQF?mo z$u#w83MZdZ-3V*#9}3O{A*wF^2xFu_c8rB+=Ib`d&3oLf7is?y>LTEy*M21p5(F8A z))3yfD%PK zeyX3~&`fCdkqx;6cPC#>`X*NyE#aeUbg)hgpRxlP$b7RcTtVEe8(K!MJTrKoi5(DYb z@8<6_9oRM}yIQ1%vqwP^GZBCaV~eRBD0u*T$-?6%nCoR||MQZ|Bf-+9q_G zvkpt1*qMr_L(&MI3PwN@h$%E$W?qRnydkHo*&RP~o-(+J+VuLINnzsDMe$A})xyJE zSmc$^AbLI{&R(HAoI z+qyuf1vl>ro?GGcA?C_DdjDfIZh#Se)7DP-vQnES|D%2DKK_U&T9e;}Tgz^V^v! z^%U>O@Y_X>@v1{(ucho>?mH(F;`_7#$EOWTOfnIe1%w)@_X>j7pYdU>72`LqlEy+& z*;V1O)%~7LcreY{@02=T1+Pvsg)85S$dT-O5D<1`cNRxz;!|e1P)yjs?&;*n5S@C@ zB-=P85g56sRunAWB6q{;fOpb(ZdP9}KHMm#e|Ie3 zkvLMgM%5FYG$DL7!$Xpo_%noUZQC4dq{w7;S4e#39pIdzsC!$iVVf{7?SM@wS)P~sv&kcAs?zKcJc zMBe@L<(DkX#q|`-j9zqwv!<$SHE*lWqNTS(K?8&>B-Wx?w5c|ZXPci~PoYzJ=3q#b zyhtEV#^KqflA3qNP7h0+*(3fi;rKLUOHr@c_o<_Dd9E#vUlmg#Asg`7xw;J*8cwUwO5lVYSg&1+Y1Q6M)tfmH=Hgv zIrwaBw&!js*gWG{g6Bift14!Gm=1n&7^eBV?WsclK+oQ!g&M(Q1|vl7nbkPXb_!X0 zakfa;k?xmFG60Dt(>+P)t;mo$lrPU%A^5yrRq*J=6f^@we8R^R=fo z52&>6G>>NU5Ey+mm)neAv2C;y16$u)u#lLk@$xq8s|A-M%(?!^?P7S3RH{m$d!r%g}qYs)J}^wcn?h<2BDP~_zx%X zLU*$(F787=hT(uuAW93)hG@dsO`Q6r@yEN%5SkBC)k%JY`JmDzWBcCB?~XSMzQszE zmu3%5eSp~aYmN|pq2Fq0*F}H?z35WuNdo;q+U}%jD}|deqZ2Q{EMVDxx3qKWTUw0y zjFlTTlL;r2ez-61kv(#m=4c6FDSxd2%_Oxx_Nhubfrt{@R<5poVLR&v73|D7}sC0rWY2%N= zm-fMrW8;5a#ST21wA6d%>?d1yEICqU8_K<3h3-Kd_RH|2ED6?G8V*xT8oqkNBJrhY zXg#=d>#I<^pJ4K{Aa1b%(_(1aa1GhPl7*#DyP~HG8Et5uFNCo`^DGtPP;b-_W=?|X z!=gWwZyeKp{4&y}LV(@vQFOb=K%LWx!F~aQwyH1LO`!Sb4nOTNH(tLlY4$~In>+{{Os}roAtCJom9;)W& zz*di}>YZM=-g0zjB}1zo()(%t2cfOR*f4DO?vNTqeFkIoSU(*30G!|K{ot-*jm_=6 zL%u=%68>60hLw)!yWv!uD=C`dtG!f=As{ z>wG>?K)~kkfp`Vv7rz3wz4Zie8s^=`ZTo;kQg889j*6(*Ag{mWI_3)j^ zEmqtZ^@aI@Q31y40dk`5b7J5anyW@mC@%_0xs<}8)=~7%>BW97lw?LHfyvKS;0X$F z*K}k)S0UX>!z3``y}NjGueninQKSrp%8~loo3+1s;kxINa9=R}i$(Un?kzjJi}i8+ zPq5HA?=H2W=Di&kK%p>G;d7$v(QT}JGWxBj!L3CwY&$!5Ub=hNRgYyyLSpJCvrn}F z%LBGLv3D8K1rsB_WTQ!flK8~P68cj_(|r@!7!Q|j1j_>mHSlVkulJShW+11Jfni-7 zaWTS~!ce|_taHwA<7;lSxe-;cwQVuwo66X`Ik@0eOTqVwnq}}`>(L6jpU=P7z-XX~ zF|3q`6wDC>U&fo$2Ek2rK8lp2`k&0Ukf(_wF}k&v{g%&|p(nP0NT*RLPK{3EjB z$lj4qL;Yo?ZkzGBZ)3+nkHRze;>U*bO=l{#3VPeR<(}ia7|Op{F1y;9Ce4|WTBae< zqs-gKNc~d%w}cR!_uWmoYX`T4iY{5HkyeMX5>3I%aZkRkmHt`{a7L8NOEyN19@#df z9zPe}{KM{OyV*=A?;4npA1IfK&=QPoPZd+mr+5H%32+yY@woeKKLp!gtx}%~Luamv z+KmM_X>`4CBscgV2cuSm~|h zBOq*`lQbzf4A<))@QyZfgvylsp6J?YRv{AlfOlpS^okHbT6p;rJo~jnBy@QwbxXj| zWPG-+Pu^W>wAAeQ&Mv~#0VuiSVc=2qGn5CQZ|!9gY%QiWpxbl1gy5-xq;w!Cs?H^?fz zgJEDmo(b%Vh@HD&pP|`)5bf39EARnPCK7nsO6!ZgdqS=T`ZE?potke(fmoppGx{_S z`@CXH`{!flwAyWNfJ*%aQeDlc6^~~Zq{qMtd=+oT@E(Sef+zk-7@W}3ZMThLPTaZi ztJ4kz0!}j^E(AWLTv+-K285%^{*i?Gfk@9WjkF68=gC{(`A_8}sSk*jU_GS5JP3K)VE>A3X$=e&uo{7Sx!Oz+DwlZCBc zM8J^1hRmVZTTrAImdA^=f zBSfK7&$j+KE!_S;V t%Ksy=^8ZTICLDVX9Xh{c&6Wdv;+6-$C$YVopP>n4&Y4Y?9kVt_FDdU|#6Z>o4%(b8@y^kSCbGSI;#kiWT zkV$87mX`~A_Q0cs5<$v(*ifFMR5IsyQ|MFZ%co^QrS zqDDh}-g$3)e^fC^w%Coro^rl8=EAaj_RAvhTU4+YxGr75=m8kdA}$!ldv6~LL9ZXL zQz-w^TUL%7iB`2Ky)dFoTSy|jthb?3El9^K`=?Yha6i-!Cz$>s`a zvyf@oXWMdr*4lT11|cL8%n<-Wc456v-uh+x(#@r4&Af9ls`v3yd?F{_;5+D%o|8Ro zYcQd0@;S0AhwRIBepGEc2>^M~!h-!3UP0J;i=JmT3w4=OVuPd;#}J+YV_Qokhe^lj zGz9VOJFi!tgD?Gjp&4g;-KQ`+a|^5Zq<`Py(h3T+Bds6|41_Mc-C9C)jza9XriZ#A z`=!zOxtTq^;YZTgpoRVMz_NziZ6rI(adM?gu3_Kw7M89nSS@ZOx!7YrW)t}00pX$e z_NJpbu&P}h;UpX8dz-kyqw~%0TF5j`;-~JeEzgSPo$QByA$Khiw)V8d)3<#!a!~Ve zsWJ?VGH$4RqeBLd&H{N>f8@CnMXoI~X(hk=QapTcezwe@-Zj9r`1qUAl6v;&UA2I_ z#G07qfRankYTuA;8k`(Yn0Z#>hM#09Ody{|4owBuY1z8#7EIG#&s2GW2_WrAA&EYI zfn=y$RaQpYMjxrV7-=ucn8}o(&ehjiCMMR~b`vsfVO(EaG9OpYet8kmp-(r}QxD-NFwz zF!}~%Z0SUY?3=L|LQnXD`vWqGt{YTfH8wQS+i^U5$Fr`u`9o5Kn@I5~@Ld&G-Hp_v zo}mFB2*TTWY<0B=ns+q8o}VoTYVFUP7!194>B_o1kQE=pmKbiL2D>ZxxMBz`v4lis zlwW?ZLUD;t%cEcItx6T_o-$IMmBZ?;!ua54Wwpj9ux-EmVtgcMDLo6SZwWfxEwrD= z6Uf0s+61NGOT`V=K9#z=?CG(YvJz9ah=E!YG2s&Xn`B~Wo1Z9qhdSTh%3(HNj(Zd= zyJ`BMYYIXAu)3aN-liWqMW~j4ZfA3UDjFNL5-fhLt<#=Gxj)7#uO5>mz44aGc^bZm z{atf(Yah+(u?5Q~k`pH=mipLRw&XQ8HaA!vS8C6RyM#@)J;w6@S;Ib%Qv$}n*9EFK z41J91YYh(b*H^Z;{GGpkWnJ?)K`dmLeqFMY>9Y^%p)#6HQtkO5x&QFntq*cv3`AER z)$@FIJjKD}@M%`gaBXk>q28GQ_d5lim9?z#mg^mlKP;*~p{-2PqV8`M39fmo4lnrd zou1}Xa4Z;%A)x1l_AjrjBh;kib;eX}T}RBbK3BrAH=>JUd;`URZ2HrnEkiW1@M&&v z9lsvZST*NqgSA<*y~(*>%zo{2I!AkOf`fQUTbJa z#!^#cN6fA$LgK=E4-?rQ_j)IHiP>Pi?eHL2#T!kyQWnkcnO`IJpcZ2GW8=Ie-mm;g z7qzHo@oId^^VZKrYwu6kT)J=`8&I=l`F)>_yK}+)=dGi(_s?2Ql>Fe!CfMi;Z?cO( z#lHc`Gaz)rrL2QAn`}@~^$$d<{lhD_sK-6XpW0H0Sb7>h1LG<7jlW@baVO8zXh@(?k)i( zoCsUb+h2#4STn{((z>DxU3?C!H}WmV<2GSx{h4a2>|6hmnW{aVjC@XBgnt$Hol3!bQd+yCZ(fOl`aNX}ME{}UkKnJiF4A}wbk@@1}+)ifE z<+#i1N2THQ=MI*C(8{o0U)y85zC86rJ*L)L)@1k)&mP&88zHh!m$m7ty)ee?qG6j`q$j(rNs9GdiD`Q`u(QhXl9?gVNnxeyKSkD-&tTUAgkHh!jNWoc zQX!;F7eRD>SW^4VB5v5{wYO7}{r;L&ZOme*4TSe{KR=1EP^=PbsccpyTKLL6N9|V_ z(JHpBEkFOeiqx?J(o=kGb$%DGFFc{gWX)f^i-`*T92FHZSdnMJQ#oD&;n#^hbiM)f% z)xCh2x2q{VKsjjh!(S{;mk@zItb})%v*P@u`6=#TCF||qER@GuC{L<))w#F>st8$^ z6F#fF{xq$F4YmO-mw9Or`x&vq|GrgW-dht_pIKVo7_ko6t0+qScX)L_C(Q05#zUs* zQss8N;lVG3L1pjakHXzARq05wjOOfmf%uCdI=Wh42$!O7UPk!1n|VVCh2;aikC~%U zuTAO~-V{^zVBZ=LjEOF98q-0al<~)%y44L4*OQyWTn zU{Aiqnhn27Gi?p>|8_js(cN@wIsHEUn)+pU+mT#a&%=6Hx}P@bS*>MkMlq^Y`gv{< zr`%78KHmp>S1`*F2wn}XIKf%0gRgyhGmS%X$!3gH%>(wVGS-XxZFy!}*L^p&)U>1i zdBV6*2qtdizai%r6K!60D zW3#?dV-Hpw@2jl1K+#7SVJgS^od=LV_y*N0Xz2^(muT_Ba^@UUdz+c3_I5MwgH;4n z_+WhBuQj{s=A+#U&5!UV-j^AmGF9!6`>CwxT;i^-r7g%-Hn`;oR|s# zV=1bp&}($hn$!oo7J6;Qry%KB+D-Y^hVaUN3cvh(V}h!)-B=gw=eO9;Eu(yBK~W{w z?<|cR7vG?kA2c=bo?faG6x26G$D|IB;$Vkep|VrCNL%D3)k_$TfcYr`x!Ci$)&E@0 z$aGL}%}w3x!Uidvpz*^L`g=H&lL@{9*RGOa0>pq^00n5+sj&O<_D3 z)iZyPKSRhJD|q~^Fdmgr5JNI)t<>&m2{te$*J82Q07bJv?E7OI&D}jDv~z4jWmMx- zkLD9SozJ;9BOU?3cnp{o0lqV3egZai+ixU)%IBvabPVz4V(lgC8g;7S>i!v12+4Yb z0hxPMZkUxhK|X5fw_G&hofLSOMLdMvq8L#V6&iPqK#fbODLRLVvCdDpg^7=JB`7>Nn^X zovidAQ~dK-{HJJ)36b5rR-C(x7^5tgE^{L3Z67Uxx6FZlHR&~f_jF@!vH6dRm*(%g zU!pGWT2z=fbKgD*UV4&=JTLwRF)u^nk+GkS#e*!9wrLV$=cKEB@{PM=YNZ{#qI--) zP+lK#y*DW4*HX=HJmIc(>3^chPd{v$b|>ZbmstXz&bmi~q=M)#^c)l&we1+^;RUf) zvQdN2>EMv(ADX`1UWh$1SGSV13q2GZqG@1-qe(rE)4u@Y-pI09`{5BxA_{|YHwbjA zr)*yx&R2bU#v~-83ZmUHg-v6pbbKR3tPCWf zo0PX*d;$H6#}yC-)8d6ggC3VsbD8sY8!IfG*+dvv$1vI(?b>aWO$2;hJqrMnZh%PK z)}>I`;M6zObQysSXsFVyrq29U+j*}99GYQYGt&@rOqE#I#?uM(X)hSw0G0jn`S<<$ zjra-+kq~H6L^o>oX%8T0B;5dJjD>rxog91X@$11^6kIYLavDalr(-X};LxMdnoJBr z+;#fBrK&Le@KV@_iUP1W^?9xi>{i%>HQW{dfyf{P|Na)3%bXIO&}c zK~gJPxxVPCRAv{vFsi9vccR3(T#G}O1=}a#0K&DUk1E(ef^|)lQ((2OBqTe7DaCn{ zQ|f)o3bS^h?uFC*m0D4Q1;H~mWW#>YNI!v}JvSrzgzwW*qM0G1zgot$b8@K+P{3nS zIJPr*o>@5R;T`3xO|vA?h71n*k}6A-5Joz->h3{ew%5U1n+3t+8iwM2+iO6TC-735 zdwX=w^NA2&;8r$ddey2Kc6KxVsKwghpK#*G5!k+>L?l`jbK4#c+%=-0*c=?B_%b680<58p8$; z26B9jbWM4}tLZqF5yjLBI>DYLOXQ3W-Qn0VD*}?csr}u!Vi1GvT`|*4LuphRO{-RhQCwjZ`SBVztlC?V`{PdkvB2& z3wg*=fBg)B9&Dj1Kzt#BGqY6;{aN{ZS~!xXoXv%AV(?P^53$H127G74I~6zF>@8^!c{3aYtl;VEgQvDR6H9Z zKpho@rJ(|FRT_HT12R!c?|K$%UHixnzLIqns&6!#j%HR=jMCdRYW?n*HoJoCZVamP zu%kqD+=|T_nU-IG*yu4uuE8!Et>%4JLh#-q#J6O1$b5$N;%!v5f>w=#srycza)qnw zXN0&O28&5T5EDK!~tT)6qcWqT@1|?s=FnaIzP>hVu~B>zN~U9!Z`BYM>y-yx7mQsLTx|jinP80-w0O1b z0@YSoc>8;PRYTivS5W$?(;uEu?QKdri2mEjFbA?s zQ9H?+Mg^3=A#rMwk7&HBDf}n?FulG@dM#g3<6XpSFMBaxX zH}AW&uE})7Hi<6)@uruAV_5}2$}f85p-h3LwF>6p8z`QTxj8r3hX+L0SQv5TF`P(M z0>9E~i1TZ}6uGzQJlE@v-sp~&_6;dc6tVEqiH(mbQARyLN>V@JfSrW{ISTl`f_U{- z$q)-O$|`a+ZAe*Z?8|`$lnr^^aWgtZ&5}JT6GbnHIye_gZydf(CAteTRGzEzPF-VH zj@`Lk=I z$J*Dqd9dRbor6(s*_q9mBwrya@zjXPIqDl1^}4FY6lT{u0^eq2{b+xfWWpSi?ONL& zndJg@=mon3m$`{t*Lit>`~r{l#e%5UO0Db%wHM{zMR4=q=1oFw=BGfdm#YvW-&&m* zs4s`VMdHz>(%Pe|*zagJXr+$F;djMvVlpPhsWd{m?eI$+Lo#PM05R&;O|d&-)$ESb zp4~YWC|ei$oawpPS>qUW_*B-Oa1AS!!K_dZR{L_zJ0RdrZVbiGI;86TNIc|Xj_r0L zSmT@zMZHc>*>jN^qJC;Vt{W0#vz#%jh+CNtHa|z&`}1(%kypTxds6ox!1Y(}!!DM|ZKR~s%+f(knBC!FjN~%XmvHN>-Nh>9 zR)n9>r8w#D>MBEJ(4ZN^2F^1T7PF#wv^UT>JwatqAo$MBJT~%zyc|)*Z1P=gGBgh! zv#1jOdfKd|lT-_@RSr$eL|@8X355zRej z+4+Zmak2_KPs=Y7WA%49*nUJI&8pdQOP8ij<|Cq-s|WS5s5ZtiolI7uf;?$wYg_h1-BP&=zsArCZ6XN zy2+Oo`-_EQWxp=AOtwShMmAbDp1Yp+Flzq%uJXxh3{4xqNyNaw;8M3OJ_`{$A5RT zJ<3Bnoc`W(Wh=Ja-BOquclaETS7BQ6;B21^)mSc)SZodSge3e2Je?VVD#-FvgZjDPgc zmcg}86X*`FAUJCKWrAK#6AdMJy+j9}_pi_OS(z`l7_)hOKg%~Vf*Pkt(J~Ny#6z5W zJt~W1N42)mOF?w7xQjH#fxea6v;Of0q!uU)Yj)*XHM6eA%*PmQr;)MW?NV44KF~Se z;HN$twQdQsOA*xu>~`f_CDAL^z#fky55yp1@9%Gk5#~@f#@v-$rZ6SEdb#k6g(hz= z%RdWAi)W-*uZ``BvftX{9gu-e$|O9#L^^o^JQF+8t>X?HDj3)eIdb12ahO}&of+JB z`z))mr?bfZ+_av{{x^CxR#?2a%hWMY=+h8o*UavxT!)FQ4<|{2%cxm#Cq6Y#kI$mK zbLVQm#eRtss(Vt@|-XmPO9rdGta6pFLd zZ@HB4xw}>Il~K(VPbj-D$ev&aj@+-Fzhs#0kRn3Sa_v&Gn06n}LK^a^y=vEbH+DK~ zo$}umRNf0f6B^6;zR#5)beXquR{jgPZN$Cb{g{`_@=-qr%jLN> z>svx?)$|RV<=pM)ws>x@lm3_Br+Dl6kuH$rKi}I6*6%&?SjS?QJMl>>>|Z9J17ZaL zA8@qKw#MGr6e}Nku!imfDy<(TJlmG*ig6Loj5fOoYBk&b?($3bEONh@qnZ(RJHM=v zLY8=S=fpIU721H|LcV`(nv22T(Ak zdUx+olZ^5Tt;`rNmN6GI3ME>uk#m#^AE(BEPB<`nRUT!5fj*A|Y;67T@8KwFU$6+f z4+ZWn!+T}Ry{)RNRrw)yYcy6g*yEVVd1LklTQ5#rDU}tsiYXbfCBvQ~a(fKDr;2j1 zQyp!)U|dQzO;Q(L?Y!dh7BNQ_6wC28R_z#Ew}oau-BM4Wtb4g7Z^pX6ZFC zK3gQ@fSVOL;alA;*;q-+Mopt9raZb1u^b zx6#Q?_9e20qa55I-ecYvInd<^od#tHC~Dk$kU;O0#kk%~N>D8DCMeTK116FiX>V{| zjtv$dO(E-9$huisBLk!qZ-?UyOd|do7I^vIM z`IZq9qF1$Fma)-`dRSet_KN3H_TTaYpyEAxEC{W6;2*({sakm*>x?lOMhSX|%gXu+ zKEj>(dMd1iQs549d-mDn&=|OR*08c|Fw&d9ao#Er&K#nbS_u6tmR!^Ac_F`6NjXqP z4^4e2ojzO2r9_^-5M(X-nXrp$PB;Y0pjS3mHs!6k7TV&@v@g%*!IolU;hfjTtOux2 z=vv3DdgauWxIXTnzWnis2ec$;CAYZ_xs85A;!#MWc$h1Pjr@7_$gdeG&0W*jFVWUJ z@Ej!Sk!C6W?5qx`VJ(18EjTv%SYZ?$z(vWT9KHq%<6OFJf=hKg4w7w}Z$MBzpaL?4 zti^K6$_b~pzsV1AphgC1l4MdQ@1%p)@8&8tj2sLqw#enFR>W3d(NL z)NFIg8Y_1oY>PyT?q)yy?UCLxhim<&cOGMQ|A$ebLB!=)DtkU32ULz=>nvzes(xWYWX_E{1KL(+y1X-Xr(2Z=*& z^TTIa#5h)AGsd0DG+wx9+C9+9=l2hi!sJGga=>QGne!L8Mf?R8s6Jg0^F!)h^sS-E z7ZXdK+omJwQK1psuH)$qNf5mF_5Q)}#S@p;pRi&mGz6E^rmLy;LRu(c|zB zkAtCc=)eL8Xy*VWZuNj2trU&g&VJAibu>BhJYAq_7$&6@>A+8+Rwog^C0=Z;OwYxQ2$ zdGG7&{%-b(Fw#kn3N0~aM?&r#wJ;rf%2URIUh;!IV9VJ|rz89$x>_m2zTqL`AP%0BTI4g84@kD?VcvOAE!CZAM zBa2YfzRnT8>*b}0M%s-NCkuT?2MQ4U1T#`gobMeCMBVGUtfX5D?5368AM5dMciaFT zHi`k4U{P-%7|syRsKvjkf{!ck^a%aez1cDgSv&g_rj=bcoxOvn&)LhC=it3X?06$K z8gr#;v?CNZG176(rRjliNM5YLvN_0q`Tg#>vC3@s9)R>1{HiRe+&S)ugmR8Ffo7|ir&0p%zF96$yLg}G$jX6ocEEh!wN^X?KxBIp7E85l+Njrzk9)bO+ z$OU$9(0aOlG(x!$u%=p9s1LjZU&KWzq@S#DFKF`%ImiyUsJxJ93b{q#I6WZHZ6dm{ z_%aW|1;fJWP>Y5Me$$V{xHPsvIU>xWsE%xsY2fG!$}fr@1xSTYgTLG3v+aoo#A}V4 z<&)e8_c+X%)7>W)?}JDu*U{S zCQdTd`qzK8S;WKz7uWSZKvp|~3NMNBPUnO5h78E%qO6_81sT3<#Tx9O8aQHcMVruZ z0^ZAj(;NLbX}_Y^jc~uv;npXkVI6J+W){*qH?zW0MxaYWo;jcL!9wW8GP0>e!MN6Q zh{O$RMz4cjlm@o`SLzV^N1~7M!^kbsxxg+h_*YliZ7m&?k3*iPSJdUyh$gkkYX{uS z5guYZbGP8Pv)lMQ@5z}J&MhgR)DZzXaXsVF&vGT*XC?P%H`33V3L^zdhGqzFMqj$!dm?6zIOO6Kv60jXcIwI_&U#`94?(qrk}PEL@{JTU z{E(ha^|puvA`mQ z{9-LzKGo>+MCEm{eZTcF z=#1Ixo+YR``fJ92eB3J+>4nSdR};M9Jz2i!*vsjaf7bsM0{RvzAR`1%#%#(IiMUBs z^=072|6n;j(+Y*Q3Bm#XXJRbGy(^+3)~yuk{EkRngX^>>iJGyo2XdHQthEgfp#lA) z;Pv$!xK`tVyYe2XQ`LA)FZpEl)}+1Crl!Z6Zkl{5eO4eBXsTT@Ws$n9^5~e7p52wL zj(;iduK!Evxx}7?n_FpHcbX2%CCYaH=++&UJ9Vt*=H;~BSrZYD8c-o>oF5w_l86oA zFeBqK3@?H?=JI@vD=Yi?N@3j!AZSM_(y^iuGHCO;Z3&Zo8$1`?0c!r`>Dj;#MRf^zhngaSY*Q`F#Eg9{Ms1v4LEDPK;@MzZOV#IU^}k*u^vhNQ=Dw) zwc8%gNiQ*`o#MXoH;;2aCkd&G!vGXkS0OI}LgoEFA6Mns@_>1M+Fn=#w81GEiViJS;HcmgnW>zOYTx&`*uuHB(8VVft#xU)N z;5Q>6YZIrt57xulbK7H^-)`sr1!}&m*QE)ys6k=G9iU|^2d9S;-@#w z2&W4P9VeY&kl-SI9FA#`zk_-4UG9QXA@jbVs}jXN${vuztNe#yg=zMvpNf!fxLM`) zdg5p)o&ufPv#IEzzuD@PxJ!7xh1Pfmxt^jjj?qJD*l+!Z&rv3kLP%IcJAM2 zlu`t2$f{u@So0pp%1Z^%3ZYUi(7Llu-skay&wAfhXZ{xF!2B@+pYb?MCMU*W`?%kS z>RJ=~4A=QKy&-HMYfi;LGInt`ED+&IKv-B*3lW+>(9B?PR93)CJ)OHa_fVx*th0CF7dzr`;SH9G7jK=Ckkn`-Z0ougzq=ss-&IdQ*()RYth~zPFL`A}z6j$l zAT=GJ<@L{&3$3G7!u>Td>nH@SM(nOY7%gNe8PK@*34BjjCyc1Uy;@3UO$b-x=RKsg zGEtK3eycDKT->~7kP#*Kg!XR9W^ct;`c>y2P2hUsKYh&D4;GV@B+#4UV-hWB&Y53f zlOMuF3j{mV^Xi{>%zw=q=O#yfQPB3vha4%sl=O9yV0tW$?qxbaAJ>Imw$(_?dydDw z)9H@#F%TMLF$F2{w2%f{KWTn3GS7~FJ1+he_?+wW5$m19*24Bjp*}b%%6S-2pk^hY z!26r)#JKu#U4v!PaYOY}-j7(Q(&*q06`YXuNQ&@=BX)oTCuL(^MX_z5ocf-oYwz6#@O@GLQkaigUh`3jfC{n z=Ns@L-{6MuGW^qp@ek8W_oGhr`BJDwO=dq3SslPx=#P`_lIe+#modBJ6nkYUTyK6I z(WU4=(A2nFYfM_L^I_BGd*ulT$rBAV$exSC#1sw1O+}r^O;t9DmV*y=K5nnEw{+Y& z5g7v_)STUO_1A^5y*;<*+vK6U3*@~q@#hR9xft@*y;l=&8*E4Wke97w>D)=J!=+|k zOOd;0u4clP!?J=_q8ESG0n%M6Ya@6oPc=>c35?>`g!8}Km=a`|cdAjgN}VN9*fO+o zn&`THVBtvyvMLV!NiwAk)FLwV} znM)ZTq70C@jbtTg)@z6TAK^5@Lh+g~iFPr#IE_W?<#16_L$C7&lRLBhoRl`x`yUr9 zlaov7E~8q=C;P-Xns+^!^=u3%zu(3#3Gx0~`o_Y!w8y^^h%;1`jD)kCa187l$))`S z*MnHR8ElzzwmQr~NF5(-oQUe@{Or5?Q-e|BOHb0Xn%;Hu%SVge`qN%#l(t-w zPUGnt7**%*mu`x$kySBzZ^zTRnU0b{2R%TCp4#kS{%3PAY>NmU~3EdFOG12%f z1mwmHL~pNX{5#x$B>)q%{xyu^tdgahllRm6m;VOFW5UBeXjsibA)Ue3yH5wg9^_Tu z{6-Ws6-NW!V&r0YMs)x&y-EG)J(j1+c+WqkkE0nyJ;TZ393&AaxjB1ybnG4qx&PDKKGP!}@9CH9FK9mpE4S(48NfkDTLdziXe=^p{&bFDU|%W^u_t3 zJTst}y(0dXtluXJrsK~r_79yUQ43k^r41 z@xa=Yju^`k0*p%%u%S0@8*U&r`eO(9G2B|oX_0VEk6Z)T7+soE7^Z#NUyI_*749YB zdj||`-=+joLT9k^K)^OOWbSGZ-g?IfNOB0}wUQj5Y^llgI4b)Vo8U6JUF@IS--k48 zjl1~|qooh@`GVRnuhsfDs*2%`^Y5io*HpfmD}M$yn>i0ks>Y_j?HD}PcDc$hX*JH} zjA8P5aR#Jhs#EDN<|Sd{^?@TEB40q)5b<5`%N~N&`siWX*D!ntf4Nq(Q}rt}q7rjl z*T8TfH=y-h7$xnDS!E*6A2WjiqdZ|xm*u$#FyoCB&P*{uk}x$e#Xs#GjQ+QPQB0p8 zsHdMl>31D6DLzFB%JbhLZeO;mK|H{t4?Xs9Wxiu48`lS}Tp4?g&2F(*dj7>aVcfV7 z3GNw3-7w0zFfjDt!QqMG3r25tQi5hHMYy*-vP*4DoQJllONjw$_g79;0cv@lhyGcy z1dF6MDSnlMM}_6|Hkpx7VRX(FH+C4KwNYE4juIeZ{{`rsb(L{UJRk6L%q}I@VRtdT zXnriW#ZZ7XPBq*<6Otq86-HjMamNvG;>>?n|93$asO$m#=DRja?%_jsXFut&|NJX+ zf0wEIiNK-rMpnrhzrq$8FPZZ|#J1%x36h++!(Sw6uvJbj%W&gwqbT&g&dJrdV{?p) zx$$>>9ZcfK)G!}@B(`fDWYOreA0G%oeq$X64)}S}@FGB)e)g$hC-OKy*Sy6;^ zk0ZX+9B@wbs_~sYD_Yv+hRLv4OSvZ9@L#2-iKD`&8M*g~-posB5f!}Ix|_#t&ky!V z+bMdD!tA$DVF#bjhq)96ReHttMDLM&+k;$EGY1w8$<`8q1%FL%zRyiPicJXr#tD&r zd6Gj;^Sm~t)m-{{CE}Y?(hiDxcxj}Unb!5RC-Vyklax8n8Xxx~izC14K!XNH7lv{q zX+}{DYDXC!#zw>)_b}3w^-y@4ga2-1ROB1KP#)+ESkL85lJrpXH)lY>@6t9@jz1^N zp7}>8-y^Tlkah8*6@orykt^1Hv14!>&9CB+#t&l+#&3h0Scwk}DHy%;K1ORNPi=x| z_uqA;ONRmR0?s%412Ahp0zb*}mp|nP`PY~!C0}BHb^TCiQpsHHhX*&WW3B8asJWj> zbcQYhEl=wC0Xuyu5fURj#Qdb7vsoF2c^V>GZV zL$s=&LO4HkP{c=A=FJ{ZkGK;NQW+P5gK7ZFI#PfLhaj^h{4V3@v0an6eVu~D@WCyS z)LW;QPek=p-DkYG{~>Na9va`CbHbjs$+DL~|NR?^Whxtk9C{TP7SuUe^^1pdhl(!S z4&UOcy{skOj}t|FQHSh{F#UiWbC>>d>&QDUl7#_tuz17<8D+c@ZHS(CY`y#PQ}kW> zDY(4EG~}Q(#eR3Ai*q`5-(ybrHv{04l_qIRLzp@C5(K;S4@r!<%&@vnM1SAzk@ zAv9Wfc5Ysl*FM3r7w4axEdMe4)0gfCtK}ynHd%&`nBp~KF|05Nlm_E3&6t%URtDa6 zw3eO2)Yiv@jt2fZ@1FUWVDV6o=NlisE-i}&-Rqwx>!SURb)(ulBLCIWxtJ8uxGYZD zo0Zb(74*`EQvN{f6*0EK+oMUEO|`Js>!UKH$=HVSspc|Za~4Gv{iDu)mPRCE%?mlo z=X8wf`1uw7)?q;F4R|V;WXU-t5Tnumn_Es+Mh71?BjV2hE+~ZmosHo!lZzBImn*nV zwGX{I19P>1noIJ{3GKTsM_z4&Z&~={QGZQ)7O4J&i9AqY*LTG&LPhw^SAVV;wg4q7 zRh$$pN5l#TjS)Hi$i6(p;upKv%KoM`!#M>#(O43+e!}%Xn##&aVBHarEKea}wi2hO z1bGOGujJf~n-4wv`|Vr)dHV$_FWpWIxImqZaChI@+NS}@w~jm-nGNc^Jl$w{R5Iet zlE$;SrApo^IJZo3D1Xw3pF;GJG&?ZfBt^34uXAoJw)Ea;78Q_FkVL0DJ;A{b-dqjS zCJJ8DYTo9LHQf{v=xxcb8zd|2yW^E4J2_RW^Ji``jvh(lpWMwGNPfdc9XTnE8R$cQ z%CRb|w$arDW~`U}x-*cng5Y{${Qp6ptVg6?v(63I-beKMnSHi2(urnnE|^UL*T<~S zBA^~PF4w^542lNva%RL0hk$B_XU+hI$#$ua$YnFm_gg zVsoAE$JpnGa_#WSxEkR{j!GykV*Q+IE=I(k5X+X-3cr|()68LgNB{(TX| zF^rXG+)_YldL`jCwr{KnyH6?%rN*V#BpD=skpCxSOp2~1Kk3?9rT2RNrpkJjYj|CAaDav)sZ?C=f$M^KdtZ!HFRcv3(L*TYe*|^PZF#g|`6m zui=1pbxjcpMBhFe&1Dh^~)D zMgz)h@+qlJ8npIb4IXpAjR|t)SXa^dV^*L0)T~Hhk-BqCxO=|g*1j2v^5qIM^8Lcv zXk@1tuabu;Q z2>&F#t#b@fUgV}3g@=5@5fULI{y!HFSt+Canqyfg=!pIC+)72*oUe62hL$=4K>D8Z zpD+G+&!7_Ld(3}L`RlQ)t}_vKQ=mLEmt-KhVc$KRo=j{UH|iq3KP;`3&RoiAmiw0y zWgL*#UX=(YGjDAe?)-^crMjMFo-*hsNzz>4fEn5$@#(pO{+M?&+XA38#qs*va|=Y& z+{lV{7fTJD8yODrPq*Hlt8I{Z(YEd$=gVg^FXghn8>qGRmlpOZjHh~{R$zWI4c-fw zd225)=_(w(9U~&fPsTZz;*?=OF_z!X8%sAB^4jqmm|$X*dXJUeugDx?JZT1UWTtQ~ zgyXUnx4-?ub!14a1>e76V{h#N&od(LrQh1KfvTG~D=~FF|Ld%0MsO(xZ1;*sa?>og z8vD#)Rd^R@7Hr#9#esIYjjtwG62QuS7eaW%(c$ zs)ygZRu^g8O#c6&tU?0m#`m|mfdJ~h-kPvTQTNy}{nwL@ILq>(o|wFUA^#TLKiyGf zRU2${?xK3|)~WuZ|M`NiwN`(yS`q(vMzykt*SvZ&eh!h(3_5;aa{DErO=)6M0}(Rxh(Kv+hn@OQdQyrr)}HDl?s@3?gdMD&gx6h7@{&C= zQdW=^TEY0SUNjrfSOm=&r0l6ITeRXpg&`BPE!TeM2C)!{_$_*~&pkAKPtUXp4eG3} z#JV}W?C?p^K!t1fn-hAm73@o@c3+E~?tg@uc17jIEug*RD->Z9EF7f{1bB5iz+^&@ zw5ax}7Vj;lXRcUR{#6Xzpjip~cCk4g!kgODRe<`F%x^EWV&RykU~{fM_bGu=-p@1W zV^0SwkIy`YOVXDIq~EY&$@IZne>>bX3Vse!sc{yk@?rJ=#bUc2H&s&S$HeDlGq(Ci z%8u3{true+gEj<+ZcG@PtX&$*v!0)9m+?C@9xDI@6GRu@wyPiGuq!Org9}h&_)sK zilAK3s4^aq$r_0pcBh@JqSsAHzAm5PgSJzpDinv}f09?3iqJXyv1_AE&2>a-`||NR z24&KmBsjkz3D*dmdz=TA<_CTrK?~l@jb6@DY@Gw|4O%N34zu3y;qq=_dkKnD8rUGw zX=APpMk*9&Q8Sjt65BX@`p=zSpX_^q9+%chJJ)#H29>mA4+&0EBTao|51Y9|Mh(~R z6dcSZZB3I^Dn2?92uNcYL&wqLEc7<8RkQ*Gy=mrJ5j%w50+oX!X6}o=VQI|M(yaNm^^G(|Wax(fxas7X`9@o8MiyBAD`gqxdvL7SN|$eQjpU@jKzN z>L6pY7W-c_HrsB_D{BWmsI##bZC%ANFO$~!3!8YnUuw7S7PhNW?3Yh&Awh$+$Ux&Z zdgxk8;1}L1Kr|aAX0io&R@KYe=pLgNddJ1jS1g%f-R%p&nSv6N+oG`Gpqa;cu*Imn zmv&PF8$A~Ue$@4yAp1DVWYviXZmwx;&Iay7%&08gvoIj?x&F2I=gN!GG-hiJn|dA1|MH#in?vu7@wq7D)c!pjOSAm+lx<9$2-CA2MxN=?~o9DiC&; zd8JOQvVWL)bR)H~1`U1ArUa zv6}l{N2)d6`aQE+gCZW(eTVJFr^TbBq_RCaVN<+n%;K-_0zSQnZOD=(2bc3 z$*OhK2xjVQ`K5ems+s_%5cCIyusM-#@TYqo*rVzLX%`%earoE6YStRw*J{ zqI~RKUx?R-QC~=R(Xplmrym<*E~K?bSV=N5_r0NeV$i&EcQgED;uOR|CiqO_T9G)9 zNkne_Q~7mXYinH9b?GASs0|t#Fe3vY|Ng4>?z#e%sdEVgnzdu0Wr{P7J1b63zqHMG z_hj%*y=bUephA8I5C~Y2k~7_$g{b8P)dd%<{v*8x9hV{GHxM z6oW51YR9k$W=41Bv^wHv7pkA|1NP{7!aJ74j?TBU2!m>>gZ+Jv!XcXg`@^u83ASnI|7aOI;Ci~$~5WC zwUMLnw46WIPdo{*e^ck9T|W)Keg8K?_+xge%Vb#Twy22jV~$7!y&5gcMM>}b7fWFmd`i@6M>quFx39GskX0A}Xs9Ajy|t9eRfSg4Cm zP-BYa2u%3%ep9M}+TD%76LyS!n>R0(oEH}*=sg+eB102>rMi+&I){B~OUkjgWM5qN zu$xXuwQgRxqyi;$Yp51!pTAk)covw$HId6L#gnvvG?*ZS-+C+&sVFK7e#dkVx!~h! zE%TTjM!3d`Ya+)`WnTL_GaS6CND0KXmR`{UE3jyw3m_k)RZz92W_qys$KoYi?n!~8 z^n&~goZ-gCbYo&fFIHIgTnVG>RSch!d~wX%D;9B@!Gu{A9Q(eRZ&d$G9!jc{bPz!S zb;dbC#ij$!?Y$3;J>8AUHSJ{eU%xK>by7hmR$c#JT^gv!aan&}X=8Cw;_;W^4YsPxzM&X;GXO0nLMaYSRqdS&Twh3fN*yoh{YVa!yd!mK%)&e@;^q+_VgmmxZ-$KaMP)x5rbvQy z8oNe6F6$;Vol#S7Bd>4Co8_Q?H=;+KkL@F9-PEQ;k+#-wMk`LJM@F|$t3$`1?jMAKW# zPku-;Sn#UXoj4#DHa1}Q^Doh83kZc;k2*eCDw)0;|NmN7GF2z?fgsB9NHw+aWF?IN zs}m>WtoGawgpQl*>UHBnjgVVtVd}&PlLYP4z1u$t6K+tFqdQB#749$5NxknMkWpUv zVo!PDl3^3M%BQ_=#{21FNu~-p996+y#cFX+&2mJ``*I<7ea$&}Q}p+`+K6Lb@mH!~ z4Owevu8bDJnfCLQgxm|lm6@FLj-|v%!&xM4S@v&Gt~lI38v9c-Ja7C$e3s7K%a#hK zgRPYq6a#G|b7Ce(`CSd9bnJFnFuLw<%#q{{)iYY>Lx*b8+n?1DWhl-h(b}4T?I459 ze7*njzF!3Ig7tYgc$KT|eSf5%>#Wz1p8|-PeOO^n)nlL@HeY%*s7vtk!pkgwtp})d zxrUs9%RcEsR53is_k(on?l79Zai@SiYczPqSmX<{yNM9)4$p5}H( z54TF|&Xx#^cB?|9+$S3RrwK42XX~4kP}MT(l=j&5Q)^t7*o5Yd*9r+-MJoZ9yfOca zvuKoj5jla>Pcf@HU{UWD5;(r|s%}?Yv!9#(WtRl^{Aq-t@E-5-XOF$~enFs!jGV?| ztqvC#`^~P`USneobw;;`h_}~ocJh2ck`0CRFKn6;+DCMv=jrnv&nEn99>-mNJD0QB zONsn5XW61%Kaay7`hKab)#WsOT8Zv@$E$~;{j8_ob9dtQqftgX7KOJ< zY5n-g>gGG|g4c!=D7g_fZ4HA9EBJRQLk_XC=Cu)?a{0h35LF|o>s(9HXK;kc-%aU{MP0&N#TR3o*eT6$*B{aDSLN$a867@v} zwsr!klNA2F8Yf5nrhxC*O5{Pwnq}|PSgQ~I-p>f(%e`&gZg8-vLF3?15Khm?w8oK&;ds+j7KC#yO5a1=gt$zdKp zsInG6+JpJqtE{c9Bs6IKXn68>ef#&ArbZBTBIKfT2z5=I_#QDg*JZ>Nvgad4QrCiK zL*y@CMfV4p0ig9Sd!+??H0hp~SSx33a+4255`CD4l*Ef8UVe(7csE7y}#spRN->29on=o_RrIQ z>38=|A;wNZu62Gn;=Z2E3suPfVd}f%*>2x(b@FO8ZPjRvRJTplUM;UKT73~}*RGMo zCJ1d6t-YyPZBat(M1*Lq)K)8qNbMOhL&P|tzw#q0!(!g^Z2~0C;?!8D8Rc%3$*a?H6EABsU_la=f#Z=yPa8LaN~?!o;Y|$liEbf# zL(`3ABK{Ij31Y|ycF1)@m99`hDC1{d0w{a*5NHeYd+ZOu|uLKY44!4_z@A)Nk zZHa5{q>!E~5T>Qy&+mO)9o}DbdmLEXwWPbV{Kq4^%L)}$YDu8ZIy?{R0S%Q*=q|vd z8E((R$-f9>bEU4e)VA(%FA^7HMksTyEQ43D2SZ?MVRWli8A=mueN>sGz-xKYO>|sZ zvbbDw*8gYhg`=+J7uuSOA)Z;;V;^~NXXF3byJuvO!>9%qFV(H3{^GcGgCMmrwF+;Y zaFdKuJ@>XWQQ9w6eyNlATbq5DC%Zdib4~t$&RQNBItj(WE!KXGe3F^e?{Z0gGD&k( zO@9&wZkn76y5O(l=By?zIjj&4-?p~r4OR@>yw*6PmboZdze4LxXEjmXXA0+CK96|8 zt7(=OhSlo2TdXao%g5ITBHow<=?na4sU_h)Q05WgT3e6XF%*?>&g@06{R5zS!a$gN z4Q{jLPd#RP(T2W|IA&)_78l7Qn+rBXrQdN#7wno22XSFUHSqRYm_-ph_K*82^4P2c za_w^-~o zZT_3>TjXOwPX{3=6Xo?!MCaDse^EbuVJ_3FbnBMU*&NIzn_sk)n*;HrfF43No$eDh zcvm)a?9|+?SV`8Y&Oq=TVDgroJG8rCil{npYHH61{(aU9ueN{$))`Jz-FrV(rZhVC z`2uIWJY?bhG_#5FA}Cx)?siS3;$SV@<$z!T)40 zRg@50T<`XNz|nB9r*(e+gb#lTaJSO&=)Ov`a;cKhsxFF=vPILKu<*_oyS_gdWXX3c^}8efYmd}| zs?20=-`s$6G2)E9|J&xL8_^=4dEq$j_RaJ#4a*S3u$>sxwqjgi}+b z(PqE==R}!h&0UDw$ogR=bPXqD>5z)XzMn z9|SFC7HTL_lmHlbc1??`V&VyWS(wz|z@x$g#sYD6s-!j$=gfC8rIp5WP9#Z@A!NLa z>Y1IIKq#MRER1p-4}GBYpi#n{j-9IiK!FKDR1$59@Z*%$r;So{*jZB8og)4?oE3n+o%=A>x|)j7F1IZ@EYk==}u=<_gHZ4Rg^ZbvHfHUk+t{Dxu>S z8{C2UTk^I{fb}=TOX&7D$FY`)g7VsJmy+BvzC-W1diOW8Wh~39h$JgtndvijsJuLk znVd?utDFwBsx*~@_)ku5=#+UxWOEp`X0_2JMQN`v0Y52WLyXNaA<`z!eOGgm&alSw23?dyjjD8P(mcE% zg?))#QT+^I{GDlq<_w1?d9VSCbI%UuJby+Xj zrEFlLY|D2`6-wm{UiHlkbskCY8Fe7|($_D9GF8*kxxeAS?89C)zSByakg$*A>m3FO zDZ~t}cG_E8%imb}jo3kg&)D{VRR??}SdlZ&k|g6T$d3cT_syS6V&d#-i@4t<|6HDO zZ$@NT7R#)uo?+w)NTd*>xJWoQjOpSBG1cUiWwRZQ48$z!I;QB`=vD#Z`L9(Q|Cw4v z%=q@B#wf1!y91LsA-aXUJ|KTCH?3EKoF12>Z`SAi)5N@p-aLPkd}rQ%E53myn^%47 zH-Fk>F#U7~@R%Xhz4S9nQ(y4Rb%q4 z@FJS(2)8Vv+E^~SGn@oP(4DpJpUUs5F6CDpK+-BtEZytmnT&JUwfa=Pqe@KPp2*W+ z|K8`;kMG&Z0{UEebTBL*qb}Z84YLYr9CX`RdIrIa7Y6R)O6CX2Q>G>GpbN3xwn0!g zk7h1-%S~ui?q zV)slGyL}diy(R)28r>vev8rOTxg!qF#!t!)Tsg0Tn4#o6l^A&Us&&Ayw6CI@i!P*k=U<-aElDyc#u9F}}pR!SP~4#35NzIh)&Q zpE3S2PiAWoVv>a!owI>ih)GZCqmhK|nOS$I-DNY2;jz8O4HIbeqwqN;K0`Dvb)_b{ zX5Folrk~a4(y?lmwm&Q62^9l+1Smgbyc%ex*KM{q>rq$7ed;L-fSs{Pe~BvN%OVX} z!prl5g)I@V;s_E=EajsQ^M)^-KDrPBzfXl}v5c^@H|vzlFqwO;jfF)$Z2*qo6zK%# z=L+D~58izdPWeO^ZRW3Y9LkP59{zDAF1)^b#|Vq|l7pSx8ptZvga;m66J(}AYr zB_#AEa^|HRk5qZF^U0kwquN^I?kC9y4%XSOaSux^v$0F)&0>mOgaiJ+Om)jzJiK}- zZ6Pq&YHSt0M=FT~%=CZW4X;~Y`{b;)1j(D*{6gr;^6-k_l74_GI13G%QmaqyJTYp?|S7^ugI{mvj=~|G7&TbHNO&OkHk=CNw%#pThSd8-L3m7>`3qph{WHd%C;0L2VJTmi`-XU^X!wMZB5-y~ z{uYkO2xl&ZEZI-{UH@eIr}RQPw_(hLu@}#YMMP8uz6Kk~g{gaL=w;?Ca)l4vaP{bXLaRz~hMVJ3e8; z*RHR_(gH9m?l(ONsLY?himtBKNjcrzCe}qZpBi^mA-FiOep1W^W*3P8CXF(k{=w|s5 z{V^xIBtI(T9er2mh7W~4;R0P#>lx(}3C4=7>1HP_MrXI9f;zeI(<551O`;J83!R?X zpnHrS1=4JZ%8TW%fe9%9qNf*^C+Ht@7xG!5Ksl~HM;xc+z)k;(@f-lAJEoFe{IW&x z(UoA1pErq6FSAudg=wOnWHUYP{Ft(SZnj^Q5!j?nGJ^2YhOt%yxxhhK<#D%>Gu$SvAAsC$qqg^Isq_*z))H-YpQ4XSYEhO<9q8gC`|wC_R9`a;E; zIDuneawJG)0}1Q`7xDTC-SH8=k^N*aUFA&6O+Y`zQt`dMHDS!uW_d+CN<%KO zpl!1fzSIn={Okeot${X+IeHuY*+m#XJW3E($&1Vu@HO)p0W%{pOXeBme+%(+hybUm>?6WrNc{a@y_+Wu$1GN31SJK-^6`lLItH19TPl*m_2ZU~`_ z875)p%eGrQH$N_xBp;evyUj*e-K-5&91^+Y1z0{y0oe=jX6KI4%>B8_9>A{i`p`Kt zlrH%U)SqqNCo$*ze--fsyAPSqTmn1w^=C&wXiyb$iXy5}T`-<+$mV=%wai9_u1Q{l z(0)8OpG_0h{gn@?e%Qup??*NIKGSI$Ig~}Ts(~ikYgg%P{^)AmT!#-;k2=_`(AT^z z%Xly)mc`R1ZoYLv%CyQ%`e?K`Jj)1A)xSwEB_U+NOA?zLA-YSFZoOOpc}dZQyXapoIk4aF>53AMPs&s;cw2h#%=S3Z5Mgx`ODAkvzTI%*%7o z=elbO#TGCq6r>ug`O%sxP@UMRj#7^}M(;5AkUuu5Hul;rM;>-LGX1`4i5S!j-&~eV z*w(QP;k(OR+tBI1s`}THif0dv%><9zIppOz+81&(cY2raPd(qUm@-$6+V`T01AhSm z7WS$S{&b~7V@03yYmXOe+jK;Hg|i3WllR3(`g^(b5G`Pj?PRnLtv62gxq3!4XO z@7$sW+kVuR7ae+ZniGNRo4GPs;XK6Zp0GC^!*u-TV(geXCx@t9KKW= zusQ?%wW@U|aCgWe;J(A0dX`ZPky#q%;y3pnM00IOvi`eBOwjKaf#*78A1WN?R+Nu& zE8eMmbQP3~@ms#ehr)=CMLUhnlF%u9sG;bUjQug}XRgUA_Ba!3T<7T!SYHRi>=6ue7)& zGEl?|6?JP5HGTN)za-GMDS;b4gRZ+xWh(qe^|LcRQ#%RQ@X-_w_&lRtVs0mrxZp+_ z6x#&qf(;uyKZ=9e{sb6rR>MmmXIs~TfXrhk75F>Ejn8wRTUG$a1=iGy^k;yz?P31c+H4g}B@ZkNT^x#yYlnl9!vQ7Byt8zN$1%FD_E`C#p@R1f8Dh-NQtW?bRl^0L>;nb5k;N%sLReC`G&9VeV!?LG#I z4x@?eun_7#J*KgAA(d24O~QGX*J4LcPvlvvj7kWLwlwl0R+P;UdEtk33D~zscL{GW zP>#!@w*P~CNhs!y@4k|QdA>N}_})-7;`7F&QN(TvdEYzWwZl+M=v4hWDyF>vp?gkW z=c(8=+Jg-E3qB1S`I9(J5a!!epE+wmKsSKsqH5NwE$B3BDy8~67x0x*De~;OzWxWw zzj~g~YrE&I!}jnLI@7NXil^w7r?b-dlNM=Q3u@6fRWtgGW^W1lgSwU{)V$L6vqn62 z+Y<2KT)5UrK!2lU+^Wl2{AQJd3F0+2ky#ip}u&^0>z%eibQ9ujdOIa%6 z|F`3whsRHe!vs9#(NRh=IE||^&kx1^``Hdyv&FdVrV*s;{nU8QM|e+xxa_?f*{vV< z_K@+rlSWwHc;aWh;6C!|Jm2&TUx_x?M0`WXrEA(*PZnokBZih&Gtb>cY5rr%1WO66 zRE-x5nN?R8^o0GQC%Oj|UsgjvQg(PlsgNO@q&)vf+v6)0DSlUl22-5W9cN97mK(g6 z7V@$q<@?tBIrnt-1h9n%30~9Hp_AEl^L5DL@Y7SbHOQ-VLj3Y?(HyOeZVA=Iihw7+ zTa$Y&%@Qf^={F`0kM1wlJOv|kerx#KQe8&s^vANy5NE@v6ym~6=Z=#lhw7$K)Rok% z{vV#JH58Lp4R^jovioF}JMp%zc!R-ub}8z^@M$b+??a;agBxIC=!710FdcgebSvxG zMgjbz70^i$>hf8nC0T`EH`7Q*%U6zUv`FL_UPaUN#j=3tZ<}!EoN?B3Hr1;&h$&_2 z?$m@2lS&d%Ullq@_Mh}Oe-l`a;3rd=XQhCoyrhjjCa;R!NGUp{J?32N8>8t7+N@3- zn4CGIcpK0}PLzE;%ZOa#vUeBKIq0LOTB%=8Wv~x!N3i-jYk?iXQlrt5CG4S|(yi*5wiL&nT^Raau;6+_RVG zow1O$ST9WW@58$9`UY|StGPDGfJ^Sw^%ypXk}LKlIhPuhGUs&#k-0?z5to>)39(5F zq0y0}ENOoop}$44U)P=zy+gp5fXCVuUgrN{OixZ!Sf|M>dV(4%>LjkJs~N;)o1Oe_ z{&-)&p@!8b`QGDt20G{|1T#N^^w|u!(@7gA<%hj`x_Z8~DBE{TSF3zqr16S}42jq@ zMB9`D>&3_x+`~zlM{!+tOUpk|3eS> zLt$gpHKL~;WI;`ZN8a6zZa@US4^@|8w);FAL$@)%4|#aSyduaVaO*ZXj)+twfEYmF zPPET>wP6fajh`&TgiN`Z7Ai1YyFS?lb;gcSf)yL1c(bea3j{lcw>2#O-^)Qyt45c% zQdWarYs6O&&%223cOD#avyCZ`Org1q^jNb{SN++!uL7iN0h^7Qm(}OH+BL~L9CT%7 zVN4K}fGlkJ$~9XLzR*9mv#P&V6+~j4PL6S8LZMCju5h+bSCrPMLOn@KSZG)GNbdH= zpZ~CwYe7I#N^u&%T2t2Bp@Sn7s)q9HJy1qU444e{C-x$YLDSf=bZSu%JR{z@#>+cYlhRlmQSb} zrriHfg3OfEDza4V{cHdFSw}jQ#V*c0Pqj`bMSn4FnaI4=VH_VEi?2~)&5XttHM)!s zEDOwE)~LV#EJJSs%}&*0800qHpuMLsIsxev4Pux%zX*h=rJ%=PmiwDTa-z@J-u>_o zYRV!&zoN5~PamMQ#b4BvuU`7kKRp`nimAp*Z78-Pe(d!`-#R%UAuGSy>*AOY)i>(P zrm7dPEhc<2=A$d>r$L+r<@Sc@YGWYx0d?wHKW@H%z?~V8d(BqaHm>5Fb|W*Cej7yr zfqrew@m-+0v{2W8?hSsQs8=egi5_e{Oxp2u|x@fr#8>sV2+nDD<;c~7OCUok6mbo=WssOh6JE&jei}$I+-fX9L zR+CmrwLLPlbQ8~ehPh8|zv;0GRIx$XJvAfAe_>o|3X7qs5WQ}SQ5oCH$a#1YF@@pX z98)Z`&3toftuo3?n-vNB6L8Ta+&dM6sC-uUmKXll z_|f79%(8ltSja>U*_REk@61(Tim0w}s@*jqryi;*CL6YPQ7Z6|9$VDXXkG0B%eYCz($5Se4;r3x7@RBpj%%$SL1i z^arAC^%}oV@hdZ(X5X!pQ)&oqfXz8zITHp59MWN~gXL&7k#1gGFn{UdbGq|i;f8h# zkCqXGz#P?&xEW3%*pfKKL9?oCgV_VK+phjh>#Y0IE{wjyJ_oK zE$J;~NL@NKL-iIg!y`e6wxZLyt%ftJa41$|Dp;$JHeNjtg;M6l_jh&#JIO=Bmk{8%DltuYW z!#Yg_4shr3kBb3C89l)Cb4~9_(|sY}^A=Lwg1j6R6M=1IqmK#pUb_Rw5u6#%qf_;}F{4oUX&zq4OY@o~BR`hG%%}-9QYn5V4 zj?pK)mNQOIq_UyR8!5htu96SpV|l}e7L&qQ*2?yO`3@= z*Hn$U!{8H#-|0zpktc`M$oUoR+yym~hBSLIn$Q>A5dLE`M+j%ctKIFD<>YM$)(e@8hrH@wb}nta z5bB(JIa(ePc*qOQa{owGyh`M7|7s{)p4#S0K1VQ5e#hWl@$Q?Yt7*o}!M<`tgx0T1 z{Gy!bU$3asQdnku4v^M8;m*zE*|`~wTYEYynob+TP6H>17oWG^GuwnEpl8&-!` z?)TS;UDeITwo#4Amjk&)Tg-Q(G_DGY;&Zkz(2$ac7`E+fCrA>-z^_s z7uw9|^djLh@%z>`S5ke%AI!iPxj24o&B(0!!_TW_r>9UYasDae6{!m}p^_~aFC@kd z66~OEK>-th*4r+c!yIhcMsFh?|M#DuGqbZvw&bheRYOn>P^3D{@~WY|Hcw>tM6e82 zss_Q%tS2PNaAt+s**lsFH+89pQJ6If6%%#;sb+;tHdj{fEjKYB=5Yp31l6^b&u(e? z9-8bsf3-*W17|i!80wmqT)MdFrEmu!7!Yy}^t(A&IrH0!YG|!c|2-iXo4WO^=IX)S;HJlF%_al!$D?e`V z1%!pqNi|l9;*v)=b$0AIGa z57GfiQw4Wx*?xs_49AY9*a$){8YGR;Mk_TurLFwXIOyp~w}TmM%xo_n%Oxx(AF<m1@m&|!8qZ^Z9fs7lyfg^2zN;}-9Pd7G$TN14W5 z@^-Lsg3EC(LD`s7etG09zI^uh*Q31Y3P$+&rRAEUG{yHB@L#0v^g}KoJ9{;*c%D86 z`&Lfx@YdVXF5==y$hJZ5vi?BTmR(gf$5%L)wL?2`@?1{9&Xg}Ab?a0{hxV~I84Qi5`VTE5G0=-froD)37ZdzCQeOSLr+Pw$ojY+6w&H64hJPOXv>E!R8X7lI5sNkLPN!5gxIPiW ztQYuTqHhP%;*A2*SyYQBLs{yeoD-S_SkYjJg9at=B*+f^Yfe+a=7pLP84qT55*6zA zmJaEr@uf)LmN?T!yii)__IszwtSn0g=)xip?7E%>^2JX_B#Ttkazs#M1dp#8m*F_@ zn%Qo|t$=&{K>G8;jNlvYBl#5rog$;aVF^n=GeL2%I2|Q@0g^bicS8YhTP;1sy&n5q zzga+Zps{*FN%$<`YJj~-fr6Vc`_fHS1Re*1k}d zbH1#e{;48Z_$}-;Es08Myz^`mT_Unb>OnSh%)Yn=)cq#!^@ld|kXO?N+5UCoZfvxq z>2GiYwJ0qh*`$05-r5+@7jJii-$yfuKKQaJQX1R;`0$ar^sf;Rv#WTRfHfp%nzt13 zH7u4cb!-88&#ud|P;iVMp#MvUAqd+iil&n08%p!(mI7-PPy!&QLfgu1PItNE^WKZ9 z%u__IUkeo8hE+w*$e5GU-f&qyTo~%^{G}Z$z5>h>p8a%>`C7`Hgf*hRZunCKK(o_k zidQPEH?ZtMwT$-DvuzZPw@c>VR$aR`db;mQbr-f$W?lGj)DQ`n!g9wDC3OSN8(p+ZA39PqCSu2OWRx3h!!??1}^P4Zcj8mHU}c{ZcSeBn)aR-wo{-M z{sw2|tfCQ9%bbul*Myv5d%GzQ!D#IZ(b`z|01Ak(7jN^{ z=l9E3^0zjwUP4N~`)@t5z1-TYd4Do~ruo_MRnu=sZ+XTi4nXHwapT?{oIs*7r=PZ9 zBJnDg=r9?uea%xM*j+fL{fXfPO9{TywvC&vVvuV$B^-vO%*=2xtFLO=8ojbn@~~l> z+Oxk+iQZOX&IZxifmzInya64(D!l>=Hzj*tRSPI>S7bm2KMKwDEZs2p*XKdC;Uz|8 z$o|_z$_p5z!OPJg4e!Qi`FYl{M& z#z=)CuaQpgYK|;NxDL&^s^)!iIVGURPN)nJI0SKI@uko{vlKqw~e~!%a5p<^r8zpK=g3kvb= zr79IWo3Wg z-L~QR#MR&)R~aCBmKL>V%MhI*t`L!ip%Z(v{mVD1blf@z@6gs=h78#n9UA}Z3hh_! zplWW`&Q?3W-+@dFv9%m3g`+G93x6tyr{?yki`B(Kq}CeuEpzq-8)M(^f4E!EeAZN7 zP`g3R&y&03XoMOu$*>iFQh3!&hfTaWfTv|Xw8yZ#4Qjf{FWMOfhsn$^AysjOTv5Y@ zXV)Uq*{xeXb!i25*z0RoY0PdfuWjv5TfY>*`2e~-DS}fr59mWR=C7A~FPDv1i?)1X z`U~64IlT!!ife!544toLEU)#*nSQ4as66i75`xLg8a`uGlg@`N&>S3-`WH>t*cZ=_ zphX%8pU4C`KwqKS^xdX8y;IXxSKVA1-xAx_d?hk$;gxmZ6E?|QakUr81AR;7;z(&! z(QW#9+c5I2Q)CIj5)spH-$dM6XY#3g0&i3v$M~BGMx&#xrkGvdSW1r?EwJ#LTpkUL zRXNiTCTV3Nx)e3g54t*9k`(gO?XpAthvJrVM1|KcAdh|`2>zfTg3X%c~uVvFMeL`WuUMf!!A_u7IA zVS6Fz=Y_w^sW#+Zh)n5g`y7xe$2U^hF>zPo%%+347~=gT{lbN4rW60C=kJ~UX5VEH zPyDxnt+luZI(m9R$RIg@Q@SI{3iX~c16Yd$ESO2(0opf*wA+-ce(uZ1nQhB zNAcwkSnJCvlIic3uCV~qm%sfsQVF5RRK%Qb3cD_N2A}@3x}U%{ei#AB9aY(}Jo?h% zZ_d`*4znBBT(Pj5*Zi!KQu(6;B`kdma<&-`jCXho$7JX-KD2XLVW1}~)wBtZ8rw{{oJa@ zfT7mfXO+mZLJ>f_U4efx0V$Y_RTzOO4zV3PiL%zOeK* zy&2_F;O0~?(OyfcyuNC@$b|(#-wh4rk482RPlsP68#dwhPz`IXUy)ccOlTnn7SGm&1A1B&{GMBfj33m$8rsgSS1!) zFUd7NV{s_#!TNAE>#QGIu`bJz8!rkVTxBkr$8)JoXAxkbTE*}YicBhEFr@t`InUJc-bYE(i5MXEGE zFCz9QhhN+(Y;N2J&y6lTlF&Q3WU=6VJr5rV1?9xJHuDfthtD=q)}mghbesW<=!~Ng zGXam#JIlBMVuQh(TtPNP(@9=Uvo|=|0y_!4N6*_rwBz0Tm zgCJ^XL0zzMZQO%QtAy%!_P)m6y_hJEXswtIb(J>^&sUpYCk4!4oSv zYl^6d%cl(uzd0oRiAp3TonW`wpu6|(D=EnkL1pCg!DtaQ;tiAV%Yb!ulSz_XD-faj zXc)#EiACc4Q9xkkX}7{(0fru*{!;(cM$ZFlo9hDYxhdcQ+4-bS1`@2!>xjKEhaSb2FN7Ee??QPM%4R~Am>7+ z?&8SUtn~;p2ePL0#?l;AM;Xax*e&)bhfZ|KivC1|ug-WAv)lD&%|;&5iHDvN&rzp# z771bDFI~5l$XOD&Xrw8zbYP0)p2PL@r866rBDW;hj8s2Kk&(Y=ak+Q7oG&~o4Gi>9 z78D*7_8C3PsF(~7Zp(Z6`}Sy8nWbKC^>Bgl*MZ}9MTM#v=$W!hix}Fq!xf$S--^0k zD^Q4il#_h5hJfQ$3@PQ+vCs|)wf+iz{n0GMmj0l*4}j6(mmOxZ7CtFShra+JfrM8lJBAtse;(E#h6P;q!+BmR2mDV1UeT_#`84h-R{Ue)0$9q{3= z)dLni!1~(D?M_aXV8ppt^P=;T&b{!sCG5k53+PHnfR6Ne$tXk7`EQto?8bbq!sc{} zfeB|_QU9Yu%v3e+U$bQV@n`iE*vNXx^!;2s?x{3(1hSRIUdAvKL6(iic7%lmD7EsX zc~T6^4MlDRdP{^(?MZ}}uZ~~c{rCIQty?utX{y#dCt&jKx2=|P(x>a#2ruQ+?`1T) zuD^{uG&?ER{t6X$i^h1*<>t^*oY_yp&utyO_C%FGazrqte~X!Y1SApmw)*#T1BV=( zEbbaW(w{CD?u>Bt<+}B3{^nlS(s+}dH|4bgd!1dPh|2#hCo*#q)x~#YQzSrtFZ$BG z6BH|r%f2baFWby~Jkz7>YQR{W1191ovkhWu*eOi~$}&66d9e zzK|9ZGH^Y|7c=VO5-JeY9rp0*p`LC(b%^|9k_oAY>->lHK-2G!q8U@6_U{oI z3%CWMn7$#1;m2q5fciR#ZgAJ^TR%xo!x-x^fLr%kqmtYko$Tb@IPnfvo8>)z?us&f z7(e&_RG3p@F*E!dN6MN|UYM*;yl+WRHU;%tw4nRgI}p<*;OA{W0)~~k#EjmP5e6Z; zAxwUocdEMfCjy7%n7@A0enjrdg3{xH@Q6R$Ef1ZvpDuebDw`+JR5)I4YE@Tw(R@Lw z?sB61^4aP*cfST#=C54N9G>1A@ztpW-RLgtA`(0GmQ^q|*mpX~XllU7vhFhx1W?_G z9d*MCxW6nGS%K6%foZb8wPN1Q_58tv zkJ2FG>!QzAzFZlchi{#)+K+Up%Rd|$n*(1StUm}|a>#-`v-Q3xO-t+hU-Rz6~se4~W-53J&(}qJa=KWEW`c#yQsm-zN*FWmMJ@B2Q zO$1z5vBW7c7vwseb_DEfFn9sq*>Hq0DCp^{7D0QP*N2y1rk3Egn@QJxcl0 z7mDU!?s#?RpGRe+f>(QE{?w$ehNlPH(v2)hlu30>TbJOanNTzKFGl}#r$ix)#g`v) zi{&ZG6e|m^pYL_ga$Y)wJPmmtNw%p50;!v+B&!rucrl>EAZF!N;lv_GxZ2ZjWycf@6*M)l5D@+S}Sw9?S3oJKOkdjAhV=$q3YuUi`uR z$*JP*Al1K3WRd^I)ohd5Wh`~4@xEvg_z^ahRIJ?3+^l<@k28rOa7 zIIwGgT8t7a&Ke&^*($xd&2~7EyuYU%SHSLG^}OD-c+Eogg{1?weJ70^bg<_xO_w-J zZAWFUyT34w;JoYzzvk7WT2(|s@f<%C%s-_^mjU&Gb~U^3hebYsF0zLQl#>eVQlbtT zR?Hs@T7-=oW-Q-IwsbIN{y)p&Unt96{bTD&dkq9*@67!f{ec-)4t!NFoFpfSSi=o` z+)WP}eB5F%999(qGOq<8rlKK1UZQ3Ctm#JML0AmPsUdc}y(f9o1b==+D z0$mIkGP5sy7m!2~sgdNEs%kLDzYMMSb5T0SK?M5EyS# z@{?do0oZM$T3-6ODsa5^J$Xc_L(R)SMwRx`W9)j2N_^GU@5uq1pk>T#Ir;X|fwtw2 z-WE|znY-@O#jfn!`eKM?$!22~x-{EWJZV-3kB}6gHXkf9A^t4c-% zywN6H`@p|kD)i7lz4jZDem){;shk&Ho8sMi6g(8Szib&>!=$^PKv6kWtCTdlr8KLH zE^G5b7kK0`D)8(~y$RkSM=$PgT__>>|1HHi^GvnKgGte51Ab9y;^RU@R2qLO6eNg{ z%A8)AQ?vY+-Bx$HU8rgUl^?CG%HY zdJNK&KpH>G>?)g^Uh*Y^1p5`ax?-wIdMHk*u%Y{Ujb0oNW5OZ+1FpW|(H==ZjDjsx zC3V&JD*2O_J&NFMg&Q+JYHWVn_^$k9wINf3_q&t=Ne6d@5(#`K!paJqFEYpC`O7`d zomO}ix|xU4P+#SyG^(H|pq*shPUtDB&^=QB2~JsFRhRRlU*6KJG>x2@1D&7;8$`wY zc4d5?eHfG-e0p<-Z?SAaQAOuZ?NZ5^29A(tAtUKHIi^$K>b4U5{7@->18DY#FO{)? zPOOxLQ$U5acvSIXG#DN9V%qW36)0x6oJEh&m(1fs@-o^oPk~QH$=_;p;&88O2wb?; zpyX+JrR(`W_?RPdr#b7p~xc0rvA6ctjhpv^>LOrXClHp z*G#6h4tsCE5tkhZ3n;YQHtl|3aFv~85vg&WR1uUdL}GAm&ZSGDGQp)FeC zE0N$d+1Lx#0o|0;*j-l8&~kr*X!80bZBl2J)or)e%3}pK0;xaTDwws8T!tnEj@?v! zNJ2yERV)Nzy7-gDpc1x7^|_Q+S03anQ_}wQ{>8~Px4bhNU@yYI`j3! z&G@E>5F+>O!ha!YHTwp-UL5{LvQ^)E4j$o2CvzYm+eB)$Z*9gZ{1#UU#So70!Ood_c0E;qq z1j(S^f#5^q{k+8mVMc;eKhs8Vc49XO9;ITH0(~_X&d(|2i{xngCA<_+WhQv!6U9%=rt(|pX^0LO+*1JMsQ}-Oe9$~l7?d}Cp-gt@O3d^ zem^As7Mr_YxsTSUdtuIZd;uibg;_v(F<%_=3w=$AS?pKj&2#-z1*h~E1#c8t*e}%1 zJqr#VRZu8g*lB+&NWK*l7v{+fGtIJmAKzwvF*)67%gR4GW)q#|M3P2Kz zB^xV;tYp>7_Gi%Mqu+y5dKw^k=3X(M?wF>0$Xy?xUb&Z#_xn#0aTN+C8qP+8rQZV|E~@`zEfCyxBJxTf2Gwc`-l13xC`_6AihKn;#o9u%IG? zQMpXf`{Dh+J}Ur~mHRT^&y8<0-O1l(&_cgai9YsUUO^-8>_}rX`Z?1T%vy}z)Myhn zBfLrF>&@Mev+G_#w6_RE+QK}JV|=wAx{@mif0E_5Abj{;$k%43efn`ObwLw3?koDo z5V64qG<}Fx90&?<(b3PDNr&QuQ^E^aV0{2i4u&are2Mcy07R3KUf}#O%!#>I_y2pq zbbm@uXdn^|KkRJu>BfX25C+a++!A(oijGZvjZsso85=@TYZj5ey*zZ8lR^HlDxftLvWT!68;e7_)8`XbFA(`Unr*vMXpI%G^EJveMg|0A0B zj0=F1`8MY5M6?;`L8uvQSYx-w{2$Fa4%IymDL;JxP~UZ3&;98m#Z?2G8r3P5*)(EJ zNhokmh&_zHjzn46n)JgX^C`s(t;)r0WVFG}5uaa}g&$;2$$#olpzwnJI#~g0tr9;5 zT{o_RpS&xu>2clt1gP65i*p>{jpr%WC@pr2q)D~%%T!+th zMm#guW{y?u`?`#|J*C$*>}Z8aor{LCB^8u{1?SD}$(WeXx-0IBWkn;$r+%^mBggG9 zohg=q^dH6lZurVFbHhv7z7roMdMpNM|DM<_qlvU3fg{C{nS2~zBgI>)KqZ{?4of@O z_i=tNpQ>L`v9o?RHme&1J?(f^&3kz>V_wtt&@jSs-Z2;b>X8n}K4tKq&%vM*UZmx6 zw>eaviOuU%)ben1j(@=lV^{E2h;KmHR9q`vsjEQ5j>D9xx|MKiK0WEIHinAw?KF%F zFomyfIp1&GxwQD_)?JfnbuayKz4@b~*?e(y{+g8&up~Kt)_e+42#>-T7ooli0jH6B$< zXy1i`{4_?tl((2KdiG%o2cv(!rmGyj02>Zv^)&Iju8C;IL-^;YU)ML${b5a^lLWGE zNv>Ue(!PI{xVR&8_q{oeO4ZK&mVyxrCUKlL9qWoBPsZiW(a2OR@l@8g;!IxOg=e=L?N&pf31o4Hv zjcR~C^*X!0*=fD6vm1BbxlojGV6H9Zr?5wH@hkjmtm38YNqp19pJBm>pf;uyL4FZl1QluE`pn^D|hAyIX5vhRy^CQGY zU=#tR1tTSdE=3}cXcSaXNF+rdAW9(A2qY9~|2_wG?#q3;Pj|iKWM`j!cJ|r(TWfu5 zmz|w{;174n!cDUBh+qJbNmry|GW>{h-KF>y<3b==4nu z=b63ypP}M3q%flW2F94=e0i8){UlYUvZ0ZPz|9kgL<>vV2W4&7;2d?Y(jO5crcF%@ zpV;DgPTKUc%d}N%+^vdVjeqFK&hVV?$ezHn&T$Er)jezl$5J1^t+cAaiWZVk)m|3H z+qwnsfzf2n0XJEj5KbjIE(_VFq9;N7K`(YgK7P@nSe{Q2D*&J>*CBj37jBTBAX0Z4 z_Py&eg1k*S+pO;59(BU7>1IV+i%?*w$Orbz<9acxr*=ZFU$6%aoiG#l-bgHeOt&he zh@v}4a2Yqa2(>y9)ek-(iOqty)P}DG=5QnaKTMtnfP)|py+FFA==X#lA#teKg8six zprA!*2qX&TaQ1%8OLMsmu+Eai(Fp5?_rgIq{ZeHQS_ziXU*HxiuYqt7;sOTh3$pPi z#~bGZpe(Vc;8maqUVb0gn4_MPIWygL2?``2oRne2g9{Ou!?W-Z3ZGx-Mpr~?@IXx< zbAtI|<$*uNCVWaVTY3y-ejEk+MDTs(UqiaD4idfu&pgXJq4^r08?&_(a*_SkYukd4 z@(y5qWUjtl##s6yu7o_qM`hD3x{N)vcBX7@FelBxukQkDb3<;qj#4w^AKtx`A`Ze4qLX`%B~83NC~;CJ)ryVi;~f)# zWkSfU5PN8)eR>;FzN;(IB|G`#h4QYtjmAr}pD0sOgES4wnpVi#=GEY%)aWJDP;G2* zJ#Jki+S#^@$l~}+#*wywlAge45h0L?37^!@2b}bh3#xm!ezGFjUcMJn1&{Pn0@yV_ zWBcYeFJ>uES)E>NiM2t$vh%KZr0g%N)Q*98iC79o1)5R2Cy!9pmT9invH-q*g{s7*aK$s{?bsQQ{| z`W0bMcgc+q;c+i{$`nqe&CmZt8Kq+SH+=Dl%O>K`YO@9u6b^3x7%_pHsh%}+F$U(F z^UdTdQpN_C=sD5dnu@g!yOtw8{>$r2Z4aJSHx8Ls!HW={E|qU`A3e4x$vyVcj2@%m zXr2_|7h&S+A=P&jG2}wFdN3qt#&s8cZKH9|k>R?{lzEq{gOris`U+n_x%ufXN~$d` zo~y5s!2TA(DGdvTYIZLgQwJikc}t^AXa)yP-K{q&BAK@C)c&{*e%W3(Jc z=})OD6`4M|Q_mE2U%Gp7XMr|2fRjT--=VG#(YQ1M*7=()FZ9dG*D`m_-zE~0*yu1+ zCwT1FNls3ISogeyESmAd%MND#O6QU$FsGik)`V zqX}0zz2_ZNmjOWAyvzSW^7Y-QvAhot&gsTAb#g%qy48LS{NQ+H_$=ZdKFOwPoj#tE zK&8|)uR7=Ww@K)`KC;R{Rq!%xT8B@@quY7eeW|3FVZ6QQf;rj!i$F#O_Gcy-=ga_j zPX^{{*)Xk)^!DA}a3S#z%hiRRn|6eBNlbX|p}$HRWjZbJ@GYx-+sIcE z$c{H#domi<H=L#LXX*^Er4_{E=o0Im)W&zX7bcsm zlQcfLYqnCAJD)_Mtty)}v~|EBE4qrC^BL0>ZKJZmRHkoFNwzvq3mxKkFYuqi)@-@V+)1OGxy$n;{5N z_d^AuVC@8Iw2B?K<~?>q!)9C2e0@8HX4^92>p)AkFiziZa3e$UM1zD1v+d8I z**<+Xe)b0m$ZoH;f)<5TD=GQuz*(stW1|UZMJ?TVVEF6$zo@)HS+Y1lV4Z&-=P+aj z(V{EsSCAbYaXuvbty`gATt^zJVUa1MPjt7iQa$LV~((X@5yN#z>_Xi0GWm{WE zB#(X6W;&Ga*trsHczV@uI~qYbV2E6%oaYgCOdu0G-fe(@x=fwM2?IS684Cc+Iy7mt3k76B-vyU+ZO@XwYfo# ztZ$t<<_H}Ij<8y3tImA%BfW>f9XRNrS|nGHPE8o2eP2|rS9&F@dR&=-e)>m2C=HjfXgERVm6`w3LZ>+;Psq?^T; zm|~5AWk7u)()5)IAba9&8sBP4l;&ER@9X(Fzo=YZ>2Qc^#@eIR!+7lW*%aGDrXc}R z2CM1Wlz4u5neKIbqX8fyJcc+r5`PpTY8tU>9U2Bh8FR#*EfNZrc zoBVVs9=N<0*IiUH7RH{*4#5*ue}e(QK(*wYi(WU+t;XiB;-`6Q(lfk>4?Nz3C06fQ zJMHGQO#LtJ4@LbR4!ZfEr^Xi~LSd{=BRb9I@UYdGiLCvtO*{ifu7)g%e3*gxaEPZ= z>q?Mu*<)VulLD5e-4k5^OUF| z^EmV9^bt(6Ib1s%n2tsKWWV+tMD~}W9cXu20`1b+=#L;?77*mv2be0Cqnh=FUFwRL zSqCJH<)wfuicfB?oNu^598Yehjd?Xq*ICl;sOBfdkKv>FB^|tECq8@q4DV8jG;>H6 zC{Xa|9Kn=no9H6vq3yMq(|y~s3{+7Z(9@f|u6f6`vaUYdzEMe6nA-R|;@2O9nN(u1 zC72JG!21$;wRfQKma~fW4SAprQJm{Gumc;L*m*v@;gv%8xhWl~ge3!|##&I|*99&S zZo6Ow2VEjUS7PbV<^LghWk3qN3oNs4>qbuiyATl5X;7hEH(=NnT!xs;dJv(2tol)P zm^p%e|6eO`drf}<6qSJ_RycV;?Ynp59DE--I0zP|&M^9A@5Zm+5TU2+FiZ4r-^v|( z$gr6SCAc9QO6BIXpvObP3>aE9SsRc3UeI_u@00L|5%<(TImFp-wyc^&{!@!~wM1fU zz1A*Bm%>t$aiPzdJ==Pk{08cLkhpcdAffl~pD_I9g19{~(KHprE~w@HlP5AO)l^Z^ zdgERjG~peVt|9aJynnsS*;E9%cq>VRRU1p1T4J48m;Xm%{_DnziSbo87OK8x1N$df zp2{Iy#L3m!{%r#X`(}qK?qTKXIQgP~Dw>9TQ4-1kZ&GcIry(4(e*9MWNt#vSUjVb2gM zL`V5ep-Q)`pDY*gS^QD<`J=1`N`@rR-D~G!x^#=|uc;x{n*L$KA=)d~@BN3w)8bs@ zoN3wazhj$S0kTXXuWYB8_R5{W&gFRzT@lOITJQEpZ&S&3tRGJSM+tp@4S@!ohehUw z|F+zjq5M)_+n-O)yz#wzVXnG4JsQ~6zcxEQ!WvX7QyRy=Ju?_jjQG~yn!FPhGNtz> zWkE-uhL`Iio3vP`O!n4~DR4zd3W xhq*v<$Ay1;eNIc38DWHm-|87VkQGxe8S?qYcdlis?uPq^`7z6*rH9@A{U0OgR|Nn7 literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/eas.json b/src/sdks/react-native/examples/expo/eas.json new file mode 100644 index 00000000..abae9aa0 --- /dev/null +++ b/src/sdks/react-native/examples/expo/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 19.0.0", + "appVersionSource": "local" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk" + }, + "ios": { + "simulator": true + } + }, + "production": { + "autoIncrement": true + } + } +} diff --git a/src/sdks/react-native/examples/expo/eslint.config.js b/src/sdks/react-native/examples/expo/eslint.config.js new file mode 100644 index 00000000..ba708ed9 --- /dev/null +++ b/src/sdks/react-native/examples/expo/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require("eslint-config-expo/flat"); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ["dist/*"], + } +]); diff --git a/src/sdks/react-native/examples/expo/index.js b/src/sdks/react-native/examples/expo/index.js new file mode 100644 index 00000000..a5ed781c --- /dev/null +++ b/src/sdks/react-native/examples/expo/index.js @@ -0,0 +1,6 @@ +import 'expo-dev-client'; +import { registerRootComponent } from 'expo'; + +import HomeScreen from './src/SmokeDashboard'; + +registerRootComponent(HomeScreen); diff --git a/src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml b/src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml new file mode 100644 index 00000000..771a20c6 --- /dev/null +++ b/src/sdks/react-native/examples/expo/maestro/installed-smoke.yaml @@ -0,0 +1,15 @@ +appId: ${APP_ID} +name: Oliphaunt installed app smoke +tags: + - oliphaunt + - smoke + - installed-app +--- +- extendedWaitUntil: + visible: + id: liboliphaunt-smoke-status-passed + timeout: ${SMOKE_TIMEOUT_MS} +- assertVisible: + id: liboliphaunt-smoke-status-passed +- assertVisible: + id: liboliphaunt-smoke-result diff --git a/src/sdks/react-native/examples/expo/metro.config.js b/src/sdks/react-native/examples/expo/metro.config.js new file mode 100644 index 00000000..930a8fff --- /dev/null +++ b/src/sdks/react-native/examples/expo/metro.config.js @@ -0,0 +1,26 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, '../..'); +const appNodeModules = path.join(projectRoot, 'node_modules'); + +const config = getDefaultConfig(projectRoot); + +config.watchFolders = Array.from(new Set([ + ...(config.watchFolders ?? []), + workspaceRoot, +])); + +config.resolver.nodeModulesPaths = [ + appNodeModules, +]; + +config.resolver.extraNodeModules = { + ...(config.resolver.extraNodeModules ?? {}), + expo: path.join(appNodeModules, 'expo'), + react: path.join(appNodeModules, 'react'), + 'react-native': path.join(appNodeModules, 'react-native'), +}; + +module.exports = config; diff --git a/src/sdks/react-native/examples/expo/package.json b/src/sdks/react-native/examples/expo/package.json new file mode 100644 index 00000000..aa27a748 --- /dev/null +++ b/src/sdks/react-native/examples/expo/package.json @@ -0,0 +1,72 @@ +{ + "name": "react-native-oliphaunt-expo", + "main": "index.js", + "version": "1.0.0", + "dependencies": { + "@expo/ui": "~56.0.13", + "@oliphaunt/react-native": "workspace:*", + "expo": "~56.0.4", + "expo-constants": "~56.0.14", + "expo-dev-client": "~56.0.15", + "expo-device": "~56.0.4", + "expo-font": "~56.0.5", + "expo-glass-effect": "~56.0.4", + "expo-image": "~56.0.9", + "expo-linking": "~56.0.11", + "expo-router": "~56.2.6", + "expo-splash-screen": "~56.0.10", + "expo-sqlite": "~56.0.4", + "expo-status-bar": "~56.0.4", + "expo-symbols": "~56.0.5", + "expo-system-ui": "~56.0.5", + "expo-web-browser": "~56.0.5", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.85.3", + "react-native-gesture-handler": "~2.31.1", + "react-native-reanimated": "4.3.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", + "react-native-web": "~0.21.0", + "react-native-worklets": "0.8.3" + }, + "devDependencies": { + "@types/react": "~19.2.2", + "eslint": "^9.0.0", + "eslint-config-expo": "~56.0.4", + "expo-doctor": "^1.19.7", + "expo-mcp": "~0.2.1", + "typescript": "~6.0.3" + }, + "scripts": { + "start": "EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client", + "reset-project": "node ./scripts/reset-project.js", + "android": "expo run:android", + "android:start": "EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client --android", + "doctor": "expo-doctor", + "ios": "expo run:ios", + "ios:start": "EXPO_UNSTABLE_MCP_SERVER=1 expo start --dev-client --ios", + "mcp:version": "expo-mcp --version", + "mcp:start": "pnpm start", + "prebuild:android": "expo prebuild --platform android", + "prebuild:ios": "expo prebuild --platform ios", + "smoke": "pnpm run smoke:android && pnpm run smoke:ios", + "mobile-build:android": "../../tools/mobile-build.sh android", + "mobile-build:ios": "../../tools/mobile-build.sh ios", + "mobile-e2e": "pnpm run mobile-e2e:android && pnpm run mobile-e2e:ios", + "mobile-e2e:android": "../../tools/mobile-e2e.sh android", + "mobile-e2e:ios": "../../tools/mobile-e2e.sh ios", + "mobile-drill:android": "../../tools/mobile-drill.sh android", + "mobile-drill:ios": "../../tools/mobile-drill.sh ios", + "bench:android": "../../tools/mobile-drill.sh android benchmark", + "bench:ios": "../../tools/mobile-drill.sh ios benchmark", + "crash:android": "../../tools/mobile-drill.sh android crash", + "crash:ios": "../../tools/mobile-drill.sh ios crash", + "smoke:android": "pnpm run mobile-build:android && pnpm run mobile-e2e:android", + "smoke:ios": "pnpm run mobile-build:ios && pnpm run mobile-e2e:ios", + "typecheck": "tsc --noEmit", + "web": "expo start --web", + "lint": "expo lint" + }, + "private": true +} diff --git a/src/sdks/react-native/examples/expo/scripts/reset-project.js b/src/sdks/react-native/examples/expo/scripts/reset-project.js new file mode 100644 index 00000000..beab8e63 --- /dev/null +++ b/src/sdks/react-native/examples/expo/scripts/reset-project.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/** + * This script is used to reset the project to a blank state. + * It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file. + * You can remove the `reset-project` script from package.json and safely delete this file after running it. + */ + +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); + +const root = process.cwd(); +const oldDirs = ["src", "scripts"]; +const exampleDir = "example"; +const newAppDir = "src/app"; +const exampleDirPath = path.join(root, exampleDir); + +const indexContent = `import { Text, View, StyleSheet } from "react-native"; + +export default function Index() { + return ( + + Edit src/app/index.tsx to edit this screen. + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, +}); +`; + +const layoutContent = `import { Stack } from "expo-router"; + +export default function RootLayout() { + return ; +} +`; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +const moveDirectories = async (userInput) => { + try { + if (userInput === "y") { + // Create the app-example directory + await fs.promises.mkdir(exampleDirPath, { recursive: true }); + console.log(`📁 /${exampleDir} directory created.`); + } + + // Move old directories to new app-example directory or delete them + for (const dir of oldDirs) { + const oldDirPath = path.join(root, dir); + if (fs.existsSync(oldDirPath)) { + if (userInput === "y") { + const newDirPath = path.join(root, exampleDir, dir); + await fs.promises.rename(oldDirPath, newDirPath); + console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`); + } else { + await fs.promises.rm(oldDirPath, { recursive: true, force: true }); + console.log(`❌ /${dir} deleted.`); + } + } else { + console.log(`➡️ /${dir} does not exist, skipping.`); + } + } + + // Create new /src/app directory + const newAppDirPath = path.join(root, newAppDir); + await fs.promises.mkdir(newAppDirPath, { recursive: true }); + console.log("\n📁 New /src/app directory created."); + + // Create index.tsx + const indexPath = path.join(newAppDirPath, "index.tsx"); + await fs.promises.writeFile(indexPath, indexContent); + console.log("📄 src/app/index.tsx created."); + + // Create _layout.tsx + const layoutPath = path.join(newAppDirPath, "_layout.tsx"); + await fs.promises.writeFile(layoutPath, layoutContent); + console.log("📄 src/app/_layout.tsx created."); + + console.log("\n✅ Project reset complete. Next steps:"); + console.log( + `1. Run \`EXPO_UNSTABLE_MCP_SERVER=1 npx expo start --dev-client\` to start the development-client server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${ + userInput === "y" + ? `\n4. Delete the /${exampleDir} directory when you're done referencing it.` + : "" + }` + ); + } catch (error) { + console.error(`❌ Error during script execution: ${error.message}`); + } +}; + +rl.question( + "Do you want to move existing files to /example instead of deleting them? (Y/n): ", + (answer) => { + const userInput = answer.trim().toLowerCase() || "y"; + if (userInput === "y" || userInput === "n") { + moveDirectories(userInput).finally(() => rl.close()); + } else { + console.log("❌ Invalid input. Please enter 'Y' or 'N'."); + rl.close(); + } + } +); diff --git a/src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx b/src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx new file mode 100644 index 00000000..716476c2 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/SmokeDashboard.tsx @@ -0,0 +1,1265 @@ +import { + Oliphaunt, + runOliphauntReactNativeBenchmark, + type EngineCapabilities, + type EngineModeSupport, + type PackageSizeReport, + type OliphauntDatabase, + type ReactNativeBenchmarkOptions, + type ReactNativeBenchmarkReport, + type ReactNativeBenchmarkWorkload, +} from '@oliphaunt/react-native'; +import { + runPostgresGamutWorkload, + runPostgresLifecycleResumeCheck, + type ActivityItem, + type OperationCheck, + type PerfReport, + type ProjectRollup, +} from './postgres-workload'; +import { + runExpoSQLiteBenchmark, + type ExpoSQLiteBenchmarkReport, +} from './sqlite-benchmark'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + AppState, + type AppStateStatus, + Linking, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +type RunState = 'idle' | 'running' | 'passed' | 'failed'; +type RunnerMode = 'smoke' | 'benchmark' | 'crash-write' | 'crash-verify'; +type BenchmarkPreset = 'full' | 'quick'; + +type SmokeReport = { + engine: string; + rawProtocolTransport: string; + selectOne: string; + parameterRoundTrip: string; + jsTimerTicks: number; + elapsedMs: number; +}; + +type AppReport = { + smoke?: SmokeReport; + perf?: PerfReport; + benchmark?: ReactNativeBenchmarkReport; + sqliteBenchmark?: ExpoSQLiteBenchmarkReport; + crashRecovery?: { + phase: 'write' | 'verify'; + root: string; + value: string; + openMs: number; + elapsedMs: number; + }; + modes?: EngineModeSupport[]; + capabilities?: EngineCapabilities; + packageSize?: PackageSizeReport | null; + projects?: ProjectRollup[]; + activity?: ActivityItem[]; + checks?: OperationCheck[]; + lifecycle?: OperationCheck; +}; + +type SmokeGlobalState = { + databasePromise?: Promise; + databaseInstance?: OliphauntDatabase; + runPromise?: Promise; +}; + +type OpenTuning = { + durability: 'safe' | 'balanced' | 'fastDev'; + runtimeFootprint: 'throughput' | 'balancedMobile' | 'smallMobile'; + startupGUCs?: string[]; + walSegmentSizeMB: string; + root?: string; +}; + +type BenchmarkTuning = Pick< + ReactNativeBenchmarkOptions, + | 'warmupIterations' + | 'rawRttIterations' + | 'typedRttIterations' + | 'parameterizedRttIterations' + | 'insertRows' + | 'lookupIterations' + | 'aggregateIterations' + | 'updateIterations' + | 'checkpointIterations' + | 'largeResultRows' +>; + +const smokeGlobalKey = '__OLIPHAUNT_EXPO_SMOKE_STATE__'; +const initialUrlTimeoutMs = 2_500; +let initialLaunchUrlPromise: Promise | undefined; + +function smokeGlobalState(): SmokeGlobalState { + const root = globalThis as unknown as Record; + root[smokeGlobalKey] ??= {}; + return root[smokeGlobalKey]; +} + +export default function HomeScreen() { + const [state, setState] = useState('idle'); + const [report, setReport] = useState({}); + const [error, setError] = useState(null); + + const run = useCallback(async () => { + const smokeState = smokeGlobalState(); + if (smokeState.runPromise) { + await smokeState.runPromise; + return; + } + const runPromise = (async () => { + setState('running'); + setError(null); + const started = now(); + const liveness = startTimerLivenessProbe(); + const stage = (name: string, extra?: Record) => + logSmokeStage(started, name, extra); + let runner: RunnerMode = 'smoke'; + + try { + runner = await resolveRunnerMode(); + if (runner === 'benchmark') { + await runBenchmark(started, liveness, stage, setReport, setState); + return; + } + if (runner === 'crash-write' || runner === 'crash-verify') { + await runCrashRecoveryPhase(runner, started, liveness, stage, setReport, setState); + return; + } + + stage('metadata:start'); + const [modes, packageSize] = await Promise.all([ + Oliphaunt.supportedModes(), + Oliphaunt.packageSizeReport().catch(() => null), + ]); + stage('metadata:done', { + modes: modes.length, + packageBytes: packageSize?.packageBytes ?? null, + }); + const extensions = extensionsForPackage(packageSize); + stage('extensions:selected', { + extensions, + mobileStaticRegistryState: packageSize?.mobileStaticRegistryState ?? null, + mobileStaticRegistryRegistered: packageSize?.mobileStaticRegistryRegistered ?? [], + }); + const databaseOpen = await openDatabase(stage, extensions); + stage('open:done', { openMs: databaseOpen.openMs }); + const db = databaseOpen.database; + stage('capabilities:start'); + const capabilities = await db.capabilities(); + assertNativeDirectCapabilities(capabilities); + stage('capabilities:done', { engine: capabilities.engine }); + + stage('query:select1:start'); + const select = await db.query('SELECT 1::text AS value'); + const selectOne = select.getText(0, 'value') ?? ''; + stage('query:select1:done', { value: selectOne }); + stage('query:parameter:start'); + const parameterized = await db.query('SELECT $1::text AS value', ['hello']); + const parameterRoundTrip = parameterized.getText(0, 'value') ?? ''; + stage('query:parameter:done', { value: parameterRoundTrip }); + + stage('workload:start'); + const workload = await runPostgresGamutWorkload(db, databaseOpen.openMs, { + extensions, + onCheckStage: check => + stage(`workload:${check.status}`, { + name: check.name, + checkElapsedMs: check.elapsedMs === undefined ? undefined : Math.round(check.elapsedMs), + }), + }); + stage('workload:done', { + checks: workload.checks.length, + rows: workload.perf.rows, + }); + const lifecycle = await runLifecycleResumeValidation(db, stage); + liveness.stop(); + const checks = lifecycle ? [...workload.checks, lifecycle] : workload.checks; + const perf = lifecycle + ? { ...workload.perf, checks: String(checks.length) } + : workload.perf; + + const smoke = { + engine: capabilities.engine, + rawProtocolTransport: capabilities.rawProtocolTransport, + selectOne, + parameterRoundTrip, + jsTimerTicks: liveness.ticks(), + elapsedMs: now() - started, + }; + const nextReport = { + smoke, + perf, + modes, + capabilities, + packageSize, + projects: workload.projects, + activity: workload.activity, + checks, + lifecycle, + }; + setReport(nextReport); + setState('passed'); + console.log( + 'OLIPHAUNT_EXPO_SMOKE_PASS', + JSON.stringify({ + elapsedMs: Math.round(now() - started), + smoke, + perf, + lifecycle, + packageBytes: packageSize?.packageBytes ?? null, + projectCount: workload.projects.length, + activityCount: workload.activity.length, + checkCount: checks.length, + }), + ); + (globalThis as Record).__OLIPHAUNT_EXPO_SMOKE_REPORT__ = nextReport; + } catch (err) { + liveness.stop(); + const message = err instanceof Error ? err.message : String(err); + stage('failed', { error: message }); + setError(message); + setState('failed'); + console.error( + failureTagForRunner(runner), + JSON.stringify({ + elapsedMs: Math.round(now() - started), + error: message, + }), + ); + } + })(); + smokeState.runPromise = runPromise; + try { + await runPromise; + } finally { + if (smokeState.runPromise === runPromise) { + smokeState.runPromise = undefined; + } + } + }, []); + + useEffect(() => { + const timeout = setTimeout(() => void run(), 0); + return () => clearTimeout(timeout); + }, [run]); + + const statusTone = useMemo(() => { + switch (state) { + case 'passed': + return styles.statusPassed; + case 'failed': + return styles.statusFailed; + case 'running': + return styles.statusRunning; + default: + return styles.statusIdle; + } + }, [state]); + + return ( + + + + + + liboliphaunt React Native + Field ops task board + + + {state === 'running' ? : null} + {state} + + + + + + + + + + + + + + + + Validation + {error ? ( + + {error} + + ) : ( + + {formatResult(report)} + + )} + + + + Project Rollup + {(report.projects ?? []).map((project) => ( + + + {project.name} + + {project.done}/{project.total} done, {project.blocked} blocked + + + {project.estimate} pts + + ))} + + + + Current Queue + {(report.activity ?? []).map((item) => ( + + + {item.title} + {item.owner} + + {item.status} + + ))} + + + + Postgres Coverage + {(report.checks ?? []).map((check) => ( + + + {check.name} + {check.detail} + + {check.elapsedMs.toFixed(1)} ms + + ))} + + + void run()} + style={({ pressed }) => [ + styles.button, + pressed && state !== 'running' ? styles.buttonPressed : null, + state === 'running' ? styles.buttonDisabled : null, + ]} + > + + {state === 'running' ? 'Running workload' : 'Run workload'} + + + + + + ); +} + +async function resolveRunnerMode(): Promise { + const envRunner = process.env.EXPO_PUBLIC_OLIPHAUNT_RUNNER; + if ( + envRunner === 'benchmark' || + envRunner === 'crash-write' || + envRunner === 'crash-verify' + ) { + return envRunner; + } + const url = await resolveInitialLaunchUrl(); + if (!url) { + return 'smoke'; + } + const urlRunner = extractQueryParam(url, 'liboliphauntRunner'); + if ( + urlRunner === 'benchmark' || + urlRunner === 'crash-write' || + urlRunner === 'crash-verify' + ) { + return urlRunner; + } + if (url.includes('liboliphauntRunner=benchmark') || url.includes('benchmark=1')) { + return 'benchmark'; + } + return 'smoke'; +} + +function failureTagForRunner(runner: RunnerMode): string { + switch (runner) { + case 'benchmark': + return 'OLIPHAUNT_EXPO_BENCH_FAIL'; + case 'crash-write': + case 'crash-verify': + return 'OLIPHAUNT_EXPO_CRASH_RECOVERY_FAIL'; + case 'smoke': + return 'OLIPHAUNT_EXPO_SMOKE_FAIL'; + } +} + +async function shouldRunLifecycleSmoke(): Promise { + if (process.env.EXPO_PUBLIC_OLIPHAUNT_LIFECYCLE_SMOKE === '1') { + return true; + } + const url = await resolveInitialLaunchUrl(); + return Boolean(url?.includes('liboliphauntLifecycle=1') || url?.includes('lifecycle=1')); +} + +async function runLifecycleResumeValidation( + db: OliphauntDatabase, + stage: (name: string, extra?: Record) => void, +): Promise { + if (!(await shouldRunLifecycleSmoke())) { + return undefined; + } + const transition = await waitForBackgroundAndForeground(stage); + stage('lifecycle:sql:start', { states: transition.states.join('>') }); + const check = await runPostgresLifecycleResumeCheck(db); + const detail = `${check.detail}; app states ${transition.states.join(' -> ')}`; + stage('lifecycle:sql:done', { elapsedMs: check.elapsedMs, detail }); + return { ...check, detail }; +} + +function waitForBackgroundAndForeground( + stage: (name: string, extra?: Record) => void, +): Promise<{ states: AppStateStatus[] }> { + const states: AppStateStatus[] = [AppState.currentState]; + let sawBackground = false; + stage('lifecycle:ready', { state: AppState.currentState }); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription.remove(); + reject( + new Error( + `timed out waiting for background/foreground lifecycle transition; states=${states.join('>')}`, + ), + ); + }, 90_000); + + const finish = () => { + clearTimeout(timeout); + subscription.remove(); + resolve({ states }); + }; + + const subscription = AppState.addEventListener('change', (nextState) => { + states.push(nextState); + stage('lifecycle:state', { state: nextState }); + if (nextState === 'background' || nextState === 'inactive') { + sawBackground = true; + return; + } + if (sawBackground && nextState === 'active') { + finish(); + } + }); + }); +} + +async function runBenchmark( + started: number, + liveness: { ticks: () => number; stop: () => void }, + stage: (name: string, extra?: Record) => void, + setReport: (report: AppReport) => void, + setState: (state: RunState) => void, +) { + stage('benchmark:start'); + const openConfig = await resolveOpenTuning(); + const benchmarkPreset = await resolveBenchmarkPreset(); + const benchmarkOptions = benchmarkOptionsForPreset(benchmarkPreset); + const metadata = { + platform: Platform.OS, + osVersion: String(Platform.Version), + runner: 'expo-dev-client', + benchmarkPreset, + durability: openConfig.durability, + runtimeFootprint: openConfig.runtimeFootprint, + startupGUCs: openConfig.startupGUCs?.join(',') ?? '', + walSegmentSizeMB: openConfig.walSegmentSizeMB, + }; + stage('benchmark:liboliphaunt:start'); + const report = await runOliphauntReactNativeBenchmark(Oliphaunt, { + open: openConfig as ReactNativeBenchmarkOptions['open'], + requirePackageSizeReport: true, + ...benchmarkOptions, + metadata, + }); + stage('benchmark:sqlite:start'); + const sqliteBenchmark = await runExpoSQLiteBenchmark({ + durability: openConfig.durability, + warmupIterations: benchmarkOptions.warmupIterations, + simpleRttIterations: benchmarkOptions.typedRttIterations, + parameterizedRttIterations: benchmarkOptions.parameterizedRttIterations, + insertRows: benchmarkOptions.insertRows, + lookupIterations: benchmarkOptions.lookupIterations, + aggregateIterations: benchmarkOptions.aggregateIterations, + updateIterations: benchmarkOptions.updateIterations, + checkpointIterations: benchmarkOptions.checkpointIterations, + largeResultRows: benchmarkOptions.largeResultRows, + metadata, + }); + liveness.stop(); + const nextReport: AppReport = { + benchmark: report, + sqliteBenchmark, + capabilities: report.capabilities, + packageSize: report.packageSizeReport, + }; + setReport(nextReport); + setState('passed'); + stage('benchmark:done', { + elapsedMs: Math.round(report.elapsedMs), + sqliteElapsedMs: Math.round(sqliteBenchmark.elapsedMs), + }); + console.log( + 'OLIPHAUNT_EXPO_BENCH_PASS', + JSON.stringify({ + ...report, + elapsedMs: Math.round(report.elapsedMs), + sqliteBenchmark, + appElapsedMs: Math.round(now() - started), + jsTimerTicks: liveness.ticks(), + packageBytes: report.packageSizeReport?.packageBytes ?? null, + }), + ); + (globalThis as Record).__OLIPHAUNT_EXPO_BENCH_REPORT__ = nextReport; +} + +async function runCrashRecoveryPhase( + runner: Extract, + started: number, + liveness: { ticks: () => number; stop: () => void }, + stage: (name: string, extra?: Record) => void, + setReport: (report: AppReport) => void, + setState: (state: RunState) => void, +) { + const openTuning = await resolveOpenTuning(); + if (!openTuning.root) { + throw new Error('crash recovery runner requires liboliphauntRoot'); + } + stage('crash:open:start', { phase: runner, root: openTuning.root }); + const databaseOpen = await openDatabase(stage, []); + const db = databaseOpen.database; + const capabilities = await db.capabilities(); + assertNativeDirectCapabilities(capabilities); + + if (runner === 'crash-write') { + const value = `crash-${Platform.OS}-${Math.round(started)}`; + stage('crash:write:start', { value }); + await db.execute(` + CREATE TABLE IF NOT EXISTS rn_crash_recovery ( + id integer PRIMARY KEY, + value text NOT NULL, + written_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO rn_crash_recovery (id, value) + VALUES (1, '${sqlLiteral(value)}') + ON CONFLICT (id) + DO UPDATE SET value = excluded.value, written_at = CURRENT_TIMESTAMP; + `); + const check = await db.query('SELECT value FROM rn_crash_recovery WHERE id = 1'); + const persisted = check.getText(0, 'value') ?? ''; + if (persisted !== value) { + throw new Error(`crash recovery write readback mismatch: ${persisted}`); + } + liveness.stop(); + const payload = { + phase: 'write' as const, + root: openTuning.root, + value, + openMs: databaseOpen.openMs, + elapsedMs: now() - started, + }; + setReport({ crashRecovery: payload, capabilities }); + setState('passed'); + stage('crash:write:ready', { value }); + console.log( + 'OLIPHAUNT_EXPO_CRASH_WRITE_READY', + JSON.stringify({ + ...payload, + elapsedMs: Math.round(payload.elapsedMs), + jsTimerTicks: liveness.ticks(), + }), + ); + return; + } + + stage('crash:verify:start'); + const recovered = await db.query('SELECT value FROM rn_crash_recovery WHERE id = 1'); + const value = recovered.getText(0, 'value') ?? ''; + if (!value.startsWith(`crash-${Platform.OS}-`)) { + throw new Error(`crash recovery verification found unexpected value '${value}'`); + } + await db.execute('INSERT INTO rn_crash_recovery (id, value) VALUES (2, \'verified\') ON CONFLICT (id) DO UPDATE SET value = excluded.value'); + await db.close(); + liveness.stop(); + const payload = { + phase: 'verify' as const, + root: openTuning.root, + value, + openMs: databaseOpen.openMs, + elapsedMs: now() - started, + }; + setReport({ crashRecovery: payload, capabilities }); + setState('passed'); + stage('crash:verify:done', { value }); + console.log( + 'OLIPHAUNT_EXPO_CRASH_RECOVERY_PASS', + JSON.stringify({ + ...payload, + elapsedMs: Math.round(payload.elapsedMs), + jsTimerTicks: liveness.ticks(), + }), + ); + (globalThis as Record).__OLIPHAUNT_EXPO_CRASH_RECOVERY_REPORT__ = payload; +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +async function openDatabase( + stage?: (name: string, extra?: Record) => void, + extensions: readonly string[] = [], +): Promise<{ database: OliphauntDatabase; openMs: number }> { + const smokeState = smokeGlobalState(); + if (smokeState.databaseInstance) { + stage?.('open:reuse-instance'); + return { database: smokeState.databaseInstance, openMs: 0 }; + } + if (!smokeState.databasePromise) { + stage?.('open:start'); + const openTuning = await resolveOpenTuning(); + const started = now(); + const { root, ...tuning } = openTuning; + const config = { + engine: 'nativeDirect', + ...(root ? { root } : { temporary: true }), + ...tuning, + extensions, + username: 'postgres', + database: 'postgres', + } as Parameters[0] & OpenTuning; + smokeState.databasePromise = Oliphaunt.open(config).then((database) => { + smokeState.databaseInstance = database; + (database as unknown as { __liboliphauntOpenMs?: number }).__liboliphauntOpenMs = now() - started; + stage?.('open:resolved', { + openMs: (database as unknown as { __liboliphauntOpenMs?: number }).__liboliphauntOpenMs, + }); + return database; + }); + } else { + stage?.('open:reuse-promise'); + } + const database = await smokeState.databasePromise; + return { + database, + openMs: (database as unknown as { __liboliphauntOpenMs?: number }).__liboliphauntOpenMs ?? 0, + }; +} + +async function resolveOpenTuning(): Promise { + const url = await resolveInitialLaunchUrl(); + const runtimeFootprint = String( + process.env.EXPO_PUBLIC_OLIPHAUNT_RUNTIME_FOOTPRINT ?? + extractQueryParam(url, 'liboliphauntRuntimeFootprint') ?? + 'balancedMobile', + ); + const durability = String( + process.env.EXPO_PUBLIC_OLIPHAUNT_DURABILITY ?? + extractQueryParam(url, 'liboliphauntDurability') ?? + 'balanced', + ); + const rawStartupGUCs = String( + process.env.EXPO_PUBLIC_OLIPHAUNT_STARTUP_GUCS ?? + extractQueryParam(url, 'liboliphauntStartupGUCs') ?? + '', + ); + const startupGUCs = rawStartupGUCs + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); + return { + durability: normalizeDurability(durability), + runtimeFootprint: normalizeRuntimeFootprint(runtimeFootprint), + startupGUCs: startupGUCs.length > 0 ? startupGUCs : undefined, + walSegmentSizeMB: String( + process.env.EXPO_PUBLIC_OLIPHAUNT_WAL_SEGSIZE_MB ?? + extractQueryParam(url, 'liboliphauntWalSegsizeMB') ?? + '16', + ), + root: optionalNonBlankString( + process.env.EXPO_PUBLIC_OLIPHAUNT_ROOT ?? + extractQueryParam(url, 'liboliphauntRoot'), + 'liboliphauntRoot', + ), + }; +} + +async function resolveBenchmarkPreset(): Promise { + const url = await resolveInitialLaunchUrl(); + const rawPreset = String( + process.env.EXPO_PUBLIC_OLIPHAUNT_BENCHMARK_PRESET ?? + extractQueryParam(url, 'liboliphauntBenchmarkPreset') ?? + 'full', + ); + return normalizeBenchmarkPreset(rawPreset); +} + +function resolveInitialLaunchUrl(): Promise { + initialLaunchUrlPromise ??= Promise.race([ + Linking.getInitialURL().catch(() => null), + new Promise((resolve) => { + setTimeout(() => resolve(null), initialUrlTimeoutMs); + }), + ]); + return initialLaunchUrlPromise; +} + +function normalizeBenchmarkPreset(value: string): BenchmarkPreset { + switch (value) { + case 'full': + return 'full'; + case 'quick': + return 'quick'; + default: + throw new Error(`unknown benchmark preset '${value}'`); + } +} + +function benchmarkOptionsForPreset(preset: BenchmarkPreset): BenchmarkTuning { + switch (preset) { + case 'full': + return { + warmupIterations: 75, + rawRttIterations: 750, + typedRttIterations: 750, + parameterizedRttIterations: 750, + insertRows: 1_500, + lookupIterations: 750, + aggregateIterations: 300, + updateIterations: 300, + checkpointIterations: 20, + largeResultRows: 750, + }; + case 'quick': + return { + warmupIterations: 10, + rawRttIterations: 75, + typedRttIterations: 75, + parameterizedRttIterations: 75, + insertRows: 250, + lookupIterations: 75, + aggregateIterations: 40, + updateIterations: 40, + checkpointIterations: 3, + largeResultRows: 250, + }; + } +} + +function optionalNonBlankString(value: string | undefined, label: string): string | undefined { + if (value === undefined || value.length === 0) { + return undefined; + } + if (value.trim().length === 0) { + throw new Error(`${label} must not be empty`); + } + if (value.includes('\0')) { + throw new Error(`${label} must not contain NUL bytes`); + } + return value; +} + +function sqlLiteral(value: string): string { + return value.replace(/'/g, "''"); +} + +function normalizeDurability(value: string): OpenTuning['durability'] { + switch (value) { + case 'safe': + return 'safe'; + case 'balanced': + return 'balanced'; + case 'fast-dev': + case 'fastDev': + return 'fastDev'; + default: + throw new Error(`unsupported durability profile: ${value}`); + } +} + +function normalizeRuntimeFootprint(value: string): OpenTuning['runtimeFootprint'] { + switch (value) { + case 'throughput': + return 'throughput'; + case 'balanced-mobile': + case 'balancedMobile': + return 'balancedMobile'; + case 'small-mobile': + case 'smallMobile': + return 'smallMobile'; + default: + throw new Error(`unsupported runtime footprint profile: ${value}`); + } +} + +function extractQueryParam(url: string | null, name: string): string | undefined { + if (!url) { + return undefined; + } + const queryStart = url.indexOf('?'); + if (queryStart < 0) { + return undefined; + } + const queryEnd = url.indexOf('#', queryStart); + const query = url.slice(queryStart + 1, queryEnd < 0 ? undefined : queryEnd); + for (const part of query.split('&')) { + const [rawKey, rawValue = ''] = part.split('=', 2); + if (decodeURIComponent(rawKey) === name) { + return decodeURIComponent(rawValue.replace(/\+/g, ' ')); + } + } + return undefined; +} + +const EXAMPLE_EXTENSIONS = ['vector'] as const; + +function extensionsForPackage(packageSize: PackageSizeReport | null): string[] { + if (!packageSize || packageSize.mobileStaticRegistryState !== 'complete') { + return []; + } + const available = new Set(packageSize.extensions.map((extension) => extension.name)); + const registered = new Set(packageSize.mobileStaticRegistryRegistered); + return EXAMPLE_EXTENSIONS.filter((extension) => available.has(extension) && registered.has(extension)); +} + +function assertNativeDirectCapabilities(capabilities: EngineCapabilities) { + if (capabilities.engine !== 'nativeDirect') { + throw new Error(`expected nativeDirect, got ${capabilities.engine}`); + } + if (capabilities.rawProtocolTransport !== 'jsi-array-buffer') { + throw new Error(`expected JSI ArrayBuffer transport, got ${capabilities.rawProtocolTransport}`); + } + if (!capabilities.protocolRaw || !capabilities.simpleQuery) { + throw new Error('nativeDirect must expose raw protocol and simple query support'); + } +} + +function firstAvailableMode(modes: EngineModeSupport[] | undefined): string | undefined { + return modes?.find((mode) => mode.available)?.engine; +} + +function formatResult(report: AppReport): string { + if (report.benchmark) { + return formatBenchmarkResult(report.benchmark, report.sqliteBenchmark); + } + if (report.crashRecovery) { + return [ + `crash phase = ${report.crashRecovery.phase}`, + `root = ${report.crashRecovery.root}`, + `value = ${report.crashRecovery.value}`, + `open = ${report.crashRecovery.openMs.toFixed(2)} ms`, + `elapsed = ${report.crashRecovery.elapsedMs.toFixed(2)} ms`, + ].join('\n'); + } + if (!report.smoke || !report.perf) { + return 'Waiting for native workload results.'; + } + return [ + `SELECT 1 = ${report.smoke.selectOne}`, + `parameter = ${report.smoke.parameterRoundTrip}`, + `done = ${report.perf.doneRows}`, + `blocked = ${report.perf.blockedRows}`, + `checksum = ${report.perf.checksum}`, + `events = ${report.perf.events}`, + `checks = ${report.perf.checks}`, + `backup = ${formatBytes(Number(report.perf.backupBytes))}`, + `stream = ${formatBytes(Number(report.perf.streamBytes))}`, + `raw protocol = ${formatBytes(Number(report.perf.rawBytes))}`, + `constraint SQLSTATE = ${report.perf.constraintSqlstate}`, + `cancel SQLSTATE = ${report.perf.cancelSqlstate}`, + `open = ${report.perf.openMs.toFixed(2)} ms`, + `schema = ${report.perf.schemaMs.toFixed(2)} ms`, + `seed = ${report.perf.seedMs.toFixed(2)} ms`, + `update = ${report.perf.updateMs.toFixed(2)} ms`, + `select p50/p90/p99 = ${report.perf.selectP50Ms.toFixed(2)} / ${report.perf.selectP90Ms.toFixed(2)} / ${report.perf.selectP99Ms.toFixed(2)} ms`, + `JS timer ticks = ${report.smoke.jsTimerTicks}`, + `elapsed = ${report.smoke.elapsedMs.toFixed(2)} ms`, + ].join('\n'); +} + +function formatBenchmarkResult( + report: ReactNativeBenchmarkReport, + sqliteBenchmark?: ExpoSQLiteBenchmarkReport, +): string { + const lines = [ + `engine = ${report.engine}`, + `transport = ${report.rawProtocolTransport}`, + `open = ${report.openMs.toFixed(2)} ms`, + `raw RTT p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(report, 'raw_simple_query_rtt'))}`, + `typed RTT p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(report, 'typed_select_rtt'))}`, + `param RTT p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(report, 'parameterized_select_rtt'))}`, + `lookup p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(report, 'indexed_lookup'))}`, + `aggregate p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(report, 'indexed_aggregate'))}`, + `update p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(report, 'indexed_update'))}`, + `background checkpoint p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(report, 'background_checkpoint'))}`, + `insert throughput = ${formatThroughput(benchmarkWorkload(report, 'transaction_insert'))}`, + `large result p90 = ${formatLatency(benchmarkWorkload(report, 'large_result_raw'))}`, + `elapsed = ${report.elapsedMs.toFixed(2)} ms`, + `JS timer ticks = ${report.jsTimerTicks}`, + ]; + if (sqliteBenchmark) { + lines.push( + `sqlite open = ${sqliteBenchmark.openMs.toFixed(2)} ms`, + `sqlite RTT p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(sqliteBenchmark, 'sqlite_parameterized_select_rtt'))}`, + `sqlite lookup p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(sqliteBenchmark, 'sqlite_indexed_lookup'))}`, + `sqlite update p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(sqliteBenchmark, 'sqlite_indexed_update'))}`, + `sqlite checkpoint p50/p90/p99 = ${formatLatencyTriplet(benchmarkWorkload(sqliteBenchmark, 'sqlite_wal_checkpoint'))}`, + `sqlite insert throughput = ${formatThroughput(benchmarkWorkload(sqliteBenchmark, 'sqlite_transaction_insert'))}`, + `sqlite large result p90 = ${formatLatency(benchmarkWorkload(sqliteBenchmark, 'sqlite_large_result'))}`, + ); + } + return lines.join('\n'); +} + +function benchmarkWorkload( + report: Pick, + id: string, +): ReactNativeBenchmarkWorkload | undefined { + return report.workloads.find((workload) => workload.id === id); +} + +function formatLatency(workload: ReactNativeBenchmarkWorkload | undefined): string { + return workload?.latency ? `${workload.latency.p90Ms.toFixed(2)} ms` : 'pending'; +} + +function formatLatencyTriplet(workload: ReactNativeBenchmarkWorkload | undefined): string { + if (!workload?.latency) { + return 'pending'; + } + const latency = workload.latency; + return `${latency.p50Ms.toFixed(2)} / ${latency.p90Ms.toFixed(2)} / ${latency.p99Ms.toFixed(2)} ms`; +} + +function formatThroughput(workload: ReactNativeBenchmarkWorkload | undefined): string { + return workload?.throughput + ? `${Math.round(workload.throughput.rowsPerSecond)} rows/s` + : 'pending'; +} + +function formatBytes(bytes: number | undefined): string { + if (bytes === undefined) { + return 'pending'; + } + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function now(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +function logSmokeStage(started: number, stage: string, extra?: Record) { + console.log( + 'OLIPHAUNT_EXPO_SMOKE_STAGE', + JSON.stringify({ + elapsedMs: Math.round(now() - started), + stage, + ...(extra ?? {}), + }), + ); +} + +function startTimerLivenessProbe(): { ticks: () => number; stop: () => void } { + let active = true; + let ticks = 0; + let timeout: ReturnType | undefined; + const schedule = () => { + timeout = setTimeout(() => { + if (!active) { + return; + } + ticks += 1; + schedule(); + }, 0); + }; + schedule(); + return { + ticks: () => ticks, + stop: () => { + active = false; + if (timeout !== undefined) { + clearTimeout(timeout); + } + }, + }; +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: '#eef2f6', + }, + safeArea: { + flex: 1, + }, + content: { + padding: 18, + gap: 14, + }, + header: { + alignItems: 'flex-start', + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + }, + headerText: { + flex: 1, + }, + eyebrow: { + color: '#4f6678', + fontSize: 12, + fontWeight: '700', + letterSpacing: 0, + textTransform: 'uppercase', + }, + title: { + color: '#13202b', + fontSize: 28, + fontWeight: '800', + letterSpacing: 0, + lineHeight: 34, + marginTop: 3, + maxWidth: 270, + }, + statusPill: { + alignItems: 'center', + borderRadius: 8, + flexDirection: 'row', + gap: 6, + minHeight: 36, + paddingHorizontal: 10, + }, + statusIdle: { + backgroundColor: '#d8dde6', + }, + statusRunning: { + backgroundColor: '#d7e8f7', + }, + statusPassed: { + backgroundColor: '#cfe8dc', + }, + statusFailed: { + backgroundColor: '#f1d0cf', + }, + statusText: { + color: '#102033', + fontSize: 13, + fontWeight: '800', + letterSpacing: 0, + textTransform: 'uppercase', + }, + metricsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + metric: { + backgroundColor: '#ffffff', + borderColor: '#d6dde4', + borderRadius: 8, + borderWidth: 1, + flexBasis: '48%', + flexGrow: 1, + minHeight: 84, + padding: 12, + }, + metricLabel: { + color: '#667789', + fontSize: 12, + fontWeight: '700', + letterSpacing: 0, + textTransform: 'uppercase', + }, + metricValue: { + color: '#13202b', + fontSize: 18, + fontWeight: '800', + letterSpacing: 0, + lineHeight: 23, + marginTop: 8, + }, + panel: { + backgroundColor: '#13202b', + borderRadius: 8, + padding: 16, + }, + panelTitle: { + color: '#9fc7dd', + fontSize: 13, + fontWeight: '800', + letterSpacing: 0, + marginBottom: 10, + textTransform: 'uppercase', + }, + resultText: { + color: '#eef7fb', + fontFamily: Platform.select({ + ios: 'Menlo', + android: 'monospace', + default: 'monospace', + }), + fontSize: 12, + letterSpacing: 0, + lineHeight: 18, + }, + errorText: { + color: '#ffc8c8', + fontSize: 13, + letterSpacing: 0, + lineHeight: 19, + }, + section: { + gap: 8, + }, + sectionTitle: { + color: '#32485c', + fontSize: 14, + fontWeight: '800', + letterSpacing: 0, + textTransform: 'uppercase', + }, + row: { + alignItems: 'center', + backgroundColor: '#ffffff', + borderColor: '#d6dde4', + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + minHeight: 64, + paddingHorizontal: 12, + paddingVertical: 10, + }, + checkRow: { + alignItems: 'flex-start', + backgroundColor: '#ffffff', + borderColor: '#d6dde4', + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + minHeight: 76, + paddingHorizontal: 12, + paddingVertical: 10, + }, + rowMain: { + flex: 1, + gap: 3, + }, + rowTitle: { + color: '#172533', + fontSize: 15, + fontWeight: '800', + letterSpacing: 0, + }, + rowMeta: { + color: '#607285', + fontSize: 13, + fontWeight: '600', + letterSpacing: 0, + }, + rowValue: { + color: '#0c6f5c', + fontSize: 15, + fontWeight: '800', + letterSpacing: 0, + }, + statusBadge: { + backgroundColor: '#e7edf3', + borderRadius: 8, + color: '#263747', + fontSize: 12, + fontWeight: '800', + letterSpacing: 0, + overflow: 'hidden', + paddingHorizontal: 8, + paddingVertical: 5, + textTransform: 'uppercase', + }, + button: { + alignItems: 'center', + backgroundColor: '#0f6cbd', + borderRadius: 8, + minHeight: 48, + justifyContent: 'center', + paddingHorizontal: 16, + }, + buttonPressed: { + backgroundColor: '#0b5799', + }, + buttonDisabled: { + backgroundColor: '#8daac1', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '800', + letterSpacing: 0, + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/components/animated-icon.module.css b/src/sdks/react-native/examples/expo/src/components/animated-icon.module.css new file mode 100644 index 00000000..f8156fec --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/animated-icon.module.css @@ -0,0 +1,6 @@ +.expoLogoBackground { + background-image: linear-gradient(180deg, #3c9ffe, #0274df); + border-radius: 40px; + width: 128px; + height: 128px; +} diff --git a/src/sdks/react-native/examples/expo/src/components/animated-icon.tsx b/src/sdks/react-native/examples/expo/src/components/animated-icon.tsx new file mode 100644 index 00000000..9d900a8c --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/animated-icon.tsx @@ -0,0 +1,132 @@ +import { Image } from 'expo-image'; +import { useState } from 'react'; +import { Dimensions, StyleSheet, View } from 'react-native'; +import Animated, { Easing, Keyframe } from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; + +const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; +const DURATION = 600; + +export function AnimatedSplashOverlay() { + const [visible, setVisible] = useState(true); + + if (!visible) return null; + + const splashKeyframe = new Keyframe({ + 0: { + transform: [{ scale: INITIAL_SCALE_FACTOR }], + opacity: 1, + }, + 20: { + opacity: 1, + }, + 70: { + opacity: 0, + easing: Easing.elastic(0.7), + }, + 100: { + opacity: 0, + transform: [{ scale: 1 }], + easing: Easing.elastic(0.7), + }, + }); + + return ( + { + 'worklet'; + if (finished) { + scheduleOnRN(setVisible, false); + } + })} + style={styles.backgroundSolidColor} + /> + ); +} + +const keyframe = new Keyframe({ + 0: { + transform: [{ scale: INITIAL_SCALE_FACTOR }], + }, + 100: { + transform: [{ scale: 1 }], + easing: Easing.elastic(0.7), + }, +}); + +const logoKeyframe = new Keyframe({ + 0: { + transform: [{ scale: 1.3 }], + opacity: 0, + }, + 40: { + transform: [{ scale: 1.3 }], + opacity: 0, + easing: Easing.elastic(0.7), + }, + 100: { + opacity: 1, + transform: [{ scale: 1 }], + easing: Easing.elastic(0.7), + }, +}); + +const glowKeyframe = new Keyframe({ + 0: { + transform: [{ rotateZ: '0deg' }], + }, + 100: { + transform: [{ rotateZ: '7200deg' }], + }, +}); + +export function AnimatedIcon() { + return ( + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + imageContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + glow: { + width: 201, + height: 201, + position: 'absolute', + }, + iconContainer: { + justifyContent: 'center', + alignItems: 'center', + width: 128, + height: 128, + zIndex: 100, + }, + image: { + position: 'absolute', + width: 76, + height: 71, + }, + background: { + borderRadius: 40, + experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`, + width: 128, + height: 128, + position: 'absolute', + }, + backgroundSolidColor: { + ...StyleSheet.absoluteFill, + backgroundColor: '#208AEF', + zIndex: 1000, + }, +}); diff --git a/src/sdks/react-native/examples/expo/src/components/animated-icon.web.tsx b/src/sdks/react-native/examples/expo/src/components/animated-icon.web.tsx new file mode 100644 index 00000000..dfbb1fd7 --- /dev/null +++ b/src/sdks/react-native/examples/expo/src/components/animated-icon.web.tsx @@ -0,0 +1,108 @@ +import { Image } from 'expo-image'; +import { StyleSheet, View } from 'react-native'; +import Animated, { Keyframe, Easing } from 'react-native-reanimated'; + +import classes from './animated-icon.module.css'; +const DURATION = 300; + +export function AnimatedSplashOverlay() { + return null; +} + +const keyframe = new Keyframe({ + 0: { + transform: [{ scale: 0 }], + }, + 60: { + transform: [{ scale: 1.2 }], + easing: Easing.elastic(1.2), + }, + 100: { + transform: [{ scale: 1 }], + easing: Easing.elastic(1.2), + }, +}); + +const logoKeyframe = new Keyframe({ + 0: { + opacity: 0, + }, + 60: { + transform: [{ scale: 1.2 }], + opacity: 0, + easing: Easing.elastic(1.2), + }, + 100: { + transform: [{ scale: 1 }], + opacity: 1, + easing: Easing.elastic(1.2), + }, +}); + +const glowKeyframe = new Keyframe({ + 0: { + transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }], + opacity: 0, + }, + [DURATION / 1000]: { + transform: [{ rotateZ: '0deg' }, { scale: 1 }], + opacity: 1, + easing: Easing.elastic(0.7), + }, + 100: { + transform: [{ rotateZ: '7200deg' }], + }, +}); + +export function AnimatedIcon() { + return ( + + + + + + +

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%0DnhQoc3A{aA*8p`p*}3)e{2L1y^pPctf^pY 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 UqrQf_p8*IwUHx3vIVCg!0A;QSnE(I) literal 0 HcmV?d00001 diff --git a/src/sdks/react-native/examples/expo/assets/images/react-logo.png b/src/sdks/react-native/examples/expo/assets/images/react-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9d72a9ffcbb39d89709073e1a7edd8ba414932c1 GIT binary patch literal 6341 zcmV;$7&_;PP)#OzF;@4h$(c0?u zg^(FgtCp&*RcTdJd={+=B9LT~@KT{#EGRIUIp^$k*PbLZ=S*fM^E%w?_s#!(`7-;x zXRp22-s`dV0F+Ti8D*4FMj2(4QAQbMluTI4g!RqV=|Y3cICwJuRV7pL zBOZ~4vos#)j*>G>y{Xc;H0M7|BbsO~?7%^YtrpCJ(CSC=IFU1)d&>;w70{^c{Ukh#~vMw!zh19oO zXQEB_@y8=GWKk^YRRmJ~8OWa}5W2aiz|mGj6!3}ypS0CDqn z6gRE#W3(rJ>U#1G)a)tD$!9Q;&UM#9!x6Qo8(OUUkE+q;%}2EQYWLzDG(i8%TmUv* zo|?v!!Qw%G5+p9OqT2Hhj7zRQwa+!STGx_*UommC?&#xVc62uXzYRQwrz>PWk-oLf zTF*$93^gcR0t0+`@Rp!kG% zObEJZakcMl$bvv?Y!2tXH_ zkZ=j~-g|bD31^4K7_amyv$69m6xxH_*dS*-0=5y-kAi;VZ;&GbEzD{WCMW8U_BlLyc2SR@1B{H=Wm>~D1BpoOWe2!;vz!}-l9Q#kuwMSEl!7VpM zQJattG2#$-yF09Z2Rw$S1mPKZj3X#DKP-nl5gLO#f;GOmyboQ-D=e|+@ByGg4Rn30 zeT51KTA~Z2z~Aux(9cnP7ataW7_8A7AM)d8kSC1YnmR zCFDU9`V=UOY7IPsA(5n*x!RxM-H~C|g;FruvgNXHHvo_NRC=!Iy z)o&P8M0sj=-4KXQqLLT^pMr<*j38`7P+HU-j;LxV0-dIP6aPypDFOBfl4pg!QW=i7 zH>xts3c+*0Vc25XXA+v$5-0+)M+WJtaO)yOPd5>dH+A`rBXDp%Q6&_B`qs`Xcx2|d zYL;~Ml3L%J&bVN;@wz0tA*J}aGuvXbp#UfiG*cnTval3#O?G%p5bYyZumkc|J=YoaxSb;|>BKAxG$W13gE*YU z)s+ol>s@Ad9YWG}FvK>E?5#(9;A@$GY8s;#!HLeGv}9WCKq=v=J*1(Tsz_Ly_+ytE z1!*hV#?fe4lXxt@#u3%5qo2qu$j_<$;r|Y-MW8UE_&e`a9jv^3^T7W4Yr8tlwM=Dy zcb<eWr7(Nj6ZbDMfYtGSB5;7N`srOw`O8iEpHRj|f51w2N{38L7~?2f44vbD20 za5l4woGpZzE9JAlfX%#;_OQKUFV`7i)@3rAvXn|Z-mD@rs{>d-G&tbn&uZVPL0ZK-YE4NXXN4oXBHjtj5~33D!=wx)!tjlZVm47Bi| zVRFzoXB!#D*tpbRB&NQ;t>3`Ghpp!RCS?mjcLb|_SES~h88-iekI+f`JHc9By#shH zXVo_FiEo({+OI;RFboAE@6C1I$22g|8oiBWfwkO)=^2O-CKT6^LF3sTUdQX(CD;iY zd%5?_YK_)0F`R=#n!q`wAcn&7$wMO&gPU|Bk-hthDBQemw>~uT`w3>h6e@QPDqNTxFUp|8NAu|Hd-Ns`N&ZptTSGR zJdpDWX$afbaqd4u3NiQ#@reg+#|phJSfkfBRU0oliJ`E3s5%wl3P+)S6=;8}Cm3k9 z&H-?_41G+}m=Ap*E>N~o9nZ*9XRze1!hMyoHSGo-ti&eF1-|plIQxvA@V?#l6m*$s z@sCbo#A6VN2YOYgMn6ybtg$HI4p65*SLeWw8obcZX6L0x%ZVTjE#`cdpl{+vQiL~J zik;Tc!CK?TO-JEJ+3Mgg`G_mR=1r4vS(qxg7r#!)ep{$6yJuoUICd9**^g|nHjsoD zG$GbDw%I#*1>9S|F)}<;*=3_}E7`EX-8Wj;rAUmQ=q^g>_v4TSp%MKy^CY}NROw^5 zo(G(1JXtG9%KI9_=1(C<^f{wgDL0Q*(08Ro?_?9v1;J6q^N=HyThP{4YX{HaLwe%K z)<>XjpT<-Den*!&$k4`Q+B=x%*9*c$TuH37>g;_{{%?cPjTScS5US5)FOM`T~B z85K`U+iG_hz;+Gu>x1VbO2`piY+ZdH9PW(gQ{`1nwZ^aeDxYd6EOm9v6*~g3uYxoykaoe(sx&&B{nNl7Ovwe zcZ~L5VB6QGn1=r+A7Uslw|E|ebrScpBc;6?)p+(*KI;nMv-?W!;5QvnZ`rm!Wmz5W zgaXjmLBphFZB8_f5M`;*X={In)e{ramfc3xa-a2g?r}b+m)$aIPgpv(vOQfQA0(8p z`L#^_5kmK~MBWAkV8#bDSl?$Ur@l#v`T<_n{rYLyj9eL4NMoCMf{Pfac&|0BP+}-7 z8HroC&cw<}A%^PB)tSmt#JoOFVkjJd72>Llsi?c!bM*)5#X^apu*@3<7c_%dr$zFbu`q!nBG#! zwvkopvQD}*KCq?lXC8=4?no|&Z4%;~Y-KX`MW_sHn=6#ciG)l)?>o z228$q?T%a&rG4U}4SR>UBzc6as-TPnL0cyE{iM`SaxRnr{oCRLUzgfkw~i<+sw61q zf&zVmahLX?Z&Sup#9o?Y-h9Vb3>bVIlmNQn3%DDEb=mS~B#6+(pPXTq)PEdkqino5 zCkP3dROu(q3P(;!mvE;aqX^Ulh2e?Nsp;jKnJwnGAa%8N!-mqCf*7e({E zmb_C4YeA+&A;hiDa7uqpYurG?pg<^XFUwrZxz@xaW;U>L$!O!XY~?Z%WYI)4rKPEh z_RbCIYbN$z9r&=Ym4dc5(-~&9d=vBU?a2%k23UGg|f?p=s9B;1D`hZ=_~l*;m=DsuNc5v1t`< zaL3X}*kisDXs6R44=7z0|GP91fxK6n+AX}Kf9cAIun;n6ZCM?^Sdf^5`Rokn`N=DuS>;Bg?HO5_n;EzjH=?Yv<`Y`jorSaqg<2HNFMGS#!B zM!OFU-LG!uGE4n;O7cHB{vM0;+E9%zl&KDgT9>)6T4Vd3n6Yf}=zePReqe8C-0gxxkG}{Jy($sV8RxRbXRm4(j+@qEwjx_i`moLYlXCf1$Ks zb@@`B_%nLK0&&_BzaGDpJ99(lcZ9AvZ$#~~HGP~?l|wct50~cH(Uu_&)@`}{z8YVP zvQ=a&QokuO94%wD%L@hN_t6z+_vnMcq!c%)rFY^Qf z%Ut4@O@6m4Y&PU)G2CcQMjb-kj&1IW5cjY)_gUT;?-=%w{>uFMNiC^z36&8`IRJEZ znBdcXU($!&eJIA|3niNjXpLVraNGlDi(!oi7Q>cUh&$`t%bsF|=2<-f^>mXDtR2vE zy_#V6p8oofxRI?JQ=XxUAvM-ylByp1K1U5oZHHIMdOnC;v9uOfLLSJAKPq!1M)!SP zf?NnGasdl_15vpE5xlfzy7hh_Y~P^#Z&jMLH>UO*$LyhJdWfO0oTXv``t@$B8_t}u zu`?%jkt^cITuiP~P~M>+uW{y4W@SDNJ(%nU9565vi|f&5gk$3aZT2-o{MLKQDAsKX zP9;W+XX3*Crt$TBiP6Q^Qf1M>Lv&$(ozGRL^X#9Vi(i(y zIK)K$y@A@Ti~QIQMWSS$i?iG5085$|@lu}7U-tMs>Q+j;b-V{{Ws~ANOo5?%6mE2+ z$Cw?S7^?NLji_em!48NbMD59&?_DU)cYdhdh>BEkoN7|%?T#VvK= zL8`SRQSxzLa9gB&dn=g54CEY_Du%&O%9+U=G?pF@G-N! zof5V8gCn6W9s3D{W6H$ad=I*})0%1A? z$zIKaHzE;n;=C8>~3|_37CC^_xRL|?uzeTV{b1-$Z?HceH-d-&uw>?3OGTwtcaes|(4IfQi zYH;3*t_`$Ce~>PJeRFIs??*1Vg;&^hi|c%O&+N#~f4a1@OynWpF}x)RQt|~k)?XF+ z3IZ$l)Xm)#A6pS@Y&K7D#;}EM67K~I-TCM~*8Khs3V^!85Px5|r$xZ8g2(WTAhTJs z##1V3$U-RU6$CQ$MwVxs*V-yr0Qqx$JH-{9aaE3G|0U%X*P3XjQIsi`rS^n@i&xr^ zxGlv)c=!0srnn&Hna(jSJBAyrNO~pL6zYtkZv8-`I`W!f}pKZN7 zqn(a|Qb5Q(#oF!!v>m|H=T>@r2%lm5Ikfk+@`IXHCSm-bBYGm~O0c2Gp-H zXtwoQ{l;hso(9K6zq#Gc!-sr9pZHV1@ezMvx*L2Ns#d>r0|g?`9Qz?FsaEr-eAyAL zJh&F9M`2iTnd*dgq|n4$4Pom-C=g1PV&W!DzPy{P$@hv|zVLKYXa4I{l?d~I*qM+g zF5F3jcu*I!{Ym*XC63ZehzM(iuMGK(_#MU2=*r)6Y=is3el}7OJ*aNx&fc&$Z?%Wj zW={jx7-8P3pM2pdL6}WF?T!$Ax>7pP*k*pa;#2!=a+`D&>)4Hi(?X-&Dv@A~FUV9- z9xf@5!O$F2uXweg**q2U02?Fcxx%*f6nG3z2~sggd!Bhp(s>SHa$~DKr>}Ce+F}y| zE#_lP&=r*Oz6j6yw}rIE2W#}SgVR)Nyjo#c<9k2YPa|0k@&ecU_^f8?)5g-!W?#*G zHu+;Ntorfot@tZ=jQ#O|+0{IVw>nz1g%H;d9Vu>e^5cuw(g3}G{LcDJmL<;WWrvU3 zwCRq)5#whI$MyY-ZOZwss?EHIC&VRbGY~wX5ib3(W7A((oas-nxhY|)IE<|8d0>=> zisLz_-0qnd3(pDC<&9$TOo!v$4dZrJZ@eRr*d!_r<^zCw-!OyXjDz4=3;N~K<|k^Oa|}?#b0oQQ&N3%IqsbU`t3OJ_Mt-~;4wTWh{D_!IwFeZtEsOW z*o6px2t-(rfa&Yr?G3Tz1NOX zl$U^m#)bw00)ms06jlCh$NpC#!GE8%nsxcV4V0s#mJ1LNI@$j!u#__C-ESwbi?W0; zP~8mf`R@y;g^-*O5Kv3;5D*8sl&FxZC-8M1gfH&!^55N@?cNmINlX=Cr@<8| zdP5c2+afSz$VA1sI8vNNVG1HdzjU(|R+9v#D3KMoZP;xGhaNr`Lh#-861nPL7)JOr5GZshWd_@eY zAuaHPF#2@N-GJLalkLS-6ysasYz4$wiX}{EoIi))(fZqs(-a7p{t%pPTJ%k{Px6lw zaxrKDGazhYJWfOAF9C@$m?;nvEhFgm)_*k;H?fAWU0mDhf>qgv5Re1ikVXvl#~@wM z!aS0mIiWocAJ20x?ePQbcfcC6W%)MJ2LeE-7*b zm)B|slB7Oc!$8&J<5*RR4%8SaabpjcKVEW2kWM31XZWA6fI53Oky!z5kV8dKS<

Iv6t_^UVtQ&HomsoUs=D4p`4+Jpd4Lm7kh@4Q3XaVsVS9|R9y}R)H_f2a9o1;NIOk|($$m+o~U7D`U$*`^boe67}VXlelOgRRCvA(ZXC46qDM~NMy=bfS6cz&MNRe zt^VZcSuNd5q4MvHr&CL6`8Pfv9R89+Y3Lb&W7$>I#%9=V3i~F7fCXpC)38f-^4e4W zt5y=6#fVJ!D?#Sc{v4|D^Gc+{KD+SR)_^_!NXnua&eLqmmSmN|=3=aZ*ZYLyo!%QF zt5hT&m;+m`Ee9So-jqhSdlP4>&SMt6GCsKVjNHnkYA0s*+5hchUIEydoL9ncml=`z zJCnW}zmNQ^GbFl7fZ`3ak>uCRVb}#8to%r6Vkoc+{!kyq(K2{Eb!hMPYJ_YbCnb{0 z?d_?{mr6py1bwk!DD?3oBErr)b-58EmTU400+8 zj^XopA?Zh$RrZrkPJMR_zfDmX0gx>ghyzjJDZcsL5A1LKqi@>1=mqtUp@Wyfa9z$CheP6If14e}Lj452#%s`{r51h{m{Aa-hhG;kSIW>H_B7 z*%7anRk1Ktheb>VI8qp?n1Dj#Z>7AAqx2ux+iG5`jWPzm!|&}yUh>M$~GSAXVn z_M2b*7rV5_)kl_r-mnTn?Vmbjz};N#@3|Ulp|;_<+UcYPXB|+cCuT3M23UahUHe3AT?J0)Eg2^9 zKLodxta1?R`2W1*oSVy8<{UzpeQ5k&eA)~h6T|k$j{oE0;IrtuJ;af=x-VRzCbI)9 zyWp0cxE`xY!fV8OZo^;lRkyYBsPo z28v5(o`cY(4=%F+(oS2yshU(O0X0*B8HqI3cx~>e+1xx06N+gT7@q6-zzwB)6|CsfOduL zG*W{uz}X1P@So&4ECJQZ8g$r9&H57#*^9!mbPQv;A$2Nc%R*WQEg@|I5OUqJB;<<$ zzUXI=LCZ}}QBC7t%_%G!vNCq+OxDu(_Xn2#@ub~Am|C7xUJ>U|{BQs1Px+61 z=H)Iz$BIkfg}^0Z>}{;%KMYHO1FLVM0-atKZ#H6i+*iwiQ`w6xy>e{+gGrQ6;&}o6 z8!^)=P^{mX{Oh>bXY{y2<%$bevZT#kNzy}tLzx7{jD#(W#4%B1K5#I`^U^Q zS&^Z@0bej}JNwCXX4nQ~!NDHB!fFEu1DKl?Em~fpJmT&J?2#+72q%X zukB*iPI^4bi_tCZ-}~TG4HgZ>#0>W3a4rx=4Chv;I#F9_zo|e)Axi_T*B`NcqDyd--!i5fZr<|8ul+3HCAy&sBPi9Y5#1Xm*AsdX*RI7X#o9Iu{X{2Q6OIJGJ5D`Sd|OJPUZ0^^g5|EGMoF^9}f`}dIVJgxUMq?{W!=u%YzMGho> z|3X~z#9703AxNR(A6*GH;-c~)(e_`)+;M7PZ1qJ)!e*VU5>rCAeeOd-8FBKF)!1U1 zP3886nmtFn#w`Y=hn|PK?9Nd5*hSZCY;Q4j3%LTvbNmqTe?j3M&-%#tpEP(C2keK6 z|4TUC{>U>suGMB*@2Cexc~Z6y$-l<`c^^&KLyE)2G_mF#I=L&o=xb<=j?8BBMQQ5^7`9bZb)L(DPD*Pg@z%egee2&vw|bI9{gpLjpZS$AGD`Rn;$$F{3VPa~ zMNwW#n*^8uGZ_<7c}Lr;wK5@(^HzYi566$<0h5X32DQ(n1fU-wZz)iO%~iQ>Jr@v7ph5p#?MHZU06i}jKum}V4JWbs@nb3XDa&V7iSNl9Irf#1W5faq)JC34TT z_wQJVtc8MVgLvfl&hlFIF%5Dt0#kxJWK!e0_&*Dq);IZ3vbe^DB08&}w|HmqOgmtI zwEndsMP0N*qea_h^v)&Sr z7kF_P#PQ&!;3XFX@IFjy3D(-4P?n?``!VT9_S=|UaD?I(&RMjOb4UsYB{;GX=;g=U zD~k>NiSLg7LwPGq4bsA*0{bx;kLRkO!kU`i!>FQyd-`HyB4PUgQ(W%>8ltbOpd-r5 z;1E~uji@b$?*Fkc3|2ytxMzvp9mfbRMdmu2(EP0NCcjS^{oOBr%l^q9z6(tWv2EVd zG^uT;KW!XHoAO~R=3?)|j{A3gzv3;wi;MlMO)8d-&nDRBv+Y5kj#WNuLaWy{#`(j2 zO2gm*e!RshHM`<-7f1waEG%ZRZemyY1eiau&sjVW{t12KJMcAExX}kn(L3n(9?#1O zk!SJO;-R1ap0$7bwJ*!g!>}>dsjBX#npH(AG0i3wAbGIebSyRHxQQSL5J-V30S@4Z zwh<&D^_n2B`FHXc1a=-A*$Co1SlvNFQL-uerY0qlrmQGZ->NTF#jdKehR>Sg`@Zqb zwa>5Bl)S6XZ=bXGTJtjI7~iVH@}~bu1KV2@$8DlBh)h8swj*rLZ<^{2+;pn|l>$2MpY zl+oGZWDhrP-0j9O$B*&yKi+*0E~&kmwca-WwIm^o8Rn*69%N@S5|{VGhg5)(DxGX1hqsrcyWQo3PbIJ4pP zXhdkd>}O32PIIO*XZ(H(C}h6j_jxi(*>|3C>dTJ8Hm2=!851;uX^1drSuyKFBaFcU zddb#zp%6N^177Do8mMzUk*nJ*Iv`a!6#)@*4#Ch;hP(PN7DOd!6EslP7GR7ajQ!Wz zt}$0Pz~QQrA)j9W?BD;LZ^fr&#svRyIgR;pvgE)b@>xAPf3|ir!Ma^j#F5(if0Vsf zGCC3APN1`LaT&|(0xFf6VoVi~&Hsjj_ZP3ZS6tEI-`YRPQMu7&o+6XNcZmFqAwtzz zgHD_ZX_hyd)(xY>FfO+0v$I}XFRm+{d&cSO$2ER#XP{!O4T1M_VBKTjv}s zI8S2Gm^juD*4`Z8_>1E}BN{veGScQ%duja|-!00VI7ub< z`TOtkSs*CegcG9}a^=R993R-hgANJkRkcCwf~osSrJfGz$Zv|D9pAI3_t?{ zSUy+LC}Wg5?8L*lA!E3TeAe?_!5si>nqkgaQ()4A4^U_V#ACAdYdp+;r8&?NBWCu~ zCPK2-D1$a&8QpWkJ=&Qqn;H6o0*J#DNdy_PU}RCLuz?X^DQD2rgC4inqB9i}LFGgQ zp3_2>ud@i-JY`&5|Go8e)W7q~@8fud+6@=Zxze)XAtTAlC28z!#!tn=xdB>@^7q<} zED~kVZ45UpMSpY7(rWzjb@`b&#>}|MS{bp>@8_^ujM$Xs&u70rs6QKr-y1$x4bXxL zjKI4AT0`rMz0HgXjO;Xh3~($%#lNdfB6fN&qvkZG96EU~n*ySH< zcxnwP-@u7IkSu(_TwDH*$-L8Pj+=8{r^~-1if7$BmKj&`@*{)sA$F9fbF%*9mWJ6@>g1vs@NWRE8?NNJTSj4=m|Nz z%YSH^uVCkWj1e2=wp^cEfVR}w7HuBf#6ONB=YGkCWyIN8`!svK9&r*K)OL@J4cEbF z(E#a;`i?|K67r4>2oXAEJ6-3$+v$avQ6+9xW@-NtfO+;Ism>XpbwFX>{2b!0vtJpraW4Rs3N4wINJm*m7!L{zk?JR+w4`V&% zukBQ3l>M?E#Xp7pUnm;j2^9ZC`#;y_!5pgG+DhHb3TT@TtDeTUEDPpljE-Xum%W24!JHkm6ye){gMXGrQe-=T} z7YPJ^mXHmA3CQBL31guw+b&%gu!sk-)Gnhff%doZnmy`cV@$Ee%pL4 z1GGghdrj)KXy#mifD7qiC{c2p1&b+oXM zTD*fkyYFN6*N#|?5-4_Yd%iY}(^I4b=A#MSlcAJV@qjChJ0g?Mp$1*b$0Q2*dUU|! zM9&sc%pAScP6GPD-0qJZ%!hWbHyHOd8mlMDqlKKdoMQ^!j}CIwRR5IG-RD>R91>;4 z$d?V6*N1=MU-(n`bML*q88cCC*uY>ZD|v*0B*W&sWjJCCAQ@LtV*u3{USaP$sX``2 zi71%w*_0I@=)J!`x9^QNYyP$(xA15kugI7{fZ740k`7|CZ4Dwu4(z((HK`j*^pUXw zg~6ev&0`zFG;sUX+kCEZST}Z>fzI!^Zf}qLm79Fbb< zLhd5zBfiX6iFN2msXhK=%oWkhT~JB&Z6(P%6LozOa^{|8Sp zxD9}q#qASmdY~^pcXO}#e9koi9kwldK}FC0Fs6R6;&o9XQehe}2S2k*HP`*9HdqJF zo?{cB9cuxB3oRK=CQll-86%oBDCzV>{N~^NAiwkBr%V>yh2Qv}OUd_OxPMQU1n|G=RpnN+bw*3`T6rXZMnl=PN(X5@0D)3SB}+O z`*ZKV75~c^pCaw@p!x_mX(s$bB(YR zc5Z1+{u%6lsV6$u48A0X9dljdPStj&-DeQ+@G^%tY|M4*W0T3o{Bj-I{AMx>-GhJz z+S~*6Lx_M+IA5jFi1!rcQws9KFT4dxNT1C%Fkd&y=!o%YT%4oH)0zqOymt_*(9Im# z8$rTB8w3z>;`#e~$^5)TR86)_bjNw-i zDmZBO)ZjdQtvf)_ORx!W4D;)obWjLA+ur*|8BUK%d+iZrJ?Akn-7ZUV2{5k2EgpCF zBl_0Z4@Pc7PCgfL_GhtdluC%z0ob3Fvj!sw1%aH8UwIX&NF`VT_!=48Wl8hNZQV#y zdwV|R#6&PqHHx;$p@M{U*oM@p@} zq8_sT@(GHDPu&ck5y8H{kR35&ujXukZ7J~kX5jJ*1F1Wq%Q$)CHIozv$HJTqe+g9j z+2UU@vQQ7AS@Gkv>nNk0_5A+zeiCB5&(b|}lYJI3_QQ5jKXUJS9Y!dVGB+Ft=p&8J zwd3urjk%@_la2ltZI9-M|LDK`&I(ARWN>BjL7xl{DwGCvj^i54+C(!CT)Sn?>HLhG z_=-P1r|eKC$8;_BII)$l%o3U9pwVzuAGGdH$|fc|j8>!tD7tMws`-NW1^J&qd!jwk z_k5IplO5No+v`;Q@Knq6AmLEVfuPj0d>oP)u4^G_6etVFz=R!Qi2eK#Zs@Lq%wSq! zZYME20=c-J?}@SYVOe0nwDIJ~2Lm9oIgSqW(CFtpd5EJ78t@3G9{!Rny?_yyKVsUNtz-cxhO0Z!c>9+~mJZ{__@4^l1{O{f?P! zhz8#0Tqdv(hByG`PD2O4Z9LofY9Cw9fP@eCK&j0X^BG($I&=u|o;NFp{gh9;LORJS z7RI@s^H=B_?^SPh&W)X=(q{xrMI8C}UZHS;t$yQIekR`ksY86Hy0L{V&b3 zfYA9K1V3-`uQi4I!=|Z<7`eA5rUwqD@fQ6hQ)^9-t;Ae7rh{0u3a1Wqt`VmRI?mIf zBIFk+U?2d9?Ug!TDspr=?Z(Ymz|7LPvbMHpL(V4iWHbsH^%%zGnM+ZzW%EJr>mvl@ z$k3+k;wQHM7b6`rX0`Dq`#)GQ93ABRr?vm_>6Snq2ed*}RcePQD^$#Be^Ax}47JZS z=&-9l^|QCvd>bdbUD6_&sCZs`!HP`=BFAhln{TIC$7-< z7@y?L?oq-!Dm>8O?H#0@Pl^pRzxHsn&?vLUUkrn_AX^6}I%FsSahqIPaP=>Sp8FUz z(x7vfIv8td0R7O8BC-X6fJvOx*YwA^aP_zsWDJ;ntmQ+tu@b=0H-Fy%B++ge z02Fyh{r2aWXP`(MSqL$zq9wJH{vm>>eLtlr<_KGouCR9|V1w(*0$dH$F_B{_gOx$s z%cz1`=587L`d5EP4Fc4tb5|-T!P+8@P9MIIRikn#?8VB*wF<$SMO()p&SFVdGw?-| zXV(b3F4?2kNgksy-OpyPcuhdAFXn)-Zif5siabK?-{Rbz@q}7VM_A+Yxs5Gj+N&G1 zD9B<%!$XZBRQZ>_{BHdGd+*5jaKq$&3cr`yGbk_#0y_lh^q>F@)daY@0n`o7bFm4y zlEO_ao@9d&b?OIN!9wdwo^+CBYw_SbiQ;G6Loa4lTPYfpSXa*s3> zxeq4?f(6HTiRyJ(}@Wk zq6!5KaowN{qIk10PPT&hH{o>iS?0({ZIn)Ivk(m%uM^uZ#$X2aZ$ai5x-wvTT*Et) z8mFMQR@(}K76Qk3gqOBm>Ci-xyw^#8tc>Cxe)~uDJAd^3o&{y(al!Ui4Yqj~lxNBb zJ9dn5NFOxY<$uF>lmCppAD8V-wYU*(Urr+Cb*U#tbh5)0jY;L^_}{V8F~8>TvN7N7 z9LL97TRROn(YZ=8XY0W$G@k9>i0LV%M;4}^J_q1@OeKInDL|ESHP^p(){T$r-7h@F zU-=3G^R|7jK0QQ-cIUftEWHBQXU_X(%QnFg86y?h76S%FeVj5$dp2R&)u>qvfN7OQ zn8HbqxI>m}8(rA1)5u)>lGv9|Wt9A1Ggwh${wPp`wzywSN(;1l`MjO! zzAp8w?`hxA&B~ZKui@a0m+SVoy)PlyPVBc=Vx7QeH6Mo#HatlIy!=?pkF~U)#Gmn590@g8`b2C&gqLH?=%U zi&a6Ij>|t*NGETbAhA#2^s&i5u&b3o&QmzdUu^>r({O_`_5I3#(Krn_z{yI9)WLx6 zSoQa@Sp>coEf;t^R*qU(Z9-oO6KU+l?RDVuBWEoL!3CNy@L?Y5WJOZX3ujwliHAih zoYuYGJ(Cz%Vgyiov2j8;4(kIw1qn&rgy7B9=W&2rmJaPePTIDL2s$j+MSbpp^goFE z`~q12&;RN>@$o08le$qRcdD%=c|{)b_KOGn#@^Xq6?9;8ZzBdP?($!;pH=gk^bh~W zJbB#-A?@IhYn{vodo2Vklkn%35GnSuXGS7||$+y_A#!Eu%D z^}x0}>Fk7#eT33A;=RvjI&P$#pA_v#mzK4* zlhY`sWW-(miCJ`3=wu&Ru6!GQ`(Afqo4?_-G-j7sIX0%2e?0|-<8b+3bfNu!_U5eg zHCPu+iMq9A4t$N)9H+Xo)%^%+0Q~A>5YsWKM7(~5-kK5A~ zdVI?!<>Rq=IAc#VeF=Xu`#(Yv@t?&0-x0aj{ZC~7rvlftv0T$5%eJfyf9W-L8mOwP zWY-%0Qn3R7;9oJ&#CHTOX57M_M3z`0Vk&Wzb0T6Y^1U^O!6w%$%Pg?XQuvSCF zPSdpPgA39_*`r(z#uq7=XgKDYX)eq2o;D>jd@!{I4>lpTH-Fd4BXVvAw&I|8&fK zkKtm^%p2)ebJ?d#w=f>B7-%33WPTIVCtQCsD03;aR%+LGzxw4b#+TlIdyUEuool0J zz-uxa`EVT+_hdAR^M+WLUk?K^$i6czfmvx?q_xUvo_DvjrS+0~82D;u?g|-RY*f!~ zrIA@7<8#{G`{=c1prAHR+s8R{h}-;vca~{ov{{CK6MHD3w`+_vhg-Z7H=HpISP$wZ zL`+G!Ir}wUIjNZZxj&q_|yT4pJ@R|1xBUx}%7Z$(cZN zFZ6lE3s6mPeU3e{IFF3G(?IVrXte)5_VrvkgaHxue|C^qNdvOOS(?At4KrdEm3(ys z4U3wN5o`zZHDL8h=Z8P4`r7Y(H#V`@SxegCA(B$Y7rHm4&svrdvaHcEbhLM6>2mu9 z`N!L7GQAC1a%k-qe=}PO0jI?JTw}TLR!>@~*m^IGpY0PTrmw1Pu)@_l4Y!N#gbu%V zFtvZrtI={m|keGCCT98@~1e2NFM+=a&!n~gen%D^A(eY6u6 z&|5<}P_denR_y^;^ic7m|&y2=yqN(i~8*5~TBdj}f?_ zjex@Wj;4DJqO1>EIbjVa_>M$!a8BriqGGmD^GQ0Ta{M*=saVj_Py2YI*%lwo_rq!V$(m%AWn1sF;2A+A48 zCt$@!|AxSF3DQ?iEWv$@)FnF^w!p|K?wv0My(E8s+7_B^7%8$)p0P4jAJViOLOiMr~Hpohl&nG z4RZ#O$4ux_E(g}1rT^E*Pg_Q-vu=@-#g7Hd!9!)*#(nAa;K`;mPHL3YO%QUJHLaYd zd0u;hmqXiu3mT(R%mZ2(#x6?{H?5pLC;>{!rh*dwvi9XVQNEIoAgm?im<* zdH>S;@6=!Vr7t#zov(maprk)yH+rhJQbz>_Dz?3k?(pw;koLPKHP=vilF`}mc6G!U zl(paYFq4e?YqI-$TRZA|+5T%yWyIv9A01I*!=zi1X!7 zH(L)_@1K|9&h^q^iV+&02bY*o;P>K{$W6`c_&WhwW%XkoW&6yrpr5IN%e^9wpV{B{ zLk9VC9WHpa_HX{Vck{i^Kebm3OLFJ|8@>VoFD6e)*6hgtY1xalroCc#Jh1So3yr3d zib2r>*?&?6|DWfh{F9^>V%NOVS)RCZ=+U3i!HInhuFsC#lw8PxPn41&s8LhVgX>S? z*vS83+ny?7bEEH1A`l@7)&3Z;_maeaOrl&cs@{FCkfS_!S# zWBI$P?t9Y?x4UJfjn(!R?)Lv&g=GurO=x8-RQm+9MLl?ZgiIFf%%lIrev+#`^S#-O z!FtHLjKIa-7T>28gXj2uY^MrES&h zQ+!hz6VNMN7>`KDXN^}ux^R+8y|MjXT=A^`X4QBI7m0u>B4J;;jSE^cTcem^5@}>c ziVO^BHn$UYdWWr%+DQ&Hy}bvdt^2B!8=)dsK^X+)?vX1-7+EzUXZf-zSJ`gRpc8P( zaA^o=UwN{-<~YX*Pczk7S#`xyC#ir!L|2Mjv|#pMBTZEsB$VBn;Sxd|Eags~K}0^od2CKCOZ%rh*0l?=TA6Z+9xaml_-35?cS}Wa z4iC*pqmgDn{K(sNe&hCCJ@&q3?!w_FWyCy;0fCBHn=+c;amQ)Ncat@|K6_dGfA2r} zW<67!ZHtBi1<*yHfSD5Oj`eqjWn%pRxyZ4NFxM_qFg@5^mku2$bmy(^>a4 zm4H%J8MBc)NWdx3fm}VtJrz~Ej<}Mr4bc`xNKn<*#}PT2WG90yjq~p1`lwp zuIMTq^fw5eR>h$dA+aPQ_G-CnEZCQeD@-}(3OJE3QK_|@Z}CkoHS!<)&Nt)3PfzIp zACJ>OE&IDc$`-_AI}mc;zEd&!#@hgCK$gEudzbu|w;Qjb)%HS%HMT;?>94xsXFUE+ z2mIFOYnlICDl<6i0(MK|xXGB$ zdT^c2E~FL?P&b`jGQa4GsB4KVrNaYTSF|opUl-UW;H5#DjnA} zX6F&NfAuGI)EG}=*E#S^0PW#bx1j+>!Qv_U#T`x)d}FPU-pN63_h$Q@|ZCP7doqWcy2SN+C5fecd!H+>G#FSiOIfCTxH5vq=%FcOYL zB3qwYHyp?mP#u=(2`47wb*v1@uuNA`nSwZS97GpJTbrsmJk6_)qwet*d^Z$4XY0zK zBEC*I86!8MPv5&r>^J791_z*FE4@tTQ5`_Va*I$#F&HE{H5R^f*tQNu!~C1&NUBT+ z%v#z1`1P;8zxFWmFr$qSweMTJ?{QQh0O4pEHMKttzz-3e&uR>vqJC(o7Q3 zd>?Ln6Yz4o(=_GANQ8I>Jp}sohQ6aV{!U&{6PUTi@bzze5a0RXC$Ls1{~FCr{u$*y z7~Cz~*An1XF|q5ks5AdvrLdI${p4r0&%bIyYU3bh#A%O*N-5wFE`B}K?Y5R`& z_N*+=P!m@;{5e5789*KR50&oHvExet4h9Z^iU5{2xq)TKws8_&0f6ivtO!jpc%T;# z5gm>&8mH;28Am=viC(j~v{EKZb`8*;48ttsNet5%Vm)4w27cCH^9j?DxZaG0F7w!E zTQ20LcQ?hURV}f!<`%=(t^X>&^U-tt&A;~n6nDkeRz=**2K`Ao-h*d*RZ?GYx1}l^ z69YU9aWHIQPaILk4d?2WfnlY$0FsYO`0|E!HcTtsTQ+LHai7_KAMX*(W9T*&TeqM{ z+4_WU^O~_1##nc3Ew=?y@7HxqW{mSb-jROzJ#}i$&$eAIO6K+K{g&&y~^=n^w zFW&zG)VJ;B7<9knJjhg~&#q>GOT%t-v0tg(4Gh!1?JE$3qW;$; zXOyuS7R3?wl{>Y;XCRwZ^~s--TJKni=vPy{A>OdI6QPk)?6R)qoEhygz7Z;eHU&l^ z$!tOL!TPLeg94(#Pv(Ws_?)hGlI&p&O_YPXvjoRZ4U{*H!_!AgM7AlYfwVNfm~D8& z_s2AI+ThI~5ChFrrnPDFw){EsYOqa#J|`pNFh>YxOsQ+A?=GgC=f!8tfhb$2dQCNg zjeWpdnUFG~T?Uo$gfgOsJ!a3!;5k}Fx6jVBH5m4xe-o%#n(P+XPtw0N7ay6~jNBe<~2tNvy%WPeE3H6a-BeAIV^iJ6jaef+Q`h#;M;KgU99cJ`{9|R!oR6 z+xp}=d|7ILSlco#*7wR(G~7xy?V!bwqkfeL#c%w|`|<)R&7GQ!b)v4WcGZFUpcoMW~eQ`o;QmY<0wZd z6Mt|NgAv`wz}9QQPyb#S_?nFP=!V$-zFtQ>3>;UajHZDIUbBz<9tnrRWO{c@^d4_S z4AfW;vFT0+8Txk#x_$Sf=lI(1es>wM9lYYU8y?15TLyZB_eA8XHs>BA8^tUt&^R?q zL(Xn&S$xSesgm;}W0e2!r2cBda{?4ObT#=*?zNABagCnkfs+5|j6U|3rm_XdJaN6? zEb9?_VN1*GoT>&Gd!;dg>ZzP@=-P{Q%qT5k=QuTEagal1F54CZm7Ky@a>HGyya!^n z-c9>XG=~LYXn=9t&#c;*y^gTzTX{tX3{ES!=jq`=`ge!?GD1Q3G@ex6Ly{%^FV);Z zrG|;h-}=Mv$G1QH6!L`D;wO34D*-zVKIp_wMw@{LlfE{j<1YV?BS>L!e;*ER8)gk$ zdS7Af*Q@uYUeP4%vcA* z3TEB|7#0z#oYG8c)~>PJ%<>lGQ*^+>WQJ8) zst$@x{>$VaPd}J@PtAYf>t-+L%$OcnSt4kH*xl#iV^%bs=7W&<&=0*NJcGgTSk5q# z8%EQOD`Ed@GqkEA;=fb-A75fjrtQdkzCB?<&@0%*K8I!cM*fiq9zDX;Y&}7UE{zZ% zy4rqVCI)~E08w*X`!#*R%@hSk=lbPQ?cVMi{$Mo zRBStR9sY+aovrh~{2BxjfbuRd_c zfQzM*-ChC^Y{Bdu!j#798=uEx1X|BUb>_d{$S(v%)8o)P5hb;X3InW8`*!wnSr6!}JnsJO7a{>^&x}VK@V?yJ`DT5T+(HxbNnKbCY*evY|7>&5Q zK?C4>_4?EXZARUy_}V2PV5dhGZG=>yjen#O_N@YdMU=WJY5cy%dup$1DZ`Xl+v}#I zPlFvdU3-P87?)BEQ;<1S$6A@vM7C;C3nzzfbLg9Llwhd+nGY6{+Xxn9jnmYhKSAD{ zuL*`!Dm#k@vZ(T_U)uZ9d+)@re)*@DW4Y}`3^Pvf`2+;~aeg7^Rc8d81~M`h!mby) z1tY2q%aesW@{d(TU)xlI5MYLB*-Y_0o$DP$DBB_W)%@zZE3oDo2ZfNvub1TV24zR8 z!VNCI+W0kIZ64T+`KX%xt2YtZFs?X92>HfmLJR+E(;lLU)~4)A^3V|fp7kqlbDLMU zbM5oLe*1s@7vJiAtOp;r6ue`%?5kWf77vWLX>0k`n0SfU)D%NQ)-OF1-9MekfHOgq6%j_j&NN zYSAtAUX?eSqXTTIFhEa#jGqKkm$m{##BM+bf+swq;cDQJ&M6)IwEzZH4@R4uV6cx* z*%e%Oa!lBlt1MYi4u{8mjpZ2MggA4L-j^Ot%PJCNl}6a(juG;?b@oI*@gWub00CcU z@Zl$)#eeYI-|VWUEiC=$hPw>S_#&tu&s(`U|KdS=jw1{PYzrT*sAB&vW5)_%WVgM` z@$?}?06q`X*h*7c=T-f58g zSj+C^jdF>hul&)r+71vAW6s6?9&>gB+vkD0`(e7d<7=S`;Drvq_LX;6Ab`ZHEz!66 zZ(-F8)s+B{|Lq{VqD&-pmj&QJU}d%8s6zgiO~nJA&yoL0a){D$*tBLWPoKbL&!X)p z%0JgXY$o<|-s#24=#T{V$mw%>Ihwm0Qe zMnRW=Bg^og^WYN$xE`6Z|MSP#|IH#fx-KH#X#eMJia)9SKUs3{PR$sm$?DEzZNLt` zL(}Vap zz5lRnOb_urAAz|viOlVr2;hCqb_mgVCtBLOsBy&jHNgYWxRkZUg;F3hpN#4Q(YT=d z#mI=zP~O|XpdYl4E-jHEqChumtBHb?=Vhpe(L|og#`>i z6idYK#t`p4iK4NAaAV_GFZ*CJu3Cn zV;EnyGZP6Rv&q|QMCr!5;GLYv*G9XIKW%(%GxfEnkYLo!Yf6@ZHW6W3#=j{gD`zT= z)>~uVM6c*YhTr+4AH?7J|9`Y5(m||4##C+QO*@w*$YSd}(fS$#O8P~-gU^>oGl}cu znpjf(kQD}93P9!}z zjv;Aez@co7aEw)fT*_VkL)Ai@XRH6l@^B+N3~LbxWxtDxw)Qc>1^+)(UY|Z_ie2N} zfwk$g2~4Kb4gU_dj;#BUSL=?tzydhsnFF%gvP#A&DE`1?(BZHgL2tdnU1~b z-16(rs|w&L{*V9j{~iubgl)TCQ|i9!f7++dsT2Mw|6BmhRE{dBMGJWaHLXIC8x-U$ zYH7e5p$f#~hN{x@WaY8|E3dtR$2#w6A#=I!m6)b6%GPxg20ZGIq(0l8MaDfTqjX61ilx{;HnMNxG%9{_pYPdo@7yplk*#!w9dRG}U&~F3{BonrZHQ(iiWkLEp^myHQ z1fxV1G!%N2S2iLxO;TzU zzxQZ)LO+2HhW(V0;$G{cg=5w>l-UV@Pz0YuARvK}d^!%TYwLPAbZvA`qi^c5D*^)s zC6W>AeOzPEp2N~|K)4^*gBPL)Bke;!eyR`cpdH?)AkBFW;B6;a&-*XD|8`#4|Fu+c zE7Ql%DqD=$OBYW!Y`7$J_+yNqb4o6&9s>rJVY~HP;epU`N^<8lBg`0%#_g_rjIYVR z#E@Z^{c81}Ipgw9BQ}`0J0}%m$f^IqCX+Z~ZU|h-TD$_bvuLDI)aWea1-D=hX|oNx ziLbsrOR@clhME9Rh1 z2Cz0_BcR)4O<*IL9OrvCke(NB;_C7EPA2hVc{l8 z$H#Oj`+bZc#Nb%u9`FX8NB*hnuHRY0D#OAIGnBB-({=j&7{f)6jm9^=VY^{tVDCC=ts@_;dMFTr44vkZJU8WX zED$)Gr3_Fn_E+o3P?mE-Q#bveIZpV?-(Ua6cjKc^x)cf+ADO!?u32P0^g92}B$;6k z^9Fb4xMlgVO2^bo%xBYoJicn)UX}ma8neIC?#=Cc&w}YX3mlM+;cg7MzVTsg8{qS@n%V=ARG^>>6^1P_q8zUm)}+@r2zvbDd<@L&O-lEZ1wVS)9ak9p5E z@4x&@UyNV44(@F(R&^AJp3?vRXyF+7I;>J)?Ablw&T*{OwEI`}i#>`>6GV%&6NqDe zU>@baWKU7Yjz;jm8`Fs65!>>SE=n8$OF3Vzx1F0p`=9uws*OB$`A^FKV6p`#L@)7~ z->V?&1RZwUnA-?mX6D;GqZIVW#A^OH`#=AL_W#IrM{-}a|FurzX25jG8evpq+2|Ft zU)S$vjz2k#XB#x~IA?SSGB9Y($KvWS=`oek-ddsJ+U5!*RDiYzALE4QZQ!lZw#p39 zW*`@eXj2-g_L%uMTG;Fe9h`Hf#};jgB$LHQ)<#;yqc$+KD4}{{reS^F$Jb8hWR50C z%|e!lTUZ{SPE98vZh5rE$uE;_FAOZVs5Kn#v`F1B%>Y>8o0teAmT4pM1Vq3L9Lx3Z z+EpD?Cz~j6U_F8;&A;l#*qn5E1{_dlG$iPhY#BfU7;f)9Vv9OkKM-&u*eFH`0i%{f z!yw10M_w~46B;~o%qgWuu1Q`YRa>+h$R-&Xej7-OWu ze+FB;B#~9A{_a}@o`Zpc>OG8@>SRxPw9wXDzvGPAJ4#M}#%PPSH_F-y!|wYFEZ|s``p zgM=AzNH;kC;JvwZpSH9lhm*B3zE=}pe{uf5^AZF&a`e5tM2yYLv@1;h0|D;{8Fu4g z<2_sc+aN2ptt4Y-dUV)Q-knP#rf7MW|8vXg?Cld6KP^i*abU1rU0OdI__SLuT<-I;WUNTF)XSm0@f+wE2W2Fb#v?6X5f@+I+8LYUl zVJal2I}O8QutWE7{#W)??d-t=txE@&>cdYfzV;FXK%EC|8;r7Fb}B)5Tjb+%u*irp zO9u2kcsDqjadVSjLCwaOiFL&|Mn)qi)E!3@3}6Eqec3gSmpILMjqWAwGq!J+4yYY# zATR;I65j5Ct5nu~Fl7c%aT|mIZBj?sc%?gRQiENs>ncWPosnDMy!9ZYz6SrhDqw=_ zu6IxGn?GmE%m4nXKNC+NLC$0Uz^`Y|f`^jgwEOhH*Ji)sQO>fbJQotMrCM$q=O%aq zj}3~iWgzE_qhxZduRkUKEyLoBHDM;$Ro{lhD*qO;bq`G3WCdfQ*vRrJyWiIwE>l zfUFv{Ypb?kt)=(l_YK;(*bZ?c=(!akpMUSI_}7vBuUV#>vp;L7$NR-({x`#h_nnPXdT}nA*m?xVXyn$4-SK!Y@Hi-7i3TlLNCc06_`N@lzx_u)>@xR2 zPEwKDHCik9pFIdN;kS?2G0hl6?PsgsD7&;&L%WqT<9 zERLm-TvHXr@hl8^g5zG`NoDu1yBhb8<4M@ku!k*D2LiCiU<_$T{BhX{;Z||b`o_d$ zyGHkOen*xaOqw)|n_#oz+$2@$Da6|37YnLn@yVz_|xn-=iet~09FqTwuX@^@i zF4h>;T-UXF2Mp-`{O*vCI}Te;lY+h2{bj=$TtIiF>Cu#t}21fs*gV}JK`sS?IcTcNp7eC9Af zI`QN5KcBU24JfW}?ZNUBK*swN9Kgc-OuiF0`lNWd<-eCWGqqsNZpNWMq=NMBUU8py?XfVRmbQqg_Gjt3~ zOO{0U!pgb|1ws+q?B5kwP$ioV8g8=C_vI91=f}8)cu%c@(<-p$QQ?WnQ8tpu%)8yw z75jbf%BGeZK42mXg1a=Knji?ebpJWYnzDtTY&N;!%U1O}0k1%>z z&JlM@59Ig#umuIDXS>FkJKA-7<(WSykqP{r)RG8vjWayrr?ra+p6cz(1O;?~&&!_3_$XL_cEY}|*!vR&O@UO%jEOQwM8Foc0im)$o9I?-OR}x^7I*j zXjAhXF37@ubI5-;T*Qsy!F$9qVJhySq1;3+90GXvwdQyxc@kcS<35405kVPhE~%I1~|ufnmL?t!%09nnMMf1 zuFZgJy%6BZE-;mo>b_8G&*$fezxLbTijRJDuEX#)gfM8zkUi!)z`h%#Nc#@wLf9v4 z`X>LiXB!zbK5w|$YcfOggI@V%X(atVRx;UYY!f+jHKzFVXZA+MHi!Ms`xrTdMSCBdO3b@liNC9N{pk)}*YXh+h530xL(qg;NyAhlI zxV8>Fy(shl!~g7O@~y+RKftcU7>u{WwgJr)G1|1PS?y%7f~Inu4w8tnwdld>%rsd` zJ>qly$#YGPJsUsrf3vfX+{6^%QSl7<=e*YYF)rBZ@XUS+3t&?FFL=SU3!AJ>$mI&i-keTr9Z)(}J{6U&NU zdXM&hYlkBHaUQtdNm=l`2IslBMO83C{nd(`-&9tffeSg-(fVKXR-gQo%umk-EB}(Xx(bEy(R} zk3kq37pPvPD&j)0LH8_vpifNl!7f{RAh-zOea7SyQzS1DuC}|@UyhG_$SrkwZc0b4gZ-?oxHh7==g>&*GBu_7Zvu`R7srm?Et zuM(PTEC8&d8<{MW`Uqp@a-xR}*FLs(lr;g%BV%U3NzS2kWtgspQ{`-1Ml1%r%b?(T z5&?!pI;S8Hk4-6SgGh0{j5!rLbLs8N`cY=6B+y!PJ02WcLfCu%Yrt+?SC+~Jo@qNg z6J0WfxmZS6tOnrPxkT!=WaMScb_j~YmHq$KFMqM;qB}yy#VT8j*c|-|$|bL<$GwbZ zY!VdDnmM$vm=heMPu%0>`h!TlmNrw&Wcy&M1`G%*H$kGm>IPwq_|J*e8DXAe5JP+} zyobPj&uZ&5lEAKWC2 z86_EWLC(6oFvHh>?|bp>?|;%8u)G`MkS;7`g0& z$5OywfDAVx@qJBJ=|TQEt&47QKtWsZp?G7%!UD$0X}!sRx;E8M@nAZhc8vwR#&sa5 zQ$>XyyUdpbUoix6R2e5)z@4&$d6aPV0ZR;XiJ7@OpA|!crG<{}D~CR6w~kqx6qd8R zKcy2y=XE;{+&Zb2b1>}sIgF<^Hk}|&)_w-f44h{Jz~S%uT&T+s_P~M7j6|t0vT&`FU@}EqtD{Ee*b%fkl5@eygUAq!5&aV_%h2V46^I+k?^|hvl{wQ zm)pEqi20U6YC^)Lj|!}MPeulLU!>u!x$ADbOw!Isen zt_SO(rL*>RGUp{m$98+}(9h%ltexU@(5d)2W1gln!GcHUUp({j^OZmIh4|%P_(BX! z3~%LBRt*C-^VDEr`!cqu$wNx#_d8|jgkOWv)0HOVY4RV)vbG*S>3Kl@(I2-da16c` zK*QeM+y^JQcq>0EIcKH$uS-k8WF+7!XKRGB)*3yNPOK%X69)%kdjjSE$lDW3&{nd) zS!e#fzB^A}+)0vc^7*>`&ueqjvGHd6e?)q-|A)4vfH}Vt4%E0=SU2320LK$B2qkX$K)(}1YFET3;LP3zYNA?A3-jgms5N*a#g%8 z0V(Va8NswMaXm*nGZBPUEtsfSqnNSY^*_n+s{KD&*>%MA>O%l+rYct?0gBNSW)W>+ z{6>p9VDw3&2y}^Zqlk{MjIL^*jto)KOQZC`Qlof4s%2P}A}Mra`+7T&-6|Tgr97xm zg9|A7xXUcpY_zdQ$O`){A@VloWVL~tSpfwn=WU{}P(0nHI+f8dv;jQvV%%vXo<&K| z9I%(*{iK+^l-ow|!OT;F3Ru6aFI(5m@Ek*w!H+pvI0^7UB^lbMZjmbjAH{@05h{x7 zD(>OGuCNk+U|cZ@7~Fem%A_wlb0b}}EF72vp@Glu&az4t&RfI7xI$nd@TR3m+N7WG zN=3>?`GtUxF@9a|-J?4HYKfL9c^%pZ>jQbU!(} zga*nO0WoE~YDgKB^_%*&pZzl`jD3=2?__QJy@-YJ3UP8oEJn$2e!1hT=s+XauOBM! zqp8}*O1@G5MPTmvef!1fxeoqY(8ku}3>-53(<;aZqk!J}0-1&NBmi80lmAq93fRG? z2DJHuVcXttjK}3)pC2$x_X;5CmXUuP2vfWjJThX;^*JevJtTAkzn+;NDpe zVwazB64F>D0z=}6{lYtr_vN<(fnx#=^?x(%EjnuaozWTj6x-sf!`8U6yqqI>G|EXk+F= zyDdyEO>fv36>Q7c#yNAU*$NOm4`B3b`@XlW0JwhtJMDKQnwL1g==7I%fBje9kEbi) zQIGn+d>*K@Okp17W>2W}T+W$ynfwDs$XcZN*#8y6ma@m&W$(>yMyN?Ff>dxZ!UIoC zs6Z|i(Y|)Zk1vu{U-uRY7Zxa^^0;3GI8)Lh!>ZibyuKFjva`hy(0kQc-wVg9! zuvHZWoPKRlisQ-LTzm!^aoIAk%@hh*K!wk5QUx@9gZ&>Nr`hd)!7#3pUE}smrw)rm zOV^!zKgm{e-j>Np(YDcgM%|N(@*AYR7@%ofg`}3MyVnr6PHT#aB z{(?mIMI~xqe;4SJ!)g%9&Dy7+cXRlyclbE}o{TzwzBDNiAj#>+?C7i? z#m(;HwCk1koDL`gbq6>zqU(YC0(dV~X5+lKwGVX*D#hdPb-Ve7ypZ3TLBs@Cdd=$A zo>n8MXsEy3K%TzM*|?2RQ{N1<5Unn!>T(AEqyOSt@!833>=po94Z8fVy9h|Us*OFh z3%Jj%m5`;^i`TM}{Y)&iS9adV5HM?-SVG2UpE(WIhC>Bs2lKjtk$;U#BVWc37DRH) zTRXVO{qZ>%rit;MoBTunIWs~5bx`E&Ks9AE%Jk$IE+Op4(+FIL5|=c$WA86)?L&N( zLq}8`B=R9GJ4Z-giS&o~&jNVR|FJp7JV>!G?sJV}_#K&;92&YI&v`99lflCjZ5$n^ zS~}~Txf45+{F1i3*^elr84WwFnkSg`3_fS!!%v>$ul@En<9U|^&Kb1RCqlc|aDxF< zF2dmO?vQTsKZM@!o+2bQR=%F0`{(K%Gwy){4+apt?|60(MhegE@4hZNf*Ab(2p1|-I+qU4`&HfdJa<_DiY&NGQg3pH4O%ZpPNoU^UdegtRU~8H4 zo8b}7-0_Hx?mE*Y$Ih1W)3tl0Z_w!{7ZkJv^ThH@tpP4P$BaAaBZhFldEK-8(0{A_ zF+OwiXIpnn!jD1uB3w$%>;Lt4N5eLpV1j6I-z}x}~Q?>!RV_vjUVWh+UmHp5RfuKO+znwiWnH){Y1&s7ae%FgU3@_%L zn=DPhxAbfenIpiE%n(m7BbLB*bcg{Z+znv@;mbR^t~;dL%|Pb5FffA5S37nV+2a(> z0m-w=`;noK(XNR@s!qNp6(^oo%n%@>`KD z>fzx&Dd97>g|>aF+$zBRmg9S6ZPZPOAQpbU0}v3@cX5hvcCr$(2)EYC_OUfT@Yxmv z1D9ee9X5vsi)G1Yb++@@|F!o6f3G|5`xmc2{A7O`G+vhIf76ILXIU-d!R4M($9>); zVMRPp#jrv2z$no&?*Q2-v~d@*iT zl}~`XHp8zRb7fb|=N!}EcfiI9RnUwc-nR_&{O0>A>lbss&hz>8uf9L@S?#B)A+Jh+ za}Ft);hz+&-WJ0rfMjQ->y9_hcYNfg8aq3%nW8~|e)hq@H9X&A$Z+`|-oh?nN^!mk$M6g;6SMC251^cn~KaomMVD>+Kps)Ziq=^aDzHti{FXkyFKxiMQ zRmWBYJ)5%sVj5XvNMIPb1R;iANkqh0`9b|b-w;!-+0s}1M@t>h&%hi-fIFBlht<;c z9{aOtZOQ0R;KGG1(|p4LNqt6t+P-tR1@n|w8t!xoMw4W#Bxjy6W3IvZ6)aOU1WFL9 zYO)03j)gkUA-Yc#{KW`6ItjE@BG6!M-N0RnYON##4U|a=@#NIPKoDbwJSKssW*-hw z14~zabF;DOFN#XU#?(#O6y3=@Eg*@Yz*s2RZy2BkX)X5Rxp0sB*y1f*5nyNwU<7C|V4# zL|N%lnIc$wVo%mOb>C(ziuI8pD>?IP#U+9cL^3K0j(BGai)#l1aMlh&M%D?6Ku!z| zQg1+k44hc1#IGg{Ng;jpbzS#dgDQ5|OvETS(ROJ$>`x6wl@E2*1_vIo^`f=@{Quzd z@ymbar$i6Q7Ka1FgY+AtWjAPMum9|CR1hF+&!fgp!g6LNrTi*m|J3B!y(X2x-P#q9 z3aU*0?3#GI6=z)7crvYjz%vX{%T|WrVn18FZ;+oG`a8wrdkJhj{60MX`dC%g&%kCH zuhH$lJGfDw>JT?KDj9q~g0DeiC2%-S56+IxWKy%r3RKR%!1)WGrud z*n6x6nmo|S^fS6{{dC?+?47Bwj~`T7KL<{!W@elF@P(ylNAd-f2pSze@0`Q}o!=X( z2MsxmFLHRt8%xRB2*Ln0->;A)SiA+3&fn!UYy^xwgX~@FW1Pb&7>9B+-gE8=I2spXt7`M(J z>zeZvvO>&l1sP~=I~5b@&u0rpiAP*(;OCn68Bg^e|K11n(I@>3g+4MNB*T-n{xJX$ zyHkVdf$p_kcW1=ZTGX(0xNP6rjlr6&y?9gq^B!9pu@59BK;?arRy1G-g7X0s8?!UE z0G2)rbAl~k;N#+F34G5x?Gkp4w5@Pc^TMAcy;C!X?cSIJZ1LR$-%EN*??K%Komzrd z1vp-NEa3IwrQ842Kl7(NpA}`*nq=FENwTE22;ku6qWt4Pz)mV*zTCghGcjEL3r{Q< z`OmPRO(V`cmaK(-l`L+{3;7?Xu?ZrarEST1D_efY)5Fc0IG=6H(JXYY1P z!Pnsr6LWp@oNJJfe`gF^n{il?NGjK3GSvIc1EUMKX8yFB(R-TCYU_&!IO2QG9aL#& z-;!VaT;#w%?2BuV6Xk^x;;bYU46gUs-TtR5pR-moz7cBKnewx8bQOaPHJBOjQOgPh z)F$1QG+&AsiI%4_!GbSZwAy_+tqm<1n|A<1;9vUsgP3xb0@-##MH0?!SY9j(q55 zbwb0GU;DfN%(zFzdXwbGuy+LotmVh{@`31ETl{z&t2H(e`Ul^vjS}^j6KPsfL-#ZsTU5D|BJgGM1+#AbDDU2p^LMR9}n0zXIwh8Ve7OKKrK_ z$YE6irvsW06{;pcF%^Tj&mQk3o?O>YRv#u#l6|Hc;JLAM&866RjwI+2mDT^;4B%eO z{P>Tb^RIv7yJGeEft*w3B{Glruyetz<(sba?^@=;(8Dwv2EYi9desKG^<7-by)SR; z;U3*LSUwdAgJqC8(WKWV2lLR-7Nqn(lNCk%`#m_od8f5@r|)AhVWjL$bfFpx)0-r> zZ>RG||7`~b@^k28wl~4`>BE06=ljy_zy7bimruO~6FfT@5muTw^J*M!n{D>$%W)Lo zHfyh6Pdn?#ls{gqdQwK{NGp&ScFp^W!sy>ob}bszK%eU0$)VkAO#*(<9eCv zS)d5?3xgh5yxsoqW^=%}X#!fwJWP^PL6Mp^GpzjyZixRQrM^NzslGqO6B)T9YiL3# zgFfloyZ$Gl7-NNxaX#I#w2Z!dS?H>NKUdYSJBKAUg69>Ob>4=-iIsUb<7`><#4`G% zj>0m^e4(!_i<1@ti#MSnG0P%u&D72y9A$3RfYU7~R4Ak4Qs+ovkY4i1&Q^#uf)3{eK+ka#Mr4GLP8c(F zGRMV7fc5P_T^z*Y2aRp{^4{Ni83w=l<)4mUc>nDntjnmVZ7#R(D*SMwbD)2W5zbZZ zl&#OKRo$cqdM${-ilHIs52F>?FL9O7|o zRN$JL_vIXyLSg-oKfUd=UuZB#y39Y$rSRE7jJ7c)`w6Qkh?3E9q^Z8^=ZJyWnE4&J z;2(VO$MK*3;rDXGuN+*?VC)Mx*CVuPF{`9cpd%;yk1ava+0lYo{(KA&0Zhy6~Kz~7-aw-cSqB!@vbON$6H6bgbOiLrFs48m48uJ93f zpk&K`P1fJ|AjMAy_V;UmGk4-@!ib+WS(;-7HaKr;Z(%F86%`iM@9Q;aT*g6!nos!S z_)Z#7Xe)r7IT{qPY%A}dbrP4Y$JBCN!?_^Zv0oJ^G01?6 zYGkGL_R_J9^=UE*r-yX<&A<19_^-e75xzHLCI`FR*z@mo9h_U@%wgE!dW!PzPBO_T zrB#&mk^l8f1j>JHP47lf-mvC1+WMGdr!yV&Y2ikH*cEeG#V+0AoS?&V?28yuPI()6 z%oy@vJ~xFW!EAr%n#SCTrcNq=jA}qPWGKEKdtgEYY+0j_#Rs;sI*#VY>xLS2FaOX zj)T6lA9YU7TE)(;Z?yjvZ0%Kv2m3!uZ7bnf#@4yV1?5+9fTy*Ny?m3<-UW^Nn}1ti zL49GVCVF8uIqrvB4>HX{F+jxGL0#AGAGCooy0eH$HT|xIl7~!;j4{@P*b0hK{$u*v zsv%@bdQxQkSI#qcP=_s%95>dm=_32BVr1;{Z-%6t6CL8>ZBAY6lZ?mMhGdM6Q)-{ST~(F@oFMh8fI+~iuu(`W!PWZH5l!x+-#ju`u3zX zL;(t05C6MSB`r{A<>{uxX(-Dv$Mpp5dQJhJf;DUtbi7_?8=ZW1>rw_i&D1pPd?3FbKVdb4O>`HET>9tRAG7lkXZHGUnw6MuA8HnMX z%`j=$uwXz{oEyh(Q&5L~zpP%<0r$ITWYAcfph<~+@^(Gnk9hpvMib7vxqah5kFOp5 zLO}w}N1x9C@1YFt*I&=nOM$8v_blR`vZs?0JklQT{34WuNIq`N#GKo^wLye}x;T zl)vAgM_^KI98op2Bp;RIop&8!hDwA=W$y<9uER_58P_y(G>HN^DTHWxVfN7fiOd%} zuU^@Dkn!t-?fjEj5ime8xWZshsZI#A#@twHDZ7QigqWbbwkd4-pY-fCV|Bn5px0$r z+eq&B@YXCwemIwF%m_EB>A5!FF80~4G5r(9NgyLTQ)u7)xd+=3CE$*r~>${zBO(i9rZ7+$UZ=h?Pm;v zmSOYt*`dMNe0}Vsqm3o4=gp_rJp=BJ+`|v?;1ugl6y*7(bjq;VA`5Gh#~ryDbk8@b3*3VgHjCJec+-`+w1~U58XFpeKl)lcsY0 z!JvTI|AwXe$a>BJXEMUJy%yYcjE{FoAIY9(-^H$iIwdB+KN&K(_mX1@R*yR66l?qw zcZg!}Ftz>-H)R!TX3u1fZ=YQFh!}&f2A9lcFuQB4_ifG$&cMK7@?8^zF+`dUhJr3W zn3kKa08q3b)Tp8>_eGV8-k4%Dj4wz`Y_(0A@g$zi2ck3_(I~jnGNKLNJFs zyED7doO5A@tLy}RTqo$$`+J0k1uJLh-kL|9kPhk3NM%4NMA$^V=lI z7wdjj*L1eZvsB%NO)MOrS$cZnG44|x#Cb+ggWu$zl~BltD+q^+3WpMQLrggru| zyCdTc>a3JHG6gkq@N?$#D(Rwx2s@77smmJ+{!?!6J}A;>A7j8q37yv>J?#oCActt9 zb}$-z*E(aa>A{wLP3rbNF==El%%RVz3ZTCAzr%F!3~TiCc~t@YRQ%;Hzl-rBp#qx# zdQ*X`ip@O`S4^q>Y{{xVonJwM#DR4UwXoliIBWU$gPRT3r)1vRk7dgZeYkEZ?Ca59 z_A2}g1ozTtj%9Wo*Hkt6Uoy?ZtRu{pSO8b7EK*>39UdBiV8+lL>nEEue9a;MvftXK z)FV-sjopc^#cwCKS^<0e&VG#aIwaeARRW3Na82Qi2}VFS3`v4|Ik&%KU-XbH*t!A3 z#$0Ix4Kb|A+O)aJfCY&h{*Y|x_%C=QE&=0 z7NUXnn&?4?{ssJ2pU4Is*kcQWZeL@S94)dn9~_;vNdX5bG|}0K3t}9-HUyM4Up;52 zf`-Xvz%t@{1rhqYG88=pY&p-ch5`ZGWNe3NX?$UhSE8@NHM*=+kDr^73aS&LUxxA4 z3n{Mb|69jiR;UB|3WxSZ>_$g~872LiCj<14PF;>+!|i>zJvg>*`-UvIuIy^h&P3%tywIu64;E>^s$=rpQ$lT`?Za40?0cgW^lb7 zl6U;-HI7~EZEQ2ddK^=-ui6Z5e*D^8eEjO;<^B6F&dhK9{s+w)*{9A1d@==5u7BbD z^5==)C9#4PKQHosKx0JXf#g4KhrKb7c8ME`1W2G4!h*E_G7@6zMWmR>d{M1N4v7rYSfxiu@lqq)VK?XwQ|}#Y5tfho;cOZMj-@q3f)&h2ZppwpY5veH_9U5c>@e8k)`HydDiDN6@CI^9 zKJ3=l4uRzpwA$3##Pro$j8k)sVlRzBR{5RyQX%^)?DG)j&7SeE5?C09fu+9C;h*o zbA7-6y$XNXW`Sw;NlQDy%DF3zOAJt260J9$>dfBO0l>zO*>&!{C-0xLj^Z{l5u?P0eU=$qw z$aXa~C4Cq6f8S>&WmK4j$HlC>Ow3qTc-ndy`q7`Cg(SYe)uP+>NfNA(tq(+}62VtQ zAeYiQ6`+xpE-jJ~su)3n6BAP%puMMo<-((?nk5eiQu?VoXahtIxB0Kk%4Mcig}z%Y z;n~k?jqrdDfb)~^BRhJ6KFNd_Gu}}SIOLVK!Q_#7Je}UOkj$&01cArmGIozXN%^Jy zCum=+ONRD#AHZPFWsl6+uVU0Nr#;5#i6c^~DWF=rv&gp(1!vY%JkRr@8ETxaZ=OWH zUK^}AGAr57KTaIt(m+={tf`XX*JM$)9+Jsu?F+QM^H%S_zpSgL7jS(#!r%-1S>P9D z2Njt-M&*KUT9{+fWn7w8!U{kT=GT`m;qtd%nB=X6(5vJ>&bNoQy~-n^X$h+!5aQ(Q zRk&Ne2ke>$_Qdi?uxl$pX0RM+BG^5*&%b4>{c`;~>%i!zrh-=qkh?}Q!KP1Z9+b`N z-~Z)H_Ww)o9=EK*tz;iRZCErbtF+%fCuPS-$@#qLOrH##$FY?RA%dI5jIiqD--%#v zx+y7YG4!L*xkYZRf@i&zhZit62y+>Pgxc2PevcFx=1l|EZd0GkXwt|mn5&1%2(!N4;tqa(m<;X;jgpVFuSCjMs98-J$S`KmEDO@jT-B<(sFsGv52;oC-5O2_X?e zLV%ma=-O^TO@Q^9YJpj;4z^}a61@W!L3 zB^V!T%zXLxsp*b={nvWnlTynHXY?YV-VyW)S7pyW*)T!t zUpu;GdJ}`J&St_B{Il9dmuFBta#7J{?fA8Pz&N7uESspES-t@_Q@fs z;4WPag^Ib1(FHe8#&VR-OD2Bd{deNu{Il=I|Kq><;e1;Ru+i3~t4qd6*?euQEl#K} zyd7(M=84nt-#Ik5g?}u+p9(3{F(L&L6lbIT6)ju`7&EeVwhU92`1sepbzwz*?yXo` z9Im$BT0utGf-mE0y>x(oG@Wdx2|DO$_@UqXJ77d(%1muB7^)sZN#pCDm$ARYol#3( z4lGcH85C>58zo*S^j6|rEwHf8bm@8KTUhZ^i{BHexG}T*|239(p3oqJ*E#n8SYgS_ z*F=D`IWjmMj3q+OrfvoX&$gnTMjO_3`9=rkt^gW+$dv=MATG z_B}(|#`P_~Z^6uY(v%YEeA*i(zd3j}k?4%A8pOaQ2>>+$*Zyyy9zSg!9Fb^U82o@t zw13)8AM~%VOC0K;Bk)+W&y2ltT}~-Pue*8+g8|fbWWq}AwgIE#p#~XF0|)`|*xW@S zt=J5NUQ4NXRX^_lsWJ-cQr%qofO}bkk|<$nZVHFQa5KM0nW@ zJGCYLscSXP)zFL~4f|3wI!rU5@tH#o)W>%7qcb$5%@G>0PBld%Zjqv7pJG%k{LvuK z$ZFfi%g|j39w|B~tW+8NjFKZ;S$qgoL(WtYoL>HVbe0XtJpmV@s%ZC6{9z*3nq}{k z4`MyaLZdT_P00gus>qx(Be30}X#1`>EcxWTqO~n2ZSbBQQo8%m2;4 z`u@xNv5ehz+J!$bDr!6Vzn~YGorv(0|Lfrl0g8YiFXl7F#Y{5%i~t9H@+ukY9tT~m z(I~?takz(oaEQb~5kEKCd3d>WD0aX10fr;&a~@%r?5e5xc0lp7;L2Kl)+((YHQY2HIQ8o`HeDGR+nM zI0;h%4RC55ba0F`JfKgQTJ8wdz-fa81uI6&8#Bb${NH{nU=vg$X=*RHGb%Zm zfLvk@zeZqt;N-)FNsUw2lUSZQKugcz30d;B7yJc_-ad$|jo(+qwP3D+k3S=xL^b}!b8&3$2JWB3(YeH;7UBN^XCKL^IPZa+F}cN}n(78ex|Jlhb3 zT7T;74kD0<7d8H)jYXdoU`7JqSbU-|1!D!M(oe2!QvnW#wtG?FgbmT_dmJSIjS(1W zhBenF&fECxAN}A*@pt~g58}(e`0ixhY~9cKwcmuhQ2!YQ#516iIGOubEd%qx{5{t= zuB(#5FoqRea*oR|F;ZfO!8#)xw2z5=*<8c=*}m?(5bVNV+V(-mzWJ}OW8kGg^M!ClNm7~ym_0U=^3H|8 zY!-C?&&F^5*B{0QA3m@1RGO5N=vs&b(UJe6YT4)YFRNQF{-;f{VCO(=ESO#IpqzGy zaLSYb*@kVmoJi^~T!~bi_=)q6?yulDtu*=*uoi70bbMviB>yXYbaWCa)_4*WN>PtP z*)&#b%q?Q{JV0i7?N~$^Gcr27Yw}1&=`$bt zIRI8BIP|1YJtdWF7(^S`J|HN$ghI>X`|&Ta1Q`?F=O3rm*qjgRLq?kge6h?mvW!cT z(_2B3#!VIqB>G)LLGc2qXtn_GB{t)5iwbZoO`-??JEZn&N_Y;-tT=VONMi`X@8!O4BV=;D{8(Rtgdj?nqZRfI?w8$H~=MLU&e9@Uz?l zscp?BJe&Kz(;B$mwB6A}P?p=vNNSWAh^_{m+5IOCiCBz*(v_0x$IZ$H1%;3Mho~;` zDLrhByI3~E{X$|~KZRPSu0lqeFF39S$tlY!?w3-ZDs|D#OeZHRd%D{>rCJJ`wkVZlKX~+?9%^uw+ZW;oO=B6I$E8_O?lTA0oIaQ)=cZoF z=F*Ud6?YaLhsz9Xky{bNw%=L$Of6+BCT zpFJyZP#O}i-5y^QInO&TBO%%{?%f5iZp#>{^9P(t;-%V2O$8Iyb|JGji-*sR23mzO zhZ*hd*BM&zlh18gJqAH4e z$mfX@EthBGO3y=k>ZyX-8D){=bLY2_A&9LpHIM-x=Im z*jUr&oZv7$lyz^5)&I`D(dL6b9pg#&*o(gA%WN8jbyv{J~EIxf{^TZ*mWxS|$ zy2eU66aU)*PorDQ&~-f?>A3#R>?etxt%J_zYF=apl4Hpp=P7ls1GQ54CY}R7S6G-H zjvMLIkNXmTm-w;u-+Jad=L-gVe);Qf{o!}x<4;c)H(+K(x{jvoM{F`YpY|tfOG51O zKh4$+E4MB;JlV0O@V_J3{3pJP5VoH+W`_qyYiqn7f(*n!W+PNN#__#k1rl}Z4fEvq zR1ual7Ru=Xhgy}rnqvTf{Wi&F!+vtHL$CB}2 zYNoFTzAG}xG2+~S+*hJnn9eeL_<)$oW{1zoAOS8-;uQTFU{x$(4awblKg|G$$sAuF zp%NB*`%~*}c7*I-`E8cCfbB~^0uf|1@l=KLX#gC(JVo-*hlagS54~^k zDLH$iL(_uaRP5O@;Mf{tLd`*Iuud1O09M@&et7ZQj7$Gm!t(7zT9lRzYBf8C5K_y=D_fc|hazKap!S7i*QcjyuXq^L2mz^^Som=& zbFN$T+lkM>)sr3Tdo}ghIrK&Nv~=)ZgITi45fjey`sfUn#b`mU&!5Ahlvw8k&%lL0 z8hci`=%;6AR`x{-)MCBd^jw_UfGJ-D97dDb=QH=mhvv05hvHr~(9z@-A$+;z%qbt9 z_ITES1c-Fd;d{mdU7pm61tnw=+zsS|e);#a=Va;PTMM&K#cF@_45#ESNq)aqlrXw` z4RKWlJmBFBCNoq{*2uc!b7HCNM8BQ z3xWUbKmY#9%zlOwh{sunKD`+bXHAy>JZWs?&DpP@Jgu_>3JNUf(5D$ZweL~Pl5*Us zT4LNX1jVG|%_~9PfO=`EpLJ?Hkh9JmK#lIX8H!V8O2=z-#cm)%T#{n%K_bVfS$@~K zTd>GxEr~7DAh&j8bi6{0;#frcP&=gWt~mHvZS6$V7|1~}PPNAb@7KTao%rStJ`2kK zho2PqmHNAoIca?$eWU2bH6dUgf`D*Yb4nPWQ(<%g+jV_sr})v+Q`9?<`4?v@zyHU; z@wJ?$=rxPV&6Mn*GrmMMRR12c7SGI52SWBH|NZ)@&$S9e&OctpVaI_`I*H+dOciL3 zf5dJ+W~I9mW=iE?A$1mL3?1@QZ&Pb${oqGue)!3-Kp-Hh3b^2-OL6MASfq_w>JkAK z=X`<0K|Oo6MZ|z5Rc<0+alz}Nr*0h#W}jt!!!F)%mDiVzpIT9qLc6_OxepPNvJZP7 zvnL!YY(QEIHxDw()?T=o%-$m?Q`}lHbhz$0@%|j8&#q__b`;Kbj2bjgH=lvwvxNXv z0B3Jm{Wt!{@5aCTpMDVE{kWg91R7>OmrWvb?8SbPhcfyv*S&q9uHZ%1esr#_GvkCL z43iVvhIIZ@8Cfyj1Azu?gVjvkI zF={ybms}CegABrJI+ldU?~6b`cNHTP{_211%WQ)-acZO?4;Z1AYy7A~x_ z;tFge!h|!%*nIi_vs0zBEd~W0jDQ9e%5x0;kmbIx{%LX7xyI2T8ZqTUHV@p_A3ry5 zodm2_qe}=7MCc`D)HXI*n)aIb%N`=2zg+aAPsb@~`d;IG9`0HoL&TYyp4IP?;-j$F zzsT3MSrA0VK>ZyJ%gWC^PXzEsFZB5I#VS}yx9gz6P}DfdI4^`v9D9*!zS%wg*Z<0U zFByP$R+g#Qt$un$e8kZ1#Cw*Z5EQViKkf+y5GEi{yapmkdb7XalOaK~yRk`LlW6DI z;6^>Yc+I)BBk>O^9R8+E6uzTQDC=c&$vMXKe)0V|hG@m`zK=IJhnlt^zcI!<+L7!G z&Db$NlM$FZP53%Tiwuo6Yoteqj6>|N9GaI?I_2|lod4j1AI0DNqaVimpSx_drzI;N ze%3tObLRd{KLvV*l#KZ=W4^YUMH(y}XQgF8HL#2|amZ@q#gY2li)VSh-MIA8i-Y;Y zA6M= zZt9fnbVUzBftjD-te?XU46d#}p5@FZjK23jgQ4_d2z>vOs2}|CnQAyYL%uNU$l=^_ z;}a&jvN^qQA^o4)Yu1lRqH_vK_! z$kqT(<8A~E8YeOA-jn*yOVIao*S3(ZJ9(kWc?CuXH>oeMs}nJVVIlp2{j20h4JV0m zXcG+6L~QhigYo&dUY33Zf-64!wBqZ(`f#rLy6X`w4&9Yxc=aE*6tal$Bx2U3ECb!|KVgi z;Pu4ctUf=*X&P$NeA7er^U}e)UwDdt<6n6{{xARE?<{8(k1ML;)04s=YB}LdP#|FN z!l&;%ov@)7#;;_Ju=Y;x@+m^paJ1I<%K8K6@F*v#{FQx3gs$ z>L$sT3LL+D^n9iqUf^PZjx^|2fIzg!6);$VrRPe@)62TlXI1p-<8J?BJ}|JQMBR-AFQ}wsc>Wc}VH) zt%|W#qgK3iY*SMjz@0)>44qYkIS`1(wAnb1!$|7nvus;w3n8kuUdO|Jh=#t^4{^ha%gb?vvK8!i|2;BRgNt{W*l5;SzAI~Wd0g3RcdwTOtm z4)-~6-WW|iu4Z8WOh&zD25XdwF1i{t-q{qnm(Blh}Ar~Knu|7qtQ4t-dqKuzi4 zcI{bk^s$cB>p$lP)EG*3FV~*r7%FGf_E~673^JVC+DxD7+jRD{-2ZXB+x;=)C=*H< zzf~shIWmn^C>U&Ng`gIp`KY}xTG_lf3UBw^1Q_Et*J7v5bj?6a|Lt1{LZ4~AXyDH8 zYAlS7HIB$Ny(?JvfBmh0yn<5)AN^Ft=gxz6v-b~_OA$C6TEWxFlk&M`36o`+Jtay` zE`|z6wwuF6M~*E)pbt7QeVqHu%^yKU?B6M}-qYfLOFP`;pP0kPn1Vmn=c$s$mj43* zRA$RM#aG*i5s(jLGZH12w{hmt$J4_;9ez?@(6I^{?jjJGn>~<;_Et+{kU?+JNW!)O z;+8jh_WXC69gXYS>apfBV{}A2t(uCX!c!fB2dQr3E>gmHixptY6pb{Q%EKlPh1zTf zLoi1?B{OVcR23!}&fIha+liV$&*%ihKK$*t zzPB^@Ri>jl2s$lQ4F-E)lg>fU(a1h97PED(Cp5aSfW@`x34lTPSve2ufQzRRui}2T z&fJTwPNrM+ee8ky`#7{|@MWBT2%w~st;Bg5y*7o3_HF}Fsh?a%o#8c6I)sCduTV^kU)Q5JSLVa6#I>v~ndKSTp$yTL6xg zZ5+Ar=GZ8R(pV&rbHI4D>hADDlsT^PkaDLR*M$+q2|&W`bRb^W&GCRMIRWO9Ghe=b z-QNjtW#|c-7JDxZ=ZO*qFnuo%03#@BKpA%0YgBQ=XKs_!Bkt;E{DD5cZA0URQV_T| zFpVO>eb7px6UteKx?|L~qtoXdxG|x*&py)*O!(fkuz*Apd4VLO?4pOg1ckk`v{_jo zNnk7WfBkkHqVua?{!{Vu@4mh6*q!-3i~pJta^j$LZkZ`u`+PT2I09>%`16|aLw+E8 z(qJ0{5K9QF^c-0fg(+Y(@9zDMK}e(<$7N zS8QycHyPj~^`g^I1iV^vonAQs8p;=U(xlIWb3J1-68YKbYZiB9J9NFS{p;WOZhZR( zpAe6g#SlIiKuZ1tk?d|Y4TKjmHlwwf#LUf%0`>!D#KWuz;OmluLAFYs2M~0k+kwRO zEImg9$KVEVFk(B*wqS+BB3(4%St2pYC4edSHfjseZVq?AsaywGor>b+rXk8!8s9O) zCBqZ%sCA3Me5wp8Q}UO#W%URbrgbpm1g>3yoqpm?rAihpa3aH_K$|g?Li~#7TW`Vj zIbh4CX-vIt1tv2YaiUx}CeT})5Q770;}Qng8Yb}Z8HO{0CaWq@6>K^3c2JeRx)w9X zR$wcrbmRnS2{@?QNL?DW>Bg|Wzw_Z|`I~?D17(X$q}Bg}JOh`AP38B-{d#i}YsNr~ zKF^wd_`Q$SUUeE~o@n=+_FHZG=eKr6Pa5b9?B#r3tTc0sF5pV+Kej9x{yFqx-DnoR z1`*6kn~aPONzWiYu7Q(g`t-m3`rNSA$KOGmv;a4H%EUe5RV{!7@QpXH80ReTAUIhQ%{MU@XR({pdc9 zg=5SE{0u2NcU>U)r|q{6&_%fvFuX+D6Lz^V8|VQ1ikvVxf#AXZmz|(xY6mss3zEjE z`xA2)ghK!mI!W@rGeA)2%JqMR#)-B~|KmH19g>8WW)7Jyb2CR;^i-gw1TfWC#HP2U z7HNd=Is`SyRh{V>_Nb}eFjx>hF{(;B?;Bk_0Ia0Els^1&(OP3=i0JyiNu?j`8nyspx&F&7)bQ?;E z{Yc96uq0($;o6MpW)4U}AKT9*3Ztt=xyDivW4Cw3#A(bLBSM&b!q6%L>@f!_Z6ZGm z8)3?|GhNtU8hK-|5iXaesxr`IWO+H(;aMmkI+1y4xW(1ni^iY=BH7`%j(Mk)UB(um z*n$GVV8Z(BIE++5aTL`PwhDNu11y9UIt!CgnL`!ZV9_yOL1j^5P3~7}W4hPA_w8N( zed*n|;xGNuPs9LXfr%ZVr|_2u19B_7s{yPjk>=?Q*Sq7H^SE3%K{d+{;Oio02v)3 z-Vj9D+DKJwHBRLN9V%~Q@p(MV>qeiP-@pCCPgVe+l#NA|&k>J=D2h%S6a914<^%VI z`C4|fZ!dG6db?4V-cCS}kyvzwh&7loI>gzR0W;JIFrs4QzB?Wb%zkK?9jOcgpPGQk ze~=T|hWuWFWMzBijYI zkoB8?@coxSz$epRgsW0a*S_S8%qfmn2z}QSXY&cpL)^4T(j?(R^ zbDY)db^U(iF8`>$;Z~!zZ%>o|qW0M3f1E%xowX@^k)3V~Gw&hHr@fY8dVSqvQTzlA zkfq=B`FXR4=N`#Pz)qtLUA$*qV|dKAGX`@sRa8awUXTt%;PBI>4e&Gpjv|3=DczG? z3w(aO+5V?}8E9Q&V1<~pUiZ1tEoywof-iTY3stb14J^eP1pKVDl?{$?oatWukyqr* z;K9QF4>;)?K&E^gk~*fezL$1O2a!N*H)2X@<+_GfCS2Fk{;z?HaD#@>wAufy|4T5} z{c~+FjN%VIc|f=RB6;!pJ5?%^wX3Dnrc#rJQ4tvq-CGz>&ccq=m|j zKBr-ojNV1#b4uG&&o5<{A#mx1OryC07$U7j2i?XUMt6QbB}7E9>vJ0EzV8$NRtGW) zv`(JZSwO^dstd-s!KSrqKu4CMYeXb}R(5KUJEpM$+-IJxfp;>tMJ?kwkSRmJC#hs+ z8vssGKX;fg>46yA8nLfPt;d!9|EvGgpMA;xztbvs$lC^*Wn;#9&&{YfvFdGzOd#O8 z*R#b@bu&iBNQv5m3seS0juMH9n40GVEX+uX%-#6gh#)2$B@%HbmFQ8^)fj;q+t5{! z9!36)E$%-Ro8jkudYf#H*PgfQc~5YC?qk90Jbj$eSwQvUTQR0Ro9Z^6hrYL8`gPeC zS9^c$Kl`>G-Mp@OV!|McMo)St7O+jIqC1`Ab}2tkqr0%+!~?JBcu^=Tg>S3<%Md!| zlzz|!CIanPpW|)OtxL6B%pZR#~QD z(?V_Bg;yOBrb@WlS2H@PIXB#x^GPOH(i zU`vVzJ9Bh&)l+9m&oT6{{XlqIbT?4_7i8nZ8D$+zMir4USf4my-1Ud_Cydf!b=dXq z(>w(ylCFsbxZSJjwt8CJ@_>J!-`jR)#xr8G1=dSj8>G|oqfb7IzxKDk6`w(%9aH@G z6O`ak?%RgWTU7w>4^iUIU$JAA_$zkBe50nR?;SsHKmM6lIDmj#JJYV&w0meu{hhZy z_;T#icRBM?Qi$V#TeQ=$Z<@fWEq%c0&s$sN_i-GqJ5qY`t}`|rdhUnrZU^ty(6RjQ zn!x);fAGOo1@KqC@;)FBTv~jTac6<$SW^B;PjMXN=28MIfv5bRZ<2p}#DkkJ@j6&6 zf3ne*|F9ciueD8$r?;rumkZoByzE7i8;6;We$eHi7Xp96H0X=WjbZkeFB;|l*{emx zP>_Gv%9#@Yp�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